Efren Yevale Varela 1 mese fa
parent
commit
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).
 1. Arm (`stt-ctl command-start`, or `a` in the TUI).
 2. Speak; the transcript fills the buffer.
 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).
 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
 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
 `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
 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
 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
 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
 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
 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
 ## 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).
 `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
 **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
 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.
             audit: Object with `record(**fields)`; called once per execution.
             relay: Sink for relayed STT events (`transcript`, `speech_*`).
             relay: Sink for relayed STT events (`transcript`, `speech_*`).
             chat_fn: Callable sending one chat turn and returning the reply.
             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.
             triage: Triage analyzer (duck-typed); None disables triage.
             laya_lane: Laya inference lane (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.
             brief_fn: Callable turning a brief prompt into plain-text brief.
@@ -408,6 +408,7 @@ class AssistantService:
             edited = self.command_edited
             edited = self.command_edited
             if not self.modes.execute():
             if not self.modes.execute():
                 return None
                 return None
+            self._clear_command()
         self._emit_mode()
         self._emit_mode()
         self._emit_command_text(force=True)
         self._emit_command_text(force=True)
         if self.executor is not None:
         if self.executor is not None:
@@ -479,8 +480,6 @@ class AssistantService:
         with self._lock:
         with self._lock:
             if not self.modes.enter_chat():
             if not self.modes.enter_chat():
                 return False
                 return False
-            self.chat_history = []
-            self._pending_chat = []
             self._chat_queue_full = False
             self._chat_queue_full = False
             self._last_chat_activity = self._now()
             self._last_chat_activity = self._now()
         self._emit_mode()
         self._emit_mode()
@@ -870,15 +869,7 @@ class AssistantService:
         with self._lock:
         with self._lock:
             if not self.modes.exit_chat():
             if not self.modes.exit_chat():
                 return False
                 return False
-            self.chat_history = []
-            self._pending_chat = []
             self._chat_queue_full = False
             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()
         self._emit_mode()
         if reason != "toggle":
         if reason != "toggle":
             self._emit(ERROR_EVENT, code=reason, message=f"chat {reason}")
             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; }
     #transcript { height: 25%; border: solid $accent; }
     #command { height: 3; border: solid $accent; }
     #command { height: 3; border: solid $accent; }
     #chat { height: 1fr; border: solid $accent; }
     #chat { height: 1fr; border: solid $accent; }
+    #output { height: 1fr; border: solid $accent; }
     #main { height: 1fr; }
     #main { height: 1fr; }
     #main #triage { width: 1fr; height: 100%; border: solid $accent; }
     #main #triage { width: 1fr; height: 100%; border: solid $accent; }
     #main #chat { width: 2fr; height: 100%; }
     #main #chat { width: 2fr; height: 100%; }
+    #main #output { width: 2fr; height: 100%; }
     #transcript.-maximized { height: 1fr; }
     #transcript.-maximized { height: 1fr; }
     #transcript.-grow { height: 1fr; }
     #transcript.-grow { height: 1fr; }
     """
     """
@@ -47,7 +49,7 @@ class AssistantTUI(App):
         Binding("ctrl+r", "execute", "execute"),
         Binding("ctrl+r", "execute", "execute"),
         Binding("escape", "cancel", "cancel"),
         Binding("escape", "cancel", "cancel"),
         Binding("e", "edit", "edit command"),
         Binding("e", "edit", "edit command"),
-        Binding("t", "chat", "chat toggle"),
+        Binding("t", "cycle_lower", "cycle pane"),
         Binding("s", "toggle_questions", "questions view"),
         Binding("s", "toggle_questions", "questions view"),
         Binding("question_mark", "toggle_help_panel", "help"),
         Binding("question_mark", "toggle_help_panel", "help"),
         Binding("q", "quit", "quit"),
         Binding("q", "quit", "quit"),
@@ -66,15 +68,18 @@ class AssistantTUI(App):
         self.connect_error: str | None = None
         self.connect_error: str | None = None
         self._events: queue.Queue = queue.Queue()
         self._events: queue.Queue = queue.Queue()
         self._seen_transcripts = 0
         self._seen_transcripts = 0
-        self._seen_lower = 0
+        self._seen_chat = 0
+        self._seen_output = 0
         self._seen_epoch = 0
         self._seen_epoch = 0
         self._seen_triage_epoch = 0
         self._seen_triage_epoch = 0
         self._seen_questions_epoch = 0
         self._seen_questions_epoch = 0
         self._show_questions = False
         self._show_questions = False
+        self._lower_view = "hidden"
         self._seen_view = "triage"
         self._seen_view = "triage"
         self._timer = None
         self._timer = None
         self._last_seg_title = ""
         self._last_seg_title = ""
         self._last_inf_title = ""
         self._last_inf_title = ""
+        self._last_output_title = ""
         self._last_triage_title = ""
         self._last_triage_title = ""
 
 
     def compose(self) -> ComposeResult:
     def compose(self) -> ComposeResult:
@@ -84,8 +89,10 @@ class AssistantTUI(App):
         if self.triage_enabled:
         if self.triage_enabled:
             with MainPane(id="main"):
             with MainPane(id="main"):
                 yield RichLog(id="triage", wrap=True, markup=False)
                 yield RichLog(id="triage", wrap=True, markup=False)
+                yield RichLog(id="output", wrap=True, markup=False)
                 yield RichLog(id="chat", wrap=True, markup=False)
                 yield RichLog(id="chat", wrap=True, markup=False)
         else:
         else:
+            yield RichLog(id="output", wrap=True, markup=False)
             yield RichLog(id="chat", wrap=True, markup=False)
             yield RichLog(id="chat", wrap=True, markup=False)
         yield Footer()
         yield Footer()
 
 
@@ -103,11 +110,16 @@ class AssistantTUI(App):
             self._last_seg_title = seg_title
             self._last_seg_title = seg_title
 
 
         inf_suffix = " · busy" if state.inference_in_flight else ""
         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:
         if inf_title != self._last_inf_title:
             self.query_one("#chat", RichLog).border_title = inf_title
             self.query_one("#chat", RichLog).border_title = inf_title
             self._last_inf_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:
         if self.triage_enabled:
             triage_title = self._triage_title()
             triage_title = self._triage_title()
             if not self._show_questions:
             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
             # A timer tick can land while the app is shutting down, after the
             # widgets are unmounted; there is nothing left to draw into.
             # widgets are unmounted; there is nothing left to draw into.
             return
             return
+        if self.state.mode in ("command_armed", "executing"):
+            self._lower_view = "output"
         state = self.state
         state = self.state
         health = "ok" if state.health_ok() else "DOWN"
         health = "ok" if state.health_ok() else "DOWN"
         conn = "ok" if state.connected else "LOST"
         conn = "ok" if state.connected else "LOST"
@@ -182,15 +196,26 @@ class AssistantTUI(App):
                 transcript_log.write(line)
                 transcript_log.write(line)
             self._seen_transcripts = state.transcripts_total
             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:
         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
             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:
         if self.triage_enabled:
             triage_log = self.query_one("#triage", RichLog)
             triage_log = self.query_one("#triage", RichLog)
@@ -222,16 +247,30 @@ class AssistantTUI(App):
             self.query_one("#status", Static).update(f"send failed: {e}")
             self.query_one("#status", Static).update(f"send failed: {e}")
 
 
     def action_arm(self) -> None:
     def action_arm(self) -> None:
+        self._leave_chat_for_command()
         self._send("command_start")
         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:
     def action_execute(self) -> None:
         self._send("command_execute")
         self._send("command_execute")
 
 
     def action_cancel(self) -> None:
     def action_cancel(self) -> None:
         self._send("command_cancel")
         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:
     def action_clear(self) -> None:
         self.push_screen(ConfirmClear(), self._on_clear_confirmed)
         self.push_screen(ConfirmClear(), self._on_clear_confirmed)
@@ -254,38 +293,39 @@ class AssistantTUI(App):
         cmd.focus()
         cmd.focus()
         cmd.cursor_position = len(cmd.value)
         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:
     def _update_main_visibility(self) -> None:
         if self.screen.maximized is not None:
         if self.screen.maximized is not None:
             return
             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"):
         if self.triage_enabled and self.query("#triage"):
             self.query_one("#triage").display = True
             self.query_one("#triage").display = True
         if self.query("#chat"):
         if self.query("#chat"):
             self.query_one("#chat").display = show_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"):
         if not self.triage_enabled and self.query("#transcript"):
             # No triage pane to fill the split: let the transcript take the
             # 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):
     def _active_pane(self):
         focused = self.screen.focused
         focused = self.screen.focused
         if focused is not None:
         if focused is not None:
             for node in focused.ancestors_with_self:
             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}"):
                     if self.query(f"#{node.id}"):
                         return self.query_one(f"#{node.id}")
                         return self.query_one(f"#{node.id}")
                     return node
                     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
         return None
 
 
     def _restore_panes(self) -> None:
     def _restore_panes(self) -> None:
@@ -300,11 +340,12 @@ class AssistantTUI(App):
         pane = self._active_pane()
         pane = self._active_pane()
         if pane is None:
         if pane is None:
             return
             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"))
             screen.maximize(self.query_one("#main"))
         else:
         else:
             screen.maximize(pane)
             screen.maximize(pane)
@@ -356,11 +397,13 @@ class AssistantTUI(App):
         text = event.value.strip()
         text = event.value.strip()
         if not text:
         if not text:
             return
             return
+        self._leave_chat_for_command()
         if self.state.mode != "command_armed":
         if self.state.mode != "command_armed":
             self._send("command_start")
             self._send("command_start")
         self._send("command_edit", text=text)
         self._send("command_edit", text=text)
         self._send("command_execute")
         self._send("command_execute")
         event.input.value = ""
         event.input.value = ""
+        self.state.command_text = ""
         self.set_focus(None)
         self.set_focus(None)
 
 
     def on_unmount(self) -> 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))
         self.lower.append((line, from_chat))
         del self.lower[:-MAX_LOWER]
         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:
     def clear_output(self) -> None:
         """Clear conversation/command output: chat, tool calls/results, errors."""
         """Clear conversation/command output: chat, tool calls/results, errors."""
         self.chat        = []
         self.chat        = []
@@ -227,13 +221,19 @@ class ViewState:
         self.triage_epoch += 1
         self.triage_epoch += 1
 
 
     def _set_mode(self, mode: str) -> None:
     def _set_mode(self, mode: str) -> None:
-        if self.mode == "chat" and mode != "chat":
-            self.clear_chat()
         self.mode = mode
         self.mode = mode
 
 
     def lower_lines(self) -> list[str]:
     def lower_lines(self) -> list[str]:
         return [line for line, _ in self.lower]
         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:
     def health_ok(self) -> bool:
         """False when the header should go red."""
         """False when the header should go red."""
         return self.stt_ok and self.device_ok
         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."))
     service.handle_event(_transcript(clock, "That's all."))
 
 
     assert service.modes.mode.value == "idle"
     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()
     service, _, clock = _service()
     assert service.toggle_chat() is True
     assert service.toggle_chat() is True
     service.handle_event(_transcript(clock, "hello"))
     service.handle_event(_transcript(clock, "hello"))
 
 
     assert service.viewer_detached() is True
     assert service.viewer_detached() is True
     assert service.modes.mode.value == "idle"
     assert service.modes.mode.value == "idle"
-    assert service.chat_history == []
+    assert service.chat_history == ["Unknown: hello"]
 
 
 
 
 def test_viewer_detached_is_noop_outside_chat():
 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"
     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()
     cfg = Config()
     resets: list[int] = []
     resets: list[int] = []
     service = AssistantService(
     service = AssistantService(
         cfg, FakeSubscriber(), time_fn=Clock(), chat_reset=lambda: resets.append(1)
         cfg, FakeSubscriber(), time_fn=Clock(), chat_reset=lambda: resets.append(1)
     )
     )
     service.toggle_chat()
     service.toggle_chat()
+    service.handle_event(_transcript(Clock(), "hello"))
 
 
     service.viewer_detached()
     service.viewer_detached()
+    service.toggle_chat()
 
 
-    assert resets == [1]
+    assert resets == []
+    assert service.chat_history == ["Unknown: hello"]
 
 
 
 
 def test_chat_accumulates_turns():
 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"]
     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():
 def test_execute_requires_readiness():
     service, _, clock = _service()
     service, _, clock = _service()
     service.arm()
     service.arm()
@@ -1830,7 +1857,7 @@ def test_exit_chat_does_not_reset_triage():
     assert lane.drops == 0
     assert lane.drops == 0
     cleared = [e for e in _triage_events(emitted) if e.get("stage") == "cleared"]
     cleared = [e for e in _triage_events(emitted) if e.get("stage") == "cleared"]
     assert cleared == []
     assert cleared == []
-    assert chats == [1]
+    assert chats == []
     assert bresets == []
     assert bresets == []
     assert brief_file.clears == 0
     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
     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):
 def test_empty_command_enter_sends_nothing(server):
     pytest.importorskip("textual")
     pytest.importorskip("textual")
     from textual.widgets import Input
     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
             observed["value_after_submit"] = app.query_one("#command", Input).value
             await pilot.press("t")
             await pilot.press("t")
             await pilot.pause()
             await pilot.pause()
+            observed["lower_view"] = app._lower_view
 
 
     asyncio.run(scenario())
     asyncio.run(scenario())
     assert [t for t, _ in sent] == [
     assert [t for t, _ in sent] == [
         "command_start",
         "command_start",
         "command_edit",
         "command_edit",
         "command_execute",
         "command_execute",
-        "chat_toggle",
     ], sent
     ], sent
     assert observed["focused_after_submit"] is None
     assert observed["focused_after_submit"] is None
     assert observed["value_after_submit"] == ""
     assert observed["value_after_submit"] == ""
+    assert observed["lower_view"] == "output"
 
 
 
 
 def test_focus_triage_then_m_maximizes_deterministically(server):
 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
     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")
     pytest.importorskip("textual")
     from stt_daemon.tui.app import AssistantTUI
     from stt_daemon.tui.app import AssistantTUI
 
 
@@ -454,15 +609,48 @@ def test_tui_renders_command_output(server):
             )
             )
             app._render()
             app._render()
             await pilot.pause()
             await pilot.pause()
-            log = app.query_one("#chat", RichLog)
+            log = app.query_one("#output", RichLog)
             return list(log.lines)
             return list(log.lines)
 
 
     lines = asyncio.run(scenario())
     lines = asyncio.run(scenario())
     assert lines, "command output was not rendered anywhere"
     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")
     pytest.importorskip("textual")
     from textual.widgets import RichLog
     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"}
                 {"type": "tool_result", "tool": "get_datetime", "ok": True, "result": "kept"}
             )
             )
             app.state.ingest({"type": "mode", "mode": "chat"})
             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()
             app._render()
             await pilot.pause()
             await pilot.pause()
 
 
             app.state.ingest({"type": "mode", "mode": "idle"})
             app.state.ingest({"type": "mode", "mode": "idle"})
             app._render()
             app._render()
             await pilot.pause()
             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):
 def test_tui_mounts_subscribes_and_ingests_broadcasts(server):
@@ -682,6 +879,7 @@ def test_triage_enabled_layout_proportions(server):
             app._drain()
             app._drain()
             # Chat needs content to be shown (hidden-by-default rule): put it in
             # Chat needs content to be shown (hidden-by-default rule): put it in
             # chat mode so the 1/3-2/3 split applies.
             # chat mode so the 1/3-2/3 split applies.
+            app._lower_view = "chat"
             app.state.ingest({"type": "mode", "mode": "chat"})
             app.state.ingest({"type": "mode", "mode": "chat"})
             app._render()
             app._render()
             await pilot.pause()
             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:
         async with app.run_test(size=(80, 40)) as pilot:
             await pilot.pause()
             await pilot.pause()
             app._timer.stop()  # keep the server status from re-hiding #chat
             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()
             app._render()
             await pilot.pause()
             await pilot.pause()
             app.query_one("#chat", RichLog).focus()
             app.query_one("#chat", RichLog).focus()
@@ -1134,7 +1332,7 @@ def test_app_bindings_list():
     assert ("a", "arm command") in pairs
     assert ("a", "arm command") in pairs
     assert ("escape", "cancel") in pairs
     assert ("escape", "cancel") in pairs
     assert ("e", "edit command") 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 ("s", "questions view") in pairs
     assert ("c", "clear") in pairs
     assert ("c", "clear") in pairs
     assert ("q", "quit") in pairs
     assert ("q", "quit") in pairs
@@ -1158,7 +1356,7 @@ def test_app_bindings_list():
         ("ctrl+r", "execute"),
         ("ctrl+r", "execute"),
         ("escape", "cancel"),
         ("escape", "cancel"),
         ("e", "edit command"),
         ("e", "edit command"),
-        ("t", "chat toggle"),
+        ("t", "cycle pane"),
         ("s", "questions view"),
         ("s", "questions view"),
         ("question_mark", "help"),
         ("question_mark", "help"),
         ("q", "quit"),
         ("q", "quit"),
@@ -1200,7 +1398,7 @@ def test_footer_shows_ordered_entries(server):
         ("ctrl+r", "execute"),
         ("ctrl+r", "execute"),
         ("escape", "cancel"),
         ("escape", "cancel"),
         ("e", "edit command"),
         ("e", "edit command"),
-        ("t", "chat toggle"),
+        ("t", "cycle pane"),
         ("s", "questions view"),
         ("s", "questions view"),
         ("question_mark", "help"),
         ("question_mark", "help"),
         ("q", "quit"),
         ("q", "quit"),
@@ -1411,6 +1609,7 @@ def test_chat_mode_shows_chat_and_restores_split(server):
             await pilot.pause()
             await pilot.pause()
             app._timer.stop()  # no background drain: server status must not clobber state
             app._timer.stop()  # no background drain: server status must not clobber state
             app._drain()
             app._drain()
+            app._lower_view = "chat"
             app.state.ingest({"type": "mode", "mode": "chat"})
             app.state.ingest({"type": "mode", "mode": "chat"})
             app._render()
             app._render()
             await pilot.pause()
             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
     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")
     pytest.importorskip("textual")
     from textual.widgets import RichLog
     from textual.widgets import RichLog
 
 
@@ -1446,6 +1645,7 @@ def test_tool_result_shows_chat(server):
             await pilot.pause()
             await pilot.pause()
             app._timer.stop()  # no background drain: server status must not clobber state
             app._timer.stop()  # no background drain: server status must not clobber state
             app._drain()
             app._drain()
+            app._lower_view = "output"
             app.state.ingest(
             app.state.ingest(
                 {
                 {
                     "type": "tool_result",
                     "type": "tool_result",
@@ -1456,14 +1656,16 @@ def test_tool_result_shows_chat(server):
             )
             )
             app._render()
             app._render()
             await pilot.pause()
             await pilot.pause()
+            observed["output_display"] = app.query_one("#output", RichLog).display
             observed["chat_display"] = app.query_one("#chat", RichLog).display
             observed["chat_display"] = app.query_one("#chat", RichLog).display
 
 
     asyncio.run(scenario())
     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")
     pytest.importorskip("textual")
     from textual.widgets import RichLog
     from textual.widgets import RichLog
 
 
@@ -1478,17 +1680,20 @@ def test_error_event_shows_chat(server):
             await pilot.pause()
             await pilot.pause()
             app._timer.stop()  # no background drain: server status must not clobber state
             app._timer.stop()  # no background drain: server status must not clobber state
             app._drain()
             app._drain()
+            app._lower_view = "output"
             app.state.ingest({"type": "error", "code": "boom", "message": "kaboom"})
             app.state.ingest({"type": "error", "code": "boom", "message": "kaboom"})
             app._render()
             app._render()
             await pilot.pause()
             await pilot.pause()
+            observed["output_display"] = app.query_one("#output", RichLog).display
             observed["chat_display"] = app.query_one("#chat", RichLog).display
             observed["chat_display"] = app.query_one("#chat", RichLog).display
 
 
     asyncio.run(scenario())
     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")
     pytest.importorskip("textual")
     from textual.widgets import RichLog
     from textual.widgets import RichLog
 
 
@@ -1503,17 +1708,20 @@ def test_executing_mode_shows_chat(server):
             await pilot.pause()
             await pilot.pause()
             app._timer.stop()  # no background drain: server status must not clobber state
             app._timer.stop()  # no background drain: server status must not clobber state
             app._drain()
             app._drain()
+            app._lower_view = "output"
             app.state.ingest({"type": "mode", "mode": "executing"})
             app.state.ingest({"type": "mode", "mode": "executing"})
             app._render()
             app._render()
             await pilot.pause()
             await pilot.pause()
+            observed["output_display"] = app.query_one("#output", RichLog).display
             observed["chat_display"] = app.query_one("#chat", RichLog).display
             observed["chat_display"] = app.query_one("#chat", RichLog).display
 
 
     asyncio.run(scenario())
     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")
     pytest.importorskip("textual")
     from textual.containers import Horizontal
     from textual.containers import Horizontal
     from textual.widgets import RichLog
     from textual.widgets import RichLog
@@ -1529,6 +1737,7 @@ def test_cleared_hides_chat_and_restores_full_width(server):
             await pilot.pause()
             await pilot.pause()
             app._timer.stop()  # no background drain: server status must not clobber state
             app._timer.stop()  # no background drain: server status must not clobber state
             app._drain()
             app._drain()
+            app._lower_view = "output"
             app.state.ingest(
             app.state.ingest(
                 {
                 {
                     "type": "tool_result",
                     "type": "tool_result",
@@ -1539,11 +1748,16 @@ def test_cleared_hides_chat_and_restores_full_width(server):
             )
             )
             app._render()
             app._render()
             await pilot.pause()
             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.state.ingest({"type": "cleared"})
             app._render()
             app._render()
             await pilot.pause()
             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["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["main_region"] = app.query_one("#main", Horizontal).region
             observed["triage_region"] = app.query_one("#triage", RichLog).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"
         "cleared: main=%s triage=%s"
         % (observed["main_region"], observed["triage_region"])
         % (observed["main_region"], observed["triage_region"])
     )
     )
+    assert observed["empty_output"] == []
+    assert observed["lower_view"] == "hidden"
     assert observed["chat_display"] is False
     assert observed["chat_display"] is False
+    assert observed["output_display"] is False
     assert (
     assert (
         abs(observed["triage_region"].width - observed["main_region"].width) <= 2
         abs(observed["triage_region"].width - observed["main_region"].width) <= 2
     ), (observed["triage_region"], observed["main_region"])
     ), (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")
     pytest.importorskip("textual")
     from textual.widgets import RichLog
     from textual.widgets import RichLog
 
 
@@ -1569,6 +1786,13 @@ def test_triage_disabled_chat_follows_content_rule(server):
     app = AssistantTUI(path)
     app = AssistantTUI(path)
     observed: dict = {}
     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 def scenario():
         async with app.run_test(size=(80, 40)) as pilot:
         async with app.run_test(size=(80, 40)) as pilot:
             await pilot.pause()
             await pilot.pause()
@@ -1576,33 +1800,120 @@ def test_triage_disabled_chat_follows_content_rule(server):
             app._drain()
             app._drain()
             app._render()
             app._render()
             await pilot.pause()
             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()
             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()
             app._render()
             await pilot.pause()
             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())
     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")
     pytest.importorskip("textual")
     from textual.widgets import RichLog
     from textual.widgets import RichLog
 
 
@@ -1617,6 +1928,7 @@ def test_maximize_restore_with_chat_content_keeps_chat_visible(server):
             await pilot.pause()
             await pilot.pause()
             app._timer.stop()  # no background drain: server status must not clobber state
             app._timer.stop()  # no background drain: server status must not clobber state
             app._drain()
             app._drain()
+            app._lower_view = "output"
             app.state.ingest(
             app.state.ingest(
                 {
                 {
                     "type": "tool_result",
                     "type": "tool_result",
@@ -1627,7 +1939,7 @@ def test_maximize_restore_with_chat_content_keeps_chat_visible(server):
             )
             )
             app._render()
             app._render()
             await pilot.pause()
             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()
             app.query_one("#triage", RichLog).focus()
             await pilot.pause()
             await pilot.pause()
             await pilot.press("m")
             await pilot.press("m")
@@ -1637,12 +1949,12 @@ def test_maximize_restore_with_chat_content_keeps_chat_visible(server):
             await pilot.pause()
             await pilot.pause()
             observed["maximized"] = app.screen.maximized
             observed["maximized"] = app.screen.maximized
             observed["triage_display"] = app.query_one("#triage", RichLog).display
             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())
     asyncio.run(scenario())
     assert observed["maximized"] is None
     assert observed["maximized"] is None
     assert observed["triage_display"] is True
     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):
 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())
     asyncio.run(scenario())
     assert observed["has_triage"] is False
     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):
 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"]
     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():
 def test_lower_log_is_bounded():
     from stt_daemon.tui.state import MAX_LOWER
     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"
     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 = ViewState()
     state.ingest({"type": "tool_call", "tool": "get_datetime", "args": {}})
     state.ingest({"type": "tool_call", "tool": "get_datetime", "args": {}})
     state.ingest({"type": "mode", "mode": "chat"})
     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
     epoch = state.chat_epoch
     state.ingest({"type": "mode", "mode": "idle"})
     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 = ViewState()
     state.ingest({"type": "mode", "mode": "chat"})
     state.ingest({"type": "mode", "mode": "chat"})
     state.ingest({"type": "chat_message", "role": "user", "text": "x"})
     state.ingest({"type": "chat_message", "role": "user", "text": "x"})
 
 
     state.ingest({"type": "heartbeat", "mode": "idle", "stt_ok": True})
     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 = ViewState()
     state.ingest({"type": "mode", "mode": "chat"})
     state.ingest({"type": "mode", "mode": "chat"})
     state.ingest(
     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()
     assert "note: Compacted 4 earlier turns into a summary." in state.lower_lines()
 
 
     state.ingest({"type": "mode", "mode": "idle"})
     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() == []
     assert state.lower_lines() == []