Skip to content

Add viewport planning and census tracking for terminal run display - #866

Merged
sroussey merged 5 commits into
mainfrom
claude/cli-status-bar-improvements-wuldmp
Aug 25, 2026
Merged

Add viewport planning and census tracking for terminal run display#866
sroussey merged 5 commits into
mainfrom
claude/cli-status-bar-improvements-wuldmp

Conversation

@sroussey

Copy link
Copy Markdown
Collaborator

Summary

Implements a viewport planning system and task census tracking to intelligently fit run task trees into terminal windows. The run footer now reports accurate task counts across the entire tree (including owned subgraphs and Map/Reduce iterations), and the display adapts dynamically to terminal size constraints by selectively hiding the deepest tree levels when space is limited.

Key Changes

  • Census tracking (useRunCensus.ts, runCensus.ts, runCensus.test.ts): Walks the live task tree to count all tasks (not just top-level), maintains a ledger of every task ever seen (so totals don't fall as work completes), and distinguishes between countable tasks and structural grouping nodes (Map iterations).

  • Viewport planning (runViewport.ts, runViewport.test.ts): Computes how many siblings each list can show to fit the terminal budget. Uses a depth-first shrinking strategy—innermost lists lose rows first—so ancestor context is preserved when space runs out. Lists never shrink below a minimum, and the live region itself never shrinks on its own (only when the terminal resizes).

  • Scroll region (ScrollRegion.tsx): A live region that grows to fit content, holds its height as rows leave (preventing footer movement), and shows a one-column gutter when overflowing. Measures content periodically and respects a budget from the caller.

  • Run status bar updates (RunStatusBar.tsx, runRowModel.ts): Now displays accurate task counts from the census, reports hidden row count when viewport planning truncates the tree, and shows elapsed time. Adds runAggregateProgress() to handle indeterminate progress bars correctly (zero is only a measurement once something has been measured or a task has settled).

  • Integration (WorkflowRunApp.tsx, TaskRunApp.tsx): Both now use the census and viewport planning. The run computes a plan once per frame and publishes it via context so all lists read their own cap. Window resize triggers a full repaint from a clean screen.

  • Supporting utilities: useRunClock.ts (wall-clock for the run), useRepaintOnResize.ts (screen clear on resize), RunViewport.tsx (context provider for the plan), AggregateProgressRow.tsx (progress bar for non-task aggregates).

  • Test coverage: Comprehensive tests for viewport planning (fitting trees to budgets, depth-first shrinking), census ledger (counting owned subgraphs, handling Map iterations), and scroll region behavior (sticky height, gutter rendering, window resize).

Notable Implementation Details

  • The census ledger uses a key cap (MAX_LEDGER_KEYS = 20,000) to prevent unbounded memory growth on massive Maps.
  • Ownership wrapper tasks (scaffolding around owned workflows) are detected and excluded from the total—they stay PENDING while their children work.
  • Progress bars adopt polled values only once a task has reported progress or settled, avoiding false "0% stuck" claims on tasks that report nothing.
  • The run's own progress bar is indeterminate until something has been measured, matching the spinner-based "extent unknown" claim.
  • Iteration grouping nodes (Map iteration brackets) are structural, not countable, so the total reflects real work, not grouping overhead.

https://claude.ai/code/session_015vyFdNcj3fSn3tmWpLgDLa

claude added 5 commits August 25, 2026 02:23
The run footer reported on the graph's top level, which for anything that
owns a workflow, regenerates a subgraph or maps over a worklist is the
least interesting part of the run: "1 / 3 tasks" while several hundred
rows were in flight.

`ui/model/runCensus` walks the tree the rows actually draw — owned
subgraphs, live Map clones — into a ledger that only ever grows, so a
retiring iteration cannot walk the total backwards. A node still PENDING
while its own children run is an ownership wrapper, not a task that can
never land: it is counted as scaffolding, and `ownershipWrapperStatus`
stops it drawing a hollow circle beside a subtree that has plainly
finished.

The footer gains a wall-clock, hard right on its own so a number that
changes every second stops shoving the counts and the spend sideways, and
loses the duration that used to ride inside the token line.

The live region no longer tracks its content's height in both directions.
It grows as rows arrive and then holds, because a block that shrinks drags
the footer up the screen mid-run. `ui/model/runViewport` prices the tree
against the window and shrinks the deepest list first, so a Map's own row
survives to explain the detail beneath it; what still overflows is
tail-pinned behind a one-column gutter, which costs no rows at exactly the
moment rows ran out. Ink's window size drives the budget, so a resize
re-prices on the next frame.

The web console takes the same footer model, over the rows it already
tracks at every depth.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vyFdNcj3fSn3tmWpLgDLa
…indow

The run's own progress row spaced its three children evenly instead of
growing the label, so on a wide terminal its bar sat in the middle of the
window rather than at the head of the column every task bar below it
lines up in. Both aggregate rows — the run's and a subgraph's — now share
one `AggregateProgressRow` laid out in the same three columns
`TaskStatusProgressRow` uses, so they cannot drift apart again.

That bar reports on the whole run, so it is drawn from a second grey:
`strong`, mixed halfway between the terminal's own foreground and
background against `medium`'s quarter. Stated as a distance from the page,
it comes out brighter on a dark terminal and darker on a light one with
neither case special-cased, and `cliPaletteFromRgb` is now where both are
derived and tested.

Narrowing the window stranded a copy of the frame above the run, once per
resize. A live region is erased by walking the cursor up as many lines as
were written, and the terminal has already reflowed each of those lines
into two rows by the time the walk runs, so it covers half the frame.
`useRepaintOnResize` repaints from an erased screen on a width change, and
leaves scrollback — the operator's record of the run — alone. Height
changes reflow nothing and are left as they were.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vyFdNcj3fSn3tmWpLgDLa
A task that reports no progress of its own drew a determinate bar at zero
for the whole of its run, above a subtree visibly moving. It was not
propagation: `Task.progress` initialises to `0` and `TaskRunner` re-stamps
`0` the moment a task starts, and the terminal's poll of the live instance
took that stamp as a reading.

Neither stamp is announced and neither is a measurement — the graph needs
a number in the denominator of its average and zero is the honest one to
put there, which is why the field cannot simply become `undefined`.
`adoptPolledProgress` draws the line at the row instead: a polled zero
counts only once the task has actually reported something, and until then
the row draws no bar and the spinner carries "working, extent unknown",
which is the claim that column already exists to make. A status event into
PROCESSING clears the row's figure, so a reused instance does not carry
the last job's percentage into the next one.

`graph_progress` averages those same unreported zeroes, so the run's own
bar inherited the lie and sat at a measured-looking 0% until something
completed. `runAggregateProgress` holds it indeterminate until a task has
reported or landed. The web console takes the same rule; its rows are
event-sourced and never saw the stamp, but its Workflow line did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vyFdNcj3fSn3tmWpLgDLa
…file

Crediting an evicted path turned on it having been seen *running*. The
walk is a 250ms poll and a short task inside a Map clone is routinely
caught once as PENDING and then never again, so one key per such iteration
was never credited and `done` stopped short of `total` by however many the
poll happened to miss. It now turns on *not settled*.

`countHidden` and the shrink loop's victim search walked every list in the
tree, including those hanging off nodes the plan had already capped away.
Those lists are not on screen: their siblings are not rows anyone is
missing, and shrinking one frees nothing. The footer reported 78 hidden
for a plan whose drawn rows hid 42, and the search spent its step budget
grinding a list nobody draws from six rows down to one. Both now walk the
lists the plan actually draws.

The census ledger outlived its subject. A second run in one process
started with the first run's paths in it, credited them COMPLETED as they
failed to reappear, and seeded the footer with a total the new run could
never meet.

`probe2.tsx` was a scratch script `git add -A` swept into the previous
commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vyFdNcj3fSn3tmWpLgDLa
…ng one

Ink decides whether to draw frames as it goes from `!isInCi && stdout.isTTY`,
so under a CI runner it resolved to non-interactive: erase sequences and
resize handling off, and only the final frame written, at unmount. Every
assertion in the viewport tests reads a frame mid-run, so they found an
empty screen on CI and passed on a developer's machine — green locally,
red on the PR, five failures pointing at product code that was fine.

The behaviour under test is what a terminal shows, so the tests now say so
with `interactive: true` instead of leaving it to the environment.
Reproduced the failure with `CI=true` first and confirmed the whole suite
passes both ways after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vyFdNcj3fSn3tmWpLgDLa
@github-actions

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 17.86% 7716 / 43194
🔵 Statements 17.92% 8093 / 45142
🔵 Functions 19.38% 1603 / 8269
🔵 Branches 9.85% 2740 / 27814
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
examples/cli/src/terminal/detectTerminalTheme.ts 9.21% 0% 21.05% 8.69% 30-34, 58-181
examples/cli/src/ui/TaskRunApp.tsx 4.81% 0% 0% 5.71% 57-69, 89-238
examples/cli/src/ui/WorkflowRunApp.tsx 92.5% 75% 83.33% 100% 82, 144, 153
examples/cli/src/ui/cliTaskUi.ts 65.38% 68.42% 57.14% 75% 37-42, 58
examples/cli/src/ui/taskGraphCliSubscriptions.ts 93.75% 80.39% 93.75% 97.95% 88, 96, 105, 197, 224, 264, 266, 306, 315, 346-347, 372, 415
examples/cli/src/ui/useGraphUsageLine.ts 97.29% 81.81% 100% 96.87% 52
examples/cli/src/ui/useRepaintOnResize.ts 100% 100% 100% 100%
examples/cli/src/ui/useRunCensus.ts 84.48% 63.63% 88.46% 91.66% 88, 100, 102, 126, 138, 171, 184, 188, 194, 209-225, 273, 306-307
examples/cli/src/ui/useRunClock.ts 100% 75% 100% 100%
examples/cli/src/ui/components/AggregateProgressRow.tsx 100% 66.66% 100% 100%
examples/cli/src/ui/components/ProgressBar.tsx 100% 70% 100% 100%
examples/cli/src/ui/components/RunStatusBar.tsx 100% 57.89% 100% 100%
examples/cli/src/ui/components/RunViewport.tsx 100% 100% 100% 100%
examples/cli/src/ui/components/ScrollRegion.tsx 96.15% 85.71% 100% 100% 54
examples/cli/src/ui/model/runCensus.ts 100% 97.61% 100% 100%
examples/cli/src/ui/model/runRowModel.ts 84.55% 80.55% 89.47% 82.85% 44-68, 80-82, 245-254, 269
examples/cli/src/ui/model/runViewport.ts 96.46% 79.31% 100% 100% 117, 133, 168, 239
examples/cli/src/ui/rows/DefaultTaskRow.tsx 100% 100% 100% 100%
examples/cli/src/ui/rows/SubtaskRows.tsx 87.17% 75.67% 90.9% 90.9% 47, 49, 99, 154-162, 275-279
examples/cli/src/ui/rows/pickRenderer.ts 57.14% 40% 100% 80% 25, 27, 29-30, 57, 58
Generated in workflow #3295 for commit e752594 by the Vitest Coverage Report Action

@sroussey
sroussey merged commit bc21127 into main Aug 25, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants