Skip to content

feat: rework the TUI transcript into collapsible episodes - #217

Merged
0xKT merged 2 commits into
mainfrom
feat/tui_episode_transcript
Jul 29, 2026
Merged

feat: rework the TUI transcript into collapsible episodes#217
0xKT merged 2 commits into
mainfrom
feat/tui_episode_transcript

Conversation

@arelchan

@arelchan arelchan commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

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

  • 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: one header plus a child row per
    call, so a burst of parallel searches no longer fills the screen.
  • In-flight tools report elapsed time; finished ones keep their measured span.
  • Rows are bound to the transcript width and clipped with an ellipsis. Without
    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_call and the new ToolResult
(model_text plus optional display_text) separate what the model reads from
what the transcript shows:

  • ask_user 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 with no changes to the verb table (it is an
    overrides list, not a required registry).
  • The call preview picks the query, question or command out of the arguments
    instead of the first value, which could be a numeric flag.

Also

  • Episodes are the default transcript style; /transcript legacy stays as an
    escape hatch and the legacy path is untouched.
  • 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.
    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.
  • 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 broken fallback.

Also in this diff, beyond the stated scope

  • The queued-message panel is restyled: the queued (N) header is gone and the
    edit hints moved to a trailing line. The count stays derivable from the window
    plus ...and N more, and the edited row is still highlighted.
  • User messages get their own tier: an accent chevron and bold text, so a prompt
    reads as a prompt next to the assistant's prose.
  • transcriptGutterWidth takes the tool glyph, so the user and assistant gutters
    are derived from one place instead of two hardcoded widths.

Review follow-ups

Addressed in the second commit, by the reviewer's IDs:

  • R1 an interrupt with no episode.start dropped the accrued segments and
    tool trail; it now falls back to the legacy trail exactly like the completion
    path, with a regression test.
  • R2 the ToolResult / display_call contract had no Python coverage. Adds
    tests/test_ask_user_tool.py and a loop test asserting add_tool_result gets
    model_text while tool.complete carries display_text.
  • R3 the transcript-mode comment was wrong on all three claims.
  • R4 ToolRegistry.execute now unwraps at the boundary and returns
    ToolOutput, a str subclass carrying the display string, so the sentinel
    action executor, subagent manager, curator and tracing can no longer receive a
    dataclass. isinstance(.*ToolResult) hits the registry only.
  • R7 drops episodeLabel, turnSummary, toolLine and episodeIndex.
  • R8 the height estimate counts the inter-step margin row.
  • R9 (mid-turn /transcript switch) and R10 (clarify answer beyond the
    200-char preview) are left for a follow-up: both add state or a wire field
    rather than fixing this diff.

Type

  • Fix
  • Feature
  • Docs
  • CI / tooling
  • Refactor
  • Other

Verification

cd ui-tui && npm run type-check          # clean
cd ui-tui && npm test                    # 927 passed | 3 skipped
cd ui-tui && npm run lint                # 0 errors, no warnings in touched files
cd ui-tui && npx prettier --check "src/**/*.{ts,tsx}"   # clean
uv run ruff check raven/ tests/          # All checks passed
uv run ruff format --check raven/ tests/ # 758 files already formatted
make test-python                         # 4621 passed, 2 pre-existing failures
cd ui-tui && npx vitest run              # 925 passed | 3 skipped

New tests cover the episode commit path (including interrupt), the tool summary
and verb fallback, the argument preview, and the rendered layout.

  • Relevant tests pass locally
  • Relevant lint / type checks pass locally
  • User-facing docs or screenshots are updated when needed

Risk

  • Security impact considered
  • Backward compatibility considered
  • Rollback path is clear for risky changes

The wire gains one optional tool.start field (display); older payloads
without it fall back to the generic preview. execute may now return either a
string or a ToolResult, so existing tools are unaffected. Rollback is
/transcript legacy for 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_search being
registered even without an API key.

Related Issues

N/A

@0xKT 0xKT left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: recordMessageComplete falls back to the legacy segments/trail when no episode.start ever arrived, with a comment saying history must not be silently lost (:583-593). finalizeInterruptedTurn returns 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 older raven gateway daemon serving a newer client - the same skew :583 was written for.
  • fix: in the episodesMode branch, when workEpisodes.length === 0, commit segments (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.ts with an interrupt case that emits tools/segments but no episode.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/ and grep -r display_call tests/ return nothing, and no test instantiates AskUserTool. Every fake tool in the suite returns str, 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.execute with a stubbed broker returns a ToolResult whose model_text keeps the User answered: ... phrasing and whose display_text is the question -> answer line(s). (b) a fake tool returning ToolResult in the loop: assert context.add_tool_result receives model_text while the emitted tool.complete preview carries display_text.
  • verify: uv run pytest tests/test_agent_loop_run_emit.py -q plus a new tests/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 /transcript session 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 at raven/agent/loop/main.py:1628
  • problem: Tool.execute was widened to str | ToolResult, but the registry boundary was not. Callers other than the loop receive the dataclass:
    • raven/cli/_proactive_stack.py:530 passes agent.tools - which always includes AskUserTool (raven/agent/loop/main.py:630) - to the sentinel ActionExecutor. raven/proactive_engine/sentinel/executor/action_executor.py:270 calls it and :280 assigns the value to output_text: str. There is no tool allow-list for exec_kind=tool, so a planned ask_user action 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-empty cid ContextVar), but nothing on it blocks the type.
    • raven/tracing/semconv.py:679 degrades tool.result_preview to the repr. Nothing raises - both to_json_text and persist_artifact use default=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 no ask_user), but the first two put the value straight into {"role": "tool", "content": ...} and wrap_untrusted falls back to str(...), so the next tool that adopts ToolResult puts a repr into the model's context.
  • fix: unwrap at the boundary - have registry.execute return model_text and expose the display string out of band (return tuple, or an attribute the loop reads) - instead of asking each caller to isinstance-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 (transcriptGutterWidth signature)
  • 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 :677 and :924, never read)
  • problem: the three exports are reachable only from episodeSummary.test.ts; episodeIndex is 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-124 vs ui-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 messageHeightKey if 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 recordMessageComplete take 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 at ui-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.

arelchan and others added 2 commits July 28, 2026 21:12
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>
@arelchan
arelchan force-pushed the feat/tui_episode_transcript branch from 17dd0a2 to dafaaeb Compare July 29, 2026 02:53
@0xKT
0xKT self-requested a review July 29, 2026 03:46
@0xKT
0xKT merged commit 0640a25 into main Jul 29, 2026
9 checks passed
@0xKT
0xKT deleted the feat/tui_episode_transcript branch July 29, 2026 07:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants