diff --git a/docs/compose/spec/tui-footer-sticky-last-line.md b/docs/compose/spec/tui-footer-sticky-last-line.md new file mode 100644 index 000000000..2d162ecc6 --- /dev/null +++ b/docs/compose/spec/tui-footer-sticky-last-line.md @@ -0,0 +1,64 @@ +--- +feature: tui-footer-sticky-last-line +status: delivered +updated: 2026-07-11 +branch: fix/tui-footer-sticky-last-line +commits: 67fecf88..f6246ae4 +--- + +# TUI footer last-line flicker + +## Report + +**What was built** — The session transcript's completion footer (`▣ Mode · model · duration`) is the scroll area's last row and now stays mounted whenever the message is last, including mid-loop `finish="tool-calls"` steps. Previously `showFooter` hid that row as soon as `finish` became `"tool-calls"`, so the last line vanished for the whole tool window and flickered at the process→complete transition. The Orchestrator-era trailing-row suppression is gone with the mode; duration still only renders when `finish` is set and ≠ `"tool-calls"`. + +**Verification** — `bun run typecheck` (packages/cli): PASS. `bun test ./test/cli/tui/ ./test/cli/cmd/tui/` (packages/cli): PASS, 362 tests / 0 fail (includes 7 footer-predicate cases). Independent review of the first cut flagged that a prior-final scan was global by `id`; that scan is gone in the final shape because the trailing-row rule no longer exists. Sticky-follow re-snap was prototyped and then deliberately dropped from this PR per review direction — net diff touches only the footer gate. + +**Journey log** — +1. Symptom looked like sticky off-by-one *or* footer unmount; the unmount is the one to fix here — `showFooter` literally unmounts the row via ``. +2. The unmount gate `props.last && finish !== "tool-calls"` existed only to suppress a trailing Orchestrator tool-calls row after a stop row. Orchestrator is deleted (`orchestrator-removal-tui.test.tsx`), so the gate has no remaining purpose. +3. User direction: don't touch sticky in this PR; `finish=tool-calls` should never make the line disappear. Final predicate is `recovering || aborted || isFinal || isLast`. +4. Keep sticky re-snap in the back pocket if the hole ever reproduces after this lands — it is a separate, layout-timing concern. + +## [S1] Problem + +In the session TUI, the completion footer (`▣ Mode · model`, the orange agent glyph + mode + model) is the last line of the scrollable transcript. Scroll tracks the bottom (`stickyScroll` + `stickyStart="bottom"`), so that line should stay visible as a stable last row while content grows. + +During a step's toolcall transition from process rendering to completed state, that line disappears for about one frame. Root cause is footer unmount, not scroll: `showFooter` was `props.last && props.message.finish !== "tool-calls"` once `final()` is false. Mid-loop assistant messages write `finish = "tool-calls"` at `finish-step` (and actor tools write it before the tool part flips to `completed`). On that write the last message stops drawing the footer until the next assistant message mounts, so the last row vanishes. + +## [S2] Design + +**Footer stability.** The last assistant row is the live turn status line. Keep it mounted for every last message, including `finish === "tool-calls"` mid-loop steps. Orchestrator (the only reason to suppress a trailing tool-calls row) is gone, so no trailing-row exception remains. + +Pure predicate (testable without the view): + +```ts +shouldShowAssistantFooter({ + recovering, + aborted, + finish, // message.finish + isLast, +}) +``` + +| recovering / aborted | `isFinal` | `isLast` | show | +| --- | --- | --- | --- | +| true | * | * | yes | +| false | true | * | yes | +| false | false | true | yes (live last row, incl. `finish="tool-calls"`) | +| false | false | false | no | + +`isFinal` = `finish` set and ≠ `"tool-calls"`. Duration still only renders when `isFinal`. + +**Out of scope.** Sticky scroll / follow-bottom re-snap (separate concern; not in this PR). Prompt-embedded mode/model row, sidebar status, pinning a footer overlay outside the scroll content. + +## [S3] Out of Scope + +- Sticky-scroll timing / follow-bottom re-snap +- Changing footer copy, glyphs, or duration math +- Non-session scrollboxes (workflow page, dialogs) + +## Tasks + +- [x] T1: Extract `shouldShowAssistantFooter` and wire `AssistantMessage.showFooter` — acceptance: mid-loop last message with `finish=tool-calls` shows the footer; non-last mid-loop rows do not (covers: S2) +- [x] T2: Regression tests for the footer predicate — acceptance: `bun test` packages/cli footer cases pass (covers: S2; depends: T1) diff --git a/packages/cli/src/cli/cmd/tui/routes/session/index.tsx b/packages/cli/src/cli/cmd/tui/routes/session/index.tsx index 9833c79f4..98beb9c91 100644 --- a/packages/cli/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/cli/src/cli/cmd/tui/routes/session/index.tsx @@ -110,6 +110,7 @@ import { SessionRetry } from "@/session/retry" import { getRevertDiffFiles } from "../../util/revert-diff" import * as Collapse from "../../util/collapse" import { shouldHideTool } from "../../util/tool-visibility" +import { isFinalAssistantFinish, shouldShowAssistantFooter } from "../../util/assistant-footer" import { planSwitchTarget } from "./plan-switch" addDefaultParsers(parsers.parsers) @@ -1790,26 +1791,21 @@ function AssistantMessage(props: { Model.name(ctx.providers(), props.message.providerID, props.message.modelID), ) - const final = createMemo(() => { - return props.message.finish && props.message.finish !== "tool-calls" - }) - - // The completion footer (▣ Agent · model · duration) must render exactly once - // per turn. A turn can produce several assistant messages: mid-loop ones finish - // with "tool-calls", the closing one finishes with "stop". In Orchestrator mode - // the turn characteristically ends on a "tool-calls" message (spawning - // subagents / handing off) that trails the final "stop" message — so `props.last` - // and `final()` land on two different messages and BOTH draw a footer (the stop - // one with a duration, the trailing tool-calls one without). Suppress the footer - // for a trailing message that already finished with "tool-calls"; still show it - // while a last message is streaming (finish undefined) or for the final/aborted - // message, which preserves non-orchestrator behavior. - const showFooter = createMemo(() => { - if (props.recovering) return true - if (props.message.error?.name === "MessageAbortedError") return true - if (final()) return true - return props.last && props.message.finish !== "tool-calls" - }) + const final = createMemo(() => isFinalAssistantFinish(props.message.finish)) + + // The completion footer (▣ Agent · model · duration) is the last row of the + // scrollable transcript and must stay mounted — including when the last + // message's finish is "tool-calls". The old gate unmounted that row on every + // mid-loop tool-calls step, so the last line vanished until the next assistant + // message appeared. Orchestrator (the only reason for that gate) is gone. + const showFooter = createMemo(() => + shouldShowAssistantFooter({ + recovering: props.recovering, + aborted: props.message.error?.name === "MessageAbortedError", + finish: props.message.finish, + isLast: props.last, + }), + ) const duration = createMemo(() => { if (!final()) return 0 diff --git a/packages/cli/src/cli/cmd/tui/util/assistant-footer.ts b/packages/cli/src/cli/cmd/tui/util/assistant-footer.ts new file mode 100644 index 000000000..e7c9c66e5 --- /dev/null +++ b/packages/cli/src/cli/cmd/tui/util/assistant-footer.ts @@ -0,0 +1,27 @@ +/** + * Completion-footer visibility for an assistant message row. + * + * The footer (`▣ Mode · model · duration`) is the last row of the scrollable + * transcript and must stay mounted — including mid-loop `finish === "tool-calls"` + * steps. The old gate hid the row whenever the last message finished with + * tool-calls, so the last line vanished for the whole tool window and flickered + * at the process→complete transition. Orchestrator (the only reason that gate + * existed) is gone, so a trailing tool-calls row no longer needs suppressing. + */ + +/** A settled turn step: finish is known and is not a mid-loop tool-calls hop. */ +export function isFinalAssistantFinish(finish: string | undefined): boolean { + return Boolean(finish) && finish !== "tool-calls" +} + +export function shouldShowAssistantFooter(input: { + recovering: boolean + aborted: boolean + finish: string | undefined + isLast: boolean +}): boolean { + if (input.recovering) return true + if (input.aborted) return true + if (isFinalAssistantFinish(input.finish)) return true + return input.isLast +} diff --git a/packages/cli/test/cli/tui/assistant-footer.test.ts b/packages/cli/test/cli/tui/assistant-footer.test.ts new file mode 100644 index 000000000..37793ce37 --- /dev/null +++ b/packages/cli/test/cli/tui/assistant-footer.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test" +import { shouldShowAssistantFooter } from "../../../src/cli/cmd/tui/util/assistant-footer" + +const base = { + recovering: false, + aborted: false, + finish: undefined as string | undefined, + isLast: true, +} + +describe("shouldShowAssistantFooter", () => { + test("shows for a recovering row even when it is not last", () => { + expect(shouldShowAssistantFooter({ ...base, recovering: true, isLast: false, finish: "tool-calls" })).toBe(true) + }) + + test("shows for an aborted row", () => { + expect(shouldShowAssistantFooter({ ...base, aborted: true, isLast: false, finish: "tool-calls" })).toBe(true) + }) + + test("shows for a final (stop) row even when a later row is last", () => { + expect(shouldShowAssistantFooter({ ...base, finish: "stop", isLast: false })).toBe(true) + }) + + test("hides a non-last mid-loop row that is not final", () => { + expect(shouldShowAssistantFooter({ ...base, finish: "tool-calls", isLast: false })).toBe(false) + }) + + test("shows the live last row while streaming (finish undefined)", () => { + expect(shouldShowAssistantFooter({ ...base, finish: undefined, isLast: true })).toBe(true) + }) + + test("keeps the last row mounted after finish=tool-calls", () => { + expect(shouldShowAssistantFooter({ ...base, finish: "tool-calls", isLast: true })).toBe(true) + }) + + test("shows for a settled non-tool-calls finish (length, cancelled, …) even when not last", () => { + expect(shouldShowAssistantFooter({ ...base, finish: "length", isLast: false })).toBe(true) + }) +})