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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions docs/compose/spec/tui-footer-sticky-last-line.md
Original file line number Diff line number Diff line change
@@ -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 `<Match when={showFooter()}>`.
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)
36 changes: 16 additions & 20 deletions packages/cli/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions packages/cli/src/cli/cmd/tui/util/assistant-footer.ts
Original file line number Diff line number Diff line change
@@ -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
}
39 changes: 39 additions & 0 deletions packages/cli/test/cli/tui/assistant-footer.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading