app.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. """Textual TUI: watch transcriptions and drive the assistant."""
  2. import argparse
  3. import os
  4. import queue
  5. import sys
  6. import time
  7. from datetime import datetime
  8. from textual.app import App, ComposeResult
  9. from textual.binding import Binding
  10. from textual.containers import Horizontal
  11. from textual.widgets import Footer, Input, RichLog, Static
  12. from ..assistant.control import default_control_path
  13. from ..config import load_config
  14. from .client import ControlClient
  15. from .dialogs import ConfirmClear
  16. from .state import ViewState
  17. class MainPane(Horizontal):
  18. """The triage/chat row; allowed to fill the screen when maximized."""
  19. ALLOW_MAXIMIZE = True
  20. class AssistantTUI(App):
  21. """Live view of transcriptions, commands, and chat."""
  22. ALLOW_IN_MAXIMIZED_VIEW = ".__never__"
  23. CSS = """
  24. #status { height: 1; }
  25. #transcript { height: 25%; border: solid $accent; }
  26. #command { height: 3; border: solid $accent; }
  27. #chat { height: 1fr; border: solid $accent; }
  28. #edit { display: none; }
  29. #main { height: 1fr; }
  30. #main #triage { width: 1fr; height: 100%; border: solid $accent; }
  31. #main #chat { width: 2fr; height: 100%; }
  32. #transcript.-maximized { height: 1fr; }
  33. """
  34. BINDINGS = [
  35. Binding("a", "arm", "arm command"),
  36. Binding("ctrl+r", "execute", "execute"),
  37. Binding("escape", "cancel", "cancel"),
  38. Binding("e", "edit", "edit command"),
  39. Binding("t", "chat", "chat toggle"),
  40. Binding("question_mark", "toggle_help_panel", "help"),
  41. Binding("q", "quit", "quit"),
  42. Binding("m", "toggle_maximize", "maximize pane", show=False),
  43. Binding("c", "clear", "clear", show=False),
  44. Binding("ctrl+s", "screenshot", "save screenshot", show=False),
  45. Binding("ctrl+p", "command_palette", "instructions", priority=True, show=False),
  46. ]
  47. def __init__(self, path: str, triage_enabled: bool = False):
  48. super().__init__()
  49. self.path = path
  50. self.triage_enabled = triage_enabled
  51. self.state = ViewState()
  52. self.client = ControlClient(path)
  53. self.connect_error: str | None = None
  54. self._events: queue.Queue = queue.Queue()
  55. self._seen_transcripts = 0
  56. self._seen_lower = 0
  57. self._seen_epoch = 0
  58. self._seen_triage_epoch = 0
  59. self._timer = None
  60. self._last_seg_title = ""
  61. self._last_inf_title = ""
  62. def compose(self) -> ComposeResult:
  63. yield Static(id="status")
  64. yield RichLog(id="transcript", wrap=True, markup=False)
  65. yield Static(id="command")
  66. if self.triage_enabled:
  67. with MainPane(id="main"):
  68. yield RichLog(id="triage", wrap=True, markup=False)
  69. yield RichLog(id="chat", wrap=True, markup=False)
  70. else:
  71. yield RichLog(id="chat", wrap=True, markup=False)
  72. yield Input(placeholder="edit the command, then press Enter", id="edit")
  73. yield Footer()
  74. def _set_titles(self) -> None:
  75. self.query_one("#command", Static).border_title = "command buffer"
  76. if self.triage_enabled:
  77. self.query_one("#triage", RichLog).border_title = "triage"
  78. self._update_titles(self.state)
  79. def _update_titles(self, state) -> None:
  80. seg_title = f"transcripts [seg {state.queue_depth}]"
  81. if seg_title != self._last_seg_title:
  82. self.query_one("#transcript", RichLog).border_title = seg_title
  83. self._last_seg_title = seg_title
  84. inf_suffix = " · busy" if state.inference_in_flight else ""
  85. inf_title = f"conversation / command output [inf {state.inference_depth}{inf_suffix}]"
  86. if inf_title != self._last_inf_title:
  87. self.query_one("#chat", RichLog).border_title = inf_title
  88. self._last_inf_title = inf_title
  89. def on_mount(self) -> None:
  90. self._set_titles()
  91. try:
  92. self.client.connect()
  93. self.client.subscribe(viewer=True)
  94. except OSError as e:
  95. self.connect_error = f"cannot connect to {self.path}: {e}"
  96. self.exit()
  97. return
  98. self.client.start(
  99. lambda event: self._events.put(event), on_state=self._on_conn_state
  100. )
  101. self._timer = self.set_interval(0.1, self._drain)
  102. def _on_conn_state(self, connected: bool) -> None:
  103. self.state.connected = connected
  104. def _drain(self) -> None:
  105. while True:
  106. try:
  107. event = self._events.get_nowait()
  108. except queue.Empty:
  109. break
  110. self.state.ingest(event)
  111. if self.screen.maximized is None:
  112. self._restore_panes()
  113. self._render()
  114. def _render(self) -> None:
  115. if not self.query("#status"):
  116. # A timer tick can land while the app is shutting down, after the
  117. # widgets are unmounted; there is nothing left to draw into.
  118. return
  119. state = self.state
  120. health = "ok" if state.health_ok() else "DOWN"
  121. conn = "ok" if state.connected else "LOST"
  122. speaking = "●" if state.speech_open else "○"
  123. status = (
  124. f"[{state.mode_style()} bold]{state.mode}[/] "
  125. f"stt:{health} conn:{conn} speech:{speaking} "
  126. f"worker_stall:{state.worker_stall_s:.1f}s "
  127. f"phase:{state.phase}"
  128. )
  129. self.query_one("#status", Static).update(status)
  130. self._update_titles(state)
  131. edited = " (edited)" if state.command_edited else ""
  132. self.query_one("#command", Static).update(
  133. f"command{edited}: {state.command_text or '<empty>'}"
  134. )
  135. transcript_log = self.query_one("#transcript", RichLog)
  136. lines = state.transcript_lines()
  137. while self._seen_transcripts < len(lines):
  138. transcript_log.write(lines[self._seen_transcripts])
  139. self._seen_transcripts += 1
  140. lower_log = self.query_one("#chat", RichLog)
  141. if self._seen_epoch != state.chat_epoch:
  142. lower_log.clear()
  143. self._seen_lower = 0
  144. self._seen_epoch = state.chat_epoch
  145. lower_lines = state.lower_lines()
  146. while self._seen_lower < len(lower_lines):
  147. lower_log.write(lower_lines[self._seen_lower])
  148. self._seen_lower += 1
  149. if self.triage_enabled:
  150. triage_log = self.query_one("#triage", RichLog)
  151. if self._seen_triage_epoch != state.triage_epoch:
  152. triage_log.clear()
  153. self._seen_triage_epoch = state.triage_epoch
  154. triage_log.write("\n".join(state.triage_lines()))
  155. def _send(self, type_: str, **fields) -> None:
  156. try:
  157. self.client.send(type_, **fields)
  158. except (OSError, RuntimeError) as e:
  159. self.query_one("#status", Static).update(f"send failed: {e}")
  160. def action_arm(self) -> None:
  161. self._send("command_start")
  162. def action_execute(self) -> None:
  163. self._send("command_execute")
  164. def action_cancel(self) -> None:
  165. self._send("command_cancel")
  166. def action_chat(self) -> None:
  167. self._send("chat_toggle")
  168. def action_clear(self) -> None:
  169. self.push_screen(ConfirmClear(), self._on_clear_confirmed)
  170. def _on_clear_confirmed(self, confirmed: bool) -> None:
  171. if confirmed:
  172. self._send("clear")
  173. def action_edit(self) -> None:
  174. edit = self.query_one("#edit", Input)
  175. edit.value = self.state.command_text
  176. edit.display = True
  177. edit.focus()
  178. def _active_pane(self):
  179. focused = self.screen.focused
  180. if focused is not None:
  181. for node in focused.ancestors_with_self:
  182. if getattr(node, "id", None) in ("transcript", "triage", "chat"):
  183. if self.query(f"#{node.id}"):
  184. return self.query_one(f"#{node.id}")
  185. return node
  186. fallback_id = "triage" if self.triage_enabled else "chat"
  187. if self.query(f"#{fallback_id}"):
  188. return self.query_one(f"#{fallback_id}")
  189. return None
  190. def _restore_panes(self) -> None:
  191. if self.query("#triage"):
  192. self.query_one("#triage").display = True
  193. if self.query("#chat"):
  194. self.query_one("#chat").display = True
  195. def action_toggle_maximize(self) -> None:
  196. screen = self.screen
  197. if screen.maximized is not None:
  198. screen.minimize()
  199. self._restore_panes()
  200. return
  201. pane = self._active_pane()
  202. if pane is None:
  203. return
  204. if pane.id in ("triage", "chat") and self.query("#main"):
  205. other_id = "#chat" if pane.id == "triage" else "#triage"
  206. other = self.query_one(other_id) if self.triage_enabled else None
  207. if other is not None:
  208. other.display = False
  209. screen.maximize(self.query_one("#main"))
  210. else:
  211. screen.maximize(pane)
  212. def action_toggle_help_panel(self) -> None:
  213. from textual.css.query import NoMatches
  214. from textual.widgets import HelpPanel
  215. try:
  216. self.screen.query_one(HelpPanel)
  217. except NoMatches:
  218. self.action_show_help_panel()
  219. else:
  220. self.action_hide_help_panel()
  221. def action_screenshot(self) -> None:
  222. name = "stt-tui-%s.svg" % datetime.now().strftime("%Y%m%d-%H%M%S")
  223. try:
  224. save = getattr(self, "save_screenshot", None)
  225. if callable(save):
  226. path = save(name)
  227. else:
  228. svg = self.export_screenshot()
  229. path = os.path.join(os.getcwd(), name)
  230. with open(path, "w", encoding="utf-8") as f:
  231. f.write(svg)
  232. except Exception as e:
  233. try:
  234. self.notify("screenshot failed: %s" % e)
  235. except Exception:
  236. pass
  237. return
  238. try:
  239. self.notify("saved " + str(path))
  240. except Exception:
  241. try:
  242. self.query_one("#status", Static).update("saved " + str(path))
  243. except Exception:
  244. pass
  245. def on_input_submitted(self, event: Input.Submitted) -> None:
  246. self._send("command_edit", text=event.value)
  247. edit = self.query_one("#edit", Input)
  248. edit.display = False
  249. def on_unmount(self) -> None:
  250. if self._timer is not None:
  251. self._timer.stop()
  252. self.client.close()
  253. def _dump(path: str, seconds: float) -> int:
  254. """Headless: print the event stream for a while (verification / debugging)."""
  255. client = ControlClient(path)
  256. try:
  257. client.connect()
  258. except OSError as e:
  259. print(f"error: cannot connect to {path}: {e}", file=sys.stderr)
  260. return 1
  261. state = ViewState()
  262. client.subscribe(viewer=True)
  263. def on_event(event):
  264. state.ingest(event)
  265. print(f"{event.get('type'):<12} {event}", flush=True)
  266. client.start(on_event)
  267. try:
  268. time.sleep(seconds)
  269. except KeyboardInterrupt:
  270. pass
  271. finally:
  272. client.close()
  273. print(f"mode={state.mode} stt_ok={state.stt_ok} transcripts={len(state.transcripts)}")
  274. return 0
  275. def main() -> int:
  276. """Entry point for `stt-tui`."""
  277. parser = argparse.ArgumentParser(description="Assistant TUI (watch + drive)")
  278. parser.add_argument("--config", default=None, help="Path to config.toml")
  279. parser.add_argument("--socket", default=None, help="Override the control socket path")
  280. parser.add_argument(
  281. "--dump",
  282. type=float,
  283. default=None,
  284. metavar="SECONDS",
  285. help="Headless: print the event stream for N seconds and exit",
  286. )
  287. args = parser.parse_args()
  288. cfg = load_config(args.config)
  289. path = args.socket
  290. if path is None:
  291. path = cfg.assistant.control_socket or default_control_path()
  292. if args.dump is not None:
  293. return _dump(path, args.dump)
  294. app = AssistantTUI(path, triage_enabled=cfg.triage.enabled)
  295. app.run()
  296. if app.connect_error:
  297. print(f"error: {app.connect_error}", file=sys.stderr)
  298. return 1
  299. return 0
  300. if __name__ == "__main__":
  301. sys.exit(main())