diff --git a/crates/lumbridge-storage/src/lib.rs b/crates/lumbridge-storage/src/lib.rs index 5044586..c52d13e 100644 --- a/crates/lumbridge-storage/src/lib.rs +++ b/crates/lumbridge-storage/src/lib.rs @@ -75,6 +75,12 @@ CREATE TABLE IF NOT EXISTS workspace_buzz_channels ( channel_name TEXT NOT NULL, PRIMARY KEY (workspace_id, account_id, channel_id) ) STRICT; + +CREATE TABLE IF NOT EXISTS workspace_snapshots ( + workspace_id TEXT PRIMARY KEY NOT NULL, + snapshot_json TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +) STRICT; "; #[derive(Debug)] @@ -222,6 +228,44 @@ impl Store { .transpose() } + /// Save a credential-free UI snapshot for a workspace. + /// + /// The caller owns the versioned JSON shape. This storage boundary treats + /// it as opaque local state and must never receive secrets or transcripts. + /// + /// # Errors + /// + /// Returns an error when `SQLite` cannot insert or update the snapshot. + pub fn save_workspace_snapshot(&self, workspace_id: &str, snapshot_json: &str) -> Result<()> { + self.connection.execute( + r" + INSERT INTO workspace_snapshots (workspace_id, snapshot_json) + VALUES (?1, ?2) + ON CONFLICT(workspace_id) DO UPDATE SET + snapshot_json = excluded.snapshot_json, + updated_at = CURRENT_TIMESTAMP + ", + params![workspace_id, snapshot_json], + )?; + Ok(()) + } + + /// Load the last credential-free UI snapshot for a workspace. + /// + /// # Errors + /// + /// Returns an error when `SQLite` cannot read the snapshot. + pub fn workspace_snapshot(&self, workspace_id: &str) -> Result> { + self.connection + .query_row( + "SELECT snapshot_json FROM workspace_snapshots WHERE workspace_id = ?1", + [workspace_id], + |row| row.get(0), + ) + .optional() + .map_err(StorageError::from) + } + #[cfg(test)] fn contains_column(&self, table: &str, column: &str) -> Result { let mut statement = self @@ -281,4 +325,23 @@ mod tests { ); } } + + #[test] + fn workspace_snapshot_round_trips_as_opaque_local_state() { + let store = Store::open_in_memory().expect("store should initialize"); + assert_eq!(store.workspace_snapshot("lumbridge-code").unwrap(), None); + store + .save_workspace_snapshot( + "lumbridge-code", + r#"{"selected":7,"panels":[{"id":7,"kind":"terminal"}]}"#, + ) + .unwrap(); + assert_eq!( + store + .workspace_snapshot("lumbridge-code") + .unwrap() + .as_deref(), + Some(r#"{"selected":7,"panels":[{"id":7,"kind":"terminal"}]}"#) + ); + } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 220b6c9..c4ec46e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -95,7 +95,12 @@ Panel attachment is independent from session lifetime. Detaching removes a pane from the layout tree but retains its definition in the workspace's detached registry and leaves its runtime-owned process alive. Reattaching restores that identity without a launch. Terminating an attached or detached session is a -separate Execute-capability operation. See decision 0010. +separate Execute-capability operation. The GPUI slice now layers an unbounded +panel registry above the six-surface comparison fixture. New Terminal, Browser, +Markdown, and Review panels receive monotonic local IDs, are inserted beside +the selection, and persist their identity, order, selection, and attachment in +a credential-free SQLite snapshot. Each Terminal panel is keyed into the +runtime registry by that durable panel ID. See decisions 0010 and 0011. We should evaluate, not blindly copy, WezTerm, Zellij, RMUX, tmux, and cmux. The first spike must compare a reusable terminal crate with a small first-party layer. diff --git a/docs/BUZZ_INTEGRATION.md b/docs/BUZZ_INTEGRATION.md index eaed1f3..97b6f6a 100644 --- a/docs/BUZZ_INTEGRATION.md +++ b/docs/BUZZ_INTEGRATION.md @@ -6,6 +6,10 @@ Buzz is Lumbridge's first-class collaboration surface, not its control plane. Lumbridge remains useful offline, owns its local workspace state, and never requires a Buzz account to open a terminal or run an agent. +Lumbridge Code engineering uses the canonical `lumbridgecode` channel on the +cloud-1 Buzz community. Buzz is the collaboration product; Lumbridge does not +refer to or depend on Slack. + The initial integration provides: - connect an existing Buzz identity using an OS credential-store reference; diff --git a/docs/TESTING.md b/docs/TESTING.md index 6bd0617..06d60c3 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -124,9 +124,12 @@ who already have the final cargo-watch release installed. - The GPUI slice tests its key-event adapter, responsive one/three/five-panel geometry, selected-panel windowing, per-panel 20/60/20 PTY sizing, xterm 256-color conversion, styled-run coalescing, cursor-run boundaries, and - scrollback shortcut routing. It renders three independently actor-owned VT - sessions while three non-terminal surfaces continue their deterministic - background workload. + scrollback shortcut routing. Its panel registry tests monotonic identity, + insert-beside-selection, detach/reattach identity preservation, snapshot + validation, SQLite round-trip, and refusal to reuse a restored ID. It renders + an independent actor-owned VT session for every Terminal panel while the + original three non-terminal surfaces continue their deterministic background + workload. - `lumbridge-terminal` retains 10,000 history lines by default and tests framework-neutral page/top/bottom viewport movement, revision changes, and live-bottom no-ops without sending history-navigation bytes to the child PTY. diff --git a/docs/UI_SPIKE_SCORECARD.md b/docs/UI_SPIKE_SCORECARD.md index 9af95b2..cd37a32 100644 --- a/docs/UI_SPIKE_SCORECARD.md +++ b/docs/UI_SPIKE_SCORECARD.md @@ -90,10 +90,13 @@ fallback. ## Measurement semantics Both renderers retain the same all-deterministic six-surface action stream for -comparison. GPUI now presents one, three, or five vertical panels, each with its -own 20/60/20 context/work/decision composition, while all three terminal -fixtures are independent actor-owned VT sessions and the three non-terminal -surfaces keep deterministic updates running. Counters +comparison. GPUI now layers an unbounded, SQLite-snapshotted panel registry over +that benchmark and presents one, three, or five vertical panels, each with its +own 20/60/20 context/work/decision composition. The Add Panel chooser creates +Terminal, Browser, Markdown, and Review panels beside the selection or +reattaches an existing session. Every Terminal panel receives an independent +actor-owned VT session; the three seed non-terminal surfaces keep deterministic +updates running. Counters separate external PTY batches/lines from total model updates. The GPUI footer reports dispatch-to-element-build p50/p95 over a bounded 256-sample window. It is deliberately not called key-to-present or frame-present latency: neither diff --git a/docs/UX_VERTICAL_SLICE.md b/docs/UX_VERTICAL_SLICE.md index 54713c9..897203d 100644 --- a/docs/UX_VERTICAL_SLICE.md +++ b/docs/UX_VERTICAL_SLICE.md @@ -20,9 +20,12 @@ decision-shelf actions, usage-detail provenance, and end-to-end platform accessibility/IME. Panel lifecycle is now visible in the slice: every panel has a Detach action, -the workspace bar has Add panel, and the sidebar exposes Detached sessions for -reattachment. The wording is deliberate because panel removal keeps its session -alive; termination is not exposed as a visually equivalent close action. +the workspace bar opens an Add Panel chooser for Terminal, Browser, Markdown, +and Review, and the sidebar and chooser expose Detached sessions for +reattachment. Created panels are inserted beside the selection and receive a +monotonic identity persisted with their ordering and attachment state in local +SQLite. The wording is deliberate because panel removal keeps its session alive; +termination is not exposed as a visually equivalent close action. Screenshots for the audit are stored outside Git under `~/shots/2026-08/lumbridge-ui-audit/`. Accessibility and IME correctness cannot @@ -87,7 +90,7 @@ surface rather than a hosted Lumbridge control plane. leaving ordinary terminal arrows and text available to the PTY. - `Alt+1` through `Alt+6`: focus a pane directly while leaving terminal digits available to the PTY. -- `Alt+Shift+N`: add or reattach the next panel in the deterministic slice. +- `Alt+Shift+N`: open the Add Panel chooser; `1`–`4` create a typed panel. - `Alt+Shift+W`: detach the selected panel while its session keeps running. - `Cmd+K` on macOS or `Ctrl+K` on Linux: open the command palette. - `Shift+PageUp/PageDown`: move the selected live terminal through retained @@ -98,8 +101,9 @@ surface rather than a hosted Lumbridge control plane. separate explicit action and is never triggered by focusing the pane. - `Escape` closes transient UI before it changes workspace state. -Every command is represented by the shared interaction model so GPUI and Floem -receive the same state transitions and tests. +The six seed surfaces still replay through the shared interaction model so GPUI +and Floem retain a comparable benchmark. Dynamic product panels use the GPUI +panel registry until that contract moves into the framework-neutral UI crate. ## Decisive workload diff --git a/docs/decisions/0009-responsive-ultrawide-work-lanes.md b/docs/decisions/0009-responsive-ultrawide-work-lanes.md index 6f5ee8e..ada05d3 100644 --- a/docs/decisions/0009-responsive-ultrawide-work-lanes.md +++ b/docs/decisions/0009-responsive-ultrawide-work-lanes.md @@ -22,6 +22,7 @@ derives approximately 71 columns by 42 rows per panel. The runtime slice now owns three independent real PTYs through a pane-indexed session registry, one for every terminal fixture. The Markdown, browser, and -review fixtures remain deterministic comparison surfaces. Adding arbitrary new -terminal panels requires replacing the fixed fixture identity pool with dynamic -pane creation; it does not require another runtime ownership design. +review fixtures remain deterministic comparison surfaces. The GPUI product +slice now adds arbitrary panels through a persistent dynamic identity layer; +each additional Terminal panel is another entry in the same runtime ownership +design. The fixed fixture remains only as the GPUI/Floem benchmark input. diff --git a/docs/decisions/0010-panel-lifecycle-detach-before-terminate.md b/docs/decisions/0010-panel-lifecycle-detach-before-terminate.md index a477adb..e953996 100644 --- a/docs/decisions/0010-panel-lifecycle-detach-before-terminate.md +++ b/docs/decisions/0010-panel-lifecycle-detach-before-terminate.md @@ -15,12 +15,11 @@ confirmation that names the process, target machine, and any dirty or waiting state. Detaching the selected panel chooses its next attached sibling, falling back to the previous sibling. A workspace retains at least one attached panel. -The add affordance has two product paths: quick activation creates the user's -default panel beside the selection, while its menu offers Terminal, Browser, -Markdown, Review, and Reattach running session. The deterministic UI spike uses -six fixed fixtures, starts with five attached, and reattaches the first detached -fixture when Add panel is activated. That fixture limit is test scaffolding, not -a product limit. +The add affordance opens a chooser for Terminal, Browser, Markdown, Review, and +Reattach running session. The deterministic comparison model still uses six +seed fixtures and starts with five attached. The GPUI slice no longer inherits +that fixture limit: Add Panel creates a new persistent panel identity beside the +selection or reattaches the exact detached identity without relaunching it. The typed command plane retains detached pane definitions separately from the layout tree. `ClosePane(Detach)` moves a definition into that registry, diff --git a/docs/decisions/0011-dynamic-panels-have-persistent-local-identities.md b/docs/decisions/0011-dynamic-panels-have-persistent-local-identities.md new file mode 100644 index 0000000..46d2a52 --- /dev/null +++ b/docs/decisions/0011-dynamic-panels-have-persistent-local-identities.md @@ -0,0 +1,23 @@ +# 0011: Dynamic panels have persistent local identities + +Status: accepted for the GPUI product slice. + +The six-surface fixture remains a deterministic benchmark shared with Floem; it +is not the product's panel database. GPUI layers an unbounded workspace registry +above it. Add Panel creates Terminal, Browser, Markdown, or Review beside the +selected panel. Each new panel receives a monotonically increasing `PanelId` +that is never derived from its title, position, surface, process ID, or visible +index. + +The registry persists panel identity, kind, title, target, order, attachment, +selection, and the next unused ID as one versioned, credential-free SQLite +snapshot. Invalid snapshots fail closed to the seed layout rather than partially +restoring aliases. SQLite never stores terminal output, credentials, private +keys, subscription tokens, or Buzz identity secrets through this boundary. + +Every Terminal panel uses its `PanelId` as the runtime-registry key. Detaching +changes only layout attachment, so the actor and PTY keep running. Reattaching +selects the same ID. A restored in-process spike must currently launch a new PTY +for that persisted panel because actor durability across UI process restarts +requires the planned separate Lumbridge runtime and authenticated IPC; the UI +does not pretend otherwise. diff --git a/spikes/gpui-shell/Cargo.lock b/spikes/gpui-shell/Cargo.lock index ccf07d7..084275d 100644 --- a/spikes/gpui-shell/Cargo.lock +++ b/spikes/gpui-shell/Cargo.lock @@ -1616,6 +1616,18 @@ dependencies = [ "zune-inflate", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "1.9.0" @@ -2961,6 +2973,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -3013,6 +3036,13 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lumbridge-core" +version = "0.0.1" +dependencies = [ + "thiserror 2.0.20", +] + [[package]] name = "lumbridge-pty" version = "0.0.1" @@ -3037,13 +3067,24 @@ dependencies = [ "gpui", "lumbridge-runtime", "lumbridge-spike-model", + "lumbridge-storage", "lumbridge-terminal", + "serde", + "serde_json", ] [[package]] name = "lumbridge-spike-model" version = "0.0.1" +[[package]] +name = "lumbridge-storage" +version = "0.0.1" +dependencies = [ + "lumbridge-core", + "rusqlite", +] + [[package]] name = "lumbridge-terminal" version = "0.0.1" @@ -4464,6 +4505,19 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]] +name = "rusqlite" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +dependencies = [ + "bitflags 2.13.1", + "fallible-iterator", + "fallible-streaming-iterator", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rust-embed" version = "8.12.0" @@ -6028,6 +6082,12 @@ dependencies = [ "sval_serde", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" diff --git a/spikes/gpui-shell/Cargo.toml b/spikes/gpui-shell/Cargo.toml index a529d11..205b8dc 100644 --- a/spikes/gpui-shell/Cargo.toml +++ b/spikes/gpui-shell/Cargo.toml @@ -11,6 +11,9 @@ publish = false gpui = "0.2.2" lumbridge-runtime = { path = "../../crates/lumbridge-runtime" } lumbridge-spike-model = { path = "../ui-shell-model" } +lumbridge-storage = { path = "../../crates/lumbridge-storage" } lumbridge-terminal = { path = "../../crates/lumbridge-terminal" } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.149" [workspace] diff --git a/spikes/gpui-shell/src/main.rs b/spikes/gpui-shell/src/main.rs index 79cc6f4..5ec7159 100644 --- a/spikes/gpui-shell/src/main.rs +++ b/spikes/gpui-shell/src/main.rs @@ -1,4 +1,7 @@ +mod panel_registry; + use std::collections::{BTreeMap, VecDeque}; +use std::path::PathBuf; use std::time::{Duration, Instant}; use gpui::{ @@ -10,15 +13,18 @@ use lumbridge_runtime::{ RuntimeEvent, RuntimeRegistry, RuntimeRegistryError, TerminalSize, }; use lumbridge_spike_model::{ - ActionOutcome, FOOTER_RIGHT, FocusDirection, INITIAL_ATTACHED_PANEL_COUNT, OutputSource, - PaneId, PaneState, ShellAction, ShellModel, SurfaceKind, WORKSPACES, + ActionOutcome, FOOTER_RIGHT, OutputSource, PaneId as FixturePaneId, PaneStatus, ShellAction, + ShellModel, SurfaceKind, WORKSPACES, }; +use lumbridge_storage::Store; use lumbridge_terminal::{ KeyModifiers, TerminalCellStyle, TerminalColor, TerminalCursorShape, TerminalDimensions, TerminalEngine, TerminalEngineOptions, TerminalKey, TerminalKeyEvent, TerminalNamedColor, TerminalScroll, TerminalSnapshot, }; +use panel_registry::{PanelId, PanelKind, PanelRegistry, SeedPane}; + const BG: u32 = 0x090c12; const PANEL: u32 = 0x101620; const PANEL_ALT: u32 = 0x151d29; @@ -33,7 +39,7 @@ const SUCCESS: u32 = 0x70d6a8; const TIMING_SAMPLE_LIMIT: usize = 256; const RUNTIME_POLL_INTERVAL: Duration = Duration::from_millis(16); const RUNTIME_DRAIN_LIMIT: usize = 64; -const LIVE_PANES: [PaneId; 3] = [PaneId::CodexRuntime, PaneId::ClaudeUi, PaneId::PiDocs]; +const WORKSPACE_SNAPSHOT_ID: &str = "lumbridge-code-gpui-spike"; const SIDEBAR_WIDTH: f32 = 248.0; const APP_HEADER_HEIGHT: f32 = 48.0; const TAB_BAR_HEIGHT: f32 = 38.0; @@ -72,12 +78,34 @@ actions!( struct LumbridgeShell { model: ShellModel, + panels: PanelRegistry, timing: RenderTiming, - runtimes: RuntimeRegistry, - live_terminals: BTreeMap, + runtimes: RuntimeRegistry, + live_terminals: BTreeMap, + store: Option, + persistence_status: String, + add_panel_chooser_open: bool, root_focus: FocusHandle, } +#[derive(Clone)] +struct PanelView { + id: PanelId, + kind: SurfaceKind, + title: String, + badge: String, + target: String, + status: PaneStatus, + lines: Vec, + output_source: OutputSource, +} + +impl PanelView { + const fn needs_input(&self) -> bool { + matches!(self.status, PaneStatus::NeedsInput) + } +} + struct LiveTerminalState { status: LiveRuntimeStatus, terminal: TerminalEngine, @@ -386,6 +414,67 @@ impl RenderTiming { } } +fn workspace_database_path() -> Option { + if let Some(path) = std::env::var_os("LUMBRIDGE_SPIKE_DB") { + return Some(PathBuf::from(path)); + } + if let Some(root) = std::env::var_os("XDG_DATA_HOME") { + return Some(PathBuf::from(root).join("lumbridge/lumbridge.db")); + } + std::env::var_os("HOME") + .map(PathBuf::from) + .map(|home| home.join(".local/share/lumbridge/lumbridge.db")) +} + +fn load_panel_registry() -> (Option, PanelRegistry, String) { + let Some(path) = workspace_database_path() else { + return ( + None, + PanelRegistry::seeded(), + "layout memory-only · no data directory".to_owned(), + ); + }; + if let Some(parent) = path.parent() + && let Err(error) = std::fs::create_dir_all(parent) + { + return ( + None, + PanelRegistry::seeded(), + format!("layout memory-only · {error}"), + ); + } + let store = match Store::open(&path) { + Ok(store) => store, + Err(error) => { + return ( + None, + PanelRegistry::seeded(), + format!("layout memory-only · {error}"), + ); + } + }; + match store.workspace_snapshot(WORKSPACE_SNAPSHOT_ID) { + Ok(Some(json)) => match PanelRegistry::from_json(&json) { + Ok(panels) => (Some(store), panels, "layout restored · SQLite".to_owned()), + Err(error) => ( + Some(store), + PanelRegistry::seeded(), + format!("invalid layout ignored · {error}"), + ), + }, + Ok(None) => ( + Some(store), + PanelRegistry::seeded(), + "layout ready · SQLite".to_owned(), + ), + Err(error) => ( + Some(store), + PanelRegistry::seeded(), + format!("layout read failed · {error}"), + ), + } +} + impl LumbridgeShell { fn new(window: &mut Window, cx: &mut Context) -> Self { let root_focus = cx.focus_handle(); @@ -426,37 +515,69 @@ impl LumbridgeShell { }) .detach(); + let (store, panels, persistence_status) = load_panel_registry(); let terminal_dimensions = - terminal_dimensions_for_window(window.bounds().size, INITIAL_ATTACHED_PANEL_COUNT); + terminal_dimensions_for_window(window.bounds().size, panels.attached_count()); let mut runtimes = RuntimeRegistry::new(); let mut live_terminals = BTreeMap::new(); - for pane in LIVE_PANES { + for panel in panels + .panels() + .iter() + .filter(|panel| panel.kind == PanelKind::Terminal) + { let mut terminal = LiveTerminalState::new(terminal_dimensions); - if let Err(error) = spawn_live_runtime(&mut runtimes, pane, terminal_dimensions) { + if let Err(error) = + spawn_live_runtime(&mut runtimes, panel.id, panel.seed, terminal_dimensions) + { terminal.status = LiveRuntimeStatus::Fault(error.to_string()); } - live_terminals.insert(pane, terminal); + live_terminals.insert(panel.id, terminal); } cx.observe_window_bounds(window, |shell, window, cx| { - let dimensions = terminal_dimensions_for_window( - window.bounds().size, - shell.model.attached_panel_count(), - ); + let dimensions = + terminal_dimensions_for_window(window.bounds().size, shell.panels.attached_count()); shell.resize_attached_terminals(dimensions.rows(), dimensions.columns(), cx); }) .detach(); Self { - model: ShellModel::with_external_outputs(LIVE_PANES) - .expect("all live comparison panes are terminals"), + model: ShellModel::with_external_outputs([ + FixturePaneId::CodexRuntime, + FixturePaneId::ClaudeUi, + FixturePaneId::PiDocs, + ]) + .expect("all live comparison panes are terminals"), + panels, timing: RenderTiming::default(), runtimes, live_terminals, + store, + persistence_status, + add_panel_chooser_open: false, root_focus, } } + fn persist_panels(&mut self) { + let Some(store) = &self.store else { + return; + }; + let result = self + .panels + .to_json() + .map_err(|error| error.to_owned()) + .and_then(|json| { + store + .save_workspace_snapshot(WORKSPACE_SNAPSHOT_ID, &json) + .map_err(|error| error.to_string()) + }); + self.persistence_status = match result { + Ok(()) => "layout saved · SQLite".to_owned(), + Err(error) => format!("layout save failed · {error}"), + }; + } + fn dispatch(&mut self, action: ShellAction) -> ActionOutcome { self.timing.mark_dispatch(); self.model.dispatch(action) @@ -464,7 +585,8 @@ impl LumbridgeShell { fn drain_runtime_events(&mut self) -> bool { let mut changed = false; - for pane in LIVE_PANES { + let panes = self.live_terminals.keys().copied().collect::>(); + for pane in panes { if self .live_terminals .get(&pane) @@ -571,13 +693,13 @@ impl LumbridgeShell { fn send_runtime_command( &self, - pane: PaneId, + pane: PanelId, command: RuntimeCommand, ) -> Result<(), RuntimeRegistryError> { self.runtimes.try_send(&pane, command) } - fn publish_terminal_snapshot(&mut self, pane: PaneId) { + fn publish_terminal_snapshot(&mut self, pane: PanelId) { let lines = { let terminal = self .live_terminals @@ -588,10 +710,17 @@ impl LumbridgeShell { terminal.snapshot = snapshot; lines }; - self.dispatch(ShellAction::ReplaceExternalOutput { pane, lines }); + let fixture = self + .panels + .panel(pane) + .and_then(|panel| panel.seed) + .map(SeedPane::fixture_id); + if let Some(pane) = fixture { + self.dispatch(ShellAction::ReplaceExternalOutput { pane, lines }); + } } - fn resize_terminal(&mut self, pane: PaneId, rows: u16, columns: u16) -> bool { + fn resize_terminal(&mut self, pane: PanelId, rows: u16, columns: u16) -> bool { let dimensions = TerminalDimensions::new(rows, columns) .expect("resize actions always retain non-zero dimensions"); let terminal = self @@ -615,8 +744,9 @@ impl LumbridgeShell { fn resize_attached_terminals(&mut self, rows: u16, columns: u16, cx: &mut Context) { let mut changed = false; - for pane in LIVE_PANES { - if self.model.is_panel_attached(pane) { + let panes = self.live_terminals.keys().copied().collect::>(); + for pane in panes { + if self.panels.panel(pane).is_some_and(|panel| panel.attached) { changed |= self.resize_terminal(pane, rows, columns); } } @@ -625,7 +755,7 @@ impl LumbridgeShell { } } - fn scroll_terminal(&mut self, pane: PaneId, scroll: TerminalScroll, cx: &mut Context) { + fn scroll_terminal(&mut self, pane: PanelId, scroll: TerminalScroll, cx: &mut Context) { if !self .live_terminals .get_mut(&pane) @@ -639,37 +769,63 @@ impl LumbridgeShell { cx.notify(); } - fn select_pane(&mut self, pane: PaneId, window: &mut Window, cx: &mut Context) { - self.dispatch(ShellAction::SelectPane(pane)); + fn select_pane(&mut self, pane: PanelId, window: &mut Window, cx: &mut Context) { + self.timing.mark_dispatch(); + if self.panels.select(pane) { + self.persist_panels(); + } + window.focus(&self.root_focus); + cx.notify(); + } + + fn select_pane_at(&mut self, index: usize, window: &mut Window, cx: &mut Context) { + self.timing.mark_dispatch(); + if self.panels.select_attached_at(index) { + self.persist_panels(); + } window.focus(&self.root_focus); cx.notify(); } fn resize_terminal_for_workspace(&mut self, window: &Window, cx: &mut Context) { let dimensions = - terminal_dimensions_for_window(window.bounds().size, self.model.attached_panel_count()); + terminal_dimensions_for_window(window.bounds().size, self.panels.attached_count()); self.resize_attached_terminals(dimensions.rows(), dimensions.columns(), cx); } - fn attach_panel(&mut self, pane: PaneId, window: &mut Window, cx: &mut Context) { - let outcome = self.dispatch(ShellAction::AttachPanel(pane)); - if outcome.changed { + fn attach_panel(&mut self, pane: PanelId, window: &mut Window, cx: &mut Context) { + self.timing.mark_dispatch(); + if self.panels.attach(pane) { + self.persist_panels(); self.resize_terminal_for_workspace(window, cx); } window.focus(&self.root_focus); cx.notify(); } - fn add_panel(&mut self, window: &mut Window, cx: &mut Context) { - let Some(pane) = self.model.next_detached_panel() else { - return; - }; - self.attach_panel(pane, window, cx); + fn create_panel(&mut self, kind: PanelKind, window: &mut Window, cx: &mut Context) { + self.timing.mark_dispatch(); + let pane = self.panels.create(kind); + if kind == PanelKind::Terminal { + let dimensions = + terminal_dimensions_for_window(window.bounds().size, self.panels.attached_count()); + let mut terminal = LiveTerminalState::new(dimensions); + if let Err(error) = spawn_live_runtime(&mut self.runtimes, pane, None, dimensions) { + terminal.status = LiveRuntimeStatus::Fault(error.to_string()); + } + self.live_terminals.insert(pane, terminal); + } + self.add_panel_chooser_open = false; + self.persist_panels(); + self.resize_terminal_for_workspace(window, cx); + window.focus(&self.root_focus); + cx.notify(); } - fn detach_panel(&mut self, pane: PaneId, window: &mut Window, cx: &mut Context) { - let outcome = self.dispatch(ShellAction::DetachPanel(pane)); - if outcome.changed { + fn detach_panel(&mut self, pane: PanelId, window: &mut Window, cx: &mut Context) { + self.timing.mark_dispatch(); + if self.panels.detach(pane) { + self.persist_panels(); self.resize_terminal_for_workspace(window, cx); } window.focus(&self.root_focus); @@ -677,7 +833,10 @@ impl LumbridgeShell { } fn add_panel_action(&mut self, _: &AddPanel, window: &mut Window, cx: &mut Context) { - self.add_panel(window, cx); + self.dispatch(ShellAction::CloseCommandPalette); + self.add_panel_chooser_open = !self.add_panel_chooser_open; + window.focus(&self.root_focus); + cx.notify(); } fn detach_selected_panel( @@ -686,37 +845,36 @@ impl LumbridgeShell { window: &mut Window, cx: &mut Context, ) { - self.detach_panel(self.model.selected_pane(), window, cx); + self.detach_panel(self.panels.selected(), window, cx); } - fn move_focus( - &mut self, - direction: FocusDirection, - window: &mut Window, - cx: &mut Context, - ) { - self.dispatch(ShellAction::MoveFocus(direction)); + fn move_focus(&mut self, delta: isize, window: &mut Window, cx: &mut Context) { + self.timing.mark_dispatch(); + if self.panels.move_horizontal(delta) { + self.persist_panels(); + } window.focus(&self.root_focus); cx.notify(); } fn focus_left(&mut self, _: &FocusLeft, window: &mut Window, cx: &mut Context) { - self.move_focus(FocusDirection::Left, window, cx); + self.move_focus(-1, window, cx); } fn focus_right(&mut self, _: &FocusRight, window: &mut Window, cx: &mut Context) { - self.move_focus(FocusDirection::Right, window, cx); + self.move_focus(1, window, cx); } fn focus_up(&mut self, _: &FocusUp, window: &mut Window, cx: &mut Context) { - self.move_focus(FocusDirection::Up, window, cx); + self.move_focus(-1, window, cx); } fn focus_down(&mut self, _: &FocusDown, window: &mut Window, cx: &mut Context) { - self.move_focus(FocusDirection::Down, window, cx); + self.move_focus(1, window, cx); } fn open_palette(&mut self, _: &OpenPalette, _: &mut Window, cx: &mut Context) { + self.add_panel_chooser_open = false; self.dispatch(ShellAction::OpenCommandPalette); cx.notify(); } @@ -726,8 +884,8 @@ impl LumbridgeShell { cx.notify(); } - fn selected_terminal_dimensions(&self) -> Option<(PaneId, TerminalDimensions)> { - let pane = self.model.selected_pane(); + fn selected_terminal_dimensions(&self) -> Option<(PanelId, TerminalDimensions)> { + let pane = self.panels.selected(); self.live_terminals .get(&pane) .map(|terminal| (pane, terminal.terminal.dimensions())) @@ -788,9 +946,23 @@ impl LumbridgeShell { } } - fn on_key_down(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context) { + fn on_key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context) { + if self.add_panel_chooser_open { + match event.keystroke.key.as_str() { + "escape" => { + self.add_panel_chooser_open = false; + cx.notify(); + } + "1" => self.create_panel(PanelKind::Terminal, window, cx), + "2" => self.create_panel(PanelKind::Browser, window, cx), + "3" => self.create_panel(PanelKind::Markdown, window, cx), + "4" => self.create_panel(PanelKind::Review, window, cx), + _ => {} + } + return; + } if !self.model.command_palette().is_open() { - let pane = self.model.selected_pane(); + let pane = self.panels.selected(); if self .live_terminals .get(&pane) @@ -916,7 +1088,68 @@ impl LumbridgeShell { .into_any_element() } - fn terminal_view(&self, pane: PaneId) -> gpui::AnyElement { + fn panel_view(&self, id: PanelId) -> PanelView { + let panel = self.panels.panel(id).expect("workspace panel exists"); + if let Some(seed) = panel.seed { + let fixture = self.model.pane(seed.fixture_id()); + return PanelView { + id, + kind: fixture.kind(), + title: panel.title.clone(), + badge: fixture.fixture().badge.to_owned(), + target: panel.target.clone(), + status: fixture.status(), + lines: fixture.lines().to_vec(), + output_source: fixture.output_source(), + }; + } + let (kind, badge, lines) = match panel.kind { + PanelKind::Terminal => (SurfaceKind::Terminal, "TERMINAL", Vec::new()), + PanelKind::Browser => ( + SurfaceKind::Browser, + "BROWSER", + vec![ + "about:blank".to_owned(), + "Content process: isolated".to_owned(), + "Choose a URL or open externally ↗".to_owned(), + ], + ), + PanelKind::Markdown => ( + SurfaceKind::Markdown, + "MARKDOWN", + vec![ + "# Untitled note".to_owned(), + "".to_owned(), + "Local-first workspace document.".to_owned(), + ], + ), + PanelKind::Review => ( + SurfaceKind::Review, + "REVIEW", + vec![ + "Working tree ready for review.".to_owned(), + "No approval has been requested.".to_owned(), + "0 files selected".to_owned(), + ], + ), + }; + PanelView { + id, + kind, + title: panel.title.clone(), + badge: badge.to_owned(), + target: panel.target.clone(), + status: PaneStatus::Ready, + lines, + output_source: if panel.kind == PanelKind::Terminal { + OutputSource::External + } else { + OutputSource::Deterministic + }, + } + } + + fn terminal_view(&self, pane: PanelId) -> gpui::AnyElement { let terminal = self .live_terminals .get(&pane) @@ -945,23 +1178,21 @@ impl LumbridgeShell { fn pane_context( &self, - pane: &PaneState, + pane: &PanelView, selected: bool, cx: &mut Context, ) -> gpui::AnyElement { - let pane_id = pane.id(); - let can_detach = self.model.attached_panel_count() > 1; - let external = pane.output_source() == OutputSource::External; + let pane_id = pane.id; + let can_detach = self.panels.attached_count() > 1; + let external = pane.output_source == OutputSource::External; let live_terminal = external.then(|| { self.live_terminals .get(&pane_id) .expect("external terminal pane has live state") }); - let status = live_terminal.map_or(pane.fixture().badge, |terminal| terminal.status.badge()); - let detail = live_terminal.map_or_else( - || pane.fixture().target.to_owned(), - |terminal| terminal.status.detail(), - ); + let status = live_terminal.map_or(pane.badge.as_str(), |terminal| terminal.status.badge()); + let detail = + live_terminal.map_or_else(|| pane.target.clone(), |terminal| terminal.status.detail()); let surface_status = live_terminal.map_or_else( || status.to_owned(), |terminal| { @@ -984,7 +1215,7 @@ impl LumbridgeShell { } }, ); - let surface = match pane.kind() { + let surface = match pane.kind { SurfaceKind::Terminal => "TERMINAL", SurfaceKind::Markdown => "CONTEXT", SurfaceKind::Browser => "BROWSER", @@ -1032,7 +1263,7 @@ impl LumbridgeShell { .truncate() .text_sm() .text_color(rgb(TEXT)) - .child(pane.fixture().title), + .child(pane.title.clone()), ) .child( div() @@ -1064,7 +1295,7 @@ impl LumbridgeShell { ) .child( div() - .id(("detach-panel", pane_id.index())) + .id(("detach-panel", pane_id.get())) .cursor_pointer() .px_2() .py_1() @@ -1116,12 +1347,12 @@ impl LumbridgeShell { .into_any_element() } - fn pane_work_surface(&self, pane: &PaneState) -> gpui::AnyElement { - let external = pane.output_source() == OutputSource::External; + fn pane_work_surface(&self, pane: &PanelView) -> gpui::AnyElement { + let external = pane.output_source == OutputSource::External; let content = if external { - self.terminal_view(pane.id()) + self.terminal_view(pane.id) } else { - let start = pane.lines().len().saturating_sub(18); + let start = pane.lines.len().saturating_sub(18); div() .flex() .flex_col() @@ -1131,7 +1362,7 @@ impl LumbridgeShell { .font_family("monospace") .text_sm() .text_color(rgb(TEXT)) - .children(pane.lines()[start..].iter().cloned()) + .children(pane.lines[start..].iter().cloned()) .into_any_element() }; @@ -1156,7 +1387,7 @@ impl LumbridgeShell { .into_any_element() } - fn pane_decision_region(&self, pane: &PaneState) -> gpui::AnyElement { + fn pane_decision_region(&self, pane: &PanelView) -> gpui::AnyElement { let choice = |label: &'static str, detail: &'static str, attention: bool| { div() .flex() @@ -1223,13 +1454,13 @@ impl LumbridgeShell { fn workspace_panel( &self, - pane: &PaneState, + pane: &PanelView, selected: bool, cx: &mut Context, ) -> gpui::AnyElement { - let pane_id = pane.id(); + let pane_id = pane.id; div() - .id(("workspace-panel", pane_id.index())) + .id(("workspace-panel", pane_id.get())) .on_click(cx.listener(move |shell, _, window, cx| { shell.select_pane(pane_id, window, cx); })) @@ -1332,7 +1563,7 @@ impl LumbridgeShell { div() .mt_1() .text_xs() - .child("Alt+Shift+N · reattach if detached"), + .child("Alt+Shift+N · choose a surface or reattach"), ), ) .child( @@ -1371,19 +1602,219 @@ impl LumbridgeShell { ), ) } + + fn add_panel_chooser(&self, cx: &mut Context) -> impl IntoElement { + let kinds = PanelKind::ALL + .into_iter() + .enumerate() + .map(|(index, kind)| { + let shortcut = index + 1; + div() + .id(("create-panel-kind", shortcut)) + .cursor_pointer() + .flex() + .items_center() + .gap_3() + .px_3() + .py_3() + .rounded(px(6.0)) + .border_1() + .border_color(rgb(BORDER)) + .bg(rgb(PANEL)) + .child( + div() + .flex() + .items_center() + .justify_center() + .size(px(34.0)) + .flex_none() + .rounded(px(5.0)) + .bg(rgb(PANEL_ACTIVE)) + .text_color(rgb(ACCENT)) + .child(match kind { + PanelKind::Terminal => ">_", + PanelKind::Browser => "◎", + PanelKind::Markdown => "¶", + PanelKind::Review => "±", + }), + ) + .child( + div() + .min_w_0() + .flex_1() + .child(div().text_sm().text_color(rgb(TEXT)).child(kind.label())) + .child( + div() + .mt_1() + .text_xs() + .text_color(rgb(MUTED)) + .child(kind.description()), + ), + ) + .child( + div() + .flex_none() + .text_xs() + .text_color(rgb(MUTED)) + .child(shortcut.to_string()), + ) + .on_click(cx.listener(move |shell, _, window, cx| { + cx.stop_propagation(); + shell.create_panel(kind, window, cx); + })) + }) + .collect::>(); + let detached = self + .panels + .detached_ids() + .into_iter() + .map(|id| { + let panel = self.panels.panel(id).expect("detached panel exists"); + (id, panel.title.clone(), panel.kind.label()) + }) + .collect::>(); + + div() + .absolute() + .inset_0() + .flex() + .justify_center() + .items_start() + .pt(px(72.0)) + .bg(gpui::black().opacity(0.72)) + .child( + div() + .w(px(620.0)) + .max_h(px(760.0)) + .overflow_hidden() + .rounded(px(9.0)) + .border_1() + .border_color(rgb(ACCENT)) + .bg(rgb(PANEL_ALT)) + .child( + div() + .flex() + .items_start() + .justify_between() + .px_4() + .py_3() + .border_b_1() + .border_color(rgb(BORDER)) + .child( + div() + .child( + div() + .text_lg() + .text_color(rgb(TEXT)) + .child("Add workspace panel"), + ) + .child( + div() + .mt_1() + .text_xs() + .text_color(rgb(MUTED)) + .child("Created beside the selected pane with a persistent local identity."), + ), + ) + .child( + div() + .id("close-add-panel-chooser") + .cursor_pointer() + .px_2() + .py_1() + .rounded(px(4.0)) + .border_1() + .border_color(rgb(BORDER)) + .text_xs() + .text_color(rgb(MUTED)) + .child("ESC") + .on_click(cx.listener(|shell, _, _, cx| { + shell.add_panel_chooser_open = false; + cx.notify(); + })), + ), + ) + .child(div().flex().flex_col().gap_2().p_3().children(kinds)) + .when(!detached.is_empty(), |view| { + view.child( + div() + .px_4() + .pt_2() + .pb_1() + .border_t_1() + .border_color(rgb(BORDER)) + .text_xs() + .text_color(rgb(MUTED)) + .child("REATTACH RUNNING SESSION"), + ) + .children(detached.into_iter().map(|(id, title, kind)| { + div() + .id(("chooser-reattach", id.get())) + .cursor_pointer() + .flex() + .items_center() + .justify_between() + .mx_3() + .mb_2() + .px_3() + .py_2() + .rounded(px(5.0)) + .bg(rgb(PANEL)) + .border_1() + .border_color(rgb(BORDER)) + .child( + div() + .child(div().text_sm().text_color(rgb(TEXT)).child(title)) + .child( + div() + .mt_1() + .text_xs() + .text_color(rgb(MUTED)) + .child(format!("{kind} · pane-{}", id.get())), + ), + ) + .child(div().text_xs().text_color(rgb(ACCENT)).child("REATTACH")) + .on_click(cx.listener(move |shell, _, window, cx| { + shell.add_panel_chooser_open = false; + shell.attach_panel(id, window, cx); + })) + })) + }) + .child( + div() + .flex() + .justify_between() + .px_4() + .py_2() + .border_t_1() + .border_color(rgb(BORDER)) + .text_xs() + .text_color(rgb(MUTED)) + .child("1–4 create · click reattaches") + .child("Esc closes"), + ), + ) + } } impl Render for LumbridgeShell { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let attention = self.model.pane(PaneId::ClaudeUi); let panel_capacity = visible_panel_count(window.bounds().size); - let attached_panes = self.model.attached_pane_ids(); + let attached_panes = self.panels.attached_ids(); let attached_count = attached_panes.len(); let detached_entries = self - .model - .detached_pane_ids() + .panels + .detached_ids() .into_iter() - .map(|pane| (pane, self.model.pane(pane).fixture().title)) + .map(|pane| { + let title = self + .panels + .panel(pane) + .expect("detached panel exists") + .title + .clone(); + (pane, title) + }) .collect::>(); let detached_count = detached_entries.len(); let visible_count = panel_capacity.min(attached_count).max(1); @@ -1427,7 +1858,7 @@ impl Render for LumbridgeShell { div() .text_sm() .text_color(rgb(TEXT)) - .child(attention.fixture().title), + .child("Claude Code · UI"), ) .child( div() @@ -1500,6 +1931,14 @@ impl Render for LumbridgeShell { .text_color(rgb(MUTED)) .child("spark-1 · sleeping"), ) + .child( + div() + .px_4() + .pt_2() + .text_xs() + .text_color(rgb(MUTED)) + .child(self.persistence_status.clone()), + ) .child( div() .mt_3() @@ -1511,7 +1950,7 @@ impl Render for LumbridgeShell { ) .children(detached_entries.into_iter().map(|(pane, title)| { div() - .id(("detached-session", pane.index())) + .id(("detached-session", pane.get())) .cursor_pointer() .mx_2() .mb_1() @@ -1593,32 +2032,31 @@ impl Render for LumbridgeShell { .py_1() .rounded(px(4.0)) .border_1() - .border_color(rgb(if detached_count > 0 { ACCENT } else { BORDER })) + .border_color(rgb(ACCENT)) .text_xs() - .text_color(rgb(if detached_count > 0 { ACCENT } else { MUTED })) - .child(if detached_count > 0 { - "+ ADD PANEL" - } else { - "ALL PANELS ATTACHED" - }) + .text_color(rgb(ACCENT)) + .child("+ ADD PANEL ▾") .on_click(cx.listener(|shell, _, window, cx| { - shell.add_panel(window, cx); + shell.dispatch(ShellAction::CloseCommandPalette); + shell.add_panel_chooser_open = !shell.add_panel_chooser_open; + window.focus(&shell.root_focus); + cx.notify(); })), ); let selected_position = attached_panes .iter() - .position(|pane| *pane == self.model.selected_pane()) + .position(|pane| *pane == self.panels.selected()) .expect("the selected pane must remain attached"); let panel_range = visible_panel_range(attached_count, selected_position, visible_count); let workspace_panels = panel_range .map(|index| { - let pane = self.model.pane(attached_panes[index]); + let pane = self.panel_view(attached_panes[index]); div() .flex_1() .min_w_0() .min_h_0() - .child(self.workspace_panel(pane, pane.id() == self.model.selected_pane(), cx)) + .child(self.workspace_panel(&pane, pane.id == self.panels.selected(), cx)) }) .collect::>(); let workspace_row = div() @@ -1668,22 +2106,22 @@ impl Render for LumbridgeShell { .on_action(cx.listener(Self::terminal_wider)) .on_action(cx.listener(Self::terminal_narrower)) .on_action(cx.listener(|shell, _: &SelectPane1, window, cx| { - shell.select_pane(PaneId::CodexRuntime, window, cx); + shell.select_pane_at(0, window, cx); })) .on_action(cx.listener(|shell, _: &SelectPane2, window, cx| { - shell.select_pane(PaneId::ClaudeUi, window, cx); + shell.select_pane_at(1, window, cx); })) .on_action(cx.listener(|shell, _: &SelectPane3, window, cx| { - shell.select_pane(PaneId::PiDocs, window, cx); + shell.select_pane_at(2, window, cx); })) .on_action(cx.listener(|shell, _: &SelectPane4, window, cx| { - shell.select_pane(PaneId::Architecture, window, cx); + shell.select_pane_at(3, window, cx); })) .on_action(cx.listener(|shell, _: &SelectPane5, window, cx| { - shell.select_pane(PaneId::AcpPreview, window, cx); + shell.select_pane_at(4, window, cx); })) .on_action(cx.listener(|shell, _: &SelectPane6, window, cx| { - shell.select_pane(PaneId::RuntimeReview, window, cx); + shell.select_pane_at(5, window, cx); })) .on_key_down(cx.listener(Self::on_key_down)) .flex() @@ -1756,6 +2194,9 @@ impl Render for LumbridgeShell { ) .when(self.model.command_palette().is_open(), |view| { view.child(self.command_palette()) + }) + .when(self.add_panel_chooser_open, |view| { + view.child(self.add_panel_chooser(cx)) }); self.timing.observe_element_build(); root @@ -1826,29 +2267,33 @@ fn terminal_scroll_from_parts(key: &str, modifiers: KeyModifiers) -> Option &'static str { - match pane { - PaneId::CodexRuntime => { +fn live_pty_script(seed: Option) -> &'static str { + match seed { + Some(SeedPane::CodexRuntime) => { "printf 'Codex runtime · independent Lumbridge PTY\\n'; exec /bin/sh -i" } - PaneId::ClaudeUi => { + Some(SeedPane::ClaudeUi) => { "printf 'Claude workspace · independent Lumbridge PTY\\n'; exec /bin/sh -i" } - PaneId::PiDocs => "printf 'Pi docs · independent Lumbridge PTY\\n'; exec /bin/sh -i", - PaneId::Architecture | PaneId::AcpPreview | PaneId::RuntimeReview => { + Some(SeedPane::PiDocs) => { + "printf 'Pi docs · independent Lumbridge PTY\\n'; exec /bin/sh -i" + } + None => "printf 'Lumbridge terminal · persistent pane identity\\n'; exec /bin/sh -i", + Some(SeedPane::Architecture | SeedPane::AcpPreview | SeedPane::RuntimeReview) => { unreachable!("only terminal fixtures own live PTYs") } } } fn spawn_live_runtime( - runtimes: &mut RuntimeRegistry, - pane: PaneId, + runtimes: &mut RuntimeRegistry, + pane: PanelId, + seed: Option, dimensions: TerminalDimensions, ) -> Result<(), RuntimeRegistryError> { let command = CommandConfig::new("/bin/sh") .map_err(RuntimeActorError::Start)? - .args(["-c", live_pty_script(pane)]); + .args(["-c", live_pty_script(seed)]); let pty_size = TerminalSize::new(dimensions.rows(), dimensions.columns()) .map_err(RuntimeActorError::Start)?; runtimes.spawn( diff --git a/spikes/gpui-shell/src/panel_registry.rs b/spikes/gpui-shell/src/panel_registry.rs new file mode 100644 index 0000000..49fef1f --- /dev/null +++ b/spikes/gpui-shell/src/panel_registry.rs @@ -0,0 +1,379 @@ +use std::collections::BTreeSet; + +use lumbridge_spike_model::PaneId as FixturePaneId; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +pub(crate) struct PanelId(u64); + +impl PanelId { + pub(crate) const fn get(self) -> u64 { + self.0 + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum PanelKind { + Terminal, + Browser, + Markdown, + Review, +} + +impl PanelKind { + pub(crate) const ALL: [Self; 4] = [Self::Terminal, Self::Browser, Self::Markdown, Self::Review]; + + pub(crate) const fn label(self) -> &'static str { + match self { + Self::Terminal => "Terminal", + Self::Browser => "Browser", + Self::Markdown => "Markdown", + Self::Review => "Review", + } + } + + pub(crate) const fn description(self) -> &'static str { + match self { + Self::Terminal => "A real local shell with its own runtime actor", + Self::Browser => "An isolated system-web-engine surface", + Self::Markdown => "Local notes, plans, and architecture", + Self::Review => "Repository changes and approval boundaries", + } + } + + const fn default_target(self) -> &'static str { + match self { + Self::Terminal => "metal · local runtime", + Self::Browser => "isolated system web engine", + Self::Markdown => "lumbridge-code · local document", + Self::Review => "lumbridge-code · working tree", + } + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum SeedPane { + CodexRuntime, + ClaudeUi, + PiDocs, + Architecture, + AcpPreview, + RuntimeReview, +} + +impl SeedPane { + pub(crate) const fn fixture_id(self) -> FixturePaneId { + match self { + Self::CodexRuntime => FixturePaneId::CodexRuntime, + Self::ClaudeUi => FixturePaneId::ClaudeUi, + Self::PiDocs => FixturePaneId::PiDocs, + Self::Architecture => FixturePaneId::Architecture, + Self::AcpPreview => FixturePaneId::AcpPreview, + Self::RuntimeReview => FixturePaneId::RuntimeReview, + } + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub(crate) struct WorkspacePanel { + pub(crate) id: PanelId, + pub(crate) kind: PanelKind, + pub(crate) title: String, + pub(crate) target: String, + pub(crate) attached: bool, + pub(crate) seed: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub(crate) struct PanelRegistry { + schema_version: u32, + panels: Vec, + selected: PanelId, + next_id: u64, +} + +impl Default for PanelRegistry { + fn default() -> Self { + Self::seeded() + } +} + +impl PanelRegistry { + pub(crate) fn seeded() -> Self { + let seeds = [ + ( + PanelKind::Terminal, + "Codex · runtime", + "metal · Tailscale SSH", + SeedPane::CodexRuntime, + ), + ( + PanelKind::Terminal, + "Claude Code · UI", + "MacBook Air · local", + SeedPane::ClaudeUi, + ), + ( + PanelKind::Terminal, + "Pi · docs", + "amd-server · OpenSSH", + SeedPane::PiDocs, + ), + ( + PanelKind::Markdown, + "Architecture.md", + "lumbridge-code · worktree", + SeedPane::Architecture, + ), + ( + PanelKind::Browser, + "Preview · ACP docs", + "isolated system web engine", + SeedPane::AcpPreview, + ), + ( + PanelKind::Review, + "Changes · lumbridge-runtime", + "metal · worktree remote-runtime", + SeedPane::RuntimeReview, + ), + ]; + let panels = seeds + .into_iter() + .enumerate() + .map(|(index, (kind, title, target, seed))| WorkspacePanel { + id: PanelId(index as u64 + 1), + kind, + title: title.to_owned(), + target: target.to_owned(), + attached: index < 5, + seed: Some(seed), + }) + .collect(); + Self { + schema_version: 1, + panels, + selected: PanelId(1), + next_id: 7, + } + } + + pub(crate) fn from_json(json: &str) -> Result { + let registry: Self = serde_json::from_str(json).map_err(|error| error.to_string())?; + registry.validate()?; + Ok(registry) + } + + pub(crate) fn to_json(&self) -> Result { + serde_json::to_string(self).map_err(|error| error.to_string()) + } + + fn validate(&self) -> Result<(), String> { + if self.schema_version != 1 { + return Err(format!( + "unsupported panel snapshot version {}", + self.schema_version + )); + } + if self.panels.is_empty() || self.attached_count() == 0 { + return Err("a workspace must retain one attached panel".to_owned()); + } + let ids = self + .panels + .iter() + .map(|panel| panel.id) + .collect::>(); + if ids.len() != self.panels.len() || ids.iter().any(|id| id.get() == 0) { + return Err("panel IDs must be unique and non-zero".to_owned()); + } + if self + .panel(self.selected) + .is_none_or(|panel| !panel.attached) + { + return Err("the selected panel must be attached".to_owned()); + } + let max_id = ids.iter().map(|id| id.get()).max().unwrap_or(0); + if self.next_id <= max_id { + return Err("the next panel ID must exceed every persisted ID".to_owned()); + } + Ok(()) + } + + pub(crate) fn panels(&self) -> &[WorkspacePanel] { + &self.panels + } + + pub(crate) fn panel(&self, id: PanelId) -> Option<&WorkspacePanel> { + self.panels.iter().find(|panel| panel.id == id) + } + + pub(crate) fn selected(&self) -> PanelId { + self.selected + } + + pub(crate) fn attached_ids(&self) -> Vec { + self.panels + .iter() + .filter(|panel| panel.attached) + .map(|panel| panel.id) + .collect() + } + + pub(crate) fn detached_ids(&self) -> Vec { + self.panels + .iter() + .filter(|panel| !panel.attached) + .map(|panel| panel.id) + .collect() + } + + pub(crate) fn attached_count(&self) -> usize { + self.panels.iter().filter(|panel| panel.attached).count() + } + + pub(crate) fn select(&mut self, id: PanelId) -> bool { + if self.panel(id).is_none_or(|panel| !panel.attached) || self.selected == id { + return false; + } + self.selected = id; + true + } + + pub(crate) fn select_attached_at(&mut self, index: usize) -> bool { + self.attached_ids() + .get(index) + .copied() + .is_some_and(|id| self.select(id)) + } + + pub(crate) fn move_horizontal(&mut self, delta: isize) -> bool { + let attached = self.attached_ids(); + let Some(position) = attached.iter().position(|id| *id == self.selected) else { + return false; + }; + let next = position as isize + delta; + if next < 0 || next >= attached.len() as isize { + return false; + } + self.select(attached[next as usize]) + } + + pub(crate) fn create(&mut self, kind: PanelKind) -> PanelId { + let id = PanelId(self.next_id); + self.next_id += 1; + let sequence = self + .panels + .iter() + .filter(|panel| panel.kind == kind) + .count() + + 1; + let title = match kind { + PanelKind::Terminal => format!("Terminal · {sequence}"), + PanelKind::Browser => format!("Browser · {sequence}"), + PanelKind::Markdown => format!("Notes · {sequence}"), + PanelKind::Review => format!("Review · {sequence}"), + }; + let selected_position = self + .panels + .iter() + .position(|panel| panel.id == self.selected) + .expect("selected panel exists"); + self.panels.insert( + selected_position + 1, + WorkspacePanel { + id, + kind, + title, + target: kind.default_target().to_owned(), + attached: true, + seed: None, + }, + ); + self.selected = id; + id + } + + pub(crate) fn detach(&mut self, id: PanelId) -> bool { + if self.attached_count() == 1 || self.panel(id).is_none_or(|panel| !panel.attached) { + return false; + } + let attached_before = self.attached_ids(); + let detached_position = attached_before + .iter() + .position(|candidate| *candidate == id) + .expect("attached panel appears in attached IDs"); + self.panels + .iter_mut() + .find(|panel| panel.id == id) + .expect("panel exists") + .attached = false; + if self.selected == id { + let remaining = self.attached_ids(); + self.selected = remaining[detached_position.min(remaining.len() - 1)]; + } + true + } + + pub(crate) fn attach(&mut self, id: PanelId) -> bool { + let Some(panel) = self.panels.iter_mut().find(|panel| panel.id == id) else { + return false; + }; + if panel.attached { + return false; + } + panel.attached = true; + self.selected = id; + true + } +} + +#[cfg(test)] +mod tests { + use super::{PanelId, PanelKind, PanelRegistry}; + + #[test] + fn dynamic_panels_receive_monotonic_ids_and_insert_beside_selection() { + let mut registry = PanelRegistry::seeded(); + let browser = registry.create(PanelKind::Browser); + let terminal = registry.create(PanelKind::Terminal); + assert_eq!(browser, PanelId(7)); + assert_eq!(terminal, PanelId(8)); + assert_eq!(registry.selected(), terminal); + assert_eq!(registry.panels()[2].id, terminal); + assert_eq!(registry.panels()[3].id, browser); + } + + #[test] + fn identity_and_detached_state_round_trip_without_reuse() { + let mut registry = PanelRegistry::seeded(); + let created = registry.create(PanelKind::Markdown); + assert!(registry.detach(created)); + let json = registry.to_json().unwrap(); + let mut restored = PanelRegistry::from_json(&json).unwrap(); + assert!(restored.detached_ids().contains(&created)); + assert_eq!(restored.create(PanelKind::Review), PanelId(8)); + } + + #[test] + fn detach_preserves_panel_and_reattach_restores_same_identity() { + let mut registry = PanelRegistry::seeded(); + let id = registry.create(PanelKind::Terminal); + assert!(registry.detach(id)); + assert!(registry.panel(id).is_some()); + assert!(registry.attach(id)); + assert_eq!(registry.selected(), id); + } + + #[test] + fn invalid_or_empty_snapshots_are_rejected() { + assert!(PanelRegistry::from_json("{}").is_err()); + assert!( + PanelRegistry::from_json( + r#"{"schema_version":1,"panels":[],"selected":1,"next_id":2}"# + ) + .is_err() + ); + } +}