From 0b5575f3b3d3359a450e1a51f1ab08d0333525af Mon Sep 17 00:00:00 2001 From: buenosairesam Date: Thu, 2 Apr 2026 22:07:11 -0300 Subject: [PATCH] shortcuts --- cht/agent/runner.py | 23 +++- cht/config.py | 3 +- cht/stream/manager.py | 58 ++++++++++ cht/window.py | 239 ++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 306 insertions(+), 17 deletions(-) diff --git a/cht/agent/runner.py b/cht/agent/runner.py index 030f4c9..26de4ed 100644 --- a/cht/agent/runner.py +++ b/cht/agent/runner.py @@ -69,17 +69,30 @@ def _parse_mentions(message: str, frames: list[FrameRef]) -> list[FrameRef]: return mentioned +def _resolve_frame_path(frames_dir: Path, raw_path: str) -> Path | None: + """Resolve a frame path from index.json, handling mounted/remote sessions.""" + p = Path(raw_path) + if p.exists(): + return p + # Try relative to frames_dir (handles path prefix mismatch from remote) + local = frames_dir / p.name + if local.exists(): + return local + return None + + def _load_frames(frames_dir: Path) -> list[FrameRef]: index_path = frames_dir / "index.json" if not index_path.exists(): return [] try: entries = json.loads(index_path.read_text()) - return [ - FrameRef(id=e["id"], path=Path(e["path"]), timestamp=e["timestamp"]) - for e in entries - if Path(e["path"]).exists() - ] + frames = [] + for e in entries: + resolved = _resolve_frame_path(frames_dir, e["path"]) + if resolved: + frames.append(FrameRef(id=e["id"], path=resolved, timestamp=e["timestamp"])) + return frames except Exception as e: log.warning("Could not load frames index: %s", e) return [] diff --git a/cht/config.py b/cht/config.py index 23f4b6e..7f7f611 100644 --- a/cht/config.py +++ b/cht/config.py @@ -1,3 +1,4 @@ +import os from pathlib import Path APP_ID = "com.cht.StreamAgent" @@ -6,7 +7,7 @@ APP_NAME = "CHT" # Default session data location — in project dir for easy clearing PROJECT_DIR = Path(__file__).resolve().parent.parent DATA_DIR = PROJECT_DIR / "data" -SESSIONS_DIR = DATA_DIR / "sessions" +SESSIONS_DIR = Path(os.environ.get("CHT_SESSIONS_DIR", DATA_DIR / "sessions")) # Stream defaults STREAM_HOST = "0.0.0.0" diff --git a/cht/stream/manager.py b/cht/stream/manager.py index 1a034c5..e85a2c8 100644 --- a/cht/stream/manager.py +++ b/cht/stream/manager.py @@ -26,6 +26,17 @@ from cht.stream import ffmpeg as ff log = logging.getLogger(__name__) +def list_sessions(): + """Return list of (session_id, session_dir) sorted newest first.""" + if not SESSIONS_DIR.exists(): + return [] + sessions = [] + for d in sorted(SESSIONS_DIR.iterdir(), reverse=True): + if d.is_dir() and (d / "frames").exists(): + sessions.append((d.name, d)) + return sessions + + class StreamManager: def __init__(self, session_id=None): if session_id is None: @@ -42,8 +53,55 @@ class StreamManager: self._stop_flags = set() self._segment = 0 self.scene_threshold = SCENE_THRESHOLD + self.readonly = False # True when loaded from existing session log.info("Session: %s", session_id) + @classmethod + def from_existing(cls, session_id): + """Load an existing session without starting any ffmpeg processes.""" + mgr = cls(session_id=session_id) + if not mgr.session_dir.exists(): + raise FileNotFoundError(f"Session not found: {session_id}") + mgr.readonly = True + # Point _segment to last recording segment + segments = mgr.recording_segments + if segments: + mgr._segment = len(segments) - 1 + log.info("Loaded existing session: %s (%d segments, %d frames)", + session_id, len(segments), mgr.frame_count) + return mgr + + @property + def frame_count(self): + index_path = self.frames_dir / "index.json" + if index_path.exists(): + try: + return len(json.loads(index_path.read_text())) + except Exception: + pass + return 0 + + def total_duration(self): + """Probe total duration across all segments (for completed sessions).""" + total = 0.0 + for seg in self.recording_segments: + try: + import ffmpeg as ffmpeg_lib + info = ffmpeg_lib.probe(str(seg)) + dur = float(info.get("format", {}).get("duration", 0)) + if dur <= 0: + for s in info.get("streams", []): + sdur = float(s.get("duration", 0)) + if sdur > 0: + dur = sdur + break + if dur <= 0: + dur = seg.stat().st_size / 65_000 + total += dur + except Exception: + total += seg.stat().st_size / 65_000 + return total + def setup_dirs(self): for d in (self.stream_dir, self.frames_dir, self.transcript_dir, self.agent_dir): d.mkdir(parents=True, exist_ok=True) diff --git a/cht/window.py b/cht/window.py index fdb1919..d3ff1a7 100644 --- a/cht/window.py +++ b/cht/window.py @@ -13,7 +13,7 @@ from gi.repository import Gtk, Gdk, Adw, GLib, Pango, GdkPixbuf from cht.config import APP_NAME, SCENE_THRESHOLD from cht.ui.timeline import Timeline, TimelineControls from cht.ui.monitor import MonitorWidget -from cht.stream.manager import StreamManager +from cht.stream.manager import StreamManager, list_sessions from cht.stream.tracker import RecordingTracker from cht.agent.runner import AgentRunner, ACTIONS, check_claude_cli @@ -32,6 +32,7 @@ class ChtWindow(Adw.ApplicationWindow): self._known_frames = set() self._selected_frame = None # currently selected frame ID self._frame_widgets = {} # frame_id → outer Box widget + self._frame_order = [] # ordered list of frame IDs # Timeline is the central state machine self._timeline = Timeline() @@ -58,11 +59,23 @@ class ChtWindow(Adw.ApplicationWindow): self._connect_btn.connect("clicked", self._on_connect_clicked) header.pack_start(self._connect_btn) + self._load_btn = Gtk.Button(label="Load Session") + self._load_btn.connect("clicked", self._on_load_session_clicked) + header.pack_start(self._load_btn) + toolbar.add_top_bar(header) toolbar.set_content(self._main_paned) self.set_content(toolbar) self.connect("close-request", self._on_close) + + # Global keyboard shortcuts for frame navigation (capture phase + # so we intercept before GTK activates focused buttons) + key_ctrl = Gtk.EventControllerKey() + key_ctrl.set_propagation_phase(Gtk.PropagationPhase.CAPTURE) + key_ctrl.connect("key-pressed", self._on_key_pressed) + self.add_controller(key_ctrl) + log.info("Window initialized") GLib.idle_add(self._start_stream) @@ -74,6 +87,87 @@ class ChtWindow(Adw.ApplicationWindow): else: self._start_stream() + def _on_load_session_clicked(self, button): + sessions = list_sessions() + if not sessions: + self._append_agent_output("No previous sessions found.\n") + return + + dialog = Adw.Window(transient_for=self, modal=True) + dialog.set_title("Load Session") + dialog.set_default_size(500, 400) + + toolbar = Adw.ToolbarView() + header = Adw.HeaderBar() + toolbar.add_top_bar(header) + + scroll = Gtk.ScrolledWindow() + scroll.set_vexpand(True) + listbox = Gtk.ListBox() + listbox.set_selection_mode(Gtk.SelectionMode.NONE) + listbox.add_css_class("boxed-list") + + for sid, sdir in sessions: + idx = sdir / "frames" / "index.json" + nframes = 0 + try: + nframes = len(json.loads(idx.read_text())) + except Exception: + pass + nrec = len(list((sdir / "stream").glob("recording_*.mp4"))) + + row = Adw.ActionRow() + row.set_title(sid) + row.set_subtitle(f"{nframes} frames, {nrec} segments") + row.set_activatable(True) + def _on_row_activated(r, s=sid, d=dialog): + d.close() + self._load_session(s) + row.connect("activated", _on_row_activated) + listbox.append(row) + + scroll.set_child(listbox) + toolbar.set_content(scroll) + dialog.set_content(toolbar) + dialog.present() + + def _load_session(self, session_id): + """Load an existing session for review (no streaming).""" + # Stop any active stream or previous loaded session + if self._streaming or self._stream_mgr: + self._stop_stream() + + try: + self._stream_mgr = StreamManager.from_existing(session_id) + except FileNotFoundError as e: + self._append_agent_output(f"Error: {e}\n") + return + + self.set_title(f"{APP_NAME} — {session_id}") + self._append_agent_output(f"Loaded session: {session_id}\n") + + # Load recording into review player if segments exist + segments = self._stream_mgr.recording_segments + if segments: + self._monitor.set_recording(segments[0]) + # Probe total duration and set timeline + duration = self._stream_mgr.total_duration() + if duration > 0: + self._timeline.set_duration(duration) + self._timeline.seek(0) # enter scrub mode at start + self._append_agent_output( + f" Recording: {len(segments)} segment(s), " + f"{int(duration)}s duration\n" + ) + else: + self._append_agent_output(" No recordings found (frames only).\n") + + # Load existing frames into the strip + self._load_existing_frames() + + # Set up agent auth/model if not already done + self._populate_model_dropdown() + def _start_stream(self): log.info("Starting stream...") self._connect_btn.set_label("Disconnect") @@ -167,16 +261,23 @@ class ChtWindow(Adw.ApplicationWindow): self._tracker.stop() self._tracker = None if self._stream_mgr: - self._stream_mgr.stop_all() + if not self._stream_mgr.readonly: + self._stream_mgr.stop_all() self._stream_mgr = None self._known_frames = set() self._selected_frame = None self._frame_widgets = {} + self._frame_order = [] + + # Clear frame strip + while child := self._frames_strip.get_first_child(): + self._frames_strip.remove(child) self._connect_btn.set_label("Connect") self._connect_btn.remove_css_class("destructive-action") self._connect_btn.add_css_class("suggested-action") self._streaming = False + self.set_title(APP_NAME) def _on_close(self, *args): self._stop_stream() @@ -323,11 +424,24 @@ class ChtWindow(Adw.ApplicationWindow): 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_margin_top(8) - label.set_margin_bottom(8) - box.append(label) + 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 b: self._agent_output_view.get_buffer().set_text("")) + header.append(clear_btn) + + box.append(header) self._agent_output_view = Gtk.TextView() self._agent_output_view.set_editable(False) @@ -395,6 +509,22 @@ class ChtWindow(Adw.ApplicationWindow): frame.set_child(outer) return frame + def _scroll_to_frame(self, widget): + """Scroll the frames strip so that widget is visible.""" + adj = self._frames_scroll.get_hadjustment() + alloc = widget.get_allocation() + x = alloc.x + w = alloc.width + if w <= 0: + return False # not allocated yet + page = adj.get_page_size() + val = adj.get_value() + if x < val: + adj.set_value(x) + elif x + w > val + page: + adj.set_value(x + w - page) + return False + def _send_action(self, verb: str): """Send a predefined action with the selected frame.""" if not self._selected_frame: @@ -403,7 +533,7 @@ class ChtWindow(Adw.ApplicationWindow): self._send_message(f"{verb} @{self._selected_frame}") def _select_frame(self, frame_id: str): - """Toggle selection of a frame thumbnail.""" + """Select a frame thumbnail (or deselect if already selected).""" # Deselect previous if self._selected_frame and self._selected_frame in self._frame_widgets: self._frame_widgets[self._selected_frame].remove_css_class("frame-selected") @@ -414,7 +544,48 @@ class ChtWindow(Adw.ApplicationWindow): self._selected_frame = frame_id if frame_id in self._frame_widgets: - self._frame_widgets[frame_id].add_css_class("frame-selected") + widget = self._frame_widgets[frame_id] + widget.add_css_class("frame-selected") + # Scroll after layout settles (idle may fire before allocation) + GLib.timeout_add(50, self._scroll_to_frame, widget) + + def _on_key_pressed(self, controller, keyval, keycode, state): + """Handle Left/Right arrow for frame selection, Enter for answer.""" + # Don't intercept when text entry is focused + focus = self.get_focus() + if isinstance(focus, (Gtk.Entry, Gtk.TextView)): + return False + + if keyval == Gdk.KEY_Left: + self._select_adjacent_frame(-1) + return True + elif keyval == Gdk.KEY_Right: + self._select_adjacent_frame(1) + return True + elif keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): + if self._selected_frame: + self._send_action("answer") + return True + elif keyval == Gdk.KEY_Delete: + self._agent_output_view.get_buffer().set_text("") + return True + return False + + def _select_adjacent_frame(self, delta): + """Select the next (+1) or previous (-1) frame. Stops at ends.""" + if not self._frame_order: + return + if self._selected_frame is None: + idx = 0 if delta > 0 else len(self._frame_order) - 1 + else: + try: + cur = self._frame_order.index(self._selected_frame) + except ValueError: + cur = 0 + idx = cur + delta + if idx < 0 or idx >= len(self._frame_order): + return # at the edge, do nothing + self._select_frame(self._frame_order[idx]) def _send_message(self, text: str | None = None): if text is None: @@ -650,6 +821,52 @@ class ChtWindow(Adw.ApplicationWindow): # -- Frame thumbnails -- + def _load_existing_frames(self): + """Load all frames from an existing session's index into the strip.""" + if not self._stream_mgr: + return + index_path = self._stream_mgr.frames_dir / "index.json" + if not index_path.exists(): + self._append_agent_output(" No frames found.\n") + return + + try: + index = json.loads(index_path.read_text()) + except (json.JSONDecodeError, IOError): + return + + # Clear existing strip + while child := self._frames_strip.get_first_child(): + self._frames_strip.remove(child) + self._known_frames.clear() + self._frame_widgets.clear() + self._frame_order.clear() + self._selected_frame = None + + loaded = 0 + for entry in index: + fid = entry["id"] + fpath = Path(entry["path"]) + # Resolve path: try absolute first, then relative to frames_dir + if not fpath.exists(): + fpath = self._stream_mgr.frames_dir / fpath.name + if not fpath.exists(): + continue + self._known_frames.add(fid) + timestamp = entry.get("timestamp", 0) + try: + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(str(fpath), 256, 144, True) + self._add_frame_thumbnail(fid, pixbuf, timestamp, auto_select=False) + loaded += 1 + except Exception as e: + log.warning("Thumbnail load failed for %s: %s", fid, e) + + # Select the last frame after bulk load + if self._frame_order: + self._select_frame(self._frame_order[-1]) + + self._append_agent_output(f" Loaded {loaded} frame thumbnails.\n") + def _poll_frames(self): if not self._stream_mgr: return False @@ -681,7 +898,7 @@ class ChtWindow(Adw.ApplicationWindow): return True - def _add_frame_thumbnail(self, frame_id, pixbuf, timestamp): + def _add_frame_thumbnail(self, frame_id, pixbuf, timestamp, auto_select=True): box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) box.set_size_request(256, -1) @@ -703,10 +920,10 @@ class ChtWindow(Adw.ApplicationWindow): box.add_controller(gesture) self._frame_widgets[frame_id] = box + self._frame_order.append(frame_id) self._frames_strip.append(box) - # Auto-scroll to show the latest frame - adj = self._frames_scroll.get_hadjustment() - GLib.idle_add(lambda: adj.set_value(adj.get_upper()) or False) + if auto_select: + self._select_frame(frame_id) log.info("Thumbnail: %s at %.1fs", frame_id, timestamp)