瀏覽代碼

Added cycle pane

Efren Yevale Varela 1 月之前
父節點
當前提交
fc65dbe38d

+ 8 - 5
docs/ASSISTANT.md

@@ -60,7 +60,7 @@ Command and chat modes are mutually exclusive.
 1. Arm (`stt-ctl command-start`, or `a` in the TUI).
 2. Speak; the transcript fills the buffer.
 3. Optionally edit with `e` (freezes the buffer, so ASR mistakes can be corrected before the model sees them).
-4. Execute (`stt-ctl command-execute` or `Enter`), or cancel (`Esc`).
+4. Execute (`stt-ctl command-execute` or `Enter`), or cancel (`Esc`). Executing clears the buffer immediately, before the command finishes.
 
 Execution is gated on **readiness**: no speech open, the last finalized segment's
 `segment_done` has arrived, and STT is healthy. With `require_viewer = true`, execute is
@@ -150,7 +150,8 @@ Every LLM call — chat turns, command executions, the compaction summarizer, an
 updates — runs through a single worker thread, bounded by `inference_queue_size`. This
 serializes requests to the one llama.cpp server. Commands jump ahead of chat turns; when the
 queue is full an execute is refused with `error{code:"busy"}` and the oldest queued chat turn
-is dropped. Leaving chat mode discards queued chat turns.
+is dropped. An explicit `clear` discards queued chat turns; leaving and re-entering chat keeps
+the conversation, including anything still queued.
 
 Brief updates run on this same lane as non-evictable, single-outstanding jobs: a full queue
 refuses the brief rather than evicting a queued chat turn, and the refused turn's deltas stay
@@ -209,13 +210,15 @@ daemon, remove the id from `speakers/speakers.json` and `prototypes.npz`, then r
 
 ## TUI keys
 
-`a` arm · `Enter` execute · `Esc` cancel · `e` edit · `t` chat toggle · `s` questions view · `c` clear · `q` quit
+`a` arm · `Enter` execute · `Esc` cancel · `e` edit · `t` cycle pane · `s` questions view · `c` clear · `q` quit
 
-`c` asks for confirmation, then wipes the conversation / command-output pane and resets the assistant's in-memory chat history (the LLM session), while staying in the current mode. Transcripts are not affected.
+`t` cycles the lower panes through command output, conversation, and hidden. Selecting the conversation turns chat mode on; cycling away turns it off. The conversation is kept across those transitions — only `c` discards it. Arming or executing a command (by key or typed input) leaves chat mode if it was on and reveals the command-output pane; this never happens from within chat itself, since a command always pre-empts it. Transcripts are not affected.
+
+`c` asks for confirmation, then wipes the conversation and command-output panes, hides whichever of them was showing, and resets the assistant's in-memory chat history (the LLM session), while staying in the current mode. Transcripts are not affected.
 
 `s` swaps the triage pane between the live signals view and a structured view of the loaded `questions.json` (one block per question with its id, type, instructions, and criteria); it is a no-op when `[triage].enabled = false`. See [TRIAGE.md](TRIAGE.md).
 
-**Live queue counters:** The pane borders show real-time queue depths. `transcripts [seg N]` displays the number of audio segments queued for transcription (the daemon's backlog). `conversation / command output [inf N]` shows the number of LLM inference jobs queued, with `· busy` appearing while one is running. The triage pane carries the same `[inf N]` depth and `· busy` marker, since brief updates share the LLM queue. These counters refresh about once per second, driven by the heartbeat cadence.
+**Live queue counters:** The pane borders show real-time queue depths. `transcripts [seg N]` displays the number of audio segments queued for transcription (the daemon's backlog). `conversation [inf N]` and `command output [inf N]` show the number of LLM inference jobs queued, with `· busy` appearing while one is running; command output (tool calls, results, and errors) lives in its own pane, separate from the conversation. The triage pane carries the same `[inf N]` depth and `· busy` marker, since brief updates share the LLM queue. These counters refresh about once per second, driven by the heartbeat cadence.
 
 **Selecting / copying text:** the TUI captures mouse events (Textual enables mouse
 reporting by default), so dragging does **not** select text the way it normally does in

+ 2 - 11
src/stt_daemon/assistant/service.py

@@ -87,7 +87,7 @@ class AssistantService:
             audit: Object with `record(**fields)`; called once per execution.
             relay: Sink for relayed STT events (`transcript`, `speech_*`).
             chat_fn: Callable sending one chat turn and returning the reply.
-            chat_reset: Callable clearing the chat session on exit.
+            chat_reset: Callable clearing the chat session on `clear`.
             triage: Triage analyzer (duck-typed); None disables triage.
             laya_lane: Laya inference lane (duck-typed); None disables triage.
             brief_fn: Callable turning a brief prompt into plain-text brief.
@@ -408,6 +408,7 @@ class AssistantService:
             edited = self.command_edited
             if not self.modes.execute():
                 return None
+            self._clear_command()
         self._emit_mode()
         self._emit_command_text(force=True)
         if self.executor is not None:
@@ -479,8 +480,6 @@ class AssistantService:
         with self._lock:
             if not self.modes.enter_chat():
                 return False
-            self.chat_history = []
-            self._pending_chat = []
             self._chat_queue_full = False
             self._last_chat_activity = self._now()
         self._emit_mode()
@@ -870,15 +869,7 @@ class AssistantService:
         with self._lock:
             if not self.modes.exit_chat():
                 return False
-            self.chat_history = []
-            self._pending_chat = []
             self._chat_queue_full = False
-        self._inference.drop(lambda j: j.name == "chat")
-        if self.chat_reset is not None:
-            try:
-                self.chat_reset()
-            except Exception as e:  # noqa: BLE001 - resetting chat must not break the mode change
-                logger.warning("chat reset failed: %s", e)
         self._emit_mode()
         if reason != "toggle":
             self._emit(ERROR_EVENT, code=reason, message=f"chat {reason}")

+ 75 - 32
src/stt_daemon/tui/app.py

@@ -35,9 +35,11 @@ class AssistantTUI(App):
     #transcript { height: 25%; border: solid $accent; }
     #command { height: 3; border: solid $accent; }
     #chat { height: 1fr; border: solid $accent; }
+    #output { height: 1fr; border: solid $accent; }
     #main { height: 1fr; }
     #main #triage { width: 1fr; height: 100%; border: solid $accent; }
     #main #chat { width: 2fr; height: 100%; }
+    #main #output { width: 2fr; height: 100%; }
     #transcript.-maximized { height: 1fr; }
     #transcript.-grow { height: 1fr; }
     """
@@ -47,7 +49,7 @@ class AssistantTUI(App):
         Binding("ctrl+r", "execute", "execute"),
         Binding("escape", "cancel", "cancel"),
         Binding("e", "edit", "edit command"),
-        Binding("t", "chat", "chat toggle"),
+        Binding("t", "cycle_lower", "cycle pane"),
         Binding("s", "toggle_questions", "questions view"),
         Binding("question_mark", "toggle_help_panel", "help"),
         Binding("q", "quit", "quit"),
@@ -66,15 +68,18 @@ class AssistantTUI(App):
         self.connect_error: str | None = None
         self._events: queue.Queue = queue.Queue()
         self._seen_transcripts = 0
-        self._seen_lower = 0
+        self._seen_chat = 0
+        self._seen_output = 0
         self._seen_epoch = 0
         self._seen_triage_epoch = 0
         self._seen_questions_epoch = 0
         self._show_questions = False
+        self._lower_view = "hidden"
         self._seen_view = "triage"
         self._timer = None
         self._last_seg_title = ""
         self._last_inf_title = ""
+        self._last_output_title = ""
         self._last_triage_title = ""
 
     def compose(self) -> ComposeResult:
@@ -84,8 +89,10 @@ class AssistantTUI(App):
         if self.triage_enabled:
             with MainPane(id="main"):
                 yield RichLog(id="triage", wrap=True, markup=False)
+                yield RichLog(id="output", wrap=True, markup=False)
                 yield RichLog(id="chat", wrap=True, markup=False)
         else:
+            yield RichLog(id="output", wrap=True, markup=False)
             yield RichLog(id="chat", wrap=True, markup=False)
         yield Footer()
 
@@ -103,11 +110,16 @@ class AssistantTUI(App):
             self._last_seg_title = seg_title
 
         inf_suffix = " · busy" if state.inference_in_flight else ""
-        inf_title = f"conversation / command output  [inf {state.inference_depth}{inf_suffix}]"
+        inf_title = f"conversation  [inf {state.inference_depth}{inf_suffix}]"
         if inf_title != self._last_inf_title:
             self.query_one("#chat", RichLog).border_title = inf_title
             self._last_inf_title = inf_title
 
+        output_title = f"command output  [inf {state.inference_depth}{inf_suffix}]"
+        if output_title != self._last_output_title:
+            self.query_one("#output", RichLog).border_title = output_title
+            self._last_output_title = output_title
+
         if self.triage_enabled:
             triage_title = self._triage_title()
             if not self._show_questions:
@@ -149,6 +161,8 @@ class AssistantTUI(App):
             # A timer tick can land while the app is shutting down, after the
             # widgets are unmounted; there is nothing left to draw into.
             return
+        if self.state.mode in ("command_armed", "executing"):
+            self._lower_view = "output"
         state = self.state
         health = "ok" if state.health_ok() else "DOWN"
         conn = "ok" if state.connected else "LOST"
@@ -182,15 +196,26 @@ class AssistantTUI(App):
                 transcript_log.write(line)
             self._seen_transcripts = state.transcripts_total
 
-        lower_log = self.query_one("#chat", RichLog)
+        # Chat turns and command output live in sibling panes, each drawn from
+        # its own selector so the two never share space.
+        chat_log = self.query_one("#chat", RichLog)
+        output_log = self.query_one("#output", RichLog)
         if self._seen_epoch != state.chat_epoch:
-            lower_log.clear()
-            self._seen_lower = 0
+            chat_log.clear()
+            output_log.clear()
+            self._seen_chat = 0
+            self._seen_output = 0
             self._seen_epoch = state.chat_epoch
-        lower_lines = state.lower_lines()
-        while self._seen_lower < len(lower_lines):
-            lower_log.write(lower_lines[self._seen_lower])
-            self._seen_lower += 1
+            if state.mode not in ("command_armed", "executing"):
+                self._lower_view = "hidden"
+        chat_lines = state.chat_output_lines()
+        while self._seen_chat < len(chat_lines):
+            chat_log.write(chat_lines[self._seen_chat])
+            self._seen_chat += 1
+        output_lines = state.output_lines()
+        while self._seen_output < len(output_lines):
+            output_log.write(output_lines[self._seen_output])
+            self._seen_output += 1
 
         if self.triage_enabled:
             triage_log = self.query_one("#triage", RichLog)
@@ -222,16 +247,30 @@ class AssistantTUI(App):
             self.query_one("#status", Static).update(f"send failed: {e}")
 
     def action_arm(self) -> None:
+        self._leave_chat_for_command()
         self._send("command_start")
 
+    def _leave_chat_for_command(self) -> None:
+        if self.state.mode == "chat":
+            self._send("chat_toggle")
+
     def action_execute(self) -> None:
         self._send("command_execute")
 
     def action_cancel(self) -> None:
         self._send("command_cancel")
 
-    def action_chat(self) -> None:
-        self._send("chat_toggle")
+    def action_cycle_lower(self) -> None:
+        order = ("output", "chat", "hidden")
+        next_view = order[(order.index(self._lower_view) + 1) % len(order)]
+        if next_view == "chat" and self.state.mode != "chat":
+            if self.state.mode != "idle":
+                return
+            self._send("chat_toggle")
+        elif self._lower_view == "chat" and next_view != "chat" and self.state.mode == "chat":
+            self._send("chat_toggle")
+        self._lower_view = next_view
+        self._render()
 
     def action_clear(self) -> None:
         self.push_screen(ConfirmClear(), self._on_clear_confirmed)
@@ -254,38 +293,39 @@ class AssistantTUI(App):
         cmd.focus()
         cmd.cursor_position = len(cmd.value)
 
-    def _chat_has_content(self) -> bool:
-        state = self.state
-        if state.mode in ("chat", "executing"):
-            return True
-        if state.tool is not None or state.tool_result is not None or state.last_error is not None:
-            return True
-        return any(not from_chat for _, from_chat in state.lower)
-
     def _update_main_visibility(self) -> None:
         if self.screen.maximized is not None:
             return
-        show_chat = self._chat_has_content()
+        view = self._lower_view
+        show_chat = view == "chat"
+        show_output = view == "output"
         if self.triage_enabled and self.query("#triage"):
             self.query_one("#triage").display = True
         if self.query("#chat"):
             self.query_one("#chat").display = show_chat
+        if self.query("#output"):
+            self.query_one("#output").display = show_output
         if not self.triage_enabled and self.query("#transcript"):
             # No triage pane to fill the split: let the transcript take the
-            # space the hidden chat pane would otherwise leave blank.
-            self.query_one("#transcript", RichLog).set_class(not show_chat, "-grow")
+            # space the hidden panes would otherwise leave blank.
+            self.query_one("#transcript", RichLog).set_class(
+                not (show_chat or show_output), "-grow"
+            )
 
     def _active_pane(self):
         focused = self.screen.focused
         if focused is not None:
             for node in focused.ancestors_with_self:
-                if getattr(node, "id", None) in ("transcript", "triage", "chat"):
+                if getattr(node, "id", None) in ("transcript", "triage", "chat", "output"):
                     if self.query(f"#{node.id}"):
                         return self.query_one(f"#{node.id}")
                     return node
-        fallback_id = "triage" if self.triage_enabled else "chat"
-        if self.query(f"#{fallback_id}"):
-            return self.query_one(f"#{fallback_id}")
+        fallbacks = ("triage",) if self.triage_enabled else ("chat", "output")
+        for fallback_id in fallbacks:
+            if self.query(f"#{fallback_id}") and self.query_one(f"#{fallback_id}").display:
+                return self.query_one(f"#{fallback_id}")
+        if self.query("#transcript"):
+            return self.query_one("#transcript")
         return None
 
     def _restore_panes(self) -> None:
@@ -300,11 +340,12 @@ class AssistantTUI(App):
         pane = self._active_pane()
         if pane is None:
             return
-        if pane.id in ("triage", "chat") and self.query("#main"):
-            other_id = "#chat" if pane.id == "triage" else "#triage"
-            other = self.query_one(other_id) if self.triage_enabled else None
-            if other is not None:
-                other.display = False
+        if pane.id in ("triage", "chat", "output") and self.query("#main"):
+            for other_id in ("triage", "chat", "output"):
+                if other_id == pane.id:
+                    continue
+                if self.query(f"#{other_id}"):
+                    self.query_one(f"#{other_id}").display = False
             screen.maximize(self.query_one("#main"))
         else:
             screen.maximize(pane)
@@ -356,11 +397,13 @@ class AssistantTUI(App):
         text = event.value.strip()
         if not text:
             return
+        self._leave_chat_for_command()
         if self.state.mode != "command_armed":
             self._send("command_start")
         self._send("command_edit", text=text)
         self._send("command_execute")
         event.input.value = ""
+        self.state.command_text = ""
         self.set_focus(None)
 
     def on_unmount(self) -> None:

+ 8 - 8
src/stt_daemon/tui/state.py

@@ -208,12 +208,6 @@ class ViewState:
         self.lower.append((line, from_chat))
         del self.lower[:-MAX_LOWER]
 
-    def clear_chat(self) -> None:
-        """Forget chat turns and their rendered lines, keeping tool/error output."""
-        self.chat = []
-        self.lower = [(line, from_chat) for line, from_chat in self.lower if not from_chat]
-        self.chat_epoch += 1
-
     def clear_output(self) -> None:
         """Clear conversation/command output: chat, tool calls/results, errors."""
         self.chat        = []
@@ -227,13 +221,19 @@ class ViewState:
         self.triage_epoch += 1
 
     def _set_mode(self, mode: str) -> None:
-        if self.mode == "chat" and mode != "chat":
-            self.clear_chat()
         self.mode = mode
 
     def lower_lines(self) -> list[str]:
         return [line for line, _ in self.lower]
 
+    def output_lines(self) -> list[str]:
+        """Command output: tool calls, results, and errors (never chat turns)."""
+        return [line for line, from_chat in self.lower if not from_chat]
+
+    def chat_output_lines(self) -> list[str]:
+        """Conversation lines: chat turns and compaction notes."""
+        return [line for line, from_chat in self.lower if from_chat]
+
     def health_ok(self) -> bool:
         """False when the header should go red."""
         return self.stt_ok and self.device_ok

+ 33 - 6
tests/test_assistant_service.py

@@ -235,17 +235,26 @@ def test_chat_exit_phrase_exits_chat():
     service.handle_event(_transcript(clock, "That's all."))
 
     assert service.modes.mode.value == "idle"
-    assert service.chat_history == []
 
 
-def test_viewer_detached_exits_chat_and_forgets_history():
+def test_exit_phrase_preserves_chat_history():
+    service, _, clock = _service()
+    service.toggle_chat()
+    service.handle_event(_transcript(clock, "hello"))
+    service.handle_event(_transcript(clock, "That's all."))
+
+    assert service.modes.mode.value == "idle"
+    assert service.chat_history == ["Unknown: hello", "Unknown: That's all."]
+
+
+def test_viewer_detached_exits_chat_and_keeps_history():
     service, _, clock = _service()
     assert service.toggle_chat() is True
     service.handle_event(_transcript(clock, "hello"))
 
     assert service.viewer_detached() is True
     assert service.modes.mode.value == "idle"
-    assert service.chat_history == []
+    assert service.chat_history == ["Unknown: hello"]
 
 
 def test_viewer_detached_is_noop_outside_chat():
@@ -254,17 +263,20 @@ def test_viewer_detached_is_noop_outside_chat():
     assert service.modes.mode.value == "idle"
 
 
-def test_exit_chat_invokes_chat_reset():
+def test_exit_chat_preserves_history_and_does_not_reset():
     cfg = Config()
     resets: list[int] = []
     service = AssistantService(
         cfg, FakeSubscriber(), time_fn=Clock(), chat_reset=lambda: resets.append(1)
     )
     service.toggle_chat()
+    service.handle_event(_transcript(Clock(), "hello"))
 
     service.viewer_detached()
+    service.toggle_chat()
 
-    assert resets == [1]
+    assert resets == []
+    assert service.chat_history == ["Unknown: hello"]
 
 
 def test_chat_accumulates_turns():
@@ -277,6 +289,21 @@ def test_chat_accumulates_turns():
     assert service.chat_history == ["Unknown: hello there", "Unknown: how are you"]
 
 
+def test_execute_clears_command_buffer_immediately():
+    service, emitted, clock = _service()
+    service.arm()
+    service.handle_event(_transcript(clock, "do it"))
+    service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=1))
+    service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=1, emitted=True))
+
+    assert service.execute() == "do it"
+    assert service.modes.mode.value == "executing"
+    assert service.command_text == ""
+    assert service.command_edited is False
+    assert service.arm_ts is None
+    assert any(e["type"] == "command_text" and e["text"] == "" for e in emitted)
+
+
 def test_execute_requires_readiness():
     service, _, clock = _service()
     service.arm()
@@ -1830,7 +1857,7 @@ def test_exit_chat_does_not_reset_triage():
     assert lane.drops == 0
     cleared = [e for e in _triage_events(emitted) if e.get("stage") == "cleared"]
     assert cleared == []
-    assert chats == [1]
+    assert chats == []
     assert bresets == []
     assert brief_file.clears == 0
 

+ 366 - 54
tests/test_tui_app.py

@@ -134,6 +134,131 @@ def test_type_command_enter_when_armed_skips_start(server):
     assert sent[0][1] == {"text": "open calendar"}, sent
 
 
+def test_arm_from_chat_leaves_chat_first(server):
+    pytest.importorskip("textual")
+
+    from stt_daemon.tui.app import AssistantTUI
+
+    path, _service = server
+    app = AssistantTUI(path)
+    sent: list[tuple] = []
+
+    async def scenario():
+        async with app.run_test() as pilot:
+            await pilot.pause()
+            app.client.send = lambda type_, **fields: sent.append((type_, fields))
+            app._timer.stop()
+            app._drain()
+            app.state.ingest({"type": "mode", "mode": "chat"})
+            await pilot.press("a")
+            await pilot.pause()
+
+    asyncio.run(scenario())
+    assert [t for t, _ in sent] == ["chat_toggle", "command_start"], sent
+
+
+def test_type_command_enter_from_chat_leaves_chat_first(server):
+    pytest.importorskip("textual")
+    from textual.widgets import Input
+
+    from stt_daemon.tui.app import AssistantTUI
+
+    path, _service = server
+    app = AssistantTUI(path)
+    sent: list[tuple] = []
+
+    async def scenario():
+        async with app.run_test() as pilot:
+            await pilot.pause()
+            app.client.send = lambda type_, **fields: sent.append((type_, fields))
+            app._timer.stop()
+            app._drain()
+            app.state.ingest({"type": "mode", "mode": "chat"})
+            app.query_one("#command", Input).focus()
+            await pilot.pause()
+            for ch in "open calendar":
+                await pilot.press(ch)
+            await pilot.pause()
+            await pilot.press("enter")
+            await pilot.pause()
+
+    asyncio.run(scenario())
+    assert [t for t, _ in sent] == [
+        "chat_toggle",
+        "command_start",
+        "command_edit",
+        "command_execute",
+    ], sent
+
+
+def test_command_from_chat_mode_reaches_executing(server):
+    """A command sent while chatting leaves chat and actually executes."""
+    pytest.importorskip("textual")
+    from textual.widgets import Input
+
+    from stt_daemon.tui.app import AssistantTUI
+
+    path, service = server
+    app = AssistantTUI(path)
+
+    async def scenario():
+        async with app.run_test() as pilot:
+            await pilot.pause()
+            assert service.toggle_chat() is True
+            for _ in range(100):
+                await pilot.pause()
+                if app.state.mode == "chat":
+                    break
+            assert app.state.mode == "chat"
+            app.query_one("#command", Input).focus()
+            await pilot.pause()
+            for ch in "do it":
+                await pilot.press(ch)
+            await pilot.pause()
+            await pilot.press("enter")
+            for _ in range(100):
+                await pilot.pause()
+                if service.modes.mode.value == "executing":
+                    break
+
+    asyncio.run(scenario())
+    assert service.modes.mode.value == "executing"
+
+
+def test_typed_execute_clears_command_input(server):
+    """Typing a command and pressing Enter clears the input as soon as it executes."""
+    pytest.importorskip("textual")
+    from textual.widgets import Input
+
+    from stt_daemon.tui.app import AssistantTUI
+
+    path, service = server
+    app = AssistantTUI(path)
+    observed: dict = {}
+
+    async def scenario():
+        async with app.run_test() as pilot:
+            await pilot.pause()
+            app.query_one("#command", Input).focus()
+            await pilot.pause()
+            for ch in "do it":
+                await pilot.press(ch)
+            await pilot.pause()
+            await pilot.press("enter")
+            for _ in range(100):
+                await pilot.pause()
+                if service.modes.mode.value == "executing":
+                    break
+            await pilot.pause()
+            observed["value"] = app.query_one("#command", Input).value
+            observed["state_text"] = app.state.command_text
+
+    asyncio.run(scenario())
+    assert service.modes.mode.value == "executing"
+    assert observed["value"] == ""
+    assert observed["state_text"] == ""
+
+
 def test_empty_command_enter_sends_nothing(server):
     pytest.importorskip("textual")
     from textual.widgets import Input
@@ -289,16 +414,17 @@ def test_submit_blurs_command_and_hotkey_still_fires(server):
             observed["value_after_submit"] = app.query_one("#command", Input).value
             await pilot.press("t")
             await pilot.pause()
+            observed["lower_view"] = app._lower_view
 
     asyncio.run(scenario())
     assert [t for t, _ in sent] == [
         "command_start",
         "command_edit",
         "command_execute",
-        "chat_toggle",
     ], sent
     assert observed["focused_after_submit"] is None
     assert observed["value_after_submit"] == ""
+    assert observed["lower_view"] == "output"
 
 
 def test_focus_triage_then_m_maximizes_deterministically(server):
@@ -350,7 +476,36 @@ def test_c_key_confirms_before_clearing(server):
     assert ("clear", {}) in sent
 
 
-def test_c_key_cancel_does_not_clear(server):
+def test_c_key_hides_visible_pane(server):
+    """Confirming clear hides whatever lower pane was showing."""
+    pytest.importorskip("textual")
+    from textual.widgets import RichLog
+
+    from stt_daemon.tui.app import AssistantTUI
+
+    path, _service = server
+    app = AssistantTUI(path)
+
+    async def scenario():
+        async with app.run_test(size=(80, 40)) as pilot:
+            await pilot.pause()
+            app.state.ingest(
+                {"type": "tool_result", "tool": "get_datetime", "ok": True, "result": "now"}
+            )
+            app._lower_view = "output"
+            app._render()
+            await pilot.pause()
+            assert app.query_one("#output", RichLog).display is True
+            await pilot.press("c")
+            await pilot.pause()
+            await pilot.press("y")
+            for _ in range(100):
+                await pilot.pause()
+                if app._lower_view == "hidden":
+                    break
+
+    asyncio.run(scenario())
+    assert app._lower_view == "hidden"
     pytest.importorskip("textual")
     from stt_daemon.tui.app import AssistantTUI
 
@@ -454,15 +609,48 @@ def test_tui_renders_command_output(server):
             )
             app._render()
             await pilot.pause()
-            log = app.query_one("#chat", RichLog)
+            log = app.query_one("#output", RichLog)
             return list(log.lines)
 
     lines = asyncio.run(scenario())
     assert lines, "command output was not rendered anywhere"
 
 
-def test_tui_clears_chat_pane_on_chat_exit(server):
-    """Leaving chat must drop the rendered conversation but keep command output."""
+def test_command_output_and_chat_render_in_separate_panes(server):
+    """Tool output lands in #output and chat turns in #chat, with no crossover."""
+    pytest.importorskip("textual")
+    from textual.widgets import RichLog
+
+    from stt_daemon.tui.app import AssistantTUI
+
+    path, _service = server
+    app = AssistantTUI(path)
+
+    async def scenario():
+        async with app.run_test() as pilot:
+            await pilot.pause()
+            app._timer.stop()  # no background drain: server status must not clobber state
+            app._drain()
+            app.state.ingest(
+                {"type": "tool_result", "tool": "get_datetime", "ok": True, "result": "cmd"}
+            )
+            app.state.ingest({"type": "mode", "mode": "chat"})
+            app.state.ingest({"type": "chat_message", "role": "user", "text": "hi"})
+            app._render()
+            await pilot.pause()
+            output = [str(line) for line in app.query_one("#output", RichLog).lines]
+            chat = [str(line) for line in app.query_one("#chat", RichLog).lines]
+            return output, chat
+
+    output, chat = asyncio.run(scenario())
+    assert any("cmd" in line for line in output)
+    assert not any("hi" in line for line in output)
+    assert any("hi" in line for line in chat)
+    assert not any("cmd" in line for line in chat)
+
+
+def test_tui_keeps_chat_pane_on_chat_exit(server):
+    """Leaving chat keeps the rendered conversation; only clear drops it."""
     pytest.importorskip("textual")
     from textual.widgets import RichLog
 
@@ -478,18 +666,27 @@ def test_tui_clears_chat_pane_on_chat_exit(server):
                 {"type": "tool_result", "tool": "get_datetime", "ok": True, "result": "kept"}
             )
             app.state.ingest({"type": "mode", "mode": "chat"})
-            app.state.ingest({"type": "chat_message", "role": "user", "text": "gone"})
+            app.state.ingest({"type": "chat_message", "role": "user", "text": "kept-turn"})
             app._render()
             await pilot.pause()
 
             app.state.ingest({"type": "mode", "mode": "idle"})
             app._render()
             await pilot.pause()
-            return [str(line) for line in app.query_one("#chat", RichLog).lines]
+            chat = [str(line) for line in app.query_one("#chat", RichLog).lines]
+            output = [str(line) for line in app.query_one("#output", RichLog).lines]
 
-    lines = asyncio.run(scenario())
-    assert not any("gone" in line for line in lines)
-    assert any("kept" in line for line in lines)
+            app.state.ingest({"type": "cleared"})
+            app._render()
+            await pilot.pause()
+            cleared = [str(line) for line in app.query_one("#chat", RichLog).lines]
+            return chat, output, cleared
+
+    chat_lines, output_lines, cleared = asyncio.run(scenario())
+    assert any("kept-turn" in line for line in chat_lines)
+    assert not any("kept-turn" in line for line in output_lines)
+    assert any("kept" in line for line in output_lines)
+    assert cleared == []
 
 
 def test_tui_mounts_subscribes_and_ingests_broadcasts(server):
@@ -682,6 +879,7 @@ def test_triage_enabled_layout_proportions(server):
             app._drain()
             # Chat needs content to be shown (hidden-by-default rule): put it in
             # chat mode so the 1/3-2/3 split applies.
+            app._lower_view = "chat"
             app.state.ingest({"type": "mode", "mode": "chat"})
             app._render()
             await pilot.pause()
@@ -1039,11 +1237,11 @@ def test_maximize_chat_when_triage_disabled(server):
         async with app.run_test(size=(80, 40)) as pilot:
             await pilot.pause()
             app._timer.stop()  # keep the server status from re-hiding #chat
-            # Disabled: no #main/#triage. #chat starts hidden, so give it output
-            # first, then focus it and press m to maximize #chat itself.
-            app.state.ingest(
-                {"type": "tool_result", "tool": "get_datetime", "ok": True, "result": "now"}
-            )
+            # Disabled: no #main/#triage. #chat starts hidden, so reveal it via
+            # the cycle, then focus it and press m to maximize #chat itself.
+            app._lower_view = "chat"
+            app.state.ingest({"type": "mode", "mode": "chat"})
+            app.state.ingest({"type": "chat_message", "role": "user", "text": "hi"})
             app._render()
             await pilot.pause()
             app.query_one("#chat", RichLog).focus()
@@ -1134,7 +1332,7 @@ def test_app_bindings_list():
     assert ("a", "arm command") in pairs
     assert ("escape", "cancel") in pairs
     assert ("e", "edit command") in pairs
-    assert ("t", "chat toggle") in pairs
+    assert ("t", "cycle pane") in pairs
     assert ("s", "questions view") in pairs
     assert ("c", "clear") in pairs
     assert ("q", "quit") in pairs
@@ -1158,7 +1356,7 @@ def test_app_bindings_list():
         ("ctrl+r", "execute"),
         ("escape", "cancel"),
         ("e", "edit command"),
-        ("t", "chat toggle"),
+        ("t", "cycle pane"),
         ("s", "questions view"),
         ("question_mark", "help"),
         ("q", "quit"),
@@ -1200,7 +1398,7 @@ def test_footer_shows_ordered_entries(server):
         ("ctrl+r", "execute"),
         ("escape", "cancel"),
         ("e", "edit command"),
-        ("t", "chat toggle"),
+        ("t", "cycle pane"),
         ("s", "questions view"),
         ("question_mark", "help"),
         ("q", "quit"),
@@ -1411,6 +1609,7 @@ def test_chat_mode_shows_chat_and_restores_split(server):
             await pilot.pause()
             app._timer.stop()  # no background drain: server status must not clobber state
             app._drain()
+            app._lower_view = "chat"
             app.state.ingest({"type": "mode", "mode": "chat"})
             app._render()
             await pilot.pause()
@@ -1430,8 +1629,8 @@ def test_chat_mode_shows_chat_and_restores_split(server):
     assert abs(observed["chat_region"].width / main_w - 2 / 3) < 0.08, observed
 
 
-def test_tool_result_shows_chat(server):
-    """Command output (tool_result) → #chat visible."""
+def test_tool_result_shows_output_pane(server):
+    """Command output (tool_result) goes to #output, leaving #chat hidden."""
     pytest.importorskip("textual")
     from textual.widgets import RichLog
 
@@ -1446,6 +1645,7 @@ def test_tool_result_shows_chat(server):
             await pilot.pause()
             app._timer.stop()  # no background drain: server status must not clobber state
             app._drain()
+            app._lower_view = "output"
             app.state.ingest(
                 {
                     "type": "tool_result",
@@ -1456,14 +1656,16 @@ def test_tool_result_shows_chat(server):
             )
             app._render()
             await pilot.pause()
+            observed["output_display"] = app.query_one("#output", RichLog).display
             observed["chat_display"] = app.query_one("#chat", RichLog).display
 
     asyncio.run(scenario())
-    assert observed["chat_display"] is True
+    assert observed["output_display"] is True
+    assert observed["chat_display"] is False
 
 
-def test_error_event_shows_chat(server):
-    """An error event → #chat visible."""
+def test_error_event_shows_output_pane(server):
+    """An error event goes to #output, leaving #chat hidden."""
     pytest.importorskip("textual")
     from textual.widgets import RichLog
 
@@ -1478,17 +1680,20 @@ def test_error_event_shows_chat(server):
             await pilot.pause()
             app._timer.stop()  # no background drain: server status must not clobber state
             app._drain()
+            app._lower_view = "output"
             app.state.ingest({"type": "error", "code": "boom", "message": "kaboom"})
             app._render()
             await pilot.pause()
+            observed["output_display"] = app.query_one("#output", RichLog).display
             observed["chat_display"] = app.query_one("#chat", RichLog).display
 
     asyncio.run(scenario())
-    assert observed["chat_display"] is True
+    assert observed["output_display"] is True
+    assert observed["chat_display"] is False
 
 
-def test_executing_mode_shows_chat(server):
-    """Assistant executing a command → #chat visible."""
+def test_executing_mode_shows_output_pane(server):
+    """Assistant executing a command → #output visible, #chat hidden."""
     pytest.importorskip("textual")
     from textual.widgets import RichLog
 
@@ -1503,17 +1708,20 @@ def test_executing_mode_shows_chat(server):
             await pilot.pause()
             app._timer.stop()  # no background drain: server status must not clobber state
             app._drain()
+            app._lower_view = "output"
             app.state.ingest({"type": "mode", "mode": "executing"})
             app._render()
             await pilot.pause()
+            observed["output_display"] = app.query_one("#output", RichLog).display
             observed["chat_display"] = app.query_one("#chat", RichLog).display
 
     asyncio.run(scenario())
-    assert observed["chat_display"] is True
+    assert observed["output_display"] is True
+    assert observed["chat_display"] is False
 
 
-def test_cleared_hides_chat_and_restores_full_width(server):
-    """cleared event → #chat hidden again, triage full width."""
+def test_cleared_hides_pane_empties_output_and_restores_full_width(server):
+    """cleared empties #output and hides it again, triage full width."""
     pytest.importorskip("textual")
     from textual.containers import Horizontal
     from textual.widgets import RichLog
@@ -1529,6 +1737,7 @@ def test_cleared_hides_chat_and_restores_full_width(server):
             await pilot.pause()
             app._timer.stop()  # no background drain: server status must not clobber state
             app._drain()
+            app._lower_view = "output"
             app.state.ingest(
                 {
                     "type": "tool_result",
@@ -1539,11 +1748,16 @@ def test_cleared_hides_chat_and_restores_full_width(server):
             )
             app._render()
             await pilot.pause()
-            assert app.query_one("#chat", RichLog).display is True
+            assert app.query_one("#output", RichLog).display is True
             app.state.ingest({"type": "cleared"})
             app._render()
             await pilot.pause()
+            observed["empty_output"] = [
+                str(line) for line in app.query_one("#output", RichLog).lines
+            ]
+            observed["lower_view"] = app._lower_view
             observed["chat_display"] = app.query_one("#chat", RichLog).display
+            observed["output_display"] = app.query_one("#output", RichLog).display
             observed["main_region"] = app.query_one("#main", Horizontal).region
             observed["triage_region"] = app.query_one("#triage", RichLog).region
 
@@ -1552,14 +1766,17 @@ def test_cleared_hides_chat_and_restores_full_width(server):
         "cleared: main=%s triage=%s"
         % (observed["main_region"], observed["triage_region"])
     )
+    assert observed["empty_output"] == []
+    assert observed["lower_view"] == "hidden"
     assert observed["chat_display"] is False
+    assert observed["output_display"] is False
     assert (
         abs(observed["triage_region"].width - observed["main_region"].width) <= 2
     ), (observed["triage_region"], observed["main_region"])
 
 
-def test_triage_disabled_chat_follows_content_rule(server):
-    """Triage disabled: #chat is hidden until chat mode or command output arrives."""
+def test_cycle_lower_switches_panes(server):
+    """t cycles hidden -> #output -> #chat -> hidden when triage is disabled."""
     pytest.importorskip("textual")
     from textual.widgets import RichLog
 
@@ -1569,6 +1786,13 @@ def test_triage_disabled_chat_follows_content_rule(server):
     app = AssistantTUI(path)
     observed: dict = {}
 
+    def snap():
+        return (
+            app.query_one("#output", RichLog).display,
+            app.query_one("#chat", RichLog).display,
+            app.query_one("#transcript", RichLog).has_class("-grow"),
+        )
+
     async def scenario():
         async with app.run_test(size=(80, 40)) as pilot:
             await pilot.pause()
@@ -1576,33 +1800,120 @@ def test_triage_disabled_chat_follows_content_rule(server):
             app._drain()
             app._render()
             await pilot.pause()
-            observed["chat_display"] = app.query_one("#chat", RichLog).display
-            observed["transcript_grown"] = app.query_one("#transcript", RichLog).has_class(
-                "-grow"
+            observed["hidden"] = snap()
+            app.action_cycle_lower()
+            app._render()
+            await pilot.pause()
+            observed["output"] = snap()
+            app.action_cycle_lower()
+            app._render()
+            await pilot.pause()
+            observed["chat"] = snap()
+            app.action_cycle_lower()
+            app._render()
+            await pilot.pause()
+            observed["hidden_again"] = snap()
+
+    asyncio.run(scenario())
+    assert observed["hidden"] == (False, False, True)
+    assert observed["output"] == (True, False, False)
+    assert observed["chat"] == (False, True, False)
+    assert observed["hidden_again"] == (False, False, True)
+
+
+def test_cycle_lower_enters_and_exits_chat_mode(server):
+    """Selecting conversation sends chat_toggle on; leaving it toggles off but
+    keeps the rendered turns until an explicit clear."""
+    pytest.importorskip("textual")
+    from textual.widgets import RichLog
+
+    from stt_daemon.tui.app import AssistantTUI
+
+    path, _service = server
+    app = AssistantTUI(path)
+    sent: list[tuple] = []
+    observed: dict = {}
+
+    async def scenario():
+        async with app.run_test(size=(80, 40)) as pilot:
+            await pilot.pause()
+            app.client.send = lambda type_, **fields: sent.append((type_, fields))
+            app._timer.stop()
+            app._drain()
+            app.action_cycle_lower()  # hidden -> output
+            app.action_cycle_lower()  # output -> chat
+            app._render()
+            await pilot.pause()
+            observed["after_enter"] = list(sent)
+            app.state.ingest({"type": "mode", "mode": "chat"})
+            app.state.ingest({"type": "chat_message", "role": "user", "text": "remember"})
+            app._render()
+            await pilot.pause()
+            app.action_cycle_lower()  # chat -> hidden (mode is chat -> exits)
+            app._render()
+            await pilot.pause()
+            observed["after_exit"] = list(sent)
+            observed["chat_lines"] = [str(line) for line in app.query_one("#chat", RichLog).lines]
+
+    asyncio.run(scenario())
+    assert observed["after_enter"] == [("chat_toggle", {})]
+    assert observed["after_exit"] == [("chat_toggle", {}), ("chat_toggle", {})]
+    assert any("remember" in line for line in observed["chat_lines"])
+
+
+def test_command_activity_reveals_output_pane(server):
+    """Arming, executing, and chatting: command modes force #output; chat does not."""
+    pytest.importorskip("textual")
+    from textual.widgets import RichLog
+
+    from stt_daemon.tui.app import AssistantTUI
+
+    path, _service = server
+    app = AssistantTUI(path, triage_enabled=True)
+    observed: dict = {}
+
+    async def scenario():
+        async with app.run_test(size=(80, 40)) as pilot:
+            await pilot.pause()
+            app._timer.stop()
+            app._drain()
+            await pilot.pause()
+            observed["hidden"] = app.query_one("#output", RichLog).display
+            app.state.ingest({"type": "mode", "mode": "command_armed"})
+            app._render()
+            await pilot.pause()
+            observed["armed"] = (
+                app._lower_view,
+                app.query_one("#output", RichLog).display,
+                app.query_one("#chat", RichLog).display,
             )
-            app._restore_panes()  # must not raise with no #triage/#main
+            app._lower_view = "hidden"
+            app.state.ingest({"type": "mode", "mode": "executing"})
+            app._render()
             await pilot.pause()
-            observed["chat_display_after"] = app.query_one("#chat", RichLog).display
-            app.state.ingest(
-                {"type": "tool_result", "tool": "get_datetime", "ok": True, "result": "now"}
+            observed["executing"] = (
+                app._lower_view,
+                app.query_one("#output", RichLog).display,
             )
+            app._lower_view = "chat"
+            app.state.ingest({"type": "mode", "mode": "chat"})
             app._render()
             await pilot.pause()
-            observed["chat_with_output"] = app.query_one("#chat", RichLog).display
-            observed["transcript_grown_with_output"] = app.query_one(
-                "#transcript", RichLog
-            ).has_class("-grow")
+            observed["chat"] = (
+                app._lower_view,
+                app.query_one("#output", RichLog).display,
+                app.query_one("#chat", RichLog).display,
+            )
 
     asyncio.run(scenario())
-    assert observed["chat_display"] is False
-    assert observed["chat_display_after"] is False
-    assert observed["transcript_grown"] is True
-    assert observed["chat_with_output"] is True
-    assert observed["transcript_grown_with_output"] is False
+    assert observed["hidden"] is False
+    assert observed["armed"] == ("output", True, False)
+    assert observed["executing"] == ("output", True)
+    assert observed["chat"] == ("chat", False, True)
 
 
-def test_maximize_restore_with_chat_content_keeps_chat_visible(server):
-    """With ingested output, m then m restores with #chat still visible."""
+def test_maximize_restore_with_output_keeps_output_visible(server):
+    """With ingested output, m then m restores with #output still visible."""
     pytest.importorskip("textual")
     from textual.widgets import RichLog
 
@@ -1617,6 +1928,7 @@ def test_maximize_restore_with_chat_content_keeps_chat_visible(server):
             await pilot.pause()
             app._timer.stop()  # no background drain: server status must not clobber state
             app._drain()
+            app._lower_view = "output"
             app.state.ingest(
                 {
                     "type": "tool_result",
@@ -1627,7 +1939,7 @@ def test_maximize_restore_with_chat_content_keeps_chat_visible(server):
             )
             app._render()
             await pilot.pause()
-            assert app.query_one("#chat", RichLog).display is True
+            assert app.query_one("#output", RichLog).display is True
             app.query_one("#triage", RichLog).focus()
             await pilot.pause()
             await pilot.press("m")
@@ -1637,12 +1949,12 @@ def test_maximize_restore_with_chat_content_keeps_chat_visible(server):
             await pilot.pause()
             observed["maximized"] = app.screen.maximized
             observed["triage_display"] = app.query_one("#triage", RichLog).display
-            observed["chat_display"] = app.query_one("#chat", RichLog).display
+            observed["output_display"] = app.query_one("#output", RichLog).display
 
     asyncio.run(scenario())
     assert observed["maximized"] is None
     assert observed["triage_display"] is True
-    assert observed["chat_display"] is True
+    assert observed["output_display"] is True
 
 
 def test_transcript_pane_keeps_updating_past_cap(server):
@@ -1896,7 +2208,7 @@ def test_triage_disabled_heartbeat_leaves_no_triage_widget(server):
 
     asyncio.run(scenario())
     assert observed["has_triage"] is False
-    assert observed["chat"] == "conversation / command output  \\[inf 9 · busy]"
+    assert observed["chat"] == "conversation  \\[inf 9 · busy]"
 
 
 def test_s_key_is_noop_when_triage_disabled(server):

+ 23 - 8
tests/test_tui_state.py

@@ -313,6 +313,19 @@ def test_chat_turns_also_appear_in_lower_log():
     assert state.lower_lines() == ["user: hello"]
 
 
+def test_output_and_chat_selectors_partition_lower_log():
+    state = ViewState()
+    state.ingest({"type": "tool_call", "tool": "get_datetime", "args": {}})
+    state.ingest({"type": "chat_message", "role": "user", "text": "hi"})
+    state.ingest({"type": "error", "code": "boom", "message": "m"})
+
+    assert state.output_lines() == ["-> tool get_datetime", "! boom: m"]
+    assert state.chat_output_lines() == ["user: hi"]
+    assert sorted(state.output_lines() + state.chat_output_lines()) == sorted(
+        state.lower_lines()
+    )
+
+
 def test_lower_log_is_bounded():
     from stt_daemon.tui.state import MAX_LOWER
 
@@ -350,7 +363,7 @@ def test_chat_and_tool_events_recorded():
     assert state.last_error is not None and state.last_error["code"] == "no_viewer"
 
 
-def test_leaving_chat_clears_chat_pane_but_keeps_command_output():
+def test_leaving_chat_keeps_chat_and_command_output():
     state = ViewState()
     state.ingest({"type": "tool_call", "tool": "get_datetime", "args": {}})
     state.ingest({"type": "mode", "mode": "chat"})
@@ -361,23 +374,23 @@ def test_leaving_chat_clears_chat_pane_but_keeps_command_output():
     epoch = state.chat_epoch
     state.ingest({"type": "mode", "mode": "idle"})
 
-    assert state.chat_lines() == []
-    assert state.lower_lines() == ["-> tool get_datetime"]
-    assert state.chat_epoch == epoch + 1
+    assert state.chat_lines() == ["user: hello", "assistant: hi"]
+    assert state.lower_lines() == ["-> tool get_datetime", "user: hello", "assistant: hi"]
+    assert state.chat_epoch == epoch
 
 
-def test_heartbeat_mode_change_clears_chat():
+def test_heartbeat_mode_change_keeps_chat():
     state = ViewState()
     state.ingest({"type": "mode", "mode": "chat"})
     state.ingest({"type": "chat_message", "role": "user", "text": "x"})
 
     state.ingest({"type": "heartbeat", "mode": "idle", "stt_ok": True})
 
-    assert state.chat_lines() == []
-    assert state.lower_lines() == []
+    assert state.chat_lines() == ["user: x"]
+    assert state.lower_lines() == ["user: x"]
 
 
-def test_compaction_note_renders_and_clears_on_chat_exit():
+def test_compaction_note_persists_until_cleared():
     state = ViewState()
     state.ingest({"type": "mode", "mode": "chat"})
     state.ingest(
@@ -391,7 +404,9 @@ def test_compaction_note_renders_and_clears_on_chat_exit():
     assert "note: Compacted 4 earlier turns into a summary." in state.lower_lines()
 
     state.ingest({"type": "mode", "mode": "idle"})
+    assert "note: Compacted 4 earlier turns into a summary." in state.lower_lines()
 
+    state.ingest({"type": "cleared"})
     assert state.lower_lines() == []