| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933 |
- """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_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()
- asyncio.run(scenario())
- assert [t for t, _ in sent] == [
- "command_start",
- "command_edit",
- "command_execute",
- "chat_toggle",
- ], sent
- assert observed["focused_after_submit"] is None
- assert observed["value_after_submit"] == ""
- 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_cancel_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("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("#chat", RichLog)
- return list(log.lines)
- lines = asyncio.run(scenario())
- assert lines, "command output was not rendered anywhere"
- def test_tui_clears_chat_pane_on_chat_exit(server):
- """Leaving chat must drop the rendered conversation but keep command output."""
- 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": "gone"})
- app._render()
- await pilot.pause()
- app.state.ingest({"type": "mode", "mode": "idle"})
- app._render()
- await pilot.pause()
- return [str(line) for line in app.query_one("#chat", RichLog).lines]
- lines = asyncio.run(scenario())
- assert not any("gone" in line for line in lines)
- assert any("kept" in line for line in lines)
- 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)
- assert triage.parent is main
- assert chat.parent is main
- assert transcript.parent is not main
- assert triage.border_title == "triage \\[inf 0]" # markup: '[' is escaped
- # triage sits LEFT of chat inside #main.
- assert main.children.index(triage) < main.children.index(chat)
- # 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.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 = 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")
- 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 #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: 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", "chat toggle") 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", "chat toggle"),
- ("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", "chat toggle"),
- ("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.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", RichLog).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_chat(server):
- """Command output (tool_result) → #chat 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.state.ingest(
- {
- "type": "tool_result",
- "tool": "get_datetime",
- "ok": True,
- "result": "2026-09-21",
- }
- )
- app._render()
- await pilot.pause()
- observed["chat_display"] = app.query_one("#chat", RichLog).display
- asyncio.run(scenario())
- assert observed["chat_display"] is True
- def test_error_event_shows_chat(server):
- """An error event → #chat 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.state.ingest({"type": "error", "code": "boom", "message": "kaboom"})
- app._render()
- await pilot.pause()
- observed["chat_display"] = app.query_one("#chat", RichLog).display
- asyncio.run(scenario())
- assert observed["chat_display"] is True
- def test_executing_mode_shows_chat(server):
- """Assistant executing a command → #chat 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.state.ingest({"type": "mode", "mode": "executing"})
- app._render()
- await pilot.pause()
- observed["chat_display"] = app.query_one("#chat", RichLog).display
- asyncio.run(scenario())
- assert observed["chat_display"] is True
- def test_cleared_hides_chat_and_restores_full_width(server):
- """cleared event → #chat hidden 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.state.ingest(
- {
- "type": "tool_result",
- "tool": "get_datetime",
- "ok": True,
- "result": "2026-09-21",
- }
- )
- app._render()
- await pilot.pause()
- assert app.query_one("#chat", RichLog).display is True
- app.state.ingest({"type": "cleared"})
- 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
- asyncio.run(scenario())
- print(
- "cleared: main=%s triage=%s"
- % (observed["main_region"], observed["triage_region"])
- )
- assert observed["chat_display"] is False
- assert (
- abs(observed["triage_region"].width - observed["main_region"].width) <= 2
- ), (observed["triage_region"], observed["main_region"])
- def test_triage_disabled_chat_stays_visible(server):
- """Triage disabled: #chat stays visible, no crash on render/restore."""
- 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() # 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
- app._restore_panes() # must not raise with no #triage/#main
- await pilot.pause()
- observed["chat_display_after"] = app.query_one("#chat", RichLog).display
- asyncio.run(scenario())
- assert observed["chat_display"] is True
- assert observed["chat_display_after"] is True
- def test_maximize_restore_with_chat_content_keeps_chat_visible(server):
- """With ingested output, m then m restores with #chat 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.state.ingest(
- {
- "type": "tool_result",
- "tool": "get_datetime",
- "ok": True,
- "result": "2026-09-21",
- }
- )
- app._render()
- await pilot.pause()
- assert app.query_one("#chat", 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["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_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 / command output \\[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"])
|