Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@ 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 muted
one-line summaries like `⏺ Bash(bun test)` (no thinking, no tool output),
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

### TUI
Expand Down
3 changes: 2 additions & 1 deletion DIVERGENCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand All @@ -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,<leader>q` → `ctrl+d,<leader>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, `<leader>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 `"<leader>b"` → `"ctrl+b,<leader>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 colors (truecolor ANSI; error-status tools get the error color, and assistant text gets a line-based markdown approximation of the session's `<markdown>` 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. |
Expand Down
18 changes: 14 additions & 4 deletions packages/tui/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 () => {
Expand All @@ -259,7 +263,13 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
destroyRenderer(renderer)
}}
>
<EpilogueProvider set={(value) => (exit.epilogue = value)}>
<EpilogueProvider
set={(value) => {
// Solid disposes after destroy; ignore the route onCleanup clearing the epilogue.
if (renderer.isDestroyed) return
exit.epilogue = value
}}
>
<ErrorBoundary fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />}>
<TuiPathsProvider
value={{
Expand Down Expand Up @@ -369,7 +379,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
}, renderer)
})
yield* Deferred.await(shutdown)
return { epilogue: exit.epilogue, reason: exit.reason }
return { epilogue: typeof exit.epilogue === "function" ? exit.epilogue() : exit.epilogue, reason: exit.reason }
}),
)
yield* Effect.sync(() => {
Expand Down
2 changes: 1 addition & 1 deletion packages/tui/src/context/epilogue.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
26 changes: 24 additions & 2 deletions packages/tui/src/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -201,9 +202,30 @@ 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,
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({ sessionID })
return transcript ? `${transcript}\n\n${epilogue}` : epilogue
})
})
onCleanup(() => setEpilogue())
const children = createMemo(() => {
Expand Down
Loading
Loading