diff --git a/packages/core/src/tool/shell.ts b/packages/core/src/tool/shell.ts index d2b8bb474482..a72d12f652d2 100644 --- a/packages/core/src/tool/shell.ts +++ b/packages/core/src/tool/shell.ts @@ -132,6 +132,8 @@ export const Plugin = { return runtime.session.synthetic({ sessionID, text: `\n${text}\n`, + description: command, + metadata: { source: "shell", state }, }) }), Effect.forkIn(scope, { startImmediately: true }), diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index 6a6a53e171d8..896cc96d03fa 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -2,7 +2,7 @@ import fs from "fs/promises" import { realpathSync } from "node:fs" import path from "path" 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 { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" @@ -443,6 +443,12 @@ describe("ShellTool", () => { reset() return withSession(tmp.path, (registry) => 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 structured = settled.output?.structured as Record | undefined const shellID = typeof structured?.shellID === "string" ? structured.shellID : undefined @@ -454,6 +460,13 @@ describe("ShellTool", () => { const id = ShellSchema.ID.make(shellID) expect((yield* shell.list()).map((info) => info.id)).toContain(id) 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", + }, + }) }), ) }, diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 5e6fc8eaf31f..efea0198a17f 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1244,21 +1244,27 @@ function SessionSwitchMessageV2(props: { message: SessionMessageInfo }) { } function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) { + const ctx = use() const { theme } = useTheme() 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 agent = () => Locale.titlecase(stringValue(metadata()?.agent) ?? "Subagent") + const actor = () => (source() === "shell" ? "Shell" : Locale.titlecase(stringValue(metadata()?.agent) ?? "Subagent")) const text = () => { if (props.message.type === "system") return props.message.text if (props.message.type === "synthetic") return props.message.description ?? "" return "" } + const description = () => (source() === "shell" ? text().replace(/\s+/g, " ").trim() : text()) const status = () => { if (state() === "completed") return "finished" if (state() === "error") return "failed" 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 = () => { if (state() === "error") return theme.error if (state() === "cancelled") return theme.warning @@ -1274,11 +1280,9 @@ function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) { } > - - - {state() === "completed" ? "↳" : "!"} {agent()} {status()} - - · {text()} + + {heading()} + {suffix()} diff --git a/packages/tui/src/util/locale.ts b/packages/tui/src/util/locale.ts index ddcb973e7439..79537410f5ee 100644 --- a/packages/tui/src/util/locale.ts +++ b/packages/tui/src/util/locale.ts @@ -63,6 +63,24 @@ export function truncate(str: string, len: number): string { 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 { if (str.length <= len) return str return "…" + str.slice(-(len - 1)) diff --git a/packages/tui/test/util/locale.test.ts b/packages/tui/test/util/locale.test.ts new file mode 100644 index 000000000000..0da9dac7b3a9 --- /dev/null +++ b/packages/tui/test/util/locale.test.ts @@ -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("") +})