agent output

This commit is contained in:
2026-04-03 05:03:30 -03:00
parent f7613c9030
commit 130fc5dac2
2 changed files with 119 additions and 96 deletions

99
cht/ui/agent_output.py Normal file
View File

@@ -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")

View File

@@ -19,7 +19,7 @@ from cht.ui.waveform import WaveformWidget
from cht.ui.frames_panel import FramesPanel from cht.ui.frames_panel import FramesPanel
from cht.ui.transcript_panel import TranscriptPanel 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.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.audio.waveform import WaveformEngine
from cht.transcriber.engine import TranscriberEngine, LANGUAGES from cht.transcriber.engine import TranscriberEngine, LANGUAGES
from cht.stream.manager import StreamManager, list_sessions, delete_sessions 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_shrink_end_child(False)
self._main_paned.set_position(450) 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() right_box = self._build_right_panels()
self._main_paned.set_end_child(right_box) self._main_paned.set_end_child(right_box)
@@ -140,7 +141,7 @@ class ChtWindow(Adw.ApplicationWindow):
def _on_load_session_clicked(self, button): def _on_load_session_clicked(self, button):
sessions = list_sessions() sessions = list_sessions()
if not sessions: if not sessions:
self._append_agent_output("No previous sessions found.\n") self._agent_output.append("No previous sessions found.\n")
return return
dialog = Adw.Window(transient_for=self, modal=True) dialog = Adw.Window(transient_for=self, modal=True)
@@ -223,11 +224,11 @@ class ChtWindow(Adw.ApplicationWindow):
try: try:
self._stream_mgr = StreamManager.from_existing(session_id) self._stream_mgr = StreamManager.from_existing(session_id)
except FileNotFoundError as e: except FileNotFoundError as e:
self._append_agent_output(f"Error: {e}\n") self._agent_output.append(f"Error: {e}\n")
return return
self.set_title(f"{APP_NAME}{session_id}") 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 segments = self._stream_mgr.recording_segments
if segments: if segments:
@@ -236,12 +237,12 @@ class ChtWindow(Adw.ApplicationWindow):
if duration > 0: if duration > 0:
self._timeline.set_duration(duration) self._timeline.set_duration(duration)
self._timeline.seek(0) self._timeline.seek(0)
self._append_agent_output( self._agent_output.append(
f" Recording: {len(segments)} segment(s), " f" Recording: {len(segments)} segment(s), "
f"{int(duration)}s duration\n" f"{int(duration)}s duration\n"
) )
else: 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_frames()
self._load_existing_transcript() self._load_existing_transcript()
@@ -469,46 +470,6 @@ class ChtWindow(Adw.ApplicationWindow):
return right_box 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): def _build_agent_input(self):
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
outer.set_margin_start(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_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_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_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) kb.attach(self)
# -- Agent actions -- # -- Agent actions --
@@ -609,7 +570,7 @@ class ChtWindow(Adw.ApplicationWindow):
def _send_action(self, verb: str): def _send_action(self, verb: str):
msg = self._build_selection_message(verb) msg = self._build_selection_message(verb)
if not msg: 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 return
self._send_message(msg) self._send_message(msg)
@@ -622,54 +583,22 @@ class ChtWindow(Adw.ApplicationWindow):
if not text: if not text:
return return
if not self._stream_mgr: if not self._stream_mgr:
self._append_agent_output("No active session.\n") self._agent_output.append("No active session.\n")
return 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( self._agent.send(
message=text, message=text,
stream_mgr=self._stream_mgr, stream_mgr=self._stream_mgr,
tracker=self._tracker, tracker=self._tracker,
on_chunk=lambda chunk: GLib.idle_add(self._replace_thinking, chunk), on_chunk=lambda chunk: GLib.idle_add(self._agent_output.replace_thinking, chunk),
on_done=lambda err: GLib.idle_add(self._on_agent_done, err), 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 -- # -- Settings callbacks --
def _on_clear_agent_output(self, _button):
self._agent_output_view.get_buffer().set_text("")
def _on_lang_changed(self, dropdown, _pspec): def _on_lang_changed(self, dropdown, _pspec):
idx = dropdown.get_selected() idx = dropdown.get_selected()
lang_names = list(LANGUAGES.keys()) lang_names = list(LANGUAGES.keys())
@@ -704,16 +633,11 @@ class ChtWindow(Adw.ApplicationWindow):
return return
err = check_claude_cli() err = check_claude_cli()
if err: if err:
self._append_agent_output(f"{err}\n") self._agent_output.append(f"{err}\n")
else: 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() 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 -- # -- Data loading --
def _load_existing_frames(self): def _load_existing_frames(self):
@@ -721,7 +645,7 @@ class ChtWindow(Adw.ApplicationWindow):
return return
index_path = self._stream_mgr.frames_dir / "index.json" index_path = self._stream_mgr.frames_dir / "index.json"
if not index_path.exists(): if not index_path.exists():
self._append_agent_output(" No frames found.\n") self._agent_output.append(" No frames found.\n")
return return
try: try:
index = json.loads(index_path.read_text()) index = json.loads(index_path.read_text())
@@ -742,7 +666,7 @@ class ChtWindow(Adw.ApplicationWindow):
if items: if items:
self._frames_panel.load_items(items) self._frames_panel.load_items(items)
self._known_frames = {item["id"] for item in 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): def _load_existing_transcript(self):
if not self._stream_mgr: if not self._stream_mgr:
@@ -754,7 +678,7 @@ class ChtWindow(Adw.ApplicationWindow):
segs = self._transcriber.all_segments() segs = self._transcriber.all_segments()
if segs: if segs:
self._transcript_panel.add_items(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): def _poll_frames(self):
if not self._stream_mgr: if not self._stream_mgr: