init commit

This commit is contained in:
2026-04-01 13:53:09 -03:00
commit 453601c072
22 changed files with 1525 additions and 0 deletions

0
cht/__init__.py Normal file
View File

0
cht/agent/__init__.py Normal file
View File

41
cht/app.py Normal file
View File

@@ -0,0 +1,41 @@
import logging
import sys
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Adw, Gio
from cht.config import APP_ID, APP_NAME
from cht.window import ChtWindow
class ChtApp(Adw.Application):
def __init__(self):
super().__init__(
application_id=APP_ID,
flags=Gio.ApplicationFlags.DEFAULT_FLAGS,
)
def do_activate(self):
win = self.props.active_window
if not win:
win = ChtWindow(application=self)
win.present()
def main():
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
datefmt="%H:%M:%S",
)
log = logging.getLogger("cht")
log.info("CHT starting")
app = ChtApp()
return app.run(sys.argv)
if __name__ == "__main__":
main()

19
cht/config.py Normal file
View File

@@ -0,0 +1,19 @@
from pathlib import Path
APP_ID = "com.cht.StreamAgent"
APP_NAME = "CHT"
# Default session data location
DATA_DIR = Path.home() / ".local" / "share" / "cht"
SESSIONS_DIR = DATA_DIR / "sessions"
# Stream defaults
STREAM_HOST = "0.0.0.0"
STREAM_PORT = 4444
# Frame extraction
SCENE_THRESHOLD = 0.3 # 0-1, lower = more sensitive
MAX_FRAME_INTERVAL = 30 # seconds, fallback if no scene change
# Segment recording
SEGMENT_DURATION = 60 # seconds per .ts segment

0
cht/index/__init__.py Normal file
View File

0
cht/stream/__init__.py Normal file
View File

148
cht/stream/ffmpeg.py Normal file
View File

@@ -0,0 +1,148 @@
"""
Thin wrapper around ffmpeg-python for building and running ffmpeg pipelines.
All ffmpeg command construction goes through this module so manager.py
and other consumers never build raw CLI arg lists.
"""
import logging
import os
import signal
import subprocess
from pathlib import Path
import ffmpeg
log = logging.getLogger(__name__)
def receive_and_segment(stream_url, segment_dir, segment_duration=60):
"""Receive mpegts stream and save as segmented .ts files.
Returns an ffmpeg-python output node (not yet running).
"""
stream = ffmpeg.input(stream_url, fflags="nobuffer", flags="low_delay")
return ffmpeg.output(
stream,
str(segment_dir / "segment_%04d.ts"),
c="copy",
f="segment",
segment_time=segment_duration,
reset_timestamps=1,
)
def receive_and_segment_with_monitor(stream_url, segment_dir, fifo_path, segment_duration=60):
"""Receive stream, save segments AND tee to a named pipe for monitoring.
Returns an ffmpeg-python merged output node.
"""
if not fifo_path.exists():
os.mkfifo(str(fifo_path))
stream = ffmpeg.input(stream_url, fflags="nobuffer", flags="low_delay")
out_segments = ffmpeg.output(
stream,
str(segment_dir / "segment_%04d.ts"),
c="copy",
f="segment",
segment_time=segment_duration,
reset_timestamps=1,
)
out_monitor = ffmpeg.output(
stream,
str(fifo_path),
c="copy",
f="mpegts",
)
return ffmpeg.merge_outputs(out_segments, out_monitor)
def extract_scene_frames(input_path, output_dir, scene_threshold=0.3,
max_interval=30, start_number=1):
"""Extract frames from a file on scene change.
Uses ffmpeg select filter with scene detection and a max-interval fallback.
Returns (process_result, stderr) for timestamp parsing.
"""
select_expr = (
f"gt(scene\\,{scene_threshold})"
f"+gte(t-prev_selected_t\\,{max_interval})"
)
stream = ffmpeg.input(str(input_path))
stream = stream.filter("select", select_expr).filter("showinfo")
output = ffmpeg.output(
stream,
str(output_dir / "F%04d.jpg"),
vsync="vfr",
**{"q:v": "2"},
start_number=start_number,
)
return run_sync(output, timeout=120)
def extract_audio_pcm(input_path):
"""Extract audio as 16kHz mono PCM wav, returning an output node for piping.
Use run_async with pipe_stdout=True to stream PCM data.
"""
stream = ffmpeg.input(str(input_path))
return ffmpeg.output(
stream.audio,
"pipe:",
vn=None,
acodec="pcm_s16le",
ar=16000,
ac=1,
f="wav",
)
def run_async(output_node, pipe_stdout=False, pipe_stderr=False):
"""Start an ffmpeg pipeline asynchronously. Returns subprocess.Popen."""
cmd = compile_cmd(output_node)
log.info("run_async: %s", " ".join(str(c) for c in cmd))
return subprocess.Popen(
cmd,
stdout=subprocess.PIPE if pipe_stdout else subprocess.DEVNULL,
stderr=subprocess.PIPE if pipe_stderr else subprocess.DEVNULL,
)
def run_sync(output_node, timeout=None):
"""Run an ffmpeg pipeline synchronously. Returns (stdout, stderr) as strings."""
cmd = compile_cmd(output_node)
log.info("run_sync: %s", " ".join(str(c) for c in cmd))
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
)
return result.stdout, result.stderr
def compile_cmd(output_node):
"""Compile an ffmpeg-python node to a command list, adding global flags."""
cmd = output_node.compile()
# Insert global flags after 'ffmpeg'
idx = 1
for flag in ["-hide_banner", "-loglevel", "warning"]:
cmd.insert(idx, flag)
idx += 1
return cmd
def stop_proc(proc, timeout=5):
"""Gracefully stop an ffmpeg subprocess."""
if proc and proc.poll() is None:
proc.send_signal(signal.SIGINT)
try:
proc.wait(timeout=timeout)
except subprocess.TimeoutExpired:
proc.kill()

194
cht/stream/manager.py Normal file
View File

@@ -0,0 +1,194 @@
"""
StreamManager: orchestrates ffmpeg pipelines for receiving, recording,
frame extraction, and audio extraction from a muxed mpegts/TCP stream.
All data goes to disk. UI reads from disk.
All ffmpeg commands go through cht.stream.ffmpeg module.
"""
import json
import logging
import os
import re
import time
from pathlib import Path
from threading import Thread
from cht.config import (
STREAM_HOST,
STREAM_PORT,
SEGMENT_DURATION,
SCENE_THRESHOLD,
MAX_FRAME_INTERVAL,
SESSIONS_DIR,
)
from cht.stream import ffmpeg as ff
log = logging.getLogger(__name__)
class StreamManager:
def __init__(self, session_id=None):
if session_id is None:
session_id = time.strftime("%Y%m%d_%H%M%S")
self.session_id = session_id
self.session_dir = SESSIONS_DIR / session_id
self.stream_dir = self.session_dir / "stream"
self.frames_dir = self.session_dir / "frames"
self.transcript_dir = self.session_dir / "transcript"
self.agent_dir = self.session_dir / "agent"
self._procs = {}
self._threads = {}
self._stop_flags = set()
log.info("StreamManager created: session=%s dir=%s", session_id, self.session_dir)
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)
log.info("Session directories created")
@property
def stream_url(self):
return f"tcp://{STREAM_HOST}:{STREAM_PORT}?listen"
def start_all(self):
self.setup_dirs()
self.start_recorder()
self.start_frame_extractor()
def stop_all(self):
log.info("Stopping all processes...")
self._stop_flags.add("stop")
for name, proc in self._procs.items():
log.info("Stopping %s (pid=%s)", name, proc.pid if proc else "?")
ff.stop_proc(proc)
self._procs.clear()
log.info("All processes stopped")
def start_recorder(self):
node = ff.receive_and_segment(
self.stream_url, self.stream_dir, SEGMENT_DURATION,
)
proc = ff.run_async(node, pipe_stderr=True)
self._procs["recorder"] = proc
log.info("Recorder started: pid=%s url=%s", proc.pid, self.stream_url)
self._start_stderr_reader("recorder", proc)
def start_recorder_with_monitor(self):
self.setup_dirs()
fifo_path = self.session_dir / "monitor.pipe"
node = ff.receive_and_segment_with_monitor(
self.stream_url, self.stream_dir, fifo_path, SEGMENT_DURATION,
)
proc = ff.run_async(node, pipe_stderr=True)
self._procs["recorder"] = proc
log.info("Recorder+monitor started: pid=%s url=%s fifo=%s", proc.pid, self.stream_url, fifo_path)
self._start_stderr_reader("recorder", proc)
return fifo_path
def _start_stderr_reader(self, name, proc):
"""Read stderr from a process in a thread and log it."""
def _read():
try:
for line in proc.stderr:
text = line.decode("utf-8", errors="replace").rstrip()
if text:
log.info("[%s:stderr] %s", name, text)
except Exception as e:
log.warning("[%s:stderr] read error: %s", name, e)
retcode = proc.poll()
log.info("[%s] process exited: code=%s", name, retcode)
t = Thread(target=_read, daemon=True, name=f"{name}_stderr")
t.start()
def start_frame_extractor(self):
log.info("Starting frame watcher...")
self._start_frame_watcher()
def _start_frame_watcher(self):
def _watch():
seen = set()
log.info("Frame watcher running, watching %s", self.stream_dir)
while "stop" not in self._stop_flags:
segments = sorted(self.stream_dir.glob("segment_*.ts"))
for seg in segments:
if seg.name not in seen and seg.stat().st_size > 0:
seen.add(seg.name)
log.info("New segment found: %s (%d bytes)", seg.name, seg.stat().st_size)
self._extract_frames_from_file(seg)
time.sleep(2)
log.info("Frame watcher stopped")
t = Thread(target=_watch, daemon=True, name="frame_watcher")
t.start()
self._threads["frame_watcher"] = t
def _extract_frames_from_file(self, segment_path):
existing = list(self.frames_dir.glob("F*.jpg"))
start_num = len(existing) + 1
log.info("Extracting frames from %s (start_num=%d)", segment_path.name, start_num)
try:
_stdout, stderr = ff.extract_scene_frames(
segment_path,
self.frames_dir,
scene_threshold=SCENE_THRESHOLD,
max_interval=MAX_FRAME_INTERVAL,
start_number=start_num,
)
if stderr:
for line in stderr.splitlines()[:10]:
log.debug("[frame_extract:stderr] %s", line)
self._parse_frame_timestamps(stderr, start_num)
new_frames = list(self.frames_dir.glob("F*.jpg"))
log.info("Frame extraction done: %d new frames", len(new_frames) - len(existing))
except Exception as e:
log.error("Frame extraction failed for %s: %s", segment_path.name, e)
def _parse_frame_timestamps(self, stderr_output, start_num):
index_path = self.frames_dir / "index.json"
if index_path.exists():
with open(index_path) as f:
index = json.load(f)
else:
index = []
frame_num = start_num
for line in stderr_output.splitlines():
if "showinfo" not in line:
continue
pts_match = re.search(r"pts_time:\s*([\d.]+)", line)
if pts_match:
pts_time = float(pts_match.group(1))
frame_id = f"F{frame_num:04d}"
frame_path = self.frames_dir / f"{frame_id}.jpg"
if frame_path.exists():
index.append({
"id": frame_id,
"timestamp": pts_time,
"path": str(frame_path),
"sent_to_agent": False,
})
log.info("Indexed frame %s at pts=%.2f", frame_id, pts_time)
frame_num += 1
with open(index_path, "w") as f:
json.dump(index, f, indent=2)
def start_audio_extractor(self):
"""Will be implemented in Phase 3."""
pass
def get_ffplay_cmd(self):
fifo_path = self.session_dir / "monitor.pipe"
return [
"ffplay",
"-hwaccel", "cuda",
"-fflags", "nobuffer",
"-flags", "low_delay",
"-framedrop",
"-i", str(fifo_path),
], fifo_path

View File

0
cht/ui/__init__.py Normal file
View File

159
cht/ui/monitor.py Normal file
View File

@@ -0,0 +1,159 @@
"""
MonitorWidget: embeds ffplay into the GTK4 window for live stream monitoring.
On X11, uses the --wid flag of mpv for native embedding, or reparents
ffplay's window via xdotool. GTK4 dropped GtkSocket so we use X11-level tricks.
"""
import logging
import subprocess
import signal
from threading import Timer
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import Gtk, GLib, Gdk
log = logging.getLogger(__name__)
class MonitorWidget(Gtk.Box):
"""Widget that embeds an ffplay/mpv window for live stream monitoring."""
def __init__(self, **kwargs):
super().__init__(orientation=Gtk.Orientation.VERTICAL, **kwargs)
self._proc = None
self._xid = None
self._drawing_area = Gtk.DrawingArea()
self._drawing_area.set_hexpand(True)
self._drawing_area.set_vexpand(True)
self._drawing_area.set_content_height(250)
self.append(self._drawing_area)
log.info("MonitorWidget initialized")
def start_ffplay(self, input_path):
log.info("Starting ffplay with input: %s", input_path)
cmd = [
"ffplay",
"-fflags", "nobuffer",
"-flags", "low_delay",
"-framedrop",
"-noborder",
"-i", str(input_path),
]
log.info("ffplay cmd: %s", " ".join(cmd))
self._proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
log.info("ffplay started: pid=%s", self._proc.pid)
self._start_stderr_log("ffplay")
Timer(1.0, self._try_reparent).start()
def start_mpv(self, input_path):
log.info("Starting mpv with input: %s", input_path)
surface = self._drawing_area.get_native().get_surface()
xid = None
if hasattr(surface, "get_xid"):
xid = surface.get_xid()
log.info("Got X11 window ID: %s", xid)
else:
log.warning("No get_xid on surface (type=%s), mpv will open separate window", type(surface).__name__)
cmd = [
"mpv",
"--no-terminal",
"--no-osc",
"--no-input-default-bindings",
"--profile=low-latency",
"--demuxer-lavf-o=fflags=+nobuffer",
]
if xid:
cmd.append(f"--wid={xid}")
cmd.append(str(input_path))
log.info("mpv cmd: %s", " ".join(cmd))
self._proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
log.info("mpv started: pid=%s", self._proc.pid)
self._start_stderr_log("mpv")
def _start_stderr_log(self, name):
"""Read stderr in background thread and log it."""
import threading
def _read():
try:
for line in self._proc.stderr:
text = line.decode("utf-8", errors="replace").rstrip()
if text:
log.info("[%s:stderr] %s", name, text)
except Exception as e:
log.warning("[%s:stderr] read error: %s", name, e)
retcode = self._proc.poll() if self._proc else None
log.info("[%s] process exited: code=%s", name, retcode)
t = threading.Thread(target=_read, daemon=True, name=f"{name}_stderr")
t.start()
def _try_reparent(self):
if self._proc is None or self._proc.poll() is not None:
log.warning("ffplay not running, cannot reparent")
return
log.info("Attempting to reparent ffplay window (pid=%s)", self._proc.pid)
try:
result = subprocess.run(
["xdotool", "search", "--pid", str(self._proc.pid)],
capture_output=True,
text=True,
timeout=5,
)
windows = result.stdout.strip().split("\n")
log.info("xdotool found windows: %s", windows)
if windows and windows[0]:
ffplay_wid = windows[0]
surface = self._drawing_area.get_native().get_surface()
if hasattr(surface, "get_xid"):
parent_xid = surface.get_xid()
log.info("Reparenting ffplay %s into %s", ffplay_wid, parent_xid)
subprocess.run(
["xdotool", "windowreparent", ffplay_wid, str(parent_xid)],
timeout=5,
)
subprocess.run(
["xdotool", "windowsize", ffplay_wid, "100%", "100%"],
timeout=5,
)
log.info("Reparenting done")
else:
log.warning("No get_xid on surface, cannot reparent")
else:
log.warning("No windows found for ffplay pid=%s", self._proc.pid)
except FileNotFoundError:
log.error("xdotool not found, cannot reparent ffplay window")
except subprocess.TimeoutExpired:
log.error("xdotool timed out")
def stop(self):
if self._proc and self._proc.poll() is None:
log.info("Stopping monitor process pid=%s", self._proc.pid)
self._proc.send_signal(signal.SIGINT)
try:
self._proc.wait(timeout=3)
log.info("Monitor process stopped gracefully")
except subprocess.TimeoutExpired:
self._proc.kill()
log.warning("Monitor process killed (did not stop in 3s)")
self._proc = None
else:
log.info("Monitor process already stopped or never started")

292
cht/window.py Normal file
View File

@@ -0,0 +1,292 @@
import logging
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Adw, GLib, Pango
from cht.config import APP_NAME
from cht.ui.monitor import MonitorWidget
from cht.stream.manager import StreamManager
log = logging.getLogger(__name__)
class ChtWindow(Adw.ApplicationWindow):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.set_title(APP_NAME)
self.set_default_size(1400, 900)
self._streaming = False
# Main horizontal paned: agent output (left) | right panels
self._main_paned = Gtk.Paned(orientation=Gtk.Orientation.HORIZONTAL)
self._main_paned.set_shrink_start_child(False)
self._main_paned.set_shrink_end_child(False)
self._main_paned.set_position(450)
# Left: Agent output panel
self._agent_output = self._build_agent_output()
self._main_paned.set_start_child(self._agent_output)
# Right: vertical stack of panels
right_box = self._build_right_panels()
self._main_paned.set_end_child(right_box)
# Wrap in toolbar view with header
toolbar = Adw.ToolbarView()
header = Adw.HeaderBar()
header.set_title_widget(Gtk.Label(label=APP_NAME))
self._connect_btn = Gtk.Button(label="Connect")
self._connect_btn.add_css_class("suggested-action")
self._connect_btn.connect("clicked", self._on_connect_clicked)
header.pack_start(self._connect_btn)
toolbar.add_top_bar(header)
toolbar.set_content(self._main_paned)
self.set_content(toolbar)
# Stream manager
self._stream_mgr = None
# Connect window close to cleanup
self.connect("close-request", self._on_close)
log.info("Window initialized")
def _on_connect_clicked(self, button):
if self._streaming:
self._stop_stream()
else:
self._start_stream()
def _start_stream(self):
log.info("Starting stream...")
self._connect_btn.set_label("Disconnect")
self._connect_btn.remove_css_class("suggested-action")
self._connect_btn.add_css_class("destructive-action")
self._streaming = True
self._stream_mgr = StreamManager()
log.info("Session: %s", self._stream_mgr.session_id)
log.info("Session dir: %s", self._stream_mgr.session_dir)
fifo_path = self._stream_mgr.start_recorder_with_monitor()
log.info("FIFO path: %s", fifo_path)
log.info("Stream URL: %s", self._stream_mgr.stream_url)
log.info("Recorder started, waiting for sender connection...")
self._monitor.start_mpv(fifo_path)
log.info("Monitor (mpv) started")
self._stream_mgr.start_frame_extractor()
log.info("Frame extractor started")
def _stop_stream(self):
log.info("Stopping stream...")
self._monitor.stop()
log.info("Monitor stopped")
if self._stream_mgr:
self._stream_mgr.stop_all()
log.info("Stream manager stopped")
self._stream_mgr = None
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
log.info("Stream stopped, ready to reconnect")
def _on_close(self, *args):
log.info("Window closing, cleaning up...")
self._monitor.stop()
if self._stream_mgr:
self._stream_mgr.stop_all()
def _build_agent_output(self):
"""Left panel: agent output log."""
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
label = Gtk.Label(label="Agent Output")
label.add_css_class("heading")
label.set_margin_top(8)
label.set_margin_bottom(8)
box.append(label)
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)
self._agent_output_view.set_top_margin(4)
self._agent_output_view.set_bottom_margin(4)
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_right_panels(self):
right_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
top_paned = Gtk.Paned(orientation=Gtk.Orientation.HORIZONTAL)
top_paned.set_shrink_start_child(False)
top_paned.set_shrink_end_child(False)
self._monitor = MonitorWidget()
self._monitor.set_hexpand(True)
stream_frame = Gtk.Frame()
stream_frame.set_child(self._monitor)
top_paned.set_start_child(stream_frame)
self._waveform_area = self._build_panel("Waveform", height=250, width=200)
top_paned.set_end_child(self._waveform_area)
top_paned.set_position(650)
right_box.append(top_paned)
self._frames_panel = self._build_frames_panel()
right_box.append(self._frames_panel)
self._transcript_panel = self._build_transcript_panel()
right_box.append(self._transcript_panel)
self._agent_input = self._build_agent_input()
right_box.append(self._agent_input)
return right_box
def _build_panel(self, title, height=200, width=-1):
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
label = Gtk.Label(label=title)
label.add_css_class("heading")
label.set_margin_top(4)
label.set_margin_bottom(4)
box.append(label)
area = Gtk.DrawingArea()
area.set_content_height(height)
if width > 0:
area.set_content_width(width)
area.set_vexpand(False)
area.set_hexpand(True)
box.append(area)
frame = Gtk.Frame()
frame.set_child(box)
return frame
def _build_frames_panel(self):
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
label = Gtk.Label(label="Frames Extracted")
label.add_css_class("heading")
label.set_margin_top(4)
label.set_margin_bottom(4)
box.append(label)
self._frames_flow = Gtk.FlowBox()
self._frames_flow.set_orientation(Gtk.Orientation.HORIZONTAL)
self._frames_flow.set_max_children_per_line(20)
self._frames_flow.set_min_children_per_line(1)
self._frames_flow.set_selection_mode(Gtk.SelectionMode.MULTIPLE)
self._frames_flow.set_homogeneous(True)
scroll = Gtk.ScrolledWindow()
scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
scroll.set_min_content_height(120)
scroll.set_child(self._frames_flow)
box.append(scroll)
frame = Gtk.Frame()
frame.set_child(box)
return frame
def _build_transcript_panel(self):
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
label = Gtk.Label(label="Transcript")
label.add_css_class("heading")
label.set_margin_top(4)
label.set_margin_bottom(4)
box.append(label)
self._transcript_view = Gtk.TextView()
self._transcript_view.set_editable(False)
self._transcript_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
self._transcript_view.set_cursor_visible(False)
self._transcript_view.set_left_margin(8)
self._transcript_view.set_right_margin(8)
scroll = Gtk.ScrolledWindow()
scroll.set_vexpand(True)
scroll.set_min_content_height(150)
scroll.set_child(self._transcript_view)
box.append(scroll)
frame = Gtk.Frame()
frame.set_child(box)
return frame
def _build_agent_input(self):
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
box.set_margin_start(4)
box.set_margin_end(4)
box.set_margin_top(4)
box.set_margin_bottom(4)
self._input_entry = Gtk.Entry()
self._input_entry.set_hexpand(True)
self._input_entry.set_placeholder_text("Message agent... (use @ to reference frames/transcripts)")
self._input_entry.connect("activate", self._on_input_activate)
box.append(self._input_entry)
send_btn = Gtk.Button(label="Send")
send_btn.add_css_class("suggested-action")
send_btn.connect("clicked", self._on_send_clicked)
box.append(send_btn)
frame = Gtk.Frame()
frame.set_child(box)
return frame
def _on_input_activate(self, entry):
self._send_message()
def _on_send_clicked(self, button):
self._send_message()
def _send_message(self):
text = self._input_entry.get_text().strip()
if not text:
return
buf = self._agent_output_view.get_buffer()
end_iter = buf.get_end_iter()
buf.insert(end_iter, f"\n> {text}\n")
self._input_entry.set_text("")
def append_agent_output(self, text):
buf = self._agent_output_view.get_buffer()
end_iter = buf.get_end_iter()
buf.insert(end_iter, text + "\n")
def append_transcript(self, entry_id, text):
buf = self._transcript_view.get_buffer()
end_iter = buf.get_end_iter()
buf.insert(end_iter, f"[{entry_id}] {text}\n")
def add_frame_thumbnail(self, frame_id, pixbuf):
img = Gtk.Image.new_from_pixbuf(pixbuf)
overlay = Gtk.Overlay()
overlay.set_child(img)
label = Gtk.Label(label=frame_id)
label.set_halign(Gtk.Align.START)
label.set_valign(Gtk.Align.END)
label.add_css_class("caption")
overlay.add_overlay(label)
self._frames_flow.append(overlay)