Salvage bounded row projection and status glyphs from PR #70899; hydrate the existing tree from session-scoped snapshots and open with Ctrl+T without disturbing drafts.
Co-authored-by: joaomarcos <joaomarcosdias444@gmail.com>
Since #90674 hermes-ink restores the shifted letter's case for CSI-u and
modifyOtherKeys input, so Cmd+Shift+Z reaches the composer as inp 'Z' with
key.shift set. The redo branch compared inp === 'z' and missed, falling
through to the printable path and inserting a literal "Z". The legacy
raw-byte path (ESC Z) already delivered 'Z', so the binding was latently
case-sensitive on both paths.
Compare with inp.toLowerCase(), matching the copy/paste chords a few lines
above. Linux Ctrl+Shift+Z is unaffected (ctrl chords keep the lowercase
key name).
The test drives the real TextInput through renderSync with kitty CSI-u
bytes (super+z, then super+shift+z) and asserts the redo lands and no "Z"
is inserted; it fails on the unfixed tree with "aZ".
Two tests still encoded the pre-PR behaviour that the PR removes:
- tests/test_tui_gateway_server.py: the mirrored fixture carries no
`context_estimated` flag, so under the PR's rule it is provider-reported
usage and must render without `~`. The old expectation asserted the
unconditional tilde main used to emit. Assert the flag-less case is
unmarked and, in the same test, flip `context_estimated` both ways to
pin that only the estimate carries `~` in the count and the percent.
- ui-tui appChromeStatusRule.test.tsx: `text.includes('~')` matched the
`~/repo` cwd label, so the "not estimated" arm was always true. Extract
the rendered context token and assert the tilde on that token only.
- display.bell_on_approval (default false): same BEL mechanism as
bell_on_complete, rings when a dangerous-command approval prompt
opens (_approval_callback / approval.request event). Complements
bell_on_clarify from the previous commit.
- fix(ui-tui): eslint curly error in useConfigSync.applyDisplay
(if without braces) that failed the CI JS & TS checks job.
Same BEL mechanism as display.bell_on_complete (\a / \x07), gated by
display.bell_on_clarify (default false). CLI rings in _clarify_callback
and _clarify_callback_batch before _paint_now(); TUI rings on
clarify.request when bellOnClarify && stdout.isTTY. Docs in
cli-config.yaml.example and website/docs/user-guide/configuration.md.
Concurrent or nested delegation batches (a parent's 9-way fan-out plus a
child's own 3-way fan-out) printed interleaved `✓ [3/3]` / `✓ [3/9]` lines
with nothing identifying which batch each belongs to.
- CLI: batch header `🔀 [6a66] delegating 9 tasks`; completion lines and
child tree-view lines become `[6a66 3/9]`; spinner remaining-count tagged.
- Relay: `delegation_id` rides on every `subagent.*` event (TUI gateway
payload, api_server SSE subagent.start/complete).
- TUI: `[6a66 3/9]` prefix on /agents rows; Desktop Agents pane groups
workers by exact delegation_id (heuristic shape/time grouping kept for
older backends) and shows the tag on the group header.
- Tag = last 4 hex of the deleg_xxxxxxxx id (format_batch_tag), same id
returned by the dispatch and used for cache/delegation/live/<id>/.
`/pr-triage [[ … [412 lines] … ]]` dispatched the LABEL: the paste
expansion was computed but only consumed by /queue, so every other
command received "[412 lines]" as its argument and the agent
faithfully reported the paste as truncated.
prepareSlashSubmission names the split the branch actually needs — the
transcript keeps the collapsed label, the dispatch carries the full
text. Image tokens stay as labels, since the gateway already holds
those files in attached_images.
parseSlashCommand split the whole line on `\s+` and rejoined with a
single space, so `/pr-triage <pasted diff>` reached the skill as one
run-on line. Only the separator between the command name and its
argument belongs to the parser; everything after it is the user text
and now survives verbatim.
Every consumer already re-splits the arg it receives, so subcommand
parsing (`/cron add`, `/model x --global`) is unchanged.
Pin gateway re-tagging of idle/preflight lifecycle lines as compacting,
and assert the TUI keeps that status until compacted rather than
restoring the busy bar after 4s.
Idle and preflight compaction arrived as lifecycle status without the
"Compacting context" marker, so TUI never entered a compacting state.
Re-tag those lines and freeze the busy FaceTicker on "compacting" for
the whole pause instead of restoring "running…" after 4s.
Extends PR #98250's classic-CLI status-bar upgrades to the Ink TUI:
- tui_gateway/server.py _get_usage() now emits cache_hit_pct,
avg_latency_s, avg_tps (reads the same per-call deque history from
agent/conversation_loop.py; keys omitted when no data — Codex
app-server has no latency, zero cache reads show no %)
- StatusRule renders the three read-outs as width-budgeted tail
segments (breakpoints 96/104/110 cols, lowest priority — they shed
first on narrow terminals)
- display.status_bar.fields (the SAME key the classic CLI honors)
filters TUI segments too: cache_hit, latency, tps, duration,
compressions, bg_tasks, bg_subagents, voice, battery, title,
context_pct, context_detail
- values ride the existing usage payload/ticker; constants between
events so the usage==last dedup keeps suppressing repaints
- 3 new server tests, 5 new TUI tests; full ui-tui suite 1727 green
CLI, ACP, and the desktop app all got nested-subtask rendering (the
optional `parent` field on a todo item), but the TUI never did. Its
TodoItem type had no `parent` field, parseTodos() in turnController.ts
dropped it even if the tool payload sent it, and TodoPanel rendered the
list with a flat map() and a single fixed indent — a session using
nested subtasks showed every subtask at the same visual level as its
parent, with no hierarchy cue, in the terminal UI.
- types.ts: add the optional `parent` field to TodoItem, matching
apps/desktop/src/lib/todos.ts's TodoItem exactly.
- turnController.ts: parseTodos() now preserves parent (trimmed,
dropped if empty or self-referential), the same normalization
desktop's parseArray() applies.
- lib/todo.ts: port todoTree() from apps/desktop/src/lib/todos.ts
verbatim — same DFS-with-depth algorithm, same dangling/cycle
handling, so both surfaces render identical hierarchy from the same
`parent` field.
- todoPanel.tsx: render todoTree(todos) instead of a flat map(), with
per-row indentation scaled by depth (capped at 4 levels, mirroring
desktop's status-row.tsx cap).
/bg (formerly /background, which is retired) keeps the existing semantics:
spawn a fresh, independent agent session in the background.
/btw is now its own command matching the convention other harnesses use:
ask a quick side question ABOUT the current conversation without
interrupting it. A one-shot auxiliary LLM call (main model by default,
overridable via auxiliary.side_question.* in config.yaml) answers from a
read-only transcript snapshot — the live session's history, role
alternation, and prompt cache are untouched, and the current turn keeps
running.
Surfaces wired: CLI (inline mid-run dispatch), gateway (all messengers,
busy-dispatch table + idle dispatch, i18n across all 17 locales), TUI
(prompt.btw RPC + btw.complete event), Discord native slash, relay
command manifest, desktop exec routing, docs (EN + zh-Hans).
On a real TTY, `hermes chat -q "…"` (and `--tui -q`) now starts a normal
interactive session with the prompt submitted literally as the first turn —
no slash-command routing, no '!' shell dispatch, no $(...) interpolation,
no file-drop rewriting — matching how other coding agents handle seeded
launches (Omarchy prompted agent terminals, basecamp/omarchy#8705).
Legacy answer-and-exit is preserved everywhere automation depends on it:
- new `hermes chat --oneshot` flag (distinct dest from top-level -z)
- -Q/--quiet machine-readable contract
- any non-TTY stdio (kanban workers, cron, pipes, A2A)
- top-level `hermes -z` unchanged
CLI: seeded prompt rides a _SeededQueryMessage sentinel through
process_loop, which skips the slash/!/file-drop dispatchers for that one
message. TUI: STARTUP_QUERY submits via a new literal path (submitLiteral)
that bypasses dispatchSubmission and the input.detect_drop rewrite.
the client half of the gateway.ping heartbeat contract (#89958); detects a silently-dropped socket via missed ping-acks and reconnects with bounded backoff; part of the #83166 recovery series.
The OpenCode Zen wire slug for the Ox Alpha stealth model is opaque
(x-preview-f-free); users searching the picker for 'ox' or 'ox-alpha'
found nothing. Adds the search alias across all four synced alias
tables (CLI, desktop, web, TUI) plus tests. Wire id is unchanged and
still what renders and gets sent to the provider, matching the k3 →
kimi-k3 precedent. No canonical-dedup collision with opencode-go's
keyed ox-alpha-free slug.
The placeholder hint and its synthetic cursor chip hand-rolled truecolor
escapes ([38;2;r;g;b / [48;2;r;g;b]) and wrote them raw past Ink's depth
layer. Legacy Terminal.app has no truecolor parser — it walks compound
params one by one, so the literal 2 in 38;2;… lands as SGR 2: dim ON,
with no 22m ever emitted. Every frame that painted the placeholder left
the terminal's dim attribute stuck, and subsequent cells rendered dimmed
until an unrelated bold span's 22m happened to clear it — text randomly
flipping dim and back, worst right after the composer empties.
Measured on a live resumed session (PTY capture, params interpreted the
legacy way): 1026 glyphs painted with stuck dim on main, 0 with the fix.
Route both helpers through Ink's own colorize, the same repair colorizeEcho
got for the fast-echo path (gray-accent bug) — the escape now downgrades
with the terminal's real color depth, and a 256-color terminal gets 38;5;N
it can actually parse.
Also harden hermes-ink's transitionAnsiCodes for compound SGRs: real tool
output ships [1;31m-style sequences whose endCode is [0m, dodging the
endCode-based weight detection — parse the params instead (skipping 38/48
extended-color arguments) so a compound bold→dim transition passes through
SGR 22 too.
Tab or Shift-Tab onto an answered question now restores its state, the
same model as the CLI panel. A choice answer puts the cursor back on
its row. An answer that matches no choice was typed via Other, so the
cursor lands on the Other row with the text staged in the input —
Enter then edits the earlier text instead of starting blank. The
restore logic lives in a pure helper (clarifyBatchRevisitState) with
direct tests, because the prompt component has no keystroke harness.
The batch prompt had a separate browse mode: Tab toggled between the
question list and the expanded question, and the arrows walked the
list. Now Tab moves to the next question and Shift-Tab moves to the
previous one, with wrap, and the active question is always the
expanded one — the same model as the CLI panel.
A locked answer now renders on its own indented line in the ok color
under its question, instead of an arrow suffix on the status line, so
the answers stay readable while Tab walks the list. A skipped (empty)
answer renders muted and italic.
Batch clarify renders as a status list — every question on its own
line (✓ answered / ▸ current / · pending) with only the active
question's choices expanded, so a 5-question batch stays a few rows
tall. Enter locks the active question's answer (clarify.respond with
question_id) and the cursor jumps to the next unanswered question;
Tab walks the question list to answer in any order; the hint reads
'confirm and continue' when one question remains. Esc cancels the
whole batch.
Answered rows collapse to '✓ question → answer'. The abandoned-prompt
transcript record keeps locked partials (they survive a server-side
timeout), and reconnect replay seeds them back into the overlay.