| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717 |
- """Smoke test: the Textual app mounts, subscribes, and ingests broadcasts."""
- import asyncio
- import os
- import shutil
- import sys
- import tempfile
- import pytest
- from stt_daemon.assistant.control import ControlServer
- from stt_daemon.assistant.service import AssistantService
- from stt_daemon.config import Config
- class FakeSubscriber:
- def events(self):
- return iter(())
- def close(self):
- pass
- @pytest.fixture
- def server():
- directory = tempfile.mkdtemp(prefix="stta-")
- path = os.path.join(directory, "c.sock")
- cfg = Config()
- cfg.assistant.control_socket = path
- service = AssistantService(cfg, FakeSubscriber())
- control = ControlServer(cfg, service, path=path)
- service.set_sinks(emit=control.broadcast, relay=control.relay)
- control.start()
- yield path, service
- control.close()
- service.stop()
- shutil.rmtree(directory, ignore_errors=True)
- def test_ctrl_r_sends_execute_and_e_focuses_command(server):
- pytest.importorskip("textual")
- from textual.widgets import Input
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path)
- sent: list[tuple] = []
- observed: dict = {}
- async def scenario():
- async with app.run_test() as pilot:
- await pilot.pause()
- app.client.send = lambda type_, **fields: sent.append((type_, fields))
- await pilot.press("ctrl+r")
- await pilot.pause()
- await pilot.press("a")
- await pilot.pause()
- await pilot.press("escape")
- await pilot.pause()
- await pilot.press("e")
- await pilot.pause()
- cmd = app.query_one("#command", Input)
- observed["cmd_focused"] = cmd.has_focus
- asyncio.run(scenario())
- assert ("command_execute", {}) in sent
- assert ("command_start", {}) in sent
- assert ("command_cancel", {}) in sent
- assert observed["cmd_focused"] is True
- def test_type_command_enter_from_idle_sends_start_edit_execute(server):
- pytest.importorskip("textual")
- from textual.widgets import Input
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path)
- sent: list[tuple] = []
- async def scenario():
- async with app.run_test() as pilot:
- await pilot.pause()
- app.client.send = lambda type_, **fields: sent.append((type_, fields))
- assert app.state.mode == "idle"
- app.query_one("#command", Input).focus()
- await pilot.pause()
- for ch in "take notes":
- await pilot.press(ch)
- await pilot.pause()
- await pilot.press("enter")
- await pilot.pause()
- asyncio.run(scenario())
- assert [t for t, _ in sent] == ["command_start", "command_edit", "command_execute"], sent
- assert sent[1][1] == {"text": "take notes"}, sent
- def test_type_command_enter_when_armed_skips_start(server):
- pytest.importorskip("textual")
- from textual.widgets import Input
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path)
- sent: list[tuple] = []
- async def scenario():
- async with app.run_test() as pilot:
- await pilot.pause()
- app.client.send = lambda type_, **fields: sent.append((type_, fields))
- app._timer.stop() # no background drain: server status must not clobber state
- app._drain()
- app.state.ingest({"type": "mode", "mode": "command_armed"})
- assert app.state.mode == "command_armed"
- app.query_one("#command", Input).focus()
- await pilot.pause()
- for ch in "open calendar":
- await pilot.press(ch)
- await pilot.pause()
- await pilot.press("enter")
- await pilot.pause()
- asyncio.run(scenario())
- assert [t for t, _ in sent] == ["command_edit", "command_execute"], sent
- assert sent[0][1] == {"text": "open calendar"}, sent
- def test_arm_from_chat_leaves_chat_first(server):
- pytest.importorskip("textual")
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path)
- sent: list[tuple] = []
- async def scenario():
- async with app.run_test() as pilot:
- await pilot.pause()
- app.client.send = lambda type_, **fields: sent.append((type_, fields))
- app._timer.stop()
- app._drain()
- app.state.ingest({"type": "mode", "mode": "chat"})
- await pilot.press("a")
- await pilot.pause()
- asyncio.run(scenario())
- assert [t for t, _ in sent] == ["chat_toggle", "command_start"], sent
- def test_type_command_enter_from_chat_leaves_chat_first(server):
- pytest.importorskip("textual")
- from textual.widgets import Input
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path)
- sent: list[tuple] = []
- async def scenario():
- async with app.run_test() as pilot:
- await pilot.pause()
- app.client.send = lambda type_, **fields: sent.append((type_, fields))
- app._timer.stop()
- app._drain()
- app.state.ingest({"type": "mode", "mode": "chat"})
- app.query_one("#command", Input).focus()
- await pilot.pause()
- for ch in "open calendar":
- await pilot.press(ch)
- await pilot.pause()
- await pilot.press("enter")
- await pilot.pause()
- asyncio.run(scenario())
- assert [t for t, _ in sent] == [
- "chat_toggle",
- "command_start",
- "command_edit",
- "command_execute",
- ], sent
- def test_command_from_chat_mode_reaches_executing(server):
- """A command sent while chatting leaves chat and actually executes."""
- pytest.importorskip("textual")
- from textual.widgets import Input
- from stt_daemon.tui.app import AssistantTUI
- path, service = server
- app = AssistantTUI(path)
- async def scenario():
- async with app.run_test() as pilot:
- await pilot.pause()
- assert service.toggle_chat() is True
- for _ in range(100):
- await pilot.pause()
- if app.state.mode == "chat":
- break
- assert app.state.mode == "chat"
- app.query_one("#command", Input).focus()
- await pilot.pause()
- for ch in "do it":
- await pilot.press(ch)
- await pilot.pause()
- await pilot.press("enter")
- for _ in range(100):
- await pilot.pause()
- if service.modes.mode.value == "executing":
- break
- asyncio.run(scenario())
- assert service.modes.mode.value == "executing"
- def test_typed_execute_clears_command_input(server):
- """Typing a command and pressing Enter clears the input as soon as it executes."""
- pytest.importorskip("textual")
- from textual.widgets import Input
- from stt_daemon.tui.app import AssistantTUI
- path, service = server
- app = AssistantTUI(path)
- observed: dict = {}
- async def scenario():
- async with app.run_test() as pilot:
- await pilot.pause()
- app.query_one("#command", Input).focus()
- await pilot.pause()
- for ch in "do it":
- await pilot.press(ch)
- await pilot.pause()
- await pilot.press("enter")
- for _ in range(100):
- await pilot.pause()
- if service.modes.mode.value == "executing":
- break
- await pilot.pause()
- observed["value"] = app.query_one("#command", Input).value
- observed["state_text"] = app.state.command_text
- asyncio.run(scenario())
- assert service.modes.mode.value == "executing"
- assert observed["value"] == ""
- assert observed["state_text"] == ""
- def test_chat_input_enter_activates_chat_sends_and_keeps_focus(server):
- """Typing in #chat-input + Enter enters chat, sends, clears, and keeps focus."""
- pytest.importorskip("textual")
- from textual.widgets import Input
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path)
- sent: list[tuple] = []
- observed: dict = {}
- async def scenario():
- async with app.run_test(size=(80, 40)) as pilot:
- await pilot.pause()
- app.client.send = lambda type_, **fields: sent.append((type_, fields))
- app._timer.stop()
- app._drain()
- chat_input = app.query_one("#chat-input", Input)
- chat_input.focus()
- await pilot.pause()
- for ch in "hello":
- await pilot.press(ch)
- await pilot.pause()
- await pilot.press("enter")
- await pilot.pause()
- observed["value"] = chat_input.value
- observed["focused"] = chat_input.has_focus
- observed["lower_view"] = app._lower_view
- asyncio.run(scenario())
- assert [t for t, _ in sent] == ["chat_toggle", "chat_send"], sent
- assert sent[1][1] == {"text": "hello"}
- assert observed["value"] == ""
- assert observed["focused"] is True
- assert observed["lower_view"] == "chat"
- def test_chat_input_from_chat_sends_without_toggle(server):
- pytest.importorskip("textual")
- from textual.widgets import Input
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path)
- sent: list[tuple] = []
- async def scenario():
- async with app.run_test(size=(80, 40)) as pilot:
- await pilot.pause()
- app.client.send = lambda type_, **fields: sent.append((type_, fields))
- app._timer.stop()
- app._drain()
- app.state.ingest({"type": "mode", "mode": "chat"})
- chat_input = app.query_one("#chat-input", Input)
- chat_input.focus()
- await pilot.pause()
- for ch in "again":
- await pilot.press(ch)
- await pilot.pause()
- await pilot.press("enter")
- await pilot.pause()
- asyncio.run(scenario())
- assert [t for t, _ in sent] == ["chat_send"], sent
- def test_chat_input_enter_activates_chat_and_sends(server):
- """End-to-end: a typed chat message enters chat mode and lands in history."""
- pytest.importorskip("textual")
- from textual.widgets import Input
- from stt_daemon.tui.app import AssistantTUI
- path, service = server
- app = AssistantTUI(path)
- observed: dict = {}
- async def scenario():
- async with app.run_test(size=(80, 40)) as pilot:
- await pilot.pause()
- chat_input = app.query_one("#chat-input", Input)
- chat_input.focus()
- await pilot.pause()
- for ch in "hello":
- await pilot.press(ch)
- await pilot.pause()
- await pilot.press("enter")
- for _ in range(100):
- await pilot.pause()
- if service.modes.mode.value == "chat" and service.chat_history:
- break
- observed["mode"] = service.modes.mode.value
- observed["history"] = list(service.chat_history)
- asyncio.run(scenario())
- assert observed["mode"] == "chat"
- assert observed["history"] == ["hello"]
- def test_command_and_chat_inputs_share_a_row(server):
- """#chat-input sits to the right of #command on the same row, titled 'chat'."""
- pytest.importorskip("textual")
- from textual.widgets import Input
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path)
- observed: dict = {}
- async def scenario():
- async with app.run_test(size=(80, 40)) as pilot:
- await pilot.pause()
- command = app.query_one("#command", Input)
- chat = app.query_one("#chat-input", Input)
- observed["same_row"] = command.region.y == chat.region.y
- observed["chat_right"] = chat.region.x > command.region.x
- observed["title"] = chat.border_title
- observed["widths"] = (command.region.width, chat.region.width)
- asyncio.run(scenario())
- assert observed["same_row"] is True
- assert observed["chat_right"] is True
- assert observed["title"] == "chat"
- command_w, chat_w = observed["widths"]
- assert abs(command_w - chat_w) <= 2, observed["widths"]
- def test_empty_command_enter_sends_nothing(server):
- pytest.importorskip("textual")
- from textual.widgets import Input
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path)
- sent: list[tuple] = []
- async def scenario():
- async with app.run_test() as pilot:
- await pilot.pause()
- app.client.send = lambda type_, **fields: sent.append((type_, fields))
- app.query_one("#command", Input).focus()
- await pilot.pause()
- await pilot.press("enter")
- await pilot.pause()
- asyncio.run(scenario())
- assert sent == []
- def test_e_focuses_command_input(server):
- pytest.importorskip("textual")
- from textual.widgets import Input
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path)
- observed: dict = {}
- async def scenario():
- async with app.run_test() as pilot:
- await pilot.pause()
- app._timer.stop() # no background drain: server status must not clobber state
- app._drain()
- app.state.ingest({"type": "command_text", "text": "voice text", "edited": False})
- app._render()
- await pilot.pause()
- await pilot.press("e")
- await pilot.pause()
- cmd = app.query_one("#command", Input)
- observed["focused"] = cmd.has_focus
- observed["value"] = cmd.value
- asyncio.run(scenario())
- assert observed["focused"] is True
- assert observed["value"] == "voice text"
- def test_voice_text_updates_unfocused_but_not_focused(server):
- pytest.importorskip("textual")
- from textual.widgets import Input
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path)
- observed: dict = {}
- async def scenario():
- async with app.run_test() as pilot:
- await pilot.pause()
- app._timer.stop() # no background drain: server status must not clobber state
- app._drain()
- cmd = app.query_one("#command", Input)
- assert not cmd.has_focus
- app.state.ingest({"type": "command_text", "text": "voice built", "edited": False})
- app._render()
- await pilot.pause()
- observed["unfocused_value"] = app.query_one("#command", Input).value
- app.query_one("#command", Input).focus()
- await pilot.pause()
- for ch in "user typing":
- await pilot.press(ch)
- await pilot.pause()
- app.state.ingest({"type": "command_text", "text": "voice late", "edited": False})
- app._render()
- await pilot.pause()
- observed["focused_value"] = app.query_one("#command", Input).value
- asyncio.run(scenario())
- assert observed["unfocused_value"] == "voice built"
- assert observed["focused_value"] == "user typing"
- def test_command_input_border_title(server):
- pytest.importorskip("textual")
- from textual.widgets import Input
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path)
- async def scenario():
- async with app.run_test() as pilot:
- await pilot.pause()
- app._render()
- await pilot.pause()
- return app.query_one("#command", Input).border_title
- title = asyncio.run(scenario())
- assert "command buffer" in title
- def test_no_autofocus_on_mount(server):
- """Nothing is focused on mount: single-letter hotkeys must not be swallowed by #command."""
- pytest.importorskip("textual")
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path)
- async def scenario():
- async with app.run_test() as pilot:
- await pilot.pause()
- await pilot.pause()
- return app.focused
- assert asyncio.run(scenario()) is None
- def test_submit_blurs_command_and_hotkey_still_fires(server):
- """Type + Enter sends messages, clears focus; a hotkey (t) still fires afterwards."""
- pytest.importorskip("textual")
- from textual.widgets import Input
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path)
- sent: list[tuple] = []
- observed: dict = {}
- async def scenario():
- async with app.run_test() as pilot:
- await pilot.pause()
- app.client.send = lambda type_, **fields: sent.append((type_, fields))
- app._timer.stop() # no background drain: server status must not clobber state
- app._drain()
- app.query_one("#command", Input).focus()
- await pilot.pause()
- for ch in "take notes":
- await pilot.press(ch)
- await pilot.pause()
- await pilot.press("enter")
- await pilot.pause()
- observed["focused_after_submit"] = app.focused
- observed["value_after_submit"] = app.query_one("#command", Input).value
- await pilot.press("t")
- await pilot.pause()
- observed["lower_view"] = app._lower_view
- asyncio.run(scenario())
- assert [t for t, _ in sent] == [
- "command_start",
- "command_edit",
- "command_execute",
- ], sent
- assert observed["focused_after_submit"] is None
- assert observed["value_after_submit"] == ""
- assert observed["lower_view"] == "output"
- def test_focus_triage_then_m_maximizes_deterministically(server):
- """Focus #triage, assert focus stuck, then m maximizes #main (no auto-focus race)."""
- pytest.importorskip("textual")
- from textual.widgets import RichLog
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path, triage_enabled=True)
- observed: dict = {}
- async def scenario():
- async with app.run_test(size=(80, 40)) as pilot:
- await pilot.pause()
- app.query_one("#triage", RichLog).focus()
- await pilot.pause()
- assert app.focused is not None and app.focused.id == "triage", app.focused
- await pilot.press("m")
- await pilot.pause()
- observed["maximized_is_main"] = app.screen.maximized is app.query_one("#main")
- asyncio.run(scenario())
- assert observed["maximized_is_main"] is True
- def test_c_key_confirms_before_clearing(server):
- pytest.importorskip("textual")
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path)
- sent: list[tuple] = []
- async def scenario():
- async with app.run_test() as pilot:
- await pilot.pause()
- app.client.send = lambda type_, **fields: sent.append((type_, fields))
- await pilot.press("c")
- await pilot.pause()
- assert not any(type_ == "clear" for type_, _ in sent), "clear sent before confirmation"
- await pilot.press("y")
- await pilot.pause()
- asyncio.run(scenario())
- assert ("clear", {}) in sent
- def test_c_key_hides_visible_pane(server):
- """Confirming clear hides whatever lower pane was showing."""
- pytest.importorskip("textual")
- from textual.widgets import RichLog
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path)
- async def scenario():
- async with app.run_test(size=(80, 40)) as pilot:
- await pilot.pause()
- app.state.ingest(
- {"type": "tool_result", "tool": "get_datetime", "ok": True, "result": "now"}
- )
- app._lower_view = "output"
- app._render()
- await pilot.pause()
- assert app.query_one("#output", RichLog).display is True
- await pilot.press("c")
- await pilot.pause()
- await pilot.press("y")
- for _ in range(100):
- await pilot.pause()
- if app._lower_view == "hidden":
- break
- asyncio.run(scenario())
- assert app._lower_view == "hidden"
- pytest.importorskip("textual")
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path)
- sent: list[tuple] = []
- async def scenario():
- async with app.run_test() as pilot:
- await pilot.pause()
- app.client.send = lambda type_, **fields: sent.append((type_, fields))
- await pilot.press("c")
- await pilot.pause()
- await pilot.press("n")
- await pilot.pause()
- asyncio.run(scenario())
- assert not any(type_ == "clear" for type_, _ in sent)
- def test_c_key_escape_does_not_clear(server):
- pytest.importorskip("textual")
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path)
- sent: list[tuple] = []
- async def scenario():
- async with app.run_test() as pilot:
- await pilot.pause()
- app.client.send = lambda type_, **fields: sent.append((type_, fields))
- await pilot.press("c")
- await pilot.pause()
- await pilot.press("escape")
- await pilot.pause()
- asyncio.run(scenario())
- assert not any(type_ == "clear" for type_, _ in sent)
- def test_c_key_clears_rendered_output(server):
- """Pressing 'c' clears the conversation pane via the cleared event."""
- pytest.importorskip("textual")
- from textual.widgets import RichLog
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path)
- async def scenario():
- async with app.run_test() as pilot:
- await pilot.pause()
- app.state.ingest({"type": "chat_message", "role": "user", "text": "hello"})
- app.state.ingest(
- {
- "type": "tool_result",
- "tool": "get_datetime",
- "ok": True,
- "result": "2026-09-21",
- }
- )
- app._render()
- await pilot.pause()
- app.state.ingest({"type": "cleared"})
- app._render()
- await pilot.pause()
- log = app.query_one("#chat", RichLog)
- return list(log.lines)
- lines = asyncio.run(scenario())
- assert not any("hello" in str(line) for line in lines)
- assert not any("2026-09-21" in str(line) for line in lines)
- def test_tui_renders_command_output(server):
- """A tool result must actually reach a widget - otherwise 'nothing happens'."""
- pytest.importorskip("textual")
- from textual.widgets import RichLog
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path)
- async def scenario():
- async with app.run_test() as pilot:
- await pilot.pause()
- app.state.ingest(
- {
- "type": "tool_result",
- "tool": "get_datetime",
- "ok": True,
- "result": "2026-09-21T01:00:00",
- }
- )
- app._render()
- await pilot.pause()
- log = app.query_one("#output", RichLog)
- return list(log.lines)
- lines = asyncio.run(scenario())
- assert lines, "command output was not rendered anywhere"
- def test_command_output_and_chat_render_in_separate_panes(server):
- """Tool output lands in #output and chat turns in #chat, with no crossover."""
- pytest.importorskip("textual")
- from textual.widgets import RichLog
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path)
- async def scenario():
- async with app.run_test() as pilot:
- await pilot.pause()
- app._timer.stop() # no background drain: server status must not clobber state
- app._drain()
- app.state.ingest(
- {"type": "tool_result", "tool": "get_datetime", "ok": True, "result": "cmd"}
- )
- app.state.ingest({"type": "mode", "mode": "chat"})
- app.state.ingest({"type": "chat_message", "role": "user", "text": "hi"})
- app._render()
- await pilot.pause()
- output = [str(line) for line in app.query_one("#output", RichLog).lines]
- chat = [str(line) for line in app.query_one("#chat", RichLog).lines]
- return output, chat
- output, chat = asyncio.run(scenario())
- assert any("cmd" in line for line in output)
- assert not any("hi" in line for line in output)
- assert any("hi" in line for line in chat)
- assert not any("cmd" in line for line in chat)
- def test_tui_keeps_chat_pane_on_chat_exit(server):
- """Leaving chat keeps the rendered conversation; only clear drops it."""
- pytest.importorskip("textual")
- from textual.widgets import RichLog
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path)
- async def scenario():
- async with app.run_test() as pilot:
- await pilot.pause()
- app.state.ingest(
- {"type": "tool_result", "tool": "get_datetime", "ok": True, "result": "kept"}
- )
- app.state.ingest({"type": "mode", "mode": "chat"})
- app.state.ingest({"type": "chat_message", "role": "user", "text": "kept-turn"})
- app._render()
- await pilot.pause()
- app.state.ingest({"type": "mode", "mode": "idle"})
- app._render()
- await pilot.pause()
- chat = [str(line) for line in app.query_one("#chat", RichLog).lines]
- output = [str(line) for line in app.query_one("#output", RichLog).lines]
- app.state.ingest({"type": "cleared"})
- app._render()
- await pilot.pause()
- cleared = [str(line) for line in app.query_one("#chat", RichLog).lines]
- return chat, output, cleared
- chat_lines, output_lines, cleared = asyncio.run(scenario())
- assert any("kept-turn" in line for line in chat_lines)
- assert not any("kept-turn" in line for line in output_lines)
- assert any("kept" in line for line in output_lines)
- assert cleared == []
- def test_tui_mounts_subscribes_and_ingests_broadcasts(server):
- pytest.importorskip("textual")
- from stt_daemon.tui.app import AssistantTUI
- path, service = server
- app = AssistantTUI(path)
- async def scenario():
- async with app.run_test() as pilot:
- await pilot.pause()
- service.arm() # broadcast to the subscribed viewer
- for _ in range(60):
- await pilot.pause()
- if app.state.mode == "command_armed":
- break
- await asyncio.sleep(0.05)
- asyncio.run(scenario())
- assert app.state.mode == "command_armed"
- def test_render_after_unmount_is_safe(server):
- """A timer tick can land during teardown; rendering must not raise."""
- pytest.importorskip("textual")
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path)
- async def scenario():
- async with app.run_test() as pilot:
- await pilot.pause()
- asyncio.run(scenario())
- app._render() # must not raise NoMatches
- def test_tui_records_connect_failure(tmp_path):
- pytest.importorskip("textual")
- from stt_daemon.tui.app import AssistantTUI
- app = AssistantTUI(str(tmp_path / "missing.sock"))
- async def scenario():
- async with app.run_test() as pilot:
- await pilot.pause()
- asyncio.run(scenario())
- assert app.connect_error is not None
- assert "cannot connect" in app.connect_error
- def test_border_titles_show_queue_and_inference_depths(server):
- """Border titles for transcript and chat show live segment and inference queue counts."""
- pytest.importorskip("textual")
- from textual.widgets import RichLog
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path)
- async def scenario():
- async with app.run_test() as pilot:
- await pilot.pause()
- app.state.ingest(
- {
- "type": "heartbeat",
- "queue_depth": 5,
- "inference_depth": 3,
- "inference_in_flight": False,
- }
- )
- app._render()
- await pilot.pause()
- transcript_title = app.query_one("#transcript", RichLog).border_title
- chat_title = app.query_one("#chat", RichLog).border_title
- assert "[seg 5]" in transcript_title
- assert "[inf 3]" in chat_title
- assert "busy" not in chat_title
- # Test with inference in flight
- app.state.ingest(
- {
- "type": "heartbeat",
- "inference_depth": 7,
- "inference_in_flight": True,
- }
- )
- app._render()
- await pilot.pause()
- chat_title = app.query_one("#chat", RichLog).border_title
- assert "[inf 7" in chat_title
- assert "busy" in chat_title
- asyncio.run(scenario())
- def test_main_exit_code_reflects_connect_failure(monkeypatch, tmp_path, capsys):
- pytest.importorskip("textual")
- from stt_daemon.tui import app as tui_app
- monkeypatch.setattr(
- tui_app.AssistantTUI,
- "run",
- lambda self: setattr(self, "connect_error", "boom"),
- )
- monkeypatch.setattr(sys, "argv", ["stt-tui", "--socket", str(tmp_path / "x.sock")])
- assert tui_app.main() == 1
- assert "boom" in capsys.readouterr().err
- monkeypatch.setattr(tui_app.AssistantTUI, "run", lambda self: None)
- assert tui_app.main() == 0
- def test_triage_disabled_composes_no_triage_widget(server):
- """Default (triage_enabled=False): no #triage, no #main — tree as today."""
- pytest.importorskip("textual")
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path)
- async def scenario():
- async with app.run_test() as pilot:
- await pilot.pause()
- assert not app.query("#triage")
- assert not app.query("#main")
- # The rest of the tree is intact.
- app.query_one("#transcript")
- app.query_one("#status")
- app.query_one("#command")
- app.query_one("#chat")
- asyncio.run(scenario())
- def test_triage_enabled_composes_split(server):
- """triage_enabled=True: #main wraps #triage (left) + #chat (right); transcript stays outside."""
- pytest.importorskip("textual")
- from textual.containers import Horizontal
- from textual.widgets import RichLog
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path, triage_enabled=True)
- async def scenario():
- async with app.run_test() as pilot:
- await pilot.pause()
- main = app.query_one("#main", Horizontal)
- transcript = app.query_one("#transcript", RichLog)
- triage = app.query_one("#triage", RichLog)
- chat = app.query_one("#chat", RichLog)
- row = app.query_one("#chat-row", Horizontal)
- assert triage.parent is main
- assert row.parent is main
- assert chat.parent is row
- assert transcript.parent is not main
- assert triage.border_title == "triage \\[inf 0]" # markup: '[' is escaped
- # triage sits LEFT of the chat row inside #main.
- assert main.children.index(triage) < main.children.index(row)
- # Outside widgets stay outside #main.
- assert app.query_one("#status").parent is not main
- assert transcript.parent is not main
- asyncio.run(scenario())
- def test_triage_enabled_layout_proportions(server):
- """Rendered proportions: #triage ~1/3 and #chat ~2/3 of #main; #transcript ~25% of screen."""
- pytest.importorskip("textual")
- from textual.containers import Horizontal
- from textual.widgets import RichLog
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path, triage_enabled=True)
- async def scenario(size=None):
- kwargs = {"size": size} if size else {}
- async with app.run_test(**kwargs) as pilot:
- await pilot.pause()
- app._timer.stop() # no background drain: server status must not clobber state
- app._drain()
- # Chat needs content to be shown (hidden-by-default rule): put it in
- # chat mode so the 1/3-2/3 split applies.
- app._lower_view = "chat"
- app.state.ingest({"type": "mode", "mode": "chat"})
- app._render()
- await pilot.pause()
- main = app.query_one("#main", Horizontal)
- transcript = app.query_one("#transcript", RichLog)
- triage = app.query_one("#triage", RichLog)
- chat_row = app.query_one("#chat-row", Horizontal)
- return (
- main.size.width,
- triage.size.width,
- chat_row.size.width,
- app.screen.size.height,
- transcript.region.height,
- )
- main_w, triage_w, chat_w, screen_h, transcript_h = asyncio.run(scenario())
- print(
- f"measured: main_w={main_w} triage_w={triage_w} chat_w={chat_w} "
- f"screen_h={screen_h} transcript_h={transcript_h}"
- )
- # The chat row (chat plus its capture controls) keeps the 2/3 share; the
- # controls column is carved out of it. Content boxes exclude the 1-cell
- # border on each side; allow ±2 px for borders/rounding.
- assert abs(triage_w / main_w - 1 / 3) < 0.05, (main_w, triage_w, chat_w)
- assert abs(chat_w / main_w - 2 / 3) < 0.05, (main_w, triage_w, chat_w)
- # region.height includes the transcript's 2 border rows; at the small
- # default size rounding is coarse, so only demand a floor here. The
- # precise 25% check runs at a taller size below.
- assert transcript_h / screen_h >= 0.18, (screen_h, transcript_h)
- assert transcript_h >= 1, transcript_h
- app2 = AssistantTUI(path, triage_enabled=True)
- async def scenario_tall():
- async with app2.run_test(size=(80, 40)) as pilot:
- await pilot.pause()
- transcript2 = app2.query_one("#transcript", RichLog)
- return app2.screen.size.height, transcript2.region.height
- screen_h2, transcript_h2 = asyncio.run(scenario_tall())
- print(f"measured tall: screen_h={screen_h2} transcript_h={transcript_h2}")
- assert abs(transcript_h2 / screen_h2 - 0.25) < 0.06, (screen_h2, transcript_h2)
- def test_triage_enabled_renders_signals_on_epoch_change(server):
- """A triage event + status event must actually write rendered lines into #triage."""
- pytest.importorskip("textual")
- from textual.widgets import RichLog
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path, triage_enabled=True)
- async def scenario():
- async with app.run_test() as pilot:
- await pilot.pause()
- app._timer.stop() # no background drain: server status must not clobber state
- app._drain()
- app.state.ingest(
- {
- "type": "triage",
- "stage": "signals",
- "triage": {
- "order": ["category"],
- "items": {
- "category": {
- "kind": "choice",
- "choice": "question",
- "answer_confidence": 0.91,
- }
- },
- },
- }
- )
- app.state.ingest(
- {"type": "status", "triage_state": "ok", "triage_reason": ""}
- )
- app._render()
- await pilot.pause()
- return [str(line) for line in app.query_one("#triage", RichLog).lines]
- lines = asyncio.run(scenario())
- rendered = "\n".join(lines)
- assert "category: question (0.91)" in rendered
- def test_triage_enabled_loading_shows_reason_line_never_blank(server):
- """loading/unknown triage_state renders the reason line — never a blank panel."""
- pytest.importorskip("textual")
- from textual.widgets import RichLog
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path, triage_enabled=True)
- async def scenario():
- async with app.run_test() as pilot:
- await pilot.pause()
- app._timer.stop() # no background drain: server status must not clobber state
- app._drain()
- app.state.ingest(
- {
- "type": "status",
- "triage_state": "loading",
- "triage_reason": "model loading",
- }
- )
- app._render()
- await pilot.pause()
- loading_lines = [str(line) for line in app.query_one("#triage", RichLog).lines]
- # Unknown state falls back to a generic reason line, no crash.
- app.state.ingest(
- {"type": "status", "triage_state": "weird_state", "triage_reason": ""}
- )
- app._render()
- await pilot.pause()
- unknown_lines = [str(line) for line in app.query_one("#triage", RichLog).lines]
- return loading_lines, unknown_lines
- loading_lines, unknown_lines = asyncio.run(scenario())
- assert loading_lines, "triage panel was blank in loading state"
- assert any("triage: unavailable" in line for line in loading_lines)
- assert any("model loading" in line for line in loading_lines)
- assert unknown_lines, "triage panel was blank for unknown state"
- assert any("triage: unavailable" in line for line in unknown_lines)
- assert any("weird_state" in line for line in unknown_lines)
- def test_triage_epoch_change_clears_before_rewrite(server):
- """Epoch bump clears #triage first: no duplicated stale lines."""
- pytest.importorskip("textual")
- from textual.widgets import RichLog
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path, triage_enabled=True)
- async def scenario():
- async with app.run_test() as pilot:
- await pilot.pause()
- app._timer.stop() # no background drain: server status must not clobber state
- app._drain()
- app.state.ingest(
- {
- "type": "triage",
- "stage": "signals",
- "triage": {"order": ["q1"], "items": {"q1": {"kind": "choice", "choice": "a"}}},
- }
- )
- app.state.ingest({"type": "status", "triage_state": "ok"})
- app._render()
- await pilot.pause()
- # New epoch: cleared stage empties the payload, panel must not keep old lines.
- app.state.ingest({"type": "triage", "stage": "cleared"})
- app._render()
- await pilot.pause()
- return [str(line) for line in app.query_one("#triage", RichLog).lines]
- lines = asyncio.run(scenario())
- rendered = "\n".join(lines)
- assert "q1" not in rendered, "stale triage lines survived an epoch change"
- assert "triage: unavailable — no data" in rendered
- def test_triage_disabled_never_writes_triage(server):
- """Disabled: triage events are ingested but #triage is never touched (it doesn't exist)."""
- pytest.importorskip("textual")
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path)
- async def scenario():
- async with app.run_test() as pilot:
- await pilot.pause()
- app.state.ingest({"type": "triage", "stage": "signals", "triage": {}})
- app.state.ingest({"type": "status", "triage_state": "ok"})
- app._render() # must not raise NoMatches for #triage
- await pilot.pause()
- assert not app.query("#triage")
- asyncio.run(scenario())
- def test_default_ctor_has_no_triage_arg(server):
- """AssistantTUI(path) stays valid: triage_enabled defaults to False."""
- pytest.importorskip("textual")
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path)
- assert app.triage_enabled is False
- assert app._seen_triage_epoch == 0
- def test_maximize_triage_hides_chat_and_fills_screen(server):
- """Focus #triage, press m: #main maximized, chat hidden, triage fills screen."""
- pytest.importorskip("textual")
- from textual.widgets import RichLog
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path, triage_enabled=True)
- observed: dict = {}
- async def scenario():
- async with app.run_test(size=(80, 40)) as pilot:
- await pilot.pause()
- app.query_one("#triage", RichLog).focus()
- await pilot.pause()
- assert app.focused is not None and app.focused.id == "triage", app.focused
- await pilot.press("m")
- await pilot.pause()
- observed["maximized_is_main"] = app.screen.maximized is app.query_one("#main")
- observed["triage_display"] = app.query_one("#triage", RichLog).display
- observed["chat_display"] = app.query_one("#chat", RichLog).display
- observed["region"] = app.query_one("#triage", RichLog).region
- observed["screen_size"] = app.screen.size
- asyncio.run(scenario())
- print("triage maximized: region=%s screen=%s" % (observed["region"], observed["screen_size"]))
- assert observed["maximized_is_main"] is True
- assert observed["triage_display"] is True
- assert observed["chat_display"] is False
- region = observed["region"]
- size = observed["screen_size"]
- assert abs(region.width - size.width) <= 2, (region, size)
- assert abs(region.height - size.height) <= 2, (region, size)
- def test_maximize_toggle_restores_layout(server):
- """Press m again: minimize restores panes; chat follows the content rule (hidden)."""
- pytest.importorskip("textual")
- from textual.widgets import RichLog
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path, triage_enabled=True)
- observed: dict = {}
- async def scenario():
- async with app.run_test(size=(80, 40)) as pilot:
- await pilot.pause()
- app.query_one("#triage", RichLog).focus()
- await pilot.pause()
- await pilot.press("m")
- await pilot.pause()
- await pilot.press("m")
- await pilot.pause()
- observed["maximized"] = app.screen.maximized
- observed["triage_display"] = app.query_one("#triage", RichLog).display
- observed["chat_display"] = app.query_one("#chat", RichLog).display
- asyncio.run(scenario())
- assert observed["maximized"] is None
- assert observed["triage_display"] is True
- # No chat/command output ingested: chat follows the content rule → hidden.
- assert observed["chat_display"] is False
- def test_maximize_transcript_fills_screen(server):
- """Focus #transcript, press m: transcript maximized and fills the screen."""
- pytest.importorskip("textual")
- from textual.widgets import RichLog
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path, triage_enabled=True)
- observed: dict = {}
- async def scenario():
- async with app.run_test(size=(80, 40)) as pilot:
- await pilot.pause()
- app.query_one("#transcript", RichLog).focus()
- await pilot.pause()
- await pilot.press("m")
- await pilot.pause()
- observed["maximized_is_transcript"] = (
- app.screen.maximized is app.query_one("#transcript")
- )
- observed["region"] = app.query_one("#transcript", RichLog).region
- observed["screen_size"] = app.screen.size
- await pilot.press("m")
- await pilot.pause()
- observed["restored"] = app.screen.maximized
- asyncio.run(scenario())
- print("transcript maximized: region=%s screen=%s"
- % (observed["region"], observed["screen_size"]))
- assert observed["maximized_is_transcript"] is True
- region = observed["region"]
- size = observed["screen_size"]
- assert abs(region.width - size.width) <= 2, (region, size)
- assert abs(region.height - size.height) <= 2, (region, size)
- assert observed["restored"] is None
- def test_escape_while_maximized_restores_panes(server):
- """Escape minimizes (ESCAPE_TO_MINIMIZE) and _drain restores the hidden sibling."""
- pytest.importorskip("textual")
- from textual.widgets import RichLog
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path, triage_enabled=True)
- observed: dict = {}
- async def scenario():
- async with app.run_test(size=(80, 40)) as pilot:
- await pilot.pause()
- app.query_one("#triage", RichLog).focus()
- await pilot.pause()
- await pilot.press("m")
- await pilot.pause()
- assert app.screen.maximized is not None
- await pilot.press("escape")
- await pilot.pause()
- # Let the 0.1s _drain timer run so the restore path executes.
- for _ in range(10):
- await pilot.pause()
- await asyncio.sleep(0.05)
- if app.screen.maximized is None:
- break
- observed["maximized"] = app.screen.maximized
- observed["triage_display"] = app.query_one("#triage", RichLog).display
- observed["chat_display"] = app.query_one("#chat", RichLog).display
- asyncio.run(scenario())
- assert observed["maximized"] is None
- assert observed["triage_display"] is True
- # No chat/command output ingested: chat follows the content rule → hidden.
- assert observed["chat_display"] is False
- def test_maximize_chat_when_triage_disabled(server):
- """triage_enabled=False: focus #chat, press m maximizes the chat row (with controls)."""
- pytest.importorskip("textual")
- from textual.widgets import RichLog
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path)
- observed: dict = {}
- async def scenario():
- async with app.run_test(size=(80, 40)) as pilot:
- await pilot.pause()
- app._timer.stop() # keep the server status from re-hiding #chat
- # Disabled: no #main/#triage. #chat starts hidden, so reveal it via
- # the cycle, then focus it and press m to maximize #chat itself.
- app._lower_view = "chat"
- app.state.ingest({"type": "mode", "mode": "chat"})
- app.state.ingest({"type": "chat_message", "role": "user", "text": "hi"})
- app._render()
- await pilot.pause()
- app.query_one("#chat", RichLog).focus()
- await pilot.pause()
- await pilot.press("m")
- await pilot.pause()
- observed["maximized"] = app.screen.maximized
- observed["chat_row"] = app.query_one("#chat-row")
- observed["region"] = app.query_one("#chat", RichLog).region
- observed["screen_size"] = app.screen.size
- await pilot.press("m")
- await pilot.pause()
- observed["restored"] = app.screen.maximized
- asyncio.run(scenario())
- print("chat maximized (disabled): region=%s screen=%s"
- % (observed["region"], observed["screen_size"]))
- assert observed["maximized"] is observed["chat_row"]
- assert observed["restored"] is None
- def test_maximize_chat_keeps_chat_input_at_bottom(server):
- """Maximizing the conversation keeps only #chat-input, docked at the bottom."""
- pytest.importorskip("textual")
- from textual.widgets import Input, RichLog
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path, triage_enabled=True)
- observed: dict = {}
- async def scenario():
- async with app.run_test(size=(80, 40)) as pilot:
- await pilot.pause()
- app._timer.stop()
- app._drain()
- app._lower_view = "chat"
- app.state.ingest({"type": "mode", "mode": "chat"})
- app.state.ingest({"type": "chat_message", "role": "user", "text": "hi"})
- app._render()
- await pilot.pause()
- app.query_one("#chat", RichLog).focus()
- await pilot.pause()
- await pilot.press("m")
- await pilot.pause()
- chat = app.query_one("#chat", RichLog)
- chat_input = app.query_one("#chat-input", Input)
- command = app.query_one("#command", Input)
- observed["allow"] = app.ALLOW_IN_MAXIMIZED_VIEW
- observed["chat_input_display"] = chat_input.display
- observed["command_display"] = command.display
- observed["chat_region"] = chat.region
- observed["input_region"] = chat_input.region
- observed["input_below_chat"] = chat_input.region.y > chat.region.y
- observed["input_bottom"] = (
- chat_input.region.y + chat_input.region.height
- >= app.screen.size.height - 1
- )
- await pilot.press("m")
- await pilot.pause()
- observed["restored_allow"] = app.ALLOW_IN_MAXIMIZED_VIEW
- observed["restored_command"] = command.display
- observed["restored_chat_input"] = chat_input.display
- asyncio.run(scenario())
- assert observed["allow"] == "#inputs"
- assert observed["chat_input_display"] is True
- assert observed["command_display"] is False
- assert observed["input_below_chat"] is True, observed
- assert observed["input_bottom"] is True
- assert observed["restored_allow"] == ".__never__"
- assert observed["restored_command"] is True
- assert observed["restored_chat_input"] is True
- def test_maximize_output_keeps_command_input_at_bottom(server):
- """Maximizing the command output keeps only #command, docked at the bottom."""
- pytest.importorskip("textual")
- from textual.widgets import Input, RichLog
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path, triage_enabled=True)
- observed: dict = {}
- async def scenario():
- async with app.run_test(size=(80, 40)) as pilot:
- await pilot.pause()
- app._timer.stop()
- app._drain()
- app._lower_view = "output"
- app.state.ingest(
- {"type": "tool_result", "tool": "get_datetime", "ok": True, "result": "now"}
- )
- app._render()
- await pilot.pause()
- app.query_one("#output", RichLog).focus()
- await pilot.pause()
- await pilot.press("m")
- await pilot.pause()
- output = app.query_one("#output", RichLog)
- command = app.query_one("#command", Input)
- chat_input = app.query_one("#chat-input", Input)
- observed["allow"] = app.ALLOW_IN_MAXIMIZED_VIEW
- observed["command_display"] = command.display
- observed["chat_input_display"] = chat_input.display
- observed["input_below_output"] = command.region.y > output.region.y
- observed["input_bottom"] = (
- command.region.y + command.region.height >= app.screen.size.height - 1
- )
- await pilot.press("m")
- await pilot.pause()
- observed["restored_allow"] = app.ALLOW_IN_MAXIMIZED_VIEW
- observed["restored_command"] = command.display
- observed["restored_chat_input"] = chat_input.display
- asyncio.run(scenario())
- assert observed["allow"] == "#inputs"
- assert observed["command_display"] is True
- assert observed["chat_input_display"] is False
- assert observed["input_below_output"] is True, observed
- assert observed["input_bottom"] is True
- assert observed["restored_allow"] == ".__never__"
- assert observed["restored_command"] is True
- assert observed["restored_chat_input"] is True
- def test_tab_reaches_docked_input_when_maximized(server):
- """Tab moves focus between the maximized pane and its docked input."""
- pytest.importorskip("textual")
- from textual.widgets import RichLog
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path, triage_enabled=True)
- observed: dict = {}
- async def scenario():
- async with app.run_test(size=(80, 40)) as pilot:
- await pilot.pause()
- app._timer.stop()
- app._drain()
- app._lower_view = "chat"
- app.state.ingest({"type": "mode", "mode": "chat"})
- app.state.ingest({"type": "chat_message", "role": "user", "text": "hi"})
- app._render()
- await pilot.pause()
- app.query_one("#chat", RichLog).focus()
- await pilot.pause()
- await pilot.press("m")
- await pilot.pause()
- await pilot.press("tab")
- await pilot.pause()
- observed["chat_to_input"] = app.focused.id if app.focused else None
- await pilot.press("tab")
- await pilot.pause()
- observed["chat_back"] = app.focused.id if app.focused else None
- await pilot.press("m") # restore
- await pilot.pause()
- app._lower_view = "output"
- app.state.ingest(
- {"type": "tool_result", "tool": "get_datetime", "ok": True, "result": "now"}
- )
- app._render()
- await pilot.pause()
- app.query_one("#output", RichLog).focus()
- await pilot.pause()
- await pilot.press("m")
- await pilot.pause()
- await pilot.press("tab")
- await pilot.pause()
- observed["output_to_input"] = app.focused.id if app.focused else None
- asyncio.run(scenario())
- assert observed["chat_to_input"] == "chat-input"
- assert observed["chat_back"] == "chat"
- assert observed["output_to_input"] == "command"
- def test_footer_hidden_while_maximized(server):
- """ALLOW_IN_MAXIMIZED_VIEW hides the Footer while a pane is maximized."""
- pytest.importorskip("textual")
- from textual.widgets import Footer, RichLog
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path, triage_enabled=True)
- observed: dict = {}
- async def scenario():
- async with app.run_test(size=(80, 40)) as pilot:
- await pilot.pause()
- app.query_one("#triage", RichLog).focus()
- await pilot.pause()
- await pilot.press("m")
- await pilot.pause()
- observed["maximized"] = app.screen.maximized
- observed["footer_region"] = app.query_one(Footer).region
- await pilot.press("m")
- await pilot.pause()
- asyncio.run(scenario())
- assert observed["maximized"] is not None
- footer_region = observed["footer_region"]
- assert footer_region.height == 0 or footer_region.width == 0, observed
- def test_question_mark_key_toggles_help_panel(server):
- pytest.importorskip("textual")
- from textual.widgets import HelpPanel
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path)
- observed: dict = {}
- async def scenario():
- async with app.run_test() as pilot:
- await pilot.pause()
- assert list(app.screen.query(HelpPanel)) == []
- await pilot.press("question_mark")
- await pilot.pause()
- observed["shown"] = list(app.screen.query(HelpPanel)) != []
- app.screen.query_one(HelpPanel) # must succeed while shown
- await pilot.press("question_mark")
- await pilot.pause()
- observed["hidden"] = list(app.screen.query(HelpPanel)) == []
- asyncio.run(scenario())
- assert observed["shown"] is True, "? did not mount HelpPanel"
- assert observed["hidden"] is True, "second ? did not remove HelpPanel"
- def test_app_bindings_list():
- """BINDINGS: 8 visible footer entries; hidden ones after; no h binding."""
- pytest.importorskip("textual")
- from stt_daemon.tui.app import AssistantTUI
- pairs = [(b.key, b.description) for b in AssistantTUI.BINDINGS]
- assert ("ctrl+r", "execute") in pairs
- assert ("m", "maximize pane") in pairs
- assert ("question_mark", "help") in pairs
- assert ("ctrl+s", "save screenshot") in pairs
- assert ("a", "arm command") in pairs
- assert ("escape", "cancel") in pairs
- assert ("e", "edit command") in pairs
- assert ("t", "cycle pane") in pairs
- assert ("s", "questions view") in pairs
- assert ("c", "clear") in pairs
- assert ("q", "quit") in pairs
- assert ("ctrl+p", "instructions") in pairs
- assert not any(key == "h" for key, _ in pairs)
- assert not any(key == "ctrl+m" for key, _ in pairs)
- assert not any(key == "ctrl+h" for key, _ in pairs)
- assert not any(key == "enter" for key, _ in pairs)
- assert not any(key == "ctrl+k" for key, _ in pairs)
- by_key = {b.key: b for b in AssistantTUI.BINDINGS}
- assert by_key["question_mark"].show is True
- assert by_key["s"].show is True
- assert by_key["m"].show is False
- assert by_key["c"].show is False
- assert by_key["ctrl+s"].show is False
- assert by_key["ctrl+p"].show is False
- # Visible eight first, in footer order.
- visible = [(b.key, b.description) for b in AssistantTUI.BINDINGS if b.show]
- assert visible == [
- ("a", "arm command"),
- ("ctrl+r", "execute"),
- ("escape", "cancel"),
- ("e", "edit command"),
- ("t", "cycle pane"),
- ("s", "questions view"),
- ("question_mark", "help"),
- ("q", "quit"),
- ]
- assert by_key["ctrl+p"].priority is True
- def test_footer_shows_ordered_entries(server):
- """Left list is the 8 visible bindings (? for help); instructions only right-aligned."""
- pytest.importorskip("textual")
- from textual.widgets import Footer
- from textual.widgets._footer import FooterKey
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path)
- observed: dict = {}
- async def scenario():
- async with app.run_test() as pilot:
- await pilot.pause()
- shown = [
- (b.binding.key, b.binding.description)
- for b in app.screen.active_bindings.values()
- if b.binding.show
- ]
- observed["shown"] = shown
- footer = app.query_one(Footer)
- keys = list(footer.query(FooterKey))
- observed["keys"] = [
- (k.key, k.key_display, k.description, k.action, set(k.classes))
- for k in keys
- ]
- asyncio.run(scenario())
- expected = [
- ("a", "arm command"),
- ("ctrl+r", "execute"),
- ("escape", "cancel"),
- ("e", "edit command"),
- ("t", "cycle pane"),
- ("s", "questions view"),
- ("question_mark", "help"),
- ("q", "quit"),
- ]
- assert observed["shown"] == expected, observed["shown"]
- # Help advertises as "?" in the footer.
- assert app.get_key_display(
- next(b for b in AssistantTUI.BINDINGS if b.key == "question_mark")
- ) == "?"
- # The composed Footer: exactly one "instructions" entry and it is the
- # right-aligned command-palette key; no left duplicate exists.
- instructions = [k for k in observed["keys"] if k[2] == "instructions"]
- assert len(instructions) == 1, observed["keys"]
- key, key_display, _desc, action, classes = instructions[0]
- assert action == "command_palette", instructions
- assert "-command-palette" in classes, instructions
- assert key == "ctrl+p", instructions
- non_palette = [k for k in observed["keys"] if "-command-palette" not in k[4]]
- assert not any(k[2] == "instructions" for k in non_palette), observed["keys"]
- assert not any(k[3] == "command_palette" for k in non_palette), observed["keys"]
- def test_ctrl_p_opens_command_palette(server):
- """ctrl+p still opens the palette; its binding description is 'instructions'."""
- pytest.importorskip("textual")
- from textual.command import CommandPalette
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path)
- observed: dict = {}
- async def scenario():
- async with app.run_test() as pilot:
- await pilot.pause()
- await pilot.press("ctrl+p")
- await pilot.pause()
- await pilot.pause()
- observed["is_palette"] = isinstance(app.screen, CommandPalette)
- observed["stack"] = [type(s).__name__ for s in app.screen_stack]
- asyncio.run(scenario())
- palette_binding = next(b for b in AssistantTUI.BINDINGS if b.key == "ctrl+p")
- assert palette_binding.description == "instructions"
- assert observed["is_palette"], "ctrl+p did not open the command palette: %s" % (
- observed["stack"],
- )
- def test_question_mark_toggles_help_panel(server):
- """? toggles the help panel and is advertised in the left footer list."""
- pytest.importorskip("textual")
- from textual.widgets import HelpPanel
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path)
- observed: dict = {}
- async def scenario():
- async with app.run_test() as pilot:
- await pilot.pause()
- assert list(app.screen.query(HelpPanel)) == []
- await pilot.press("?")
- await pilot.pause()
- observed["shown"] = list(app.screen.query(HelpPanel)) != []
- await pilot.press("?")
- await pilot.pause()
- observed["hidden"] = list(app.screen.query(HelpPanel)) == []
- asyncio.run(scenario())
- assert observed["shown"] is True, "? did not mount HelpPanel"
- assert observed["hidden"] is True, "second ? did not remove HelpPanel"
- def test_plain_m_maximizes_triage_pane(server):
- pytest.importorskip("textual")
- from stt_daemon.tui.app import AssistantTUI
- from textual.widgets import RichLog
- path, _service = server
- app = AssistantTUI(path, triage_enabled=True)
- async def scenario():
- async with app.run_test(size=(80, 40)) as pilot:
- await pilot.pause()
- app.query_one("#triage", RichLog).focus()
- await pilot.pause()
- await pilot.press("m")
- await pilot.pause()
- assert app.screen.maximized is app.query_one("#main")
- await pilot.press("m")
- await pilot.pause()
- assert app.screen.maximized is None
- assert app.query_one("#triage", RichLog).display is True
- # No chat/command output ingested: chat follows the content rule → hidden.
- assert app.query_one("#chat", RichLog).display is False
- asyncio.run(scenario())
- def test_screenshot_saves_svg(server, tmp_path, monkeypatch):
- pytest.importorskip("textual")
- import glob
- from stt_daemon.tui.app import AssistantTUI
- monkeypatch.chdir(tmp_path)
- path, _service = server
- app = AssistantTUI(path)
- async def scenario():
- async with app.run_test() as pilot:
- await pilot.pause()
- await pilot.press("ctrl+s")
- await pilot.pause()
- await pilot.pause()
- asyncio.run(scenario())
- out_dir = tmp_path / "screenshots"
- assert out_dir.is_dir(), "screenshots/ not created in %s" % tmp_path
- files = glob.glob(str(out_dir / "stt-tui-*.svg"))
- assert files, "no stt-tui-*.svg found in %s" % out_dir
- assert os.path.getsize(files[0]) > 0
- assert glob.glob(str(tmp_path / "stt-tui-*.svg")) == [], \
- "screenshot leaked into %s instead of screenshots/" % tmp_path
- def test_screenshot_saves_into_session_dir(server, tmp_path, monkeypatch):
- pytest.importorskip("textual")
- import glob
- from stt_daemon.tui.app import AssistantTUI
- monkeypatch.chdir(tmp_path)
- path, _service = server
- app = AssistantTUI(path)
- app.state.session_dir = "transcripts/x"
- async def scenario():
- async with app.run_test() as pilot:
- await pilot.pause()
- await pilot.press("ctrl+s")
- await pilot.pause()
- await pilot.pause()
- asyncio.run(scenario())
- out_dir = tmp_path / "transcripts" / "x" / "screenshots"
- assert out_dir.is_dir(), "session screenshots/ not created in %s" % out_dir
- files = glob.glob(str(out_dir / "stt-tui-*.svg"))
- assert files, "no stt-tui-*.svg found in %s" % out_dir
- assert os.path.getsize(files[0]) > 0
- assert glob.glob(str(tmp_path / "screenshots" / "stt-tui-*.svg")) == [], \
- "screenshot leaked into screenshots/ instead of the session dir"
- def test_chat_hidden_by_default_triage_fills_main(server):
- """Fresh state: #chat hidden, #triage fills the whole #main width."""
- pytest.importorskip("textual")
- from textual.containers import Horizontal
- from textual.widgets import RichLog
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path, triage_enabled=True)
- observed: dict = {}
- async def scenario():
- async with app.run_test(size=(80, 40)) as pilot:
- await pilot.pause()
- app._timer.stop() # no background drain: server status must not clobber state
- app._drain()
- app._render()
- await pilot.pause()
- observed["chat_display"] = app.query_one("#chat", RichLog).display
- observed["triage_display"] = app.query_one("#triage", RichLog).display
- observed["main_region"] = app.query_one("#main", Horizontal).region
- observed["triage_region"] = app.query_one("#triage", RichLog).region
- asyncio.run(scenario())
- print("hidden: main=%s triage=%s" % (observed["main_region"], observed["triage_region"]))
- assert observed["chat_display"] is False
- assert observed["triage_display"] is True
- assert (
- abs(observed["triage_region"].width - observed["main_region"].width) <= 2
- ), (observed["triage_region"], observed["main_region"])
- def test_chat_mode_shows_chat_and_restores_split(server):
- """mode=chat → #chat visible, triage back to ~1/3 width."""
- pytest.importorskip("textual")
- from textual.containers import Horizontal
- from textual.widgets import RichLog
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path, triage_enabled=True)
- observed: dict = {}
- async def scenario():
- async with app.run_test(size=(80, 40)) as pilot:
- await pilot.pause()
- app._timer.stop() # no background drain: server status must not clobber state
- app._drain()
- app._lower_view = "chat"
- app.state.ingest({"type": "mode", "mode": "chat"})
- app._render()
- await pilot.pause()
- observed["chat_display"] = app.query_one("#chat", RichLog).display
- observed["main_region"] = app.query_one("#main", Horizontal).region
- observed["triage_region"] = app.query_one("#triage", RichLog).region
- observed["chat_region"] = app.query_one("#chat-row", Horizontal).region
- asyncio.run(scenario())
- print(
- "shown: main=%s triage=%s chat=%s"
- % (observed["main_region"], observed["triage_region"], observed["chat_region"])
- )
- assert observed["chat_display"] is True
- main_w = observed["main_region"].width
- assert abs(observed["triage_region"].width / main_w - 1 / 3) < 0.08, observed
- assert abs(observed["chat_region"].width / main_w - 2 / 3) < 0.08, observed
- def test_tool_result_shows_output_pane(server):
- """Command output (tool_result) goes to #output, leaving #chat hidden."""
- pytest.importorskip("textual")
- from textual.widgets import RichLog
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path, triage_enabled=True)
- observed: dict = {}
- async def scenario():
- async with app.run_test(size=(80, 40)) as pilot:
- await pilot.pause()
- app._timer.stop() # no background drain: server status must not clobber state
- app._drain()
- app._lower_view = "output"
- app.state.ingest(
- {
- "type": "tool_result",
- "tool": "get_datetime",
- "ok": True,
- "result": "2026-09-21",
- }
- )
- app._render()
- await pilot.pause()
- observed["output_display"] = app.query_one("#output", RichLog).display
- observed["chat_display"] = app.query_one("#chat", RichLog).display
- asyncio.run(scenario())
- assert observed["output_display"] is True
- assert observed["chat_display"] is False
- def test_error_event_shows_output_pane(server):
- """An error event goes to #output, leaving #chat hidden."""
- pytest.importorskip("textual")
- from textual.widgets import RichLog
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path, triage_enabled=True)
- observed: dict = {}
- async def scenario():
- async with app.run_test(size=(80, 40)) as pilot:
- await pilot.pause()
- app._timer.stop() # no background drain: server status must not clobber state
- app._drain()
- app._lower_view = "output"
- app.state.ingest({"type": "error", "code": "boom", "message": "kaboom"})
- app._render()
- await pilot.pause()
- observed["output_display"] = app.query_one("#output", RichLog).display
- observed["chat_display"] = app.query_one("#chat", RichLog).display
- asyncio.run(scenario())
- assert observed["output_display"] is True
- assert observed["chat_display"] is False
- def test_executing_mode_shows_output_pane(server):
- """Assistant executing a command → #output visible, #chat hidden."""
- pytest.importorskip("textual")
- from textual.widgets import RichLog
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path, triage_enabled=True)
- observed: dict = {}
- async def scenario():
- async with app.run_test(size=(80, 40)) as pilot:
- await pilot.pause()
- app._timer.stop() # no background drain: server status must not clobber state
- app._drain()
- app._lower_view = "output"
- app.state.ingest({"type": "mode", "mode": "executing"})
- app._render()
- await pilot.pause()
- observed["output_display"] = app.query_one("#output", RichLog).display
- observed["chat_display"] = app.query_one("#chat", RichLog).display
- asyncio.run(scenario())
- assert observed["output_display"] is True
- assert observed["chat_display"] is False
- def test_cleared_hides_pane_empties_output_and_restores_full_width(server):
- """cleared empties #output and hides it again, triage full width."""
- pytest.importorskip("textual")
- from textual.containers import Horizontal
- from textual.widgets import RichLog
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path, triage_enabled=True)
- observed: dict = {}
- async def scenario():
- async with app.run_test(size=(80, 40)) as pilot:
- await pilot.pause()
- app._timer.stop() # no background drain: server status must not clobber state
- app._drain()
- app._lower_view = "output"
- app.state.ingest(
- {
- "type": "tool_result",
- "tool": "get_datetime",
- "ok": True,
- "result": "2026-09-21",
- }
- )
- app._render()
- await pilot.pause()
- assert app.query_one("#output", RichLog).display is True
- app.state.ingest({"type": "cleared"})
- app._render()
- await pilot.pause()
- observed["empty_output"] = [
- str(line) for line in app.query_one("#output", RichLog).lines
- ]
- observed["lower_view"] = app._lower_view
- observed["chat_display"] = app.query_one("#chat", RichLog).display
- observed["output_display"] = app.query_one("#output", RichLog).display
- observed["main_region"] = app.query_one("#main", Horizontal).region
- observed["triage_region"] = app.query_one("#triage", RichLog).region
- asyncio.run(scenario())
- print(
- "cleared: main=%s triage=%s"
- % (observed["main_region"], observed["triage_region"])
- )
- assert observed["empty_output"] == []
- assert observed["lower_view"] == "hidden"
- assert observed["chat_display"] is False
- assert observed["output_display"] is False
- assert (
- abs(observed["triage_region"].width - observed["main_region"].width) <= 2
- ), (observed["triage_region"], observed["main_region"])
- def test_cycle_lower_switches_panes(server):
- """t cycles hidden -> #output -> #chat -> hidden when triage is disabled."""
- pytest.importorskip("textual")
- from textual.widgets import RichLog
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path)
- observed: dict = {}
- def snap():
- return (
- app.query_one("#output", RichLog).display,
- app.query_one("#chat", RichLog).display,
- app.query_one("#transcript", RichLog).has_class("-grow"),
- )
- async def scenario():
- async with app.run_test(size=(80, 40)) as pilot:
- await pilot.pause()
- app._timer.stop() # no background drain: server status must not clobber state
- app._drain()
- app._render()
- await pilot.pause()
- observed["hidden"] = snap()
- app.action_cycle_lower()
- app._render()
- await pilot.pause()
- observed["output"] = snap()
- app.action_cycle_lower()
- app._render()
- await pilot.pause()
- observed["chat"] = snap()
- app.action_cycle_lower()
- app._render()
- await pilot.pause()
- observed["hidden_again"] = snap()
- asyncio.run(scenario())
- assert observed["hidden"] == (False, False, True)
- assert observed["output"] == (True, False, False)
- assert observed["chat"] == (False, True, False)
- assert observed["hidden_again"] == (False, False, True)
- def test_cycle_lower_enters_and_exits_chat_mode(server):
- """Selecting conversation sends chat_toggle on; leaving it toggles off but
- keeps the rendered turns until an explicit clear."""
- pytest.importorskip("textual")
- from textual.widgets import RichLog
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path)
- sent: list[tuple] = []
- observed: dict = {}
- async def scenario():
- async with app.run_test(size=(80, 40)) as pilot:
- await pilot.pause()
- app.client.send = lambda type_, **fields: sent.append((type_, fields))
- app._timer.stop()
- app._drain()
- app.action_cycle_lower() # hidden -> output
- app.action_cycle_lower() # output -> chat
- app._render()
- await pilot.pause()
- observed["after_enter"] = list(sent)
- app.state.ingest({"type": "mode", "mode": "chat"})
- app.state.ingest({"type": "chat_message", "role": "user", "text": "remember"})
- app._render()
- await pilot.pause()
- app.action_cycle_lower() # chat -> hidden (mode is chat -> exits)
- app._render()
- await pilot.pause()
- observed["after_exit"] = list(sent)
- observed["chat_lines"] = [str(line) for line in app.query_one("#chat", RichLog).lines]
- asyncio.run(scenario())
- assert observed["after_enter"] == [("chat_toggle", {})]
- assert observed["after_exit"] == [("chat_toggle", {}), ("chat_toggle", {})]
- assert any("remember" in line for line in observed["chat_lines"])
- def test_command_activity_reveals_output_pane(server):
- """Arming, executing, and chatting: command modes force #output; chat does not."""
- pytest.importorskip("textual")
- from textual.widgets import RichLog
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path, triage_enabled=True)
- observed: dict = {}
- async def scenario():
- async with app.run_test(size=(80, 40)) as pilot:
- await pilot.pause()
- app._timer.stop()
- app._drain()
- await pilot.pause()
- observed["hidden"] = app.query_one("#output", RichLog).display
- app.state.ingest({"type": "mode", "mode": "command_armed"})
- app._render()
- await pilot.pause()
- observed["armed"] = (
- app._lower_view,
- app.query_one("#output", RichLog).display,
- app.query_one("#chat", RichLog).display,
- )
- app._lower_view = "hidden"
- app.state.ingest({"type": "mode", "mode": "executing"})
- app._render()
- await pilot.pause()
- observed["executing"] = (
- app._lower_view,
- app.query_one("#output", RichLog).display,
- )
- app._lower_view = "chat"
- app.state.ingest({"type": "mode", "mode": "chat"})
- app._render()
- await pilot.pause()
- observed["chat"] = (
- app._lower_view,
- app.query_one("#output", RichLog).display,
- app.query_one("#chat", RichLog).display,
- )
- asyncio.run(scenario())
- assert observed["hidden"] is False
- assert observed["armed"] == ("output", True, False)
- assert observed["executing"] == ("output", True)
- assert observed["chat"] == ("chat", False, True)
- def test_maximize_restore_with_output_keeps_output_visible(server):
- """With ingested output, m then m restores with #output still visible."""
- pytest.importorskip("textual")
- from textual.widgets import RichLog
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path, triage_enabled=True)
- observed: dict = {}
- async def scenario():
- async with app.run_test(size=(80, 40)) as pilot:
- await pilot.pause()
- app._timer.stop() # no background drain: server status must not clobber state
- app._drain()
- app._lower_view = "output"
- app.state.ingest(
- {
- "type": "tool_result",
- "tool": "get_datetime",
- "ok": True,
- "result": "2026-09-21",
- }
- )
- app._render()
- await pilot.pause()
- assert app.query_one("#output", RichLog).display is True
- app.query_one("#triage", RichLog).focus()
- await pilot.pause()
- await pilot.press("m")
- await pilot.pause()
- assert app.screen.maximized is not None
- await pilot.press("m")
- await pilot.pause()
- observed["maximized"] = app.screen.maximized
- observed["triage_display"] = app.query_one("#triage", RichLog).display
- observed["output_display"] = app.query_one("#output", RichLog).display
- asyncio.run(scenario())
- assert observed["maximized"] is None
- assert observed["triage_display"] is True
- assert observed["output_display"] is True
- def test_transcript_pane_keeps_updating_past_cap(server):
- """Regression: past MAX_TRANSCRIPTS the running log must still append."""
- pytest.importorskip("textual")
- from textual.widgets import RichLog
- from stt_daemon.tui.app import AssistantTUI
- from stt_daemon.tui.state import MAX_TRANSCRIPTS
- path, _service = server
- app = AssistantTUI(path)
- observed: dict = {}
- async def scenario():
- async with app.run_test(size=(80, 40)) as pilot:
- await pilot.pause()
- app._timer.stop() # no background drain: server status must not clobber state
- app._drain()
- for i in range(MAX_TRANSCRIPTS + 5):
- app.state.ingest({"type": "transcript", "text": f"t{i}", "lang": "en"})
- app._render()
- await pilot.pause()
- app.state.ingest({"type": "transcript", "text": "after cap", "lang": "en"})
- app._render()
- await pilot.pause()
- log = app.query_one("#transcript", RichLog)
- observed["lines"] = [str(line) for line in log.lines]
- asyncio.run(scenario())
- assert any("after cap" in line for line in observed["lines"])
- assert len(observed["lines"]) > MAX_TRANSCRIPTS
- def _questions_payload():
- return {
- "topic": {
- "type": "choice",
- "instructions": "What is the main theme?",
- "criteria": {"planning": "roadmap or prioritization"},
- }
- }
- def _rendered_lines(log):
- return [line.text for line in log.lines]
- def _ingest_triage_signals(app):
- app.state.ingest(
- {
- "type": "triage",
- "stage": "signals",
- "triage": {
- "order": ["topic"],
- "items": {
- "topic": {
- "kind": "choice",
- "choice": "planning",
- "answer_confidence": 0.91,
- }
- },
- },
- }
- )
- app.state.ingest(
- {
- "type": "status",
- "triage_state": "ok",
- "triage_reason": "",
- "questions": _questions_payload(),
- }
- )
- def test_s_key_swaps_triage_pane_to_questions_view(server):
- """Press s: #triage border flips to questions and renders the structured blocks."""
- pytest.importorskip("textual")
- from textual.widgets import RichLog
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path, triage_enabled=True)
- observed: dict = {}
- async def scenario():
- async with app.run_test() as pilot:
- await pilot.pause()
- app._timer.stop() # no background drain: server status must not clobber state
- app._drain()
- _ingest_triage_signals(app)
- app._render()
- await pilot.pause()
- observed["default_title"] = app.query_one("#triage", RichLog).border_title
- observed["default_lines"] = _rendered_lines(app.query_one("#triage", RichLog))
- await pilot.press("s")
- await pilot.pause()
- observed["questions_title"] = app.query_one("#triage", RichLog).border_title
- observed["questions_lines"] = _rendered_lines(app.query_one("#triage", RichLog))
- asyncio.run(scenario())
- assert observed["default_title"] == "triage \\[inf 0]" # markup: '[' is escaped
- assert any("topic: planning (0.91)" in line for line in observed["default_lines"])
- assert observed["questions_title"] == "questions"
- rendered = "\n".join(observed["questions_lines"])
- assert "topic [choice]" in rendered
- assert "What is the main theme?" in rendered
- assert "planning — roadmap or prioritization" in rendered
- assert "topic: planning" not in rendered
- def test_s_key_again_restores_triage_view(server):
- """Second s: border flips back to triage and the signals view is rewritten."""
- pytest.importorskip("textual")
- from textual.widgets import RichLog
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path, triage_enabled=True)
- observed: dict = {}
- async def scenario():
- async with app.run_test() as pilot:
- await pilot.pause()
- app._timer.stop() # no background drain: server status must not clobber state
- app._drain()
- _ingest_triage_signals(app)
- app._render()
- await pilot.pause()
- await pilot.press("s")
- await pilot.pause()
- observed["mid_title"] = app.query_one("#triage", RichLog).border_title
- await pilot.press("s")
- await pilot.pause()
- observed["triage_title"] = app.query_one("#triage", RichLog).border_title
- observed["triage_lines"] = _rendered_lines(app.query_one("#triage", RichLog))
- asyncio.run(scenario())
- assert observed["mid_title"] == "questions"
- assert observed["triage_title"] == "triage \\[inf 0]" # markup: '[' is escaped
- rendered = "\n".join(observed["triage_lines"])
- assert "topic: planning (0.91)" in rendered
- assert "What is the main theme?" not in rendered
- def test_triage_brief_title_mirrors_inference_depth_and_busy(server):
- """Brief view: #triage title carries [inf N] and the busy marker, exactly."""
- pytest.importorskip("textual")
- from textual.widgets import RichLog
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path, triage_enabled=True)
- observed: dict = {}
- async def scenario():
- async with app.run_test() as pilot:
- await pilot.pause()
- app._timer.stop() # no background drain: server status must not clobber state
- app._drain()
- app.state.ingest(
- {"type": "heartbeat", "inference_depth": 3, "inference_in_flight": False}
- )
- app._render()
- await pilot.pause()
- observed["idle"] = app.query_one("#triage", RichLog).border_title
- app.state.ingest(
- {"type": "heartbeat", "inference_depth": 7, "inference_in_flight": True}
- )
- app._render()
- await pilot.pause()
- observed["busy"] = app.query_one("#triage", RichLog).border_title
- asyncio.run(scenario())
- # border_title round-trips as console markup, so '[' reads back as '\['.
- assert observed["idle"] == "triage \\[inf 3]"
- assert observed["busy"] == "triage \\[inf 7 · busy]"
- def test_questions_view_title_has_no_inference_suffix(server):
- """Questions view: title is exactly 'questions'; toggling back restores the suffix."""
- pytest.importorskip("textual")
- from textual.widgets import RichLog
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path, triage_enabled=True)
- observed: dict = {}
- async def scenario():
- async with app.run_test() as pilot:
- await pilot.pause()
- app._timer.stop()
- app._drain()
- app.state.ingest(
- {"type": "heartbeat", "inference_depth": 5, "inference_in_flight": True}
- )
- app._render()
- await pilot.pause()
- observed["brief"] = app.query_one("#triage", RichLog).border_title
- await pilot.press("s")
- await pilot.pause()
- observed["questions"] = app.query_one("#triage", RichLog).border_title
- await pilot.press("s")
- await pilot.pause()
- observed["restored"] = app.query_one("#triage", RichLog).border_title
- asyncio.run(scenario())
- assert observed["brief"] == "triage \\[inf 5 · busy]"
- assert observed["questions"] == "questions"
- assert "inf" not in observed["questions"]
- assert "busy" not in observed["questions"]
- # No stale plain title: the brief view comes back fully suffixed.
- assert observed["restored"] == "triage \\[inf 5 · busy]"
- def test_triage_disabled_heartbeat_leaves_no_triage_widget(server):
- """triage_enabled=False: depths update #chat only and never create a #triage pane."""
- pytest.importorskip("textual")
- from textual.widgets import RichLog
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path)
- observed: dict = {}
- async def scenario():
- async with app.run_test() as pilot:
- await pilot.pause()
- app._timer.stop()
- app._drain()
- app.state.ingest(
- {"type": "heartbeat", "inference_depth": 9, "inference_in_flight": True}
- )
- app._render()
- await pilot.pause()
- observed["has_triage"] = bool(app.query("#triage"))
- observed["chat"] = app.query_one("#chat", RichLog).border_title
- asyncio.run(scenario())
- assert observed["has_triage"] is False
- assert observed["chat"] == "conversation \\[inf 9 · busy]"
- def test_s_key_is_noop_when_triage_disabled(server):
- """triage_enabled=False: action_toggle_questions and s do nothing and never crash."""
- pytest.importorskip("textual")
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path)
- async def scenario():
- async with app.run_test() as pilot:
- await pilot.pause()
- app.action_toggle_questions()
- await pilot.pause()
- assert app._show_questions is False
- assert not app.query("#triage")
- await pilot.press("s")
- await pilot.pause()
- assert app._show_questions is False
- assert not app.query("#triage")
- asyncio.run(scenario())
- def test_status_event_questions_populate_questions_view(server):
- """A status event carrying questions fills state and the toggled pane."""
- pytest.importorskip("textual")
- from textual.widgets import RichLog
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path, triage_enabled=True)
- observed: dict = {}
- async def scenario():
- async with app.run_test() as pilot:
- await pilot.pause()
- app._timer.stop() # no background drain: server status must not clobber state
- app._drain()
- observed["before_epoch"] = app.state.questions_epoch
- app.state.ingest({"type": "status", "questions": _questions_payload()})
- app._render()
- await pilot.pause()
- observed["questions"] = app.state.questions
- observed["epoch"] = app.state.questions_epoch
- await pilot.press("s")
- await pilot.pause()
- observed["lines"] = _rendered_lines(app.query_one("#triage", RichLog))
- asyncio.run(scenario())
- assert observed["questions"] == _questions_payload()
- assert observed["epoch"] == observed["before_epoch"] + 1
- assert any("topic [choice]" in line for line in observed["lines"])
- def test_chat_capture_checkbox_toggles_and_sends_control(server):
- """Toggling #chat-capture on sends a chat_capture control message."""
- pytest.importorskip("textual")
- from textual.widgets import Checkbox
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path)
- sent: list[tuple] = []
- observed: dict = {}
- async def scenario():
- async with app.run_test() as pilot:
- await pilot.pause()
- app._timer.stop() # no background drain: server status must not clobber state
- app._drain()
- app.client.send = lambda type_, **fields: sent.append((type_, fields))
- capture = app.query_one("#chat-capture", Checkbox)
- observed["initial"] = capture.value
- capture.value = True
- await pilot.pause()
- observed["after_on"] = capture.value
- asyncio.run(scenario())
- assert observed["initial"] is False
- assert observed["after_on"] is True
- assert ("chat_capture", {"on": True}) in sent
- def test_chat_capture_checkbox_follows_incoming_event(server):
- """A chat_capture event updates the checkbox without bouncing a send back."""
- pytest.importorskip("textual")
- from textual.widgets import Checkbox
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path)
- observed: dict = {}
- async def scenario():
- async with app.run_test() as pilot:
- await pilot.pause()
- app._timer.stop() # no background drain: the raw event is the only source
- app._drain()
- app.state.ingest({"type": "chat_capture", "on": True})
- app._render()
- await pilot.pause()
- observed["value"] = app.query_one("#chat-capture", Checkbox).value
- asyncio.run(scenario())
- assert observed["value"] is True
- def test_maximize_chat_docks_capture_controls_below(server):
- """Maximizing chat keeps the capture controls visible, docked under the pane."""
- pytest.importorskip("textual")
- from textual.containers import Horizontal, Vertical
- from textual.widgets import RichLog
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path, triage_enabled=True)
- observed: dict = {}
- async def scenario():
- async with app.run_test(size=(80, 40)) as pilot:
- await pilot.pause()
- app._timer.stop()
- app._drain()
- app._lower_view = "chat"
- app.state.ingest({"type": "mode", "mode": "chat"})
- app.state.ingest({"type": "chat_message", "role": "user", "text": "hi"})
- app._render()
- await pilot.pause()
- chat = app.query_one("#chat", RichLog)
- controls = app.query_one("#chat-controls", Vertical)
- observed["normal_right_of_chat"] = controls.region.x > chat.region.x
- observed["normal_same_top"] = controls.region.y == chat.region.y
- app.query_one("#chat", RichLog).focus()
- await pilot.pause()
- await pilot.press("m")
- await pilot.pause()
- row = app.query_one("#chat-row", Horizontal)
- observed["maximized_is_main"] = app.screen.maximized is app.query_one("#main")
- observed["controls_display"] = controls.display
- observed["controls_below_chat"] = controls.region.y > chat.region.y
- observed["controls_span_row"] = controls.region.width == row.region.width
- asyncio.run(scenario())
- print("capture controls:", observed)
- assert observed["normal_right_of_chat"] is True
- assert observed["normal_same_top"] is True
- assert observed["maximized_is_main"] is True
- assert observed["controls_display"] is True
- assert observed["controls_below_chat"] is True
- assert observed["controls_span_row"] is True
- def test_triage_chat_checkbox_only_when_triage_enabled(server):
- """The send-to-triage checkbox is absent without the triage panel."""
- pytest.importorskip("textual")
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- disabled = AssistantTUI(path)
- enabled = AssistantTUI(path, triage_enabled=True)
- async def scenario():
- async with disabled.run_test() as pilot:
- await pilot.pause()
- observed_disabled = bool(disabled.query("#triage-chat"))
- async with enabled.run_test() as pilot:
- await pilot.pause()
- observed_enabled = bool(enabled.query("#triage-chat"))
- return observed_disabled, observed_enabled
- observed_disabled, observed_enabled = asyncio.run(scenario())
- assert observed_disabled is False
- assert observed_enabled is True
- def test_triage_chat_checkbox_toggles_and_sends_control(server):
- """Toggling #triage-chat on sends a triage_chat control message."""
- pytest.importorskip("textual")
- from textual.widgets import Checkbox
- from stt_daemon.tui.app import AssistantTUI
- path, _service = server
- app = AssistantTUI(path, triage_enabled=True)
- sent: list[tuple] = []
- observed: dict = {}
- async def scenario():
- async with app.run_test() as pilot:
- await pilot.pause()
- app._timer.stop() # no background drain: server status must not clobber state
- app._drain()
- app.client.send = lambda type_, **fields: sent.append((type_, fields))
- control = app.query_one("#triage-chat", Checkbox)
- observed["initial"] = control.value
- control.value = True
- await pilot.pause()
- observed["after_on"] = control.value
- asyncio.run(scenario())
- assert observed["initial"] is False
- assert observed["after_on"] is True
- assert ("triage_chat", {"on": True}) in sent
|