test_tui_app.py 79 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267
  1. """Smoke test: the Textual app mounts, subscribes, and ingests broadcasts."""
  2. import asyncio
  3. import os
  4. import shutil
  5. import sys
  6. import tempfile
  7. import pytest
  8. from stt_daemon.assistant.control import ControlServer
  9. from stt_daemon.assistant.service import AssistantService
  10. from stt_daemon.config import Config
  11. class FakeSubscriber:
  12. def events(self):
  13. return iter(())
  14. def close(self):
  15. pass
  16. @pytest.fixture
  17. def server():
  18. directory = tempfile.mkdtemp(prefix="stta-")
  19. path = os.path.join(directory, "c.sock")
  20. cfg = Config()
  21. cfg.assistant.control_socket = path
  22. service = AssistantService(cfg, FakeSubscriber())
  23. control = ControlServer(cfg, service, path=path)
  24. service.set_sinks(emit=control.broadcast, relay=control.relay)
  25. control.start()
  26. yield path, service
  27. control.close()
  28. service.stop()
  29. shutil.rmtree(directory, ignore_errors=True)
  30. def test_ctrl_r_sends_execute_and_e_focuses_command(server):
  31. pytest.importorskip("textual")
  32. from textual.widgets import Input
  33. from stt_daemon.tui.app import AssistantTUI
  34. path, _service = server
  35. app = AssistantTUI(path)
  36. sent: list[tuple] = []
  37. observed: dict = {}
  38. async def scenario():
  39. async with app.run_test() as pilot:
  40. await pilot.pause()
  41. app.client.send = lambda type_, **fields: sent.append((type_, fields))
  42. await pilot.press("ctrl+r")
  43. await pilot.pause()
  44. await pilot.press("a")
  45. await pilot.pause()
  46. await pilot.press("escape")
  47. await pilot.pause()
  48. await pilot.press("e")
  49. await pilot.pause()
  50. cmd = app.query_one("#command", Input)
  51. observed["cmd_focused"] = cmd.has_focus
  52. asyncio.run(scenario())
  53. assert ("command_execute", {}) in sent
  54. assert ("command_start", {}) in sent
  55. assert ("command_cancel", {}) in sent
  56. assert observed["cmd_focused"] is True
  57. def test_type_command_enter_from_idle_sends_start_edit_execute(server):
  58. pytest.importorskip("textual")
  59. from textual.widgets import Input
  60. from stt_daemon.tui.app import AssistantTUI
  61. path, _service = server
  62. app = AssistantTUI(path)
  63. sent: list[tuple] = []
  64. async def scenario():
  65. async with app.run_test() as pilot:
  66. await pilot.pause()
  67. app.client.send = lambda type_, **fields: sent.append((type_, fields))
  68. assert app.state.mode == "idle"
  69. app.query_one("#command", Input).focus()
  70. await pilot.pause()
  71. for ch in "take notes":
  72. await pilot.press(ch)
  73. await pilot.pause()
  74. await pilot.press("enter")
  75. await pilot.pause()
  76. asyncio.run(scenario())
  77. assert [t for t, _ in sent] == ["command_start", "command_edit", "command_execute"], sent
  78. assert sent[1][1] == {"text": "take notes"}, sent
  79. def test_type_command_enter_when_armed_skips_start(server):
  80. pytest.importorskip("textual")
  81. from textual.widgets import Input
  82. from stt_daemon.tui.app import AssistantTUI
  83. path, _service = server
  84. app = AssistantTUI(path)
  85. sent: list[tuple] = []
  86. async def scenario():
  87. async with app.run_test() as pilot:
  88. await pilot.pause()
  89. app.client.send = lambda type_, **fields: sent.append((type_, fields))
  90. app._timer.stop() # no background drain: server status must not clobber state
  91. app._drain()
  92. app.state.ingest({"type": "mode", "mode": "command_armed"})
  93. assert app.state.mode == "command_armed"
  94. app.query_one("#command", Input).focus()
  95. await pilot.pause()
  96. for ch in "open calendar":
  97. await pilot.press(ch)
  98. await pilot.pause()
  99. await pilot.press("enter")
  100. await pilot.pause()
  101. asyncio.run(scenario())
  102. assert [t for t, _ in sent] == ["command_edit", "command_execute"], sent
  103. assert sent[0][1] == {"text": "open calendar"}, sent
  104. def test_arm_from_chat_leaves_chat_first(server):
  105. pytest.importorskip("textual")
  106. from stt_daemon.tui.app import AssistantTUI
  107. path, _service = server
  108. app = AssistantTUI(path)
  109. sent: list[tuple] = []
  110. async def scenario():
  111. async with app.run_test() as pilot:
  112. await pilot.pause()
  113. app.client.send = lambda type_, **fields: sent.append((type_, fields))
  114. app._timer.stop()
  115. app._drain()
  116. app.state.ingest({"type": "mode", "mode": "chat"})
  117. await pilot.press("a")
  118. await pilot.pause()
  119. asyncio.run(scenario())
  120. assert [t for t, _ in sent] == ["chat_toggle", "command_start"], sent
  121. def test_type_command_enter_from_chat_leaves_chat_first(server):
  122. pytest.importorskip("textual")
  123. from textual.widgets import Input
  124. from stt_daemon.tui.app import AssistantTUI
  125. path, _service = server
  126. app = AssistantTUI(path)
  127. sent: list[tuple] = []
  128. async def scenario():
  129. async with app.run_test() as pilot:
  130. await pilot.pause()
  131. app.client.send = lambda type_, **fields: sent.append((type_, fields))
  132. app._timer.stop()
  133. app._drain()
  134. app.state.ingest({"type": "mode", "mode": "chat"})
  135. app.query_one("#command", Input).focus()
  136. await pilot.pause()
  137. for ch in "open calendar":
  138. await pilot.press(ch)
  139. await pilot.pause()
  140. await pilot.press("enter")
  141. await pilot.pause()
  142. asyncio.run(scenario())
  143. assert [t for t, _ in sent] == [
  144. "chat_toggle",
  145. "command_start",
  146. "command_edit",
  147. "command_execute",
  148. ], sent
  149. def test_command_from_chat_mode_reaches_executing(server):
  150. """A command sent while chatting leaves chat and actually executes."""
  151. pytest.importorskip("textual")
  152. from textual.widgets import Input
  153. from stt_daemon.tui.app import AssistantTUI
  154. path, service = server
  155. app = AssistantTUI(path)
  156. async def scenario():
  157. async with app.run_test() as pilot:
  158. await pilot.pause()
  159. assert service.toggle_chat() is True
  160. for _ in range(100):
  161. await pilot.pause()
  162. if app.state.mode == "chat":
  163. break
  164. assert app.state.mode == "chat"
  165. app.query_one("#command", Input).focus()
  166. await pilot.pause()
  167. for ch in "do it":
  168. await pilot.press(ch)
  169. await pilot.pause()
  170. await pilot.press("enter")
  171. for _ in range(100):
  172. await pilot.pause()
  173. if service.modes.mode.value == "executing":
  174. break
  175. asyncio.run(scenario())
  176. assert service.modes.mode.value == "executing"
  177. def test_typed_execute_clears_command_input(server):
  178. """Typing a command and pressing Enter clears the input as soon as it executes."""
  179. pytest.importorskip("textual")
  180. from textual.widgets import Input
  181. from stt_daemon.tui.app import AssistantTUI
  182. path, service = server
  183. app = AssistantTUI(path)
  184. observed: dict = {}
  185. async def scenario():
  186. async with app.run_test() as pilot:
  187. await pilot.pause()
  188. app.query_one("#command", Input).focus()
  189. await pilot.pause()
  190. for ch in "do it":
  191. await pilot.press(ch)
  192. await pilot.pause()
  193. await pilot.press("enter")
  194. for _ in range(100):
  195. await pilot.pause()
  196. if service.modes.mode.value == "executing":
  197. break
  198. await pilot.pause()
  199. observed["value"] = app.query_one("#command", Input).value
  200. observed["state_text"] = app.state.command_text
  201. asyncio.run(scenario())
  202. assert service.modes.mode.value == "executing"
  203. assert observed["value"] == ""
  204. assert observed["state_text"] == ""
  205. def test_empty_command_enter_sends_nothing(server):
  206. pytest.importorskip("textual")
  207. from textual.widgets import Input
  208. from stt_daemon.tui.app import AssistantTUI
  209. path, _service = server
  210. app = AssistantTUI(path)
  211. sent: list[tuple] = []
  212. async def scenario():
  213. async with app.run_test() as pilot:
  214. await pilot.pause()
  215. app.client.send = lambda type_, **fields: sent.append((type_, fields))
  216. app.query_one("#command", Input).focus()
  217. await pilot.pause()
  218. await pilot.press("enter")
  219. await pilot.pause()
  220. asyncio.run(scenario())
  221. assert sent == []
  222. def test_e_focuses_command_input(server):
  223. pytest.importorskip("textual")
  224. from textual.widgets import Input
  225. from stt_daemon.tui.app import AssistantTUI
  226. path, _service = server
  227. app = AssistantTUI(path)
  228. observed: dict = {}
  229. async def scenario():
  230. async with app.run_test() as pilot:
  231. await pilot.pause()
  232. app._timer.stop() # no background drain: server status must not clobber state
  233. app._drain()
  234. app.state.ingest({"type": "command_text", "text": "voice text", "edited": False})
  235. app._render()
  236. await pilot.pause()
  237. await pilot.press("e")
  238. await pilot.pause()
  239. cmd = app.query_one("#command", Input)
  240. observed["focused"] = cmd.has_focus
  241. observed["value"] = cmd.value
  242. asyncio.run(scenario())
  243. assert observed["focused"] is True
  244. assert observed["value"] == "voice text"
  245. def test_voice_text_updates_unfocused_but_not_focused(server):
  246. pytest.importorskip("textual")
  247. from textual.widgets import Input
  248. from stt_daemon.tui.app import AssistantTUI
  249. path, _service = server
  250. app = AssistantTUI(path)
  251. observed: dict = {}
  252. async def scenario():
  253. async with app.run_test() as pilot:
  254. await pilot.pause()
  255. app._timer.stop() # no background drain: server status must not clobber state
  256. app._drain()
  257. cmd = app.query_one("#command", Input)
  258. assert not cmd.has_focus
  259. app.state.ingest({"type": "command_text", "text": "voice built", "edited": False})
  260. app._render()
  261. await pilot.pause()
  262. observed["unfocused_value"] = app.query_one("#command", Input).value
  263. app.query_one("#command", Input).focus()
  264. await pilot.pause()
  265. for ch in "user typing":
  266. await pilot.press(ch)
  267. await pilot.pause()
  268. app.state.ingest({"type": "command_text", "text": "voice late", "edited": False})
  269. app._render()
  270. await pilot.pause()
  271. observed["focused_value"] = app.query_one("#command", Input).value
  272. asyncio.run(scenario())
  273. assert observed["unfocused_value"] == "voice built"
  274. assert observed["focused_value"] == "user typing"
  275. def test_command_input_border_title(server):
  276. pytest.importorskip("textual")
  277. from textual.widgets import Input
  278. from stt_daemon.tui.app import AssistantTUI
  279. path, _service = server
  280. app = AssistantTUI(path)
  281. async def scenario():
  282. async with app.run_test() as pilot:
  283. await pilot.pause()
  284. app._render()
  285. await pilot.pause()
  286. return app.query_one("#command", Input).border_title
  287. title = asyncio.run(scenario())
  288. assert "command buffer" in title
  289. def test_no_autofocus_on_mount(server):
  290. """Nothing is focused on mount: single-letter hotkeys must not be swallowed by #command."""
  291. pytest.importorskip("textual")
  292. from stt_daemon.tui.app import AssistantTUI
  293. path, _service = server
  294. app = AssistantTUI(path)
  295. async def scenario():
  296. async with app.run_test() as pilot:
  297. await pilot.pause()
  298. await pilot.pause()
  299. return app.focused
  300. assert asyncio.run(scenario()) is None
  301. def test_submit_blurs_command_and_hotkey_still_fires(server):
  302. """Type + Enter sends messages, clears focus; a hotkey (t) still fires afterwards."""
  303. pytest.importorskip("textual")
  304. from textual.widgets import Input
  305. from stt_daemon.tui.app import AssistantTUI
  306. path, _service = server
  307. app = AssistantTUI(path)
  308. sent: list[tuple] = []
  309. observed: dict = {}
  310. async def scenario():
  311. async with app.run_test() as pilot:
  312. await pilot.pause()
  313. app.client.send = lambda type_, **fields: sent.append((type_, fields))
  314. app._timer.stop() # no background drain: server status must not clobber state
  315. app._drain()
  316. app.query_one("#command", Input).focus()
  317. await pilot.pause()
  318. for ch in "take notes":
  319. await pilot.press(ch)
  320. await pilot.pause()
  321. await pilot.press("enter")
  322. await pilot.pause()
  323. observed["focused_after_submit"] = app.focused
  324. observed["value_after_submit"] = app.query_one("#command", Input).value
  325. await pilot.press("t")
  326. await pilot.pause()
  327. observed["lower_view"] = app._lower_view
  328. asyncio.run(scenario())
  329. assert [t for t, _ in sent] == [
  330. "command_start",
  331. "command_edit",
  332. "command_execute",
  333. ], sent
  334. assert observed["focused_after_submit"] is None
  335. assert observed["value_after_submit"] == ""
  336. assert observed["lower_view"] == "output"
  337. def test_focus_triage_then_m_maximizes_deterministically(server):
  338. """Focus #triage, assert focus stuck, then m maximizes #main (no auto-focus race)."""
  339. pytest.importorskip("textual")
  340. from textual.widgets import RichLog
  341. from stt_daemon.tui.app import AssistantTUI
  342. path, _service = server
  343. app = AssistantTUI(path, triage_enabled=True)
  344. observed: dict = {}
  345. async def scenario():
  346. async with app.run_test(size=(80, 40)) as pilot:
  347. await pilot.pause()
  348. app.query_one("#triage", RichLog).focus()
  349. await pilot.pause()
  350. assert app.focused is not None and app.focused.id == "triage", app.focused
  351. await pilot.press("m")
  352. await pilot.pause()
  353. observed["maximized_is_main"] = app.screen.maximized is app.query_one("#main")
  354. asyncio.run(scenario())
  355. assert observed["maximized_is_main"] is True
  356. def test_c_key_confirms_before_clearing(server):
  357. pytest.importorskip("textual")
  358. from stt_daemon.tui.app import AssistantTUI
  359. path, _service = server
  360. app = AssistantTUI(path)
  361. sent: list[tuple] = []
  362. async def scenario():
  363. async with app.run_test() as pilot:
  364. await pilot.pause()
  365. app.client.send = lambda type_, **fields: sent.append((type_, fields))
  366. await pilot.press("c")
  367. await pilot.pause()
  368. assert not any(type_ == "clear" for type_, _ in sent), "clear sent before confirmation"
  369. await pilot.press("y")
  370. await pilot.pause()
  371. asyncio.run(scenario())
  372. assert ("clear", {}) in sent
  373. def test_c_key_hides_visible_pane(server):
  374. """Confirming clear hides whatever lower pane was showing."""
  375. pytest.importorskip("textual")
  376. from textual.widgets import RichLog
  377. from stt_daemon.tui.app import AssistantTUI
  378. path, _service = server
  379. app = AssistantTUI(path)
  380. async def scenario():
  381. async with app.run_test(size=(80, 40)) as pilot:
  382. await pilot.pause()
  383. app.state.ingest(
  384. {"type": "tool_result", "tool": "get_datetime", "ok": True, "result": "now"}
  385. )
  386. app._lower_view = "output"
  387. app._render()
  388. await pilot.pause()
  389. assert app.query_one("#output", RichLog).display is True
  390. await pilot.press("c")
  391. await pilot.pause()
  392. await pilot.press("y")
  393. for _ in range(100):
  394. await pilot.pause()
  395. if app._lower_view == "hidden":
  396. break
  397. asyncio.run(scenario())
  398. assert app._lower_view == "hidden"
  399. pytest.importorskip("textual")
  400. from stt_daemon.tui.app import AssistantTUI
  401. path, _service = server
  402. app = AssistantTUI(path)
  403. sent: list[tuple] = []
  404. async def scenario():
  405. async with app.run_test() as pilot:
  406. await pilot.pause()
  407. app.client.send = lambda type_, **fields: sent.append((type_, fields))
  408. await pilot.press("c")
  409. await pilot.pause()
  410. await pilot.press("n")
  411. await pilot.pause()
  412. asyncio.run(scenario())
  413. assert not any(type_ == "clear" for type_, _ in sent)
  414. def test_c_key_escape_does_not_clear(server):
  415. pytest.importorskip("textual")
  416. from stt_daemon.tui.app import AssistantTUI
  417. path, _service = server
  418. app = AssistantTUI(path)
  419. sent: list[tuple] = []
  420. async def scenario():
  421. async with app.run_test() as pilot:
  422. await pilot.pause()
  423. app.client.send = lambda type_, **fields: sent.append((type_, fields))
  424. await pilot.press("c")
  425. await pilot.pause()
  426. await pilot.press("escape")
  427. await pilot.pause()
  428. asyncio.run(scenario())
  429. assert not any(type_ == "clear" for type_, _ in sent)
  430. def test_c_key_clears_rendered_output(server):
  431. """Pressing 'c' clears the conversation pane via the cleared event."""
  432. pytest.importorskip("textual")
  433. from textual.widgets import RichLog
  434. from stt_daemon.tui.app import AssistantTUI
  435. path, _service = server
  436. app = AssistantTUI(path)
  437. async def scenario():
  438. async with app.run_test() as pilot:
  439. await pilot.pause()
  440. app.state.ingest({"type": "chat_message", "role": "user", "text": "hello"})
  441. app.state.ingest(
  442. {
  443. "type": "tool_result",
  444. "tool": "get_datetime",
  445. "ok": True,
  446. "result": "2026-09-21",
  447. }
  448. )
  449. app._render()
  450. await pilot.pause()
  451. app.state.ingest({"type": "cleared"})
  452. app._render()
  453. await pilot.pause()
  454. log = app.query_one("#chat", RichLog)
  455. return list(log.lines)
  456. lines = asyncio.run(scenario())
  457. assert not any("hello" in str(line) for line in lines)
  458. assert not any("2026-09-21" in str(line) for line in lines)
  459. def test_tui_renders_command_output(server):
  460. """A tool result must actually reach a widget - otherwise 'nothing happens'."""
  461. pytest.importorskip("textual")
  462. from textual.widgets import RichLog
  463. from stt_daemon.tui.app import AssistantTUI
  464. path, _service = server
  465. app = AssistantTUI(path)
  466. async def scenario():
  467. async with app.run_test() as pilot:
  468. await pilot.pause()
  469. app.state.ingest(
  470. {
  471. "type": "tool_result",
  472. "tool": "get_datetime",
  473. "ok": True,
  474. "result": "2026-09-21T01:00:00",
  475. }
  476. )
  477. app._render()
  478. await pilot.pause()
  479. log = app.query_one("#output", RichLog)
  480. return list(log.lines)
  481. lines = asyncio.run(scenario())
  482. assert lines, "command output was not rendered anywhere"
  483. def test_command_output_and_chat_render_in_separate_panes(server):
  484. """Tool output lands in #output and chat turns in #chat, with no crossover."""
  485. pytest.importorskip("textual")
  486. from textual.widgets import RichLog
  487. from stt_daemon.tui.app import AssistantTUI
  488. path, _service = server
  489. app = AssistantTUI(path)
  490. async def scenario():
  491. async with app.run_test() as pilot:
  492. await pilot.pause()
  493. app._timer.stop() # no background drain: server status must not clobber state
  494. app._drain()
  495. app.state.ingest(
  496. {"type": "tool_result", "tool": "get_datetime", "ok": True, "result": "cmd"}
  497. )
  498. app.state.ingest({"type": "mode", "mode": "chat"})
  499. app.state.ingest({"type": "chat_message", "role": "user", "text": "hi"})
  500. app._render()
  501. await pilot.pause()
  502. output = [str(line) for line in app.query_one("#output", RichLog).lines]
  503. chat = [str(line) for line in app.query_one("#chat", RichLog).lines]
  504. return output, chat
  505. output, chat = asyncio.run(scenario())
  506. assert any("cmd" in line for line in output)
  507. assert not any("hi" in line for line in output)
  508. assert any("hi" in line for line in chat)
  509. assert not any("cmd" in line for line in chat)
  510. def test_tui_keeps_chat_pane_on_chat_exit(server):
  511. """Leaving chat keeps the rendered conversation; only clear drops it."""
  512. pytest.importorskip("textual")
  513. from textual.widgets import RichLog
  514. from stt_daemon.tui.app import AssistantTUI
  515. path, _service = server
  516. app = AssistantTUI(path)
  517. async def scenario():
  518. async with app.run_test() as pilot:
  519. await pilot.pause()
  520. app.state.ingest(
  521. {"type": "tool_result", "tool": "get_datetime", "ok": True, "result": "kept"}
  522. )
  523. app.state.ingest({"type": "mode", "mode": "chat"})
  524. app.state.ingest({"type": "chat_message", "role": "user", "text": "kept-turn"})
  525. app._render()
  526. await pilot.pause()
  527. app.state.ingest({"type": "mode", "mode": "idle"})
  528. app._render()
  529. await pilot.pause()
  530. chat = [str(line) for line in app.query_one("#chat", RichLog).lines]
  531. output = [str(line) for line in app.query_one("#output", RichLog).lines]
  532. app.state.ingest({"type": "cleared"})
  533. app._render()
  534. await pilot.pause()
  535. cleared = [str(line) for line in app.query_one("#chat", RichLog).lines]
  536. return chat, output, cleared
  537. chat_lines, output_lines, cleared = asyncio.run(scenario())
  538. assert any("kept-turn" in line for line in chat_lines)
  539. assert not any("kept-turn" in line for line in output_lines)
  540. assert any("kept" in line for line in output_lines)
  541. assert cleared == []
  542. def test_tui_mounts_subscribes_and_ingests_broadcasts(server):
  543. pytest.importorskip("textual")
  544. from stt_daemon.tui.app import AssistantTUI
  545. path, service = server
  546. app = AssistantTUI(path)
  547. async def scenario():
  548. async with app.run_test() as pilot:
  549. await pilot.pause()
  550. service.arm() # broadcast to the subscribed viewer
  551. for _ in range(60):
  552. await pilot.pause()
  553. if app.state.mode == "command_armed":
  554. break
  555. await asyncio.sleep(0.05)
  556. asyncio.run(scenario())
  557. assert app.state.mode == "command_armed"
  558. def test_render_after_unmount_is_safe(server):
  559. """A timer tick can land during teardown; rendering must not raise."""
  560. pytest.importorskip("textual")
  561. from stt_daemon.tui.app import AssistantTUI
  562. path, _service = server
  563. app = AssistantTUI(path)
  564. async def scenario():
  565. async with app.run_test() as pilot:
  566. await pilot.pause()
  567. asyncio.run(scenario())
  568. app._render() # must not raise NoMatches
  569. def test_tui_records_connect_failure(tmp_path):
  570. pytest.importorskip("textual")
  571. from stt_daemon.tui.app import AssistantTUI
  572. app = AssistantTUI(str(tmp_path / "missing.sock"))
  573. async def scenario():
  574. async with app.run_test() as pilot:
  575. await pilot.pause()
  576. asyncio.run(scenario())
  577. assert app.connect_error is not None
  578. assert "cannot connect" in app.connect_error
  579. def test_border_titles_show_queue_and_inference_depths(server):
  580. """Border titles for transcript and chat show live segment and inference queue counts."""
  581. pytest.importorskip("textual")
  582. from textual.widgets import RichLog
  583. from stt_daemon.tui.app import AssistantTUI
  584. path, _service = server
  585. app = AssistantTUI(path)
  586. async def scenario():
  587. async with app.run_test() as pilot:
  588. await pilot.pause()
  589. app.state.ingest(
  590. {
  591. "type": "heartbeat",
  592. "queue_depth": 5,
  593. "inference_depth": 3,
  594. "inference_in_flight": False,
  595. }
  596. )
  597. app._render()
  598. await pilot.pause()
  599. transcript_title = app.query_one("#transcript", RichLog).border_title
  600. chat_title = app.query_one("#chat", RichLog).border_title
  601. assert "[seg 5]" in transcript_title
  602. assert "[inf 3]" in chat_title
  603. assert "busy" not in chat_title
  604. # Test with inference in flight
  605. app.state.ingest(
  606. {
  607. "type": "heartbeat",
  608. "inference_depth": 7,
  609. "inference_in_flight": True,
  610. }
  611. )
  612. app._render()
  613. await pilot.pause()
  614. chat_title = app.query_one("#chat", RichLog).border_title
  615. assert "[inf 7" in chat_title
  616. assert "busy" in chat_title
  617. asyncio.run(scenario())
  618. def test_main_exit_code_reflects_connect_failure(monkeypatch, tmp_path, capsys):
  619. pytest.importorskip("textual")
  620. from stt_daemon.tui import app as tui_app
  621. monkeypatch.setattr(
  622. tui_app.AssistantTUI,
  623. "run",
  624. lambda self: setattr(self, "connect_error", "boom"),
  625. )
  626. monkeypatch.setattr(sys, "argv", ["stt-tui", "--socket", str(tmp_path / "x.sock")])
  627. assert tui_app.main() == 1
  628. assert "boom" in capsys.readouterr().err
  629. monkeypatch.setattr(tui_app.AssistantTUI, "run", lambda self: None)
  630. assert tui_app.main() == 0
  631. def test_triage_disabled_composes_no_triage_widget(server):
  632. """Default (triage_enabled=False): no #triage, no #main — tree as today."""
  633. pytest.importorskip("textual")
  634. from stt_daemon.tui.app import AssistantTUI
  635. path, _service = server
  636. app = AssistantTUI(path)
  637. async def scenario():
  638. async with app.run_test() as pilot:
  639. await pilot.pause()
  640. assert not app.query("#triage")
  641. assert not app.query("#main")
  642. # The rest of the tree is intact.
  643. app.query_one("#transcript")
  644. app.query_one("#status")
  645. app.query_one("#command")
  646. app.query_one("#chat")
  647. asyncio.run(scenario())
  648. def test_triage_enabled_composes_split(server):
  649. """triage_enabled=True: #main wraps #triage (left) + #chat (right); transcript stays outside."""
  650. pytest.importorskip("textual")
  651. from textual.containers import Horizontal
  652. from textual.widgets import RichLog
  653. from stt_daemon.tui.app import AssistantTUI
  654. path, _service = server
  655. app = AssistantTUI(path, triage_enabled=True)
  656. async def scenario():
  657. async with app.run_test() as pilot:
  658. await pilot.pause()
  659. main = app.query_one("#main", Horizontal)
  660. transcript = app.query_one("#transcript", RichLog)
  661. triage = app.query_one("#triage", RichLog)
  662. chat = app.query_one("#chat", RichLog)
  663. assert triage.parent is main
  664. assert chat.parent is main
  665. assert transcript.parent is not main
  666. assert triage.border_title == "triage \\[inf 0]" # markup: '[' is escaped
  667. # triage sits LEFT of chat inside #main.
  668. assert main.children.index(triage) < main.children.index(chat)
  669. # Outside widgets stay outside #main.
  670. assert app.query_one("#status").parent is not main
  671. assert transcript.parent is not main
  672. asyncio.run(scenario())
  673. def test_triage_enabled_layout_proportions(server):
  674. """Rendered proportions: #triage ~1/3 and #chat ~2/3 of #main; #transcript ~25% of screen."""
  675. pytest.importorskip("textual")
  676. from textual.containers import Horizontal
  677. from textual.widgets import RichLog
  678. from stt_daemon.tui.app import AssistantTUI
  679. path, _service = server
  680. app = AssistantTUI(path, triage_enabled=True)
  681. async def scenario(size=None):
  682. kwargs = {"size": size} if size else {}
  683. async with app.run_test(**kwargs) as pilot:
  684. await pilot.pause()
  685. app._timer.stop() # no background drain: server status must not clobber state
  686. app._drain()
  687. # Chat needs content to be shown (hidden-by-default rule): put it in
  688. # chat mode so the 1/3-2/3 split applies.
  689. app._lower_view = "chat"
  690. app.state.ingest({"type": "mode", "mode": "chat"})
  691. app._render()
  692. await pilot.pause()
  693. main = app.query_one("#main", Horizontal)
  694. transcript = app.query_one("#transcript", RichLog)
  695. triage = app.query_one("#triage", RichLog)
  696. chat = app.query_one("#chat", RichLog)
  697. return (
  698. main.size.width,
  699. triage.size.width,
  700. chat.size.width,
  701. app.screen.size.height,
  702. transcript.region.height,
  703. )
  704. main_w, triage_w, chat_w, screen_h, transcript_h = asyncio.run(scenario())
  705. print(
  706. f"measured: main_w={main_w} triage_w={triage_w} chat_w={chat_w} "
  707. f"screen_h={screen_h} transcript_h={transcript_h}"
  708. )
  709. # Content boxes exclude the 1-cell border on each side; allow ±2 px for
  710. # borders/rounding on the fractional split.
  711. assert abs(triage_w / main_w - 1 / 3) < 0.05, (main_w, triage_w, chat_w)
  712. assert abs(chat_w / main_w - 2 / 3) < 0.05, (main_w, triage_w, chat_w)
  713. # region.height includes the transcript's 2 border rows; at the small
  714. # default size rounding is coarse, so only demand a floor here. The
  715. # precise 25% check runs at a taller size below.
  716. assert transcript_h / screen_h >= 0.18, (screen_h, transcript_h)
  717. assert transcript_h >= 1, transcript_h
  718. app2 = AssistantTUI(path, triage_enabled=True)
  719. async def scenario_tall():
  720. async with app2.run_test(size=(80, 40)) as pilot:
  721. await pilot.pause()
  722. transcript2 = app2.query_one("#transcript", RichLog)
  723. return app2.screen.size.height, transcript2.region.height
  724. screen_h2, transcript_h2 = asyncio.run(scenario_tall())
  725. print(f"measured tall: screen_h={screen_h2} transcript_h={transcript_h2}")
  726. assert abs(transcript_h2 / screen_h2 - 0.25) < 0.06, (screen_h2, transcript_h2)
  727. def test_triage_enabled_renders_signals_on_epoch_change(server):
  728. """A triage event + status event must actually write rendered lines into #triage."""
  729. pytest.importorskip("textual")
  730. from textual.widgets import RichLog
  731. from stt_daemon.tui.app import AssistantTUI
  732. path, _service = server
  733. app = AssistantTUI(path, triage_enabled=True)
  734. async def scenario():
  735. async with app.run_test() as pilot:
  736. await pilot.pause()
  737. app._timer.stop() # no background drain: server status must not clobber state
  738. app._drain()
  739. app.state.ingest(
  740. {
  741. "type": "triage",
  742. "stage": "signals",
  743. "triage": {
  744. "order": ["category"],
  745. "items": {
  746. "category": {
  747. "kind": "choice",
  748. "choice": "question",
  749. "answer_confidence": 0.91,
  750. }
  751. },
  752. },
  753. }
  754. )
  755. app.state.ingest(
  756. {"type": "status", "triage_state": "ok", "triage_reason": ""}
  757. )
  758. app._render()
  759. await pilot.pause()
  760. return [str(line) for line in app.query_one("#triage", RichLog).lines]
  761. lines = asyncio.run(scenario())
  762. rendered = "\n".join(lines)
  763. assert "category: question (0.91)" in rendered
  764. def test_triage_enabled_loading_shows_reason_line_never_blank(server):
  765. """loading/unknown triage_state renders the reason line — never a blank panel."""
  766. pytest.importorskip("textual")
  767. from textual.widgets import RichLog
  768. from stt_daemon.tui.app import AssistantTUI
  769. path, _service = server
  770. app = AssistantTUI(path, triage_enabled=True)
  771. async def scenario():
  772. async with app.run_test() as pilot:
  773. await pilot.pause()
  774. app._timer.stop() # no background drain: server status must not clobber state
  775. app._drain()
  776. app.state.ingest(
  777. {
  778. "type": "status",
  779. "triage_state": "loading",
  780. "triage_reason": "model loading",
  781. }
  782. )
  783. app._render()
  784. await pilot.pause()
  785. loading_lines = [str(line) for line in app.query_one("#triage", RichLog).lines]
  786. # Unknown state falls back to a generic reason line, no crash.
  787. app.state.ingest(
  788. {"type": "status", "triage_state": "weird_state", "triage_reason": ""}
  789. )
  790. app._render()
  791. await pilot.pause()
  792. unknown_lines = [str(line) for line in app.query_one("#triage", RichLog).lines]
  793. return loading_lines, unknown_lines
  794. loading_lines, unknown_lines = asyncio.run(scenario())
  795. assert loading_lines, "triage panel was blank in loading state"
  796. assert any("triage: unavailable" in line for line in loading_lines)
  797. assert any("model loading" in line for line in loading_lines)
  798. assert unknown_lines, "triage panel was blank for unknown state"
  799. assert any("triage: unavailable" in line for line in unknown_lines)
  800. assert any("weird_state" in line for line in unknown_lines)
  801. def test_triage_epoch_change_clears_before_rewrite(server):
  802. """Epoch bump clears #triage first: no duplicated stale lines."""
  803. pytest.importorskip("textual")
  804. from textual.widgets import RichLog
  805. from stt_daemon.tui.app import AssistantTUI
  806. path, _service = server
  807. app = AssistantTUI(path, triage_enabled=True)
  808. async def scenario():
  809. async with app.run_test() as pilot:
  810. await pilot.pause()
  811. app._timer.stop() # no background drain: server status must not clobber state
  812. app._drain()
  813. app.state.ingest(
  814. {
  815. "type": "triage",
  816. "stage": "signals",
  817. "triage": {"order": ["q1"], "items": {"q1": {"kind": "choice", "choice": "a"}}},
  818. }
  819. )
  820. app.state.ingest({"type": "status", "triage_state": "ok"})
  821. app._render()
  822. await pilot.pause()
  823. # New epoch: cleared stage empties the payload, panel must not keep old lines.
  824. app.state.ingest({"type": "triage", "stage": "cleared"})
  825. app._render()
  826. await pilot.pause()
  827. return [str(line) for line in app.query_one("#triage", RichLog).lines]
  828. lines = asyncio.run(scenario())
  829. rendered = "\n".join(lines)
  830. assert "q1" not in rendered, "stale triage lines survived an epoch change"
  831. assert "triage: unavailable — no data" in rendered
  832. def test_triage_disabled_never_writes_triage(server):
  833. """Disabled: triage events are ingested but #triage is never touched (it doesn't exist)."""
  834. pytest.importorskip("textual")
  835. from stt_daemon.tui.app import AssistantTUI
  836. path, _service = server
  837. app = AssistantTUI(path)
  838. async def scenario():
  839. async with app.run_test() as pilot:
  840. await pilot.pause()
  841. app.state.ingest({"type": "triage", "stage": "signals", "triage": {}})
  842. app.state.ingest({"type": "status", "triage_state": "ok"})
  843. app._render() # must not raise NoMatches for #triage
  844. await pilot.pause()
  845. assert not app.query("#triage")
  846. asyncio.run(scenario())
  847. def test_default_ctor_has_no_triage_arg(server):
  848. """AssistantTUI(path) stays valid: triage_enabled defaults to False."""
  849. pytest.importorskip("textual")
  850. from stt_daemon.tui.app import AssistantTUI
  851. path, _service = server
  852. app = AssistantTUI(path)
  853. assert app.triage_enabled is False
  854. assert app._seen_triage_epoch == 0
  855. def test_maximize_triage_hides_chat_and_fills_screen(server):
  856. """Focus #triage, press m: #main maximized, chat hidden, triage fills screen."""
  857. pytest.importorskip("textual")
  858. from textual.widgets import RichLog
  859. from stt_daemon.tui.app import AssistantTUI
  860. path, _service = server
  861. app = AssistantTUI(path, triage_enabled=True)
  862. observed: dict = {}
  863. async def scenario():
  864. async with app.run_test(size=(80, 40)) as pilot:
  865. await pilot.pause()
  866. app.query_one("#triage", RichLog).focus()
  867. await pilot.pause()
  868. assert app.focused is not None and app.focused.id == "triage", app.focused
  869. await pilot.press("m")
  870. await pilot.pause()
  871. observed["maximized_is_main"] = app.screen.maximized is app.query_one("#main")
  872. observed["triage_display"] = app.query_one("#triage", RichLog).display
  873. observed["chat_display"] = app.query_one("#chat", RichLog).display
  874. observed["region"] = app.query_one("#triage", RichLog).region
  875. observed["screen_size"] = app.screen.size
  876. asyncio.run(scenario())
  877. print("triage maximized: region=%s screen=%s" % (observed["region"], observed["screen_size"]))
  878. assert observed["maximized_is_main"] is True
  879. assert observed["triage_display"] is True
  880. assert observed["chat_display"] is False
  881. region = observed["region"]
  882. size = observed["screen_size"]
  883. assert abs(region.width - size.width) <= 2, (region, size)
  884. assert abs(region.height - size.height) <= 2, (region, size)
  885. def test_maximize_toggle_restores_layout(server):
  886. """Press m again: minimize restores panes; chat follows the content rule (hidden)."""
  887. pytest.importorskip("textual")
  888. from textual.widgets import RichLog
  889. from stt_daemon.tui.app import AssistantTUI
  890. path, _service = server
  891. app = AssistantTUI(path, triage_enabled=True)
  892. observed: dict = {}
  893. async def scenario():
  894. async with app.run_test(size=(80, 40)) as pilot:
  895. await pilot.pause()
  896. app.query_one("#triage", RichLog).focus()
  897. await pilot.pause()
  898. await pilot.press("m")
  899. await pilot.pause()
  900. await pilot.press("m")
  901. await pilot.pause()
  902. observed["maximized"] = app.screen.maximized
  903. observed["triage_display"] = app.query_one("#triage", RichLog).display
  904. observed["chat_display"] = app.query_one("#chat", RichLog).display
  905. asyncio.run(scenario())
  906. assert observed["maximized"] is None
  907. assert observed["triage_display"] is True
  908. # No chat/command output ingested: chat follows the content rule → hidden.
  909. assert observed["chat_display"] is False
  910. def test_maximize_transcript_fills_screen(server):
  911. """Focus #transcript, press m: transcript maximized and fills the screen."""
  912. pytest.importorskip("textual")
  913. from textual.widgets import RichLog
  914. from stt_daemon.tui.app import AssistantTUI
  915. path, _service = server
  916. app = AssistantTUI(path, triage_enabled=True)
  917. observed: dict = {}
  918. async def scenario():
  919. async with app.run_test(size=(80, 40)) as pilot:
  920. await pilot.pause()
  921. app.query_one("#transcript", RichLog).focus()
  922. await pilot.pause()
  923. await pilot.press("m")
  924. await pilot.pause()
  925. observed["maximized_is_transcript"] = (
  926. app.screen.maximized is app.query_one("#transcript")
  927. )
  928. observed["region"] = app.query_one("#transcript", RichLog).region
  929. observed["screen_size"] = app.screen.size
  930. await pilot.press("m")
  931. await pilot.pause()
  932. observed["restored"] = app.screen.maximized
  933. asyncio.run(scenario())
  934. print("transcript maximized: region=%s screen=%s"
  935. % (observed["region"], observed["screen_size"]))
  936. assert observed["maximized_is_transcript"] is True
  937. region = observed["region"]
  938. size = observed["screen_size"]
  939. assert abs(region.width - size.width) <= 2, (region, size)
  940. assert abs(region.height - size.height) <= 2, (region, size)
  941. assert observed["restored"] is None
  942. def test_escape_while_maximized_restores_panes(server):
  943. """Escape minimizes (ESCAPE_TO_MINIMIZE) and _drain restores the hidden sibling."""
  944. pytest.importorskip("textual")
  945. from textual.widgets import RichLog
  946. from stt_daemon.tui.app import AssistantTUI
  947. path, _service = server
  948. app = AssistantTUI(path, triage_enabled=True)
  949. observed: dict = {}
  950. async def scenario():
  951. async with app.run_test(size=(80, 40)) as pilot:
  952. await pilot.pause()
  953. app.query_one("#triage", RichLog).focus()
  954. await pilot.pause()
  955. await pilot.press("m")
  956. await pilot.pause()
  957. assert app.screen.maximized is not None
  958. await pilot.press("escape")
  959. await pilot.pause()
  960. # Let the 0.1s _drain timer run so the restore path executes.
  961. for _ in range(10):
  962. await pilot.pause()
  963. await asyncio.sleep(0.05)
  964. if app.screen.maximized is None:
  965. break
  966. observed["maximized"] = app.screen.maximized
  967. observed["triage_display"] = app.query_one("#triage", RichLog).display
  968. observed["chat_display"] = app.query_one("#chat", RichLog).display
  969. asyncio.run(scenario())
  970. assert observed["maximized"] is None
  971. assert observed["triage_display"] is True
  972. # No chat/command output ingested: chat follows the content rule → hidden.
  973. assert observed["chat_display"] is False
  974. def test_maximize_chat_when_triage_disabled(server):
  975. """triage_enabled=False: focus #chat, press m maximizes #main without crashing."""
  976. pytest.importorskip("textual")
  977. from textual.widgets import RichLog
  978. from stt_daemon.tui.app import AssistantTUI
  979. path, _service = server
  980. app = AssistantTUI(path)
  981. observed: dict = {}
  982. async def scenario():
  983. async with app.run_test(size=(80, 40)) as pilot:
  984. await pilot.pause()
  985. app._timer.stop() # keep the server status from re-hiding #chat
  986. # Disabled: no #main/#triage. #chat starts hidden, so reveal it via
  987. # the cycle, then focus it and press m to maximize #chat itself.
  988. app._lower_view = "chat"
  989. app.state.ingest({"type": "mode", "mode": "chat"})
  990. app.state.ingest({"type": "chat_message", "role": "user", "text": "hi"})
  991. app._render()
  992. await pilot.pause()
  993. app.query_one("#chat", RichLog).focus()
  994. await pilot.pause()
  995. await pilot.press("m")
  996. await pilot.pause()
  997. observed["maximized"] = app.screen.maximized
  998. observed["chat"] = app.query_one("#chat", RichLog)
  999. observed["region"] = app.query_one("#chat", RichLog).region
  1000. observed["screen_size"] = app.screen.size
  1001. await pilot.press("m")
  1002. await pilot.pause()
  1003. observed["restored"] = app.screen.maximized
  1004. asyncio.run(scenario())
  1005. print("chat maximized (disabled): region=%s screen=%s"
  1006. % (observed["region"], observed["screen_size"]))
  1007. assert observed["maximized"] is observed["chat"]
  1008. assert observed["restored"] is None
  1009. def test_footer_hidden_while_maximized(server):
  1010. """ALLOW_IN_MAXIMIZED_VIEW hides the Footer while a pane is maximized."""
  1011. pytest.importorskip("textual")
  1012. from textual.widgets import Footer, RichLog
  1013. from stt_daemon.tui.app import AssistantTUI
  1014. path, _service = server
  1015. app = AssistantTUI(path, triage_enabled=True)
  1016. observed: dict = {}
  1017. async def scenario():
  1018. async with app.run_test(size=(80, 40)) as pilot:
  1019. await pilot.pause()
  1020. app.query_one("#triage", RichLog).focus()
  1021. await pilot.pause()
  1022. await pilot.press("m")
  1023. await pilot.pause()
  1024. observed["maximized"] = app.screen.maximized
  1025. observed["footer_region"] = app.query_one(Footer).region
  1026. await pilot.press("m")
  1027. await pilot.pause()
  1028. asyncio.run(scenario())
  1029. assert observed["maximized"] is not None
  1030. footer_region = observed["footer_region"]
  1031. assert footer_region.height == 0 or footer_region.width == 0, observed
  1032. def test_question_mark_key_toggles_help_panel(server):
  1033. pytest.importorskip("textual")
  1034. from textual.widgets import HelpPanel
  1035. from stt_daemon.tui.app import AssistantTUI
  1036. path, _service = server
  1037. app = AssistantTUI(path)
  1038. observed: dict = {}
  1039. async def scenario():
  1040. async with app.run_test() as pilot:
  1041. await pilot.pause()
  1042. assert list(app.screen.query(HelpPanel)) == []
  1043. await pilot.press("question_mark")
  1044. await pilot.pause()
  1045. observed["shown"] = list(app.screen.query(HelpPanel)) != []
  1046. app.screen.query_one(HelpPanel) # must succeed while shown
  1047. await pilot.press("question_mark")
  1048. await pilot.pause()
  1049. observed["hidden"] = list(app.screen.query(HelpPanel)) == []
  1050. asyncio.run(scenario())
  1051. assert observed["shown"] is True, "? did not mount HelpPanel"
  1052. assert observed["hidden"] is True, "second ? did not remove HelpPanel"
  1053. def test_app_bindings_list():
  1054. """BINDINGS: 8 visible footer entries; hidden ones after; no h binding."""
  1055. pytest.importorskip("textual")
  1056. from stt_daemon.tui.app import AssistantTUI
  1057. pairs = [(b.key, b.description) for b in AssistantTUI.BINDINGS]
  1058. assert ("ctrl+r", "execute") in pairs
  1059. assert ("m", "maximize pane") in pairs
  1060. assert ("question_mark", "help") in pairs
  1061. assert ("ctrl+s", "save screenshot") in pairs
  1062. assert ("a", "arm command") in pairs
  1063. assert ("escape", "cancel") in pairs
  1064. assert ("e", "edit command") in pairs
  1065. assert ("t", "cycle pane") in pairs
  1066. assert ("s", "questions view") in pairs
  1067. assert ("c", "clear") in pairs
  1068. assert ("q", "quit") in pairs
  1069. assert ("ctrl+p", "instructions") in pairs
  1070. assert not any(key == "h" for key, _ in pairs)
  1071. assert not any(key == "ctrl+m" for key, _ in pairs)
  1072. assert not any(key == "ctrl+h" for key, _ in pairs)
  1073. assert not any(key == "enter" for key, _ in pairs)
  1074. assert not any(key == "ctrl+k" for key, _ in pairs)
  1075. by_key = {b.key: b for b in AssistantTUI.BINDINGS}
  1076. assert by_key["question_mark"].show is True
  1077. assert by_key["s"].show is True
  1078. assert by_key["m"].show is False
  1079. assert by_key["c"].show is False
  1080. assert by_key["ctrl+s"].show is False
  1081. assert by_key["ctrl+p"].show is False
  1082. # Visible eight first, in footer order.
  1083. visible = [(b.key, b.description) for b in AssistantTUI.BINDINGS if b.show]
  1084. assert visible == [
  1085. ("a", "arm command"),
  1086. ("ctrl+r", "execute"),
  1087. ("escape", "cancel"),
  1088. ("e", "edit command"),
  1089. ("t", "cycle pane"),
  1090. ("s", "questions view"),
  1091. ("question_mark", "help"),
  1092. ("q", "quit"),
  1093. ]
  1094. assert by_key["ctrl+p"].priority is True
  1095. def test_footer_shows_ordered_entries(server):
  1096. """Left list is the 8 visible bindings (? for help); instructions only right-aligned."""
  1097. pytest.importorskip("textual")
  1098. from textual.widgets import Footer
  1099. from textual.widgets._footer import FooterKey
  1100. from stt_daemon.tui.app import AssistantTUI
  1101. path, _service = server
  1102. app = AssistantTUI(path)
  1103. observed: dict = {}
  1104. async def scenario():
  1105. async with app.run_test() as pilot:
  1106. await pilot.pause()
  1107. shown = [
  1108. (b.binding.key, b.binding.description)
  1109. for b in app.screen.active_bindings.values()
  1110. if b.binding.show
  1111. ]
  1112. observed["shown"] = shown
  1113. footer = app.query_one(Footer)
  1114. keys = list(footer.query(FooterKey))
  1115. observed["keys"] = [
  1116. (k.key, k.key_display, k.description, k.action, set(k.classes))
  1117. for k in keys
  1118. ]
  1119. asyncio.run(scenario())
  1120. expected = [
  1121. ("a", "arm command"),
  1122. ("ctrl+r", "execute"),
  1123. ("escape", "cancel"),
  1124. ("e", "edit command"),
  1125. ("t", "cycle pane"),
  1126. ("s", "questions view"),
  1127. ("question_mark", "help"),
  1128. ("q", "quit"),
  1129. ]
  1130. assert observed["shown"] == expected, observed["shown"]
  1131. # Help advertises as "?" in the footer.
  1132. assert app.get_key_display(
  1133. next(b for b in AssistantTUI.BINDINGS if b.key == "question_mark")
  1134. ) == "?"
  1135. # The composed Footer: exactly one "instructions" entry and it is the
  1136. # right-aligned command-palette key; no left duplicate exists.
  1137. instructions = [k for k in observed["keys"] if k[2] == "instructions"]
  1138. assert len(instructions) == 1, observed["keys"]
  1139. key, key_display, _desc, action, classes = instructions[0]
  1140. assert action == "command_palette", instructions
  1141. assert "-command-palette" in classes, instructions
  1142. assert key == "ctrl+p", instructions
  1143. non_palette = [k for k in observed["keys"] if "-command-palette" not in k[4]]
  1144. assert not any(k[2] == "instructions" for k in non_palette), observed["keys"]
  1145. assert not any(k[3] == "command_palette" for k in non_palette), observed["keys"]
  1146. def test_ctrl_p_opens_command_palette(server):
  1147. """ctrl+p still opens the palette; its binding description is 'instructions'."""
  1148. pytest.importorskip("textual")
  1149. from textual.command import CommandPalette
  1150. from stt_daemon.tui.app import AssistantTUI
  1151. path, _service = server
  1152. app = AssistantTUI(path)
  1153. observed: dict = {}
  1154. async def scenario():
  1155. async with app.run_test() as pilot:
  1156. await pilot.pause()
  1157. await pilot.press("ctrl+p")
  1158. await pilot.pause()
  1159. await pilot.pause()
  1160. observed["is_palette"] = isinstance(app.screen, CommandPalette)
  1161. observed["stack"] = [type(s).__name__ for s in app.screen_stack]
  1162. asyncio.run(scenario())
  1163. palette_binding = next(b for b in AssistantTUI.BINDINGS if b.key == "ctrl+p")
  1164. assert palette_binding.description == "instructions"
  1165. assert observed["is_palette"], "ctrl+p did not open the command palette: %s" % (
  1166. observed["stack"],
  1167. )
  1168. def test_question_mark_toggles_help_panel(server):
  1169. """? toggles the help panel and is advertised in the left footer list."""
  1170. pytest.importorskip("textual")
  1171. from textual.widgets import HelpPanel
  1172. from stt_daemon.tui.app import AssistantTUI
  1173. path, _service = server
  1174. app = AssistantTUI(path)
  1175. observed: dict = {}
  1176. async def scenario():
  1177. async with app.run_test() as pilot:
  1178. await pilot.pause()
  1179. assert list(app.screen.query(HelpPanel)) == []
  1180. await pilot.press("?")
  1181. await pilot.pause()
  1182. observed["shown"] = list(app.screen.query(HelpPanel)) != []
  1183. await pilot.press("?")
  1184. await pilot.pause()
  1185. observed["hidden"] = list(app.screen.query(HelpPanel)) == []
  1186. asyncio.run(scenario())
  1187. assert observed["shown"] is True, "? did not mount HelpPanel"
  1188. assert observed["hidden"] is True, "second ? did not remove HelpPanel"
  1189. def test_plain_m_maximizes_triage_pane(server):
  1190. pytest.importorskip("textual")
  1191. from stt_daemon.tui.app import AssistantTUI
  1192. from textual.widgets import RichLog
  1193. path, _service = server
  1194. app = AssistantTUI(path, triage_enabled=True)
  1195. async def scenario():
  1196. async with app.run_test(size=(80, 40)) as pilot:
  1197. await pilot.pause()
  1198. app.query_one("#triage", RichLog).focus()
  1199. await pilot.pause()
  1200. await pilot.press("m")
  1201. await pilot.pause()
  1202. assert app.screen.maximized is app.query_one("#main")
  1203. await pilot.press("m")
  1204. await pilot.pause()
  1205. assert app.screen.maximized is None
  1206. assert app.query_one("#triage", RichLog).display is True
  1207. # No chat/command output ingested: chat follows the content rule → hidden.
  1208. assert app.query_one("#chat", RichLog).display is False
  1209. asyncio.run(scenario())
  1210. def test_screenshot_saves_svg(server, tmp_path, monkeypatch):
  1211. pytest.importorskip("textual")
  1212. import glob
  1213. from stt_daemon.tui.app import AssistantTUI
  1214. monkeypatch.chdir(tmp_path)
  1215. path, _service = server
  1216. app = AssistantTUI(path)
  1217. async def scenario():
  1218. async with app.run_test() as pilot:
  1219. await pilot.pause()
  1220. await pilot.press("ctrl+s")
  1221. await pilot.pause()
  1222. await pilot.pause()
  1223. asyncio.run(scenario())
  1224. out_dir = tmp_path / "screenshots"
  1225. assert out_dir.is_dir(), "screenshots/ not created in %s" % tmp_path
  1226. files = glob.glob(str(out_dir / "stt-tui-*.svg"))
  1227. assert files, "no stt-tui-*.svg found in %s" % out_dir
  1228. assert os.path.getsize(files[0]) > 0
  1229. assert glob.glob(str(tmp_path / "stt-tui-*.svg")) == [], \
  1230. "screenshot leaked into %s instead of screenshots/" % tmp_path
  1231. def test_screenshot_saves_into_session_dir(server, tmp_path, monkeypatch):
  1232. pytest.importorskip("textual")
  1233. import glob
  1234. from stt_daemon.tui.app import AssistantTUI
  1235. monkeypatch.chdir(tmp_path)
  1236. path, _service = server
  1237. app = AssistantTUI(path)
  1238. app.state.session_dir = "transcripts/x"
  1239. async def scenario():
  1240. async with app.run_test() as pilot:
  1241. await pilot.pause()
  1242. await pilot.press("ctrl+s")
  1243. await pilot.pause()
  1244. await pilot.pause()
  1245. asyncio.run(scenario())
  1246. out_dir = tmp_path / "transcripts" / "x" / "screenshots"
  1247. assert out_dir.is_dir(), "session screenshots/ not created in %s" % out_dir
  1248. files = glob.glob(str(out_dir / "stt-tui-*.svg"))
  1249. assert files, "no stt-tui-*.svg found in %s" % out_dir
  1250. assert os.path.getsize(files[0]) > 0
  1251. assert glob.glob(str(tmp_path / "screenshots" / "stt-tui-*.svg")) == [], \
  1252. "screenshot leaked into screenshots/ instead of the session dir"
  1253. def test_chat_hidden_by_default_triage_fills_main(server):
  1254. """Fresh state: #chat hidden, #triage fills the whole #main width."""
  1255. pytest.importorskip("textual")
  1256. from textual.containers import Horizontal
  1257. from textual.widgets import RichLog
  1258. from stt_daemon.tui.app import AssistantTUI
  1259. path, _service = server
  1260. app = AssistantTUI(path, triage_enabled=True)
  1261. observed: dict = {}
  1262. async def scenario():
  1263. async with app.run_test(size=(80, 40)) as pilot:
  1264. await pilot.pause()
  1265. app._timer.stop() # no background drain: server status must not clobber state
  1266. app._drain()
  1267. app._render()
  1268. await pilot.pause()
  1269. observed["chat_display"] = app.query_one("#chat", RichLog).display
  1270. observed["triage_display"] = app.query_one("#triage", RichLog).display
  1271. observed["main_region"] = app.query_one("#main", Horizontal).region
  1272. observed["triage_region"] = app.query_one("#triage", RichLog).region
  1273. asyncio.run(scenario())
  1274. print("hidden: main=%s triage=%s" % (observed["main_region"], observed["triage_region"]))
  1275. assert observed["chat_display"] is False
  1276. assert observed["triage_display"] is True
  1277. assert (
  1278. abs(observed["triage_region"].width - observed["main_region"].width) <= 2
  1279. ), (observed["triage_region"], observed["main_region"])
  1280. def test_chat_mode_shows_chat_and_restores_split(server):
  1281. """mode=chat → #chat visible, triage back to ~1/3 width."""
  1282. pytest.importorskip("textual")
  1283. from textual.containers import Horizontal
  1284. from textual.widgets import RichLog
  1285. from stt_daemon.tui.app import AssistantTUI
  1286. path, _service = server
  1287. app = AssistantTUI(path, triage_enabled=True)
  1288. observed: dict = {}
  1289. async def scenario():
  1290. async with app.run_test(size=(80, 40)) as pilot:
  1291. await pilot.pause()
  1292. app._timer.stop() # no background drain: server status must not clobber state
  1293. app._drain()
  1294. app._lower_view = "chat"
  1295. app.state.ingest({"type": "mode", "mode": "chat"})
  1296. app._render()
  1297. await pilot.pause()
  1298. observed["chat_display"] = app.query_one("#chat", RichLog).display
  1299. observed["main_region"] = app.query_one("#main", Horizontal).region
  1300. observed["triage_region"] = app.query_one("#triage", RichLog).region
  1301. observed["chat_region"] = app.query_one("#chat", RichLog).region
  1302. asyncio.run(scenario())
  1303. print(
  1304. "shown: main=%s triage=%s chat=%s"
  1305. % (observed["main_region"], observed["triage_region"], observed["chat_region"])
  1306. )
  1307. assert observed["chat_display"] is True
  1308. main_w = observed["main_region"].width
  1309. assert abs(observed["triage_region"].width / main_w - 1 / 3) < 0.08, observed
  1310. assert abs(observed["chat_region"].width / main_w - 2 / 3) < 0.08, observed
  1311. def test_tool_result_shows_output_pane(server):
  1312. """Command output (tool_result) goes to #output, leaving #chat hidden."""
  1313. pytest.importorskip("textual")
  1314. from textual.widgets import RichLog
  1315. from stt_daemon.tui.app import AssistantTUI
  1316. path, _service = server
  1317. app = AssistantTUI(path, triage_enabled=True)
  1318. observed: dict = {}
  1319. async def scenario():
  1320. async with app.run_test(size=(80, 40)) as pilot:
  1321. await pilot.pause()
  1322. app._timer.stop() # no background drain: server status must not clobber state
  1323. app._drain()
  1324. app._lower_view = "output"
  1325. app.state.ingest(
  1326. {
  1327. "type": "tool_result",
  1328. "tool": "get_datetime",
  1329. "ok": True,
  1330. "result": "2026-09-21",
  1331. }
  1332. )
  1333. app._render()
  1334. await pilot.pause()
  1335. observed["output_display"] = app.query_one("#output", RichLog).display
  1336. observed["chat_display"] = app.query_one("#chat", RichLog).display
  1337. asyncio.run(scenario())
  1338. assert observed["output_display"] is True
  1339. assert observed["chat_display"] is False
  1340. def test_error_event_shows_output_pane(server):
  1341. """An error event goes to #output, leaving #chat hidden."""
  1342. pytest.importorskip("textual")
  1343. from textual.widgets import RichLog
  1344. from stt_daemon.tui.app import AssistantTUI
  1345. path, _service = server
  1346. app = AssistantTUI(path, triage_enabled=True)
  1347. observed: dict = {}
  1348. async def scenario():
  1349. async with app.run_test(size=(80, 40)) as pilot:
  1350. await pilot.pause()
  1351. app._timer.stop() # no background drain: server status must not clobber state
  1352. app._drain()
  1353. app._lower_view = "output"
  1354. app.state.ingest({"type": "error", "code": "boom", "message": "kaboom"})
  1355. app._render()
  1356. await pilot.pause()
  1357. observed["output_display"] = app.query_one("#output", RichLog).display
  1358. observed["chat_display"] = app.query_one("#chat", RichLog).display
  1359. asyncio.run(scenario())
  1360. assert observed["output_display"] is True
  1361. assert observed["chat_display"] is False
  1362. def test_executing_mode_shows_output_pane(server):
  1363. """Assistant executing a command → #output visible, #chat hidden."""
  1364. pytest.importorskip("textual")
  1365. from textual.widgets import RichLog
  1366. from stt_daemon.tui.app import AssistantTUI
  1367. path, _service = server
  1368. app = AssistantTUI(path, triage_enabled=True)
  1369. observed: dict = {}
  1370. async def scenario():
  1371. async with app.run_test(size=(80, 40)) as pilot:
  1372. await pilot.pause()
  1373. app._timer.stop() # no background drain: server status must not clobber state
  1374. app._drain()
  1375. app._lower_view = "output"
  1376. app.state.ingest({"type": "mode", "mode": "executing"})
  1377. app._render()
  1378. await pilot.pause()
  1379. observed["output_display"] = app.query_one("#output", RichLog).display
  1380. observed["chat_display"] = app.query_one("#chat", RichLog).display
  1381. asyncio.run(scenario())
  1382. assert observed["output_display"] is True
  1383. assert observed["chat_display"] is False
  1384. def test_cleared_hides_pane_empties_output_and_restores_full_width(server):
  1385. """cleared empties #output and hides it again, triage full width."""
  1386. pytest.importorskip("textual")
  1387. from textual.containers import Horizontal
  1388. from textual.widgets import RichLog
  1389. from stt_daemon.tui.app import AssistantTUI
  1390. path, _service = server
  1391. app = AssistantTUI(path, triage_enabled=True)
  1392. observed: dict = {}
  1393. async def scenario():
  1394. async with app.run_test(size=(80, 40)) as pilot:
  1395. await pilot.pause()
  1396. app._timer.stop() # no background drain: server status must not clobber state
  1397. app._drain()
  1398. app._lower_view = "output"
  1399. app.state.ingest(
  1400. {
  1401. "type": "tool_result",
  1402. "tool": "get_datetime",
  1403. "ok": True,
  1404. "result": "2026-09-21",
  1405. }
  1406. )
  1407. app._render()
  1408. await pilot.pause()
  1409. assert app.query_one("#output", RichLog).display is True
  1410. app.state.ingest({"type": "cleared"})
  1411. app._render()
  1412. await pilot.pause()
  1413. observed["empty_output"] = [
  1414. str(line) for line in app.query_one("#output", RichLog).lines
  1415. ]
  1416. observed["lower_view"] = app._lower_view
  1417. observed["chat_display"] = app.query_one("#chat", RichLog).display
  1418. observed["output_display"] = app.query_one("#output", RichLog).display
  1419. observed["main_region"] = app.query_one("#main", Horizontal).region
  1420. observed["triage_region"] = app.query_one("#triage", RichLog).region
  1421. asyncio.run(scenario())
  1422. print(
  1423. "cleared: main=%s triage=%s"
  1424. % (observed["main_region"], observed["triage_region"])
  1425. )
  1426. assert observed["empty_output"] == []
  1427. assert observed["lower_view"] == "hidden"
  1428. assert observed["chat_display"] is False
  1429. assert observed["output_display"] is False
  1430. assert (
  1431. abs(observed["triage_region"].width - observed["main_region"].width) <= 2
  1432. ), (observed["triage_region"], observed["main_region"])
  1433. def test_cycle_lower_switches_panes(server):
  1434. """t cycles hidden -> #output -> #chat -> hidden when triage is disabled."""
  1435. pytest.importorskip("textual")
  1436. from textual.widgets import RichLog
  1437. from stt_daemon.tui.app import AssistantTUI
  1438. path, _service = server
  1439. app = AssistantTUI(path)
  1440. observed: dict = {}
  1441. def snap():
  1442. return (
  1443. app.query_one("#output", RichLog).display,
  1444. app.query_one("#chat", RichLog).display,
  1445. app.query_one("#transcript", RichLog).has_class("-grow"),
  1446. )
  1447. async def scenario():
  1448. async with app.run_test(size=(80, 40)) as pilot:
  1449. await pilot.pause()
  1450. app._timer.stop() # no background drain: server status must not clobber state
  1451. app._drain()
  1452. app._render()
  1453. await pilot.pause()
  1454. observed["hidden"] = snap()
  1455. app.action_cycle_lower()
  1456. app._render()
  1457. await pilot.pause()
  1458. observed["output"] = snap()
  1459. app.action_cycle_lower()
  1460. app._render()
  1461. await pilot.pause()
  1462. observed["chat"] = snap()
  1463. app.action_cycle_lower()
  1464. app._render()
  1465. await pilot.pause()
  1466. observed["hidden_again"] = snap()
  1467. asyncio.run(scenario())
  1468. assert observed["hidden"] == (False, False, True)
  1469. assert observed["output"] == (True, False, False)
  1470. assert observed["chat"] == (False, True, False)
  1471. assert observed["hidden_again"] == (False, False, True)
  1472. def test_cycle_lower_enters_and_exits_chat_mode(server):
  1473. """Selecting conversation sends chat_toggle on; leaving it toggles off but
  1474. keeps the rendered turns until an explicit clear."""
  1475. pytest.importorskip("textual")
  1476. from textual.widgets import RichLog
  1477. from stt_daemon.tui.app import AssistantTUI
  1478. path, _service = server
  1479. app = AssistantTUI(path)
  1480. sent: list[tuple] = []
  1481. observed: dict = {}
  1482. async def scenario():
  1483. async with app.run_test(size=(80, 40)) as pilot:
  1484. await pilot.pause()
  1485. app.client.send = lambda type_, **fields: sent.append((type_, fields))
  1486. app._timer.stop()
  1487. app._drain()
  1488. app.action_cycle_lower() # hidden -> output
  1489. app.action_cycle_lower() # output -> chat
  1490. app._render()
  1491. await pilot.pause()
  1492. observed["after_enter"] = list(sent)
  1493. app.state.ingest({"type": "mode", "mode": "chat"})
  1494. app.state.ingest({"type": "chat_message", "role": "user", "text": "remember"})
  1495. app._render()
  1496. await pilot.pause()
  1497. app.action_cycle_lower() # chat -> hidden (mode is chat -> exits)
  1498. app._render()
  1499. await pilot.pause()
  1500. observed["after_exit"] = list(sent)
  1501. observed["chat_lines"] = [str(line) for line in app.query_one("#chat", RichLog).lines]
  1502. asyncio.run(scenario())
  1503. assert observed["after_enter"] == [("chat_toggle", {})]
  1504. assert observed["after_exit"] == [("chat_toggle", {}), ("chat_toggle", {})]
  1505. assert any("remember" in line for line in observed["chat_lines"])
  1506. def test_command_activity_reveals_output_pane(server):
  1507. """Arming, executing, and chatting: command modes force #output; chat does not."""
  1508. pytest.importorskip("textual")
  1509. from textual.widgets import RichLog
  1510. from stt_daemon.tui.app import AssistantTUI
  1511. path, _service = server
  1512. app = AssistantTUI(path, triage_enabled=True)
  1513. observed: dict = {}
  1514. async def scenario():
  1515. async with app.run_test(size=(80, 40)) as pilot:
  1516. await pilot.pause()
  1517. app._timer.stop()
  1518. app._drain()
  1519. await pilot.pause()
  1520. observed["hidden"] = app.query_one("#output", RichLog).display
  1521. app.state.ingest({"type": "mode", "mode": "command_armed"})
  1522. app._render()
  1523. await pilot.pause()
  1524. observed["armed"] = (
  1525. app._lower_view,
  1526. app.query_one("#output", RichLog).display,
  1527. app.query_one("#chat", RichLog).display,
  1528. )
  1529. app._lower_view = "hidden"
  1530. app.state.ingest({"type": "mode", "mode": "executing"})
  1531. app._render()
  1532. await pilot.pause()
  1533. observed["executing"] = (
  1534. app._lower_view,
  1535. app.query_one("#output", RichLog).display,
  1536. )
  1537. app._lower_view = "chat"
  1538. app.state.ingest({"type": "mode", "mode": "chat"})
  1539. app._render()
  1540. await pilot.pause()
  1541. observed["chat"] = (
  1542. app._lower_view,
  1543. app.query_one("#output", RichLog).display,
  1544. app.query_one("#chat", RichLog).display,
  1545. )
  1546. asyncio.run(scenario())
  1547. assert observed["hidden"] is False
  1548. assert observed["armed"] == ("output", True, False)
  1549. assert observed["executing"] == ("output", True)
  1550. assert observed["chat"] == ("chat", False, True)
  1551. def test_maximize_restore_with_output_keeps_output_visible(server):
  1552. """With ingested output, m then m restores with #output still visible."""
  1553. pytest.importorskip("textual")
  1554. from textual.widgets import RichLog
  1555. from stt_daemon.tui.app import AssistantTUI
  1556. path, _service = server
  1557. app = AssistantTUI(path, triage_enabled=True)
  1558. observed: dict = {}
  1559. async def scenario():
  1560. async with app.run_test(size=(80, 40)) as pilot:
  1561. await pilot.pause()
  1562. app._timer.stop() # no background drain: server status must not clobber state
  1563. app._drain()
  1564. app._lower_view = "output"
  1565. app.state.ingest(
  1566. {
  1567. "type": "tool_result",
  1568. "tool": "get_datetime",
  1569. "ok": True,
  1570. "result": "2026-09-21",
  1571. }
  1572. )
  1573. app._render()
  1574. await pilot.pause()
  1575. assert app.query_one("#output", RichLog).display is True
  1576. app.query_one("#triage", RichLog).focus()
  1577. await pilot.pause()
  1578. await pilot.press("m")
  1579. await pilot.pause()
  1580. assert app.screen.maximized is not None
  1581. await pilot.press("m")
  1582. await pilot.pause()
  1583. observed["maximized"] = app.screen.maximized
  1584. observed["triage_display"] = app.query_one("#triage", RichLog).display
  1585. observed["output_display"] = app.query_one("#output", RichLog).display
  1586. asyncio.run(scenario())
  1587. assert observed["maximized"] is None
  1588. assert observed["triage_display"] is True
  1589. assert observed["output_display"] is True
  1590. def test_transcript_pane_keeps_updating_past_cap(server):
  1591. """Regression: past MAX_TRANSCRIPTS the running log must still append."""
  1592. pytest.importorskip("textual")
  1593. from textual.widgets import RichLog
  1594. from stt_daemon.tui.app import AssistantTUI
  1595. from stt_daemon.tui.state import MAX_TRANSCRIPTS
  1596. path, _service = server
  1597. app = AssistantTUI(path)
  1598. observed: dict = {}
  1599. async def scenario():
  1600. async with app.run_test(size=(80, 40)) as pilot:
  1601. await pilot.pause()
  1602. app._timer.stop() # no background drain: server status must not clobber state
  1603. app._drain()
  1604. for i in range(MAX_TRANSCRIPTS + 5):
  1605. app.state.ingest({"type": "transcript", "text": f"t{i}", "lang": "en"})
  1606. app._render()
  1607. await pilot.pause()
  1608. app.state.ingest({"type": "transcript", "text": "after cap", "lang": "en"})
  1609. app._render()
  1610. await pilot.pause()
  1611. log = app.query_one("#transcript", RichLog)
  1612. observed["lines"] = [str(line) for line in log.lines]
  1613. asyncio.run(scenario())
  1614. assert any("after cap" in line for line in observed["lines"])
  1615. assert len(observed["lines"]) > MAX_TRANSCRIPTS
  1616. def _questions_payload():
  1617. return {
  1618. "topic": {
  1619. "type": "choice",
  1620. "instructions": "What is the main theme?",
  1621. "criteria": {"planning": "roadmap or prioritization"},
  1622. }
  1623. }
  1624. def _rendered_lines(log):
  1625. return [line.text for line in log.lines]
  1626. def _ingest_triage_signals(app):
  1627. app.state.ingest(
  1628. {
  1629. "type": "triage",
  1630. "stage": "signals",
  1631. "triage": {
  1632. "order": ["topic"],
  1633. "items": {
  1634. "topic": {
  1635. "kind": "choice",
  1636. "choice": "planning",
  1637. "answer_confidence": 0.91,
  1638. }
  1639. },
  1640. },
  1641. }
  1642. )
  1643. app.state.ingest(
  1644. {
  1645. "type": "status",
  1646. "triage_state": "ok",
  1647. "triage_reason": "",
  1648. "questions": _questions_payload(),
  1649. }
  1650. )
  1651. def test_s_key_swaps_triage_pane_to_questions_view(server):
  1652. """Press s: #triage border flips to questions and renders the structured blocks."""
  1653. pytest.importorskip("textual")
  1654. from textual.widgets import RichLog
  1655. from stt_daemon.tui.app import AssistantTUI
  1656. path, _service = server
  1657. app = AssistantTUI(path, triage_enabled=True)
  1658. observed: dict = {}
  1659. async def scenario():
  1660. async with app.run_test() as pilot:
  1661. await pilot.pause()
  1662. app._timer.stop() # no background drain: server status must not clobber state
  1663. app._drain()
  1664. _ingest_triage_signals(app)
  1665. app._render()
  1666. await pilot.pause()
  1667. observed["default_title"] = app.query_one("#triage", RichLog).border_title
  1668. observed["default_lines"] = _rendered_lines(app.query_one("#triage", RichLog))
  1669. await pilot.press("s")
  1670. await pilot.pause()
  1671. observed["questions_title"] = app.query_one("#triage", RichLog).border_title
  1672. observed["questions_lines"] = _rendered_lines(app.query_one("#triage", RichLog))
  1673. asyncio.run(scenario())
  1674. assert observed["default_title"] == "triage \\[inf 0]" # markup: '[' is escaped
  1675. assert any("topic: planning (0.91)" in line for line in observed["default_lines"])
  1676. assert observed["questions_title"] == "questions"
  1677. rendered = "\n".join(observed["questions_lines"])
  1678. assert "topic [choice]" in rendered
  1679. assert "What is the main theme?" in rendered
  1680. assert "planning — roadmap or prioritization" in rendered
  1681. assert "topic: planning" not in rendered
  1682. def test_s_key_again_restores_triage_view(server):
  1683. """Second s: border flips back to triage and the signals view is rewritten."""
  1684. pytest.importorskip("textual")
  1685. from textual.widgets import RichLog
  1686. from stt_daemon.tui.app import AssistantTUI
  1687. path, _service = server
  1688. app = AssistantTUI(path, triage_enabled=True)
  1689. observed: dict = {}
  1690. async def scenario():
  1691. async with app.run_test() as pilot:
  1692. await pilot.pause()
  1693. app._timer.stop() # no background drain: server status must not clobber state
  1694. app._drain()
  1695. _ingest_triage_signals(app)
  1696. app._render()
  1697. await pilot.pause()
  1698. await pilot.press("s")
  1699. await pilot.pause()
  1700. observed["mid_title"] = app.query_one("#triage", RichLog).border_title
  1701. await pilot.press("s")
  1702. await pilot.pause()
  1703. observed["triage_title"] = app.query_one("#triage", RichLog).border_title
  1704. observed["triage_lines"] = _rendered_lines(app.query_one("#triage", RichLog))
  1705. asyncio.run(scenario())
  1706. assert observed["mid_title"] == "questions"
  1707. assert observed["triage_title"] == "triage \\[inf 0]" # markup: '[' is escaped
  1708. rendered = "\n".join(observed["triage_lines"])
  1709. assert "topic: planning (0.91)" in rendered
  1710. assert "What is the main theme?" not in rendered
  1711. def test_triage_brief_title_mirrors_inference_depth_and_busy(server):
  1712. """Brief view: #triage title carries [inf N] and the busy marker, exactly."""
  1713. pytest.importorskip("textual")
  1714. from textual.widgets import RichLog
  1715. from stt_daemon.tui.app import AssistantTUI
  1716. path, _service = server
  1717. app = AssistantTUI(path, triage_enabled=True)
  1718. observed: dict = {}
  1719. async def scenario():
  1720. async with app.run_test() as pilot:
  1721. await pilot.pause()
  1722. app._timer.stop() # no background drain: server status must not clobber state
  1723. app._drain()
  1724. app.state.ingest(
  1725. {"type": "heartbeat", "inference_depth": 3, "inference_in_flight": False}
  1726. )
  1727. app._render()
  1728. await pilot.pause()
  1729. observed["idle"] = app.query_one("#triage", RichLog).border_title
  1730. app.state.ingest(
  1731. {"type": "heartbeat", "inference_depth": 7, "inference_in_flight": True}
  1732. )
  1733. app._render()
  1734. await pilot.pause()
  1735. observed["busy"] = app.query_one("#triage", RichLog).border_title
  1736. asyncio.run(scenario())
  1737. # border_title round-trips as console markup, so '[' reads back as '\['.
  1738. assert observed["idle"] == "triage \\[inf 3]"
  1739. assert observed["busy"] == "triage \\[inf 7 · busy]"
  1740. def test_questions_view_title_has_no_inference_suffix(server):
  1741. """Questions view: title is exactly 'questions'; toggling back restores the suffix."""
  1742. pytest.importorskip("textual")
  1743. from textual.widgets import RichLog
  1744. from stt_daemon.tui.app import AssistantTUI
  1745. path, _service = server
  1746. app = AssistantTUI(path, triage_enabled=True)
  1747. observed: dict = {}
  1748. async def scenario():
  1749. async with app.run_test() as pilot:
  1750. await pilot.pause()
  1751. app._timer.stop()
  1752. app._drain()
  1753. app.state.ingest(
  1754. {"type": "heartbeat", "inference_depth": 5, "inference_in_flight": True}
  1755. )
  1756. app._render()
  1757. await pilot.pause()
  1758. observed["brief"] = app.query_one("#triage", RichLog).border_title
  1759. await pilot.press("s")
  1760. await pilot.pause()
  1761. observed["questions"] = app.query_one("#triage", RichLog).border_title
  1762. await pilot.press("s")
  1763. await pilot.pause()
  1764. observed["restored"] = app.query_one("#triage", RichLog).border_title
  1765. asyncio.run(scenario())
  1766. assert observed["brief"] == "triage \\[inf 5 · busy]"
  1767. assert observed["questions"] == "questions"
  1768. assert "inf" not in observed["questions"]
  1769. assert "busy" not in observed["questions"]
  1770. # No stale plain title: the brief view comes back fully suffixed.
  1771. assert observed["restored"] == "triage \\[inf 5 · busy]"
  1772. def test_triage_disabled_heartbeat_leaves_no_triage_widget(server):
  1773. """triage_enabled=False: depths update #chat only and never create a #triage pane."""
  1774. pytest.importorskip("textual")
  1775. from textual.widgets import RichLog
  1776. from stt_daemon.tui.app import AssistantTUI
  1777. path, _service = server
  1778. app = AssistantTUI(path)
  1779. observed: dict = {}
  1780. async def scenario():
  1781. async with app.run_test() as pilot:
  1782. await pilot.pause()
  1783. app._timer.stop()
  1784. app._drain()
  1785. app.state.ingest(
  1786. {"type": "heartbeat", "inference_depth": 9, "inference_in_flight": True}
  1787. )
  1788. app._render()
  1789. await pilot.pause()
  1790. observed["has_triage"] = bool(app.query("#triage"))
  1791. observed["chat"] = app.query_one("#chat", RichLog).border_title
  1792. asyncio.run(scenario())
  1793. assert observed["has_triage"] is False
  1794. assert observed["chat"] == "conversation \\[inf 9 · busy]"
  1795. def test_s_key_is_noop_when_triage_disabled(server):
  1796. """triage_enabled=False: action_toggle_questions and s do nothing and never crash."""
  1797. pytest.importorskip("textual")
  1798. from stt_daemon.tui.app import AssistantTUI
  1799. path, _service = server
  1800. app = AssistantTUI(path)
  1801. async def scenario():
  1802. async with app.run_test() as pilot:
  1803. await pilot.pause()
  1804. app.action_toggle_questions()
  1805. await pilot.pause()
  1806. assert app._show_questions is False
  1807. assert not app.query("#triage")
  1808. await pilot.press("s")
  1809. await pilot.pause()
  1810. assert app._show_questions is False
  1811. assert not app.query("#triage")
  1812. asyncio.run(scenario())
  1813. def test_status_event_questions_populate_questions_view(server):
  1814. """A status event carrying questions fills state and the toggled pane."""
  1815. pytest.importorskip("textual")
  1816. from textual.widgets import RichLog
  1817. from stt_daemon.tui.app import AssistantTUI
  1818. path, _service = server
  1819. app = AssistantTUI(path, triage_enabled=True)
  1820. observed: dict = {}
  1821. async def scenario():
  1822. async with app.run_test() as pilot:
  1823. await pilot.pause()
  1824. app._timer.stop() # no background drain: server status must not clobber state
  1825. app._drain()
  1826. observed["before_epoch"] = app.state.questions_epoch
  1827. app.state.ingest({"type": "status", "questions": _questions_payload()})
  1828. app._render()
  1829. await pilot.pause()
  1830. observed["questions"] = app.state.questions
  1831. observed["epoch"] = app.state.questions_epoch
  1832. await pilot.press("s")
  1833. await pilot.pause()
  1834. observed["lines"] = _rendered_lines(app.query_one("#triage", RichLog))
  1835. asyncio.run(scenario())
  1836. assert observed["questions"] == _questions_payload()
  1837. assert observed["epoch"] == observed["before_epoch"] + 1
  1838. assert any("topic [choice]" in line for line in observed["lines"])