test_tui_app.py 68 KB

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