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>/.
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).
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.
The composer renders one flat string, so a reference only became visible
after sending. It now wears the theme accent live, through both the cursor
and selection renderers; a masked input is a password and never highlights.
Two things the fast-echo bypass needed. It writes only the new cells, so a
keystroke that RECOLORS existing ones — `]` closing a token, a second `/`
demoting `/usr` to a path — has to take the Ink path instead. And its own
escape went through Ink's colorize rather than a hand-rolled truecolor
sequence: `38;2;` is unparseable on a 256-color terminal, where the accent
fell back to the default foreground and read gray.
A sent message accented a `/skill` named mid-prose and nothing else, so an
`@file:` ref and an `[[ Image 1 ]]` token flattened into body text. The
composer painted none of it.
splitComposerHighlights covers the whole vocabulary the desktop chips —
`/work` invoked or referenced, every `@ref` shape including quoted values,
and attachment/paste tokens — and both surfaces read it, so what you type
is what you see once it lands. Supersedes splitSlashSkillRefs.
* fix(tui): send atomic CSI u for modified Enter in IDE terminals
VS Code/Cursor/Windsurf terminals bound Shift/Ctrl/Cmd+Enter to the
legacy \\r\n sequence, which Ink's parse-keypress split into a
backslash keypress plus a plain Return — inserting a stray backslash and
submitting instead of adding a newline. Emit Kitty CSI u sequences that
encode the modifier atomically, and migrate keybindings users already
have on disk.
Co-authored-by: yatesjalex <yatesjalex@users.noreply.github.com>
* fix(tui): treat a bare LF as a newline in macOS composer terminals
Terminals that can't send a distinct Shift+Enter collapse a modified
Enter / Ctrl+J down to a bare LF. shouldPreserveCtrlJNewline() already
handles the env-detectable cases (SSH, Windows Terminal, Ghostty, WSL),
but plain macOS terminals (Terminal.app, iTerm2 defaults) do the same and
aren't env-detectable, leaving no keyboard-driven newline there. Fold the
return-key decision into shouldInsertNewlineOnReturn() and accept a bare
LF as a multiline fallback on macOS too, keeping CR as submit everywhere.
Co-authored-by: LeonSGP43 <LeonSGP43@users.noreply.github.com>
---------
Co-authored-by: yatesjalex <yatesjalex@users.noreply.github.com>
Co-authored-by: LeonSGP43 <LeonSGP43@users.noreply.github.com>
The dashboard maps Ctrl+Delete to ESC d for delete-word-forward, but the
composer had no binding for it: hermes-ink decodes ESC d as meta+'d', which
fell through to the printable path and typed a literal "d" instead of
deleting the next word. Add a meta+d branch that mirrors the existing
Ctrl+W delete-word-backward, sharing a deleteWordForward helper with the
Delete+word path.
The new cut() wrote the clipboard fire-and-forget and removed the selected
text immediately, so on a headless/SSH box with no clipboard backend the
write fails and the text is lost with no copy to paste back. Make cut
transactional via cutSelection(): await the write and only remove the
selection when it succeeds; on failure the selection stays intact. The
removal also re-checks the selection to avoid slicing with stale offsets
after the awaited write.
One config key everywhere (#41531): the same display.timestamps that stamps
[HH:MM] on classic-CLI labels now gates the desktop transcript's timeline
timestamps and renders dim [HH:MM] labels on TUI user/assistant rows.
- desktop: $displayTimestamps store fed from config.yaml via
use-hermes-config; TimelineTimestamp renders nothing while the key is off
(the default). Hover tooltips with the exact time stay ungated (#70450).
- TUI: tui_gateway forwards each persisted row's timestamp in the display
projection; toTranscriptMessages threads it as Msg.createdAt; live rows
are stamped at append (the #82840 rule); MessageLine shows a dim [HH:MM]
above user/assistant rows when display.timestamps is on.
- No new config keys, no HERMES_* env vars; display-only, prompt-cache safe.
Under display.sections.thinking: collapsed, the TUI now keeps the LIVE
reasoning panel open while reasoning streams and collapses it the moment
the reasoning phase ends (first tool call, final answer, or new turn).
Previously 'collapsed' meant the panel was always collapsed — including
the currently-streaming reasoning — and there was no way to get
'expanded while live, collapsed when done'. This makes 'collapsed' an
auto preference:
- turnController tags the open reasoning segment isLiveReasoning and
seals the tag in endReasoningPhase/closeReasoningSegment
- streamingAssistant passes reasoningActive only to the live segment,
so sealed reasoning segments from earlier phases stay collapsed
- ToolTrail auto-opens while reasoningActive under collapsed mode;
expanded/hidden/MoA-reference semantics are unchanged
Adds thinkingLiveCollapse.test.tsx covering open-on-stream, close-on-
finish (including mid-turn rerender), and the expanded-mode no-op.
(cherry picked from commit 6ef4ef77d3a0837c8ef5c5dac3de74f352bd637d)
Third-party Vietnamese IMEs (OpenKey/Unikey/EVKey in Telex mode) recompose
a syllable by emitting an erase burst followed by the finished characters.
Two layers of the TUI input pipeline mishandled this, dropping letters and
leaving a stray space mid-syllable (e.g. "hạnh" rendered as "hạ ", and
"vương sỹ hạnh" as "vương sỹ hạ ").
Root causes, both confirmed from real captured byte streams:
1. parse-keypress: an IME often fuses a control byte (\x7f/\b, or even the
U+202F marker OpenKey injects) with the recomposed text in a single stdin
read. parseKeypress only recognizes a control key when the whole string is
exactly that byte, so a mixed chunk fell through every branch, returned
name:"" with a non-printable sequence, and the composer's printable gate
discarded the entire chunk — taking the surrounding letters with it. Split
text tokens on every control byte so the printable runs survive.
CR/LF are deliberately not split, preserving paste/return semantics.
2. textInput: multi-character (IME/paste) inserts were committed through the
16ms deferred key-burst path, which raced an interleaved re-render and
snapped the buffer back to a stale value, dropping the recomposed tail.
Commit them synchronously. Additionally, the fast-echo "\b \b" backspace
shortcut desynced the screen when it ran right after an Ink repaint (forced
by the U+202F marker), stranding the marker glyph; suppress fast-echo for
the recompose burst that follows an Ink repaint and resume it on the next
real keystroke.
Tested with real OpenKey and EVKey captures of "vương sỹ hạnh" across read
timings, plus parser unit coverage and an EVKey no-regression guard.
Preserve printable IME commit text when xterm delivers it in the same input burst as Return, so Dashboard/TUI submits the visible draft instead of dropping the final segment.
Also fixes the TUI type-check stdio tuple typing and adds focused regression coverage.
Fixes#82465.
The session-name chip at the right end of the TUI status bar rendered
white/near-white text (t.color.statusFg) on a raw, full-saturation
accent-hue background (t.color.accent, #FFBF00 -- bright yellow -- in
DARK_SEEDS). Two token problems stacked: accent is the accent
IDENTITY hue, never used elsewhere as a solid fill (fills are always
softened, e.g. activeRow = mix(surface, accent, 0.22)); and statusFg
is derived as a light gray lifted toward near-white text, a tone never
designed to sit on a saturated fill. Together: roughly 1.5-2:1
contrast, unreadable on the default dark theme.
Applied the issue's recommended first option: drop the background fill
entirely and render the title as accent-colored text on the normal
status bar background. Same highlight intent (the title still stands
out via its color), readable contrast on both dark and light seeds.
Updated the existing test that had encoded the buggy background-fill
expectation, and added an explicit contrast-regression assertion.
Verified as a genuine regression by reverting the fix and confirming
the test fails with the exact reported #FFBF00 background color.
54/54 pass across the three appChrome-related test files (no
regression).
Review follow-up: $isStatusRuleOccluded and FloatingOverlays each
enumerated the same six overlay kinds — adding a 7th floating panel
required updating both or the timer gate silently missed it. Extracted
hasFloatingPanel as the shared predicate (completions stays local to
FloatingOverlays; it deliberately never occludes the status rule).
Full ui-tui suite green (1487 tests).
`$isBlocked` answers "is text input suspended", not "is the status rule
covered". appLayout uses it only to hide the input rows (appLayout.tsx:384);
`StatusRulePane` renders outside that guard, at :365 for `at="top"` and :449
for `at="bottom"`. So the previous revision paused FaceTicker /
SessionDuration / IdleSince under prompts that leave the rule fully on
screen — approval, billing, subscription, confirm, clarify, sudo and secret
all render through PromptZone in NORMAL FLOW above ComposerPane
(appOverlays.tsx:58-162, appLayout.tsx:553-568). They push the rule down;
they do not cover it. Freezing a visible clock is a worse bug than the churn
being removed.
Replace it with `$isStatusRuleOccluded`, a narrow derived store over
overlay + ui state covering only what actually paints over the rule:
- `widget` — the modal widget slot renders at viewport level
(ActiveWidgetSlot, sdk/host.tsx:209) so it can anchor the full-screen
absolute `Overlay` against the whole terminal.
- the FloatingOverlays set (modelPicker, pager, petPicker, sessions,
skillsHub, pluginsHub) — but only when `ui.statusBar === 'top'`. That
panel is `position="absolute" bottom="100%"` inside ComposerPane's
relative Box (appOverlays.tsx:387), so it grows UPWARD over the top rule
and can never reach the bottom one.
Deliberately excluded: the PromptZone flow states above; `agents` and
`journey`, which unmount the entire ComposerPane subtree (appLayout.tsx:553)
so React's effect cleanup already clears the intervals; `ambient`, an
in-flow dock; and composer completions, which share the floating grid but
are a render prop that changes per keystroke — re-arming a 1s interval on
every character would restart the countdown each time and starve the tick.
`statusBar: 'off'` needs no branch: StatusRulePane returns null for both
slots, so the timers never mount.
Tests: the store-level cases are re-split into occluding and non-occluding
sets, and an AppLayout-level `describe` mounts the real layout so the rule
sits in its true position — asserting that under approval and sudo the rule
is still rendered AND its clock advances (1m 0s to 1m 30s), that a floating
model picker suppresses the clocks with the rule at the top, and that the
same picker leaves them armed with the rule at the bottom.
`StatusRulePane` renders `StatusRule` outside the `!isBlocked` guard in
appLayout.tsx, so the status rule stays mounted underneath approval,
model-picker, pager, sessions and every other blocking overlay. Its three
timer-driven components keep firing the whole time: `FaceTicker` (glyph,
1s clock, verb rotation), `SessionDuration` (1s) and `IdleSince` (1s).
Every tick re-renders a rule nobody can see, and in an Ink TUI that churn
reads to the user as the dialog flickering.
Gate all three components' interval creation on the existing `$isBlocked`
computed store, so nothing is armed while an overlay covers the rule.
The pause alone would leave the elapsed read-outs frozen at the moment the
overlay opened, so each effect re-seeds `now` from the wall clock when it
re-arms. `SessionDuration` and `IdleSince` already did this; `FaceTicker`
gains the same re-sync. Closing a five-minute overlay now resumes at the
true elapsed time instead of the pre-overlay value.
No new store is introduced — `$isBlocked` already exists and already ORs
the current OverlayState field set.
* feat(tui): Ctrl+O opens the model picker without clearing your draft
Reaching the model picker meant typing /model, which forces you to wipe
whatever you'd already drafted. Bind Ctrl+O to open the same picker overlay
directly, leaving the composer untouched. Ctrl+O is added to the textInput
pass-through allowlist so the composer doesn't swallow it, mirroring the
existing Ctrl+X session-switcher path.
* feat(tui): apply a mid-turn model switch at the next turn instead of rejecting it
Picking a model while a turn was streaming hit a 4009 'session busy' reject:
switch_model() mutates the agent's model/provider/base_url/client in place and
the worker thread reads those every iteration. Now config.set queues the pick
in session[pending_model_switch] and _apply_pending_model_switch applies it on
the turn thread at the next turn start, before any model call — no race, no
interrupt, no waiting on the client rebuild. The TUI paints the pick optimistically
and notes '(applies next turn)'.
Both kills operated on the whole buffer: Ctrl+U wiped everything before
the cursor and Ctrl+K everything after, regardless of newlines. Readline
scopes them to the current logical line, and Claude Code documents Ctrl+U
as "repeat to clear across lines in multiline input" — which only works
if a press at a line boundary consumes the newline and makes progress.
Extract killToLineStart / killToLineEnd and route all four call sites
through them, so the Cmd chords and their Ctrl equivalents cannot drift.
On terminals that send Cmd+Backspace as a CSI-u sequence rather than
rewriting it to Ctrl+U, the keystroke fell through to the word-delete
branch and erased a single word.
The modifier this hinges on is easy to get wrong. isActionMod accepts
key.meta on macOS, and hermes-ink reports Option as meta — so keying on
it would turn Option+Backspace, the platform's delete-word shortcut, into
delete-the-whole-line. On Linux/Windows isActionMod is key.ctrl, where
Ctrl+Backspace is likewise delete-word in readline, VS Code, browsers,
and Windows Terminal. Only the super bit, set by kitty CSI-u and xterm
modifyOtherKeys, unambiguously means Cmd.
Extract that decision into isLineKillModifier so the reasoning lives in
one place and the regressions above are pinned by tests.
Kimi Coding discovers the flagship as wire id `k3`. Picker search used
only that id, so typing "kimi" hid it next to every other kimi-* model.
Add picker-only search aliases without changing the wire id.
Plain prose rendered with no color at all, so it fell through to the
terminal's default foreground while inline tokens on the same line
(code, links, math, muted markers) carried a theme tone. One rendered
line therefore mixed two foregrounds — and because an inline token can
match mid-word (`re-render_terminal_output` trips underscore-italic),
so could a single word.
MdInline now takes an optional color and the body-prose callers
(paragraphs, bullets, numbered items, definitions) pass t.color.text.
Callers that already wrap it in a colored parent — headings, quotes,
footnotes — keep inheriting and are untouched.
Both chat renderers resolve link titles over the network and render them
in place of the link's text, unconditionally — so they also replaced text
the agent deliberately wrote. `[#71706](url)` rendered as the whole GitHub
page title, and prose labels were swapped out mid-sentence.
The TUI ordered `fetched || label` and always passed the URL to
useLinkTitle. Desktop looked guarded but wasn't: chat markdown hands
authored text to PrettyLink as `fallbackLabel`, not `label`, so
`useLinkTitle(label ? null : target)` never skipped the fetch and
`fetched || label || fallbackLabel` still let the title win.
Treat an authored label as the intent: it wins, and it skips the fetch. A
label that is just the URL still resolves, since `[url](url)` and `<url>`
are bare links wearing markdown syntax — desktop already applied that same
URL-equality rule before handing the label over.
Co-authored-by: Kinkoolino-Hermes <297364961+Kinkoolino-Hermes@users.noreply.github.com>
The composer offers a mid-prose /skill as a completion, but the transcript
knew nothing about slash references and rendered the whole user message as
one flat run of text, so the skill lost its accent the moment it was sent.
User messages are now split into plain and reference runs, and the
reference keeps the accent it wore in the composer. The text is unchanged —
concatenating the segments reproduces the input exactly — so this is
presentation only, and the backend still receives the literal /clean.
The scan has to reject a token that continues into a path, since it runs
over finished text rather than anchoring at the caret: /usr/local/bin would
otherwise style as /usr.
Maintainer review (hermes-sweeper) on this PR found the fix was
incomplete: two paths still hid the MoA reference panel under
thinking: hidden.
1. thinking.tsx: the mount useState correctly seeds openThinking from
(visible.thinking === 'expanded' || reasoningAlwaysVisible), but the
re-sync effect on [visible] fires after the FIRST render too, not
just later updates, and lacks the reasoningAlwaysVisible OR — so it
immediately collapsed a just-opened MoA panel right after mount.
Skip only the effect's very first run (a ref flag); every later
visible change still re-syncs without the override, preserving the
documented no-OR-at-effect-time contract (manual collapse sticks).
2. useMainApp.ts: showProgressArea's streamSegments predicate gated
thinking content on thinkingPanelVisible alone, so an MoA reference
segment (segment.isMoaReference, same flag messageLine.tsx's
shouldShowThinkingTrail already honors per #64657) never kept the
live progress area up when thinking was hidden — StreamingAssistant
then returned early before MessageLine was ever reached. Added the
same override.
Added tests/thinkingMoaReferenceVisibility.test.tsx: mounts ToolTrail
with reasoningAlwaysVisible + sections.thinking: hidden, awaits queued
effects, and asserts the chevron is still open (▾, not ▸) once they
settle.
Validation:
npx vitest run src/__tests__/thinkingMoaReferenceVisibility.test.tsx
-> 1 passed
Fail-before: reverting only the thinking.tsx ref-guard reproduces the
exact regression -- the same test's frame capture shows the panel
open on first paint then collapsing to ▸ once the effect fires, and
the 'not.toContain(▸)' assertion fails as expected.
npx vitest run (full ui-tui suite): 1115 passed, 8 failed -- all 8
pre-existing and unrelated (terminalSetup/terminalParity/editor
resolution env-path tests), confirmed by running them in isolation
with the same result regardless of this diff.
npx tsc --noEmit: clean.
npx eslint src/components/thinking.tsx src/app/useMainApp.ts: clean.
Every moa.reference gateway event stores its labelled reference-model
output in a Msg's generic `thinking` field (turnController's
recordMoaReference), which messageLine.tsx and the ToolTrail component gate
on `display.sections.thinking`'s resolved mode. When that mode resolves to
`hidden`, MoA reference blocks were suppressed along with ordinary model
reasoning — even though (per #53855) references are the mixture-of-agents
process the user explicitly opted into, not private reasoning, and should
stay visible regardless of the thinking-section setting.
Adds Msg.isMoaReference (set by recordMoaReference), a shouldShowThinkingTrail
helper mirroring the existing shouldShowResponseSeparator pattern, and a
reasoningAlwaysVisible prop threaded into ToolTrail to bypass the two
suppression gates (the trail-wrapper return-null check and the
allHidden/panel-push checks) plus the panel's initial open state and the
shift-click expand-all gesture, so a MoA reference panel is not just present
in the tree but actually visible and openable on first paint.
Fixes#64657
* fix(desktop): hide persisted agent-only history scaffolding
Filter verification-stop nudges and context-compaction handoffs at the
stored-history mapper boundary. Preserve a real reply when a compaction
handoff shares its stored message.
* test(desktop): build persisted E2E sessions through the real agent
Drive tui_gateway.entry over its stdio JSON-RPC transport against the mock
provider, wait for real completion events, and persist normal session history
through AIAgent and SessionDB. Migrate resume and hidden-history coverage,
including real compression and live verify-on-stop scaffolding, then remove
the unused direct SessionDB import scripts.
* fix(desktop): use the provisioned Python for real-session E2Es
Run the stdio gateway through uv's synced project environment outside the
Nix dev shell, while retaining the fully provisioned Nix Python when the
shell advertises HERMES_PYTHON_SRC_ROOT.
* fix(nix): expose the provisioned Python environment to uv
Mark the Nix-built Python environment active in the dev shell so the shared
E2E session builder can always run through `uv run --active --no-sync`.
* fix(timeline): persist typed display events
* fix(timeline): strip display-only fields from provider payloads, preserve through rewrites, fix /resume display history
Three review findings from PR #69771:
1. Provider payload leak: display_kind and display_metadata were forwarded
to the provider API as unknown message fields. Strict OpenAI-compatible
backends can reject the next request after a model switch or resumed
typed event. Strip both from the per-request api_msg copy in
conversation_loop alongside the existing api_content pop.
2. Rewrite/import data loss: _insert_message_rows preserved display_kind
but silently dropped display_metadata. After replace_messages,
archive_and_compact, or session import, async-delegation completion
events lost their task counts and fell back to generic display text.
Add display_metadata to the INSERT columns and bind tuple.
3. CLI /resume stale recap: startup --resume A set _resume_display_history
from A's lineage. A subsequent in-session /resume B loaded B only into
conversation_history via get_messages_as_conversation, leaving the stale
A display projection. _display_resumed_history preferentially read the
stale attribute, showing A's recap for B. Switch /resume to
get_resume_conversations and update _resume_display_history alongside
conversation_history.
Tests: 890 Python (5 files), 35 desktop TS — all green.
* feat(tui): render typed display events as ◈ markers in the Ink TUI
The TUI was not handling display_kind at all — model switch markers and
async delegation completions rendered as opaque user messages with the
full [System: ...] text, and hidden compaction handoffs were visible.
Wire display_kind through the full TUI chain:
- _history_to_messages (tui_gateway/server.py) forwards display_kind
and display_metadata to the gateway transcript payload.
- GatewayTranscriptMessage (gatewayTypes.ts) gains both fields.
- Msg.kind (types.ts) gains 'event' value.
- toTranscriptMessages (domain/messages.ts) maps:
- hidden → skip entirely
- model_switch → event "model changed"
- async_delegation_complete → event "N background agents finished"
(or "background agent work finished" without metadata)
- messageGroup (blockLayout.ts) routes event to its own group, with
SELF_SPACED + PAINTS_TRAILING_GAP so it owns its margins.
- messageLine.tsx renders event-kind as a dim ◈ marker with no gutter,
matching the CLI's ◈ event rendering.
- 4 new TUI tests for hidden/model_switch/async_delegation mapping.
TUI typecheck: clean. TUI lint: 0 errors (2 pre-existing warnings).
TUI tests: 9 passed (1 pre-existing failure on main, unrelated).
Live-repaint's composer gap: flip a light terminal to a dark skin and the
input goes black-on-black. The placeholder was already explicit truecolor
(theme muted), but TYPED text rendered with no color at all — the terminal's
default foreground — in both paint paths:
- the Ink render (<Text wrap="wrap">{rendered}</Text>, no color), and
- the fast-echo bypass, which writes raw cells straight to stdout.
The skin owns the background (OSC-11) but the default fg still belongs to
the host terminal's polarity, so any skin/terminal polarity mismatch made
input invisible. Every other transcript line already paints
theme.color.text (the completed inputBuf rows directly above the composer).
Give TextInput a color prop and paint both paths with it: the Ink <Text>
(chalk re-opens the outer color after the placeholder chips' embedded [39m
closes; INV cursor/selection cells never touch fg) and the fast-echo write
via colorizeEcho — same explicit-truecolor-only rule as colorizeHint, so
the bypass cell can't flash terminal-default before the next frame. All six
TextInput sites (composer, prompts, masked, billing ×2, session switcher)
pass theme text; no color ⇒ passthrough, unthemed inputs keep the terminal
default.
Tests: colorizeEcho SGR wrap + passthrough contracts; full ui-tui suite
1338✓; typecheck clean.
* feat(desktop): revamp Billing page — plan card, in-app plans view, tier art
Reshape the desktop Billing settings per wayfinder ticket 09. New page order:
Plan → Payment → One-time top-up → Automatic refill → Usage, with the at-a-glance
summary strip unchanged at the top.
- CurrentPlanCard replaces the old Subscription row: tier name + price + renewal
and at most one button — "View plans" (free/no-sub + can_change_plan),
"Change plan" (subscriber + can_change_plan), or none for teams / non-changers.
Teams keep the portal "Adjust plan ↗" link so they are not stranded. The button
navigates in-app to the plans sub-view.
- bview=plans sub-view mirrors the settings pview/kview pattern (useRouteEnumParam,
default overview). BillingPlansView renders a grid of PlanCard from live tiers[]
(is_enabled, sorted by tier_order, free tier included).
- PlanCard: tier art + name + $/mo + monthly credits as dollars ("$110 credits/mo").
Current tier is highlighted + inert; higher/no-current tiers get "Choose ↗"
(opens portal with plan=<tierId>); lower tiers are a DISABLED "Downgrade" with a
caption — downgrades move in-app in ticket 11 (gateway pending-change flow), so
this PR intentionally links them out/disabled rather than wiring the money path.
- buildManageSubscriptionUrl gains an optional third arg (tierId) → appends
plan=<tierId>. Signature kept identical to draft PR #68666 for a trivial rebase;
NAS #748 validates the param server-side.
- Tier art: four NAS hero webps rendered as ~40px thumbnails over a Nous-blue well
with per-tier blend modes (the only place Nous blue appears). Keyed by lowercase
tier NAME (free/starter→connect, plus→memory, super→automation, ultra→sandbox);
unknown name → text-only card. Imported via vite static imports for packaged
file:// + webSecurity.
- Top-up vs auto-refill disambiguated by section label + first sentence: "One-time
top-up" / "Buy credits now" vs "Automatic refill" / "Refill when low" (configured
copy reads "Charges $X automatically when your balance falls below $Y.").
- Variant-A auto-refill editing: Manage swaps the row's left side (caption → the two
$ fields with a pre-allocated error line) and the action column (Manage → Save/
Cancel) in place, with the row height reserved for the tallest state so the Usage
section never shifts. Fixes the spurious on-open validation error (errors now show
only after an edit or a save attempt). Save/disable API calls + confirm-disable
flow unchanged.
- Remove subscriptionTierChips and the subscription-row chips; reshape (not delete)
deriveBillingView to expose plan + tiers. Buy-credits row keeps the chips seam.
- Dev fixtures: add free-personal and subscriber-personal (personal orgs, full
4-tier Free/Plus/Super/Ultra catalog) so the plans view is exercisable.
Tests: update/extend index.test.tsx + use-billing-state.test.ts, add tier-art.test.ts;
delete the old chips tests. Desktop billing suite 70/70 green, typecheck clean.
* fix(desktop): mark the free/lowest tier current (not an upgrade) when there is no subscription
Visual verification caught a spec-fidelity bug: in the plans grid, an account with
no active subscription rendered the Free tier ($0/mo, tier_order 0) as a "Choose ↗"
upgrade — clicking would deep-link the portal to "subscribe to Free".
Ruling: current-card = tier.is_current OR (subscription.current == null AND the tier
is the lowest-order / $0 tier). derivePlanTiers now falls back to the lowest-order
tier as the stand-in current plan when there is no subscription, so the free card
renders exactly like is_current (inert, "Current plan") and — being the lowest order
— no tier can be a downgrade; every paid tier is a "Choose ↗" upgrade.
CurrentPlanCard is unaffected (still "Free" + "View plans"); subscriber-personal is
unchanged (Free stays a disabled Downgrade below the current Plus tier).
Tests: free-personal grid now asserts Free = current/inert, no downgrade state, three
Choose buttons; text-only unknown-tier test gains a free tier so the unknown paid tier
is unambiguously an upgrade. Billing suite 70/70 green, typecheck + lint clean.
* chore(desktop): shrink bundled tier art to 128px thumbnails
The plan-card wells render the art at ~40px; shipping the full landing
images added 2.7 MB to the repo for no visible difference. 128px covers
2x displays; total is now 26 KB.
* fix(desktop): address 6 adversarial-review findings on the Billing revamp
1. Grandfathered current tier (BLOCKER). NAS marks a grandfathered current tier
is_enabled:false; the enabled-only filter dropped it, leaving currentOrder
undefined so every lower tier rendered as an actionable "Choose ↗". derivePlanTiers
now resolves current identity/ordering against the UNFILTERED tiers and keeps the
grandfathered current tier in the grid as the inert "Current plan" card; downgrades
classify against its tier_order. (Non-current disabled tiers are still dropped.)
2. Dead plan-card button. derivePlanCard offered "View plans"/"Change plan" purely on
can_change_plan, but the grid could be empty / current-only and showPlans refused,
so the button no-oped. It now offers the in-app action ONLY when the grid has ≥1
actionable (non-current) tier; otherwise it falls back to the portal link.
3. Deep-link bypass. showPlans now gates on the same capability that renders the button
(view.plan?.action), so a team / non-changer deep-linking bview=plans always falls
back to overview instead of a grid of live Choose buttons.
4. Lost portal escape hatch. Whenever the card has no in-app action (teams, non-changers,
refused subscription, empty catalog) it now ALWAYS carries the "Adjust plan ↗" portal
link built from subscription?.portal_url ?? billing.portal_url — the refusal caption
no longer promises a portal the UI didn't render.
5. Choose URLs dropping org_id/plan. (a) derivePlanTiers now threads billing.portal_url
as the fallback base for the Choose URL. (b) buildManageSubscriptionUrl treats the
hard-coded FALLBACK_PORTAL_BILLING_URL as a last-resort ORIGIN (applying org_id/plan)
instead of a bare return, so a null portal_url never strips the routing params.
6. Zero-shift on narrow panes. Replaced the magic min-h-28 (under-reserved once the two
inputs stack below @2xl) with exact reservation: the edit form is always rendered and
both states share one grid cell ([grid-template-areas:'stack']), invisible+aria-hidden
when not editing — the row equals the tallest state at every width, no breakpoint math.
The refusal stays inside the reserved layer.
Tests: +12 (grandfathered current, no-dead-button + empty-catalog portal link, team &
personal deep-link fallback to overview, billing.portal_url-backed Choose URL, fallback
org_id/plan, reserved-form-mounted); updated the two portal-link expectations for §4.
Billing suite 78/78 green; typecheck (app/electron/e2e) + lint clean.
* refactor(desktop): reuse the shared openExternalLink helper in the plans view
* fix(desktop): honor the auto_reload wire contract — null card + disable amounts
A full-stack contract sweep (desktop ↔ shared types ↔ gateway ↔ NAS) surfaced two
real desktop bugs in the auto-refill row:
A. auto_reload.card can be null. The gateway's _parse_auto_reload_card returns None
for a missing/unknown-kind card and _serialize_billing_state emits `card: null`,
but the shared BillingAutoReload.card union had no null arm and use-billing-state
dereferenced `autoReload.card.kind` bare — a crash on the enabled path. Add `| null`
to the shared union (contract honesty) and guard the read (`card?.kind`); null now
falls through to the default enabled path, same as a canonical card.
B. Disable was rejected by the gateway. billing.auto_reload unconditionally requires
threshold + top_up_amount, so `updateAutoReload({ enabled: false })` came back
invalid_request. (The TUI always sends both; desktop fixture mode stubbed it.)
disable() now sends the current threshold_usd/reload_to_usd from the autoReload
prop alongside enabled: false, matching the TUI.
Tests: enabled auto_reload with card:null renders the normal enabled row (derivation
+ render, no crash); disable call carries both current amounts. Billing suite 80/80
green; typecheck (app/electron/e2e) + lint clean.
* fix(tui): guard the nullable auto_reload card in the auto-reload screen
The shared BillingAutoReload.card union gained its honest null arm (the
gateway emits card: null for a missing/unknown card); the TUI's only bare
dereference follows the same default path as a canonical card.
* fix(desktop): align billing inputs to the sm control height
The three billing inputs used an ad-hoc h-8 (32px) next to size=sm
buttons (24px). They now use the control system's size=sm with a
py-[3px] compensation for the input's real 1px border — buttons draw
theirs as an inset shadow, so sm alone still sits 2px taller. All five
controls in the buy row now measure 24px.
* fix(desktop): plan-card actionability + billing view-model hardening
Code-quality review of the Billing revamp (PR #68722).
BLOCKING — a top-tier subscriber (only downgrades/current below them) opened a
plans grid with zero enabled actions AND no portal link. The plan card gated its
in-app button on `tiers.some(state !== 'current')`, which counts the (disabled)
downgrade tiles. It now gates on an actual UPGRADE being present
(`capable && tiers.some(state === 'upgrade')`); with no upgrade the card falls back
to its "Adjust plan ↗" portal link, and the bview=plans deep link (gated on the same
plan.action) falls back to overview.
Reviewer structural items:
- One "plans capability" verdict (personal + can_change_plan + subscription ok) is
derived once in deriveBillingView and threaded to BOTH derivePlanCard and
derivePlanTiers; the grid only mints upgrade actions when capable, so the invariant
lives in one place.
- BillingPlanTierView is now a discriminated union (`current` | `downgrade` w/
disabledCaption | `upgrade` w/ required action), and BillingPlanCardView is an
action-XOR-link union — deleting the `tier.action?.url ?? ''` and `plan.link?.url`
defensive branches in the consumers.
- `findCurrentTier(subscription)` replaces the repeated is_current||id predicate at
its three sites (plan card price, grid ordering, summary plan line).
- BillingView exposes named `paymentRow` / `topupRow` / `refillRow` instead of an
`accountRows[]` + three `.find(id)` lookups.
- The auto-refill row that edits in place carries an explicit `manageInApp: true`;
AutoReloadRow keys off it instead of sniffing the action label/url.
- tier-art header comment no longer cites an internal repo path; dead `?.` removed
from RowValue (via a destructured const) and the plan-card link handler.
Behavior is identical except the blocking fix. Billing suite 82/82 green; typecheck
(app/electron/e2e) + lint clean.
* refactor(desktop): adopt inline-review nits on the billing plan card
Resolves the inline suggestion threads:
- plan-card gate reads a named `hasActionableTier` = "a tile carries an action"
(union-safe `'action' in tier`, equivalent to the old upgrade-only check).
- re-narrow link/action inside the click callbacks (`plan.link && …`,
`tier.action && …`) rather than relying on outer narrowing.
Behavior unchanged; billing suite 82/82 green, typecheck + lint clean.
Theming was semantic-only: the gold tool `●` was `accent`, shared with
headings/links/chevrons, so "recolor tool calls" was impossible and the agent
had no key to point at. Add `ui_tool` (● + tool spinner) and `ui_thinking`
(reasoning body) tokens that fall back to accent/muted — defaults unchanged,
but now independently settable. Make diffs skinnable too (`diff_*`), which
fromSkin previously hardcoded. Document the full element→key map in the skill so
Hermes knows which knob turns what.
A full placement grid so the agent can put a widget where it asks — dock-top/
bottom and corner zones, with corners as reserved rails that take real space
instead of floating over content. A per-widget error boundary plus lenient
ShimmerRows means generated widget code can't crash the TUI.
Reusable render primitives the SDK exposes to widget authors: sparkline/gauge/
hbars chart helpers (dimension-stable so live updates never resize the card),
an Accordion for expand/collapse sections, animated shimmer loaders, and a
streams demo that no longer reserves a phantom icon column on unfocused titles.
Widgets can render as ambient (glanceable, non-blocking) instead of modal,
docked in the normal layout flow above/below the status bar rather than taking
over the screen. The slash catalog is generated from the widget registry so new
apps surface automatically, and /ticker lands as the first live-animation
ambient demo.
The SDK the desktop app already has, ported to the TUI: a WidgetApp contract
(id/help/mode/init/reduce/render/usage), a registry, and a host that owns the
active widget, routes input to its reducer, and renders it. The grid-test and
dialog-test debug surfaces are reimplemented as widget apps instead of bespoke
overlay state, and slash commands are generated from the registry. Input for an
open widget is owned by the active app (supersedes the demo-only stacked-modal
routing) — the single active widget enforces topmost-owns-input structurally.