Sticky prompt selection only considered the top edge of the viewport, so it could keep showing an older user prompt even when a newer one was already visible lower down. Suppress sticky output whenever a user message is visible in the viewport and cover it with a regression test.
Renderer-driven follow-to-bottom was restoring the viewport to the tail without notifying ScrollBox subscribers, so StickyPromptTracker could stay stale-visible. Notify on render-time scroll/sticky changes and treat near-bottom as bottom for prompt hiding.
When the viewport is away from the bottom, keep the last visible progress snapshot instead of rebuilding the streaming/thinking subtree on every turn-store update. This cuts scroll-time churn while preserving live updates near the tail and on turn completion.
Broaden the settle repaint from xterm.js-only to all alt-screen terminals. Ink upstream and ConPTY/xterm reports point to resize/reflow desync as a general stale-cell class, not a host-specific quirk.
Copilot flagged the variable as unused. LogUpdate.render only sees prev/next, so a simulated "physical terminal" has no hook in the public API. Kept the narrative in the comment and tightened the assertion to demonstrate the test's actual invariant: identical prev/next emits no heal patches.
Replace 28-line guard + nested queueMicrotask + pendingResizeRender flag-reuse with a named canAltScreenRepaint predicate and a single flat paint. setTimeout already drained the burst coalescer; the nested defer and flag dance were paranoia.
- run the xterm.js settle-heal pass through a full render commit instead of diff-only scheduleRender
- guard against overlapping resize renders and clear settle timers on unmount
- force full alt-screen damage in xterm.js hosts to avoid stale glyph artifacts
- skip incremental scroll optimization there and repaint from a cleared screen atomically
- appLayout.tsx: restore the 1-row placeholder when `showStickyPrompt`
is false. Dropping it saved a row but the composer height shifted by
one as the prompt appeared/disappeared, jumping the input vertically
on scroll.
- useInputHandlers: gateway.rpc (from useMainApp) already catches errors
with its own sys() message and resolves to null. The previous `.catch`
was dead code and on RPC failures the user saw both 'error: ...' (from
rpc) and 'failed to toggle yolo'. Drop the catch and gate 'failed to
toggle yolo' on a non-null response so null (= rpc already spoke)
stays silent.
- normalizeStatusBar: replace Set + early-returns + cast with a single
alias lookup table. Handles legacy `false`, trims/lowercases strings,
maps `on` → `top` in one pass. One expression, no `as` hacks.
- Tab title block: drop the narrative comment, fold
blockedOnInput/titleStatus/cwdTag/terminalTitle into inline expressions
inside useTerminalTitle. Avoids shadowing the outer `cwd`.
- tui_gateway statusbar set branch: read `display` once instead of
`cfg0.get("display")` twice.
Previously the terminal tab title was `{⏳/✓} {model} — Hermes` which
only distinguished busy vs idle. Users juggling multiple Hermes tabs had
no way to tell which one was waiting on them for approval/clarify/sudo/
secret, and no cue for which workspace the tab was attached to.
- 3-state marker: `⚠` when an overlay prompt is open, `⏳` busy, `✓` idle.
- Append `· {shortCwd}` (28-char budget, $HOME → ~) so the tab surfaces
the workspace directly.
- Drop the `— Hermes` suffix — the marker already signals what this is,
and tab titles are tight.
Copilot on #14145 flagged that the shift+tab yolo handler treated any
non-null RPC result as valid, so a response shape like {value: undefined}
or {value: 'weird'} would incorrectly echo 'yolo off'. Now only '1' and
'0' map to on/off; anything else (including missing value) surfaces as
'failed to toggle yolo', matching the null/catch branches.
- entry.tsx no longer writes bootBanner() to the main screen before the
alt-screen enters. The <Banner> renders inside the alt screen via the
seeded intro row, so nothing is lost — just the flash that preceded it.
Fixes the torn first frame reported on Alacritty (blitz row 5 #17) and
shaves the 'starting agent' hang perception (row 5 #1) since the UI
paints straight into the steady-state view
- AlternateScreen prefixes ERASE_SCROLLBACK (\x1b[3J) to its entry so
strict emulators start from a pristine grid; named constants replace
the inline sequences for clarity
- bootBanner.ts deleted — dead code
- normalizeStatusBar: trim/lowercase + 'on' → 'top' alias so user-edited
YAML variants (Top, " bottom ", on) coerce correctly
- shift-tab yolo: no-op with sys note when no live session; success-gated
echo and catch fallback so RPC failures don't report as 'yolo off'
- tui_gateway config.set/get statusbar: isinstance(display, dict) guards
mirroring the compact branch so a malformed display scalar in config.yaml
can't raise
Tests: +1 vitest for trim/case/on, +2 pytest for non-dict display survival.
- normalizeStatusBar collapses to one ternary expression
- /statusbar slash hoists the toggle value and flattens the branch tree
- shift-tab yolo comment reduced to one line
- cursorLayout/offsetFromPosition lose paragraph-length comments
- appLayout collapses the three {!overlay.agents && …} into one fragment
- StatusRule drops redundant flexShrink={0} (Yoga default)
- server.py uses a walrus + frozenset and trims the compat helper
Net -43 LoC. 237 vitest + 46 pytest green, layouts unchanged.
The heart was rendered as a literal space when inactive. Because it's
absolutely positioned at right:0 inside the composer row, that blank
still overpainted the rightmost input cell. On wrapped 2-line drafts,
editing near the boundary made the final visible character appear to
jump in/out as it crossed the overpainted column.
When inactive, render nothing; only mount the heart while it's actually
animating.
The 'columns' prop passed to TextInput was cols - pw, but the actual
render width is cols - pw - 2 (NoSelect's paddingX={1} on each side
subtracts two cols from the composer area). cursorLayout thought it
had two extra cols, so wrap-ansi wrapped at render col N while the
declared cursor sat at col N+2 on the same row. The render and the
declared cursor disagreed right at the wrap boundary — the last
letter of a sentence spanning two lines flickered in/out as each
keystroke flipped which cell the cursor claimed.
Also polish the /help hotkeys panel — the !cmd / {!cmd} placeholders
read as literal commands to type, so show them with angle-bracket
syntax and a concrete example (blitz row 5 sub-item 4).
Previous revision added marginTop={1} to the input which stacked as a
phantom gap BETWEEN status and input. The breathing row should sit
ABOVE the status-in-top cluster, not inside it.
- StatusRulePane at="top" now carries its own marginTop={1} so it
always has a one-row gap above (separating it from transcript or,
when queue is present, from the last queue item)
- Input Box marginTop flips: 0 in top mode (status is the separator),
1 in bottom/off mode (input itself caps the composer cluster)
- Net: status and input are tight together in 'top'; input and status
are tight together at the bottom in 'bottom'; one-row breathing room
above whichever element sits on top of the cluster
Three bugs rolled together, all in the composer area:
- StatusRule was measuring as 2 rows in Yoga due to a quirk with the
complex nested <Text wrap="truncate-end"> content. Lock the outer box
to height={1} so 'top' mode actually abuts the input instead of
leaving a phantom blank row between them
- FloatingOverlays (slash completions, /model picker, /resume, /skills
browser, pager) was anchored to the status box. In 'bottom' mode the
status box moved away, so overlays vanished. Move the overlays into
the input row (which is position:relative) so they always pop up
above the input regardless of status position
- Drop the <Text> </Text> fallback in the sticky-prompt slot (only
render a row when there's an actual sticky prompt to show) and
collapse the now-unused Box column wrapping the input. Saves two
rows of dead vertical space in the default layout
'top' and 'bottom' are positions relative to the input row, not the alt
screen viewport:
- top (default) → inline above the input, where the bar originally lived
(what 'on' used to mean)
- bottom → below the input, pinned to the last row
- off → hidden
Drops the literal top-of-screen placement; 'on' is kept as a backward-
compat alias that resolves to 'top' at both the config layer
(normalizeStatusBar, _coerce_statusbar) and the slash command.
Default is back to 'on' (inline, above the input) — bottom was too far
from the input and felt disconnected. Users who want it pinned can
opt in explicitly.
- UiState.statusBar: boolean → 'on' | 'off' | 'bottom' | 'top'
- /statusbar [on|off|bottom|top|toggle]; no-arg still binary-toggles
between off and on (preserves muscle memory)
- appLayout renders StatusRulePane in three slots (inline inside
ComposerPane for 'on', above transcript row for 'top', after
ComposerPane for 'bottom'); only the slot matching ui.statusBar
actually mounts
- drop the input's marginBottom when 'bottom' so the rule sits tight
against the input instead of floating a row below
- useConfigSync.normalizeStatusBar coerces legacy bool (true→on,
false→off) and unknown shapes to 'on' for forward-compat reads
- tui_gateway: split compact from statusbar config handlers; persist
string enum with _coerce_statusbar helper for legacy bool configs
- input wrap: add <Text wrap="wrap-char"> mode that drives wrap-ansi with
wordWrap:false, and align cursorLayout/offsetFromPosition to that same
boundary (w=cols, trailing-cell overflow). Word-wrap's whitespace
reshuffle was causing the cursor to jump a word left/right on each
keystroke near the right edge — blitz row 9
- shift-tab: toggle per-session yolo without submitting a turn (mirrors
Claude Code's in-place dangerously-approve); slash /yolo still works
for discoverability — blitz row 5 sub-item 11
- statusline: lift StatusRule out of ComposerPane to a new StatusRulePane
anchored at the bottom of AppLayout, below the input — blitz row 5
sub-item 12
Pull duplicated rules into ui-tui/src/lib/subagentTree so the live overlay,
disk snapshot label, and diff pane all speak one dialect:
- export fmtDuration(seconds) — was a private helper in subagentTree;
agentsOverlay's local secLabel/fmtDur/fmtElapsedLabel now wrap the same
core (with UI-only empty-string policy).
- export topLevelSubagents(items) — matches buildSubagentTree's orphan
semantics (no parent OR parent not in snapshot). Replaces three hand-
rolled copies across createGatewayEventHandler (disk label), agentsOverlay
DiffPane, and prior inline filters.
Also collapse agentsOverlay boilerplate:
- replace IIFE title + inner `delta` helper with straight expressions;
- introduce module-level diffMetricLine for replay-diff rows;
- tighten OverlayScrollbar (single thumbColor expression, vBar/thumbBody).
Adds unit coverage for the new exports (fmtDuration + topLevelSubagents).
No behaviour change; 221 tests pass.
- delegate_task: use shared tool_error() for the paused-spawn early return
so the error envelope matches the rest of the tool.
- Disk snapshot label: treat orphaned nodes (parentId missing from the
snapshot) as top-level, matching buildSubagentTree / summarizeLabel.
- List rows: pad the status dot with space before (heat-marker gap or
matching 2-space filler) and after (3 spaces to goal) so `●` / `○` /
`✓` / `■` / `✗` don't read glued to the heat bar or the goal text.
- Gantt rows: bump id→bar separator from 1 to 2 spaces; widen the id
gutter from 4 to 5 cols and re-align the ruler lead to match.
Four real issues Copilot flagged:
1. delegate_tool: `_build_child_agent` never passed `toolsets` to the
progress callback, so the event payload's `toolsets` field (wired
through every layer) was always empty and the overlay's toolsets
row never populated. Thread `child_toolsets` through.
2. event handler: the race-protection on subagent.spawn_requested /
subagent.start only preserved `completed`, so a late-arriving queued
event could clobber `failed` / `interrupted` too. Preserve any
terminal status (`completed | failed | interrupted`).
3. SpawnHud: comment claimed concurrency was approximated by "widest
level in the tree" but code used `totals.activeCount` (total across
all parents). `max_concurrent_children` is a per-parent cap, so
activeCount over-warns for multi-orchestrator runs. Switch to
`max(widthByDepth(tree))`; the label now reads `⚡W/cap+extra` where
W is the widest level (drives the ratio) and `+extra` is the rest.
4. spawn_tree.list: comment said "peek header without parsing full list"
but the code json.loads()'d every snapshot. Adds a per-session
`_index.jsonl` sidecar written on save; list() reads only the index
(with a full-scan fallback for pre-index sessions). O(1) per
snapshot now vs O(file-size).
- createGatewayEventHandler: remove dead `return` after a block that
always returns (tool.complete case). The inner block exits via
both branches so the outer statement was never reachable. Was
pre-existing on main; fixed here because it was the only thing
blocking `npm run fix` on this branch.
- agentsOverlay + ops: prettier reformatting.
`npm run fix` / `npm run type-check` / `npm test` all clean.
The Write tool that wrote the cleaned overlay split the `if` keyword
across two lines in 9 places (` i\nf (cond) {`), which silently
passed one typecheck run but actually left the handler as broken
JS — every keystroke threw. Input froze in the /agents overlay
(j/k/arrows/q/etc. all no-ops) while the 500ms now-tick kept
rendering, so the UI looked "frozen but the timeline moves".
Reflows the handler as-intended with no behaviour change.
Adds a live + post-hoc audit surface for recursive delegate_task fan-out.
None of cc/oc/oclaw tackle nested subagent trees inside an Ink overlay;
this ships a view-switched dashboard that handles arbitrary depth + width.
Python
- delegate_tool: every subagent event now carries subagent_id, parent_id,
depth, model, tool_count; subagent.complete also ships input/output/
reasoning tokens, cost, api_calls, files_read/files_written, and a
tail of tool-call outputs
- delegate_tool: new subagent.spawn_requested event + _active_subagents
registry so the overlay can kill a branch by id and pause new spawns
- tui_gateway: new RPCs delegation.status, delegation.pause,
subagent.interrupt, spawn_tree.save/list/load (disk under
\$HERMES_HOME/spawn-trees/<session>/<ts>.json)
TUI
- /agents overlay: full-width list mode (gantt strip + row picker) and
Enter-to-drill full-width scrollable detail mode; inverse+amber
selection, heat-coloured branch markers, wall-clock gantt with tick
ruler, per-branch rollups
- Detail pane: collapsible accordions (Budget, Files, Tool calls, Output,
Progress, Summary); open-state persists across agents + mode switches
via a shared atom
- /replay [N|last|list|load <path>] for in-memory + disk history;
/replay-diff <a> <b> for side-by-side tree comparison
- Status-bar SpawnHud warns as depth/concurrency approaches caps;
overlay auto-follows the just-finished turn onto history[1]
- Theme: bump DARK dim #B8860B → #CC9B1F for readable secondary text
globally; keep LIGHT untouched
Tests: +29 new subagentTree unit tests; 215/215 passing.
Restore the old-CLI contract where only complete failures tint Activity
red. Everything else is still visible for debugging but no longer
commandeers attention.
- gateway.stderr: always tone='info' (drops the ERRLIKE_RE regex)
- gateway.protocol_error: both pushes demoted to 'info'
- commands.catalog cold-start failure: demoted to 'info'
- approval.request: no longer duplicates the overlay into Activity
Kept as 'error': terminal `error` event, gateway.start_timeout,
gateway-exited, explicit status.update kinds.
Reverts the auto-expand-on-new-error effect added in 93b47d96. The
effect overrode the user's chosen detailsMode and visually interrupted
every turn. Red/yellow chevron tint remains as the passive signal —
click to read, just like Thinking and Tool calls.
After the prior inline-diff fix, the gateway still prepends a literal
" ┊ review diff" line to inline_diff (it's terminal chrome written by
`_emit_inline_diff`). Wrapping that in a ```diff fence left that header
inside the code block. The agent also often narrates its own edit in a
second fenced diff, so the assistant message ended up stacking two
diff blocks for the same change.
- Strip the leading "┊ review diff" header from queued inline diffs
before fencing.
- Skip appending the fenced diff entirely when the assistant already
wrote its own ```diff (or ```patch) fence.
Keeps the single-surface diff UX even when the agent is chatty.
When tool.complete already carries inline_diff, the assistant message owns the full diff block. Suppress the tool-row summary/detail in that case so the turn shows one detailed diff surface instead of a rich diff plus a duplicated tool-detail payload.
Avoid duplicate diff rendering in #13729 flow. We now skip queued inline diffs that are already present in final assistant text and dedupe repeated queued diffs by exact content.
Follow-up for #13729: segment-level system artifacts still looked detached in real flow.\n\nInstead of appending inline_diff as a standalone segment/system row, queue sanitized diffs during tool.complete and append them as a fenced diff block to the assistant completion text on message.complete. This keeps the diff in the same message flow as the assistant response.
Follow-up on multiline arrow behavior: Up/Down now fall back to queue/history whenever there is no logical line above/below the caret (not only at absolute start/end character positions). This makes Up from the end of the top line cycle history, matching expected readline-ish behavior.
Follow-up on #13729 from blitz screenshot feedback.\n\n- When tool.complete carried inline_diff but no buffered assistant text existed, pending tool rows were still in streamPendingTools, so diff rendered above the tool row section. appendSegmentMessage now emits pending tool rows as a trail segment before appending the diff artifact.\n- Strip ANSI color escapes from inline_diff payloads so we don't render loud red/green terminal palettes in the transcript.
Follow-up on #13726 from blitz feedback: Up/Down history cycling should only trigger when the caret is at the start/end boundary (or the input is empty).\n\nPreviously useInputHandlers intercepted arrows whenever inputBuf was empty, which still stole Up/Down from normal multiline editing. textInput now publishes caret position through inputSelectionStore even with no active selection, and useInputHandlers gates history/queue cycling on those boundaries.
Reported during TUI v2 blitz retest: code-review diffs from tool.complete
appeared at the top of the current interaction thread, out of sequence
with the agent's messages and tool rows below them.
Root cause — `sys(inline_diff)` appends to `historyItems`, which sits
above the `StreamingAssistant` pane that renders the active turn.
Until the turn closed, the diff visually floated above everything
else happening in the same turn.
Route the diff through `turnController.appendSegmentMessage` instead
so it flushes any pending streaming text first, then lands in the
segment stream beside assistant output and tool calls. On
`message.complete` the segment list is committed to history in emit
order (diff → final text), matching what the gateway sent.
Adds a regression test that exercises tool.complete → message.complete
with an inline_diff payload and asserts both the streaming and final
placement.