Bläddra i källkod

Updated presentation

Efren Yevale Varela 1 månad sedan
förälder
incheckning
94a43a8764
3 ändrade filer med 614 tillägg och 33 borttagningar
  1. 99 11
      src/stt_daemon/tui/app.py
  2. 7 17
      tests/test_triage.py
  3. 508 5
      tests/test_tui_app.py

+ 99 - 11
src/stt_daemon/tui/app.py

@@ -1,9 +1,11 @@
 """Textual TUI: watch transcriptions and drive the assistant."""
 
 import argparse
+import os
 import queue
 import sys
 import time
+from datetime import datetime
 
 from textual.app import App, ComposeResult
 from textual.binding import Binding
@@ -17,28 +19,40 @@ from .dialogs import ConfirmClear
 from .state import ViewState
 
 
+class MainPane(Horizontal):
+    """The triage/chat row; allowed to fill the screen when maximized."""
+    ALLOW_MAXIMIZE = True
+
+
 class AssistantTUI(App):
     """Live view of transcriptions, commands, and chat."""
 
+    ALLOW_IN_MAXIMIZED_VIEW = ".__never__"
+
     CSS = """
     #status { height: 1; }
-    #transcript { height: 1fr; border: solid $accent; }
+    #transcript { height: 25%; border: solid $accent; }
     #command { height: 3; border: solid $accent; }
     #chat { height: 1fr; border: solid $accent; }
     #edit { display: none; }
     #main { height: 1fr; }
-    #main #transcript { width: 2fr; }
-    #triage { width: 1fr; height: 100%; border: solid $accent; }
+    #main #triage { width: 1fr; height: 100%; border: solid $accent; }
+    #main #chat { width: 2fr; height: 100%; }
+    #transcript.-maximized { height: 1fr; }
     """
 
     BINDINGS = [
         Binding("a", "arm", "arm command"),
-        Binding("enter", "execute", "execute"),
+        Binding("ctrl+r", "execute", "execute"),
         Binding("escape", "cancel", "cancel"),
-        Binding("e", "edit", "edit"),
+        Binding("e", "edit", "edit command"),
         Binding("t", "chat", "chat toggle"),
-        Binding("c", "clear", "clear"),
+        Binding("question_mark", "toggle_help_panel", "help"),
         Binding("q", "quit", "quit"),
+        Binding("m", "toggle_maximize", "maximize pane", show=False),
+        Binding("c", "clear", "clear", show=False),
+        Binding("ctrl+s", "screenshot", "save screenshot", show=False),
+        Binding("ctrl+p", "command_palette", "instructions", priority=True, show=False),
     ]
 
     def __init__(self, path: str, triage_enabled: bool = False):
@@ -59,14 +73,14 @@ class AssistantTUI(App):
 
     def compose(self) -> ComposeResult:
         yield Static(id="status")
+        yield RichLog(id="transcript", wrap=True, markup=False)
+        yield Static(id="command")
         if self.triage_enabled:
-            with Horizontal(id="main"):
-                yield RichLog(id="transcript", wrap=True, markup=False)
+            with MainPane(id="main"):
                 yield RichLog(id="triage", wrap=True, markup=False)
+                yield RichLog(id="chat", wrap=True, markup=False)
         else:
-            yield RichLog(id="transcript", wrap=True, markup=False)
-        yield Static(id="command")
-        yield RichLog(id="chat", wrap=True, markup=False)
+            yield RichLog(id="chat", wrap=True, markup=False)
         yield Input(placeholder="edit the command, then press Enter", id="edit")
         yield Footer()
 
@@ -112,6 +126,8 @@ class AssistantTUI(App):
             except queue.Empty:
                 break
             self.state.ingest(event)
+        if self.screen.maximized is None:
+            self._restore_panes()
         self._render()
 
     def _render(self) -> None:
@@ -191,6 +207,78 @@ class AssistantTUI(App):
         edit.display = True
         edit.focus()
 
+    def _active_pane(self):
+        focused = self.screen.focused
+        if focused is not None:
+            for node in focused.ancestors_with_self:
+                if getattr(node, "id", None) in ("transcript", "triage", "chat"):
+                    if self.query(f"#{node.id}"):
+                        return self.query_one(f"#{node.id}")
+                    return node
+        fallback_id = "triage" if self.triage_enabled else "chat"
+        if self.query(f"#{fallback_id}"):
+            return self.query_one(f"#{fallback_id}")
+        return None
+
+    def _restore_panes(self) -> None:
+        if self.query("#triage"):
+            self.query_one("#triage").display = True
+        if self.query("#chat"):
+            self.query_one("#chat").display = True
+
+    def action_toggle_maximize(self) -> None:
+        screen = self.screen
+        if screen.maximized is not None:
+            screen.minimize()
+            self._restore_panes()
+            return
+        pane = self._active_pane()
+        if pane is None:
+            return
+        if pane.id in ("triage", "chat") and self.query("#main"):
+            other_id = "#chat" if pane.id == "triage" else "#triage"
+            other = self.query_one(other_id) if self.triage_enabled else None
+            if other is not None:
+                other.display = False
+            screen.maximize(self.query_one("#main"))
+        else:
+            screen.maximize(pane)
+
+    def action_toggle_help_panel(self) -> None:
+        from textual.css.query import NoMatches
+        from textual.widgets import HelpPanel
+        try:
+            self.screen.query_one(HelpPanel)
+        except NoMatches:
+            self.action_show_help_panel()
+        else:
+            self.action_hide_help_panel()
+
+    def action_screenshot(self) -> None:
+        name = "stt-tui-%s.svg" % datetime.now().strftime("%Y%m%d-%H%M%S")
+        try:
+            save = getattr(self, "save_screenshot", None)
+            if callable(save):
+                path = save(name)
+            else:
+                svg = self.export_screenshot()
+                path = os.path.join(os.getcwd(), name)
+                with open(path, "w", encoding="utf-8") as f:
+                    f.write(svg)
+        except Exception as e:
+            try:
+                self.notify("screenshot failed: %s" % e)
+            except Exception:
+                pass
+            return
+        try:
+            self.notify("saved " + str(path))
+        except Exception:
+            try:
+                self.query_one("#status", Static).update("saved " + str(path))
+            except Exception:
+                pass
+
     def on_input_submitted(self, event: Input.Submitted) -> None:
         self._send("command_edit", text=event.value)
         edit = self.query_one("#edit", Input)

+ 7 - 17
tests/test_triage.py

@@ -16,18 +16,6 @@ from stt_daemon.tui.state import ViewState
 
 EXAMPLE_PATH = str(Path(__file__).resolve().parent.parent / "questions.example.json")
 
-EXPECTED_ORDER = [
-    "category",
-    "urgency",
-    "frustration",
-    "churn_risk",
-    "needs_action",
-    "needs_clarification",
-    "contains_idea",
-    "business_opportunity",
-    "positive_sentiment",
-]
-
 
 def _cfg(path=EXAMPLE_PATH, **overrides):
     base = {
@@ -57,13 +45,15 @@ def _analyzer(tmp_path, questions=None, **overrides):
 
 
 def test_load_example_order_preserved():
+    raw = json.loads(Path(EXAMPLE_PATH).read_text(encoding="utf-8"))
     questions = load_questions(EXAMPLE_PATH)
     assert questions is not None
-    assert list(questions.keys()) == EXPECTED_ORDER
-    assert questions["category"]["type"] == "choice"
-    assert questions["urgency"]["type"] == "choice"
-    for qid in EXPECTED_ORDER[2:]:
-        assert questions[qid]["type"] == "noul"
+    assert questions
+    assert list(questions.keys()) == list(raw.keys())
+    for definition in questions.values():
+        assert definition["type"] in {"choice", "score", "noul"}
+        assert isinstance(definition["instructions"], str)
+        assert definition["instructions"].strip()
 
 
 def test_load_missing_file_returns_none_with_warning(caplog):

+ 508 - 5
tests/test_tui_app.py

@@ -40,30 +40,41 @@ def server():
     shutil.rmtree(directory, ignore_errors=True)
 
 
-def test_enter_sends_execute_and_e_opens_editor(server):
+def test_ctrl_r_sends_execute_and_e_opens_editor(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] = []
+    observed: dict = {}
 
     async def scenario():
         async with app.run_test() as pilot:
             await pilot.pause()
             app.client.send = lambda type_, **fields: sent.append((type_, fields))
 
-            await pilot.press("enter")
+            await pilot.press("ctrl+r")
             await pilot.pause()
             await pilot.press("a")
             await pilot.pause()
             await pilot.press("escape")
             await pilot.pause()
 
+            await pilot.press("e")
+            await pilot.pause()
+            edit = app.query_one("#edit", Input)
+            observed["edit_display"] = edit.display
+            observed["edit_focused"] = edit.has_focus
+
     asyncio.run(scenario())
     assert ("command_execute", {}) in sent
     assert ("command_start", {}) in sent
     assert ("command_cancel", {}) in sent
+    assert observed["edit_display"] is True
+    assert observed["edit_focused"] is True
 
 
 def test_c_key_confirms_before_clearing(server):
@@ -374,7 +385,7 @@ def test_triage_disabled_composes_no_triage_widget(server):
 
 
 def test_triage_enabled_composes_split(server):
-    """triage_enabled=True: #main wraps #transcript + #triage."""
+    """triage_enabled=True: #main wraps #triage (left) + #chat (right); transcript stays outside."""
     pytest.importorskip("textual")
     from textual.containers import Horizontal
     from textual.widgets import RichLog
@@ -390,16 +401,75 @@ def test_triage_enabled_composes_split(server):
             main = app.query_one("#main", Horizontal)
             transcript = app.query_one("#transcript", RichLog)
             triage = app.query_one("#triage", RichLog)
-            assert transcript.parent is main
+            chat = app.query_one("#chat", RichLog)
             assert triage.parent is main
+            assert chat.parent is main
+            assert transcript.parent is not main
             assert triage.border_title == "triage"
+            # triage sits LEFT of chat inside #main.
+            assert main.children.index(triage) < main.children.index(chat)
             # Outside widgets stay outside #main.
             assert app.query_one("#status").parent is not main
-            assert app.query_one("#chat").parent is not main
+            assert transcript.parent is not main
 
     asyncio.run(scenario())
 
 
+def test_triage_enabled_layout_proportions(server):
+    """Rendered proportions: #triage ~1/3 and #chat ~2/3 of #main; #transcript ~25% of screen."""
+    pytest.importorskip("textual")
+    from textual.containers import Horizontal
+    from textual.widgets import RichLog
+
+    from stt_daemon.tui.app import AssistantTUI
+
+    path, _service = server
+    app = AssistantTUI(path, triage_enabled=True)
+
+    async def scenario(size=None):
+        kwargs = {"size": size} if size else {}
+        async with app.run_test(**kwargs) as pilot:
+            await pilot.pause()
+            main = app.query_one("#main", Horizontal)
+            transcript = app.query_one("#transcript", RichLog)
+            triage = app.query_one("#triage", RichLog)
+            chat = app.query_one("#chat", RichLog)
+            return (
+                main.size.width,
+                triage.size.width,
+                chat.size.width,
+                app.screen.size.height,
+                transcript.region.height,
+            )
+
+    main_w, triage_w, chat_w, screen_h, transcript_h = asyncio.run(scenario())
+    print(
+        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.
+    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
+    # default size rounding is coarse, so only demand a floor here. The
+    # precise 25% check runs at a taller size below.
+    assert transcript_h / screen_h >= 0.18, (screen_h, transcript_h)
+    assert transcript_h >= 1, transcript_h
+
+    app2 = AssistantTUI(path, triage_enabled=True)
+
+    async def scenario_tall():
+        async with app2.run_test(size=(80, 40)) as pilot:
+            await pilot.pause()
+            transcript2 = app2.query_one("#transcript", RichLog)
+            return app2.screen.size.height, transcript2.region.height
+
+    screen_h2, transcript_h2 = asyncio.run(scenario_tall())
+    print(f"measured tall: screen_h={screen_h2} transcript_h={transcript_h2}")
+    assert abs(transcript_h2 / screen_h2 - 0.25) < 0.06, (screen_h2, transcript_h2)
+
+
 def test_triage_enabled_renders_signals_on_epoch_change(server):
     """A triage event + status event must actually write rendered lines into #triage."""
     pytest.importorskip("textual")
@@ -554,3 +624,436 @@ def test_default_ctor_has_no_triage_arg(server):
     app = AssistantTUI(path)
     assert app.triage_enabled is False
     assert app._seen_triage_epoch == 0
+
+
+def test_maximize_triage_hides_chat_and_fills_screen(server):
+    """Focus #triage, press m: #main maximized, chat hidden, triage fills screen."""
+    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.query_one("#triage", RichLog).focus()
+            await pilot.pause()
+            await pilot.press("m")
+            await pilot.pause()
+            observed["maximized_is_main"] = app.screen.maximized is app.query_one("#main")
+            observed["triage_display"] = app.query_one("#triage", RichLog).display
+            observed["chat_display"] = app.query_one("#chat", RichLog).display
+            observed["region"] = app.query_one("#triage", RichLog).region
+            observed["screen_size"] = app.screen.size
+
+    asyncio.run(scenario())
+    print("triage maximized: region=%s screen=%s" % (observed["region"], observed["screen_size"]))
+    assert observed["maximized_is_main"] is True
+    assert observed["triage_display"] is True
+    assert observed["chat_display"] is False
+    region = observed["region"]
+    size = observed["screen_size"]
+    assert abs(region.width - size.width) <= 2, (region, size)
+    assert abs(region.height - size.height) <= 2, (region, size)
+
+
+def test_maximize_toggle_restores_layout(server):
+    """Press m again: minimize restores both panes."""
+    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.query_one("#triage", RichLog).focus()
+            await pilot.pause()
+            await pilot.press("m")
+            await pilot.pause()
+            await pilot.press("m")
+            await pilot.pause()
+            observed["maximized"] = app.screen.maximized
+            observed["triage_display"] = app.query_one("#triage", RichLog).display
+            observed["chat_display"] = app.query_one("#chat", RichLog).display
+
+    asyncio.run(scenario())
+    assert observed["maximized"] is None
+    assert observed["triage_display"] is True
+    assert observed["chat_display"] is True
+
+
+def test_maximize_transcript_fills_screen(server):
+    """Focus #transcript, press m: transcript maximized and fills the screen."""
+    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.query_one("#transcript", RichLog).focus()
+            await pilot.pause()
+            await pilot.press("m")
+            await pilot.pause()
+            observed["maximized_is_transcript"] = (
+                app.screen.maximized is app.query_one("#transcript")
+            )
+            observed["region"] = app.query_one("#transcript", RichLog).region
+            observed["screen_size"] = app.screen.size
+            await pilot.press("m")
+            await pilot.pause()
+            observed["restored"] = app.screen.maximized
+
+    asyncio.run(scenario())
+    print("transcript maximized: region=%s screen=%s"
+          % (observed["region"], observed["screen_size"]))
+    assert observed["maximized_is_transcript"] is True
+    region = observed["region"]
+    size = observed["screen_size"]
+    assert abs(region.width - size.width) <= 2, (region, size)
+    assert abs(region.height - size.height) <= 2, (region, size)
+    assert observed["restored"] is None
+
+
+def test_escape_while_maximized_restores_panes(server):
+    """Escape minimizes (ESCAPE_TO_MINIMIZE) and _drain restores the hidden sibling."""
+    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.query_one("#triage", RichLog).focus()
+            await pilot.pause()
+            await pilot.press("m")
+            await pilot.pause()
+            assert app.screen.maximized is not None
+            await pilot.press("escape")
+            await pilot.pause()
+            # Let the 0.1s _drain timer run so the restore path executes.
+            for _ in range(10):
+                await pilot.pause()
+                await asyncio.sleep(0.05)
+                if app.screen.maximized is None and app.query_one("#chat", RichLog).display:
+                    break
+            observed["maximized"] = app.screen.maximized
+            observed["triage_display"] = app.query_one("#triage", RichLog).display
+            observed["chat_display"] = app.query_one("#chat", RichLog).display
+
+    asyncio.run(scenario())
+    assert observed["maximized"] is None
+    assert observed["triage_display"] is True
+    assert observed["chat_display"] is True
+
+
+def test_maximize_chat_when_triage_disabled(server):
+    """triage_enabled=False: focus #chat, press m maximizes #main without crashing."""
+    pytest.importorskip("textual")
+    from textual.widgets import RichLog
+
+    from stt_daemon.tui.app import AssistantTUI
+
+    path, _service = server
+    app = AssistantTUI(path)
+    observed: dict = {}
+
+    async def scenario():
+        async with app.run_test(size=(80, 40)) as pilot:
+            await pilot.pause()
+            # Disabled: no #main/#triage; focusing #chat then m maximizes #chat itself.
+            app.query_one("#chat", RichLog).focus()
+            await pilot.pause()
+            await pilot.press("m")
+            await pilot.pause()
+            observed["maximized"] = app.screen.maximized
+            observed["chat"] = app.query_one("#chat", RichLog)
+            observed["region"] = app.query_one("#chat", RichLog).region
+            observed["screen_size"] = app.screen.size
+            await pilot.press("m")
+            await pilot.pause()
+            observed["restored"] = app.screen.maximized
+
+    asyncio.run(scenario())
+    print("chat maximized (disabled): region=%s screen=%s"
+          % (observed["region"], observed["screen_size"]))
+    assert observed["maximized"] is observed["chat"]
+    assert observed["restored"] is None
+
+
+def test_footer_hidden_while_maximized(server):
+    """ALLOW_IN_MAXIMIZED_VIEW hides the Footer while a pane is maximized."""
+    pytest.importorskip("textual")
+    from textual.widgets import Footer, 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.query_one("#triage", RichLog).focus()
+            await pilot.pause()
+            await pilot.press("m")
+            await pilot.pause()
+            observed["maximized"] = app.screen.maximized
+            observed["footer_region"] = app.query_one(Footer).region
+            await pilot.press("m")
+            await pilot.pause()
+
+    asyncio.run(scenario())
+    assert observed["maximized"] is not None
+    footer_region = observed["footer_region"]
+    assert footer_region.height == 0 or footer_region.width == 0, observed
+
+
+def test_question_mark_key_toggles_help_panel(server):
+    pytest.importorskip("textual")
+    from textual.widgets import HelpPanel
+
+    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()
+            assert list(app.screen.query(HelpPanel)) == []
+            await pilot.press("question_mark")
+            await pilot.pause()
+            observed["shown"] = list(app.screen.query(HelpPanel)) != []
+            app.screen.query_one(HelpPanel)  # must succeed while shown
+            await pilot.press("question_mark")
+            await pilot.pause()
+            observed["hidden"] = list(app.screen.query(HelpPanel)) == []
+
+    asyncio.run(scenario())
+    assert observed["shown"] is True, "? did not mount HelpPanel"
+    assert observed["hidden"] is True, "second ? did not remove HelpPanel"
+
+
+def test_app_bindings_list():
+    """BINDINGS: 7 visible footer entries; hidden ones after; no h binding."""
+    pytest.importorskip("textual")
+    from stt_daemon.tui.app import AssistantTUI
+
+    pairs = [(b.key, b.description) for b in AssistantTUI.BINDINGS]
+    assert ("ctrl+r", "execute") in pairs
+    assert ("m", "maximize pane") in pairs
+    assert ("question_mark", "help") in pairs
+    assert ("ctrl+s", "save screenshot") in pairs
+    assert ("a", "arm command") in pairs
+    assert ("escape", "cancel") in pairs
+    assert ("e", "edit command") in pairs
+    assert ("t", "chat toggle") in pairs
+    assert ("c", "clear") in pairs
+    assert ("q", "quit") in pairs
+    assert ("ctrl+p", "instructions") in pairs
+    assert not any(key == "h" for key, _ in pairs)
+    assert not any(key == "ctrl+m" for key, _ in pairs)
+    assert not any(key == "ctrl+h" for key, _ in pairs)
+    assert not any(key == "enter" for key, _ in pairs)
+    assert not any(key == "ctrl+k" for key, _ in pairs)
+    by_key = {b.key: b for b in AssistantTUI.BINDINGS}
+    assert by_key["question_mark"].show is True
+    assert by_key["m"].show is False
+    assert by_key["c"].show is False
+    assert by_key["ctrl+s"].show is False
+    assert by_key["ctrl+p"].show is False
+    # Visible seven first, in footer order.
+    visible = [(b.key, b.description) for b in AssistantTUI.BINDINGS if b.show]
+    assert visible == [
+        ("a", "arm command"),
+        ("ctrl+r", "execute"),
+        ("escape", "cancel"),
+        ("e", "edit command"),
+        ("t", "chat toggle"),
+        ("question_mark", "help"),
+        ("q", "quit"),
+    ]
+    assert by_key["ctrl+p"].priority is True
+
+
+def test_footer_shows_ordered_entries(server):
+    """Left list is the 7 visible bindings (? for help); instructions only right-aligned."""
+    pytest.importorskip("textual")
+    from textual.widgets import Footer
+    from textual.widgets._footer import FooterKey
+
+    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()
+            shown = [
+                (b.binding.key, b.binding.description)
+                for b in app.screen.active_bindings.values()
+                if b.binding.show
+            ]
+            observed["shown"] = shown
+            footer = app.query_one(Footer)
+            keys = list(footer.query(FooterKey))
+            observed["keys"] = [
+                (k.key, k.key_display, k.description, k.action, set(k.classes))
+                for k in keys
+            ]
+
+    asyncio.run(scenario())
+    expected = [
+        ("a", "arm command"),
+        ("ctrl+r", "execute"),
+        ("escape", "cancel"),
+        ("e", "edit command"),
+        ("t", "chat toggle"),
+        ("question_mark", "help"),
+        ("q", "quit"),
+    ]
+    assert observed["shown"] == expected, observed["shown"]
+    # Help advertises as "?" in the footer.
+    assert app.get_key_display(
+        next(b for b in AssistantTUI.BINDINGS if b.key == "question_mark")
+    ) == "?"
+    # The composed Footer: exactly one "instructions" entry and it is the
+    # right-aligned command-palette key; no left duplicate exists.
+    instructions = [k for k in observed["keys"] if k[2] == "instructions"]
+    assert len(instructions) == 1, observed["keys"]
+    key, key_display, _desc, action, classes = instructions[0]
+    assert action == "command_palette", instructions
+    assert "-command-palette" in classes, instructions
+    assert key == "ctrl+p", instructions
+    non_palette = [k for k in observed["keys"] if "-command-palette" not in k[4]]
+    assert not any(k[2] == "instructions" for k in non_palette), observed["keys"]
+    assert not any(k[3] == "command_palette" for k in non_palette), observed["keys"]
+
+
+def test_ctrl_p_opens_command_palette(server):
+    """ctrl+p still opens the palette; its binding description is 'instructions'."""
+    pytest.importorskip("textual")
+    from textual.command import CommandPalette
+
+    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()
+            await pilot.press("ctrl+p")
+            await pilot.pause()
+            await pilot.pause()
+            observed["is_palette"] = isinstance(app.screen, CommandPalette)
+            observed["stack"] = [type(s).__name__ for s in app.screen_stack]
+
+    asyncio.run(scenario())
+    palette_binding = next(b for b in AssistantTUI.BINDINGS if b.key == "ctrl+p")
+    assert palette_binding.description == "instructions"
+    assert observed["is_palette"], "ctrl+p did not open the command palette: %s" % (
+        observed["stack"],
+    )
+
+
+def test_question_mark_toggles_help_panel(server):
+    """? toggles the help panel and is advertised in the left footer list."""
+    pytest.importorskip("textual")
+    from textual.widgets import HelpPanel
+
+    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()
+            assert list(app.screen.query(HelpPanel)) == []
+            await pilot.press("?")
+            await pilot.pause()
+            observed["shown"] = list(app.screen.query(HelpPanel)) != []
+            await pilot.press("?")
+            await pilot.pause()
+            observed["hidden"] = list(app.screen.query(HelpPanel)) == []
+
+    asyncio.run(scenario())
+    assert observed["shown"] is True, "? did not mount HelpPanel"
+    assert observed["hidden"] is True, "second ? did not remove HelpPanel"
+
+
+def test_plain_m_maximizes_triage_pane(server):
+    pytest.importorskip("textual")
+
+    from stt_daemon.tui.app import AssistantTUI
+
+    from textual.widgets import RichLog
+
+    path, _service = server
+    app = AssistantTUI(path, triage_enabled=True)
+
+    async def scenario():
+        async with app.run_test(size=(80, 40)) as pilot:
+            await pilot.pause()
+            app.query_one("#triage", RichLog).focus()
+            await pilot.pause()
+            await pilot.press("m")
+            await pilot.pause()
+            assert app.screen.maximized is app.query_one("#main")
+            await pilot.press("m")
+            await pilot.pause()
+            assert app.screen.maximized is None
+            assert app.query_one("#triage", RichLog).display is True
+            assert app.query_one("#chat", RichLog).display is True
+
+    asyncio.run(scenario())
+
+
+def test_screenshot_saves_svg(server, tmp_path, monkeypatch):
+    pytest.importorskip("textual")
+    import glob
+
+    from stt_daemon.tui.app import AssistantTUI
+
+    monkeypatch.chdir(tmp_path)
+    path, _service = server
+    app = AssistantTUI(path)
+
+    async def scenario():
+        async with app.run_test() as pilot:
+            await pilot.pause()
+            await pilot.press("ctrl+s")
+            await pilot.pause()
+            await pilot.pause()
+
+    asyncio.run(scenario())
+    files = glob.glob(str(tmp_path / "stt-tui-*.svg"))
+    assert files, "no stt-tui-*.svg found in %s" % tmp_path
+    assert os.path.getsize(files[0]) > 0