test_tui_app.py 45 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312
  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. main = app.query_one("#main", Horizontal)
  524. transcript = app.query_one("#transcript", RichLog)
  525. triage = app.query_one("#triage", RichLog)
  526. chat = app.query_one("#chat", RichLog)
  527. return (
  528. main.size.width,
  529. triage.size.width,
  530. chat.size.width,
  531. app.screen.size.height,
  532. transcript.region.height,
  533. )
  534. main_w, triage_w, chat_w, screen_h, transcript_h = asyncio.run(scenario())
  535. print(
  536. f"measured: main_w={main_w} triage_w={triage_w} chat_w={chat_w} "
  537. f"screen_h={screen_h} transcript_h={transcript_h}"
  538. )
  539. # Content boxes exclude the 1-cell border on each side; allow ±2 px for
  540. # borders/rounding on the fractional split.
  541. assert abs(triage_w / main_w - 1 / 3) < 0.05, (main_w, triage_w, chat_w)
  542. assert abs(chat_w / main_w - 2 / 3) < 0.05, (main_w, triage_w, chat_w)
  543. # region.height includes the transcript's 2 border rows; at the small
  544. # default size rounding is coarse, so only demand a floor here. The
  545. # precise 25% check runs at a taller size below.
  546. assert transcript_h / screen_h >= 0.18, (screen_h, transcript_h)
  547. assert transcript_h >= 1, transcript_h
  548. app2 = AssistantTUI(path, triage_enabled=True)
  549. async def scenario_tall():
  550. async with app2.run_test(size=(80, 40)) as pilot:
  551. await pilot.pause()
  552. transcript2 = app2.query_one("#transcript", RichLog)
  553. return app2.screen.size.height, transcript2.region.height
  554. screen_h2, transcript_h2 = asyncio.run(scenario_tall())
  555. print(f"measured tall: screen_h={screen_h2} transcript_h={transcript_h2}")
  556. assert abs(transcript_h2 / screen_h2 - 0.25) < 0.06, (screen_h2, transcript_h2)
  557. def test_triage_enabled_renders_signals_on_epoch_change(server):
  558. """A triage event + status event must actually write rendered lines into #triage."""
  559. pytest.importorskip("textual")
  560. from textual.widgets import RichLog
  561. from stt_daemon.tui.app import AssistantTUI
  562. path, _service = server
  563. app = AssistantTUI(path, triage_enabled=True)
  564. async def scenario():
  565. async with app.run_test() as pilot:
  566. await pilot.pause()
  567. app._timer.stop() # no background drain: server status must not clobber state
  568. app._drain()
  569. app.state.ingest(
  570. {
  571. "type": "triage",
  572. "stage": "signals",
  573. "triage": {
  574. "order": ["category"],
  575. "items": {
  576. "category": {
  577. "kind": "choice",
  578. "choice": "question",
  579. "answer_confidence": 0.91,
  580. }
  581. },
  582. },
  583. }
  584. )
  585. app.state.ingest(
  586. {"type": "status", "triage_state": "ok", "triage_reason": ""}
  587. )
  588. app._render()
  589. await pilot.pause()
  590. return [str(line) for line in app.query_one("#triage", RichLog).lines]
  591. lines = asyncio.run(scenario())
  592. rendered = "\n".join(lines)
  593. assert "category: question (0.91)" in rendered
  594. def test_triage_enabled_loading_shows_reason_line_never_blank(server):
  595. """loading/unknown triage_state renders the reason line — never a blank panel."""
  596. pytest.importorskip("textual")
  597. from textual.widgets import RichLog
  598. from stt_daemon.tui.app import AssistantTUI
  599. path, _service = server
  600. app = AssistantTUI(path, triage_enabled=True)
  601. async def scenario():
  602. async with app.run_test() as pilot:
  603. await pilot.pause()
  604. app._timer.stop() # no background drain: server status must not clobber state
  605. app._drain()
  606. app.state.ingest(
  607. {
  608. "type": "status",
  609. "triage_state": "loading",
  610. "triage_reason": "model loading",
  611. }
  612. )
  613. app._render()
  614. await pilot.pause()
  615. loading_lines = [str(line) for line in app.query_one("#triage", RichLog).lines]
  616. # Unknown state falls back to a generic reason line, no crash.
  617. app.state.ingest(
  618. {"type": "status", "triage_state": "weird_state", "triage_reason": ""}
  619. )
  620. app._render()
  621. await pilot.pause()
  622. unknown_lines = [str(line) for line in app.query_one("#triage", RichLog).lines]
  623. return loading_lines, unknown_lines
  624. loading_lines, unknown_lines = asyncio.run(scenario())
  625. assert loading_lines, "triage panel was blank in loading state"
  626. assert any("triage: unavailable" in line for line in loading_lines)
  627. assert any("model loading" in line for line in loading_lines)
  628. assert unknown_lines, "triage panel was blank for unknown state"
  629. assert any("triage: unavailable" in line for line in unknown_lines)
  630. assert any("weird_state" in line for line in unknown_lines)
  631. def test_triage_epoch_change_clears_before_rewrite(server):
  632. """Epoch bump clears #triage first: no duplicated stale lines."""
  633. pytest.importorskip("textual")
  634. from textual.widgets import RichLog
  635. from stt_daemon.tui.app import AssistantTUI
  636. path, _service = server
  637. app = AssistantTUI(path, triage_enabled=True)
  638. async def scenario():
  639. async with app.run_test() as pilot:
  640. await pilot.pause()
  641. app._timer.stop() # no background drain: server status must not clobber state
  642. app._drain()
  643. app.state.ingest(
  644. {
  645. "type": "triage",
  646. "stage": "signals",
  647. "triage": {"order": ["q1"], "items": {"q1": {"kind": "choice", "choice": "a"}}},
  648. }
  649. )
  650. app.state.ingest({"type": "status", "triage_state": "ok"})
  651. app._render()
  652. await pilot.pause()
  653. # New epoch: cleared stage empties the payload, panel must not keep old lines.
  654. app.state.ingest({"type": "triage", "stage": "cleared"})
  655. app._render()
  656. await pilot.pause()
  657. return [str(line) for line in app.query_one("#triage", RichLog).lines]
  658. lines = asyncio.run(scenario())
  659. rendered = "\n".join(lines)
  660. assert "q1" not in rendered, "stale triage lines survived an epoch change"
  661. assert "triage: unavailable — no data" in rendered
  662. def test_triage_disabled_never_writes_triage(server):
  663. """Disabled: triage events are ingested but #triage is never touched (it doesn't exist)."""
  664. pytest.importorskip("textual")
  665. from stt_daemon.tui.app import AssistantTUI
  666. path, _service = server
  667. app = AssistantTUI(path)
  668. async def scenario():
  669. async with app.run_test() as pilot:
  670. await pilot.pause()
  671. app.state.ingest({"type": "triage", "stage": "signals", "triage": {}})
  672. app.state.ingest({"type": "status", "triage_state": "ok"})
  673. app._render() # must not raise NoMatches for #triage
  674. await pilot.pause()
  675. assert not app.query("#triage")
  676. asyncio.run(scenario())
  677. def test_default_ctor_has_no_triage_arg(server):
  678. """AssistantTUI(path) stays valid: triage_enabled defaults to False."""
  679. pytest.importorskip("textual")
  680. from stt_daemon.tui.app import AssistantTUI
  681. path, _service = server
  682. app = AssistantTUI(path)
  683. assert app.triage_enabled is False
  684. assert app._seen_triage_epoch == 0
  685. def test_maximize_triage_hides_chat_and_fills_screen(server):
  686. """Focus #triage, press m: #main maximized, chat hidden, triage fills screen."""
  687. pytest.importorskip("textual")
  688. from textual.widgets import RichLog
  689. from stt_daemon.tui.app import AssistantTUI
  690. path, _service = server
  691. app = AssistantTUI(path, triage_enabled=True)
  692. observed: dict = {}
  693. async def scenario():
  694. async with app.run_test(size=(80, 40)) as pilot:
  695. await pilot.pause()
  696. app.query_one("#triage", RichLog).focus()
  697. await pilot.pause()
  698. assert app.focused is not None and app.focused.id == "triage", app.focused
  699. await pilot.press("m")
  700. await pilot.pause()
  701. observed["maximized_is_main"] = app.screen.maximized is app.query_one("#main")
  702. observed["triage_display"] = app.query_one("#triage", RichLog).display
  703. observed["chat_display"] = app.query_one("#chat", RichLog).display
  704. observed["region"] = app.query_one("#triage", RichLog).region
  705. observed["screen_size"] = app.screen.size
  706. asyncio.run(scenario())
  707. print("triage maximized: region=%s screen=%s" % (observed["region"], observed["screen_size"]))
  708. assert observed["maximized_is_main"] is True
  709. assert observed["triage_display"] is True
  710. assert observed["chat_display"] is False
  711. region = observed["region"]
  712. size = observed["screen_size"]
  713. assert abs(region.width - size.width) <= 2, (region, size)
  714. assert abs(region.height - size.height) <= 2, (region, size)
  715. def test_maximize_toggle_restores_layout(server):
  716. """Press m again: minimize restores both panes."""
  717. pytest.importorskip("textual")
  718. from textual.widgets import RichLog
  719. from stt_daemon.tui.app import AssistantTUI
  720. path, _service = server
  721. app = AssistantTUI(path, triage_enabled=True)
  722. observed: dict = {}
  723. async def scenario():
  724. async with app.run_test(size=(80, 40)) as pilot:
  725. await pilot.pause()
  726. app.query_one("#triage", RichLog).focus()
  727. await pilot.pause()
  728. await pilot.press("m")
  729. await pilot.pause()
  730. await pilot.press("m")
  731. await pilot.pause()
  732. observed["maximized"] = app.screen.maximized
  733. observed["triage_display"] = app.query_one("#triage", RichLog).display
  734. observed["chat_display"] = app.query_one("#chat", RichLog).display
  735. asyncio.run(scenario())
  736. assert observed["maximized"] is None
  737. assert observed["triage_display"] is True
  738. assert observed["chat_display"] is True
  739. def test_maximize_transcript_fills_screen(server):
  740. """Focus #transcript, press m: transcript maximized and fills the screen."""
  741. pytest.importorskip("textual")
  742. from textual.widgets import RichLog
  743. from stt_daemon.tui.app import AssistantTUI
  744. path, _service = server
  745. app = AssistantTUI(path, triage_enabled=True)
  746. observed: dict = {}
  747. async def scenario():
  748. async with app.run_test(size=(80, 40)) as pilot:
  749. await pilot.pause()
  750. app.query_one("#transcript", RichLog).focus()
  751. await pilot.pause()
  752. await pilot.press("m")
  753. await pilot.pause()
  754. observed["maximized_is_transcript"] = (
  755. app.screen.maximized is app.query_one("#transcript")
  756. )
  757. observed["region"] = app.query_one("#transcript", RichLog).region
  758. observed["screen_size"] = app.screen.size
  759. await pilot.press("m")
  760. await pilot.pause()
  761. observed["restored"] = app.screen.maximized
  762. asyncio.run(scenario())
  763. print("transcript maximized: region=%s screen=%s"
  764. % (observed["region"], observed["screen_size"]))
  765. assert observed["maximized_is_transcript"] is True
  766. region = observed["region"]
  767. size = observed["screen_size"]
  768. assert abs(region.width - size.width) <= 2, (region, size)
  769. assert abs(region.height - size.height) <= 2, (region, size)
  770. assert observed["restored"] is None
  771. def test_escape_while_maximized_restores_panes(server):
  772. """Escape minimizes (ESCAPE_TO_MINIMIZE) and _drain restores the hidden sibling."""
  773. pytest.importorskip("textual")
  774. from textual.widgets import RichLog
  775. from stt_daemon.tui.app import AssistantTUI
  776. path, _service = server
  777. app = AssistantTUI(path, triage_enabled=True)
  778. observed: dict = {}
  779. async def scenario():
  780. async with app.run_test(size=(80, 40)) as pilot:
  781. await pilot.pause()
  782. app.query_one("#triage", RichLog).focus()
  783. await pilot.pause()
  784. await pilot.press("m")
  785. await pilot.pause()
  786. assert app.screen.maximized is not None
  787. await pilot.press("escape")
  788. await pilot.pause()
  789. # Let the 0.1s _drain timer run so the restore path executes.
  790. for _ in range(10):
  791. await pilot.pause()
  792. await asyncio.sleep(0.05)
  793. if app.screen.maximized is None and app.query_one("#chat", RichLog).display:
  794. break
  795. observed["maximized"] = app.screen.maximized
  796. observed["triage_display"] = app.query_one("#triage", RichLog).display
  797. observed["chat_display"] = app.query_one("#chat", RichLog).display
  798. asyncio.run(scenario())
  799. assert observed["maximized"] is None
  800. assert observed["triage_display"] is True
  801. assert observed["chat_display"] is True
  802. def test_maximize_chat_when_triage_disabled(server):
  803. """triage_enabled=False: focus #chat, press m maximizes #main without crashing."""
  804. pytest.importorskip("textual")
  805. from textual.widgets import RichLog
  806. from stt_daemon.tui.app import AssistantTUI
  807. path, _service = server
  808. app = AssistantTUI(path)
  809. observed: dict = {}
  810. async def scenario():
  811. async with app.run_test(size=(80, 40)) as pilot:
  812. await pilot.pause()
  813. # Disabled: no #main/#triage; focusing #chat then m maximizes #chat itself.
  814. app.query_one("#chat", RichLog).focus()
  815. await pilot.pause()
  816. await pilot.press("m")
  817. await pilot.pause()
  818. observed["maximized"] = app.screen.maximized
  819. observed["chat"] = app.query_one("#chat", RichLog)
  820. observed["region"] = app.query_one("#chat", RichLog).region
  821. observed["screen_size"] = app.screen.size
  822. await pilot.press("m")
  823. await pilot.pause()
  824. observed["restored"] = app.screen.maximized
  825. asyncio.run(scenario())
  826. print("chat maximized (disabled): region=%s screen=%s"
  827. % (observed["region"], observed["screen_size"]))
  828. assert observed["maximized"] is observed["chat"]
  829. assert observed["restored"] is None
  830. def test_footer_hidden_while_maximized(server):
  831. """ALLOW_IN_MAXIMIZED_VIEW hides the Footer while a pane is maximized."""
  832. pytest.importorskip("textual")
  833. from textual.widgets import Footer, RichLog
  834. from stt_daemon.tui.app import AssistantTUI
  835. path, _service = server
  836. app = AssistantTUI(path, triage_enabled=True)
  837. observed: dict = {}
  838. async def scenario():
  839. async with app.run_test(size=(80, 40)) as pilot:
  840. await pilot.pause()
  841. app.query_one("#triage", RichLog).focus()
  842. await pilot.pause()
  843. await pilot.press("m")
  844. await pilot.pause()
  845. observed["maximized"] = app.screen.maximized
  846. observed["footer_region"] = app.query_one(Footer).region
  847. await pilot.press("m")
  848. await pilot.pause()
  849. asyncio.run(scenario())
  850. assert observed["maximized"] is not None
  851. footer_region = observed["footer_region"]
  852. assert footer_region.height == 0 or footer_region.width == 0, observed
  853. def test_question_mark_key_toggles_help_panel(server):
  854. pytest.importorskip("textual")
  855. from textual.widgets import HelpPanel
  856. from stt_daemon.tui.app import AssistantTUI
  857. path, _service = server
  858. app = AssistantTUI(path)
  859. observed: dict = {}
  860. async def scenario():
  861. async with app.run_test() as pilot:
  862. await pilot.pause()
  863. assert list(app.screen.query(HelpPanel)) == []
  864. await pilot.press("question_mark")
  865. await pilot.pause()
  866. observed["shown"] = list(app.screen.query(HelpPanel)) != []
  867. app.screen.query_one(HelpPanel) # must succeed while shown
  868. await pilot.press("question_mark")
  869. await pilot.pause()
  870. observed["hidden"] = list(app.screen.query(HelpPanel)) == []
  871. asyncio.run(scenario())
  872. assert observed["shown"] is True, "? did not mount HelpPanel"
  873. assert observed["hidden"] is True, "second ? did not remove HelpPanel"
  874. def test_app_bindings_list():
  875. """BINDINGS: 7 visible footer entries; hidden ones after; no h binding."""
  876. pytest.importorskip("textual")
  877. from stt_daemon.tui.app import AssistantTUI
  878. pairs = [(b.key, b.description) for b in AssistantTUI.BINDINGS]
  879. assert ("ctrl+r", "execute") in pairs
  880. assert ("m", "maximize pane") in pairs
  881. assert ("question_mark", "help") in pairs
  882. assert ("ctrl+s", "save screenshot") in pairs
  883. assert ("a", "arm command") in pairs
  884. assert ("escape", "cancel") in pairs
  885. assert ("e", "edit command") in pairs
  886. assert ("t", "chat toggle") in pairs
  887. assert ("c", "clear") in pairs
  888. assert ("q", "quit") in pairs
  889. assert ("ctrl+p", "instructions") in pairs
  890. assert not any(key == "h" for key, _ in pairs)
  891. assert not any(key == "ctrl+m" for key, _ in pairs)
  892. assert not any(key == "ctrl+h" for key, _ in pairs)
  893. assert not any(key == "enter" for key, _ in pairs)
  894. assert not any(key == "ctrl+k" for key, _ in pairs)
  895. by_key = {b.key: b for b in AssistantTUI.BINDINGS}
  896. assert by_key["question_mark"].show is True
  897. assert by_key["m"].show is False
  898. assert by_key["c"].show is False
  899. assert by_key["ctrl+s"].show is False
  900. assert by_key["ctrl+p"].show is False
  901. # Visible seven first, in footer order.
  902. visible = [(b.key, b.description) for b in AssistantTUI.BINDINGS if b.show]
  903. assert visible == [
  904. ("a", "arm command"),
  905. ("ctrl+r", "execute"),
  906. ("escape", "cancel"),
  907. ("e", "edit command"),
  908. ("t", "chat toggle"),
  909. ("question_mark", "help"),
  910. ("q", "quit"),
  911. ]
  912. assert by_key["ctrl+p"].priority is True
  913. def test_footer_shows_ordered_entries(server):
  914. """Left list is the 7 visible bindings (? for help); instructions only right-aligned."""
  915. pytest.importorskip("textual")
  916. from textual.widgets import Footer
  917. from textual.widgets._footer import FooterKey
  918. from stt_daemon.tui.app import AssistantTUI
  919. path, _service = server
  920. app = AssistantTUI(path)
  921. observed: dict = {}
  922. async def scenario():
  923. async with app.run_test() as pilot:
  924. await pilot.pause()
  925. shown = [
  926. (b.binding.key, b.binding.description)
  927. for b in app.screen.active_bindings.values()
  928. if b.binding.show
  929. ]
  930. observed["shown"] = shown
  931. footer = app.query_one(Footer)
  932. keys = list(footer.query(FooterKey))
  933. observed["keys"] = [
  934. (k.key, k.key_display, k.description, k.action, set(k.classes))
  935. for k in keys
  936. ]
  937. asyncio.run(scenario())
  938. expected = [
  939. ("a", "arm command"),
  940. ("ctrl+r", "execute"),
  941. ("escape", "cancel"),
  942. ("e", "edit command"),
  943. ("t", "chat toggle"),
  944. ("question_mark", "help"),
  945. ("q", "quit"),
  946. ]
  947. assert observed["shown"] == expected, observed["shown"]
  948. # Help advertises as "?" in the footer.
  949. assert app.get_key_display(
  950. next(b for b in AssistantTUI.BINDINGS if b.key == "question_mark")
  951. ) == "?"
  952. # The composed Footer: exactly one "instructions" entry and it is the
  953. # right-aligned command-palette key; no left duplicate exists.
  954. instructions = [k for k in observed["keys"] if k[2] == "instructions"]
  955. assert len(instructions) == 1, observed["keys"]
  956. key, key_display, _desc, action, classes = instructions[0]
  957. assert action == "command_palette", instructions
  958. assert "-command-palette" in classes, instructions
  959. assert key == "ctrl+p", instructions
  960. non_palette = [k for k in observed["keys"] if "-command-palette" not in k[4]]
  961. assert not any(k[2] == "instructions" for k in non_palette), observed["keys"]
  962. assert not any(k[3] == "command_palette" for k in non_palette), observed["keys"]
  963. def test_ctrl_p_opens_command_palette(server):
  964. """ctrl+p still opens the palette; its binding description is 'instructions'."""
  965. pytest.importorskip("textual")
  966. from textual.command import CommandPalette
  967. from stt_daemon.tui.app import AssistantTUI
  968. path, _service = server
  969. app = AssistantTUI(path)
  970. observed: dict = {}
  971. async def scenario():
  972. async with app.run_test() as pilot:
  973. await pilot.pause()
  974. await pilot.press("ctrl+p")
  975. await pilot.pause()
  976. await pilot.pause()
  977. observed["is_palette"] = isinstance(app.screen, CommandPalette)
  978. observed["stack"] = [type(s).__name__ for s in app.screen_stack]
  979. asyncio.run(scenario())
  980. palette_binding = next(b for b in AssistantTUI.BINDINGS if b.key == "ctrl+p")
  981. assert palette_binding.description == "instructions"
  982. assert observed["is_palette"], "ctrl+p did not open the command palette: %s" % (
  983. observed["stack"],
  984. )
  985. def test_question_mark_toggles_help_panel(server):
  986. """? toggles the help panel and is advertised in the left footer list."""
  987. pytest.importorskip("textual")
  988. from textual.widgets import HelpPanel
  989. from stt_daemon.tui.app import AssistantTUI
  990. path, _service = server
  991. app = AssistantTUI(path)
  992. observed: dict = {}
  993. async def scenario():
  994. async with app.run_test() as pilot:
  995. await pilot.pause()
  996. assert list(app.screen.query(HelpPanel)) == []
  997. await pilot.press("?")
  998. await pilot.pause()
  999. observed["shown"] = list(app.screen.query(HelpPanel)) != []
  1000. await pilot.press("?")
  1001. await pilot.pause()
  1002. observed["hidden"] = list(app.screen.query(HelpPanel)) == []
  1003. asyncio.run(scenario())
  1004. assert observed["shown"] is True, "? did not mount HelpPanel"
  1005. assert observed["hidden"] is True, "second ? did not remove HelpPanel"
  1006. def test_plain_m_maximizes_triage_pane(server):
  1007. pytest.importorskip("textual")
  1008. from stt_daemon.tui.app import AssistantTUI
  1009. from textual.widgets import RichLog
  1010. path, _service = server
  1011. app = AssistantTUI(path, triage_enabled=True)
  1012. async def scenario():
  1013. async with app.run_test(size=(80, 40)) as pilot:
  1014. await pilot.pause()
  1015. app.query_one("#triage", RichLog).focus()
  1016. await pilot.pause()
  1017. await pilot.press("m")
  1018. await pilot.pause()
  1019. assert app.screen.maximized is app.query_one("#main")
  1020. await pilot.press("m")
  1021. await pilot.pause()
  1022. assert app.screen.maximized is None
  1023. assert app.query_one("#triage", RichLog).display is True
  1024. assert app.query_one("#chat", RichLog).display is True
  1025. asyncio.run(scenario())
  1026. def test_screenshot_saves_svg(server, tmp_path, monkeypatch):
  1027. pytest.importorskip("textual")
  1028. import glob
  1029. from stt_daemon.tui.app import AssistantTUI
  1030. monkeypatch.chdir(tmp_path)
  1031. path, _service = server
  1032. app = AssistantTUI(path)
  1033. async def scenario():
  1034. async with app.run_test() as pilot:
  1035. await pilot.pause()
  1036. await pilot.press("ctrl+s")
  1037. await pilot.pause()
  1038. await pilot.pause()
  1039. asyncio.run(scenario())
  1040. out_dir = tmp_path / "screenshots"
  1041. assert out_dir.is_dir(), "screenshots/ not created in %s" % tmp_path
  1042. files = glob.glob(str(out_dir / "stt-tui-*.svg"))
  1043. assert files, "no stt-tui-*.svg found in %s" % out_dir
  1044. assert os.path.getsize(files[0]) > 0
  1045. assert glob.glob(str(tmp_path / "stt-tui-*.svg")) == [], \
  1046. "screenshot leaked into %s instead of screenshots/" % tmp_path