From f1ccbb352390fa1cd67cbb6e2fc35cb8051124a8 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Thu, 30 Jul 2026 09:31:57 +0700 Subject: [PATCH 1/4] feat(tui): print session transcript to scrollback on exit Quitting the TUI restored the alternate screen and threw the conversation away. The exit epilogue can now be a lazy thunk, materialized in the renderer destroy listener while the reactive tree is still alive; the session route's thunk prepends a compact Claude-Code-style transcript (user "> " prompts, assistant text, dim one-line tool calls) to the logo/session epilogue. Also fixes a teardown race where the route onCleanup cleared the epilogue during Solid dispose before it printed, and repairs the epilogue lifecycle test: its session id "dummy" collided with the fork's placeholder route id, so the session route never mounted and the test could never pass. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 7 + DIVERGENCES.md | 1 + packages/tui/src/app.tsx | 18 ++- packages/tui/src/context/epilogue.tsx | 2 +- packages/tui/src/routes/session/index.tsx | 13 +- packages/tui/src/util/exit-transcript.ts | 69 ++++++++++ packages/tui/test/app-lifecycle.test.tsx | 94 +++++++++++-- .../tui/test/util/exit-transcript.test.ts | 126 ++++++++++++++++++ 8 files changed, 311 insertions(+), 19 deletions(-) create mode 100644 packages/tui/src/util/exit-transcript.ts create mode 100644 packages/tui/test/util/exit-transcript.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 73ac88cf06..172600c327 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,13 @@ file is the human-readable summary of what changed and why. toast the first time; consent is remembered for the rest of the run, so later toggles are instant. Declining or dismissing the dialog stays in manual mode. +- **The conversation survives quitting** — on exit the TUI prints the session + transcript to the terminal scrollback in a compact format: user prompts + prefixed with `>`, assistant replies as plain text, and tool calls as dim + one-line summaries like `⏺ Bash(bun test)` (no thinking, no tool output), + followed by the usual logo/session epilogue. Also fixes a teardown race that + could swallow the epilogue entirely. + ## 1.18.4-2 ### TUI diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 82fc7222f7..938a6ac6ce 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -108,6 +108,7 @@ from the squash message first, or use a plain merge. | `packages/tui/src/config/keybind.ts`, `packages/tui/src/routes/session/question.tsx`, `packages/tui/src/ui/toast.tsx`, `packages/tui/test/app-lifecycle.test.tsx` | Double-press Ctrl+C to quit. keybind.ts: `app_exit` `ctrl+c,ctrl+d,q` → `ctrl+d,q`, added `app_exit_confirm: "ctrl+c"` definition + its `app.exit.confirm` CommandMap entry. question.tsx: a local `app.exit.confirm` command mirroring its `app.exit` reject, with the confirm binding gathered in both option branches (Ctrl+C still rejects the question — single press). toast.tsx: `ToastOptions.variant` union gains `"secondary"` (resolves to `theme.secondary`, the banner brand color, for the confirm toast). app-lifecycle.test.tsx: added the "app.exit.confirm requires two presses to exit" test. See also the `app.tsx`, `dialog.tsx`, and `permission.tsx` rows for their `app.exit.confirm` handlers. | A stray Ctrl+C on an empty/unfocused prompt no longer quits instantly: the first press shows a "Press Ctrl+C again to exit" toast (1.5 s window), the second within that window quits. Ctrl+D, `q`, and the `/exit` slash commands stay immediate. | Keep our binding split, the `app_exit_confirm`/`app.exit.confirm` definition + mapping, the local confirm commands + gathered bindings, the toast `"secondary"` variant, and the test; take upstream's surrounding changes. If upstream re-adds `ctrl+c` to `app_exit`, keep it removed. | | `packages/tui/src/config/keybind.ts`, `packages/opencode/test/cli/run/footer.view.test.tsx` | `sidebar_toggle` default `"b"` → `"ctrl+b,b"` (ctrl+b first so single-binding displays — the prompt-footer sidebar hint, the sidebar tip — resolve to it); `ctrl+b`'s former jobs move to `ctrl+o`: `input_move_left` `"left,ctrl+b"` → `"left,ctrl+o"`, `session_background` `"ctrl+b"` → `"ctrl+o"`; footer.view.test.tsx asserts the run-CLI footer background hint as `"ctrl+o background"` (was `"ctrl+b background"`) since it resolves the real `session_background` default | Single-chord sidebar toggle on `ctrl+b` (tmux/VS Code sidebar muscle memory); its displaced bindings land on `ctrl+o`, which was unbound in every scope and free of terminal-encoding collisions (unlike `ctrl+h`/`ctrl+i`/`ctrl+m`) and flow control (`ctrl+s`/`ctrl+q`). | Keep our defaults on all three keybinds and the footer hint assertion; take upstream's surrounding changes. If upstream binds `ctrl+o` to something else, re-audit and pick a new free key. | | `packages/tui/src/config/keybind.ts`, `packages/tui/src/component/prompt/index.tsx`, `packages/tui/src/app.tsx` | Single-press Ctrl+C interrupt. keybind.ts: added `session_interrupt_now: "ctrl+c"` + its `session.interrupt.now` CommandMap entry (`session_interrupt` keeps `escape`; its description gains "(press twice)"). prompt/index.tsx: added the `session.interrupt.now` command — same guards as upstream's `session.interrupt` but it aborts on the first press, and its `enabled` requires a busy session AND an empty prompt so ctrl+c falls through to `prompt.clear` (draft present) or `app.exit.confirm` (idle) instead of racing them on layer order; gathered the command in the `prompt.palette` binding list; the busy footer hint reads `interruptShortcut()` (resolves `session.interrupt`, mapping `escape` → `esc` and falling back to the `session.interrupt.now` label when escape is unbound) instead of the hardcoded `esc`. app.tsx: split `app_exit_confirm` out of the shared exit `useBindings` into its own layer whose `enabled` also requires `sync.data.session_status[route.data.sessionID]` to be idle, so ctrl+c can't arm the quit confirm mid-turn (`app_exit` keeps the shared predicate). Covered by the fork-only `packages/tui/test/interrupt.view.test.tsx`. See also the double-Ctrl+C row above, whose scope this narrows. | Ctrl+C is what many users reach for to stop a running agent, so it interrupts too; `escape` stays the advertised one in the busy footer and the home tip. Gating on `enabled` rather than binding order keeps the ctrl+c precedence (clear draft → interrupt → exit confirm) deterministic, since same-priority layers resolve by registration order and `useBindings` re-registers on every reactive change. | Keep the `session_interrupt_now` definition + mapping, the `session.interrupt.now` command and its `enabled` predicate, the footer shortcut memos, the split exit layer, and the test; take upstream's surrounding changes. If upstream binds `ctrl+c` to something else in the prompt, re-audit the precedence chain end to end. | +| `packages/tui/src/app.tsx`, `packages/tui/src/context/epilogue.tsx`, `packages/tui/src/routes/session/index.tsx`, `packages/tui/test/app-lifecycle.test.tsx` | Exit transcript. epilogue.tsx: the epilogue setter accepts `string \| (() => string)`. app.tsx: the `renderer.once("destroy")` listener materializes a thunk epilogue before resolving `shutdown` (it runs before `@opentui/solid`'s own destroy listener, so the reactive tree and sync store are still alive), and the `EpilogueProvider` setter ignores writes once `renderer.isDestroyed` — fixing a pre-existing race where the session route's `onCleanup(() => setEpilogue())` ran during Solid dispose and clobbered the epilogue before `run()` printed it. routes/session/index.tsx: the epilogue effect sets a thunk that prepends a compact transcript of the session (fork-only `util/exit-transcript.ts` + `test/util/exit-transcript.test.ts`, not tracked here) to `sessionEpilogue`, reading `messages()`/parts only inside the thunk so message updates don't rebuild it. app-lifecycle.test.tsx: the epilogue test seeds a session with messages and asserts the transcript prints; its session id is `ses_demo` because `dummy` collides with the fork's placeholder route id and the Session route never mounts for it (this collision is why the test failed before), with session-scoped endpoints and a paid model stubbed and a frame-poll wait for the session view before dispatching `app.exit`. | Quitting the TUI restored the alternate screen and threw the conversation away; now the transcript (user `>` prompts, assistant text, dim `⏺ Tool(arg)` one-liners — no thinking, no tool output) survives in terminal scrollback like Claude Code, followed by the usual epilogue. | Keep the thunk union, the destroy-listener materialization, the `isDestroyed` setter guard, the thunk-producing effect, and the test's `ses_demo` id + stubs; take upstream's surrounding changes. | | `packages/opencode/src/installation/index.ts` | Updater detects/queries/installs `codev-code` (npm-family); GitHub fallback → quickbeard/codev-code. `method()` drops upstream's `~/.local/bin` → `curl` check, keeping only `~/.opencode/bin`. The `curl` upgrade branch refuses instead of running upstream's install script: `upgradeCurl` + `upgradeScriptShell` + the `Stream` import are removed, and `case "curl"` returns `UpgradeFailedError` pointing at `npm i -g codev-code` | `codev-code upgrade` and the update notification target OUR npm package, never stock opencode. brew/choco/scoop probes never match (no such packages). A `curl`-detected binary (in `~/.opencode/bin`) is a stock opencode install; piping upstream's `opencode.ai/install` script would silently replace the fork with stock opencode. `~/.local/bin` is dropped because it's also a common npm/bun global-bin prefix, so a legitimate package-manager install there was misdetected as curl (now falls through to the manager probes). | Keep our package names, the `~/.local/bin` removal, and the curl refusal; keep `upgradeCurl`/`upgradeScriptShell`/`Stream` deleted (take upstream's changes elsewhere). | | `packages/opencode/src/cli/cmd/uninstall.ts` | npm-family uninstall commands (display + exec maps) `opencode-ai` → `codev-code` | `codev uninstall` must remove the fork's actual npm package; the old name was a silent no-op for every npm-installed fork user. NOT renamed: brew/choco/scoop package names (`opencode` — the fork publishes no such formula/package, and `method()` never resolves to those for a fork install) and the curl `.opencode/bin` + `# opencode` shell markers (they match upstream install-script artifacts, not the fork's). | Keep our `codev-code` names; take upstream's surrounding changes. | | `packages/opencode/src/config/config.ts` | `share` defaults to `"disabled"` when unset | Sharing publishes sessions to upstream's share service; internal users must opt in via config. | Keep our default block. | diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index f7bf0214f6..f46aea824b 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -193,7 +193,7 @@ function isVersionGreater(left: string, right: string) { export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { const global = yield* Global.Service - const exit = { epilogue: undefined as string | undefined, reason: undefined as unknown } + const exit = { epilogue: undefined as string | (() => string) | undefined, reason: undefined as unknown } const result = yield* Effect.scoped( Effect.gen(function* () { const renderer = yield* Effect.acquireRelease( @@ -241,7 +241,11 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { Effect.sync(() => process.on("SIGHUP", onSighup)), () => Effect.sync(() => process.off("SIGHUP", onSighup)), ) - renderer.once("destroy", () => Deferred.doneUnsafe(shutdown, Effect.void)) + renderer.once("destroy", () => { + // Materialize before @opentui/solid's own "destroy" listener disposes the reactive tree. + if (typeof exit.epilogue === "function") exit.epilogue = exit.epilogue() + Deferred.doneUnsafe(shutdown, Effect.void) + }) const pluginRuntime = createPluginRuntime() yield* Effect.tryPromise(async () => { @@ -259,7 +263,13 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { destroyRenderer(renderer) }} > - (exit.epilogue = value)}> + { + // Solid disposes after destroy; ignore the route onCleanup clearing the epilogue. + if (renderer.isDestroyed) return + exit.epilogue = value + }} + > }> { diff --git a/packages/tui/src/context/epilogue.tsx b/packages/tui/src/context/epilogue.tsx index 9e6dce81c7..af6b95beec 100644 --- a/packages/tui/src/context/epilogue.tsx +++ b/packages/tui/src/context/epilogue.tsx @@ -2,5 +2,5 @@ import { createSimpleContext } from "./helper" export const { use: useEpilogue, provider: EpilogueProvider } = createSimpleContext({ name: "Epilogue", - init: (props: { set(value?: string): void }) => props.set, + init: (props: { set(value?: string | (() => string)): void }) => props.set, }) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 59ccd56e08..b569c2c161 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -71,6 +71,7 @@ import { QuestionPrompt } from "./question" import { DialogExportOptions } from "../../ui/dialog-export-options" import * as Model from "../../util/model" import { formatTranscript } from "../../util/transcript" +import { exitTranscript } from "../../util/exit-transcript" import { sessionEpilogue } from "../../util/presentation" import { setPreLayoutSiblingMargin } from "../../util/layout" import { useTuiConfig } from "../../config" @@ -201,9 +202,19 @@ export function Session() { return current ? { directory: current.directory, workspaceID: current.workspaceID } : undefined }) + const pathFormatter = usePathFormatter() createEffect(() => { const title = Locale.truncate(session()?.title ?? "", 50) - setEpilogue(sessionEpilogue({ title, sessionID: session()?.id })) + const sessionID = session()?.id + // Thunk keeps message reads out of the effect; it runs once at renderer destroy. + setEpilogue(() => { + const transcript = exitTranscript({ + messages: messages().map((msg) => ({ info: msg, parts: sync.data.part[msg.id] ?? [] })), + formatPath: pathFormatter.format, + }) + const epilogue = sessionEpilogue({ title, sessionID }) + return transcript ? `${transcript}\n\n${epilogue}` : epilogue + }) }) onCleanup(() => setEpilogue()) const children = createMemo(() => { diff --git a/packages/tui/src/util/exit-transcript.ts b/packages/tui/src/util/exit-transcript.ts new file mode 100644 index 0000000000..4c9c14d22c --- /dev/null +++ b/packages/tui/src/util/exit-transcript.ts @@ -0,0 +1,69 @@ +import type { ToolPart } from "@opencode-ai/sdk/v2" +import { Locale } from "./locale" +import type { MessageWithParts } from "./transcript" + +const reset = "\x1b[0m" +const dim = "\x1b[90m" + +const PRIMARY_INPUT: Record = { + bash: "command", + read: "filePath", + write: "filePath", + edit: "filePath", + glob: "pattern", + grep: "pattern", + webfetch: "url", + websearch: "query", + task: "description", + skill: "name", +} + +const PATH_TOOLS = new Set(["read", "write", "edit"]) + +export function exitTranscript(input: { + messages: MessageWithParts[] + formatPath?: (value?: string) => string +}): string { + const blocks = input.messages.flatMap((msg) => + msg.parts.flatMap((part) => { + if (part.type === "text" && !part.synthetic && part.text.trim()) { + if (msg.info.role === "user") return [{ tool: false, content: userBlock(part.text) }] + return [{ tool: false, content: part.text.trim() }] + } + if (part.type === "tool") return [{ tool: true, content: toolLine(part, input.formatPath) }] + return [] + }), + ) + return blocks + .map((block, index) => { + if (index === 0) return block.content + const separator = block.tool && blocks[index - 1].tool ? "\n" : "\n\n" + return separator + block.content + }) + .join("") +} + +function userBlock(text: string) { + const lines = text.trim().split("\n") + return [`${dim}>${reset} ${lines[0]}`, ...lines.slice(1).map((line) => ` ${line}`)].join("\n") +} + +function toolLine(part: ToolPart, formatPath?: (value?: string) => string) { + const name = Locale.titlecase(part.tool) + const arg = toolArgument(part, formatPath) + if (!arg) return `${dim}⏺ ${name}${reset}` + return `${dim}⏺ ${name}(${Locale.truncate(arg.replaceAll("\n", " "), 120)})${reset}` +} + +function toolArgument(part: ToolPart, formatPath?: (value?: string) => string) { + const raw = part.state.input + if (!raw || typeof raw !== "object") return undefined + const record = raw as Record + const key = PRIMARY_INPUT[part.tool] + const primary = key ? record[key] : undefined + const value = + typeof primary === "string" ? primary : Object.values(record).find((entry) => typeof entry === "string") + if (typeof value !== "string" || !value) return undefined + if (PATH_TOOLS.has(part.tool) && formatPath) return formatPath(value) + return value +} diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index b5207d4ca1..d7e27bc477 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -60,24 +60,81 @@ test("SIGHUP clears title and disposes scoped resources once", async () => { } }) -test("app.exit prints the session epilogue after scoped cleanup", async () => { +test("app.exit prints the session transcript and epilogue after scoped cleanup", async () => { const setup = await createTestRenderer({ width: 80, height: 24, useThread: false }) const core = await import("@opentui/core") mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer })) const events = createEventSource() + const session = { + id: "ses_demo", + title: "Demo session", + slug: "demo", + projectID: "proj_test", + directory, + version: "0.0.0-test", + time: { created: 0, updated: 0 }, + } const calls = createFetch((url) => { - if (url.pathname === "/session") + if (url.pathname === "/session") return json([session]) + if (url.pathname === "/session/ses_demo") return json(session) + if (url.pathname === "/session/ses_demo/message") return json([ { - id: "dummy", - title: "Demo session", - slug: "dummy", - projectID: "project", - directory, - version: "0.0.0-test", - time: { created: 0, updated: 0 }, + info: { + id: "msg_1", + sessionID: "ses_demo", + role: "user", + agent: "build", + model: { providerID: "anthropic", modelID: "claude-sonnet-4-20250514" }, + time: { created: 1 }, + }, + parts: [{ id: "prt_1", sessionID: "ses_demo", messageID: "msg_1", type: "text", text: "hello there" }], + }, + { + info: { + id: "msg_2", + sessionID: "ses_demo", + role: "assistant", + agent: "build", + modelID: "claude-sonnet-4-20250514", + providerID: "anthropic", + mode: "", + parentID: "msg_1", + path: { cwd: directory, root: directory }, + cost: 0, + tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 2, completed: 3 }, + }, + parts: [ + { + id: "prt_2", + sessionID: "ses_demo", + messageID: "msg_2", + type: "tool", + callID: "call_1", + tool: "bash", + state: { + status: "completed", + input: { command: "bun test" }, + output: "ok", + title: "bash", + metadata: {}, + time: { start: 1, end: 2 }, + }, + }, + { id: "prt_3", sessionID: "ses_demo", messageID: "msg_2", type: "text", text: "All tests pass." }, + ], }, ]) + if (url.pathname === "/session/ses_demo/todo") return json([]) + if (url.pathname === "/session/ses_demo/diff") return json([]) + if (url.pathname === "/project/proj_test/directories") return json([]) + // Register a paid model so the fork's first-run configure dialog stays closed. + if (url.pathname === "/config/providers") + return json({ + providers: [{ id: "test", name: "Test", models: { m1: { name: "M1", cost: { input: 1 } } } }], + default: { test: "m1" }, + }) }) const originalWrite = process.stdout.write.bind(process.stdout) let stdout = "" @@ -113,13 +170,24 @@ test("app.exit prints the session epilogue after scoped cleanup", async () => { ) await ready - await setup.renderOnce() - await setup.renderOnce() + // Wait for the session route to mount and render the synced messages; the + // epilogue thunk reads them from the sync store at renderer destroy. + const start = Date.now() + while (!setup.captureCharFrame().includes("hello there")) { + await setup.renderOnce() + if (Date.now() - start > 15_000) + throw new Error("timed out waiting for the session view:\n" + setup.captureCharFrame()) + await Bun.sleep(25) + } api?.keymap.dispatchCommand("app.exit") await task - expect(stdout).toContain("Demo session") - expect(stdout).toContain("codev -s dummy") + const plain = Bun.stripANSI(stdout) + expect(plain).toContain("> hello there") + expect(plain).toContain("⏺ Bash(bun test)") + expect(plain).toContain("All tests pass.") + expect(plain).toContain("Demo session") + expect(plain).toContain("codev -s ses_demo") } finally { process.stdout.write = originalWrite if (!setup.renderer.isDestroyed) setup.renderer.destroy() diff --git a/packages/tui/test/util/exit-transcript.test.ts b/packages/tui/test/util/exit-transcript.test.ts new file mode 100644 index 0000000000..49228ad14f --- /dev/null +++ b/packages/tui/test/util/exit-transcript.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, test } from "bun:test" +import { exitTranscript } from "../../src/util/exit-transcript" +import type { AssistantMessage, Part, UserMessage } from "@opencode-ai/sdk/v2" + +const userInfo: UserMessage = { + id: "msg_user", + sessionID: "ses_123", + role: "user", + agent: "build", + model: { providerID: "anthropic", modelID: "claude-sonnet-4-20250514" }, + time: { created: 1000000 }, +} + +const assistantInfo: AssistantMessage = { + id: "msg_assistant", + sessionID: "ses_123", + role: "assistant", + agent: "build", + modelID: "claude-sonnet-4-20250514", + providerID: "anthropic", + mode: "", + parentID: "msg_user", + path: { cwd: "/test", root: "/test" }, + cost: 0.001, + tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1000000, completed: 1005400 }, +} + +function textPart(messageID: string, text: string, synthetic?: boolean): Part { + return { id: `part_${text.slice(0, 8)}`, sessionID: "ses_123", messageID, type: "text", text, synthetic } +} + +function toolPart(tool: string, input: Record): Part { + return { + id: `part_${tool}`, + sessionID: "ses_123", + messageID: "msg_assistant", + type: "tool", + callID: `call_${tool}`, + tool, + state: { + status: "completed", + input, + output: "ignored output", + title: tool, + metadata: {}, + time: { start: 1000, end: 1100 }, + }, + } +} + +describe("exitTranscript", () => { + test("returns empty string for no messages", () => { + expect(exitTranscript({ messages: [] })).toBe("") + }) + + test("prefixes user text and indents continuation lines", () => { + const result = exitTranscript({ + messages: [{ info: userInfo, parts: [textPart("msg_user", "fix the bug\nin the scroll code")] }], + }) + expect(Bun.stripANSI(result)).toBe("> fix the bug\n in the scroll code") + expect(result).toContain("\x1b[90m>") + }) + + test("renders assistant text plain and tool calls as dim one-liners", () => { + const result = exitTranscript({ + messages: [ + { info: userInfo, parts: [textPart("msg_user", "run the tests")] }, + { + info: assistantInfo, + parts: [ + toolPart("bash", { command: "bun test" }), + toolPart("read", { filePath: "/test/src/foo.ts" }), + textPart("msg_assistant", "All tests pass."), + ], + }, + ], + formatPath: (value) => value?.replace("/test/", "") ?? "", + }) + const plain = Bun.stripANSI(result) + expect(plain).toBe("> run the tests\n\n⏺ Bash(bun test)\n⏺ Read(src/foo.ts)\n\nAll tests pass.") + expect(result).toContain("\x1b[90m⏺ Bash(bun test)\x1b[0m") + }) + + test("falls back to first string input for unknown tools and bare name without input", () => { + const result = exitTranscript({ + messages: [ + { + info: assistantInfo, + parts: [toolPart("mystery", { target: "thing", count: 3 }), toolPart("todowrite", { count: 2 })], + }, + ], + }) + expect(Bun.stripANSI(result)).toBe("⏺ Mystery(thing)\n⏺ Todowrite") + }) + + test("collapses newlines and truncates long tool arguments", () => { + const result = exitTranscript({ + messages: [{ info: assistantInfo, parts: [toolPart("bash", { command: `echo a\necho ${"b".repeat(200)}` })] }], + }) + const plain = Bun.stripANSI(result) + expect(plain).not.toContain("\n") + expect(plain.length).toBeLessThan(140) + expect(plain).toContain("echo a echo b") + }) + + test("skips reasoning, synthetic, and other part types", () => { + const reasoning: Part = { + id: "part_r", + sessionID: "ses_123", + messageID: "msg_assistant", + type: "reasoning", + text: "thinking hard", + time: { start: 1000 }, + } + const result = exitTranscript({ + messages: [ + { + info: assistantInfo, + parts: [reasoning, textPart("msg_assistant", "synthetic note", true), textPart("msg_assistant", "Done.")], + }, + ], + }) + expect(Bun.stripANSI(result)).toBe("Done.") + }) +}) From 560e07c6423898e62ca5cccdd42c3e66ee12e53d Mon Sep 17 00:00:00 2001 From: minhn4 Date: Thu, 30 Jul 2026 09:42:27 +0700 Subject: [PATCH 2/4] feat(tui): paint exit transcript with session theme colors The exit transcript takes optional theme colors (text, textMuted, error) and emits truecolor ANSI: user/assistant text in the theme text color, tool one-liners muted, error-status tools in the error color. The session route passes the active theme; without colors the formatter keeps the plain dim fallback. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 7 +-- DIVERGENCES.md | 2 +- packages/tui/src/routes/session/index.tsx | 1 + packages/tui/src/util/exit-transcript.ts | 44 +++++++++++++++---- .../tui/test/util/exit-transcript.test.ts | 30 +++++++++++++ 5 files changed, 71 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 172600c327..d87ccf4a21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,10 +22,11 @@ file is the human-readable summary of what changed and why. - **The conversation survives quitting** — on exit the TUI prints the session transcript to the terminal scrollback in a compact format: user prompts - prefixed with `>`, assistant replies as plain text, and tool calls as dim + prefixed with `>`, assistant replies as plain text, and tool calls as muted one-line summaries like `⏺ Bash(bun test)` (no thinking, no tool output), - followed by the usual logo/session epilogue. Also fixes a teardown race that - could swallow the epilogue entirely. + followed by the usual logo/session epilogue. The transcript keeps the + session's theme colors — text, muted, and error (for failed tool calls). + Also fixes a teardown race that could swallow the epilogue entirely. ## 1.18.4-2 diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 938a6ac6ce..d54bb0d33a 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -108,7 +108,7 @@ from the squash message first, or use a plain merge. | `packages/tui/src/config/keybind.ts`, `packages/tui/src/routes/session/question.tsx`, `packages/tui/src/ui/toast.tsx`, `packages/tui/test/app-lifecycle.test.tsx` | Double-press Ctrl+C to quit. keybind.ts: `app_exit` `ctrl+c,ctrl+d,q` → `ctrl+d,q`, added `app_exit_confirm: "ctrl+c"` definition + its `app.exit.confirm` CommandMap entry. question.tsx: a local `app.exit.confirm` command mirroring its `app.exit` reject, with the confirm binding gathered in both option branches (Ctrl+C still rejects the question — single press). toast.tsx: `ToastOptions.variant` union gains `"secondary"` (resolves to `theme.secondary`, the banner brand color, for the confirm toast). app-lifecycle.test.tsx: added the "app.exit.confirm requires two presses to exit" test. See also the `app.tsx`, `dialog.tsx`, and `permission.tsx` rows for their `app.exit.confirm` handlers. | A stray Ctrl+C on an empty/unfocused prompt no longer quits instantly: the first press shows a "Press Ctrl+C again to exit" toast (1.5 s window), the second within that window quits. Ctrl+D, `q`, and the `/exit` slash commands stay immediate. | Keep our binding split, the `app_exit_confirm`/`app.exit.confirm` definition + mapping, the local confirm commands + gathered bindings, the toast `"secondary"` variant, and the test; take upstream's surrounding changes. If upstream re-adds `ctrl+c` to `app_exit`, keep it removed. | | `packages/tui/src/config/keybind.ts`, `packages/opencode/test/cli/run/footer.view.test.tsx` | `sidebar_toggle` default `"b"` → `"ctrl+b,b"` (ctrl+b first so single-binding displays — the prompt-footer sidebar hint, the sidebar tip — resolve to it); `ctrl+b`'s former jobs move to `ctrl+o`: `input_move_left` `"left,ctrl+b"` → `"left,ctrl+o"`, `session_background` `"ctrl+b"` → `"ctrl+o"`; footer.view.test.tsx asserts the run-CLI footer background hint as `"ctrl+o background"` (was `"ctrl+b background"`) since it resolves the real `session_background` default | Single-chord sidebar toggle on `ctrl+b` (tmux/VS Code sidebar muscle memory); its displaced bindings land on `ctrl+o`, which was unbound in every scope and free of terminal-encoding collisions (unlike `ctrl+h`/`ctrl+i`/`ctrl+m`) and flow control (`ctrl+s`/`ctrl+q`). | Keep our defaults on all three keybinds and the footer hint assertion; take upstream's surrounding changes. If upstream binds `ctrl+o` to something else, re-audit and pick a new free key. | | `packages/tui/src/config/keybind.ts`, `packages/tui/src/component/prompt/index.tsx`, `packages/tui/src/app.tsx` | Single-press Ctrl+C interrupt. keybind.ts: added `session_interrupt_now: "ctrl+c"` + its `session.interrupt.now` CommandMap entry (`session_interrupt` keeps `escape`; its description gains "(press twice)"). prompt/index.tsx: added the `session.interrupt.now` command — same guards as upstream's `session.interrupt` but it aborts on the first press, and its `enabled` requires a busy session AND an empty prompt so ctrl+c falls through to `prompt.clear` (draft present) or `app.exit.confirm` (idle) instead of racing them on layer order; gathered the command in the `prompt.palette` binding list; the busy footer hint reads `interruptShortcut()` (resolves `session.interrupt`, mapping `escape` → `esc` and falling back to the `session.interrupt.now` label when escape is unbound) instead of the hardcoded `esc`. app.tsx: split `app_exit_confirm` out of the shared exit `useBindings` into its own layer whose `enabled` also requires `sync.data.session_status[route.data.sessionID]` to be idle, so ctrl+c can't arm the quit confirm mid-turn (`app_exit` keeps the shared predicate). Covered by the fork-only `packages/tui/test/interrupt.view.test.tsx`. See also the double-Ctrl+C row above, whose scope this narrows. | Ctrl+C is what many users reach for to stop a running agent, so it interrupts too; `escape` stays the advertised one in the busy footer and the home tip. Gating on `enabled` rather than binding order keeps the ctrl+c precedence (clear draft → interrupt → exit confirm) deterministic, since same-priority layers resolve by registration order and `useBindings` re-registers on every reactive change. | Keep the `session_interrupt_now` definition + mapping, the `session.interrupt.now` command and its `enabled` predicate, the footer shortcut memos, the split exit layer, and the test; take upstream's surrounding changes. If upstream binds `ctrl+c` to something else in the prompt, re-audit the precedence chain end to end. | -| `packages/tui/src/app.tsx`, `packages/tui/src/context/epilogue.tsx`, `packages/tui/src/routes/session/index.tsx`, `packages/tui/test/app-lifecycle.test.tsx` | Exit transcript. epilogue.tsx: the epilogue setter accepts `string \| (() => string)`. app.tsx: the `renderer.once("destroy")` listener materializes a thunk epilogue before resolving `shutdown` (it runs before `@opentui/solid`'s own destroy listener, so the reactive tree and sync store are still alive), and the `EpilogueProvider` setter ignores writes once `renderer.isDestroyed` — fixing a pre-existing race where the session route's `onCleanup(() => setEpilogue())` ran during Solid dispose and clobbered the epilogue before `run()` printed it. routes/session/index.tsx: the epilogue effect sets a thunk that prepends a compact transcript of the session (fork-only `util/exit-transcript.ts` + `test/util/exit-transcript.test.ts`, not tracked here) to `sessionEpilogue`, reading `messages()`/parts only inside the thunk so message updates don't rebuild it. app-lifecycle.test.tsx: the epilogue test seeds a session with messages and asserts the transcript prints; its session id is `ses_demo` because `dummy` collides with the fork's placeholder route id and the Session route never mounts for it (this collision is why the test failed before), with session-scoped endpoints and a paid model stubbed and a frame-poll wait for the session view before dispatching `app.exit`. | Quitting the TUI restored the alternate screen and threw the conversation away; now the transcript (user `>` prompts, assistant text, dim `⏺ Tool(arg)` one-liners — no thinking, no tool output) survives in terminal scrollback like Claude Code, followed by the usual epilogue. | Keep the thunk union, the destroy-listener materialization, the `isDestroyed` setter guard, the thunk-producing effect, and the test's `ses_demo` id + stubs; take upstream's surrounding changes. | +| `packages/tui/src/app.tsx`, `packages/tui/src/context/epilogue.tsx`, `packages/tui/src/routes/session/index.tsx`, `packages/tui/test/app-lifecycle.test.tsx` | Exit transcript. epilogue.tsx: the epilogue setter accepts `string \| (() => string)`. app.tsx: the `renderer.once("destroy")` listener materializes a thunk epilogue before resolving `shutdown` (it runs before `@opentui/solid`'s own destroy listener, so the reactive tree and sync store are still alive), and the `EpilogueProvider` setter ignores writes once `renderer.isDestroyed` — fixing a pre-existing race where the session route's `onCleanup(() => setEpilogue())` ran during Solid dispose and clobbered the epilogue before `run()` printed it. routes/session/index.tsx: the epilogue effect sets a thunk that prepends a compact transcript of the session (fork-only `util/exit-transcript.ts` + `test/util/exit-transcript.test.ts`, not tracked here) to `sessionEpilogue`, painted with the active theme's `text`/`textMuted`/`error` colors (truecolor ANSI; error-status tools get the error color), reading `messages()`/parts/theme only inside the thunk so message updates don't rebuild it. app-lifecycle.test.tsx: the epilogue test seeds a session with messages and asserts the transcript prints; its session id is `ses_demo` because `dummy` collides with the fork's placeholder route id and the Session route never mounts for it (this collision is why the test failed before), with session-scoped endpoints and a paid model stubbed and a frame-poll wait for the session view before dispatching `app.exit`. | Quitting the TUI restored the alternate screen and threw the conversation away; now the transcript (user `>` prompts, assistant text, dim `⏺ Tool(arg)` one-liners — no thinking, no tool output) survives in terminal scrollback like Claude Code, followed by the usual epilogue. | Keep the thunk union, the destroy-listener materialization, the `isDestroyed` setter guard, the thunk-producing effect, and the test's `ses_demo` id + stubs; take upstream's surrounding changes. | | `packages/opencode/src/installation/index.ts` | Updater detects/queries/installs `codev-code` (npm-family); GitHub fallback → quickbeard/codev-code. `method()` drops upstream's `~/.local/bin` → `curl` check, keeping only `~/.opencode/bin`. The `curl` upgrade branch refuses instead of running upstream's install script: `upgradeCurl` + `upgradeScriptShell` + the `Stream` import are removed, and `case "curl"` returns `UpgradeFailedError` pointing at `npm i -g codev-code` | `codev-code upgrade` and the update notification target OUR npm package, never stock opencode. brew/choco/scoop probes never match (no such packages). A `curl`-detected binary (in `~/.opencode/bin`) is a stock opencode install; piping upstream's `opencode.ai/install` script would silently replace the fork with stock opencode. `~/.local/bin` is dropped because it's also a common npm/bun global-bin prefix, so a legitimate package-manager install there was misdetected as curl (now falls through to the manager probes). | Keep our package names, the `~/.local/bin` removal, and the curl refusal; keep `upgradeCurl`/`upgradeScriptShell`/`Stream` deleted (take upstream's changes elsewhere). | | `packages/opencode/src/cli/cmd/uninstall.ts` | npm-family uninstall commands (display + exec maps) `opencode-ai` → `codev-code` | `codev uninstall` must remove the fork's actual npm package; the old name was a silent no-op for every npm-installed fork user. NOT renamed: brew/choco/scoop package names (`opencode` — the fork publishes no such formula/package, and `method()` never resolves to those for a fork install) and the curl `.opencode/bin` + `# opencode` shell markers (they match upstream install-script artifacts, not the fork's). | Keep our `codev-code` names; take upstream's surrounding changes. | | `packages/opencode/src/config/config.ts` | `share` defaults to `"disabled"` when unset | Sharing publishes sessions to upstream's share service; internal users must opt in via config. | Keep our default block. | diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index b569c2c161..d01f821e8f 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -211,6 +211,7 @@ export function Session() { const transcript = exitTranscript({ messages: messages().map((msg) => ({ info: msg, parts: sync.data.part[msg.id] ?? [] })), formatPath: pathFormatter.format, + colors: { text: theme.text, textMuted: theme.textMuted, error: theme.error }, }) const epilogue = sessionEpilogue({ title, sessionID }) return transcript ? `${transcript}\n\n${epilogue}` : epilogue diff --git a/packages/tui/src/util/exit-transcript.ts b/packages/tui/src/util/exit-transcript.ts index 4c9c14d22c..4a1c380352 100644 --- a/packages/tui/src/util/exit-transcript.ts +++ b/packages/tui/src/util/exit-transcript.ts @@ -1,3 +1,4 @@ +import type { RGBA } from "@opentui/core" import type { ToolPart } from "@opencode-ai/sdk/v2" import { Locale } from "./locale" import type { MessageWithParts } from "./transcript" @@ -5,6 +6,12 @@ import type { MessageWithParts } from "./transcript" const reset = "\x1b[0m" const dim = "\x1b[90m" +export type ExitTranscriptColors = { + text: RGBA + textMuted: RGBA + error: RGBA +} + const PRIMARY_INPUT: Record = { bash: "command", read: "filePath", @@ -23,14 +30,20 @@ const PATH_TOOLS = new Set(["read", "write", "edit"]) export function exitTranscript(input: { messages: MessageWithParts[] formatPath?: (value?: string) => string + colors?: ExitTranscriptColors }): string { + const text = fg(input.colors?.text) + const muted = input.colors ? fg(input.colors.textMuted) : dim const blocks = input.messages.flatMap((msg) => msg.parts.flatMap((part) => { if (part.type === "text" && !part.synthetic && part.text.trim()) { - if (msg.info.role === "user") return [{ tool: false, content: userBlock(part.text) }] - return [{ tool: false, content: part.text.trim() }] + if (msg.info.role === "user") return [{ tool: false, content: userBlock(part.text, text, muted) }] + return [{ tool: false, content: paint(part.text.trim(), text) }] + } + if (part.type === "tool") { + const color = part.state.status === "error" && input.colors ? fg(input.colors.error) : muted + return [{ tool: true, content: toolLine(part, color, input.formatPath) }] } - if (part.type === "tool") return [{ tool: true, content: toolLine(part, input.formatPath) }] return [] }), ) @@ -43,16 +56,29 @@ export function exitTranscript(input: { .join("") } -function userBlock(text: string) { - const lines = text.trim().split("\n") - return [`${dim}>${reset} ${lines[0]}`, ...lines.slice(1).map((line) => ` ${line}`)].join("\n") +function fg(color?: RGBA) { + if (!color) return "" + const [r, g, b] = color.toInts() + return `\x1b[38;2;${r};${g};${b}m` +} + +function paint(value: string, color: string) { + if (!color) return value + return `${color}${value}${reset}` +} + +function userBlock(value: string, text: string, muted: string) { + const lines = value.trim().split("\n") + return [`${muted}>${reset} ${paint(lines[0], text)}`, ...lines.slice(1).map((line) => ` ${paint(line, text)}`)].join( + "\n", + ) } -function toolLine(part: ToolPart, formatPath?: (value?: string) => string) { +function toolLine(part: ToolPart, color: string, formatPath?: (value?: string) => string) { const name = Locale.titlecase(part.tool) const arg = toolArgument(part, formatPath) - if (!arg) return `${dim}⏺ ${name}${reset}` - return `${dim}⏺ ${name}(${Locale.truncate(arg.replaceAll("\n", " "), 120)})${reset}` + if (!arg) return `${color}⏺ ${name}${reset}` + return `${color}⏺ ${name}(${Locale.truncate(arg.replaceAll("\n", " "), 120)})${reset}` } function toolArgument(part: ToolPart, formatPath?: (value?: string) => string) { diff --git a/packages/tui/test/util/exit-transcript.test.ts b/packages/tui/test/util/exit-transcript.test.ts index 49228ad14f..6196a785ec 100644 --- a/packages/tui/test/util/exit-transcript.test.ts +++ b/packages/tui/test/util/exit-transcript.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import { RGBA } from "@opentui/core" import { exitTranscript } from "../../src/util/exit-transcript" import type { AssistantMessage, Part, UserMessage } from "@opencode-ai/sdk/v2" @@ -104,6 +105,35 @@ describe("exitTranscript", () => { expect(plain).toContain("echo a echo b") }) + test("paints theme colors when provided", () => { + const errorTool: Part = { + id: "part_err", + sessionID: "ses_123", + messageID: "msg_assistant", + type: "tool", + callID: "call_err", + tool: "bash", + state: { status: "error", input: { command: "boom" }, error: "failed", time: { start: 1, end: 2 } }, + } + const result = exitTranscript({ + messages: [ + { info: userInfo, parts: [textPart("msg_user", "hi")] }, + { info: assistantInfo, parts: [toolPart("bash", { command: "bun test" }), errorTool, textPart("msg_assistant", "Done.")] }, + ], + colors: { + text: RGBA.fromInts(1, 2, 3), + textMuted: RGBA.fromInts(4, 5, 6), + error: RGBA.fromInts(7, 8, 9), + }, + }) + expect(result).toContain("\x1b[38;2;1;2;3mhi\x1b[0m") + expect(result).toContain("\x1b[38;2;1;2;3mDone.\x1b[0m") + expect(result).toContain("\x1b[38;2;4;5;6m⏺ Bash(bun test)\x1b[0m") + expect(result).toContain("\x1b[38;2;7;8;9m⏺ Bash(boom)\x1b[0m") + expect(result).toContain("\x1b[38;2;4;5;6m>\x1b[0m") + expect(result).not.toContain("\x1b[90m") + }) + test("skips reasoning, synthetic, and other part types", () => { const reasoning: Part = { id: "part_r", From 89bab71a9c8912a5328e0a72ec1aeaa405b3dfb5 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Thu, 30 Jul 2026 09:50:10 +0700 Subject: [PATCH 3/4] feat(tui): style exit transcript markdown with theme colors Assistant text printed at quit was raw markdown in a single color. Approximate the session's renderable line by line using the theme's markdown* colors: headings bold in markdownHeading, bold/italic spans in markdownStrong/markdownEmph, inline code and fenced blocks in markdownCode, list bullets in markdownListItem, blockquotes in markdownBlockQuote, links underlined in markdownLink with the URL muted. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 6 ++- DIVERGENCES.md | 2 +- packages/tui/src/routes/session/index.tsx | 13 ++++- packages/tui/src/util/exit-transcript.ts | 54 ++++++++++++++++++- .../tui/test/util/exit-transcript.test.ts | 47 ++++++++++++++-- 5 files changed, 112 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d87ccf4a21..bd84543cce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,8 +25,10 @@ file is the human-readable summary of what changed and why. prefixed with `>`, assistant replies as plain text, and tool calls as muted one-line summaries like `⏺ Bash(bun test)` (no thinking, no tool output), followed by the usual logo/session epilogue. The transcript keeps the - session's theme colors — text, muted, and error (for failed tool calls). - Also fixes a teardown race that could swallow the epilogue entirely. + session's theme colors — text, muted, error (failed tool calls), and the + markdown styling of assistant replies (headings, bold, italic, inline code, + code fences, list bullets, links). Also fixes a teardown race that could + swallow the epilogue entirely. ## 1.18.4-2 diff --git a/DIVERGENCES.md b/DIVERGENCES.md index d54bb0d33a..62d3974e5f 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -108,7 +108,7 @@ from the squash message first, or use a plain merge. | `packages/tui/src/config/keybind.ts`, `packages/tui/src/routes/session/question.tsx`, `packages/tui/src/ui/toast.tsx`, `packages/tui/test/app-lifecycle.test.tsx` | Double-press Ctrl+C to quit. keybind.ts: `app_exit` `ctrl+c,ctrl+d,q` → `ctrl+d,q`, added `app_exit_confirm: "ctrl+c"` definition + its `app.exit.confirm` CommandMap entry. question.tsx: a local `app.exit.confirm` command mirroring its `app.exit` reject, with the confirm binding gathered in both option branches (Ctrl+C still rejects the question — single press). toast.tsx: `ToastOptions.variant` union gains `"secondary"` (resolves to `theme.secondary`, the banner brand color, for the confirm toast). app-lifecycle.test.tsx: added the "app.exit.confirm requires two presses to exit" test. See also the `app.tsx`, `dialog.tsx`, and `permission.tsx` rows for their `app.exit.confirm` handlers. | A stray Ctrl+C on an empty/unfocused prompt no longer quits instantly: the first press shows a "Press Ctrl+C again to exit" toast (1.5 s window), the second within that window quits. Ctrl+D, `q`, and the `/exit` slash commands stay immediate. | Keep our binding split, the `app_exit_confirm`/`app.exit.confirm` definition + mapping, the local confirm commands + gathered bindings, the toast `"secondary"` variant, and the test; take upstream's surrounding changes. If upstream re-adds `ctrl+c` to `app_exit`, keep it removed. | | `packages/tui/src/config/keybind.ts`, `packages/opencode/test/cli/run/footer.view.test.tsx` | `sidebar_toggle` default `"b"` → `"ctrl+b,b"` (ctrl+b first so single-binding displays — the prompt-footer sidebar hint, the sidebar tip — resolve to it); `ctrl+b`'s former jobs move to `ctrl+o`: `input_move_left` `"left,ctrl+b"` → `"left,ctrl+o"`, `session_background` `"ctrl+b"` → `"ctrl+o"`; footer.view.test.tsx asserts the run-CLI footer background hint as `"ctrl+o background"` (was `"ctrl+b background"`) since it resolves the real `session_background` default | Single-chord sidebar toggle on `ctrl+b` (tmux/VS Code sidebar muscle memory); its displaced bindings land on `ctrl+o`, which was unbound in every scope and free of terminal-encoding collisions (unlike `ctrl+h`/`ctrl+i`/`ctrl+m`) and flow control (`ctrl+s`/`ctrl+q`). | Keep our defaults on all three keybinds and the footer hint assertion; take upstream's surrounding changes. If upstream binds `ctrl+o` to something else, re-audit and pick a new free key. | | `packages/tui/src/config/keybind.ts`, `packages/tui/src/component/prompt/index.tsx`, `packages/tui/src/app.tsx` | Single-press Ctrl+C interrupt. keybind.ts: added `session_interrupt_now: "ctrl+c"` + its `session.interrupt.now` CommandMap entry (`session_interrupt` keeps `escape`; its description gains "(press twice)"). prompt/index.tsx: added the `session.interrupt.now` command — same guards as upstream's `session.interrupt` but it aborts on the first press, and its `enabled` requires a busy session AND an empty prompt so ctrl+c falls through to `prompt.clear` (draft present) or `app.exit.confirm` (idle) instead of racing them on layer order; gathered the command in the `prompt.palette` binding list; the busy footer hint reads `interruptShortcut()` (resolves `session.interrupt`, mapping `escape` → `esc` and falling back to the `session.interrupt.now` label when escape is unbound) instead of the hardcoded `esc`. app.tsx: split `app_exit_confirm` out of the shared exit `useBindings` into its own layer whose `enabled` also requires `sync.data.session_status[route.data.sessionID]` to be idle, so ctrl+c can't arm the quit confirm mid-turn (`app_exit` keeps the shared predicate). Covered by the fork-only `packages/tui/test/interrupt.view.test.tsx`. See also the double-Ctrl+C row above, whose scope this narrows. | Ctrl+C is what many users reach for to stop a running agent, so it interrupts too; `escape` stays the advertised one in the busy footer and the home tip. Gating on `enabled` rather than binding order keeps the ctrl+c precedence (clear draft → interrupt → exit confirm) deterministic, since same-priority layers resolve by registration order and `useBindings` re-registers on every reactive change. | Keep the `session_interrupt_now` definition + mapping, the `session.interrupt.now` command and its `enabled` predicate, the footer shortcut memos, the split exit layer, and the test; take upstream's surrounding changes. If upstream binds `ctrl+c` to something else in the prompt, re-audit the precedence chain end to end. | -| `packages/tui/src/app.tsx`, `packages/tui/src/context/epilogue.tsx`, `packages/tui/src/routes/session/index.tsx`, `packages/tui/test/app-lifecycle.test.tsx` | Exit transcript. epilogue.tsx: the epilogue setter accepts `string \| (() => string)`. app.tsx: the `renderer.once("destroy")` listener materializes a thunk epilogue before resolving `shutdown` (it runs before `@opentui/solid`'s own destroy listener, so the reactive tree and sync store are still alive), and the `EpilogueProvider` setter ignores writes once `renderer.isDestroyed` — fixing a pre-existing race where the session route's `onCleanup(() => setEpilogue())` ran during Solid dispose and clobbered the epilogue before `run()` printed it. routes/session/index.tsx: the epilogue effect sets a thunk that prepends a compact transcript of the session (fork-only `util/exit-transcript.ts` + `test/util/exit-transcript.test.ts`, not tracked here) to `sessionEpilogue`, painted with the active theme's `text`/`textMuted`/`error` colors (truecolor ANSI; error-status tools get the error color), reading `messages()`/parts/theme only inside the thunk so message updates don't rebuild it. app-lifecycle.test.tsx: the epilogue test seeds a session with messages and asserts the transcript prints; its session id is `ses_demo` because `dummy` collides with the fork's placeholder route id and the Session route never mounts for it (this collision is why the test failed before), with session-scoped endpoints and a paid model stubbed and a frame-poll wait for the session view before dispatching `app.exit`. | Quitting the TUI restored the alternate screen and threw the conversation away; now the transcript (user `>` prompts, assistant text, dim `⏺ Tool(arg)` one-liners — no thinking, no tool output) survives in terminal scrollback like Claude Code, followed by the usual epilogue. | Keep the thunk union, the destroy-listener materialization, the `isDestroyed` setter guard, the thunk-producing effect, and the test's `ses_demo` id + stubs; take upstream's surrounding changes. | +| `packages/tui/src/app.tsx`, `packages/tui/src/context/epilogue.tsx`, `packages/tui/src/routes/session/index.tsx`, `packages/tui/test/app-lifecycle.test.tsx` | Exit transcript. epilogue.tsx: the epilogue setter accepts `string \| (() => string)`. app.tsx: the `renderer.once("destroy")` listener materializes a thunk epilogue before resolving `shutdown` (it runs before `@opentui/solid`'s own destroy listener, so the reactive tree and sync store are still alive), and the `EpilogueProvider` setter ignores writes once `renderer.isDestroyed` — fixing a pre-existing race where the session route's `onCleanup(() => setEpilogue())` ran during Solid dispose and clobbered the epilogue before `run()` printed it. routes/session/index.tsx: the epilogue effect sets a thunk that prepends a compact transcript of the session (fork-only `util/exit-transcript.ts` + `test/util/exit-transcript.test.ts`, not tracked here) to `sessionEpilogue`, painted with the active theme's colors (truecolor ANSI; error-status tools get the error color, and assistant text gets a line-based markdown approximation of the session's `` renderable — headings/bold/italic/inline code/fences/lists/links styled with the theme's `markdown*` colors), reading `messages()`/parts/theme only inside the thunk so message updates don't rebuild it. app-lifecycle.test.tsx: the epilogue test seeds a session with messages and asserts the transcript prints; its session id is `ses_demo` because `dummy` collides with the fork's placeholder route id and the Session route never mounts for it (this collision is why the test failed before), with session-scoped endpoints and a paid model stubbed and a frame-poll wait for the session view before dispatching `app.exit`. | Quitting the TUI restored the alternate screen and threw the conversation away; now the transcript (user `>` prompts, assistant text, dim `⏺ Tool(arg)` one-liners — no thinking, no tool output) survives in terminal scrollback like Claude Code, followed by the usual epilogue. | Keep the thunk union, the destroy-listener materialization, the `isDestroyed` setter guard, the thunk-producing effect, and the test's `ses_demo` id + stubs; take upstream's surrounding changes. | | `packages/opencode/src/installation/index.ts` | Updater detects/queries/installs `codev-code` (npm-family); GitHub fallback → quickbeard/codev-code. `method()` drops upstream's `~/.local/bin` → `curl` check, keeping only `~/.opencode/bin`. The `curl` upgrade branch refuses instead of running upstream's install script: `upgradeCurl` + `upgradeScriptShell` + the `Stream` import are removed, and `case "curl"` returns `UpgradeFailedError` pointing at `npm i -g codev-code` | `codev-code upgrade` and the update notification target OUR npm package, never stock opencode. brew/choco/scoop probes never match (no such packages). A `curl`-detected binary (in `~/.opencode/bin`) is a stock opencode install; piping upstream's `opencode.ai/install` script would silently replace the fork with stock opencode. `~/.local/bin` is dropped because it's also a common npm/bun global-bin prefix, so a legitimate package-manager install there was misdetected as curl (now falls through to the manager probes). | Keep our package names, the `~/.local/bin` removal, and the curl refusal; keep `upgradeCurl`/`upgradeScriptShell`/`Stream` deleted (take upstream's changes elsewhere). | | `packages/opencode/src/cli/cmd/uninstall.ts` | npm-family uninstall commands (display + exec maps) `opencode-ai` → `codev-code` | `codev uninstall` must remove the fork's actual npm package; the old name was a silent no-op for every npm-installed fork user. NOT renamed: brew/choco/scoop package names (`opencode` — the fork publishes no such formula/package, and `method()` never resolves to those for a fork install) and the curl `.opencode/bin` + `# opencode` shell markers (they match upstream install-script artifacts, not the fork's). | Keep our `codev-code` names; take upstream's surrounding changes. | | `packages/opencode/src/config/config.ts` | `share` defaults to `"disabled"` when unset | Sharing publishes sessions to upstream's share service; internal users must opt in via config. | Keep our default block. | diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index d01f821e8f..61e2b75ff2 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -211,7 +211,18 @@ export function Session() { const transcript = exitTranscript({ messages: messages().map((msg) => ({ info: msg, parts: sync.data.part[msg.id] ?? [] })), formatPath: pathFormatter.format, - colors: { text: theme.text, textMuted: theme.textMuted, error: theme.error }, + colors: { + text: theme.text, + textMuted: theme.textMuted, + error: theme.error, + markdownHeading: theme.markdownHeading, + markdownStrong: theme.markdownStrong, + markdownEmph: theme.markdownEmph, + markdownCode: theme.markdownCode, + markdownListItem: theme.markdownListItem, + markdownBlockQuote: theme.markdownBlockQuote, + markdownLink: theme.markdownLink, + }, }) const epilogue = sessionEpilogue({ title, sessionID }) return transcript ? `${transcript}\n\n${epilogue}` : epilogue diff --git a/packages/tui/src/util/exit-transcript.ts b/packages/tui/src/util/exit-transcript.ts index 4a1c380352..43b636baac 100644 --- a/packages/tui/src/util/exit-transcript.ts +++ b/packages/tui/src/util/exit-transcript.ts @@ -5,11 +5,21 @@ import type { MessageWithParts } from "./transcript" const reset = "\x1b[0m" const dim = "\x1b[90m" +const bold = "\x1b[1m" +const italic = "\x1b[3m" +const underline = "\x1b[4m" export type ExitTranscriptColors = { text: RGBA textMuted: RGBA error: RGBA + markdownHeading: RGBA + markdownStrong: RGBA + markdownEmph: RGBA + markdownCode: RGBA + markdownListItem: RGBA + markdownBlockQuote: RGBA + markdownLink: RGBA } const PRIMARY_INPUT: Record = { @@ -38,7 +48,8 @@ export function exitTranscript(input: { msg.parts.flatMap((part) => { if (part.type === "text" && !part.synthetic && part.text.trim()) { if (msg.info.role === "user") return [{ tool: false, content: userBlock(part.text, text, muted) }] - return [{ tool: false, content: paint(part.text.trim(), text) }] + if (input.colors) return [{ tool: false, content: markdownBlock(part.text.trim(), input.colors) }] + return [{ tool: false, content: part.text.trim() }] } if (part.type === "tool") { const color = part.state.status === "error" && input.colors ? fg(input.colors.error) : muted @@ -74,6 +85,47 @@ function userBlock(value: string, text: string, muted: string) { ) } +// Line-based approximation of the session's renderable, using the +// same theme colors its tree-sitter SyntaxStyle assigns to markup tokens. +function markdownBlock(value: string, colors: ExitTranscriptColors) { + const base = fg(colors.text) + const code = fg(colors.markdownCode) + let fenced = false + return value + .split("\n") + .map((line) => { + if (/^\s*(```|~~~)/.test(line)) { + fenced = !fenced + return paint(line, code) + } + if (fenced) return paint(line, code) + if (/^#{1,6}\s/.test(line)) return `${bold}${fg(colors.markdownHeading)}${line}${reset}` + if (/^\s*>\s?/.test(line)) return `${italic}${fg(colors.markdownBlockQuote)}${line}${reset}` + const bullet = line.match(/^(\s*)(?:[-*+]|\d+[.)])\s/) + if (bullet) + return `${fg(colors.markdownListItem)}${line.slice(0, bullet[0].length)}${reset}${base}${inlineMarkdown(line.slice(bullet[0].length), colors, base)}${reset}` + return `${base}${inlineMarkdown(line, colors, base)}${reset}` + }) + .join("\n") +} + +function inlineMarkdown(value: string, colors: ExitTranscriptColors, base: string) { + return value + .split(/(`[^`]+`)/) + .map((segment) => { + if (segment.startsWith("`") && segment.endsWith("`") && segment.length > 2) + return `${fg(colors.markdownCode)}${segment}${reset}${base}` + return segment + .replace(/\*\*([^*]+)\*\*/g, `${bold}${fg(colors.markdownStrong)}$1${reset}${base}`) + .replace(/(? string) { const name = Locale.titlecase(part.tool) const arg = toolArgument(part, formatPath) diff --git a/packages/tui/test/util/exit-transcript.test.ts b/packages/tui/test/util/exit-transcript.test.ts index 6196a785ec..79a572ec40 100644 --- a/packages/tui/test/util/exit-transcript.test.ts +++ b/packages/tui/test/util/exit-transcript.test.ts @@ -31,6 +31,19 @@ function textPart(messageID: string, text: string, synthetic?: boolean): Part { return { id: `part_${text.slice(0, 8)}`, sessionID: "ses_123", messageID, type: "text", text, synthetic } } +const testColors = { + text: RGBA.fromInts(1, 2, 3), + textMuted: RGBA.fromInts(4, 5, 6), + error: RGBA.fromInts(7, 8, 9), + markdownHeading: RGBA.fromInts(10, 11, 12), + markdownStrong: RGBA.fromInts(13, 14, 15), + markdownEmph: RGBA.fromInts(16, 17, 18), + markdownCode: RGBA.fromInts(19, 20, 21), + markdownListItem: RGBA.fromInts(22, 23, 24), + markdownBlockQuote: RGBA.fromInts(25, 26, 27), + markdownLink: RGBA.fromInts(28, 29, 30), +} + function toolPart(tool: string, input: Record): Part { return { id: `part_${tool}`, @@ -120,11 +133,7 @@ describe("exitTranscript", () => { { info: userInfo, parts: [textPart("msg_user", "hi")] }, { info: assistantInfo, parts: [toolPart("bash", { command: "bun test" }), errorTool, textPart("msg_assistant", "Done.")] }, ], - colors: { - text: RGBA.fromInts(1, 2, 3), - textMuted: RGBA.fromInts(4, 5, 6), - error: RGBA.fromInts(7, 8, 9), - }, + colors: testColors, }) expect(result).toContain("\x1b[38;2;1;2;3mhi\x1b[0m") expect(result).toContain("\x1b[38;2;1;2;3mDone.\x1b[0m") @@ -134,6 +143,34 @@ describe("exitTranscript", () => { expect(result).not.toContain("\x1b[90m") }) + test("styles assistant markdown like the session", () => { + const markdown = [ + "## Working agreements", + "", + "- Tests run from `packages/tui` and **never** from root.", + "", + "```ts", + "const x = 1", + "```", + "", + "See [the docs](https://example.com) for *details*.", + ].join("\n") + const result = exitTranscript({ + messages: [{ info: assistantInfo, parts: [textPart("msg_assistant", markdown)] }], + colors: testColors, + }) + expect(result).toContain("\x1b[1m\x1b[38;2;10;11;12m## Working agreements\x1b[0m") + expect(result).toContain("\x1b[38;2;22;23;24m- \x1b[0m") + expect(result).toContain("\x1b[38;2;19;20;21m`packages/tui`\x1b[0m") + expect(result).toContain("\x1b[1m\x1b[38;2;13;14;15mnever\x1b[0m") + expect(result).toContain("\x1b[38;2;19;20;21m```ts\x1b[0m") + expect(result).toContain("\x1b[38;2;19;20;21mconst x = 1\x1b[0m") + expect(result).toContain("\x1b[4m\x1b[38;2;28;29;30mthe docs\x1b[0m") + expect(result).toContain("(https://example.com)") + expect(result).toContain("\x1b[3m\x1b[38;2;16;17;18mdetails\x1b[0m") + expect(Bun.stripANSI(result)).toContain("- Tests run from `packages/tui` and never from root.") + }) + test("skips reasoning, synthetic, and other part types", () => { const reasoning: Part = { id: "part_r", From d6e928977669845e4eb6ea4efa1d3dc10191f5cd Mon Sep 17 00:00:00 2001 From: minhn4 Date: Thu, 30 Jul 2026 09:57:46 +0700 Subject: [PATCH 4/4] feat(tui): trim exit epilogue to the Continue line only Drop the codev wordmark banner and the "Session " line from sessionEpilogue; the exit transcript already shows the conversation, so only the "Continue codev -s <id>" resume hint remains. Removes the now unused title input. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- CHANGELOG.md | 11 ++++++----- DIVERGENCES.md | 2 +- packages/tui/src/routes/session/index.tsx | 3 +-- packages/tui/src/util/presentation.ts | 12 ++---------- packages/tui/test/app-lifecycle.test.tsx | 1 - packages/tui/test/util/presentation.test.ts | 4 ++-- 6 files changed, 12 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd84543cce..267807bdbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,11 +24,12 @@ file is the human-readable summary of what changed and why. transcript to the terminal scrollback in a compact format: user prompts prefixed with `>`, assistant replies as plain text, and tool calls as muted one-line summaries like `⏺ Bash(bun test)` (no thinking, no tool output), - followed by the usual logo/session epilogue. The transcript keeps the - session's theme colors — text, muted, error (failed tool calls), and the - markdown styling of assistant replies (headings, bold, italic, inline code, - code fences, list bullets, links). Also fixes a teardown race that could - swallow the epilogue entirely. + followed by a one-line `Continue codev -s <id>` resume hint (the logo + wordmark and session-title banner that used to print on quit are gone). The + transcript keeps the session's theme colors — text, muted, error (failed + tool calls), and the markdown styling of assistant replies (headings, bold, + italic, inline code, code fences, list bullets, links). Also fixes a + teardown race that could swallow the epilogue entirely. ## 1.18.4-2 diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 62d3974e5f..4309b5bc9c 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -89,7 +89,7 @@ from the squash message first, or use a plain merge. | `packages/tui/src/component/logo.tsx` | Renders the lowercase codev wordmark in the theme's `secondary` color (bold, with a `tint`-derived drop shadow) instead of upstream's two-tone "open"+"code" | Home banner shows the brand wordmark in the active theme's brand color (teal in dark, purple in light). | Keep our version wholesale. | | `packages/tui/src/theme/assets/codev.json` (new file), `packages/tui/src/theme/index.ts`, `packages/tui/src/context/theme.tsx` | Added the `codev` theme — upstream's `opencode.json` palette with `darkSecondary` → `#0891b2` (brand teal) — registered it in the theme index, and made it the default/fallback theme (`"opencode"` → `"codev"` in the store default, kv fallback, and load-failure fallbacks). `theme/assets/opencode.json` itself matches upstream again. | Brand accent lives in a fork-owned theme instead of edits to upstream's palette; the default theme carries the brand. | Keep our theme file, its index registration, and the fallback strings; when upstream changes the opencode palette, mirror it into `codev.json` (keeping our `darkSecondary`). | | `packages/opencode/src/cli/ui.ts` | `wordmark`/`logo()` render the lowercase codev wordmark via `ansiWordmark` (bold brand teal + drop shadow on TTY) | Help banner matches the TUI home banner. | Keep our wordmark + logo(); take upstream's surrounding changes. | -| `packages/tui/src/util/presentation.ts` | Local logo copy replaced by the shared `ansiWordmark` (brand teal + drop shadow) | Presentation-mode wordmark was still upstream-branded. | Keep our wordmark lines; take upstream's surrounding changes. | +| `packages/tui/src/util/presentation.ts` | `sessionEpilogue` returns only the one-line `Continue codev -s <id>` hint — dropped the `ansiWordmark` banner and the `Session <title>` line (and the now-unused `title` input) | The exit transcript (see the exit-transcript row) already prints the conversation on quit; the wordmark + title banner was redundant noise above the resume hint. | Keep the single Continue line; if upstream re-adds a banner/title here, keep it removed. | | `packages/tui/src/feature-plugins/sidebar/footer.tsx` | Removed the footer's brand + version line entirely (upstream's two-tone "Open"+"Code" spans, status dot, and `app.version`); the `cwd:branch` path is now the last footer item | The fork's sidebar doesn't advertise the product name/version; the path takes that bottom slot. (Supersedes the earlier single-"CoDev"-span rebrand of this line.) | Keep the line deleted; take upstream's surrounding changes. | | `packages/tui/src/feature-plugins/sidebar/footer.tsx` | Added the editor-context label above the `cwd:branch` line: `useEditorLabel()` (fork-only `src/context/editor-label.tsx`) supplies the effective selection, an `editorFile` memo formats it via `editorContextFileLabel(...)` at a fixed width of 36 (the sidebar's 42 columns less 2 of padding a side and 2 for the bullet), and `<EditorContextLabel>` renders it — both from the fork-only `./editor-context-label.tsx`. | The open file + selected range belong with the sidebar's other ambient session state rather than in the prompt footer's crowded keybind-hint row; they sit next to the cwd for a single "where am I working" group. Moved out of the prompt footer, which no longer shows it at all. | Keep the `useEditorLabel` import, the `editorFile` memo, and the `<EditorContextLabel>` element as the box's first child; take upstream's changes to the path display below it. | | `packages/tui/src/feature-plugins/sidebar/lsp.tsx` | Whole LSP section wrapped in `<Show when={!off()}>` so it renders nothing when `config.lsp` is falsy; the "LSPs are disabled" placeholder text removed (empty state is only "LSPs will activate as files are read") | A disabled feature shouldn't occupy sidebar space with a permanent placeholder. | Keep our `Show` wrapper + removed placeholder; take upstream's surrounding changes. | diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 61e2b75ff2..b93aacc5a2 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -204,7 +204,6 @@ export function Session() { const pathFormatter = usePathFormatter() createEffect(() => { - const title = Locale.truncate(session()?.title ?? "", 50) const sessionID = session()?.id // Thunk keeps message reads out of the effect; it runs once at renderer destroy. setEpilogue(() => { @@ -224,7 +223,7 @@ export function Session() { markdownLink: theme.markdownLink, }, }) - const epilogue = sessionEpilogue({ title, sessionID }) + const epilogue = sessionEpilogue({ sessionID }) return transcript ? `${transcript}\n\n${epilogue}` : epilogue }) }) diff --git a/packages/tui/src/util/presentation.ts b/packages/tui/src/util/presentation.ts index 409ad8007f..9203eb04e2 100644 --- a/packages/tui/src/util/presentation.ts +++ b/packages/tui/src/util/presentation.ts @@ -1,16 +1,8 @@ -import { ansiWordmark } from "../logo" - const reset = "\x1b[0m" const bold = "\x1b[1m" const dim = "\x1b[90m" -export function sessionEpilogue(input: { title: string; sessionID?: string }) { +export function sessionEpilogue(input: { sessionID?: string }) { const weak = (text: string) => `${dim}${text.padEnd(10, " ")}${reset}` - return [ - ...ansiWordmark(" "), - "", - ` ${weak("Session")}${bold}${input.title}${reset}`, - ` ${weak("Continue")}${bold}codev -s ${input.sessionID}${reset}`, - "", - ].join("\n") + return ` ${weak("Continue")}${bold}codev -s ${input.sessionID}${reset}` } diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index d7e27bc477..8be378b848 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -186,7 +186,6 @@ test("app.exit prints the session transcript and epilogue after scoped cleanup", expect(plain).toContain("> hello there") expect(plain).toContain("⏺ Bash(bun test)") expect(plain).toContain("All tests pass.") - expect(plain).toContain("Demo session") expect(plain).toContain("codev -s ses_demo") } finally { process.stdout.write = originalWrite diff --git a/packages/tui/test/util/presentation.test.ts b/packages/tui/test/util/presentation.test.ts index 47f870ba8c..27ac7884a6 100644 --- a/packages/tui/test/util/presentation.test.ts +++ b/packages/tui/test/util/presentation.test.ts @@ -2,7 +2,7 @@ import { expect, test } from "bun:test" import { sessionEpilogue } from "../../src/util/presentation" test("formats session continuation summary", () => { - const epilogue = sessionEpilogue({ title: "A session", sessionID: "ses_123" }) - expect(epilogue).toContain("A session") + const epilogue = sessionEpilogue({ sessionID: "ses_123" }) + expect(epilogue).toContain("Continue") expect(epilogue).toContain("codev -s ses_123") })