From b70340a5afc53ee3c4551235bd0d82c70f82f554 Mon Sep 17 00:00:00 2001 From: Lavindeep Dhillon Date: Sun, 28 Jun 2026 08:59:39 -0400 Subject: [PATCH] feat(ui): inline collapsible thinking trail in the full-screen app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the model's reasoning inline as a faint, collapsible trail (design section 31.19). stream_thinking now retains the thinking text (display-only — never recorded in history or fed back to the model) on a per-phase _Trail; the collapsed view shows the first 10 non-blank lines under a "thinking - N reasoning" header with a "+N hidden lines - press t to expand" footer, and the expanded view shows the full trail with a "press t to collapse" footer. t on an empty dock with no active approval toggles the latest trail (the active one while the model runs, the most-recent after); older trails freeze, with no per-trail selection by design. When there is no trail it falls through to ordinary self-insert (event.data * event.arg) so a message starting with t still types, repeat-count included. Any non-thinking transcript content finalizes the active trail, so a tool call between two reasoning phases yields two separate inline blocks. show_reasoning=False builds no trail; cancel/error/stale-turn cleanup clears only the active trail and never resets finished ones. --- docs/DESIGN.md | 12 +++ shellpilot/cli/app.py | 19 ++++ shellpilot/cli/app_ui.py | 129 +++++++++++++++++++++++--- tests/test_app.py | 148 ++++++++++++++++++++++++++++++ tests/test_app_ui.py | 189 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 484 insertions(+), 13 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index c015688..45799a4 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -2907,6 +2907,18 @@ The dock gains four refinements; each is additive and a default (non-app) sessio **Live status values.** The status bar reads its dir/model/profile/locality/ctx through an optional `status_fn` callable evaluated per render, so `/model use` (the cloud indicator!), `/profile use`, `/cwd set`, and context growth reflect immediately. The cloud bit stays the real `is_egressing` signal — now live, never model output, still unspoofable (§15.2). The git branch segment stays build-time (re-reading `.git/HEAD` every render would be wasteful; a mid-session `/cwd set` does not refresh it). With `status_fn` absent the bar uses the build-time params, unchanged. +### 31.19 Thinking trail (inline, collapsible) + +The live indicator (§31.14) only ever surfaced a *count* of reasoning; the model's actual thinking was captured (`reply.thinking`) but never shown. The trail surfaces it inline, in place, as a faint collapsible block — display-only, additive, and a default session with no streamed thinking is byte-identical. + +**One trail per reasoning phase.** `stream_thinking` retains the thinking *text* on the current `_Trail` instead of only bumping the reasoning-char count. The trail is an entry in the `AppUI._renderables` transcript (not a separate channel), so it lands at its own position in scroll order. A reasoning phase opens a fresh trail at the current transcript position (closing any open response first, so the block sits below streamed answer text); any non-thinking transcript content — a tool call, status line, answer token, done line, aborted marker, user echo — *finalizes* the active trail (`_finalize_active_trail` clears the single `_active_trail` pointer at the top of `_add_renderable`/`stream_token`), so the next thinking phase starts a new block. Finalizing is the **only** cleanup: a finished trail stays visible and never has its state reset. + +**Collapsed by default, expandable.** The collapsed view shows the first `TRAIL_COLLAPSED_LINES` (10) non-blank lines under a `thinking · N reasoning` header (blank lines are dropped so the cap counts real reasoning lines); when more are hidden a footer reads `… +N hidden lines · press t to expand`. Expanded shows every line and a `press t to collapse` footer. A caret in the header (`▸`/`▾`, ASCII `>`/`v`) shows the state. Thinking text is model-controlled, so **every displayed line is `_sanitize_line`-stripped** at the sink. + +**`t` toggles the latest trail.** A bare `t` on an *empty* dock with *no active approval* flips `_latest_trail.expanded` — the active trail while the model runs, the most-recent finished trail after. Older trails freeze: there is no per-trail selection, by design. The keybinding is gated on dock-empty + no-approval (mirroring the §31.18 recall gate); when there is no trail to toggle (`toggle_thinking_trail()` returns `False` — fresh session, or `show_reasoning` off) it falls through and inserts a literal `t`, so a message that starts with `t` still types normally. `show_reasoning=False` builds no trail at all (the readout is hidden too), so the toggle is a no-op there. + +**Display-only.** The trail text never changes model history and is never fed back to the model (the same discard as the §31.15 aborted partial). `AppUI` has no history — it is a pure state+render object — so the trail lives only in the pane. + ## 32. Model Selection And Preload ### 32.1 Boot Model Picker diff --git a/shellpilot/cli/app.py b/shellpilot/cli/app.py index 28dfda9..267a5b2 100644 --- a/shellpilot/cli/app.py +++ b/shellpilot/cli/app.py @@ -443,6 +443,25 @@ def _recall(event: KeyPressEvent) -> None: dock_buffer.cursor_position = len(dock_buffer.text) pending["text"] = None + @kb.add( + "t", + filter=dock_focused + & Condition(lambda: not dock_buffer.text) + & Condition(lambda: approval_gate is None or not approval_gate.active), + ) + def _toggle_trail(event: KeyPressEvent) -> None: + # `t` on an EMPTY dock with no active approval toggles the latest thinking + # trail's collapse state (§31.19) — works while the model runs or after. + # If there is no trail to toggle (fresh session / show_reasoning off), fall + # through and type the key so a message that starts with 't' still works. + # Mirror prompt_toolkit's self-insert (event.data * event.arg) rather than a + # literal so a numeric-argument prefix (e.g. `Esc 5 t`) still repeats. NOTE: + # when a trail DOES exist, a bare 't' on an empty dock is the toggle, not the + # first char of a word — the spec's explicit dock-empty gate; type the 't' + # after any other char, or it toggles. + if not _ui.toggle_thinking_trail(): + dock_buffer.insert_text(event.data * event.arg) + @kb.add("escape", "enter", filter=dock_focused) def _newline(event: KeyPressEvent) -> None: dock_buffer.insert_text("\n") diff --git a/shellpilot/cli/app_ui.py b/shellpilot/cli/app_ui.py index 6b0f659..a4eb8af 100644 --- a/shellpilot/cli/app_ui.py +++ b/shellpilot/cli/app_ui.py @@ -52,6 +52,10 @@ # the glide is a pure function of the (injected) clock — no animation thread. FRAME_SECONDS = 0.15 +# A collapsed thinking trail shows this many non-blank reasoning lines; the rest +# fold behind a "+N hidden lines" footer until `t` expands it (design section 31.19). +TRAIL_COLLAPSED_LINES = 10 + def _fmt_count(n: int) -> str: """k/m abbreviation for token counts (design section 31.14). @@ -80,6 +84,22 @@ class _TurnIndicator: reasoning_chars: int = 0 +@dataclass +class _Trail: + """An inline, display-only thinking trail for one reasoning phase (§31.19). + + ``text`` accumulates raw model thinking (display only — never fed back to the + model or recorded in history). ``expanded`` is the per-trail collapse state + that ``t`` toggles on the LATEST trail. The collapsed view shows the first + ``TRAIL_COLLAPSED_LINES`` non-blank lines; a footer reports the hidden + remainder. No ``finished`` flag is needed — a trail stops accumulating the + moment it is no longer ``AppUI._active_trail``. + """ + + text: str = "" + expanded: bool = False + + class AppUI: """RuntimeUI implementation that routes content into the full-screen app pane. @@ -110,8 +130,9 @@ def __init__( # Injected clock so the indicator's elapsed/animation is testable with a # fixed time_fn rather than the wall clock. self._time_fn = time_fn - # Source of truth: every committed renderable in the transcript. - self._renderables: list[RenderableType] = [] + # Source of truth: every committed renderable in the transcript. A _Trail + # entry is a live thinking block rendered via _render_trail (§31.19). + self._renderables: list[RenderableType | _Trail] = [] # Accumulated token text for the current open response. # None means no response is open; an open response is the last renderable # in spirit but lives here until end_response() finalizes it. @@ -120,6 +141,13 @@ def __init__( # _TurnIndicator while a turn runs. begin_response starts it; turn_finished # freezes it to a permanent done line and clears it. self._indicator: _TurnIndicator | None = None + # Inline thinking trails (§31.19): _active_trail is the one currently + # accumulating stream_thinking text (None between reasoning phases); + # _latest_trail is the most-recent trail ever created — the toggle target + # for `t` (older trails freeze at their last state but stay visible). Both + # are part of _renderables; these are just pointers into it. + self._active_trail: _Trail | None = None + self._latest_trail: _Trail | None = None # Width-keyed ANSI cache: (width, ansi_string), or None when stale. self._cache: tuple[int, str] | None = None @@ -134,12 +162,22 @@ def _close_open_response(self) -> None: self._open_response = None self._cache = None + def _finalize_active_trail(self) -> None: + # Any non-thinking transcript content ends the active reasoning phase. The + # trail STAYS in _renderables (finished trails remain visible and the latest + # stays `t`-toggleable until a new turn supersedes it); it just stops + # accumulating. This is the ONLY cleanup the spec's "clear active unfinished + # trail state, never reset prior finished trails" needs — we touch a single + # pointer, never another trail's expanded state. + self._active_trail = None + def _add_renderable(self, renderable: RenderableType) -> None: """Close any open response first, then append a renderable to the transcript. Preserves ordering: response → tool call → response produces three distinct transcript entries (the open response closes around the tool call). """ + self._finalize_active_trail() self._close_open_response() self._renderables.append(renderable) self._cache = None @@ -169,7 +207,7 @@ def _render_ansi(self) -> str: theme=SHELLPILOT_THEME, width=width, ) - renderables: list[RenderableType] = list(self._renderables) + renderables: list[RenderableType | _Trail] = list(self._renderables) if self._open_response is not None: # Include in-progress response without committing it yet. Sanitize the # model text at the sink (mirrors ResponseStream, streaming.py:171) — @@ -180,7 +218,10 @@ def _render_ansi(self) -> str: # content, so it "moves down" as tool calls/responses append above it. renderables.append(self._indicator_line()) for renderable in renderables: - console.print(renderable) + if isinstance(renderable, _Trail): + console.print(self._render_trail(renderable)) + else: + console.print(renderable) ansi = buf.getvalue() if not active: self._cache = (width, ansi) @@ -209,12 +250,44 @@ def _indicator_line(self) -> Text: line.append(f" · {_fmt_count(reasoning)} reasoning", style="sp.dim") return line + def _render_trail(self, trail: _Trail) -> Text: + # Display-only thinking (§31.19), faint + indented, header carries the + # reasoning-token estimate. Model-controlled text → EVERY line sanitized. + # Blank lines are dropped so the 10-line cap counts real reasoning lines. + lines = [ln for ln in trail.text.splitlines() if ln.strip()] + reasoning = math.ceil(len(trail.text) / CHARS_PER_TOKEN) + caret = ( + ("▾" if trail.expanded else "▸") + if self._glyphs == UNICODE_GLYPHS + else ("v" if trail.expanded else ">") + ) + parts: list[Text] = [ + Text(f"{caret} thinking · {_fmt_count(reasoning)} reasoning", style="sp.dim") + ] + shown = lines if trail.expanded else lines[:TRAIL_COLLAPSED_LINES] + parts.extend(Text(" " + _sanitize_line(ln), style="sp.faint") for ln in shown) + if trail.expanded: + parts.append(Text(" press t to collapse", style="sp.faint")) + else: + hidden = len(lines) - len(shown) + if hidden > 0: + parts.append( + Text( + f" {self._glyphs.ellipsis} +{hidden} hidden lines · press t to expand", + style="sp.faint", + ) + ) + return Text("\n").join(parts) + # ------------------------------------------------------------------ # RuntimeUI content methods — mirroring TerminalUI exactly # ------------------------------------------------------------------ def stream_token(self, token: str) -> None: """Accumulate a streaming token into the open response.""" + # Answer text ends any active reasoning phase (§31.19): the next thinking + # chunk starts a fresh trail block below the streamed answer. + self._finalize_active_trail() if self._open_response is None: self._open_response = token else: @@ -269,15 +342,41 @@ def abort_turn(self) -> None: marker = "⏹" if self._glyphs == UNICODE_GLYPHS else self._glyphs.cross self._add_renderable(Text(f"{marker} aborted", style="sp.warn")) + def toggle_thinking_trail(self) -> bool: + # Flip the LATEST trail's collapse state (§31.19). `t` toggles the active + # trail while running and the most-recent finished trail after — older + # trails freeze (no per-trail selection, by design). Returns False when there + # is no trail (fresh session, or show_reasoning off) so the keybinding can + # treat `t` as ordinary text input instead of swallowing it. + if self._latest_trail is None: + return False + self._latest_trail.expanded = not self._latest_trail.expanded + self._cache = None + return True + def stream_thinking(self, text: str) -> None: - # The reasoning count climbs ONLY while the model is thinking, so it - # freezes naturally when thinking stops and resumes if it thinks again. No - # reasoning TEXT is ever rendered — only its char count, as a token - # estimate. A no-op when no turn is active (defensive; begin_response runs - # first in the runtime's tool loop). - if self._indicator is not None: - self._indicator.reasoning_chars += len(text) - self._cache = None + # The reasoning count climbs ONLY while the model is thinking, so it freezes + # naturally when thinking stops and resumes if it thinks again. A no-op when + # no turn is active (defensive; begin_response runs first in the tool loop). + if self._indicator is None: + return # no active turn — nothing to attribute the thinking to + self._indicator.reasoning_chars += len(text) + self._cache = None + # Inline trail (§31.19): retain the thinking TEXT for display only (never fed + # back to the model). Gated on show_reasoning — when off, no trail is built + # (the readout is hidden too). A new reasoning phase (no active trail) opens a + # fresh trail block at the current transcript position; close any open + # response first so the block lands after streamed answer text. + if not self._show_reasoning: + return + if self._active_trail is None: + self._close_open_response() + trail = _Trail() + self._renderables.append(trail) + self._active_trail = trail + self._latest_trail = trail + self._active_trail.text += text + self._cache = None def show_user_message(self, text: str) -> None: # Echo the submitted user message into the transcript. App-side (NOT a @@ -345,7 +444,11 @@ def show_command_output(self, line: str) -> None: def show_plan_progress(self, plan: TaskPlan) -> None: # Uses plan_step_line (not plan_panel) with 2-cell left indent, then a blank - # line — mirroring TerminalUI.show_plan_progress exactly. + # line — mirroring TerminalUI.show_plan_progress exactly. This is the one + # content-appender that bypasses _add_renderable, so it finalizes the active + # trail itself — keeping the §31.19 invariant (any non-thinking content ends + # the reasoning phase) uniformly true rather than relying on the caller. + self._finalize_active_trail() self._close_open_response() for index, step in enumerate(plan.steps, 1): self._renderables.append( diff --git a/tests/test_app.py b/tests/test_app.py index b7dee17..eee3934 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -824,3 +824,151 @@ def test_ascii_chip_uses_ascii_marker(tmp_path: Path) -> None: assert "queued:" in chip assert "hello" in chip assert "⏳" not in chip + + +# --- Branch-10 thinking-trail toggle (§31.19) --------------------------------- + + +def _seed_trail_ui() -> AppUI: + ui = AppUI(glyphs=UNICODE_GLYPHS, width_fn=lambda: 80) + ui.begin_response() + ui.stream_thinking("line a\nline b\nline c") + return ui + + +def test_t_key_toggles_seeded_trail(tmp_path: Path) -> None: + # `t` on an empty dock with a trail present toggles the latest trail's collapse + # state: one press expands it. + ui = _seed_trail_ui() + assert ui._latest_trail is not None and ui._latest_trail.expanded is False + with create_pipe_input() as inp: + app = build_app( + workspace=tmp_path, + model="gemma4:e4b", + profile="balanced", + glyphs=UNICODE_GLYPHS, + commands=command_words(), + input=inp, # type: ignore[arg-type] + output=DummyOutput(), + ui=ui, + ) + inp.send_text("t") # empty dock + trail present → toggle (not literal text) + inp.send_text("/exit\n") + app.run() + assert ui._latest_trail.expanded is True + + +def test_t_key_pressed_twice_returns_to_collapsed(tmp_path: Path) -> None: + ui = _seed_trail_ui() + with create_pipe_input() as inp: + app = build_app( + workspace=tmp_path, + model="gemma4:e4b", + profile="balanced", + glyphs=UNICODE_GLYPHS, + commands=command_words(), + input=inp, # type: ignore[arg-type] + output=DummyOutput(), + ui=ui, + ) + inp.send_text("tt") # toggle, then toggle back (dock stays empty) + inp.send_text("/exit\n") + app.run() + assert ui._latest_trail is not None and ui._latest_trail.expanded is False + + +def test_t_key_inserts_literal_when_no_trail(tmp_path: Path) -> None: + # With no trail (fresh session / show_reasoning off) `t` is ordinary input: it + # inserts a literal 't', so a message starting with 't' still works. + submits: list[str] = [] + ui = AppUI(glyphs=UNICODE_GLYPHS, width_fn=lambda: 80) + with create_pipe_input() as inp: + app = build_app( + workspace=tmp_path, + model="gemma4:e4b", + profile="balanced", + glyphs=UNICODE_GLYPHS, + commands=command_words(), + input=inp, # type: ignore[arg-type] + output=DummyOutput(), + ui=ui, + on_submit=submits.append, + ) + inp.send_text("t") # no trail → falls through → literal 't' + inp.send_text("\n") # submit "t" + inp.send_text("/exit\n") + app.run() + assert submits == ["t"] + + +def test_t_key_no_trail_respects_numeric_arg_repeat(tmp_path: Path) -> None: + # The no-trail fall-through mirrors prompt_toolkit self-insert (event.data * + # event.arg), so a numeric-argument prefix (Esc 5 t) repeats the key instead of + # inserting a single literal — typing fidelity matches the default binding. + submits: list[str] = [] + ui = AppUI(glyphs=UNICODE_GLYPHS, width_fn=lambda: 80) + with create_pipe_input() as inp: + app = build_app( + workspace=tmp_path, + model="gemma4:e4b", + profile="balanced", + glyphs=UNICODE_GLYPHS, + commands=command_words(), + input=inp, # type: ignore[arg-type] + output=DummyOutput(), + ui=ui, + on_submit=submits.append, + ) + inp.send_text("\x1b5t") # Esc 5 t → numeric-arg repeat 5 → "ttttt" + inp.send_text("\n") # submit + inp.send_text("/exit\n") + app.run() + assert submits == ["ttttt"] + + +def test_t_key_with_dock_text_does_not_toggle(tmp_path: Path) -> None: + # A trail exists but the dock is non-empty: the filter is false, so `t` inserts + # rather than toggling. + ui = _seed_trail_ui() + assert ui._latest_trail is not None and ui._latest_trail.expanded is False + with create_pipe_input() as inp: + app = build_app( + workspace=tmp_path, + model="gemma4:e4b", + profile="balanced", + glyphs=UNICODE_GLYPHS, + commands=command_words(), + input=inp, # type: ignore[arg-type] + output=DummyOutput(), + ui=ui, + ) + inp.send_text("xt\n") # 'x' then 't' (dock non-empty) both insert, submit clears + inp.send_text("/exit\n") + app.run() + assert ui._latest_trail.expanded is False # never toggled + + +def test_t_key_during_approval_does_not_toggle(tmp_path: Path) -> None: + # During an active approval the dock IS the approval input; `t` is routed to the + # gate as input, never the toggle. + ui = _seed_trail_ui() + assert ui._latest_trail is not None and ui._latest_trail.expanded is False + gate = _FakeGate(active=True) + with create_pipe_input() as inp: + app = build_app( + workspace=tmp_path, + model="gemma4:e4b", + profile="balanced", + glyphs=UNICODE_GLYPHS, + commands=command_words(), + input=inp, # type: ignore[arg-type] + output=DummyOutput(), + ui=ui, + approval_gate=gate, # type: ignore[arg-type] + ) + inp.send_text("t") # approval active → literal input, not toggle + inp.send_text("\n") # submit "t" to the gate (deactivates it) + inp.send_text("/exit\n") # gate inactive → quits + app.run() + assert gate.submitted == ["t"] + assert ui._latest_trail.expanded is False # never toggled diff --git a/tests/test_app_ui.py b/tests/test_app_ui.py index 937908e..cb76158 100644 --- a/tests/test_app_ui.py +++ b/tests/test_app_ui.py @@ -646,3 +646,192 @@ def test_frontier_ordering_user_then_content_then_done() -> None: i_tool = out.index("read_file") i_done = out.index("done") assert i_msg < i_tool < i_done + + +# --------------------------------------------------------------------------- +# §31.19 — inline collapsible thinking trail +# --------------------------------------------------------------------------- + + +def _trail_ui() -> AppUI: + return AppUI(glyphs=GLYPHS, width_fn=lambda: 80, time_fn=lambda: 0.0) + + +def test_thinking_trail_created_collapsed_no_footer() -> None: + # stream_thinking during an active turn builds an inline trail, collapsed by + # default; with <=10 lines all are shown and there is NO hidden-lines footer. + ui = _trail_ui() + ui.begin_response() + ui.stream_thinking("alpha\nbeta\ngamma") + out = plain(ui) + assert "thinking" in out # the trail header + assert "alpha" in out and "beta" in out and "gamma" in out + assert "hidden lines" not in out + + +def test_thinking_trail_collapsed_hides_overflow_with_footer() -> None: + # More than TRAIL_COLLAPSED_LINES non-blank lines → first 10 shown, the rest + # hidden behind the exact footer wording with the correct remainder count. + ui = _trail_ui() + ui.begin_response() + ui.stream_thinking("\n".join(f"line{i}" for i in range(15))) + out = plain(ui) + for i in range(10): + assert f"line{i}" in out # first 10 shown + assert "line10" not in out # 11th+ hidden + assert "+5 hidden lines · press t to expand" in out + + +def test_toggle_expands_and_collapses_latest_trail() -> None: + # toggle returns True and expands to all lines + the collapse hint; toggling + # again collapses back to the first 10 + the hidden footer. + ui = _trail_ui() + ui.begin_response() + ui.stream_thinking("\n".join(f"row{i}" for i in range(15))) + assert ui.toggle_thinking_trail() is True + out = plain(ui) + for i in range(15): + assert f"row{i}" in out + assert "press t to collapse" in out + assert "hidden lines" not in out + assert ui.toggle_thinking_trail() is True + out2 = plain(ui) + assert "row14" not in out2 + assert "+5 hidden lines · press t to expand" in out2 + + +def test_toggle_with_no_trail_returns_false() -> None: + ui = make_ui() + assert ui.toggle_thinking_trail() is False # no trail → no-op, no raise + + +def test_show_reasoning_false_builds_no_trail() -> None: + # With the reasoning readout off, no trail is built and the toggle is a no-op; + # the live indicator stays free of any reasoning readout (existing behavior). + ui = AppUI(glyphs=GLYPHS, width_fn=lambda: 80, show_reasoning=False, time_fn=lambda: 0.0) + ui.begin_response() + ui.stream_thinking("secret thoughts here") + out = plain(ui) + assert "thinking" not in out + assert "secret thoughts here" not in out + assert ui.toggle_thinking_trail() is False + assert "reasoning" not in out + + +def test_two_phases_separate_trails_toggle_latest_only() -> None: + # Two reasoning phases separated by a tool call produce two distinct trail + # blocks; the latest is the second, and the toggle only flips the latest. + from shellpilot.cli.app_ui import _Trail + + ui = _trail_ui() + ui.begin_response() + ui.stream_thinking("phase one A\nphase one B") + ui.show_tool_call("read_file", {"path": "x"}) + ui.stream_thinking("phase two A\nphase two B") + trails = [r for r in ui._renderables if isinstance(r, _Trail)] + assert len(trails) == 2 + assert ui._latest_trail is trails[1] + assert ui._latest_trail is not trails[0] + assert ui.toggle_thinking_trail() is True + assert trails[1].expanded is True + assert trails[0].expanded is False # the older trail is untouched + + +def test_new_turn_trail_defaults_collapsed_older_keeps_state() -> None: + # A fresh turn's trail is collapsed even if the prior turn's trail was expanded; + # the older trail keeps its expanded state. + ui = _trail_ui() + ui.begin_response() + ui.stream_thinking("first turn thinking") + assert ui.toggle_thinking_trail() is True + first = ui._latest_trail + assert first is not None and first.expanded is True + ui.show_user_message("next") + ui.begin_response() + ui.stream_thinking("second turn thinking") + second = ui._latest_trail + assert second is not first + assert second is not None and second.expanded is False + assert first.expanded is True + + +def test_abort_turn_preserves_trail_state() -> None: + # abort_turn keeps the active trail visible and never resets a prior finished + # trail's expanded state; the active-trail pointer is cleared afterwards. + ui = _trail_ui() + ui.begin_response() + ui.stream_thinking("phase one thoughts") + ui.show_tool_call("read_file", {"path": "x"}) # finalize phase one + assert ui.toggle_thinking_trail() is True # expand the finished trail + first = ui._latest_trail + ui.stream_thinking("phase two thoughts") # a fresh active trail + ui.abort_turn() + out = plain(ui) + assert "phase two thoughts" in out # active trail still visible + assert ui._active_trail is None + assert first is not None and first.expanded is True + + +def test_show_user_message_finalizes_active_trail() -> None: + # A new turn's user echo finalizes a dangling active trail without resetting + # any prior trail's expanded state. + ui = _trail_ui() + ui.begin_response() + ui.stream_thinking("dangling thoughts") + assert ui._active_trail is not None + assert ui.toggle_thinking_trail() is True + ui.show_user_message("new question") + assert ui._active_trail is None + assert ui._latest_trail is not None and ui._latest_trail.expanded is True + + +def test_trail_sanitizes_control_chars() -> None: + # Thinking text is model-controlled → every displayed line is sanitized; no raw + # BEL or escape-injection bytes reach the pane. + ui = _trail_ui() + ui.begin_response() + ui.stream_thinking("danger\x07\x1b[31mred") + raw = ansi_text(ui) + assert "\x07" not in raw + assert "\x1b[31m" not in raw + assert "danger" in plain(ui) + + +def test_trail_header_reasoning_count() -> None: + # The trail header carries the reasoning-token estimate (chars / CHARS_PER_TOKEN), + # matching the indicator's estimate. + ui = _trail_ui() + ui.begin_response() + ui.stream_thinking("x" * 2000) # 2000 / 4 = 500 tokens + assert "500 reasoning" in plain(ui) + + +def test_show_plan_progress_finalizes_active_trail() -> None: + # show_plan_progress is the one content-appender that bypasses _add_renderable; + # it must still finalize the active trail so the §31.19 invariant holds and the + # next reasoning phase opens a fresh block (not merge into the prior one). + from shellpilot.cli.app_ui import _Trail + + ui = _trail_ui() + ui.begin_response() + ui.stream_thinking("pre-plan thoughts") + assert ui._active_trail is not None + ui.show_plan_progress(make_plan()) + assert ui._active_trail is None # finalized + ui.stream_thinking("post-plan thoughts") + trails = [r for r in ui._renderables if isinstance(r, _Trail)] + assert len(trails) == 2 # a fresh trail, not appended to the first + + +def test_toggle_finished_trail_while_idle_rerenders() -> None: + # Toggling a FINISHED trail after the turn ended (indicator None → the width + # cache is live) must still re-render: the toggle invalidates the cache, so the + # newly-shown lines appear. Guards the idle-toggle path the live UI uses. + ui = _trail_ui() + ui.begin_response() + ui.stream_thinking("\n".join(f"idle{i}" for i in range(15))) + ui.turn_finished(make_stats()) # indicator → None + assert ui._indicator is None + assert "idle14" not in plain(ui) # collapsed: 15th line hidden + assert ui.toggle_thinking_trail() is True + assert "idle14" in plain(ui) # expanded render reflects the toggle (cache busted)