From fc6dc3fdb59d2ccc23286a805cabcdc5afe5a0db Mon Sep 17 00:00:00 2001 From: Jason Matthew Suhari Date: Tue, 7 Jul 2026 16:42:11 -0700 Subject: [PATCH] feat: integrate hidden manager groups Refs #66. Signed-off-by: Jason Matthew Suhari --- README.md | 12 + config.example.toml | 1 + ...7-integrate-hidden-manager-agent-groups.md | 30 + src/app.rs | 662 +++++++++++++++++- src/cli.rs | 4 + src/config.rs | 17 +- src/main.rs | 1 + src/orchestrator.rs | 176 +++++ src/ui.rs | 113 ++- src/vibe.rs | 32 + 10 files changed, 1022 insertions(+), 26 deletions(-) create mode 100644 docs/devlogs/2026-07-07-integrate-hidden-manager-agent-groups.md create mode 100644 src/orchestrator.rs diff --git a/README.md b/README.md index c0e9c70..da2d5f2 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ GridBash is a Windows-native Rust TUI multiplexer built for agent-heavy developm - Sleeping panes stay visually hidden until hovered, then wake without crossing input into other panes. - Normal terminal keys pass through to the focused pane, or to selected panes when multiple panes are selected. - Modeless Alt shortcuts for pane focus, selection, rename, settings, and quit. +- Hidden manager agent groups let a manager profile coordinate selected worker panes without taking a visible grid slot. - Compact dark theme with focus, selection, activity, exit, and output-volume badges. - Claude, Codex, and other agent panes show a compact conversation summary in the footer line. - Built-in launch profiles for common CLI coding agents. @@ -174,6 +175,8 @@ GridBash captures drag selection so selected text stays inside the pane where th | Alt+a | Select all panes, or clear selection when all panes are selected | | Alt+r | Rename the focused pane | | Alt+z | Put the focused pane to sleep; when multiple panes are selected, sleep the selected panes | +| Alt+g | Group selected panes under a hidden manager; with no selection, open the focused group's manager prompt | +| Alt+u | Dissolve the focused pane's manager group | | Hover sleeping pane | Wake the pane and make its terminal contents visible again | | Alt+o | Open sample settings | | Alt+q | Quit | @@ -205,6 +208,7 @@ Example: ```toml [defaults] profile = "powershell" +manager_profile = "claude-1" [profiles.review] command = "codex" @@ -224,6 +228,14 @@ Default profile resolution order: --profile > GRIDBASH_PROFILE > [defaults].profile > git-bash ``` +Hidden manager groups use this manager profile resolution order: + +```text +--manager-profile > GRIDBASH_MANAGER_PROFILE > [defaults].manager_profile +``` + +The manager profile can be a normal GridBash profile or a ready Vibe profile. To create a group, select one or more awake panes and press `Alt+g`. GridBash launches the manager as a hidden PTY, marks the grouped panes with a `G` badge, relays worker output snapshots to the manager, and forwards manager `gridbash send` blocks back to awake workers in that group. + ## Design Goals GridBash is inspired by agent-first multiplexers such as Mato and terminal workspaces such as Zellij, but V1 takes a different path: Windows-native, single binary, visual selection, scoped multi-pane input, and a hard bias toward fast multi-agent grids. diff --git a/config.example.toml b/config.example.toml index 67c3030..4c7bf19 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1,5 +1,6 @@ [defaults] profile = "git-bash" +manager_profile = "claude-1" [profiles.codex-fast] command = "codex" diff --git a/docs/devlogs/2026-07-07-integrate-hidden-manager-agent-groups.md b/docs/devlogs/2026-07-07-integrate-hidden-manager-agent-groups.md new file mode 100644 index 0000000..cdecca3 --- /dev/null +++ b/docs/devlogs/2026-07-07-integrate-hidden-manager-agent-groups.md @@ -0,0 +1,30 @@ +# Integrate hidden manager agent groups + +Date: 2026-07-07 +Release target: unreleased + +## Summary + +- Integrated hidden manager agent groups from `origin/feat/hidden-agent-groups` onto current `main` without carrying over the old branch's unrelated deletes. +- Tracks issue #66. + +## What Changed + +- Added `--manager-profile`, `GRIDBASH_MANAGER_PROFILE`, and `[defaults].manager_profile` resolution for hidden group managers. +- Added hidden manager PTYs that coordinate selected awake worker panes, parse manager `gridbash send` blocks, and relay worker output snapshots back to managers. +- Added group badges and a manager prompt overlay while preserving visible pane arrays for resize, sleep, swap, worktree labels, usage labels, and pane-contained input selection. +- Added focused tests for manager config parsing, Vibe profile resolution, send-block parsing, and existing pane behavior. + +## Why It Matters + +- Manager agents can coordinate worker panes without consuming a visible grid slot. +- The integration avoids regressing current `main` behavior that landed after the source feature branch diverged. + +## Validation + +- `npm test` +- Result: 34 passed, 0 failed, 1 ignored Windows ConPTY smoke test. + +## Release Notes + +- Added hidden manager agent groups behind `Alt+g`/`Alt+u` and manager profile configuration. diff --git a/src/app.rs b/src/app.rs index d141963..6de8b90 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,6 +2,7 @@ use std::{ collections::{BTreeMap, BTreeSet}, env, io::{self, Stdout, Write}, + path::PathBuf, sync::mpsc as std_mpsc, time::{Duration, Instant}, }; @@ -25,22 +26,30 @@ use crate::{ composer::Composer, config::Config, layout::{GridLayout, GridSize, PaneId, pane_at}, - profiles::find_profile, + orchestrator::{ + GroupColor, GroupId, MAX_GROUPS, SendBlock, SendTargets, extract_send_blocks, group_color, + group_label, manager_pane_id, + }, + profiles::{Profile, find_profile}, pty::{PtyEvent, PtyPane}, setup::{LaunchPlan, PaneLaunchSpec}, ui, usage::{self, UsageEvent, UsageTarget}, + vibe, worktrees::ManagedWorktreeOptions, }; pub type Tui = Terminal>; const INPUT_POLL_INTERVAL: Duration = Duration::from_millis(16); +const WORKER_RELAY_IDLE: Duration = Duration::from_millis(900); +const WORKER_RELAY_MAX_BYTES: usize = 6000; const MAX_PANE_NAME_CHARS: usize = 32; const CONVERSATION_SUMMARY_MAX_CHARS: usize = 120; pub struct App { config: Config, + manager_profile_name: Option, worktrees: Option, launch_plan: Option, layout: GridLayout, @@ -51,6 +60,9 @@ pub struct App { pane_names: Vec>, text_selection: Option, sleeping: BTreeSet, + groups: Vec, + next_group_id: usize, + prompt: Option, rects: Vec, mouse_enabled: bool, settings: SettingsState, @@ -153,6 +165,36 @@ enum SwapSelection { Pair(usize, usize), } +struct AgentGroup { + id: GroupId, + palette_index: usize, + label: char, + workers: BTreeSet, + manager: PtyPane, + manager_buffer: String, + relay_buffers: BTreeMap, + last_worker_output: Option, + status: String, +} + +struct PromptState { + group_id: GroupId, + input: String, +} + +#[derive(Debug, Clone, Copy)] +pub struct PaneGroupView { + pub label: char, + pub color: GroupColor, +} + +#[derive(Debug, Clone)] +pub struct PromptView { + pub label: char, + pub color: GroupColor, + pub input: String, +} + #[derive(Debug, Clone)] pub struct SettingsRow { pub selected: bool, @@ -399,6 +441,7 @@ impl App { .then(|| ManagedWorktreeOptions::new(cli.worktree_prefix.clone())) .transpose()?; let launch_plan = resolve_direct_launch_plan(&cli, &config, worktrees.as_ref())?; + let manager_profile_name = resolve_manager_profile_name(&cli, &config); let mouse_enabled = !cli.no_mouse; let grid = launch_plan .as_ref() @@ -412,6 +455,7 @@ impl App { Ok(Self { config, + manager_profile_name, worktrees, launch_plan, layout: GridLayout::new(grid), @@ -422,15 +466,18 @@ impl App { pane_names: Vec::new(), text_selection: None, sleeping: BTreeSet::new(), + groups: Vec::new(), + next_group_id: 0, + prompt: None, rects: Vec::new(), 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" + "Drag copies within pane | Alt+arrows move | Alt+Shift+arrows resize | Alt+r rename | Alt+x swap | Alt+z sleep | Alt+g group | Alt+u ungroup | 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" + "Alt+arrows move | Alt+Shift+arrows resize | Alt+s select | Alt+r rename | Alt+x swap | Alt+z sleep | Alt+g group | Alt+u ungroup | Alt+o settings" .into() }, next_pane_id: 0, @@ -482,6 +529,9 @@ impl App { self.text_selection = None; self.sleeping.clear(); self.next_pane_id = 0; + self.groups.clear(); + self.next_group_id = 0; + self.prompt = None; for spec in &plan.panes { self.spawn_pane_spec(spec)?; @@ -530,6 +580,7 @@ impl App { needs_render |= self.drain_pty_events(); needs_render |= self.drain_usage_events(); needs_render |= self.decay_activity(); + needs_render |= self.relay_worker_output(); if needs_render { terminal.draw(|frame| { @@ -556,12 +607,19 @@ impl App { self.rename.insert_text(&text); needs_render = true; } + Event::Paste(text) if self.prompt.is_some() => { + if let Some(prompt) = &mut self.prompt { + prompt.input.push_str(&text); + needs_render = true; + } + } Event::Paste(text) if !self.settings.open => { self.route_input(text.as_bytes())?; } Event::Mouse(mouse) if (self.mouse_enabled || !self.sleeping.is_empty()) - && !self.settings.open => + && !self.settings.open + && self.prompt.is_none() => { needs_render |= self.handle_mouse(mouse, terminal)?; } @@ -594,6 +652,7 @@ impl App { fn drain_pty_events(&mut self) -> bool { let mut changed = false; + let mut dispatches = Vec::new(); while let Ok(event) = self.event_rx.try_recv() { match event { @@ -602,23 +661,24 @@ impl App { generation, bytes, } => { - if let Some(target) = self - .panes - .iter_mut() - .find(|p| p.id() == pane && p.generation() == generation) - { + if let Some(index) = self.visible_pane_index(pane, generation) { + let target = &mut self.panes[index]; target.process_output(&bytes); + self.capture_worker_output(index, &bytes); + changed = true; + } else if self.process_manager_output(pane, generation, &bytes, &mut dispatches) + { changed = true; } } PtyEvent::Exited { pane, generation } => { - if let Some(target) = self - .panes - .iter_mut() - .find(|p| p.id() == pane && p.generation() == generation) - && !target.exited - { - target.exited = true; + if let Some(index) = self.visible_pane_index(pane, generation) { + let target = &mut self.panes[index]; + if !target.exited { + target.exited = true; + changed = true; + } + } else if self.process_manager_exit(pane, generation) { changed = true; } } @@ -628,6 +688,168 @@ impl App { for pane in &mut self.panes { changed |= pane.poll_exit(); } + for group in &mut self.groups { + let exited_now = group.manager.poll_exit(); + if exited_now { + group.status = "manager exited".into(); + } + changed |= exited_now; + } + + changed |= self.dispatch_manager_commands(dispatches); + + changed + } + + fn visible_pane_index(&self, pane: PaneId, generation: u64) -> Option { + self.panes + .iter() + .position(|target| target.id() == pane && target.generation() == generation) + } + + fn process_manager_output( + &mut self, + pane: PaneId, + generation: u64, + bytes: &[u8], + dispatches: &mut Vec<(GroupId, SendBlock)>, + ) -> bool { + let Some(group) = self + .groups + .iter_mut() + .find(|group| group.manager.id() == pane && group.manager.generation() == generation) + else { + return false; + }; + + group.manager.process_output(bytes); + group + .manager_buffer + .push_str(&String::from_utf8_lossy(bytes)); + for block in extract_send_blocks(&mut group.manager_buffer) { + dispatches.push((group.id, block)); + } + let label = group.label; + group.status = "manager active".into(); + self.status = format!("group {label}: manager active"); + true + } + + fn process_manager_exit(&mut self, pane: PaneId, generation: u64) -> bool { + let Some(group) = self + .groups + .iter_mut() + .find(|group| group.manager.id() == pane && group.manager.generation() == generation) + else { + return false; + }; + + if group.manager.exited { + return false; + } + + let label = group.label; + group.manager.exited = true; + group.status = "manager exited".into(); + self.status = format!("group {label}: manager exited"); + true + } + + fn capture_worker_output(&mut self, pane_index: usize, bytes: &[u8]) { + if self.sleeping.contains(&pane_index) { + return; + } + + let output = String::from_utf8_lossy(bytes); + if output.trim().is_empty() { + return; + } + + for group in &mut self.groups { + if !group.workers.contains(&pane_index) { + continue; + } + + let buffer = group.relay_buffers.entry(pane_index).or_default(); + buffer.push_str(&output); + trim_relay_buffer(buffer); + group.last_worker_output = Some(Instant::now()); + } + } + + fn dispatch_manager_commands(&mut self, dispatches: Vec<(GroupId, SendBlock)>) -> bool { + let mut changed = false; + + for (group_id, block) in dispatches { + let Some(group_index) = self.groups.iter().position(|group| group.id == group_id) + else { + continue; + }; + let workers = self.groups[group_index].workers.clone(); + let targets = match block.targets { + SendTargets::All => workers, + SendTargets::Panes(panes) => panes + .into_iter() + .filter_map(|pane_number| pane_number.checked_sub(1)) + .filter(|pane_index| workers.contains(pane_index)) + .collect::>(), + }; + let targets = targets + .into_iter() + .filter(|pane_index| !self.sleeping.contains(pane_index)) + .collect::>(); + + if targets.is_empty() { + self.groups[group_index].status = "manager send had no awake valid targets".into(); + self.status = format!( + "group {} send skipped: no awake valid targets", + self.groups[group_index].label + ); + changed = true; + continue; + } + + let bytes = paste_and_enter_bytes(&block.message); + let mut sent = 0_usize; + for pane_index in targets { + if let Some(pane) = self.panes.get(pane_index) + && pane.write(&bytes).is_ok() + { + sent += 1; + } + } + + let label = self.groups[group_index].label; + self.groups[group_index].status = format!("sent to {sent} worker(s)"); + self.status = format!("group {label} manager sent to {sent} worker(s)"); + changed = true; + } + + changed + } + + fn relay_worker_output(&mut self) -> bool { + let now = Instant::now(); + let mut changed = false; + + for group in &mut self.groups { + let Some(last_output) = group.last_worker_output else { + continue; + }; + if now.duration_since(last_output) < WORKER_RELAY_IDLE || group.relay_buffers.is_empty() + { + continue; + } + + let relay = worker_relay_message(group.label, &group.relay_buffers); + if group.manager.write(&paste_and_enter_bytes(&relay)).is_ok() { + group.status = format!("relayed {} worker(s)", group.relay_buffers.len()); + self.status = format!("group {}: worker output relayed", group.label); + group.relay_buffers.clear(); + group.last_worker_output = None; + changed = true; + } + } changed } @@ -679,6 +901,11 @@ impl App { let selection_cleared = self.clear_text_selection(); + if self.prompt.is_some() { + let outcome = self.handle_prompt_key(key)?; + return Ok(render_if_selection_cleared(outcome, selection_cleared)); + } + if self.settings.open { let outcome = self.handle_settings_key(key)?; return Ok(render_if_selection_cleared(outcome, selection_cleared)); @@ -773,6 +1000,18 @@ impl App { self.swap_selected_tiles(); Ok(Some(false)) } + 'g' => { + if self.selected.is_empty() { + self.open_manager_prompt()?; + } else { + self.create_group_from_selection()?; + } + Ok(Some(false)) + } + 'u' => { + self.dissolve_focused_group(); + Ok(Some(false)) + } 'o' => { self.settings.open = true; self.status = "settings open".into(); @@ -920,10 +1159,319 @@ impl App { plan.panes.swap(first, second); } swap_set_indices(&mut self.sleeping, first, second); + self.swap_group_indices(first, second); self.focus = swapped_index(self.focus, first, second); self.status = format!("swapped panes {} and {}", first + 1, second + 1); } + fn swap_group_indices(&mut self, first: usize, second: usize) { + for group in &mut self.groups { + swap_set_indices(&mut group.workers, first, second); + + let first_buffer = group.relay_buffers.remove(&first); + let second_buffer = group.relay_buffers.remove(&second); + if let Some(buffer) = first_buffer { + group.relay_buffers.insert(second, buffer); + } + if let Some(buffer) = second_buffer { + group.relay_buffers.insert(first, buffer); + } + } + } + + fn create_group_from_selection(&mut self) -> Result<()> { + if self.groups.len() >= MAX_GROUPS { + self.status = format!("group limit reached ({MAX_GROUPS})"); + return Ok(()); + } + + let workers = self + .selected + .iter() + .copied() + .filter(|index| *index < self.panes.len() && !self.sleeping.contains(index)) + .collect::>(); + if workers.is_empty() { + self.status = "select awake worker panes before grouping".into(); + return Ok(()); + } + + if let Some((pane_index, label)) = self.first_grouped_pane(&workers) { + self.status = format!("pane {} already belongs to group {label}", pane_index + 1); + return Ok(()); + } + + let Some(palette_index) = self.next_palette_index() else { + self.status = format!("group limit reached ({MAX_GROUPS})"); + return Ok(()); + }; + let label = group_label(palette_index); + + let (manager_name, manager_profile) = match self.resolve_manager_profile() { + Ok(profile) => profile, + Err(error) => { + self.status = format!("manager profile unavailable: {error:#}"); + return Ok(()); + } + }; + let launch = match manager_profile.resolved_command() { + Ok(launch) => launch, + Err(error) => { + self.status = format!("manager profile failed: {error:#}"); + return Ok(()); + } + }; + + let group_id = GroupId(self.next_group_id); + self.next_group_id += 1; + let cwd = self.group_cwd(&workers)?; + let manager = match PtyPane::spawn( + PaneId(manager_pane_id(group_id)), + group_id.0 as u64 + 1, + &launch.command, + &launch.args, + &cwd, + self.event_tx.clone(), + ) { + Ok(manager) => manager, + Err(error) => { + self.status = format!("manager spawn failed: {error:#}"); + return Ok(()); + } + }; + + let intro = self.manager_intro_message(label, &workers); + if let Err(error) = manager.write(&paste_and_enter_bytes(&intro)) { + self.status = format!("manager init failed: {error:#}"); + return Ok(()); + } + + self.groups.push(AgentGroup { + id: group_id, + palette_index, + label, + workers, + manager, + manager_buffer: String::new(), + relay_buffers: BTreeMap::new(), + last_worker_output: None, + status: format!("manager {manager_name} ready"), + }); + self.selected.clear(); + self.status = format!("group {label} attached to hidden manager {manager_name}"); + Ok(()) + } + + fn open_manager_prompt(&mut self) -> Result<()> { + let group_id = self + .group_for_pane(self.focus) + .or_else(|| (self.groups.len() == 1).then_some(self.groups[0].id)); + let Some(group_id) = group_id else { + self.status = "select panes to create a group, or focus a grouped pane".into(); + return Ok(()); + }; + + let Some(group) = self.groups.iter().find(|group| group.id == group_id) else { + self.status = "group is no longer available".into(); + return Ok(()); + }; + + self.prompt = Some(PromptState { + group_id, + input: String::new(), + }); + self.status = format!("talking to group {} manager", group.label); + Ok(()) + } + + fn dissolve_focused_group(&mut self) { + let group_id = self + .group_for_pane(self.focus) + .or_else(|| (self.groups.len() == 1).then_some(self.groups[0].id)); + let Some(group_id) = group_id else { + self.status = "focus a grouped pane to dissolve its group".into(); + return; + }; + + let Some(index) = self.groups.iter().position(|group| group.id == group_id) else { + self.status = "group is no longer available".into(); + return; + }; + + let label = self.groups[index].label; + if self + .prompt + .as_ref() + .is_some_and(|prompt| prompt.group_id == group_id) + { + self.prompt = None; + } + self.groups.remove(index); + self.status = format!("group {label} dissolved"); + } + + fn handle_prompt_key(&mut self, key: KeyEvent) -> Result { + if key.modifiers.contains(KeyModifiers::ALT) && matches!(key.code, KeyCode::Char('q')) { + return Ok(KeyOutcome::Quit); + } + + let Some(prompt) = &mut self.prompt else { + return Ok(KeyOutcome::Continue); + }; + + match key.code { + KeyCode::Esc => { + self.prompt = None; + self.status = "manager prompt closed".into(); + Ok(KeyOutcome::Render) + } + KeyCode::Enter => self.send_prompt_to_manager(), + KeyCode::Backspace => { + prompt.input.pop(); + Ok(KeyOutcome::Render) + } + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + prompt.input.clear(); + Ok(KeyOutcome::Render) + } + KeyCode::Char(ch) + if !key.modifiers.contains(KeyModifiers::CONTROL) + && !key.modifiers.contains(KeyModifiers::ALT) => + { + prompt.input.push(ch); + Ok(KeyOutcome::Render) + } + _ => Ok(KeyOutcome::Continue), + } + } + + fn send_prompt_to_manager(&mut self) -> Result { + let Some(prompt) = self.prompt.take() else { + return Ok(KeyOutcome::Continue); + }; + let input = prompt.input.trim(); + if input.is_empty() { + self.status = "manager prompt skipped".into(); + return Ok(KeyOutcome::Render); + } + + let Some(group) = self + .groups + .iter_mut() + .find(|group| group.id == prompt.group_id) + else { + self.status = "group is no longer available".into(); + return Ok(KeyOutcome::Render); + }; + + let message = user_manager_message(group.label, input); + match group.manager.write(&paste_and_enter_bytes(&message)) { + Ok(()) => { + group.status = "manager prompted".into(); + self.status = format!("sent prompt to group {} manager", group.label); + } + Err(error) => { + group.status = "manager write failed".into(); + self.status = format!("manager prompt failed: {error:#}"); + } + } + + Ok(KeyOutcome::Render) + } + + fn first_grouped_pane(&self, workers: &BTreeSet) -> Option<(usize, char)> { + workers.iter().find_map(|pane_index| { + self.groups + .iter() + .find(|group| group.workers.contains(pane_index)) + .map(|group| (*pane_index, group.label)) + }) + } + + fn next_palette_index(&self) -> Option { + let used = self + .groups + .iter() + .map(|group| group.palette_index) + .collect::>(); + (0..MAX_GROUPS).find(|index| !used.contains(index)) + } + + fn resolve_manager_profile(&self) -> Result<(String, Profile)> { + let name = self + .manager_profile_name + .as_deref() + .ok_or_else(|| anyhow!("set --manager-profile or [defaults].manager_profile"))?; + + if let Ok(profile) = find_profile(&self.config, name) { + return Ok((name.to_string(), profile)); + } + + let profiles = vibe::load_profiles()?; + let profile = vibe::profile_for_name(name, &profiles) + .ok_or_else(|| anyhow!("vibe profile '{name}' is missing or not ready"))?; + Ok((name.to_string(), profile)) + } + + fn group_cwd(&self, workers: &BTreeSet) -> Result { + let Some(first_worker) = workers.iter().next() else { + return resolved_current_dir(); + }; + Ok(self + .panes + .get(*first_worker) + .map(|pane| pane.cwd().to_path_buf()) + .unwrap_or(resolved_current_dir()?)) + } + + fn manager_intro_message(&self, label: char, workers: &BTreeSet) -> String { + let worker_lines = workers + .iter() + .map(|pane_index| { + format!( + "- pane {}: {}", + pane_index + 1, + self.worker_label(*pane_index) + ) + }) + .collect::>() + .join("\n"); + + format!( + "You are the hidden GridBash manager for group {label}.\n\ + Coordinate only these worker panes:\n\ + {worker_lines}\n\n\ + When you need GridBash to send instructions to workers, emit a fenced block whose opening line is three backticks immediately followed by one of these commands:\n\ + gridbash send all\n\ + gridbash send panes 1, 3\n\ + Put only the worker instruction text inside that fence.\n\n\ + I will relay worker output snapshots back to you. Keep routing blocks concise and only target panes in this group." + ) + } + + fn worker_label(&self, pane_index: usize) -> String { + let folder = self + .pane_folder(pane_index) + .map(str::to_string) + .unwrap_or_else(|| { + self.panes + .get(pane_index) + .map(|pane| pane.cwd().display().to_string()) + .unwrap_or_else(|| "unknown cwd".into()) + }); + match self.pane_worktree(pane_index) { + Some(worktree) => format!("{folder} ({worktree})"), + None => folder, + } + } + + fn group_for_pane(&self, pane_index: usize) -> Option { + self.groups + .iter() + .find(|group| group.workers.contains(&pane_index)) + .map(|group| group.id) + } + fn handle_settings_key(&mut self, key: KeyEvent) -> Result { if key.modifiers.contains(KeyModifiers::ALT) && matches!(key.code, KeyCode::Char('q')) { return Ok(KeyOutcome::Quit); @@ -1271,9 +1819,28 @@ impl App { { self.text_selection = None; } + self.truncate_groups(target_count); true } + fn truncate_groups(&mut self, target_count: usize) { + self.groups.retain_mut(|group| { + group.workers.retain(|index| *index < target_count); + group + .relay_buffers + .retain(|pane_index, _| *pane_index < target_count); + !group.workers.is_empty() + }); + + if self + .prompt + .as_ref() + .is_some_and(|prompt| !self.groups.iter().any(|group| group.id == prompt.group_id)) + { + self.prompt = None; + } + } + fn toggle_sleep_for_targets(&mut self) { let targets = self.target_panes(); if targets.is_empty() { @@ -1432,6 +1999,29 @@ impl App { }) } + pub fn pane_group(&self, index: usize) -> Option { + self.groups + .iter() + .find(|group| group.workers.contains(&index)) + .map(|group| PaneGroupView { + label: group.label, + color: group_color(group.palette_index), + }) + } + + pub fn prompt_view(&self) -> Option { + let prompt = self.prompt.as_ref()?; + let group = self + .groups + .iter() + .find(|group| group.id == prompt.group_id)?; + Some(PromptView { + label: group.label, + color: group_color(group.palette_index), + input: prompt.input.clone(), + }) + } + pub fn pane_folder(&self, index: usize) -> Option<&str> { self.launch_plan .as_ref() @@ -1565,11 +2155,51 @@ fn resolve_profile_name(cli: &Cli, config: &Config) -> String { .unwrap_or_else(|| "git-bash".into()) } +fn resolve_manager_profile_name(cli: &Cli, config: &Config) -> Option { + cli.manager_profile + .clone() + .or_else(|| env::var("GRIDBASH_MANAGER_PROFILE").ok()) + .or_else(|| config.defaults.manager_profile.clone()) +} + fn resolved_current_dir() -> Result { let current = env::current_dir().context("failed to resolve current directory")?; Ok(current.canonicalize().unwrap_or(current)) } +fn trim_relay_buffer(buffer: &mut String) { + if buffer.len() > WORKER_RELAY_MAX_BYTES { + let keep_from = buffer.len().saturating_sub(WORKER_RELAY_MAX_BYTES); + buffer.drain(..keep_from); + } +} + +fn worker_relay_message(label: char, buffers: &BTreeMap) -> String { + let mut message = format!("GridBash worker output snapshot for group {label}."); + for (pane_index, output) in buffers { + message.push_str(&format!( + "\n\n[pane {} output]\n{}", + pane_index + 1, + output.trim() + )); + } + message +} + +fn user_manager_message(label: char, input: &str) -> String { + format!( + "User instruction for GridBash group {label}:\n{input}\n\nRoute work to workers with gridbash send blocks when needed." + ) +} + +fn paste_and_enter_bytes(text: &str) -> Vec { + let mut bytes = Vec::with_capacity(text.len() + 16); + bytes.extend_from_slice(b"\x1b[200~"); + bytes.extend_from_slice(text.as_bytes()); + bytes.extend_from_slice(b"\x1b[201~\r"); + bytes +} + fn toggle_selection(selected: &mut BTreeSet, index: usize) { if !selected.insert(index) { selected.remove(&index); diff --git a/src/cli.rs b/src/cli.rs index 40c44fd..afa2167 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -18,6 +18,10 @@ pub struct Cli { #[arg(long)] pub profile: Option, + /// Profile for hidden manager agents attached to worker groups. + #[arg(long)] + pub manager_profile: Option, + /// Persist the default profile to the GridBash config file and exit. #[arg(long, visible_alias = "set-default")] pub set_default_profile: Option, diff --git a/src/config.rs b/src/config.rs index cdd8254..9421433 100644 --- a/src/config.rs +++ b/src/config.rs @@ -24,6 +24,8 @@ pub struct Config { pub struct Defaults { #[serde(default, skip_serializing_if = "Option::is_none")] pub profile: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub manager_profile: Option, } impl Config { @@ -78,7 +80,7 @@ impl Config { impl Defaults { fn is_empty(&self) -> bool { - self.profile.is_none() + self.profile.is_none() && self.manager_profile.is_none() } } @@ -99,6 +101,19 @@ mod tests { assert_eq!(config.defaults.profile.as_deref(), Some("powershell")); } + #[test] + fn parses_manager_profile_default() { + let config: Config = toml::from_str( + r#" + [defaults] + manager_profile = "claude-1" + "#, + ) + .expect("parse config"); + + assert_eq!(config.defaults.manager_profile.as_deref(), Some("claude-1")); + } + #[test] fn parses_named_setups() { let config: Config = toml::from_str( diff --git a/src/main.rs b/src/main.rs index 0c8dd71..5fa1aef 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod composer; mod config; mod layout; mod onboarding; +mod orchestrator; mod profiles; mod pty; mod setup; diff --git a/src/orchestrator.rs b/src/orchestrator.rs new file mode 100644 index 0000000..7f48490 --- /dev/null +++ b/src/orchestrator.rs @@ -0,0 +1,176 @@ +use std::collections::BTreeSet; + +pub const HIDDEN_PANE_BASE: usize = 10_000; +pub const MAX_GROUPS: usize = PALETTE.len(); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct GroupId(pub usize); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct GroupColor { + pub name: &'static str, + pub rgb: (u8, u8, u8), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SendBlock { + pub targets: SendTargets, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SendTargets { + All, + Panes(BTreeSet), +} + +const PALETTE: [GroupColor; 6] = [ + GroupColor { + name: "blue", + rgb: (82, 166, 255), + }, + GroupColor { + name: "violet", + rgb: (176, 132, 255), + }, + GroupColor { + name: "rose", + rgb: (255, 111, 145), + }, + GroupColor { + name: "amber", + rgb: (245, 173, 66), + }, + GroupColor { + name: "magenta", + rgb: (236, 101, 255), + }, + GroupColor { + name: "slate", + rgb: (136, 160, 185), + }, +]; + +pub fn group_color(index: usize) -> GroupColor { + PALETTE[index % PALETTE.len()] +} + +pub fn group_label(index: usize) -> char { + (b'A' + (index % 26) as u8) as char +} + +pub fn manager_pane_id(group_id: GroupId) -> usize { + HIDDEN_PANE_BASE + group_id.0 +} + +pub fn extract_send_blocks(buffer: &mut String) -> Vec { + let mut blocks = Vec::new(); + + loop { + let Some(start) = buffer.find("```gridbash send") else { + trim_unmatched_buffer(buffer); + break; + }; + + if start > 0 { + buffer.drain(..start); + } + + let Some(header_end) = buffer.find('\n') else { + break; + }; + let content_start = header_end + 1; + let Some(close_offset) = buffer[content_start..].find("\n```") else { + break; + }; + let content_end = content_start + close_offset; + + let header = buffer[..header_end].trim(); + let body = buffer[content_start..content_end].trim(); + if let Some(targets) = parse_targets(header) + && !body.is_empty() + { + blocks.push(SendBlock { + targets, + message: body.to_string(), + }); + } + + let close_end = content_end + "\n```".len(); + buffer.drain(..close_end); + } + + blocks +} + +fn trim_unmatched_buffer(buffer: &mut String) { + const MAX_BUFFER: usize = 4096; + if buffer.len() > MAX_BUFFER { + let keep_from = buffer.len().saturating_sub(MAX_BUFFER); + buffer.drain(..keep_from); + } +} + +fn parse_targets(header: &str) -> Option { + let rest = header.strip_prefix("```gridbash send")?.trim(); + if rest.is_empty() || matches!(rest, "all" | "workers") { + return Some(SendTargets::All); + } + + let rest = rest + .strip_prefix("panes") + .or_else(|| rest.strip_prefix("pane")) + .unwrap_or(rest) + .trim(); + let panes = rest + .split([',', ' ', '\t']) + .filter(|part| !part.is_empty()) + .map(str::parse::) + .collect::, _>>() + .ok()?; + + (!panes.is_empty()).then_some(SendTargets::Panes(panes)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn palette_is_deterministic_and_not_green() { + let names = (0..MAX_GROUPS) + .map(|index| group_color(index).name) + .collect::>(); + + assert_eq!( + names, + vec!["blue", "violet", "rose", "amber", "magenta", "slate"] + ); + assert!(!names.contains(&"green")); + } + + #[test] + fn extracts_completed_send_blocks() { + let mut raw = + "thinking\n```gridbash send panes 1, 3\nship the feature\n```\ndone".to_string(); + + let blocks = extract_send_blocks(&mut raw); + + assert_eq!( + blocks, + vec![SendBlock { + targets: SendTargets::Panes(BTreeSet::from([1, 3])), + message: "ship the feature".into(), + }] + ); + assert_eq!(raw, "\ndone"); + } + + #[test] + fn leaves_incomplete_blocks_buffered() { + let mut raw = "```gridbash send all\nwait".to_string(); + + assert!(extract_send_blocks(&mut raw).is_empty()); + assert_eq!(raw, "```gridbash send all\nwait"); + } +} diff --git a/src/ui.rs b/src/ui.rs index 4cac790..a89b0f0 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -8,7 +8,7 @@ use ratatui::{ use std::path::Path; use vt100::Cell; -use crate::app::{App, PaneSelection, RenamePaneView, SettingsRow}; +use crate::app::{App, PaneGroupView, PaneSelection, PromptView, RenamePaneView, SettingsRow}; const APP_BG: Color = Color::Rgb(11, 15, 20); const SETTINGS_BG: Color = Color::Rgb(9, 14, 19); @@ -35,7 +35,8 @@ pub fn draw(frame: &mut Frame<'_>, app: &App) -> DrawState { let status_area = chunks[1]; let rects = app.pane_rects(grid_area); let rename_view = app.rename_pane_view(); - let modal_open = app.settings_open() || rename_view.is_some(); + let prompt_view = app.prompt_view(); + let modal_open = app.settings_open() || rename_view.is_some() || prompt_view.is_some(); for (index, pane) in app.panes().iter().enumerate() { let Some(rect) = rects.get(index).copied() else { @@ -45,7 +46,15 @@ pub fn draw(frame: &mut Frame<'_>, app: &App) -> DrawState { let focused = app.focus() == index; let selected = app.selected().contains(&index); let sleeping = app.pane_sleeping(index); - let chrome = pane_chrome(selected, focused, pane.active, pane.exited, sleeping); + let group = app.pane_group(index); + let chrome = pane_chrome( + selected, + focused, + pane.active, + pane.exited, + sleeping, + group.map(|group| group.color.rgb), + ); let folder = app .pane_folder(index) @@ -77,6 +86,9 @@ pub fn draw(frame: &mut Frame<'_>, app: &App) -> DrawState { } else { render_screen(frame, inner, pane.screen(), app.selection_for_pane(index)); } + if let Some(group) = group { + render_group_badge(frame, rect, group); + } if focused && !sleeping && !modal_open { set_terminal_cursor(frame, inner, pane.screen()); @@ -116,7 +128,9 @@ pub fn draw(frame: &mut Frame<'_>, app: &App) -> DrawState { Span::raw(format!("{} selected", app.selected().len())), Span::raw(" | "), Span::raw(app.status().to_string()), - Span::raw(" | Alt+x swap | Alt+z sleep | hover wakes | Alt+q quit"), + Span::raw( + " | Alt+x swap | Alt+z sleep | Alt+g group | Alt+u ungroup | hover wakes | Alt+q quit", + ), ]); frame.render_widget( Paragraph::new(status).style(Style::default().bg(APP_BG)), @@ -129,6 +143,9 @@ pub fn draw(frame: &mut Frame<'_>, app: &App) -> DrawState { if let Some(rename) = rename_view.as_ref() { render_rename_pane(frame, area, rename); } + if let Some(prompt) = prompt_view.as_ref() { + render_manager_prompt(frame, area, prompt); + } DrawState { grid_area, @@ -151,6 +168,72 @@ fn pane_title( } } +fn render_group_badge(frame: &mut Frame<'_>, rect: Rect, group: PaneGroupView) { + let label = format!(" G{} ", group.label); + let width = label.len() as u16; + if rect.width <= width.saturating_add(2) { + return; + } + + let area = Rect { + x: rect.x + rect.width - width - 1, + y: rect.y, + width, + height: 1, + }; + frame.render_widget( + Paragraph::new(label).style( + Style::default() + .fg(Color::Black) + .bg(rgb_color(group.color.rgb)) + .add_modifier(Modifier::BOLD), + ), + area, + ); +} + +fn render_manager_prompt(frame: &mut Frame<'_>, area: Rect, prompt: &PromptView) { + let width = area.width.saturating_sub(4).max(24); + let prompt_area = Rect { + x: area.x.saturating_add(2), + y: area.y.saturating_add(area.height.saturating_sub(5)), + width: width.min(area.width), + height: 3, + }; + frame.render_widget(Clear, prompt_area); + + let input = if prompt.input.is_empty() { + Span::styled( + "type instruction, Enter sends, Esc cancels", + Style::default().fg(Color::DarkGray), + ) + } else { + Span::raw(prompt.input.as_str()) + }; + let line = Line::from(vec![ + Span::styled( + format!(" G{} ", prompt.label), + Style::default() + .fg(Color::Black) + .bg(rgb_color(prompt.color.rgb)) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + input, + ]); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(rgb_color(prompt.color.rgb))) + .title(" Manager "); + frame.render_widget( + Paragraph::new(line) + .block(block) + .style(Style::default().fg(Color::Rgb(230, 237, 243)).bg(APP_BG)), + prompt_area, + ); +} + #[derive(Debug, PartialEq)] struct PaneChrome { border_style: Style, @@ -163,6 +246,7 @@ fn pane_chrome( _active: bool, exited: bool, sleeping: bool, + group_color: Option<(u8, u8, u8)>, ) -> PaneChrome { let border_style = if sleeping { Style::default().fg(Color::Rgb(32, 36, 42)) @@ -176,6 +260,10 @@ fn pane_chrome( .add_modifier(Modifier::BOLD) } else if exited { Style::default().fg(Color::Red) + } else if let Some(group_color) = group_color { + Style::default() + .fg(rgb_color(group_color)) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::DarkGray) }; @@ -822,6 +910,10 @@ fn selection_style(style: Style, selection: Option, row: u16, col } } +fn rgb_color((red, green, blue): (u8, u8, u8)) -> Color { + Color::Rgb(red, green, blue) +} + fn vt_color(color: vt100::Color, default: Color) -> Color { match color { vt100::Color::Default => default, @@ -888,24 +980,27 @@ mod tests { #[test] fn output_activity_does_not_change_idle_pane_chrome() { assert_eq!( - pane_chrome(false, false, false, false, false), - pane_chrome(false, false, true, false, false) + pane_chrome(false, false, false, false, false, None), + pane_chrome(false, false, true, false, false, None) ); } #[test] fn selected_and_exited_badges_remain_visible() { assert_eq!( - pane_chrome(true, false, true, false, false).badge, + pane_chrome(true, false, true, false, false, None).badge, " selected" ); - assert_eq!(pane_chrome(true, false, true, true, false).badge, " exited"); + assert_eq!( + pane_chrome(true, false, true, true, false, None).badge, + " exited" + ); } #[test] fn sleeping_panes_show_sleep_badge() { assert_eq!( - pane_chrome(false, false, true, false, true).badge, + pane_chrome(false, false, true, false, true, None).badge, " asleep" ); } diff --git a/src/vibe.rs b/src/vibe.rs index ea3683d..a2c19e3 100644 --- a/src/vibe.rs +++ b/src/vibe.rs @@ -46,6 +46,17 @@ pub fn parse_profiles(raw: &str) -> Vec { .collect::>() } +pub fn profile_for_name(name: &str, profiles: &[VibeProfile]) -> Option { + profiles + .iter() + .find(|profile| profile.name == name && profile.ready) + .map(|profile| Profile { + command: "vibe".into(), + args: vec!["run".into(), profile.name.clone(), "--".into()], + title: Some(profile.name.clone()), + }) +} + fn parse_profile_line(line: &str) -> Option { let trimmed = line.trim(); if trimmed.is_empty() @@ -105,4 +116,25 @@ Profiles in C:\Users\Jason\.claude-profiles: ] ); } + + #[test] + fn builds_profile_for_ready_vibe_agent() { + let profiles = vec![ + VibeProfile { + name: "claude-1".into(), + ready: true, + status: "auth files present".into(), + }, + VibeProfile { + name: "codex-1".into(), + ready: false, + status: "not logged in".into(), + }, + ]; + + let profile = profile_for_name("claude-1", &profiles).expect("ready profile"); + assert_eq!(profile.command, "vibe"); + assert_eq!(profile.args, vec!["run", "claude-1", "--"]); + assert!(profile_for_name("codex-1", &profiles).is_none()); + } }