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>
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 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.
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.
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.
* 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>
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.
PowerShell's Get-Clipboard -Raw outputs text in the system's ANSI codepage
(e.g. CP1252, CP936), not UTF-8. When Node.js reads this with encoding: 'utf8',
non-ASCII characters (CJK, emoji, accented chars) are corrupted.
The write path already solved this by base64-encoding UTF-8 bytes and passing
them via -Command argument (see comment at line 94-98). This fix applies the
same approach to the read path:
- Change PowerShell read command to base64-encode the clipboard content
using [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes(...))
- Add base64 flag to read command type for PowerShell entries
- Decode base64 result in readClipboardText when flag is set
Also adds child.unref() to spawned clipboard child processes in the write
path to prevent delaying process.exit() when the app exits mid-clipboard-write.
CI caught a split defect in this salvage: the PR's long-reasoning tail
test was kept but its production hunk was dropped as 'cosmetics'. It
isn't — cleanThinkingText runs several full-string regex passes and
reasoning grows on every streamed token, so re-cleaning the whole
accumulated string per chunk is O(n) per token / O(n^2) per stream.
Only the tail is displayed (boundedLiveRenderText caps it downstream),
so bound the input to 1.5x LIVE_RENDER_MAX_CHARS first.
Restores the one text.ts hunk from cd99e65fc (author preserved); the
italic-thinking display change and profile script from that commit
remain out of scope.
Carry a display string through the submit path so the transcript renders
what the user typed while the agent still receives the expanded skill.
Drops the '⚡ loading skill' line — the invocation bubble says it.
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.
Self-hosted forges answer a missing or private page with a 200 and a
"Page not found" title, which then rendered as the link's text. Both
surfaces keep their own copy of the error-title list, so extend both.
Co-authored-by: Kinkoolino-Hermes <297364961+Kinkoolino-Hermes@users.noreply.github.com>
Open the shared ConfirmPrompt (full-width, themed, same lane as approval/clarify)
rather than a truncating status-bar notice. One recovery action: Nous → /topup
(opens the rich billing overlay), other providers → their billing page, or /model
to switch when there's no URL. The transcript keeps the full guidance; the dialog
is the concise actionable layer.
The input fix's sibling, hit immediately after: the composer was themed but
AGENT text went black-on-black the same way. Root cause is the class, not
the call site — markdown body, borders, and every token rendered without an
explicit color falls back to the terminal's DEFAULT foreground, which
belongs to the HOST profile's polarity, not the skin's. A dark skin on a
light terminal repaints the backdrop via OSC-11 while thousands of
default-fg cells stay near-black.
Chasing every <Text> is unwinnable. Instead own the default itself: when a
skin authors a background (the existing opt-in), paint the default
foreground from the resolved theme's text color via OSC-10. Every unthemed
token — present and future — re-bases onto the skin atomically, exactly
like the background.
terminalModes: the OSC-11 slot generalizes to defaultColorSlot(10|11) —
same paint/clear/exit-restore contract, tracked per slot, so a skinless
session still never touches the terminal. reapplyTheme repaints the fg too:
polarity flips swap paired palettes, moving the text tone while the
background stays.
Tests: slot contract runs table-driven over both OSC codes; handler test
pins the invariant (default fg == theme text; dropping the background
releases both defaults). Suite 1344✓, typecheck/lint/prettier clean.
Code highlighting reused brand tokens (accent/text/border/muted), so it couldn't
be themed independently. Add syntax_string/number/keyword/comment skin keys →
syntax* theme tokens (defaulting to those brand tokens, so defaults are
unchanged) and point the highlighter at them. Documented in the element→key map.
The TUI inherited the terminal's background; now a skin's `background` paints the
whole surface via OSC 11 when a skin is applied, and clears back to the terminal
default (OSC 111) on revert and on exit (ridden in through resetTerminalModes).
Opt-in: a skin with no `background` leaves the terminal untouched, and the
restore only fires if we actually painted. Desktop already themed its own bg;
this closes the loop so Hermes owns its background on every surface.
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.
Review on #20379, finding 2 (High). The boot cache seeded the previous
session's background into HERMES_TUI_BACKGROUND, the same slot a CURRENT
OSC-11 answer occupies — so a cache written on a light terminal pinned a
now-pure-black terminal to light forever: the new OSC-11 #000000 answer is
distrusted by design, the pure-white OSC-10 foreground is distrusted too,
and the macOS appearance fallback refuses to run while the slot is set.
Seeding now records provenance (themeBoot.seedBootEnvironment, extracted
and testable against a passed env). When the current terminal answers the
background probe with the untrusted fingerprint, the gateway handler calls
invalidateBootBackground(): the slot clears ONLY while it still holds the
seeded value (a trusted answer that overwrote it is authoritative), OSC-10
gets first claim in the same startup batch, and a short settle pass re-
derives from the live fallback chain if nothing answered.
The cache is also pin-coherent now: commitTheme persists the config mode
pin (display.tui_theme) alongside the resolved theme + physical background.
Previously "/theme light" on a dark terminal cached a light theme next to a
dark background — the next launch painted light, flipped dark when the skin
resolved against the seeded background, then flipped light again on config
hydration: the exact multi-stage flash the cache exists to eliminate. The
seeded pin counts as config-owned (bootSeededPin), so a later 'auto' can
still clear it instead of mistaking it for a user shell export.
Tests cover the review's sequences: stale-light cache vs current dark
terminal (invalidate → fallback chain), stale-dark vs ambiguous light,
pinned light on a dark physical background across restart (and the
inverse), trusted-overwrite protection, and the explicit-signal guards.
Four responsiveness/hardening fixes surfaced by rapid /skin switching:
config.get mtime now carries an mcp_rev hash so the TUI reloads MCP only
when MCP-relevant config changed (cosmetic /skin writes cost seconds of
reconnects before); reload.mcp runs on the RPC pool serialized by a lock
(inline it froze the stdio reader for the duration of a flapping server's
retry loop — config.set/complete.slash sat unread and the TUI appeared
dead); theme swaps schedule one full repaint (incremental diffs after a
recolor tear — stale cells keep the old palette, read as "shadows"); and
OSC-11 pure-black answers are distrusted universally (unset-default
fingerprint on xterm.js hosts and tmux). Parent EIO zombie fixed: a dead
PTY made every render write throw once a second forever; exit after 5
consecutive dead-stream errors.
The desktop color-mix system, ported: a theme is a handful of identity
SEEDS (text, primary, accent, border, status hues); every secondary tone
(muted, label, surfaces, chips, selection) is a color-mix derivative
against the real terminal background. lib/color.ts consolidates the
primitives (parse/mix/luminance/contrast/retone + xterm.js's multiplicative
liftForContrast). Knobs are grid-search fitted so the math reproduces the
classic hand-tuned literals (contract-tested). The display shim renders
authored palettes RAW and only rescues near-invisible colors. Boot reads
the last resolved theme from $HERMES_HOME/tui-theme-boot.json so the first
frame paints in the right palette (no default-dark flash), and the
placeholder cursor follows the bubbles textinput pattern.
Banner (responsive tiers: full logo -> compact rule -> text -> hidden),
SessionPanel (fixed hero track + flexible info track, the desktop pane
shell's fixed-vs-flex contract), floating overlays, prompt zone, and the
pickers all render through WidgetGrid instead of hand-rolled flex math.
Pickers gain maxWidth so grid cells can cap them.
Rebase of the widget-grid PR onto current main, then grow it into a real
2-axis engine: resolveGridTracks (grid-template tracks — fixed cell counts
and weighted fr shares with mins), layoutWidgetGrid (1D auto-packing flow),
and layoutGridAreas (2D absolute placement with row/col spans and implicit
row growth). Overlay/Dialog primitives give zoned viewport-level modals
with an optional scrim. /grid-test (interactive: areas, nesting, zoom,
gap/padding) and /grid-test streams (4x3 mission-control GridAreas board
with promote-to-main) exercise everything end to end.
Pull the inline leading+trailing resize throttle out of useMainApp into
createResizeCoalescer (src/lib/resizeCoalescer.ts) and cover it directly
with fake-timer tests: leading-edge immediacy, burst collapse to one
trailing reflow, fresh leading edge after the window, cancel() dropping a
pending reflow, and sustained-drag staying ~one reflow per interval.
Also seed lastReflow at -Infinity instead of 0 so the leading edge fires on
the first event independent of the wall clock (the inline version only
worked because Date.now() is large at runtime).
TUI /journey gets d/e with confirm + $EDITOR; desktop gets a right-click
context menu with inline edit modal. Both refresh the graph after mutation.
Extract openInEditor into the shared TUI editor helper.
Memories are the only drillable rows, so give them the primary "clickable"
ink and demote skills (dead-ends) to the muted complement — previously the
non-openable skills wore the link-looking primary color. Flipped in both
the TUI and CLI palettes for parity.
Terminal rendition of the desktop Star Map / Memory Graph: learned skills
and memories on a timeline, shared by `hermes journey` and the TUI
`/journey` overlay via one size-aware Python renderer
(agent/learning_graph_render.py).
- TUI overlay mirrors /agents: static chart overview + selectable slice
list → slice detail → single skill/memory body, with the shared
inverse-row selection treatment and a pinned footer.
- Reuse primitives: extract OverlayScrollbar into its own module (now
shared with agentsOverlay), scroll the item body via ScrollBox, and
unify both lists through one table-driven ListRow.
- No animation/playback in the TUI — pure data; the renderer's reveal
scrubber stays available in the CLI (`--play`, `--reveal`).
Bring apps/desktop and ui-tui to a clean state for typecheck, eslint,
and prettier:
- Run prettier across both trees (printWidth/wrap drift; prettier is not
CI-enforced for these JS projects, so main had accumulated drift).
- Apply eslint --fix for padding-line-between-statements and perfectionist
import/export sorting.
- Manual fixes for non-auto-fixable rules:
- remove unused node:net import in electron/main.cjs (uses Electron net)
- replace inline `typeof import(...)` annotations with top-level
`import type * as EnvModule` in two ui-tui test files
- scoped eslint-disable no-control-regex on intentional sentinel/ANSI
regexes (mathUnicode.ts, text.ts)
- resolve react-hooks/exhaustive-deps per-case: correct swapped/missing
deps, collapse redundant session.* members, and justified disables on
settings mount-only data-load effects to preserve run-once behavior
No behavior changes; test pass/fail counts are unchanged from the main
baseline.
On-disk vitest coverage for the auto-heapdump disk-safety guard: opt-in
gating (suppressed diagnostics-only path), truthy-spelling acceptance,
manual-trigger passthrough, and the retention prune. Test approach
adapted from #21780 (briandevans) and #21822 (LeonSGP43), reconciled to
the merged gate semantics. Maps alarcritty into AUTHOR_MAP for CI.
Automatic heap dumps from the TUI memory monitor could write multi-GiB
.heapsnapshot files on every threshold cross, growing ~/.hermes/heapdumps
to tens of GiB. Add four layered safeguards:
- Gate auto-high/auto-critical snapshots behind HERMES_AUTO_HEAPDUMP=1;
manual dumps remain unchanged.
- Always write the lightweight diagnostics JSON sidecar so users still
get an actionable artifact when the snapshot is suppressed.
- Cap total bytes in the dump dir (HERMES_HEAPDUMP_MAX_BYTES, default
2 GiB), evicting oldest first, retaining the newest.
- Add a cooldown between auto dumps (HERMES_AUTO_HEAPDUMP_COOLDOWN_MS,
default 10 min) so an oscillating heap can't re-trigger.
Closes#21767
When a clarify prompt times out (backend _block returns an empty answer
after the configured timeout) or is dismissed with Esc/Ctrl+C, the live
ClarifyPrompt overlay was torn down by turnController.idle() ->
resetFlowOverlays() with no persistent transcript record. The question and
options vanished from the screen while the agent's follow-up still referred
to "the options above".
The answered path already persists the question + answer; only the
unanswered exits left no trace. This asymmetry is the bug.
Fix (TUI layer only, no Python/protocol change):
- formatAbandonedClarify() in lib/text.ts renders the question + the same
1-based numbered option list shown by ClarifyPrompt, plus a reason
('timed out' / 'cancelled').
- Timeout: createGatewayEventHandler flushes a still-live clarify into the
transcript as a plain system line when the clarify tool's own tool.complete
fires. A live capture of the event stream confirmed this is the only point
where the overlay is still set after a timeout: the sequence is
clarify.request -> (timeout) -> tool.complete -> message.complete, with NO
intervening message.start/tool.start. On a real answer, answerClarify()
clears the overlay before tool.complete arrives, so the hook no-ops there
(no double-write); a per-requestId guard set is belt-and-braces.
- Explicit cancel: answerClarify('') persists the prompt as a system line
instead of a transient 'prompt cancelled' flash.
System lines always render (unlike trail lines, which /details can hide),
so the record reliably survives on screen as standard output.
Verified live in the TUI: an Esc-cancelled clarify now leaves the question +
options + '(cancelled - no selection)' in the transcript after the turn ends.
Tests: formatAbandonedClarify unit cases + gateway-handler behavioral cases
(persist on clarify tool.complete, no flush on a non-clarify tool.complete,
no double-persist on repeat tool.complete, no-op when the overlay was already
cleared by an answer).
A heavy --tui session (browser snapshots, large tool outputs) silently
OOM-killed the Node parent within minutes — closing the gateway child's
stdin, which the user saw only as a bare "gateway exited" / stdin EOF.
CLI was immune. Root cause: each completed tool's verbose trail line
embedded up to 16KB of result_text, persisted in transcript Msg.tools[]
for the whole session and rendered EXPANDED by default, so an Ink
render-node tree was built for every one of up to 800 messages at once.
That tree blew past Node's heap at a few hundred MB — far below the 2.5GB
memory-monitor exit threshold, so the death was never even attributed.
- text.ts: persisted verbose tool-trail blocks now cap to a small preview
(VERBOSE_TRAIL_MAX_CHARS=800/12 lines), not the 16KB live-render budget.
Retained trail strings drop ~17x (12.2MB -> 0.7MB at 800 msgs); the live
streaming tail still uses the larger LIVE_RENDER budget.
- tui_gateway/server.py: lower the gateway-side verbose text cap to match
(1KB/16 lines) so we stop shipping output the TUI no longer renders.
- memoryMonitor.ts: derive critical/high thresholds from the real V8 heap
ceiling (~88%/70%) instead of the hardcoded 2.5GB that killed the process
at 31% of an 8GB ceiling; add a one-shot onWarn early-warning on fast
sub-threshold heap growth so the next such death is diagnosable, not silent.
- entry.tsx: wire onWarn to a crash-log breadcrumb + stderr line.
Full tool output is unchanged in the agent context and SQLite session — this
is display/transport only, no behavior or context change.
Fixes#34095. Related #27282.
Tests: ui-tui text + new memoryMonitor suites (33 pass), python verbose-cap
guard (5 pass); full ui-tui suite shows no new failures vs pristine main.
E2E repro confirms the retention drop.
Two TUI polish fixes.
(1) Right-click copy now clears the highlight.
The right-click handler copied an active selection via onCopySelectionNoClear
(the copy-on-select variant that keeps the highlight during a drag) and never
cleared it, so after right-click-to-copy the selection stayed lit with no
confirmation and a follow-up right-click re-copied the stale range instead of
pasting. A successful right-click copy now clears the selection and notifies;
if the copy fails (no clipboard path) the highlight survives and we fall back
to the right-click paste handler, exactly as before.
(2) Group transcript blocks so boundaries read clearly.
Model replies, reasoning/tool trails, and system/error notes rendered with no
vertical separation, so distinct block types butted together and were hard to
scan. Group adjacent blocks by kind: one blank line opens only where the visual
group changes (model prose <-> reasoning/tool trails <-> notes), while a run of
same-kind blocks renders flush. The rule lives in domain/blockLayout.ts
(messageGroup + hasLeadGap) and is applied intrinsically in MessageLine via a
`prev` prop, which fixes the things ad-hoc per-block margins kept breaking:
- Streaming stability: the gap is derived from the stable predecessor, never
the live block's own changing text, so the actively-streaming reply computes
the same gap while it streams as the settled segment does once it flushes.
No reflow/jump.
- Transparent empty trails: a trail hidden by /details, or one carrying only a
token tally (the finalDetails segment message.complete appends), renders
nothing and is transparent to grouping (prevRenderedMsg skips it), so there
are no floating gaps, no doubled gap after a prompt, and no padded space
above the final reply. In the default/collapsed modes content-bearing trails
always render, so the grouping is a no-op there.
The virtual-height estimator counts the group-boundary line so scroll math
stays accurate before Yoga remeasures.
ui-tui/src/domain/blockLayout.ts (new), components/messageLine.tsx,
components/streamingAssistant.tsx, components/appLayout.tsx,
lib/virtualHeights.ts, app/useMainApp.ts.
Tests: blockLayout.test.ts (grouping + hidden/empty-trail visibility),
virtualHeights leadGap, app-mouse.test.ts copy behavior. Full ui-tui suite
green apart from 3 pre-existing local/env failures (cursorDrift, ink-resize,
virtualHeights user-prompt-width) unchanged from main.
Adds fuzzy subsequence matching with quality ranking to the model
pickers, replacing the WebUI's exact-substring filter and giving the
TUI a search where it previously had none.
- New fuzzy scorer (ui-tui/src/lib/fuzzy.ts + an identical copy at
web/src/lib/fuzzy.ts, since the two are separate TS packages with no
shared module). Matches a query as an ordered subsequence (so `g4o`
matches `gpt-4o`), scores by quality (exact > prefix > word-boundary >
contiguous > scattered) and returns matched character positions for
highlighting. Multi-token AND semantics (`clad snnt` -> claude-sonnet).
15 vitest tests cover the algorithm.
- WebUI ModelPickerDialog: ranked fuzzy filter on providers + models;
matched characters in model rows are highlighted via <mark>.
- TUI modelPicker: type-to-filter on the provider and model stages with
live ranking. Backspace edits the filter, Ctrl+U clears it, Esc clears
a non-empty filter before navigating back. Persist-global / disconnect
shortcuts moved from g/d to Ctrl+G / Ctrl+D so letters feed the filter.
Closes#30849
Adds the TUI half of the /rewind feature so the Ink terminal UI gets
the same affordance as the prompt_toolkit CLI.
Python side (tui_gateway/server.py):
- /rewind added to _PENDING_INPUT_COMMANDS so slash.exec rejects it
and the TUI falls through to command.dispatch (the only path with
access to live session state + memory hooks).
- New command.dispatch branch for name == "rewind":
v1 auto-picks the most recent user turn (Claude-Code-style single-
step undo), calls SessionDB.rewind_to_message, refreshes the
in-memory history, fires _memory_manager.on_session_switch with
rewound=True, and returns the new "prefill" payload.
- A dedicated picker overlay (multi-step rewind) is tracked as a
follow-up to #21910.
TS side (ui-tui/src/):
- New "prefill" variant on CommandDispatchResponse + asCommandDispatch
validator. Mirrors "send" but does NOT auto-submit; the client drops
the message into the composer for editing.
- createSlashHandler renders the optional notice via sys() and calls
ctx.composer.setInput(d.message), letting the user edit-and-resubmit
the rewound turn — the core UX promised by the issue.
Tests:
- 7 new tui_gateway tests covering prefill payload shape, in-memory
history truncation, DB soft-delete, memory-provider notification
(rewound=True), busy-session refusal, missing-session error, and
registry placement in _PENDING_INPUT_COMMANDS.
- Extended asCommandDispatch vitest covering the new prefill variant
(with + without notice, and rejection of malformed payloads).
Out of scope for v1 (tracked as #21910 follow-up):
- Dedicated picker overlay in Ink (the multi-step rewind UI). v1 auto-
picks the most recent user turn, matching the most common case.
- Gateway platforms (Telegram, Discord, etc.) — issue scopes v1 to
CLI + TUI only.