diff --git a/README.md b/README.md index c0e9c70..1d941ca 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ GridBash is a Windows-native Rust TUI multiplexer built for agent-heavy developm - Claude, Codex, and other agent panes show a compact conversation summary in the footer line. - Built-in launch profiles for common CLI coding agents. - Startup dimension picker with a live grid preview. +- `gridbash resume` for reopening prior grids with per-pane command and output context. - Optional managed git worktrees so every pane can work in an isolated checkout. ## Demo @@ -130,6 +131,25 @@ List detected profiles: gridbash --list-profiles ``` +Resume a prior grid: + +```powershell +gridbash resume +``` + +Resume the latest saved grid without prompting: + +```powershell +gridbash resume --latest +``` + +List saved sessions or resume a specific id: + +```powershell +gridbash resume --list +gridbash resume +``` + Start in a repo: ```powershell @@ -138,6 +158,8 @@ gridbash 3x4 --profile codex --cwd C:\Users\Jason\Documents\GitHub\fluent Passing grid, count, profile, or cwd arguments bypasses the startup picker and uses the direct launch path. +GridBash saves bounded session snapshots to local app data as you launch and exit grids. A resumed session restores the grid dimensions, pane profiles, working directories, labels, worktree names, and a pane-local history view with recent submitted commands and output. It relaunches child terminals; it does not reattach still-running processes or replay old commands into shells. + Launch every pane in a separate repo-local git worktree: ```powershell diff --git a/docs/devlogs/2026-07-07-add-grid-session-resume-command.md b/docs/devlogs/2026-07-07-add-grid-session-resume-command.md new file mode 100644 index 0000000..10aa760 --- /dev/null +++ b/docs/devlogs/2026-07-07-add-grid-session-resume-command.md @@ -0,0 +1,29 @@ +# Add grid session resume command + +Date: 2026-07-07 +Release target: unreleased + +## Summary + +- Added `gridbash resume` for finding and reopening saved GridBash sessions. + +## What Changed + +- Added a session snapshot store under GridBash local app data. +- Normal grid launches now save bounded session metadata for the grid, pane profiles, working directories, labels, worktree names, submitted command history, and recent output context. +- Added `gridbash resume`, `gridbash resume --latest`, `gridbash resume --list`, and `gridbash resume `. +- Resumed sessions relaunch the saved grid and show per-pane history context without replaying old commands into child shells. + +## Why It Matters + +- Agent-heavy work often spans multiple panes and folders. Resume makes prior GridBash grids discoverable as whole workspaces instead of forcing users to rebuild layout and context by hand. + +## Validation + +- `cargo fmt --check` +- `cargo test` +- `cargo clippy -- -D warnings` + +## Release Notes + +- Add `gridbash resume` to reopen saved grids with per-pane command and output history context. diff --git a/docs/devlogs/2026-07-07-integrate-session-resume-branch.md b/docs/devlogs/2026-07-07-integrate-session-resume-branch.md new file mode 100644 index 0000000..2940003 --- /dev/null +++ b/docs/devlogs/2026-07-07-integrate-session-resume-branch.md @@ -0,0 +1,26 @@ +# Integrate session resume branch + +Date: 2026-07-07 +Release target: unreleased + +## Summary + +- Integrated the older `origin/feat/resume-sessions` work onto latest `origin/main`. + +## What Changed + +- Resolved session resume against the current app state that includes pane IDs, sleeping panes, worktree labels, usage labels, and runtime resize handling. +- Kept the terminal-query response tail fix so terminal capability/cursor queries do not get repeatedly replayed as input. +- Preserved current modeless pane controls while adding session recorder state, restored pane histories, and `gridbash resume` entry points. + +## Why It Matters + +- Resume support can land without regressing the recent terminal-grid behavior that current users rely on. + +## Validation + +- `npm test` + +## Release Notes + +- Integrate `gridbash resume` with the current GridBash terminal grid. diff --git a/src/app.rs b/src/app.rs index d141963..bf4dfb0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -27,6 +27,7 @@ use crate::{ layout::{GridLayout, GridSize, PaneId, pane_at}, profiles::find_profile, pty::{PtyEvent, PtyPane}, + session::{SavedPaneHistory, SessionRecord, SessionRecorder}, setup::{LaunchPlan, PaneLaunchSpec}, ui, usage::{self, UsageEvent, UsageTarget}, @@ -56,6 +57,8 @@ pub struct App { settings: SettingsState, rename: RenamePaneState, status: String, + restored_histories: Vec, + session_recorder: Option, next_pane_id: usize, event_tx: mpsc::UnboundedSender, event_rx: mpsc::UnboundedReceiver, @@ -66,6 +69,17 @@ pub struct App { last_activity_decay: Instant, } +struct AppInit { + config: Config, + worktrees: Option, + launch_plan: Option, + grid: GridSize, + mouse_enabled: bool, + restored_histories: Vec, + session_recorder: Option, + status: String, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum KeyOutcome { Continue, @@ -392,6 +406,16 @@ fn switch_value(enabled: bool) -> String { if enabled { "on".into() } else { "off".into() } } +fn default_status(mouse_enabled: bool) -> String { + if mouse_enabled { + "Drag copies within pane | Alt+arrows move | Alt+Shift+arrows resize | Alt+r rename | Alt+x swap | Alt+z sleep | Alt+o settings" + .into() + } else { + "Alt+arrows move | Alt+Shift+arrows resize | Alt+s select | Alt+r rename | Alt+x swap | Alt+z sleep | Alt+o settings" + .into() + } +} + impl App { pub fn new(cli: Cli, config: Config) -> Result { let worktrees = cli @@ -407,14 +431,46 @@ impl App { rows: 2, columns: 3, }); - let (event_tx, event_rx) = mpsc::unbounded_channel(); - let (usage_tx, usage_rx) = std_mpsc::channel(); - - Ok(Self { + Ok(Self::from_parts(AppInit { config, worktrees, launch_plan, - layout: GridLayout::new(grid), + grid, + mouse_enabled, + restored_histories: Vec::new(), + session_recorder: None, + status: default_status(mouse_enabled), + })) + } + + pub fn resume(config: Config, record: SessionRecord, mouse_enabled: bool) -> Result { + let launch_plan = record.session.launch_plan()?; + let grid = launch_plan.grid; + let restored_histories = record.session.pane_histories(); + let session_id = record.session.id.clone(); + let recorder = SessionRecorder::continue_record(record); + + Ok(Self::from_parts(AppInit { + config, + worktrees: None, + launch_plan: Some(launch_plan), + grid, + mouse_enabled, + restored_histories, + session_recorder: Some(recorder), + status: format!("resumed session {session_id}"), + })) + } + + fn from_parts(init: AppInit) -> Self { + let (event_tx, event_rx) = mpsc::unbounded_channel(); + let (usage_tx, usage_rx) = std_mpsc::channel(); + + Self { + config: init.config, + worktrees: init.worktrees, + launch_plan: init.launch_plan, + layout: GridLayout::new(init.grid), grid_area: Rect::default(), panes: Vec::new(), focus: 0, @@ -423,16 +479,12 @@ impl App { text_selection: None, sleeping: BTreeSet::new(), rects: Vec::new(), - mouse_enabled, + mouse_enabled: init.mouse_enabled, settings: SettingsState::default(), rename: RenamePaneState::default(), - status: if mouse_enabled { - "Drag copies within pane | Alt+arrows move | Alt+Shift+arrows resize | Alt+r rename | Alt+x swap | Alt+z sleep | Alt+o settings" - .into() - } else { - "Alt+arrows move | Alt+Shift+arrows resize | Alt+s select | Alt+r rename | Alt+x swap | Alt+z sleep | Alt+o settings" - .into() - }, + status: init.status, + restored_histories: init.restored_histories, + session_recorder: init.session_recorder, next_pane_id: 0, event_tx, event_rx, @@ -441,7 +493,7 @@ impl App { profile_usage: BTreeMap::new(), api_spend_label: None, last_activity_decay: Instant::now(), - }) + } } pub fn run(&mut self) -> Result<()> { @@ -463,12 +515,20 @@ impl App { self.spawn_initial_panes()?; self.sync_initial_pane_sizes(terminal)?; - self.run_loop(terminal) + + let run_result = self.run_loop(terminal); + let save_result = self.save_session_snapshot(); + match (run_result, save_result) { + (Err(error), _) => Err(error), + (Ok(()), Err(error)) => Err(error), + (Ok(()), Ok(())) => Ok(()), + } } fn set_launch_plan(&mut self, plan: LaunchPlan) { self.layout = GridLayout::new(plan.grid); self.launch_plan = Some(plan); + self.restored_histories.clear(); } fn spawn_initial_panes(&mut self) -> Result<()> { @@ -482,13 +542,15 @@ impl App { self.text_selection = None; self.sleeping.clear(); self.next_pane_id = 0; + self.start_session_recorder(&plan)?; - for spec in &plan.panes { - self.spawn_pane_spec(spec)?; + for (index, spec) in plan.panes.iter().enumerate() { + self.spawn_pane_spec(index, spec)?; } + self.restored_histories.clear(); self.start_usage_monitor(&plan); - Ok(()) + self.save_session_snapshot() } fn start_usage_monitor(&mut self, plan: &LaunchPlan) { @@ -506,7 +568,7 @@ impl App { usage::spawn_usage_monitor(targets, self.usage_tx.clone()); } - fn spawn_pane_spec(&mut self, spec: &PaneLaunchSpec) -> Result<()> { + fn spawn_pane_spec(&mut self, index: usize, spec: &PaneLaunchSpec) -> Result<()> { let launch = spec.resolved_command()?; let id = PaneId(self.next_pane_id); self.next_pane_id += 1; @@ -518,6 +580,10 @@ impl App { &spec.cwd, self.event_tx.clone(), )?; + let mut pane = pane; + if let Some(history) = self.restored_histories.get(index) { + pane.restore_history_display(&history.output_tail, &history.input_history); + } self.panes.push(pane); Ok(()) } @@ -1206,7 +1272,8 @@ impl App { fn spawn_panes_to_fill(&mut self, target_count: usize) -> Result<()> { let specs = self.pane_specs_to_fill(target_count)?; for spec in specs { - self.spawn_pane_spec(&spec)?; + let index = self.panes.len(); + self.spawn_pane_spec(index, &spec)?; } self.pane_names.resize(self.panes.len(), None); Ok(()) @@ -1306,10 +1373,13 @@ impl App { fn route_input(&mut self, bytes: &[u8]) -> Result<()> { let targets = self.input_targets(); for index in targets { - self.panes - .get(index) - .ok_or_else(|| anyhow!("invalid pane index {index}"))? - .write(bytes)?; + let pane = self + .panes + .get_mut(index) + .ok_or_else(|| anyhow!("invalid pane index {index}"))?; + pane.record_input(bytes); + pane.write(bytes) + .with_context(|| format!("failed to route input to pane {}", index + 1))?; } Ok(()) } @@ -1499,6 +1569,25 @@ impl App { self.sync_pane_sizes(); Ok(()) } + + fn start_session_recorder(&mut self, plan: &LaunchPlan) -> Result<()> { + if self.session_recorder.is_none() { + self.session_recorder = Some(SessionRecorder::start_new(plan)?); + } + Ok(()) + } + + fn save_session_snapshot(&mut self) -> Result<()> { + let Some(plan) = self.launch_plan.clone() else { + return Ok(()); + }; + let Some(recorder) = self.session_recorder.as_mut() else { + return Ok(()); + }; + + recorder.update(&plan, &self.panes); + recorder.save() + } } fn resolve_grid(cli: &Cli) -> Result { diff --git a/src/cli.rs b/src/cli.rs index 40c44fd..514cc15 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,12 +1,15 @@ use std::path::PathBuf; -use clap::{Parser, ValueEnum}; +use clap::{Args, Parser, Subcommand, ValueEnum}; #[derive(Debug, Clone, Parser)] #[command(name = "gridbash")] #[command(about = "Fast, beautiful terminal grids for CLI agents")] #[command(version)] pub struct Cli { + #[command(subcommand)] + pub command: Option, + /// Grid size as rows x columns, for example 2x3. pub grid: Option, @@ -60,3 +63,48 @@ pub enum GridMode { Grid, Auto, } + +#[derive(Debug, Clone, Subcommand)] +pub enum Command { + /// Find and reopen a saved GridBash session. + Resume(ResumeArgs), +} + +#[derive(Debug, Clone, Args)] +pub struct ResumeArgs { + /// Session id or unique id prefix to resume. + pub session: Option, + + /// Print saved sessions and exit. + #[arg(long)] + pub list: bool, + + /// Resume the most recently updated session without prompting. + #[arg(long)] + pub latest: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_resume_subcommand() { + let cli = Cli::parse_from(["gridbash", "resume", "--latest"]); + let Some(Command::Resume(args)) = cli.command else { + panic!("expected resume command"); + }; + + assert!(args.latest); + assert!(cli.grid.is_none()); + } + + #[test] + fn keeps_grid_positional_launches() { + let cli = Cli::parse_from(["gridbash", "2x3", "--profile", "git-bash"]); + + assert!(cli.command.is_none()); + assert_eq!(cli.grid.as_deref(), Some("2x3")); + assert_eq!(cli.profile.as_deref(), Some("git-bash")); + } +} diff --git a/src/main.rs b/src/main.rs index 0c8dd71..be28f40 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ mod layout; mod onboarding; mod profiles; mod pty; +mod session; mod setup; mod ui; mod usage; @@ -17,10 +18,11 @@ use clap::Parser; use crate::{ app::App, - cli::Cli, + cli::{Cli, Command}, config::Config, onboarding::OnboardingResult, profiles::{available_profiles, find_profile}, + session::select_resume_session, }; fn main() -> Result<()> { @@ -44,6 +46,15 @@ fn main() -> Result<()> { return Ok(()); } + if let Some(Command::Resume(args)) = &cli.command { + let Some(record) = select_resume_session(args)? else { + return Ok(()); + }; + + let mut app = App::resume(config, record, !cli.no_mouse)?; + return app.run(); + } + if onboarding::should_run(&cli, &config) && onboarding::run(&mut config, cli.config.as_deref())? == OnboardingResult::Quit { diff --git a/src/onboarding.rs b/src/onboarding.rs index 235a556..5de2bff 100644 --- a/src/onboarding.rs +++ b/src/onboarding.rs @@ -56,7 +56,8 @@ struct TerminalChoice { } pub fn should_run(cli: &Cli, config: &Config) -> bool { - cli.profile.is_none() + cli.command.is_none() + && cli.profile.is_none() && env::var_os("GRIDBASH_PROFILE").is_none() && config.defaults.profile.is_none() } diff --git a/src/pty.rs b/src/pty.rs index ac774a2..52c061d 100644 --- a/src/pty.rs +++ b/src/pty.rs @@ -17,6 +17,10 @@ const CURSOR_POSITION_QUERY: &[u8] = b"\x1b[6n"; const PRIMARY_DEVICE_ATTRIBUTES_QUERY: &[u8] = b"\x1b[c"; const PRIMARY_DEVICE_ATTRIBUTES_ZERO_QUERY: &[u8] = b"\x1b[0c"; const MAX_TERMINAL_QUERY_LEN: usize = 4; +const MAX_INPUT_HISTORY: usize = 200; +const MAX_INPUT_LINE_CHARS: usize = 4096; +const MAX_OUTPUT_TAIL_CHARS: usize = 40_000; +const MAX_REPLAY_OUTPUT_CHARS: usize = 18_000; #[derive(Debug, Clone)] pub enum PtyEvent { @@ -42,6 +46,9 @@ pub struct PtyPane { rows: u16, cols: u16, response_scan_tail: Vec, + input_history: Vec, + pending_input: String, + output_tail: String, pub active: bool, pub exited: bool, } @@ -102,6 +109,9 @@ impl PtyPane { rows: 24, cols: 80, response_scan_tail: Vec::new(), + input_history: Vec::new(), + pending_input: String::new(), + output_tail: String::new(), active: false, exited: false, }) @@ -125,10 +135,41 @@ impl PtyPane { pub fn process_output(&mut self, bytes: &[u8]) { self.parser.process(bytes); + self.append_plain_output(bytes); self.active = true; self.answer_terminal_queries(bytes); } + pub fn record_input(&mut self, bytes: &[u8]) { + record_input_bytes(bytes, &mut self.pending_input, &mut self.input_history); + } + + pub fn input_history(&self) -> &[String] { + &self.input_history + } + + pub fn output_tail(&self) -> &str { + &self.output_tail + } + + pub fn restore_history_display(&mut self, output_tail: &str, input_history: &[String]) { + self.output_tail = output_tail.to_string(); + trim_string_tail(&mut self.output_tail, MAX_OUTPUT_TAIL_CHARS); + self.input_history = input_history + .iter() + .filter(|line| !line.trim().is_empty()) + .rev() + .take(MAX_INPUT_HISTORY) + .cloned() + .collect::>(); + self.input_history.reverse(); + + let replay = history_replay_text(&self.output_tail, &self.input_history); + if !replay.is_empty() { + self.parser.process(replay.as_bytes()); + } + } + pub fn write(&self, bytes: &[u8]) -> Result<()> { let mut writer = self.writer.lock().expect("PTY writer lock poisoned"); writer.write_all(bytes).context("failed to write to PTY")?; @@ -206,6 +247,16 @@ impl PtyPane { let _ = self.child.kill(); self.exited = true; } + + fn append_plain_output(&mut self, bytes: &[u8]) { + let plain = plain_terminal_text(bytes); + if plain.is_empty() { + return; + } + + self.output_tail.push_str(&plain); + trim_string_tail(&mut self.output_tail, MAX_OUTPUT_TAIL_CHARS); + } } impl Drop for PtyPane { @@ -265,6 +316,239 @@ fn terminal_query_scan_tail(scan: &[u8]) -> Vec { scan[tail_start..].to_vec() } +fn record_input_bytes(bytes: &[u8], pending_input: &mut String, input_history: &mut Vec) { + let mut index = 0; + while index < bytes.len() { + let byte = bytes[index]; + if byte == 0x1b { + index = skip_escape_sequence(bytes, index); + continue; + } + + match byte { + b'\r' | b'\n' => finish_pending_input(pending_input, input_history), + 0x08 | 0x7f => { + pending_input.pop(); + } + b'\t' => push_pending_input(pending_input, '\t'), + 0x20..=0x7e => push_pending_input(pending_input, byte as char), + _ => {} + } + index += 1; + } +} + +fn push_pending_input(pending_input: &mut String, ch: char) { + if pending_input.chars().count() < MAX_INPUT_LINE_CHARS { + pending_input.push(ch); + } +} + +fn finish_pending_input(pending_input: &mut String, input_history: &mut Vec) { + let line = pending_input.trim().to_string(); + pending_input.clear(); + if line.is_empty() { + return; + } + + input_history.push(line); + if input_history.len() > MAX_INPUT_HISTORY { + let extra = input_history.len() - MAX_INPUT_HISTORY; + input_history.drain(..extra); + } +} + +fn history_replay_text(output_tail: &str, input_history: &[String]) -> String { + if output_tail.trim().is_empty() && input_history.is_empty() { + return String::new(); + } + + let mut replay = String::from( + "\x1b[90mGridBash resumed pane history. Commands were not replayed.\x1b[0m\r\n", + ); + + if !input_history.is_empty() { + replay.push_str("\x1b[36mprevious commands\x1b[0m\r\n"); + for line in input_history + .iter() + .rev() + .take(12) + .collect::>() + .into_iter() + .rev() + { + replay.push_str("> "); + replay.push_str(line); + replay.push_str("\r\n"); + } + } + + let output = tail_chars(output_tail, MAX_REPLAY_OUTPUT_CHARS); + if !output.trim().is_empty() { + if !input_history.is_empty() { + replay.push_str("\r\n"); + } + replay.push_str("\x1b[36mlast output\x1b[0m\r\n"); + replay.push_str(&output.replace('\n', "\r\n")); + replay.push_str("\r\n"); + } + + replay +} + +fn plain_terminal_text(bytes: &[u8]) -> String { + let raw = String::from_utf8_lossy(bytes); + let mut plain = String::new(); + let chars = raw.chars().collect::>(); + let mut index = 0; + + while index < chars.len() { + let ch = chars[index]; + if ch == '\x1b' { + index = skip_escape_chars(&chars, index); + continue; + } + + match ch { + '\r' => { + if !plain.ends_with('\n') { + plain.push('\n'); + } + } + '\n' => { + if !plain.ends_with('\n') { + plain.push('\n'); + } + } + '\t' => plain.push(ch), + '\x08' => { + plain.pop(); + } + ch if ch.is_control() => {} + ch => plain.push(ch), + } + index += 1; + } + + plain +} + +fn skip_escape_sequence(bytes: &[u8], start: usize) -> usize { + let mut index = start.saturating_add(1); + if index >= bytes.len() { + return index; + } + + match bytes[index] { + b'[' => { + index += 1; + while index < bytes.len() { + let byte = bytes[index]; + index += 1; + if (0x40..=0x7e).contains(&byte) { + break; + } + } + } + b'O' => { + index += 1; + if index < bytes.len() { + index += 1; + } + } + b']' => { + index += 1; + while index < bytes.len() { + if bytes[index] == 0x07 { + index += 1; + break; + } + if bytes[index] == 0x1b && index + 1 < bytes.len() && bytes[index + 1] == b'\\' { + index += 2; + break; + } + index += 1; + } + } + _ => index += 1, + } + + index +} + +fn skip_escape_chars(chars: &[char], start: usize) -> usize { + let mut index = start.saturating_add(1); + if index >= chars.len() { + return index; + } + + match chars[index] { + '[' => { + index += 1; + while index < chars.len() { + let ch = chars[index]; + index += 1; + if ('\u{40}'..='\u{7e}').contains(&ch) { + break; + } + } + } + 'O' => { + index += 1; + if index < chars.len() { + index += 1; + } + } + ']' => { + index += 1; + while index < chars.len() { + if chars[index] == '\x07' { + index += 1; + break; + } + if chars[index] == '\x1b' && index + 1 < chars.len() && chars[index + 1] == '\\' { + index += 2; + break; + } + index += 1; + } + } + _ => index += 1, + } + + index +} + +fn trim_string_tail(value: &mut String, max_chars: usize) { + let char_count = value.chars().count(); + if char_count <= max_chars { + return; + } + + let keep_from = char_count - max_chars; + let byte_index = value + .char_indices() + .nth(keep_from) + .map(|(index, _)| index) + .unwrap_or(0); + value.drain(..byte_index); +} + +fn tail_chars(value: &str, max_chars: usize) -> String { + let char_count = value.chars().count(); + if char_count <= max_chars { + return value.to_string(); + } + + let keep_from = char_count - max_chars; + let byte_index = value + .char_indices() + .nth(keep_from) + .map(|(index, _)| index) + .unwrap_or(0); + value[byte_index..].to_string() +} + #[cfg(test)] mod tests { use std::{ @@ -298,6 +582,26 @@ mod tests { assert_eq!(count_sequence(&scan, CURSOR_POSITION_QUERY), 0); } + #[test] + fn records_entered_command_lines() { + let mut pending = String::new(); + let mut history = Vec::new(); + + record_input_bytes(b"cargo test\r", &mut pending, &mut history); + record_input_bytes(b"\x1b[A", &mut pending, &mut history); + record_input_bytes(b"\x1bOP", &mut pending, &mut history); + record_input_bytes(b"git status\x7f\x7f\r", &mut pending, &mut history); + + assert_eq!(history, ["cargo test", "git stat"]); + } + + #[test] + fn strips_escape_sequences_from_output_history() { + let plain = plain_terminal_text(b"\x1b[31mred\x1b[0m\r\nok\x1b]0;title\x07"); + + assert_eq!(plain, "red\nok"); + } + #[test] #[ignore = "Windows ConPTY smoke test requires an interactive console; run manually when debugging PTY I/O"] fn spawned_pty_receives_output_and_input() { diff --git a/src/session.rs b/src/session.rs new file mode 100644 index 0000000..de881f1 --- /dev/null +++ b/src/session.rs @@ -0,0 +1,513 @@ +use std::{ + collections::BTreeSet, + fs, + io::{self, Write}, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, +}; + +use anyhow::{Context, Result, anyhow, bail}; +use directories::ProjectDirs; +use serde::{Deserialize, Serialize}; + +use crate::{ + cli::ResumeArgs, + layout::GridSize, + profiles::Profile, + pty::PtyPane, + setup::{LaunchPlan, PaneLaunchSpec}, +}; + +const SESSION_VERSION: u16 = 1; +const MAX_SAVED_SESSIONS: usize = 50; + +#[derive(Debug, Clone)] +pub struct SessionRecord { + pub path: PathBuf, + pub session: SavedSession, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SavedSession { + pub version: u16, + pub id: String, + pub started_at: u64, + pub updated_at: u64, + pub grid: SavedGrid, + #[serde(default)] + pub panes: Vec, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct SavedGrid { + pub rows: usize, + pub columns: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SavedPane { + pub index: usize, + pub profile_name: String, + pub command: Profile, + pub cwd: PathBuf, + pub folder_name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worktree_name: Option, + #[serde(default)] + pub history: SavedPaneHistory, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SavedPaneHistory { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub input_history: Vec, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub output_tail: String, +} + +pub struct SessionRecorder { + path: PathBuf, + session: SavedSession, +} + +impl SessionRecord { + pub fn summary(&self) -> String { + self.session.summary() + } +} + +impl SavedSession { + pub fn launch_plan(&self) -> Result { + let grid = GridSize::new(self.grid.rows, self.grid.columns).ok_or_else(|| { + anyhow!( + "saved session {} has invalid grid {}x{}", + self.id, + self.grid.rows, + self.grid.columns + ) + })?; + + let panes = self + .panes + .iter() + .map(|pane| PaneLaunchSpec { + profile_name: pane.profile_name.clone(), + command: pane.command.clone(), + cwd: pane.cwd.clone(), + folder_name: pane.folder_name.clone(), + worktree_name: pane.worktree_name.clone(), + }) + .collect::>(); + + if panes.is_empty() { + bail!("saved session {} has no panes", self.id); + } + + Ok(LaunchPlan { panes, grid }) + } + + pub fn pane_histories(&self) -> Vec { + self.panes.iter().map(|pane| pane.history.clone()).collect() + } + + fn new(id: String, plan: &LaunchPlan) -> Self { + let now = now_seconds(); + Self { + version: SESSION_VERSION, + id, + started_at: now, + updated_at: now, + grid: plan.grid.into(), + panes: plan + .panes + .iter() + .enumerate() + .map(|(index, spec)| SavedPane::from_spec(index, spec, SavedPaneHistory::default())) + .collect(), + } + } + + fn update_from_live(&mut self, plan: &LaunchPlan, panes: &[PtyPane]) { + self.version = SESSION_VERSION; + self.updated_at = now_seconds(); + self.grid = plan.grid.into(); + self.panes = plan + .panes + .iter() + .enumerate() + .map(|(index, spec)| { + let history = panes + .get(index) + .map(SavedPaneHistory::from_pane) + .unwrap_or_default(); + SavedPane::from_spec(index, spec, history) + }) + .collect(); + } + + fn summary(&self) -> String { + let folders = compact_labels( + self.panes + .iter() + .map(|pane| pane.folder_name.as_str()) + .filter(|name| !name.is_empty()), + ); + let profiles = compact_labels( + self.panes + .iter() + .map(|pane| pane.profile_name.as_str()) + .filter(|name| !name.is_empty()), + ); + + format!( + "{} | {}x{} | {} pane{} | {} | {}", + age_label(self.updated_at), + self.grid.rows, + self.grid.columns, + self.panes.len(), + if self.panes.len() == 1 { "" } else { "s" }, + folders.unwrap_or_else(|| "unknown folders".into()), + profiles.unwrap_or_else(|| "unknown profiles".into()) + ) + } +} + +impl SavedPane { + fn from_spec(index: usize, spec: &PaneLaunchSpec, history: SavedPaneHistory) -> Self { + Self { + index, + profile_name: spec.profile_name.clone(), + command: spec.command.clone(), + cwd: spec.cwd.clone(), + folder_name: spec.folder_name.clone(), + worktree_name: spec.worktree_name.clone(), + history, + } + } +} + +impl SavedPaneHistory { + fn from_pane(pane: &PtyPane) -> Self { + Self { + input_history: pane.input_history().to_vec(), + output_tail: pane.output_tail().to_string(), + } + } +} + +impl From for SavedGrid { + fn from(grid: GridSize) -> Self { + Self { + rows: grid.rows, + columns: grid.columns, + } + } +} + +impl SessionRecorder { + pub fn start_new(plan: &LaunchPlan) -> Result { + let id = new_session_id(); + let path = session_file_path(&id)?; + let recorder = Self { + path, + session: SavedSession::new(id, plan), + }; + recorder.save()?; + prune_old_sessions()?; + Ok(recorder) + } + + pub fn continue_record(record: SessionRecord) -> Self { + Self { + path: record.path, + session: record.session, + } + } + + pub fn update(&mut self, plan: &LaunchPlan, panes: &[PtyPane]) { + self.session.update_from_live(plan, panes); + } + + pub fn save(&self) -> Result<()> { + if let Some(parent) = self.path.parent() { + fs::create_dir_all(parent).with_context(|| { + format!("failed to create session directory {}", parent.display()) + })?; + } + + let raw = toml::to_string_pretty(&self.session).context("failed to serialize session")?; + fs::write(&self.path, raw) + .with_context(|| format!("failed to write session {}", self.path.display())) + } +} + +pub fn select_resume_session(args: &ResumeArgs) -> Result> { + let sessions = load_recent_sessions()?; + if args.list { + print_sessions(&sessions); + return Ok(None); + } + + if sessions.is_empty() { + println!("gridbash: no saved sessions found"); + return Ok(None); + } + + if let Some(query) = args.session.as_deref() { + return find_session(&sessions, query).map(Some); + } + + if args.latest || sessions.len() == 1 { + return Ok(sessions.into_iter().next()); + } + + prompt_for_session(&sessions) +} + +pub fn load_recent_sessions() -> Result> { + let directory = sessions_dir()?; + if !directory.exists() { + return Ok(Vec::new()); + } + + let mut sessions = Vec::new(); + for entry in fs::read_dir(&directory) + .with_context(|| format!("failed to read session directory {}", directory.display()))? + { + let Ok(entry) = entry else { + continue; + }; + let path = entry.path(); + if path.extension().and_then(|value| value.to_str()) != Some("toml") { + continue; + } + + let Ok(raw) = fs::read_to_string(&path) else { + continue; + }; + let Ok(session) = toml::from_str::(&raw) else { + continue; + }; + if session.version == SESSION_VERSION && !session.id.is_empty() { + sessions.push(SessionRecord { path, session }); + } + } + + sessions.sort_by(|left, right| { + right + .session + .updated_at + .cmp(&left.session.updated_at) + .then_with(|| right.session.started_at.cmp(&left.session.started_at)) + .then_with(|| right.session.id.cmp(&left.session.id)) + }); + Ok(sessions) +} + +fn print_sessions(sessions: &[SessionRecord]) { + if sessions.is_empty() { + println!("gridbash: no saved sessions found"); + return; + } + + for record in sessions { + println!("{}\t{}", record.session.id, record.summary()); + } +} + +fn find_session(sessions: &[SessionRecord], query: &str) -> Result { + let matches = sessions + .iter() + .filter(|record| record.session.id == query || record.session.id.starts_with(query)) + .cloned() + .collect::>(); + + match matches.len() { + 0 => bail!("no saved session matches '{query}'"), + 1 => Ok(matches[0].clone()), + _ => bail!("session id prefix '{query}' is ambiguous"), + } +} + +fn prompt_for_session(sessions: &[SessionRecord]) -> Result> { + println!("Recent GridBash sessions:"); + for (index, record) in sessions.iter().take(20).enumerate() { + println!( + "{:>2}. {} {}", + index + 1, + record.session.id, + record.summary() + ); + } + + print!("Select session [1], or q to cancel: "); + io::stdout().flush().context("failed to flush stdout")?; + + let mut input = String::new(); + io::stdin() + .read_line(&mut input) + .context("failed to read session selection")?; + let input = input.trim(); + if input.eq_ignore_ascii_case("q") { + return Ok(None); + } + + let selected = if input.is_empty() { + 1 + } else { + input + .parse::() + .with_context(|| format!("invalid session selection '{input}'"))? + }; + + if selected == 0 || selected > sessions.len().min(20) { + bail!("session selection out of range: {selected}"); + } + + Ok(Some(sessions[selected - 1].clone())) +} + +fn prune_old_sessions() -> Result<()> { + let sessions = load_recent_sessions()?; + for record in sessions.into_iter().skip(MAX_SAVED_SESSIONS) { + let _ = fs::remove_file(record.path); + } + Ok(()) +} + +fn sessions_dir() -> Result { + ProjectDirs::from("", "", "GridBash") + .map(|dirs| dirs.data_local_dir().join("sessions")) + .ok_or_else(|| anyhow!("failed to resolve GridBash session directory")) +} + +fn session_file_path(id: &str) -> Result { + Ok(sessions_dir()?.join(format!("{id}.toml"))) +} + +fn new_session_id() -> String { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + format!("{millis}-{}", std::process::id()) +} + +fn now_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn age_label(updated_at: u64) -> String { + let elapsed = now_seconds().saturating_sub(updated_at); + if elapsed < 60 { + return format!("{elapsed}s ago"); + } + + let minutes = elapsed / 60; + if minutes < 60 { + return format!("{minutes}m ago"); + } + + let hours = minutes / 60; + if hours < 48 { + return format!("{hours}h ago"); + } + + let days = hours / 24; + format!("{days}d ago") +} + +fn compact_labels<'a>(labels: impl Iterator) -> Option { + let unique = labels.collect::>(); + if unique.is_empty() { + return None; + } + + let shown = unique.iter().take(3).copied().collect::>(); + let extra = unique.len().saturating_sub(shown.len()); + let mut label = shown.join(", "); + if extra > 0 { + label.push_str(&format!(" +{extra}")); + } + Some(label) +} + +#[cfg(test)] +mod tests { + use std::env; + + use super::*; + + #[test] + fn saved_session_restores_launch_plan() { + let cwd = env::current_dir().expect("cwd"); + let grid = GridSize { + rows: 1, + columns: 2, + }; + let plan = LaunchPlan::legacy( + "cmd".into(), + Profile { + command: "cmd".into(), + args: Vec::new(), + title: Some("cmd".into()), + }, + cwd.clone(), + 2, + grid, + ); + + let session = SavedSession::new("test".into(), &plan); + let restored = session.launch_plan().expect("launch plan"); + + assert_eq!(restored.grid, grid); + assert_eq!(restored.panes.len(), 2); + assert_eq!(restored.panes[0].profile_name, "cmd"); + assert_eq!(restored.panes[0].cwd, cwd); + } + + #[test] + fn summarizes_unique_folders_and_profiles() { + let session = SavedSession { + version: SESSION_VERSION, + id: "test".into(), + started_at: now_seconds(), + updated_at: now_seconds(), + grid: SavedGrid { + rows: 2, + columns: 2, + }, + panes: vec![ + pane("one", "claude"), + pane("two", "codex"), + pane("one", "claude"), + ], + }; + + let summary = session.summary(); + + assert!(summary.contains("2x2")); + assert!(summary.contains("3 panes")); + assert!(summary.contains("one, two")); + assert!(summary.contains("claude, codex")); + } + + fn pane(folder_name: &str, profile_name: &str) -> SavedPane { + SavedPane { + index: 0, + profile_name: profile_name.into(), + command: Profile { + command: profile_name.into(), + args: Vec::new(), + title: None, + }, + cwd: PathBuf::from("."), + folder_name: folder_name.into(), + worktree_name: None, + history: SavedPaneHistory::default(), + } + } +}