test_tui_app.py 96 KB

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