| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559 |
- """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)
- 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._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 = 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()
- 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"] = 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_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", 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_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"])
|