From cfb7230aa046f5e81f6d1be03d87a57a24b521ca Mon Sep 17 00:00:00 2001 From: yanyihan Date: Mon, 19 Oct 2026 18:07:58 +0800 Subject: [PATCH 1/6] fix(tui): keep completion footer mounted and re-snap sticky bottom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scrollable transcript's last row (▣ Mode · model) flickered away for a frame when a toolcall flipped to completed: mid-loop finish="tool-calls" unmounted the footer, and height growth could leave sticky bottom one row short. Keep the live last row mounted and re-snap after layout in follow mode. --- .../src/cli/cmd/tui/routes/session/index.tsx | 50 ++++++++---- .../src/cli/cmd/tui/util/assistant-footer.ts | 25 ++++++ .../cli/src/cli/cmd/tui/util/sticky-follow.ts | 52 ++++++++++++ .../cli/test/cli/tui/assistant-footer.test.ts | 44 ++++++++++ .../cli/test/cli/tui/sticky-follow.test.ts | 81 +++++++++++++++++++ 5 files changed, 235 insertions(+), 17 deletions(-) create mode 100644 packages/cli/src/cli/cmd/tui/util/assistant-footer.ts create mode 100644 packages/cli/src/cli/cmd/tui/util/sticky-follow.ts create mode 100644 packages/cli/test/cli/tui/assistant-footer.test.ts create mode 100644 packages/cli/test/cli/tui/sticky-follow.test.ts 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..b6e74953f 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,8 @@ import { SessionRetry } from "@/session/retry" import { getRevertDiffFiles } from "../../util/revert-diff" import * as Collapse from "../../util/collapse" import { shouldHideTool } from "../../util/tool-visibility" +import { shouldShowAssistantFooter } from "../../util/assistant-footer" +import { scheduleFollowStickyBottom } from "../../util/sticky-follow" import { planSwitchTarget } from "./plan-switch" addDefaultParsers(parsers.parsers) @@ -314,8 +316,16 @@ export function Session() { let lastSwitch: string | undefined = undefined event.on("message.part.updated", (evt) => { const part = evt.properties.part - if (part.type !== "tool") return if (part.sessionID !== route.sessionID) return + + // Tool process→complete (and any part row) can grow the transcript after + // opentui has already snapped sticky bottom, clipping the footer for a + // frame. Re-snap after layout while the user is still following. + if (part.type === "tool") { + scheduleFollowStickyBottom(scroll as never, { setTimeout, clearTimeout }) + } + + if (part.type !== "tool") return if (part.id === lastSwitch) return const agent = planSwitchTarget(part) @@ -1794,22 +1804,28 @@ function AssistantMessage(props: { 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" - }) + // The completion footer (▣ Agent · model · duration) is the last row of the + // scrollable transcript. Keep it mounted for the live last message — including + // mid-loop finish="tool-calls" steps — so sticky bottom never clips a hole + // where the row used to be. A trailing Orchestrator tool-calls message that + // follows an already-final turn stays suppressed so the stop message keeps + // the single duration footer. + const showFooter = createMemo(() => + shouldShowAssistantFooter({ + recovering: props.recovering, + aborted: props.message.error?.name === "MessageAbortedError", + finish: props.message.finish, + isLast: props.last, + hasPriorFinal: messages().some( + (m) => + m.role === "assistant" && + m.id < props.message.id && + m.finish && + m.finish !== "tool-calls" && + m.error?.name !== "MessageAbortedError", + ), + }), + ) 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..88e7b2b67 --- /dev/null +++ b/packages/cli/src/cli/cmd/tui/util/assistant-footer.ts @@ -0,0 +1,25 @@ +/** + * Completion-footer visibility for an assistant message row. + * + * The footer (`▣ Mode · model · duration`) is the last row of the scrollable + * transcript. It must stay mounted for the live last message — including + * mid-loop `finish === "tool-calls"` steps — so sticky bottom never has a hole + * where the row used to be. The only tool-calls row we still suppress is a + * trailing Orchestrator message that follows an already-final turn, so the + * stop message keeps the single duration footer. + */ +export function shouldShowAssistantFooter(input: { + recovering: boolean + aborted: boolean + finish: string | undefined + isLast: boolean + hasPriorFinal: boolean +}): boolean { + if (input.recovering) return true + if (input.aborted) return true + const isFinal = Boolean(input.finish) && input.finish !== "tool-calls" + if (isFinal) return true + if (!input.isLast) return false + if (input.finish === "tool-calls") return !input.hasPriorFinal + return true +} diff --git a/packages/cli/src/cli/cmd/tui/util/sticky-follow.ts b/packages/cli/src/cli/cmd/tui/util/sticky-follow.ts new file mode 100644 index 000000000..d8d2d84d0 --- /dev/null +++ b/packages/cli/src/cli/cmd/tui/util/sticky-follow.ts @@ -0,0 +1,52 @@ +/** + * Follow-bottom helpers for the session transcript ScrollBox. + * + * opentui's `stickyScroll` + `stickyStart="bottom"` tracks the edge, but a + * height change (tool process→complete, footer mount) can land in a frame + * where `scrollHeight` has grown and the bar has not been re-applied yet — + * the last row is then just out of view. Re-snap after layout when the user + * is still in follow mode. `_hasManualScroll` is the same authority the + * agent-switch path uses: set only on a user scroll away from the sticky + * edge, cleared when they return. + */ + +export type StickyScrollBox = { + _hasManualScroll?: boolean + isDestroyed?: boolean + scrollHeight: number + scrollTo: (y: number) => void +} + +export function isFollowingBottom(scroll: { _hasManualScroll?: boolean } | undefined): boolean { + if (!scroll) return false + // Missing flag → treat as following (safe direction: snap rather than leave + // a stale offset). Matches the agent-switch fallback. + return scroll._hasManualScroll !== true +} + +export function followStickyBottom(scroll: StickyScrollBox | undefined): void { + if (!scroll || scroll.isDestroyed) return + if (!isFollowingBottom(scroll)) return + scroll.scrollTo(scroll.scrollHeight) +} + +/** + * Coalesce part/message height changes into one post-layout snap. The + * synchronous event fires before Solid re-renders and opentui lays out the + * taller content, so an immediate `scrollTo(scrollHeight)` still sees the old + * height and the next frame is short exactly one row. + */ +export function scheduleFollowStickyBottom( + scroll: StickyScrollBox | undefined, + timers: { setTimeout: typeof setTimeout; clearTimeout: typeof clearTimeout }, + delayMs = 0, +): void { + const state = scheduleFollowStickyBottom.state + if (state.timer !== undefined) timers.clearTimeout(state.timer) + state.timer = timers.setTimeout(() => { + state.timer = undefined + followStickyBottom(scroll) + }, delayMs) +} + +scheduleFollowStickyBottom.state = { timer: undefined as ReturnType | undefined } 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..63d963b37 --- /dev/null +++ b/packages/cli/test/cli/tui/assistant-footer.test.ts @@ -0,0 +1,44 @@ +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, + hasPriorFinal: false, +} + +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 tool-calls row is last", () => { + expect(shouldShowAssistantFooter({ ...base, finish: "stop", isLast: false, hasPriorFinal: 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 mid-loop finish=tool-calls", () => { + expect(shouldShowAssistantFooter({ ...base, finish: "tool-calls", isLast: true, hasPriorFinal: false })).toBe(true) + }) + + test("hides a trailing tool-calls row that follows an already-final turn", () => { + expect(shouldShowAssistantFooter({ ...base, finish: "tool-calls", isLast: true, hasPriorFinal: true })).toBe(false) + }) + + test("shows the last row for a non-tool-calls, non-final finish (length, cancelled, …)", () => { + expect(shouldShowAssistantFooter({ ...base, finish: "length", isLast: true })).toBe(true) + }) +}) diff --git a/packages/cli/test/cli/tui/sticky-follow.test.ts b/packages/cli/test/cli/tui/sticky-follow.test.ts new file mode 100644 index 000000000..06f1ce43c --- /dev/null +++ b/packages/cli/test/cli/tui/sticky-follow.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from "bun:test" +import { + followStickyBottom, + isFollowingBottom, + scheduleFollowStickyBottom, +} from "../../../src/cli/cmd/tui/util/sticky-follow" + +function box(overrides: Partial[0]> = {}) { + const calls: number[] = [] + const scroll = { + _hasManualScroll: false, + isDestroyed: false, + scrollHeight: 100, + scrollTo: (y: number) => { + calls.push(y) + }, + ...overrides, + } + return { scroll, calls } +} + +describe("isFollowingBottom", () => { + test("treats an explicit manual scroll as not following", () => { + expect(isFollowingBottom({ _hasManualScroll: true })).toBe(false) + }) + + test("treats sticky-edge as following", () => { + expect(isFollowingBottom({ _hasManualScroll: false })).toBe(true) + }) + + test("treats a missing flag as following (safe snap direction)", () => { + expect(isFollowingBottom({})).toBe(true) + expect(isFollowingBottom(undefined)).toBe(false) + }) +}) + +describe("followStickyBottom", () => { + test("snaps to the new height while following", () => { + const { scroll, calls } = box({ scrollHeight: 240 }) + followStickyBottom(scroll as never) + expect(calls).toEqual([240]) + }) + + test("leaves a manual scroll alone", () => { + const { scroll, calls } = box({ _hasManualScroll: true, scrollHeight: 240 }) + followStickyBottom(scroll as never) + expect(calls).toEqual([]) + }) + + test("ignores a destroyed scrollbox", () => { + const { scroll, calls } = box({ isDestroyed: true, scrollHeight: 240 }) + followStickyBottom(scroll as never) + expect(calls).toEqual([]) + }) +}) + +describe("scheduleFollowStickyBottom", () => { + test("coalesces bursts and snaps once after layout", () => { + const timers: { cb?: () => void; n: number } = { n: 0 } + const fakeSetTimeout = ((cb: () => void) => { + timers.n += 1 + timers.cb = cb + return timers.n as unknown as ReturnType + }) as typeof setTimeout + const fakeClearTimeout = (() => { + timers.cb = undefined + }) as typeof clearTimeout + + const { scroll, calls } = box({ scrollHeight: 320 }) + scheduleFollowStickyBottom(scroll as never, { setTimeout: fakeSetTimeout, clearTimeout: fakeClearTimeout }) + scheduleFollowStickyBottom(scroll as never, { setTimeout: fakeSetTimeout, clearTimeout: fakeClearTimeout }) + scheduleFollowStickyBottom(scroll as never, { setTimeout: fakeSetTimeout, clearTimeout: fakeClearTimeout }) + + // Bursts must not queue one snap per part event. + expect(timers.n).toBe(3) + expect(calls).toEqual([]) + + timers.cb?.() + expect(calls).toEqual([320]) + }) +}) From 183b8edce80bf1832a3ba43e5792230cfb5688e7 Mon Sep 17 00:00:00 2001 From: yanyihan Date: Mon, 19 Oct 2026 18:13:09 +0800 Subject: [PATCH 2/6] fix(tui): scope prior-final footer scan to the same turn A global id-order scan treated every later mid-loop tool-calls step as trailing after turn 1 and re-hid the live last footer on turn 2+. Key the prior-final check on parentID and count aborted rows as owning the turn footer. --- .../src/cli/cmd/tui/routes/session/index.tsx | 21 +++----- .../src/cli/cmd/tui/util/assistant-footer.ts | 37 +++++++++++++- .../cli/test/cli/tui/assistant-footer.test.ts | 49 ++++++++++++++++++- 3 files changed, 90 insertions(+), 17 deletions(-) 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 b6e74953f..4fe628d1d 100644 --- a/packages/cli/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/cli/src/cli/cmd/tui/routes/session/index.tsx @@ -110,7 +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 { shouldShowAssistantFooter } from "../../util/assistant-footer" +import { shouldShowAssistantFooter, hasPriorFinalAssistant } from "../../util/assistant-footer" import { scheduleFollowStickyBottom } from "../../util/sticky-follow" import { planSwitchTarget } from "./plan-switch" @@ -318,9 +318,9 @@ export function Session() { const part = evt.properties.part if (part.sessionID !== route.sessionID) return - // Tool process→complete (and any part row) can grow the transcript after - // opentui has already snapped sticky bottom, clipping the footer for a - // frame. Re-snap after layout while the user is still following. + // Tool process→complete can grow the transcript after opentui has already + // snapped sticky bottom, clipping the footer for a frame. Re-snap after + // layout while the user is still following. if (part.type === "tool") { scheduleFollowStickyBottom(scroll as never, { setTimeout, clearTimeout }) } @@ -1808,22 +1808,15 @@ function AssistantMessage(props: { // scrollable transcript. Keep it mounted for the live last message — including // mid-loop finish="tool-calls" steps — so sticky bottom never clips a hole // where the row used to be. A trailing Orchestrator tool-calls message that - // follows an already-final turn stays suppressed so the stop message keeps - // the single duration footer. + // follows an already-settled turn stays suppressed so the stop/aborted message + // keeps the single duration footer. Prior-turn rows are ignored (parentID). const showFooter = createMemo(() => shouldShowAssistantFooter({ recovering: props.recovering, aborted: props.message.error?.name === "MessageAbortedError", finish: props.message.finish, isLast: props.last, - hasPriorFinal: messages().some( - (m) => - m.role === "assistant" && - m.id < props.message.id && - m.finish && - m.finish !== "tool-calls" && - m.error?.name !== "MessageAbortedError", - ), + hasPriorFinal: hasPriorFinalAssistant(messages(), props.message), }), ) diff --git a/packages/cli/src/cli/cmd/tui/util/assistant-footer.ts b/packages/cli/src/cli/cmd/tui/util/assistant-footer.ts index 88e7b2b67..b1c678f5b 100644 --- a/packages/cli/src/cli/cmd/tui/util/assistant-footer.ts +++ b/packages/cli/src/cli/cmd/tui/util/assistant-footer.ts @@ -5,9 +5,42 @@ * transcript. It must stay mounted for the live last message — including * mid-loop `finish === "tool-calls"` steps — so sticky bottom never has a hole * where the row used to be. The only tool-calls row we still suppress is a - * trailing Orchestrator message that follows an already-final turn, so the - * stop message keeps the single duration footer. + * trailing Orchestrator message that follows an already-settled turn, so the + * stop/aborted message keeps the single duration footer. */ + +export type AssistantFooterMessage = { + id: string + role: string + parentID?: string + finish?: string | null + error?: { name?: string } | null +} + +function isSettledFooterRow(message: AssistantFooterMessage): boolean { + const isFinal = Boolean(message.finish) && message.finish !== "tool-calls" + return isFinal || message.error?.name === "MessageAbortedError" +} + +/** + * True when an earlier assistant row in the same turn already draws the + * settled turn footer. Scope by `parentID` (the user message that opened the + * turn) — a global scan would treat every later mid-loop `tool-calls` step as + * trailing after turn 1 and re-hide the live last row on turn 2+. + */ +export function hasPriorFinalAssistant( + messages: ReadonlyArray, + current: { id: string; parentID?: string }, +): boolean { + return messages.some( + (message) => + message.role === "assistant" && + message.id < current.id && + message.parentID === current.parentID && + isSettledFooterRow(message), + ) +} + export function shouldShowAssistantFooter(input: { recovering: boolean aborted: boolean diff --git a/packages/cli/test/cli/tui/assistant-footer.test.ts b/packages/cli/test/cli/tui/assistant-footer.test.ts index 63d963b37..382bbc058 100644 --- a/packages/cli/test/cli/tui/assistant-footer.test.ts +++ b/packages/cli/test/cli/tui/assistant-footer.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from "bun:test" -import { shouldShowAssistantFooter } from "../../../src/cli/cmd/tui/util/assistant-footer" +import { + hasPriorFinalAssistant, + shouldShowAssistantFooter, +} from "../../../src/cli/cmd/tui/util/assistant-footer" const base = { recovering: false, @@ -9,6 +12,13 @@ const base = { hasPriorFinal: false, } +const assistant = (input: { + id: string + parentID: string + finish?: string + error?: { name?: string } +}) => ({ role: "assistant", ...input }) + 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) @@ -42,3 +52,40 @@ describe("shouldShowAssistantFooter", () => { expect(shouldShowAssistantFooter({ ...base, finish: "length", isLast: true })).toBe(true) }) }) + +describe("hasPriorFinalAssistant", () => { + test("ignores settled rows from earlier turns", () => { + // Turn 1 closed with stop. Turn 2 is mid-loop on tool-calls — that live last + // row must stay mounted or the footer hole reappears on turn 2+. + const messages = [ + assistant({ id: "m1", parentID: "u1", finish: "stop" }), + assistant({ id: "m2", parentID: "u2", finish: "tool-calls" }), + ] + expect(hasPriorFinalAssistant(messages, { id: "m2", parentID: "u2" })).toBe(false) + }) + + test("sees a settled row inside the same turn", () => { + const messages = [ + assistant({ id: "m1", parentID: "u1", finish: "stop" }), + assistant({ id: "m2", parentID: "u1", finish: "tool-calls" }), + ] + expect(hasPriorFinalAssistant(messages, { id: "m2", parentID: "u1" })).toBe(true) + }) + + test("treats a prior aborted row as owning the turn footer", () => { + const messages = [ + assistant({ id: "m1", parentID: "u1", error: { name: "MessageAbortedError" } }), + assistant({ id: "m2", parentID: "u1", finish: "tool-calls" }), + ] + expect(hasPriorFinalAssistant(messages, { id: "m2", parentID: "u1" })).toBe(true) + }) + + test("ignores later and non-assistant rows", () => { + const messages = [ + { role: "user", id: "m0", parentID: "u1" }, + assistant({ id: "m3", parentID: "u1", finish: "stop" }), + assistant({ id: "m1", parentID: "u1", finish: "tool-calls" }), + ] + expect(hasPriorFinalAssistant(messages, { id: "m1", parentID: "u1" })).toBe(false) + }) +}) From b1d35effec5addc7f8f928b87605dea14ebbb5b9 Mon Sep 17 00:00:00 2001 From: yanyihan Date: Mon, 19 Oct 2026 18:13:27 +0800 Subject: [PATCH 3/6] docs(compose): record tui-footer-sticky-last-line delivery --- .../spec/tui-footer-sticky-last-line.md | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 docs/compose/spec/tui-footer-sticky-last-line.md 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..0c183f46d --- /dev/null +++ b/docs/compose/spec/tui-footer-sticky-last-line.md @@ -0,0 +1,75 @@ +--- +feature: tui-footer-sticky-last-line +status: delivered +updated: 2026-07-11 +branch: fix/tui-footer-sticky-last-line +commits: 67fecf88..55e10011 +--- + +# TUI footer last-line flicker + +## Report + +**What was built** — The session transcript's completion footer (`▣ Mode · model · duration`) is now a stable last row while scroll tracks the bottom. Mid-loop `finish="tool-calls"` steps keep the live last row mounted (`shouldShowAssistantFooter` in `util/assistant-footer.ts`); only a trailing Orchestrator tool-calls row that follows an already-settled *same-turn* footer is suppressed, so the stop/aborted message still owns the single duration line. Tool process→complete height changes schedule a coalesced post-layout re-snap (`scheduleFollowStickyBottom` in `util/sticky-follow.ts`) that respects `_hasManualScroll` and never yanks a user who scrolled away. + +**Verification** — `bun run typecheck` (packages/cli): PASS. `bun test ./test/cli/tui/ ./test/cli/cmd/tui/` (packages/cli): PASS, 374 tests / 0 fail (includes 12 footer-predicate + 7 sticky-follow cases). Independent review of `67fecf88..df0fc443` passed T2/T3 and flagged one critical: the prior-final scan was global by `id`, which re-hid the footer on turn 2+. Fixed in `55e10011` by scoping `hasPriorFinalAssistant` on `parentID` and treating a prior aborted row as owning the turn footer; multi-turn and aborted-prior cases covered in tests. + +**Journey log** — +1. Symptom matched two mechanisms (footer unmount vs sticky off-by-one); both are real and both were fixed — do not drop either half. +2. `showFooter`'s old `finish !== "tool-calls"` guard was the unmount; keeping the live last row mounted is the whole point of the predicate. +3. Review caught that "prior final" must be turn-scoped (`parentID`), not a global `id` scan — a global scan collapses every later turn into "trailing after turn 1". +4. opentui `scrollTo(scrollHeight)` clamps via `scrollPosition` and clears `_hasManualScroll` at the bottom, so programmatic re-snap is safe w.r.t. manual-scroll authority. +5. Sticky re-snap must run *after* layout (`scheduleFollowStickyBottom`); an immediate snap still sees the old height. + +## [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 (screenshot gap above the prompt). Other transcript content still refreshes smoothly and the prompt bar under the scroll area never moves. Two mechanisms can produce the same symptom and both are in play: + +1. **Footer unmount** — `showFooter` is `props.last && finish !== "tool-calls"` once `final()` is false. Mid-loop assistant messages write `finish = "tool-calls"` at `finish-step` (and actor tools write it even 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. +2. **Sticky off-by-one** — tool complete often changes content height (InlineTool ↔ BlockTool, `renderBefore` margin 0→1, hidden tool rows). `recalculateBarProps` → `applyStickyStart` can lag that growth by a frame, so the viewport is one row short and clips exactly the footer. + +## [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. Only suppress the trailing Orchestrator `tool-calls` message that follows an already-settled footer in the **same turn** (`parentID`), so the stop/aborted message still owns the single duration footer. + +Pure predicates (testable without the view): + +```ts +hasPriorFinalAssistant(messages, current) // same parentID + earlier id + settled row +shouldShowAssistantFooter({ + recovering, + aborted, + finish, // message.finish + isLast, + hasPriorFinal, +}) +``` + +| recovering / aborted | `final()` | `isLast` | `finish === "tool-calls"` | `hasPriorFinal` | show | +| --- | --- | --- | --- | --- | --- | +| true | * | * | * | * | yes | +| false | true | * | * | * | yes | +| false | false | false | * | * | no | +| false | false | true | false | * | yes | +| false | false | true | true | false | yes (mid-loop live row) | +| false | false | true | true | true | no (trailing after a settled same-turn footer) | + +`hasPriorFinal` is true only for an earlier assistant message with the same `parentID` that is settled (`finish` set and ≠ `"tool-calls"`, or `MessageAbortedError`). A prior turn's settled row must not count. Duration still only renders when `final()` is true. + +**Sticky follow.** When the user is in follow-bottom mode (`!_hasManualScroll`) and transcript height changes (tool complete, part row, footer mount), re-apply bottom after layout so the last row is never clipped. Use the same `_hasManualScroll` authority the agent-switch path already documents; never yank a user who scrolled away. Height-change snaps are coalesced so a burst of part updates yields one post-layout snap. + +**Out of scope.** Prompt-embedded mode/model row, sidebar status, pinning a footer overlay outside the scroll content, Orchestrator UX changes beyond the existing single-footer rule. + +## [S3] Out of Scope + +- Changing footer copy, glyphs, or duration math +- Sticky behavior when the user has manually scrolled +- Non-session scrollboxes (workflow page, dialogs) + +## Tasks + +- [x] T1: Extract `shouldShowAssistantFooter` and wire `AssistantMessage.showFooter` — acceptance: mid-loop last message with `finish=tool-calls` and no prior final shows the footer; trailing tool-calls after a settled same-turn footer does not (covers: S2) +- [x] T2: Re-apply sticky bottom after transcript height changes in follow mode — acceptance: tool process→complete growth keeps the footer row in the viewport; manual scroll is left alone (covers: S2) +- [x] T3: Regression tests for the footer predicate and follow-bottom reapply — acceptance: `bun test` packages/cli footer/sticky cases pass (covers: S2; depends: T1, T2) From 3730b02b68cef2c44aa02930aa4a0d4ab25064ae Mon Sep 17 00:00:00 2001 From: yanyihan Date: Mon, 19 Oct 2026 18:16:51 +0800 Subject: [PATCH 4/6] fix(tui): never unmount the completion footer on finish=tool-calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the sticky-follow half of this change and the Orchestrator-era trailing-row suppression. The footer is the transcript's last row and must stay mounted whenever the message is last — including mid-loop finish= "tool-calls" steps. Orchestrator is gone, so that gate has no remaining purpose and only caused the one-frame hole. --- .../src/cli/cmd/tui/routes/session/index.tsx | 23 ++---- .../src/cli/cmd/tui/util/assistant-footer.ts | 48 ++--------- .../cli/src/cli/cmd/tui/util/sticky-follow.ts | 52 ------------ .../cli/test/cli/tui/assistant-footer.test.ts | 62 ++------------ .../cli/test/cli/tui/sticky-follow.test.ts | 81 ------------------- 5 files changed, 17 insertions(+), 249 deletions(-) delete mode 100644 packages/cli/src/cli/cmd/tui/util/sticky-follow.ts delete mode 100644 packages/cli/test/cli/tui/sticky-follow.test.ts 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 4fe628d1d..21ea892c8 100644 --- a/packages/cli/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/cli/src/cli/cmd/tui/routes/session/index.tsx @@ -110,8 +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 { shouldShowAssistantFooter, hasPriorFinalAssistant } from "../../util/assistant-footer" -import { scheduleFollowStickyBottom } from "../../util/sticky-follow" +import { shouldShowAssistantFooter } from "../../util/assistant-footer" import { planSwitchTarget } from "./plan-switch" addDefaultParsers(parsers.parsers) @@ -316,16 +315,8 @@ export function Session() { let lastSwitch: string | undefined = undefined event.on("message.part.updated", (evt) => { const part = evt.properties.part - if (part.sessionID !== route.sessionID) return - - // Tool process→complete can grow the transcript after opentui has already - // snapped sticky bottom, clipping the footer for a frame. Re-snap after - // layout while the user is still following. - if (part.type === "tool") { - scheduleFollowStickyBottom(scroll as never, { setTimeout, clearTimeout }) - } - if (part.type !== "tool") return + if (part.sessionID !== route.sessionID) return if (part.id === lastSwitch) return const agent = planSwitchTarget(part) @@ -1805,18 +1796,16 @@ function AssistantMessage(props: { }) // The completion footer (▣ Agent · model · duration) is the last row of the - // scrollable transcript. Keep it mounted for the live last message — including - // mid-loop finish="tool-calls" steps — so sticky bottom never clips a hole - // where the row used to be. A trailing Orchestrator tool-calls message that - // follows an already-settled turn stays suppressed so the stop/aborted message - // keeps the single duration footer. Prior-turn rows are ignored (parentID). + // 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, - hasPriorFinal: hasPriorFinalAssistant(messages(), props.message), }), ) diff --git a/packages/cli/src/cli/cmd/tui/util/assistant-footer.ts b/packages/cli/src/cli/cmd/tui/util/assistant-footer.ts index b1c678f5b..e74930407 100644 --- a/packages/cli/src/cli/cmd/tui/util/assistant-footer.ts +++ b/packages/cli/src/cli/cmd/tui/util/assistant-footer.ts @@ -2,57 +2,21 @@ * Completion-footer visibility for an assistant message row. * * The footer (`▣ Mode · model · duration`) is the last row of the scrollable - * transcript. It must stay mounted for the live last message — including - * mid-loop `finish === "tool-calls"` steps — so sticky bottom never has a hole - * where the row used to be. The only tool-calls row we still suppress is a - * trailing Orchestrator message that follows an already-settled turn, so the - * stop/aborted message keeps the single duration footer. + * 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. */ - -export type AssistantFooterMessage = { - id: string - role: string - parentID?: string - finish?: string | null - error?: { name?: string } | null -} - -function isSettledFooterRow(message: AssistantFooterMessage): boolean { - const isFinal = Boolean(message.finish) && message.finish !== "tool-calls" - return isFinal || message.error?.name === "MessageAbortedError" -} - -/** - * True when an earlier assistant row in the same turn already draws the - * settled turn footer. Scope by `parentID` (the user message that opened the - * turn) — a global scan would treat every later mid-loop `tool-calls` step as - * trailing after turn 1 and re-hide the live last row on turn 2+. - */ -export function hasPriorFinalAssistant( - messages: ReadonlyArray, - current: { id: string; parentID?: string }, -): boolean { - return messages.some( - (message) => - message.role === "assistant" && - message.id < current.id && - message.parentID === current.parentID && - isSettledFooterRow(message), - ) -} - export function shouldShowAssistantFooter(input: { recovering: boolean aborted: boolean finish: string | undefined isLast: boolean - hasPriorFinal: boolean }): boolean { if (input.recovering) return true if (input.aborted) return true const isFinal = Boolean(input.finish) && input.finish !== "tool-calls" if (isFinal) return true - if (!input.isLast) return false - if (input.finish === "tool-calls") return !input.hasPriorFinal - return true + return input.isLast } diff --git a/packages/cli/src/cli/cmd/tui/util/sticky-follow.ts b/packages/cli/src/cli/cmd/tui/util/sticky-follow.ts deleted file mode 100644 index d8d2d84d0..000000000 --- a/packages/cli/src/cli/cmd/tui/util/sticky-follow.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Follow-bottom helpers for the session transcript ScrollBox. - * - * opentui's `stickyScroll` + `stickyStart="bottom"` tracks the edge, but a - * height change (tool process→complete, footer mount) can land in a frame - * where `scrollHeight` has grown and the bar has not been re-applied yet — - * the last row is then just out of view. Re-snap after layout when the user - * is still in follow mode. `_hasManualScroll` is the same authority the - * agent-switch path uses: set only on a user scroll away from the sticky - * edge, cleared when they return. - */ - -export type StickyScrollBox = { - _hasManualScroll?: boolean - isDestroyed?: boolean - scrollHeight: number - scrollTo: (y: number) => void -} - -export function isFollowingBottom(scroll: { _hasManualScroll?: boolean } | undefined): boolean { - if (!scroll) return false - // Missing flag → treat as following (safe direction: snap rather than leave - // a stale offset). Matches the agent-switch fallback. - return scroll._hasManualScroll !== true -} - -export function followStickyBottom(scroll: StickyScrollBox | undefined): void { - if (!scroll || scroll.isDestroyed) return - if (!isFollowingBottom(scroll)) return - scroll.scrollTo(scroll.scrollHeight) -} - -/** - * Coalesce part/message height changes into one post-layout snap. The - * synchronous event fires before Solid re-renders and opentui lays out the - * taller content, so an immediate `scrollTo(scrollHeight)` still sees the old - * height and the next frame is short exactly one row. - */ -export function scheduleFollowStickyBottom( - scroll: StickyScrollBox | undefined, - timers: { setTimeout: typeof setTimeout; clearTimeout: typeof clearTimeout }, - delayMs = 0, -): void { - const state = scheduleFollowStickyBottom.state - if (state.timer !== undefined) timers.clearTimeout(state.timer) - state.timer = timers.setTimeout(() => { - state.timer = undefined - followStickyBottom(scroll) - }, delayMs) -} - -scheduleFollowStickyBottom.state = { timer: undefined as ReturnType | undefined } diff --git a/packages/cli/test/cli/tui/assistant-footer.test.ts b/packages/cli/test/cli/tui/assistant-footer.test.ts index 382bbc058..1bcee82dc 100644 --- a/packages/cli/test/cli/tui/assistant-footer.test.ts +++ b/packages/cli/test/cli/tui/assistant-footer.test.ts @@ -1,24 +1,13 @@ import { describe, expect, test } from "bun:test" -import { - hasPriorFinalAssistant, - shouldShowAssistantFooter, -} from "../../../src/cli/cmd/tui/util/assistant-footer" +import { shouldShowAssistantFooter } from "../../../src/cli/cmd/tui/util/assistant-footer" const base = { recovering: false, aborted: false, finish: undefined as string | undefined, isLast: true, - hasPriorFinal: false, } -const assistant = (input: { - id: string - parentID: string - finish?: string - error?: { name?: string } -}) => ({ role: "assistant", ...input }) - 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) @@ -28,8 +17,8 @@ describe("shouldShowAssistantFooter", () => { expect(shouldShowAssistantFooter({ ...base, aborted: true, isLast: false, finish: "tool-calls" })).toBe(true) }) - test("shows for a final (stop) row even when a later tool-calls row is last", () => { - expect(shouldShowAssistantFooter({ ...base, finish: "stop", isLast: false, hasPriorFinal: false })).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", () => { @@ -40,52 +29,11 @@ describe("shouldShowAssistantFooter", () => { expect(shouldShowAssistantFooter({ ...base, finish: undefined, isLast: true })).toBe(true) }) - test("keeps the last row mounted after mid-loop finish=tool-calls", () => { - expect(shouldShowAssistantFooter({ ...base, finish: "tool-calls", isLast: true, hasPriorFinal: false })).toBe(true) - }) - - test("hides a trailing tool-calls row that follows an already-final turn", () => { - expect(shouldShowAssistantFooter({ ...base, finish: "tool-calls", isLast: true, hasPriorFinal: true })).toBe(false) + test("keeps the last row mounted after finish=tool-calls", () => { + expect(shouldShowAssistantFooter({ ...base, finish: "tool-calls", isLast: true })).toBe(true) }) test("shows the last row for a non-tool-calls, non-final finish (length, cancelled, …)", () => { expect(shouldShowAssistantFooter({ ...base, finish: "length", isLast: true })).toBe(true) }) }) - -describe("hasPriorFinalAssistant", () => { - test("ignores settled rows from earlier turns", () => { - // Turn 1 closed with stop. Turn 2 is mid-loop on tool-calls — that live last - // row must stay mounted or the footer hole reappears on turn 2+. - const messages = [ - assistant({ id: "m1", parentID: "u1", finish: "stop" }), - assistant({ id: "m2", parentID: "u2", finish: "tool-calls" }), - ] - expect(hasPriorFinalAssistant(messages, { id: "m2", parentID: "u2" })).toBe(false) - }) - - test("sees a settled row inside the same turn", () => { - const messages = [ - assistant({ id: "m1", parentID: "u1", finish: "stop" }), - assistant({ id: "m2", parentID: "u1", finish: "tool-calls" }), - ] - expect(hasPriorFinalAssistant(messages, { id: "m2", parentID: "u1" })).toBe(true) - }) - - test("treats a prior aborted row as owning the turn footer", () => { - const messages = [ - assistant({ id: "m1", parentID: "u1", error: { name: "MessageAbortedError" } }), - assistant({ id: "m2", parentID: "u1", finish: "tool-calls" }), - ] - expect(hasPriorFinalAssistant(messages, { id: "m2", parentID: "u1" })).toBe(true) - }) - - test("ignores later and non-assistant rows", () => { - const messages = [ - { role: "user", id: "m0", parentID: "u1" }, - assistant({ id: "m3", parentID: "u1", finish: "stop" }), - assistant({ id: "m1", parentID: "u1", finish: "tool-calls" }), - ] - expect(hasPriorFinalAssistant(messages, { id: "m1", parentID: "u1" })).toBe(false) - }) -}) diff --git a/packages/cli/test/cli/tui/sticky-follow.test.ts b/packages/cli/test/cli/tui/sticky-follow.test.ts deleted file mode 100644 index 06f1ce43c..000000000 --- a/packages/cli/test/cli/tui/sticky-follow.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { - followStickyBottom, - isFollowingBottom, - scheduleFollowStickyBottom, -} from "../../../src/cli/cmd/tui/util/sticky-follow" - -function box(overrides: Partial[0]> = {}) { - const calls: number[] = [] - const scroll = { - _hasManualScroll: false, - isDestroyed: false, - scrollHeight: 100, - scrollTo: (y: number) => { - calls.push(y) - }, - ...overrides, - } - return { scroll, calls } -} - -describe("isFollowingBottom", () => { - test("treats an explicit manual scroll as not following", () => { - expect(isFollowingBottom({ _hasManualScroll: true })).toBe(false) - }) - - test("treats sticky-edge as following", () => { - expect(isFollowingBottom({ _hasManualScroll: false })).toBe(true) - }) - - test("treats a missing flag as following (safe snap direction)", () => { - expect(isFollowingBottom({})).toBe(true) - expect(isFollowingBottom(undefined)).toBe(false) - }) -}) - -describe("followStickyBottom", () => { - test("snaps to the new height while following", () => { - const { scroll, calls } = box({ scrollHeight: 240 }) - followStickyBottom(scroll as never) - expect(calls).toEqual([240]) - }) - - test("leaves a manual scroll alone", () => { - const { scroll, calls } = box({ _hasManualScroll: true, scrollHeight: 240 }) - followStickyBottom(scroll as never) - expect(calls).toEqual([]) - }) - - test("ignores a destroyed scrollbox", () => { - const { scroll, calls } = box({ isDestroyed: true, scrollHeight: 240 }) - followStickyBottom(scroll as never) - expect(calls).toEqual([]) - }) -}) - -describe("scheduleFollowStickyBottom", () => { - test("coalesces bursts and snaps once after layout", () => { - const timers: { cb?: () => void; n: number } = { n: 0 } - const fakeSetTimeout = ((cb: () => void) => { - timers.n += 1 - timers.cb = cb - return timers.n as unknown as ReturnType - }) as typeof setTimeout - const fakeClearTimeout = (() => { - timers.cb = undefined - }) as typeof clearTimeout - - const { scroll, calls } = box({ scrollHeight: 320 }) - scheduleFollowStickyBottom(scroll as never, { setTimeout: fakeSetTimeout, clearTimeout: fakeClearTimeout }) - scheduleFollowStickyBottom(scroll as never, { setTimeout: fakeSetTimeout, clearTimeout: fakeClearTimeout }) - scheduleFollowStickyBottom(scroll as never, { setTimeout: fakeSetTimeout, clearTimeout: fakeClearTimeout }) - - // Bursts must not queue one snap per part event. - expect(timers.n).toBe(3) - expect(calls).toEqual([]) - - timers.cb?.() - expect(calls).toEqual([320]) - }) -}) From f6246ae40657cd3497dad6f65b6233a09a246bd2 Mon Sep 17 00:00:00 2001 From: yanyihan Date: Mon, 19 Oct 2026 18:17:08 +0800 Subject: [PATCH 5/6] docs(compose): narrow tui-footer spec to the unmount-only fix --- .../spec/tui-footer-sticky-last-line.md | 55 ++++++++----------- 1 file changed, 22 insertions(+), 33 deletions(-) diff --git a/docs/compose/spec/tui-footer-sticky-last-line.md b/docs/compose/spec/tui-footer-sticky-last-line.md index 0c183f46d..2f91de8fb 100644 --- a/docs/compose/spec/tui-footer-sticky-last-line.md +++ b/docs/compose/spec/tui-footer-sticky-last-line.md @@ -3,73 +3,62 @@ feature: tui-footer-sticky-last-line status: delivered updated: 2026-07-11 branch: fix/tui-footer-sticky-last-line -commits: 67fecf88..55e10011 +commits: 67fecf88..b12e3d7c --- # TUI footer last-line flicker ## Report -**What was built** — The session transcript's completion footer (`▣ Mode · model · duration`) is now a stable last row while scroll tracks the bottom. Mid-loop `finish="tool-calls"` steps keep the live last row mounted (`shouldShowAssistantFooter` in `util/assistant-footer.ts`); only a trailing Orchestrator tool-calls row that follows an already-settled *same-turn* footer is suppressed, so the stop/aborted message still owns the single duration line. Tool process→complete height changes schedule a coalesced post-layout re-snap (`scheduleFollowStickyBottom` in `util/sticky-follow.ts`) that respects `_hasManualScroll` and never yanks a user who scrolled away. +**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, 374 tests / 0 fail (includes 12 footer-predicate + 7 sticky-follow cases). Independent review of `67fecf88..df0fc443` passed T2/T3 and flagged one critical: the prior-final scan was global by `id`, which re-hid the footer on turn 2+. Fixed in `55e10011` by scoping `hasPriorFinalAssistant` on `parentID` and treating a prior aborted row as owning the turn footer; multi-turn and aborted-prior cases covered in tests. +**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 matched two mechanisms (footer unmount vs sticky off-by-one); both are real and both were fixed — do not drop either half. -2. `showFooter`'s old `finish !== "tool-calls"` guard was the unmount; keeping the live last row mounted is the whole point of the predicate. -3. Review caught that "prior final" must be turn-scoped (`parentID`), not a global `id` scan — a global scan collapses every later turn into "trailing after turn 1". -4. opentui `scrollTo(scrollHeight)` clamps via `scrollPosition` and clears `_hasManualScroll` at the bottom, so programmatic re-snap is safe w.r.t. manual-scroll authority. -5. Sticky re-snap must run *after* layout (`scheduleFollowStickyBottom`); an immediate snap still sees the old height. +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 (screenshot gap above the prompt). Other transcript content still refreshes smoothly and the prompt bar under the scroll area never moves. Two mechanisms can produce the same symptom and both are in play: - -1. **Footer unmount** — `showFooter` is `props.last && finish !== "tool-calls"` once `final()` is false. Mid-loop assistant messages write `finish = "tool-calls"` at `finish-step` (and actor tools write it even 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. -2. **Sticky off-by-one** — tool complete often changes content height (InlineTool ↔ BlockTool, `renderBefore` margin 0→1, hidden tool rows). `recalculateBarProps` → `applyStickyStart` can lag that growth by a frame, so the viewport is one row short and clips exactly the footer. +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. Only suppress the trailing Orchestrator `tool-calls` message that follows an already-settled footer in the **same turn** (`parentID`), so the stop/aborted message still owns the single duration footer. +**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 predicates (testable without the view): +Pure predicate (testable without the view): ```ts -hasPriorFinalAssistant(messages, current) // same parentID + earlier id + settled row shouldShowAssistantFooter({ recovering, aborted, - finish, // message.finish + finish, // message.finish isLast, - hasPriorFinal, }) ``` -| recovering / aborted | `final()` | `isLast` | `finish === "tool-calls"` | `hasPriorFinal` | show | -| --- | --- | --- | --- | --- | --- | -| true | * | * | * | * | yes | -| false | true | * | * | * | yes | -| false | false | false | * | * | no | -| false | false | true | false | * | yes | -| false | false | true | true | false | yes (mid-loop live row) | -| false | false | true | true | true | no (trailing after a settled same-turn footer) | - -`hasPriorFinal` is true only for an earlier assistant message with the same `parentID` that is settled (`finish` set and ≠ `"tool-calls"`, or `MessageAbortedError`). A prior turn's settled row must not count. Duration still only renders when `final()` is true. +| 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 | -**Sticky follow.** When the user is in follow-bottom mode (`!_hasManualScroll`) and transcript height changes (tool complete, part row, footer mount), re-apply bottom after layout so the last row is never clipped. Use the same `_hasManualScroll` authority the agent-switch path already documents; never yank a user who scrolled away. Height-change snaps are coalesced so a burst of part updates yields one post-layout snap. +`isFinal` = `finish` set and ≠ `"tool-calls"`. Duration still only renders when `isFinal`. -**Out of scope.** Prompt-embedded mode/model row, sidebar status, pinning a footer overlay outside the scroll content, Orchestrator UX changes beyond the existing single-footer rule. +**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 -- Sticky behavior when the user has manually scrolled - Non-session scrollboxes (workflow page, dialogs) ## Tasks -- [x] T1: Extract `shouldShowAssistantFooter` and wire `AssistantMessage.showFooter` — acceptance: mid-loop last message with `finish=tool-calls` and no prior final shows the footer; trailing tool-calls after a settled same-turn footer does not (covers: S2) -- [x] T2: Re-apply sticky bottom after transcript height changes in follow mode — acceptance: tool process→complete growth keeps the footer row in the viewport; manual scroll is left alone (covers: S2) -- [x] T3: Regression tests for the footer predicate and follow-bottom reapply — acceptance: `bun test` packages/cli footer/sticky cases pass (covers: S2; depends: T1, T2) +- [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) From 3a6a95b30e19bf8805a46cd659f41947cfc75040 Mon Sep 17 00:00:00 2001 From: yanyihan Date: Mon, 19 Oct 2026 18:36:11 +0800 Subject: [PATCH 6/6] refactor(tui): share final-finish check and fix footer test wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract isFinalAssistantFinish so the footer predicate and the duration gate agree on what a settled turn step is. Rename the length/cancelled test to say "settled non-tool-calls finish" — length is final under that rule — and point it at the non-last path it actually exercises. Refresh the spec commit range. --- docs/compose/spec/tui-footer-sticky-last-line.md | 2 +- packages/cli/src/cli/cmd/tui/routes/session/index.tsx | 6 ++---- packages/cli/src/cli/cmd/tui/util/assistant-footer.ts | 9 +++++++-- packages/cli/test/cli/tui/assistant-footer.test.ts | 4 ++-- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/docs/compose/spec/tui-footer-sticky-last-line.md b/docs/compose/spec/tui-footer-sticky-last-line.md index 2f91de8fb..2d162ecc6 100644 --- a/docs/compose/spec/tui-footer-sticky-last-line.md +++ b/docs/compose/spec/tui-footer-sticky-last-line.md @@ -3,7 +3,7 @@ feature: tui-footer-sticky-last-line status: delivered updated: 2026-07-11 branch: fix/tui-footer-sticky-last-line -commits: 67fecf88..b12e3d7c +commits: 67fecf88..f6246ae4 --- # TUI footer last-line flicker 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 21ea892c8..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,7 +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 { shouldShowAssistantFooter } from "../../util/assistant-footer" +import { isFinalAssistantFinish, shouldShowAssistantFooter } from "../../util/assistant-footer" import { planSwitchTarget } from "./plan-switch" addDefaultParsers(parsers.parsers) @@ -1791,9 +1791,7 @@ 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" - }) + 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 diff --git a/packages/cli/src/cli/cmd/tui/util/assistant-footer.ts b/packages/cli/src/cli/cmd/tui/util/assistant-footer.ts index e74930407..e7c9c66e5 100644 --- a/packages/cli/src/cli/cmd/tui/util/assistant-footer.ts +++ b/packages/cli/src/cli/cmd/tui/util/assistant-footer.ts @@ -8,6 +8,12 @@ * 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 @@ -16,7 +22,6 @@ export function shouldShowAssistantFooter(input: { }): boolean { if (input.recovering) return true if (input.aborted) return true - const isFinal = Boolean(input.finish) && input.finish !== "tool-calls" - if (isFinal) 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 index 1bcee82dc..37793ce37 100644 --- a/packages/cli/test/cli/tui/assistant-footer.test.ts +++ b/packages/cli/test/cli/tui/assistant-footer.test.ts @@ -33,7 +33,7 @@ describe("shouldShowAssistantFooter", () => { expect(shouldShowAssistantFooter({ ...base, finish: "tool-calls", isLast: true })).toBe(true) }) - test("shows the last row for a non-tool-calls, non-final finish (length, cancelled, …)", () => { - expect(shouldShowAssistantFooter({ ...base, finish: "length", 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) }) })