fix(tui): show background shell completion (#36534)
This commit is contained in:
@@ -132,6 +132,8 @@ export const Plugin = {
|
|||||||
return runtime.session.synthetic({
|
return runtime.session.synthetic({
|
||||||
sessionID,
|
sessionID,
|
||||||
text: `<shell id="${callID}" state="${state}" command="${command}">\n${text}\n</shell>`,
|
text: `<shell id="${callID}" state="${state}" command="${command}">\n${text}\n</shell>`,
|
||||||
|
description: command,
|
||||||
|
metadata: { source: "shell", state },
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
Effect.forkIn(scope, { startImmediately: true }),
|
Effect.forkIn(scope, { startImmediately: true }),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import fs from "fs/promises"
|
|||||||
import { realpathSync } from "node:fs"
|
import { realpathSync } from "node:fs"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { DateTime, Duration, Effect, Fiber, Layer, Scope } from "effect"
|
import { DateTime, Duration, Effect, Fiber, Layer, Scope, Stream } from "effect"
|
||||||
import { Money } from "@opencode-ai/schema/money"
|
import { Money } from "@opencode-ai/schema/money"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
@@ -443,6 +443,12 @@ describe("ShellTool", () => {
|
|||||||
reset()
|
reset()
|
||||||
return withSession(tmp.path, (registry) =>
|
return withSession(tmp.path, (registry) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
const admitted = yield* events.subscribe(SessionEvent.InputAdmitted).pipe(
|
||||||
|
Stream.filter((event) => event.data.sessionID === sessionID && event.data.input.type === "synthetic"),
|
||||||
|
Stream.runHead,
|
||||||
|
Effect.forkScoped({ startImmediately: true }),
|
||||||
|
)
|
||||||
const settled = yield* settleTool(registry, call({ command: idleCommand, timeout: 50, background: true }))
|
const settled = yield* settleTool(registry, call({ command: idleCommand, timeout: 50, background: true }))
|
||||||
const structured = settled.output?.structured as Record<string, unknown> | undefined
|
const structured = settled.output?.structured as Record<string, unknown> | undefined
|
||||||
const shellID = typeof structured?.shellID === "string" ? structured.shellID : undefined
|
const shellID = typeof structured?.shellID === "string" ? structured.shellID : undefined
|
||||||
@@ -454,6 +460,13 @@ describe("ShellTool", () => {
|
|||||||
const id = ShellSchema.ID.make(shellID)
|
const id = ShellSchema.ID.make(shellID)
|
||||||
expect((yield* shell.list()).map((info) => info.id)).toContain(id)
|
expect((yield* shell.list()).map((info) => info.id)).toContain(id)
|
||||||
expect((yield* shell.wait(id)).status).toBe("timeout")
|
expect((yield* shell.wait(id)).status).toBe("timeout")
|
||||||
|
expect((yield* Fiber.join(admitted)).valueOrUndefined?.data.input.data).toMatchObject({
|
||||||
|
description: idleCommand,
|
||||||
|
metadata: {
|
||||||
|
source: "shell",
|
||||||
|
state: "completed",
|
||||||
|
},
|
||||||
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1231,21 +1231,27 @@ function SessionSwitchMessageV2(props: { message: SessionMessageInfo }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
|
function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
|
||||||
|
const ctx = use()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const metadata = () => (props.message.type === "synthetic" ? props.message.metadata : undefined)
|
const metadata = () => (props.message.type === "synthetic" ? props.message.metadata : undefined)
|
||||||
const completion = () => metadata()?.source === "subagent"
|
const source = () => stringValue(metadata()?.source)
|
||||||
|
const completion = () => source() === "subagent" || source() === "shell"
|
||||||
const state = () => stringValue(metadata()?.state)
|
const state = () => stringValue(metadata()?.state)
|
||||||
const agent = () => Locale.titlecase(stringValue(metadata()?.agent) ?? "Subagent")
|
const actor = () => (source() === "shell" ? "Shell" : Locale.titlecase(stringValue(metadata()?.agent) ?? "Subagent"))
|
||||||
const text = () => {
|
const text = () => {
|
||||||
if (props.message.type === "system") return props.message.text
|
if (props.message.type === "system") return props.message.text
|
||||||
if (props.message.type === "synthetic") return props.message.description ?? ""
|
if (props.message.type === "synthetic") return props.message.description ?? ""
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
const description = () => (source() === "shell" ? text().replace(/\s+/g, " ").trim() : text())
|
||||||
const status = () => {
|
const status = () => {
|
||||||
if (state() === "completed") return "finished"
|
if (state() === "completed") return "finished"
|
||||||
if (state() === "error") return "failed"
|
if (state() === "error") return "failed"
|
||||||
return state() ?? "finished"
|
return state() ?? "finished"
|
||||||
}
|
}
|
||||||
|
const heading = () => `${state() === "completed" ? "↳" : "!"} ${actor()} ${status()}`
|
||||||
|
const suffix = () =>
|
||||||
|
Locale.truncateWidth(` · ${description()}`, Math.max(0, ctx.width - 3 - Bun.stringWidth(heading())))
|
||||||
const color = () => {
|
const color = () => {
|
||||||
if (state() === "error") return theme.error
|
if (state() === "error") return theme.error
|
||||||
if (state() === "cancelled") return theme.warning
|
if (state() === "cancelled") return theme.warning
|
||||||
@@ -1261,11 +1267,9 @@ function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<box marginLeft={3}>
|
<box marginLeft={3}>
|
||||||
<text>
|
<text wrapMode="none">
|
||||||
<span style={{ fg: color() }}>
|
<span style={{ fg: color() }}>{heading()}</span>
|
||||||
{state() === "completed" ? "↳" : "!"} {agent()} {status()}
|
<span style={{ fg: theme.textMuted }}>{suffix()}</span>
|
||||||
</span>
|
|
||||||
<span style={{ fg: theme.textMuted }}> · {text()}</span>
|
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -63,6 +63,24 @@ export function truncate(str: string, len: number): string {
|
|||||||
return str.slice(0, len - 1) + "…"
|
return str.slice(0, len - 1) + "…"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
||||||
|
|
||||||
|
export function truncateWidth(str: string, width: number): string {
|
||||||
|
if (width <= 0) return ""
|
||||||
|
if (Bun.stringWidth(str) <= width) return str
|
||||||
|
if (width === 1) return "…"
|
||||||
|
|
||||||
|
const result: string[] = []
|
||||||
|
let used = 0
|
||||||
|
for (const item of graphemeSegmenter.segment(str)) {
|
||||||
|
const next = Bun.stringWidth(item.segment)
|
||||||
|
if (used + next > width - 1) break
|
||||||
|
result.push(item.segment)
|
||||||
|
used += next
|
||||||
|
}
|
||||||
|
return result.join("") + "…"
|
||||||
|
}
|
||||||
|
|
||||||
export function truncateLeft(str: string, len: number): string {
|
export function truncateLeft(str: string, len: number): string {
|
||||||
if (str.length <= len) return str
|
if (str.length <= len) return str
|
||||||
return "…" + str.slice(-(len - 1))
|
return "…" + str.slice(-(len - 1))
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import { Locale } from "../../src/util/locale"
|
||||||
|
|
||||||
|
test("truncates text from the right by terminal width", () => {
|
||||||
|
expect(Locale.truncateWidth("abcdefgh", 5)).toBe("abcd…")
|
||||||
|
expect(Locale.truncateWidth("ab界cd", 5)).toBe("ab界…")
|
||||||
|
expect(Locale.truncateWidth("abcdefgh", 1)).toBe("…")
|
||||||
|
expect(Locale.truncateWidth("abcdefgh", 0)).toBe("")
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user