From bb8f6559bf8478438a12347b7cca6b774edc8616 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Sun, 2 Aug 2026 17:33:48 +0700 Subject: [PATCH 1/2] feat(tui): pin the last message trailer and shed busy footer hints Two layout changes above the prompt. The last assistant message's trailer is extracted into an exported MessageTrailer and hoisted out of the transcript into the pinned block below the todo panel, so "Baked for " sits directly above the prompt and stays there when you scroll back through history. History keeps its trailers inline. Spacing moves with it: the todo panel takes over the blank row that separated the pinned block from the transcript, and the pinned trailer only adds its own top gap when no panel precedes it. The busy footer carries spinner, interrupt hint, modified-file count and context/cost. As the terminal narrows flexbox runs the gaps to zero and below ~85 columns the row wraps and clips the cost. It now sheds by value instead: the palette hint below 108 columns, the modified-file count below 88. The spinner and interrupt hint always stay. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 13 ++ DIVERGENCES.md | 4 +- packages/tui/src/component/prompt/index.tsx | 33 ++-- packages/tui/src/routes/session/index.tsx | 152 +++++++++------- .../src/routes/session/todo-panel-model.ts | 7 + .../tui/src/routes/session/todo-panel.tsx | 8 +- packages/tui/test/footer-width.view.test.tsx | 170 ++++++++++++++++++ .../tui/test/message-trailer.view.test.tsx | 120 +++++++++++++ 8 files changed, 426 insertions(+), 81 deletions(-) create mode 100644 packages/tui/test/footer-width.view.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index ad2dce3da1..e1e3b1112b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,19 @@ file is the human-readable summary of what changed and why. ### TUI +- **The last turn's `Baked for` line is pinned above the prompt** — it used to + live at the bottom of the transcript, so the todo panel sat between it and the + input box and it scrolled away as soon as you moved up through history. It now + renders below the todo panel, directly above the prompt, and stays there. + Older messages in the transcript keep their trailer inline where it belongs. + +- **The busy footer stays on one line in narrow terminals** — while a turn is + running the row carries the spinner, the interrupt hint, the modified-file + count and the context/cost readout. Below ~85 columns that wrapped to two + lines and clipped the cost. The row now sheds hints as it narrows: the + `commands` shortcut goes below 108 columns, the modified-file count below 88. + The spinner and the interrupt hint always stay. + - **Tighter prompt and footer spacing** — the input box swaps its full-row top margin for a half-block edge (matching the existing bottom edge), the blank row between the transcript and the input box is gone (the half-block edge is diff --git a/DIVERGENCES.md b/DIVERGENCES.md index f05fa11427..8afa184c95 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -100,6 +100,7 @@ from the squash message first, or use a plain merge. | `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` | 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/tui/src/routes/session/index.tsx` | Trailer extracted from `AssistantMessage` into an exported `MessageTrailer` component (it owns the `model`/`final`/`duration`/`suppressed`/`aborted` memos, which moved with it) and the last assistant message's copy is hoisted out of the transcript: `AssistantMessage` renders it inline only `when={!props.last}`, and `Session` renders `` in the pinned block between the todo panel and the `session_prompt` slot. Spacing moved with it — the pinned instance uses box `paddingTop` (only when no todo panel precedes it) / `paddingBottom` instead of the inline ``. `Session` gained a `todoPanelVisible` memo built on the fork-only `todoActive` predicate. | `Baked for ` is footer-grade status, not transcript content: pinned below the todo panel it stays put instead of scrolling away with history, and the two status blocks above the prompt read as one group. | Keep `MessageTrailer` and both call sites; take upstream's surrounding changes. If upstream restructures the trailer, re-apply the extraction and keep the last message's copy out of the `` over messages. | | `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. | | `packages/tui/src/feature-plugins/system/which-key.tsx` | `HomeHint` reads the new `placement` prop from the `home_bottom` slot; with `"start"` it left-aligns | Same top-left banner alignment as the tips row above. | Keep our `placement` threading + conditional alignItems; take upstream's surrounding changes. | @@ -121,7 +122,8 @@ from the squash message first, or use a plain merge. | `packages/tui/src/component/dialog-provider.tsx` | The "Other" custom-provider option routes into the fork-only `runCustomProviderFlow` (component/dialog-custom-provider.tsx, new file) — id → API key → optional base URL → model discovery via `/v1/models` → `provider.` block PATCHed into global codev.json (key stays in auth.json) — instead of upstream's credential-only `ApiMethod`; the local `promptCustomProviderID` helper moved into that flow (upstream's "only stores a credential" description no longer holds). `ApiMethod`'s `custom` prop is now unreferenced but left in place. | An empty base URL keeps upstream's credential-only behavior; with one, the provider is usable without hand-editing codev.json. The configure gate's "Custom provider" option shares the same flow. | Keep our `onSelect` + import and the helper deletion; take upstream's changes to `ApiMethod`/other methods (the flow only wraps the entry point). | | `packages/tui/src/component/prompt/index.tsx` | Gated the prompt footer's model/provider indicator on `useConnected()` (the shared "a real provider is configured" predicate) | Don't advertise the free hosted Zen default (e.g. "Big Pickle · OpenCode Zen") in the footer while nothing is configured — same rationale and predicate as `app.tsx`'s startup gate and the provider/model filters. A configured gateway/real provider, or a deliberately-authenticated opencode/opencode-go, still shows its model. | Keep the extra `Show` condition; take upstream's surrounding changes. | | `packages/tui/src/component/use-connected.tsx`, `packages/tui/src/context/local.tsx` | use-connected.tsx: the provider predicate is extracted into an exported `isConnectedProvider`; `useConnected` now delegates to it (same logic). local.tsx: `fallbackModel`'s last resort is `sync.data.provider.find(isConnectedProvider) ?? sync.data.provider[0]` instead of a blind `sync.data.provider[0]` (covered by fork-only `test/component/use-connected.test.ts`, not tracked here). | The hosted Zen provider self-registers (free models need no auth) and is inserted ahead of config-only providers, so with no config `model` pin and empty recents (a fresh `codevhub install` — the pin was dropped in PR #142 / hub PR #221) the TUI defaulted to "Big Pickle · OpenCode Zen" and would send messages to upstream's hosted gateway instead of the configured one. Same predicate as `app.tsx`'s startup gate and the footer indicator; when no provider qualifies the old first-provider behavior is kept. | Keep the predicate export and the `find` fallback; take upstream's surrounding changes to either file. If upstream reworks `fallbackModel`, re-apply the connected-first selection to its last-resort branch. | -| `packages/tui/src/component/prompt/index.tsx` | The busy-state footer arm (`status().type !== "idle"`) also renders `props.hint` after the `esc interrupt` text, gated on `status().type !== "retry"` | The hidden-sidebar hint (see the `routes/session/index.tsx` hint row) only rendered in the footer `Switch`'s idle fallback arm, so it vanished for the whole duration of a running turn — exactly when todos are being created. Retry keeps its `space-between` error row uncluttered. | Keep the `{props.hint}` line in the busy `Match` arm (additive); take upstream's surrounding changes. | +| `packages/tui/src/component/prompt/index.tsx` | The busy-state footer arm (`status().type !== "idle"`) also renders `props.hint` after the `esc interrupt` text, gated on `status().type !== "retry"` | The hidden-sidebar hint (see the `routes/session/index.tsx` hint row) only rendered in the footer `Switch`'s idle fallback arm, so it vanished for the whole duration of a running turn — exactly when todos are being created. Retry keeps its `space-between` error row uncluttered. | Keep the `{props.hint}` line in the busy `Match` arm (additive); take upstream's surrounding changes. | +| `packages/tui/src/component/prompt/index.tsx` | Width-tiered busy footer: module-level `BUSY_FOOTER_PALETTE_MIN_WIDTH` (108) and `BUSY_FOOTER_HINT_MIN_WIDTH` (88) drive a `showPaletteHint` memo (`status().type === "idle" \|\| width >= 108`) wrapping the ` commands` text and a `showBusyHint` memo (`width >= 88`) added to the busy arm's `props.hint` gate | The busy row carries spinner + interrupt hint + sidebar hint + context/cost; as the terminal narrows flexbox runs the gaps to zero (cramped but intact) and below ~85 columns the row wraps to two lines and clips the cost. Shedding by value keeps it on one line down to 70: the static palette hint goes first, then the modified-file count; the spinner and interrupt hint never go. | Keep both constants, both memos and the two `Show` gates; take upstream's surrounding changes. Re-measure the thresholds if segments are added to the row. | | `packages/tui/src/component/prompt/index.tsx` | Removed the editor open-file label from the footer's right-side group and lifted the state behind it out of `Prompt`: the local `dismissedEditorSelectionKey` signal and the `editorContext`/`editorPath`/`editorSelectionLabel`/`editorFileLabel`/`editorFileLabelDisplay`/`editorContextLabelState` memos are gone, replaced by `useEditorLabel()` (fork-only `src/context/editor-label.tsx`) — `selection()` feeds the `prompt.editor_context.clear` `enabled` predicate and the submit-time editor part, `state()` replaces `editor.labelState()` there, and `dismiss()` replaces the local `dismissEditorContext`. The pure helpers `hasEditorRangeSelection`/`getEditorRangeLabel` moved to that context module and are imported back for `formatEditorContext`. | The label now lives in the sidebar footer (see its row above). The dismissal state had to move with it: the sidebar must label the *effective* selection — the one this file actually attaches on submit — or it would advertise context the prompt has dismissed and won't send. | Keep the deletions, the `useEditorLabel` import + its three call sites, and the helper import from `../../context/editor-label`; take upstream's surrounding changes. If upstream reworks the editor-context label, apply the change to `editorContextFileLabel` in the fork-only sidebar module rather than restoring the footer render. | | `packages/tui/src/routes/session/index.tsx`, `packages/tui/src/component/prompt/index.tsx` | Left accent border removed entirely: the user-message block's `border={["left"]}`/`borderColor` (agent accent)/`customBorderChars` props and the prompt input box's two bordered boxes (main box with its `bottomLeft: "╹"` char, plus the height-1 `╹` terminator row) are replaced with `paddingLeft={1}`; the now-unused `borderHighlight` memo and the `tint`/`SplitBorder` imports are removed from the prompt (the `highlight`/`agentMetaAlpha` memos stay — they still color the agent name label); the fork's in-scrollbox banner box gains `paddingLeft={1}` so the banner/tips left edge lines up with the prompt/message blocks' background edge (the removed border glyph used to occupy that aligned column); `BlockTool` (expanded tool-output panels) likewise drops its `border={["left"]}`/`customBorderChars`/`borderColor` (the background-colored `┃` glyph over the panel background rendered as a stray vertical line on the panel's left edge) and bumps `paddingLeft` 2 → 3 to keep the content column | No vertical accent line next to sent messages, the prompt, or tool-output panels — removed rather than recolored to the background because background-colored glyphs render oddly on some terminals (e.g. PowerShell). `paddingLeft={1}` keeps the same 1-column offset, so text alignment with other transcript blocks is unchanged. | Keep our `paddingLeft={1}` boxes and the `borderHighlight`/`tint`/`SplitBorder` removals; take upstream's surrounding changes. If upstream adds new accent-colored left borders to these blocks, replace them with `paddingLeft={1}` the same way. | | `packages/tui/src/component/spinner.tsx` | `SPINNER_FRAMES` swapped from cli-spinners "dots" (10 braille frames) to cli-spinners "sand" (35 braille frames, falling-sand animation); interval stays 80 ms | Fork visual identity for the shared `Spinner` component (in-progress tool/thinking rows, startup loading, session-list dialogs). | Keep our frames array; take upstream's surrounding changes. | diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 1070db0c02..47a8deb670 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -63,16 +63,15 @@ import { useLocation } from "../../context/location" registerOpencodeSpinner() // "aesthetic" from sindresorhus/cli-spinners (MIT) -const FOOTER_FRAMES = [ - "▰▱▱▱▱▱▱", - "▰▰▱▱▱▱▱", - "▰▰▰▱▱▱▱", - "▰▰▰▰▱▱▱", - "▰▰▰▰▰▱▱", - "▰▰▰▰▰▰▱", - "▰▰▰▰▰▰▰", - "▰▱▱▱▱▱▱", -] +const FOOTER_FRAMES = ["▰▱▱▱▱▱▱", "▰▰▱▱▱▱▱", "▰▰▰▱▱▱▱", "▰▰▰▰▱▱▱", "▰▰▰▰▰▱▱", "▰▰▰▰▰▰▱", "▰▰▰▰▰▰▰", "▰▱▱▱▱▱▱"] + +// A busy footer carries the spinner, the interrupt hint and the sidebar hint on +// the left plus context/cost on the right. As the terminal narrows the row runs +// its gaps down to nothing and then wraps, so shed by value: the static palette +// hint first (idle-state discoverability, not live status), then the sidebar's +// modified-file count. The spinner and the interrupt hint always stay. +const BUSY_FOOTER_PALETTE_MIN_WIDTH = 108 +const BUSY_FOOTER_HINT_MIN_WIDTH = 88 export type PromptProps = { sessionID?: string @@ -1339,6 +1338,10 @@ export function Prompt(props: PromptProps) { FOOTER_FRAMES[frameIndex][charIndex] === "▰" ? color : inactive, } }) + const showPaletteHint = createMemo( + () => status().type === "idle" || dimensions().width >= BUSY_FOOTER_PALETTE_MIN_WIDTH, + ) + const showBusyHint = createMemo(() => dimensions().width >= BUSY_FOOTER_HINT_MIN_WIDTH) const maxHeight = createMemo(() => tuiConfig.prompt?.max_height ?? Math.max(6, Math.floor(dimensions().height / 3))) const moveLabelWidth = createMemo(() => Math.max(12, Math.min(44, dimensions().width - 48))) @@ -1593,7 +1596,7 @@ export function Prompt(props: PromptProps) { {store.interrupt > 0 ? "again to interrupt" : "interrupt"} - {props.hint} + {props.hint} @@ -1677,9 +1680,11 @@ export function Prompt(props: PromptProps) { - - {paletteShortcut()} commands - + + + {paletteShortcut()} commands + + diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index c7eb48b3e0..62fecafe60 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -57,6 +57,7 @@ import { placeholder } from "../home" import { Sidebar } from "./sidebar" import { sidebarHintLabel } from "./sidebar-hint" import { TodoPanel } from "./todo-panel" +import { todoActive } from "./todo-panel-model" import { SubagentFooter } from "./subagent-footer.tsx" import { filetype } from "../../util/filetype" import parsers from "../../parsers-config" @@ -292,6 +293,7 @@ export function Session() { const showTimestamps = createMemo(() => timestamps() === "show") const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4) const sidebarShortcut = useCommandShortcut("session.sidebar.toggle") + const todoPanelVisible = createMemo(() => !sidebarVisible() && todoActive(sync.data.todo[route.sessionID] ?? [])) const sidebarHint = createMemo(() => { if (sidebarVisible() || session()?.parentID) return return sidebarHintLabel((sync.data.session_diff[route.sessionID] ?? []).length) @@ -1358,13 +1360,16 @@ export function Session() { - + + + {(message) => } + sync.data.message[props.message.sessionID] ?? []) - const model = createMemo(() => Model.name(ctx.providers(), props.message.providerID, props.message.modelID)) - - const final = createMemo(() => { - return props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish) - }) - - const duration = createMemo(() => { - if (!final()) return 0 - if (!props.message.time.completed) return 0 - const user = messages().find((x) => x.role === "user" && x.id === props.message.parentID) - if (!user || !user.time) return 0 - return props.message.time.completed - user.time.created - }) - - // the prompt footer already shows the current agent and model, so hide the - // trailer's copy on the last message when it would repeat that selection - const suppressed = createMemo(() => { - if (!props.last) return false - const currentModel = local.model.current() - return ( - local.agent.current()?.name === props.message.mode && - currentModel?.providerID === props.message.providerID && - currentModel?.modelID === props.message.modelID - ) - }) const childShortcut = useCommandShortcut("session.child.first") const backgroundShortcut = useCommandShortcut("session.background") @@ -1625,44 +1602,93 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las {errorMessage(props.message.error)} - - - - alwaysSeparate.add(el)} paddingLeft={3}> - - - - ▣{" "} - {" "} - {Locale.titlecase(props.message.mode)} - · {model()} - - - - {suppressed() ? `Baked for ${Locale.duration(duration())}` : ` · ${Locale.duration(duration())}`} - - - - - {suppressed() && !duration() ? "interrupted" : " · interrupted"} - - - - - - - + {/* The last message's trailer is hoisted out of the transcript and + pinned below the todo panel, so only history renders one inline. */} + + + ) } +// The agent · model · duration line under an assistant turn. History renders it +// inline from AssistantMessage; the last turn's is rendered by Session below the +// todo panel, where it sits directly above the prompt instead of scrolling with +// the transcript. +export function MessageTrailer(props: { message: AssistantMessage; last: boolean; spaced?: boolean }) { + const ctx = use() + const local = useLocal() + const { theme } = useTheme() + const sync = useSync() + const messages = createMemo(() => sync.data.message[props.message.sessionID] ?? []) + const model = createMemo(() => Model.name(ctx.providers(), props.message.providerID, props.message.modelID)) + const aborted = createMemo(() => props.message.error?.name === "MessageAbortedError") + + const final = createMemo(() => { + return props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish) + }) + + const duration = createMemo(() => { + if (!final()) return 0 + if (!props.message.time.completed) return 0 + const user = messages().find((x) => x.role === "user" && x.id === props.message.parentID) + if (!user || !user.time) return 0 + return props.message.time.completed - user.time.created + }) + + // the prompt footer already shows the current agent and model, so hide the + // trailer's copy on the last message when it would repeat that selection + const suppressed = createMemo(() => { + if (!props.last) return false + const currentModel = local.model.current() + return ( + local.agent.current()?.name === props.message.mode && + currentModel?.providerID === props.message.providerID && + currentModel?.modelID === props.message.modelID + ) + }) + + return ( + + + {/* Inline trailers space themselves off the message above; the pinned + one owns the blank row before the prompt's top edge, and only needs + one above when no todo panel already supplied it. */} + alwaysSeparate.add(el)} + paddingLeft={3} + paddingTop={props.last && props.spaced ? 1 : 0} + paddingBottom={props.last ? 1 : 0} + > + + + + ▣{" "} + {" "} + {Locale.titlecase(props.message.mode)} + · {model()} + + + + {suppressed() ? `Baked for ${Locale.duration(duration())}` : ` · ${Locale.duration(duration())}`} + + + + + {suppressed() && !duration() ? "interrupted" : " · interrupted"} + + + + + + + ) +} + const PART_MAPPING = { text: TextPart, tool: ToolPart, diff --git a/packages/tui/src/routes/session/todo-panel-model.ts b/packages/tui/src/routes/session/todo-panel-model.ts index a54df356b9..b09787a8c7 100644 --- a/packages/tui/src/routes/session/todo-panel-model.ts +++ b/packages/tui/src/routes/session/todo-panel-model.ts @@ -5,6 +5,13 @@ export interface TodoLike { export const TODO_PANEL_SIZE = 4 +// The panel is about work still in flight, so a list with nothing left to do +// renders nothing. Session needs this too, to know whether the panel already +// supplied the blank row above the pinned message trailer. +export function todoActive(todos: TodoLike[]) { + return todos.some((item) => item.status !== "completed") +} + // 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 diff --git a/packages/tui/src/routes/session/todo-panel.tsx b/packages/tui/src/routes/session/todo-panel.tsx index 158d939f39..506ace4ed5 100644 --- a/packages/tui/src/routes/session/todo-panel.tsx +++ b/packages/tui/src/routes/session/todo-panel.tsx @@ -3,7 +3,7 @@ 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" +import { todoActive, todoSummary, todoWindow } from "./todo-panel-model" // "star" from sindresorhus/cli-spinners (MIT) const STAR_FRAMES = ["✶", "✸", "✹", "✺", "✹", "✷"] @@ -14,14 +14,16 @@ const STAR_IDLE = "✺" export function TodoPanel(props: { todos: Todo[]; shortcut: string; busy: boolean }) { const { theme } = useTheme() const kv = useKV() - const show = createMemo(() => props.todos.some((item) => item.status !== "completed")) + const show = createMemo(() => todoActive(props.todos)) const visible = createMemo(() => todoWindow(props.todos)) const summary = createMemo(() => todoSummary(props.todos)) const summaryColor = createMemo(() => (summary().finished ? theme.success : theme.textMuted)) return ( - + {/* The panel now leads the pinned block, so it owns the blank row that + separates it from the transcript — the trailer used to supply it. */} + {(item) => } >, 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) + } +} + +const session = { + id: "ses_footer", + title: "Footer session", + slug: "footer", + projectID: "proj_test", + directory, + version: "0.0.0-test", + time: { created: 1, updated: 1 }, + cost: 123.45, +} + +// Twelve modified files so the fork's sidebar hint reads "12 modified files". +const diffs = Array.from({ length: 12 }, (_, index) => ({ + file: `packages/core/src/file-${index}.ts`, + added: 3, + removed: 1, + status: "modified", +})) + +// The busy footer carries spinner + interrupt hint + modified-file count + +// context/cost. It sheds the palette hint below 108 columns and the file count +// below 88 so the row never wraps; the interrupt hint always survives. +async function busyFooterRow(width: number) { + const setup = await createTestRenderer({ width, height: 24, useThread: false }) + const core = await import("@opentui/core") + mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer })) + const events = createEventSource() + const calls = createFetch((url) => { + if (url.pathname === "/session") return json([session]) + if (url.pathname === `/session/${session.id}`) return json(session) + if (url.pathname === "/agent") return json([{ name: "build", mode: "primary" }]) + if (url.pathname === `/session/${session.id}/message`) + return json([ + { + info: { + id: "msg_1", + sessionID: session.id, + role: "user", + agent: "build", + model: { providerID: "test", modelID: "m1" }, + time: { created: 1000 }, + }, + parts: [{ id: "prt_1", sessionID: session.id, messageID: "msg_1", type: "text", text: "hello" }], + }, + { + info: { + id: "msg_2", + sessionID: session.id, + role: "assistant", + agent: "build", + mode: "build", + parentID: "msg_1", + modelID: "m1", + providerID: "test", + finish: "stop", + path: { cwd: directory, root: directory }, + cost: 123.45, + tokens: { input: 1_200_000, output: 34_567, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 2000, completed: 2600 }, + }, + parts: [{ id: "prt_2", sessionID: session.id, messageID: "msg_2", type: "text", text: "working" }], + }, + ]) + if (url.pathname === `/session/${session.id}/todo`) return json([]) + if (url.pathname === `/session/${session.id}/diff`) return json(diffs) + if (url.pathname === "/project/proj_test/directories") return json([]) + if (url.pathname === "/config/providers") + return json({ + providers: [ + { + id: "test", + name: "Test", + models: { m1: { name: "M1", cost: { input: 1 }, limit: { context: 1_260_000, output: 8_192 } } }, + }, + ], + default: { test: "m1" }, + }) + return undefined + }, events) + 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: { + async start(input) { + input.runtime.setupSlots(input.api) + started() + }, + async dispose() {}, + }, + }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))), + ) + await ready + await waitForFrame(setup, (frame) => frame.includes("Baked for")) + const busy: GlobalEvent = { + directory, + project: "proj_test", + payload: { + id: "evt_busy", + type: "session.status", + properties: { sessionID: session.id, status: { type: "busy" } }, + }, + } + events.emit(busy) + const frame = await waitForFrame(setup, (f) => f.includes("interrupt")) + const rows = frame.split("\n").map((line) => line.replace(/\s+$/, "")) + process.emit("SIGHUP") + await task + return rows[rows.findLastIndex((line) => line.includes("interrupt"))] + } finally { + if (!setup.renderer.isDestroyed) setup.renderer.destroy() + mock.restore() + } +} + +test("busy footer keeps every hint on a wide terminal", async () => { + const row = await busyFooterRow(120) + expect(row).toContain("interrupt") + expect(row).toContain("modified files") + expect(row).toContain("commands") + expect(row.length).toBeLessThanOrEqual(120) +}, 30_000) + +test("busy footer sheds the palette hint at 100 columns", async () => { + const row = await busyFooterRow(100) + expect(row).toContain("interrupt") + expect(row).toContain("modified files") + expect(row).not.toContain("commands") + expect(row.length).toBeLessThanOrEqual(100) +}, 30_000) + +test("busy footer sheds the modified-file count at 80 columns", async () => { + const row = await busyFooterRow(80) + expect(row).toContain("interrupt") + expect(row).not.toContain("modified files") + expect(row).not.toContain("commands") + // Still one line: the row that carries the interrupt hint also carries usage. + expect(row).toContain("$123.45") + expect(row.length).toBeLessThanOrEqual(80) +}, 30_000) diff --git a/packages/tui/test/message-trailer.view.test.tsx b/packages/tui/test/message-trailer.view.test.tsx index 48e720d17d..ae6af02767 100644 --- a/packages/tui/test/message-trailer.view.test.tsx +++ b/packages/tui/test/message-trailer.view.test.tsx @@ -152,3 +152,123 @@ test("last-message trailer collapses to Baked for and reappears on agent switch" mock.restore() } }, 30_000) + +// The last message's trailer is hoisted out of the transcript and pinned in the +// block above the prompt, below the todo panel — so a live todo list renders +// between the assistant's reply and its "Baked for" line, not after it. +test("last-message trailer is pinned below the todo panel", 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 events = createEventSource() + const session = { + id: "ses_pinned", + title: "Pinned trailer session", + slug: "pinned", + projectID: "proj_test", + directory, + version: "0.0.0-test", + time: { created: 1, updated: 1 }, + } + const calls = createFetch((url) => { + if (url.pathname === "/session") return json([session]) + if (url.pathname === `/session/${session.id}`) return json(session) + if (url.pathname === "/agent") return json([{ name: "build", mode: "primary" }]) + if (url.pathname === `/session/${session.id}/message`) + return json([ + { + info: { + id: "msg_1", + sessionID: session.id, + role: "user", + agent: "build", + model: { providerID: "test", modelID: "m1" }, + time: { created: 1000 }, + }, + parts: [{ id: "prt_1", sessionID: session.id, messageID: "msg_1", type: "text", text: "hello there" }], + }, + { + info: { + id: "msg_2", + sessionID: session.id, + role: "assistant", + agent: "build", + mode: "build", + parentID: "msg_1", + modelID: "m1", + providerID: "test", + finish: "stop", + path: { cwd: directory, root: directory }, + cost: 0, + tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 2000, completed: 2600 }, + }, + parts: [{ id: "prt_2", sessionID: session.id, messageID: "msg_2", type: "text", text: "All good." }], + }, + ]) + // A list with work still open keeps the panel on screen alongside the trailer. + if (url.pathname === `/session/${session.id}/todo`) + return json([ + { content: "wire the resolver", status: "completed", priority: "medium" }, + { content: "backfill the tests", status: "in_progress", priority: "medium" }, + ]) + if (url.pathname === `/session/${session.id}/diff`) return json([]) + if (url.pathname === "/project/proj_test/directories") return json([]) + if (url.pathname === "/config/providers") + return json({ + providers: [ + { + id: "test", + name: "Test", + models: { m1: { name: "M1", cost: { input: 1 }, limit: { context: 200_000, output: 8_192 } } }, + }, + ], + default: { test: "m1" }, + }) + return 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: { + async start(input) { + input.runtime.setupSlots(input.api) + started() + }, + async dispose() {}, + }, + }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))), + ) + await ready + + const frame = await waitForFrame(setup, (f) => f.includes("Baked for 1.6s")) + const rows = frame.split("\n") + const reply = rows.findIndex((row) => row.includes("All good.")) + const todo = rows.findIndex((row) => row.includes("backfill the tests")) + const trailer = rows.findIndex((row) => row.includes("Baked for 1.6s")) + const promptEdge = rows.findIndex((row) => row.includes("▄▄▄")) + + expect(reply).toBeGreaterThan(-1) + expect(todo).toBeGreaterThan(reply) + expect(trailer).toBeGreaterThan(todo) + expect(promptEdge).toBeGreaterThan(trailer) + + process.emit("SIGHUP") + await task + } finally { + if (!setup.renderer.isDestroyed) setup.renderer.destroy() + mock.restore() + } +}, 30_000) From a88d16e935491aa6549811371fb90c7a702e4be7 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Sun, 2 Aug 2026 17:37:07 +0700 Subject: [PATCH 2/2] minor changes --- CHANGELOG.md | 31 ------------------------------- 1 file changed, 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1e3b1112b..a621432b7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,37 +7,6 @@ file is the human-readable summary of what changed and why. ## Unreleased -### TUI - -- **The last turn's `Baked for` line is pinned above the prompt** — it used to - live at the bottom of the transcript, so the todo panel sat between it and the - input box and it scrolled away as soon as you moved up through history. It now - renders below the todo panel, directly above the prompt, and stays there. - Older messages in the transcript keep their trailer inline where it belongs. - -- **The busy footer stays on one line in narrow terminals** — while a turn is - running the row carries the spinner, the interrupt hint, the modified-file - count and the context/cost readout. Below ~85 columns that wrapped to two - lines and clipped the cost. The row now sheds hints as it narrows: the - `commands` shortcut goes below 108 columns, the modified-file count below 88. - The spinner and the interrupt hint always stay. - -- **Tighter prompt and footer spacing** — the input box swaps its full-row top - margin for a half-block edge (matching the existing bottom edge), the blank - row between the transcript and the input box is gone (the half-block edge is - the separation), and the status footer now sits on the terminal's last row - instead of two rows above it. - -- **Visible selection in the question panel** — the highlighted answer (and the - hovered question tab) used a background one shade off the panel, which was - effectively invisible in light themes; the highlight is now derived from the - panel and text colors so it reads in both light and dark. - -- **No stray line on tool output** — expanded tool-output panels (bash output, - generic tool results) no longer draw a background-colored left border glyph, - which showed up as an odd thin vertical line along the panel's left edge on - some terminals. - ## 1.18.4-5 ### TUI