From 0d48bfd20ce43fbd0c7d44e43d391cb28e9280ca Mon Sep 17 00:00:00 2001 From: minhn4 Date: Sun, 2 Aug 2026 11:27:25 +0700 Subject: [PATCH 1/6] feat(tui): compact todo panel above the prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While the sidebar is hidden and the session has unfinished todos, render up to 4 items windowed around the active one directly above the input, with an adaptive summary line (`2 of 6 finished · ctrl+b to show full TODO`, or `still N items in the TODO list · …` before anything is done). The full list stays in the sidebar; the prompt-footer hint now reports only modified files since the panel covers todos. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 8 + DIVERGENCES.md | 2 +- packages/tui/src/routes/session/index.tsx | 9 +- .../tui/src/routes/session/sidebar-hint.ts | 4 +- .../src/routes/session/todo-panel-model.ts | 28 ++++ .../tui/src/routes/session/todo-panel.tsx | 20 +++ packages/tui/test/routes/sidebar-hint.test.ts | 25 +-- .../tui/test/routes/todo-panel-model.test.ts | 102 ++++++++++++ packages/tui/test/sidebar-hint.view.test.tsx | 17 +- packages/tui/test/todo-panel.view.test.tsx | 153 ++++++++++++++++++ 10 files changed, 336 insertions(+), 32 deletions(-) create mode 100644 packages/tui/src/routes/session/todo-panel-model.ts create mode 100644 packages/tui/src/routes/session/todo-panel.tsx create mode 100644 packages/tui/test/routes/todo-panel-model.test.ts create mode 100644 packages/tui/test/todo-panel.view.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 8843328a9c..daaac05033 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,14 @@ file is the human-readable summary of what changed and why. ### TUI +- **Compact TODO panel above the prompt** — while the sidebar is hidden and the + session has unfinished todos, up to 4 items around the active one now render + directly above the input, with a summary line such as + `2 of 6 finished · ctrl+b to show full TODO` (or + `still 6 items in the TODO list · …` before anything is done). The full list + stays in the sidebar (`ctrl+b`). The prompt-footer hint now reports only + modified files, since the panel covers todos. + - **`ctrl+v` pastes into dialog prompts** — dialog text fields (the API key and base URL prompts in `/connect` and the custom-provider and configure flows, workspace names, …) previously relied entirely on the terminal performing a diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 3ce5e62e15..a0451fcc31 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -98,7 +98,7 @@ from the squash message first, or use a plain merge. | `packages/tui/src/app.tsx` | Terminal title "OpenCode"/"OC \|" → "CoDev Code"/"CC \|"; the configure-gate effect is a stateless forced-setup invariant — while `sync.status === "complete" && !useConnected() && dialog.stack.length === 0` it (re)opens the fork's `DialogConfigure` (component/dialog-configure.tsx, new file), so escaping any gate-launched flow or a credential-only save bounces back to the gate instead of an unconfigured chat (gates on `!useConnected()`, not `provider.length === 0` — hosted Zen free models always register); added the `codev.configure` command (`/configure`) and a `CODEV_CONFIGURE=1` mount hook for `codev configure`; home screen skipped — plain startup seeds the `dummy` session route and auto-opens a new empty session (reusing a leftover default-titled empty one via `openNewSession`/`findEmptySession`), `session.new` and the current-session `session.deleted` handler open a new session instead of navigating home, and the `-c` resolver prefers non-default-titled sessions so it lands on the last real conversation; added the `app.exit.confirm` command + `EXIT_CONFIRM_WINDOW` const (double-press Ctrl+C to quit: first press shows a toast, second within 1.5 s exits) and gathered its `app_exit_confirm` binding alongside `app_exit` | Tab title branding; first-run setup is a forced gate the user can't escape into an unconfigured chat (the invariant reopens it); `codev` starts directly in the session layout instead of the home screen; a stray Ctrl+C no longer quits on the first press. | Keep our strings, the invariant gate effect, dialog swap + command block, route seed, `openNewSession`/`findEmptySession` + call sites, the `-c` default-title filter, and the `app.exit.confirm` command + binding; take upstream's surrounding changes. | | `packages/tui/src/routes/session/index.tsx` | Flow transcript: the scrollbox opens with a home-like banner (`Logo` + `home_bottom` tips slot, `placement="start"`) as its first entry for root sessions (subagent sessions keep a plain 1-row pad), its `maxHeight` is clamped to the content height (a `transcriptHeight` signal read off the content's yoga node in the renderer root's `layout-changed` handler, which then re-runs `calculateLayout` so the clamp lands in the same frame — `scrollHeight` is a frame stale there, and painting with a stale clamp flickers during streaming) so the `Prompt` rides directly below the last message, and a trailing flexGrow spacer absorbs the leftover space until content fills the screen (then the prompt is effectively bottom-anchored and the banner scrolls away with history); the session `Prompt` gets home's `placeholder` pool when the session has no messages so the input shows an `Ask anything... ""` placeholder like home | Startup lands in an empty session instead of the home screen; the transcript flows top-down like terminal scrollback — logo top-left, input following the conversation down — instead of a bottom-anchored prompt with a void above it. | Keep our banner block inside the scrollbox, the `transcriptHeight` clamp + `layout-changed` listener, the trailing spacer, and the `placeholders` prop; take upstream's surrounding changes. | | `packages/tui/src/routes/session/index.tsx` | Sidebar visibility is session-local: removed upstream's persisted `kv.signal<"auto" \| "hide">("sidebar", "auto")` (and the `sidebar() === "auto" && wide()` branch of `sidebarVisible`); visibility is driven only by the non-persisted `sidebarOpen` signal, which the `session.sidebar.toggle` command flips (supersedes the earlier `"auto"` → `"hide"` default change) | The right sidebar starts hidden in every session regardless of terminal width or what the user did last session; `ctrl+b`/`b` only affects the current session. A stale `sidebar` key in existing kv.json files is simply never read. | Keep the kv signal removed and `sidebarVisible` reading only `parentID` + `sidebarOpen`; take upstream's surrounding changes. | -| `packages/tui/src/routes/session/index.tsx` | Sidebar hint in the prompt footer: a `sidebarHint` memo (active-todo count from `sync.data.todo`, modified-file count from `sync.data.session_diff`, only when the sidebar is hidden and the session isn't a subagent; the label text comes from `sidebarHintLabel` in the fork-only `routes/session/sidebar-hint.ts`) plus a `sidebarShortcut` (`useCommandShortcut("session.sidebar.toggle")`), passed as the `Prompt`'s previously unused `hint` prop — e.g. `ctrl+b 3 todos · 5 files` | The sidebar starts hidden in this fork (see the row above), so users would otherwise never learn there are todos/modified files or how to reveal them. | Keep our `sidebarShortcut`/`sidebarHint` memos, the `sidebar-hint` import, and the `hint={…}` prop on `` (all additive); take upstream's surrounding changes. If upstream starts passing its own `hint` to this ``, merge the two under one `Show`. | +| `packages/tui/src/routes/session/index.tsx` | Hidden-sidebar surfaces: a `sidebarHint` memo (modified-file count from `sync.data.session_diff`, only when the sidebar is hidden and the session isn't a subagent; label from `sidebarHintLabel` in the fork-only `routes/session/sidebar-hint.ts`) plus a `sidebarShortcut` (`useCommandShortcut("session.sidebar.toggle")`), passed as the `Prompt`'s previously unused `hint` prop — e.g. `ctrl+b 5 modified files`; and a compact `` (fork-only `routes/session/todo-panel.tsx` + `todo-panel-model.ts`) mounted inside `` under an extra ``, directly above the `session_prompt` slot — up to 4 checkbox items windowed around the active todo plus a summary line (`2 of 6 finished · ctrl+b to show full TODO`), shown only while an unfinished todo exists | The sidebar starts hidden in this fork (see the row above), so users would otherwise never see the agent's plan or modified files or how to reveal them. The panel shows the active todos inline, so the footer hint only needs to cover modified files. | Keep our `sidebarShortcut`/`sidebarHint` memos, the `sidebar-hint` and `todo-panel` imports, the `TodoPanel` mount above the `session_prompt` slot, and the `hint={…}` prop on `` (all additive); take upstream's surrounding changes. If upstream starts passing its own `hint` to this ``, merge the two under one `Show`; if upstream adds its own children to the bottom box, keep `TodoPanel` directly above the prompt slot. | | `packages/tui/src/routes/session/index.tsx` | Assistant-message trailer dedup: a `suppressed` memo in `AssistantMessage` hides the `▣ Agent · Model` prefix on the last message when it matches the currently selected agent (`local.agent.current()?.name === message.mode`) and model (`local.model.current()` vs the message's `providerID`/`modelID`), since the prompt footer right below already shows that selection; the duration still renders as `Baked for ` (and `interrupted` keeps rendering on aborts), and the trailer box is skipped entirely when suppression leaves nothing to show | The last message's trailer and the prompt footer showed the same agent + model twice, one line apart. | Keep the `suppressed` memo and the `Show` gates around the trailer spans; take upstream's surrounding trailer changes (new suffixes should go inside the existing `` with a `·` separator like `interrupted`). | | `packages/plugin/src/tui.ts` | `home_bottom` slot props widened from `{}` to `{ placement?: "center" \| "start" }` | The empty-session banner asks `home_bottom` contributors to left-align; optional prop keeps the home route and third-party plugins (no prop → centered) unchanged. | Keep our `placement` prop; take upstream's other slot-map changes. | | `packages/tui/src/feature-plugins/home/tips.tsx` | `home_bottom` slot fn reads the new `placement` prop; with `"start"` the tips box left-aligns (`flex-start`) and drops its `paddingTop` 3 | The top-left session banner needs the tip flush under the logo; the centered home route keeps upstream spacing. | Keep our `placement` threading + conditional alignItems/paddingTop; take upstream's surrounding changes. | diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index ac9c3360ea..e3bcf9349b 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -56,6 +56,7 @@ import { Logo } from "../../component/logo" import { placeholder } from "../home" import { Sidebar } from "./sidebar" import { sidebarHintLabel } from "./sidebar-hint" +import { TodoPanel } from "./todo-panel" import { SubagentFooter } from "./subagent-footer.tsx" import { filetype } from "../../util/filetype" import parsers from "../../parsers-config" @@ -293,10 +294,7 @@ export function Session() { const sidebarShortcut = useCommandShortcut("session.sidebar.toggle") const sidebarHint = createMemo(() => { if (sidebarVisible() || session()?.parentID) return - return sidebarHintLabel( - (sync.data.todo[route.sessionID] ?? []).filter((item) => item.status !== "completed").length, - (sync.data.session_diff[route.sessionID] ?? []).length, - ) + return sidebarHintLabel((sync.data.session_diff[route.sessionID] ?? []).length) }) const providers = createMemo(() => Model.index(sync.data.provider)) @@ -1360,6 +1358,9 @@ export function Session() { + + + 1 ? "s" : ""} · ${files} file${files > 1 ? "s" : ""}` - if (todos) return `${todos} todo${todos > 1 ? "s" : ""}` +export function sidebarHintLabel(files: number) { if (files) return `${files} modified file${files > 1 ? "s" : ""}` } diff --git a/packages/tui/src/routes/session/todo-panel-model.ts b/packages/tui/src/routes/session/todo-panel-model.ts new file mode 100644 index 0000000000..56fdeddecd --- /dev/null +++ b/packages/tui/src/routes/session/todo-panel-model.ts @@ -0,0 +1,28 @@ +export interface TodoLike { + content: string + status: string +} + +export const TODO_PANEL_SIZE = 4 + +// Active-window selection: show one item of context before the active item, +// then the active item and what follows, clamped to a full window when +// enough items exist. Active = first in_progress, else first pending, else +// the last item. +export function todoWindow(todos: T[], size = TODO_PANEL_SIZE): T[] { + if (todos.length === 0) return [] + const inProgress = todos.findIndex((item) => item.status === "in_progress") + const pending = todos.findIndex((item) => item.status === "pending") + const active = inProgress !== -1 ? inProgress : pending !== -1 ? pending : todos.length - 1 + const start = Math.min(Math.max(active - 1, 0), Math.max(0, todos.length - size)) + return todos.slice(start, start + size) +} + +// Cancelled counts as unfinished: only "completed" is done, and the total is +// the full list, so a visible panel never claims everything is finished. +export function todoSummaryLabel(todos: TodoLike[], shortcut: string): string { + const done = todos.filter((item) => item.status === "completed").length + const suffix = `${shortcut} to show full TODO` + if (done > 0) return `${done} of ${todos.length} finished · ${suffix}` + return `still ${todos.length} item${todos.length > 1 ? "s" : ""} in the TODO list · ${suffix}` +} diff --git a/packages/tui/src/routes/session/todo-panel.tsx b/packages/tui/src/routes/session/todo-panel.tsx new file mode 100644 index 0000000000..1d3628bf51 --- /dev/null +++ b/packages/tui/src/routes/session/todo-panel.tsx @@ -0,0 +1,20 @@ +import { createMemo, For, Show } from "solid-js" +import type { Todo } from "@opencode-ai/sdk/v2" +import { TodoItem } from "../../component/todo-item" +import { useTheme } from "../../context/theme" +import { todoSummaryLabel, todoWindow } from "./todo-panel-model" + +export function TodoPanel(props: { todos: Todo[]; shortcut: string }) { + const { theme } = useTheme() + const show = createMemo(() => props.todos.some((item) => item.status !== "completed")) + const visible = createMemo(() => todoWindow(props.todos)) + + return ( + + + {(item) => } + {todoSummaryLabel(props.todos, props.shortcut)} + + + ) +} diff --git a/packages/tui/test/routes/sidebar-hint.test.ts b/packages/tui/test/routes/sidebar-hint.test.ts index 5ea7607da2..a8c3de6827 100644 --- a/packages/tui/test/routes/sidebar-hint.test.ts +++ b/packages/tui/test/routes/sidebar-hint.test.ts @@ -3,29 +3,20 @@ import { sidebarHintLabel } from "../../src/routes/session/sidebar-hint" import { TuiKeybind } from "../../src/config/keybind" describe("sidebarHintLabel", () => { - test("returns nothing when there are no todos or files", () => { - expect(sidebarHintLabel(0, 0)).toBeUndefined() + test("returns nothing when there are no files", () => { + expect(sidebarHintLabel(0)).toBeUndefined() }) - test("pluralizes todo counts", () => { - expect(sidebarHintLabel(1, 0)).toBe("1 todo") - expect(sidebarHintLabel(2, 0)).toBe("2 todos") - }) - - test("uses the long form for files alone", () => { - expect(sidebarHintLabel(0, 1)).toBe("1 modified file") - expect(sidebarHintLabel(0, 5)).toBe("5 modified files") - }) - - test("combines both counts with the short file form", () => { - expect(sidebarHintLabel(1, 1)).toBe("1 todo · 1 file") - expect(sidebarHintLabel(3, 5)).toBe("3 todos · 5 files") + test("pluralizes file counts", () => { + expect(sidebarHintLabel(1)).toBe("1 modified file") + expect(sidebarHintLabel(5)).toBe("5 modified files") }) }) describe("sidebar_toggle default", () => { - // The prompt-footer hint and the sidebar tip display the FIRST binding - // (useCommandShortcut), so ctrl+b must stay ahead of the leader chord. + // The prompt-footer hint, the todo panel, and the sidebar tip display the + // FIRST binding (useCommandShortcut), so ctrl+b must stay ahead of the + // leader chord. test("lists ctrl+b before the leader binding", () => { expect(TuiKeybind.Definitions.sidebar_toggle.default).toBe("ctrl+b,b") }) diff --git a/packages/tui/test/routes/todo-panel-model.test.ts b/packages/tui/test/routes/todo-panel-model.test.ts new file mode 100644 index 0000000000..3ca40e3be0 --- /dev/null +++ b/packages/tui/test/routes/todo-panel-model.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, test } from "bun:test" +import { todoSummaryLabel, todoWindow } from "../../src/routes/session/todo-panel-model" + +const list = (statuses: string[]) => statuses.map((status, index) => ({ content: `task ${index + 1}`, status })) +const contents = (statuses: string[]) => todoWindow(list(statuses)).map((item) => item.content) + +describe("todoWindow", () => { + test("returns nothing for an empty list", () => { + expect(todoWindow([])).toEqual([]) + }) + + test("returns short lists whole", () => { + expect(contents(["completed", "in_progress", "pending"])).toEqual(["task 1", "task 2", "task 3"]) + expect(contents(["completed", "in_progress", "pending", "pending"])).toEqual([ + "task 1", + "task 2", + "task 3", + "task 4", + ]) + }) + + test("windows one item before the active item", () => { + expect(contents(["completed", "completed", "completed", "in_progress", "pending", "pending"])).toEqual([ + "task 3", + "task 4", + "task 5", + "task 6", + ]) + }) + + test("clamps when the active item is first", () => { + expect(contents(["in_progress", "pending", "pending", "pending", "pending", "pending"])).toEqual([ + "task 1", + "task 2", + "task 3", + "task 4", + ]) + }) + + test("clamps to a full window when the active item is last", () => { + expect(contents(["completed", "completed", "completed", "completed", "completed", "in_progress"])).toEqual([ + "task 3", + "task 4", + "task 5", + "task 6", + ]) + }) + + test("falls back to the first pending item without an in_progress one", () => { + expect(contents(["completed", "completed", "completed", "completed", "pending", "pending"])).toEqual([ + "task 3", + "task 4", + "task 5", + "task 6", + ]) + }) + + test("falls back to the last item when nothing is active", () => { + expect(contents(["completed", "cancelled", "completed", "completed", "completed", "cancelled"])).toEqual([ + "task 3", + "task 4", + "task 5", + "task 6", + ]) + }) + + test("prefers in_progress over an earlier pending item", () => { + expect(contents(["pending", "pending", "pending", "in_progress", "pending", "pending"])).toEqual([ + "task 3", + "task 4", + "task 5", + "task 6", + ]) + }) +}) + +describe("todoSummaryLabel", () => { + test("counts remaining items before anything is finished", () => { + expect(todoSummaryLabel(list(["pending", "pending", "in_progress"]), "ctrl+b")).toBe( + "still 3 items in the TODO list · ctrl+b to show full TODO", + ) + }) + + test("uses the singular form for one item", () => { + expect(todoSummaryLabel(list(["pending"]), "ctrl+b")).toBe("still 1 item in the TODO list · ctrl+b to show full TODO") + }) + + test("shows progress once something is finished", () => { + expect(todoSummaryLabel(list(["completed", "in_progress", "pending"]), "ctrl+b")).toBe( + "1 of 3 finished · ctrl+b to show full TODO", + ) + }) + + test("does not count cancelled items as finished", () => { + expect(todoSummaryLabel(list(["completed", "cancelled", "pending"]), "ctrl+b")).toBe( + "1 of 3 finished · ctrl+b to show full TODO", + ) + expect(todoSummaryLabel(list(["cancelled", "cancelled"]), "ctrl+b")).toBe( + "still 2 items in the TODO list · ctrl+b to show full TODO", + ) + }) +}) diff --git a/packages/tui/test/sidebar-hint.view.test.tsx b/packages/tui/test/sidebar-hint.view.test.tsx index 3241f52a5d..6ae7a5de13 100644 --- a/packages/tui/test/sidebar-hint.view.test.tsx +++ b/packages/tui/test/sidebar-hint.view.test.tsx @@ -133,8 +133,11 @@ test("prompt footer hints hidden-sidebar todos and files, live and toggle-aware" ) await ready - // Hydrated counts: 2 active todos (the completed one is excluded) + 1 file. - const hinted = await waitFrame(setup, (frame) => frame.includes("ctrl+b 2 todos · 1 file")) + // Hydrated: the files-only hint plus the todo panel above the prompt. + const hinted = await waitFrame( + setup, + (frame) => frame.includes("ctrl+b 1 modified file") && frame.includes("1 of 3 finished · ctrl+b to show full TODO"), + ) expect(hinted).not.toContain("Modified Files") // Toggle via the command layer (raw keypresses can be swallowed by prompt @@ -143,15 +146,15 @@ test("prompt footer hints hidden-sidebar todos and files, live and toggle-aware" api?.keymap.dispatchCommand("session.sidebar.toggle") const open = await waitFrame(setup, (frame) => frame.includes("Modified Files")) expect(open).toContain("a.txt") - expect(open).not.toContain("ctrl+b 2 todos · 1 file") + expect(open).not.toContain("to show full TODO") - // Toggling again hides the sidebar; the hint returns. + // Toggling again hides the sidebar; the hint and the panel return. api?.keymap.dispatchCommand("session.sidebar.toggle") - await waitFrame(setup, (frame) => frame.includes("ctrl+b 2 todos · 1 file") && !frame.includes("Modified Files")) + await waitFrame(setup, (frame) => frame.includes("to show full TODO") && !frame.includes("Modified Files")) - // Live updates: completing every todo leaves the files-only long form. + // Live updates: completing every todo hides the panel but keeps the hint. events.emit(todoEvent(todos(["completed", "completed", "completed"]))) - await waitFrame(setup, (frame) => frame.includes("ctrl+b 1 modified file")) + await waitFrame(setup, (frame) => frame.includes("ctrl+b 1 modified file") && !frame.includes("to show full TODO")) // Clearing the diff removes the hint entirely. events.emit(diffEvent([])) diff --git a/packages/tui/test/todo-panel.view.test.tsx b/packages/tui/test/todo-panel.view.test.tsx new file mode 100644 index 0000000000..533799c19a --- /dev/null +++ b/packages/tui/test/todo-panel.view.test.tsx @@ -0,0 +1,153 @@ +import { expect, mock, test } from "bun:test" +import type { TuiPluginApi, TuiSlotPlugin } from "@opencode-ai/plugin/tui" +import type { GlobalEvent } from "@opencode-ai/sdk/v2" +import { createTestRenderer } from "@opentui/core/testing" +import { Effect } from "effect" +import fs from "fs/promises" +import os from "os" +import path from "path" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { Global } from "@opencode-ai/core/global" +import { createTuiResolvedConfig } from "./fixture/tui-runtime" +import { createEventSource, createFetch, directory, json, worktree } from "./fixture/tui-sdk" + +// Listed session that the -c resolver navigates to during bootstrap. +const session = { + id: "ses_todopanel", + title: "Todo panel session", + slug: "todopanel", + projectID: "proj_test", + directory, + version: "0.0.0-test", + time: { created: 1, updated: 1 }, +} + +const todos = (statuses: string[]) => + statuses.map((status, index) => ({ content: `task ${index + 1}`, status, priority: "medium" })) + +function todoEvent(list: ReturnType): GlobalEvent { + return { + directory, + project: "proj_test", + payload: { + id: `evt_todo_${list.length}`, + type: "todo.updated", + properties: { sessionID: session.id, todos: list }, + }, + } +} + +async function waitFrame(setup: Awaited>, fn: (frame: string) => boolean) { + const start = Date.now() + while (true) { + await setup.renderOnce() + const frame = setup.captureCharFrame() + if (fn(frame)) return frame + if (Date.now() - start > 15_000) throw new Error("timed out waiting for frame:\n" + frame) + await Bun.sleep(25) + } +} + +test("todo panel windows around the active item and tracks live updates", async () => { + const setup = await createTestRenderer({ width: 100, height: 30, useThread: false }) + const core = await import("@opentui/core") + mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer })) + const state = await fs.mkdtemp(path.join(os.tmpdir(), "tui-todo-panel-kv-")) + const events = createEventSource() + const calls = createFetch((url) => { + // Sandbox kv.json writes. + if (url.pathname === "/path") return json({ home: "", state, config: "", worktree, directory }) + if (url.pathname === "/session") return json([session]) + if (url.pathname.startsWith(`/session/${session.id}/message`)) return json([]) + if (url.pathname === `/session/${session.id}/todo`) + return json(todos(["completed", "completed", "completed", "in_progress", "pending", "pending"])) + if (url.pathname === `/session/${session.id}/diff`) return json([]) + if (url.pathname === `/session/${session.id}`) return json(session) + 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" }, + }) + return undefined + }, events) + let api: TuiPluginApi | undefined + let started!: () => void + const ready = new Promise((resolve) => { + started = resolve + }) + + try { + const { run } = await import("../src/app") + const task = Effect.runPromise( + run({ + url: "http://test", + directory, + config: createTuiResolvedConfig({ plugin_enabled: {} }), + fetch: calls.fetch, + events: events.source, + args: { continue: true }, + pluginHost: { + // The session view renders through plugin slots; nothing paints + // until the slot registry is set up, and the sidebar sections are + // builtin plugins the real host would register. + async start(input) { + const hostSlots = input.runtime.setupSlots(input.api) + const { createBuiltinPlugins } = await import("../src/feature-plugins/builtins") + const sidebar = createBuiltinPlugins({ experimentalEventSystem: false }).filter((plugin) => + plugin.id.startsWith("internal:sidebar-"), + ) + for (const plugin of sidebar) { + const scoped = { + ...input.api, + slots: { + register: (registration: TuiSlotPlugin) => { + hostSlots.register({ ...registration, id: plugin.id }) + return plugin.id + }, + }, + } as typeof input.api + await plugin.tui(scoped, undefined, {} as never) + } + api = input.api + started() + }, + async dispose() {}, + }, + }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))), + ) + await ready + + // Hydrated: in_progress at index 3 of 6 windows to tasks 3-6 with one + // completed item of context; the first two completed items are hidden. + const hinted = await waitFrame(setup, (frame) => frame.includes("3 of 6 finished · ctrl+b to show full TODO")) + expect(hinted).toContain("task 3") + expect(hinted).toContain("task 6") + expect(hinted).not.toContain("task 1") + + // Progress moves the window: with 7 items and the active one second to + // last, the window clamps to the last four items. + events.emit( + todoEvent(todos(["completed", "completed", "completed", "completed", "completed", "in_progress", "pending"])), + ) + const shifted = await waitFrame(setup, (frame) => frame.includes("5 of 7 finished · ctrl+b to show full TODO")) + expect(shifted).toContain("task 7") + expect(shifted).not.toContain("task 3") + + // Completing everything hides the panel. + events.emit(todoEvent(todos(["completed", "completed"]))) + await waitFrame(setup, (frame) => frame.includes("ctrl+p commands") && !frame.includes("to show full TODO")) + + // A fresh single-item list uses the singular remaining form. + events.emit(todoEvent(todos(["pending"]))) + await waitFrame(setup, (frame) => frame.includes("still 1 item in the TODO list · ctrl+b to show full TODO")) + + api?.keymap.dispatchCommand("app.exit") + await task + } finally { + if (!setup.renderer.isDestroyed) setup.renderer.destroy() + mock.restore() + await fs.rm(state, { recursive: true, force: true }) + } +}, 60_000) From 461b5aaee43588b9cb9f6a0ebea8cdbf221fa77c Mon Sep 17 00:00:00 2001 From: minhn4 Date: Sun, 2 Aug 2026 11:35:57 +0700 Subject: [PATCH 2/6] feat(tui): highlight todo panel progress in green MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the summary helper so the view renders the progress text (`3 of 12 finished`) in theme.success once anything is completed, while the `· ctrl+b to show full TODO` suffix and the not-started form stay muted. Co-Authored-By: Claude Fable 5 --- .../src/routes/session/todo-panel-model.ts | 11 ++++--- .../tui/src/routes/session/todo-panel.tsx | 8 +++-- .../tui/test/routes/todo-panel-model.test.ts | 31 ++++++++++--------- 3 files changed, 28 insertions(+), 22 deletions(-) diff --git a/packages/tui/src/routes/session/todo-panel-model.ts b/packages/tui/src/routes/session/todo-panel-model.ts index 56fdeddecd..a54df356b9 100644 --- a/packages/tui/src/routes/session/todo-panel-model.ts +++ b/packages/tui/src/routes/session/todo-panel-model.ts @@ -19,10 +19,11 @@ export function todoWindow(todos: T[], size = TODO_PANEL_SIZ } // Cancelled counts as unfinished: only "completed" is done, and the total is -// the full list, so a visible panel never claims everything is finished. -export function todoSummaryLabel(todos: TodoLike[], shortcut: string): string { +// the full list, so a visible panel never claims everything is finished. The +// progress text is returned separately from the shortcut suffix so the view +// can highlight it once something is finished. +export function todoSummary(todos: TodoLike[]) { const done = todos.filter((item) => item.status === "completed").length - const suffix = `${shortcut} to show full TODO` - if (done > 0) return `${done} of ${todos.length} finished · ${suffix}` - return `still ${todos.length} item${todos.length > 1 ? "s" : ""} in the TODO list · ${suffix}` + if (done > 0) return { text: `${done} of ${todos.length} finished`, finished: true } + return { text: `still ${todos.length} item${todos.length > 1 ? "s" : ""} in the TODO list`, finished: false } } diff --git a/packages/tui/src/routes/session/todo-panel.tsx b/packages/tui/src/routes/session/todo-panel.tsx index 1d3628bf51..cd1c71cfaa 100644 --- a/packages/tui/src/routes/session/todo-panel.tsx +++ b/packages/tui/src/routes/session/todo-panel.tsx @@ -2,18 +2,22 @@ import { createMemo, For, Show } from "solid-js" import type { Todo } from "@opencode-ai/sdk/v2" import { TodoItem } from "../../component/todo-item" import { useTheme } from "../../context/theme" -import { todoSummaryLabel, todoWindow } from "./todo-panel-model" +import { todoSummary, todoWindow } from "./todo-panel-model" export function TodoPanel(props: { todos: Todo[]; shortcut: string }) { const { theme } = useTheme() const show = createMemo(() => props.todos.some((item) => item.status !== "completed")) const visible = createMemo(() => todoWindow(props.todos)) + const summary = createMemo(() => todoSummary(props.todos)) return ( {(item) => } - {todoSummaryLabel(props.todos, props.shortcut)} + + {summary().text} + {` · ${props.shortcut} to show full TODO`} + ) diff --git a/packages/tui/test/routes/todo-panel-model.test.ts b/packages/tui/test/routes/todo-panel-model.test.ts index 3ca40e3be0..2ce0171c32 100644 --- a/packages/tui/test/routes/todo-panel-model.test.ts +++ b/packages/tui/test/routes/todo-panel-model.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { todoSummaryLabel, todoWindow } from "../../src/routes/session/todo-panel-model" +import { todoSummary, todoWindow } from "../../src/routes/session/todo-panel-model" const list = (statuses: string[]) => statuses.map((status, index) => ({ content: `task ${index + 1}`, status })) const contents = (statuses: string[]) => todoWindow(list(statuses)).map((item) => item.content) @@ -74,29 +74,30 @@ describe("todoWindow", () => { }) }) -describe("todoSummaryLabel", () => { +describe("todoSummary", () => { test("counts remaining items before anything is finished", () => { - expect(todoSummaryLabel(list(["pending", "pending", "in_progress"]), "ctrl+b")).toBe( - "still 3 items in the TODO list · ctrl+b to show full TODO", - ) + expect(todoSummary(list(["pending", "pending", "in_progress"]))).toEqual({ + text: "still 3 items in the TODO list", + finished: false, + }) }) test("uses the singular form for one item", () => { - expect(todoSummaryLabel(list(["pending"]), "ctrl+b")).toBe("still 1 item in the TODO list · ctrl+b to show full TODO") + expect(todoSummary(list(["pending"]))).toEqual({ text: "still 1 item in the TODO list", finished: false }) }) test("shows progress once something is finished", () => { - expect(todoSummaryLabel(list(["completed", "in_progress", "pending"]), "ctrl+b")).toBe( - "1 of 3 finished · ctrl+b to show full TODO", - ) + expect(todoSummary(list(["completed", "in_progress", "pending"]))).toEqual({ + text: "1 of 3 finished", + finished: true, + }) }) test("does not count cancelled items as finished", () => { - expect(todoSummaryLabel(list(["completed", "cancelled", "pending"]), "ctrl+b")).toBe( - "1 of 3 finished · ctrl+b to show full TODO", - ) - expect(todoSummaryLabel(list(["cancelled", "cancelled"]), "ctrl+b")).toBe( - "still 2 items in the TODO list · ctrl+b to show full TODO", - ) + expect(todoSummary(list(["completed", "cancelled", "pending"]))).toEqual({ text: "1 of 3 finished", finished: true }) + expect(todoSummary(list(["cancelled", "cancelled"]))).toEqual({ + text: "still 2 items in the TODO list", + finished: false, + }) }) }) From dec57fef6c00f0d4298cc4094b7bf663dfbbab9a Mon Sep 17 00:00:00 2001 From: minhn4 Date: Sun, 2 Aug 2026 12:17:49 +0700 Subject: [PATCH 3/6] feat(tui): star spinner before the todo panel summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prefix the summary line with cli-spinners "star", colored like the progress text (success green once something is finished, muted before). Follows the fork's spinner conventions: inlined frames with MIT attribution, 120 ms interval (stock cli-spinners timing feels too fast), and a static ⋯ fallback when animations are disabled. Co-Authored-By: Claude Fable 5 --- .../tui/src/routes/session/todo-panel.tsx | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/tui/src/routes/session/todo-panel.tsx b/packages/tui/src/routes/session/todo-panel.tsx index cd1c71cfaa..1ad90cedbd 100644 --- a/packages/tui/src/routes/session/todo-panel.tsx +++ b/packages/tui/src/routes/session/todo-panel.tsx @@ -2,22 +2,33 @@ import { createMemo, For, Show } from "solid-js" import type { Todo } from "@opencode-ai/sdk/v2" import { TodoItem } from "../../component/todo-item" import { useTheme } from "../../context/theme" +import { useKV } from "../../context/kv" import { todoSummary, todoWindow } from "./todo-panel-model" +// "star" from sindresorhus/cli-spinners (MIT) +const STAR_FRAMES = ["✶", "✸", "✹", "✺", "✹", "✷"] + export function TodoPanel(props: { todos: Todo[]; shortcut: string }) { const { theme } = useTheme() + const kv = useKV() const show = createMemo(() => props.todos.some((item) => item.status !== "completed")) const visible = createMemo(() => todoWindow(props.todos)) const summary = createMemo(() => todoSummary(props.todos)) + const summaryColor = createMemo(() => (summary().finished ? theme.success : theme.textMuted)) return ( {(item) => } - - {summary().text} - {` · ${props.shortcut} to show full TODO`} - + + ⋯}> + + + + {summary().text} + {` · ${props.shortcut} to show full TODO`} + + ) From 2acddab38cfc7ef9780a9b90f94b1aad68c8b17a Mon Sep 17 00:00:00 2001 From: minhn4 Date: Sun, 2 Aug 2026 12:21:42 +0700 Subject: [PATCH 4/6] feat(tui): color the todo panel spinner like the active item MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The star spinner (and its static fallback) now uses theme.warning — the same accent as the [•] in_progress item — instead of following the summary text's green/muted color. Co-Authored-By: Claude Fable 5 --- packages/tui/src/routes/session/todo-panel.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/tui/src/routes/session/todo-panel.tsx b/packages/tui/src/routes/session/todo-panel.tsx index 1ad90cedbd..3740b738af 100644 --- a/packages/tui/src/routes/session/todo-panel.tsx +++ b/packages/tui/src/routes/session/todo-panel.tsx @@ -21,8 +21,8 @@ export function TodoPanel(props: { todos: Todo[]; shortcut: string }) { {(item) => } - ⋯}> - + ⋯}> + {summary().text} From 5831787e5c99923a3c04ac824406c146acf0b0f4 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Sun, 2 Aug 2026 12:50:22 +0700 Subject: [PATCH 5/6] feat(tui): park the todo panel spinner while idle The star spinner now animates only while the session is busy (same session_status source as the prompt footer); when idle or interrupted it parks on a static sixteen-pointed star, which also replaces the old animations-disabled fallback glyph. Co-Authored-By: Claude Fable 5 --- packages/tui/src/routes/session/index.tsx | 6 +++++- packages/tui/src/routes/session/todo-panel.tsx | 10 ++++++++-- packages/tui/test/todo-panel.view.test.tsx | 3 +++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index e3bcf9349b..93b51235e9 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1359,7 +1359,11 @@ export function Session() { - + props.todos.some((item) => item.status !== "completed")) @@ -21,7 +24,10 @@ export function TodoPanel(props: { todos: Todo[]; shortcut: string }) { {(item) => } - ⋯}> + {STAR_IDLE}} + > diff --git a/packages/tui/test/todo-panel.view.test.tsx b/packages/tui/test/todo-panel.view.test.tsx index 533799c19a..a5ea6de6d9 100644 --- a/packages/tui/test/todo-panel.view.test.tsx +++ b/packages/tui/test/todo-panel.view.test.tsx @@ -125,6 +125,9 @@ test("todo panel windows around the active item and tracks live updates", async expect(hinted).toContain("task 3") expect(hinted).toContain("task 6") expect(hinted).not.toContain("task 1") + // No session.status event was emitted, so the session is idle and the + // spinner is parked on its static most-pointed frame. + expect(hinted).toContain("✺ 3 of 6 finished") // Progress moves the window: with 7 items and the active one second to // last, the window clamps to the last four items. From 8a2fa968c9b94c883e5522832c1545b9e8c223ab Mon Sep 17 00:00:00 2001 From: minhn4 Date: Sun, 2 Aug 2026 15:13:51 +0700 Subject: [PATCH 6/6] docs: link todo panel changelog entry to PR Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index daaac05033..044e1e5b0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ file is the human-readable summary of what changed and why. `still 6 items in the TODO list · …` before anything is done). The full list stays in the sidebar (`ctrl+b`). The prompt-footer hint now reports only modified files, since the panel covers todos. + ([#150](https://github.com/quickbeard/codev-code/pull/150)) - **`ctrl+v` pastes into dialog prompts** — dialog text fields (the API key and base URL prompts in `/connect` and the custom-provider and configure flows,