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("")
+})