diff --git a/AGENTS.md b/AGENTS.md index 23214a6..a7d5c39 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,17 +81,23 @@ Press **⌘⇧S** (macOS) / **Ctrl+Shift+S** (rebindable as `capture`) to dump t running app's state for an AI assistant to read — rung 0+1 of `F-capture`. Each press writes a timestamped pair to `~/.termherd/captures/`: -- `capture-.json` — a diffable state dump: active tab, every tab's title / - activity status / hosted sessions, the focused pane, and the focused - terminal's visible text. No vision needed. +- `capture-.json` — a diffable state dump of the whole workspace: focus, + resolved config, the sidebar, every tab with its panes (each pane's stable + handle, kind, cwd, status), and the focused terminal's visible text. No vision + needed. - `capture-.png` — the real window pixels (iced `window::screenshot`), for render / colour / glyph bugs the text dump can't show. +The dump **is** the `WorkspaceSnapshot` the MCP `snapshot` tool reports, under a +fixed full filter (`SnapshotFilter::capture()`) — one model, two readers, so a +field never means one thing on disk and another on the wire. + `` is a UTC `YYYYMMDD-HHMMSS-mmm` stamp, so the **latest capture is the highest-named pair** — an AI finds it by sorting the directory. Capture stays -pure in `core` (`Event::Capture` → `Effect::Capture(CaptureDump)`); all I/O — -the clock, JSON/PNG encoding, the files — lives in the `app` adapter -(`crates/app/src/capture.rs`). +pure in `core` (`Event::Capture(SnapshotInputs)` → +`Effect::Capture(WorkspaceSnapshot)`); all I/O — the clock, JSON/PNG encoding, +the files — lives in the `app` adapter (`crates/app/src/capture.rs`), which +shares its wire form with the MCP handler (`crates/app/src/snapshot_dto.rs`). For motion (rung 2, #124), press **⌘⇧R** / **Ctrl+Shift+R** (rebindable as `toggle-record`) to start a **GIF screencast**; press again to stop, or let it diff --git a/ROADMAP.md b/ROADMAP.md index 6c6ddfd..db0a65f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -380,8 +380,19 @@ geometry) with drag-resize split out to #55 (blocked-by #54; feature-torture opt-in; depends on #195; product-scope question open (may be cut) - [ ] `F-mcp-screenshot` (#215) — expose the window PNG as an MCP tool (async window round-trip); pixel companion to #212, depends on #212/#193 - - [ ] `F-mcp-snapshot-g1` (#216) — single-source the G1 capture dump (#108) - onto the `WorkspaceSnapshot` model; changes the dump format, depends on #212 + - [x] `F-mcp-snapshot-g1` (#216) — **one model, two readers.** The G1 dev-loop + dump (`~/.termherd/captures/capture-.json`) is now the same + `WorkspaceSnapshot` the MCP `snapshot` tool reports, under a fixed + `SnapshotFilter::capture()` (every section, the focused pane's screen kept + whole — a file pays for the full picture where a call does not). + `CaptureDump`/`CaptureTab` and `core::capture` are gone: `Event::Capture` + carries the adapter-injected `SnapshotInputs`, `Effect::Capture` the + snapshot, and one JSON wire form (`app::snapshot_dto`, extracted from the MCP + handler) serves both readers — so the dump gained config, the sidebar and + per-pane detail, and a field can no longer mean one thing on disk and another + on the wire. Breaking dump-format change (`focused_pty` → `terminals` by + handle; status words follow the MCP vocabulary, `ready` → `idle`); the + newest-stamp discovery contract is untouched. Depends on #212 - [x] `F-terminal-palette` — configurable terminal colours (#181, shipped in #183; tortured 👍, feature-torture `F-terminal-palette.md`): an optional diff --git a/crates/app/src/capture.rs b/crates/app/src/capture.rs index c7ea265..7f7afcc 100644 --- a/crates/app/src/capture.rs +++ b/crates/app/src/capture.rs @@ -1,5 +1,5 @@ //! Capture adapter — write the state dump + PNG screenshot for the AI dev loop -//! (G1). `core` builds the pure [`CaptureDump`]; this module owns the I/O +//! (G1). `core` builds the pure [`WorkspaceSnapshot`]; this module owns the I/O //! it deliberately keeps out: the clock, the JSON encoding, the PNG encoding, //! and the on-disk layout. //! @@ -14,8 +14,9 @@ use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use iced::window::Screenshot; -use serde::Serialize; -use termherd_core::CaptureDump; +use termherd_core::WorkspaceSnapshot; + +use crate::snapshot_dto::SnapshotDto; /// `~/.termherd/captures` — the capture output dir (PRD §7 app data dir). `None` /// when no home directory is set, in which case capture is skipped. @@ -59,16 +60,15 @@ fn civil_from_days(days: i64) -> (i64, u32, u32) { (if month <= 2 { year + 1 } else { year }, month, day) } -/// Encode a [`CaptureDump`] as pretty JSON. The encoding lives here, not in -/// `core`: `core` carries no serde dependency, so the dump stays a plain value -/// and the adapter owns its wire form. -pub fn to_json(dump: &CaptureDump) -> Result { - serde_json::to_string_pretty(&DumpDto::from(dump)) +/// Encode a [`WorkspaceSnapshot`] as pretty JSON — the same wire form the MCP +/// `snapshot` tool reports, so a reader learns one shape for both. +pub fn to_json(dump: &WorkspaceSnapshot) -> Result { + serde_json::to_string_pretty(&SnapshotDto::from(dump)) } /// Write the JSON dump to `dir/capture-.json`, returning the path /// written. The companion PNG shares the stamp ([`png_path`]). -pub fn write_dump(dir: &Path, stamp: &str, dump: &CaptureDump) -> io::Result { +pub fn write_dump(dir: &Path, stamp: &str, dump: &WorkspaceSnapshot) -> io::Result { let path = dir.join(format!("capture-{stamp}.json")); let json = to_json(dump).map_err(io::Error::other)?; std::fs::write(&path, json)?; @@ -94,51 +94,14 @@ pub fn write_png(path: &Path, screenshot: &Screenshot) -> io::Result<()> { .map_err(io::Error::other) } -/// On-disk JSON shape of a [`CaptureDump`]. A thin serde mirror so `core` keeps -/// no serde dependency; per-tab `focus_session` is omitted when absent. -#[derive(Serialize)] -struct DumpDto<'a> { - active_tab: Option, - tabs: Vec>, - focused_pty: Option<&'a str>, -} - -#[derive(Serialize)] -struct TabDto<'a> { - active: bool, - title: &'a str, - status: Option<&'static str>, - sessions: &'a [u64], - #[serde(skip_serializing_if = "Option::is_none")] - focus_session: Option, -} - -impl<'a> From<&'a CaptureDump> for DumpDto<'a> { - fn from(dump: &'a CaptureDump) -> Self { - DumpDto { - active_tab: dump.active_tab, - focused_pty: dump.focused_pty.as_deref(), - tabs: dump - .tabs - .iter() - .map(|tab| TabDto { - active: tab.active, - title: &tab.title, - // The word form of the status the UI only paints as a dot - // colour, so a reader of the dump needs no colour key. - status: tab.status.map(crate::strings::status_label), - sessions: &tab.sessions, - focus_session: tab.focus_session, - }) - .collect(), - } - } -} - #[cfg(test)] mod tests { use super::*; - use termherd_core::{CaptureTab, SessionStatus}; + use std::collections::BTreeMap; + use termherd_core::{ + ConfigSummary, FocusRef, PaneSnapshot, ProjectSnapshot, SessionKind, SessionStatus, + SidebarSnapshot, TabSnapshot, + }; #[test] fn stamp_formats_a_known_instant_in_utc() { @@ -157,43 +120,72 @@ mod tests { assert!(earlier < later, "{earlier} should sort before {later}"); } - fn dump() -> CaptureDump { - CaptureDump { - active_tab: Some(1), - focused_pty: Some("$ cargo test".to_owned()), - tabs: vec![ - CaptureTab { + fn dump() -> WorkspaceSnapshot { + WorkspaceSnapshot { + focus: FocusRef { + tab: Some(1), + session: Some(7), + }, + config: Some(ConfigSummary { + font_size: 14.0, + terminal_scheme: Some("gruvbox-dark".to_owned()), + record_fps: 8, + record_scale: 0.5, + keymap_overrides: 2, + }), + sidebar: Some(SidebarSnapshot { + hidden: false, + search: String::new(), + search_titles_only: false, + show_archived: false, + projects: vec![ProjectSnapshot { + path: "/proj".to_owned(), + session_count: 2, + collapsed: false, + }], + }), + tabs: Some(vec![ + TabSnapshot { active: false, title: "proj $".to_owned(), status: Some(SessionStatus::Idle), - sessions: vec![3], - focus_session: None, + panes: vec![pane(3, SessionStatus::Idle)], }, - CaptureTab { + TabSnapshot { active: true, title: "repo 🤖".to_owned(), status: Some(SessionStatus::Busy), - sessions: vec![6, 7], - focus_session: Some(7), + panes: vec![pane(6, SessionStatus::Idle), pane(7, SessionStatus::Busy)], }, - ], + ]), + terminals: BTreeMap::from([(7, "$ cargo test".to_owned())]), + } + } + + fn pane(handle: u64, status: SessionStatus) -> PaneSnapshot { + PaneSnapshot { + handle, + kind: SessionKind::Shell, + cwd: Some("/proj".to_owned()), + status, } } #[test] - fn to_json_encodes_the_dump_shape() { + fn to_json_encodes_the_whole_workspace_snapshot() { let json: serde_json::Value = serde_json::from_str(&to_json(&dump()).expect("encode")).expect("valid json"); - assert_eq!(json["active_tab"], 1); - assert_eq!(json["focused_pty"], "$ cargo test"); + // The dump is the *whole* app: focus, config, sidebar, tabs, terminals — + // the same shape (and vocabulary) the MCP `snapshot` tool reports. + assert_eq!(json["focus"]["tab"], 1); + assert_eq!(json["focus"]["session"], "7", "handles are strings"); + assert_eq!(json["config"]["terminal_scheme"], "gruvbox-dark"); + assert_eq!(json["sidebar"]["projects"][0]["path"], "/proj"); assert_eq!(json["tabs"][0]["title"], "proj $"); - // Status is stringified through `status_label`: Idle renders "ready". - assert_eq!(json["tabs"][0]["status"], "ready"); - // focus_session is omitted on the inactive tab, present on the active. - assert!(json["tabs"][0].get("focus_session").is_none()); - assert_eq!(json["tabs"][1]["status"], "busy"); - assert_eq!(json["tabs"][1]["sessions"], serde_json::json!([6, 7])); - assert_eq!(json["tabs"][1]["focus_session"], 7); + assert_eq!(json["tabs"][0]["status"], "idle"); + assert_eq!(json["tabs"][1]["panes"][1]["handle"], "7"); + assert_eq!(json["tabs"][1]["panes"][1]["status"], "busy"); + assert_eq!(json["terminals"]["7"], "$ cargo test"); } #[test] diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index c88908d..86e811a 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -19,6 +19,7 @@ mod record; mod record_config; mod settings; mod shell; +mod snapshot_dto; mod strings; mod tracing_init; mod window_config; diff --git a/crates/app/src/mcp/handler.rs b/crates/app/src/mcp/handler.rs index 1440b98..e3e84e8 100644 --- a/crates/app/src/mcp/handler.rs +++ b/crates/app/src/mcp/handler.rs @@ -9,8 +9,6 @@ use std::time::Duration; -use std::collections::BTreeMap; - use rmcp::{ ErrorData, ServerHandler, handler::server::{router::tool::ToolRouter, wrapper::Parameters}, @@ -20,9 +18,10 @@ use rmcp::{ }; use serde::{Deserialize, Serialize}; use termherd_core::workspace::SplitDir; -use termherd_core::{Section, SessionStatus, SnapshotFilter, TerminalScope, WorkspaceSnapshot}; +use termherd_core::{Section, SnapshotFilter, TerminalScope}; use crate::shell::bridge::{Action, BridgeHandle, Reply, Request, SessionInfo, SessionKind}; +use crate::snapshot_dto::{SnapshotDto, status_str}; /// How long a tool waits for the shell to answer before failing the caller. /// Bounds the whole round-trip (enqueue + reply) via [`BridgeHandle::call`], so @@ -445,144 +444,6 @@ fn parse_optional_handle(handle: Option) -> Result, ErrorDat handle.map(|h| parse_handle(&h)).transpose() } -/// The on-the-wire snapshot: `core`'s model flattened to string-typed enums an -/// MCP client reads without knowing termherd's internals. Absent sections and -/// empty terminal text are omitted, keeping a light call light. -#[derive(Serialize)] -struct SnapshotDto { - focus: FocusDto, - #[serde(skip_serializing_if = "Option::is_none")] - config: Option, - #[serde(skip_serializing_if = "Option::is_none")] - sidebar: Option, - #[serde(skip_serializing_if = "Option::is_none")] - tabs: Option>, - /// Scoped terminal text by handle (string keys in JSON). Empty when none was - /// requested. - #[serde(skip_serializing_if = "BTreeMap::is_empty")] - terminals: BTreeMap, -} - -#[derive(Serialize)] -struct FocusDto { - tab: Option, - /// Focused session handle as a string, matching `list_sessions`. - session: Option, -} - -#[derive(Serialize)] -struct ConfigDto { - font_size: f32, - terminal_scheme: Option, - record_fps: u32, - record_scale: f32, - keymap_overrides: usize, -} - -#[derive(Serialize)] -struct SidebarDto { - hidden: bool, - search: String, - search_titles_only: bool, - show_archived: bool, - projects: Vec, -} - -#[derive(Serialize)] -struct ProjectDto { - path: String, - session_count: usize, - collapsed: bool, -} - -#[derive(Serialize)] -struct TabDto { - active: bool, - title: String, - /// Most-urgent status among the tab's sessions, or `None` if none live. - status: Option<&'static str>, - panes: Vec, -} - -#[derive(Serialize)] -struct PaneDto { - /// Stable session handle as a string, matching `list_sessions` and the - /// `terminals` argument. - handle: String, - /// `"shell"` or `"claude"`. - kind: &'static str, - cwd: Option, - /// `"starting"`, `"busy"`, `"idle"`, `"attention"`, or `"exited"`. - status: &'static str, -} - -/// The stable external string for a session status — one place both DTOs read. -fn status_str(status: SessionStatus) -> &'static str { - match status { - SessionStatus::Starting => "starting", - SessionStatus::Busy => "busy", - SessionStatus::Idle => "idle", - SessionStatus::Attention => "attention", - SessionStatus::Exited => "exited", - } -} - -impl From<&WorkspaceSnapshot> for SnapshotDto { - fn from(snapshot: &WorkspaceSnapshot) -> Self { - Self { - focus: FocusDto { - tab: snapshot.focus.tab, - session: snapshot.focus.session.map(|handle| handle.to_string()), - }, - config: snapshot.config.as_ref().map(|config| ConfigDto { - font_size: config.font_size, - terminal_scheme: config.terminal_scheme.clone(), - record_fps: config.record_fps, - record_scale: config.record_scale, - keymap_overrides: config.keymap_overrides, - }), - sidebar: snapshot.sidebar.as_ref().map(|sidebar| SidebarDto { - hidden: sidebar.hidden, - search: sidebar.search.clone(), - search_titles_only: sidebar.search_titles_only, - show_archived: sidebar.show_archived, - projects: sidebar - .projects - .iter() - .map(|project| ProjectDto { - path: project.path.clone(), - session_count: project.session_count, - collapsed: project.collapsed, - }) - .collect(), - }), - tabs: snapshot.tabs.as_ref().map(|tabs| { - tabs.iter() - .map(|tab| TabDto { - active: tab.active, - title: tab.title.clone(), - status: tab.status.map(status_str), - panes: tab - .panes - .iter() - .map(|pane| PaneDto { - handle: pane.handle.to_string(), - kind: match pane.kind { - termherd_core::SessionKind::Shell => "shell", - termherd_core::SessionKind::Claude => "claude", - }, - cwd: pane.cwd.clone(), - status: status_str(pane.status), - }) - .collect(), - }) - .collect() - }), - terminals: snapshot.terminals.clone(), - } - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/app/src/shell.rs b/crates/app/src/shell.rs index 1430913..f07eb6c 100644 --- a/crates/app/src/shell.rs +++ b/crates/app/src/shell.rs @@ -1243,7 +1243,7 @@ mod key_routing { use iced::keyboard::{Key, Location, Modifiers}; use std::sync::Mutex as StdMutex; use termherd_core::ports::{PtyError, ScanError}; - use termherd_core::{Action, SelectSide, SpawnSpec}; + use termherd_core::{Action, SelectSide, SnapshotFilter, SpawnSpec}; /// A `PtyHost` double recording every write and kill; all calls succeed. #[derive(Default)] @@ -2181,19 +2181,19 @@ mod key_routing { } #[test] - fn capture_writes_a_json_dump_with_the_focused_pty_text() { - // a capture writes capture-.json with the focused tab, its - // status, and the focused terminal's visible text. Driven through the - // `perform_capture` dir seam so it lands in a tempdir, not the real home; - // the PNG is an async iced screenshot and is not exercised here. + fn capture_writes_a_json_dump_of_the_whole_workspace() { + // a capture writes capture-.json holding the whole workspace — + // focus, config, sidebar, tabs — plus the focused terminal's visible + // text. Driven through the `perform_capture` dir seam so it lands in a + // tempdir, not the real home; the PNG is an async iced screenshot and is + // not exercised here. let (mut shell, _pty) = shell_with_terminal(); let session = shell.core.workspace.focused_session().expect("focused"); shell.screens.insert(session, screen_of("$ cargo test")); - // Build the dump through the same seam `capture()` uses for PTY text. - let text = shell.focused_pty_text(); - assert_eq!(text.as_deref(), Some("$ cargo test")); - let dump = shell.core.build_capture(text); + // Build the dump through the same seams `capture()` uses. + let inputs = shell.snapshot_inputs_for(&SnapshotFilter::capture()); + let dump = shell.core.build_capture(&inputs); let dir = tempfile::tempdir().expect("tempdir"); let _ = shell.perform_capture(dir.path(), dump); @@ -2210,10 +2210,22 @@ mod key_routing { let json: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(written.path()).expect("read")) .expect("valid json"); - assert_eq!(json["active_tab"], 0); - assert_eq!(json["focused_pty"], "$ cargo test"); + assert_eq!(json["focus"]["tab"], 0); + assert_eq!(json["focus"]["session"], session.0.get().to_string()); assert_eq!(json["tabs"][0]["title"], "project $"); - assert_eq!(json["tabs"][0]["focus_session"], session.0.get()); + assert_eq!( + json["tabs"][0]["panes"][0]["handle"], + session.0.get().to_string() + ); + assert_eq!( + json["terminals"][session.0.get().to_string()], + "$ cargo test", + "the focused pane's screen rides in the dump" + ); + assert!( + json["config"].is_object(), + "a capture carries the resolved config, not just the terminal" + ); } #[test] diff --git a/crates/app/src/shell/effects.rs b/crates/app/src/shell/effects.rs index ba66c31..75a46ce 100644 --- a/crates/app/src/shell/effects.rs +++ b/crates/app/src/shell/effects.rs @@ -14,10 +14,9 @@ use std::time::SystemTime; use iced::{Task, window}; use termherd_core::{ - CaptureDump, Effect, Launch, McpConfig, Section, SessionId, SnapshotFilter, SnapshotInputs, - SpawnSpec, TerminalScope, + Effect, Launch, McpConfig, Section, SessionId, SnapshotFilter, SnapshotInputs, SpawnSpec, + TerminalScope, WorkspaceSnapshot, }; -use termherd_pty::Screen; use super::bridge::Request; use super::{Message, Shell}; @@ -114,14 +113,6 @@ impl Shell { } } - /// The focused terminal's visible grid as text, for a capture. `None` - /// when nothing is focused or its screen has not rendered yet — `core` then - /// records a focus-less dump. - pub(super) fn focused_pty_text(&self) -> Option { - let id = self.core.workspace.focused_session()?; - self.screens.get(&id).map(Screen::text) - } - /// Gather the adapter-owned inputs for a bridge request: the config summary /// and the scoped terminal text a `snapshot` needs, which the pure `core` /// cannot read (settings live here, the grid in the `pty` adapter). Only the @@ -131,6 +122,13 @@ impl Shell { let Request::Snapshot(filter) = request else { return SnapshotInputs::default(); }; + self.snapshot_inputs_for(filter) + } + + /// The adapter-owned inputs a given filter needs. Shared by the bridge + /// request above and the dev-loop capture, so both gather the config and + /// the terminal text exactly one way. + pub(super) fn snapshot_inputs_for(&self, filter: &SnapshotFilter) -> SnapshotInputs { SnapshotInputs { config: filter .includes(Section::Config) @@ -161,20 +159,19 @@ impl Shell { .collect() } - /// Capture the current state for the AI dev loop: hand `core` the focused - /// terminal's text, then perform the returned effects — the `Effect::Capture` - /// writes the JSON dump and schedules the PNG into `~/.termherd/captures/`. + /// Capture the current state for the AI dev loop: hand `core` the inputs it + /// cannot read (config, focused terminal text) under the capture filter, + /// then perform the returned effects — the `Effect::Capture` writes the JSON + /// dump and schedules the PNG into `~/.termherd/captures/`. pub(super) fn capture(&mut self) -> Task { - let focused_pty_text = self.focused_pty_text(); - let effects = self - .core - .apply(termherd_core::Event::Capture { focused_pty_text }); + let inputs = self.snapshot_inputs_for(&SnapshotFilter::capture()); + let effects = self.core.apply(termherd_core::Event::Capture(inputs)); self.perform(effects) } /// Resolve the captures dir and write the dump for an `Effect::Capture`. A /// no-op when no home directory is set. - fn capture_dump(&self, dump: CaptureDump) -> Task { + fn capture_dump(&self, dump: WorkspaceSnapshot) -> Task { let Some(dir) = crate::capture::captures_dir() else { tracing::warn!("no home directory; capture skipped"); return Task::none(); @@ -191,7 +188,7 @@ impl Shell { pub(super) fn perform_capture( &self, dir: &std::path::Path, - dump: CaptureDump, + dump: WorkspaceSnapshot, ) -> Task { if let Err(error) = std::fs::create_dir_all(dir) { tracing::warn!(%error, "could not create captures dir; capture skipped"); diff --git a/crates/app/src/shell/view/style.rs b/crates/app/src/shell/view/style.rs index 6eef179..0ba2bd2 100644 --- a/crates/app/src/shell/view/style.rs +++ b/crates/app/src/shell/view/style.rs @@ -10,8 +10,8 @@ use termherd_core::SessionStatus; /// The dot colour for an activity status (FR8). Shared by the tab strip's /// chips and the sidebar's per-session dots so both stay in sync. The colour is -/// the only place the UI shows a status; the word form -/// ([`crate::strings::status_label`]) is for the capture dump, not the screen. +/// the only place the UI shows a status; the word form an agent reads lives in +/// [`crate::snapshot_dto`], not on the screen. pub(super) fn status_color(status: SessionStatus) -> Color { match status { SessionStatus::Starting => Color::from_rgb(0.55, 0.55, 0.6), diff --git a/crates/app/src/snapshot_dto.rs b/crates/app/src/snapshot_dto.rs new file mode 100644 index 0000000..d34250f --- /dev/null +++ b/crates/app/src/snapshot_dto.rs @@ -0,0 +1,149 @@ +//! The JSON wire form of [`WorkspaceSnapshot`] — `core`'s model flattened to +//! string-typed enums a reader consumes without knowing termherd's internals. +//! +//! It lives in the `app` adapter, not in `core`: `core` carries no serde +//! dependency, so the snapshot stays a plain value and the adapter owns its wire +//! form. Absent sections and empty terminal text are omitted, keeping a light +//! read light. + +use std::collections::BTreeMap; + +use serde::Serialize; +use termherd_core::{SessionStatus, WorkspaceSnapshot}; + +/// The on-the-wire snapshot. +#[derive(Serialize)] +pub(crate) struct SnapshotDto { + focus: FocusDto, + #[serde(skip_serializing_if = "Option::is_none")] + config: Option, + #[serde(skip_serializing_if = "Option::is_none")] + sidebar: Option, + #[serde(skip_serializing_if = "Option::is_none")] + tabs: Option>, + /// Scoped terminal text by handle (string keys in JSON). Empty when none was + /// requested. + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + terminals: BTreeMap, +} + +#[derive(Serialize)] +struct FocusDto { + tab: Option, + /// Focused session handle as a string, matching `list_sessions`. + session: Option, +} + +#[derive(Serialize)] +struct ConfigDto { + font_size: f32, + terminal_scheme: Option, + record_fps: u32, + record_scale: f32, + keymap_overrides: usize, +} + +#[derive(Serialize)] +struct SidebarDto { + hidden: bool, + search: String, + search_titles_only: bool, + show_archived: bool, + projects: Vec, +} + +#[derive(Serialize)] +struct ProjectDto { + path: String, + session_count: usize, + collapsed: bool, +} + +#[derive(Serialize)] +struct TabDto { + active: bool, + title: String, + /// Most-urgent status among the tab's sessions, or `None` if none live. + status: Option<&'static str>, + panes: Vec, +} + +#[derive(Serialize)] +struct PaneDto { + /// Stable session handle as a string, matching `list_sessions` and the + /// `terminals` argument. + handle: String, + /// `"shell"` or `"claude"`. + kind: &'static str, + cwd: Option, + /// `"starting"`, `"busy"`, `"idle"`, `"attention"`, or `"exited"`. + status: &'static str, +} + +/// The stable external string for a session status — one place every DTO reads. +pub(crate) fn status_str(status: SessionStatus) -> &'static str { + match status { + SessionStatus::Starting => "starting", + SessionStatus::Busy => "busy", + SessionStatus::Idle => "idle", + SessionStatus::Attention => "attention", + SessionStatus::Exited => "exited", + } +} + +impl From<&WorkspaceSnapshot> for SnapshotDto { + fn from(snapshot: &WorkspaceSnapshot) -> Self { + Self { + focus: FocusDto { + tab: snapshot.focus.tab, + session: snapshot.focus.session.map(|handle| handle.to_string()), + }, + config: snapshot.config.as_ref().map(|config| ConfigDto { + font_size: config.font_size, + terminal_scheme: config.terminal_scheme.clone(), + record_fps: config.record_fps, + record_scale: config.record_scale, + keymap_overrides: config.keymap_overrides, + }), + sidebar: snapshot.sidebar.as_ref().map(|sidebar| SidebarDto { + hidden: sidebar.hidden, + search: sidebar.search.clone(), + search_titles_only: sidebar.search_titles_only, + show_archived: sidebar.show_archived, + projects: sidebar + .projects + .iter() + .map(|project| ProjectDto { + path: project.path.clone(), + session_count: project.session_count, + collapsed: project.collapsed, + }) + .collect(), + }), + tabs: snapshot.tabs.as_ref().map(|tabs| { + tabs.iter() + .map(|tab| TabDto { + active: tab.active, + title: tab.title.clone(), + status: tab.status.map(status_str), + panes: tab.panes.iter().map(pane_dto).collect(), + }) + .collect() + }), + terminals: snapshot.terminals.clone(), + } + } +} + +/// One pane on the wire. Free function — it reads only the pane. +fn pane_dto(pane: &termherd_core::PaneSnapshot) -> PaneDto { + PaneDto { + handle: pane.handle.to_string(), + kind: match pane.kind { + termherd_core::SessionKind::Shell => "shell", + termherd_core::SessionKind::Claude => "claude", + }, + cwd: pane.cwd.clone(), + status: status_str(pane.status), + } +} diff --git a/crates/app/src/strings.rs b/crates/app/src/strings.rs index 7a1b7f5..0d1dee0 100644 --- a/crates/app/src/strings.rs +++ b/crates/app/src/strings.rs @@ -5,8 +5,6 @@ //! user-facing literal should live in the view/shell code. Static labels are //! `const`s; strings built from runtime values are functions. -use termherd_core::SessionStatus; - // --- Sidebar --- pub const SEARCH_PLACEHOLDER: &str = "Search…"; pub const TITLES_ONLY: &str = "Titles only"; @@ -96,18 +94,3 @@ pub fn quit_prompt(live: usize) -> String { ) } } - -// --- Activity status (FR8) --- -/// The short label for a session's activity status. The UI itself only paints -/// the status as a dot colour ([`crate::shell::view`]); this word form exists -/// for the capture dump, where a colour carries nothing. -#[must_use] -pub fn status_label(status: SessionStatus) -> &'static str { - match status { - SessionStatus::Starting => "starting", - SessionStatus::Busy => "busy", - SessionStatus::Idle => "ready", - SessionStatus::Attention => "attention", - SessionStatus::Exited => "exited", - } -} diff --git a/crates/core/src/app.rs b/crates/core/src/app.rs index 28948b1..8d34776 100644 --- a/crates/core/src/app.rs +++ b/crates/core/src/app.rs @@ -263,9 +263,7 @@ impl App { Event::Zoom(zoom) => self.zoom(zoom), Event::OpenUrl(url) => Self::open_url(url), Event::SessionNotified { session, body } => self.notify_session(session, body), - Event::Capture { focused_pty_text } => { - vec![Effect::Capture(self.build_capture(focused_pty_text))] - } + Event::Capture(inputs) => vec![Effect::Capture(self.build_capture(&inputs))], Event::ToggleRecord { max_frames } => self.toggle_record(max_frames), Event::RecordTick => self.record_tick(), Event::WindowFocusChanged(focused) => { diff --git a/crates/core/src/app/capture.rs b/crates/core/src/app/capture.rs index 6139aa9..83dd836 100644 --- a/crates/core/src/app/capture.rs +++ b/crates/core/src/app/capture.rs @@ -1,52 +1,48 @@ //! The capture snapshot for the AI dev loop (`F-capture`, rung 0/1). -use crate::capture::{CaptureDump, CaptureTab}; +use crate::snapshot::{SnapshotFilter, SnapshotInputs, WorkspaceSnapshot}; use super::*; impl App { - /// Assemble the capture snapshot for the AI dev loop. Pure: it reads - /// the workspace and live-session state and folds in the focused terminal's - /// text the shell supplied (the grid lives in the `pty` adapter). The result - /// is the diffable rung-0 payload; the shell adds the rung-1 PNG. + /// Assemble the capture payload for the AI dev loop: the same + /// [`WorkspaceSnapshot`] an MCP client reads, under the fixed + /// [`SnapshotFilter::capture`] shape. The shell injects what it owns + /// (`inputs`: the resolved config, the focused pane's text) and adds the + /// rung-1 PNG; this stays the pure, diffable rung-0 payload. #[must_use] - pub fn build_capture(&self, focused_pty_text: Option) -> CaptureDump { - let active_tab = (!self.workspace.tabs.is_empty()).then_some(self.workspace.active); - let focused = self.workspace.focused_session(); - let tabs = self - .workspace - .tabs - .iter() - .enumerate() - .map(|(index, tab)| { - let active = active_tab == Some(index); - CaptureTab { - active, - title: tab.display_title().to_owned(), - status: self.tab_status(index), - sessions: tab.sessions().into_iter().map(|s| s.0.get()).collect(), - // Only the active tab has a live focus to report. - focus_session: focused.filter(|_| active).map(|s| s.0.get()), - } - }) - .collect(); - CaptureDump { - active_tab, - tabs, - focused_pty: focused_pty_text, - } + pub fn build_capture(&self, inputs: &SnapshotInputs) -> WorkspaceSnapshot { + self.snapshot(&SnapshotFilter::capture(), inputs) } } #[cfg(test)] mod tests { + use std::collections::BTreeMap; + use super::*; use crate::app::testsupport::*; + use crate::snapshot::ConfigInput; use crate::workspace::SplitDir; + /// Adapter-injected inputs carrying a config block and the focused pane's + /// text — what the shell hands in on a capture. + fn inputs(focused: SessionId, text: &str) -> SnapshotInputs { + SnapshotInputs { + config: Some(ConfigInput { + terminal_scheme: Some("gruvbox-dark".into()), + record_fps: 8, + record_scale: 0.5, + keymap_overrides: 2, + }), + terminals: BTreeMap::from([(focused.0.get(), text.to_owned())]), + } + } + #[test] - fn capture_snapshots_tabs_focus_status_and_pty_text() { + fn capture_dumps_every_section_of_the_workspace_snapshot() { let mut app = App::new(); + app.apply(Event::ScanCompleted(vec![record("s0", "/p", "work")])); let first = launch(&mut app, "proj $"); let second = launch(&mut app, "repo 🤖"); app.apply(Event::StatusChanged { @@ -54,32 +50,64 @@ mod tests { status: SessionStatus::Busy, }); - let effects = app.apply(Event::Capture { - focused_pty_text: Some("$ cargo test\nok".to_owned()), - }); - let dump = capture_dump(&effects); - - // The active tab is the last launched one, carrying its focus. - assert_eq!(dump.active_tab, Some(1)); - assert_eq!(dump.tabs.len(), 2); - assert_eq!(dump.focused_pty.as_deref(), Some("$ cargo test\nok")); - - let tab0 = &dump.tabs[0]; - assert!(!tab0.active); - assert_eq!(tab0.title, "proj $"); - assert_eq!(tab0.status, Some(SessionStatus::Starting)); - assert_eq!(tab0.sessions, vec![first.0.get()]); + let effects = app.apply(Event::Capture(inputs(second, "$ cargo test\nok"))); + let snapshot = captured_snapshot(&effects); + + // Focus: the active tab is the last launched one, carrying its session. + assert_eq!(snapshot.focus.tab, Some(1)); + assert_eq!(snapshot.focus.session, Some(second.0.get())); + // The three structural sections a capture always carries — the dump is + // the *whole* app, not just the terminal. + let config = snapshot.config.as_ref().expect("config section"); + assert_eq!(config.terminal_scheme.as_deref(), Some("gruvbox-dark")); + let sidebar = snapshot.sidebar.as_ref().expect("sidebar section"); + assert_eq!(sidebar.projects.len(), 1); + let tabs = snapshot.tabs.as_ref().expect("tabs section"); + assert_eq!(tabs.len(), 2); + assert!(!tabs[0].active); + assert_eq!(tabs[0].title, "proj $"); + assert_eq!(tabs[0].status, Some(SessionStatus::Starting)); + assert_eq!(tabs[0].panes[0].handle, first.0.get()); + assert!(tabs[1].active); + assert_eq!(tabs[1].status, Some(SessionStatus::Busy)); + } + + #[test] + fn capture_carries_the_focused_terminal_text_whole() { + let mut app = App::new(); + let session = launch(&mut app, "proj"); + let long = (1..=500) + .map(|i| format!("line {i}")) + .collect::>() + .join("\n"); + + let effects = app.apply(Event::Capture(inputs(session, &long))); + let snapshot = captured_snapshot(&effects); assert_eq!( - tab0.focus_session, None, - "only the active tab reports focus" + snapshot.terminals.get(&session.0.get()).map(String::as_str), + Some(long.as_str()), + "the dev-loop dump keeps the focused grid untruncated" ); + assert_eq!(snapshot.terminals.len(), 1, "only the focused pane"); + } + + #[test] + fn capture_ignores_text_for_panes_that_do_not_hold_focus() { + let mut app = App::new(); + let base = launch(&mut app, "proj"); + app.apply(Event::SplitFocused(SplitDir::Vertical)); + let split = app.workspace.focused_session().expect("focused split pane"); + let mut injected = inputs(split, "focused output"); + injected + .terminals + .insert(base.0.get(), "background output".to_owned()); - let tab1 = &dump.tabs[1]; - assert!(tab1.active); - assert_eq!(tab1.title, "repo 🤖"); - assert_eq!(tab1.status, Some(SessionStatus::Busy)); - assert_eq!(tab1.sessions, vec![second.0.get()]); - assert_eq!(tab1.focus_session, Some(second.0.get())); + let effects = app.apply(Event::Capture(injected)); + let snapshot = captured_snapshot(&effects); + assert_eq!( + snapshot.terminals.keys().copied().collect::>(), + vec![split.0.get()] + ); } #[test] @@ -91,25 +119,21 @@ mod tests { title: "My work".into(), }); - let effects = app.apply(Event::Capture { - focused_pty_text: None, - }); - let dump = capture_dump(&effects); + let effects = app.apply(Event::Capture(SnapshotInputs::default())); + let snapshot = captured_snapshot(&effects); // The dump must match what the user sees on the chip, or an AI reading // the state would name the tab wrong. - assert_eq!(dump.tabs[0].title, "My work"); + assert_eq!(snapshot.tabs.as_ref().expect("tabs")[0].title, "My work"); } #[test] - fn capture_on_an_empty_workspace_has_no_active_tab() { + fn capture_on_an_empty_workspace_has_no_focus_and_no_tabs() { let mut app = App::new(); - let effects = app.apply(Event::Capture { - focused_pty_text: None, - }); - let dump = capture_dump(&effects); - assert_eq!(dump.active_tab, None); - assert!(dump.tabs.is_empty()); - assert_eq!(dump.focused_pty, None); + let effects = app.apply(Event::Capture(SnapshotInputs::default())); + let snapshot = captured_snapshot(&effects); + assert_eq!(snapshot.focus, crate::snapshot::FocusRef::default()); + assert_eq!(snapshot.tabs, Some(Vec::new())); + assert!(snapshot.terminals.is_empty()); } #[test] @@ -121,12 +145,11 @@ mod tests { app.apply(Event::SplitFocused(SplitDir::Vertical)); let split = app.workspace.focused_session().expect("focused split pane"); - let effects = app.apply(Event::Capture { - focused_pty_text: None, - }); - let dump = capture_dump(&effects); - let tab = &dump.tabs[0]; - assert_eq!(tab.sessions, vec![base.0.get(), split.0.get()]); - assert_eq!(tab.focus_session, Some(split.0.get())); + let effects = app.apply(Event::Capture(SnapshotInputs::default())); + let snapshot = captured_snapshot(&effects); + let tab = &snapshot.tabs.as_ref().expect("tabs")[0]; + let handles: Vec = tab.panes.iter().map(|pane| pane.handle).collect(); + assert_eq!(handles, vec![base.0.get(), split.0.get()]); + assert_eq!(snapshot.focus.session, Some(split.0.get())); } } diff --git a/crates/core/src/app/effects.rs b/crates/core/src/app/effects.rs index 6eb7456..4793ade 100644 --- a/crates/core/src/app/effects.rs +++ b/crates/core/src/app/effects.rs @@ -6,8 +6,8 @@ use std::collections::HashSet; -use crate::capture::CaptureDump; use crate::metadata::Overlay; +use crate::snapshot::WorkspaceSnapshot; use crate::workspace::SessionId; use super::{ScrollTarget, SelectOp, SpawnSpec}; @@ -51,8 +51,10 @@ pub enum Effect { Notify { title: String, body: String }, /// Write a captured state snapshot for the AI dev loop (G1). The shell /// encodes it to `capture-.json` and takes the companion PNG; `core` - /// only builds the pure, diffable payload. - Capture(CaptureDump), + /// only builds the pure, diffable payload. It is the same + /// [`WorkspaceSnapshot`] the MCP `snapshot` tool reports — one model, two + /// readers. + Capture(WorkspaceSnapshot), /// Begin a GIF screencast: the app opens the encoder and starts its /// frame timer. `core` has already entered the recording state. StartRecording, diff --git a/crates/core/src/app/events.rs b/crates/core/src/app/events.rs index cb8735b..9be1f03 100644 --- a/crates/core/src/app/events.rs +++ b/crates/core/src/app/events.rs @@ -8,6 +8,7 @@ use std::collections::HashSet; use crate::browser::SessionRecord; use crate::metadata::Overlay; +use crate::snapshot::SnapshotInputs; use crate::workspace::{Direction, SessionId, SplitDir}; use super::{LaunchSpec, ScrollTarget, SelectOp, SessionStatus, Zoom}; @@ -146,12 +147,11 @@ pub enum Event { session: SessionId, body: String, }, - /// Capture the current state for the AI dev loop (G1). The shell - /// injects the focused terminal's visible text (the grid lives in the `pty` - /// adapter, not here); `core` assembles the rest of the dump. - Capture { - focused_pty_text: Option, - }, + /// Capture the current state for the AI dev loop (G1). The shell injects + /// the parts it owns — the resolved config and the focused terminal's + /// visible text (the grid lives in the `pty` adapter) — and `core` + /// assembles the rest of the workspace snapshot. + Capture(SnapshotInputs), /// Start or stop the GIF screencast. Starting carries the frame cap /// (`fps × max_seconds`) the app derives from settings; a no-op when the cap /// is zero. diff --git a/crates/core/src/app/testsupport.rs b/crates/core/src/app/testsupport.rs index 0dbfb4c..13311ea 100644 --- a/crates/core/src/app/testsupport.rs +++ b/crates/core/src/app/testsupport.rs @@ -3,7 +3,7 @@ use termherd_claude::digest::SessionDigest; use crate::browser::SessionRecord; -use crate::capture::CaptureDump; +use crate::snapshot::WorkspaceSnapshot; use super::*; @@ -83,9 +83,9 @@ pub(crate) fn notify_effect(effects: &[Effect]) -> Option<(&str, &str)> { /// The single `Effect::Capture` payload a `Capture` event should produce. /// Panics on any other effect shape so a regression fails loudly. -pub(crate) fn capture_dump(effects: &[Effect]) -> &CaptureDump { +pub(crate) fn captured_snapshot(effects: &[Effect]) -> &WorkspaceSnapshot { match effects { - [Effect::Capture(dump)] => dump, + [Effect::Capture(snapshot)] => snapshot, other => panic!("expected one Capture effect, got {other:?}"), } } diff --git a/crates/core/src/capture.rs b/crates/core/src/capture.rs deleted file mode 100644 index 64bd4e7..0000000 --- a/crates/core/src/capture.rs +++ /dev/null @@ -1,42 +0,0 @@ -//! Capture — a pure snapshot of the workspace for the AI dev loop (G1). -//! -//! Rung 0 of the `F-capture` fidelity ladder: a deterministic, diffable model -//! of the current state — tabs, focus, per-tab activity, pane membership — plus -//! the focused terminal's visible text (injected by the shell, since the grid -//! lives in the `pty` adapter, not here). The shell encodes this to JSON and -//! writes it next to the rung-1 PNG. **Pure**: no I/O, no clock, no panic. - -use crate::app::SessionStatus; - -/// A snapshot of the whole workspace at capture time. The shell serialises it -/// to `capture-.json`; rung 1 (the PNG) captures the pixels this can't. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CaptureDump { - /// Index of the active tab, or `None` when no tab is open. - pub active_tab: Option, - /// Every open tab, in tab order. - pub tabs: Vec, - /// The focused terminal's visible grid as text, injected by the shell. - /// `None` when nothing is focused or its screen has not rendered yet. - pub focused_pty: Option, -} - -/// One tab in a [`CaptureDump`]: its label, derived activity, the sessions it -/// hosts (pane membership, left-to-right), and — for the active tab only — which -/// of them holds focus. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CaptureTab { - /// Whether this is the active tab. - pub active: bool, - /// The tab label the user sees. - pub title: String, - /// The most urgent activity among the tab's sessions, or `None` if none of - /// them are still live. - pub status: Option, - /// Runtime ids of the sessions this tab hosts, in pane order. One id for a - /// plain tab; several for a split. - pub sessions: Vec, - /// The focused leaf's session id — only set on the active tab, `None` - /// elsewhere (an inactive tab has no live focus to report). - pub focus_session: Option, -} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index fb3fcf2..de2a29e 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -5,7 +5,6 @@ pub mod app; pub mod browser; -pub mod capture; pub mod docscope; pub mod keymap; pub mod links; @@ -20,7 +19,6 @@ pub use app::{ ScrollTarget, SelectOp, SelectSide, SessionStatus, SidebarFold, SpawnSpec, Zoom, }; pub use browser::{ProjectGroup, SessionRecord}; -pub use capture::{CaptureDump, CaptureTab}; pub use keymap::{Action, ActionBinding, ChordError, KeyChord, Keymap, action_catalog}; pub use metadata::{Overlay, RepoMeta, SessionMeta}; pub use record::Recording; diff --git a/crates/core/src/snapshot.rs b/crates/core/src/snapshot.rs index de3db99..d91af4b 100644 --- a/crates/core/src/snapshot.rs +++ b/crates/core/src/snapshot.rs @@ -72,6 +72,23 @@ impl Default for SnapshotFilter { } impl SnapshotFilter { + /// The fixed filter behind a dev-loop capture: every structural section + /// plus the focused pane's screen, kept whole. A capture is written to a + /// file, not streamed into a call, so it pays for the full picture — but it + /// stays scoped to the focused pane, which is the one the developer is + /// looking at when they press the key. + #[must_use] + pub fn capture() -> Self { + Self { + terminals: TerminalScope::Focused, + // No truncation: the injected text is already just the visible + // grid, and a capture that silently dropped its head would defeat + // the point of a diffable dump. + text_lines: usize::MAX, + ..Self::default() + } + } + /// Whether `section` was requested. #[must_use] pub fn includes(&self, section: Section) -> bool { @@ -246,6 +263,23 @@ mod tests { assert_eq!(filter.text_lines, DEFAULT_TEXT_LINES); } + #[test] + fn capture_filter_is_every_section_plus_the_focused_terminal_whole() { + let filter = SnapshotFilter::capture(); + assert!(filter.includes(Section::Config)); + assert!(filter.includes(Section::Sidebar)); + assert!(filter.includes(Section::Tabs)); + assert_eq!(filter.terminals, TerminalScope::Focused); + // The dev-loop dump keeps the focused grid whole — a truncation here + // would silently drop lines the previous dump format carried. + assert_eq!( + tail_lines(&"x\n".repeat(10_000), filter.text_lines) + .lines() + .count(), + 10_000 + ); + } + #[test] fn includes_reflects_the_requested_sections() { let filter = SnapshotFilter { diff --git a/docs/notes/issue-108-capture.md b/docs/notes/issue-108-capture.md index 8618485..6b8fb3f 100644 --- a/docs/notes/issue-108-capture.md +++ b/docs/notes/issue-108-capture.md @@ -11,8 +11,8 @@ Press **⌘⇧S** (macOS) / **Ctrl+Shift+S** elsewhere — rebindable in `~/.termherd/captures/`: - `capture-.json` — a deterministic, *diffable* state dump. No vision - needed: tabs, focus, per-tab activity, pane membership, and the focused - terminal's visible text. + needed: focus, resolved config, the sidebar, every tab with its panes, and the + focused terminal's visible text. - `capture-.png` — the real window pixels (iced `window::screenshot`), for render / colour / glyph bugs the text dump can't show. @@ -23,15 +23,17 @@ highest filename** — an AI finds the latest by sorting the directory. ```text ⌘⇧S ──> Shell::capture() - │ reads focused pane's visible grid as text + │ gathers the adapter-owned inputs: resolved config + │ + the focused pane's visible grid as text ▼ ┌───────────────────────────────────────────────┐ │ core (pure: no I/O, no clock, no panic) │ │ │ - │ Event::Capture { focused_pty_text } │ + │ Event::Capture(SnapshotInputs) │ │ │ │ │ ▼ App::build_capture() │ - │ Effect::Capture(CaptureDump) ──────────────────┼──┐ + │ = App::snapshot(capture filter) │ + │ Effect::Capture(WorkspaceSnapshot) ────────────┼──┐ └─────────────────────────────────────────────────┘ │ ▼ app adapter (crates/app/src/capture.rs) @@ -53,66 +55,89 @@ fire-and-forget loop can't return. ## The `core` model -`App::build_capture` folds the workspace, the live-session statuses, and the -shell-injected PTY text into one pure value (the grid itself lives in the `pty` -adapter, so the shell hands it in via the event): +The dump **is** the `WorkspaceSnapshot` the MCP `snapshot` tool reports — one +model, two readers, so a field can never mean one thing on disk and another on +the wire. A capture just fixes the filter (`SnapshotFilter::capture()`): every +structural section, plus the focused pane's screen kept whole (a file pays for +the full picture where a streamed call must not). + +`App::build_capture` is therefore a call to the snapshot builder. The parts the +pure core cannot read — the resolved config (settings live in `app`) and the +terminal text (the grid lives in `pty`) — ride in on the event as +`SnapshotInputs`: ```text -CaptureDump -├─ active_tab : Option // None when no tab is open -├─ tabs : Vec -│ ├─ active : bool -│ ├─ title : String -│ ├─ status : Option // most-urgent of the tab's sessions -│ ├─ sessions : Vec // pane leaves, left→right -│ └─ focus_session : Option // set ONLY on the active tab -└─ focused_pty : Option // focused terminal's visible text +WorkspaceSnapshot +├─ focus // always present +│ ├─ tab : Option // None when no tab is open +│ └─ session : Option // focused pane's stable handle +├─ config : Option // font size, scheme, record budget, … +├─ sidebar : Option // filter knobs + one row per project +├─ tabs : Option> +│ ├─ active : bool +│ ├─ title : String +│ ├─ status : Option // most-urgent of the tab's sessions +│ └─ panes : Vec // left→right: handle, kind, cwd, status +└─ terminals : BTreeMap // scoped text by handle (here: focused) ``` ## Example dumps -Two tabs; the active one is a split with pane `3` focused (`focus_session` is -emitted only for the active tab): +Two tabs; the active one is a split with pane `3` focused: ```json { - "active_tab": 1, + "focus": { "tab": 1, "session": "3" }, + "config": { "font_size": 14.0, "terminal_scheme": "gruvbox-dark", + "record_fps": 8, "record_scale": 0.5, "keymap_overrides": 0 }, + "sidebar": { "hidden": false, "search": "", "search_titles_only": false, + "show_archived": false, + "projects": [ { "path": "/Users/me/dev/termherd", + "session_count": 4, "collapsed": false } ] }, "tabs": [ - { "active": false, "title": "termherd $", "status": "ready", "sessions": [1] }, - { - "active": true, - "title": "termherd 🤖", - "status": "busy", - "sessions": [2, 3], - "focus_session": 3 - } + { "active": false, "title": "termherd $", "status": "idle", + "panes": [ { "handle": "1", "kind": "shell", + "cwd": "/Users/me/dev/termherd", "status": "idle" } ] }, + { "active": true, "title": "termherd 🤖", "status": "busy", + "panes": [ { "handle": "2", "kind": "claude", + "cwd": "/Users/me/dev/termherd", "status": "idle" }, + { "handle": "3", "kind": "shell", + "cwd": "/Users/me/dev/termherd", "status": "busy" } ] } ], - "focused_pty": "$ cargo test\n Compiling termherd-core\ntest result: ok. 146 passed" + "terminals": { + "3": "$ cargo test\n Compiling termherd-core\ntest result: ok. 146 passed" + } } ``` -Empty workspace (nothing launched yet): +Empty workspace (nothing launched yet) — absent sections and an empty +`terminals` map are omitted entirely: ```json -{ "active_tab": null, "tabs": [], "focused_pty": null } +{ "focus": { "tab": null, "session": null }, "config": { … }, + "sidebar": { … }, "tabs": [] } ``` Field rules: -- `status` is the UI badge vocabulary (`strings::status_label`, so the dump - reads the same as the PNG): `starting` / `busy` / `ready` / `attention` / - `exited` (the most urgent among a tab's sessions). -- `sessions` are the tab's panes left to right — one id for a plain tab, several - for a split. -- `focused_pty` is the focused terminal's visible text (`\n`-joined rows, - trailing blanks trimmed), or `null` when nothing is focused. +- **Handles are strings** (`"3"`), everywhere, matching the MCP `list_sessions` + and `snapshot` surface. The tab index in `focus` stays a number. +- `status` is the agent-facing vocabulary shared with MCP: `starting` / `busy` / + `idle` / `attention` / `exited`. (It used to be the UI badge wording, where + `Idle` read `ready`; single-sourcing the model retired that second vocabulary.) +- `panes` are the tab's leaves left to right — one for a plain tab, several for + a split — each with its stable handle, `shell`/`claude` kind, cwd and status. +- `terminals` is keyed by handle and holds only the **focused** pane's visible + text (`\n`-joined rows, trailing blanks trimmed), kept whole. It is absent + when nothing is focused. ## Design decisions - **JSON encoding lives in `app`, not `core`.** `core` carries no serde dependency and the issue forbids new deps, so the `Effect` carries the - structured `CaptureDump` and the adapter owns the wire form. Pathing (the - timestamp, the home dir) is likewise an `app` concern — so `app`, not `core`, + structured snapshot and the adapter owns the wire form — one shared mirror + (`crates/app/src/snapshot_dto.rs`) for both the dump and the MCP tool. Pathing + (the timestamp, the home dir) is likewise an `app` concern — so `app`, not `core`, names the files. The issue's "Effect carrying the target path" became "`app` owns paths" because a path needs the clock + home dir that `core` must not touch. @@ -121,27 +146,30 @@ Field rules: deliberately left to the pixel rung. Different bug classes, one shared keybind. -## Tests (9) +## Tests -- **core (3):** dump snapshots tabs/focus/status/pty-text; empty-workspace - dump; split pane membership in order; keymap `mod+shift+s` ↔ `capture`. -- **app (6):** `stamp` formats a known UTC instant and sorts chronologically; - JSON shape (incl. `focus_session` omitted on the inactive tab); `write_dump`; - `write_png` round-trips dimensions; shell-level capture writes the JSON with - the focused PTY text (driven through a dir seam — env mutation is `unsafe` in - edition 2024, which the crate denies). +- **core:** the dump carries every section; the focused text rides whole; a + non-focused pane's injected text stays out; a tab's custom title wins over its + derived one; empty workspace; split pane order; the capture filter itself; + keymap `mod+shift+s` ↔ `capture`. +- **app:** `stamp` formats a known UTC instant and sorts chronologically; the + JSON is the MCP shape (string handles, `idle`); `write_dump`; `write_png` + round-trips dimensions; shell-level capture writes the whole-workspace JSON + (driven through a dir seam — env mutation is `unsafe` in edition 2024, which + the crate denies). Not exercised headless: the actual PNG, which needs a live iced window — verify by running the app. ## Files -- `crates/core/src/capture.rs` — `CaptureDump` / `CaptureTab`. -- `crates/core/src/app.rs` — `Event::Capture`, `Effect::Capture`, - `App::build_capture`. +- `crates/core/src/snapshot.rs` — the shared model and `SnapshotFilter::capture`. +- `crates/core/src/app.rs` — `Event::Capture`, `Effect::Capture`. +- `crates/core/src/app/capture.rs` — `App::build_capture`. - `crates/core/src/keymap.rs` — `Action::Capture`, default `mod+shift+s`. -- `crates/app/src/capture.rs` — stamp, JSON encode (status via the shared UI - `strings::status_label`), PNG encode, output dir. +- `crates/app/src/capture.rs` — stamp, JSON encode, PNG encode, output dir. +- `crates/app/src/snapshot_dto.rs` — the JSON wire form, shared with the MCP + handler. - `crates/app/src/paths.rs` — shared `home_dir` / `termherd_dir` the stores resolve through (one `~/.termherd` resolver, not seven). - `crates/app/src/shell.rs` — `Shell::capture` / `perform_capture`, the