shortcuts

This commit is contained in:
2026-04-02 22:07:11 -03:00
parent 8c1138c746
commit 0b5575f3b3
4 changed files with 306 additions and 17 deletions

View File

@@ -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 []

View File

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

View File

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

View File

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