diff --git a/Cargo.lock b/Cargo.lock index 4f23e15..257c524 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -647,6 +647,7 @@ name = "gridbash" version = "0.1.6" dependencies = [ "anyhow", + "base64", "clap", "crossterm", "directories", diff --git a/Cargo.toml b/Cargo.toml index 87bf564..22d810a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ categories = ["command-line-utilities", "development-tools"] [dependencies] anyhow = "1.0" +base64 = "0.22" clap = { version = "4.5", features = ["derive"] } crossterm = { version = "0.29", features = ["event-stream"] } directories = "6.0" diff --git a/README.md b/README.md index e3f6a5b..4ae1d4b 100644 --- a/README.md +++ b/README.md @@ -66,15 +66,18 @@ Its niche is Windows-native, PTY-backed, agent-first terminal grids. Traditional - Real PTY-backed panes through Windows ConPTY via `portable-pty`. - Up to 100 panes in one terminal process. +- Multiple tabbed grids in one terminal process. - Configurable default terminal profile: Git Bash, PowerShell, cmd, agents, or custom. - Pane-contained drag selection that copies selected terminal text without crossing into sibling panes. - 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. +- Modeless Alt shortcuts for pane focus, selection, rename, settings, grouping, 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, sleep, exit, usage, and quiet-output cues. - 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 Assets @@ -175,6 +178,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 @@ -183,6 +205,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 @@ -238,13 +262,21 @@ GridBash captures drag selection so selected text stays inside the pane where th | Alt+Up / Alt+Down | Focus pane above / below in the column, wrapping at column edges | | Alt+Shift+Up / Alt+Shift+Down | Remove / add a row when safe | | Alt+Shift+Left / Alt+Shift+Right | Remove / add a column when safe | +| Alt+n | Open the startup picker and launch a new tab | +| Alt+t | Switch to the next tab | | Alt+s | Toggle focused pane selection | | Alt+a | Select all panes, or clear selection when all panes are selected | +| Alt+c | Focus or unfocus the command bar | | Alt+p | Open settings for the focused pane; use Reload past history to refresh its visible conversation snapshot | +| Alt+Shift+p | Open the previous panes list | | Alt+r | Rename the focused pane | -| Alt+t | Restart exited focused pane; when multiple panes are selected, restart exited selected panes | +| Alt+Shift+r | Rename the current tab | +| Alt+Shift+t | Restart exited focused pane; when multiple panes are selected, restart exited selected panes | | 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+e | Expand or hide command output | | Alt+o | Open settings | | Alt+q | Quit | @@ -253,18 +285,43 @@ conversation history snapshot. Press `Esc`, `q`, or `Alt+p` to close it, or `Alt+o` to switch to overall settings. When the focused pane has exited, GridBash shows a recovery dialog. Press `Enter`, -`r`, or `t` to restart it, or press `z` to put it to sleep. `Alt+t` still restarts -exited target panes directly when your terminal reports Alt shortcuts as modified -key events. +`r`, or `t` to restart it, or press `z` to put it to sleep. `Alt+Shift+t` restarts +exited target panes directly. -Typing goes to selected panes whenever multiple panes are selected. With zero or one pane selected, input goes to the focused pane. +Typing goes to selected panes whenever multiple panes are selected. With zero or one pane selected, input goes to the focused pane. When the one-line command bar is focused, typing stays in that bar; Enter runs the command from the cwd shown in the prompt and keeps output hidden until expanded. Renamed pane headers replace the numeric prefix for the current session. Saving a blank name restores the default number. +Settings includes a General tab for local runtime display controls and an Auth tab for GridBash-wide Claude/Codex auth defaults. + Pane titles add a small quiet-output marker after roughly three seconds without output. The marker means a pane produced output and then went idle; it does not mean the process exited or completed its task. The settings screen includes sample controls plus live color controls for the accent, focus, selected, quiet, and exited grid roles. Palette changes apply immediately for the current run. +## Auth Profiles + +GridBash can launch Claude and Codex with isolated auth/config directories. It discovers profiles from: + +```text +GRIDBASH_AUTH_HOME > CLAUDE_PROFILES_HOME > [auth].home > %USERPROFILE%\.claude-profiles +``` + +Claude profiles launch with `CLAUDE_CONFIG_DIR=`. Codex profiles launch with `CODEX_HOME=`. + +Auth settings controls: + +| Input | Action | +| --- | --- | +| Tab | Switch Settings tabs | +| Up / Down | Move through auth profiles | +| d | Set selected profile as the GridBash-wide default for its kind | +| n | Create a profile directory | +| l | Open the selected profile's login command | +| r | Refresh local account and usage status | +| Esc / q | Close settings | + +Usage status is best-effort. GridBash reads local auth metadata, masks account emails, and uses short-timeout `curl.exe` requests only while the Auth settings view is refreshed. + ## Profiles Built-in profile keys: @@ -278,7 +335,7 @@ GridBash resolves Windows `.exe` and `.cmd` shims before extensionless npm shims Optional config file: ```text -%APPDATA%\GridBash\config.toml +%APPDATA%\GridBash\config\config.toml ``` Example: @@ -286,11 +343,21 @@ Example: ```toml [defaults] profile = "powershell" +manager_profile = "claude-1" + +[auth] +home = "C:\\Users\\Jason\\.claude-profiles" +usage_status = true + +[auth.defaults] +claude = "claude-1" +codex = "codex-2" [profiles.review] command = "codex" args = ["--model", "gpt-5.5"] title = "Codex Review" +agent_kind = "codex" ``` Then run: @@ -305,6 +372,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..210c367 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1,15 +1,33 @@ [defaults] profile = "git-bash" +manager_profile = "claude-1" + +[todos] +idle_seconds = 90 +prompts = [ + "Review the latest changes and summarize anything risky.", + "Run the fastest relevant validation and report failures.", +] + +[auth] +home = "C:\\Users\\Jason\\.claude-profiles" +usage_status = true + +[auth.defaults] +claude = "claude-1" +codex = "codex-2" [profiles.codex-fast] command = "codex" args = ["--model", "gpt-5.4-mini"] title = "Codex Fast" +agent_kind = "codex" [profiles.claude-review] command = "claude" args = [] title = "Claude Review" +agent_kind = "claude" [profiles.git-worktree] command = "bash" 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-dedicated-command-bar.md b/docs/devlogs/2026-07-07-dedicated-command-bar.md new file mode 100644 index 0000000..72cb7d1 --- /dev/null +++ b/docs/devlogs/2026-07-07-dedicated-command-bar.md @@ -0,0 +1,29 @@ +# Dedicated command bar + +Date: 2026-07-07 +Release target: unreleased + +## Summary + +- Added a dedicated one-line command bar above the GridBash status footer. + +## What Changed + +- The live view now reserves a command prompt line that starts in the directory where GridBash was launched. +- Alt-arrow focus navigation can move into and out of the command line alongside panes. +- Commands run through the host shell with output captured into a hidden buffer by default. +- `Alt+e` toggles the captured command output panel, leaving `Alt+x` for pane swapping. +- Built-in `cd`, `pwd`, `clear`, and `cls` behavior keeps the command cwd useful without requiring a persistent shell. +- The command line was ported onto the current grid behavior without regressing pane sleep, swap, mouse wake, usage labels, runtime resize, or worktree labels. + +## Why It Matters + +- Users can run quick workspace commands without stealing input from a live agent pane or dedicating a full pane to command output. + +## Validation + +- `npm test` + +## Release Notes + +- Adds a focused command bar with hidden output capture and Alt-key output expansion. diff --git a/docs/devlogs/2026-07-07-idle-terminal-todo-prompts.md b/docs/devlogs/2026-07-07-idle-terminal-todo-prompts.md new file mode 100644 index 0000000..cdbf392 --- /dev/null +++ b/docs/devlogs/2026-07-07-idle-terminal-todo-prompts.md @@ -0,0 +1,28 @@ +# Idle terminal todo prompts + +Date: 2026-07-07 +Release target: unreleased + +## Summary + +- Added settings-managed todo prompts that can be offered to idle terminals. + +## What Changed + +- Added persisted `[todos]` config support for enabling idle prompts, setting the quiet delay, and storing prompt text. +- Added a TODO section to the redesigned settings pane with add, edit, delete, enable, and quiet-delay controls. +- Added per-pane quiet tracking and a small follow-up dialog that can send, cycle, remove, or dismiss queued prompts. +- Updated the example config with sample todo prompts. + +## Why It Matters + +- Users can keep spare terminals productive by preparing follow-up prompts ahead of time, then sending one when a pane has gone quiet. + +## Validation + +- `cargo check` +- `npm test` + +## Release Notes + +- New settings todo list can suggest queued follow-up prompts for quiet panes. 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/docs/devlogs/2026-07-07-integrate-native-auth-manager.md b/docs/devlogs/2026-07-07-integrate-native-auth-manager.md new file mode 100644 index 0000000..c62180c --- /dev/null +++ b/docs/devlogs/2026-07-07-integrate-native-auth-manager.md @@ -0,0 +1,34 @@ +# Integrate native auth manager + +Date: 2026-07-07 +Release target: unreleased + +## Summary + +- Integrated the native Claude/Codex auth profile manager with current `main`. +- Preserved the existing live pane usage labels while making them prefer the auth directory applied to a pane. + +## What Changed + +- Added `[auth]` config with per-kind defaults and local auth profile discovery. +- Added an Auth tab to Settings for browsing profiles, creating profile directories, setting defaults, refreshing status, and launching login. +- Tagged built-in Claude/Codex profiles with `agent_kind` so auth defaults can set `CLAUDE_CONFIG_DIR` or `CODEX_HOME` at launch. +- Pane titles now include launch profile and auth profile metadata while keeping current usage labels. +- Usage monitoring now follows the applied native auth directory before falling back to existing profile/default auth lookup. + +## Why It Matters + +- Multiple Claude and Codex accounts can run through GridBash without manually exporting auth environment variables. +- Usage labels stay accurate for panes launched with native auth defaults. +- The Settings/Profile flow from current `main` remains the base behavior. + +## Validation + +- Ran `cargo fmt`. +- Ran `npm test` with 55 passing tests and 1 ignored interactive ConPTY smoke test after rebasing onto the latest `origin/main`. + +## Release Notes + +- Added native Claude/Codex auth profile management in Settings. +- Added GridBash-wide auth defaults for built-in and kind-tagged custom agent profiles. +- Pane headers now show launch profile/auth profile metadata alongside existing usage labels. 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/docs/devlogs/2026-07-07-optimize-runtime-hot-paths.md b/docs/devlogs/2026-07-07-optimize-runtime-hot-paths.md new file mode 100644 index 0000000..811dd36 --- /dev/null +++ b/docs/devlogs/2026-07-07-optimize-runtime-hot-paths.md @@ -0,0 +1,19 @@ +# Optimize Runtime Hot Paths + +Date: 2026-07-07 +Release target: unreleased + +## Summary + +- Reduced avoidable work in GridBash's runtime loop for busy, high-pane-count sessions. + +## What Changed + +- Batched pending PTY output by pane before parsing it into vt100 screens. +- Throttled child exit polling to avoid checking every pane on every loop tick. +- Increased the PTY read buffer to reduce message churn during output bursts. +- Preserved these optimizations across active and inactive tab panes. + +## Validation + +- `cargo check` diff --git a/docs/devlogs/2026-07-07-runtime-grid-color-palette-settings.md b/docs/devlogs/2026-07-07-runtime-grid-color-palette-settings.md new file mode 100644 index 0000000..894d4e3 --- /dev/null +++ b/docs/devlogs/2026-07-07-runtime-grid-color-palette-settings.md @@ -0,0 +1,32 @@ +# Runtime grid color palette settings + +Date: 2026-07-07 +Release target: unreleased + +## Summary + +- Added live settings for GridBash grid color roles. +- References issue #34. + +## What Changed + +- Replaced the sample settings rows with runtime palette controls. +- Added palette roles for accent/chrome, focus border, selected border, active border, quiet border, and exited border. +- Settings rows show a color swatch and current color name. +- Palette changes apply immediately while GridBash is running. +- Quiet panes now default to a sky-blue border instead of violet. + +## Why It Matters + +- Users can tune loud or ugly colors without changing code. +- Quiet-output indicators stay noticeable while still fitting the user's preferred GridBash palette. + +## Validation + +- Ran `cargo fmt --check`. +- Ran `cargo clippy -- -D warnings`. +- Ran `cargo test`. + +## Release Notes + +- Added runtime GridBash palette settings for the existing grid color roles. diff --git a/docs/devlogs/2026-07-07-show-output-done-pane-badges.md b/docs/devlogs/2026-07-07-show-output-done-pane-badges.md new file mode 100644 index 0000000..02e9bf0 --- /dev/null +++ b/docs/devlogs/2026-07-07-show-output-done-pane-badges.md @@ -0,0 +1,32 @@ +# Show output-done pane badges + +Date: 2026-07-07 +Release target: unreleased + +## Summary + +- Added subtle output-quiet pane indicators for tracking terminals that have stopped producing output. +- References issues #27, #29, #30, and #34. + +## What Changed + +- Panes now remember when output last arrived. +- New output clears the quiet state immediately. +- After roughly three seconds without output, a pane becomes `quiet`. +- The pane title now shows a compact filled-dot icon for quiet panes. +- Quiet panes use a palette-controlled border color instead of a text badge or footer count. + +## Why It Matters + +- Dense agent grids are easier to scan because panes that need attention stand out after they stop streaming output without adding loud text. +- The first version avoids overclaiming semantic task completion; it tracks output quietness while keeping true process exit distinct. + +## Validation + +- Ran `cargo fmt --check`. +- Ran `cargo clippy -- -D warnings`. +- Ran `cargo test`. + +## Release Notes + +- Added subtle quiet-output pane indicators for panes that recently stopped producing terminal output. diff --git a/docs/devlogs/2026-07-09-add-focused-pane-settings.md b/docs/devlogs/2026-07-09-add-focused-pane-settings.md index ca2598e..9d71a20 100644 --- a/docs/devlogs/2026-07-09-add-focused-pane-settings.md +++ b/docs/devlogs/2026-07-09-add-focused-pane-settings.md @@ -10,6 +10,7 @@ Release target: unreleased ## What Changed - `Alt+p` now opens settings inside the focused pane instead of drawing a global picker modal. +- The previous panes selector remains available from its button and `Alt+Shift+p`. - Added a `Reload past history` action that refreshes the pane's visible conversation snapshot. - Kept `Alt+o` dedicated to overall GridBash settings, including from the pane settings view. diff --git a/docs/devlogs/2026-07-09-add-previous-panes-list-button.md b/docs/devlogs/2026-07-09-add-previous-panes-list-button.md index 0efe7f7..842289f 100644 --- a/docs/devlogs/2026-07-09-add-previous-panes-list-button.md +++ b/docs/devlogs/2026-07-09-add-previous-panes-list-button.md @@ -25,4 +25,4 @@ Release target: unreleased ## Release Notes -- Added a previous panes list button and `Alt+p` selector for focusing panes by their conversation summary. +- Added a previous panes list button and `Alt+Shift+p` selector for focusing panes by their conversation summary. diff --git a/docs/devlogs/2026-07-09-land-remaining-unmerged-branches.md b/docs/devlogs/2026-07-09-land-remaining-unmerged-branches.md new file mode 100644 index 0000000..51b2dc5 --- /dev/null +++ b/docs/devlogs/2026-07-09-land-remaining-unmerged-branches.md @@ -0,0 +1,29 @@ +# Land remaining unmerged branches + +Date: 2026-07-09 +Release target: unreleased + +## Summary + +- Audited Codex sessions launched from the GridBash repo, reviewed remaining local and remote branches, and consolidated the useful pending feature work onto one integration branch. +- Ported stale branch work manually where direct merges would have removed newer code, then marked the superseded branch tips as merged so they can be closed and cleaned up. + +## What Changed + +- Integrated the remaining branch queue into the current app surface: tabbed grids, dedicated command bar, native auth/profile management, hidden manager groups, session resume, idle todo follow-ups, runtime grid resizing, pane rename/swap/deselect/sleep/restart/history settings, worktree management, output idle badges, color palette settings, SEO docs, startup update checks, and release workflow fixes. +- Reviewed local-only branches that were not visible in the remote unmerged check. Their useful patches were already present on this branch, including swap selected tiles, deselect specific panes, contributor onboarding, exact-version release fixes, release retry fixes, and startup grid picker fixes. +- Audited direct user feature requests from GridBash Codex sessions and verified the requested UI/tooling behaviors are present in the integrated tree or represented by tracked docs/devlogs. +- Fixed the one shortcut mismatch found during the audit: `Alt+p` now opens focused-pane settings/history as requested, while the previous-panes list remains available from its status-bar button and `Alt+Shift+p`. + +## Why It Matters + +- GridBash had useful work spread across stale PRs, local worktrees, and sessions that had not landed on `main`, which made the installed command feel behind the requested feature set. +- Keeping the features on one reviewed integration branch gives `main` a single coherent merge point and makes branch cleanup safer after validation passes. + +## Validation + +- Pending final integration validation from this branch, then again from local `main` after the PR is merged. + +## Release Notes + +- Landed the remaining reviewed GridBash feature backlog and aligned the focused-pane settings shortcut with the requested `Alt+p` behavior. diff --git a/src/app.rs b/src/app.rs index 00a4dfd..0beeec3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,8 +1,13 @@ use std::{ collections::{BTreeMap, BTreeSet}, env, + ffi::OsString, io::{self, Stdout, Write}, + mem, + path::{Path, PathBuf}, + process::Command, sync::mpsc as std_mpsc, + thread, time::{Duration, Instant}, }; @@ -21,50 +26,88 @@ use tokio::sync::mpsc; use vt100::{MouseProtocolEncoding, MouseProtocolMode, Screen}; use crate::{ + auth::{self, AgentKind, AuthProfile}, cli::{Cli, GridMode}, composer::Composer, config::Config, control::{self, ControlCommand, ControlEnvelope, ControlHandle, ControlResponse}, image_preview::{self, ImagePreview}, 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}, + session::{SavedPaneHistory, SessionRecord, SessionRecorder}, 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 EXIT_POLL_INTERVAL: Duration = Duration::from_millis(500); +const WORKER_RELAY_MAX_BYTES: usize = 6000; const MAX_PANE_NAME_CHARS: usize = 32; +const MAX_TAB_TITLE_CHARS: usize = 40; const CONVERSATION_SUMMARY_MAX_CHARS: usize = 120; +const TODO_INPUT_LIMIT: usize = 240; +const MIN_TODO_IDLE_SECONDS: u64 = 15; +const MAX_TODO_IDLE_SECONDS: u64 = 600; +const TODO_IDLE_STEP_SECONDS: u64 = 15; +const COMMAND_OUTPUT_MAX_LINES: usize = 2000; const ACTIVITY_DECAY_INTERVAL: Duration = Duration::from_millis(250); const OUTPUT_QUIET_AFTER: Duration = Duration::from_secs(3); pub struct App { config: Config, + config_path: Option, + manager_profile_name: Option, worktrees: Option, + tabs: Vec>, + active_tab: usize, + tab_title: String, launch_plan: Option, layout: GridLayout, grid_area: Rect, panes: Vec, + pane_idle: Vec, focus: usize, selected: BTreeSet, pane_names: Vec>, text_selection: Option, sleeping: BTreeSet, + groups: Vec, + next_group_id: usize, + prompt: Option, rects: Vec, mouse_enabled: bool, + command_line: CommandLineState, + command_tx: mpsc::UnboundedSender, + command_rx: mpsc::UnboundedReceiver, control_handle: Option, control_rx: Option>, image_overlay: Option, settings: SettingsState, rename: RenamePaneState, + tab_rename: RenameTabState, + previous_panes: PreviousPanesState, + follow_up: Option, + auth_profiles: Vec, + auth_refresh_rx: Option, String>>>, pane_settings: PaneSettingsState, status: String, + restored_histories: Vec, + session_recorder: Option, next_pane_id: usize, + next_tab_number: usize, + previous_panes_button: Option, + previous_pane_rows: Vec<(usize, Rect)>, pane_settings_button: Option, pane_settings_reload_button: Option, event_tx: mpsc::UnboundedSender, @@ -74,12 +117,56 @@ pub struct App { profile_usage: BTreeMap, api_spend_label: Option, last_activity_decay: Instant, + last_exit_poll: Instant, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct AppInit { + config: Config, + config_path: Option, + manager_profile_name: Option, + worktrees: Option, + launch_plan: Option, + grid: GridSize, + mouse_enabled: bool, + command_cwd: PathBuf, + control_handle: Option, + control_rx: Option>, + settings: SettingsState, + restored_histories: Vec, + session_recorder: Option, + status: String, +} + +struct GridTabSnapshot { + title: String, + launch_plan: Option, + layout: GridLayout, + panes: Vec, + pane_idle: Vec, + focus: usize, + selected: BTreeSet, + pane_names: Vec>, + text_selection: Option, + sleeping: BTreeSet, + groups: Vec, + next_group_id: usize, + prompt: Option, + rects: Vec, +} + +#[derive(Debug, Clone)] +pub struct TabLabel { + pub title: String, + pub active: bool, + pub activity: bool, + pub exited: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] enum KeyOutcome { Continue, Render, + AuthLogin(AuthProfile), Quit, } @@ -163,6 +250,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, Copy, PartialEq, Eq)] enum ExitedRecoveryAction { Restart, @@ -346,10 +463,86 @@ impl GridPalette { #[derive(Debug, Clone)] pub struct SettingsRow { pub selected: bool, - pub label: &'static str, + pub group: SettingsGroup, + pub value_kind: SettingsValueKind, + pub editing: bool, + pub label: String, pub value: String, pub value_color: Option, - pub hint: &'static str, + pub hint: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SettingsGroup { + Display, + Workflow, + Todo, + Performance, + Theme, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SettingsValueKind { + Switch, + Stepper, + Choice, + Text, + Action, +} + +#[derive(Debug, Clone)] +pub struct FollowUpDialog { + pub pane_number: usize, + pub prompt: String, + pub todo_position: usize, + pub todo_count: usize, + pub quiet_seconds: u64, +} + +#[derive(Debug, Clone, Copy)] +struct PaneIdleState { + last_output_at: Instant, + snoozed_until: Option, +} + +#[derive(Debug, Clone, Copy)] +struct FollowUpPromptState { + pane_index: usize, + todo_index: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SettingsTarget { + CompactTitles, + ActivityBadges, + ConfirmQuit, + IdleFollowups, + IdleSeconds, + Todo(usize), + AddTodo, + PaneDensity, + Scrollback, + RefreshMs, + Palette(PaletteRole), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct TodoEditState { + target: TodoEditTarget, + buffer: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TodoEditTarget { + Existing(usize), + New, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SettingsChange { + None, + Render, + SaveTodos, } #[derive(Debug, Clone)] @@ -360,6 +553,66 @@ pub struct RenamePaneView { pub cursor: usize, } +#[derive(Debug, Clone)] +pub struct RenameTabView { + pub title: String, + pub value: String, + pub cursor: usize, +} + +#[derive(Debug, Clone)] +pub struct PreviousPanesView { + pub cursor: usize, + pub panes: Vec, +} + +#[derive(Debug, Clone)] +pub struct PreviousPaneView { + pub index: usize, + pub label: String, + pub folder: String, + pub worktree: Option, + pub summary: String, + pub focused: bool, + pub selected: bool, + pub sleeping: bool, + pub exited: bool, +} + +#[derive(Debug, Clone, Default)] +struct PreviousPanesState { + open: bool, + cursor: usize, +} + +impl PreviousPanesState { + fn begin(&mut self, focus: usize, pane_count: usize) { + self.open = true; + self.cursor = focus.min(pane_count.saturating_sub(1)); + } + + fn close(&mut self) { + self.open = false; + } + + fn move_cursor(&mut self, delta: isize, pane_count: usize) { + if pane_count == 0 { + self.cursor = 0; + return; + } + + self.cursor = (self.cursor as isize + delta).clamp(0, pane_count as isize - 1) as usize; + } + + fn move_to_start(&mut self) { + self.cursor = 0; + } + + fn move_to_end(&mut self, pane_count: usize) { + self.cursor = pane_count.saturating_sub(1); + } +} + #[derive(Debug, Clone)] pub struct PaneSettingsView { pub index: usize, @@ -404,6 +657,18 @@ pub struct ExitedPaneRecoveryView { pub target_count: usize, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SettingsTab { + General, + Auth, +} + +#[derive(Debug, Clone)] +pub struct AuthCreateState { + pub kind: AgentKind, + pub name: String, +} + #[derive(Debug, Clone, Default)] struct RenamePaneState { open: bool, @@ -483,173 +748,706 @@ impl RenamePaneState { } } +#[derive(Debug, Clone, Default)] +struct RenameTabState { + open: bool, + value: String, + cursor: usize, +} + +impl RenameTabState { + fn begin(&mut self, title: &str) { + self.open = true; + self.value = title.to_string(); + self.cursor = self.value.chars().count(); + } + + fn close(&mut self) { + self.open = false; + self.value.clear(); + self.cursor = 0; + } + + fn move_cursor(&mut self, delta: isize) { + let count = self.value.chars().count() as isize; + self.cursor = (self.cursor as isize + delta).clamp(0, count) as usize; + } + + fn move_to_start(&mut self) { + self.cursor = 0; + } + + fn move_to_end(&mut self) { + self.cursor = self.value.chars().count(); + } + + fn clear(&mut self) { + self.value.clear(); + self.cursor = 0; + } + + fn insert_char(&mut self, ch: char) { + if ch.is_control() || self.value.chars().count() >= MAX_TAB_TITLE_CHARS { + return; + } + + let index = char_to_byte_index(&self.value, self.cursor); + self.value.insert(index, ch); + self.cursor += 1; + } + + fn insert_text(&mut self, text: &str) { + for ch in text.chars() { + self.insert_char(ch); + } + } + + fn backspace(&mut self) { + if self.cursor == 0 { + return; + } + + let start = char_to_byte_index(&self.value, self.cursor - 1); + let end = char_to_byte_index(&self.value, self.cursor); + self.value.replace_range(start..end, ""); + self.cursor -= 1; + } + + fn delete(&mut self) { + let count = self.value.chars().count(); + if self.cursor >= count { + return; + } + + let start = char_to_byte_index(&self.value, self.cursor); + let end = char_to_byte_index(&self.value, self.cursor + 1); + self.value.replace_range(start..end, ""); + } +} + #[derive(Debug, Clone)] struct SettingsState { open: bool, + tab: SettingsTab, cursor: usize, + auth_cursor: usize, + auth_refreshing: bool, + create_auth: Option, compact_titles: bool, activity_badges: bool, confirm_quit: bool, + idle_followups: bool, + idle_seconds: u64, + todo_prompts: Vec, + todo_edit: Option, pane_density: i32, scrollback: i32, refresh_ms: i32, palette: GridPalette, } -impl Default for SettingsState { - fn default() -> Self { +#[derive(Debug, Clone)] +struct CommandLineState { + focused: bool, + cwd: PathBuf, + input: String, + cursor: usize, + output_lines: Vec, + output_expanded: bool, + running: bool, +} + +#[derive(Debug, Clone)] +struct CommandRunEvent { + command: String, + stdout: String, + stderr: String, + exit_code: Option, + error: Option, +} + +impl CommandLineState { + fn new(cwd: PathBuf) -> Self { Self { - open: false, + focused: false, + cwd, + input: String::new(), cursor: 0, - compact_titles: false, - activity_badges: true, - confirm_quit: false, - pane_density: 2, - scrollback: 10_000, - refresh_ms: 16, - palette: GridPalette::default(), + output_lines: Vec::new(), + output_expanded: false, + running: false, } } -} -impl SettingsState { - const BASE_ROW_COUNT: usize = 6; - const ROW_COUNT: usize = Self::BASE_ROW_COUNT + PaletteRole::ALL.len(); + fn insert_text(&mut self, text: &str) { + for ch in text.chars() { + if matches!(ch, '\r' | '\n') { + self.insert_char(' '); + } else if !ch.is_control() { + self.insert_char(ch); + } + } + } - fn move_cursor(&mut self, delta: isize) { - let current = self.cursor as isize; - self.cursor = (current + delta).clamp(0, Self::ROW_COUNT as isize - 1) as usize; + fn insert_char(&mut self, ch: char) { + self.input.insert(self.cursor, ch); + self.cursor += ch.len_utf8(); } - fn activate(&mut self) { - if self.palette_role().is_some() { - self.adjust(1); - return; + fn backspace(&mut self) -> bool { + let Some(previous) = previous_char_boundary(&self.input, self.cursor) else { + return false; + }; + self.input.replace_range(previous..self.cursor, ""); + self.cursor = previous; + true + } + + fn delete(&mut self) -> bool { + if self.cursor >= self.input.len() { + return false; } + let next = next_char_boundary(&self.input, self.cursor); + self.input.replace_range(self.cursor..next, ""); + true + } + + fn move_left(&mut self) -> bool { + let Some(previous) = previous_char_boundary(&self.input, self.cursor) else { + return false; + }; + self.cursor = previous; + true + } - match self.cursor { - 0 => self.compact_titles = !self.compact_titles, - 1 => self.activity_badges = !self.activity_badges, - 2 => self.confirm_quit = !self.confirm_quit, - _ => self.adjust(1), + fn move_right(&mut self) -> bool { + if self.cursor >= self.input.len() { + return false; } + self.cursor = next_char_boundary(&self.input, self.cursor); + true } - fn adjust(&mut self, delta: i32) { - if let Some(role) = self.palette_role() { - self.palette.adjust(role, delta as isize); - return; + fn move_home(&mut self) -> bool { + if self.cursor == 0 { + return false; } + self.cursor = 0; + true + } - match self.cursor { - 0 => { - if delta != 0 { - self.compact_titles = !self.compact_titles; - } - } - 1 => { - if delta != 0 { - self.activity_badges = !self.activity_badges; - } - } - 2 => { - if delta != 0 { - self.confirm_quit = !self.confirm_quit; - } - } - 3 => self.pane_density = (self.pane_density + delta).clamp(1, 5), - 4 => self.scrollback = (self.scrollback + delta * 1000).clamp(1_000, 50_000), - 5 => self.refresh_ms = (self.refresh_ms + delta * 4).clamp(8, 100), - _ => {} + fn move_end(&mut self) -> bool { + if self.cursor == self.input.len() { + return false; } + self.cursor = self.input.len(); + true } - fn rows(&self) -> Vec { - let mut rows = vec![ - self.row( - 0, - "Compact pane titles", - switch_value(self.compact_titles), - None, - "shorter labels in pane chrome", - ), - self.row( - 1, - "Activity badges", - switch_value(self.activity_badges), - None, - "quiet markers", - ), - self.row( - 2, - "Confirm before quit", - switch_value(self.confirm_quit), - None, - "extra guard for Alt+q", - ), - self.row( - 3, - "Pane density", - self.pane_density.to_string(), - None, - "spacing scale from 1 to 5", - ), - self.row( - 4, - "Scrollback rows", - self.scrollback.to_string(), - None, - "history budget per pane", - ), - self.row( - 5, - "Refresh delay", - format!("{} ms", self.refresh_ms), - None, - "render loop throttle", - ), + fn clear_input(&mut self) -> bool { + if self.input.is_empty() { + return false; + } + self.input.clear(); + self.cursor = 0; + true + } + + fn take_submission(&mut self) -> Option { + let command = self.input.trim().to_string(); + self.input.clear(); + self.cursor = 0; + (!command.is_empty()).then_some(command) + } + + fn cursor_chars(&self) -> usize { + self.input[..self.cursor].chars().count() + } + + fn push_output_line(&mut self, line: impl Into) { + self.output_lines.push(line.into()); + if self.output_lines.len() > COMMAND_OUTPUT_MAX_LINES { + let excess = self.output_lines.len() - COMMAND_OUTPUT_MAX_LINES; + self.output_lines.drain(0..excess); + } + } + + fn push_output_text(&mut self, text: &str) { + if text.is_empty() { + return; + } + + for line in text.replace("\r\n", "\n").replace('\r', "\n").lines() { + self.push_output_line(line.to_string()); + } + } +} + +impl Default for SettingsState { + fn default() -> Self { + Self { + open: false, + tab: SettingsTab::General, + cursor: 0, + auth_cursor: 0, + auth_refreshing: false, + create_auth: None, + compact_titles: false, + activity_badges: true, + confirm_quit: false, + idle_followups: true, + idle_seconds: crate::config::TodoSettings::default_idle_seconds(), + todo_prompts: Vec::new(), + todo_edit: None, + pane_density: 2, + scrollback: 10_000, + refresh_ms: 16, + palette: GridPalette::default(), + } + } +} + +impl SettingsState { + fn from_config(config: &Config) -> Self { + Self { + idle_followups: config.todos.enabled, + idle_seconds: config + .todos + .idle_seconds + .clamp(MIN_TODO_IDLE_SECONDS, MAX_TODO_IDLE_SECONDS), + todo_prompts: config.todos.normalized_prompts(), + ..Self::default() + } + } + + fn move_cursor(&mut self, delta: isize) { + self.cancel_todo_edit(); + let row_count = self.row_targets().len(); + if row_count == 0 { + self.cursor = 0; + return; + } + + let current = self.cursor as isize; + self.cursor = (current + delta).clamp(0, row_count as isize - 1) as usize; + } + + fn activate(&mut self) -> SettingsChange { + match self.selected_target() { + Some(SettingsTarget::CompactTitles) => { + self.compact_titles = !self.compact_titles; + SettingsChange::Render + } + Some(SettingsTarget::ActivityBadges) => { + self.activity_badges = !self.activity_badges; + SettingsChange::Render + } + Some(SettingsTarget::ConfirmQuit) => { + self.confirm_quit = !self.confirm_quit; + SettingsChange::Render + } + Some(SettingsTarget::IdleFollowups) => { + self.idle_followups = !self.idle_followups; + SettingsChange::SaveTodos + } + Some(SettingsTarget::Todo(index)) => { + self.start_todo_edit(TodoEditTarget::Existing(index)); + SettingsChange::Render + } + Some(SettingsTarget::AddTodo) => { + self.start_todo_edit(TodoEditTarget::New); + SettingsChange::Render + } + Some(_) => self.adjust(1), + None => SettingsChange::None, + } + } + + fn adjust(&mut self, delta: i32) -> SettingsChange { + self.cancel_todo_edit(); + match self.selected_target() { + Some(SettingsTarget::CompactTitles) => { + if delta != 0 { + self.compact_titles = !self.compact_titles; + } + SettingsChange::Render + } + Some(SettingsTarget::ActivityBadges) => { + if delta != 0 { + self.activity_badges = !self.activity_badges; + } + SettingsChange::Render + } + Some(SettingsTarget::ConfirmQuit) => { + if delta != 0 { + self.confirm_quit = !self.confirm_quit; + } + SettingsChange::Render + } + Some(SettingsTarget::IdleFollowups) => { + if delta != 0 { + self.idle_followups = !self.idle_followups; + } + SettingsChange::SaveTodos + } + Some(SettingsTarget::IdleSeconds) => { + let step = (delta as i64) * (TODO_IDLE_STEP_SECONDS as i64); + let next = (self.idle_seconds as i64 + step) + .clamp(MIN_TODO_IDLE_SECONDS as i64, MAX_TODO_IDLE_SECONDS as i64) + as u64; + self.idle_seconds = next; + SettingsChange::SaveTodos + } + Some(SettingsTarget::PaneDensity) => { + self.pane_density = (self.pane_density + delta).clamp(1, 5); + SettingsChange::Render + } + Some(SettingsTarget::Scrollback) => { + self.scrollback = (self.scrollback + delta * 1000).clamp(1_000, 50_000); + SettingsChange::Render + } + Some(SettingsTarget::RefreshMs) => { + self.refresh_ms = (self.refresh_ms + delta * 4).clamp(8, 100); + SettingsChange::Render + } + Some(SettingsTarget::Palette(role)) => { + self.palette.adjust(role, delta as isize); + SettingsChange::Render + } + Some(SettingsTarget::Todo(index)) => { + self.start_todo_edit(TodoEditTarget::Existing(index)); + SettingsChange::Render + } + Some(SettingsTarget::AddTodo) => { + self.start_todo_edit(TodoEditTarget::New); + SettingsChange::Render + } + None => SettingsChange::None, + } + } + + fn delete_selected_todo(&mut self) -> SettingsChange { + self.cancel_todo_edit(); + if let Some(SettingsTarget::Todo(index)) = self.selected_target() + && index < self.todo_prompts.len() + { + self.todo_prompts.remove(index); + self.clamp_cursor(); + return SettingsChange::SaveTodos; + } + + SettingsChange::None + } + + fn editing_todo(&self) -> bool { + self.todo_edit.is_some() + } + + fn cancel_todo_edit(&mut self) { + self.todo_edit = None; + } + + fn insert_todo_text(&mut self, text: &str) -> bool { + let Some(edit) = self.todo_edit.as_mut() else { + return false; + }; + + let available = TODO_INPUT_LIMIT.saturating_sub(edit.buffer.len()); + if available == 0 { + return false; + } + + edit.buffer.extend(text.chars().take(available)); + true + } + + fn backspace_todo_text(&mut self) -> bool { + self.todo_edit + .as_mut() + .and_then(|edit| edit.buffer.pop()) + .is_some() + } + + fn commit_todo_edit(&mut self) -> bool { + let Some(edit) = self.todo_edit.take() else { + return false; + }; + + let prompt = edit.buffer.trim().to_string(); + match edit.target { + TodoEditTarget::Existing(index) if prompt.is_empty() => { + if index < self.todo_prompts.len() { + self.todo_prompts.remove(index); + self.clamp_cursor(); + return true; + } + } + TodoEditTarget::Existing(index) => { + if let Some(existing) = self.todo_prompts.get_mut(index) { + *existing = prompt; + return true; + } + } + TodoEditTarget::New if !prompt.is_empty() => { + self.todo_prompts.push(prompt); + self.cursor = self.row_targets().len().saturating_sub(1); + return true; + } + TodoEditTarget::New => {} + } + + false + } + + fn todo_settings(&self) -> crate::config::TodoSettings { + crate::config::TodoSettings { + enabled: self.idle_followups, + idle_seconds: self.idle_seconds, + prompts: self.todo_prompts.clone(), + } + } + + fn idle_delay(&self) -> Duration { + Duration::from_secs( + self.idle_seconds + .clamp(MIN_TODO_IDLE_SECONDS, MAX_TODO_IDLE_SECONDS), + ) + } + + fn selected_target(&self) -> Option { + self.row_targets().get(self.cursor).copied() + } + + fn row_targets(&self) -> Vec { + let mut targets = vec![ + SettingsTarget::CompactTitles, + SettingsTarget::ActivityBadges, + SettingsTarget::ConfirmQuit, + SettingsTarget::IdleFollowups, + SettingsTarget::IdleSeconds, ]; + targets.extend( + self.todo_prompts + .iter() + .enumerate() + .map(|(index, _)| SettingsTarget::Todo(index)), + ); + targets.extend([ + SettingsTarget::AddTodo, + SettingsTarget::PaneDensity, + SettingsTarget::Scrollback, + SettingsTarget::RefreshMs, + ]); + targets.extend( + PaletteRole::ALL + .iter() + .copied() + .map(SettingsTarget::Palette), + ); + targets + } + + fn clamp_cursor(&mut self) { + let last = self.row_targets().len().saturating_sub(1); + self.cursor = self.cursor.min(last); + } + + fn start_todo_edit(&mut self, target: TodoEditTarget) { + let buffer = match target { + TodoEditTarget::Existing(index) => { + self.todo_prompts.get(index).cloned().unwrap_or_default() + } + TodoEditTarget::New => String::new(), + }; + self.todo_edit = Some(TodoEditState { target, buffer }); + } + + fn todo_edit_value(&self, target: SettingsTarget) -> Option { + let edit = self.todo_edit.as_ref()?; + let matches = match (target, edit.target) { + (SettingsTarget::Todo(row), TodoEditTarget::Existing(editing)) => row == editing, + (SettingsTarget::AddTodo, TodoEditTarget::New) => true, + _ => false, + }; + + matches.then(|| format!("{}_", edit.buffer)) + } + + fn is_editing_target(&self, target: SettingsTarget) -> bool { + self.todo_edit_value(target).is_some() + } + + fn rows(&self) -> Vec { + let mut rows = Vec::new(); + + rows.push(self.row( + SettingsTarget::CompactTitles, + SettingsGroup::Display, + SettingsValueKind::Switch, + "Compact pane titles", + switch_value(self.compact_titles), + "shorter labels in pane chrome", + )); + rows.push(self.row( + SettingsTarget::ActivityBadges, + SettingsGroup::Display, + SettingsValueKind::Switch, + "Activity badges", + switch_value(self.activity_badges), + "show live and selected state", + )); + rows.push(self.row( + SettingsTarget::ConfirmQuit, + SettingsGroup::Workflow, + SettingsValueKind::Switch, + "Confirm before quit", + switch_value(self.confirm_quit), + "extra guard for Alt+q", + )); + rows.push(self.row( + SettingsTarget::IdleFollowups, + SettingsGroup::Todo, + SettingsValueKind::Switch, + "Idle todo prompts", + switch_value(self.idle_followups), + "ask before sending queued follow-ups", + )); + rows.push(self.row( + SettingsTarget::IdleSeconds, + SettingsGroup::Todo, + SettingsValueKind::Stepper, + "Quiet delay", + format!("{} s", self.idle_seconds), + "time since last terminal output", + )); + + for (index, prompt) in self.todo_prompts.iter().enumerate() { + let target = SettingsTarget::Todo(index); + let value = self + .todo_edit_value(target) + .unwrap_or_else(|| prompt.to_string()); + let hint = if self.is_editing_target(target) { + "Enter save | Esc cancel" + } else { + "Enter edit | Del remove" + }; + rows.push(self.row( + target, + SettingsGroup::Todo, + SettingsValueKind::Text, + format!("Todo {}", index + 1), + value, + hint, + )); + } + let add_target = SettingsTarget::AddTodo; + let add_value = self + .todo_edit_value(add_target) + .unwrap_or_else(|| "new prompt".to_string()); + let add_hint = if self.is_editing_target(add_target) { + "Enter save | Esc cancel" + } else { + "Enter add" + }; + rows.push(self.row( + add_target, + SettingsGroup::Todo, + if self.is_editing_target(add_target) { + SettingsValueKind::Text + } else { + SettingsValueKind::Action + }, + "Add todo", + add_value, + add_hint, + )); + + rows.push(self.row( + SettingsTarget::PaneDensity, + SettingsGroup::Performance, + SettingsValueKind::Stepper, + "Pane density", + self.pane_density.to_string(), + "spacing scale from 1 to 5", + )); + rows.push(self.row( + SettingsTarget::Scrollback, + SettingsGroup::Performance, + SettingsValueKind::Stepper, + "Scrollback rows", + self.scrollback.to_string(), + "history budget per pane", + )); + rows.push(self.row( + SettingsTarget::RefreshMs, + SettingsGroup::Performance, + SettingsValueKind::Stepper, + "Refresh delay", + format!("{} ms", self.refresh_ms), + "render loop throttle", + )); rows.extend( PaletteRole::ALL .iter() - .enumerate() - .map(|(offset, role)| self.palette_row(Self::BASE_ROW_COUNT + offset, *role)), + .copied() + .map(|role| self.palette_row(role)), ); rows } - fn palette_role(&self) -> Option { - self.cursor - .checked_sub(Self::BASE_ROW_COUNT) - .and_then(|index| PaletteRole::ALL.get(index).copied()) - } - - fn palette_row(&self, index: usize, role: PaletteRole) -> SettingsRow { + fn palette_row(&self, role: PaletteRole) -> SettingsRow { let color = self.palette.color_for(role); - self.row( - index, - role.label(), - color.name().to_string(), - Some(color.color()), - "-/+ color", - ) + let target = SettingsTarget::Palette(role); + SettingsRow { + selected: self.selected_target() == Some(target), + group: SettingsGroup::Theme, + value_kind: SettingsValueKind::Choice, + editing: false, + label: role.label().into(), + value: color.name().to_string(), + value_color: Some(color.color()), + hint: "-/+ color".into(), + } } fn row( &self, - index: usize, - label: &'static str, + target: SettingsTarget, + group: SettingsGroup, + value_kind: SettingsValueKind, + label: impl Into, value: String, - value_color: Option, - hint: &'static str, + hint: impl Into, ) -> SettingsRow { SettingsRow { - selected: self.cursor == index, - label, + selected: self.selected_target() == Some(target), + group, + value_kind, + editing: self.is_editing_target(target), + label: label.into(), value, - value_color, - hint, + value_color: None, + hint: hint.into(), + } + } +} + +impl SettingsChange { + fn render(self) -> bool { + !matches!(self, Self::None) + } + + fn save_todos(self) -> bool { + matches!(self, Self::SaveTodos) + } +} + +impl PaneIdleState { + fn new(now: Instant) -> Self { + Self { + last_output_at: now, + snoozed_until: None, } } } @@ -658,13 +1456,29 @@ 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+n new tab | Alt+t tab | Alt+Shift+t restart | Alt+c command | Alt+p pane settings | Alt+r rename | Alt+e output | 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+n new tab | Alt+t tab | Alt+Shift+t restart | Alt+s select | Alt+c command | Alt+p pane settings | Alt+r rename | Alt+e output | Alt+x swap | Alt+z sleep | Alt+g group | Alt+u ungroup | Alt+o settings" + .into() + } +} + impl App { pub fn new(cli: Cli, config: Config) -> Result { + let startup_cwd = resolved_current_dir()?; let worktrees = cli .worktrees .then(|| ManagedWorktreeOptions::new(cli.worktree_prefix.clone())) .transpose()?; - let launch_plan = resolve_direct_launch_plan(&cli, &config, worktrees.as_ref())?; + let mut launch_plan = resolve_direct_launch_plan(&cli, &config, worktrees.as_ref())?; + let config_path = cli.config.clone(); + if let Some(plan) = launch_plan.as_mut() { + apply_auth_defaults(plan, &config)?; + } + let manager_profile_name = resolve_manager_profile_name(&cli, &config); let mouse_enabled = !cli.no_mouse; let grid = launch_plan .as_ref() @@ -673,8 +1487,6 @@ impl App { rows: 2, columns: 3, }); - let (event_tx, event_rx) = mpsc::unbounded_channel(); - let (usage_tx, usage_rx) = std_mpsc::channel(); let agent_api_enabled = cli.agent_api || cli.agent_api_port != 0; let (control_handle, control_rx) = if agent_api_enabled { let (control_tx, control_rx) = std_mpsc::channel(); @@ -688,38 +1500,113 @@ impl App { } else { (None, None) }; - let base_status = if mouse_enabled { - "Drag copies | Right-click toggles pane | Alt+arrows move | Alt+Shift+arrows resize | Alt+r rename | Alt+t restart | Alt+x swap | Alt+z sleep" - } else { - "Alt+arrows move | Alt+Shift+arrows resize | Alt+s select | Alt+r rename | Alt+t restart | Alt+x swap | Alt+z sleep" - }; + let base_status = default_status(mouse_enabled); let status = control_handle .as_ref() .map(|control| format!("agent API {} | {base_status}", control.endpoint())) - .unwrap_or_else(|| base_status.into()); + .unwrap_or(base_status); + let settings = SettingsState::from_config(&config); - Ok(Self { + Ok(Self::from_parts(AppInit { config, + config_path, + manager_profile_name, worktrees, launch_plan, - layout: GridLayout::new(grid), + grid, + mouse_enabled, + command_cwd: startup_cwd, + control_handle, + control_rx, + settings, + restored_histories: Vec::new(), + session_recorder: None, + status, + })) + } + + pub fn resume(config: Config, record: SessionRecord, mouse_enabled: bool) -> Result { + let mut launch_plan = record.session.launch_plan()?; + apply_auth_defaults(&mut launch_plan, &config)?; + 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); + let manager_profile_name = config.defaults.manager_profile.clone(); + let settings = SettingsState::from_config(&config); + let command_cwd = launch_plan + .panes + .first() + .map(|pane| pane.cwd.clone()) + .unwrap_or(resolved_current_dir()?); + + Ok(Self::from_parts(AppInit { + config, + config_path: None, + manager_profile_name, + worktrees: None, + launch_plan: Some(launch_plan), + grid, + mouse_enabled, + command_cwd, + control_handle: None, + control_rx: None, + settings, + 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(); + let (command_tx, command_rx) = mpsc::unbounded_channel(); + + Self { + config: init.config, + config_path: init.config_path, + manager_profile_name: init.manager_profile_name, + worktrees: init.worktrees, + tabs: vec![None], + active_tab: 0, + tab_title: "Grid 1".into(), + launch_plan: init.launch_plan, + layout: GridLayout::new(init.grid), grid_area: Rect::default(), panes: Vec::new(), + pane_idle: Vec::new(), focus: 0, selected: BTreeSet::new(), pane_names: Vec::new(), text_selection: None, sleeping: BTreeSet::new(), + groups: Vec::new(), + next_group_id: 0, + prompt: None, rects: Vec::new(), - mouse_enabled, - control_handle, - control_rx, + mouse_enabled: init.mouse_enabled, + command_line: CommandLineState::new(init.command_cwd), + command_tx, + command_rx, + control_handle: init.control_handle, + control_rx: init.control_rx, image_overlay: None, - settings: SettingsState::default(), + settings: init.settings, rename: RenamePaneState::default(), + tab_rename: RenameTabState::default(), + previous_panes: PreviousPanesState::default(), + follow_up: None, + auth_profiles: Vec::new(), + auth_refresh_rx: None, pane_settings: PaneSettingsState::default(), - status, + status: init.status, + restored_histories: init.restored_histories, + session_recorder: init.session_recorder, next_pane_id: 0, + next_tab_number: 2, + previous_panes_button: None, + previous_pane_rows: Vec::new(), pane_settings_button: None, pane_settings_reload_button: None, event_tx, @@ -729,7 +1616,8 @@ impl App { profile_usage: BTreeMap::new(), api_spend_label: None, last_activity_decay: Instant::now(), - }) + last_exit_poll: Instant::now(), + } } pub fn run(&mut self) -> Result<()> { @@ -746,17 +1634,27 @@ impl App { let Some(plan) = composer.run(terminal, &self.config)? else { return Ok(()); }; - self.set_launch_plan(plan); + self.set_launch_plan(plan)?; } 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) { + fn set_launch_plan(&mut self, mut plan: LaunchPlan) -> Result<()> { + apply_auth_defaults(&mut plan, &self.config)?; self.layout = GridLayout::new(plan.grid); self.launch_plan = Some(plan); + self.restored_histories.clear(); + Ok(()) } fn spawn_initial_panes(&mut self) -> Result<()> { @@ -764,21 +1662,136 @@ impl App { .launch_plan .clone() .ok_or_else(|| anyhow!("no launch plan selected"))?; + self.tabs.clear(); + self.tabs.push(None); + self.active_tab = 0; + self.tab_title = "Grid 1".into(); + self.next_tab_number = 2; + self.tab_rename.close(); self.layout = GridLayout::new(plan.grid); self.panes.clear(); self.pane_names = vec![None; plan.panes.len()]; self.text_selection = None; self.sleeping.clear(); self.next_pane_id = 0; - - for spec in &plan.panes { - self.spawn_pane_spec(spec)?; + self.pane_idle.clear(); + self.last_exit_poll = Instant::now(); + self.follow_up = None; + self.groups.clear(); + self.next_group_id = 0; + self.prompt = None; + self.start_session_recorder(&plan)?; + + for (index, spec) in plan.panes.iter().enumerate() { + self.spawn_pane_spec(index, spec)?; } + self.restored_histories.clear(); self.start_usage_monitor(&plan); + self.save_session_snapshot() + } + + fn next_tab_title(&mut self) -> String { + let title = format!("Grid {}", self.next_tab_number); + self.next_tab_number += 1; + title + } + + fn take_current_tab_snapshot(&mut self) -> GridTabSnapshot { + let placeholder_layout = GridLayout::new(self.layout.size()); + GridTabSnapshot { + title: mem::take(&mut self.tab_title), + launch_plan: self.launch_plan.take(), + layout: mem::replace(&mut self.layout, placeholder_layout), + panes: mem::take(&mut self.panes), + pane_idle: mem::take(&mut self.pane_idle), + focus: self.focus, + selected: mem::take(&mut self.selected), + pane_names: mem::take(&mut self.pane_names), + text_selection: self.text_selection.take(), + sleeping: mem::take(&mut self.sleeping), + groups: mem::take(&mut self.groups), + next_group_id: self.next_group_id, + prompt: self.prompt.take(), + rects: mem::take(&mut self.rects), + } + } + + fn restore_tab_snapshot(&mut self, tab: GridTabSnapshot) { + self.tab_title = tab.title; + self.launch_plan = tab.launch_plan; + self.layout = tab.layout; + self.panes = tab.panes; + self.pane_idle = tab.pane_idle; + self.focus = tab.focus.min(self.panes.len().saturating_sub(1)); + self.selected = tab.selected; + self.pane_names = tab.pane_names; + self.text_selection = tab.text_selection; + self.sleeping = tab.sleeping; + self.groups = tab.groups; + self.next_group_id = tab.next_group_id; + self.prompt = tab.prompt; + self.rects = tab.rects; + } + + fn save_current_tab(&mut self) { + if self.active_tab >= self.tabs.len() { + self.tabs.resize_with(self.active_tab + 1, || None); + } + let snapshot = self.take_current_tab_snapshot(); + self.tabs[self.active_tab] = Some(snapshot); + } + + fn close_tab_modals(&mut self) { + self.rename.close(); + self.tab_rename.close(); + self.previous_panes.close(); + self.pane_settings.close(); + self.follow_up = None; + self.prompt = None; + self.text_selection = None; + self.command_line.focused = false; + } + + fn activate_plan_as_tab(&mut self, title: String, mut plan: LaunchPlan) -> Result<()> { + apply_auth_defaults(&mut plan, &self.config)?; + self.tab_title = title.clone(); + self.launch_plan = Some(plan.clone()); + self.layout = GridLayout::new(plan.grid); + self.panes.clear(); + self.pane_idle.clear(); + self.focus = 0; + self.selected.clear(); + self.pane_names = vec![None; plan.panes.len()]; + self.text_selection = None; + self.sleeping.clear(); + self.groups.clear(); + self.next_group_id = 0; + self.prompt = None; + self.rects.clear(); + self.follow_up = None; + self.restored_histories.clear(); + if let Some(cwd) = plan.panes.first().map(|pane| pane.cwd.clone()) { + self.command_line.cwd = cwd; + } + + for (index, spec) in plan.panes.iter().enumerate() { + self.spawn_pane_spec(index, spec)?; + } + self.start_usage_monitor(&plan); + self.status = format!("opened tab {title}"); Ok(()) } + fn add_tab_from_plan(&mut self, plan: LaunchPlan) -> Result<()> { + self.save_current_tab(); + self.tabs.push(None); + self.active_tab = self.tabs.len() - 1; + let title = self.next_tab_title(); + self.close_tab_modals(); + self.activate_plan_as_tab(title, plan) + } + fn start_usage_monitor(&mut self, plan: &LaunchPlan) { self.profile_usage.clear(); self.api_spend_label = None; @@ -789,15 +1802,20 @@ impl App { .map(|spec| UsageTarget { profile_name: spec.profile_name.clone(), command: spec.command.command.clone(), + auth_kind: spec.auth_kind, + auth_dir: spec.auth_dir.clone(), }) .collect::>(); usage::spawn_usage_monitor(targets, self.usage_tx.clone()); } - fn spawn_pane_spec(&mut self, spec: &PaneLaunchSpec) -> Result<()> { - let pane_index = self.panes.len(); - let pane = self.spawn_pane_instance(spec, pane_index)?; + fn spawn_pane_spec(&mut self, index: usize, spec: &PaneLaunchSpec) -> Result<()> { + let mut pane = self.spawn_pane_instance(spec, index)?; + if let Some(history) = self.restored_histories.get(index) { + pane.restore_history_display(&history.output_tail, &history.input_history); + } self.panes.push(pane); + self.pane_idle.push(PaneIdleState::new(Instant::now())); Ok(()) } @@ -807,10 +1825,12 @@ impl App { self.next_pane_id += 1; let extra_env = self.pane_env(pane_index); PtyPane::spawn( + &spec.profile_name, id, 0, &launch.command, &launch.args, + &spec.env, &spec.cwd, &extra_env, self.event_tx.clone(), @@ -839,14 +1859,20 @@ impl App { loop { needs_render |= self.drain_pty_events(); needs_render |= self.drain_usage_events(); + needs_render |= self.drain_auth_refresh(); + needs_render |= self.drain_command_events(); needs_render |= self.drain_control_events(); needs_render |= self.decay_activity(); + needs_render |= self.update_follow_up_prompt(); + needs_render |= self.relay_worker_output(); if needs_render { terminal.draw(|frame| { let draw_state = ui::draw(frame, self); self.grid_area = draw_state.grid_area; self.rects = draw_state.pane_rects; + self.previous_panes_button = draw_state.previous_panes_button; + self.previous_pane_rows = draw_state.previous_pane_rows; self.pane_settings_button = draw_state.pane_settings_button; self.pane_settings_reload_button = draw_state.pane_settings_reload_button; })?; @@ -858,27 +1884,58 @@ impl App { if event::poll(INPUT_POLL_INTERVAL)? { match event::read()? { Event::Key(key) if key.kind == KeyEventKind::Press => { - match self.handle_key(key)? { + match self.handle_key(terminal, key)? { KeyOutcome::Continue => {} KeyOutcome::Render => needs_render = true, + KeyOutcome::AuthLogin(profile) => { + self.run_auth_login(terminal, profile)?; + mouse_capture_enabled = false; + needs_render = true; + } KeyOutcome::Quit => break, } } Event::Resize(_, _) => needs_render = true, + Event::Paste(text) if self.tab_rename.open => { + self.tab_rename.insert_text(&text); + needs_render = true; + } Event::Paste(text) if self.rename.open => { self.rename.insert_text(&text); needs_render = true; } + Event::Paste(text) if self.settings.editing_todo() => { + if self.settings.insert_todo_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.rename.open + && !self.previous_panes.open && !self.pane_settings.open - && self.image_overlay.is_none() => + && self.image_overlay.is_none() + && self.follow_up.is_none() + && self.prompt.is_none() => { - self.route_input(text.as_bytes())?; + if self.command_line.focused { + self.command_line.insert_text(&text); + needs_render = true; + } else { + self.route_input(text.as_bytes())?; + } } Event::Mouse(mouse) if (self.mouse_enabled || !self.sleeping.is_empty()) - && !self.settings.open => + && !self.settings.open + && self.follow_up.is_none() + && self.prompt.is_none() => { needs_render |= self.handle_mouse(mouse, terminal)?; } @@ -911,6 +1968,9 @@ impl App { fn drain_pty_events(&mut self) -> bool { let mut changed = false; + let mut dispatches = Vec::new(); + let mut pending_output = BTreeMap::<(PaneId, u64), Vec>::new(); + let mut exited = Vec::new(); while let Ok(event) = self.event_rx.try_recv() { match event { @@ -919,38 +1979,300 @@ impl App { generation, bytes, } => { - if let Some(target) = self - .panes - .iter_mut() - .find(|p| p.id() == pane && p.generation() == generation) - { - target.process_output(&bytes); - changed = true; - } + pending_output + .entry((pane, generation)) + .or_default() + .extend(bytes); } 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; - changed = true; - } + exited.push((pane, generation)); } } } - for pane in &mut self.panes { - changed |= pane.poll_exit(); + for ((pane, generation), bytes) in pending_output { + 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); + self.mark_pane_touched(index); + changed = true; + } else if self.process_manager_output(pane, generation, &bytes, &mut dispatches) + || self.process_inactive_output(pane, generation, &bytes) + { + changed = true; + } } - changed - } + for (pane, generation) in exited { + if let Some(index) = self.visible_pane_index(pane, generation) { + let target = &mut self.panes[index]; + if !target.exited { + target.exited = true; + changed = true; + } + if self + .follow_up + .is_some_and(|prompt| prompt.pane_index == index) + { + self.follow_up = None; + } + } else if self.process_manager_exit(pane, generation) + || self.process_inactive_exit(pane, generation) + { + changed = true; + } + } - fn drain_usage_events(&mut self) -> bool { - let mut changed = false; + if self.last_exit_poll.elapsed() >= EXIT_POLL_INTERVAL { + for (index, pane) in self.panes.iter_mut().enumerate() { + if pane.poll_exit() { + if self + .follow_up + .is_some_and(|prompt| prompt.pane_index == index) + { + self.follow_up = None; + } + changed = true; + } + } + for group in &mut self.groups { + let exited_now = group.manager.poll_exit(); + if exited_now { + group.status = "manager exited".into(); + } + changed |= exited_now; + } + for tab in self.tabs.iter_mut().filter_map(Option::as_mut) { + for pane in &mut tab.panes { + changed |= pane.poll_exit(); + } + for group in &mut tab.groups { + let exited_now = group.manager.poll_exit(); + if exited_now { + group.status = "manager exited".into(); + } + changed |= exited_now; + } + } + self.last_exit_poll = Instant::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 process_inactive_output(&mut self, pane: PaneId, generation: u64, bytes: &[u8]) -> bool { + for tab in self.tabs.iter_mut().filter_map(Option::as_mut) { + if let Some(target) = tab + .panes + .iter_mut() + .find(|target| target.id() == pane && target.generation() == generation) + { + target.process_output(bytes); + return true; + } + + if let Some(group) = tab.groups.iter_mut().find(|group| { + group.manager.id() == pane && group.manager.generation() == generation + }) { + group.manager.process_output(bytes); + group + .manager_buffer + .push_str(&String::from_utf8_lossy(bytes)); + group.status = "manager active".into(); + return true; + } + } + + false + } + + fn process_inactive_exit(&mut self, pane: PaneId, generation: u64) -> bool { + for tab in self.tabs.iter_mut().filter_map(Option::as_mut) { + if let Some(target) = tab + .panes + .iter_mut() + .find(|target| target.id() == pane && target.generation() == generation) + { + if target.exited { + return false; + } + target.exited = true; + return true; + } + + if let Some(group) = tab.groups.iter_mut().find(|group| { + group.manager.id() == pane && group.manager.generation() == generation + }) { + if group.manager.exited { + return false; + } + group.manager.exited = true; + group.status = "manager exited".into(); + return true; + } + } + + false + } + + 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 + } + + fn drain_usage_events(&mut self) -> bool { + let mut changed = false; while let Ok(event) = self.usage_rx.try_recv() { match event { @@ -976,6 +2298,44 @@ impl App { changed } + fn drain_command_events(&mut self) -> bool { + let mut changed = false; + + while let Ok(event) = self.command_rx.try_recv() { + self.command_line.running = false; + + if let Some(error) = event.error { + self.command_line + .push_output_line(format!("error: {error}")); + self.status = format!("command failed: {error}"); + changed = true; + continue; + } + + self.command_line.push_output_text(&event.stdout); + if !event.stderr.is_empty() { + self.command_line.push_output_text(&event.stderr); + } + + match event.exit_code { + Some(0) => { + self.status = format!("command done: {}", event.command); + } + Some(code) => { + self.command_line.push_output_line(format!("[exit {code}]")); + self.status = format!("command exited {code}: {}", event.command); + } + None => { + self.command_line.push_output_line("[terminated]"); + self.status = format!("command terminated: {}", event.command); + } + } + changed = true; + } + + changed + } + fn drain_control_events(&mut self) -> bool { let mut changed = false; @@ -1119,33 +2479,61 @@ impl App { } changed |= pane.refresh_output_activity(now, OUTPUT_QUIET_AFTER); } + for tab in self.tabs.iter_mut().filter_map(Option::as_mut) { + for pane in &mut tab.panes { + if pane.active { + pane.active = false; + changed = true; + } + changed |= pane.refresh_output_activity(now, OUTPUT_QUIET_AFTER); + } + } self.last_activity_decay = now; changed } - fn handle_key(&mut self, key: KeyEvent) -> Result { + fn handle_key(&mut self, terminal: &mut Tui, key: KeyEvent) -> Result { if self.image_overlay.is_some() { return Ok(self.handle_image_overlay_key(key)); } + if self.tab_rename.open { + return self.handle_tab_rename_key(key); + } + if self.rename.open { return self.handle_rename_key(key); } let selection_cleared = self.clear_text_selection(); + if self.previous_panes.open { + let outcome = self.handle_previous_panes_key(key); + return Ok(render_if_selection_cleared(outcome, selection_cleared)); + } + if self.pane_settings.open { let outcome = self.handle_pane_settings_key(key); return Ok(render_if_selection_cleared(outcome, selection_cleared)); } + if self.follow_up.is_some() { + let outcome = self.handle_follow_up_key(key)?; + return Ok(render_if_selection_cleared(outcome, selection_cleared)); + } + + 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)); } if key.modifiers.contains(KeyModifiers::ALT) - && let Some(quit) = self.handle_app_key(key)? + && let Some(quit) = self.handle_app_key(terminal, key)? { return Ok(if quit { KeyOutcome::Quit @@ -1154,6 +2542,14 @@ impl App { }); } + if self.command_line.focused { + return Ok(if self.handle_command_key(key)? { + KeyOutcome::Render + } else { + KeyOutcome::Continue + }); + } + if self.exited_recovery_view().is_some() && let Some(outcome) = self.handle_exited_recovery_key(key) { @@ -1191,9 +2587,9 @@ impl App { } } - fn handle_app_key(&mut self, key: KeyEvent) -> Result> { + fn handle_app_key(&mut self, terminal: &mut Tui, key: KeyEvent) -> Result> { match key.code { - KeyCode::Char(ch) => self.handle_alt_char(ch, key.modifiers), + KeyCode::Char(ch) => self.handle_alt_char(terminal, ch, key.modifiers), KeyCode::Left if key.modifiers.contains(KeyModifiers::SHIFT) => { self.adjust_grid(GridAxis::Columns, -1)?; Ok(Some(false)) @@ -1212,34 +2608,43 @@ impl App { } KeyCode::Left => { self.focus_previous(); - self.status = format!("focused pane {}", self.focus + 1); + self.status = self.focus_status(); Ok(Some(false)) } KeyCode::Right => { self.focus_next(); - self.status = format!("focused pane {}", self.focus + 1); + self.status = self.focus_status(); Ok(Some(false)) } KeyCode::Up => { self.focus_in_grid(-1); - self.status = format!("focused pane {}", self.focus + 1); + self.status = self.focus_status(); Ok(Some(false)) } KeyCode::Down => { self.focus_in_grid(1); - self.status = format!("focused pane {}", self.focus + 1); + self.status = self.focus_status(); Ok(Some(false)) } _ => Ok(None), } } - fn handle_alt_char(&mut self, ch: char, _modifiers: KeyModifiers) -> Result> { + fn handle_alt_char( + &mut self, + terminal: &mut Tui, + ch: char, + modifiers: KeyModifiers, + ) -> Result> { let lower = ch.to_ascii_lowercase(); match lower { 'q' => Ok(Some(true)), 's' => { - self.toggle_pane_selection(self.focus); + if self.command_line.focused { + self.status = "command line focused".into(); + } else { + self.toggle_pane_selection(self.focus); + } Ok(Some(false)) } 'a' => { @@ -1255,23 +2660,68 @@ impl App { self.toggle_sleep_for_targets(); Ok(Some(false)) } - 't' => { + 't' if modifiers.contains(KeyModifiers::SHIFT) => { self.restart_exited_targets(); Ok(Some(false)) } + 't' => { + self.next_tab(); + Ok(Some(false)) + } + 'n' => { + self.open_new_tab(terminal)?; + Ok(Some(false)) + } 'x' => { self.swap_selected_tiles(); Ok(Some(false)) } + 'c' => { + self.command_line.focused = !self.command_line.focused; + self.status = self.focus_status(); + Ok(Some(false)) + } + 'e' => { + self.command_line.output_expanded = !self.command_line.output_expanded; + self.status = if self.command_line.output_expanded { + "command output expanded".into() + } else { + "command output hidden".into() + }; + 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; + if self.settings.tab == SettingsTab::Auth { + self.start_auth_refresh(); + } self.status = "settings open".into(); Ok(Some(false)) } + 'p' if modifiers.contains(KeyModifiers::SHIFT) => { + self.open_previous_panes(); + Ok(Some(false)) + } 'p' => { self.toggle_pane_settings(); Ok(Some(false)) } + 'r' if modifiers.contains(KeyModifiers::SHIFT) => { + self.begin_tab_rename(); + Ok(Some(false)) + } 'r' => { self.begin_rename(); Ok(Some(false)) @@ -1294,74 +2744,276 @@ impl App { } } - fn begin_rename(&mut self) { - if self.panes.is_empty() { - self.status = "no panes to rename".into(); - return; + fn open_new_tab(&mut self, terminal: &mut Tui) -> Result<()> { + let current_dir = self.active_pane_cwd().unwrap_or(resolved_current_dir()?); + let mut composer = Composer::new(current_dir, self.worktrees.clone()); + match composer.run(terminal, &self.config)? { + Some(plan) => { + self.add_tab_from_plan(plan)?; + self.sync_initial_pane_sizes(terminal)?; + } + None => { + self.status = "new tab canceled".into(); + } } - - let pane_index = self.focus.min(self.panes.len() - 1); - let current_name = self - .pane_names - .get(pane_index) - .and_then(|name| name.clone()); - self.rename.begin(pane_index, current_name.as_deref()); - self.status = format!("renaming pane {}", pane_index + 1); + Ok(()) } - fn toggle_pane_settings(&mut self) { - if self.pane_settings.open && self.pane_settings.pane_index == self.focus { - self.close_pane_settings(); + fn next_tab(&mut self) { + if self.tabs.len() <= 1 { + self.status = "only one tab open".into(); return; } - self.open_pane_settings(); + let next = (self.active_tab + 1) % self.tabs.len(); + self.switch_to_tab(next); } - fn open_pane_settings(&mut self) { - if self.panes.is_empty() { - self.status = "no pane settings to show".into(); + fn switch_to_tab(&mut self, index: usize) { + if index >= self.tabs.len() || index == self.active_tab { return; } - let pane_index = self.focus.min(self.panes.len() - 1); - let history_summary = self.pane_history_summary(pane_index); - self.pane_settings.open(pane_index, history_summary); - self.status = format!("pane {} settings open", pane_index + 1); + self.close_tab_modals(); + self.save_current_tab(); + let Some(snapshot) = self.tabs[index].take() else { + self.status = format!("tab {} is not available", index + 1); + return; + }; + self.active_tab = index; + self.restore_tab_snapshot(snapshot); + self.start_usage_for_active_tab(); + self.status = format!("active tab {}", self.tab_title); } - fn close_pane_settings(&mut self) { - let pane_number = self.pane_settings.pane_index + 1; - self.pane_settings.close(); - self.status = format!("pane {pane_number} settings closed"); + fn begin_tab_rename(&mut self) { + self.rename.close(); + self.tab_rename.begin(&self.tab_title); + self.status = format!("renaming tab {}", self.tab_title); } - fn handle_pane_settings_key(&mut self, key: KeyEvent) -> KeyOutcome { + fn handle_tab_rename_key(&mut self, key: KeyEvent) -> Result { if key.modifiers.contains(KeyModifiers::ALT) && matches!(key.code, KeyCode::Char('q')) { - return KeyOutcome::Quit; - } - if key.modifiers.contains(KeyModifiers::ALT) - && matches!(key.code, KeyCode::Char('p') | KeyCode::Char('P')) - { - self.close_pane_settings(); - return KeyOutcome::Render; - } - if key.modifiers.contains(KeyModifiers::ALT) - && matches!(key.code, KeyCode::Char('o') | KeyCode::Char('O')) - { - self.pane_settings.close(); - self.settings.open = true; - self.status = "settings open".into(); - return KeyOutcome::Render; + return Ok(KeyOutcome::Quit); } let changed = match key.code { - KeyCode::Esc | KeyCode::Char('q') => { - self.close_pane_settings(); + KeyCode::Esc => { + self.tab_rename.close(); + self.status = "tab rename canceled".into(); true } - KeyCode::Enter | KeyCode::Char(' ') | KeyCode::Char('r') | KeyCode::Char('R') => { - self.reload_pane_history(); + KeyCode::Enter => { + self.save_tab_rename(); + true + } + KeyCode::Backspace => { + self.tab_rename.backspace(); + true + } + KeyCode::Delete => { + self.tab_rename.delete(); + true + } + KeyCode::Left => { + self.tab_rename.move_cursor(-1); + true + } + KeyCode::Right => { + self.tab_rename.move_cursor(1); + true + } + KeyCode::Home => { + self.tab_rename.move_to_start(); + true + } + KeyCode::End => { + self.tab_rename.move_to_end(); + true + } + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.tab_rename.clear(); + true + } + KeyCode::Char(ch) + if !key.modifiers.contains(KeyModifiers::CONTROL) + && !key.modifiers.contains(KeyModifiers::ALT) => + { + self.tab_rename.insert_char(ch); + true + } + _ => false, + }; + + Ok(if changed { + KeyOutcome::Render + } else { + KeyOutcome::Continue + }) + } + + fn save_tab_rename(&mut self) { + let name = normalized_tab_title(&self.tab_rename.value); + match name { + Some(name) => { + self.tab_title = name.clone(); + self.tab_rename.close(); + self.status = format!("renamed tab to {name}"); + } + None => { + self.status = "tab name cannot be empty".into(); + } + } + } + + fn open_previous_panes(&mut self) { + if self.panes.is_empty() { + self.status = "no panes to list".into(); + return; + } + + self.pane_settings.close(); + self.previous_panes.begin(self.focus, self.panes.len()); + self.status = "previous panes open".into(); + } + + fn close_previous_panes(&mut self) { + self.previous_panes.close(); + self.status = "previous panes closed".into(); + } + + fn handle_previous_panes_key(&mut self, key: KeyEvent) -> KeyOutcome { + if key.modifiers.contains(KeyModifiers::ALT) && matches!(key.code, KeyCode::Char('q')) { + return KeyOutcome::Quit; + } + if key.modifiers.contains(KeyModifiers::ALT) + && matches!(key.code, KeyCode::Char('p') | KeyCode::Char('P')) + { + self.close_previous_panes(); + return KeyOutcome::Render; + } + + let changed = match key.code { + KeyCode::Esc | KeyCode::Char('q') => { + self.close_previous_panes(); + true + } + KeyCode::Up => { + self.previous_panes.move_cursor(-1, self.panes.len()); + true + } + KeyCode::Down => { + self.previous_panes.move_cursor(1, self.panes.len()); + true + } + KeyCode::Home => { + self.previous_panes.move_to_start(); + true + } + KeyCode::End => { + self.previous_panes.move_to_end(self.panes.len()); + true + } + KeyCode::Enter | KeyCode::Char(' ') => { + self.focus_previous_pane_entry(self.previous_panes.cursor); + true + } + _ => false, + }; + + if changed { + KeyOutcome::Render + } else { + KeyOutcome::Continue + } + } + + fn focus_previous_pane_entry(&mut self, index: usize) { + if index >= self.panes.len() { + self.previous_panes.close(); + self.status = format!("pane {} is no longer available", index + 1); + return; + } + + self.focus = index; + let woke = self.sleeping.remove(&index); + self.previous_panes.close(); + self.status = if woke { + format!("woke pane {}", index + 1) + } else { + format!("focused pane {}", index + 1) + }; + } + + fn begin_rename(&mut self) { + if self.panes.is_empty() { + self.status = "no panes to rename".into(); + return; + } + + let pane_index = self.focus.min(self.panes.len() - 1); + let current_name = self + .pane_names + .get(pane_index) + .and_then(|name| name.clone()); + self.rename.begin(pane_index, current_name.as_deref()); + self.status = format!("renaming pane {}", pane_index + 1); + } + + fn toggle_pane_settings(&mut self) { + if self.pane_settings.open && self.pane_settings.pane_index == self.focus { + self.close_pane_settings(); + return; + } + + self.open_pane_settings(); + } + + fn open_pane_settings(&mut self) { + if self.panes.is_empty() { + self.status = "no pane settings to show".into(); + return; + } + + self.previous_panes.close(); + let pane_index = self.focus.min(self.panes.len() - 1); + let history_summary = self.pane_history_summary(pane_index); + self.pane_settings.open(pane_index, history_summary); + self.status = format!("pane {} settings open", pane_index + 1); + } + + fn close_pane_settings(&mut self) { + let pane_number = self.pane_settings.pane_index + 1; + self.pane_settings.close(); + self.status = format!("pane {pane_number} settings closed"); + } + + fn handle_pane_settings_key(&mut self, key: KeyEvent) -> KeyOutcome { + if key.modifiers.contains(KeyModifiers::ALT) && matches!(key.code, KeyCode::Char('q')) { + return KeyOutcome::Quit; + } + if key.modifiers.contains(KeyModifiers::ALT) + && matches!(key.code, KeyCode::Char('p') | KeyCode::Char('P')) + { + self.close_pane_settings(); + return KeyOutcome::Render; + } + if key.modifiers.contains(KeyModifiers::ALT) + && matches!(key.code, KeyCode::Char('o') | KeyCode::Char('O')) + { + self.pane_settings.close(); + self.settings.open = true; + self.status = "settings open".into(); + return KeyOutcome::Render; + } + + let changed = match key.code { + KeyCode::Esc | KeyCode::Char('q') => { + self.close_pane_settings(); + true + } + KeyCode::Enter | KeyCode::Char(' ') | KeyCode::Char('r') | KeyCode::Char('R') => { + self.reload_pane_history(); true } _ => false, @@ -1493,12 +3145,18 @@ impl App { if first < self.pane_names.len() && second < self.pane_names.len() { self.pane_names.swap(first, second); } + if first < self.pane_idle.len() && second < self.pane_idle.len() { + self.pane_idle.swap(first, second); + } if let Some(selection) = self.text_selection { self.text_selection = Some(MouseSelection { pane: swapped_index(selection.pane, first, second), ..selection }); } + if let Some(prompt) = self.follow_up.as_mut() { + prompt.pane_index = swapped_index(prompt.pane_index, first, second); + } if let Some(plan) = self.launch_plan.as_mut() && first < plan.panes.len() && second < plan.panes.len() @@ -1506,14 +3164,337 @@ 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 mut manager_env = BTreeMap::new(); + if let Some(kind) = manager_profile.agent_kind + && let Some(auth_env) = auth::env_for_default(&self.config.auth, kind)? + { + manager_env.extend(auth_env.env_map()); + } + let manager = match PtyPane::spawn( + &manager_name, + PaneId(manager_pane_id(group_id)), + group_id.0 as u64 + 1, + &launch.command, + &launch.args, + &manager_env, + &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); } + + if self.settings.editing_todo() { + return self.handle_todo_edit_key(key); + } + if key.modifiers.contains(KeyModifiers::ALT) && matches!(key.code, KeyCode::Char('o') | KeyCode::Char('O')) { @@ -1522,30 +3503,84 @@ impl App { return Ok(KeyOutcome::Render); } - let changed = match key.code { + if self.settings.create_auth.is_some() { + return Ok(if self.handle_auth_create_key(key)? { + KeyOutcome::Render + } else { + KeyOutcome::Continue + }); + } + + if matches!(key.code, KeyCode::Tab | KeyCode::BackTab) { + self.toggle_settings_tab(); + return Ok(KeyOutcome::Render); + } + + if self.settings.tab == SettingsTab::Auth { + return self.handle_auth_settings_key(key); + } + + let change = match key.code { KeyCode::Esc | KeyCode::Char('q') => { self.settings.open = false; self.status = "settings closed".into(); - true + SettingsChange::Render } KeyCode::Up => { self.settings.move_cursor(-1); - true + SettingsChange::Render } KeyCode::Down => { self.settings.move_cursor(1); + SettingsChange::Render + } + KeyCode::Left | KeyCode::Char('-') => self.settings.adjust(-1), + KeyCode::Right | KeyCode::Char('+') | KeyCode::Char('=') => self.settings.adjust(1), + KeyCode::Enter | KeyCode::Char(' ') => self.settings.activate(), + KeyCode::Delete => self.settings.delete_selected_todo(), + _ => SettingsChange::None, + }; + + self.apply_settings_change(change); + Ok(if change.render() { + KeyOutcome::Render + } else { + KeyOutcome::Continue + }) + } + + fn handle_auth_settings_key(&mut self, key: KeyEvent) -> Result { + let changed = match key.code { + KeyCode::Esc | KeyCode::Char('q') => { + self.settings.open = false; + self.status = "settings closed".into(); true } - KeyCode::Left | KeyCode::Char('-') => { - self.settings.adjust(-1); + KeyCode::Up | KeyCode::Char('k') => { + self.move_auth_cursor(-1); true } - KeyCode::Right | KeyCode::Char('+') | KeyCode::Char('=') => { - self.settings.adjust(1); + KeyCode::Down | KeyCode::Char('j') => { + self.move_auth_cursor(1); true } - KeyCode::Enter | KeyCode::Char(' ') => { - self.settings.activate(); + KeyCode::Char('r') | KeyCode::Char('R') => { + self.start_auth_refresh(); + true + } + KeyCode::Char('d') | KeyCode::Char('D') => { + self.set_selected_auth_default()?; + true + } + KeyCode::Char('n') | KeyCode::Char('N') => { + self.start_auth_create()?; + true + } + KeyCode::Char('l') | KeyCode::Char('L') => { + if let Some(profile) = self.selected_auth_profile().cloned() { + return Ok(KeyOutcome::AuthLogin(profile)); + } + self.status = "no auth profile selected".into(); true } _ => false, @@ -1558,6 +3593,41 @@ impl App { }) } + fn handle_auth_create_key(&mut self, key: KeyEvent) -> Result { + let changed = match key.code { + KeyCode::Esc => { + self.settings.create_auth = None; + self.status = "auth profile creation cancelled".into(); + true + } + KeyCode::Tab | KeyCode::Left | KeyCode::Right => { + self.toggle_create_auth_kind()?; + true + } + KeyCode::Enter => { + self.create_auth_profile()?; + true + } + KeyCode::Backspace => { + if let Some(create) = &mut self.settings.create_auth { + create.name.pop(); + } + true + } + KeyCode::Char(ch) if valid_auth_name_char(ch) => { + if let Some(create) = &mut self.settings.create_auth + && create.name.len() < 64 + { + create.name.push(ch); + } + true + } + _ => false, + }; + + Ok(changed) + } + fn handle_mouse(&mut self, mouse: MouseEvent, terminal: &mut Tui) -> Result { if !matches!( mouse.kind, @@ -1573,6 +3643,18 @@ impl App { return Ok(false); } + if self.mouse_enabled + && matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) + && self.previous_panes_button_at(mouse.column, mouse.row) + { + if self.previous_panes.open { + self.close_previous_panes(); + } else { + self.open_previous_panes(); + } + return Ok(true); + } + if self.mouse_enabled && matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) && self.pane_settings_button_at(mouse.column, mouse.row) @@ -1581,6 +3663,14 @@ impl App { return Ok(true); } + if self.previous_panes.open { + return Ok(if self.mouse_enabled { + self.handle_previous_panes_mouse(mouse) + } else { + false + }); + } + if self.pane_settings.open { return Ok(if self.mouse_enabled { self.handle_pane_settings_mouse(mouse) @@ -1593,6 +3683,7 @@ impl App { && self.sleeping.remove(&index) { self.focus = index; + self.mark_pane_touched(index); self.status = format!("woke pane {}", index + 1); return Ok(true); } @@ -1681,6 +3772,20 @@ impl App { Ok(changed) } + fn handle_previous_panes_mouse(&mut self, mouse: MouseEvent) -> bool { + if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { + return false; + } + + let Some(index) = self.previous_pane_row_at(mouse.column, mouse.row) else { + return false; + }; + + self.previous_panes.cursor = index; + self.focus_previous_pane_entry(index); + true + } + fn handle_pane_settings_mouse(&mut self, mouse: MouseEvent) -> bool { if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { return false; @@ -1694,6 +3799,17 @@ impl App { false } + fn previous_panes_button_at(&self, x: u16, y: u16) -> bool { + self.previous_panes_button + .is_some_and(|rect| rect_contains(rect, x, y)) + } + + fn previous_pane_row_at(&self, x: u16, y: u16) -> Option { + self.previous_pane_rows + .iter() + .find_map(|(index, rect)| rect_contains(*rect, x, y).then_some(*index)) + } + fn pane_settings_button_at(&self, x: u16, y: u16) -> bool { self.pane_settings_button .is_some_and(|rect| rect_contains(rect, x, y)) @@ -1894,105 +4010,456 @@ 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(()) } - fn pane_specs_to_fill(&mut self, target_count: usize) -> Result> { - let plan = self - .launch_plan - .as_mut() - .ok_or_else(|| anyhow!("no launch plan selected"))?; - if plan.panes.is_empty() { - return Err(anyhow!("no pane template available")); + fn pane_specs_to_fill(&mut self, target_count: usize) -> Result> { + let plan = self + .launch_plan + .as_mut() + .ok_or_else(|| anyhow!("no launch plan selected"))?; + if plan.panes.is_empty() { + return Err(anyhow!("no pane template available")); + } + + let templates = plan.panes.clone(); + while plan.panes.len() < target_count { + let spec = templates[plan.panes.len() % templates.len()].clone(); + plan.panes.push(spec); + } + + Ok(plan.panes[self.panes.len()..target_count].to_vec()) + } + + fn remove_overflow_panes(&mut self, target_count: usize, next: GridSize) -> bool { + let running = self + .panes + .iter() + .skip(target_count) + .filter(|pane| !pane.exited) + .count(); + if running > 0 { + self.status = format!( + "cannot shrink to {}x{}; {running} running pane(s) would be removed", + next.rows, next.columns + ); + return false; + } + + self.panes.truncate(target_count); + if let Some(plan) = &mut self.launch_plan { + plan.panes.truncate(target_count); + } + self.selected = self + .selected + .iter() + .copied() + .filter(|index| *index < target_count) + .collect(); + if self.focus >= target_count { + self.focus = target_count.saturating_sub(1); + } + self.pane_names.truncate(target_count); + self.pane_idle.truncate(target_count); + if self + .follow_up + .is_some_and(|prompt| prompt.pane_index >= target_count) + { + self.follow_up = None; + } + self.sleeping = self + .sleeping + .iter() + .copied() + .filter(|index| *index < target_count) + .collect(); + if self + .text_selection + .is_some_and(|selection| selection.pane >= target_count) + { + 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(); + self.toggle_sleep_for_panes(&targets); + } + + fn toggle_sleep_for_panes(&mut self, targets: &[usize]) { + if targets.is_empty() { + return; + } + + let should_sleep = targets.iter().any(|index| !self.sleeping.contains(index)); + if should_sleep { + for index in targets { + self.sleeping.insert(*index); + self.selected.remove(index); + } + if self + .follow_up + .is_some_and(|prompt| targets.contains(&prompt.pane_index)) + { + self.follow_up = None; + } + + if self.sleeping.contains(&self.focus) + && let Some(index) = self.next_awake_pane(self.focus) + { + self.focus = index; + } + } else { + for index in targets { + self.sleeping.remove(index); + self.mark_pane_touched(*index); + } + self.focus = targets[0]; + } + + let action = if should_sleep { "slept" } else { "woke" }; + self.status = format!("{} {} {}", action, targets.len(), pane_word(targets.len())); + } + + fn handle_todo_edit_key(&mut self, key: KeyEvent) -> Result { + if key.modifiers.contains(KeyModifiers::ALT) + && matches!(key.code, KeyCode::Char('o') | KeyCode::Char('O')) + { + self.settings.cancel_todo_edit(); + self.settings.open = false; + self.status = "settings closed".into(); + return Ok(KeyOutcome::Render); + } + + match key.code { + KeyCode::Esc => { + self.settings.cancel_todo_edit(); + self.status = "todo edit cancelled".into(); + Ok(KeyOutcome::Render) + } + KeyCode::Enter => { + let saved = self.settings.commit_todo_edit(); + if saved { + if self.save_todo_settings() { + self.status = "todo prompt saved".into(); + } + } else { + self.status = "empty todo ignored".into(); + } + Ok(KeyOutcome::Render) + } + KeyCode::Backspace => Ok(if self.settings.backspace_todo_text() { + KeyOutcome::Render + } else { + KeyOutcome::Continue + }), + KeyCode::Char(ch) + if !key.modifiers.contains(KeyModifiers::CONTROL) + && !key.modifiers.contains(KeyModifiers::ALT) => + { + let mut buffer = [0; 4]; + let changed = self.settings.insert_todo_text(ch.encode_utf8(&mut buffer)); + Ok(if changed { + KeyOutcome::Render + } else { + KeyOutcome::Continue + }) + } + _ => Ok(KeyOutcome::Continue), + } + } + + fn handle_command_key(&mut self, key: KeyEvent) -> Result { + if key.modifiers.contains(KeyModifiers::ALT) { + return Ok(false); + } + + let changed = match key.code { + KeyCode::Enter => { + self.submit_command_line()?; + true + } + KeyCode::Backspace => self.command_line.backspace(), + KeyCode::Delete => self.command_line.delete(), + KeyCode::Left => self.command_line.move_left(), + KeyCode::Right => self.command_line.move_right(), + KeyCode::Home => self.command_line.move_home(), + KeyCode::End => self.command_line.move_end(), + KeyCode::Esc => self.command_line.clear_input(), + KeyCode::Char(ch) if key.modifiers.contains(KeyModifiers::CONTROL) => { + match ch.to_ascii_lowercase() { + 'a' => self.command_line.move_home(), + 'e' => self.command_line.move_end(), + 'u' => self.command_line.clear_input(), + _ => false, + } + } + KeyCode::Char(ch) => { + self.command_line.insert_char(ch); + true + } + _ => false, + }; + + Ok(changed) + } + + fn submit_command_line(&mut self) -> Result<()> { + if self.command_line.running { + self.status = "command still running".into(); + return Ok(()); + } + + let Some(command) = self.command_line.take_submission() else { + return Ok(()); + }; + + self.command_line.push_output_line(format!("> {command}")); + if self.handle_builtin_command(&command) { + return Ok(()); + } + + self.command_line.running = true; + self.status = format!("running: {command}"); + spawn_hidden_command( + command, + self.command_line.cwd.clone(), + self.command_tx.clone(), + ); + Ok(()) + } + + fn handle_builtin_command(&mut self, command: &str) -> bool { + if let Some(target) = parse_cd_target(command) { + match resolve_cd_target(&self.command_line.cwd, target.as_deref()) { + Ok(Some(cwd)) => { + self.command_line.cwd = cwd; + self.status = format!("cwd: {}", self.command_line.cwd.display()); + } + Ok(None) => { + self.command_line + .push_output_line(self.command_line.cwd.display().to_string()); + self.status = format!("cwd: {}", self.command_line.cwd.display()); + } + Err(error) => { + self.command_line.push_output_line(format!("cd: {error:#}")); + self.status = format!("cd failed: {error:#}"); + } + } + return true; } - let templates = plan.panes.clone(); - while plan.panes.len() < target_count { - let spec = templates[plan.panes.len() % templates.len()].clone(); - plan.panes.push(spec); + match command.trim().to_ascii_lowercase().as_str() { + "pwd" => { + self.command_line + .push_output_line(self.command_line.cwd.display().to_string()); + self.status = format!("cwd: {}", self.command_line.cwd.display()); + true + } + "clear" | "cls" => { + self.command_line.output_lines.clear(); + self.status = "command output cleared".into(); + true + } + _ => false, } + } - Ok(plan.panes[self.panes.len()..target_count].to_vec()) + fn apply_settings_change(&mut self, change: SettingsChange) { + if change.save_todos() { + self.save_todo_settings(); + } } - fn remove_overflow_panes(&mut self, target_count: usize, next: GridSize) -> bool { - let running = self - .panes - .iter() - .skip(target_count) - .filter(|pane| !pane.exited) - .count(); - if running > 0 { - self.status = format!( - "cannot shrink to {}x{}; {running} running pane(s) would be removed", - next.rows, next.columns - ); + fn save_todo_settings(&mut self) -> bool { + self.config.todos = self.settings.todo_settings(); + match self.config.save(self.config_path.as_deref()) { + Ok(_) => true, + Err(error) => { + self.status = format!("failed to save todos: {error:#}"); + false + } + } + } + + fn update_follow_up_prompt(&mut self) -> bool { + if self.follow_up.is_some() + || self.settings.open + || !self.settings.idle_followups + || self.settings.todo_prompts.is_empty() + { return false; } - self.panes.truncate(target_count); - if let Some(plan) = &mut self.launch_plan { - plan.panes.truncate(target_count); + let now = Instant::now(); + let idle_delay = self.settings.idle_delay(); + for (index, pane) in self.panes.iter().enumerate() { + if pane.exited || self.sleeping.contains(&index) { + continue; + } + + let Some(idle) = self.pane_idle.get(index) else { + continue; + }; + if idle.snoozed_until.is_some_and(|until| now < until) { + continue; + } + if now.duration_since(idle.last_output_at) < idle_delay { + continue; + } + + self.follow_up = Some(FollowUpPromptState { + pane_index: index, + todo_index: 0, + }); + self.status = format!("pane {} is quiet; todo follow-up ready", index + 1); + return true; } - self.selected = self - .selected - .iter() - .copied() - .filter(|index| *index < target_count) - .collect(); - if self.focus >= target_count { - self.focus = target_count.saturating_sub(1); + + false + } + + fn handle_follow_up_key(&mut self, key: KeyEvent) -> Result { + if key.modifiers.contains(KeyModifiers::ALT) && matches!(key.code, KeyCode::Char('q')) { + return Ok(KeyOutcome::Quit); } - self.pane_names.truncate(target_count); - self.sleeping = self - .sleeping - .iter() - .copied() - .filter(|index| *index < target_count) - .collect(); - if self - .text_selection - .is_some_and(|selection| selection.pane >= target_count) - { - self.text_selection = None; + + match key.code { + KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => { + self.send_follow_up_prompt()?; + Ok(KeyOutcome::Render) + } + KeyCode::Tab | KeyCode::Right | KeyCode::Char(']') => { + self.cycle_follow_up_prompt(1); + Ok(KeyOutcome::Render) + } + KeyCode::BackTab | KeyCode::Left | KeyCode::Char('[') => { + self.cycle_follow_up_prompt(-1); + Ok(KeyOutcome::Render) + } + KeyCode::Delete => { + self.delete_follow_up_prompt(); + Ok(KeyOutcome::Render) + } + KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => { + self.dismiss_follow_up_prompt(); + Ok(KeyOutcome::Render) + } + _ => Ok(KeyOutcome::Continue), } - true } - fn toggle_sleep_for_targets(&mut self) { - let targets = self.target_panes(); - self.toggle_sleep_for_panes(&targets); + fn send_follow_up_prompt(&mut self) -> Result<()> { + let Some(dialog) = self.follow_up.take() else { + return Ok(()); + }; + let Some(prompt) = self.settings.todo_prompts.get(dialog.todo_index).cloned() else { + self.status = "todo prompt no longer exists".into(); + return Ok(()); + }; + + let mut bytes = prompt.into_bytes(); + bytes.push(b'\r'); + self.panes + .get(dialog.pane_index) + .ok_or_else(|| anyhow!("invalid pane index {}", dialog.pane_index))? + .write(&bytes)?; + self.mark_pane_touched(dialog.pane_index); + + if dialog.todo_index < self.settings.todo_prompts.len() { + self.settings.todo_prompts.remove(dialog.todo_index); + self.settings.clamp_cursor(); + if self.save_todo_settings() { + self.status = format!("sent todo follow-up to pane {}", dialog.pane_index + 1); + } + } else { + self.status = format!("sent todo follow-up to pane {}", dialog.pane_index + 1); + } + + Ok(()) } - fn toggle_sleep_for_panes(&mut self, targets: &[usize]) { - if targets.is_empty() { + fn dismiss_follow_up_prompt(&mut self) { + let Some(dialog) = self.follow_up.take() else { return; + }; + let delay = self.settings.idle_delay(); + if let Some(idle) = self.pane_idle.get_mut(dialog.pane_index) { + idle.snoozed_until = Some(Instant::now() + delay); } + self.status = format!("todo follow-up snoozed for pane {}", dialog.pane_index + 1); + } - let should_sleep = targets.iter().any(|index| !self.sleeping.contains(index)); - if should_sleep { - for index in targets { - self.sleeping.insert(*index); - self.selected.remove(index); + fn delete_follow_up_prompt(&mut self) { + let Some(dialog) = self.follow_up.take() else { + return; + }; + if dialog.todo_index < self.settings.todo_prompts.len() { + self.settings.todo_prompts.remove(dialog.todo_index); + self.settings.clamp_cursor(); + if self.save_todo_settings() { + self.status = "todo prompt removed".into(); } + } + if let Some(idle) = self.pane_idle.get_mut(dialog.pane_index) { + idle.snoozed_until = Some(Instant::now() + self.settings.idle_delay()); + } + } - if self.sleeping.contains(&self.focus) - && let Some(index) = self.next_awake_pane(self.focus) - { - self.focus = index; - } - } else { - for index in targets { - self.sleeping.remove(index); - } - self.focus = targets[0]; + fn cycle_follow_up_prompt(&mut self, delta: isize) { + let count = self.settings.todo_prompts.len(); + let Some(dialog) = self.follow_up.as_mut() else { + return; + }; + if count <= 1 { + return; } - let action = if should_sleep { "slept" } else { "woke" }; - self.status = format!("{} {} {}", action, targets.len(), pane_word(targets.len())); + dialog.todo_index = + (dialog.todo_index as isize + delta).rem_euclid(count as isize) as usize; + self.status = format!( + "todo follow-up {}/{} for pane {}", + dialog.todo_index + 1, + count, + dialog.pane_index + 1 + ); + } + + fn mark_pane_touched(&mut self, index: usize) { + if let Some(idle) = self.pane_idle.get_mut(index) { + idle.last_output_at = Instant::now(); + idle.snoozed_until = None; + } + if self + .follow_up + .is_some_and(|prompt| prompt.pane_index == index) + { + self.follow_up = None; + } } fn route_input(&mut self, bytes: &[u8]) -> Result { @@ -2002,13 +4469,16 @@ impl App { for index in targets { let pane = self .panes - .get(index) + .get_mut(index) .ok_or_else(|| anyhow!("invalid pane index {index}"))?; if pane.exited { skipped_exited += 1; continue; } - pane.write(bytes)?; + pane.record_input(bytes); + pane.write(bytes) + .with_context(|| format!("failed to route input to pane {}", index + 1))?; + self.mark_pane_touched(index); } if skipped_exited > 0 { @@ -2072,7 +4542,7 @@ impl App { .collect::>(); let restart = restart_targets_for(targets, &exited); if restart.indices.is_empty() { - self.status = "no exited target panes; Alt+t restarts exited panes".into(); + self.status = "no exited target panes; Alt+Shift+t restarts exited panes".into(); return; } @@ -2097,6 +4567,9 @@ impl App { }; self.panes[index] = pane; + if let Some(idle) = self.pane_idle.get_mut(index) { + *idle = PaneIdleState::new(Instant::now()); + } self.sleeping.remove(&index); restarted += 1; } @@ -2115,6 +4588,16 @@ impl App { } fn focus_next(&mut self) { + if self.command_line.focused { + if let Some(candidate) = + (0..self.panes.len()).find(|index| !self.sleeping.contains(index)) + { + self.command_line.focused = false; + self.focus = candidate; + } + return; + } + if let Some(candidate) = wrapped_row_focus_target( self.focus, self.panes.len(), @@ -2127,6 +4610,17 @@ impl App { } fn focus_previous(&mut self) { + if self.command_line.focused { + if let Some(candidate) = (0..self.panes.len()) + .rev() + .find(|index| !self.sleeping.contains(index)) + { + self.command_line.focused = false; + self.focus = candidate; + } + return; + } + if let Some(candidate) = wrapped_row_focus_target( self.focus, self.panes.len(), @@ -2139,6 +4633,18 @@ impl App { } fn focus_in_grid(&mut self, row_delta: isize) { + if self.command_line.focused { + if row_delta.is_negative() + && let Some(candidate) = (0..self.panes.len()) + .rev() + .find(|index| !self.sleeping.contains(index)) + { + self.command_line.focused = false; + self.focus = candidate; + } + return; + } + if let Some(candidate) = wrapped_column_focus_target( self.focus, self.panes.len(), @@ -2150,6 +4656,14 @@ impl App { } } + fn focus_status(&self) -> String { + if self.command_line.focused { + "focused command line".into() + } else { + format!("focused pane {}", self.focus + 1) + } + } + fn next_awake_pane(&self, start: usize) -> Option { if self.panes.is_empty() { return None; @@ -2168,8 +4682,8 @@ impl App { &self.panes } - pub fn focus(&self) -> usize { - self.focus + pub fn focused_pane(&self) -> Option { + (!self.command_line.focused).then_some(self.focus) } pub fn selected(&self) -> &BTreeSet { @@ -2198,6 +4712,10 @@ impl App { self.pane_settings.open } + pub fn previous_panes_open(&self) -> bool { + self.previous_panes.open + } + pub fn image_overlay_view(&self) -> Option<&ImagePreview> { self.image_overlay.as_ref() } @@ -2219,6 +4737,36 @@ impl App { self.settings.rows() } + pub fn settings_tab(&self) -> SettingsTab { + self.settings.tab + } + + pub fn auth_profiles(&self) -> &[AuthProfile] { + &self.auth_profiles + } + + pub fn auth_cursor(&self) -> usize { + self.settings.auth_cursor + } + + pub fn auth_refreshing(&self) -> bool { + self.settings.auth_refreshing + } + + pub fn auth_create(&self) -> Option<&AuthCreateState> { + self.settings.create_auth.as_ref() + } + + pub fn auth_default(&self, kind: AgentKind) -> Option<&str> { + self.config.auth.defaults.get(kind) + } + + pub fn auth_home_label(&self) -> String { + auth::resolve_home(&self.config.auth) + .map(|path| path.display().to_string()) + .unwrap_or_else(|error| format!("unresolved: {error:#}")) + } + pub fn activity_badges_enabled(&self) -> bool { self.settings.activity_badges } @@ -2236,39 +4784,198 @@ impl App { .unwrap_or_else(|| (index + 1).to_string()) } - pub fn rename_pane_view(&self) -> Option { - self.rename.open.then(|| RenamePaneView { - pane_index: self.rename.pane_index, - pane_label: self.pane_label(self.rename.pane_index), - value: self.rename.value.clone(), - cursor: self.rename.cursor, - }) + pub fn rename_pane_view(&self) -> Option { + self.rename.open.then(|| RenamePaneView { + pane_index: self.rename.pane_index, + pane_label: self.pane_label(self.rename.pane_index), + value: self.rename.value.clone(), + cursor: self.rename.cursor, + }) + } + + pub fn rename_tab_view(&self) -> Option { + self.tab_rename.open.then(|| RenameTabView { + title: self.tab_title.clone(), + value: self.tab_rename.value.clone(), + cursor: self.tab_rename.cursor, + }) + } + + pub fn tab_labels(&self) -> Vec { + (0..self.tabs.len()) + .map(|index| { + if index == self.active_tab { + return TabLabel { + title: self.tab_title.clone(), + active: true, + activity: self.panes.iter().any(|pane| pane.active), + exited: !self.panes.is_empty() && self.panes.iter().all(|pane| pane.exited), + }; + } + + let Some(tab) = self.tabs.get(index).and_then(Option::as_ref) else { + return TabLabel { + title: format!("Grid {}", index + 1), + active: false, + activity: false, + exited: false, + }; + }; + + TabLabel { + title: tab.title.clone(), + active: false, + activity: tab.panes.iter().any(|pane| pane.active), + exited: !tab.panes.is_empty() && tab.panes.iter().all(|pane| pane.exited), + } + }) + .collect() + } + + pub fn pane_settings_view(&self, index: usize) -> Option { + if !self.pane_settings.open || self.pane_settings.pane_index != index { + return None; + } + + let pane = self.panes.get(index)?; + Some(PaneSettingsView { + index, + label: self.pane_label(index), + folder: self + .pane_folder(index) + .map(str::to_string) + .unwrap_or_else(|| path_label(pane.cwd())), + worktree: self.pane_worktree(index).map(str::to_string), + history_summary: self + .pane_settings + .history_summary + .clone() + .unwrap_or_else(|| self.pane_history_summary(index)), + focused: self.focus == index, + selected: self.selected.contains(&index), + sleeping: self.sleeping.contains(&index), + exited: pane.exited, + }) + } + + pub fn previous_panes_view(&self) -> Option { + self.previous_panes.open.then(|| PreviousPanesView { + cursor: self + .previous_panes + .cursor + .min(self.panes.len().saturating_sub(1)), + panes: self + .panes + .iter() + .enumerate() + .map(|(index, pane)| { + let agent_label = self + .launch_plan + .as_ref() + .and_then(|plan| plan.panes.get(index)) + .and_then(|pane| pane.agent_label()); + let summary = conversation_summary(pane.screen()) + .unwrap_or_else(|| "waiting for output".into()); + let summary = agent_label + .map(|label| format!("{label} | {summary}")) + .unwrap_or(summary); + + PreviousPaneView { + index, + label: self.pane_label(index), + folder: self + .pane_folder(index) + .map(str::to_string) + .unwrap_or_else(|| path_label(pane.cwd())), + worktree: self.pane_worktree(index).map(str::to_string), + summary, + focused: self.focus == index, + selected: self.selected.contains(&index), + sleeping: self.sleeping.contains(&index), + exited: pane.exited, + } + }) + .collect(), + }) + } + + pub fn follow_up_dialog(&self) -> Option { + let prompt = self.follow_up.as_ref()?; + let todo = self.settings.todo_prompts.get(prompt.todo_index)?; + let quiet_seconds = self + .pane_idle + .get(prompt.pane_index) + .map(|idle| idle.last_output_at.elapsed().as_secs()) + .unwrap_or_default(); + + Some(FollowUpDialog { + pane_number: prompt.pane_index + 1, + prompt: todo.clone(), + todo_position: prompt.todo_index + 1, + todo_count: self.settings.todo_prompts.len(), + quiet_seconds, + }) + } + + 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 command_focused(&self) -> bool { + self.command_line.focused + } + + pub fn command_cwd(&self) -> &Path { + &self.command_line.cwd + } + + pub fn command_input(&self) -> &str { + &self.command_line.input + } + + pub fn command_cursor_chars(&self) -> usize { + self.command_line.cursor_chars() + } + + pub fn command_output_expanded(&self) -> bool { + self.command_line.output_expanded + } + + pub fn command_output_lines(&self) -> &[String] { + &self.command_line.output_lines } - pub fn pane_settings_view(&self, index: usize) -> Option { - if !self.pane_settings.open || self.pane_settings.pane_index != index { - return None; - } + pub fn command_running(&self) -> bool { + self.command_line.running + } - let pane = self.panes.get(index)?; - Some(PaneSettingsView { - index, - label: self.pane_label(index), - folder: self - .pane_folder(index) - .map(str::to_string) - .unwrap_or_else(|| path_label(pane.cwd())), - worktree: self.pane_worktree(index).map(str::to_string), - history_summary: self - .pane_settings - .history_summary - .clone() - .unwrap_or_else(|| self.pane_history_summary(index)), - focused: self.focus == index, - selected: self.selected.contains(&index), - sleeping: self.sleeping.contains(&index), - exited: pane.exited, - }) + pub fn input_scope_label(&self) -> &'static str { + if self.command_line.focused { + "command line" + } else if self.selected.len() > 1 { + "selected panes" + } else { + "focused pane" + } } pub fn pane_folder(&self, index: usize) -> Option<&str> { @@ -2278,6 +4985,13 @@ impl App { .map(|pane| pane.folder_name.as_str()) } + pub fn pane_profile(&self, index: usize) -> Option<&str> { + self.launch_plan + .as_ref() + .and_then(|plan| plan.panes.get(index)) + .map(|pane| pane.profile_name.as_str()) + } + pub fn pane_worktree(&self, index: usize) -> Option<&str> { self.launch_plan .as_ref() @@ -2285,6 +4999,13 @@ impl App { .and_then(|pane| pane.worktree_name.as_deref()) } + pub fn pane_auth(&self, index: usize) -> Option<&str> { + self.launch_plan + .as_ref() + .and_then(|plan| plan.panes.get(index)) + .and_then(|pane| pane.auth_name.as_deref()) + } + pub fn pane_conversation_footer(&self, index: usize, max_chars: usize) -> Option { let label = self .launch_plan @@ -2348,11 +5069,211 @@ impl App { fn sync_initial_pane_sizes(&mut self, terminal: &Tui) -> Result<()> { let size = terminal.size().context("failed to read terminal size")?; - self.grid_area = Rect::new(0, 0, size.width, size.height.saturating_sub(1)); + self.grid_area = Rect::new(0, 1, size.width, size.height.saturating_sub(3)); self.rects = self.pane_rects(self.grid_area); self.sync_pane_sizes(); Ok(()) } + + fn active_pane_cwd(&self) -> Option { + self.panes + .get(self.focus) + .map(|pane| pane.cwd().to_path_buf()) + .or_else(|| self.panes.first().map(|pane| pane.cwd().to_path_buf())) + .or_else(|| { + self.launch_plan + .as_ref() + .and_then(|plan| plan.panes.first()) + .map(|pane| pane.cwd.clone()) + }) + } + + fn start_usage_for_active_tab(&mut self) { + if let Some(plan) = self.launch_plan.clone() { + self.start_usage_monitor(&plan); + } + } + + fn toggle_settings_tab(&mut self) { + self.settings.tab = match self.settings.tab { + SettingsTab::General => SettingsTab::Auth, + SettingsTab::Auth => SettingsTab::General, + }; + if self.settings.tab == SettingsTab::Auth && self.auth_profiles.is_empty() { + self.start_auth_refresh(); + } + } + + fn start_auth_refresh(&mut self) { + if self.settings.auth_refreshing { + self.status = "auth refresh already running".into(); + return; + } + + let auth_config = self.config.auth.clone(); + let (tx, rx) = std_mpsc::channel(); + self.auth_refresh_rx = Some(rx); + self.settings.auth_refreshing = true; + self.status = "refreshing auth profiles".into(); + + thread::spawn(move || { + let result = auth::discover_profiles_with_usage(&auth_config) + .map_err(|error| format!("{error:#}")); + let _ = tx.send(result); + }); + } + + fn drain_auth_refresh(&mut self) -> bool { + let Some(rx) = &self.auth_refresh_rx else { + return false; + }; + + let result = match rx.try_recv() { + Ok(result) => Some(result), + Err(std_mpsc::TryRecvError::Empty) => None, + Err(std_mpsc::TryRecvError::Disconnected) => Some(Err("auth refresh stopped".into())), + }; + + let Some(result) = result else { + return false; + }; + + self.auth_refresh_rx = None; + self.settings.auth_refreshing = false; + match result { + Ok(profiles) => { + self.auth_profiles = profiles; + self.settings.auth_cursor = self + .settings + .auth_cursor + .min(self.auth_profiles.len().saturating_sub(1)); + self.status = format!("loaded {} auth profiles", self.auth_profiles.len()); + } + Err(error) => self.status = format!("auth refresh failed: {error}"), + } + true + } + + fn move_auth_cursor(&mut self, delta: isize) { + if self.auth_profiles.is_empty() { + return; + } + + let len = self.auth_profiles.len() as isize; + self.settings.auth_cursor = + (self.settings.auth_cursor as isize + delta).clamp(0, len - 1) as usize; + } + + fn selected_auth_profile(&self) -> Option<&AuthProfile> { + self.auth_profiles.get(self.settings.auth_cursor) + } + + fn set_selected_auth_default(&mut self) -> Result<()> { + let Some(profile) = self.selected_auth_profile().cloned() else { + self.status = "no auth profile selected".into(); + return Ok(()); + }; + + self.config + .auth + .defaults + .set(profile.kind, profile.name.clone()); + let path = self.config.save(self.config_path.as_deref())?; + self.status = format!( + "{} default auth: {} ({})", + profile.kind.display_name(), + profile.name, + path.display() + ); + Ok(()) + } + + fn start_auth_create(&mut self) -> Result<()> { + let kind = self + .selected_auth_profile() + .map(|profile| profile.kind) + .unwrap_or(AgentKind::Claude); + let name = auth::next_profile_name(&self.config.auth, kind)?; + self.settings.create_auth = Some(AuthCreateState { kind, name }); + self.status = "creating auth profile".into(); + Ok(()) + } + + fn toggle_create_auth_kind(&mut self) -> Result<()> { + let Some(create) = &mut self.settings.create_auth else { + return Ok(()); + }; + let kind = create.kind.toggle(); + let name = auth::next_profile_name(&self.config.auth, kind)?; + *create = AuthCreateState { kind, name }; + Ok(()) + } + + fn create_auth_profile(&mut self) -> Result<()> { + let Some(create) = self.settings.create_auth.clone() else { + return Ok(()); + }; + let profile = auth::create_profile(&self.config.auth, create.kind, create.name.trim())?; + self.settings.create_auth = None; + self.auth_profiles = auth::discover_profiles(&self.config.auth)?; + if let Some(index) = self + .auth_profiles + .iter() + .position(|candidate| candidate.name == profile.name) + { + self.settings.auth_cursor = index; + } + self.status = format!( + "created {} auth profile {}", + create.kind.as_str(), + profile.name + ); + Ok(()) + } + + fn run_auth_login(&mut self, terminal: &mut Tui, profile: AuthProfile) -> Result<()> { + let launch = auth::login_command(&profile); + suspend_terminal(terminal)?; + let run_result = Command::new(&launch.command) + .args(&launch.args) + .envs(&launch.env) + .status() + .with_context(|| format!("failed to run {}", launch.command.display())); + let resume_result = resume_terminal(terminal); + resume_result?; + + match run_result { + Ok(status) if status.success() => { + self.status = format!("{} login completed", profile.name); + self.start_auth_refresh(); + } + Ok(status) => { + self.status = format!("{} login exited with {}", profile.name, status); + self.start_auth_refresh(); + } + Err(error) => self.status = format!("auth login failed: {error:#}"), + } + 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 { @@ -2403,6 +5324,22 @@ fn resolve_direct_launch_plan( )?)) } +fn apply_auth_defaults(plan: &mut LaunchPlan, config: &Config) -> Result<()> { + for spec in &mut plan.panes { + let Some(kind) = spec.command.agent_kind else { + continue; + }; + let Some(auth_env) = auth::env_for_default(&config.auth, kind)? else { + continue; + }; + spec.env.extend(auth_env.env_map()); + spec.auth_name = Some(auth_env.name); + spec.auth_kind = Some(auth_env.kind); + spec.auth_dir = Some(auth_env.dir); + } + Ok(()) +} + fn uses_direct_launch(cli: &Cli) -> bool { cli.grid.is_some() || cli.count.is_some() @@ -2419,11 +5356,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) -> bool { if selected.remove(&index) { false @@ -2433,6 +5410,155 @@ fn toggle_selection(selected: &mut BTreeSet, index: usize) -> bool { } } +fn spawn_hidden_command( + command: String, + cwd: PathBuf, + event_tx: mpsc::UnboundedSender, +) { + thread::spawn(move || { + let event = match run_shell_command(&command, &cwd) { + Ok(output) => CommandRunEvent { + command, + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + exit_code: output.status.code(), + error: None, + }, + Err(error) => CommandRunEvent { + command, + stdout: String::new(), + stderr: String::new(), + exit_code: None, + error: Some(format!("{error:#}")), + }, + }; + let _ = event_tx.send(event); + }); +} + +fn run_shell_command(command: &str, cwd: &Path) -> io::Result { + let mut shell = if cfg!(windows) { + let mut shell = + Command::new(env::var_os("COMSPEC").unwrap_or_else(|| OsString::from("cmd.exe"))); + shell.arg("/C").arg(command); + shell + } else { + let mut shell = Command::new(env::var_os("SHELL").unwrap_or_else(|| OsString::from("sh"))); + shell.arg("-c").arg(command); + shell + }; + + shell.current_dir(cwd).output() +} + +fn parse_cd_target(command: &str) -> Option> { + let trimmed = command.trim(); + let lower = trimmed.to_ascii_lowercase(); + + if matches!(lower.as_str(), "cd" | "chdir") { + return Some(None); + } + if lower == "cd.." { + return Some(Some("..".into())); + } + if lower.starts_with("cd ") { + return Some(Some(normalize_cd_target(&trimmed[2..]))); + } + if lower.starts_with("chdir ") { + return Some(Some(normalize_cd_target(&trimmed[5..]))); + } + + None +} + +fn normalize_cd_target(raw: &str) -> String { + let mut value = raw.trim(); + if value + .get(..3) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("/d ")) + { + value = value[3..].trim(); + } + trim_matching_quotes(value).to_string() +} + +fn trim_matching_quotes(value: &str) -> &str { + if value.len() >= 2 { + let bytes = value.as_bytes(); + if matches!( + (bytes[0], bytes[value.len() - 1]), + (b'"', b'"') | (b'\'', b'\'') + ) { + return &value[1..value.len() - 1]; + } + } + value +} + +fn resolve_cd_target(current: &Path, target: Option<&str>) -> Result> { + let Some(target) = target.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(None); + }; + + let path = if target == "~" { + home_dir().ok_or_else(|| anyhow!("home directory is not available"))? + } else if let Some(rest) = target + .strip_prefix("~/") + .or_else(|| target.strip_prefix("~\\")) + { + home_dir() + .ok_or_else(|| anyhow!("home directory is not available"))? + .join(rest) + } else { + let path = PathBuf::from(target); + if path.is_absolute() { + path + } else { + current.join(path) + } + }; + + let canonical = path + .canonicalize() + .with_context(|| format!("directory not found: {}", path.display()))?; + if !canonical.is_dir() { + return Err(anyhow!("not a directory: {}", canonical.display())); + } + Ok(Some(canonical)) +} + +fn home_dir() -> Option { + env::var_os("USERPROFILE") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .or_else(|| { + let drive = env::var_os("HOMEDRIVE")?; + let path = env::var_os("HOMEPATH")?; + let mut home = PathBuf::from(drive); + home.push(path); + Some(home) + }) + .or_else(|| env::var_os("HOME").map(PathBuf::from)) +} + +fn previous_char_boundary(value: &str, cursor: usize) -> Option { + if cursor == 0 { + return None; + } + value[..cursor] + .char_indices() + .last() + .map(|(index, _)| index) +} + +fn next_char_boundary(value: &str, cursor: usize) -> usize { + value[cursor..] + .char_indices() + .nth(1) + .map(|(offset, _)| cursor + offset) + .unwrap_or(value.len()) +} + fn selected_swap_pair(selected: &BTreeSet) -> SwapSelection { match selected.len() { 0 | 1 => SwapSelection::NeedsMore, @@ -2600,6 +5726,19 @@ fn normalized_pane_name(value: &str) -> Option { (!name.is_empty()).then_some(name) } +fn normalized_tab_title(value: &str) -> Option { + let title = value + .split_whitespace() + .collect::>() + .join(" ") + .chars() + .filter(|ch| !ch.is_control()) + .take(MAX_TAB_TITLE_CHARS) + .collect::(); + + (!title.is_empty()).then_some(title) +} + fn char_to_byte_index(value: &str, cursor: usize) -> usize { value .char_indices() @@ -2766,6 +5905,10 @@ fn pane_word(count: usize) -> &'static str { if count == 1 { "pane" } else { "panes" } } +fn valid_auth_name_char(ch: char) -> bool { + ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-') +} + fn pane_number_list(indices: &[usize]) -> String { indices .iter() @@ -2926,9 +6069,37 @@ fn utf8_mouse_bytes(button: u8, column: u16, row: u16) -> Option> { #[cfg(test)] mod tests { + use std::{ + fs, + time::{SystemTime, UNIX_EPOCH}, + }; + use super::*; + use crate::profiles::Profile; use vt100::Parser; + struct TempHome { + path: PathBuf, + } + + impl TempHome { + fn new() -> Self { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let path = env::temp_dir().join(format!("gridbash-app-auth-test-{nonce}")); + fs::create_dir_all(&path).expect("temp home"); + Self { path } + } + } + + impl Drop for TempHome { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } + } + fn selected(indices: &[usize]) -> BTreeSet { indices.iter().copied().collect() } @@ -3190,6 +6361,67 @@ mod tests { ); } + #[test] + fn applies_auth_defaults_to_agent_launch_specs() { + let temp = TempHome::new(); + let codex_dir = temp.path.join("codex-2"); + fs::create_dir(&codex_dir).expect("codex dir"); + fs::write(codex_dir.join(".profile-kind"), "codex").expect("kind"); + let mut config = Config::default(); + config.auth.home = Some(temp.path.clone()); + config.auth.defaults.set(AgentKind::Codex, "codex-2"); + let cwd = env::current_dir().expect("cwd"); + let mut plan = LaunchPlan::legacy( + "codex".into(), + Profile { + command: "codex".into(), + args: vec![], + title: Some("codex".into()), + agent_kind: Some(AgentKind::Codex), + }, + cwd, + 1, + GridSize { + rows: 1, + columns: 1, + }, + ); + + apply_auth_defaults(&mut plan, &config).expect("apply auth"); + + assert_eq!( + plan.panes[0].env.get("CODEX_HOME"), + Some(&codex_dir.display().to_string()) + ); + assert_eq!(plan.panes[0].auth_name.as_deref(), Some("codex-2")); + assert_eq!(plan.panes[0].auth_kind, Some(AgentKind::Codex)); + assert_eq!(plan.panes[0].auth_dir.as_deref(), Some(codex_dir.as_path())); + } + + #[test] + fn command_line_edits_at_cursor() { + let mut command = CommandLineState::new(PathBuf::from("C:\\repo")); + command.insert_text("abc"); + assert!(command.move_left()); + command.insert_char('X'); + + assert_eq!(command.input, "abXc"); + assert_eq!(command.cursor_chars(), 3); + assert!(command.backspace()); + assert_eq!(command.input, "abc"); + } + + #[test] + fn parses_cd_commands_without_treating_other_commands_as_cd() { + assert_eq!(parse_cd_target("cd"), Some(None)); + assert_eq!(parse_cd_target("cd.."), Some(Some("..".into()))); + assert_eq!( + parse_cd_target("cd /d \"C:\\Users\\Jason\""), + Some(Some("C:\\Users\\Jason".into())) + ); + assert_eq!(parse_cd_target("cargo test"), None); + } + #[test] fn palette_color_cycles_in_both_directions() { assert_eq!(PaletteColor::Cyan.adjust(1), PaletteColor::Sky); @@ -3200,16 +6432,17 @@ mod tests { fn settings_rows_include_live_grid_palette_roles() { let settings = SettingsState::default(); let rows = settings.rows(); + let palette_start = rows + .iter() + .position(|row| row.label == "Accent color") + .expect("accent palette row"); - assert_eq!(rows.len(), SettingsState::ROW_COUNT); + assert_eq!(rows.len(), settings.row_targets().len()); assert_eq!(rows[1].label, "Activity badges"); assert_eq!(rows[1].value, "on"); - assert_eq!(rows[SettingsState::BASE_ROW_COUNT].label, "Accent color"); - assert_eq!( - rows[SettingsState::BASE_ROW_COUNT + 3].label, - "Quiet border" - ); - assert_eq!(rows[SettingsState::BASE_ROW_COUNT + 3].value, "dark gray"); + assert_eq!(rows[palette_start].label, "Accent color"); + assert_eq!(rows[palette_start + 3].label, "Quiet border"); + assert_eq!(rows[palette_start + 3].value, "dark gray"); } #[test] @@ -3311,6 +6544,28 @@ fn teardown_terminal(terminal: &mut Tui, enable_mouse: bool) -> Result<()> { Ok(()) } +fn suspend_terminal(terminal: &mut Tui) -> Result<()> { + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + DisableBracketedPaste, + DisableMouseCapture, + LeaveAlternateScreen + )?; + terminal.show_cursor()?; + Ok(()) +} + +fn resume_terminal(terminal: &mut Tui) -> Result<()> { + enable_raw_mode()?; + execute!( + terminal.backend_mut(), + EnterAlternateScreen, + EnableBracketedPaste + )?; + Ok(()) +} + #[cfg(test)] mod selection_tests { use super::*; diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..e6cb18a --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,761 @@ +use std::{ + collections::BTreeMap, + env, fs, + path::{Path, PathBuf}, + process::Command, + time::Duration, +}; + +use anyhow::{Context, Result, anyhow}; +use base64::{Engine as _, engine::general_purpose}; +use directories::BaseDirs; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +const GRIDBASH_AUTH_HOME: &str = "GRIDBASH_AUTH_HOME"; +const CLAUDE_PROFILES_HOME: &str = "CLAUDE_PROFILES_HOME"; +const PROFILE_KIND_FILE: &str = ".profile-kind"; +const CLAUDE_USAGE_ENDPOINT: &str = "https://api.anthropic.com/api/oauth/usage"; +const CODEX_USAGE_ENDPOINT: &str = "https://chatgpt.com/backend-api/codex/usage"; +const USAGE_TIMEOUT: Duration = Duration::from_secs(3); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AgentKind { + Claude, + Codex, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AuthConfig { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub home: Option, + #[serde(default, skip_serializing_if = "AuthDefaults::is_empty")] + pub defaults: AuthDefaults, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub usage_status: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AuthDefaults { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub claude: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub codex: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthProfile { + pub name: String, + pub kind: AgentKind, + pub dir: PathBuf, + pub ready: bool, + pub account_label: Option, + pub account_detail: Option, + pub usage: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UsageInfo { + pub five_hour: Option, + pub seven_day: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UsageWindow { + pub available_percent: u8, + pub resets_at: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthEnv { + pub kind: AgentKind, + pub name: String, + pub dir: PathBuf, + pub env_var: &'static str, + pub env_value: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthLaunchCommand { + pub command: PathBuf, + pub args: Vec, + pub env: BTreeMap, +} + +impl AgentKind { + pub fn as_str(self) -> &'static str { + match self { + Self::Claude => "claude", + Self::Codex => "codex", + } + } + + pub fn display_name(self) -> &'static str { + match self { + Self::Claude => "Claude", + Self::Codex => "Codex", + } + } + + pub fn env_var(self) -> &'static str { + match self { + Self::Claude => "CLAUDE_CONFIG_DIR", + Self::Codex => "CODEX_HOME", + } + } + + pub fn default_command(self) -> &'static str { + match self { + Self::Claude => "claude", + Self::Codex => "codex", + } + } + + pub fn toggle(self) -> Self { + match self { + Self::Claude => Self::Codex, + Self::Codex => Self::Claude, + } + } +} + +impl AuthConfig { + pub fn is_empty(&self) -> bool { + self.home.is_none() && self.defaults.is_empty() && self.usage_status.is_none() + } + + pub fn usage_enabled(&self) -> bool { + self.usage_status.unwrap_or(true) + } +} + +impl AuthDefaults { + pub fn is_empty(&self) -> bool { + self.claude.is_none() && self.codex.is_none() + } + + pub fn get(&self, kind: AgentKind) -> Option<&str> { + match kind { + AgentKind::Claude => self.claude.as_deref(), + AgentKind::Codex => self.codex.as_deref(), + } + } + + pub fn set(&mut self, kind: AgentKind, name: impl Into) { + match kind { + AgentKind::Claude => self.claude = Some(name.into()), + AgentKind::Codex => self.codex = Some(name.into()), + } + } +} + +impl AuthProfile { + pub fn status_label(&self) -> &'static str { + if self.ready { "ready" } else { "login needed" } + } +} + +impl UsageInfo { + pub fn display_label(&self) -> String { + let five = self + .five_hour + .as_ref() + .map(|value| format!("5h {}%", value.available_percent)) + .unwrap_or_else(|| "5h n/a".into()); + let seven = self + .seven_day + .as_ref() + .map(|value| format!("7d {}%", value.available_percent)) + .unwrap_or_else(|| "7d n/a".into()); + format!("{five} | {seven}") + } +} + +impl AuthEnv { + pub fn env_map(&self) -> BTreeMap { + BTreeMap::from([(self.env_var.to_string(), self.env_value.clone())]) + } +} + +pub fn resolve_home(config: &AuthConfig) -> Result { + non_empty_env_path(GRIDBASH_AUTH_HOME) + .or_else(|| non_empty_env_path(CLAUDE_PROFILES_HOME)) + .or_else(|| config.home.clone()) + .or_else(|| BaseDirs::new().map(|dirs| dirs.home_dir().join(".claude-profiles"))) + .ok_or_else(|| anyhow!("failed to resolve auth profile home")) +} + +pub fn discover_profiles(config: &AuthConfig) -> Result> { + let home = resolve_home(config)?; + if !home.exists() { + return Ok(Vec::new()); + } + + let mut profiles = fs::read_dir(&home) + .with_context(|| format!("failed to read auth profile home {}", home.display()))? + .filter_map(|entry| entry.ok()) + .filter(|entry| is_profile_entry(entry.path(), &entry.file_name().to_string_lossy())) + .map(|entry| { + profile_from_dir( + entry.file_name().to_string_lossy().to_string(), + entry.path(), + ) + }) + .collect::>>()?; + profiles.sort_by(|left, right| { + left.kind + .as_str() + .cmp(right.kind.as_str()) + .then_with(|| left.name.cmp(&right.name)) + }); + Ok(profiles) +} + +pub fn discover_profiles_with_usage(config: &AuthConfig) -> Result> { + let mut profiles = discover_profiles(config)?; + if !config.usage_enabled() { + return Ok(profiles); + } + + for profile in &mut profiles { + profile.usage = fetch_usage(profile).unwrap_or(None); + } + Ok(profiles) +} + +pub fn create_profile(config: &AuthConfig, kind: AgentKind, name: &str) -> Result { + validate_profile_name(name)?; + let home = resolve_home(config)?; + fs::create_dir_all(&home) + .with_context(|| format!("failed to create auth profile home {}", home.display()))?; + let dir = home.join(name); + if dir.exists() { + return Err(anyhow!("auth profile already exists: {name}")); + } + + fs::create_dir(&dir) + .with_context(|| format!("failed to create auth profile {}", dir.display()))?; + fs::write(dir.join(PROFILE_KIND_FILE), kind.as_str()) + .with_context(|| format!("failed to write profile kind for {}", dir.display()))?; + fs::write(dir.join("PROFILE.md"), profile_readme(kind, name)) + .with_context(|| format!("failed to write profile readme for {}", dir.display()))?; + + profile_from_dir(name.to_string(), dir) +} + +pub fn next_profile_name(config: &AuthConfig, kind: AgentKind) -> Result { + let profiles = discover_profiles(config)?; + let names = profiles + .into_iter() + .map(|profile| profile.name) + .collect::>(); + for index in 1..=999 { + let candidate = format!("{}-{index}", kind.as_str()); + if !names.contains(&candidate) { + return Ok(candidate); + } + } + Err(anyhow!( + "failed to find available {} profile name", + kind.as_str() + )) +} + +pub fn env_for_default(config: &AuthConfig, kind: AgentKind) -> Result> { + let Some(name) = config.defaults.get(kind) else { + return Ok(None); + }; + env_for_profile(config, kind, name).map(Some) +} + +pub fn env_for_profile(config: &AuthConfig, kind: AgentKind, name: &str) -> Result { + validate_profile_name(name)?; + let dir = resolve_home(config)?.join(name); + if !dir.is_dir() { + return Err(anyhow!( + "configured {} auth profile not found: {}", + kind.as_str(), + name + )); + } + let actual_kind = profile_kind(&dir); + if actual_kind != kind { + return Err(anyhow!( + "configured auth profile '{}' is {}, not {}", + name, + actual_kind.as_str(), + kind.as_str() + )); + } + Ok(AuthEnv { + kind, + name: name.to_string(), + env_var: kind.env_var(), + env_value: dir.display().to_string(), + dir, + }) +} + +pub fn login_command(profile: &AuthProfile) -> AuthLaunchCommand { + let command = resolve_agent_executable(profile.kind) + .unwrap_or_else(|| PathBuf::from(profile.kind.default_command())); + let args = match profile.kind { + AgentKind::Claude => vec!["auth".into(), "login".into()], + AgentKind::Codex => vec!["login".into()], + }; + AuthLaunchCommand { + command, + args, + env: BTreeMap::from([( + profile.kind.env_var().to_string(), + profile.dir.display().to_string(), + )]), + } +} + +pub fn resolve_agent_executable(kind: AgentKind) -> Option { + match kind { + AgentKind::Claude => non_empty_env_path("CLAUDE_BIN") + .filter(|path| path.exists()) + .or_else(resolve_installed_claude) + .or_else(|| resolve_on_path("claude")), + AgentKind::Codex => non_empty_env_path("CODEX_BIN") + .filter(|path| path.exists()) + .or_else(resolve_installed_codex) + .or_else(|| resolve_on_path("codex")), + } +} + +fn non_empty_env_path(name: &str) -> Option { + env::var_os(name).and_then(|value| { + let path = PathBuf::from(value); + (!path.as_os_str().is_empty()).then_some(path) + }) +} + +fn resolve_installed_claude() -> Option { + let appdata = env::var_os("APPDATA")?; + let candidate = PathBuf::from(appdata) + .join("npm") + .join("node_modules") + .join("@anthropic-ai") + .join("claude-code") + .join("bin") + .join("claude.exe"); + candidate.is_file().then_some(candidate) +} + +fn resolve_installed_codex() -> Option { + let appdata = env::var_os("APPDATA")?; + let candidate = PathBuf::from(appdata).join("npm").join("codex.exe"); + candidate.is_file().then_some(candidate) +} + +fn resolve_on_path(command: &str) -> Option { + let path = env::var_os("PATH")?; + let pathext: Vec = env::var_os("PATHEXT") + .map(|value| { + env::split_paths(&value) + .filter_map(|p| p.to_str().map(|s| s.trim_start_matches('.').to_string())) + .collect() + }) + .unwrap_or_else(|| vec!["exe".into(), "cmd".into(), "bat".into(), "ps1".into()]); + + for dir in env::split_paths(&path) { + for ext in &pathext { + let candidate = dir.join(format!("{command}.{ext}")); + if candidate.is_file() { + return Some(candidate); + } + } + + let direct = dir.join(command); + if direct.is_file() { + return Some(direct); + } + } + None +} + +fn is_profile_entry(path: PathBuf, name: &str) -> bool { + path.is_dir() && !name.starts_with('.') && !name.ends_with(".lock") +} + +fn profile_from_dir(name: String, dir: PathBuf) -> Result { + let kind = profile_kind(&dir); + let ready = has_auth_files(&dir, kind); + let (account_label, account_detail) = read_account(&dir, kind); + Ok(AuthProfile { + name, + kind, + dir, + ready, + account_label, + account_detail, + usage: None, + }) +} + +fn profile_kind(dir: &Path) -> AgentKind { + match fs::read_to_string(dir.join(PROFILE_KIND_FILE)) { + Ok(raw) if raw.trim().eq_ignore_ascii_case("codex") => AgentKind::Codex, + _ => AgentKind::Claude, + } +} + +fn has_auth_files(dir: &Path, kind: AgentKind) -> bool { + match kind { + AgentKind::Claude => [".credentials.json", "accounts", ".claude.json"] + .iter() + .any(|name| dir.join(name).exists()), + AgentKind::Codex => dir.join("auth.json").exists(), + } +} + +fn read_account(dir: &Path, kind: AgentKind) -> (Option, Option) { + match kind { + AgentKind::Claude => read_claude_email(dir) + .map(|email| (Some(mask_email(&email)), None)) + .unwrap_or((None, None)), + AgentKind::Codex => { + let account = read_codex_account(dir); + ( + account.email.as_deref().map(mask_email), + account.plan.map(|plan| plan.to_ascii_lowercase()), + ) + } + } +} + +fn read_claude_email(dir: &Path) -> Option { + let data = read_json_file(&dir.join(".claude.json"))?; + data.pointer("/oauthAccount/emailAddress") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +#[derive(Debug, Default)] +struct CodexAccount { + email: Option, + plan: Option, +} + +fn read_codex_account(dir: &Path) -> CodexAccount { + let Some(auth) = read_json_file(&dir.join("auth.json")) else { + return CodexAccount::default(); + }; + let Some(token) = auth.pointer("/tokens/id_token").and_then(Value::as_str) else { + return CodexAccount::default(); + }; + let Some(claims) = decode_jwt_payload(token) else { + return CodexAccount::default(); + }; + let email = claims + .get("email") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let plan = claims + .get("https://api.openai.com/auth") + .and_then(|inner| inner.get("chatgpt_plan_type")) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string); + CodexAccount { email, plan } +} + +fn decode_jwt_payload(token: &str) -> Option { + let encoded = token.split('.').nth(1)?; + let bytes = general_purpose::URL_SAFE_NO_PAD + .decode(encoded) + .or_else(|_| general_purpose::URL_SAFE.decode(encoded)) + .ok()?; + serde_json::from_slice(&bytes).ok() +} + +fn fetch_usage(profile: &AuthProfile) -> Result> { + match profile.kind { + AgentKind::Claude => fetch_claude_usage(&profile.dir), + AgentKind::Codex => fetch_codex_usage(&profile.dir), + } +} + +fn fetch_claude_usage(dir: &Path) -> Result> { + let Some(creds) = read_json_file(&dir.join(".credentials.json")) else { + return Ok(None); + }; + let Some(token) = creds + .pointer("/claudeAiOauth/accessToken") + .and_then(Value::as_str) + else { + return Ok(None); + }; + let body = curl_json( + CLAUDE_USAGE_ENDPOINT, + &[ + ("Authorization", format!("Bearer {token}")), + ("anthropic-beta", "oauth-2025-04-20".into()), + ], + )?; + Ok(parse_usage_response(&body)) +} + +fn fetch_codex_usage(dir: &Path) -> Result> { + let Some(auth) = read_json_file(&dir.join("auth.json")) else { + return Ok(None); + }; + let Some(token) = auth.pointer("/tokens/access_token").and_then(Value::as_str) else { + return Ok(None); + }; + let mut headers = vec![ + ("Authorization", format!("Bearer {token}")), + ("User-Agent", "codex-cli".into()), + ]; + if let Some(account_id) = auth.pointer("/tokens/account_id").and_then(Value::as_str) { + headers.push(("chatgpt-account-id", account_id.to_string())); + } + let body = curl_json(CODEX_USAGE_ENDPOINT, &headers)?; + Ok(parse_usage_response(&body)) +} + +fn curl_json(url: &str, headers: &[(&str, String)]) -> Result { + let mut args = vec![ + "-s".to_string(), + "-m".to_string(), + USAGE_TIMEOUT.as_secs().to_string(), + ]; + for (name, value) in headers { + args.push("-H".into()); + args.push(format!("{name}: {value}")); + } + args.push(url.to_string()); + + let output = Command::new("curl.exe") + .args(args) + .output() + .context("failed to run curl.exe for usage status")?; + if !output.status.success() { + return Ok(Value::Null); + } + Ok(serde_json::from_slice(&output.stdout).unwrap_or(Value::Null)) +} + +fn parse_usage_response(value: &Value) -> Option { + let five_hour = parse_usage_window(value.get("five_hour")); + let seven_day = parse_usage_window(value.get("seven_day")); + (five_hour.is_some() || seven_day.is_some()).then_some(UsageInfo { + five_hour, + seven_day, + }) +} + +fn parse_usage_window(value: Option<&Value>) -> Option { + let value = value?; + let utilization = value.get("utilization").and_then(Value::as_f64)?; + let used_percent = if utilization <= 1.0 { + utilization * 100.0 + } else { + utilization + }; + let available_percent = (100.0 - used_percent).round().clamp(0.0, 100.0) as u8; + let resets_at = value + .get("resets_at") + .and_then(Value::as_str) + .map(str::to_string); + Some(UsageWindow { + available_percent, + resets_at, + }) +} + +fn read_json_file(path: &Path) -> Option { + let raw = fs::read_to_string(path).ok()?; + serde_json::from_str(&raw).ok() +} + +fn mask_email(email: &str) -> String { + let Some((local, domain)) = email.split_once('@') else { + return email.to_string(); + }; + if local.len() > 4 { + format!("{}...@{domain}", &local[..4]) + } else { + format!("{local}@{domain}") + } +} + +fn validate_profile_name(name: &str) -> Result<()> { + if name.is_empty() + || !name + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-')) + || !name + .chars() + .next() + .is_some_and(|ch| ch.is_ascii_alphanumeric()) + { + return Err(anyhow!( + "invalid auth profile name '{name}'; use letters, numbers, dot, underscore, or dash" + )); + } + Ok(()) +} + +fn profile_readme(kind: AgentKind, name: &str) -> String { + let tool = match kind { + AgentKind::Claude => "Claude Code", + AgentKind::Codex => "Codex", + }; + format!( + "# {tool} profile: {name}\n\nThis directory is selected with {}.\n{tool} stores this profile's auth, settings, sessions, and history here.\n\nDo not commit credentials from this directory.\n", + kind.env_var() + ) +} + +#[cfg(test)] +mod tests { + use std::{ + fs, + time::{SystemTime, UNIX_EPOCH}, + }; + + use super::*; + + struct TempHome { + path: PathBuf, + } + + impl TempHome { + fn new() -> Self { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let path = env::temp_dir().join(format!("gridbash-auth-test-{nonce}")); + fs::create_dir_all(&path).expect("temp home"); + Self { path } + } + + fn config(&self) -> AuthConfig { + AuthConfig { + home: Some(self.path.clone()), + defaults: AuthDefaults::default(), + usage_status: Some(false), + } + } + } + + impl Drop for TempHome { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } + } + + #[test] + fn discovers_claude_and_codex_profiles() { + let temp = TempHome::new(); + let claude = temp.path.join("claude-1"); + let codex = temp.path.join("codex-2"); + fs::create_dir(&claude).expect("claude dir"); + fs::create_dir(&codex).expect("codex dir"); + fs::write( + claude.join(".claude.json"), + r#"{"oauthAccount":{"emailAddress":"jason@example.com"}}"#, + ) + .expect("claude auth"); + fs::write(codex.join(PROFILE_KIND_FILE), "codex").expect("kind"); + fs::write(codex.join("auth.json"), r#"{"tokens":{}}"#).expect("codex auth"); + + let profiles = discover_profiles(&temp.config()).expect("profiles"); + + assert_eq!(profiles.len(), 2); + assert_eq!(profiles[0].kind, AgentKind::Claude); + assert_eq!( + profiles[0].account_label.as_deref(), + Some("jaso...@example.com") + ); + assert!(profiles[0].ready); + assert_eq!(profiles[1].kind, AgentKind::Codex); + assert!(profiles[1].ready); + } + + #[test] + fn builds_default_env_for_kind() { + let temp = TempHome::new(); + let dir = temp.path.join("codex-1"); + fs::create_dir(&dir).expect("dir"); + fs::write(dir.join(PROFILE_KIND_FILE), "codex").expect("kind"); + let mut config = temp.config(); + config.defaults.set(AgentKind::Codex, "codex-1"); + + let env = env_for_default(&config, AgentKind::Codex) + .expect("env") + .expect("present"); + + assert_eq!(env.env_var, "CODEX_HOME"); + assert_eq!(env.name, "codex-1"); + } + + #[test] + fn rejects_wrong_kind_default() { + let temp = TempHome::new(); + fs::create_dir(temp.path.join("claude-1")).expect("dir"); + let mut config = temp.config(); + config.defaults.set(AgentKind::Codex, "claude-1"); + + let error = env_for_default(&config, AgentKind::Codex).expect_err("wrong kind"); + + assert!(error.to_string().contains("not codex")); + } + + #[test] + fn creates_profile_with_marker_and_readme() { + let temp = TempHome::new(); + + let profile = create_profile(&temp.config(), AgentKind::Codex, "codex-1").expect("create"); + + assert_eq!(profile.kind, AgentKind::Codex); + assert_eq!( + fs::read_to_string(profile.dir.join(PROFILE_KIND_FILE)).expect("kind"), + "codex" + ); + assert!(profile.dir.join("PROFILE.md").is_file()); + } + + #[test] + fn decodes_codex_account_without_exposing_token() { + let temp = TempHome::new(); + let dir = temp.path.join("codex-1"); + fs::create_dir(&dir).expect("dir"); + let claims = serde_json::json!({ + "email": "longperson@example.com", + "https://api.openai.com/auth": {"chatgpt_plan_type": "Plus"} + }); + let payload = general_purpose::URL_SAFE_NO_PAD.encode(claims.to_string()); + fs::write( + dir.join("auth.json"), + format!(r#"{{"tokens":{{"id_token":"header.{payload}.sig"}}}}"#), + ) + .expect("auth"); + + let (label, detail) = read_account(&dir, AgentKind::Codex); + + assert_eq!(label.as_deref(), Some("long...@example.com")); + assert_eq!(detail.as_deref(), Some("plus")); + } + + #[test] + fn formats_usage_response_as_available_percent() { + let value = serde_json::json!({ + "five_hour": {"utilization": 25.0, "resets_at": "2026-07-07T12:00:00Z"}, + "seven_day": {"utilization": 0.5} + }); + + let usage = parse_usage_response(&value).expect("usage"); + + assert_eq!(usage.five_hour.unwrap().available_percent, 75); + assert_eq!(usage.seven_day.unwrap().available_percent, 50); + } +} diff --git a/src/cli.rs b/src/cli.rs index d383fc9..4771886 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, @@ -18,6 +21,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, @@ -72,3 +79,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/composer.rs b/src/composer.rs index 71fcdb2..41fcaa9 100644 --- a/src/composer.rs +++ b/src/composer.rs @@ -1,46 +1,45 @@ -use std::{ - env, - io::Stdout, - path::{Path, PathBuf}, - time::Duration, -}; +use std::{collections::BTreeSet, io::Stdout, path::PathBuf, time::Duration}; -use anyhow::{Context, Result}; +use anyhow::Result; use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind}; use ratatui::{ Frame, Terminal, backend::CrosstermBackend, - layout::{Alignment, Constraint, Direction, Layout, Rect}, + layout::{Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Clear, Paragraph}, + widgets::{Block, Borders, Paragraph}, }; -use crate::worktrees::ManagedWorktreeOptions; -use crate::{config::Config, layout::GridSize, profiles::find_profile, setup::LaunchPlan}; +use crate::{ + config::Config, + setup::{LaunchPlan, LaunchSelection, launch_selection_from}, + vibe::{self, VibeProfile}, + worktrees::ManagedWorktreeOptions, +}; 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, - rows: usize, - columns: usize, - active_field: DimensionField, + 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, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum DimensionField { - Rows, - Columns, +enum Stage { + Folders, + PathInput, + Agents, + Preview, } enum ComposerEvent { @@ -51,19 +50,31 @@ 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, - rows: DEFAULT_ROWS, - columns: DEFAULT_COLUMNS, - active_field: DimensionField::Rows, + 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(), } } pub fn run( &mut self, terminal: &mut ComposerTerminal, - config: &Config, + _config: &Config, ) -> Result> { loop { terminal.draw(|frame| self.draw(frame))?; @@ -72,10 +83,10 @@ impl Composer { continue; } - let result = match event::read()? { - Event::Key(key) if key.kind == KeyEventKind::Press => { - self.handle_key(key, config)? - } + 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), _ => ComposerEvent::Continue, }; @@ -87,300 +98,394 @@ impl Composer { } } - fn handle_key(&mut self, key: KeyEvent, config: &Config) -> Result { + 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); + } + match key.code { - 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::Up => self.folder_cursor = self.folder_cursor.saturating_sub(1), + KeyCode::Down => { + self.folder_cursor = (self.folder_cursor + 1).min(self.folders.len()); } - KeyCode::Right | KeyCode::Char('l') | KeyCode::Tab => { - self.active_field = DimensionField::Columns; - Ok(ComposerEvent::Continue) + KeyCode::Char('a') => { + self.path_input.clear(); + self.stage = Stage::PathInput; + self.status = "Paste or type a folder path, then Enter".into(); } - KeyCode::Up | KeyCode::Char('+') | KeyCode::Char('=') | KeyCode::Char('k') => { - self.adjust_active(1); - Ok(ComposerEvent::Continue) + 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::Down | KeyCode::Char('-') | KeyCode::Char('j') => { - self.adjust_active(-1); - 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::Char('r') => { - self.active_field = DimensionField::Rows; - 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::Char('c') => { - self.active_field = DimensionField::Columns; - Ok(ComposerEvent::Continue) + KeyCode::Esc => { + self.path_input.clear(); + self.stage = Stage::Folders; + } + KeyCode::Backspace => { + self.path_input.pop(); + } + 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(ch) if ch.is_ascii_digit() => { - self.set_active_from_digit(ch); + 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; Ok(ComposerEvent::Continue) } _ => 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, + fn launch_preview(&mut self) -> Result { + let Some(preview) = &self.preview_selection else { + self.status = "No launch preview selected".into(); + return Ok(ComposerEvent::Continue); }; - *value = (*value as isize + delta).clamp(1, MAX_DIMENSION as isize) as usize; + + match preview.launch_plan(self.worktrees.as_ref()) { + Ok(plan) => Ok(ComposerEvent::Launch(plan)), + Err(error) => { + self.status = format!("{error:#}"); + Ok(ComposerEvent::Continue) + } + } } - fn set_active_from_digit(&mut self, ch: char) { - let Some(mut value) = ch.to_digit(10).map(|digit| digit as usize) else { + fn toggle_agent(&mut self, index: usize) { + let Some(profile) = self.vibe_profiles.get(index) else { return; }; - if value == 0 { - value = MAX_DIMENSION; + if !profile.ready { + self.status = format!("{} is not logged in", profile.name); + return; } - match self.active_field { - DimensionField::Rows => self.rows = value.min(MAX_DIMENSION), - DimensionField::Columns => self.columns = value.min(MAX_DIMENSION), + if !self.selected_agents.insert(index) { + self.selected_agents.remove(&index); } } - 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 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 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(8), - Constraint::Length(5), + Constraint::Min(5), + Constraint::Length(1), ]) - .split(inner); + .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]); + } + + 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; + } - self.draw_header(frame, chunks[0]); - self.draw_preview(frame, chunks[1]); - self.draw_controls(frame, chunks[2]); + 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); } - fn draw_header(&self, frame: &mut Frame<'_>, area: Rect) { - let cwd = display_path(&self.current_dir); + fn draw_path_input(&self, frame: &mut Frame<'_>, area: Rect) { let lines = vec![ + Line::from("Paste or type a folder path."), + Line::from(""), Line::from(vec![ - 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)), + Span::styled("> ", Style::default().fg(Color::Cyan)), + Span::raw(self.path_input.clone()), ]), + Line::from(""), + Line::from("Enter adds the folder. Esc returns."), ]; - frame.render_widget(Paragraph::new(lines).style(panel_style()), area); + 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); } fn draw_preview(&self, frame: &mut Frame<'_>, area: Rect) { - let preview_area = inset(area, 1, 0); - let grid = GridSize { - rows: self.rows, - columns: self.columns, + 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 rects = square_preview_rects(preview_area, grid); - for (index, rect) in rects.into_iter().enumerate() { - if rect.width == 0 || rect.height == 0 { - continue; + 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 + ))); } - - 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); + Err(error) => lines.push(Line::from(Span::styled( + format!("{error:#}"), + Style::default().fg(Color::Red), + ))), } - } + lines.push(Line::from("")); + lines.push(Line::from("Enter launches. Esc returns.")); - 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, - ); + frame.render_widget(panel("Preview", lines), area); } } -fn display_path(path: &Path) -> String { - let text = path.display().to_string(); - text.strip_prefix(r"\\?\").unwrap_or(&text).to_string() -} - -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 default_selected_agents(profiles: &[VibeProfile]) -> BTreeSet { + profiles + .iter() + .enumerate() + .filter_map(|(index, profile)| profile.ready.then_some(index)) + .take(3) + .collect() } -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 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 background() -> Paragraph<'static> { - Paragraph::new("").style(Style::default().bg(Color::Rgb(7, 11, 15))) +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 panel_style() -> Style { - Style::default() - .fg(Color::Rgb(230, 237, 243)) - .bg(Color::Rgb(11, 15, 20)) +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 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 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 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 +fn cursor_style(selected: bool) -> Style { + if selected { + Style::default().fg(Color::Yellow) } 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 { - 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, - }); - } + Style::default().fg(Color::DarkGray) } - 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)) } diff --git a/src/config.rs b/src/config.rs index b3d402b..5a89e47 100644 --- a/src/config.rs +++ b/src/config.rs @@ -8,12 +8,16 @@ use anyhow::{Context, Result, anyhow}; use directories::ProjectDirs; use serde::{Deserialize, Serialize}; -use crate::profiles::Profile; +use crate::{auth::AuthConfig, profiles::Profile}; #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Config { #[serde(default, skip_serializing_if = "Defaults::is_empty")] pub defaults: Defaults, + #[serde(default, skip_serializing_if = "TodoSettings::is_empty")] + pub todos: TodoSettings, + #[serde(default, skip_serializing_if = "AuthConfig::is_empty")] + pub auth: AuthConfig, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub profiles: BTreeMap, } @@ -22,6 +26,34 @@ 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, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TodoSettings { + #[serde( + default = "TodoSettings::default_enabled", + skip_serializing_if = "TodoSettings::is_enabled" + )] + pub enabled: bool, + #[serde( + default = "TodoSettings::default_idle_seconds", + skip_serializing_if = "TodoSettings::is_default_idle_seconds" + )] + pub idle_seconds: u64, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub prompts: Vec, +} + +impl Default for TodoSettings { + fn default() -> Self { + Self { + enabled: Self::default_enabled(), + idle_seconds: Self::default_idle_seconds(), + prompts: Vec::new(), + } + } } impl Config { @@ -71,7 +103,40 @@ impl Config { impl Defaults { fn is_empty(&self) -> bool { - self.profile.is_none() + self.profile.is_none() && self.manager_profile.is_none() + } +} + +impl TodoSettings { + pub fn default_enabled() -> bool { + true + } + + pub fn default_idle_seconds() -> u64 { + 90 + } + + pub fn normalized_prompts(&self) -> Vec { + self.prompts + .iter() + .map(|prompt| prompt.trim()) + .filter(|prompt| !prompt.is_empty()) + .map(str::to_string) + .collect() + } + + fn is_empty(&self) -> bool { + self.enabled == Self::default_enabled() + && self.idle_seconds == Self::default_idle_seconds() + && self.prompts.is_empty() + } + + fn is_enabled(value: &bool) -> bool { + *value + } + + fn is_default_idle_seconds(value: &u64) -> bool { + *value == Self::default_idle_seconds() } } @@ -92,6 +157,39 @@ mod tests { assert_eq!(config.defaults.profile.as_deref(), Some("powershell")); } + #[test] + fn parses_auth_defaults() { + let config: Config = toml::from_str( + r#" + [auth] + home = "C:\\Users\\Jason\\.claude-profiles" + usage_status = true + + [auth.defaults] + claude = "claude-1" + codex = "codex-2" + "#, + ) + .expect("parse config"); + + assert_eq!(config.auth.defaults.claude.as_deref(), Some("claude-1")); + assert_eq!(config.auth.defaults.codex.as_deref(), Some("codex-2")); + assert_eq!(config.auth.usage_status, Some(true)); + } + + #[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 ignores_legacy_setups_table() { let config: Config = toml::from_str( @@ -112,4 +210,24 @@ mod tests { assert_eq!(config.defaults.profile.as_deref(), Some("powershell")); assert!(config.profiles.is_empty()); } + + #[test] + fn parses_todo_settings() { + let config: Config = toml::from_str( + r#" + [todos] + enabled = false + idle_seconds = 45 + prompts = ["Review the diff", "Run tests"] + "#, + ) + .expect("parse config"); + + assert!(!config.todos.enabled); + assert_eq!(config.todos.idle_seconds, 45); + assert_eq!( + config.todos.normalized_prompts(), + vec!["Review the diff".to_string(), "Run tests".to_string()] + ); + } } diff --git a/src/layout.rs b/src/layout.rs index 3c9dac8..f47f8d2 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -1,6 +1,6 @@ use ratatui::layout::Rect; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct PaneId(pub usize); #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/src/main.rs b/src/main.rs index dd9823c..e077c9a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ mod app; +mod auth; mod cli; mod composer; mod config; @@ -6,8 +7,10 @@ mod control; mod image_preview; mod layout; mod onboarding; +mod orchestrator; mod profiles; mod pty; +mod session; mod setup; mod ui; mod usage; @@ -19,10 +22,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<()> { @@ -51,6 +55,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/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/profiles.rs b/src/profiles.rs index ab6f61a..57ef5e4 100644 --- a/src/profiles.rs +++ b/src/profiles.rs @@ -7,7 +7,7 @@ use std::{ use anyhow::{Context, Result, anyhow}; use serde::{Deserialize, Serialize}; -use crate::config::Config; +use crate::{auth::AgentKind, config::Config}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Profile { @@ -16,6 +16,8 @@ pub struct Profile { pub args: Vec, #[serde(default)] pub title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_kind: Option, } #[derive(Debug, Clone)] @@ -31,7 +33,8 @@ pub const AGENT_PROFILE_NAMES: &[&str] = &[ impl Profile { pub fn resolved_command(&self) -> Result { - let exe = resolve_executable(&self.command) + let exe = self + .resolved_executable() .ok_or_else(|| anyhow!("profile command not found on PATH: {}", self.command))?; Ok(wrap_for_windows_script(exe, &self.args)) } @@ -39,6 +42,17 @@ impl Profile { pub fn display_name(&self, key: &str) -> String { self.title.clone().unwrap_or_else(|| key.to_string()) } + + fn resolved_executable(&self) -> Option { + self.native_agent_executable() + .or_else(|| resolve_executable(&self.command)) + } + + fn native_agent_executable(&self) -> Option { + let kind = self.agent_kind?; + (self.command == kind.default_command()) + .then(|| crate::auth::resolve_agent_executable(kind))? + } } fn wrap_for_windows_script(command: PathBuf, args: &[String]) -> LaunchCommand { @@ -109,7 +123,7 @@ pub fn available_profiles(config: &Config) -> Vec<(String, bool)> { all_profiles(config) .into_iter() .map(|(name, profile)| { - let available = resolve_executable(&profile.command).is_some(); + let available = profile.resolved_executable().is_some(); (name, available) }) .collect() @@ -136,6 +150,7 @@ fn builtin_profiles() -> BTreeMap { command: "bash".into(), args: vec!["--login".into(), "-i".into()], title: Some("Git Bash".into()), + agent_kind: None, }, ); profiles.insert( @@ -144,6 +159,7 @@ fn builtin_profiles() -> BTreeMap { command: "pwsh".into(), args: vec!["-NoLogo".into()], title: Some("PowerShell 7".into()), + agent_kind: None, }, ); profiles.insert( @@ -152,6 +168,7 @@ fn builtin_profiles() -> BTreeMap { command: "powershell".into(), args: vec!["-NoLogo".into()], title: Some("PowerShell".into()), + agent_kind: None, }, ); profiles.insert( @@ -160,6 +177,7 @@ fn builtin_profiles() -> BTreeMap { command: "cmd".into(), args: vec![], title: Some("cmd".into()), + agent_kind: None, }, ); @@ -170,6 +188,7 @@ fn builtin_profiles() -> BTreeMap { command: (*agent).into(), args: vec![], title: Some((*agent).into()), + agent_kind: builtin_agent_kind(agent), }, ); } @@ -177,6 +196,14 @@ fn builtin_profiles() -> BTreeMap { profiles } +fn builtin_agent_kind(agent: &str) -> Option { + match agent { + "codex" => Some(AgentKind::Codex), + "claude" => Some(AgentKind::Claude), + _ => None, + } +} + pub fn resolve_executable(command: &str) -> Option { let command_path = Path::new(command); if command_path.is_absolute() || command_path.components().count() > 1 { diff --git a/src/pty.rs b/src/pty.rs index a501340..0e80360 100644 --- a/src/pty.rs +++ b/src/pty.rs @@ -1,4 +1,6 @@ use std::{ + collections::BTreeMap, + env, io::{Read, Write}, path::{Path, PathBuf}, sync::{Arc, Mutex}, @@ -18,6 +20,12 @@ 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; +const MAX_OSC_SCAN: usize = 4096; +const PTY_READ_BUFFER_BYTES: usize = 32 * 1024; #[derive(Debug, Clone)] pub enum PtyEvent { @@ -78,6 +86,10 @@ pub struct PtyPane { cols: u16, response_scan_tail: Vec, output_activity: OutputActivity, + osc_scan_tail: Vec, + input_history: Vec, + pending_input: String, + output_tail: String, pub active: bool, pub exited: bool, } @@ -85,10 +97,12 @@ pub struct PtyPane { impl PtyPane { #[allow(clippy::too_many_arguments)] pub fn spawn( + profile_name: &str, id: PaneId, generation: u64, command: &Path, args: &[String], + env: &BTreeMap, cwd: &Path, extra_env: &[(String, String)], event_tx: mpsc::UnboundedSender, @@ -104,12 +118,17 @@ impl PtyPane { .context("failed to open PTY")?; let mut command_builder = CommandBuilder::new(command); - for arg in args { + configure_cwd_reporting(profile_name, &mut command_builder); + let args = args_with_cwd_reporting(profile_name, args); + for arg in &args { command_builder.arg(arg); } command_builder.cwd(cwd); command_builder.env("TERM", "xterm-256color"); command_builder.env("COLORTERM", "truecolor"); + for (key, value) in env { + command_builder.env(key, value); + } for (key, value) in extra_env { command_builder.env(key, value); } @@ -143,6 +162,10 @@ impl PtyPane { cols: 80, response_scan_tail: Vec::new(), output_activity: OutputActivity::default(), + osc_scan_tail: Vec::new(), + input_history: Vec::new(), + pending_input: String::new(), + output_tail: String::new(), active: false, exited: false, }) @@ -165,7 +188,9 @@ impl PtyPane { } pub fn process_output(&mut self, bytes: &[u8]) { + self.update_cwd_from_osc7(bytes); self.parser.process(bytes); + self.append_plain_output(bytes); self.active = true; self.output_activity.record_output(Instant::now()); self.answer_terminal_queries(bytes); @@ -183,6 +208,36 @@ impl PtyPane { self.output_activity.refresh(now, quiet_after) } + 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")?; @@ -252,6 +307,21 @@ impl PtyPane { self.response_scan_tail = terminal_query_scan_tail(&scan); } + fn update_cwd_from_osc7(&mut self, bytes: &[u8]) { + let mut scan = Vec::with_capacity(self.osc_scan_tail.len() + bytes.len()); + scan.extend_from_slice(&self.osc_scan_tail); + scan.extend_from_slice(bytes); + + for payload in osc_payloads(&scan) { + if let Some(path) = cwd_from_osc7_payload(payload) { + self.cwd = path; + } + } + + let tail_start = scan.len().saturating_sub(MAX_OSC_SCAN); + self.osc_scan_tail = scan[tail_start..].to_vec(); + } + pub fn terminate(&mut self) { if self.exited { return; @@ -260,6 +330,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 { @@ -268,6 +348,47 @@ impl Drop for PtyPane { } } +fn configure_cwd_reporting(profile_name: &str, command_builder: &mut CommandBuilder) { + match profile_name { + "git-bash" => configure_bash_cwd_reporting(command_builder), + "cmd" => command_builder.env("PROMPT", "$E]7;file:///$P$E\\$P$G"), + _ => {} + } +} + +fn configure_bash_cwd_reporting(command_builder: &mut CommandBuilder) { + let hook = "printf '\\033]7;file://%s%s\\007' \"${HOSTNAME:-localhost}\" \"$PWD\""; + let prompt_command = match env::var("PROMPT_COMMAND") { + Ok(existing) if !existing.trim().is_empty() => format!("{hook}; {existing}"), + _ => hook.to_string(), + }; + command_builder.env("PROMPT_COMMAND", prompt_command); +} + +fn args_with_cwd_reporting(profile_name: &str, args: &[String]) -> Vec { + let mut args = args.to_vec(); + if matches!(profile_name, "pwsh" | "powershell") && !has_powershell_entrypoint(&args) { + args.push("-NoExit".into()); + args.push("-Command".into()); + args.push(powershell_cwd_hook().into()); + } + args +} + +fn has_powershell_entrypoint(args: &[String]) -> bool { + args.iter().any(|arg| { + let normalized = arg.trim_start_matches('/').trim_start_matches('-'); + matches!( + normalized.to_ascii_lowercase().as_str(), + "command" | "c" | "file" | "f" | "encodedcommand" | "e" | "ec" + ) + }) +} + +fn powershell_cwd_hook() -> &'static str { + "$global:__GridBashPrompt = (Get-Command prompt -CommandType Function -ErrorAction SilentlyContinue).ScriptBlock; function global:prompt { $p = $ExecutionContext.SessionState.Path.CurrentLocation.ProviderPath; if ($p) { [Console]::Write(\"$([char]27)]7;$([System.Uri]::new($p).AbsoluteUri)$([char]7)\") }; if ($global:__GridBashPrompt) { & $global:__GridBashPrompt } else { \"PS $($ExecutionContext.SessionState.Path.CurrentLocation)> \" } }" +} + fn spawn_reader( pane: PaneId, generation: u64, @@ -275,7 +396,7 @@ fn spawn_reader( mut reader: Box, ) { thread::spawn(move || { - let mut buffer = [0_u8; 8192]; + let mut buffer = [0_u8; PTY_READ_BUFFER_BYTES]; loop { match reader.read(&mut buffer) { Ok(0) => { @@ -319,6 +440,358 @@ fn terminal_query_scan_tail(scan: &[u8]) -> Vec { scan[tail_start..].to_vec() } +fn osc_payloads(buffer: &[u8]) -> Vec<&[u8]> { + let mut payloads = Vec::new(); + let mut cursor = 0; + + while let Some(start_offset) = find_sequence(&buffer[cursor..], b"\x1b]") { + let payload_start = cursor + start_offset + 2; + let mut index = payload_start; + let mut payload_end = None; + let mut next_cursor = payload_start; + + while index < buffer.len() { + if buffer[index] == 0x07 { + payload_end = Some(index); + next_cursor = index + 1; + break; + } + if buffer[index] == 0x1b && buffer.get(index + 1) == Some(&b'\\') { + payload_end = Some(index); + next_cursor = index + 2; + break; + } + index += 1; + } + + let Some(payload_end) = payload_end else { + break; + }; + + payloads.push(&buffer[payload_start..payload_end]); + cursor = next_cursor; + } + + payloads +} + +fn cwd_from_osc7_payload(payload: &[u8]) -> Option { + let payload = String::from_utf8_lossy(payload); + let body = payload.strip_prefix("7;file://")?; + let uri_path = if body.starts_with('/') { + body + } else { + let path_start = body.find('/')?; + &body[path_start..] + }; + + let decoded = percent_decode(uri_path); + let path = windows_path_from_uri_path(&decoded); + Some(PathBuf::from(path)) +} + +fn windows_path_from_uri_path(path: &str) -> String { + let path = path.replace('\\', "/"); + + if path.len() >= 3 { + let bytes = path.as_bytes(); + if bytes[0] == b'/' + && bytes[1].is_ascii_alphabetic() + && (bytes[2] == b':' || bytes[2] == b'|') + { + return format!("{}:{}", (bytes[1] as char).to_ascii_uppercase(), &path[3..]); + } + } + + if path.len() >= 3 { + let bytes = path.as_bytes(); + if bytes[0] == b'/' && bytes[1].is_ascii_alphabetic() && bytes[2] == b'/' { + return format!( + "{}:/{}", + (bytes[1] as char).to_ascii_uppercase(), + &path[3..] + ); + } + } + + path +} + +fn percent_decode(value: &str) -> String { + let bytes = value.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + + while index < bytes.len() { + if bytes[index] == b'%' + && index + 2 < bytes.len() + && let (Some(high), Some(low)) = + (hex_value(bytes[index + 1]), hex_value(bytes[index + 2])) + { + decoded.push((high << 4) | low); + index += 3; + continue; + } + + decoded.push(bytes[index]); + index += 1; + } + + String::from_utf8_lossy(&decoded).into_owned() +} + +fn hex_value(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + +fn find_sequence(buffer: &[u8], sequence: &[u8]) -> Option { + if sequence.is_empty() || buffer.len() < sequence.len() { + return None; + } + + buffer + .windows(sequence.len()) + .position(|window| window == sequence) +} + +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::{ @@ -352,6 +825,33 @@ mod tests { assert_eq!(count_sequence(&scan, CURSOR_POSITION_QUERY), 0); } + #[test] + fn parses_osc7_windows_cwd() { + let payload = b"7;file://localhost/C:/Users/Jason/My%20Repo"; + assert_eq!( + cwd_from_osc7_payload(payload), + Some(PathBuf::from("C:/Users/Jason/My Repo")) + ); + } + + #[test] + fn parses_osc7_msys_cwd() { + let payload = b"7;file://host/c/Users/Jason/gridbash"; + assert_eq!( + cwd_from_osc7_payload(payload), + Some(PathBuf::from("C:/Users/Jason/gridbash")) + ); + } + + #[test] + fn finds_osc_payloads_with_bel_and_st_terminators() { + let payloads = osc_payloads(b"a\x1b]7;file://localhost/C:/one\x07b\x1b]0;title\x1b\\c"); + assert_eq!( + payloads, + vec![&b"7;file://localhost/C:/one"[..], &b"0;title"[..],] + ); + } + #[test] fn output_activity_marks_quiet_after_output_stops() { let start = Instant::now(); @@ -373,12 +873,33 @@ mod tests { assert!(!activity.is_quiet()); } + #[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() { let (event_tx, mut event_rx) = mpsc::unbounded_channel(); let cwd = env::current_dir().expect("current dir"); let mut pane = PtyPane::spawn( + "cmd", PaneId(0), 0, Path::new("cmd.exe"), @@ -389,6 +910,7 @@ mod tests { "/c".to_string(), "set /p GRIDBASH_IN= & echo GRIDBASH_READY:!GRIDBASH_IN!".to_string(), ], + &BTreeMap::new(), &cwd, &[], event_tx, diff --git a/src/session.rs b/src/session.rs new file mode 100644 index 0000000..6be8f93 --- /dev/null +++ b/src/session.rs @@ -0,0 +1,519 @@ +use std::{ + collections::{BTreeMap, 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(), + env: BTreeMap::new(), + cwd: pane.cwd.clone(), + folder_name: pane.folder_name.clone(), + worktree_name: pane.worktree_name.clone(), + auth_name: None, + auth_kind: None, + auth_dir: None, + }) + .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()), + agent_kind: None, + }, + 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, + agent_kind: None, + }, + cwd: PathBuf::from("."), + folder_name: folder_name.into(), + worktree_name: None, + history: SavedPaneHistory::default(), + } + } +} diff --git a/src/setup.rs b/src/setup.rs index aab61bd..952b865 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -1,16 +1,30 @@ use std::{ + collections::BTreeMap, path::{Path, PathBuf}, process::Command, }; -use anyhow::Result; +use anyhow::{Context, Result, anyhow}; use crate::{ + auth::AgentKind, layout::GridSize, profiles::{AGENT_PROFILE_NAMES, LaunchCommand, Profile}, 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, @@ -19,12 +33,102 @@ pub struct LaunchPlan { #[derive(Debug, Clone)] pub struct PaneLaunchSpec { - #[allow(dead_code)] pub profile_name: String, pub command: Profile, + pub env: BTreeMap, pub cwd: PathBuf, pub folder_name: String, pub worktree_name: Option, + pub auth_name: Option, + pub auth_kind: Option, + 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 { @@ -56,9 +160,13 @@ impl LaunchPlan { .map(|_| PaneLaunchSpec { profile_name: profile_name.clone(), command: command.clone(), + env: BTreeMap::new(), cwd: cwd.clone(), folder_name: folder_name.clone(), worktree_name: worktree_name.clone(), + auth_name: None, + auth_kind: None, + auth_dir: None, }) .collect(); @@ -78,9 +186,13 @@ impl LaunchPlan { .map(|worktree| PaneLaunchSpec { profile_name: profile_name.clone(), command: command.clone(), + env: BTreeMap::new(), cwd: worktree.cwd, folder_name: worktree.folder_name, worktree_name: Some(worktree.branch_name), + auth_name: None, + auth_kind: None, + auth_dir: None, }) .collect(); @@ -151,6 +263,51 @@ 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 @@ -198,6 +355,47 @@ 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"); @@ -207,10 +405,15 @@ mod tests { command: "vibe".into(), args: vec!["run".into(), "claude-1".into(), "--".into()], title: Some("claude-1".into()), + agent_kind: None, }, + env: BTreeMap::new(), cwd, folder_name: "repo".into(), worktree_name: None, + auth_name: None, + auth_kind: None, + auth_dir: None, }; assert_eq!(spec.agent_label().as_deref(), Some("claude-1")); @@ -225,10 +428,15 @@ mod tests { command: "codex".into(), args: vec!["--model".into(), "gpt-5.5".into()], title: Some("Codex Review".into()), + agent_kind: Some(AgentKind::Codex), }, + env: BTreeMap::new(), cwd, folder_name: "repo".into(), worktree_name: None, + auth_name: None, + auth_kind: None, + auth_dir: None, }; assert_eq!(spec.agent_label().as_deref(), Some("Codex Review")); @@ -243,10 +451,15 @@ mod tests { command: "bash".into(), args: vec!["--login".into()], title: Some("Git Bash".into()), + agent_kind: None, }, + env: BTreeMap::new(), cwd, folder_name: "repo".into(), worktree_name: None, + auth_name: None, + auth_kind: None, + auth_dir: None, }; assert_eq!(spec.agent_label(), None); diff --git a/src/ui.rs b/src/ui.rs index 04a50c5..616d594 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -10,9 +10,11 @@ use vt100::Cell; use crate::{ app::{ - App, ExitedPaneRecoveryView, GridPalette, PaneSelection, PaneSettingsView, RenamePaneView, - SettingsRow, + App, ExitedPaneRecoveryView, FollowUpDialog, GridPalette, PaneGroupView, PaneSelection, + PaneSettingsView, PreviousPaneView, PreviousPanesView, PromptView, RenamePaneView, + RenameTabView, SettingsGroup, SettingsRow, SettingsTab, SettingsValueKind, TabLabel, }, + auth::{AgentKind, AuthProfile}, image_preview::ImagePreview, }; @@ -28,31 +30,56 @@ const SETTINGS_TEXT: Color = Color::Rgb(230, 237, 243); pub struct DrawState { pub grid_area: Rect, pub pane_rects: Vec, + pub previous_panes_button: Option, + pub previous_pane_rows: Vec<(usize, Rect)>, pub pane_settings_button: Option, pub pane_settings_reload_button: Option, } const QUIET_MARKER: &str = " *"; const STATUS_BRAND: &str = " GridBash "; +const PREVIOUS_PANES_BUTTON: &str = " Panes "; const PANE_SETTINGS_BUTTON: &str = " Pane "; pub fn draw(frame: &mut Frame<'_>, app: &App) -> DrawState { let area = frame.area(); + let output_height = if app.command_output_expanded() { + command_output_height(area.height, app.command_output_lines().len()) + } else { + 0 + }; let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Min(1), Constraint::Length(1)]) + .constraints([ + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(output_height), + Constraint::Length(1), + Constraint::Length(1), + ]) .split(area); - let grid_area = chunks[0]; - let status_area = chunks[1]; + let tab_area = chunks[0]; + let grid_area = chunks[1]; + let command_output_area = chunks[2]; + let command_area = chunks[3]; + let status_area = chunks[4]; let rects = app.pane_rects(grid_area); let palette = app.palette(); let rename_view = app.rename_pane_view(); + let tab_rename_view = app.rename_tab_view(); + let previous_panes_view = app.previous_panes_view(); + let follow_up_dialog = app.follow_up_dialog(); + let prompt_view = app.prompt_view(); let pane_settings_open = app.pane_settings_open(); let image_overlay = app.image_overlay_view(); let exited_recovery = if app.settings_open() + || previous_panes_view.is_some() || pane_settings_open || rename_view.is_some() + || tab_rename_view.is_some() + || follow_up_dialog.is_some() + || prompt_view.is_some() || image_overlay.is_some() { None @@ -60,27 +87,33 @@ pub fn draw(frame: &mut Frame<'_>, app: &App) -> DrawState { app.exited_recovery_view() }; let modal_open = app.settings_open() + || previous_panes_view.is_some() || pane_settings_open || rename_view.is_some() + || tab_rename_view.is_some() + || follow_up_dialog.is_some() + || prompt_view.is_some() || image_overlay.is_some() || exited_recovery.is_some(); let mut pane_settings_reload_button = None; + render_tabs(frame, tab_area, &app.tab_labels(), palette); for (index, pane) in app.panes().iter().enumerate() { let Some(rect) = rects.get(index).copied() else { continue; }; - let focused = app.focus() == index; + let focused = app.focused_pane() == Some(index); let selected = app.selected().contains(&index); let sleeping = app.pane_sleeping(index); + let group = app.pane_group(index); let quiet = app.activity_badges_enabled() && pane.output_quiet(); let chrome = pane_chrome( selected, focused, - pane.active, pane.exited, sleeping, + group.map(|group| group.color.rgb), quiet, palette, ); @@ -95,6 +128,8 @@ pub fn draw(frame: &mut Frame<'_>, app: &App) -> DrawState { chrome.quiet_marker, &folder, app.pane_worktree(index), + app.pane_profile(index), + app.pane_auth(index), usage.as_deref(), chrome.badge, ); @@ -118,17 +153,22 @@ 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()); } } - let input_scope = if app.selected().len() > 1 { - "selected panes" - } else { - "focused pane" - }; + if output_height > 0 { + render_command_output(frame, command_output_area, app); + } + render_command_line(frame, command_area, app); + + let input_scope = app.input_scope_label(); + let previous_panes_button = previous_panes_button_rect(status_area); let pane_settings_button = pane_settings_button_rect(status_area); let status = Line::from(vec![ Span::styled( @@ -139,6 +179,11 @@ pub fn draw(frame: &mut Frame<'_>, app: &App) -> DrawState { .add_modifier(Modifier::BOLD), ), Span::raw(" "), + Span::styled( + PREVIOUS_PANES_BUTTON, + previous_panes_button_style(app.previous_panes_open(), palette), + ), + Span::raw(" "), Span::styled( PANE_SETTINGS_BUTTON, pane_settings_button_style(app.pane_settings_open(), palette), @@ -153,7 +198,9 @@ pub fn draw(frame: &mut Frame<'_>, app: &App) -> DrawState { Span::raw(" | "), Span::styled( input_scope, - Style::default().fg(if app.selected().len() > 1 { + Style::default().fg(if app.command_focused() { + palette.accent() + } else if app.selected().len() > 1 { palette.selected() } else { Color::Gray @@ -163,7 +210,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+p pane settings | Alt+t restart | Alt+x swap | Alt+z sleep | Alt+q quit"), + Span::raw( + " | Alt+n new | Alt+t tab | Alt+Shift+t restart | Alt+c command | Alt+e output | Alt+p panes | Alt+P pane | Alt+x swap | Alt+z sleep | Alt+q quit", + ), ]); frame.render_widget( Paragraph::new(status).style(Style::default().bg(APP_BG)), @@ -171,11 +220,24 @@ pub fn draw(frame: &mut Frame<'_>, app: &App) -> DrawState { ); if app.settings_open() { - render_settings(frame, area, &app.settings_rows(), palette); + render_settings(frame, area, app, palette); + } else if let Some(dialog) = follow_up_dialog.as_ref() { + render_follow_up_dialog(frame, area, dialog); } + let previous_pane_rows = if let Some(view) = previous_panes_view.as_ref() { + render_previous_panes(frame, area, view, palette) + } else { + Vec::new() + }; if let Some(rename) = rename_view.as_ref() { render_rename_pane(frame, area, rename); } + if let Some(rename) = tab_rename_view.as_ref() { + render_rename_tab(frame, area, rename); + } + if let Some(prompt) = prompt_view.as_ref() { + render_manager_prompt(frame, area, prompt); + } if let Some(image) = image_overlay { render_image_overlay(frame, area, image); } @@ -186,28 +248,311 @@ pub fn draw(frame: &mut Frame<'_>, app: &App) -> DrawState { DrawState { grid_area, pane_rects: rects, + previous_panes_button, + previous_pane_rows, pane_settings_button, pane_settings_reload_button, } } +#[allow(clippy::too_many_arguments)] fn pane_title( label: &str, quiet_marker: &str, folder: &str, worktree: Option<&str>, + profile: Option<&str>, + auth: Option<&str>, usage: Option<&str>, badge: &str, ) -> String { - let label = format!("{label}{quiet_marker}"); - let usage = usage.map(|label| format!(" | {label}")).unwrap_or_default(); - if let Some(worktree) = worktree { - format!(" {label} | {folder} | {worktree}{usage}{badge} ") + let mut parts = vec![format!("{label}{quiet_marker}"), folder.to_string()]; + if let Some(worktree) = worktree.filter(|value| !value.is_empty()) { + parts.push(worktree.to_string()); + } + if let Some(profile) = profile.filter(|value| !value.is_empty()) { + parts.push(profile.to_string()); + } + if let Some(auth) = auth.filter(|value| !value.is_empty()) { + parts.push(auth.to_string()); + } + if let Some(usage) = usage.filter(|value| !value.is_empty()) { + parts.push(usage.to_string()); + } + + format!(" {}{} ", parts.join(" | "), badge) +} + +fn render_tabs(frame: &mut Frame<'_>, area: Rect, tabs: &[TabLabel], palette: &GridPalette) { + if area.width == 0 || area.height == 0 { + return; + } + + let mut spans = vec![ + Span::styled( + STATUS_BRAND, + Style::default() + .fg(Color::Black) + .bg(palette.accent()) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + ]; + + for (index, tab) in tabs.iter().enumerate() { + let marker = if tab.exited { + "!" + } else if tab.activity && !tab.active { + "*" + } else { + "" + }; + let label = format!( + " {}:{}{} ", + index + 1, + truncate_text(&tab.title, 18), + marker + ); + let style = if tab.active { + Style::default() + .fg(Color::Black) + .bg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else if tab.exited { + Style::default().fg(palette.exited()) + } else if tab.activity { + Style::default().fg(palette.quiet()) + } else { + Style::default().fg(Color::Gray) + }; + spans.push(Span::styled(label, style)); + } + + spans.push(Span::raw(" ")); + spans.push(Span::styled( + "Alt+n new", + Style::default().fg(Color::DarkGray), + )); + spans.push(Span::raw(" ")); + spans.push(Span::styled( + "Alt+Shift+r rename tab", + Style::default().fg(Color::DarkGray), + )); + + frame.render_widget( + Paragraph::new(Line::from(spans)).style(Style::default().bg(APP_BG)), + area, + ); +} + +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, + ); +} + +fn command_output_height(total_height: u16, line_count: usize) -> u16 { + let available = total_height.saturating_sub(3); + if available < 3 { + return 0; + } + + let max_height = (total_height / 3).clamp(3, 12).min(available); + (line_count as u16).saturating_add(2).clamp(3, max_height) +} + +fn render_command_output(frame: &mut Frame<'_>, area: Rect, app: &App) { + if area.width == 0 || area.height == 0 { + return; + } + + let border_style = if app.command_focused() { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }; + let title = if app.command_running() { + " Command output | running " + } else { + " Command output " + }; + let block = Block::default() + .borders(Borders::ALL) + .border_style(border_style) + .title(title); + let inner = block.inner(area); + frame.render_widget(block, area); + + let lines = app.command_output_lines(); + let start = lines.len().saturating_sub(inner.height as usize); + let visible = lines[start..] + .iter() + .cloned() + .map(Line::from) + .collect::>(); + + frame.render_widget( + Paragraph::new(visible).style( + Style::default() + .fg(Color::Rgb(230, 237, 243)) + .bg(Color::Rgb(11, 15, 20)), + ), + inner, + ); +} + +fn render_command_line(frame: &mut Frame<'_>, area: Rect, app: &App) { + if area.width == 0 || area.height == 0 { + return; + } + + let width = area.width as usize; + let cwd = app.command_cwd().display().to_string(); + let cwd_budget = command_cwd_budget(width, app.command_input()); + let cwd = truncate_start(&cwd, cwd_budget); + let prompt = format!(" {cwd} > "); + let prompt_width = prompt.chars().count(); + let input_width = width.saturating_sub(prompt_width); + let (input, cursor_offset) = + visible_input(app.command_input(), app.command_cursor_chars(), input_width); + let focused = app.command_focused(); + + let prompt_style = if focused { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Gray) + }; + let input_style = if focused { + Style::default().fg(Color::White) } else { - format!(" {label} | {folder}{usage}{badge} ") + Style::default().fg(Color::Rgb(180, 190, 202)) + }; + + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled(prompt, prompt_style), + Span::styled(input, input_style), + ])) + .style(Style::default().bg(Color::Rgb(14, 20, 28))), + area, + ); + + if focused { + let x = area + .x + .saturating_add((prompt_width + cursor_offset).min(width.saturating_sub(1)) as u16); + frame.set_cursor_position((x, area.y)); } } +fn command_cwd_budget(width: usize, input: &str) -> usize { + if width <= 4 { + return 0; + } + if input.is_empty() { + return width.saturating_sub(4); + } + + width.saturating_sub(14).min((width * 2) / 3) +} + +fn truncate_start(value: &str, max_chars: usize) -> String { + let chars = value.chars().collect::>(); + if chars.len() <= max_chars { + return value.to_string(); + } + if max_chars <= 3 { + return chars[chars.len().saturating_sub(max_chars)..] + .iter() + .collect(); + } + + let tail = chars[chars.len() - (max_chars - 3)..] + .iter() + .collect::(); + format!("...{tail}") +} + +fn visible_input(input: &str, cursor_chars: usize, width: usize) -> (String, usize) { + if width == 0 { + return (String::new(), 0); + } + + let chars = input.chars().collect::>(); + let cursor = cursor_chars.min(chars.len()); + if chars.len() <= width { + return (input.to_string(), cursor); + } + + let start = cursor.saturating_sub(width.saturating_sub(1)); + let end = (start + width).min(chars.len()); + (chars[start..end].iter().collect(), cursor - start) +} + #[derive(Debug, PartialEq)] struct PaneChrome { border_style: Style, @@ -218,9 +563,9 @@ struct PaneChrome { fn pane_chrome( selected: bool, focused: bool, - _active: bool, exited: bool, sleeping: bool, + group_color: Option<(u8, u8, u8)>, quiet: bool, palette: &GridPalette, ) -> PaneChrome { @@ -236,6 +581,10 @@ fn pane_chrome( .add_modifier(Modifier::BOLD) } else if exited { Style::default().fg(palette.exited()) + } else if let Some(group_color) = group_color { + Style::default() + .fg(rgb_color(group_color)) + .add_modifier(Modifier::BOLD) } else if quiet { Style::default().fg(palette.quiet()) } else { @@ -264,8 +613,37 @@ fn pane_chrome( } } -fn pane_settings_button_rect(status_area: Rect) -> Option { +fn previous_panes_button_rect(status_area: Rect) -> Option { let offset = STATUS_BRAND.len() as u16 + 1; + let width = PREVIOUS_PANES_BUTTON.len() as u16; + if status_area.height == 0 || status_area.width < offset.saturating_add(width) { + return None; + } + + Some(Rect { + x: status_area.x.saturating_add(offset), + y: status_area.y, + width, + height: 1, + }) +} + +fn previous_panes_button_style(open: bool, palette: &GridPalette) -> Style { + if open { + Style::default() + .fg(Color::Black) + .bg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + .fg(Color::Black) + .bg(palette.focus()) + .add_modifier(Modifier::BOLD) + } +} + +fn pane_settings_button_rect(status_area: Rect) -> Option { + let offset = STATUS_BRAND.len() as u16 + 1 + PREVIOUS_PANES_BUTTON.len() as u16 + 1; let width = PANE_SETTINGS_BUTTON.len() as u16; if status_area.height == 0 || status_area.width < offset.saturating_add(width) { return None; @@ -449,8 +827,202 @@ fn pane_settings_reload_rect(area: Rect) -> Option { }) } -fn render_settings(frame: &mut Frame<'_>, area: Rect, rows: &[SettingsRow], palette: &GridPalette) { - let modal = settings_modal_rect(area, rows.len()); +fn render_previous_panes( + frame: &mut Frame<'_>, + area: Rect, + view: &PreviousPanesView, + palette: &GridPalette, +) -> Vec<(usize, Rect)> { + let modal = previous_panes_modal_rect(area, view.panes.len()); + let shadow = settings_shadow_rect(area, modal); + let mut row_hits = Vec::new(); + + if shadow != modal { + frame.render_widget(Clear, shadow); + frame.render_widget( + Paragraph::new("").style(Style::default().bg(SETTINGS_SHADOW)), + shadow, + ); + } + + frame.render_widget(Clear, modal); + + let block = Block::default() + .borders(Borders::ALL) + .border_style( + Style::default() + .fg(palette.focus()) + .add_modifier(Modifier::BOLD), + ) + .style(settings_panel_style()) + .title(" Previous Panes "); + let inner = block.inner(modal); + frame.render_widget(block, modal); + + if inner.width == 0 || inner.height == 0 { + return row_hits; + } + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(2), + Constraint::Min(1), + Constraint::Length(1), + ]) + .split(inner); + + let header = Line::from(vec![ + Span::raw(" "), + Span::styled( + format!("{} panes", view.panes.len()), + Style::default() + .fg(palette.focus()) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" current session", Style::default().fg(Color::Gray)), + ]); + frame.render_widget( + Paragraph::new(vec![header, Line::from("")]).style(settings_panel_style()), + chunks[0], + ); + + let list_area = chunks[1]; + let visible = + visible_previous_pane_range(view.panes.len(), view.cursor, list_area.height as usize); + let mut rows = Vec::new(); + + for (row_offset, index) in visible.enumerate() { + let Some(pane) = view.panes.get(index) else { + continue; + }; + let row_area = Rect { + x: list_area.x, + y: list_area.y.saturating_add(row_offset as u16), + width: list_area.width, + height: 1, + }; + row_hits.push((index, row_area)); + rows.push(previous_pane_line( + pane, + view.cursor == index, + list_area.width, + )); + } + + frame.render_widget( + Paragraph::new(rows).style(settings_panel_style()), + list_area, + ); + frame.render_widget( + Paragraph::new(previous_panes_command_bar(chunks[2].width)).style(settings_panel_style()), + chunks[2], + ); + + row_hits +} + +fn previous_pane_line(pane: &PreviousPaneView, active: bool, width: u16) -> Line<'static> { + let (state, state_color) = previous_pane_state(pane); + let label_width = if width < 62 { 10 } else { 16 }; + let location_width = if width < 62 { 14 } else { 24 }; + let marker = if active { ">" } else { " " }; + let location = pane + .worktree + .as_ref() + .map(|worktree| format!("{} | {worktree}", pane.folder)) + .unwrap_or_else(|| pane.folder.clone()); + let text = format!( + "{marker} {:>2} {: (&'static str, Color) { + if pane.exited { + ("exited", Color::Red) + } else if pane.sleeping { + ("asleep", Color::DarkGray) + } else if pane.focused { + ("focus", Color::Yellow) + } else if pane.selected { + ("selected", Color::Cyan) + } else { + ("live", SETTINGS_TEXT) + } +} + +fn previous_panes_command_bar(width: u16) -> Line<'static> { + if width < 44 { + return Line::from(vec![ + Span::raw(" "), + command_key("Enter"), + Span::styled(" focus ", Style::default().fg(Color::Gray)), + command_key("Esc"), + Span::styled(" close", Style::default().fg(Color::Gray)), + ]); + } + + Line::from(vec![ + Span::raw(" "), + command_key("Up/Down"), + Span::styled(" move ", Style::default().fg(Color::Gray)), + command_key("Enter"), + Span::styled(" focus ", Style::default().fg(Color::Gray)), + command_key("Esc"), + Span::styled(" close", Style::default().fg(Color::Gray)), + ]) +} + +fn previous_panes_modal_rect(area: Rect, pane_count: usize) -> Rect { + let width = area.width.saturating_sub(4).min(96).max(area.width.min(1)); + let desired_height = (pane_count as u16).saturating_add(6).clamp(8, 24); + let height = area + .height + .saturating_sub(2) + .min(desired_height) + .max(area.height.min(1)); + + Rect { + x: area.x + area.width.saturating_sub(width) / 2, + y: area.y + area.height.saturating_sub(height) / 2, + width, + height, + } +} + +fn visible_previous_pane_range( + pane_count: usize, + cursor: usize, + capacity: usize, +) -> std::ops::Range { + if pane_count == 0 || capacity == 0 { + return 0..0; + } + + let capacity = capacity.min(pane_count); + let cursor = cursor.min(pane_count - 1); + let mut start = cursor.saturating_sub(capacity / 2); + if start + capacity > pane_count { + start = pane_count - capacity; + } + + start..start + capacity +} + +fn render_settings(frame: &mut Frame<'_>, area: Rect, app: &App, palette: &GridPalette) { + let modal = settings_modal_rect(area, settings_content_row_count(app)); let shadow = settings_shadow_rect(area, modal); if shadow != modal { @@ -475,7 +1047,7 @@ fn render_settings(frame: &mut Frame<'_>, area: Rect, rows: &[SettingsRow], pale let inner = block.inner(modal); frame.render_widget(block, modal); frame.render_widget( - Paragraph::new(settings_lines(rows, inner.width)).style(settings_panel_style()), + Paragraph::new(settings_lines(app, inner.width)).style(settings_panel_style()), inner, ); } @@ -563,6 +1135,86 @@ fn render_rename_pane(frame: &mut Frame<'_>, area: Rect, rename: &RenamePaneView } } +fn render_rename_tab(frame: &mut Frame<'_>, area: Rect, rename: &RenameTabView) { + let modal = centered_rect(area, 62, 28); + frame.render_widget(Clear, modal); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Yellow)) + .title(" Rename Tab "); + let inner = block.inner(modal); + frame.render_widget(block, modal); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(2), + Constraint::Length(3), + Constraint::Length(2), + Constraint::Min(0), + ]) + .split(inner); + + let header = Line::from(vec![ + Span::styled( + "Current tab", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled(rename.title.clone(), Style::default().fg(Color::DarkGray)), + ]); + frame.render_widget( + Paragraph::new(header).style(Style::default().fg(Color::Rgb(230, 237, 243))), + chunks[0], + ); + + let input_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + .title(" Title "); + let input_inner = input_block.inner(chunks[1]); + let input_line = if rename.value.is_empty() { + Line::from(Span::styled( + "tab title required", + Style::default().fg(Color::DarkGray), + )) + } else { + Line::from(rename.value.clone()) + }; + frame.render_widget( + Paragraph::new(input_line).block(input_block).style( + Style::default() + .fg(Color::Rgb(230, 237, 243)) + .bg(Color::Rgb(11, 15, 20)), + ), + chunks[1], + ); + + let help = Line::from(vec![ + Span::styled("Enter", Style::default().fg(Color::Yellow)), + Span::raw(" save "), + Span::styled("Esc", Style::default().fg(Color::Yellow)), + Span::raw(" cancel "), + Span::styled("Ctrl+u", Style::default().fg(Color::Yellow)), + Span::raw(" clear"), + ]); + frame.render_widget( + Paragraph::new(help).style(Style::default().fg(Color::Gray)), + chunks[2], + ); + + if input_inner.width > 0 && input_inner.height > 0 { + let cursor = rename.cursor.min(rename.value.chars().count()) as u16; + let x = input_inner + .x + .saturating_add(cursor.min(input_inner.width.saturating_sub(1))); + frame.set_cursor_position((x, input_inner.y)); + } +} + fn render_exited_recovery( frame: &mut Frame<'_>, area: Rect, @@ -627,6 +1279,76 @@ fn render_exited_recovery( ); } +fn render_follow_up_dialog(frame: &mut Frame<'_>, area: Rect, dialog: &FollowUpDialog) { + let modal = follow_up_modal_rect(area); + let shadow = settings_shadow_rect(area, modal); + + if shadow != modal { + frame.render_widget(Clear, shadow); + frame.render_widget( + Paragraph::new("").style(Style::default().bg(SETTINGS_SHADOW)), + shadow, + ); + } + + frame.render_widget(Clear, modal); + + let block = Block::default() + .borders(Borders::ALL) + .border_style( + Style::default() + .fg(SETTINGS_BORDER) + .add_modifier(Modifier::BOLD), + ) + .style(settings_panel_style()) + .title(" Todo Follow-up "); + let inner = block.inner(modal); + frame.render_widget(block, modal); + frame.render_widget( + Paragraph::new(follow_up_lines(dialog, inner.width)).style(settings_panel_style()), + inner, + ); +} + +fn settings_content_row_count(app: &App) -> usize { + match app.settings_tab() { + SettingsTab::General => app.settings_rows().len(), + SettingsTab::Auth => { + app.auth_profiles().len().max(1) + usize::from(app.auth_create().is_some()) * 3 + } + } +} + +fn settings_lines(app: &App, width: u16) -> Vec> { + match app.settings_tab() { + SettingsTab::General => general_settings_lines(&app.settings_rows(), width), + SettingsTab::Auth => auth_settings_lines(app, width), + } +} + +fn settings_tabs(active: SettingsTab) -> Line<'static> { + Line::from(vec![ + Span::raw(" "), + settings_tab("General", active == SettingsTab::General), + Span::raw(" "), + settings_tab("Auth", active == SettingsTab::Auth), + Span::raw(" "), + Span::styled("Tab switches", Style::default().fg(SETTINGS_MUTED)), + ]) +} + +fn settings_tab(label: &'static str, active: bool) -> 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(SETTINGS_SURFACE) + }; + Span::styled(format!(" {label} "), style) +} + fn render_image_overlay(frame: &mut Frame<'_>, area: Rect, image: &ImagePreview) { let modal = image_modal_rect(area, image); frame.render_widget(Clear, modal); @@ -643,33 +1365,104 @@ fn render_image_overlay(frame: &mut Frame<'_>, area: Rect, image: &ImagePreview) let inner = block.inner(modal); frame.render_widget(block, modal); - if inner.width == 0 || inner.height == 0 { - return; + if inner.width == 0 || inner.height == 0 { + return; + } + + let mut lines = Vec::new(); + lines.push(image_meta_line(image, inner.width)); + lines.push(Line::from("")); + + let available_image_rows = inner.height.saturating_sub(4) as usize; + let max_columns = inner.width as usize; + for row in image.rows.iter().take(available_image_rows) { + lines.push(image_row(row, max_columns)); + } + + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::raw(" "), + command_key("Esc"), + Span::styled(" close ", Style::default().fg(Color::Gray)), + command_key("q"), + Span::styled(" close", Style::default().fg(Color::Gray)), + ])); + + frame.render_widget( + Paragraph::new(lines).style(Style::default().fg(SETTINGS_TEXT).bg(APP_BG)), + inner, + ); +} + +fn follow_up_lines(dialog: &FollowUpDialog, width: u16) -> Vec> { + let quiet = format!( + "Pane {} has been quiet for {}s.", + dialog.pane_number, dialog.quiet_seconds + ); + let count = format!("Todo {}/{}", dialog.todo_position, dialog.todo_count); + let prompt_width = width.saturating_sub(4) as usize; + let mut lines = vec![ + Line::from(vec![ + Span::raw(" "), + Span::styled( + quiet, + Style::default() + .fg(SETTINGS_BORDER) + .add_modifier(Modifier::BOLD), + ), + ]), + Line::from(vec![ + Span::raw(" "), + Span::styled( + "Send this queued prompt?", + Style::default().fg(SETTINGS_TEXT), + ), + Span::raw(" "), + Span::styled( + count, + Style::default() + .fg(Color::Black) + .bg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + ]), + Line::from(""), + ]; + + for line in wrap_dialog_text(&dialog.prompt, prompt_width, 3) { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(line, Style::default().fg(Color::LightCyan)), + ])); } - let mut lines = Vec::new(); - lines.push(image_meta_line(image, inner.width)); lines.push(Line::from("")); + lines.push(follow_up_command_bar(width)); + lines +} - let available_image_rows = inner.height.saturating_sub(4) as usize; - let max_columns = inner.width as usize; - for row in image.rows.iter().take(available_image_rows) { - lines.push(image_row(row, max_columns)); +fn follow_up_command_bar(width: u16) -> Line<'static> { + if width < 54 { + return Line::from(vec![ + Span::raw(" "), + command_key("Enter"), + Span::styled(" send ", Style::default().fg(Color::Gray)), + command_key("Esc"), + Span::styled(" no", Style::default().fg(Color::Gray)), + ]); } - lines.push(Line::from("")); - lines.push(Line::from(vec![ + Line::from(vec![ Span::raw(" "), - command_key("Esc"), - Span::styled(" close ", Style::default().fg(Color::Gray)), - command_key("q"), - Span::styled(" close", Style::default().fg(Color::Gray)), - ])); - - frame.render_widget( - Paragraph::new(lines).style(Style::default().fg(SETTINGS_TEXT).bg(APP_BG)), - inner, - ); + command_key("Enter/Y"), + Span::styled(" send ", Style::default().fg(Color::Gray)), + command_key("Tab"), + Span::styled(" next ", Style::default().fg(Color::Gray)), + command_key("Del"), + Span::styled(" remove ", Style::default().fg(Color::Gray)), + command_key("Esc/N"), + Span::styled(" no", Style::default().fg(Color::Gray)), + ]) } fn image_meta_line(image: &ImagePreview, width: u16) -> Line<'static> { @@ -727,8 +1520,10 @@ fn image_modal_rect(area: Rect, image: &ImagePreview) -> Rect { } } -fn settings_lines(rows: &[SettingsRow], width: u16) -> Vec> { +fn general_settings_lines(rows: &[SettingsRow], width: u16) -> Vec> { let mut lines = vec![ + settings_tabs(SettingsTab::General), + Line::from(""), Line::from(vec![ Span::raw(" "), Span::styled( @@ -751,48 +1546,206 @@ fn settings_lines(rows: &[SettingsRow], width: u16) -> Vec> { Span::styled(settings_summary(width), Style::default().fg(Color::Gray)), ]), Line::from(""), - settings_section("DISPLAY", "title bar and state signals", width), ]; - for row in rows.iter().take(2) { - lines.push(settings_row(row, width)); - } - - lines.push(Line::from("")); - lines.push(settings_section( + push_settings_group( + &mut lines, + rows, + SettingsGroup::Display, + "DISPLAY", + "title bar and state signals", + width, + ); + push_settings_group( + &mut lines, + rows, + SettingsGroup::Workflow, "WORKFLOW", "guard rails for high-speed sessions", width, - )); - if let Some(row) = rows.get(2) { - lines.push(settings_row(row, width)); - } - - lines.push(Line::from("")); - lines.push(settings_section( + ); + push_settings_group( + &mut lines, + rows, + SettingsGroup::Todo, + "TODO", + "queued prompts for quiet panes", + width, + ); + push_settings_group( + &mut lines, + rows, + SettingsGroup::Performance, "PERFORMANCE", "spacing and terminal budget", width, - )); - for row in rows.iter().skip(3).take(3) { - lines.push(settings_row(row, width)); - } - - lines.push(Line::from("")); - lines.push(settings_section( + ); + push_settings_group( + &mut lines, + rows, + SettingsGroup::Theme, "THEME", "runtime palette for grid chrome", width, - )); - for row in rows.iter().skip(6) { - lines.push(settings_row(row, width)); - } + ); lines.push(Line::from("")); lines.push(settings_command_bar(width)); lines } +fn auth_settings_lines(app: &App, width: u16) -> Vec> { + let mut lines = vec![ + settings_tabs(SettingsTab::Auth), + Line::from(""), + settings_section( + "AUTH PROFILES", + if app.auth_refreshing() { + "refreshing local account and usage status" + } else { + "Claude and Codex defaults" + }, + width, + ), + Line::from(vec![ + Span::raw(" "), + Span::styled("home", Style::default().fg(SETTINGS_MUTED)), + Span::raw(" "), + Span::styled( + truncate_text(&app.auth_home_label(), width.saturating_sub(8) as usize), + Style::default().fg(Color::Gray), + ), + ]), + Line::from(""), + ]; + + if app.auth_profiles().is_empty() { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled("No auth profiles found.", Style::default().fg(Color::Gray)), + Span::raw(" "), + Span::styled("n", Style::default().fg(Color::Yellow)), + Span::styled(" creates one", Style::default().fg(SETTINGS_MUTED)), + ])); + } else { + for (index, profile) in app.auth_profiles().iter().enumerate() { + lines.push(auth_profile_row( + profile, + index == app.auth_cursor(), + app.auth_default(profile.kind) == Some(profile.name.as_str()), + width, + )); + } + } + + if let Some(create) = app.auth_create() { + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled("Create", Style::default().fg(SETTINGS_MUTED)), + Span::raw(" "), + Span::styled( + create.kind.display_name(), + Style::default().fg(kind_color(create.kind)), + ), + Span::raw(" "), + Span::styled(create.name.clone(), Style::default().fg(Color::Yellow)), + Span::styled("_", Style::default().fg(Color::Yellow)), + ])); + lines.push(auth_create_command_bar()); + } + + lines.push(Line::from("")); + lines.push(auth_command_bar(width)); + lines +} + +fn auth_profile_row( + profile: &AuthProfile, + selected: bool, + is_default: bool, + width: u16, +) -> Line<'static> { + let row_bg = selected.then_some(SETTINGS_ROW_ACTIVE); + let marker = if selected { "> " } else { " " }; + let default = if is_default { "default" } else { "" }; + let account = profile.account_label.as_deref().unwrap_or("no account"); + let detail = profile.account_detail.as_deref().unwrap_or(""); + let usage = profile + .usage + .as_ref() + .map(|usage| usage.display_label()) + .unwrap_or_else(|| "usage n/a".into()); + let summary = format!( + "{:<14} {:<7} {:<8} {:<12} {:<24} {:<8} {}", + profile.name, + profile.kind.as_str(), + default, + profile.status_label(), + account, + detail, + usage + ); + let available = width.saturating_sub(2) as usize; + + Line::from(vec![ + Span::styled(marker.to_string(), row_style(Color::Yellow, row_bg, false)), + Span::styled( + truncate_text(&summary, available), + row_style(SETTINGS_TEXT, row_bg, selected), + ), + ]) +} + +fn auth_command_bar(width: u16) -> Line<'static> { + if width < 58 { + return Line::from(vec![ + Span::raw(" "), + command_key("Up/Down"), + Span::styled(" move ", Style::default().fg(Color::Gray)), + command_key("d"), + Span::styled(" default ", Style::default().fg(Color::Gray)), + command_key("Esc"), + Span::styled(" close", Style::default().fg(Color::Gray)), + ]); + } + + Line::from(vec![ + Span::raw(" "), + command_key("Up/Down"), + Span::styled(" move ", Style::default().fg(Color::Gray)), + command_key("d"), + Span::styled(" default ", Style::default().fg(Color::Gray)), + command_key("n"), + Span::styled(" new ", Style::default().fg(Color::Gray)), + command_key("l"), + Span::styled(" login ", Style::default().fg(Color::Gray)), + command_key("r"), + Span::styled(" refresh ", Style::default().fg(Color::Gray)), + command_key("Esc"), + Span::styled(" close", Style::default().fg(Color::Gray)), + ]) +} + +fn auth_create_command_bar() -> Line<'static> { + Line::from(vec![ + Span::raw(" "), + command_key("Tab"), + Span::styled(" kind ", Style::default().fg(Color::Gray)), + command_key("Enter"), + Span::styled(" create ", Style::default().fg(Color::Gray)), + command_key("Esc"), + Span::styled(" cancel", Style::default().fg(Color::Gray)), + ]) +} + +fn kind_color(kind: AgentKind) -> Color { + match kind { + AgentKind::Claude => Color::Magenta, + AgentKind::Codex => Color::Cyan, + } +} + fn conversation_footer(summary: String, emphasized: bool) -> Line<'static> { let summary_style = if emphasized { Style::default() @@ -812,13 +1765,38 @@ fn conversation_footer(summary: String, emphasized: bool) -> Line<'static> { fn settings_summary(width: u16) -> String { let text = if width < 70 { - "Refine pane chrome, safety prompts, and highlight color." + "Refine pane chrome, todo prompts, and highlight color." } else { - "Refine pane chrome, safety prompts, performance, and highlight color." + "Refine pane chrome, idle follow-up todos, performance, and highlight color." }; truncate_text(text, width.saturating_sub(2) as usize) } +fn push_settings_group( + lines: &mut Vec>, + rows: &[SettingsRow], + group: SettingsGroup, + title: &'static str, + helper: &'static str, + width: u16, +) { + let group_rows = rows + .iter() + .filter(|row| row.group == group) + .collect::>(); + if group_rows.is_empty() { + return; + } + + if lines.last().is_none_or(|line| line.width() != 0) { + lines.push(Line::from("")); + } + lines.push(settings_section(title, helper, width)); + for row in group_rows { + lines.push(settings_row(row, width)); + } +} + fn settings_section(title: &'static str, helper: &'static str, width: u16) -> Line<'static> { let used = 2 + title.len() + 2; let helper = width @@ -844,6 +1822,15 @@ fn settings_section(title: &'static str, helper: &'static str, width: u16) -> Li } fn settings_row(row: &SettingsRow, width: u16) -> Line<'static> { + if row.group == SettingsGroup::Todo + && matches!( + row.value_kind, + SettingsValueKind::Text | SettingsValueKind::Action + ) + { + return settings_todo_row(row, width); + } + let width = width as usize; let narrow = width < 66; let label_width = if narrow { 20 } else { 24 }; @@ -851,10 +1838,10 @@ fn settings_row(row: &SettingsRow, width: u16) -> Line<'static> { let reserved = 2 + label_width + 2 + value_width + 2; let hint_width = width.saturating_sub(reserved); let marker = if row.selected { "> " } else { " " }; - let label = fixed_width(row.label, label_width); + let label = fixed_width(&row.label, label_width); let value = fixed_width(&settings_value_label(row), value_width); let hint = if hint_width >= 10 { - truncate_text(row.hint, hint_width) + truncate_text(&row.hint, hint_width) } else { String::new() }; @@ -883,6 +1870,42 @@ fn settings_row(row: &SettingsRow, width: u16) -> Line<'static> { Line::from(spans) } +fn settings_todo_row(row: &SettingsRow, width: u16) -> Line<'static> { + let width = width as usize; + let marker = if row.selected { "> " } else { " " }; + let label_width = if width < 66 { 10 } else { 12 }; + let hint_width = if row.selected && width >= 72 { 24 } else { 0 }; + let hint_gap = if hint_width > 0 { 2 } else { 0 }; + let reserved = marker.len() + label_width + 2 + hint_width + hint_gap; + let value_width = width.saturating_sub(reserved); + let row_bg = row.selected.then_some(SETTINGS_ROW_ACTIVE); + let label = fixed_width(&row.label, label_width); + let value = fixed_width(&settings_value_label(row), value_width); + let mut used = marker.len() + label.len() + 2 + value.len(); + let mut spans = vec![ + Span::styled(marker.to_string(), row_style(Color::Yellow, row_bg, false)), + Span::styled(label, row_style(SETTINGS_TEXT, row_bg, row.selected)), + Span::styled(" ", row_style(SETTINGS_TEXT, row_bg, false)), + Span::styled(value, settings_value_style(row)), + ]; + + if hint_width > 0 { + let hint = fixed_width(&row.hint, hint_width); + used += 2 + hint.len(); + spans.push(Span::styled(" ", row_style(SETTINGS_TEXT, row_bg, false))); + spans.push(Span::styled(hint, row_style(SETTINGS_MUTED, row_bg, false))); + } + + if used < width { + spans.push(Span::styled( + " ".repeat(width - used), + row_style(SETTINGS_TEXT, row_bg, false), + )); + } + + Line::from(spans) +} + fn settings_command_bar(width: u16) -> Line<'static> { if width < 50 { return Line::from(vec![ @@ -894,7 +1917,7 @@ fn settings_command_bar(width: u16) -> Line<'static> { ]); } - if width < 58 { + if width < 62 { return Line::from(vec![ Span::raw(" "), command_key("Up/Down"), @@ -914,6 +1937,8 @@ fn settings_command_bar(width: u16) -> Line<'static> { Span::styled(" toggle ", Style::default().fg(Color::Gray)), command_key("Left/Right"), Span::styled(" adjust ", Style::default().fg(Color::Gray)), + command_key("Del"), + Span::styled(" remove ", Style::default().fg(Color::Gray)), command_key("Esc"), Span::styled(" close", Style::default().fg(Color::Gray)), ]) @@ -930,10 +1955,13 @@ fn command_key(label: &'static str) -> Span<'static> { } fn settings_value_label(row: &SettingsRow) -> String { - match row.value.as_str() { - "on" | "off" => format!("[ {} ]", row.value), - _ if row.value_color.is_some() => format!("< {} >", row.value), - _ => format!("- {} +", row.value), + match row.value_kind { + SettingsValueKind::Switch => format!("[ {} ]", row.value), + SettingsValueKind::Choice => format!("< {} >", row.value), + SettingsValueKind::Stepper => format!("- {} +", row.value), + SettingsValueKind::Action => format!("[ {} ]", row.value), + SettingsValueKind::Text if row.value.is_empty() => "(empty)".into(), + SettingsValueKind::Text => row.value.clone(), } } @@ -945,12 +1973,36 @@ fn settings_value_style(row: &SettingsRow) -> Style { .add_modifier(Modifier::BOLD); } - let mut style = match row.value.as_str() { - "on" => Style::default() + let mut style = match row.value_kind { + SettingsValueKind::Switch if row.value == "on" => Style::default() .fg(Color::Black) .bg(SETTINGS_BORDER) .add_modifier(Modifier::BOLD), - "off" => Style::default().fg(SETTINGS_MUTED).bg(SETTINGS_SURFACE), + SettingsValueKind::Switch => Style::default().fg(SETTINGS_MUTED).bg(SETTINGS_SURFACE), + SettingsValueKind::Choice if row.value == "cyan" => Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD), + SettingsValueKind::Choice if row.value == "yellow" => Style::default() + .fg(Color::Black) + .bg(Color::Yellow) + .add_modifier(Modifier::BOLD), + SettingsValueKind::Choice if row.value == "green" => Style::default() + .fg(Color::Black) + .bg(Color::Green) + .add_modifier(Modifier::BOLD), + SettingsValueKind::Choice if row.value == "magenta" => Style::default() + .fg(Color::Black) + .bg(Color::Magenta) + .add_modifier(Modifier::BOLD), + SettingsValueKind::Text if row.editing => Style::default() + .fg(Color::Black) + .bg(Color::Yellow) + .add_modifier(Modifier::BOLD), + SettingsValueKind::Action if row.selected => Style::default() + .fg(Color::Black) + .bg(Color::Yellow) + .add_modifier(Modifier::BOLD), _ if row.selected => Style::default() .fg(Color::Black) .bg(Color::Yellow) @@ -961,7 +2013,7 @@ fn settings_value_style(row: &SettingsRow) -> Style { .add_modifier(Modifier::BOLD), }; - if row.selected && matches!(row.value.as_str(), "off") { + if row.selected && row.value_kind == SettingsValueKind::Switch && row.value == "off" { style = style.fg(Color::White); } @@ -1035,6 +2087,22 @@ fn exited_recovery_modal_rect(area: Rect) -> Rect { } } +fn follow_up_modal_rect(area: Rect) -> Rect { + let width = area.width.saturating_sub(4).min(74).max(area.width.min(1)); + let height = area + .height + .saturating_sub(2) + .min(12) + .max(area.height.min(1)); + + Rect { + x: area.x + area.width.saturating_sub(width) / 2, + y: area.y + area.height.saturating_sub(height) / 2, + width, + height, + } +} + fn settings_shadow_rect(area: Rect, modal: Rect) -> Rect { let offset_x = if modal.x.saturating_add(modal.width).saturating_add(2) <= area.x.saturating_add(area.width) @@ -1078,6 +2146,63 @@ fn truncate_text(text: &str, width: usize) -> String { format!("{}...", text.chars().take(width - 3).collect::()) } +fn wrap_dialog_text(text: &str, width: usize, max_lines: usize) -> Vec { + if width == 0 || max_lines == 0 { + return Vec::new(); + } + + let mut lines = Vec::new(); + let mut current = String::new(); + + for word in text.split_whitespace() { + let next_len = if current.is_empty() { + word.len() + } else { + current.len() + 1 + word.len() + }; + + if next_len <= width { + if !current.is_empty() { + current.push(' '); + } + current.push_str(word); + continue; + } + + if !current.is_empty() { + lines.push(current); + current = String::new(); + } + + if word.len() > width { + lines.push(truncate_text(word, width)); + } else { + current.push_str(word); + } + + if lines.len() == max_lines { + break; + } + } + + if !current.is_empty() && lines.len() < max_lines { + lines.push(current); + } + + if lines.is_empty() { + lines.push("(empty prompt)".into()); + } + if lines.len() == max_lines + && text.len() > lines.join(" ").len() + && let Some(last) = lines.last_mut() + { + *last = truncate_text(last, width.saturating_sub(3)); + last.push_str("..."); + } + + lines +} + fn folder_label(cwd: &Path) -> String { let label = cwd .file_name() @@ -1242,6 +2367,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, @@ -1306,13 +2435,12 @@ mod tests { use super::*; #[test] - fn output_activity_does_not_change_idle_pane_chrome() { + fn idle_pane_has_no_state_badges() { let palette = GridPalette::default(); + let chrome = pane_chrome(false, false, false, false, None, false, &palette); - assert_eq!( - pane_chrome(false, false, false, false, false, false, &palette), - pane_chrome(false, false, true, false, false, false, &palette) - ); + assert_eq!(chrome.badge, ""); + assert_eq!(chrome.quiet_marker, ""); } #[test] @@ -1320,11 +2448,11 @@ mod tests { let palette = GridPalette::default(); assert_eq!( - pane_chrome(true, false, true, false, false, true, &palette).badge, + pane_chrome(true, false, false, false, None, true, &palette).badge, " selected" ); assert_eq!( - pane_chrome(true, false, true, true, false, true, &palette).badge, + pane_chrome(true, false, true, false, None, true, &palette).badge, " exited" ); } @@ -1334,7 +2462,7 @@ mod tests { let palette = GridPalette::default(); assert_eq!( - pane_chrome(false, false, true, false, true, true, &palette).badge, + pane_chrome(false, false, false, true, None, true, &palette).badge, " asleep" ); } @@ -1342,23 +2470,54 @@ mod tests { #[test] fn pane_title_uses_custom_label_in_number_slot() { assert_eq!( - pane_title("api", "", "gridbash/", Some("feat/rename-panes"), None, ""), + pane_title( + "api", + "", + "gridbash/", + Some("feat/rename-panes"), + None, + None, + None, + "" + ), " api | gridbash/ | feat/rename-panes " ); assert_eq!( - pane_title("1", "", "gridbash/", None, None, " selected"), + pane_title("1", "", "gridbash/", None, None, None, None, " selected"), " 1 | gridbash/ selected " ); assert_eq!( - pane_title("2", "", "gridbash/", None, Some("5h 80% left"), " selected"), + pane_title( + "2", + "", + "gridbash/", + None, + None, + None, + Some("5h 80% left"), + " selected" + ), " 2 | gridbash/ | 5h 80% left selected " ); + assert_eq!( + pane_title( + "api", + "", + "gridbash/", + Some("main"), + Some("codex"), + Some("codex-2"), + Some("5h 80% left"), + " selected" + ), + " api | gridbash/ | main | codex | codex-2 | 5h 80% left selected " + ); } #[test] fn pane_title_keeps_quiet_marker_with_custom_label() { assert_eq!( - pane_title("api", QUIET_MARKER, "gridbash/", None, None, ""), + pane_title("api", QUIET_MARKER, "gridbash/", None, None, None, None, ""), " api * | gridbash/ " ); } @@ -1388,11 +2547,32 @@ mod tests { #[test] fn quiet_output_marks_idle_pane_without_active_chrome() { let palette = GridPalette::default(); - let quiet = pane_chrome(false, false, false, false, false, true, &palette); - let active_quiet = pane_chrome(false, false, true, false, false, true, &palette); + let quiet = pane_chrome(false, false, false, false, None, true, &palette); assert_eq!(quiet.quiet_marker, QUIET_MARKER); - assert_eq!(quiet.border_style, Style::default().fg(Color::DarkGray)); - assert_eq!(quiet.border_style, active_quiet.border_style); + assert_eq!(quiet.border_style, Style::default().fg(palette.quiet())); + } + + #[test] + fn grouped_quiet_pane_keeps_group_border_and_marker() { + let palette = GridPalette::default(); + let group_color = (82, 166, 255); + let chrome = pane_chrome( + false, + false, + false, + false, + Some(group_color), + true, + &palette, + ); + + assert_eq!(chrome.quiet_marker, QUIET_MARKER); + assert_eq!( + chrome.border_style, + Style::default() + .fg(rgb_color(group_color)) + .add_modifier(Modifier::BOLD) + ); } } diff --git a/src/usage.rs b/src/usage.rs index 3ce7d01..3ebc7da 100644 --- a/src/usage.rs +++ b/src/usage.rs @@ -11,6 +11,8 @@ use std::{ use serde::Deserialize; +use crate::auth::AgentKind; + const CLAUDE_USAGE_ENDPOINT: &str = "https://api.anthropic.com/api/oauth/usage"; const CODEX_USAGE_ENDPOINT: &str = "https://chatgpt.com/backend-api/codex/usage"; const OPENAI_COSTS_ENDPOINT: &str = "https://api.openai.com/v1/organization/costs"; @@ -20,6 +22,8 @@ const REFRESH_INTERVAL: Duration = Duration::from_secs(60); pub struct UsageTarget { pub profile_name: String, pub command: String, + pub auth_kind: Option, + pub auth_dir: Option, } #[derive(Debug, Clone, PartialEq)] @@ -39,6 +43,15 @@ enum AuthKind { Codex, } +impl From for AuthKind { + fn from(value: AgentKind) -> Self { + match value { + AgentKind::Claude => Self::Claude, + AgentKind::Codex => Self::Codex, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] struct UsageSource { profile_name: String, @@ -100,6 +113,16 @@ fn resolve_usage_sources(targets: &[UsageTarget]) -> Vec { } fn resolve_usage_source(target: &UsageTarget) -> Option { + if let (Some(kind), Some(dir)) = (target.auth_kind, target.auth_dir.as_ref()) + && dir.is_dir() + { + return Some(UsageSource { + profile_name: target.profile_name.clone(), + dir: dir.clone(), + kind: kind.into(), + }); + } + let vibe_dir = profiles_home().join(&target.profile_name); if vibe_dir.is_dir() { let kind = profile_kind(&vibe_dir) @@ -602,4 +625,28 @@ mod tests { Some("5h 30% left / 7d 80% left".into()) ); } + + #[test] + fn resolves_usage_from_applied_auth_dir_first() { + let temp = env::temp_dir().join(format!( + "gridbash-usage-source-test-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time") + .as_nanos() + )); + fs::create_dir(&temp).expect("temp auth dir"); + + let source = resolve_usage_source(&UsageTarget { + profile_name: "codex".into(), + command: "codex".into(), + auth_kind: Some(AgentKind::Codex), + auth_dir: Some(temp.clone()), + }) + .expect("usage source"); + let _ = fs::remove_dir_all(&temp); + + assert_eq!(source.dir, temp); + assert_eq!(source.kind, AuthKind::Codex); + } } diff --git a/src/vibe.rs b/src/vibe.rs index ea3683d..06dd3af 100644 --- a/src/vibe.rs +++ b/src/vibe.rs @@ -17,6 +17,7 @@ pub fn load_profiles() -> Result> { command: "vibe".into(), args: vec!["profiles".into()], title: None, + agent_kind: None, } .resolved_command() .context("vibe is required for GridBash orchestration, but it was not found")?; @@ -46,6 +47,18 @@ 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()), + agent_kind: None, + }) +} + fn parse_profile_line(line: &str) -> Option { let trimmed = line.trim(); if trimmed.is_empty() @@ -105,4 +118,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()); + } }