From 130fc5dac2cf26d31845fdf22c39c35c84bd6515 Mon Sep 17 00:00:00 2001 From: buenosairesam Date: Fri, 3 Apr 2026 05:03:30 -0300 Subject: [PATCH] agent output --- cht/ui/agent_output.py | 99 +++++++++++++++++++++++++++++++++++ cht/window.py | 116 +++++++---------------------------------- 2 files changed, 119 insertions(+), 96 deletions(-) create mode 100644 cht/ui/agent_output.py diff --git a/cht/ui/agent_output.py b/cht/ui/agent_output.py new file mode 100644 index 0000000..315495c --- /dev/null +++ b/cht/ui/agent_output.py @@ -0,0 +1,99 @@ +"""Agent output panel — scrollable text view with markdown rendering.""" + +import gi +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk + +from cht.ui import markdown + + +class AgentOutputPanel(Gtk.Frame): + """Scrollable text view that displays agent responses with markdown.""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + + header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) + header.set_margin_start(8) + header.set_margin_end(8) + header.set_margin_top(8) + header.set_margin_bottom(8) + label = Gtk.Label(label="Agent Output") + label.add_css_class("heading") + label.set_hexpand(True) + label.set_halign(Gtk.Align.START) + header.append(label) + + clear_btn = Gtk.Button(label="Clear") + clear_btn.add_css_class("flat") + clear_btn.connect("clicked", lambda _: self.clear()) + header.append(clear_btn) + + box.append(header) + + self._view = Gtk.TextView() + self._view.set_editable(False) + self._view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) + self._view.set_cursor_visible(False) + self._view.set_left_margin(8) + self._view.set_right_margin(8) + markdown.setup_tags(self._view.get_buffer()) + + scroll = Gtk.ScrolledWindow() + scroll.set_vexpand(True) + scroll.set_child(self._view) + box.append(scroll) + + self.set_child(box) + + # Streaming state + self._thinking_replaced = False + self._response_start_mark = None + self._response_accum = [] + + def append(self, text: str) -> None: + """Append plain text and auto-scroll.""" + buf = self._view.get_buffer() + buf.insert(buf.get_end_iter(), text) + self._view.scroll_to_iter(buf.get_end_iter(), 0, False, 0, 0) + + def clear(self) -> None: + """Clear all output.""" + self._view.get_buffer().set_text("") + + def begin_response(self) -> None: + """Reset streaming state for a new response (call before sending).""" + self._thinking_replaced = False + self._response_start_mark = None + self._response_accum = [] + + def replace_thinking(self, chunk: str) -> None: + """Replace the '...' placeholder with streamed chunks.""" + buf = self._view.get_buffer() + if not self._thinking_replaced: + self._thinking_replaced = True + end = buf.get_end_iter() + start = end.copy() + start.backward_chars(2) + buf.delete(start, end) + self._response_start_mark = buf.create_mark( + None, buf.get_end_iter(), left_gravity=True + ) + self._response_accum.append(chunk) + self.append(chunk) + + def finish_response(self, err: str | None) -> None: + """Finalize the response — render markdown or show error.""" + if err: + self.append(f"[Error: {err}]\n") + return + if self._response_start_mark and self._response_accum: + buf = self._view.get_buffer() + start = buf.get_iter_at_mark(self._response_start_mark) + end = buf.get_end_iter() + buf.delete(start, end) + markdown.render(buf, start, "".join(self._response_accum)) + buf.delete_mark(self._response_start_mark) + self.append("\n") diff --git a/cht/window.py b/cht/window.py index 9cf3459..df8b57e 100644 --- a/cht/window.py +++ b/cht/window.py @@ -19,7 +19,7 @@ from cht.ui.waveform import WaveformWidget from cht.ui.frames_panel import FramesPanel from cht.ui.transcript_panel import TranscriptPanel from cht.ui.keyboard import KeyboardManager, KEY_LEFT, KEY_RIGHT, KEY_UP, KEY_DOWN, KEY_RETURN, KEY_KP_ENTER, KEY_ESCAPE, KEY_DELETE -from cht.ui import markdown +from cht.ui.agent_output import AgentOutputPanel from cht.audio.waveform import WaveformEngine from cht.transcriber.engine import TranscriberEngine, LANGUAGES from cht.stream.manager import StreamManager, list_sessions, delete_sessions @@ -58,7 +58,8 @@ class ChtWindow(Adw.ApplicationWindow): self._main_paned.set_shrink_end_child(False) self._main_paned.set_position(450) - self._main_paned.set_start_child(self._build_agent_output()) + self._agent_output = AgentOutputPanel() + self._main_paned.set_start_child(self._agent_output) right_box = self._build_right_panels() self._main_paned.set_end_child(right_box) @@ -140,7 +141,7 @@ class ChtWindow(Adw.ApplicationWindow): def _on_load_session_clicked(self, button): sessions = list_sessions() if not sessions: - self._append_agent_output("No previous sessions found.\n") + self._agent_output.append("No previous sessions found.\n") return dialog = Adw.Window(transient_for=self, modal=True) @@ -223,11 +224,11 @@ class ChtWindow(Adw.ApplicationWindow): try: self._stream_mgr = StreamManager.from_existing(session_id) except FileNotFoundError as e: - self._append_agent_output(f"Error: {e}\n") + self._agent_output.append(f"Error: {e}\n") return self.set_title(f"{APP_NAME} — {session_id}") - self._append_agent_output(f"Loaded session: {session_id}\n") + self._agent_output.append(f"Loaded session: {session_id}\n") segments = self._stream_mgr.recording_segments if segments: @@ -236,12 +237,12 @@ class ChtWindow(Adw.ApplicationWindow): if duration > 0: self._timeline.set_duration(duration) self._timeline.seek(0) - self._append_agent_output( + self._agent_output.append( f" Recording: {len(segments)} segment(s), " f"{int(duration)}s duration\n" ) else: - self._append_agent_output(" No recordings found (frames only).\n") + self._agent_output.append(" No recordings found (frames only).\n") self._load_existing_frames() self._load_existing_transcript() @@ -469,46 +470,6 @@ class ChtWindow(Adw.ApplicationWindow): return right_box - # -- Agent panels -- - - def _build_agent_output(self): - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) - - header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) - header.set_margin_start(8) - header.set_margin_end(8) - header.set_margin_top(8) - header.set_margin_bottom(8) - label = Gtk.Label(label="Agent Output") - label.add_css_class("heading") - label.set_hexpand(True) - label.set_halign(Gtk.Align.START) - header.append(label) - - clear_btn = Gtk.Button(label="Clear") - clear_btn.add_css_class("flat") - clear_btn.connect("clicked", self._on_clear_agent_output) - header.append(clear_btn) - - box.append(header) - - self._agent_output_view = Gtk.TextView() - self._agent_output_view.set_editable(False) - self._agent_output_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) - self._agent_output_view.set_cursor_visible(False) - self._agent_output_view.set_left_margin(8) - self._agent_output_view.set_right_margin(8) - markdown.setup_tags(self._agent_output_view.get_buffer()) - - scroll = Gtk.ScrolledWindow() - scroll.set_vexpand(True) - scroll.set_child(self._agent_output_view) - box.append(scroll) - - frame = Gtk.Frame() - frame.set_child(box) - return frame - def _build_agent_input(self): outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) outer.set_margin_start(4) @@ -592,7 +553,7 @@ class ChtWindow(Adw.ApplicationWindow): kb.bind(KEY_RETURN, lambda **_: self._send_message(self._build_selection_message("answer")) if self._build_selection_message("answer") else None) kb.bind(KEY_KP_ENTER, lambda **_: self._send_message(self._build_selection_message("answer")) if self._build_selection_message("answer") else None) kb.bind(KEY_ESCAPE, lambda **_: (self.set_focus(None), self._frames_panel.clear_selection(), self._transcript_panel.clear_selection())) - kb.bind(KEY_DELETE, lambda **_: self._on_clear_agent_output(None)) + kb.bind(KEY_DELETE, lambda **_: self._agent_output.clear()) kb.attach(self) # -- Agent actions -- @@ -609,7 +570,7 @@ class ChtWindow(Adw.ApplicationWindow): def _send_action(self, verb: str): msg = self._build_selection_message(verb) if not msg: - self._append_agent_output("Select a frame or transcript first.\n") + self._agent_output.append("Select a frame or transcript first.\n") return self._send_message(msg) @@ -622,54 +583,22 @@ class ChtWindow(Adw.ApplicationWindow): if not text: return if not self._stream_mgr: - self._append_agent_output("No active session.\n") + self._agent_output.append("No active session.\n") return - self._append_agent_output(f"\n> {text}\n…\n") + self._agent_output.append(f"\n> {text}\n…\n") + self._agent_output.begin_response() self._agent.send( message=text, stream_mgr=self._stream_mgr, tracker=self._tracker, - on_chunk=lambda chunk: GLib.idle_add(self._replace_thinking, chunk), - on_done=lambda err: GLib.idle_add(self._on_agent_done, err), + on_chunk=lambda chunk: GLib.idle_add(self._agent_output.replace_thinking, chunk), + on_done=lambda err: GLib.idle_add(self._agent_output.finish_response, err), ) - self._thinking_replaced = False - self._response_start_mark = None - self._response_accum = [] - - def _replace_thinking(self, chunk: str): - buf = self._agent_output_view.get_buffer() - if not self._thinking_replaced: - self._thinking_replaced = True - end = buf.get_end_iter() - start = end.copy() - start.backward_chars(2) - buf.delete(start, end) - self._response_start_mark = buf.create_mark( - None, buf.get_end_iter(), left_gravity=True - ) - self._response_accum.append(chunk) - self._append_agent_output(chunk) - - def _on_agent_done(self, err: str | None): - if err: - self._append_agent_output(f"[Error: {err}]\n") - return - if self._response_start_mark and self._response_accum: - buf = self._agent_output_view.get_buffer() - start = buf.get_iter_at_mark(self._response_start_mark) - end = buf.get_end_iter() - buf.delete(start, end) - markdown.render(buf, start, "".join(self._response_accum)) - buf.delete_mark(self._response_start_mark) - self._append_agent_output("\n") # -- Settings callbacks -- - def _on_clear_agent_output(self, _button): - self._agent_output_view.get_buffer().set_text("") - def _on_lang_changed(self, dropdown, _pspec): idx = dropdown.get_selected() lang_names = list(LANGUAGES.keys()) @@ -704,16 +633,11 @@ class ChtWindow(Adw.ApplicationWindow): return err = check_claude_cli() if err: - self._append_agent_output(f"⚠ {err}\n") + self._agent_output.append(f"⚠ {err}\n") else: - self._append_agent_output(f"Agent ready ({self._agent.provider_name})\n") + self._agent_output.append(f"Agent ready ({self._agent.provider_name})\n") self._populate_model_dropdown() - def _append_agent_output(self, text: str): - buf = self._agent_output_view.get_buffer() - buf.insert(buf.get_end_iter(), text) - self._agent_output_view.scroll_to_iter(buf.get_end_iter(), 0, False, 0, 0) - # -- Data loading -- def _load_existing_frames(self): @@ -721,7 +645,7 @@ class ChtWindow(Adw.ApplicationWindow): return index_path = self._stream_mgr.frames_dir / "index.json" if not index_path.exists(): - self._append_agent_output(" No frames found.\n") + self._agent_output.append(" No frames found.\n") return try: index = json.loads(index_path.read_text()) @@ -742,7 +666,7 @@ class ChtWindow(Adw.ApplicationWindow): if items: self._frames_panel.load_items(items) self._known_frames = {item["id"] for item in items} - self._append_agent_output(f" Loaded {len(items)} frame thumbnails.\n") + self._agent_output.append(f" Loaded {len(items)} frame thumbnails.\n") def _load_existing_transcript(self): if not self._stream_mgr: @@ -754,7 +678,7 @@ class ChtWindow(Adw.ApplicationWindow): segs = self._transcriber.all_segments() if segs: self._transcript_panel.add_items(segs) - self._append_agent_output(f" Loaded {len(segs)} transcript segments.\n") + self._agent_output.append(f" Loaded {len(segs)} transcript segments.\n") def _poll_frames(self): if not self._stream_mgr: