feat: rework the TUI transcript into collapsible episodes - #217
Conversation
0xKT
left a comment
There was a problem hiding this comment.
Reviewed the full diff on feat/tui_episode_transcript (17dd0a2) and ran the Python tests it touches: 75 passed. CI is green, the branch merges cleanly into main. No blocker.
Findings below are numbered R1..R10. IDs are stable - reply, commit, or close per ID. Tiers: before-merge = would fix in this PR; follow-up = fine as a separate PR; describe = code is fine, the description should say it; nit = optional.
| ID | tier | where | summary |
|---|---|---|---|
| R1 | before-merge | ui-tui/src/app/turnController.ts:259 | interrupt path lacks the no-episode fallback that the completion path has |
| R2 | before-merge | tests/ | new ToolResult / display_call contract has zero Python coverage |
| R3 | before-merge | ui-tui/src/app/interfaces.ts:135 | comment contradicts the shipped default, the sync policy, and the switch |
| R4 | follow-up | raven/agent/tools/registry.py:46 | execute is typed -> str but can return ToolResult; only the loop unwraps |
| R5 | describe | ui-tui/src/components/markdown.tsx:219 | wide-table cells now lose content, and cell markup is stripped |
| R6 | describe | ui-tui/src/components/queuedMessages.tsx:23 | queued panel / user-message restyle / gutter signature are not in the description |
| R7 | nit | ui-tui/src/domain/episodeSummary.ts:184 | three exports and one field are only reachable from tests, or never read |
| R8 | nit | ui-tui/src/lib/virtualHeights.ts:96 | episode height estimate omits inter-step marginTop rows and fold state |
| R9 | nit | ui-tui/src/app/turnController.ts:574 | switching /transcript mid-turn (legacy -> episodes) drops what already streamed |
| R10 | nit | ui-tui/src/app/useMainApp.ts:513 | in episodes mode a clarify answer survives only as a 200-char preview |
R1 [before-merge] interrupt path lacks the no-episode fallback
- where:
ui-tui/src/app/turnController.ts:259(compare:574) - problem:
recordMessageCompletefalls back to the legacy segments/trail when noepisode.startever arrived, with a comment saying history must not be silently lost (:583-593).finalizeInterruptedTurnreturns early in episodes mode without that fallback, so in the same situation an interrupt drops the accrued segments and tool trail and commits only*[interrupted]*. - trigger: episodes mode (now the default) + a whole turn with zero
episode.start+ Esc. Realistic path is a long-running olderraven gatewaydaemon serving a newer client - the same skew:583was written for. - fix: in the
episodesModebranch, whenworkEpisodes.length === 0, commitsegments(and the pending-tools message) exactly like the legacy branch below it before appending the interrupted indicator. - verify: extend
ui-tui/src/__tests__/turnControllerEpisodes.test.tswith an interrupt case that emits tools/segments but noepisode.start, and assert the trail message is committed.
R2 [before-merge] no Python coverage for the new tool-result contract
- where:
raven/agent/tools/base.py:9(ToolResult),base.py:84(display_call),raven/agent/loop/main.py:1628(unwrap branch),raven/agent/tools/ask_user.py:151 - problem:
grep -r ToolResult tests/andgrep -r display_call tests/return nothing, and no test instantiatesAskUserTool. Every fake tool in the suite returnsstr, so the unwrap branch at main.py:1628 is never entered - the split between model-facing and display-facing text is unpinned in the exact place the PR introduces it. - fix: two small tests. (a)
AskUserTool.executewith a stubbed broker returns aToolResultwhosemodel_textkeeps theUser answered: ...phrasing and whosedisplay_textis thequestion -> answerline(s). (b) a fake tool returningToolResultin the loop: assertcontext.add_tool_resultreceivesmodel_textwhile the emittedtool.completepreview carriesdisplay_text. - verify:
uv run pytest tests/test_agent_loop_run_emit.py -qplus a newtests/test_ask_user_tool.py(naming per AGENTS.md 5.1).
R3 [before-merge] stale comment on the transcript flag
- where:
ui-tui/src/app/interfaces.ts:135 - problem: the comment reads "Default legacy until the episodes path is complete; switch via config display.transcript". All three claims are now wrong: the default is
episodes(ui-tui/src/app/uiStore.ts:32), it is deliberately not config-synced (ui-tui/src/app/useConfigSync.ts:157), and the switch is the/transcriptsession command (ui-tui/src/app/slash/commands/core.ts:256). - fix: rewrite the comment to state the shipped behaviour: default
episodes, session-scoped, runtime-only via/transcript, never persisted.
R4 [follow-up] ToolRegistry.execute is typed -> str but can return ToolResult
- where:
raven/agent/tools/registry.py:46(annotation and pass-through at:72), unwrap only atraven/agent/loop/main.py:1628 - problem:
Tool.executewas widened tostr | ToolResult, but the registry boundary was not. Callers other than the loop receive the dataclass:raven/cli/_proactive_stack.py:530passesagent.tools- which always includesAskUserTool(raven/agent/loop/main.py:630) - to the sentinelActionExecutor.raven/proactive_engine/sentinel/executor/action_executor.py:270calls it and:280assigns the value tooutput_text: str. There is no tool allow-list forexec_kind=tool, so a plannedask_useraction formats the dataclass repr into the user-facing reply. It does not raise; the chain is long and low-probability (it also needs a live broker and a non-emptycidContextVar), but nothing on it blocks the type.raven/tracing/semconv.py:679degradestool.result_previewto the repr. Nothing raises - bothto_json_textandpersist_artifactusedefault=str(raven/tracing/store.py:46,:141) - so the trace is written, just wrapped in a repr.raven/agent/subagent/manager.py:212,raven/context_engine/segments/curator.py:222,raven/agent/tools/tool_search.py:133: latent today (those registries carry noask_user), but the first two put the value straight into{"role": "tool", "content": ...}andwrap_untrustedfalls back tostr(...), so the next tool that adoptsToolResultputs a repr into the model's context.
- fix: unwrap at the boundary - have
registry.executereturnmodel_textand expose the display string out of band (return tuple, or an attribute the loop reads) - instead of asking each caller toisinstance-check. Minimal form is around ten lines, so this could also land in this PR if you prefer. - verify: after the change,
grep -rn "isinstance(.*ToolResult" raven/should hit the registry only.
R5 [describe] wide tables now lose content
- where:
ui-tui/src/components/markdown.tsx:219(clipWidth),:319(clipWidth(stripInlineMarkup(...))) - problem: when a table is wider than the terminal, cells are clipped to a single line with an ellipsis and there is no way to expand them in the TUI; previously the content soft-wrapped, which looked bad but kept every character. Cell-level inline markup (bold, code, links) is also stripped rather than rendered.
- fix: no code change requested - the trade is defensible and the code comment states it. But "Markdown table columns fit the available width with CJK-aware clipping" reads like a pure layout fix; please say in the description that over-wide cells are truncated and cell markup is flattened.
R6 [describe] undocumented drive-by changes
- where:
ui-tui/src/components/queuedMessages.tsx:23(panel restyle:queued (N)header dropped, edit hints moved to a trailing line),ui-tui/src/domain/roles.ts:11+ui-tui/src/components/messageLine.tsx:184(user-message accent chevron and bold),ui-tui/src/lib/inputMetrics.ts:177(transcriptGutterWidthsignature) - problem: all three are reasonable on their own, none is mentioned in the description. The queued-panel change is not an information regression (the count is still derivable from the window plus
...and N more, and the edited row stays visible and highlighted), but it is a visible redesign a reviewer would not expect from the stated scope. - fix: one sentence in the description covering the queued panel, the user-message tier, and the gutter-width unification.
R7 [nit] exports and a field with no non-test reader
- where:
ui-tui/src/domain/episodeSummary.ts:184(episodeLabel),:208(turnSummary),:115(toolLine);ui-tui/src/app/turnController.ts:139(episodeIndex, written at:677and:924, never read) - problem: the three exports are reachable only from
episodeSummary.test.ts;episodeIndexis write-only. - fix: drop them (and the tests that only exist to cover them), or wire them into the view if they are meant for the collapsed-turn label.
R8 [nit] episode height estimate under-counts
- where:
ui-tui/src/lib/virtualHeights.ts:96-124vsui-tui/src/components/episodeView.tsx:397,:427 - problem: each step after one that displayed tool rows renders with
marginTop={1}, and the estimator counts no row for it; fold state (ep:/rsn:/ open diff) is not in the height key either. Both make the estimate low, which is the stated input to the stale-cell symptom this PR set out to fix. The measured-height sync (ui-tui/src/app/useMainApp.ts:302) probably absorbs it, so this may only be a transient overdraw. - fix: add one row per step whose predecessor showed tools, and include the open-fold set in
messageHeightKeyif that is cheap.
R9 [nit] mid-turn /transcript switch loses the earlier half
- where:
ui-tui/src/app/turnController.ts:574 - problem: switching legacy -> episodes during a turn makes
recordMessageCompletetake the episodes branch, which commits no segments, so whatever streamed before the switch is dropped. The reverse direction is lossless (segments are recorded throughout). - fix: either ignore a mode switch until the turn ends, or keep the legacy commit when the turn started in legacy mode.
R10 [nit] clarify answer survives only as a 200-char preview
- where:
ui-tui/src/app/useMainApp.ts:513, truncation atui-tui/src/app/turnController.ts:789 - problem: in episodes mode the clarify answer is no longer appended as a user message; it reaches the transcript only through the tool's
resultPreview, which is sliced to 200 chars. De-duplicating is right, but a long answer now loses its tail from the visible history. - fix: keep the full answer on the episode tool (a separate field from the 200-char preview) and render it in the expanded row.
Making episodes the default looks right to me: /transcript legacy is an instant, session-scoped escape hatch, the legacy bookkeeping runs in parallel throughout, and the completion path already has the old-gateway fallback. R1 is the one place where the escape hatch does not help, which is why it is the only rendering item I would land before merge.
Group every model call into one episode (its reasoning, narration and tools)
and render a turn as a flat stream of steps instead of interleaved thinking
blocks and tool lines.
Rendering:
- A step with no narration collapses to one summary line ("reasoning for 8s,
read 2 files"); a narrated or running step shows a reasoning fold, the
narration as prose, then its tool rows.
- Reasoning expands while it streams and folds once the step produces anything
visible (narration, a tool, or the answer text).
- A run of same-name calls renders as a tree with one header and a child row
per call.
- In-flight tools report elapsed time; finished ones keep their measured span.
- Rows are bound to the transcript width and clipped with an ellipsis, so long
details no longer overflow the container and soft-wrap.
Tool display contract:
- Tool.display_call and the new ToolResult keep the model-facing text separate
from what the transcript shows. ask_user now renders its question and the
answer instead of a raw arguments blob, pairing each question with its answer
when several are asked at once.
- Tools with no explicit verb derive a readable one from their own name, so a
newly added tool renders sensibly without touching the table.
- The call preview picks the query, question or command out of the arguments
rather than the first value, which could be a numeric flag.
Also:
- Episodes are now the default transcript style; "/transcript legacy" stays as
an escape hatch.
- An interrupt commits the collapsed episode view instead of dumping the raw
expanded segments.
- The virtual-height estimate accounts for episode rows; without it the
transcript reserved too few rows and left stale cells on screen.
- Markdown table columns fit the available width with CJK-aware clipping.
- The deep-research offer states that a regular search is the user's chosen
path, not a failure, so the model stops narrating it as a fallback.
Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
R1: an interrupt with no episode.start dropped the turn's history. finalizeInterruptedTurn returned early in episodes mode without the no-episode fallback recordMessageComplete has, so an older gateway that never emits episode.start plus Esc committed only the interrupted marker. It now commits the segment trail exactly like the completion path. R2: the ToolResult / display_call contract had no Python coverage -- every fake tool in the suite returned a bare str, so the unwrap branch never ran. Adds tests for ask_user's model-facing vs display-facing split and for the loop, asserting add_tool_result receives model_text while tool.complete carries display_text. R4: ToolRegistry.execute was typed -> str but passed ToolResult through, so the sentinel action executor, the subagent manager, the curator and tracing could format a dataclass repr into model context and user-facing replies. The registry now unwraps at the boundary and returns ToolOutput, a str subclass carrying the display string, so every existing caller keeps a real str and only the loop reads the extra field. R3: the transcript-mode comment claimed a legacy default, config sync and a config switch; all three are wrong. R7: drops episodeLabel, turnSummary, toolLine and episodeIndex, which no non-test code read. R8: the episode height estimate now counts the inter-step margin row the view renders, matching episodeView's prevShowedTools rule. Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
17dd0a2 to
dafaaeb
Compare
Summary
Reworks the TUI transcript so a turn reads as a sequence of steps instead of
interleaved thinking blocks and tool lines. Every model call becomes one
"episode" holding its reasoning, narration and tools.
Rendering
read 2 files"); a narrated or running step shows a reasoning fold, the
narration as prose, then its tool rows.
visible (narration, a tool, or the answer text).
call, so a burst of parallel searches no longer fills the screen.
the bound, long details overflowed the container and the terminal soft-wrapped
them, which looked like stray blank lines and edge-cut text.
Tool display contract
Tools now own how they appear.
Tool.display_calland the newToolResult(
model_textplus optionaldisplay_text) separate what the model reads fromwhat the transcript shows:
ask_userrenders its question and the answer instead of a raw argumentsblob, pairing each question with its answer when several are asked at once.
newly added tool renders sensibly with no changes to the verb table (it is an
overrides list, not a required registry).
instead of the first value, which could be a numeric flag.
Also
/transcript legacystays as anescape hatch and the legacy path is untouched.
expanded segments.
transcript reserved too few rows and left stale cells on screen.
A cell wider than its column is truncated with an ellipsis and cannot be
expanded in the TUI (it used to soft-wrap, which kept every character but
looked broken), and inline markup inside a cell is flattened to plain text
rather than rendered.
path, not a failure, so the model stops narrating it as a broken fallback.
Also in this diff, beyond the stated scope
queued (N)header is gone and theedit hints moved to a trailing line. The count stays derivable from the window
plus
...and N more, and the edited row is still highlighted.reads as a prompt next to the assistant's prose.
transcriptGutterWidthtakes the tool glyph, so the user and assistant guttersare derived from one place instead of two hardcoded widths.
Review follow-ups
Addressed in the second commit, by the reviewer's IDs:
episode.startdropped the accrued segments andtool trail; it now falls back to the legacy trail exactly like the completion
path, with a regression test.
ToolResult/display_callcontract had no Python coverage. Addstests/test_ask_user_tool.pyand a loop test assertingadd_tool_resultgetsmodel_textwhiletool.completecarriesdisplay_text.ToolRegistry.executenow unwraps at the boundary and returnsToolOutput, astrsubclass carrying the display string, so the sentinelaction executor, subagent manager, curator and tracing can no longer receive a
dataclass.
isinstance(.*ToolResult)hits the registry only.episodeLabel,turnSummary,toolLineandepisodeIndex./transcriptswitch) and R10 (clarify answer beyond the200-char preview) are left for a follow-up: both add state or a wire field
rather than fixing this diff.
Type
Verification
New tests cover the episode commit path (including interrupt), the tool summary
and verb fallback, the argument preview, and the rendered layout.
Risk
The wire gains one optional
tool.startfield (display); older payloadswithout it fall back to the generic preview.
executemay now return either astring or a
ToolResult, so existing tools are unaffected. Rollback is/transcript legacyfor the view, or reverting this commit.Known follow-ups, not addressed here: occasional screen garbling seen when
messages are queued (root cause not yet identified), and
web_searchbeingregistered even without an API key.
Related Issues
N/A