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