agent output
This commit is contained in:
99
cht/ui/agent_output.py
Normal file
99
cht/ui/agent_output.py
Normal 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")
|
||||||
116
cht/window.py
116
cht/window.py
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user