Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <session-id>
```

Start in a repo:

```powershell
Expand All @@ -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
Expand Down
29 changes: 29 additions & 0 deletions docs/devlogs/2026-07-07-add-grid-session-resume-command.md
Original file line number Diff line number Diff line change
@@ -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 <session-id>`.
- 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.
26 changes: 26 additions & 0 deletions docs/devlogs/2026-07-07-integrate-session-resume-branch.md
Original file line number Diff line number Diff line change
@@ -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.
137 changes: 113 additions & 24 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -56,6 +57,8 @@ pub struct App {
settings: SettingsState,
rename: RenamePaneState,
status: String,
restored_histories: Vec<SavedPaneHistory>,
session_recorder: Option<SessionRecorder>,
next_pane_id: usize,
event_tx: mpsc::UnboundedSender<PtyEvent>,
event_rx: mpsc::UnboundedReceiver<PtyEvent>,
Expand All @@ -66,6 +69,17 @@ pub struct App {
last_activity_decay: Instant,
}

struct AppInit {
config: Config,
worktrees: Option<ManagedWorktreeOptions>,
launch_plan: Option<LaunchPlan>,
grid: GridSize,
mouse_enabled: bool,
restored_histories: Vec<SavedPaneHistory>,
session_recorder: Option<SessionRecorder>,
status: String,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum KeyOutcome {
Continue,
Expand Down Expand Up @@ -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<Self> {
let worktrees = cli
Expand All @@ -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<Self> {
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,
Expand All @@ -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,
Expand All @@ -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<()> {
Expand All @@ -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<()> {
Expand All @@ -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) {
Expand All @@ -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;
Expand All @@ -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(())
}
Expand Down Expand Up @@ -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(())
Expand Down Expand Up @@ -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(())
}
Expand Down Expand Up @@ -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<GridSize> {
Expand Down
Loading