diff --git a/docs/devlogs/2026-07-09-restore-startup-grid-picker.md b/docs/devlogs/2026-07-09-restore-startup-grid-picker.md new file mode 100644 index 0000000..6623465 --- /dev/null +++ b/docs/devlogs/2026-07-09-restore-startup-grid-picker.md @@ -0,0 +1,32 @@ +# Restore startup grid picker + +Date: 2026-07-09 +Release target: unreleased + +## Summary + +- Restored the no-argument startup grid picker so the installed command matches the README and release notes. +- Fixes #114. + +## What Changed + +- Replaced the stale multi-step composer flow with the fullscreen row/column picker. +- Restored the live 2x3 default preview, numeric dimension controls, and direct launch plan creation from the configured default profile. +- Removed stale guided-composer setup helpers that were no longer used. + +## Why It Matters + +- Running `gridbash` now starts with the expected grid-size choice instead of the older folder/profile/preview wizard. +- Users can see and adjust the pane layout before spawning terminals. + +## Validation + +- `cargo fmt --check` +- `cargo clippy -- -D warnings` +- `cargo test` +- `cargo build --release` +- `npm run test:launcher` + +## Release Notes + +- Plain `gridbash` once again opens the startup grid picker with a live preview when no direct launch arguments are supplied. diff --git a/src/composer.rs b/src/composer.rs index 41fcaa9..00c574d 100644 --- a/src/composer.rs +++ b/src/composer.rs @@ -1,45 +1,46 @@ -use std::{collections::BTreeSet, io::Stdout, path::PathBuf, time::Duration}; +use std::{ + env, + io::Stdout, + path::{Path, PathBuf}, + time::Duration, +}; -use anyhow::Result; +use anyhow::{Context, Result}; use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind}; use ratatui::{ Frame, Terminal, backend::CrosstermBackend, - layout::{Constraint, Direction, Layout, Rect}, + layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Paragraph}, + widgets::{Block, Borders, Clear, Paragraph}, }; -use crate::{ - config::Config, - setup::{LaunchPlan, LaunchSelection, launch_selection_from}, - vibe::{self, VibeProfile}, - worktrees::ManagedWorktreeOptions, -}; +use crate::worktrees::ManagedWorktreeOptions; +use crate::{config::Config, layout::GridSize, profiles::find_profile, setup::LaunchPlan}; type ComposerTerminal = Terminal>; +const DEFAULT_ROWS: usize = 2; +const DEFAULT_COLUMNS: usize = 3; +const MAX_DIMENSION: usize = 10; +const PREVIEW_BORDER: Color = Color::Rgb(95, 214, 150); +const PREVIEW_LIGHT: Color = Color::Rgb(43, 128, 84); +const PREVIEW_MID: Color = Color::Rgb(28, 96, 65); +const PREVIEW_DARK: Color = Color::Rgb(15, 65, 49); + pub struct Composer { + current_dir: PathBuf, worktrees: Option, - stage: Stage, - vibe_profiles: Vec, - vibe_error: Option, - folders: Vec, - folder_cursor: usize, - path_input: String, - agent_cursor: usize, - selected_agents: BTreeSet, - preview_selection: Option, - status: String, + rows: usize, + columns: usize, + active_field: DimensionField, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Stage { - Folders, - PathInput, - Agents, - Preview, +enum DimensionField { + Rows, + Columns, } enum ComposerEvent { @@ -50,31 +51,19 @@ enum ComposerEvent { impl Composer { pub fn new(current_dir: PathBuf, worktrees: Option) -> Self { - let (vibe_profiles, vibe_error) = match vibe::load_profiles() { - Ok(profiles) => (profiles, None), - Err(error) => (Vec::new(), Some(format!("{error:#}"))), - }; - let selected_agents = default_selected_agents(&vibe_profiles); - Self { + current_dir, worktrees, - stage: Stage::Folders, - vibe_profiles, - vibe_error, - folders: vec![current_dir], - folder_cursor: 0, - path_input: String::new(), - agent_cursor: 0, - selected_agents, - preview_selection: None, - status: "Folders: Enter continues | a adds | d removes".into(), + rows: DEFAULT_ROWS, + columns: DEFAULT_COLUMNS, + active_field: DimensionField::Rows, } } pub fn run( &mut self, terminal: &mut ComposerTerminal, - _config: &Config, + config: &Config, ) -> Result> { loop { terminal.draw(|frame| self.draw(frame))?; @@ -83,10 +72,10 @@ impl Composer { continue; } - let event = event::read()?; - let result = match event { - Event::Key(key) if key.kind == KeyEventKind::Press => self.handle_key(key)?, - Event::Paste(text) => self.handle_paste(text), + let result = match event::read()? { + Event::Key(key) if key.kind == KeyEventKind::Press => { + self.handle_key(key, config)? + } _ => ComposerEvent::Continue, }; @@ -98,394 +87,330 @@ impl Composer { } } - fn handle_key(&mut self, key: KeyEvent) -> Result { - if matches!(key.code, KeyCode::Char('q')) && self.stage != Stage::PathInput { - return Ok(ComposerEvent::Quit); - } - - match self.stage { - Stage::Folders => self.handle_folders_key(key), - Stage::PathInput => self.handle_path_input_key(key), - Stage::Agents => self.handle_agents_key(key), - Stage::Preview => self.handle_preview_key(key), - } - } - - fn handle_paste(&mut self, text: String) -> ComposerEvent { - if self.stage == Stage::PathInput { - self.path_input.push_str(text.trim()); - } - ComposerEvent::Continue - } - - fn handle_folders_key(&mut self, key: KeyEvent) -> Result { - if let Some(error) = &self.vibe_error { - self.status = format!("vibe is required before launch: {error}"); - return Ok(ComposerEvent::Continue); - } - + fn handle_key(&mut self, key: KeyEvent, config: &Config) -> Result { match key.code { - KeyCode::Up => self.folder_cursor = self.folder_cursor.saturating_sub(1), - KeyCode::Down => { - self.folder_cursor = (self.folder_cursor + 1).min(self.folders.len()); - } - KeyCode::Char('a') => { - self.path_input.clear(); - self.stage = Stage::PathInput; - self.status = "Paste or type a folder path, then Enter".into(); - } - KeyCode::Char('d') => { - if self.folders.len() > 1 && self.folder_cursor < self.folders.len() { - self.folders.remove(self.folder_cursor); - self.folder_cursor = self.folder_cursor.min(self.folders.len() - 1); - } + KeyCode::Esc | KeyCode::Char('q') => Ok(ComposerEvent::Quit), + KeyCode::Enter => self.launch_plan(config).map(ComposerEvent::Launch), + KeyCode::Left | KeyCode::Char('h') | KeyCode::BackTab => { + self.active_field = DimensionField::Rows; + Ok(ComposerEvent::Continue) } - KeyCode::Enter | KeyCode::Right => { - if self.folder_cursor >= self.folders.len() { - self.path_input.clear(); - self.stage = Stage::PathInput; - } else { - self.stage = Stage::Agents; - self.status = "Agents: Space toggles | Enter previews".into(); - } + KeyCode::Right | KeyCode::Char('l') | KeyCode::Tab => { + self.active_field = DimensionField::Columns; + Ok(ComposerEvent::Continue) } - KeyCode::Esc => self.status = "Press q to quit".into(), - _ => {} - } - - Ok(ComposerEvent::Continue) - } - - fn handle_path_input_key(&mut self, key: KeyEvent) -> Result { - match key.code { - KeyCode::Enter => { - let candidate = PathBuf::from(self.path_input.trim()); - if let Ok(path) = candidate.canonicalize() { - if path.is_dir() { - if !self.folders.iter().any(|folder| folder == &path) { - self.folders.push(path); - } - self.folder_cursor = self.folders.len().saturating_sub(1); - self.path_input.clear(); - self.stage = Stage::Folders; - self.status = "Folder added".into(); - } else { - self.status = "Path exists, but it is not a folder".into(); - } - } else { - self.status = "Folder path does not exist".into(); - } + KeyCode::Up | KeyCode::Char('+') | KeyCode::Char('=') | KeyCode::Char('k') => { + self.adjust_active(1); + Ok(ComposerEvent::Continue) } - KeyCode::Esc => { - self.path_input.clear(); - self.stage = Stage::Folders; + KeyCode::Down | KeyCode::Char('-') | KeyCode::Char('j') => { + self.adjust_active(-1); + Ok(ComposerEvent::Continue) } - KeyCode::Backspace => { - self.path_input.pop(); + KeyCode::Char('r') => { + self.active_field = DimensionField::Rows; + Ok(ComposerEvent::Continue) } - KeyCode::Char(ch) => self.path_input.push(ch), - _ => {} - } - - Ok(ComposerEvent::Continue) - } - - fn handle_agents_key(&mut self, key: KeyEvent) -> Result { - let max = self.vibe_profiles.len().saturating_sub(1); - match key.code { - KeyCode::Up => self.agent_cursor = self.agent_cursor.saturating_sub(1), - KeyCode::Down => self.agent_cursor = (self.agent_cursor + 1).min(max), - KeyCode::Char(' ') => self.toggle_agent(self.agent_cursor), - KeyCode::Char('a') => self.select_all_ready_agents(), - KeyCode::Enter | KeyCode::Right => { - let agents = self.selected_agent_names(); - if agents.is_empty() { - self.status = "Select at least one logged-in vibe profile".into(); - } else { - self.preview_selection = - Some(launch_selection_from(self.folders.clone(), agents)?); - self.stage = Stage::Preview; - self.status = "Enter launches | Esc returns".into(); - } + KeyCode::Char('c') => { + self.active_field = DimensionField::Columns; + Ok(ComposerEvent::Continue) } - KeyCode::Esc => self.stage = Stage::Folders, - _ => {} - } - - Ok(ComposerEvent::Continue) - } - - fn handle_preview_key(&mut self, key: KeyEvent) -> Result { - match key.code { - KeyCode::Enter => self.launch_preview(), - KeyCode::Esc => { - self.stage = Stage::Agents; + KeyCode::Char(ch) if ch.is_ascii_digit() => { + self.set_active_from_digit(ch); Ok(ComposerEvent::Continue) } _ => Ok(ComposerEvent::Continue), } } - fn launch_preview(&mut self) -> Result { - let Some(preview) = &self.preview_selection else { - self.status = "No launch preview selected".into(); - return Ok(ComposerEvent::Continue); + fn adjust_active(&mut self, delta: isize) { + let value = match self.active_field { + DimensionField::Rows => &mut self.rows, + DimensionField::Columns => &mut self.columns, }; - - match preview.launch_plan(self.worktrees.as_ref()) { - Ok(plan) => Ok(ComposerEvent::Launch(plan)), - Err(error) => { - self.status = format!("{error:#}"); - Ok(ComposerEvent::Continue) - } - } + *value = (*value as isize + delta).clamp(1, MAX_DIMENSION as isize) as usize; } - fn toggle_agent(&mut self, index: usize) { - let Some(profile) = self.vibe_profiles.get(index) else { + fn set_active_from_digit(&mut self, ch: char) { + let Some(mut value) = ch.to_digit(10).map(|digit| digit as usize) else { return; }; - if !profile.ready { - self.status = format!("{} is not logged in", profile.name); - return; + if value == 0 { + value = MAX_DIMENSION; } - if !self.selected_agents.insert(index) { - self.selected_agents.remove(&index); + match self.active_field { + DimensionField::Rows => self.rows = value.min(MAX_DIMENSION), + DimensionField::Columns => self.columns = value.min(MAX_DIMENSION), } } - fn select_all_ready_agents(&mut self) { - self.selected_agents = self - .vibe_profiles - .iter() - .enumerate() - .filter_map(|(index, profile)| profile.ready.then_some(index)) - .collect(); - self.status = format!("Selected {} ready agents", self.selected_agents.len()); - } - - fn selected_agent_names(&self) -> Vec { - self.selected_agents - .iter() - .filter_map(|index| self.vibe_profiles.get(*index)) - .filter(|profile| profile.ready) - .map(|profile| profile.name.clone()) - .collect() + fn launch_plan(&self, config: &Config) -> Result { + let grid = GridSize::new(self.rows, self.columns).context("invalid grid dimensions")?; + let profile_name = startup_profile_name(config); + let profile = find_profile(config, &profile_name)?; + + LaunchPlan::from_launch_options( + profile_name, + profile, + self.current_dir.clone(), + grid.count(), + grid, + self.worktrees.as_ref(), + ) } fn draw(&self, frame: &mut Frame<'_>) { let area = frame.area(); + frame.render_widget(background(), area); + + let panel = area; + frame.render_widget(Clear, panel); + let block = Block::default() + .borders(Borders::ALL) + .title(" GridBash Startup ") + .border_style(Style::default().fg(Color::Cyan)) + .style(panel_style()); + let inner = block.inner(panel); + frame.render_widget(block, panel); + let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(3), - Constraint::Min(5), - Constraint::Length(1), + Constraint::Min(8), + Constraint::Length(5), ]) - .split(area); - - frame.render_widget(header(), chunks[0]); - match self.stage { - Stage::Folders => self.draw_folders(frame, chunks[1]), - Stage::PathInput => self.draw_path_input(frame, chunks[1]), - Stage::Agents => self.draw_agents(frame, chunks[1]), - Stage::Preview => self.draw_preview(frame, chunks[1]), - } - frame.render_widget(status_bar(&self.status), chunks[2]); - } + .split(inner); - fn draw_folders(&self, frame: &mut Frame<'_>, area: Rect) { - if let Some(error) = &self.vibe_error { - let lines = vec![ - Line::from(Span::styled( - "vibe is required for orchestration", - Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), - )), - Line::from(""), - Line::from(error.clone()), - Line::from(""), - Line::from("Install or repair vibe, then reopen GridBash."), - Line::from("Press q to quit."), - ]; - frame.render_widget(panel("Launch", lines), area); - return; - } - - let mut lines = vec![Line::from("Choose where agents should work.")]; - lines.push(Line::from("")); - for (index, folder) in self.folders.iter().enumerate() { - lines.push(selectable_line( - self.folder_cursor == index, - &format!("{} {}", index + 1, folder.display()), - )); - } - lines.push(selectable_line( - self.folder_cursor >= self.folders.len(), - "+ Add folder", - )); - lines.push(Line::from("")); - lines.push(Line::from( - "Enter continues on a folder row. Use a to add, d to remove.", - )); - - frame.render_widget(panel("Folders", lines), area); + self.draw_header(frame, chunks[0]); + self.draw_preview(frame, chunks[1]); + self.draw_controls(frame, chunks[2]); } - fn draw_path_input(&self, frame: &mut Frame<'_>, area: Rect) { + fn draw_header(&self, frame: &mut Frame<'_>, area: Rect) { + let cwd = display_path(&self.current_dir); let lines = vec![ - Line::from("Paste or type a folder path."), - Line::from(""), Line::from(vec![ - Span::styled("> ", Style::default().fg(Color::Cyan)), - Span::raw(self.path_input.clone()), + Span::styled( + "Choose grid dimensions", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled( + format!("{} panes", self.rows * self.columns), + Style::default().fg(Color::Yellow), + ), + ]), + Line::from(vec![ + Span::styled("cwd ", Style::default().fg(Color::DarkGray)), + Span::styled(cwd, Style::default().fg(Color::Gray)), ]), - Line::from(""), - Line::from("Enter adds the folder. Esc returns."), ]; - frame.render_widget(panel("Add Folder", lines), area); - } - - fn draw_agents(&self, frame: &mut Frame<'_>, area: Rect) { - let mut lines = vec![Line::from("Choose the vibe profiles to launch.")]; - lines.push(Line::from("")); - for (index, profile) in self.vibe_profiles.iter().enumerate() { - let selected = self.selected_agents.contains(&index); - let marker = if selected { "[x]" } else { "[ ]" }; - let status_style = if profile.ready { - Style::default().fg(Color::Green) - } else { - Style::default().fg(Color::Red) - }; - let prefix = if self.agent_cursor == index { - "> " - } else { - " " - }; - lines.push(Line::from(vec![ - Span::styled(prefix, cursor_style(self.agent_cursor == index)), - Span::raw(format!("{marker} {:<18}", profile.name)), - Span::styled(profile.status.clone(), status_style), - ])); - } - lines.push(Line::from("")); - lines.push(Line::from( - "Space toggles. a selects all ready agents. Enter previews.", - )); - - frame.render_widget(panel("Agents", lines), area); + frame.render_widget(Paragraph::new(lines).style(panel_style()), area); } fn draw_preview(&self, frame: &mut Frame<'_>, area: Rect) { - let mut lines = Vec::new(); - let Some(preview) = &self.preview_selection else { - frame.render_widget( - panel("Preview", vec![Line::from("No launch preview selected")]), - area, - ); - return; + let preview_area = inset(area, 1, 0); + let grid = GridSize { + rows: self.rows, + columns: self.columns, }; + let rects = square_preview_rects(preview_area, grid); - lines.push(Line::from("Launch preview")); - lines.push(Line::from("")); - match preview.launch_plan(self.worktrees.as_ref()) { - Ok(plan) => { - for (index, pane) in plan.panes.iter().enumerate() { - lines.push(Line::from(format!( - "Pane {:>2}: {:<18} -> {} ({})", - index + 1, - pane.profile_name, - pane.folder_name, - pane.cwd.display() - ))); - } - lines.push(Line::from("")); - lines.push(Line::from(format!( - "Grid: {} pane(s), auto layout {}x{}", - plan.panes.len(), - plan.grid.rows, - plan.grid.columns - ))); + for (index, rect) in rects.into_iter().enumerate() { + if rect.width == 0 || rect.height == 0 { + continue; } - Err(error) => lines.push(Line::from(Span::styled( - format!("{error:#}"), - Style::default().fg(Color::Red), - ))), + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(PREVIEW_BORDER)) + .style(Style::default().bg(PREVIEW_DARK)); + let inner = block.inner(rect); + frame.render_widget(block, rect); + frame.render_widget(dithered_fill(index, inner), inner); } - lines.push(Line::from("")); - lines.push(Line::from("Enter launches. Esc returns.")); + } - frame.render_widget(panel("Preview", lines), area); + fn draw_controls(&self, frame: &mut Frame<'_>, area: Rect) { + let lines = vec![ + Line::from(""), + Line::from(vec![ + control_box(self.active_field == DimensionField::Rows, self.rows), + Span::raw(" "), + Span::styled("rows", Style::default().fg(Color::Gray)), + Span::styled(" x ", Style::default().fg(Color::DarkGray)), + control_box(self.active_field == DimensionField::Columns, self.columns), + Span::raw(" "), + Span::styled("cols", Style::default().fg(Color::Gray)), + ]), + Line::from(""), + Line::from(vec![ + Span::styled("Up/Down", Style::default().fg(Color::Yellow)), + Span::raw(" change "), + Span::styled("Left/Right", Style::default().fg(Color::Yellow)), + Span::raw(" switch "), + Span::styled("Enter", Style::default().fg(Color::Yellow)), + Span::raw(" launch"), + ]), + ]; + + frame.render_widget( + Paragraph::new(lines) + .alignment(Alignment::Center) + .style(panel_style()), + area, + ); } } -fn default_selected_agents(profiles: &[VibeProfile]) -> BTreeSet { - profiles - .iter() - .enumerate() - .filter_map(|(index, profile)| profile.ready.then_some(index)) - .take(3) - .collect() +fn display_path(path: &Path) -> String { + let text = path.display().to_string(); + text.strip_prefix(r"\\?\").unwrap_or(&text).to_string() } -fn header<'a>() -> Paragraph<'a> { - Paragraph::new(vec![ - Line::from(vec![Span::styled( - " GridBash ", - Style::default() - .fg(Color::Black) - .bg(Color::Cyan) - .add_modifier(Modifier::BOLD), - )]), - Line::from("Guided agent launch"), - ]) - .style(Style::default().bg(Color::Rgb(11, 15, 20))) +fn startup_profile_name(config: &Config) -> String { + env::var("GRIDBASH_PROFILE") + .ok() + .or_else(|| config.defaults.profile.clone()) + .unwrap_or_else(|| "git-bash".into()) } -fn status_bar<'a>(status: &str) -> Paragraph<'a> { - Paragraph::new(Line::from(vec![ - Span::raw(" "), - Span::styled(status.to_string(), Style::default().fg(Color::Gray)), - ])) - .style(Style::default().bg(Color::Rgb(11, 15, 20))) +fn control_box(active: bool, value: usize) -> Span<'static> { + let style = if active { + Style::default() + .fg(Color::Black) + .bg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + .fg(Color::LightCyan) + .bg(Color::Rgb(20, 35, 44)) + .add_modifier(Modifier::BOLD) + }; + + Span::styled(format!(" {value:>2} "), style) } -fn panel<'a>(title: &'a str, lines: Vec>) -> Paragraph<'a> { - Paragraph::new(lines) - .block( - Block::default() - .borders(Borders::ALL) - .title(format!(" {title} ")) - .border_style(Style::default().fg(Color::DarkGray)), - ) - .style( - Style::default() - .fg(Color::Rgb(230, 237, 243)) - .bg(Color::Rgb(11, 15, 20)), - ) +fn background() -> Paragraph<'static> { + Paragraph::new("").style(Style::default().bg(Color::Rgb(7, 11, 15))) } -fn selectable_line<'a>(selected: bool, text: &str) -> Line<'a> { - Line::from(vec![ - Span::styled(if selected { "> " } else { " " }, cursor_style(selected)), - Span::styled( - text.to_string(), - if selected { - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD) - } else { - Style::default() - }, - ), - ]) +fn panel_style() -> Style { + Style::default() + .fg(Color::Rgb(230, 237, 243)) + .bg(Color::Rgb(11, 15, 20)) } -fn cursor_style(selected: bool) -> Style { - if selected { - Style::default().fg(Color::Yellow) +fn inset(area: Rect, x: u16, y: u16) -> Rect { + Rect { + x: area.x.saturating_add(x), + y: area.y.saturating_add(y), + width: area.width.saturating_sub(x.saturating_mul(2)), + height: area.height.saturating_sub(y.saturating_mul(2)), + } +} + +fn square_preview_rects(area: Rect, grid: GridSize) -> Vec { + let rows = grid.rows as u16; + let columns = grid.columns as u16; + if rows == 0 || columns == 0 || area.width == 0 || area.height == 0 { + return Vec::new(); + } + + let gap_y = if area.height >= rows.saturating_mul(2).saturating_sub(1) { + 1 + } else { + 0 + }; + let gap_x = if area.width >= columns.saturating_mul(4).saturating_sub(2) { + 2 + } else if area.width >= columns.saturating_mul(3).saturating_sub(1) { + 1 } else { - Style::default().fg(Color::DarkGray) + 0 + }; + + let row_gaps = rows.saturating_sub(1).saturating_mul(gap_y); + let column_gaps = columns.saturating_sub(1).saturating_mul(gap_x); + let height_fit = area.height.saturating_sub(row_gaps) / rows; + let width_fit = area.width.saturating_sub(column_gaps) / columns / 2; + let side_height = height_fit.min(width_fit).max(1); + let side_width = side_height.saturating_mul(2).max(1); + let total_height = rows + .saturating_mul(side_height) + .saturating_add(row_gaps) + .min(area.height); + let total_width = columns + .saturating_mul(side_width) + .saturating_add(column_gaps) + .min(area.width); + let start_y = area.y + area.height.saturating_sub(total_height) / 2; + let start_x = area.x + area.width.saturating_sub(total_width) / 2; + + let mut rects = Vec::with_capacity(grid.count()); + for row in 0..rows { + for column in 0..columns { + rects.push(Rect { + x: start_x + column.saturating_mul(side_width.saturating_add(gap_x)), + y: start_y + row.saturating_mul(side_height.saturating_add(gap_y)), + width: side_width, + height: side_height, + }); + } + } + rects +} + +fn dithered_fill(index: usize, rect: Rect) -> Paragraph<'static> { + let lines = (0..rect.height) + .map(|y| { + let mut spans = Vec::with_capacity(rect.width as usize); + for x in 0..rect.width { + let bg = match (x.saturating_mul(3) + y.saturating_mul(5) + index as u16) % 6 { + 0 | 3 => PREVIEW_LIGHT, + 1 | 4 => PREVIEW_MID, + _ => PREVIEW_DARK, + }; + spans.push(Span::styled(" ", Style::default().bg(bg))); + } + Line::from(spans) + }) + .collect::>(); + + Paragraph::new(lines).style(Style::default().bg(PREVIEW_DARK)) +} + +#[cfg(test)] +mod tests { + use std::env; + + use super::*; + + #[test] + fn launch_plan_uses_default_startup_grid_and_profile() { + if env::var_os("GRIDBASH_PROFILE").is_some() { + return; + } + + let mut config = Config::default(); + config.set_default_profile("powershell"); + let current_dir = env::current_dir().expect("current dir"); + + let composer = Composer::new(current_dir.clone(), None); + let plan = composer.launch_plan(&config).expect("launch plan"); + + assert_eq!(plan.grid.rows, DEFAULT_ROWS); + assert_eq!(plan.grid.columns, DEFAULT_COLUMNS); + assert_eq!(plan.panes.len(), DEFAULT_ROWS * DEFAULT_COLUMNS); + assert!( + plan.panes + .iter() + .all(|pane| { pane.profile_name == "powershell" && pane.cwd == current_dir }) + ); } } diff --git a/src/setup.rs b/src/setup.rs index 952b865..5da76fc 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -4,7 +4,7 @@ use std::{ process::Command, }; -use anyhow::{Context, Result, anyhow}; +use anyhow::Result; use crate::{ auth::AgentKind, @@ -13,18 +13,6 @@ use crate::{ worktrees::{ManagedWorktreeOptions, ensure_pane_worktrees}, }; -#[derive(Debug, Clone)] -pub struct LaunchSelection { - pub folders: Vec, - pub agents: Vec, -} - -#[derive(Debug, Clone)] -pub struct LaunchFolder { - pub name: String, - pub path: PathBuf, -} - #[derive(Debug, Clone)] pub struct LaunchPlan { pub panes: Vec, @@ -44,93 +32,6 @@ pub struct PaneLaunchSpec { pub auth_dir: Option, } -impl LaunchSelection { - pub fn new(folders: Vec, agents: Vec) -> Self { - Self { folders, agents } - } - - pub fn validate(&self) -> Result<()> { - if self.folders.is_empty() { - return Err(anyhow!("launch needs at least one folder")); - } - if self.agents.is_empty() { - return Err(anyhow!("launch needs at least one agent")); - } - for folder in &self.folders { - if !folder.path.is_dir() { - return Err(anyhow!("folder does not exist: {}", folder.path.display())); - } - } - Ok(()) - } - - pub fn launch_plan(&self, worktrees: Option<&ManagedWorktreeOptions>) -> Result { - self.validate()?; - let panes = if let Some(options) = worktrees { - self.managed_worktree_panes(options)? - } else { - self.legacy_panes() - }; - let grid = GridSize::from_count(panes.len()); - Ok(LaunchPlan { panes, grid }) - } - - fn legacy_panes(&self) -> Vec { - self.agents - .iter() - .enumerate() - .map(|(index, agent)| { - let folder = &self.folders[index % self.folders.len()]; - vibe_pane_spec(agent, folder) - }) - .collect() - } - - fn managed_worktree_panes( - &self, - options: &ManagedWorktreeOptions, - ) -> Result> { - let mut counts = vec![0usize; self.folders.len()]; - for index in 0..self.agents.len() { - counts[index % self.folders.len()] += 1; - } - - let worktrees_by_folder = self - .folders - .iter() - .zip(counts) - .map(|(folder, count)| ensure_pane_worktrees(&folder.path, count, options)) - .collect::>>()?; - let mut next_by_folder = vec![0usize; self.folders.len()]; - - self.agents - .iter() - .enumerate() - .map(|(index, agent)| { - let folder_index = index % self.folders.len(); - let worktree_index = next_by_folder[folder_index]; - next_by_folder[folder_index] += 1; - let worktree = worktrees_by_folder[folder_index] - .get(worktree_index) - .ok_or_else(|| anyhow!("managed worktree missing for pane {}", index + 1))?; - Ok(vibe_pane_spec_for_worktree( - agent, - worktree.cwd.clone(), - worktree.folder_name.clone(), - worktree.branch_name.clone(), - )) - }) - .collect() - } -} - -impl LaunchFolder { - pub fn from_path(path: PathBuf) -> Self { - let name = folder_display_name(&path); - Self { name, path } - } -} - impl LaunchPlan { pub fn from_launch_options( profile_name: String, @@ -263,51 +164,6 @@ fn run_git(path: &Path, args: &[&str]) -> Option { (!value.is_empty()).then_some(value) } -fn vibe_pane_spec(agent: &str, folder: &LaunchFolder) -> PaneLaunchSpec { - vibe_pane_spec_for_worktree( - agent, - folder.path.clone(), - folder.name.clone(), - git_worktree_name(&folder.path).unwrap_or_default(), - ) -} - -fn vibe_pane_spec_for_worktree( - agent: &str, - cwd: PathBuf, - folder_name: String, - worktree_name: String, -) -> PaneLaunchSpec { - PaneLaunchSpec { - profile_name: agent.to_string(), - command: Profile { - command: "vibe".into(), - args: vec!["run".into(), agent.into(), "--".into()], - title: Some(agent.into()), - agent_kind: None, - }, - env: BTreeMap::new(), - cwd, - folder_name, - worktree_name: (!worktree_name.is_empty()).then_some(worktree_name), - auth_name: None, - auth_kind: None, - auth_dir: None, - } -} - -pub fn launch_selection_from( - folders: Vec, - agents: Vec, -) -> Result { - let selection = LaunchSelection::new( - folders.into_iter().map(LaunchFolder::from_path).collect(), - agents, - ); - selection.validate().context("invalid launch selection")?; - Ok(selection) -} - fn command_basename(command: &str) -> Option { let path = Path::new(command); let file_name = path @@ -355,47 +211,6 @@ mod tests { use super::*; - #[test] - fn assigns_agents_round_robin_across_folders() { - let cwd = env::current_dir().expect("cwd"); - let other = cwd.parent().unwrap_or(&cwd).to_path_buf(); - let selection = LaunchSelection::new( - vec![ - LaunchFolder { - name: "one".into(), - path: cwd, - }, - LaunchFolder { - name: "two".into(), - path: other, - }, - ], - vec!["claude-1".into(), "claude-2".into(), "codex-2".into()], - ); - - let plan = selection.launch_plan(None).expect("launch plan"); - assert_eq!(plan.panes[0].folder_name, "one"); - assert_eq!(plan.panes[1].folder_name, "two"); - assert_eq!(plan.panes[2].folder_name, "one"); - assert_eq!(plan.grid.count(), 4); - } - - #[test] - fn builds_vibe_run_command_for_agent() { - let cwd = env::current_dir().expect("cwd"); - let selection = LaunchSelection::new( - vec![LaunchFolder { - name: "repo".into(), - path: cwd, - }], - vec!["claude-1".into()], - ); - - let plan = selection.launch_plan(None).expect("launch plan"); - assert_eq!(plan.panes[0].command.command, "vibe"); - assert_eq!(plan.panes[0].command.args, vec!["run", "claude-1", "--"]); - } - #[test] fn detects_vibe_agent_panes() { let cwd = env::current_dir().expect("cwd");