test_tui_app.py 62 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800
  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"
  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. # Disabled: no #main/#triage; focusing #chat then m maximizes #chat itself.
  823. app.query_one("#chat", RichLog).focus()
  824. await pilot.pause()
  825. await pilot.press("m")
  826. await pilot.pause()
  827. observed["maximized"] = app.screen.maximized
  828. observed["chat"] = app.query_one("#chat", RichLog)
  829. observed["region"] = app.query_one("#chat", RichLog).region
  830. observed["screen_size"] = app.screen.size
  831. await pilot.press("m")
  832. await pilot.pause()
  833. observed["restored"] = app.screen.maximized
  834. asyncio.run(scenario())
  835. print("chat maximized (disabled): region=%s screen=%s"
  836. % (observed["region"], observed["screen_size"]))
  837. assert observed["maximized"] is observed["chat"]
  838. assert observed["restored"] is None
  839. def test_footer_hidden_while_maximized(server):
  840. """ALLOW_IN_MAXIMIZED_VIEW hides the Footer while a pane is maximized."""
  841. pytest.importorskip("textual")
  842. from textual.widgets import Footer, RichLog
  843. from stt_daemon.tui.app import AssistantTUI
  844. path, _service = server
  845. app = AssistantTUI(path, triage_enabled=True)
  846. observed: dict = {}
  847. async def scenario():
  848. async with app.run_test(size=(80, 40)) as pilot:
  849. await pilot.pause()
  850. app.query_one("#triage", RichLog).focus()
  851. await pilot.pause()
  852. await pilot.press("m")
  853. await pilot.pause()
  854. observed["maximized"] = app.screen.maximized
  855. observed["footer_region"] = app.query_one(Footer).region
  856. await pilot.press("m")
  857. await pilot.pause()
  858. asyncio.run(scenario())
  859. assert observed["maximized"] is not None
  860. footer_region = observed["footer_region"]
  861. assert footer_region.height == 0 or footer_region.width == 0, observed
  862. def test_question_mark_key_toggles_help_panel(server):
  863. pytest.importorskip("textual")
  864. from textual.widgets import HelpPanel
  865. from stt_daemon.tui.app import AssistantTUI
  866. path, _service = server
  867. app = AssistantTUI(path)
  868. observed: dict = {}
  869. async def scenario():
  870. async with app.run_test() as pilot:
  871. await pilot.pause()
  872. assert list(app.screen.query(HelpPanel)) == []
  873. await pilot.press("question_mark")
  874. await pilot.pause()
  875. observed["shown"] = list(app.screen.query(HelpPanel)) != []
  876. app.screen.query_one(HelpPanel) # must succeed while shown
  877. await pilot.press("question_mark")
  878. await pilot.pause()
  879. observed["hidden"] = list(app.screen.query(HelpPanel)) == []
  880. asyncio.run(scenario())
  881. assert observed["shown"] is True, "? did not mount HelpPanel"
  882. assert observed["hidden"] is True, "second ? did not remove HelpPanel"
  883. def test_app_bindings_list():
  884. """BINDINGS: 8 visible footer entries; hidden ones after; no h binding."""
  885. pytest.importorskip("textual")
  886. from stt_daemon.tui.app import AssistantTUI
  887. pairs = [(b.key, b.description) for b in AssistantTUI.BINDINGS]
  888. assert ("ctrl+r", "execute") in pairs
  889. assert ("m", "maximize pane") in pairs
  890. assert ("question_mark", "help") in pairs
  891. assert ("ctrl+s", "save screenshot") in pairs
  892. assert ("a", "arm command") in pairs
  893. assert ("escape", "cancel") in pairs
  894. assert ("e", "edit command") in pairs
  895. assert ("t", "chat toggle") in pairs
  896. assert ("s", "questions view") in pairs
  897. assert ("c", "clear") in pairs
  898. assert ("q", "quit") in pairs
  899. assert ("ctrl+p", "instructions") in pairs
  900. assert not any(key == "h" for key, _ in pairs)
  901. assert not any(key == "ctrl+m" for key, _ in pairs)
  902. assert not any(key == "ctrl+h" for key, _ in pairs)
  903. assert not any(key == "enter" for key, _ in pairs)
  904. assert not any(key == "ctrl+k" for key, _ in pairs)
  905. by_key = {b.key: b for b in AssistantTUI.BINDINGS}
  906. assert by_key["question_mark"].show is True
  907. assert by_key["s"].show is True
  908. assert by_key["m"].show is False
  909. assert by_key["c"].show is False
  910. assert by_key["ctrl+s"].show is False
  911. assert by_key["ctrl+p"].show is False
  912. # Visible eight first, in footer order.
  913. visible = [(b.key, b.description) for b in AssistantTUI.BINDINGS if b.show]
  914. assert visible == [
  915. ("a", "arm command"),
  916. ("ctrl+r", "execute"),
  917. ("escape", "cancel"),
  918. ("e", "edit command"),
  919. ("t", "chat toggle"),
  920. ("s", "questions view"),
  921. ("question_mark", "help"),
  922. ("q", "quit"),
  923. ]
  924. assert by_key["ctrl+p"].priority is True
  925. def test_footer_shows_ordered_entries(server):
  926. """Left list is the 8 visible bindings (? for help); instructions only right-aligned."""
  927. pytest.importorskip("textual")
  928. from textual.widgets import Footer
  929. from textual.widgets._footer import FooterKey
  930. from stt_daemon.tui.app import AssistantTUI
  931. path, _service = server
  932. app = AssistantTUI(path)
  933. observed: dict = {}
  934. async def scenario():
  935. async with app.run_test() as pilot:
  936. await pilot.pause()
  937. shown = [
  938. (b.binding.key, b.binding.description)
  939. for b in app.screen.active_bindings.values()
  940. if b.binding.show
  941. ]
  942. observed["shown"] = shown
  943. footer = app.query_one(Footer)
  944. keys = list(footer.query(FooterKey))
  945. observed["keys"] = [
  946. (k.key, k.key_display, k.description, k.action, set(k.classes))
  947. for k in keys
  948. ]
  949. asyncio.run(scenario())
  950. expected = [
  951. ("a", "arm command"),
  952. ("ctrl+r", "execute"),
  953. ("escape", "cancel"),
  954. ("e", "edit command"),
  955. ("t", "chat toggle"),
  956. ("s", "questions view"),
  957. ("question_mark", "help"),
  958. ("q", "quit"),
  959. ]
  960. assert observed["shown"] == expected, observed["shown"]
  961. # Help advertises as "?" in the footer.
  962. assert app.get_key_display(
  963. next(b for b in AssistantTUI.BINDINGS if b.key == "question_mark")
  964. ) == "?"
  965. # The composed Footer: exactly one "instructions" entry and it is the
  966. # right-aligned command-palette key; no left duplicate exists.
  967. instructions = [k for k in observed["keys"] if k[2] == "instructions"]
  968. assert len(instructions) == 1, observed["keys"]
  969. key, key_display, _desc, action, classes = instructions[0]
  970. assert action == "command_palette", instructions
  971. assert "-command-palette" in classes, instructions
  972. assert key == "ctrl+p", instructions
  973. non_palette = [k for k in observed["keys"] if "-command-palette" not in k[4]]
  974. assert not any(k[2] == "instructions" for k in non_palette), observed["keys"]
  975. assert not any(k[3] == "command_palette" for k in non_palette), observed["keys"]
  976. def test_ctrl_p_opens_command_palette(server):
  977. """ctrl+p still opens the palette; its binding description is 'instructions'."""
  978. pytest.importorskip("textual")
  979. from textual.command import CommandPalette
  980. from stt_daemon.tui.app import AssistantTUI
  981. path, _service = server
  982. app = AssistantTUI(path)
  983. observed: dict = {}
  984. async def scenario():
  985. async with app.run_test() as pilot:
  986. await pilot.pause()
  987. await pilot.press("ctrl+p")
  988. await pilot.pause()
  989. await pilot.pause()
  990. observed["is_palette"] = isinstance(app.screen, CommandPalette)
  991. observed["stack"] = [type(s).__name__ for s in app.screen_stack]
  992. asyncio.run(scenario())
  993. palette_binding = next(b for b in AssistantTUI.BINDINGS if b.key == "ctrl+p")
  994. assert palette_binding.description == "instructions"
  995. assert observed["is_palette"], "ctrl+p did not open the command palette: %s" % (
  996. observed["stack"],
  997. )
  998. def test_question_mark_toggles_help_panel(server):
  999. """? toggles the help panel and is advertised in the left footer list."""
  1000. pytest.importorskip("textual")
  1001. from textual.widgets import HelpPanel
  1002. from stt_daemon.tui.app import AssistantTUI
  1003. path, _service = server
  1004. app = AssistantTUI(path)
  1005. observed: dict = {}
  1006. async def scenario():
  1007. async with app.run_test() as pilot:
  1008. await pilot.pause()
  1009. assert list(app.screen.query(HelpPanel)) == []
  1010. await pilot.press("?")
  1011. await pilot.pause()
  1012. observed["shown"] = list(app.screen.query(HelpPanel)) != []
  1013. await pilot.press("?")
  1014. await pilot.pause()
  1015. observed["hidden"] = list(app.screen.query(HelpPanel)) == []
  1016. asyncio.run(scenario())
  1017. assert observed["shown"] is True, "? did not mount HelpPanel"
  1018. assert observed["hidden"] is True, "second ? did not remove HelpPanel"
  1019. def test_plain_m_maximizes_triage_pane(server):
  1020. pytest.importorskip("textual")
  1021. from stt_daemon.tui.app import AssistantTUI
  1022. from textual.widgets import RichLog
  1023. path, _service = server
  1024. app = AssistantTUI(path, triage_enabled=True)
  1025. async def scenario():
  1026. async with app.run_test(size=(80, 40)) as pilot:
  1027. await pilot.pause()
  1028. app.query_one("#triage", RichLog).focus()
  1029. await pilot.pause()
  1030. await pilot.press("m")
  1031. await pilot.pause()
  1032. assert app.screen.maximized is app.query_one("#main")
  1033. await pilot.press("m")
  1034. await pilot.pause()
  1035. assert app.screen.maximized is None
  1036. assert app.query_one("#triage", RichLog).display is True
  1037. # No chat/command output ingested: chat follows the content rule → hidden.
  1038. assert app.query_one("#chat", RichLog).display is False
  1039. asyncio.run(scenario())
  1040. def test_screenshot_saves_svg(server, tmp_path, monkeypatch):
  1041. pytest.importorskip("textual")
  1042. import glob
  1043. from stt_daemon.tui.app import AssistantTUI
  1044. monkeypatch.chdir(tmp_path)
  1045. path, _service = server
  1046. app = AssistantTUI(path)
  1047. async def scenario():
  1048. async with app.run_test() as pilot:
  1049. await pilot.pause()
  1050. await pilot.press("ctrl+s")
  1051. await pilot.pause()
  1052. await pilot.pause()
  1053. asyncio.run(scenario())
  1054. out_dir = tmp_path / "screenshots"
  1055. assert out_dir.is_dir(), "screenshots/ not created in %s" % tmp_path
  1056. files = glob.glob(str(out_dir / "stt-tui-*.svg"))
  1057. assert files, "no stt-tui-*.svg found in %s" % out_dir
  1058. assert os.path.getsize(files[0]) > 0
  1059. assert glob.glob(str(tmp_path / "stt-tui-*.svg")) == [], \
  1060. "screenshot leaked into %s instead of screenshots/" % tmp_path
  1061. def test_chat_hidden_by_default_triage_fills_main(server):
  1062. """Fresh state: #chat hidden, #triage fills the whole #main width."""
  1063. pytest.importorskip("textual")
  1064. from textual.containers import Horizontal
  1065. from textual.widgets import RichLog
  1066. from stt_daemon.tui.app import AssistantTUI
  1067. path, _service = server
  1068. app = AssistantTUI(path, triage_enabled=True)
  1069. observed: dict = {}
  1070. async def scenario():
  1071. async with app.run_test(size=(80, 40)) as pilot:
  1072. await pilot.pause()
  1073. app._timer.stop() # no background drain: server status must not clobber state
  1074. app._drain()
  1075. app._render()
  1076. await pilot.pause()
  1077. observed["chat_display"] = app.query_one("#chat", RichLog).display
  1078. observed["triage_display"] = app.query_one("#triage", RichLog).display
  1079. observed["main_region"] = app.query_one("#main", Horizontal).region
  1080. observed["triage_region"] = app.query_one("#triage", RichLog).region
  1081. asyncio.run(scenario())
  1082. print("hidden: main=%s triage=%s" % (observed["main_region"], observed["triage_region"]))
  1083. assert observed["chat_display"] is False
  1084. assert observed["triage_display"] is True
  1085. assert (
  1086. abs(observed["triage_region"].width - observed["main_region"].width) <= 2
  1087. ), (observed["triage_region"], observed["main_region"])
  1088. def test_chat_mode_shows_chat_and_restores_split(server):
  1089. """mode=chat → #chat visible, triage back to ~1/3 width."""
  1090. pytest.importorskip("textual")
  1091. from textual.containers import Horizontal
  1092. from textual.widgets import RichLog
  1093. from stt_daemon.tui.app import AssistantTUI
  1094. path, _service = server
  1095. app = AssistantTUI(path, triage_enabled=True)
  1096. observed: dict = {}
  1097. async def scenario():
  1098. async with app.run_test(size=(80, 40)) as pilot:
  1099. await pilot.pause()
  1100. app._timer.stop() # no background drain: server status must not clobber state
  1101. app._drain()
  1102. app.state.ingest({"type": "mode", "mode": "chat"})
  1103. app._render()
  1104. await pilot.pause()
  1105. observed["chat_display"] = app.query_one("#chat", RichLog).display
  1106. observed["main_region"] = app.query_one("#main", Horizontal).region
  1107. observed["triage_region"] = app.query_one("#triage", RichLog).region
  1108. observed["chat_region"] = app.query_one("#chat", RichLog).region
  1109. asyncio.run(scenario())
  1110. print(
  1111. "shown: main=%s triage=%s chat=%s"
  1112. % (observed["main_region"], observed["triage_region"], observed["chat_region"])
  1113. )
  1114. assert observed["chat_display"] is True
  1115. main_w = observed["main_region"].width
  1116. assert abs(observed["triage_region"].width / main_w - 1 / 3) < 0.08, observed
  1117. assert abs(observed["chat_region"].width / main_w - 2 / 3) < 0.08, observed
  1118. def test_tool_result_shows_chat(server):
  1119. """Command output (tool_result) → #chat visible."""
  1120. pytest.importorskip("textual")
  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(
  1132. {
  1133. "type": "tool_result",
  1134. "tool": "get_datetime",
  1135. "ok": True,
  1136. "result": "2026-09-21",
  1137. }
  1138. )
  1139. app._render()
  1140. await pilot.pause()
  1141. observed["chat_display"] = app.query_one("#chat", RichLog).display
  1142. asyncio.run(scenario())
  1143. assert observed["chat_display"] is True
  1144. def test_error_event_shows_chat(server):
  1145. """An error event → #chat visible."""
  1146. pytest.importorskip("textual")
  1147. from textual.widgets import RichLog
  1148. from stt_daemon.tui.app import AssistantTUI
  1149. path, _service = server
  1150. app = AssistantTUI(path, triage_enabled=True)
  1151. observed: dict = {}
  1152. async def scenario():
  1153. async with app.run_test(size=(80, 40)) as pilot:
  1154. await pilot.pause()
  1155. app._timer.stop() # no background drain: server status must not clobber state
  1156. app._drain()
  1157. app.state.ingest({"type": "error", "code": "boom", "message": "kaboom"})
  1158. app._render()
  1159. await pilot.pause()
  1160. observed["chat_display"] = app.query_one("#chat", RichLog).display
  1161. asyncio.run(scenario())
  1162. assert observed["chat_display"] is True
  1163. def test_executing_mode_shows_chat(server):
  1164. """Assistant executing a command → #chat visible."""
  1165. pytest.importorskip("textual")
  1166. from textual.widgets import RichLog
  1167. from stt_daemon.tui.app import AssistantTUI
  1168. path, _service = server
  1169. app = AssistantTUI(path, triage_enabled=True)
  1170. observed: dict = {}
  1171. async def scenario():
  1172. async with app.run_test(size=(80, 40)) as pilot:
  1173. await pilot.pause()
  1174. app._timer.stop() # no background drain: server status must not clobber state
  1175. app._drain()
  1176. app.state.ingest({"type": "mode", "mode": "executing"})
  1177. app._render()
  1178. await pilot.pause()
  1179. observed["chat_display"] = app.query_one("#chat", RichLog).display
  1180. asyncio.run(scenario())
  1181. assert observed["chat_display"] is True
  1182. def test_cleared_hides_chat_and_restores_full_width(server):
  1183. """cleared event → #chat hidden again, triage full width."""
  1184. pytest.importorskip("textual")
  1185. from textual.containers import Horizontal
  1186. from textual.widgets import RichLog
  1187. from stt_daemon.tui.app import AssistantTUI
  1188. path, _service = server
  1189. app = AssistantTUI(path, triage_enabled=True)
  1190. observed: dict = {}
  1191. async def scenario():
  1192. async with app.run_test(size=(80, 40)) as pilot:
  1193. await pilot.pause()
  1194. app._timer.stop() # no background drain: server status must not clobber state
  1195. app._drain()
  1196. app.state.ingest(
  1197. {
  1198. "type": "tool_result",
  1199. "tool": "get_datetime",
  1200. "ok": True,
  1201. "result": "2026-09-21",
  1202. }
  1203. )
  1204. app._render()
  1205. await pilot.pause()
  1206. assert app.query_one("#chat", RichLog).display is True
  1207. app.state.ingest({"type": "cleared"})
  1208. app._render()
  1209. await pilot.pause()
  1210. observed["chat_display"] = app.query_one("#chat", RichLog).display
  1211. observed["main_region"] = app.query_one("#main", Horizontal).region
  1212. observed["triage_region"] = app.query_one("#triage", RichLog).region
  1213. asyncio.run(scenario())
  1214. print(
  1215. "cleared: main=%s triage=%s"
  1216. % (observed["main_region"], observed["triage_region"])
  1217. )
  1218. assert observed["chat_display"] is False
  1219. assert (
  1220. abs(observed["triage_region"].width - observed["main_region"].width) <= 2
  1221. ), (observed["triage_region"], observed["main_region"])
  1222. def test_triage_disabled_chat_stays_visible(server):
  1223. """Triage disabled: #chat stays visible, no crash on render/restore."""
  1224. pytest.importorskip("textual")
  1225. from textual.widgets import RichLog
  1226. from stt_daemon.tui.app import AssistantTUI
  1227. path, _service = server
  1228. app = AssistantTUI(path)
  1229. observed: dict = {}
  1230. async def scenario():
  1231. async with app.run_test(size=(80, 40)) as pilot:
  1232. await pilot.pause()
  1233. app._timer.stop() # no background drain: server status must not clobber state
  1234. app._drain()
  1235. app._render()
  1236. await pilot.pause()
  1237. observed["chat_display"] = app.query_one("#chat", RichLog).display
  1238. app._restore_panes() # must not raise with no #triage/#main
  1239. await pilot.pause()
  1240. observed["chat_display_after"] = app.query_one("#chat", RichLog).display
  1241. asyncio.run(scenario())
  1242. assert observed["chat_display"] is True
  1243. assert observed["chat_display_after"] is True
  1244. def test_maximize_restore_with_chat_content_keeps_chat_visible(server):
  1245. """With ingested output, m then m restores with #chat still visible."""
  1246. pytest.importorskip("textual")
  1247. from textual.widgets import RichLog
  1248. from stt_daemon.tui.app import AssistantTUI
  1249. path, _service = server
  1250. app = AssistantTUI(path, triage_enabled=True)
  1251. observed: dict = {}
  1252. async def scenario():
  1253. async with app.run_test(size=(80, 40)) as pilot:
  1254. await pilot.pause()
  1255. app._timer.stop() # no background drain: server status must not clobber state
  1256. app._drain()
  1257. app.state.ingest(
  1258. {
  1259. "type": "tool_result",
  1260. "tool": "get_datetime",
  1261. "ok": True,
  1262. "result": "2026-09-21",
  1263. }
  1264. )
  1265. app._render()
  1266. await pilot.pause()
  1267. assert app.query_one("#chat", RichLog).display is True
  1268. app.query_one("#triage", RichLog).focus()
  1269. await pilot.pause()
  1270. await pilot.press("m")
  1271. await pilot.pause()
  1272. assert app.screen.maximized is not None
  1273. await pilot.press("m")
  1274. await pilot.pause()
  1275. observed["maximized"] = app.screen.maximized
  1276. observed["triage_display"] = app.query_one("#triage", RichLog).display
  1277. observed["chat_display"] = app.query_one("#chat", RichLog).display
  1278. asyncio.run(scenario())
  1279. assert observed["maximized"] is None
  1280. assert observed["triage_display"] is True
  1281. assert observed["chat_display"] is True
  1282. def test_transcript_pane_keeps_updating_past_cap(server):
  1283. """Regression: past MAX_TRANSCRIPTS the running log must still append."""
  1284. pytest.importorskip("textual")
  1285. from textual.widgets import RichLog
  1286. from stt_daemon.tui.app import AssistantTUI
  1287. from stt_daemon.tui.state import MAX_TRANSCRIPTS
  1288. path, _service = server
  1289. app = AssistantTUI(path)
  1290. observed: dict = {}
  1291. async def scenario():
  1292. async with app.run_test(size=(80, 40)) as pilot:
  1293. await pilot.pause()
  1294. app._timer.stop() # no background drain: server status must not clobber state
  1295. app._drain()
  1296. for i in range(MAX_TRANSCRIPTS + 5):
  1297. app.state.ingest({"type": "transcript", "text": f"t{i}", "lang": "en"})
  1298. app._render()
  1299. await pilot.pause()
  1300. app.state.ingest({"type": "transcript", "text": "after cap", "lang": "en"})
  1301. app._render()
  1302. await pilot.pause()
  1303. log = app.query_one("#transcript", RichLog)
  1304. observed["lines"] = [str(line) for line in log.lines]
  1305. asyncio.run(scenario())
  1306. assert any("after cap" in line for line in observed["lines"])
  1307. assert len(observed["lines"]) > MAX_TRANSCRIPTS
  1308. def _questions_payload():
  1309. return {
  1310. "topic": {
  1311. "type": "choice",
  1312. "instructions": "What is the main theme?",
  1313. "criteria": {"planning": "roadmap or prioritization"},
  1314. }
  1315. }
  1316. def _rendered_lines(log):
  1317. return [line.text for line in log.lines]
  1318. def _ingest_triage_signals(app):
  1319. app.state.ingest(
  1320. {
  1321. "type": "triage",
  1322. "stage": "signals",
  1323. "triage": {
  1324. "order": ["topic"],
  1325. "items": {
  1326. "topic": {
  1327. "kind": "choice",
  1328. "choice": "planning",
  1329. "answer_confidence": 0.91,
  1330. }
  1331. },
  1332. },
  1333. }
  1334. )
  1335. app.state.ingest(
  1336. {
  1337. "type": "status",
  1338. "triage_state": "ok",
  1339. "triage_reason": "",
  1340. "questions": _questions_payload(),
  1341. }
  1342. )
  1343. def test_s_key_swaps_triage_pane_to_questions_view(server):
  1344. """Press s: #triage border flips to questions and renders the structured blocks."""
  1345. pytest.importorskip("textual")
  1346. from textual.widgets import RichLog
  1347. from stt_daemon.tui.app import AssistantTUI
  1348. path, _service = server
  1349. app = AssistantTUI(path, triage_enabled=True)
  1350. observed: dict = {}
  1351. async def scenario():
  1352. async with app.run_test() as pilot:
  1353. await pilot.pause()
  1354. app._timer.stop() # no background drain: server status must not clobber state
  1355. app._drain()
  1356. _ingest_triage_signals(app)
  1357. app._render()
  1358. await pilot.pause()
  1359. observed["default_title"] = app.query_one("#triage", RichLog).border_title
  1360. observed["default_lines"] = _rendered_lines(app.query_one("#triage", RichLog))
  1361. await pilot.press("s")
  1362. await pilot.pause()
  1363. observed["questions_title"] = app.query_one("#triage", RichLog).border_title
  1364. observed["questions_lines"] = _rendered_lines(app.query_one("#triage", RichLog))
  1365. asyncio.run(scenario())
  1366. assert observed["default_title"] == "triage"
  1367. assert any("topic: planning (0.91)" in line for line in observed["default_lines"])
  1368. assert observed["questions_title"] == "questions"
  1369. rendered = "\n".join(observed["questions_lines"])
  1370. assert "topic [choice]" in rendered
  1371. assert "What is the main theme?" in rendered
  1372. assert "planning — roadmap or prioritization" in rendered
  1373. assert "topic: planning" not in rendered
  1374. def test_s_key_again_restores_triage_view(server):
  1375. """Second s: border flips back to triage and the signals view is rewritten."""
  1376. pytest.importorskip("textual")
  1377. from textual.widgets import RichLog
  1378. from stt_daemon.tui.app import AssistantTUI
  1379. path, _service = server
  1380. app = AssistantTUI(path, triage_enabled=True)
  1381. observed: dict = {}
  1382. async def scenario():
  1383. async with app.run_test() as pilot:
  1384. await pilot.pause()
  1385. app._timer.stop() # no background drain: server status must not clobber state
  1386. app._drain()
  1387. _ingest_triage_signals(app)
  1388. app._render()
  1389. await pilot.pause()
  1390. await pilot.press("s")
  1391. await pilot.pause()
  1392. observed["mid_title"] = app.query_one("#triage", RichLog).border_title
  1393. await pilot.press("s")
  1394. await pilot.pause()
  1395. observed["triage_title"] = app.query_one("#triage", RichLog).border_title
  1396. observed["triage_lines"] = _rendered_lines(app.query_one("#triage", RichLog))
  1397. asyncio.run(scenario())
  1398. assert observed["mid_title"] == "questions"
  1399. assert observed["triage_title"] == "triage"
  1400. rendered = "\n".join(observed["triage_lines"])
  1401. assert "topic: planning (0.91)" in rendered
  1402. assert "What is the main theme?" not in rendered
  1403. def test_s_key_is_noop_when_triage_disabled(server):
  1404. """triage_enabled=False: action_toggle_questions and s do nothing and never crash."""
  1405. pytest.importorskip("textual")
  1406. from stt_daemon.tui.app import AssistantTUI
  1407. path, _service = server
  1408. app = AssistantTUI(path)
  1409. async def scenario():
  1410. async with app.run_test() as pilot:
  1411. await pilot.pause()
  1412. app.action_toggle_questions()
  1413. await pilot.pause()
  1414. assert app._show_questions is False
  1415. assert not app.query("#triage")
  1416. await pilot.press("s")
  1417. await pilot.pause()
  1418. assert app._show_questions is False
  1419. assert not app.query("#triage")
  1420. asyncio.run(scenario())
  1421. def test_status_event_questions_populate_questions_view(server):
  1422. """A status event carrying questions fills state and the toggled pane."""
  1423. pytest.importorskip("textual")
  1424. from textual.widgets import RichLog
  1425. from stt_daemon.tui.app import AssistantTUI
  1426. path, _service = server
  1427. app = AssistantTUI(path, triage_enabled=True)
  1428. observed: dict = {}
  1429. async def scenario():
  1430. async with app.run_test() as pilot:
  1431. await pilot.pause()
  1432. app._timer.stop() # no background drain: server status must not clobber state
  1433. app._drain()
  1434. observed["before_epoch"] = app.state.questions_epoch
  1435. app.state.ingest({"type": "status", "questions": _questions_payload()})
  1436. app._render()
  1437. await pilot.pause()
  1438. observed["questions"] = app.state.questions
  1439. observed["epoch"] = app.state.questions_epoch
  1440. await pilot.press("s")
  1441. await pilot.pause()
  1442. observed["lines"] = _rendered_lines(app.query_one("#triage", RichLog))
  1443. asyncio.run(scenario())
  1444. assert observed["questions"] == _questions_payload()
  1445. assert observed["epoch"] == observed["before_epoch"] + 1
  1446. assert any("topic [choice]" in line for line in observed["lines"])