Переглянути джерело

Added optional transcriptions for chat

Efren Yevale Varela 1 місяць тому
батько
коміт
b4b1021aef

+ 13 - 4
docs/ASSISTANT.md

@@ -51,10 +51,15 @@ Set `[llm].enabled = false` to run without an LLM (commands then report
 | `idle` | listening; nothing is sent to the model |
 | `command_armed` | the next complete turn becomes a command; the text accumulates live |
 | `executing` | a command is running through the tool loop |
-| `chat` | every completed turn is sent to the model; no tools |
+| `chat` | every completed spoken turn is sent to the model; no tools |
 
 Command and chat modes are mutually exclusive.
 
+While in chat mode, spoken transcripts are captured automatically. The TUI's
+`send transcripts` checkbox (or `{"type":"chat_capture","on":false}`) disables that
+automatic capture without leaving chat: typed `chat-send` turns still reach the model,
+and triage/brief keep consuming transcripts independently.
+
 ## The two-press command flow
 
 1. Arm (`stt-ctl command-start`, or `a` in the TUI).
@@ -84,8 +89,12 @@ Speaker control adds the inbound types `{"type":"speaker_list"}` and
 and `{"type":"error","code":"bad_message"}` for an empty name.
 
 Assistant → clients: `mode`, `command_text`, `transcript`, `speech_start`/`speech_stop`,
-`tool_call`, `tool_result`, `chat_message`, `speakers`, `cleared`, `heartbeat`, `error`, plus the
-direct replies `ack` (command accepted), `status`, and `speaker_list`.
+`tool_call`, `tool_result`, `chat_message`, `chat_capture`, `speakers`, `cleared`, `heartbeat`,
+`error`, plus the direct replies `ack` (command accepted), `status`, and `speaker_list`.
+
+`chat_capture` (inbound set, outbound broadcast) carries `{"on": bool}` and gates whether spoken
+transcripts are auto-fed into the chat session. `status` echoes the current value as
+`chat_capture`.
 
 The `speakers` event carries `{"speakers":[...]}`. The daemon fires it on **mint / confirm /
 rename / prune only** (not on every adapt); the assistant caches that roster to answer
@@ -214,7 +223,7 @@ daemon, remove the id from `speakers/speakers.json` and `prototypes.npz`, then r
 
 `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.
 
-The row below the transcript holds two inputs, split 50/50: the command buffer on the left and a **chat** box on the right. Typing a message into the chat box and pressing Enter turns chat mode on (if it was off) and shows the conversation pane, sends the message immediately (no turn-end wait), clears the box, and keeps it focused for the next message. Chat mode does not need to be entered first. Maximizing the conversation pane (`m`) hides the command buffer and keeps the chat box docked at the bottom of the screen; maximizing the command-output pane keeps the command buffer docked there instead. While maximized, `Tab` / `Shift+Tab` cycle focus between the pane and its docked input.
+The row below the transcript holds two inputs, split 50/50: the command buffer on the left and a **chat** box on the right. Typing a message into the chat box and pressing Enter turns chat mode on (if it was off) and shows the conversation pane, sends the message immediately (no turn-end wait), clears the box, and keeps it focused for the next message. Chat mode does not need to be entered first. To the right of the conversation pane sits a **send transcripts** checkbox: turning it off stops spoken transcripts from being fed to the model while staying in chat (typed messages still send, and triage/brief are unaffected); it is clickable, or focusable with `Tab` outside the maximized view and toggled with Space. Maximizing the conversation pane (`m`) hides the command buffer, keeps the chat box docked at the bottom of the screen, and docks the checkbox beneath the pane; maximizing the command-output pane keeps the command buffer docked there instead. While maximized, `Tab` / `Shift+Tab` cycle focus between the pane and its docked input.
 
 `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.
 

+ 3 - 0
src/stt_daemon/assistant/control.py

@@ -261,6 +261,9 @@ class ControlServer:
         elif command == "chat_send":
             ok = service.submit_chat_text(str(message.get("text", "")))
             self._ack_or_error(client, command, ok, "cannot send chat now")
+        elif command == "chat_capture":
+            ok = service.set_chat_capture(bool(message.get("on", True)))
+            self._ack_or_error(client, command, ok, "cannot set chat capture")
         elif command == "clear":
             self._ack_or_error(client, command, service.clear(), "nothing to clear")
         elif command == "speaker_list":

+ 14 - 1
src/stt_daemon/assistant/service.py

@@ -30,6 +30,7 @@ ERROR_EVENT = "error"
 TOOL_CALL_EVENT = "tool_call"
 TOOL_RESULT_EVENT = "tool_result"
 CHAT_MESSAGE_EVENT = "chat_message"
+CHAT_CAPTURE_EVENT = "chat_capture"
 CLEARED_EVENT = "cleared"
 TRIAGE_EVENT = "triage"
 
@@ -122,6 +123,7 @@ class AssistantService:
         self.chat_history: list[str] = []
         self._pending_chat: list[str] = []
         self.triage_history: list[str] = []
+        self.chat_capture = True
 
         # Speaker roster cache (display-name join only; the daemon owns identity).
         self._speakers: dict[str, dict] = {}
@@ -326,6 +328,7 @@ class AssistantService:
             "awaited_seg_id": self.awaited_seg_id,
             "session_dir": self._session_dir,
             "chat_turns": len(self.chat_history),
+            "chat_capture": self.chat_capture,
             "inference_depth": self._inference.depth(),
             "inference_in_flight": self._inference.in_flight(),
             "triage_state": triage_state,
@@ -485,6 +488,16 @@ class AssistantService:
         self._emit_mode()
         return True
 
+    def set_chat_capture(self, on: bool) -> bool:
+        """Gate automatic capture of spoken transcripts into the chat session."""
+        on = bool(on)
+        with self._lock:
+            if on == self.chat_capture:
+                return True
+            self.chat_capture = on
+        self._emit(CHAT_CAPTURE_EVENT, on=on)
+        return True
+
     def submit_chat_text(self, text: str) -> bool:
         """Send one typed chat turn immediately, bypassing the turn-end gate."""
         text = (text or "").strip()
@@ -837,7 +850,7 @@ class AssistantService:
             if self.arm_ts is None or ts >= self.arm_ts:
                 with self._lock:
                     self.command_text = f"{self.command_text} {text}".strip()
-        elif self.modes.mode is Mode.CHAT:
+        elif self.modes.mode is Mode.CHAT and self.chat_capture:
             with self._lock:
                 labeled = f"{self._speaker_display(event)}: {text}"
                 self.chat_history.append(labeled)

+ 49 - 9
src/stt_daemon/tui/app.py

@@ -9,8 +9,8 @@ from datetime import datetime
 
 from textual.app import App, ComposeResult
 from textual.binding import Binding
-from textual.containers import Horizontal
-from textual.widgets import Footer, Input, RichLog, Static
+from textual.containers import Horizontal, Vertical
+from textual.widgets import Checkbox, Footer, Input, RichLog, Static
 
 from ..assistant.control import default_control_path
 from ..config import load_config
@@ -24,6 +24,11 @@ class MainPane(Horizontal):
     ALLOW_MAXIMIZE = True
 
 
+class ChatPaneRow(Horizontal):
+    """The chat pane plus its capture controls; fills the screen when maximized."""
+    ALLOW_MAXIMIZE = True
+
+
 class AssistantTUI(App):
     """Live view of transcriptions, commands, and chat."""
 
@@ -36,15 +41,19 @@ class AssistantTUI(App):
     #inputs { height: 3; }
     #command { width: 1fr; border: solid $accent; }
     #chat-input { width: 1fr; border: solid $accent; }
-    #chat { height: 1fr; border: solid $accent; }
+    #chat-row { height: 1fr; }
+    #chat-row #chat { width: 1fr; height: 1fr; border: solid $accent; }
+    #chat-controls { width: 26; height: 1fr; padding: 1 1; }
+    #chat-capture { border: none; }
     #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 #chat-row { width: 2fr; height: 100%; }
     #main #output { width: 2fr; height: 100%; }
     #transcript.-maximized { height: 1fr; }
     #transcript.-grow { height: 1fr; }
     Screen.-maximized-view #inputs { dock: bottom; }
+    Screen.-maximized-view #chat-controls { dock: bottom; width: 100%; height: 3; }
     """
 
     BINDINGS = [
@@ -79,6 +88,7 @@ class AssistantTUI(App):
         self._show_questions = False
         self._lower_view = "hidden"
         self._maximized_input = None
+        self._syncing_capture = False
         self._seen_view = "triage"
         self._timer = None
         self._last_seg_title = ""
@@ -95,11 +105,17 @@ class AssistantTUI(App):
         if self.triage_enabled:
             with MainPane(id="main"):
                 yield RichLog(id="triage", wrap=True, markup=False)
+                with ChatPaneRow(id="chat-row"):
+                    yield RichLog(id="chat", wrap=True, markup=False)
+                    with Vertical(id="chat-controls"):
+                        yield Checkbox("send transcripts", value=True, id="chat-capture")
                 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)
+            with ChatPaneRow(id="chat-row"):
+                yield RichLog(id="chat", wrap=True, markup=False)
+                with Vertical(id="chat-controls"):
+                    yield Checkbox("send transcripts", value=True, id="chat-capture")
         yield Footer()
 
     def _set_titles(self) -> None:
@@ -245,6 +261,12 @@ class AssistantTUI(App):
                 for item in renderables:
                     triage_log.write(item)
                 self._seen_view = view
+        if self.query("#chat-capture"):
+            capture = self.query_one("#chat-capture", Checkbox)
+            if capture.value != state.chat_capture:
+                self._syncing_capture = True
+                capture.value = state.chat_capture
+                self._syncing_capture = False
         self._update_main_visibility()
 
     def _send(self, type_: str, **fields) -> None:
@@ -308,6 +330,8 @@ class AssistantTUI(App):
         show_output = view == "output"
         if self.triage_enabled and self.query("#triage"):
             self.query_one("#triage").display = True
+        if self.query("#chat-row"):
+            self.query_one("#chat-row").display = show_chat
         if self.query("#chat"):
             self.query_one("#chat").display = show_chat
         if self.query("#output"):
@@ -323,9 +347,14 @@ class AssistantTUI(App):
         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", "output"):
-                    if self.query(f"#{node.id}"):
-                        return self.query_one(f"#{node.id}")
+                node_id = getattr(node, "id", None)
+                if node_id in ("transcript", "triage", "chat", "output"):
+                    if self.query(f"#{node_id}"):
+                        return self.query_one(f"#{node_id}")
+                    return node
+                if node_id == "chat-controls":
+                    if self.query("#chat"):
+                        return self.query_one("#chat")
                     return node
         fallbacks = ("triage",) if self.triage_enabled else ("chat", "output")
         for fallback_id in fallbacks:
@@ -355,6 +384,10 @@ class AssistantTUI(App):
                 if self.query(f"#{pane_id}") and self.query_one(f"#{pane_id}").display:
                     return self.query_one(f"#{pane_id}")
             return None
+        if maximized.id == "chat-row":
+            if self.query("#chat") and self.query_one("#chat").display:
+                return self.query_one("#chat")
+            return None
         return maximized
 
     def action_focus_next(self) -> None:
@@ -401,6 +434,8 @@ class AssistantTUI(App):
             for other_id in ("triage", "chat", "output"):
                 if other_id == pane.id:
                     continue
+                if other_id == "chat" and self.query("#chat-row"):
+                    self.query_one("#chat-row").display = False
                 if self.query(f"#{other_id}"):
                     self.query_one(f"#{other_id}").display = False
             screen.maximize(self.query_one("#main"))
@@ -479,6 +514,11 @@ class AssistantTUI(App):
         self.query_one("#chat-input", Input).value = ""
         self.query_one("#chat-input", Input).focus()
 
+    def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
+        if event.checkbox.id != "chat-capture" or self._syncing_capture:
+            return
+        self._send("chat_capture", on=bool(event.value))
+
     def on_unmount(self) -> None:
         if self._timer is not None:
             self._timer.stop()

+ 5 - 0
src/stt_daemon/tui/state.py

@@ -90,6 +90,7 @@ class ViewState:
     command_text: str = ""
     command_edited: bool = False
     in_flight: bool = False
+    chat_capture: bool = True
     transcripts: list[dict] = field(default_factory=list)
     transcripts_total: int = 0
     chat: list[dict] = field(default_factory=list)
@@ -120,6 +121,8 @@ class ViewState:
             self.phase = event.get("phase", self.phase)
             self.command_text = event.get("command_text", self.command_text)
             self.stt_ok = bool(event.get("stt_ok", self.stt_ok))
+            if "chat_capture" in event:
+                self.chat_capture = bool(event["chat_capture"])
             if "speech_open" in event:
                 self.speech_open = bool(event["speech_open"])
             changed = False
@@ -182,6 +185,8 @@ class ViewState:
             self.transcripts.append(event)
             del self.transcripts[:-MAX_TRANSCRIPTS]
             self.transcripts_total += 1
+        elif etype == "chat_capture":
+            self.chat_capture = bool(event.get("on", self.chat_capture))
         elif etype == "chat_message":
             self.chat.append(event)
             del self.chat[:-MAX_CHAT]

+ 40 - 0
tests/test_assistant_service.py

@@ -289,6 +289,46 @@ def test_chat_accumulates_turns():
     assert service.chat_history == ["Unknown: hello there", "Unknown: how are you"]
 
 
+def test_chat_capture_off_skips_spoken_transcripts_and_exit_phrase():
+    service, _, clock = _service()
+    service.toggle_chat()
+    assert service.set_chat_capture(False) is True
+
+    service.handle_event(_transcript(clock, "hello"))
+    service.handle_event(_transcript(clock, "That's all."))
+
+    assert service.chat_history == []
+    assert service._pending_chat == []
+    assert service.modes.mode.value == "chat"
+
+
+def test_chat_capture_off_still_allows_typed_turns_and_triage():
+    service, _, clock = _service()
+    service.toggle_chat()
+    service.set_chat_capture(False)
+
+    service.handle_event(_transcript(clock, "spoken"))
+
+    assert service.submit_chat_text("typed") is True
+    assert service.chat_history == ["typed"]
+    assert service.triage_history == ["spoken"]
+
+
+def test_set_chat_capture_emits_once_and_reports_status():
+    service, emitted, _ = _service()
+    assert service.status()["chat_capture"] is True
+
+    assert service.set_chat_capture(False) is True
+    assert [e for e in emitted if e["type"] == "chat_capture"] == [
+        {"type": "chat_capture", "on": False}
+    ]
+    assert service.status()["chat_capture"] is False
+
+    emitted.clear()
+    service.set_chat_capture(False)
+    assert emitted == []
+
+
 def test_submit_chat_text_requires_chat_and_dispatches_immediately():
     cfg = Config()
     cfg.assistant.turn_gap_s = 999.0

+ 27 - 1
tests/test_control.py

@@ -263,6 +263,23 @@ def test_chat_send_refused_outside_chat(harness):
     client.close()
 
 
+def test_chat_capture_command_sets_and_broadcasts(harness):
+    client = Client(harness.path)
+    client.send(type="subscribe", viewer=True)
+    status = client.wait_for(lambda m: m["type"] == "status")
+    assert status is not None
+    assert status["chat_capture"] is True
+
+    client.send(type="chat_capture", on=False)
+    assert client.wait_for(lambda m: m["type"] == "ack" and m["command"] == "chat_capture")
+    assert harness.service.chat_capture is False
+    assert client.wait_for(
+        lambda m: m["type"] == "chat_capture" and m["on"] is False
+    ) is not None
+
+    client.close()
+
+
 def test_last_viewer_disconnect_ends_chat(harness):
     client = Client(harness.path)
     client.send(type="subscribe", viewer=True)
@@ -305,7 +322,16 @@ def test_status_reply_contains_fields(harness):
 
     status = client.wait_for(lambda m: m["type"] == "status")
     assert status is not None
-    for field in ("mode", "ready", "stt_ok", "phase", "command_text", "speech_open", "chat_turns"):
+    for field in (
+        "mode",
+        "ready",
+        "stt_ok",
+        "phase",
+        "command_text",
+        "speech_open",
+        "chat_turns",
+        "chat_capture",
+    ):
         assert field in status
 
     client.close()

+ 115 - 11
tests/test_tui_app.py

@@ -977,12 +977,14 @@ def test_triage_enabled_composes_split(server):
             transcript = app.query_one("#transcript", RichLog)
             triage = app.query_one("#triage", RichLog)
             chat = app.query_one("#chat", RichLog)
+            row = app.query_one("#chat-row", Horizontal)
             assert triage.parent is main
-            assert chat.parent is main
+            assert row.parent is main
+            assert chat.parent is row
             assert transcript.parent is not main
             assert triage.border_title == "triage  \\[inf 0]"  # markup: '[' is escaped
-            # triage sits LEFT of chat inside #main.
-            assert main.children.index(triage) < main.children.index(chat)
+            # triage sits LEFT of the chat row inside #main.
+            assert main.children.index(triage) < main.children.index(row)
             # Outside widgets stay outside #main.
             assert app.query_one("#status").parent is not main
             assert transcript.parent is not main
@@ -1016,11 +1018,11 @@ def test_triage_enabled_layout_proportions(server):
             main = app.query_one("#main", Horizontal)
             transcript = app.query_one("#transcript", RichLog)
             triage = app.query_one("#triage", RichLog)
-            chat = app.query_one("#chat", RichLog)
+            chat_row = app.query_one("#chat-row", Horizontal)
             return (
                 main.size.width,
                 triage.size.width,
-                chat.size.width,
+                chat_row.size.width,
                 app.screen.size.height,
                 transcript.region.height,
             )
@@ -1030,8 +1032,9 @@ def test_triage_enabled_layout_proportions(server):
         f"measured: main_w={main_w} triage_w={triage_w} chat_w={chat_w} "
         f"screen_h={screen_h} transcript_h={transcript_h}"
     )
-    # Content boxes exclude the 1-cell border on each side; allow ±2 px for
-    # borders/rounding on the fractional split.
+    # The chat row (chat plus its capture controls) keeps the 2/3 share; the
+    # controls column is carved out of it. Content boxes exclude the 1-cell
+    # border on each side; allow ±2 px for borders/rounding.
     assert abs(triage_w / main_w - 1 / 3) < 0.05, (main_w, triage_w, chat_w)
     assert abs(chat_w / main_w - 2 / 3) < 0.05, (main_w, triage_w, chat_w)
     # region.height includes the transcript's 2 border rows; at the small
@@ -1353,7 +1356,7 @@ def test_escape_while_maximized_restores_panes(server):
 
 
 def test_maximize_chat_when_triage_disabled(server):
-    """triage_enabled=False: focus #chat, press m maximizes #main without crashing."""
+    """triage_enabled=False: focus #chat, press m maximizes the chat row (with controls)."""
     pytest.importorskip("textual")
     from textual.widgets import RichLog
 
@@ -1379,7 +1382,7 @@ def test_maximize_chat_when_triage_disabled(server):
             await pilot.press("m")
             await pilot.pause()
             observed["maximized"] = app.screen.maximized
-            observed["chat"] = app.query_one("#chat", RichLog)
+            observed["chat_row"] = app.query_one("#chat-row")
             observed["region"] = app.query_one("#chat", RichLog).region
             observed["screen_size"] = app.screen.size
             await pilot.press("m")
@@ -1389,7 +1392,7 @@ def test_maximize_chat_when_triage_disabled(server):
     asyncio.run(scenario())
     print("chat maximized (disabled): region=%s screen=%s"
           % (observed["region"], observed["screen_size"]))
-    assert observed["maximized"] is observed["chat"]
+    assert observed["maximized"] is observed["chat_row"]
     assert observed["restored"] is None
 
 
@@ -1908,7 +1911,7 @@ def test_chat_mode_shows_chat_and_restores_split(server):
             observed["chat_display"] = app.query_one("#chat", RichLog).display
             observed["main_region"] = app.query_one("#main", Horizontal).region
             observed["triage_region"] = app.query_one("#triage", RichLog).region
-            observed["chat_region"] = app.query_one("#chat", RichLog).region
+            observed["chat_region"] = app.query_one("#chat-row", Horizontal).region
 
     asyncio.run(scenario())
     print(
@@ -2557,3 +2560,104 @@ def test_status_event_questions_populate_questions_view(server):
     assert observed["questions"] == _questions_payload()
     assert observed["epoch"] == observed["before_epoch"] + 1
     assert any("topic  [choice]" in line for line in observed["lines"])
+
+
+def test_chat_capture_checkbox_toggles_and_sends_control(server):
+    """Toggling #chat-capture off sends a chat_capture control message."""
+    pytest.importorskip("textual")
+    from textual.widgets import Checkbox
+
+    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() as pilot:
+            await pilot.pause()
+            app._timer.stop()  # no background drain: server status must not clobber state
+            app._drain()
+            app.client.send = lambda type_, **fields: sent.append((type_, fields))
+            capture = app.query_one("#chat-capture", Checkbox)
+            observed["initial"] = capture.value
+            capture.value = False
+            await pilot.pause()
+            observed["after_off"] = capture.value
+
+    asyncio.run(scenario())
+    assert observed["initial"] is True
+    assert observed["after_off"] is False
+    assert ("chat_capture", {"on": False}) in sent
+
+
+def test_chat_capture_checkbox_follows_incoming_event(server):
+    """A chat_capture event updates the checkbox without bouncing a send back."""
+    pytest.importorskip("textual")
+    from textual.widgets import Checkbox
+
+    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._timer.stop()  # no background drain: the raw event is the only source
+            app._drain()
+            app.state.ingest({"type": "chat_capture", "on": False})
+            app._render()
+            await pilot.pause()
+            observed["value"] = app.query_one("#chat-capture", Checkbox).value
+
+    asyncio.run(scenario())
+    assert observed["value"] is False
+
+
+def test_maximize_chat_docks_capture_controls_below(server):
+    """Maximizing chat keeps the capture controls visible, docked under the pane."""
+    pytest.importorskip("textual")
+    from textual.containers import Horizontal, Vertical
+    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()
+            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()
+            chat = app.query_one("#chat", RichLog)
+            controls = app.query_one("#chat-controls", Vertical)
+            observed["normal_right_of_chat"] = controls.region.x > chat.region.x
+            observed["normal_same_top"] = controls.region.y == chat.region.y
+            app.query_one("#chat", RichLog).focus()
+            await pilot.pause()
+            await pilot.press("m")
+            await pilot.pause()
+            row = app.query_one("#chat-row", Horizontal)
+            observed["maximized_is_main"] = app.screen.maximized is app.query_one("#main")
+            observed["controls_display"] = controls.display
+            observed["controls_below_chat"] = controls.region.y > chat.region.y
+            observed["controls_span_row"] = controls.region.width == row.region.width
+
+    asyncio.run(scenario())
+    print("capture controls:", observed)
+    assert observed["normal_right_of_chat"] is True
+    assert observed["normal_same_top"] is True
+    assert observed["maximized_is_main"] is True
+    assert observed["controls_display"] is True
+    assert observed["controls_below_chat"] is True
+    assert observed["controls_span_row"] is True

+ 11 - 0
tests/test_tui_state.py

@@ -116,6 +116,17 @@ def test_status_populates_fields():
     assert state.command_text == "x"
 
 
+def test_chat_capture_defaults_on_and_follows_event_and_status():
+    state = ViewState()
+    assert state.chat_capture is True
+
+    state.ingest({"type": "chat_capture", "on": False})
+    assert state.chat_capture is False
+
+    state.ingest({"type": "status", "chat_capture": True})
+    assert state.chat_capture is True
+
+
 def test_transcripts_are_bounded():
     state = ViewState()
     for i in range(MAX_TRANSCRIPTS + 25):