"""Smoke test: the Textual app mounts, subscribes, and ingests broadcasts.""" import asyncio import os import shutil import sys import tempfile import pytest from stt_daemon.assistant.control import ControlServer from stt_daemon.assistant.service import AssistantService from stt_daemon.config import Config class FakeSubscriber: def events(self): return iter(()) def close(self): pass @pytest.fixture def server(): directory = tempfile.mkdtemp(prefix="stta-") path = os.path.join(directory, "c.sock") cfg = Config() cfg.assistant.control_socket = path service = AssistantService(cfg, FakeSubscriber()) control = ControlServer(cfg, service, path=path) service.set_sinks(emit=control.broadcast, relay=control.relay) control.start() yield path, service control.close() service.stop() shutil.rmtree(directory, ignore_errors=True) def test_ctrl_r_sends_execute_and_e_focuses_command(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("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() cmd = app.query_one("#command", Input) observed["cmd_focused"] = cmd.has_focus asyncio.run(scenario()) assert ("command_execute", {}) in sent assert ("command_start", {}) in sent assert ("command_cancel", {}) in sent assert observed["cmd_focused"] is True def test_type_command_enter_from_idle_sends_start_edit_execute(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)) assert app.state.mode == "idle" app.query_one("#command", Input).focus() await pilot.pause() for ch in "take notes": await pilot.press(ch) await pilot.pause() await pilot.press("enter") await pilot.pause() asyncio.run(scenario()) assert [t for t, _ in sent] == ["command_start", "command_edit", "command_execute"], sent assert sent[1][1] == {"text": "take notes"}, sent def test_type_command_enter_when_armed_skips_start(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() # no background drain: server status must not clobber state app._drain() app.state.ingest({"type": "mode", "mode": "command_armed"}) assert app.state.mode == "command_armed" 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] == ["command_edit", "command_execute"], 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_chat_input_enter_activates_chat_sends_and_keeps_focus(server): """Typing in #chat-input + Enter enters chat, sends, clears, and keeps focus.""" 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(size=(80, 40)) as pilot: await pilot.pause() app.client.send = lambda type_, **fields: sent.append((type_, fields)) app._timer.stop() app._drain() chat_input = app.query_one("#chat-input", Input) chat_input.focus() await pilot.pause() for ch in "hello": await pilot.press(ch) await pilot.pause() await pilot.press("enter") await pilot.pause() observed["value"] = chat_input.value observed["focused"] = chat_input.has_focus observed["lower_view"] = app._lower_view asyncio.run(scenario()) assert [t for t, _ in sent] == ["chat_toggle", "chat_send"], sent assert sent[1][1] == {"text": "hello"} assert observed["value"] == "" assert observed["focused"] is True assert observed["lower_view"] == "chat" def test_chat_input_from_chat_sends_without_toggle(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(size=(80, 40)) 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"}) chat_input = app.query_one("#chat-input", Input) chat_input.focus() await pilot.pause() for ch in "again": await pilot.press(ch) await pilot.pause() await pilot.press("enter") await pilot.pause() asyncio.run(scenario()) assert [t for t, _ in sent] == ["chat_send"], sent def test_chat_input_enter_activates_chat_and_sends(server): """End-to-end: a typed chat message enters chat mode and lands in history.""" 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(size=(80, 40)) as pilot: await pilot.pause() chat_input = app.query_one("#chat-input", Input) chat_input.focus() await pilot.pause() for ch in "hello": await pilot.press(ch) await pilot.pause() await pilot.press("enter") for _ in range(100): await pilot.pause() if service.modes.mode.value == "chat" and service.chat_history: break observed["mode"] = service.modes.mode.value observed["history"] = list(service.chat_history) asyncio.run(scenario()) assert observed["mode"] == "chat" assert observed["history"] == ["hello"] def test_command_and_chat_inputs_share_a_row(server): """#chat-input sits to the right of #command on the same row, titled 'chat'.""" 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(size=(80, 40)) as pilot: await pilot.pause() command = app.query_one("#command", Input) chat = app.query_one("#chat-input", Input) observed["same_row"] = command.region.y == chat.region.y observed["chat_right"] = chat.region.x > command.region.x observed["title"] = chat.border_title observed["widths"] = (command.region.width, chat.region.width) asyncio.run(scenario()) assert observed["same_row"] is True assert observed["chat_right"] is True assert observed["title"] == "chat" command_w, chat_w = observed["widths"] assert abs(command_w - chat_w) <= 2, observed["widths"] def test_empty_command_enter_sends_nothing(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.query_one("#command", Input).focus() await pilot.pause() await pilot.press("enter") await pilot.pause() asyncio.run(scenario()) assert sent == [] def test_e_focuses_command_input(server): 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._timer.stop() # no background drain: server status must not clobber state app._drain() app.state.ingest({"type": "command_text", "text": "voice text", "edited": False}) app._render() await pilot.pause() await pilot.press("e") await pilot.pause() cmd = app.query_one("#command", Input) observed["focused"] = cmd.has_focus observed["value"] = cmd.value asyncio.run(scenario()) assert observed["focused"] is True assert observed["value"] == "voice text" def test_voice_text_updates_unfocused_but_not_focused(server): 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._timer.stop() # no background drain: server status must not clobber state app._drain() cmd = app.query_one("#command", Input) assert not cmd.has_focus app.state.ingest({"type": "command_text", "text": "voice built", "edited": False}) app._render() await pilot.pause() observed["unfocused_value"] = app.query_one("#command", Input).value app.query_one("#command", Input).focus() await pilot.pause() for ch in "user typing": await pilot.press(ch) await pilot.pause() app.state.ingest({"type": "command_text", "text": "voice late", "edited": False}) app._render() await pilot.pause() observed["focused_value"] = app.query_one("#command", Input).value asyncio.run(scenario()) assert observed["unfocused_value"] == "voice built" assert observed["focused_value"] == "user typing" def test_command_input_border_title(server): 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() app._render() await pilot.pause() return app.query_one("#command", Input).border_title title = asyncio.run(scenario()) assert "command buffer" in title def test_no_autofocus_on_mount(server): """Nothing is focused on mount: single-letter hotkeys must not be swallowed by #command.""" pytest.importorskip("textual") 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() await pilot.pause() return app.focused assert asyncio.run(scenario()) is None def test_submit_blurs_command_and_hotkey_still_fires(server): """Type + Enter sends messages, clears focus; a hotkey (t) still fires afterwards.""" 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)) app._timer.stop() # no background drain: server status must not clobber state app._drain() app.query_one("#command", Input).focus() await pilot.pause() for ch in "take notes": await pilot.press(ch) await pilot.pause() await pilot.press("enter") await pilot.pause() observed["focused_after_submit"] = app.focused observed["value_after_submit"] = app.query_one("#command", Input).value await pilot.press("t") await pilot.pause() observed["lower_view"] = app._lower_view asyncio.run(scenario()) assert [t for t, _ in sent] == [ "command_start", "command_edit", "command_execute", ], sent assert observed["focused_after_submit"] is None assert observed["value_after_submit"] == "" assert observed["lower_view"] == "output" def test_focus_triage_then_m_maximizes_deterministically(server): """Focus #triage, assert focus stuck, then m maximizes #main (no auto-focus race).""" 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() assert app.focused is not None and app.focused.id == "triage", app.focused await pilot.press("m") await pilot.pause() observed["maximized_is_main"] = app.screen.maximized is app.query_one("#main") asyncio.run(scenario()) assert observed["maximized_is_main"] is True def test_c_key_confirms_before_clearing(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)) await pilot.press("c") await pilot.pause() assert not any(type_ == "clear" for type_, _ in sent), "clear sent before confirmation" await pilot.press("y") await pilot.pause() asyncio.run(scenario()) assert ("clear", {}) in sent def test_c_key_hides_visible_pane(server): """Confirming clear hides whatever lower pane was showing.""" pytest.importorskip("textual") from textual.widgets import RichLog from stt_daemon.tui.app import AssistantTUI path, _service = server app = AssistantTUI(path) async def scenario(): async with app.run_test(size=(80, 40)) as pilot: await pilot.pause() app.state.ingest( {"type": "tool_result", "tool": "get_datetime", "ok": True, "result": "now"} ) app._lower_view = "output" app._render() await pilot.pause() assert app.query_one("#output", RichLog).display is True await pilot.press("c") await pilot.pause() await pilot.press("y") for _ in range(100): await pilot.pause() if app._lower_view == "hidden": break asyncio.run(scenario()) assert app._lower_view == "hidden" pytest.importorskip("textual") from stt_daemon.tui.app import AssistantTUI 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)) await pilot.press("c") await pilot.pause() await pilot.press("n") await pilot.pause() asyncio.run(scenario()) assert not any(type_ == "clear" for type_, _ in sent) def test_c_key_escape_does_not_clear(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)) await pilot.press("c") await pilot.pause() await pilot.press("escape") await pilot.pause() asyncio.run(scenario()) assert not any(type_ == "clear" for type_, _ in sent) def test_c_key_clears_rendered_output(server): """Pressing 'c' clears the conversation pane via the cleared event.""" 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.state.ingest({"type": "chat_message", "role": "user", "text": "hello"}) app.state.ingest( { "type": "tool_result", "tool": "get_datetime", "ok": True, "result": "2026-09-21", } ) app._render() await pilot.pause() app.state.ingest({"type": "cleared"}) app._render() await pilot.pause() log = app.query_one("#chat", RichLog) return list(log.lines) lines = asyncio.run(scenario()) assert not any("hello" in str(line) for line in lines) assert not any("2026-09-21" in str(line) for line in lines) def test_tui_renders_command_output(server): """A tool result must actually reach a widget - otherwise 'nothing happens'.""" 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.state.ingest( { "type": "tool_result", "tool": "get_datetime", "ok": True, "result": "2026-09-21T01:00:00", } ) app._render() await pilot.pause() log = app.query_one("#output", RichLog) return list(log.lines) lines = asyncio.run(scenario()) assert lines, "command output was not rendered anywhere" def test_command_output_and_chat_render_in_separate_panes(server): """Tool output lands in #output and chat turns in #chat, with no crossover.""" pytest.importorskip("textual") from textual.widgets import RichLog from stt_daemon.tui.app import AssistantTUI path, _service = server app = AssistantTUI(path) async def scenario(): async with app.run_test() as pilot: await pilot.pause() app._timer.stop() # no background drain: server status must not clobber state app._drain() app.state.ingest( {"type": "tool_result", "tool": "get_datetime", "ok": True, "result": "cmd"} ) app.state.ingest({"type": "mode", "mode": "chat"}) app.state.ingest({"type": "chat_message", "role": "user", "text": "hi"}) app._render() await pilot.pause() output = [str(line) for line in app.query_one("#output", RichLog).lines] chat = [str(line) for line in app.query_one("#chat", RichLog).lines] return output, chat output, chat = asyncio.run(scenario()) assert any("cmd" in line for line in output) assert not any("hi" in line for line in output) assert any("hi" in line for line in chat) assert not any("cmd" in line for line in chat) def test_tui_keeps_chat_pane_on_chat_exit(server): """Leaving chat keeps the rendered conversation; only clear drops it.""" pytest.importorskip("textual") from textual.widgets import RichLog 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.state.ingest( {"type": "tool_result", "tool": "get_datetime", "ok": True, "result": "kept"} ) app.state.ingest({"type": "mode", "mode": "chat"}) app.state.ingest({"type": "chat_message", "role": "user", "text": "kept-turn"}) app._render() await pilot.pause() app.state.ingest({"type": "mode", "mode": "idle"}) app._render() await pilot.pause() chat = [str(line) for line in app.query_one("#chat", RichLog).lines] output = [str(line) for line in app.query_one("#output", RichLog).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): pytest.importorskip("textual") 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() service.arm() # broadcast to the subscribed viewer for _ in range(60): await pilot.pause() if app.state.mode == "command_armed": break await asyncio.sleep(0.05) asyncio.run(scenario()) assert app.state.mode == "command_armed" def test_render_after_unmount_is_safe(server): """A timer tick can land during teardown; rendering must not raise.""" pytest.importorskip("textual") 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() asyncio.run(scenario()) app._render() # must not raise NoMatches def test_tui_records_connect_failure(tmp_path): pytest.importorskip("textual") from stt_daemon.tui.app import AssistantTUI app = AssistantTUI(str(tmp_path / "missing.sock")) async def scenario(): async with app.run_test() as pilot: await pilot.pause() asyncio.run(scenario()) assert app.connect_error is not None assert "cannot connect" in app.connect_error def test_border_titles_show_queue_and_inference_depths(server): """Border titles for transcript and chat show live segment and inference queue counts.""" 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.state.ingest( { "type": "heartbeat", "queue_depth": 5, "inference_depth": 3, "inference_in_flight": False, } ) app._render() await pilot.pause() transcript_title = app.query_one("#transcript", RichLog).border_title chat_title = app.query_one("#chat", RichLog).border_title assert "[seg 5]" in transcript_title assert "[inf 3]" in chat_title assert "busy" not in chat_title # Test with inference in flight app.state.ingest( { "type": "heartbeat", "inference_depth": 7, "inference_in_flight": True, } ) app._render() await pilot.pause() chat_title = app.query_one("#chat", RichLog).border_title assert "[inf 7" in chat_title assert "busy" in chat_title asyncio.run(scenario()) def test_main_exit_code_reflects_connect_failure(monkeypatch, tmp_path, capsys): pytest.importorskip("textual") from stt_daemon.tui import app as tui_app monkeypatch.setattr( tui_app.AssistantTUI, "run", lambda self: setattr(self, "connect_error", "boom"), ) monkeypatch.setattr(sys, "argv", ["stt-tui", "--socket", str(tmp_path / "x.sock")]) assert tui_app.main() == 1 assert "boom" in capsys.readouterr().err monkeypatch.setattr(tui_app.AssistantTUI, "run", lambda self: None) assert tui_app.main() == 0 def test_triage_disabled_composes_no_triage_widget(server): """Default (triage_enabled=False): no #triage, no #main — tree as today.""" pytest.importorskip("textual") 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 not app.query("#triage") assert not app.query("#main") # The rest of the tree is intact. app.query_one("#transcript") app.query_one("#status") app.query_one("#command") app.query_one("#chat") asyncio.run(scenario()) def test_triage_enabled_composes_split(server): """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 from stt_daemon.tui.app import AssistantTUI path, _service = server app = AssistantTUI(path, triage_enabled=True) async def scenario(): async with app.run_test() 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) row = app.query_one("#chat-row", Horizontal) assert triage.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 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 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() app._timer.stop() # no background drain: server status must not clobber state app._drain() # Chat needs content to be shown (hidden-by-default rule): put it in # chat mode so the 1/3-2/3 split applies. app._lower_view = "chat" app.state.ingest({"type": "mode", "mode": "chat"}) app._render() await pilot.pause() main = app.query_one("#main", Horizontal) transcript = app.query_one("#transcript", RichLog) triage = app.query_one("#triage", RichLog) chat_row = app.query_one("#chat-row", Horizontal) return ( main.size.width, triage.size.width, chat_row.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}" ) # 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 # 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") from textual.widgets import RichLog from stt_daemon.tui.app import AssistantTUI path, _service = server app = AssistantTUI(path, triage_enabled=True) 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": "triage", "stage": "signals", "triage": { "order": ["category"], "items": { "category": { "kind": "choice", "choice": "question", "answer_confidence": 0.91, } }, }, } ) app.state.ingest( {"type": "status", "triage_state": "ok", "triage_reason": ""} ) app._render() await pilot.pause() return [str(line) for line in app.query_one("#triage", RichLog).lines] lines = asyncio.run(scenario()) rendered = "\n".join(lines) assert "category: question (0.91)" in rendered def test_triage_enabled_loading_shows_reason_line_never_blank(server): """loading/unknown triage_state renders the reason line — never a blank panel.""" pytest.importorskip("textual") from textual.widgets import RichLog from stt_daemon.tui.app import AssistantTUI path, _service = server app = AssistantTUI(path, triage_enabled=True) 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": "status", "triage_state": "loading", "triage_reason": "model loading", } ) app._render() await pilot.pause() loading_lines = [str(line) for line in app.query_one("#triage", RichLog).lines] # Unknown state falls back to a generic reason line, no crash. app.state.ingest( {"type": "status", "triage_state": "weird_state", "triage_reason": ""} ) app._render() await pilot.pause() unknown_lines = [str(line) for line in app.query_one("#triage", RichLog).lines] return loading_lines, unknown_lines loading_lines, unknown_lines = asyncio.run(scenario()) assert loading_lines, "triage panel was blank in loading state" assert any("triage: unavailable" in line for line in loading_lines) assert any("model loading" in line for line in loading_lines) assert unknown_lines, "triage panel was blank for unknown state" assert any("triage: unavailable" in line for line in unknown_lines) assert any("weird_state" in line for line in unknown_lines) def test_triage_epoch_change_clears_before_rewrite(server): """Epoch bump clears #triage first: no duplicated stale lines.""" pytest.importorskip("textual") from textual.widgets import RichLog from stt_daemon.tui.app import AssistantTUI path, _service = server app = AssistantTUI(path, triage_enabled=True) 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": "triage", "stage": "signals", "triage": {"order": ["q1"], "items": {"q1": {"kind": "choice", "choice": "a"}}}, } ) app.state.ingest({"type": "status", "triage_state": "ok"}) app._render() await pilot.pause() # New epoch: cleared stage empties the payload, panel must not keep old lines. app.state.ingest({"type": "triage", "stage": "cleared"}) app._render() await pilot.pause() return [str(line) for line in app.query_one("#triage", RichLog).lines] lines = asyncio.run(scenario()) rendered = "\n".join(lines) assert "q1" not in rendered, "stale triage lines survived an epoch change" assert "triage: unavailable — no data" in rendered def test_triage_disabled_never_writes_triage(server): """Disabled: triage events are ingested but #triage is never touched (it doesn't exist).""" pytest.importorskip("textual") 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.state.ingest({"type": "triage", "stage": "signals", "triage": {}}) app.state.ingest({"type": "status", "triage_state": "ok"}) app._render() # must not raise NoMatches for #triage await pilot.pause() assert not app.query("#triage") asyncio.run(scenario()) def test_default_ctor_has_no_triage_arg(server): """AssistantTUI(path) stays valid: triage_enabled defaults to False.""" pytest.importorskip("textual") from stt_daemon.tui.app import AssistantTUI path, _service = 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() assert app.focused is not None and app.focused.id == "triage", app.focused 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 panes; chat follows the content rule (hidden).""" 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 # No chat/command output ingested: chat follows the content rule → hidden. assert observed["chat_display"] is False 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: 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 # No chat/command output ingested: chat follows the content rule → hidden. assert observed["chat_display"] is False def test_maximize_chat_when_triage_disabled(server): """triage_enabled=False: focus #chat, press m maximizes the chat row (with controls).""" 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() app._timer.stop() # keep the server status from re-hiding #chat # Disabled: no #main/#triage. #chat starts hidden, so reveal it via # the cycle, then focus it and press m to maximize #chat itself. app._lower_view = "chat" app.state.ingest({"type": "mode", "mode": "chat"}) app.state.ingest({"type": "chat_message", "role": "user", "text": "hi"}) app._render() await pilot.pause() app.query_one("#chat", RichLog).focus() await pilot.pause() await pilot.press("m") await pilot.pause() observed["maximized"] = app.screen.maximized 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") 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_row"] assert observed["restored"] is None def test_maximize_chat_keeps_chat_input_at_bottom(server): """Maximizing the conversation keeps only #chat-input, docked at the bottom.""" pytest.importorskip("textual") from textual.widgets import Input, 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() app.query_one("#chat", RichLog).focus() await pilot.pause() await pilot.press("m") await pilot.pause() chat = app.query_one("#chat", RichLog) chat_input = app.query_one("#chat-input", Input) command = app.query_one("#command", Input) observed["allow"] = app.ALLOW_IN_MAXIMIZED_VIEW observed["chat_input_display"] = chat_input.display observed["command_display"] = command.display observed["chat_region"] = chat.region observed["input_region"] = chat_input.region observed["input_below_chat"] = chat_input.region.y > chat.region.y observed["input_bottom"] = ( chat_input.region.y + chat_input.region.height >= app.screen.size.height - 1 ) await pilot.press("m") await pilot.pause() observed["restored_allow"] = app.ALLOW_IN_MAXIMIZED_VIEW observed["restored_command"] = command.display observed["restored_chat_input"] = chat_input.display asyncio.run(scenario()) assert observed["allow"] == "#inputs" assert observed["chat_input_display"] is True assert observed["command_display"] is False assert observed["input_below_chat"] is True, observed assert observed["input_bottom"] is True assert observed["restored_allow"] == ".__never__" assert observed["restored_command"] is True assert observed["restored_chat_input"] is True def test_maximize_output_keeps_command_input_at_bottom(server): """Maximizing the command output keeps only #command, docked at the bottom.""" pytest.importorskip("textual") from textual.widgets import Input, 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 = "output" app.state.ingest( {"type": "tool_result", "tool": "get_datetime", "ok": True, "result": "now"} ) app._render() await pilot.pause() app.query_one("#output", RichLog).focus() await pilot.pause() await pilot.press("m") await pilot.pause() output = app.query_one("#output", RichLog) command = app.query_one("#command", Input) chat_input = app.query_one("#chat-input", Input) observed["allow"] = app.ALLOW_IN_MAXIMIZED_VIEW observed["command_display"] = command.display observed["chat_input_display"] = chat_input.display observed["input_below_output"] = command.region.y > output.region.y observed["input_bottom"] = ( command.region.y + command.region.height >= app.screen.size.height - 1 ) await pilot.press("m") await pilot.pause() observed["restored_allow"] = app.ALLOW_IN_MAXIMIZED_VIEW observed["restored_command"] = command.display observed["restored_chat_input"] = chat_input.display asyncio.run(scenario()) assert observed["allow"] == "#inputs" assert observed["command_display"] is True assert observed["chat_input_display"] is False assert observed["input_below_output"] is True, observed assert observed["input_bottom"] is True assert observed["restored_allow"] == ".__never__" assert observed["restored_command"] is True assert observed["restored_chat_input"] is True def test_tab_reaches_docked_input_when_maximized(server): """Tab moves focus between the maximized pane and its docked input.""" 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() app._lower_view = "chat" app.state.ingest({"type": "mode", "mode": "chat"}) app.state.ingest({"type": "chat_message", "role": "user", "text": "hi"}) app._render() await pilot.pause() app.query_one("#chat", RichLog).focus() await pilot.pause() await pilot.press("m") await pilot.pause() await pilot.press("tab") await pilot.pause() observed["chat_to_input"] = app.focused.id if app.focused else None await pilot.press("tab") await pilot.pause() observed["chat_back"] = app.focused.id if app.focused else None await pilot.press("m") # restore await pilot.pause() app._lower_view = "output" app.state.ingest( {"type": "tool_result", "tool": "get_datetime", "ok": True, "result": "now"} ) app._render() await pilot.pause() app.query_one("#output", RichLog).focus() await pilot.pause() await pilot.press("m") await pilot.pause() await pilot.press("tab") await pilot.pause() observed["output_to_input"] = app.focused.id if app.focused else None asyncio.run(scenario()) assert observed["chat_to_input"] == "chat-input" assert observed["chat_back"] == "chat" assert observed["output_to_input"] == "command" 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: 8 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", "cycle pane") in pairs assert ("s", "questions view") 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["s"].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 eight 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", "cycle pane"), ("s", "questions view"), ("question_mark", "help"), ("q", "quit"), ] assert by_key["ctrl+p"].priority is True def test_footer_shows_ordered_entries(server): """Left list is the 8 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", "cycle pane"), ("s", "questions view"), ("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 # No chat/command output ingested: chat follows the content rule → hidden. assert app.query_one("#chat", RichLog).display is False 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()) out_dir = tmp_path / "screenshots" assert out_dir.is_dir(), "screenshots/ not created in %s" % tmp_path files = glob.glob(str(out_dir / "stt-tui-*.svg")) assert files, "no stt-tui-*.svg found in %s" % out_dir assert os.path.getsize(files[0]) > 0 assert glob.glob(str(tmp_path / "stt-tui-*.svg")) == [], \ "screenshot leaked into %s instead of screenshots/" % tmp_path def test_screenshot_saves_into_session_dir(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) app.state.session_dir = "transcripts/x" 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()) out_dir = tmp_path / "transcripts" / "x" / "screenshots" assert out_dir.is_dir(), "session screenshots/ not created in %s" % out_dir files = glob.glob(str(out_dir / "stt-tui-*.svg")) assert files, "no stt-tui-*.svg found in %s" % out_dir assert os.path.getsize(files[0]) > 0 assert glob.glob(str(tmp_path / "screenshots" / "stt-tui-*.svg")) == [], \ "screenshot leaked into screenshots/ instead of the session dir" def test_chat_hidden_by_default_triage_fills_main(server): """Fresh state: #chat hidden, #triage fills the whole #main width.""" 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) observed: dict = {} async def scenario(): async with app.run_test(size=(80, 40)) as pilot: await pilot.pause() app._timer.stop() # no background drain: server status must not clobber state app._drain() app._render() await pilot.pause() observed["chat_display"] = app.query_one("#chat", RichLog).display observed["triage_display"] = app.query_one("#triage", RichLog).display observed["main_region"] = app.query_one("#main", Horizontal).region observed["triage_region"] = app.query_one("#triage", RichLog).region asyncio.run(scenario()) print("hidden: main=%s triage=%s" % (observed["main_region"], observed["triage_region"])) assert observed["chat_display"] is False assert observed["triage_display"] is True assert ( abs(observed["triage_region"].width - observed["main_region"].width) <= 2 ), (observed["triage_region"], observed["main_region"]) def test_chat_mode_shows_chat_and_restores_split(server): """mode=chat → #chat visible, triage back to ~1/3 width.""" 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) observed: dict = {} async def scenario(): async with app.run_test(size=(80, 40)) as pilot: await pilot.pause() app._timer.stop() # no background drain: server status must not clobber state app._drain() app._lower_view = "chat" app.state.ingest({"type": "mode", "mode": "chat"}) app._render() await pilot.pause() 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-row", Horizontal).region asyncio.run(scenario()) print( "shown: main=%s triage=%s chat=%s" % (observed["main_region"], observed["triage_region"], observed["chat_region"]) ) assert observed["chat_display"] is True main_w = observed["main_region"].width assert abs(observed["triage_region"].width / main_w - 1 / 3) < 0.08, observed assert abs(observed["chat_region"].width / main_w - 2 / 3) < 0.08, observed def test_tool_result_shows_output_pane(server): """Command output (tool_result) goes to #output, leaving #chat hidden.""" pytest.importorskip("textual") from textual.widgets import RichLog 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() # no background drain: server status must not clobber state app._drain() app._lower_view = "output" app.state.ingest( { "type": "tool_result", "tool": "get_datetime", "ok": True, "result": "2026-09-21", } ) app._render() await pilot.pause() observed["output_display"] = app.query_one("#output", RichLog).display observed["chat_display"] = app.query_one("#chat", RichLog).display asyncio.run(scenario()) assert observed["output_display"] is True assert observed["chat_display"] is False def test_error_event_shows_output_pane(server): """An error event goes to #output, leaving #chat hidden.""" pytest.importorskip("textual") from textual.widgets import RichLog 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() # no background drain: server status must not clobber state app._drain() app._lower_view = "output" app.state.ingest({"type": "error", "code": "boom", "message": "kaboom"}) app._render() await pilot.pause() observed["output_display"] = app.query_one("#output", RichLog).display observed["chat_display"] = app.query_one("#chat", RichLog).display asyncio.run(scenario()) assert observed["output_display"] is True assert observed["chat_display"] is False def test_executing_mode_shows_output_pane(server): """Assistant executing a command → #output visible, #chat hidden.""" 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() # no background drain: server status must not clobber state app._drain() app._lower_view = "output" app.state.ingest({"type": "mode", "mode": "executing"}) app._render() await pilot.pause() observed["output_display"] = app.query_one("#output", RichLog).display observed["chat_display"] = app.query_one("#chat", RichLog).display asyncio.run(scenario()) assert observed["output_display"] is True assert observed["chat_display"] is False def test_cleared_hides_pane_empties_output_and_restores_full_width(server): """cleared empties #output and hides it again, triage full width.""" pytest.importorskip("textual") from textual.containers import Horizontal from textual.widgets import RichLog 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() # no background drain: server status must not clobber state app._drain() app._lower_view = "output" app.state.ingest( { "type": "tool_result", "tool": "get_datetime", "ok": True, "result": "2026-09-21", } ) app._render() await pilot.pause() assert app.query_one("#output", RichLog).display is True app.state.ingest({"type": "cleared"}) app._render() await pilot.pause() observed["empty_output"] = [ str(line) for line in app.query_one("#output", RichLog).lines ] observed["lower_view"] = app._lower_view observed["chat_display"] = app.query_one("#chat", RichLog).display observed["output_display"] = app.query_one("#output", RichLog).display observed["main_region"] = app.query_one("#main", Horizontal).region observed["triage_region"] = app.query_one("#triage", RichLog).region asyncio.run(scenario()) print( "cleared: main=%s triage=%s" % (observed["main_region"], observed["triage_region"]) ) assert observed["empty_output"] == [] assert observed["lower_view"] == "hidden" assert observed["chat_display"] is False assert observed["output_display"] is False assert ( abs(observed["triage_region"].width - observed["main_region"].width) <= 2 ), (observed["triage_region"], observed["main_region"]) def test_cycle_lower_switches_panes(server): """t cycles hidden -> #output -> #chat -> hidden when triage is disabled.""" pytest.importorskip("textual") from textual.widgets import RichLog from stt_daemon.tui.app import AssistantTUI path, _service = server app = AssistantTUI(path) observed: dict = {} def snap(): return ( app.query_one("#output", RichLog).display, app.query_one("#chat", RichLog).display, app.query_one("#transcript", RichLog).has_class("-grow"), ) async def scenario(): async with app.run_test(size=(80, 40)) as pilot: await pilot.pause() app._timer.stop() # no background drain: server status must not clobber state app._drain() app._render() await pilot.pause() 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._lower_view = "hidden" app.state.ingest({"type": "mode", "mode": "executing"}) app._render() await pilot.pause() observed["executing"] = ( app._lower_view, app.query_one("#output", RichLog).display, ) app._lower_view = "chat" app.state.ingest({"type": "mode", "mode": "chat"}) app._render() await pilot.pause() observed["chat"] = ( app._lower_view, app.query_one("#output", RichLog).display, app.query_one("#chat", RichLog).display, ) asyncio.run(scenario()) 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_output_keeps_output_visible(server): """With ingested output, m then m restores with #output still visible.""" 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() # no background drain: server status must not clobber state app._drain() app._lower_view = "output" app.state.ingest( { "type": "tool_result", "tool": "get_datetime", "ok": True, "result": "2026-09-21", } ) app._render() await pilot.pause() assert app.query_one("#output", RichLog).display is True 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("m") await pilot.pause() observed["maximized"] = app.screen.maximized observed["triage_display"] = app.query_one("#triage", RichLog).display observed["output_display"] = app.query_one("#output", RichLog).display asyncio.run(scenario()) assert observed["maximized"] is None assert observed["triage_display"] is True assert observed["output_display"] is True def test_transcript_pane_keeps_updating_past_cap(server): """Regression: past MAX_TRANSCRIPTS the running log must still append.""" pytest.importorskip("textual") from textual.widgets import RichLog from stt_daemon.tui.app import AssistantTUI from stt_daemon.tui.state import MAX_TRANSCRIPTS path, _service = server app = AssistantTUI(path) observed: dict = {} async def scenario(): async with app.run_test(size=(80, 40)) as pilot: await pilot.pause() app._timer.stop() # no background drain: server status must not clobber state app._drain() for i in range(MAX_TRANSCRIPTS + 5): app.state.ingest({"type": "transcript", "text": f"t{i}", "lang": "en"}) app._render() await pilot.pause() app.state.ingest({"type": "transcript", "text": "after cap", "lang": "en"}) app._render() await pilot.pause() log = app.query_one("#transcript", RichLog) observed["lines"] = [str(line) for line in log.lines] asyncio.run(scenario()) assert any("after cap" in line for line in observed["lines"]) assert len(observed["lines"]) > MAX_TRANSCRIPTS def _questions_payload(): return { "topic": { "type": "choice", "instructions": "What is the main theme?", "criteria": {"planning": "roadmap or prioritization"}, } } def _rendered_lines(log): return [line.text for line in log.lines] def _ingest_triage_signals(app): app.state.ingest( { "type": "triage", "stage": "signals", "triage": { "order": ["topic"], "items": { "topic": { "kind": "choice", "choice": "planning", "answer_confidence": 0.91, } }, }, } ) app.state.ingest( { "type": "status", "triage_state": "ok", "triage_reason": "", "questions": _questions_payload(), } ) def test_s_key_swaps_triage_pane_to_questions_view(server): """Press s: #triage border flips to questions and renders the structured blocks.""" 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() as pilot: await pilot.pause() app._timer.stop() # no background drain: server status must not clobber state app._drain() _ingest_triage_signals(app) app._render() await pilot.pause() observed["default_title"] = app.query_one("#triage", RichLog).border_title observed["default_lines"] = _rendered_lines(app.query_one("#triage", RichLog)) await pilot.press("s") await pilot.pause() observed["questions_title"] = app.query_one("#triage", RichLog).border_title observed["questions_lines"] = _rendered_lines(app.query_one("#triage", RichLog)) asyncio.run(scenario()) assert observed["default_title"] == "triage \\[inf 0]" # markup: '[' is escaped assert any("topic: planning (0.91)" in line for line in observed["default_lines"]) assert observed["questions_title"] == "questions" rendered = "\n".join(observed["questions_lines"]) assert "topic [choice]" in rendered assert "What is the main theme?" in rendered assert "planning — roadmap or prioritization" in rendered assert "topic: planning" not in rendered def test_s_key_again_restores_triage_view(server): """Second s: border flips back to triage and the signals view is rewritten.""" 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() as pilot: await pilot.pause() app._timer.stop() # no background drain: server status must not clobber state app._drain() _ingest_triage_signals(app) app._render() await pilot.pause() await pilot.press("s") await pilot.pause() observed["mid_title"] = app.query_one("#triage", RichLog).border_title await pilot.press("s") await pilot.pause() observed["triage_title"] = app.query_one("#triage", RichLog).border_title observed["triage_lines"] = _rendered_lines(app.query_one("#triage", RichLog)) asyncio.run(scenario()) assert observed["mid_title"] == "questions" assert observed["triage_title"] == "triage \\[inf 0]" # markup: '[' is escaped rendered = "\n".join(observed["triage_lines"]) assert "topic: planning (0.91)" in rendered assert "What is the main theme?" not in rendered def test_triage_brief_title_mirrors_inference_depth_and_busy(server): """Brief view: #triage title carries [inf N] and the busy marker, exactly.""" 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() as pilot: await pilot.pause() app._timer.stop() # no background drain: server status must not clobber state app._drain() app.state.ingest( {"type": "heartbeat", "inference_depth": 3, "inference_in_flight": False} ) app._render() await pilot.pause() observed["idle"] = app.query_one("#triage", RichLog).border_title app.state.ingest( {"type": "heartbeat", "inference_depth": 7, "inference_in_flight": True} ) app._render() await pilot.pause() observed["busy"] = app.query_one("#triage", RichLog).border_title asyncio.run(scenario()) # border_title round-trips as console markup, so '[' reads back as '\['. assert observed["idle"] == "triage \\[inf 3]" assert observed["busy"] == "triage \\[inf 7 · busy]" def test_questions_view_title_has_no_inference_suffix(server): """Questions view: title is exactly 'questions'; toggling back restores the suffix.""" 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() as pilot: await pilot.pause() app._timer.stop() app._drain() app.state.ingest( {"type": "heartbeat", "inference_depth": 5, "inference_in_flight": True} ) app._render() await pilot.pause() observed["brief"] = app.query_one("#triage", RichLog).border_title await pilot.press("s") await pilot.pause() observed["questions"] = app.query_one("#triage", RichLog).border_title await pilot.press("s") await pilot.pause() observed["restored"] = app.query_one("#triage", RichLog).border_title asyncio.run(scenario()) assert observed["brief"] == "triage \\[inf 5 · busy]" assert observed["questions"] == "questions" assert "inf" not in observed["questions"] assert "busy" not in observed["questions"] # No stale plain title: the brief view comes back fully suffixed. assert observed["restored"] == "triage \\[inf 5 · busy]" def test_triage_disabled_heartbeat_leaves_no_triage_widget(server): """triage_enabled=False: depths update #chat only and never create a #triage pane.""" 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() as pilot: await pilot.pause() app._timer.stop() app._drain() app.state.ingest( {"type": "heartbeat", "inference_depth": 9, "inference_in_flight": True} ) app._render() await pilot.pause() observed["has_triage"] = bool(app.query("#triage")) observed["chat"] = app.query_one("#chat", RichLog).border_title asyncio.run(scenario()) assert observed["has_triage"] is False assert observed["chat"] == "conversation \\[inf 9 · busy]" def test_s_key_is_noop_when_triage_disabled(server): """triage_enabled=False: action_toggle_questions and s do nothing and never crash.""" pytest.importorskip("textual") 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.action_toggle_questions() await pilot.pause() assert app._show_questions is False assert not app.query("#triage") await pilot.press("s") await pilot.pause() assert app._show_questions is False assert not app.query("#triage") asyncio.run(scenario()) def test_status_event_questions_populate_questions_view(server): """A status event carrying questions fills state and the toggled pane.""" 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() as pilot: await pilot.pause() app._timer.stop() # no background drain: server status must not clobber state app._drain() observed["before_epoch"] = app.state.questions_epoch app.state.ingest({"type": "status", "questions": _questions_payload()}) app._render() await pilot.pause() observed["questions"] = app.state.questions observed["epoch"] = app.state.questions_epoch await pilot.press("s") await pilot.pause() observed["lines"] = _rendered_lines(app.query_one("#triage", RichLog)) asyncio.run(scenario()) 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 def test_triage_chat_checkbox_only_when_triage_enabled(server): """The send-to-triage checkbox is absent without the triage panel.""" pytest.importorskip("textual") from stt_daemon.tui.app import AssistantTUI path, _service = server disabled = AssistantTUI(path) enabled = AssistantTUI(path, triage_enabled=True) async def scenario(): async with disabled.run_test() as pilot: await pilot.pause() observed_disabled = bool(disabled.query("#triage-chat")) async with enabled.run_test() as pilot: await pilot.pause() observed_enabled = bool(enabled.query("#triage-chat")) return observed_disabled, observed_enabled observed_disabled, observed_enabled = asyncio.run(scenario()) assert observed_disabled is False assert observed_enabled is True def test_triage_chat_checkbox_toggles_and_sends_control(server): """Toggling #triage-chat on sends a triage_chat control message.""" pytest.importorskip("textual") from textual.widgets import Checkbox from stt_daemon.tui.app import AssistantTUI path, _service = server app = AssistantTUI(path, triage_enabled=True) 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)) control = app.query_one("#triage-chat", Checkbox) observed["initial"] = control.value control.value = True await pilot.pause() observed["after_on"] = control.value asyncio.run(scenario()) assert observed["initial"] is False assert observed["after_on"] is True assert ("triage_chat", {"on": True}) in sent