diff --git a/raven/agent/loop/main.py b/raven/agent/loop/main.py
index 3c097cf6..5a17f8c1 100644
--- a/raven/agent/loop/main.py
+++ b/raven/agent/loop/main.py
@@ -1433,6 +1433,7 @@ async def _run_agent_loop(
on_token_delta: Callable[[str], Awaitable[None]] | None = None,
on_reasoning_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_event: Callable[[str, dict], Awaitable[None]] | None = None,
+ on_episode_start: Callable[[int], Awaitable[None]] | None = None,
usage_sink: dict[str, Any] | None = None,
drain: Drain | None = None,
) -> tuple[str | None, list[str], list[dict], TurnOutcome]:
@@ -1483,6 +1484,11 @@ async def _run_agent_loop(
effective_model,
)
+ # Mark the episode boundary (one per model call) so an outlet can
+ # group this call's reasoning + text + tools into a single step.
+ if on_episode_start is not None:
+ await on_episode_start(iteration - 1)
+
# Merge any INJECT-ed user messages (BusyPolicy.INJECT) before this
# iteration's LLM call. Media-carrying injects keep their file
# paths in the text so nothing is silently dropped.
@@ -1601,24 +1607,35 @@ async def _run_agent_loop(
# second emit here would double it.
emit_tool_event = on_tool_event is not None and tool_call.name != "message"
if emit_tool_event:
+ _tool = self.tools.get(tool_call.name)
await on_tool_event(
"start",
{
"tool_call_id": tool_call.id,
"name": tool_call.name,
"arguments": tool_call.arguments,
+ # Tool-authored call label; None -> UI derives one.
+ "display": _tool.display_call(tool_call.arguments) if _tool else None,
},
)
tool_t0 = time.monotonic()
result = await self.tools.execute(tool_call.name, tool_call.arguments)
duration_ms = int((time.monotonic() - tool_t0) * 1000)
- result_str = str(result)
- preview = result_str.replace("\n", " ")[:200]
+ # The registry already unwrapped any ToolResult: `result` is
+ # the model-facing text, with the optional display string
+ # riding along on it (ToolOutput). The model always gets the
+ # model text; the UI preview prefers the display string.
+ model_text = str(result)
+ display_src = getattr(result, "display_text", None) or model_text
+ # The log stays one line; the UI event keeps newlines so a
+ # tool that reports several items (e.g. ask_user's
+ # question -> answer pairs) renders one row each.
+ preview = display_src[:200]
logger.info(
"Tool result: {} duration={}ms result={}",
tool_call.name,
duration_ms,
- preview,
+ preview.replace("\n", " ")[:200],
)
if emit_tool_event:
await on_tool_event(
@@ -1626,13 +1643,13 @@ async def _run_agent_loop(
{
"tool_call_id": tool_call.id,
"result_preview": preview,
- "truncated": len(result_str) > 200,
+ "truncated": len(display_src) > 200,
},
)
- messages = self.context.add_tool_result(messages, tool_call.id, tool_call.name, result)
+ messages = self.context.add_tool_result(messages, tool_call.id, tool_call.name, model_text)
# #1b Track consecutive same-tool deterministic failures
# (transient errors excluded — a retry would clear those).
- if _is_hard_tool_failure(result):
+ if _is_hard_tool_failure(model_text):
if tool_call.name == loop_fail_tool:
loop_fail_streak += 1
else:
@@ -1872,6 +1889,7 @@ async def _process_message(
on_token_delta: Callable[[str], Awaitable[None]] | None = None,
on_reasoning_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_event: Callable[[str, dict], Awaitable[None]] | None = None,
+ on_episode_start: Callable[[int], Awaitable[None]] | None = None,
usage_sink: dict[str, Any] | None = None,
origin: Origin | None = None,
drain: Drain | None = None,
@@ -2109,6 +2127,7 @@ async def _extract():
on_token_delta=on_token_delta,
on_reasoning_delta=on_reasoning_delta,
on_tool_event=on_tool_event,
+ on_episode_start=on_episode_start,
usage_sink=usage_sink,
drain=drain,
)
@@ -2282,6 +2301,7 @@ async def run_turn(
"""
from raven.proactive_engine.schedulers.cron.tool import CronTool
from raven.spine.events import (
+ EpisodeStart,
MediaOut,
Notice,
NoticeKind,
@@ -2329,6 +2349,9 @@ async def on_reasoning(text: str) -> None:
if text:
await emit(Reasoning(content=text))
+ async def on_episode(index: int) -> None:
+ await emit(EpisodeStart(index=index))
+
async def on_tool(phase: str, info: dict[str, Any]) -> None:
if phase == "start":
await emit(
@@ -2337,6 +2360,7 @@ async def on_tool(phase: str, info: dict[str, Any]) -> None:
tool_call_id=info["tool_call_id"],
name=info["name"],
arguments=info["arguments"],
+ display=info.get("display"),
)
)
else:
@@ -2439,6 +2463,7 @@ async def _route_deep_research(kind: str, text: str) -> None:
on_token_delta=on_token if stream else None,
on_reasoning_delta=on_reasoning if stream else None,
on_tool_event=on_tool,
+ on_episode_start=on_episode if stream else None,
usage_sink=usage_sink,
origin=req.origin,
drain=drain,
diff --git a/raven/agent/tools/ask_user.py b/raven/agent/tools/ask_user.py
index 9ec08ac2..9ca9bb6c 100644
--- a/raven/agent/tools/ask_user.py
+++ b/raven/agent/tools/ask_user.py
@@ -11,7 +11,7 @@
from contextvars import ContextVar
from typing import Any
-from raven.agent.tools.base import Tool
+from raven.agent.tools.base import Tool, ToolResult
from raven.tui_rpc.question_broker import QuestionBroker
@@ -102,7 +102,19 @@ def parameters(self) -> dict[str, Any]:
"required": ["questions"],
}
- async def execute(self, questions: list[dict[str, Any]], **kwargs: Any) -> str:
+ def display_call(self, args: dict[str, Any]) -> str | None:
+ """Show the question itself, not the raw arguments blob. A batch keeps
+ every question visible (joined) so the row still says what was asked;
+ the UI elides whatever does not fit."""
+ questions = [str(q.get("question", "")).strip() for q in args.get("questions") or []]
+ questions = [q for q in questions if q]
+ if not questions:
+ return None
+ if len(questions) == 1:
+ return questions[0]
+ return " | ".join(questions)
+
+ async def execute(self, questions: list[dict[str, Any]], **kwargs: Any) -> "str | ToolResult":
cid = self._cid.get()
if not self._broker:
return "Error: ask_user not configured (no question broker)"
@@ -111,7 +123,11 @@ async def execute(self, questions: list[dict[str, Any]], **kwargs: Any) -> str:
if not questions:
return "Error: ask_user requires at least one question"
- results: list[str] = []
+ told: list[str] = [] # model-facing
+ # Human-facing display: one "question -> answer" line per question, so a
+ # batch shows which answer belongs to which question. The UI renders each
+ # line as its own row.
+ picks: list[str] = []
for entry in questions:
question = str(entry.get("question", "")).strip()
if not question:
@@ -124,10 +140,15 @@ async def execute(self, questions: list[dict[str, Any]], **kwargs: Any) -> str:
choices=[str(o) for o in options],
)
if answer:
- results.append(f'User answered: "{question}" -> "{answer}".')
+ told.append(f'User answered: "{question}" -> "{answer}".')
+ picks.append(f"{question} -> {answer}" if len(questions) > 1 else str(answer))
else:
- results.append(f'For "{question}": (user did not answer; proceed with best judgment).')
+ told.append(f'For "{question}": (user did not answer; proceed with best judgment).')
+ picks.append(f"{question} -> (no answer)" if len(questions) > 1 else "(no answer)")
- if not results:
+ if not told:
return "Error: ask_user requires at least one non-empty question"
- return " ".join(results) + " Continue."
+ return ToolResult(
+ model_text=" ".join(told) + " Continue.",
+ display_text="\n".join(picks) if len(picks) > 1 else f"answered: {picks[0]}",
+ )
diff --git a/raven/agent/tools/base.py b/raven/agent/tools/base.py
index ba04ff1d..5fe74093 100644
--- a/raven/agent/tools/base.py
+++ b/raven/agent/tools/base.py
@@ -1,9 +1,46 @@
"""Base class for agent tools."""
from abc import ABC, abstractmethod
+from dataclasses import dataclass
from typing import Any
+@dataclass
+class ToolResult:
+ """A tool's output split into the model-facing text and an optional
+ human-facing display string.
+
+ ``execute`` may return a bare ``str`` (model text only — the UI falls back
+ to a generic preview of it) or this, when the tool wants a cleaner
+ transcript rendering than what it feeds the model. ``display_text`` must be
+ built from the tool's own execution data, not by re-parsing ``model_text``.
+ """
+
+ model_text: str
+ display_text: str | None = None
+
+
+class ToolOutput(str):
+ """What :meth:`ToolRegistry.execute` hands back: the model-facing text with
+ the optional display string attached.
+
+ A ``str`` subclass on purpose. Every caller of the registry boundary --
+ the sentinel action executor, the subagent manager, the context curator,
+ tracing -- puts the return value straight into a message, a preview or an
+ artifact, so the boundary has to return something that *is* a str; handing
+ them a :class:`ToolResult` would format its repr into model context and
+ user-facing replies. The agent loop reads ``display_text`` off it to render
+ the transcript row.
+ """
+
+ display_text: str | None
+
+ def __new__(cls, model_text: str, display_text: str | None = None) -> "ToolOutput":
+ out = super().__new__(cls, model_text)
+ out.display_text = display_text
+ return out
+
+
class Tool(ABC):
"""
Abstract base class for agent tools.
@@ -52,7 +89,7 @@ def parameters(self) -> dict[str, Any]:
pass
@abstractmethod
- async def execute(self, **kwargs: Any) -> str:
+ async def execute(self, **kwargs: Any) -> "str | ToolResult":
"""
Execute the tool with given parameters.
@@ -60,10 +97,21 @@ async def execute(self, **kwargs: Any) -> str:
**kwargs: Tool-specific parameters.
Returns:
- String result of the tool execution.
+ The model-facing result as a ``str``, or a :class:`ToolResult` when
+ the tool wants a distinct human-facing display string.
"""
pass
+ def display_call(self, args: dict[str, Any]) -> str | None:
+ """Human-facing one-line summary of a call to this tool.
+
+ ``None`` (the default) lets the UI derive a generic summary from the
+ arguments. Override only when a tool wants a cleaner label than the
+ generic one (e.g. ask_user showing just its question, not the raw
+ arguments blob).
+ """
+ return None
+
def cast_params(self, params: dict[str, Any]) -> dict[str, Any]:
"""Apply safe schema-driven casts before validation."""
schema = self.parameters or {}
diff --git a/raven/agent/tools/deep_research.py b/raven/agent/tools/deep_research.py
index fa711cc9..564cecc7 100644
--- a/raven/agent/tools/deep_research.py
+++ b/raven/agent/tools/deep_research.py
@@ -311,7 +311,11 @@ def _receipt(status: str, report_ref: str | None) -> str:
# well-aligned model treats tool output as data and refuses embedded commands (a
# strong "do NOT answer / reply with only this" was rejected as a prompt injection
# in testing). (The older receipt/ack strings below keep mild imperatives.)
-_USE_REGULAR = "deep_research did not run: the user chose a regular web search instead."
+_USE_REGULAR = (
+ "The user chose a regular web search for this query, not the deep-research engine. "
+ "Go ahead and answer it with a normal web search -- this is the user's chosen path, "
+ "not a failure or a fallback."
+)
# Shown to the USER directly via the broker prompt below. Command FIRST so it
# survives the scrollback's truncated tool-call line (cut at ~60 chars); the live
# picker wraps and shows it all anyway.
diff --git a/raven/agent/tools/registry.py b/raven/agent/tools/registry.py
index 7ca4135f..061bd84b 100644
--- a/raven/agent/tools/registry.py
+++ b/raven/agent/tools/registry.py
@@ -3,7 +3,7 @@
import asyncio
from typing import Any
-from raven.agent.tools.base import Tool
+from raven.agent.tools.base import Tool, ToolOutput, ToolResult
from raven.tracing import semconv, trace
@@ -67,9 +67,17 @@ async def execute(self, name: str, params: dict[str, Any]) -> str:
else:
result = await asyncio.wait_for(tool.execute(**params), timeout=ceiling)
- if isinstance(result, str) and result.startswith("Error"):
- return result + _hint
- return result
+ # Unwrap ToolResult here, at the boundary: `execute` promises model
+ # text to every caller, and only the agent loop wants the display
+ # string (which rides along on ToolOutput).
+ if isinstance(result, ToolResult):
+ model_text, display_text = result.model_text, result.display_text
+ else:
+ model_text, display_text = str(result), None
+
+ if model_text.startswith("Error"):
+ return ToolOutput(model_text + _hint, display_text)
+ return ToolOutput(model_text, display_text)
except asyncio.TimeoutError:
return f"Error: Tool '{name}' timed out after {ceiling:.0f}s." + _hint
except Exception as e:
diff --git a/raven/spine/__init__.py b/raven/spine/__init__.py
index 626c0e51..33bd80df 100644
--- a/raven/spine/__init__.py
+++ b/raven/spine/__init__.py
@@ -7,6 +7,7 @@
from raven.spine.events import (
Deliverable,
+ EpisodeStart,
MediaOut,
Notice,
NoticeKind,
@@ -32,6 +33,7 @@
"ChatType",
"Deliverable",
"Emit",
+ "EpisodeStart",
"Media",
"MediaOut",
"Notice",
diff --git a/raven/spine/events.py b/raven/spine/events.py
index 342262f5..085b97f0 100644
--- a/raven/spine/events.py
+++ b/raven/spine/events.py
@@ -66,6 +66,8 @@ class ToolEvent:
tool_call_id: str
name: str = ""
arguments: dict[str, Any] | None = None
+ # Tool-authored call label for the start phase; None -> UI derives one.
+ display: str | None = None
result_preview: str = ""
truncated: bool = False
source: Source | None = None
@@ -110,7 +112,18 @@ class Notice:
conversation_id: str | None = None
-RunnerEvent = ToolEvent | Text | MediaOut | StreamDelta | Reasoning | Notice
+@dataclass(frozen=True)
+class EpisodeStart:
+ """Boundary marker: a new model call (episode) begins. ``index`` is the
+ 0-based step within the turn. Outlets that group a turn into per-call
+ episodes use it to start a fresh bucket; others ignore it."""
+
+ index: int
+ source: Source | None = None
+ conversation_id: str | None = None
+
+
+RunnerEvent = ToolEvent | Text | MediaOut | StreamDelta | Reasoning | Notice | EpisodeStart
# Same union, named for its delivery role: what the hub routes and an Outlet renders.
Deliverable = RunnerEvent
TurnEvent = TurnStarted | TurnFailed | TurnEnded | RunnerEvent
diff --git a/raven/tui_rpc/models.py b/raven/tui_rpc/models.py
index 1896a878..7dac4788 100644
--- a/raven/tui_rpc/models.py
+++ b/raven/tui_rpc/models.py
@@ -139,6 +139,15 @@ class MessageStartEvent(_Strict):
payload: MessageStartPayload
+class EpisodeStartPayload(_Strict):
+ index: int
+
+
+class EpisodeStartEvent(_Strict):
+ type: Literal["episode.start"]
+ payload: EpisodeStartPayload
+
+
class TokenDeltaPayload(_Strict):
text: str
@@ -161,6 +170,7 @@ class ToolStartPayload(_Strict):
tool_call_id: str
name: str
arguments: dict[str, JsonValue]
+ display: str | None = None
class ToolStartEvent(_Strict):
@@ -226,6 +236,7 @@ class CronDeliveredEvent(_Strict):
TurnEvent = Annotated[
Union[
MessageStartEvent,
+ EpisodeStartEvent,
TokenDeltaEvent,
ThinkingDeltaEvent,
ToolStartEvent,
@@ -897,6 +908,7 @@ class ToolsConfigureParams(_Strict):
"SessionExportParams",
"SessionExportResult",
"MessageStartEvent",
+ "EpisodeStartEvent",
"TokenDeltaEvent",
"ThinkingDeltaEvent",
"ToolStartEvent",
diff --git a/raven/tui_rpc/spine.py b/raven/tui_rpc/spine.py
index 9e9e2881..f239180b 100644
--- a/raven/tui_rpc/spine.py
+++ b/raven/tui_rpc/spine.py
@@ -23,6 +23,7 @@
from raven.agent.tools.message import MessageTool
from raven.spine import (
Deliverable,
+ EpisodeStart,
Origin,
OriginPools,
Reasoning,
@@ -143,6 +144,7 @@ async def deliver(self, out: Deliverable) -> None:
"tool_call_id": out.tool_call_id,
"name": out.name,
"arguments": out.arguments or {},
+ "display": out.display,
},
},
)
@@ -164,6 +166,10 @@ async def deliver(self, out: Deliverable) -> None:
# reply uses, so message.complete finalizes it like any other text.
if out.content:
await self._emitter.emit(cid, {"type": "token.delta", "payload": {"text": out.content}})
+ elif isinstance(out, EpisodeStart):
+ # Boundary marker; the TUI buckets this model call's reasoning +
+ # text + tools into one collapsible episode.
+ await self._emitter.emit(cid, {"type": "episode.start", "payload": {"index": out.index}})
# Notice / MediaOut: eaten (no wire event today).
async def send_stream_chunk(self, chat_id: str, stream_id: str, delta: str, *, done: bool = False) -> None:
diff --git a/tests/test_agent_loop_run_emit.py b/tests/test_agent_loop_run_emit.py
index b51cfcdc..81a4bc85 100644
--- a/tests/test_agent_loop_run_emit.py
+++ b/tests/test_agent_loop_run_emit.py
@@ -14,11 +14,12 @@
import pytest
from raven.agent.loop import AgentLoop
-from raven.agent.tools.base import Tool
+from raven.agent.tools.base import Tool, ToolResult
from raven.agent.tools.deep_research import DeepResearchOfferTool
from raven.config.schema import DeepResearchToolConfig
from raven.providers.base import LLMResponse, StreamDelta, ToolCallRequest
from raven.sandbox import SandboxInitError
+from raven.spine.events import EpisodeStart as EvEpisodeStart
from raven.spine.events import MediaOut as EvMediaOut
from raven.spine.events import Notice as EvNotice
from raven.spine.events import NoticeKind, ToolPhase
@@ -57,6 +58,31 @@ async def execute(self, **kwargs) -> str:
return "tool-ran"
+class _SplitTool(Tool):
+ """Returns ToolResult so the loop's unwrap branch is actually exercised."""
+
+ MODEL_TEXT = 'User answered: "Which base?" -> "main". Continue.'
+ DISPLAY_TEXT = "Which base? -> main"
+
+ @property
+ def name(self) -> str:
+ return "splittool"
+
+ @property
+ def description(self) -> str:
+ return "fake tool with distinct model-facing and display-facing text"
+
+ @property
+ def parameters(self) -> dict:
+ return {"type": "object", "properties": {}, "required": []}
+
+ def display_call(self, args: dict) -> str | None:
+ return "Which base?"
+
+ async def execute(self, **kwargs) -> ToolResult:
+ return ToolResult(model_text=self.MODEL_TEXT, display_text=self.DISPLAY_TEXT)
+
+
class _FakeDeepResearch(Tool):
"""Fake deep_research: on execute it drives its stream callback with a progress
line then the finished answer, so run_turn's inline routing can be pinned
@@ -227,8 +253,10 @@ async def test_run_streams_then_dissolves_main_response(tmp_path):
outcome = await loop.run_turn(_req("hi"), sink, _drain)
- assert [type(e).__name__ for e in sink.events] == ["StreamDelta", "StreamDelta"]
- assert [e.delta for e in sink.events] == ["Hel", "lo"]
+ # The turn opens with an episode boundary, then the two streamed deltas.
+ assert isinstance(sink.events[0], EvEpisodeStart) and sink.events[0].index == 0
+ deltas = [e for e in sink.events if isinstance(e, EvStreamDelta)]
+ assert [e.delta for e in deltas] == ["Hel", "lo"]
assert not any(isinstance(e, EvText) for e in sink.events) # dissolved, no double
assert outcome.usage.total_tokens == 5
assert outcome.explicit_reply is True
@@ -245,7 +273,9 @@ async def test_run_emits_reasoning_then_stream(tmp_path):
await loop.run_turn(_req("hi"), sink, _drain)
- assert isinstance(sink.events[0], EvReasoning) and sink.events[0].content == "think"
+ assert isinstance(sink.events[0], EvEpisodeStart)
+ reasoning = next(e for e in sink.events if isinstance(e, EvReasoning))
+ assert reasoning.content == "think"
assert any(isinstance(e, EvStreamDelta) and e.delta == "answer" for e in sink.events)
assert not any(isinstance(e, EvText) for e in sink.events)
@@ -288,6 +318,83 @@ async def test_run_tool_call_emits_tool_events_and_notice(tmp_path):
assert not any(isinstance(e, EvText) for e in sink.events) # streamed final dissolves
+async def test_tool_result_splits_model_text_from_display_preview(tmp_path):
+ # The ToolResult branch: the model's context gets model_text while the UI
+ # event's preview carries display_text. Every other fake tool in this file
+ # returns a bare str, so without this the unwrap branch never runs.
+ provider = _FakeStreamToolProvider(
+ [
+ [
+ StreamDelta(
+ content=None,
+ tool_call_delta={
+ "tool_calls": [{"index": 0, "id": "t9", "function": {"name": "splittool", "arguments": "{}"}}]
+ },
+ )
+ ],
+ [StreamDelta(content="done")],
+ ]
+ )
+ loop = AgentLoop(provider=provider, workspace=tmp_path)
+ _stub_edges(loop)
+ loop.tools.register(_SplitTool())
+
+ recorded: list[tuple[str, str, str]] = []
+ original_add = loop.context.add_tool_result
+
+ def _record(messages, tool_call_id, tool_name, result):
+ recorded.append((tool_call_id, tool_name, result))
+ return original_add(messages, tool_call_id, tool_name, result)
+
+ loop.context.add_tool_result = _record # type: ignore[method-assign]
+ sink = _EmitCollector()
+
+ await loop.run_turn(_req("hi"), sink, _drain)
+
+ # What the model reads.
+ assert recorded and recorded[0][:2] == ("t9", "splittool")
+ assert recorded[0][2] == _SplitTool.MODEL_TEXT
+ # What the transcript shows -- display_text, not the model sentence.
+ complete = next(e for e in sink.events if isinstance(e, EvToolEvent) and e.phase is ToolPhase.COMPLETE)
+ assert complete.result_preview == _SplitTool.DISPLAY_TEXT
+ assert complete.truncated is False
+ # display_call rides tool.start so the row can label itself.
+ start = next(e for e in sink.events if isinstance(e, EvToolEvent) and e.phase is ToolPhase.START)
+ assert start.display == "Which base?"
+
+
+async def test_run_emits_one_episode_start_per_model_call(tmp_path):
+ # Episode boundary: run() emits one EpisodeStart per model call, 0-based and
+ # increasing. This turn has two calls (a tool-call iteration, then a stop
+ # iteration), so indices are [0, 1], and the first boundary precedes the
+ # first tool event of that call.
+ provider = _FakeStreamToolProvider(
+ [
+ [
+ StreamDelta(
+ content=None,
+ tool_call_delta={
+ "tool_calls": [{"index": 0, "id": "t1", "function": {"name": "faketool", "arguments": "{}"}}]
+ },
+ )
+ ],
+ [StreamDelta(content="done")],
+ ]
+ )
+ loop = AgentLoop(provider=provider, workspace=tmp_path)
+ _stub_edges(loop)
+ loop.tools.register(_FakeTool())
+ sink = _EmitCollector()
+
+ await loop.run_turn(_req("hi"), sink, _drain)
+
+ episodes = [e for e in sink.events if isinstance(e, EvEpisodeStart)]
+ assert [e.index for e in episodes] == [0, 1]
+ first_ep = next(i for i, e in enumerate(sink.events) if isinstance(e, EvEpisodeStart))
+ first_tool = next(i for i, e in enumerate(sink.events) if isinstance(e, EvToolEvent))
+ assert first_ep < first_tool
+
+
# ── deep_research inline streaming: run_turn's _route_deep_research (2a) ──
diff --git a/tests/test_ask_user_tool.py b/tests/test_ask_user_tool.py
new file mode 100644
index 00000000..2c187b72
--- /dev/null
+++ b/tests/test_ask_user_tool.py
@@ -0,0 +1,99 @@
+"""Tests for the ask_user tool's display contract (raven/agent/tools/ask_user.py).
+
+``ask_user`` is the first tool to return :class:`ToolResult`, so this pins the
+split it introduces: ``model_text`` keeps the natural-language phrasing the
+model reads, ``display_text`` carries the question/answer pairing the transcript
+renders, and ``display_call`` labels the row with the question rather than the
+raw arguments blob.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from raven.agent.tools.ask_user import AskUserTool
+from raven.agent.tools.base import ToolResult
+
+
+class _StubBroker:
+ """Stands in for QuestionBroker: replies from a scripted answer map."""
+
+ def __init__(self, answers: dict[str, str]) -> None:
+ self.answers = answers
+ self.asked: list[tuple[str, list[str]]] = []
+
+ async def await_question(self, cid: str, *, prompt: str, choices: list[str]) -> str:
+ self.asked.append((prompt, choices))
+ return self.answers.get(prompt, "")
+
+
+def _tool(answers: dict[str, str]) -> tuple[AskUserTool, _StubBroker]:
+ broker = _StubBroker(answers)
+ tool = AskUserTool(broker=broker, conversation_id="tui:test") # type: ignore[arg-type]
+ return tool, broker
+
+
+@pytest.mark.asyncio
+async def test_single_question_splits_model_and_display_text():
+ tool, broker = _tool({"Which package manager?": "uv"})
+
+ result = await tool.execute(questions=[{"question": "Which package manager?", "options": ["uv", "pip"]}])
+
+ assert isinstance(result, ToolResult)
+ # The model reads the full sentence, including the question it asked.
+ assert 'User answered: "Which package manager?" -> "uv".' in result.model_text
+ assert result.model_text.endswith("Continue.")
+ # A single question needs no pairing in the transcript; the row already
+ # shows the question via display_call.
+ assert result.display_text == "answered: uv"
+ assert broker.asked == [("Which package manager?", ["uv", "pip"])]
+
+
+@pytest.mark.asyncio
+async def test_batch_pairs_each_question_with_its_answer():
+ tool, _ = _tool({"Base branch?": "main", "Squash?": "yes"})
+
+ result = await tool.execute(questions=[{"question": "Base branch?"}, {"question": "Squash?"}])
+
+ assert isinstance(result, ToolResult)
+ # With several questions the display text must say which answer belongs to
+ # which question -- one line per pair, order preserved.
+ assert result.display_text == "Base branch? -> main\nSquash? -> yes"
+ assert 'User answered: "Base branch?" -> "main".' in result.model_text
+ assert 'User answered: "Squash?" -> "yes".' in result.model_text
+
+
+@pytest.mark.asyncio
+async def test_unanswered_question_is_explicit_in_both_texts():
+ tool, _ = _tool({})
+
+ result = await tool.execute(questions=[{"question": "Ship it?"}])
+
+ assert isinstance(result, ToolResult)
+ assert "did not answer" in result.model_text
+ assert result.display_text == "answered: (no answer)"
+
+
+@pytest.mark.asyncio
+async def test_error_paths_return_plain_strings():
+ # No broker / no conversation id / no questions predate ToolResult and stay
+ # bare strings, so the loop's str branch still has to work.
+ tool = AskUserTool(broker=None, conversation_id="tui:test")
+ assert await tool.execute(questions=[{"question": "hi"}]) == "Error: ask_user not configured (no question broker)"
+
+ tool, _ = _tool({})
+ assert await tool.execute(questions=[]) == "Error: ask_user requires at least one question"
+ assert await tool.execute(questions=[{"question": " "}]) == (
+ "Error: ask_user requires at least one non-empty question"
+ )
+
+
+def test_display_call_labels_the_row_with_the_question():
+ tool, _ = _tool({})
+
+ assert tool.display_call({"questions": [{"question": "Which base branch?"}]}) == "Which base branch?"
+ assert tool.display_call({"questions": [{"question": "Base?"}, {"question": "Squash?"}]}) == "Base? | Squash?"
+ # Nothing worth showing falls back to the UI's generic preview.
+ assert tool.display_call({"questions": []}) is None
+ assert tool.display_call({"questions": [{"question": " "}]}) is None
+ assert tool.display_call({}) is None
diff --git a/tests/test_spine_events.py b/tests/test_spine_events.py
index 0c52c9b1..18b7c4d8 100644
--- a/tests/test_spine_events.py
+++ b/tests/test_spine_events.py
@@ -6,6 +6,7 @@
from raven.spine import (
ChatType,
Deliverable,
+ EpisodeStart,
Media,
MediaOut,
Notice,
@@ -129,7 +130,7 @@ def test_tool_event_phase_is_typed():
def test_runner_event_is_the_six_deliverables_excluding_lifecycle():
runner_members = set(get_args(RunnerEvent))
- assert runner_members == {ToolEvent, Text, MediaOut, StreamDelta, Reasoning, Notice}
+ assert runner_members == {ToolEvent, Text, MediaOut, StreamDelta, Reasoning, Notice, EpisodeStart}
# lifecycle events are not deliverable: the basis for narrowing the runner's emit
assert TurnStarted not in runner_members
assert TurnFailed not in runner_members
diff --git a/tests/test_tools_registry.py b/tests/test_tools_registry.py
new file mode 100644
index 00000000..da474021
--- /dev/null
+++ b/tests/test_tools_registry.py
@@ -0,0 +1,118 @@
+"""Tests for the ToolRegistry execute boundary (raven/agent/tools/registry.py).
+
+``Tool.execute`` may return ``str`` or :class:`ToolResult`, but every consumer
+of the registry other than the agent loop -- the sentinel action executor, the
+subagent manager, the context curator, tracing -- treats the return value as
+model text and puts it straight into a message, a reply or an artifact. So the
+registry unwraps here and hands back a ``str`` with the display string attached.
+"""
+
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from raven.agent.tools.base import Tool, ToolOutput, ToolResult
+from raven.agent.tools.registry import ToolRegistry
+
+
+class _Split(Tool):
+ def __init__(self, model_text: str, display_text: str | None) -> None:
+ self._model_text = model_text
+ self._display_text = display_text
+
+ @property
+ def name(self) -> str:
+ return "split"
+
+ @property
+ def description(self) -> str:
+ return "returns ToolResult"
+
+ @property
+ def parameters(self) -> dict:
+ return {"type": "object", "properties": {}, "required": []}
+
+ async def execute(self, **kwargs) -> ToolResult:
+ return ToolResult(model_text=self._model_text, display_text=self._display_text)
+
+
+class _Plain(Tool):
+ @property
+ def name(self) -> str:
+ return "plain"
+
+ @property
+ def description(self) -> str:
+ return "returns a bare str"
+
+ @property
+ def parameters(self) -> dict:
+ return {"type": "object", "properties": {}, "required": []}
+
+ async def execute(self, **kwargs) -> str:
+ return "plain output"
+
+
+def _registry(*tools: Tool) -> ToolRegistry:
+ reg = ToolRegistry()
+ for tool in tools:
+ reg.register(tool)
+ return reg
+
+
+@pytest.mark.asyncio
+async def test_tool_result_is_unwrapped_to_model_text_at_the_boundary():
+ reg = _registry(_Split("model sentence", "question -> answer"))
+
+ result = await reg.execute("split", {})
+
+ # A str, equal to the model text -- not a dataclass, and no repr anywhere.
+ assert isinstance(result, str)
+ assert result == "model sentence"
+ assert "ToolResult(" not in f"{result}"
+ assert json.loads(json.dumps({"result": result}))["result"] == "model sentence"
+ # The display string rides along for the agent loop.
+ assert result.display_text == "question -> answer" # type: ignore[attr-defined]
+
+
+@pytest.mark.asyncio
+async def test_plain_string_tools_carry_no_display_text():
+ reg = _registry(_Plain())
+
+ result = await reg.execute("plain", {})
+
+ assert result == "plain output"
+ assert getattr(result, "display_text", None) is None
+
+
+@pytest.mark.asyncio
+async def test_error_prefixed_tool_result_keeps_hint_and_display():
+ reg = _registry(_Split("Error: broker unavailable", "asked -> nothing"))
+
+ result = await reg.execute("split", {})
+
+ assert result.startswith("Error: broker unavailable")
+ assert "try a different approach" in result
+ assert result.display_text == "asked -> nothing" # type: ignore[attr-defined]
+
+
+@pytest.mark.asyncio
+async def test_missing_tool_still_returns_a_plain_string():
+ reg = _registry(_Plain())
+
+ result = await reg.execute("nope", {})
+
+ assert isinstance(result, str)
+ assert "not found" in result
+
+
+def test_tool_output_is_a_str_subclass():
+ out = ToolOutput("text", "display")
+
+ assert isinstance(out, str)
+ assert out == "text"
+ assert out.display_text == "display"
+ # Attribute is always present, so callers can getattr without a default dance.
+ assert ToolOutput("text").display_text is None
diff --git a/tests/test_tui_rpc_spine.py b/tests/test_tui_rpc_spine.py
index 0bbf4dd9..bd769b05 100644
--- a/tests/test_tui_rpc_spine.py
+++ b/tests/test_tui_rpc_spine.py
@@ -183,7 +183,10 @@ async def test_outlet_deliver_tool_event_to_tool_start_and_complete():
assert emitter.emitted == [
(
"tui:c1",
- {"type": "tool.start", "payload": {"tool_call_id": "t1", "name": "shell", "arguments": {"cmd": "ls"}}},
+ {
+ "type": "tool.start",
+ "payload": {"tool_call_id": "t1", "name": "shell", "arguments": {"cmd": "ls"}, "display": None},
+ },
),
(
"tui:c1",
diff --git a/ui-tui/rpc-schema/openrpc.json b/ui-tui/rpc-schema/openrpc.json
index 790bab5f..d39c9667 100644
--- a/ui-tui/rpc-schema/openrpc.json
+++ b/ui-tui/rpc-schema/openrpc.json
@@ -1525,6 +1525,22 @@
}
}
},
+ "EpisodeStartEvent": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["type", "payload"],
+ "properties": {
+ "type": { "type": "string", "const": "episode.start" },
+ "payload": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["index"],
+ "properties": {
+ "index": { "type": "integer" }
+ }
+ }
+ }
+ },
"TokenDeltaEvent": {
"type": "object",
"additionalProperties": false,
@@ -1573,7 +1589,8 @@
"arguments": {
"type": "object",
"additionalProperties": { "$ref": "#/components/schemas/JsonValue" }
- }
+ },
+ "display": { "type": ["string", "null"] }
}
}
}
@@ -1675,6 +1692,7 @@
"description": "Discriminated union of turn streaming events. The 'type' field is the discriminator.",
"oneOf": [
{ "$ref": "#/components/schemas/MessageStartEvent" },
+ { "$ref": "#/components/schemas/EpisodeStartEvent" },
{ "$ref": "#/components/schemas/TokenDeltaEvent" },
{ "$ref": "#/components/schemas/ThinkingDeltaEvent" },
{ "$ref": "#/components/schemas/ToolStartEvent" },
@@ -1688,6 +1706,7 @@
"propertyName": "type",
"mapping": {
"message.start": "#/components/schemas/MessageStartEvent",
+ "episode.start": "#/components/schemas/EpisodeStartEvent",
"token.delta": "#/components/schemas/TokenDeltaEvent",
"thinking.delta": "#/components/schemas/ThinkingDeltaEvent",
"tool.start": "#/components/schemas/ToolStartEvent",
diff --git a/ui-tui/src/__tests__/episodeSummary.test.ts b/ui-tui/src/__tests__/episodeSummary.test.ts
new file mode 100644
index 00000000..9c3ab32e
--- /dev/null
+++ b/ui-tui/src/__tests__/episodeSummary.test.ts
@@ -0,0 +1,86 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 EverMind.
+// See NOTICES.md.
+
+import { describe, expect, it } from 'vitest'
+
+import type { Episode, EpisodeTool } from '../types.js'
+
+import { episodeFailed, toolParts, toolsSummary } from '../domain/episodeSummary.js'
+
+const tool = (name: string, summary: string, ok = true): EpisodeTool => ({
+ id: `${name}:${summary}`,
+ name,
+ summary,
+ ok
+})
+const ep = (tools: EpisodeTool[], reasoning = '', narration = ''): Episode => ({
+ index: 0,
+ reasoning,
+ narration,
+ tools
+})
+
+describe('toolsSummary', () => {
+ it('counts a run of the same info tool', () => {
+ expect(toolsSummary([tool('read_file', 'a.go'), tool('read_file', 'b.go'), tool('read_file', 'c.go')])).toBe(
+ 'read 3 files'
+ )
+ })
+
+ it('shows the target for a single call', () => {
+ expect(toolsSummary([tool('read_file', 'src/device/approve.go')])).toBe('read approve.go')
+ expect(toolsSummary([tool('exec', 'ls internal/biz/')])).toBe('ran ls internal/biz/')
+ expect(toolsSummary([tool('grep', 'DeviceFlow')])).toBe('searched "DeviceFlow"')
+ })
+
+ it('joins distinct tools in order', () => {
+ expect(toolsSummary([tool('read_file', 'a.go'), tool('read_file', 'b.go'), tool('exec', 'ls')])).toBe(
+ 'read 2 files, ran ls'
+ )
+ })
+
+ it('uses target-style plural for a run of action tools', () => {
+ expect(toolsSummary([tool('exec', 'ls'), tool('exec', 'cat x')])).toBe('ran 2 commands')
+ })
+
+ it('derives a humanized verb for tools with no override (never a wrong "ran")', () => {
+ // A tool we never listed still gets its own name as the verb, not "ran".
+ expect(toolsSummary([tool('quantum_leap', ''), tool('quantum_leap', '')])).toBe('quantum leap 2 calls')
+ // Real backend tools that predate this table render sensibly with no edits:
+ expect(toolsSummary([tool('image_generate', 'a red fox')])).toBe('image generate a red fox')
+ expect(toolsSummary([tool('web_search', 'hermes agent')])).toBe('searched "hermes agent"')
+ })
+
+ it('shows +added -removed for an edited file', () => {})
+
+ it('splits a tool row into verb + detail (full path, not just basename)', () => {
+ expect(toolParts(tool('read_file', 'src/approve.go'))).toEqual({ verb: 'read', detail: 'src/approve.go' })
+ expect(toolParts(tool('exec', 'ls biz/'))).toEqual({ verb: 'ran', detail: 'ls biz/' })
+ expect(toolParts({ ...tool('edit_file', 'notes.md'), added: 12, removed: 3 })).toEqual({
+ verb: 'edited',
+ detail: 'notes.md +12 -3'
+ })
+ })
+
+ it('left-clips a long path to keep the tail', () => {
+ const long = `/tmp/everme/server/internal/controller/${'nested/'.repeat(12)}memory/agent_memory.go`
+ const { detail } = toolParts(tool('read_file', long))
+ expect(detail.startsWith('…')).toBe(true)
+ expect(detail.endsWith('agent_memory.go')).toBe(true)
+ })
+
+ it('summarizes delegated subagents by count', () => {
+ expect(toolsSummary([tool('spawn', 'a'), tool('spawn', 'b'), tool('spawn', 'c'), tool('spawn', 'd')])).toBe(
+ 'delegated 4 subagents'
+ )
+ })
+})
+
+describe('episodeFailed', () => {
+ it('aggregates across episodes and flags failures', () => {
+ const episodes = [ep([tool('read_file', 'a.go'), tool('read_file', 'b.go')]), ep([tool('exec', 'ls', false)])]
+ expect(episodeFailed(episodes[1]!)).toBe(true)
+ expect(episodeFailed(episodes[0]!)).toBe(false)
+ })
+})
diff --git a/ui-tui/src/__tests__/episodeView.test.tsx b/ui-tui/src/__tests__/episodeView.test.tsx
new file mode 100644
index 00000000..3b906c89
--- /dev/null
+++ b/ui-tui/src/__tests__/episodeView.test.tsx
@@ -0,0 +1,75 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 EverMind.
+// See NOTICES.md.
+
+import { render } from 'ink-testing-library'
+import React from 'react'
+import { describe, expect, it } from 'vitest'
+
+import type { Episode } from '../types.js'
+
+import { EpisodeView } from '../components/episodeView.js'
+import { stripAnsi } from '../lib/text.js'
+import { DEFAULT_THEME } from '../theme.js'
+
+const frame = (node: React.ReactElement) => stripAnsi(render(node).lastFrame() ?? '')
+
+const noNarrationStep: Episode = {
+ index: 0,
+ reasoning: 'SECRET internal plan',
+ narration: '',
+ reasoningMs: 8000,
+ tools: [
+ { id: 'a', name: 'read_file', summary: 'a.go', ok: true, done: true, resultPreview: 'package a' },
+ { id: 'b', name: 'read_file', summary: 'b.go', ok: true, done: true }
+ ]
+}
+
+const narratedStep: Episode = {
+ index: 1,
+ reasoning: 'THE chain of thought',
+ narration: 'I will read the controller files',
+ reasoningMs: 3000,
+ tools: [{ id: 'c', name: 'read_file', summary: 'ctrl.go', ok: true, done: true, resultPreview: 'package ctrl' }]
+}
+
+describe('EpisodeView (flat, one-level)', () => {
+ it('collapses a no-narration step to one summary line', () => {
+ const f = frame()
+ // The collapsed summary line: "reasoning for 8s · read 2 files"
+ expect(f).toContain('reasoning for 8s')
+ expect(f).toContain('read 2 files')
+ // Final answer prose renders below.
+ expect(f).toContain('FINAL ANSWER')
+ // Collapsed => the reasoning text and tool results stay hidden.
+ expect(f).not.toContain('SECRET internal plan')
+ expect(f).not.toContain('package a')
+ })
+
+ it('expands a narrated step: reasoning fold before narration, narration prose, tools with inline result', () => {
+ const f = frame()
+ expect(f).toContain('reasoning for 3s') // reasoning fold line (collapsed content)
+ expect(f).toContain('I will read the controller files') // narration prose
+ expect(f).toContain('read') // tool verb
+ expect(f).toContain('ctrl.go') // tool arg
+ expect(f).toContain('package ctrl') // result shown inline
+ // CoT stays folded until the reasoning row is opened.
+ expect(f).not.toContain('THE chain of thought')
+ // reasoning line comes before the narration line
+ expect(f.indexOf('reasoning for 3s')).toBeLessThan(f.indexOf('I will read the controller files'))
+ })
+
+ it('renders only the final answer when there are no steps', () => {
+ const f = frame()
+ expect(f).toContain('just an answer')
+ expect(f).not.toContain('reasoning')
+ })
+
+ it('live: the running (last) step is expanded and streams its text', () => {
+ const f = frame()
+ // Running step is expanded (not the collapsed one-liner), so its tool shows...
+ expect(f).toContain('a.go')
+ // ...and the in-progress text streams as prose.
+ expect(f).toContain('LIVE OUTPUT')
+ })
+})
diff --git a/ui-tui/src/__tests__/toolArgs.test.ts b/ui-tui/src/__tests__/toolArgs.test.ts
new file mode 100644
index 00000000..1217ad16
--- /dev/null
+++ b/ui-tui/src/__tests__/toolArgs.test.ts
@@ -0,0 +1,37 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 EverMind.
+// See NOTICES.md.
+
+import { describe, expect, it } from 'vitest'
+
+import { argPreview } from '../lib/toolArgs.js'
+
+describe('argPreview', () => {
+ it('prefers the query over a numeric flag (web_search), regardless of key order', () => {
+ expect(argPreview({ count: 10, query: 'hermes agent' })).toBe('hermes agent')
+ expect(argPreview({ query: 'hermes agent', count: 10 })).toBe('hermes agent')
+ })
+
+ it('digs a question out of a nested blob instead of dumping JSON (ask_user)', () => {
+ expect(
+ argPreview({
+ questions: [{ options: [{ name: 'A' }, { name: 'B' }], question: '你想调研的 Hermes 是哪个?' }]
+ })
+ ).toBe('你想调研的 Hermes 是哪个?')
+ })
+
+ it('reads the obvious argument for common tools', () => {
+ expect(argPreview({ command: 'ls -la' })).toBe('ls -la')
+ expect(argPreview({ path: 'src/app/chatStream.ts', limit: 20 })).toBe('src/app/chatStream.ts')
+ expect(argPreview({ pattern: 'TODO', path: '.' })).toBe('TODO')
+ expect(argPreview({ url: 'https://example.com' })).toBe('https://example.com')
+ expect(argPreview({ prompt: 'a red fox in snow' })).toBe('a red fox in snow')
+ })
+
+ it('never returns a bare number or a JSON blob', () => {
+ expect(argPreview({ count: 10, verbose: true })).toBe('')
+ expect(argPreview({})).toBe('')
+ // Falls back to the first reachable string when no preferred key matches.
+ expect(argPreview({ misc: 'plain value' })).toBe('plain value')
+ })
+})
diff --git a/ui-tui/src/__tests__/turnControllerEpisodes.test.ts b/ui-tui/src/__tests__/turnControllerEpisodes.test.ts
new file mode 100644
index 00000000..a161e87f
--- /dev/null
+++ b/ui-tui/src/__tests__/turnControllerEpisodes.test.ts
@@ -0,0 +1,125 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 EverMind.
+// See NOTICES.md.
+
+import { afterEach, describe, expect, it } from 'vitest'
+
+import type { Msg } from '../types.js'
+
+import { turnController } from '../app/turnController.js'
+import { patchUiState } from '../app/uiStore.js'
+
+afterEach(() => {
+ patchUiState({ transcript: 'legacy' })
+ turnController.reset()
+})
+
+describe('turnController episodes commit', () => {
+ it('buckets a turn into episodes and commits one episodes message', () => {
+ patchUiState({ transcript: 'episodes' })
+ turnController.reset()
+
+ // Call 0: reason, then read two files.
+ turnController.recordEpisodeStart(0)
+ turnController.recordReasoningDelta('planning the reads')
+ turnController.recordToolStart('a', 'read_file', 'src/approve.go')
+ turnController.recordToolComplete('a', 'read_file', undefined, 'package device', 0.1)
+ turnController.recordToolStart('b', 'read_file', 'src/token.go')
+ turnController.recordToolComplete('b', 'read_file', undefined, 'package device 2', 0.1)
+ // Call 1: the stop call — no tools, just the final answer.
+ turnController.recordEpisodeStart(1)
+
+ const { finalMessages } = turnController.recordMessageComplete({ text: 'FINAL ANSWER' })
+
+ const epMsg = finalMessages.find(m => m.kind === 'episodes')
+ expect(epMsg).toBeTruthy()
+ expect(epMsg!.text).toBe('FINAL ANSWER')
+ // The stop call (no tools, no reasoning) is dropped; only the work step remains.
+ expect(epMsg!.episodes).toHaveLength(1)
+ const [work] = epMsg!.episodes!
+ expect(work!.tools.map(t => t.name)).toEqual(['read_file', 'read_file'])
+ expect(work!.tools.every(t => t.ok)).toBe(true)
+ expect(work!.reasoning).toContain('planning the reads')
+ })
+
+ it('records +/- line counts from an inline-diff edit', () => {
+ patchUiState({ transcript: 'episodes' })
+ turnController.reset()
+
+ turnController.recordEpisodeStart(0)
+ turnController.recordToolStart('e', 'edit_file', 'notes.md')
+ turnController.recordInlineDiffToolComplete('--- a\n+++ b\n-old\n+new1\n+new2', 'e', 'edit_file', undefined, 0.1)
+ turnController.recordEpisodeStart(1)
+
+ const { finalMessages } = turnController.recordMessageComplete({ text: 'done' })
+ const editTool = finalMessages.find(m => m.kind === 'episodes')!.episodes![0]!.tools[0]!
+
+ expect(editTool.added).toBe(2)
+ expect(editTool.removed).toBe(1)
+ })
+
+ it('interrupt in episodes mode commits one collapsed episodes message, not raw segments', () => {
+ patchUiState({ transcript: 'episodes' })
+ turnController.reset()
+
+ turnController.recordEpisodeStart(0)
+ turnController.recordReasoningDelta('thinking about it')
+ turnController.recordToolStart('a', 'web_search', 'gtm agent')
+ turnController.recordToolComplete('a', 'web_search', 'boom', undefined, 0.1)
+
+ const appended: Msg[] = []
+ turnController.finalizeInterruptedTurn({ appendMessage: m => appended.push(m) })
+
+ // Exactly one collapsed episodes message — no raw per-segment dump (the bug:
+ // a Ctrl+C used to append every expanded thinking/tool segment).
+ expect(appended.every(m => m.kind === 'episodes')).toBe(true)
+ const epMsgs = appended.filter(m => m.kind === 'episodes')
+ expect(epMsgs).toHaveLength(1)
+ expect(epMsgs[0]!.episodes!).toHaveLength(1)
+ expect(epMsgs[0]!.episodes![0]!.tools[0]!.name).toBe('web_search')
+ expect(epMsgs[0]!.text).toContain('[interrupted]')
+ })
+
+ it('interrupt with no episode.start still keeps the legacy trail', () => {
+ // An older gateway never emits episode.start. The completion path already
+ // falls back to the segment trail in that case; the interrupt path must
+ // too, or a Ctrl+C silently drops everything the turn had accrued.
+ patchUiState({ transcript: 'episodes' })
+ turnController.reset()
+
+ turnController.recordReasoningDelta('reasoned without any episode boundary')
+ turnController.recordToolStart('a', 'web_search', 'gtm agent')
+ turnController.recordToolComplete('a', 'web_search', undefined, '12 results', 0.2)
+
+ expect(turnController.episodes).toHaveLength(0)
+
+ const appended: Msg[] = []
+ turnController.finalizeInterruptedTurn({ appendMessage: m => appended.push(m) })
+
+ expect(appended.some(m => m.kind === 'episodes')).toBe(false)
+ // The legacy trail survives with the reasoning and the tool line on it...
+ const trail = appended.find(m => m.kind === 'trail')
+ expect(trail).toBeTruthy()
+ expect(trail!.thinking).toContain('without any episode boundary')
+ expect(trail!.tools!.join(' ')).toContain('Web Search')
+ expect(trail!.tools!.join(' ')).toContain('12 results')
+ // ...and the interruption is still recorded.
+ expect(appended.some(m => (m.text ?? '').includes('[interrupted]'))).toBe(true)
+ })
+
+ it('legacy mode is fully inert: no episodes accrue and no episodes message', () => {
+ patchUiState({ transcript: 'legacy' })
+ turnController.reset()
+
+ turnController.recordEpisodeStart(0)
+ turnController.recordToolStart('a', 'exec', 'ls')
+ turnController.recordToolComplete('a', 'exec', undefined, 'out', 0.1)
+
+ // recordEpisodeStart is gated on episodes mode, so nothing accrued.
+ expect(turnController.episodes).toHaveLength(0)
+
+ const { finalMessages } = turnController.recordMessageComplete({ text: 'hi' })
+
+ expect(finalMessages.some(m => m.kind === 'episodes')).toBe(false)
+ })
+})
diff --git a/ui-tui/src/app/chatStream.ts b/ui-tui/src/app/chatStream.ts
index 8070916f..c34fb184 100644
--- a/ui-tui/src/app/chatStream.ts
+++ b/ui-tui/src/app/chatStream.ts
@@ -33,6 +33,7 @@ import type {
} from '../rpc/index.js'
import type { Msg } from '../types.js'
+import { argPreview } from '../lib/toolArgs.js'
import { turnController } from './turnController.js'
import { patchTurnState } from './turnStore.js'
import { patchUiState } from './uiStore.js'
@@ -109,6 +110,9 @@ const dispatch = (
case 'message.start':
onMessageStart(state, event)
return
+ case 'episode.start':
+ turnController.recordEpisodeStart(event.payload.index)
+ return
case 'token.delta':
onTokenDelta(event)
return
@@ -162,15 +166,10 @@ const onTokenDelta = (ev: TokenDeltaEvent): void => {
}
const onToolStart = (ev: ToolStartEvent): void => {
- const { tool_call_id, name, arguments: args } = ev.payload
- // Render a short context line from the first scalar argument value so the
- // active-tool list shows useful preview text without leaking the full
- // argument blob into the UI.
- const previewKey = Object.keys(args)[0]
- const previewVal = previewKey !== undefined ? args[previewKey] : undefined
- const context =
- typeof previewVal === 'string' ? previewVal : previewVal !== undefined ? JSON.stringify(previewVal) : ''
- turnController.recordToolStart(tool_call_id, name, context)
+ const { tool_call_id, name, arguments: args, display } = ev.payload
+ // Prefer the tool-authored call label; else preview the "what" of the call
+ // (query/question/command), skipping numeric flags and raw JSON. See lib/toolArgs.
+ turnController.recordToolStart(tool_call_id, name, display ?? argPreview(args))
}
const onToolComplete = (ev: ToolCompleteEvent): void => {
diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts
index 240f7697..6f7e0e2e 100644
--- a/ui-tui/src/app/createGatewayEventHandler.ts
+++ b/ui-tui/src/app/createGatewayEventHandler.ts
@@ -301,6 +301,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
case 'message.start':
turnController.startMessage()
+ return
+ case 'episode.start':
+ turnController.recordEpisodeStart(Number(ev.payload?.index ?? 0))
+
return
case 'status.update': {
const p = ev.payload
diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts
index c32ef7cc..e219a6b2 100644
--- a/ui-tui/src/app/interfaces.ts
+++ b/ui-tui/src/app/interfaces.ts
@@ -132,6 +132,12 @@ export interface UiState {
status: string
statusBar: StatusBarMode
streaming: boolean
+ // Transcript rendering mode. 'legacy' = the flat thinking/tool panels;
+ // 'episodes' = one collapsible line per model call, and the default. Session
+ // scoped and runtime only: `/transcript legacy|episodes` switches it and it is
+ // deliberately never config-synced or persisted, so it stays an instant escape
+ // hatch that cannot get stuck in a config file.
+ transcript: 'episodes' | 'legacy'
theme: Theme
usage: Usage
}
diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts
index 3f758b1c..f9bb861f 100644
--- a/ui-tui/src/app/slash/commands/core.ts
+++ b/ui-tui/src/app/slash/commands/core.ts
@@ -248,6 +248,23 @@ export const coreCommands: SlashCommand[] = [
}
},
+ {
+ // Runtime-only: `display` is not a persisted config block, so this never
+ // touches config.json (writing it there fails the backend Config schema
+ // and breaks every raven that reads the shared file). Session-scoped.
+ help: 'transcript style: episodes (one line per step) or legacy',
+ name: 'transcript',
+ run: (arg, ctx) => {
+ const a = arg.trim().toLowerCase()
+ const next: 'episodes' | 'legacy' =
+ a === 'episodes' || a === 'legacy' ? a : ctx.ui.transcript === 'episodes' ? 'legacy' : 'episodes'
+
+ patchUiState({ transcript: next })
+
+ queueMicrotask(() => ctx.transcript.sys(`transcript: ${next} (this session)`))
+ }
+ },
+
{
aliases: ['detail'],
help: 'control agent detail visibility (global or per-section)',
diff --git a/ui-tui/src/app/turnController.ts b/ui-tui/src/app/turnController.ts
index 46ac2d9d..b893a201 100644
--- a/ui-tui/src/app/turnController.ts
+++ b/ui-tui/src/app/turnController.ts
@@ -4,7 +4,7 @@
// See NOTICES.md and LICENSES/MIT-hermes-agent.txt.
import type { SessionInterruptResponse, SubagentEventPayload } from '../gatewayTypes.js'
-import type { ActiveTool, ActivityItem, Msg, SubagentProgress, TodoItem } from '../types.js'
+import type { ActiveTool, ActivityItem, Episode, Msg, SubagentProgress, TodoItem } from '../types.js'
import {
REASONING_PULSE_MS,
@@ -14,7 +14,7 @@ import {
STREAM_TYPING_BATCH_MS
} from '../config/timing.js'
import { appendToolShelfMessage, isToolShelfMessage } from '../lib/liveProgress.js'
-import { hasReasoningTag, splitReasoning } from '../lib/reasoning.js'
+import { hasMeaningfulReasoning, hasReasoningTag, splitReasoning } from '../lib/reasoning.js'
import {
boundedLiveRenderText,
buildToolTrailLine,
@@ -48,6 +48,23 @@ const diffSegmentBody = (msg: Msg): null | string => {
const hasDetails = (msg: Msg): boolean => Boolean(msg.thinking || msg.tools?.length || msg.toolTokens)
+// Count added/removed lines in a unified diff, ignoring the +++/--- file
+// headers and @@ hunk markers. Drives the "edited foo.ts (+12 -3)" label.
+const diffStat = (diff: string): { added: number; removed: number } => {
+ let added = 0
+ let removed = 0
+
+ for (const line of diff.split('\n')) {
+ if (line.startsWith('+') && !line.startsWith('+++')) {
+ added++
+ } else if (line.startsWith('-') && !line.startsWith('---')) {
+ removed++
+ }
+ }
+
+ return { added, removed }
+}
+
const isTodoStatus = (status: unknown): status is TodoItem['status'] =>
status === 'pending' || status === 'in_progress' || status === 'completed' || status === 'cancelled'
@@ -119,6 +136,8 @@ const clear = (t: Timer): null => {
class TurnController {
bufRef = ''
+ episodes: Episode[] = []
+ private lastEpisodeStartMs = 0
interrupted = false
lastStatusNote = ''
persistedToolLabels = new Set()
@@ -178,8 +197,11 @@ class TurnController {
this.bufRef = ''
this.pendingSegmentTools = []
this.segmentMessages = []
+ this.episodes = []
+ this.lastEpisodeStartMs = 0
patchTurnState({
+ episodes: [],
streamPendingTools: [],
streamSegments: [],
streaming: '',
@@ -208,6 +230,13 @@ class TurnController {
const segments = this.segmentMessages
const partial = this.bufRef.trimStart()
const tools = this.pendingSegmentTools
+ // Capture the accrued episodes before idle() drops them, so an interrupt in
+ // episodes mode commits the same collapsed one-message view as a normal
+ // completion instead of dumping the raw, fully-expanded segment stream.
+ const episodesMode = getUiState().transcript === 'episodes'
+ const workEpisodes = episodesMode
+ ? this.episodes.filter(ep => ep.tools.length > 0 || hasMeaningfulReasoning(ep.reasoning ?? ''))
+ : []
// Drain streaming/segment state off the nanostore before writing the
// preserved snapshot to the transcript — otherwise each flushed segment
@@ -221,6 +250,45 @@ class TurnController {
return
}
+ const interruptedText = partial ? `${partial}\n\n*[interrupted]*` : '*[interrupted]*'
+
+ // Episodes mode: never dump the raw expanded segments. Commit the accrued
+ // steps as one collapsed episodes message (mirrors recordMessageComplete);
+ // with nothing accrued yet, fall back to the bare interrupted indicator.
+ if (episodesMode) {
+ if (workEpisodes.length) {
+ appendMessage({ kind: 'episodes', role: 'assistant', text: interruptedText, episodes: workEpisodes })
+
+ return
+ }
+
+ // No episode boundary ever arrived (an older gateway that doesn't emit
+ // episode.start) yet the turn did run tools or stream text. Keep the
+ // legacy trail exactly as recordMessageComplete does, so an interrupt
+ // never loses history the completion path would have preserved.
+ if (segments.length || tools.length) {
+ for (const msg of segments) {
+ appendMessage(msg)
+ }
+
+ appendMessage({
+ role: 'assistant',
+ text: interruptedText,
+ ...(tools.length && { tools })
+ })
+
+ return
+ }
+
+ if (partial) {
+ appendMessage({ role: 'assistant', text: interruptedText })
+ } else if (!reentrant) {
+ sys?.('interrupted')
+ }
+
+ return
+ }
+
for (const msg of segments) {
appendMessage(msg)
}
@@ -232,7 +300,7 @@ class TurnController {
if (partial || tools.length) {
appendMessage({
role: 'assistant',
- text: partial ? `${partial}\n\n*[interrupted]*` : '*[interrupted]*',
+ text: interruptedText,
...(tools.length && { tools })
})
} else if (!reentrant) {
@@ -328,6 +396,14 @@ class TurnController {
this.pushSegment(msg)
}
+ if (split.text) {
+ const ep = this.currentEpisode()
+
+ if (ep) {
+ ep.narration = (ep.narration ?? '') + split.text
+ }
+ }
+
this.pendingSegmentTools = []
this.bufRef = ''
patchTurnState({ streamPendingTools: [], streamSegments: this.segmentMessages, streaming: '' })
@@ -458,6 +534,7 @@ class TurnController {
recordMessageComplete(payload: { rendered?: string; reasoning?: string; text?: string }) {
this.closeReasoningSegment()
+ this.stampLastEpisodeDuration()
// Ink renders markdown via ; the gateway's Rich-rendered ANSI
// (`payload.rendered`) is for terminals that can't. Prioritising
@@ -513,14 +590,34 @@ class TurnController {
// Archive prepended so the trail msg anchors under the user prompt,
// not between thinking/tools and final assistant text.
- const finalMessages: Msg[] = [
- ...archiveDoneTodos(),
- ...segments,
- ...(hasDetails(finalDetails) ? [finalDetails] : [])
- ]
+ const finalMessages: Msg[] = [...archiveDoneTodos()]
+
+ if (getUiState().transcript === 'episodes') {
+ // Episodes mode: collapse the whole turn's steps into one message. A step
+ // counts when it ran tools or genuinely reasoned; the stop call (no tools)
+ // is left out so its text isn't duplicated below the steps.
+ const workEpisodes = this.episodes.filter(ep => ep.tools.length > 0 || hasMeaningfulReasoning(ep.reasoning ?? ''))
+
+ if (workEpisodes.length) {
+ finalMessages.push({ kind: 'episodes', role: 'assistant', text: finalText, episodes: workEpisodes })
+ } else if (segments.length || hasDetails(finalDetails)) {
+ // No episode boundaries arrived (e.g. an older gateway that doesn't emit
+ // episode.start) but the turn did run tools/reasoning — fall back to the
+ // legacy trail so that history isn't silently lost.
+ finalMessages.push(...segments, ...(hasDetails(finalDetails) ? [finalDetails] : []))
+
+ if (finalText) {
+ finalMessages.push({ role: 'assistant', text: finalText })
+ }
+ } else if (finalText) {
+ finalMessages.push({ role: 'assistant', text: finalText })
+ }
+ } else {
+ finalMessages.push(...segments, ...(hasDetails(finalDetails) ? [finalDetails] : []))
- if (finalText) {
- finalMessages.push({ role: 'assistant', text: finalText })
+ if (finalText) {
+ finalMessages.push({ role: 'assistant', text: finalText })
+ }
}
const wasInterrupted = this.interrupted
@@ -558,6 +655,15 @@ class TurnController {
this.pruneTransient()
this.endReasoningPhase()
+ // First visible token of this step ends its thinking phase: stamp the span so
+ // the reasoning row freezes there instead of counting for the whole turn.
+ const ep = this.currentEpisode()
+
+ if (ep && ep.reasoningMs == null && ep.tools.length === 0 && this.lastEpisodeStartMs) {
+ ep.reasoningMs = Date.now() - this.lastEpisodeStartMs
+ this.publishEpisodes()
+ }
+
// Always accumulate the raw text delta. The pre-#16391 path replaced
// the entire buffer with `rendered` (an *incremental* Rich ANSI
// fragment), which on every tick discarded everything streamed so far
@@ -570,6 +676,53 @@ class TurnController {
}
}
+ // Boundary marker: the backend has started a new model call. Opens a fresh
+ // episode bucket that subsequent reasoning / narration / tools accrue into
+ // (episodes-mode rendering); harmless in legacy mode.
+ recordEpisodeStart(index: number) {
+ // Fully inert in legacy mode: opening no episode means no accrual, no
+ // publishEpisodes churn, and the legacy render path is untouched.
+ if (this.interrupted || getUiState().transcript !== 'episodes') {
+ return
+ }
+
+ const now = Date.now()
+ const prev = this.episodes.at(-1)
+
+ // A new call ends the previous episode: stamp its wall-clock duration.
+ if (prev && this.lastEpisodeStartMs) {
+ prev.durationMs = now - this.lastEpisodeStartMs
+ }
+
+ this.lastEpisodeStartMs = now
+ this.episodes = [...this.episodes, { index, startedAt: now, reasoning: '', narration: '', tools: [] }]
+ this.publishEpisodes()
+ }
+
+ // Stamp the final episode's duration at turn end (no next episode to do it).
+ private stampLastEpisodeDuration() {
+ const last = this.episodes.at(-1)
+
+ if (last && this.lastEpisodeStartMs) {
+ last.durationMs = Date.now() - this.lastEpisodeStartMs
+
+ // A tool-less step never stamped reasoningMs at a first tool; its whole
+ // wall time is thinking.
+ if (last.reasoningMs == null) {
+ last.reasoningMs = last.durationMs
+ }
+ }
+ }
+
+ private currentEpisode(): Episode | undefined {
+ return this.episodes.at(-1)
+ }
+
+ private publishEpisodes() {
+ // Deep-ish copy so React sees new refs for the mutated current episode/tool.
+ patchTurnState({ episodes: this.episodes.map(ep => ({ ...ep, tools: ep.tools.map(t => ({ ...t })) })) })
+ }
+
recordReasoningAvailable(text: string) {
if (this.interrupted || !getUiState().showReasoning) {
return
@@ -604,6 +757,16 @@ class TurnController {
this.reasoningText = this.reasoningText.slice(-60_000)
}
+ const ep = this.currentEpisode()
+
+ if (ep) {
+ ep.reasoning = (ep.reasoning ?? '') + text
+
+ if (ep.reasoning.length > 20_000) {
+ ep.reasoning = ep.reasoning.slice(-16_000)
+ }
+ }
+
this.scheduleReasoning()
this.syncReasoningSegment()
this.pulseReasoningStreaming()
@@ -624,11 +787,50 @@ class TurnController {
this.recordTodos(todos)
const line = this.completeTool(toolId, fallbackName, error, summary, duration)
+ this.finalizeEpisodeTool(toolId, error, summary, duration)
this.pendingSegmentTools = [...this.pendingSegmentTools, line]
this.flushPendingToolsIntoLastSegment()
this.publishToolState()
}
+ private finalizeEpisodeTool(toolId: string, error?: string, summary?: string, duration?: number, diff?: string) {
+ // No episodes open (legacy mode / no episode.start) => nothing to finalize
+ // and no publish, keeping legacy turns free of episode-store churn.
+ if (!this.episodes.length) {
+ return
+ }
+
+ for (const ep of this.episodes) {
+ const et = ep.tools.find(tool => tool.id === toolId)
+
+ if (et) {
+ et.ok = !error
+ et.done = true
+ et.resultPreview = (error || summary || '').slice(0, 200) || undefined
+ // Fall back to the client-measured span: the typed RPC path does not
+ // carry a duration, and leaving it unset made the row's live timer keep
+ // ticking after the step had moved on.
+ et.durationMs =
+ duration != null
+ ? Math.round(duration * 1000)
+ : et.startedAt
+ ? Math.max(0, Date.now() - et.startedAt)
+ : undefined
+
+ if (diff) {
+ et.diff = diff
+ const stat = diffStat(diff)
+ et.added = stat.added
+ et.removed = stat.removed
+ }
+
+ break
+ }
+ }
+
+ this.publishEpisodes()
+ }
+
recordInlineDiffToolComplete(
diffText: string,
toolId: string,
@@ -642,6 +844,7 @@ class TurnController {
this.flushStreamingSegment()
this.pushInlineDiffSegment(diffText, [this.completeTool(toolId, fallbackName, error, '', duration)])
+ this.finalizeEpisodeTool(toolId, error, '', duration, diffText)
this.publishToolState()
}
@@ -718,6 +921,18 @@ class TurnController {
this.toolTokenAcc += sample ? estimateTokensRough(sample) : 0
this.activeTools = [...this.activeTools, { context, id: toolId, name, startedAt: Date.now() }]
+ const ep = this.currentEpisode()
+
+ if (ep) {
+ // First tool of the step: the elapsed so far is the thinking time.
+ if (ep.tools.length === 0 && this.lastEpisodeStartMs) {
+ ep.reasoningMs = Date.now() - this.lastEpisodeStartMs
+ }
+
+ ep.tools.push({ id: toolId, name, summary: context, ok: true, done: false, startedAt: Date.now() })
+ this.publishEpisodes()
+ }
+
patchTurnState({ toolTokens: this.toolTokenAcc, tools: this.activeTools })
}
@@ -755,6 +970,11 @@ class TurnController {
reasoning: this.reasoningText,
reasoningTokens: estimateTokensRough(this.reasoningText)
})
+ // Episodes mode: push the growing reasoning so the running step streams
+ // its thought live instead of only revealing it once the step completes.
+ if (getUiState().transcript === 'episodes') {
+ this.publishEpisodes()
+ }
}, STREAM_BATCH_MS)
}
diff --git a/ui-tui/src/app/turnStore.ts b/ui-tui/src/app/turnStore.ts
index 6d47b036..731d6ab9 100644
--- a/ui-tui/src/app/turnStore.ts
+++ b/ui-tui/src/app/turnStore.ts
@@ -6,12 +6,13 @@
import { atom } from 'nanostores'
import { useSyncExternalStore } from 'react'
-import type { ActiveTool, ActivityItem, Msg, SubagentProgress, TodoItem } from '../types.js'
+import type { ActiveTool, ActivityItem, Episode, Msg, SubagentProgress, TodoItem } from '../types.js'
import { isTodoDone } from '../lib/liveProgress.js'
const buildTurnState = (): TurnState => ({
activity: [],
+ episodes: [],
outcome: '',
reasoning: '',
reasoningActive: false,
@@ -74,6 +75,7 @@ export const resetTurnState = () => $turnState.set(buildTurnState())
export interface TurnState {
activity: ActivityItem[]
+ episodes: Episode[]
outcome: string
reasoning: string
reasoningActive: boolean
diff --git a/ui-tui/src/app/uiStore.ts b/ui-tui/src/app/uiStore.ts
index fde851b0..d87c69b7 100644
--- a/ui-tui/src/app/uiStore.ts
+++ b/ui-tui/src/app/uiStore.ts
@@ -29,6 +29,7 @@ const buildUiState = (): UiState => ({
status: 'summoning raven…',
statusBar: 'bottom',
streaming: true,
+ transcript: 'episodes',
theme: DEFAULT_THEME,
usage: ZERO
})
diff --git a/ui-tui/src/app/useConfigSync.ts b/ui-tui/src/app/useConfigSync.ts
index 8c0707f0..07c7d7e1 100644
--- a/ui-tui/src/app/useConfigSync.ts
+++ b/ui-tui/src/app/useConfigSync.ts
@@ -154,6 +154,11 @@ export const applyDisplay = (
showReasoning: d.show_reasoning !== false,
statusBar: normalizeStatusBar(d.tui_statusbar),
streaming: d.streaming !== false
+ // NOTE: `transcript` is intentionally NOT synced here. It is a runtime-only
+ // session flag toggled by `/transcript` (`display` is not a persisted config
+ // block). Re-hydration runs on every config mtime poll; setting transcript
+ // here would silently revert the user's `/transcript episodes` choice within
+ // seconds of any other config change.
})
}
diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts
index 3d08a4b0..b5a56806 100644
--- a/ui-tui/src/app/useMainApp.ts
+++ b/ui-tui/src/app/useMainApp.ts
@@ -506,14 +506,20 @@ export function useMainApp(gw: GatewayClient, rpcClient?: ChatStreamRpcClient) {
}
if (answer) {
- turnController.persistedToolLabels.add(label)
- appendMessage({
- kind: 'trail',
- role: 'system',
- text: '',
- tools: [buildToolTrailLine('clarify', clarify.question)]
- })
- appendMessage({ role: 'user', text: answer })
+ // Legacy transcript: record the clarify as a tool-trail panel plus the
+ // answer as a message. In episodes mode the ask_user / deep_research
+ // tool already renders this Q&A as a step, so committing them here would
+ // double it — and the answer would masquerade as a typed user message.
+ if (getUiState().transcript !== 'episodes') {
+ turnController.persistedToolLabels.add(label)
+ appendMessage({
+ kind: 'trail',
+ role: 'system',
+ text: '',
+ tools: [buildToolTrailLine('clarify', clarify.question)]
+ })
+ appendMessage({ role: 'user', text: answer })
+ }
patchUiState({ status: 'running…' })
} else {
sys('prompt cancelled')
diff --git a/ui-tui/src/components/episodeView.tsx b/ui-tui/src/components/episodeView.tsx
new file mode 100644
index 00000000..92113e71
--- /dev/null
+++ b/ui-tui/src/components/episodeView.tsx
@@ -0,0 +1,479 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 EverMind.
+// See NOTICES.md.
+
+import { Box, NoSelect, stringWidth, Text } from '@hermes/ink'
+import { memo, useEffect, useState } from 'react'
+
+import type { Theme } from '../theme.js'
+import type { Episode, EpisodeTool, Msg } from '../types.js'
+
+import { episodeFailed, groupTools, toolParts, toolsSummary } from '../domain/episodeSummary.js'
+import { fmtDuration } from '../domain/messages.js'
+import { hasMeaningfulReasoning } from '../lib/reasoning.js'
+import { boundedLiveRenderText, clipToWidth, compactPreview, tailPreview } from '../lib/text.js'
+import { Md } from './markdown.js'
+import { StreamingMd } from './streamingMarkdown.js'
+import { Spinner } from './thinking.js'
+
+// Activity rows (tool calls and their result previews) are clipped a few cells
+// short of the container so the transcript keeps a right-hand margin instead of
+// running flush to the edge. Prose keeps the full width — it wraps, so it never
+// looks crammed.
+const ROW_SLACK = 4
+
+// Re-render once a second while `active`, so an in-flight tool can show how
+// long it has been running. Idle turns install no timer.
+const useNow = (active: boolean) => {
+ const [now, setNow] = useState(() => Date.now())
+
+ useEffect(() => {
+ if (!active) {
+ return
+ }
+
+ const id = setInterval(() => setNow(Date.now()), 1000)
+
+ return () => clearInterval(id)
+ }, [active])
+
+ return now
+}
+
+// Elapsed for a tool: its final duration once known, else the live time since it
+// started — so a long call reports progress instead of just spinning. A finished
+// tool never reports live time (that kept old rows counting up).
+const toolElapsed = (tool: EpisodeTool, now: number): number | undefined =>
+ tool.durationMs ?? (!tool.done && tool.startedAt ? Math.max(0, now - tool.startedAt) : undefined)
+
+// One tool row: the `· verb (detail) (dur) ✗` line plus its inline result
+// preview, with a large diff click-to-expand. Shared by both step layouts.
+const ToolRow = memo(function ToolRow({
+ compact,
+ isOpen,
+ live,
+ now,
+ onToggle,
+ t,
+ tool,
+ width
+}: {
+ compact?: boolean
+ isOpen: boolean
+ live: boolean
+ now: number
+ onToggle: () => void
+ t: Theme
+ tool: EpisodeTool
+ width?: number
+}) {
+ const toolRunning = live && !tool.done
+ const parts = toolParts(tool)
+ const elapsed = toolElapsed(tool, now)
+ // ink's `truncate-end` is a no-op once a nests other nodes (the
+ // bold verb + dim detail), so clip the detail to the row's own budget instead
+ // of trusting the wrap mode.
+ const room = (width ?? 120) - ROW_SLACK
+ const suffixW = (elapsed != null ? fmtDuration(elapsed).length + (toolRunning ? 4 : 3) : 0) + (tool.ok ? 0 : 2)
+ const detail = parts.detail
+ ? clipToWidth(parts.detail, Math.max(6, room - 2 - stringWidth(parts.verb) - 3 - suffixW))
+ : ''
+ const resultLines = (tool.resultPreview ?? '')
+ .split('\n')
+ .map(l => l.trim())
+ .filter(Boolean)
+
+ return (
+
+
+
+ {toolRunning ? (
+
+ {' '}
+
+ ) : (
+ {tool.diff ? (isOpen ? '▾ ' : '▸ ') : '· '}
+ )}
+
+
+
+
+ {parts.verb}
+
+ {detail ? (
+
+ {' ('}
+ {detail}
+ {')'}
+
+ ) : (
+ ''
+ )}
+
+
+ {elapsed != null ? (
+
+
+ {' ('}
+ {fmtDuration(elapsed)}
+ {toolRunning ? '…' : ''}
+ {')'}
+
+
+ ) : null}
+ {tool.ok ? null : (
+
+ ✗
+
+ )}
+
+
+ {/* Result preview is shown inline (info density); only a large diff is
+ click-to-expand. A tool may report several lines (ask_user's
+ question -> answer pairs); each becomes its own row. */}
+ {resultLines.map((line, i) => (
+
+
+
+ {' '}
+ {i === resultLines.length - 1 ? '└ ' : '├ '}
+
+
+
+
+ {clipToWidth(line, Math.max(8, room - 4))}
+
+
+
+ ))}
+
+ {isOpen && tool.diff ? : null}
+
+ )
+})
+
+// A run of N same-name tool calls in one step (e.g. 4 parallel searches),
+// rendered as a tree: one verb header, each call a `├`/`└` child with its arg
+// and result. Keeps a burst of identical calls from filling the transcript.
+const ToolGroup = memo(function ToolGroup({
+ live,
+ now,
+ t,
+ tools,
+ width
+}: {
+ live: boolean
+ now: number
+ t: Theme
+ tools: EpisodeTool[]
+ width?: number
+}) {
+ const verb = toolParts(tools[0]!).verb
+ const anyFailed = tools.some(tool => !tool.ok)
+
+ return (
+
+
+
+ {'· '}
+
+
+
+ {verb}
+
+
+ {' ('}
+ {tools.length}
+ {')'}
+
+
+
+
+ {tools.map((tool, i) => {
+ const last = i === tools.length - 1
+ const toolRunning = live && !tool.done
+ const elapsed = toolElapsed(tool, now)
+ const suffixW = (elapsed != null ? fmtDuration(elapsed).length + (toolRunning ? 4 : 3) : 0) + (tool.ok ? 0 : 2)
+ // 4 = the ' ├ ' child prefix.
+ const detail = clipToWidth(
+ toolParts(tool).detail || toolParts(tool).verb,
+ Math.max(6, (width ?? 120) - ROW_SLACK - 4 - suffixW)
+ )
+
+ return (
+
+
+
+ {toolRunning ? (
+
+ {' '}
+ {' '}
+
+ ) : (
+
+ {' '}
+ {last ? '└ ' : '├ '}
+
+ )}
+
+
+
+ {detail}
+
+
+ {elapsed != null ? (
+
+
+ {' ('}
+ {fmtDuration(elapsed)}
+ {toolRunning ? '…' : ''}
+ {')'}
+
+
+ ) : null}
+ {tool.ok ? null : (
+
+ ✗
+
+ )}
+
+
+ {tool.resultPreview ? (
+
+
+
+ {' '}
+ {last ? ' ' : '│ '}
+ {'└ '}
+
+
+
+
+ {clipToWidth(tool.resultPreview, Math.max(8, (width ?? 120) - ROW_SLACK - 6))}
+
+
+
+ ) : null}
+
+ )
+ })}
+
+ )
+})
+
+// Renders a turn as a flat, single-level stream of rows — no outer wrapper, no
+// nesting. Two visual tiers:
+// - narration + the final answer render as normal prose (the model's voice),
+// - reasoning and tool rows render as dim, foldable "activity" lines.
+// A step with no narration collapses to one line ("reasoning for 8s · read 2
+// files"); a step with narration (or the running step) shows its reasoning
+// fold, the narration prose, then its tool folds.
+export const EpisodeView = memo(function EpisodeView({
+ cols,
+ compact,
+ episodes,
+ live = false,
+ t,
+ text
+}: {
+ cols?: number
+ compact?: boolean
+ episodes: Episode[]
+ live?: boolean
+ t: Theme
+ text?: string
+}) {
+ // One fold set keyed by role: `ep:N` expands a collapsed no-narration step,
+ // `rsn:N` its reasoning, `tool:ID` a tool's result/diff.
+ const [open, setOpen] = useState>(() => new Set())
+ const toggle = (key: string) =>
+ setOpen(prev => {
+ const next = new Set(prev)
+
+ if (!next.delete(key)) {
+ next.add(key)
+ }
+
+ return next
+ })
+
+ const lastIdx = episodes.length - 1
+ // Tick only while a tool is actually in flight.
+ const now = useNow(live && episodes.length > 0)
+
+ const reasoningText = (ms?: number) => (ms ? `reasoning for ${fmtDuration(ms)}` : 'reasoning')
+
+ // Bound the view so each row has a finite width to truncate/wrap against.
+ // The transcript wraps its rows in `paddingX={1}` and keeps a scrollbar gutter
+ // on the right (appLayout), so the real room is 4 cells less than `cols` —
+ // assuming only 2 made rows overflow the container and soft-wrap in the
+ // terminal, which showed up as stray blank lines and edge-cut text.
+ const width = cols ? Math.max(20, cols - 4) : undefined
+ // Prose (narration + the final answer) is indented one step in, so it gets
+ // that much less room. Both must use the SAME indent: the streaming text
+ // renders inside the running step and the committed answer renders here, so a
+ // mismatch makes the whole block visibly jump sideways when the turn ends.
+ const PROSE_INDENT = 2
+ const proseWidth = width ? Math.max(20, width - PROSE_INDENT) : undefined
+
+ return (
+
+ {episodes.map((ep, epIdx) => {
+ // A step that follows one which displayed tool rows gets a blank line, so
+ // the next `reasoning` row doesn't butt straight up against the previous
+ // step's tool output. Collapsed one-line steps stay tight together.
+ const prev = epIdx > 0 ? episodes[epIdx - 1] : undefined
+ const prevShowedTools = Boolean(
+ prev &&
+ prev.tools.length > 0 &&
+ ((prev.narration ?? '').trim() || (live && prev.index === episodes[lastIdx]?.index))
+ )
+ const running = live && ep.index === episodes[lastIdx]?.index
+ const reasoning = (ep.reasoning ?? '').trim()
+ const hasReasoning = hasMeaningfulReasoning(reasoning)
+ // The model streams reasoning and visible content on separate channels;
+ // when it splits a sentence across that boundary the narration can begin
+ // with a dangling separator (",那我用…"). Trim leading punctuation/space.
+ const narration = (ep.narration ?? '').trim().replace(/^[\s,,、;;::。.]+/, '')
+ const hasNarration = narration.length > 0
+ // Live only while the step is genuinely still thinking — once it speaks,
+ // runs a tool, or starts the answer, the span is fixed (the controller
+ // stamps reasoningMs at that moment). Keying this off `running` alone made
+ // the row keep counting for the rest of the turn.
+ const thinking = running && !hasNarration && ep.tools.length === 0 && !text
+ const reasoningMs =
+ ep.reasoningMs ?? ep.durationMs ?? (thinking && ep.startedAt ? Math.max(0, now - ep.startedAt) : undefined)
+
+ // A run of the same tool collapses into one ToolGroup tree; a lone call
+ // stays a flat ToolRow (with its own result/diff fold).
+ const toolRows = groupTools(ep.tools).map(g =>
+ g.length === 1 ? (
+ toggle(`tool:${g[0]!.id}`)}
+ t={t}
+ tool={g[0]!}
+ width={width}
+ />
+ ) : (
+
+ )
+ )
+
+ // Tools render as a tight block, set off from the prose above by one
+ // blank line (so reasoning/narration and the tool list don't run together).
+ const toolBlock = ep.tools.length ? (
+
+ {toolRows}
+
+ ) : null
+
+ // `tail` follows the stream (recent tokens) while thinking live; the
+ // head preview is fine for a finished, folded step.
+ const cot = (tail: boolean) =>
+ hasReasoning ? (
+
+
+ {(tail ? tailPreview : compactPreview)(reasoning, 4000)}
+
+
+ ) : null
+
+ // ── No-narration, finished step: one collapsible summary line ──
+ // A single `ep:N` toggle flips the whole step, so opening it can be undone
+ // (the earlier design toggled the reasoning fold instead, leaving the step
+ // stuck open with its tools exposed).
+ if (!hasNarration && !running) {
+ const epOpen = open.has(`ep:${ep.index}`)
+ const bits = [hasReasoning ? reasoningText(reasoningMs) : '', toolsSummary(ep.tools)]
+ .filter(Boolean)
+ .join(' · ')
+
+ return (
+
+
+ toggle(`ep:${ep.index}`)}>
+ {epOpen ? '▾ ' : '▸ '}
+
+
+
+ {bits ? clipToWidth(bits, Math.max(8, (width ?? 120) - ROW_SLACK - 2)) : '…'}
+
+
+
+ {epOpen ? (
+ <>
+ {cot(false)}
+ {toolBlock}
+ >
+ ) : null}
+
+ )
+ }
+
+ // ── Narrated or running step: always open; reasoning is its own fold ──
+ // Expanded while the reasoning is actually streaming, folded once the
+ // step has produced anything visible — narration, tools, or (for the
+ // final step, which gets neither) the streaming answer text. Without the
+ // `!text` case the last step's CoT stayed open for the whole answer.
+ const liveReasoning = thinking && hasReasoning
+ const rsnOpen = liveReasoning || open.has(`rsn:${ep.index}`)
+
+ return (
+
+ {hasReasoning ? (
+
+
+ toggle(`rsn:${ep.index}`)}>
+ {rsnOpen ? '▾ ' : '▸ '}
+
+
+ {reasoningText(reasoningMs)}
+
+
+ {rsnOpen ? cot(liveReasoning || running) : null}
+
+ ) : null}
+
+ {hasNarration || (running && text) ? (
+
+ {hasNarration ? (
+
+ ) : (
+
+ )}
+
+ ) : null}
+
+ {toolBlock}
+
+ )
+ })}
+
+ {text && (!live || episodes.length === 0) ? (
+ 0 ? 1 : 0} paddingLeft={PROSE_INDENT}>
+
+
+ ) : null}
+
+ )
+})
+
+// History path: a committed `kind: 'episodes'` message.
+export const EpisodeMessage = memo(function EpisodeMessage({
+ cols,
+ compact,
+ msg,
+ t
+}: {
+ cols?: number
+ compact?: boolean
+ msg: Msg
+ t: Theme
+}) {
+ return
+})
diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx
index 2c096ec5..694b4dc2 100644
--- a/ui-tui/src/components/markdown.tsx
+++ b/ui-tui/src/components/markdown.tsx
@@ -206,7 +206,71 @@ export const stripInlineMarkup = (v: string) =>
.replace(/(? {
+// Hard-wrap to a display-cell width. Terminal word-wrap breaks on spaces only,
+// so CJK (which has none) becomes one unbreakable "word" and overflows its
+// column — the cause of tables bleeding across columns. Break per character,
+// preferring a space boundary when the line has one (so latin still wraps by
+// word).
+// Clip to a display-cell width. Table cells stay ONE line on purpose: the
+// transcript's virtual-scroll height estimate counts one row per markdown table
+// line, so a wrapping cell would render taller than its reserved slot and leave
+// stale cells behind (visible as garbled overdraw). Alignment beats completeness
+// here — widen the terminal to see more.
+const clipWidth = (raw: string, width: number): string => {
+ const text = raw.replace(/\s+/g, ' ').trim()
+
+ if (width <= 0 || stringWidth(text) <= width) {
+ return text
+ }
+
+ let out = ''
+ let w = 0
+
+ for (const ch of text) {
+ const cw = stringWidth(ch)
+
+ if (w + cw > width - 1) {
+ break
+ }
+
+ out += ch
+ w += cw
+ }
+
+ return `${out}…`
+}
+
+// Fit content widths into the available budget: scale down proportionally, then
+// shave the widest column until it fits. Keeps every column present (min 6
+// cells) instead of letting the row overflow the terminal.
+const fitWidths = (content: number[], budget: number): number[] => {
+ const total = content.reduce((a, b) => a + b, 0)
+
+ if (total <= budget) {
+ return content
+ }
+
+ const min = 6
+ const scale = budget / total
+ const widths = content.map(c => Math.max(min, Math.floor(c * scale)))
+
+ let over = widths.reduce((a, b) => a + b, 0) - budget
+
+ while (over > 0) {
+ const widest = widths.indexOf(Math.max(...widths))
+
+ if (widths[widest]! <= min) {
+ break
+ }
+
+ widths[widest] = widths[widest]! - 1
+ over--
+ }
+
+ return widths
+}
+
+const renderTable = (k: number, rows: string[][], t: Theme, avail?: number) => {
// Column widths in *display cells*, not UTF-16 code units. CJK
// glyphs and most emoji render as two cells but `String#length`
// counts them as one, which collapses Chinese / Japanese / Korean
@@ -215,7 +279,11 @@ const renderTable = (k: number, rows: string[][], t: Theme) => {
// @hermes/ink) returns the actual cell count.
const cellWidth = (raw: string) => stringWidth(stripInlineMarkup(raw))
- const widths = rows[0]!.map((_, ci) => Math.max(...rows.map(r => cellWidth(r[ci] ?? ''))))
+ const GAP = 2
+ const content = rows[0]!.map((_, ci) => Math.max(...rows.map(r => cellWidth(r[ci] ?? ''))))
+ // paddingLeft(2) + the transcript gutter/scrollbar margin come off the top.
+ const room = (avail ?? process.stdout.columns ?? 80) - 8 - GAP * (content.length - 1)
+ const widths = fitWidths(content, Math.max(content.length * 6, room))
// Thin divider under the header. Without it tables look like prose
// with extra spacing because the header is just accent-coloured text
@@ -223,25 +291,43 @@ const renderTable = (k: number, rows: string[][], t: Theme) => {
// from `stringWidth(...)`, so the dividers and the row content stay
// in sync on CJK / emoji tables; tab-style column gaps still read
// cleanly without the boxed look.
- const sep = widths.map(w => '─'.repeat(Math.max(1, w))).join(' ')
+ // Each cell is a fixed-width but *shrinkable* box, so a table wider than the
+ // terminal shrinks its columns instead of letting rows wrap raggedly across
+ // column boundaries (which also tore the divider into fragments). Text wraps
+ // inside its own column, keeping rows aligned; `minWidth={0}` is what allows
+ // a flex child to shrink below its content width at all.
+ const cells = (render: (w: number, ci: number) => ReactNode) =>
+ widths.map((w, ci) => (
+
+ {ci > 0 ? : null}
+
+ {render(w, ci)}
+
+
+ ))
return (
{rows.map((row, ri) => (
- {widths.map((w, ci) => (
-
-
- {' '.repeat(Math.max(0, w - cellWidth(row[ci] ?? '')))}
- {ci < widths.length - 1 ? ' ' : ''}
+ {/* Cells are pre-wrapped to their own column width, so inline
+ markup is stripped rather than rendered via MdInline: a nested
+ would re-wrap on spaces and undo the CJK-aware wrap. */}
+ {cells((w, ci) => (
+
+ {clipWidth(stripInlineMarkup(row[ci] ?? ''), w)}
))}
{ri === 0 && rows.length > 1 ? (
-
- {sep}
-
+
+ {cells(w => (
+
+ {'─'.repeat(Math.max(1, w))}
+
+ ))}
+
) : null}
))}
@@ -401,10 +487,10 @@ const cacheSet = (b: Map, key: string, v: ReactNode[]) => {
}
}
-function MdImpl({ compact, t, text }: MdProps) {
+function MdImpl({ avail, compact, t, text }: MdProps) {
const nodes = useMemo(() => {
const bucket = cacheBucket(t)
- const cacheKey = `${compact ? '1' : '0'}|${text}`
+ const cacheKey = `${compact ? '1' : '0'}|${avail ?? 0}|${text}`
const cached = cacheGet(bucket, cacheKey)
if (cached) {
@@ -791,7 +877,7 @@ function MdImpl({ compact, t, text }: MdProps) {
rows.push(splitRow(lines[i]!))
}
- nodes.push(renderTable(key, rows, t))
+ nodes.push(renderTable(key, rows, t, avail))
continue
}
@@ -844,7 +930,7 @@ function MdImpl({ compact, t, text }: MdProps) {
}
if (rows.length) {
- nodes.push(renderTable(key, rows, t))
+ nodes.push(renderTable(key, rows, t, avail))
}
continue
@@ -858,7 +944,7 @@ function MdImpl({ compact, t, text }: MdProps) {
cacheSet(bucket, cacheKey, nodes)
return nodes
- }, [compact, t, text])
+ }, [avail, compact, t, text])
return {nodes}
}
@@ -868,6 +954,9 @@ export const Md = memo(MdImpl)
type Kind = 'blank' | 'code' | 'heading' | 'list' | 'paragraph' | 'quote' | 'rule' | 'table' | null
interface MdProps {
+ // Display cells available for layout. Only tables need it (to fit their
+ // columns); omitted, they fall back to the terminal width.
+ avail?: number
compact?: boolean
t: Theme
text: string
diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx
index cfe49234..ea2d3397 100644
--- a/ui-tui/src/components/messageLine.tsx
+++ b/ui-tui/src/components/messageLine.tsx
@@ -22,6 +22,7 @@ import {
isPasteBackedText,
stripAnsi
} from '../lib/text.js'
+import { EpisodeMessage } from './episodeView.js'
import { Md } from './markdown.js'
import { StreamingMd } from './streamingMarkdown.js'
import { ToolTrail } from './thinking.js'
@@ -58,6 +59,10 @@ export const MessageLine = memo(function MessageLine({
const systemIsLong = msg.role === 'system' && msg.text.length > SYSTEM_COLLAPSE_CHARS
const [systemOpen, setSystemOpen] = useState(false)
+ if (msg.kind === 'episodes') {
+ return
+ }
+
if (msg.kind === 'trail' && msg.todos?.length) {
return (
) : (
-
+
)
}
@@ -168,7 +178,13 @@ export const MessageLine = memo(function MessageLine({
)
}
- return {msg.text}
+ // Bold for the user's own prompt — pairs with the accent chevron to set it
+ // apart from assistant prose and the dim activity lines.
+ return (
+
+ {msg.text}
+
+ )
})()
// Diff segments (emitted by pushInlineDiffSegment between narration
diff --git a/ui-tui/src/components/queuedMessages.tsx b/ui-tui/src/components/queuedMessages.tsx
index e2b41646..5b066c3f 100644
--- a/ui-tui/src/components/queuedMessages.tsx
+++ b/ui-tui/src/components/queuedMessages.tsx
@@ -7,7 +7,7 @@ import { Box, Text } from '@hermes/ink'
import type { Theme } from '../theme.js'
-import { compactPreview } from '../lib/text.js'
+import { clipToWidth } from '../lib/text.js'
export const QUEUE_WINDOW = 3
@@ -26,19 +26,20 @@ export function QueuedMessages({ cols, queueEditIdx, queued, t }: QueuedMessages
}
const q = getQueueWindow(queued.length, queueEditIdx)
+ const room = Math.max(16, cols - 10)
+ // A dim left rail, sitting right above the composer: the position already says
+ // "waiting to be sent", so the block needs no header word. Every other glyph in
+ // the transcript is taken — `❯` is a user message, `·` + `├`/`└` is a tool call,
+ // `▸` is a fold — so a rail is the one shape that cannot be misread as those.
+ //
+ // Rows are plain : this panel shares the bottom pane with absolutely
+ // positioned overlays, where block-level children garbled the frame.
return (
-
- {`queued (${queued.length})${
- queueEditIdx !== null ? ` · editing ${queueEditIdx + 1} · Ctrl+X delete · Esc cancel` : ''
- }`}
-
-
{q.showLead && (
-
- {' '}
- …
+
+ {'│ ⋮'}
)}
@@ -47,15 +48,28 @@ export function QueuedMessages({ cols, queueEditIdx, queued, t }: QueuedMessages
const active = queueEditIdx === idx
return (
-
- {active ? '▸' : ' '} {idx + 1}. {compactPreview(item, Math.max(16, cols - 10))}
+
+ {/* The edited row swaps the rail for a caret and lights up. */}
+
+ {active ? '▸ ' : '│ '}
+
+ {/* Queued text is the user's own words: full-weight so it stays
+ readable against the dim rail. */}
+ {clipToWidth(item, room)}
)
})}
{q.showTail && (
-
- {' '}…and {queued.length - q.end} more
+
+ {'│ …and '}
+ {queued.length - q.end} more
+
+ )}
+
+ {queueEditIdx !== null && (
+
+ {' Ctrl+X delete · Esc cancel'}
)}
diff --git a/ui-tui/src/components/streamingAssistant.tsx b/ui-tui/src/components/streamingAssistant.tsx
index bfe00718..6c94e7a7 100644
--- a/ui-tui/src/components/streamingAssistant.tsx
+++ b/ui-tui/src/components/streamingAssistant.tsx
@@ -12,6 +12,7 @@ import type { DetailsMode, Msg, SectionVisibility } from '../types.js'
import { toggleTodoCollapsed, useTurnSelector } from '../app/turnStore.js'
import { $uiState } from '../app/uiStore.js'
import { appendToolShelfMessage } from '../lib/liveProgress.js'
+import { EpisodeView } from './episodeView.js'
import { MessageLine } from './messageLine.js'
import { TodoPanel } from './todoPanel.js'
@@ -31,12 +32,21 @@ export const StreamingAssistant = memo(function StreamingAssistant({
const streamPendingTools = useTurnSelector(state => state.streamPendingTools)
const streaming = useTurnSelector(state => state.streaming)
const activeTools = useTurnSelector(state => state.tools)
+ const episodes = useTurnSelector(state => state.episodes)
const showStreamingArea = Boolean(streaming)
- if (!progress.showProgressArea && !showStreamingArea && !activeTools.length) {
+ if (!progress.showProgressArea && !showStreamingArea && !activeTools.length && !episodes.length) {
return null
}
+ // Episodes mode: one live, drilldown view of the running turn instead of the
+ // flat segment/tool/stream stack.
+ if (ui.transcript === 'episodes') {
+ return (
+
+ )
+ }
+
return (
<>
{groupedSegments(streamSegments).map((msg, i) => (
diff --git a/ui-tui/src/domain/episodeSummary.ts b/ui-tui/src/domain/episodeSummary.ts
new file mode 100644
index 00000000..235e846d
--- /dev/null
+++ b/ui-tui/src/domain/episodeSummary.ts
@@ -0,0 +1,169 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 EverMind.
+// See NOTICES.md.
+
+import type { Episode, EpisodeTool } from '../types.js'
+
+// Per-tool phrasing. `style` decides how a run of the same tool collapses:
+// count — homogeneous info tools -> "read 6 files"; single -> the target
+// target — heterogeneous/mutating tools -> show the target; many -> "ran 3 commands"
+// Verbs are deliberately Raven's own plain lowercase (not Claude Code's labels).
+interface VerbRule {
+ verb: string
+ unit: string
+ style: 'count' | 'target'
+}
+
+// OVERRIDES are polish, not the source of truth: a hand-picked verb only for the
+// tools whose phrasing we're confident about. Every other tool — one we haven't
+// listed, or one added later — is handled by `ruleFor` below, which derives a
+// readable label from the tool name itself. So a new backend tool renders
+// sensibly with zero changes here, instead of silently degrading to "ran".
+// Tool names are the real backend names (raven/agent/tools/*.py).
+const OVERRIDES: Record = {
+ read_file: { verb: 'read', unit: 'files', style: 'count' },
+ grep: { verb: 'searched', unit: 'patterns', style: 'count' },
+ find: { verb: 'found', unit: 'files', style: 'count' },
+ list_dir: { verb: 'listed', unit: 'dirs', style: 'count' },
+ web_search: { verb: 'searched', unit: 'queries', style: 'target' },
+ web_fetch: { verb: 'fetched', unit: 'urls', style: 'target' },
+ exec: { verb: 'ran', unit: 'commands', style: 'target' },
+ edit_file: { verb: 'edited', unit: 'files', style: 'target' },
+ write_file: { verb: 'wrote', unit: 'files', style: 'target' },
+ deep_research: { verb: 'researched', unit: '', style: 'target' },
+ cron: { verb: 'scheduled', unit: '', style: 'target' },
+ ask_user: { verb: 'asked', unit: '', style: 'target' },
+ spawn: { verb: 'delegated', unit: 'subagents', style: 'count' }
+}
+
+// A tool name is a snake_case identifier; humanize it to a plain lowercase
+// phrase used as the fallback verb: "web_search" -> "web search",
+// "image_generate" -> "image generate". Never misleading, always maintenance-free.
+const humanize = (name: string) => name.split('_').filter(Boolean).join(' ')
+
+// The rule for any tool: its override if we have one, else a generic rule built
+// from the humanized name. This is what keeps the table from needing a row per
+// tool — unknown tools get a real label ("image generate"), not a wrong "ran".
+const ruleFor = (name: string): VerbRule =>
+ OVERRIDES[name] ?? { verb: humanize(name) || name, unit: 'calls', style: 'target' }
+
+// Search-like tools read better with the needle quoted: searched "DeviceFlow".
+const QUOTED = new Set(['grep', 'find', 'web_search'])
+
+// Only path-like arguments are clipped from the LEFT (a path's meaning lives in
+// its tail). Questions, commands and prose must keep their head, or the row
+// becomes unreadable ("…体是指哪个?").
+const PATHY = new Set(['read_file', 'write_file', 'edit_file', 'list_dir'])
+
+const titleName = (name: string) =>
+ name
+ .split('_')
+ .filter(Boolean)
+ .map(p => p[0]!.toUpperCase() + p.slice(1))
+ .join(' ') || name
+
+const clip = (s: string, n = 40) => {
+ const one = s.replace(/\s+/g, ' ').trim()
+
+ return one.length > n ? `${one.slice(0, n - 1)}…` : one
+}
+
+// The visible target for a single call: a file basename for path-like tools, a
+// quoted needle for search tools, else the trimmed argument itself.
+const target = (tool: EpisodeTool): string => {
+ const raw = tool.summary.trim()
+
+ if (!raw) {
+ return ''
+ }
+
+ if (
+ tool.name === 'read_file' ||
+ tool.name === 'write_file' ||
+ tool.name === 'edit_file' ||
+ tool.name === 'list_dir'
+ ) {
+ return clip(raw.split(/[\\/]/).pop() || raw)
+ }
+
+ if (QUOTED.has(tool.name)) {
+ return `"${clip(raw, 32)}"`
+ }
+
+ return clip(raw)
+}
+
+const phraseFor = (tools: EpisodeTool[]): string => {
+ const rule = ruleFor(tools[0]!.name)
+ const verb = rule.verb
+
+ if (tools.length === 1) {
+ const only = tools[0]!
+ const t = target(only)
+ const base = t ? `${verb} ${t}` : verb || titleName(only.name)
+ const stat = only.added != null || only.removed != null ? ` (+${only.added ?? 0} -${only.removed ?? 0})` : ''
+
+ return `${base}${stat}`
+ }
+
+ const unit = rule.unit || 'calls'
+
+ return `${verb} ${tools.length} ${unit}`
+}
+
+// Left-clip so a long path keeps its meaningful tail (basename) instead of the
+// truncate-end losing it: "…/controller/memory/agent_memory.go".
+const clipPath = (s: string, n = 60): string => {
+ const one = s.replace(/\s+/g, ' ').trim()
+
+ return one.length > n ? `…${one.slice(one.length - (n - 1))}` : one
+}
+
+// Verb + detail split so a tool row can weight them differently (verb normal,
+// "(detail)" dim) instead of one flat same-color string. Unlike the collapsed
+// label (which uses the basename), the expanded row shows the fuller argument
+// (full path / command) so drilling in restores the detail: read
+// (…/memory/agent_memory.go), ran (ls -la /tmp/…), edited (notes.md +12 -3).
+export const toolParts = (tool: EpisodeTool): { verb: string; detail: string } => {
+ const verb = ruleFor(tool.name).verb
+ const raw = tool.summary.trim()
+ const stat = tool.added != null || tool.removed != null ? `+${tool.added ?? 0} -${tool.removed ?? 0}` : ''
+ // Budget is generous: the row itself truncates to the terminal width, so
+ // pre-clipping only guards against pathological one-liners.
+ const arg = QUOTED.has(tool.name)
+ ? raw
+ ? `"${clip(raw, 120)}"`
+ : ''
+ : PATHY.has(tool.name)
+ ? clipPath(raw, 120)
+ : clip(raw, 120)
+ const detail = [arg, stat].filter(Boolean).join(' ')
+
+ return { verb: verb || titleName(tool.name), detail }
+}
+
+// Splits an episode's tools into consecutive same-name runs, e.g.
+// [read, read, exec, read] -> [[read, read], [exec], [read]]. Used both to
+// summarize a collapsed step and to render a run of N same-tool calls as a tree.
+export const groupTools = (tools: EpisodeTool[]): EpisodeTool[][] => {
+ const groups: EpisodeTool[][] = []
+
+ for (const tool of tools) {
+ const last = groups.at(-1)
+
+ if (last && last[0]!.name === tool.name) {
+ last.push(tool)
+ } else {
+ groups.push([tool])
+ }
+ }
+
+ return groups
+}
+
+// Groups an episode's tools by name (consecutive runs) and joins the phrases:
+// "read 6 files, ran ls, edited notes.md".
+export const toolsSummary = (tools: EpisodeTool[]): string => groupTools(tools).map(phraseFor).join(', ')
+
+// Whether any tool in the episode failed (drives the error tint on the label).
+export const episodeFailed = (ep: Episode): boolean => ep.tools.some(t => !t.ok)
diff --git a/ui-tui/src/domain/roles.ts b/ui-tui/src/domain/roles.ts
index df92b68c..f0d1e95b 100644
--- a/ui-tui/src/domain/roles.ts
+++ b/ui-tui/src/domain/roles.ts
@@ -5,5 +5,8 @@ export const ROLE: Record { body: string; glyph: string; pre
assistant: t => ({ body: t.color.text, glyph: t.brand.tool, prefix: t.color.muted }),
system: t => ({ body: '', glyph: '·', prefix: t.color.muted }),
tool: t => ({ body: t.color.muted, glyph: '⚡', prefix: t.color.muted }),
- user: t => ({ body: t.color.label, glyph: t.brand.prompt, prefix: t.color.label })
+ // The user's own words get their own tier: an accent chevron plus full-weight
+ // text, so a prompt reads as the turn's heading instead of blending into the
+ // dim reasoning/tool activity lines (which also used `label`).
+ user: t => ({ body: t.color.text, glyph: t.brand.prompt, prefix: t.color.accent })
}
diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts
index 7a09a823..c73e74a6 100644
--- a/ui-tui/src/gatewayTypes.ts
+++ b/ui-tui/src/gatewayTypes.ts
@@ -487,6 +487,7 @@ export type GatewayEvent =
| { payload: SessionInfo; session_id?: string; type: 'session.info' }
| { payload?: { text?: string }; session_id?: string; type: 'thinking.delta' }
| { payload?: undefined; session_id?: string; type: 'message.start' }
+ | { payload?: { index?: number }; session_id?: string; type: 'episode.start' }
| { payload?: { kind?: string; text?: string }; session_id?: string; type: 'status.update' }
| { payload?: { state?: 'idle' | 'listening' | 'transcribing' }; session_id?: string; type: 'voice.status' }
| { payload?: { no_speech_limit?: boolean; text?: string }; session_id?: string; type: 'voice.transcript' }
diff --git a/ui-tui/src/lib/inputMetrics.ts b/ui-tui/src/lib/inputMetrics.ts
index 84a16e6b..b1a132c3 100644
--- a/ui-tui/src/lib/inputMetrics.ts
+++ b/ui-tui/src/lib/inputMetrics.ts
@@ -171,12 +171,15 @@ export function composerPromptWidth(promptText: string) {
return Math.max(1, stringWidth(promptText)) + COMPOSER_PROMPT_GAP_WIDTH
}
-export function transcriptGutterWidth(role: Role, userPrompt: string) {
- return role === 'user' ? composerPromptWidth(userPrompt) : 3
+// Both gutters derive from their glyph's display width plus the same gap, so a
+// user line and an assistant line start their text in the same column (a
+// hardcoded non-user width silently drifted one cell off the `❯` gutter).
+export function transcriptGutterWidth(role: Role, userPrompt: string, toolGlyph = '┊') {
+ return composerPromptWidth(role === 'user' ? userPrompt : toolGlyph)
}
-export function transcriptBodyWidth(totalCols: number, role: Role, userPrompt: string) {
- return Math.max(20, totalCols - transcriptGutterWidth(role, userPrompt) - 2)
+export function transcriptBodyWidth(totalCols: number, role: Role, userPrompt: string, toolGlyph?: string) {
+ return Math.max(20, totalCols - transcriptGutterWidth(role, userPrompt, toolGlyph) - 2)
}
export function stableComposerColumns(totalCols: number, promptWidth: number) {
diff --git a/ui-tui/src/lib/reasoning.ts b/ui-tui/src/lib/reasoning.ts
index f384285e..3eeea52a 100644
--- a/ui-tui/src/lib/reasoning.ts
+++ b/ui-tui/src/lib/reasoning.ts
@@ -44,6 +44,12 @@ export function splitReasoning(input: string): SplitReasoning {
}
}
+// A reasoning burst that carries no actual words is noise, not thought: some
+// models (e.g. minimax-m3 during a mechanical tool loop) emit reasoning_content
+// that is just "." / ".\n.\n" placeholder dots. Require at least one letter or
+// digit in any script.
+export const hasMeaningfulReasoning = (input: string) => /[\p{L}\p{N}]/u.test(input)
+
export const hasReasoningTag = (input: string) => {
for (const tag of TAGS) {
if (input.includes(`<${tag}>`)) {
diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts
index 5d4611b2..a96d8f0b 100644
--- a/ui-tui/src/lib/text.ts
+++ b/ui-tui/src/lib/text.ts
@@ -3,6 +3,8 @@
// Modifications Copyright (c) 2026 EverMind.
// See NOTICES.md and LICENSES/MIT-hermes-agent.txt.
+import { stringWidth } from '@hermes/ink'
+
import type { ThinkingMode } from '../types.js'
import {
@@ -57,6 +59,42 @@ export const compactPreview = (s: string, max: number) => {
return !one ? '' : one.length > max ? one.slice(0, max - 1) + '…' : one
}
+// Clip to a display-cell budget (CJK/emoji aware). Needed wherever a row must
+// fit a known column count and the surrounding markup nests (a nested
+// Text makes ink's own `truncate-end` a no-op, so the text would overflow).
+export const clipToWidth = (raw: string, width: number) => {
+ const one = raw.replace(WS_RE, ' ').trim()
+
+ if (width <= 0 || stringWidth(one) <= width) {
+ return one
+ }
+
+ let out = ''
+ let w = 0
+
+ for (const ch of one) {
+ const cw = stringWidth(ch)
+
+ if (w + cw > width - 1) {
+ break
+ }
+
+ out += ch
+ w += cw
+ }
+
+ return `${out}…`
+}
+
+// Like compactPreview but keeps the tail — for live-streaming text (reasoning)
+// where the most recent tokens matter, so the view follows the stream instead
+// of freezing on the first `max` chars.
+export const tailPreview = (s: string, max: number) => {
+ const one = s.replace(WS_RE, ' ').trim()
+
+ return !one ? '' : one.length > max ? '…' + one.slice(one.length - (max - 1)) : one
+}
+
export const estimateTokensRough = (text: string) => (!text ? 0 : (text.length + 3) >> 2)
export const edgePreview = (s: string, head = 16, tail = 28) => {
diff --git a/ui-tui/src/lib/toolArgs.ts b/ui-tui/src/lib/toolArgs.ts
new file mode 100644
index 00000000..133ff81b
--- /dev/null
+++ b/ui-tui/src/lib/toolArgs.ts
@@ -0,0 +1,76 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 EverMind.
+// See NOTICES.md.
+
+// Keys that name the "what" of a tool call. Tried first (in this order) when
+// building a preview so the row shows the query/question/command rather than
+// whatever argument happens to come first — e.g. web_search's numeric `count`,
+// or ask_user's nested `questions` blob. Generic across tools: no per-tool
+// table to keep in sync as tools are added.
+const PREVIEW_KEYS = [
+ 'question',
+ 'query',
+ 'q',
+ 'command',
+ 'cmd',
+ 'pattern',
+ 'path',
+ 'file',
+ 'url',
+ 'prompt',
+ 'text',
+ 'goal',
+ 'task',
+ 'name'
+]
+
+// The first non-empty string reachable from a value, preferring PREVIEW_KEYS
+// when descending into objects/arrays. Depth-bounded so a deeply nested (or
+// cyclic-looking) argument blob can't run away. Non-string scalars (numbers,
+// booleans) are skipped, so `count: 10` never becomes the preview.
+const firstStringArg = (value: unknown, depth = 0): string | undefined => {
+ if (typeof value === 'string') {
+ return value.trim() || undefined
+ }
+
+ if (depth >= 4 || value == null || typeof value !== 'object') {
+ return undefined
+ }
+
+ if (Array.isArray(value)) {
+ for (const item of value) {
+ const found = firstStringArg(item, depth + 1)
+
+ if (found) {
+ return found
+ }
+ }
+
+ return undefined
+ }
+
+ const obj = value as Record
+
+ for (const key of PREVIEW_KEYS) {
+ const found = firstStringArg(obj[key], depth + 1)
+
+ if (found) {
+ return found
+ }
+ }
+
+ for (const val of Object.values(obj)) {
+ const found = firstStringArg(val, depth + 1)
+
+ if (found) {
+ return found
+ }
+ }
+
+ return undefined
+}
+
+// A short, human preview of a tool call's arguments: the query / question /
+// command text, never a numeric flag or a raw JSON dump. Empty string when the
+// call carries no string-ish argument (the row then shows just the verb).
+export const argPreview = (args: Record): string => firstStringArg(args) ?? ''
diff --git a/ui-tui/src/lib/virtualHeights.ts b/ui-tui/src/lib/virtualHeights.ts
index 2006a5f9..812175ee 100644
--- a/ui-tui/src/lib/virtualHeights.ts
+++ b/ui-tui/src/lib/virtualHeights.ts
@@ -5,6 +5,7 @@
import type { Msg } from '../types.js'
+import { groupTools } from '../domain/episodeSummary.js'
import { transcriptBodyWidth } from './inputMetrics.js'
import { boundedHistoryRenderText } from './text.js'
@@ -28,10 +29,22 @@ export const messageHeightKey = (msg: Msg) => {
const introSig = msg.kind === 'intro' ? (msg.info?.version ?? '') : ''
+ // Episodes drive the height for `kind: 'episodes'`, so they must be part of
+ // the cache key — otherwise a stale height survives a step-count change.
+ const epSig =
+ msg.episodes
+ ?.map(
+ ep =>
+ `${ep.narration?.length ?? 0}:${ep.tools
+ .map(tool => (tool.resultPreview ? tool.resultPreview.split('\n').filter(Boolean).length + 1 : 1))
+ .join(',')}`
+ )
+ .join('\u0001') ?? ''
+
return [
msg.role,
msg.kind ?? '',
- hashText([msg.text, msg.thinking ?? '', msg.tools?.join('\n') ?? '', todoSig, panelSig, introSig].join('\0'))
+ hashText([msg.text, msg.thinking ?? '', msg.tools?.join('\n') ?? '', todoSig, panelSig, introSig, epSig].join('\0'))
].join(':')
}
@@ -75,6 +88,56 @@ export const estimatedMsgHeight = (
}
const bodyWidth = transcriptBodyWidth(cols, msg.role, userPrompt)
+
+ // An `episodes` message renders a whole step stream (reasoning rows, prose,
+ // tool rows, result previews) — none of which lives in `msg.text`. Estimating
+ // it from the text alone under-counted by a wide margin, which makes the
+ // virtualized transcript reserve too few rows and leave stale cells behind.
+ if (msg.kind === 'episodes') {
+ let h = 0
+ // episodeView spaces a step from the previous one with marginTop={1} when
+ // that previous step showed tool rows. Counting no row for it is what keeps
+ // this estimate low, and a low estimate is the stale-cell symptom.
+ let prevShowedTools = false
+
+ for (const ep of msg.episodes ?? []) {
+ const narration = (ep.narration ?? '').trim()
+
+ if (prevShowedTools) {
+ h++
+ }
+
+ prevShowedTools = ep.tools.length > 0
+
+ // A step with no narration collapses to a single summary row.
+ if (!narration) {
+ h++
+ continue
+ }
+
+ // reasoning row + blank line + wrapped prose
+ h += 2 + wrappedLines(narration, bodyWidth)
+
+ if (ep.tools.length) {
+ h++ // blank line above the tool block
+
+ for (const group of groupTools(ep.tools)) {
+ // A multi-call run adds a header row above its children.
+ h += group.length > 1 ? 1 : 0
+ for (const tool of group) {
+ // 1 row for the call + one row per reported result line.
+ h += 1 + (tool.resultPreview ? tool.resultPreview.split('\n').filter(Boolean).length : 0)
+ }
+ }
+ }
+ }
+
+ if (msg.text) {
+ h += 1 + wrappedLines(msg.text, bodyWidth)
+ }
+
+ return Math.max(1, h)
+ }
const text = msg.role === 'assistant' && limitHistory ? boundedHistoryRenderText(msg.text) : msg.text
let h = wrappedLines(text || ' ', bodyWidth)
diff --git a/ui-tui/src/rpc/generated.ts b/ui-tui/src/rpc/generated.ts
index 6e3a313a..5d1a8bde 100644
--- a/ui-tui/src/rpc/generated.ts
+++ b/ui-tui/src/rpc/generated.ts
@@ -25,6 +25,7 @@ export type JsonValue = string | number | boolean | null | unknown[] | {};
*/
export type TurnEvent =
| MessageStartEvent
+ | EpisodeStartEvent
| TokenDeltaEvent
| ThinkingDeltaEvent
| ToolStartEvent
@@ -281,6 +282,16 @@ export interface MessageStartEvent {
turn_id: string;
};
}
+/**
+ * This interface was referenced by `RavenRpcRoot`'s JSON-Schema
+ * via the `definition` "EpisodeStartEvent".
+ */
+export interface EpisodeStartEvent {
+ type: 'episode.start';
+ payload: {
+ index: number;
+ };
+}
/**
* This interface was referenced by `RavenRpcRoot`'s JSON-Schema
* via the `definition` "TokenDeltaEvent".
@@ -313,6 +324,7 @@ export interface ToolStartEvent {
arguments: {
[k: string]: JsonValue;
};
+ display?: string | null;
};
}
/**
diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts
index 23228ccf..fa09690f 100644
--- a/ui-tui/src/types.ts
+++ b/ui-tui/src/types.ts
@@ -114,9 +114,43 @@ export interface ClarifyReq {
requestId: string
}
+// One tool invocation inside an episode. `summary` is a target-string ("ls
+// biz/", "approve.go") or empty; the verb comes from the tool name at render.
+export interface EpisodeTool {
+ id: string
+ name: string
+ summary: string
+ resultPreview?: string
+ diff?: string
+ added?: number
+ removed?: number
+ ok: boolean
+ done?: boolean
+ // Wall-clock start, so an in-flight call can show a live elapsed timer
+ // (durationMs is only known once it finishes).
+ startedAt?: number
+ durationMs?: number
+}
+
+// One model call. The transcript groups a turn into these; each collapses to a
+// single summary line and expands to its reasoning + tools.
+export interface Episode {
+ index: number
+ // When this model call began — lets the folded reasoning row show a live
+ // duration while the step is still thinking.
+ startedAt?: number
+ reasoning?: string
+ narration?: string
+ tools: EpisodeTool[]
+ durationMs?: number
+ // Wall time spent before this step's first tool (≈ the model's thinking
+ // time), so "reasoning for Ns" reflects thought, not tool-execution time.
+ reasoningMs?: number
+}
+
export interface Msg {
info?: SessionInfo
- kind?: 'diff' | 'intro' | 'panel' | 'slash' | 'trail'
+ kind?: 'diff' | 'episodes' | 'intro' | 'panel' | 'slash' | 'trail'
panelData?: PanelData
role: Role
text: string
@@ -124,6 +158,7 @@ export interface Msg {
thinkingTokens?: number
toolTokens?: number
tools?: string[]
+ episodes?: Episode[]
todos?: TodoItem[]
todoIncomplete?: boolean
todoCollapsedByDefault?: boolean