diff --git a/.gitignore b/.gitignore index 460ebf6..5b1fa12 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,3 @@ Cargo.lock.bak .DS_Store /.idea .pitboss/ -pitboss-contributions.md diff --git a/src/cli/config.rs b/src/cli/config.rs deleted file mode 100644 index f3bbc54..0000000 --- a/src/cli/config.rs +++ /dev/null @@ -1,427 +0,0 @@ -//! `pitboss config` — interactive TUI wizard for `.pitboss/config.toml`. -//! -//! On a fresh workspace, walks the user through every config knob (models, -//! budget, sweeps, auditor, tests) and scaffolds the rest of `.pitboss/`. -//! -//! On an existing workspace, opens straight to a summary of the current -//! settings pre-populated from disk. From there the user can save (no-op) -//! or step back through every screen to edit individual values. -//! -//! Aliased as `pitboss setup` for backwards compatibility — the wizard- -//! execution body is exposed as the crate-internal helper -//! `run_config_wizard` so `pitboss start` can reuse the create flow. - -use std::io::IsTerminal; -use std::path::{Path, PathBuf}; - -use anyhow::{Context, Result}; - -use crate::tui::wizard::{run_wizard, run_wizard_existing, WizardResult}; -use crate::util::write_atomic; - -/// Outcome of [`run_config_wizard`] — lets callers decide what trailing -/// messaging or follow-up commands to issue. -pub(crate) enum ConfigOutcome { - /// User cancelled the wizard (Esc / Ctrl+C). No files were written. - Cancelled, - /// Wizard wrote `.pitboss/config.toml` (and, on a fresh workspace, the - /// rest of the scaffolding). - Completed, -} - -/// Whether `pitboss config` is creating a workspace from scratch or editing -/// an existing one. Callers pass this in explicitly so `pitboss start` can -/// always request the create flow even if `.pitboss/` happens to exist. -pub(crate) enum ConfigMode { - /// No `.pitboss/` present — scaffold directories, run the full wizard - /// starting at Welcome, write all template content files. - Create, - /// `.pitboss/` already exists — pre-populate the wizard from the current - /// `config.toml`, start on the summary screen, only rewrite - /// `config.toml` on save. - Edit, -} - -pub async fn run(workspace: PathBuf) -> Result<()> { - if !std::io::stdin().is_terminal() { - anyhow::bail!( - "pitboss config requires an interactive terminal.\n\ - For non-interactive scaffolding, use `pitboss init`." - ); - } - - let pitboss_dir = workspace.join(".pitboss"); - let mode = if pitboss_dir.is_dir() { - ConfigMode::Edit - } else { - ConfigMode::Create - }; - - match run_config_wizard(&workspace, mode).await? { - ConfigOutcome::Cancelled => { - eprintln!("config cancelled — no changes written"); - } - ConfigOutcome::Completed => { - // Tight pointer at what to do next — the user just configured - // pitboss, the obvious next thing is to write/edit a plan. - println!(" Next: run `pitboss start` to create or edit a plan,"); - println!(" or edit .pitboss/play/plan.md directly."); - println!(); - } - } - - Ok(()) -} - -/// Run the config wizard end-to-end against `workspace` in either mode. -/// -/// - [`ConfigMode::Create`] scaffolds directories, runs the full wizard -/// starting at Welcome, then writes `config.toml`, `plan.md`, and -/// `deferred.md`. Used by `pitboss config` on a fresh repo and by -/// `pitboss start`'s new-user branch. -/// - [`ConfigMode::Edit`] loads the current `config.toml`, opens the wizard -/// on the summary screen pre-populated from it, and rewrites only -/// `config.toml` on save. `plan.md` and `deferred.md` are untouched. -pub(crate) async fn run_config_wizard(workspace: &Path, mode: ConfigMode) -> Result { - match mode { - ConfigMode::Create => { - // Full init scaffolding — directories, state.json, `.gitignore` - // entry. Matches what `pitboss init` produces, minus the template - // content files which the wizard writes itself below. - super::init::scaffold_dirs(workspace)?; - - let result = match run_wizard(workspace).await? { - Some(r) => r, - None => return Ok(ConfigOutcome::Cancelled), - }; - - write_config(workspace, &result)?; - write_plan(workspace, &result)?; - write_deferred(workspace)?; - print_workspace_summary(workspace)?; - Ok(ConfigOutcome::Completed) - } - ConfigMode::Edit => { - let cfg = crate::config::load(workspace) - .with_context(|| format!("config: loading {:?}", workspace))?; - - let result = match run_wizard_existing(workspace, &cfg).await? { - Some(r) => r, - None => return Ok(ConfigOutcome::Cancelled), - }; - - // Rewrite config.toml from the (possibly edited) wizard result. - // Skip plan.md / deferred.md — those are run content, not config. - write_config(workspace, &result)?; - println!(); - println!(" Updated .pitboss/config.toml"); - println!(); - - // Re-run prereq check against the freshly-saved config in case - // the user changed agent backend or other PATH-affecting bits. - let cfg = crate::config::load(workspace) - .with_context(|| format!("config: reloading {:?}", workspace))?; - let prereqs = crate::cli::start::check_prereqs(workspace, &cfg); - crate::cli::start::print_prereqs(&prereqs); - - Ok(ConfigOutcome::Completed) - } - } -} - -/// "Workspace ready" panel printed after the create flow writes its files. -/// Mirrors what `pitboss init` produces so the user sees the full picture. -fn print_workspace_summary(workspace: &Path) -> Result<()> { - println!(); - println!(" Workspace ready."); - println!(" .pitboss/config.toml"); - println!(" .pitboss/play/plan.md"); - println!(" .pitboss/play/deferred.md"); - println!(" .pitboss/play/state.json"); - println!(" .pitboss/play/{{snapshots,logs}}/"); - println!(" .pitboss/grind/{{prompts,rotations,runs}}/"); - println!(" .gitignore (added `.pitboss/`)"); - println!(); - - let cfg = crate::config::load(workspace) - .with_context(|| format!("config: loading {:?}", workspace))?; - let prereqs = crate::cli::start::check_prereqs(workspace, &cfg); - crate::cli::start::print_prereqs(&prereqs); - Ok(()) -} - -/// Build the full `.pitboss/config.toml` body. Emits every config section -/// the runner reads, filling in pitboss's documented defaults for any value -/// the wizard didn't collect. This way the file is self-documenting: a user -/// can read it to see what every knob does and what value it currently -/// holds, without having to remember which defaults are baked into the -/// loader. -/// -/// Concretely: when the user doesn't override `trigger_max_items` (the -/// wizard only asks for the min threshold), we still write -/// `trigger_max_items = N` to the file so the `min <= max` invariant -/// becomes visible — that's the bug that bit us when the wizard previously -/// wrote `trigger_min_items = 10` and let the loader supply the -/// invisible-default `8`. -fn write_config(workspace: &Path, result: &WizardResult) -> Result<()> { - let planner = result.model_preset.planner(); - let worker = result.model_preset.worker(); - - let audit_enabled = result.audit_enabled; - let sweep_enabled = result.sweep_enabled; - let sweep_min = result.sweep_threshold.unwrap_or(5).max(1); - // Pitboss requires `trigger_min_items <= trigger_max_items` (default - // max = 8). The wizard only collects min, so when the user picks a - // threshold above 8 we lift max alongside it with a small buffer so - // the agent still has headroom to drain a few extra items per sweep. - let sweep_max = sweep_min.saturating_add(3).max(8); - - let caveman_intensity = "full"; // pitboss default; wizard doesn't ask - let caveman_enabled = false; - - let mut config = format!( - "# pitboss configuration — every section / field with its current\n\ - # value. Defaults shown explicitly so you can edit any knob without\n\ - # having to remember which value pitboss assumes for missing keys.\n\ - # See README for the full meaning of each setting.\n\ - \n\ - # ── Models ──────────────────────────────────────────────────────────\n\ - # Per-role model IDs. Strings pass verbatim to the agent CLI, so they\n\ - # must be valid model identifiers.\n\ - [models]\n\ - planner = \"{planner}\"\n\ - implementer = \"{worker}\"\n\ - auditor = \"{worker}\"\n\ - fixer = \"{worker}\"\n\ - \n\ - # ── Retries ─────────────────────────────────────────────────────────\n\ - # Bounded — pitboss never loops forever. Once a budget is exhausted\n\ - # the runner halts with a clear reason.\n\ - [retries]\n\ - fixer_max_attempts = 2 # 0 disables the fixer entirely\n\ - max_phase_attempts = 3 # all roles combined per phase\n\ - \n\ - # ── Auditor ─────────────────────────────────────────────────────────\n\ - # The auditor reviews each phase's diff before commit. Small findings\n\ - # are inlined; larger ones go to deferred.md for the next sweep.\n\ - [audit]\n\ - enabled = {audit_enabled}\n\ - small_fix_line_limit = 30 # diff-line threshold: inline vs. defer\n\ - \n\ - # ── Sweeps ──────────────────────────────────────────────────────────\n\ - # Sweeps drain deferred items between regular phases. Triggered when\n\ - # the unchecked-item count crosses trigger_min_items. trigger_max_items\n\ - # is the soft cap surfaced to the sweep agent's prompt. The runner\n\ - # enforces trigger_min_items <= trigger_max_items.\n\ - [sweep]\n\ - enabled = {sweep_enabled}\n\ - trigger_min_items = {sweep_min}\n\ - trigger_max_items = {sweep_max}\n\ - max_consecutive = 1 # back-to-back sweeps before a real phase\n\ - escalate_after = 3 # sweep attempts before staleness flag\n\ - audit_enabled = true # run auditor after each sweep too\n\ - final_sweep_enabled = true # drain loop after the final phase\n\ - final_sweep_max_iterations = 3 # cap on the drain loop\n\ - \n\ - # ── Git ─────────────────────────────────────────────────────────────\n\ - # The per-run branch is ``.\n\ - [git]\n\ - branch_prefix = \"pitboss/play/\"\n\ - create_pr = false # `true` opens a PR via `gh` after the final phase\n\ - \n\ - # ── Caveman mode ────────────────────────────────────────────────────\n\ - # Prepends a `talk-like-caveman` directive to every agent system\n\ - # prompt to cut output tokens. Intensity: lite | full | ultra.\n\ - [caveman]\n\ - enabled = {caveman_enabled}\n\ - intensity = \"{caveman_intensity}\"\n\ - \n\ - # ── Grind ───────────────────────────────────────────────────────────\n\ - # Defaults for `pitboss grind` (rotating prompt runner).\n\ - [grind]\n\ - max_parallel = 1\n\ - consecutive_failure_limit = 3\n\ - hook_timeout_secs = 60\n" - ); - - // Tests section: always present so the user can see what command will be - // used. Blank `command` = pitboss auto-detects from project layout. - config.push_str( - "\n\ - # ── Tests ───────────────────────────────────────────────────────────\n\ - # Pitboss runs this after every phase. Comment out / delete `command`\n\ - # to fall back to autodetection (cargo / npm / pytest / go test).\n\ - [tests]\n", - ); - match &result.test_command_override { - Some(cmd) => { - let escaped = cmd.replace('\\', "\\\\").replace('"', "\\\""); - config.push_str(&format!("command = \"{escaped}\"\n")); - } - None => { - config.push_str( - "# command = \"cargo test --workspace\" # autodetected when commented\n", - ); - } - } - - // Budgets: always present, with caps written when set and commented - // when not. The pricing tables match pitboss's built-in defaults so - // anyone can see / edit per-model rates. - config.push_str( - "\n\ - # ── Budgets ─────────────────────────────────────────────────────────\n\ - # Setting either cap activates budget enforcement: the runner halts\n\ - # before the next dispatch that would exceed it.\n\ - [budgets]\n", - ); - match result.max_total_usd { - Some(usd) => config.push_str(&format!("max_total_usd = {usd:.2}\n")), - None => config.push_str("# max_total_usd = 5.00 # uncomment to enforce a USD cap\n"), - } - match result.max_run_tokens { - Some(tokens) => config.push_str(&format!("max_total_tokens = {tokens}\n")), - None => { - config.push_str("# max_total_tokens = 1_000_000 # uncomment to enforce a token cap\n") - } - } - config.push_str( - "\n\ - # Per-model price points (USD per million tokens). Override or add\n\ - # entries to teach pitboss about new / non-default models.\n\ - [budgets.pricing.claude-opus-4-7]\n\ - input_per_million_usd = 15.0\n\ - output_per_million_usd = 75.0\n\ - \n\ - [budgets.pricing.claude-sonnet-4-6]\n\ - input_per_million_usd = 3.0\n\ - output_per_million_usd = 15.0\n\ - \n\ - [budgets.pricing.claude-haiku-4-5]\n\ - input_per_million_usd = 1.0\n\ - output_per_million_usd = 5.0\n", - ); - - let path = workspace.join(".pitboss/config.toml"); - write_atomic(&path, config.as_bytes()).with_context(|| format!("config: writing {:?}", path)) -} - -fn write_plan(workspace: &Path, _result: &WizardResult) -> Result<()> { - // The wizard no longer collects a goal — write the canonical init seed - // so the workspace has a parseable plan.md ready for the user to fill - // in (or for `pitboss start` → New plan to overwrite). - let path = workspace.join(".pitboss/play/plan.md"); - write_atomic(&path, super::init::PLAN_TEMPLATE.as_bytes()) - .with_context(|| format!("config: writing {:?}", path)) -} - -fn write_deferred(workspace: &Path) -> Result<()> { - let path = workspace.join(".pitboss/play/deferred.md"); - if path.exists() { - return Ok(()); - } - write_atomic(&path, b"## Deferred items\n\n## Deferred phases\n") - .with_context(|| format!("config: writing {:?}", path)) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::tui::wizard::ModelPreset; - use tempfile::tempdir; - - fn default_result() -> WizardResult { - // Every field at its "user accepted the default" value, matching - // what WizState::new produces before any input. - WizardResult { - model_preset: ModelPreset::Quality, - max_run_tokens: None, - max_total_usd: None, - sweep_enabled: true, - sweep_threshold: None, - audit_enabled: true, - test_command_override: None, - } - } - - /// The emitted config.toml must round-trip through `config::load` so the - /// wizard never produces a file pitboss refuses to read. - #[test] - fn write_config_defaults_load_cleanly() { - let dir = tempdir().unwrap(); - let ws = dir.path(); - std::fs::create_dir_all(ws.join(".pitboss")).unwrap(); - write_config(ws, &default_result()).expect("write_config"); - let cfg = crate::config::load(ws).expect("load round-trips"); - assert_eq!(cfg.sweep.trigger_min_items, 5); - assert_eq!(cfg.sweep.trigger_max_items, 8); - assert!(cfg.sweep.trigger_min_items <= cfg.sweep.trigger_max_items); - assert_eq!(cfg.models.planner, "claude-opus-4-7"); - assert!(cfg.audit.enabled); - assert!(cfg.sweep.enabled); - assert_eq!(cfg.git.branch_prefix, "pitboss/play/"); - } - - /// Regression for the bug that triggered this rewrite: a high sweep - /// threshold (10) must auto-bump `trigger_max_items` so the loader's - /// `min <= max` validator doesn't bail. - #[test] - fn write_config_high_sweep_threshold_passes_validation() { - let dir = tempdir().unwrap(); - let ws = dir.path(); - std::fs::create_dir_all(ws.join(".pitboss")).unwrap(); - let mut r = default_result(); - r.sweep_threshold = Some(10); - write_config(ws, &r).expect("write_config"); - let cfg = crate::config::load(ws).expect("load round-trips"); - assert_eq!(cfg.sweep.trigger_min_items, 10); - assert!( - cfg.sweep.trigger_max_items >= cfg.sweep.trigger_min_items, - "max ({}) must be >= min ({})", - cfg.sweep.trigger_max_items, - cfg.sweep.trigger_min_items - ); - } - - #[test] - fn write_config_zero_sweep_threshold_clamps_to_one() { - let dir = tempdir().unwrap(); - let ws = dir.path(); - std::fs::create_dir_all(ws.join(".pitboss")).unwrap(); - let mut r = default_result(); - r.sweep_threshold = Some(0); - write_config(ws, &r).expect("write_config"); - let cfg = crate::config::load(ws).expect("load round-trips"); - assert_eq!(cfg.sweep.trigger_min_items, 1); - assert!(cfg.sweep.trigger_max_items >= cfg.sweep.trigger_min_items); - } - - /// User-supplied budget caps must reach the config; the missing-cap path - /// must still be loadable (commented placeholders, not invalid TOML). - #[test] - fn write_config_with_budget_caps_load_cleanly() { - let dir = tempdir().unwrap(); - let ws = dir.path(); - std::fs::create_dir_all(ws.join(".pitboss")).unwrap(); - let mut r = default_result(); - r.max_total_usd = Some(12.50); - r.max_run_tokens = Some(1_000_000); - write_config(ws, &r).expect("write_config"); - let cfg = crate::config::load(ws).expect("load round-trips"); - assert_eq!(cfg.budgets.max_total_usd, Some(12.50)); - assert_eq!(cfg.budgets.max_total_tokens, Some(1_000_000)); - } - - /// Setting a custom test command must surface in the loaded config. - #[test] - fn write_config_with_test_command_override_round_trips() { - let dir = tempdir().unwrap(); - let ws = dir.path(); - std::fs::create_dir_all(ws.join(".pitboss")).unwrap(); - let mut r = default_result(); - r.test_command_override = Some("pnpm test --run".to_string()); - write_config(ws, &r).expect("write_config"); - let cfg = crate::config::load(ws).expect("load round-trips"); - assert_eq!(cfg.tests.command.as_deref(), Some("pnpm test --run")); - } -} diff --git a/src/cli/init.rs b/src/cli/init.rs index 1a92dde..bc2eab0 100644 --- a/src/cli/init.rs +++ b/src/cli/init.rs @@ -175,32 +175,6 @@ pub fn run(workspace: impl AsRef) -> Result<()> { Ok(()) } -/// Create the directory structure, state file, and `.gitignore` entry without -/// writing any template content files. Used by `pitboss setup`, which -/// provides its own `plan.md` and `config.toml` via the wizard. -pub(crate) fn scaffold_dirs(workspace: &Path) -> Result<()> { - fs::create_dir_all(workspace) - .with_context(|| format!("init: creating workspace {:?}", workspace))?; - - let pitboss_root = workspace.join(".pitboss"); - if pitboss_root.exists() && !pitboss_root.is_dir() { - anyhow::bail!( - "init: {:?} exists but is not a directory; refusing to overwrite", - pitboss_root - ); - } - - let mut report = Vec::new(); - ensure_dir(workspace, ".pitboss/play/snapshots", &mut report)?; - ensure_dir(workspace, ".pitboss/play/logs", &mut report)?; - ensure_dir(workspace, ".pitboss/grind/prompts", &mut report)?; - ensure_dir(workspace, ".pitboss/grind/rotations", &mut report)?; - ensure_dir(workspace, ".pitboss/grind/runs", &mut report)?; - init_state_file(workspace, &mut report)?; - update_gitignore(workspace, &mut report)?; - Ok(()) -} - fn write_if_missing( workspace: &Path, rel: &str, diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 6d26207..139e996 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -14,18 +14,15 @@ use anyhow::Result; use clap::{Parser, Subcommand}; -pub mod config; pub mod exit_code; pub mod fold; pub mod grind; pub mod init; pub mod interview; -pub mod nuke; pub mod plan; pub mod play; pub mod prompts; pub mod rebuy; -pub mod start; pub mod status; pub mod sweep; @@ -69,18 +66,12 @@ impl Cli { match &self.command { Command::Play { tui, .. } | Command::Rebuy { tui, .. } => *tui, Command::Grind(args) => args.tui, - // Config and Start both run a full-screen wizard in alternate - // mode — suppress the fmt layer so tracing output doesn't corrupt - // the terminal buffer. (Start may also chain into `play --tui`, - // which keeps the same alternate-screen mode active.) - Command::Config { .. } | Command::Start => true, Command::Init | Command::Plan { .. } | Command::Status | Command::Fold { .. } | Command::Sweep(_) - | Command::Prompts(_) - | Command::Nuke => false, + | Command::Prompts(_) => false, } } } @@ -89,28 +80,6 @@ impl Cli { pub enum Command { /// Scaffold a new pitboss workspace in the current directory. Init, - /// Interactive TUI wizard for `.pitboss/config.toml`. On a fresh - /// workspace, walks the user through every config knob (models, budget, - /// sweeps, auditor, tests). On an existing workspace, opens straight to - /// a summary of current settings with an option to edit. Aliased as - /// `setup` for backwards compatibility. - #[command(alias = "setup")] - Config { - /// Accepted for backwards compatibility with `pitboss setup --force`. - /// No-op: `pitboss config` always allows re-editing an existing - /// workspace. - #[arg(long, hide = true)] - force: bool, - }, - /// Universal entry point. Auto-detects whether `.pitboss/` exists: - /// without it, runs the setup wizard and prints next steps; with it, - /// opens the iteration wizard (current budget, deferred items, and - /// completed phases) and offers continue / sweep / new-plan paths. - Start, - /// Completely remove pitboss from the workspace. Deletes the - /// `.pitboss/` directory (config, plan, deferred items, state, all - /// logs) after a `y/N` confirmation. Cannot be undone. - Nuke, /// Generate a `plan.md` for a goal using the planner agent. Plan { /// Free-form description of what to build. @@ -219,18 +188,6 @@ pub async fn dispatch(cli: Cli) -> Result { init::run(std::env::current_dir()?)?; Ok(ExitCode::Success) } - Command::Config { force: _ } => { - config::run(std::env::current_dir()?).await?; - Ok(ExitCode::Success) - } - Command::Start => { - start::run(std::env::current_dir()?).await?; - Ok(ExitCode::Success) - } - Command::Nuke => { - nuke::run(std::env::current_dir()?).await?; - Ok(ExitCode::Success) - } Command::Plan { goal, force, diff --git a/src/cli/nuke.rs b/src/cli/nuke.rs deleted file mode 100644 index 16dbd3d..0000000 --- a/src/cli/nuke.rs +++ /dev/null @@ -1,141 +0,0 @@ -//! `pitboss nuke` — completely remove pitboss from the workspace. -//! -//! Deletes the `.pitboss/` directory (config, plan, deferred items, state, -//! per-run logs, grind state — everything) after an explicit `y/N` prompt. -//! Refuses to run when there's nothing to delete. Leaves the `.pitboss/` -//! entry in `.gitignore` alone — adding it again on a future `pitboss -//! config` would be a no-op anyway, and the entry shouldn't matter to -//! anything else. - -use std::fs; -use std::io::Write as _; -use std::path::PathBuf; - -use anyhow::{Context, Result}; - -pub async fn run(workspace: PathBuf) -> Result<()> { - let pitboss_dir = workspace.join(".pitboss"); - - if !pitboss_dir.is_dir() { - anyhow::bail!( - "nothing to nuke: {:?} has no `.pitboss/` directory.", - workspace - ); - } - - // Show what's about to die so the user can sanity-check before - // confirming. Lists the workspace path + the top-level contents that - // are about to be removed. - println!(); - println!(" About to delete:"); - println!(" {}", pitboss_dir.display()); - if let Ok(entries) = fs::read_dir(&pitboss_dir) { - for entry in entries.flatten() { - let name = entry.file_name().to_string_lossy().into_owned(); - let suffix = if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { - "/" - } else { - "" - }; - println!(" .pitboss/{}{}", name, suffix); - } - } - println!(); - println!(" This removes all config, plan content, deferred items, run state,"); - println!(" agent logs, and grind state. The action cannot be undone."); - println!(); - - if !confirm()? { - eprintln!("nuke cancelled — nothing removed"); - return Ok(()); - } - - fs::remove_dir_all(&pitboss_dir) - .with_context(|| format!("nuke: removing {:?}", pitboss_dir))?; - - println!(); - println!(" Pitboss deleted from project."); - println!(" Run `pitboss config` to add it again."); - println!(); - Ok(()) -} - -/// Prompt the user with `Are you sure you want to delete? (y/N): ` and -/// return `true` only when the answer starts with `y`/`Y`. Empty answer or -/// any other input is treated as no. Non-interactive stdin (piped input) -/// bails — there's no safe default for a destructive action without an -/// explicit human. -fn confirm() -> Result { - if !is_interactive() { - anyhow::bail!("pitboss nuke requires an interactive terminal to confirm the deletion."); - } - - print!(" Are you sure you want to delete? (y/N): "); - std::io::stdout().flush().ok(); - - let mut line = String::new(); - std::io::stdin() - .read_line(&mut line) - .context("nuke: reading confirmation")?; - Ok(is_yes_answer(&line)) -} - -fn is_yes_answer(answer: &str) -> bool { - answer.trim().to_ascii_lowercase().starts_with('y') -} - -#[cfg(not(test))] -fn is_interactive() -> bool { - use std::io::IsTerminal; - std::io::stdin().is_terminal() -} - -// In `cargo test`, stdin is still wired to the real TTY, so the production -// `is_terminal()` check returns true and `read_line` blocks forever. Force -// non-interactive under cfg(test) so the bail path is exercisable. -#[cfg(test)] -fn is_interactive() -> bool { - false -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::tempdir; - - #[tokio::test] - async fn bails_when_no_pitboss_dir() { - let dir = tempdir().unwrap(); - let err = run(dir.path().to_path_buf()).await.unwrap_err(); - assert!( - err.to_string().contains("nothing to nuke"), - "expected 'nothing to nuke' message, got: {err}" - ); - } - - #[tokio::test] - async fn refuses_non_interactive_when_pitboss_exists() { - let dir = tempdir().unwrap(); - std::fs::create_dir_all(dir.path().join(".pitboss/play")).unwrap(); - // No TTY in tests — the confirm step must refuse rather than nuke - // by accident. - let err = run(dir.path().to_path_buf()).await.unwrap_err(); - assert!( - err.to_string().contains("interactive terminal"), - "expected interactive-terminal bail, got: {err}" - ); - // Critical: .pitboss/ must still exist after the bail. - assert!(dir.path().join(".pitboss").is_dir()); - } - - #[test] - fn accepts_any_yes_prefix() { - assert!(is_yes_answer("y")); - assert!(is_yes_answer("yes")); - assert!(is_yes_answer("YEP")); - assert!(is_yes_answer(" yolo ")); - assert!(!is_yes_answer("")); - assert!(!is_yes_answer("n")); - assert!(!is_yes_answer("nope")); - } -} diff --git a/src/cli/plan.rs b/src/cli/plan.rs index 221ad2f..be7da22 100644 --- a/src/cli/plan.rs +++ b/src/cli/plan.rs @@ -380,7 +380,7 @@ const MANIFEST_FILES: &[&str] = &[ /// READMEs we surface verbatim (truncated). Lowercase variants are tried too. const README_FILES: &[&str] = &["README.md", "README", "README.txt", "README.rst"]; -pub(crate) fn collect_repo_summary(workspace: &Path) -> Result { +fn collect_repo_summary(workspace: &Path) -> Result { let mut sections: Vec = Vec::new(); sections.push(format!( "Top-level entries:\n{}", diff --git a/src/cli/start.rs b/src/cli/start.rs deleted file mode 100644 index be3e82d..0000000 --- a/src/cli/start.rs +++ /dev/null @@ -1,572 +0,0 @@ -//! `pitboss start` — universal entry point. -//! -//! Auto-detects whether `.pitboss/` exists in the current directory: -//! -//! - **Missing:** runs the new-user setup wizard (shared with `pitboss setup`) -//! and, if the user opted into AI plan generation, chains into -//! `pitboss play --tui`. End-to-end install → configure → plan → play in -//! one command. -//! - **Present:** runs the iteration wizard ([`crate::tui::iteration`]) which -//! shows a snapshot of the current run (budget used, phase progress, -//! deferred items) and offers continue / sweep / new-plan paths, each with -//! an optional reset-budget toggle. -//! -//! This command is purely additive: it composes existing pitboss commands -//! (`setup`, `plan`, `play`, `rebuy`, `sweep`). It does not change their -//! behavior or schemas. - -use std::fs; -use std::io::IsTerminal; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::time::Duration; - -use anyhow::{anyhow, bail, Context, Result}; -use chrono::Utc; -use tokio::sync::mpsc; -use tokio_util::sync::CancellationToken; -use tracing::debug; - -use crate::agent::{self, Agent, AgentEvent, AgentRequest, Role, StopReason}; -use crate::config::{self, Config}; -use crate::deferred; -use crate::plan::{self, Plan}; -use crate::prompts; -use crate::runner; -use crate::state::{self, TokenUsage}; -use crate::tui::iteration::{run_iteration_wizard, IterationOutcome, WorkspaceSnapshot}; -use crate::util::{paths, write_atomic}; - -use super::config::{run_config_wizard, ConfigMode, ConfigOutcome}; -use super::init::PLAN_TEMPLATE; -use super::sweep::SweepArgs; - -/// Wall-clock cap for an in-wizard questioner dispatch. Mirrors -/// `interview::QUESTIONER_TIMEOUT` — short text-only task. -const QUESTIONER_TIMEOUT: Duration = Duration::from_secs(5 * 60); - -/// Wall-clock cap for an in-wizard planner dispatch. Mirrors -/// `plan::PLANNER_TIMEOUT`. -const PLANNER_TIMEOUT: Duration = Duration::from_secs(30 * 60); - -pub async fn run(workspace: PathBuf) -> Result<()> { - if !std::io::stdin().is_terminal() { - anyhow::bail!( - "pitboss start requires an interactive terminal.\n\ - For non-interactive scaffolding, use `pitboss init` followed by `pitboss plan` and `pitboss play`." - ); - } - - let pitboss_dir = workspace.join(".pitboss"); - if pitboss_dir.is_dir() { - run_existing_user_flow(workspace).await - } else { - run_new_user_flow(workspace).await - } -} - -/// New-user branch: scaffold the workspace via the shared setup wizard. -/// The user always writes (or edits) `plan.md` themselves after setup — -/// `pitboss start` doesn't dispatch the planner agent here. To AI-generate a -/// plan they can re-run `pitboss start` once the workspace exists; the -/// iteration wizard's "New plan" path runs the planner (with an optional -/// design interview). -async fn run_new_user_flow(workspace: PathBuf) -> Result<()> { - match run_config_wizard(&workspace, ConfigMode::Create).await? { - ConfigOutcome::Cancelled => { - eprintln!("start cancelled — no files written"); - } - ConfigOutcome::Completed => { - println!(" Next steps:"); - println!(" 1. Edit .pitboss/play/plan.md to describe the phases."); - println!(" 2. Run `pitboss start` again — the iteration wizard"); - println!(" can also generate plan.md from a goal via the"); - println!(" planner agent (New plan → enable interview)."); - println!(); - } - } - Ok(()) -} - -/// Existing-user branch: build a workspace snapshot, open the iteration -/// wizard, dispatch the chosen outcome. -async fn run_existing_user_flow(workspace: PathBuf) -> Result<()> { - let snapshot = load_snapshot(&workspace)?; - let had_state = !snapshot.no_state; - - // The wizard's "new plan" path needs an agent to call the questioner + - // planner inside the TUI. We build it once and hand it in as an `Arc` - // so it can be cheaply cloned into spawned tasks. - let cfg = config::load(&workspace) - .with_context(|| format!("start: loading config in {:?}", workspace))?; - let agent: Arc = Arc::from(agent::build_agent(&cfg)?); - - let outcome = match run_iteration_wizard(&snapshot, &workspace, &cfg, agent.clone()).await? { - Some(o) => o, - None => { - eprintln!("start cancelled — no changes made"); - return Ok(()); - } - }; - - dispatch_outcome(workspace, outcome, had_state, &cfg).await -} - -/// Read config, state, plan, and deferred from `workspace` and project them -/// into the flat [`WorkspaceSnapshot`] the iteration wizard renders. -/// -/// Missing or malformed `plan.md` / `state.json` is handled gracefully — the -/// snapshot exposes `no_plan` / `no_state` flags so the wizard can adjust the -/// available actions without bailing. -fn load_snapshot(workspace: &Path) -> Result { - let config = config::load(workspace) - .with_context(|| format!("start: loading config in {:?}", workspace))?; - let state = state::load(workspace) - .with_context(|| format!("start: loading state in {:?}", workspace))?; - - // Plan: tolerate missing or malformed files so the wizard can offer - // "New plan" as a recovery path. - let plan_path = paths::plan_path(workspace); - let plan = match fs::read_to_string(&plan_path) { - Ok(text) => plan::parse(&text).ok(), - Err(_) => None, - }; - - // Deferred: empty file or missing file → zero pending items. - let deferred_path = paths::deferred_path(workspace); - let deferred_count = match fs::read_to_string(&deferred_path) { - Ok(text) => deferred::parse(&text) - .map(|doc| doc.items.iter().filter(|i| !i.done).count()) - .unwrap_or(0), - Err(_) => 0, - }; - - let (tokens_used, usd_used) = state - .as_ref() - .map(|s| runner::budget_totals(&config, &s.token_usage)) - .unwrap_or((0, 0.0)); - - let (total_phases, current_phase) = match &plan { - Some(p) => (p.phases.len(), Some(p.current_phase.clone())), - None => (0, None), - }; - - Ok(WorkspaceSnapshot { - branch: state.as_ref().map(|s| s.branch.clone()), - current_phase, - completed_count: state.as_ref().map(|s| s.completed.len()).unwrap_or(0), - total_phases, - tokens_used, - tokens_cap: config.budgets.max_total_tokens, - usd_used, - usd_cap: config.budgets.max_total_usd, - deferred_count, - planner_model: config.models.planner.clone(), - implementer_model: config.models.implementer.clone(), - no_state: state.is_none(), - no_plan: plan.is_none(), - }) -} - -/// Run the outcome the user picked in the iteration wizard. For Continue and -/// Sweep we delegate to existing commands; for the NewPlan* variants, the -/// wizard has already generated `plan.md` (and possibly archived it), so we -/// just launch play, print a summary, or print a recovery hint. -async fn dispatch_outcome( - workspace: PathBuf, - outcome: IterationOutcome, - had_state: bool, - cfg: &Config, -) -> Result<()> { - match outcome { - IterationOutcome::Continue { reset_budget } => { - if reset_budget { - reset_budget_in_state(&workspace)?; - } - if had_state { - super::rebuy::run(workspace, true, false, false, false, false).await - } else { - super::play::run(workspace, true, false, false, false, false).await - } - } - IterationOutcome::Sweep { reset_budget } => { - if reset_budget { - reset_budget_in_state(&workspace)?; - } - super::sweep::run(workspace, default_sweep_args()) - .await - .map(|_| ()) - } - IterationOutcome::NewPlanLaunchPlay { plan: _ } => { - // Wizard archived state.json and wrote a fresh plan.md already. - // Note: state archive happens here on launch so resuming a fresh - // plan starts with zero accumulated usage. - archive_state(&workspace)?; - super::play::run(workspace, true, false, false, false, false).await - } - IterationOutcome::NewPlanWait { plan } => { - // Plan is on disk; user wants to start later. Print a tight - // summary so they know exactly what to do next. - archive_state(&workspace)?; - print_wait_summary(&workspace, &plan, cfg); - Ok(()) - } - IterationOutcome::NewPlanTerminated => { - // The wizard already archived plan.md and restored the seed. - // Tell the user the workspace is back to a clean state and what - // to do if they change their mind. - println!(); - println!(" No plan instilled — workspace is back to the init seed."); - println!(" Run `pitboss start` again when you're ready to try a new plan."); - println!(); - Ok(()) - } - } -} - -/// Tight summary printed to plain stdout (after the alternate screen has -/// torn down) when the user picks "wait until later" on the post-plan -/// review screen. Tells them: -/// - that the plan is saved -/// - how many phases it contains (0/N completed) -/// - their current budget headroom -/// - how to launch it later -fn print_wait_summary(workspace: &Path, plan: &Plan, cfg: &Config) { - let total = plan.phases.len(); - let usd_cap = cfg - .budgets - .max_total_usd - .map(|c| format!("${:.2} cap", c)) - .unwrap_or_else(|| "no cap".to_string()); - let token_cap = cfg - .budgets - .max_total_tokens - .map(|c| format!("{} token cap", c)) - .unwrap_or_else(|| "no token cap".to_string()); - - println!(); - println!(" Plan saved to {}", paths::plan_path(workspace).display()); - println!( - " {} phase{} ready • 0 / {} completed", - total, - if total == 1 { "" } else { "s" }, - total - ); - println!(" Budget: {} • {}", usd_cap, token_cap); - println!(); - println!(" When you're ready:"); - println!(" pitboss start # opens the wizard again"); - println!(" pitboss play --tui # launches the plan directly"); - println!(); -} - -/// Zero out `state.token_usage` in place. Used by the wizard's -/// "reset budget" toggle so a run that hit its cap can resume without -/// hand-editing state.json. No-op when no state file exists. -fn reset_budget_in_state(workspace: &Path) -> Result<()> { - let mut state = match state::load(workspace)? { - Some(s) => s, - None => { - debug!("reset_budget requested but state.json is missing — no-op"); - return Ok(()); - } - }; - state.token_usage = TokenUsage::default(); - state::save(workspace, Some(&state)) - .with_context(|| format!("start: saving reset state in {:?}", workspace))?; - println!(" Budget reset — token usage zeroed."); - Ok(()) -} - -/// Move `state.json` to `state..json.bak` and clear the live -/// file. Used by the "new plan" path so the next `play --tui` starts fresh -/// while the prior run is preserved for diagnostics. -fn archive_state(workspace: &Path) -> Result<()> { - let live = paths::state_path(workspace); - if !live.is_file() { - // Nothing to archive; just ensure the file is absent. - return state::save(workspace, None); - } - let stamp = Utc::now().format("%Y%m%dT%H%M%SZ"); - let backup = paths::play_dir(workspace).join(format!("state.{}.json.bak", stamp)); - fs::rename(&live, &backup) - .with_context(|| format!("start: archiving state.json to {:?}", backup))?; - println!(" Archived prior state to {}", backup.display()); - Ok(()) -} - -// ── Prerequisite checks ───────────────────────────────────────────────────── -// -// Run after the setup wizard writes files so the user immediately sees -// whether their workspace is actually ready for `pitboss play`. We only -// report — `pitboss play` enforces these at run time on its own. - -/// Result of [`check_prereqs`]. -pub(crate) struct Prereqs { - /// `.git/` exists in the workspace. Pitboss play creates a per-run branch - /// and refuses to operate without one (see README → Troubleshooting). - pub git_repo: bool, - /// CLI binary the configured backend wraps (e.g. "claude" for - /// `claude_code`). - pub agent_cli_name: &'static str, - /// `agent_cli_name` was found on `$PATH`. - pub agent_cli_found: bool, -} - -/// Probe the workspace for the things `pitboss play` needs at run time: -/// a git repo and the agent CLI that matches `[agent] backend`. Both -/// failures are warnings, never errors — the user can `git init` or -/// install the CLI later and the workspace remains valid. -pub(crate) fn check_prereqs(workspace: &Path, cfg: &Config) -> Prereqs { - let git_repo = workspace.join(".git").exists(); - - let cli_name: &'static str = match cfg.agent.backend.as_deref() { - Some("codex") => "codex", - Some("aider") => "aider", - Some("gemini") => "gemini", - // Default and explicit "claude_code" → the Claude Code CLI is - // installed as `claude`. - _ => "claude", - }; - let agent_cli_found = binary_on_path(cli_name); - - Prereqs { - git_repo, - agent_cli_name: cli_name, - agent_cli_found, - } -} - -/// Return `true` if `name` resolves to a file in any directory on `$PATH`. -/// Cross-platform safe (also checks `.exe` on Windows-style PATH). -fn binary_on_path(name: &str) -> bool { - let Some(paths) = std::env::var_os("PATH") else { - return false; - }; - std::env::split_paths(&paths).any(|dir| { - let direct = dir.join(name); - if direct.is_file() { - return true; - } - if cfg!(windows) { - let exe = dir.join(format!("{name}.exe")); - if exe.is_file() { - return true; - } - } - false - }) -} - -/// Print the prereq panel after the wizard's "Workspace ready" summary so -/// users immediately know whether `pitboss play` will work. -pub(crate) fn print_prereqs(p: &Prereqs) { - println!(" Prerequisites:"); - if p.git_repo { - println!(" ✓ Git repository detected"); - } else { - println!(" ⚠ Not a git repository"); - println!( - " Run `git init` before `pitboss play` — pitboss creates a per-run branch." - ); - } - if p.agent_cli_found { - println!(" ✓ Agent CLI `{}` found on PATH", p.agent_cli_name); - } else { - println!(" ⚠ Agent CLI `{}` not found on PATH", p.agent_cli_name); - println!( - " Install it before running pitboss play. See README → Runtime dependencies." - ); - } - println!(); -} - -/// Default `SweepArgs` matching `pitboss sweep` with no flags. -fn default_sweep_args() -> SweepArgs { - SweepArgs { - max_items: None, - audit: false, - no_audit: false, - dry_run: false, - after: None, - } -} - -// ── In-wizard agent dispatch helpers ───────────────────────────────────────── -// -// These mirror `interview::dispatch_questioner` and `plan::dispatch_planner` -// but collect the agent's stdout silently via the event channel so they don't -// write into the alternate-screen TUI. The wizard renders a "thinking" frame -// while these futures are in flight. - -/// Dispatch the configured agent against the ranged questioner prompt and -/// return the parsed numbered list of design questions. `min`/`max` are -/// inclusive bounds passed verbatim into the prompt; the agent is instructed -/// to stay within them. -#[allow(clippy::too_many_arguments)] -pub(crate) async fn dispatch_questioner_silent( - workspace: &Path, - cfg: &Config, - agent: &(dyn Agent + Send + Sync), - goal: &str, - repo_summary: &str, - min: u32, - max: u32, - cancel: CancellationToken, -) -> Result> { - let logs_dir = paths::play_logs_dir(workspace); - std::fs::create_dir_all(&logs_dir).context("start: creating logs dir")?; - - let user_prompt = prompts::questioner_ranged(goal, repo_summary, min, max); - let request = AgentRequest { - role: Role::Planner, - model: cfg.models.planner.clone(), - system_prompt: prompts::caveman::system_prompt(&cfg.caveman), - user_prompt, - workdir: workspace.to_path_buf(), - log_path: logs_dir.join("wizard_questioner.log"), - timeout: QUESTIONER_TIMEOUT, - env: std::collections::HashMap::new(), - }; - - let body = run_agent_collect(agent, request, cancel).await?; - let questions = parse_numbered_questions(&body); - if questions.is_empty() { - bail!("questioner produced no parseable questions"); - } - Ok(questions) -} - -/// Dispatch the planner agent with the (possibly Q&A-augmented) goal and -/// write the resulting `plan.md` atomically. Returns the parsed [`Plan`] so -/// the wizard can render the generated phase list on its review screen. -/// -/// Mirrors `plan::run_with_agent` but without the eprintln/println status -/// chatter — safe to call from inside the wizard's alternate screen. -pub(crate) async fn dispatch_planner_silent( - workspace: &Path, - cfg: &Config, - agent: &(dyn Agent + Send + Sync), - goal: &str, - repo_summary: &str, - cancel: CancellationToken, -) -> Result { - let logs_dir = paths::play_logs_dir(workspace); - std::fs::create_dir_all(&logs_dir).context("start: creating logs dir")?; - - let user_prompt = prompts::planner(goal, repo_summary); - let request = AgentRequest { - role: Role::Planner, - model: cfg.models.planner.clone(), - system_prompt: prompts::caveman::system_prompt(&cfg.caveman), - user_prompt, - workdir: workspace.to_path_buf(), - log_path: logs_dir.join("wizard_planner.log"), - timeout: PLANNER_TIMEOUT, - env: std::collections::HashMap::new(), - }; - - let body = run_agent_collect(agent, request, cancel).await?; - let parsed = plan::parse(&body).map_err(|e| anyhow!("planner output failed to parse: {e}"))?; - - let plan_path = paths::plan_path(workspace); - write_atomic(&plan_path, body.as_bytes()) - .with_context(|| format!("start: writing {}", plan_path.display()))?; - - Ok(parsed) -} - -/// Spawn `agent.run(request)`, accumulate every `Stdout` chunk into a -/// `String`, and return the concatenated body when the agent finishes. -/// Shared between [`dispatch_questioner_silent`] and -/// [`dispatch_planner_silent`]. -async fn run_agent_collect( - agent: &(dyn Agent + Send + Sync), - request: AgentRequest, - cancel: CancellationToken, -) -> Result { - let (tx, mut rx) = mpsc::channel::(64); - - let collector = tokio::spawn(async move { - let mut buf = String::new(); - while let Some(ev) = rx.recv().await { - if let AgentEvent::Stdout(chunk) = ev { - buf.push_str(&chunk); - } - } - buf - }); - - let outcome = agent.run(request, tx, cancel).await?; - let body = collector.await.unwrap_or_default(); - - match outcome.stop_reason { - StopReason::Completed if outcome.exit_code == 0 => Ok(body), - StopReason::Completed => { - bail!("agent exited with code {}", outcome.exit_code) - } - StopReason::Timeout => bail!("agent timed out"), - StopReason::Cancelled => bail!("agent was cancelled"), - StopReason::Error(msg) => bail!("agent failed: {msg}"), - } -} - -/// Extract questions from an agent's numbered-list response. Recognises -/// lines of the form `1. Question text` (leading whitespace allowed) and -/// preserves source order. Mirrors `interview::parse_questions`. -pub(crate) fn parse_numbered_questions(raw: &str) -> Vec { - let mut out = Vec::new(); - for line in raw.lines() { - let trimmed = line.trim(); - if let Some((prefix, rest)) = trimmed.split_once(". ") { - if prefix.parse::().is_ok() { - let q = rest.trim().to_string(); - if !q.is_empty() { - out.push(q); - } - } - } - } - out -} - -/// Format Q&A pairs as the spec snippet appended to the planner goal. -/// Mirrors `interview::format_spec`. -pub(crate) fn format_qa_spec(pairs: &[(String, String)]) -> String { - let mut out = String::new(); - for (i, (q, a)) in pairs.iter().enumerate() { - out.push_str(&format!("Q{n}: {q}\nA{n}: {a}\n\n", n = i + 1)); - } - out.trim_end().to_string() -} - -/// Combine a base goal with an optional Q&A spec the way `pitboss plan -/// --interview` does, so the planner gets the design context. -pub(crate) fn goal_with_spec(goal: &str, spec: &str) -> String { - if spec.is_empty() { - goal.to_string() - } else { - format!("{goal}\n\n## Design Specification\n\n{spec}") - } -} - -/// Archive the live `plan.md` to `plan..md.bak` next to it -/// and rewrite `plan.md` with the canonical [`PLAN_TEMPLATE`] seed. -/// -/// Used by the wizard's "terminate plan" path so a user who rejects a -/// generated plan leaves the workspace in the same state `pitboss init` -/// would have produced — recoverable from the backup but cleanly -/// re-pickable by `pitboss start`. -pub(crate) fn archive_plan_and_restore_seed(workspace: &Path) -> Result<()> { - let live = paths::plan_path(workspace); - if live.is_file() { - let stamp = Utc::now().format("%Y%m%dT%H%M%SZ"); - let backup = paths::play_dir(workspace).join(format!("plan.{}.md.bak", stamp)); - fs::rename(&live, &backup) - .with_context(|| format!("start: archiving plan.md to {:?}", backup))?; - println!(" Archived generated plan to {}", backup.display()); - } - write_atomic(&live, PLAN_TEMPLATE.as_bytes()) - .with_context(|| format!("start: writing seed plan to {}", live.display()))?; - Ok(()) -} diff --git a/src/prompts/mod.rs b/src/prompts/mod.rs index 29adb65..a6c959c 100644 --- a/src/prompts/mod.rs +++ b/src/prompts/mod.rs @@ -36,7 +36,6 @@ const SWEEP_FIXER_TEMPLATE: &str = include_str!("templates/sweep_fixer.txt"); const SWEEP_AUDITOR_TEMPLATE: &str = include_str!("templates/sweep_auditor.txt"); const PLANNER_TEMPLATE: &str = include_str!("templates/planner.txt"); const QUESTIONER_TEMPLATE: &str = include_str!("templates/questioner.txt"); -const QUESTIONER_RANGED_TEMPLATE: &str = include_str!("templates/questioner_ranged.txt"); /// Approximate ceiling on the static portion of any single template. /// @@ -121,29 +120,6 @@ pub fn questioner(goal: &str, repo_summary: &str, max_questions: u32) -> String ) } -/// Render the ranged questioner prompt used by the wizard's interview flow. -/// Tells the agent to produce between `min_questions` and `max_questions` -/// (inclusive) — strict bounds so user-selected ranges like 1-5 actually -/// produce 1-5 questions rather than the legacy 20+ default. -pub fn questioner_ranged( - goal: &str, - repo_summary: &str, - min_questions: u32, - max_questions: u32, -) -> String { - let min = min_questions.to_string(); - let max = max_questions.to_string(); - render( - QUESTIONER_RANGED_TEMPLATE, - &[ - ("goal", goal), - ("repo_summary", repo_summary), - ("min_questions", &min), - ("max_questions", &max), - ], - ) -} - /// Variant of [`auditor`] that accepts an explicit `deferred.md` rendering, so /// the runner can present the same canonical text the agent will see on disk. pub fn auditor_with_deferred( @@ -628,7 +604,6 @@ mod tests { ("sweep_auditor", SWEEP_AUDITOR_TEMPLATE), ("planner", PLANNER_TEMPLATE), ("questioner", QUESTIONER_TEMPLATE), - ("questioner_ranged", QUESTIONER_RANGED_TEMPLATE), ] { assert!( body.len() <= TEMPLATE_STATIC_BUDGET, diff --git a/src/prompts/templates/questioner_ranged.txt b/src/prompts/templates/questioner_ranged.txt deleted file mode 100644 index 2897302..0000000 --- a/src/prompts/templates/questioner_ranged.txt +++ /dev/null @@ -1,26 +0,0 @@ -You are a design interviewer. Given a goal and a repository overview, generate a numbered list of targeted design questions to help produce a precise implementation spec before planning begins. - -Generate between {min_questions} and {max_questions} questions — no fewer, no more. Cover what matters most given the budget: -- Core requirements and constraints -- Technical choices: algorithms, data structures, libraries to use or avoid -- Interface design: CLI flags, output format, error messages, user flow -- Architecture: module layout, key abstractions, naming conventions -- Integration with existing code, APIs, or external tools -- Edge cases and failure modes -- Testing approach - -If the budget is small (5 or fewer), prioritize requirements and interface decisions over architecture details. If the budget is large (15+), include edge cases and testing. - -Output ONLY a numbered list. One question per line. No preamble, no commentary, no section headers. - -1. First question? -2. Second question? -... - -# Goal - -{goal} - -# Repository overview - -{repo_summary} diff --git a/src/tui/iteration.rs b/src/tui/iteration.rs deleted file mode 100644 index 18dafc4..0000000 --- a/src/tui/iteration.rs +++ /dev/null @@ -1,1770 +0,0 @@ -//! Interactive TUI iteration wizard for `pitboss start` when `.pitboss/` -//! already exists. Shows current run state (budget, phase progress, deferred -//! item count, configured models) and lets the user pick the next action: -//! continue the run, run a one-shot sweep, or start a new plan. -//! -//! The "new plan" path optionally runs an in-wizard design interview against -//! the planner agent (toggle + question-range picker on the goal-input -//! screen), then dispatches the planner and shows the generated phase list so -//! the user can launch `play --tui`, save the plan for later, or terminate -//! it. The agent dispatches are performed by helpers in -//! [`crate::cli::start`] — see `dispatch_questioner_silent` and -//! `dispatch_planner_silent` — so no agent output leaks into the TUI. - -use std::path::Path; -use std::sync::Arc; - -use anyhow::Result; -use crossterm::event::{Event as CtEvent, EventStream, KeyCode, KeyEventKind, KeyModifiers}; -use futures::StreamExt; -use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::style::{Color, Modifier, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap}; -use tokio::task::JoinHandle; -use tokio_util::sync::CancellationToken; - -use crate::agent::Agent; -use crate::cli::start::{ - dispatch_planner_silent, dispatch_questioner_silent, format_qa_spec, goal_with_spec, -}; -use crate::config::Config; -use crate::plan::{Phase, PhaseId, Plan}; -use crate::tui::{TerminalGuard, TICK_INTERVAL}; - -/// Shorthand for the trait object the wizard hands to spawned tasks. -type SharedAgent = Arc; - -/// Read-only summary of the workspace shown on the iteration wizard's first -/// screen. Built by `cli::start::load_snapshot`. -pub struct WorkspaceSnapshot { - pub branch: Option, - pub current_phase: Option, - pub completed_count: usize, - pub total_phases: usize, - pub tokens_used: u64, - pub tokens_cap: Option, - pub usd_used: f64, - pub usd_cap: Option, - pub deferred_count: usize, - pub planner_model: String, - pub implementer_model: String, - /// True when `state.json` is missing or `null` — no run has started yet. - /// Affects whether Continue dispatches to `play` (fresh) or `rebuy` - /// (resume) downstream. - pub no_state: bool, - /// True when `plan.md` is missing or unparseable. The wizard restricts - /// the action list to "New plan" only in this case. - pub no_plan: bool, -} - -/// Question-count bounds passed to the planner's questioner prompt. Maps to -/// the (`min`, `max`) tuple consumed by `prompts::questioner_ranged`. -#[derive(Clone, Copy, PartialEq, Debug)] -pub enum QuestionRange { - OneToFive, - FiveToTen, - TenToTwenty, - /// Hard bounds are `(10, 50)` despite the label. An uncapped range lets - /// some models emit 100+ questions; the 50-question ceiling keeps the - /// interview session tractable while still giving the agent wide latitude. - AsManyAsNeeded, -} - -impl QuestionRange { - pub fn bounds(self) -> (u32, u32) { - match self { - Self::OneToFive => (1, 5), - Self::FiveToTen => (5, 10), - Self::TenToTwenty => (10, 20), - Self::AsManyAsNeeded => (10, 50), - } - } - fn label(self) -> &'static str { - match self { - Self::OneToFive => "1–5 questions", - Self::FiveToTen => "5–10 questions", - Self::TenToTwenty => "10–20 questions", - Self::AsManyAsNeeded => "as many as needed", - } - } - fn all() -> [Self; 4] { - [ - Self::OneToFive, - Self::FiveToTen, - Self::TenToTwenty, - Self::AsManyAsNeeded, - ] - } -} - -/// What the wizard ultimately decided. `Continue`/`Sweep` are dispatched by -/// `cli::start::dispatch_action`; the `NewPlan*` variants signal that the -/// wizard has already generated (and possibly archived) `plan.md` and that -/// the caller's job is only to launch play or print a final hint. -pub enum IterationOutcome { - Continue { - reset_budget: bool, - }, - Sweep { - reset_budget: bool, - }, - /// Plan generated and accepted; launch `play --tui` against the new plan. - NewPlanLaunchPlay { - plan: Plan, - }, - /// Plan generated and accepted; the user wants to start it later. The - /// caller prints a summary to stdout and exits. - NewPlanWait { - plan: Plan, - }, - /// User rejected the generated plan. By the time this is returned, - /// `plan.md` has been archived and reset to the seed template so the - /// workspace is back to its pre-generation state. - NewPlanTerminated, -} - -#[derive(Clone, Copy, PartialEq)] -enum Action { - Continue, - Sweep, - NewPlan, -} - -#[derive(Clone, Copy, PartialEq)] -enum PostPlanChoice { - LaunchPlay, - WaitForLater, - Terminate, -} - -impl PostPlanChoice { - fn all() -> [Self; 3] { - [Self::LaunchPlay, Self::WaitForLater, Self::Terminate] - } - fn label(self) -> &'static str { - match self { - Self::LaunchPlay => "Run it now (launches play --tui)", - Self::WaitForLater => "Wait until later (plan saved, exit to terminal)", - Self::Terminate => "Terminate plan (not recommended — archives plan.md)", - } - } -} - -#[derive(Clone, PartialEq)] -enum Step { - /// Summary + action picker + reset-budget toggle. - Summary, - /// Goal text + interview toggle + range picker. - NewPlanInput, - /// Questioner agent dispatching; spinner. - InterviewThinking, - /// One question shown at a time; user types an answer. - InterviewQA, - /// Planner agent dispatching; spinner. - PlanThinking, - /// Generated phase list + Run/Wait/Terminate picker. - PlanReview, -} - -/// Which focusable field on the NewPlanInput screen has keyboard focus. -#[derive(Clone, Copy, PartialEq)] -enum NewPlanField { - Goal, - InterviewToggle, - RangePicker, -} - -/// Async task currently in flight. The render loop waits on the join handle -/// in `tokio::select!` and transitions to the next step on completion. -enum PendingTask { - Questioner { - cancel: CancellationToken, - handle: JoinHandle>>, - }, - Planner { - cancel: CancellationToken, - handle: JoinHandle>, - }, -} - -impl PendingTask { - /// Signal the `CancellationToken`. Returns immediately — the spawned task - /// checks the token on its next `tokio::select!` iteration and exits - /// cleanly rather than being abruptly aborted. - fn cancel(&self) { - match self { - Self::Questioner { cancel, .. } => cancel.cancel(), - Self::Planner { cancel, .. } => cancel.cancel(), - } - } -} - -struct IterState { - step: Step, - cursor: usize, - reset_budget: bool, - actions: Vec, - - // NewPlanInput state - goal_input: String, - /// Cursor position in `goal_input`, measured in Unicode scalar values. - goal_cursor: usize, - /// When `Some`, the user has manually scrolled the goal input via - /// PageUp/PageDown. Render uses this offset instead of auto-following - /// the cursor. Reset to `None` on any keystroke that mutates the text - /// so typing snaps back to following the cursor. - goal_scroll: Option, - /// Actual text for each `[Pasted text #N]` placeholder in `goal_input`. - /// Expanded back to full text before the goal is dispatched to the planner. - pastes: Vec, - new_plan_field: NewPlanField, - interview_enabled: bool, - range_cursor: usize, - - // Interview state - questions: Vec, - answers: Vec<(String, String)>, - current_q: usize, - current_answer: String, - /// Cursor position in `current_answer`, measured in Unicode scalar values. - answer_cursor: usize, - /// Same idea as `goal_scroll` but for the per-question answer field. - answer_scroll: Option, - - // Plan review state - generated_plan: Option, - review_cursor: usize, - - // Error captured from a failed task — shown in a small banner over the - // current screen. - error: Option, - - // Spinner animation tick. - tick: u64, -} - -impl IterState { - fn new(snapshot: &WorkspaceSnapshot) -> Self { - // Restrict to NewPlan when plan.md is absent: Continue needs a - // parseable plan to know which phase to resume from, and Sweep needs - // it to anchor deferred items to a phase context. - let actions = if snapshot.no_plan { - vec![Action::NewPlan] - } else { - vec![Action::Continue, Action::Sweep, Action::NewPlan] - }; - Self { - step: Step::Summary, - cursor: 0, - reset_budget: false, - actions, - goal_input: String::new(), - goal_cursor: 0, - goal_scroll: None, - pastes: Vec::new(), - new_plan_field: NewPlanField::Goal, - interview_enabled: false, - range_cursor: 0, - questions: Vec::new(), - answers: Vec::new(), - current_q: 0, - current_answer: String::new(), - answer_cursor: 0, - answer_scroll: None, - generated_plan: None, - review_cursor: 0, - error: None, - tick: 0, - } - } - - fn selected_action(&self) -> Action { - self.actions[self.cursor] - } - - fn selected_range(&self) -> QuestionRange { - QuestionRange::all()[self.range_cursor] - } - - fn focus_advance(&mut self) { - // Tab order: Goal → InterviewToggle (always, so the user can turn the - // interview on even when it's currently off) → RangePicker (only when - // interview is enabled) → Goal. The `if interview_enabled` guard on the - // Goal arm is redundant — both branches go to InterviewToggle — but kept - // for symmetry with `focus_back`. - self.new_plan_field = match self.new_plan_field { - NewPlanField::Goal if self.interview_enabled => NewPlanField::InterviewToggle, - NewPlanField::Goal => NewPlanField::InterviewToggle, - NewPlanField::InterviewToggle if self.interview_enabled => NewPlanField::RangePicker, - NewPlanField::InterviewToggle => NewPlanField::Goal, - NewPlanField::RangePicker => NewPlanField::Goal, - }; - } - - fn focus_back(&mut self) { - self.new_plan_field = match self.new_plan_field { - NewPlanField::Goal if self.interview_enabled => NewPlanField::RangePicker, - NewPlanField::Goal => NewPlanField::InterviewToggle, - NewPlanField::InterviewToggle => NewPlanField::Goal, - NewPlanField::RangePicker => NewPlanField::InterviewToggle, - }; - } -} - -enum LoopEv { - Continue, - Quit, - Done(IterationOutcome), - /// Caller should kick off the questioner agent for the current goal. - StartQuestioner, - /// Caller should kick off the planner agent (after collecting Q&A). - StartPlanner, - /// Caller should archive plan.md + restore seed, then return - /// `NewPlanTerminated`. - StartTerminate, -} - -pub async fn run_iteration_wizard( - snapshot: &WorkspaceSnapshot, - workspace: &Path, - cfg: &Config, - agent: SharedAgent, -) -> Result> { - let mut guard = TerminalGuard::setup()?; - // Force a real clear-screen escape. The `Clear` widget alone diffs - // against an empty initial buffer and sends nothing on first draw, so - // prior terminal contents leak around the centered dialog. Calling - // `terminal.clear()` emits the actual erase-display sequence. - guard.terminal().clear()?; - let mut state = IterState::new(snapshot); - let mut input = EventStream::new(); - let mut pending: Option = None; - let workspace = workspace.to_path_buf(); - - let result = loop { - guard.terminal().draw(|f| render(f, snapshot, &state))?; - - if let Some(task) = pending.as_mut() { - // A background dispatch is running. Wait for input (to cancel), - // a tick (to animate the spinner), or task completion. - tokio::select! { - ev = input.next() => { - match ev { - Some(Ok(CtEvent::Key(key))) if key.kind == KeyEventKind::Press => { - if is_cancel(key.code, key.modifiers) { - task.cancel(); - } - } - Some(Ok(CtEvent::Paste(_))) => {} // ignore paste during dispatch - Some(Ok(_)) => {} - Some(Err(e)) => return Err(e.into()), - None => break None, - } - } - done = poll_task(task) => { - match done { - TaskFinished::Questioner(res) => { - pending = None; - match res { - Ok(qs) if qs.is_empty() => { - state.error = Some( - "Questioner returned no parseable questions — skipping interview." - .to_string(), - ); - // Fall through to planner directly with the bare goal. - state.step = Step::PlanThinking; - let cancel = CancellationToken::new(); - let handle = spawn_planner( - agent.clone(), cfg.clone(), workspace.clone(), - state.goal_input.clone(), cancel.clone(), - ); - pending = Some(PendingTask::Planner { cancel, handle }); - } - Ok(qs) => { - state.questions = qs; - state.current_q = 0; - state.current_answer.clear(); - state.answer_cursor = 0; - state.step = Step::InterviewQA; - } - Err(e) => { - state.error = Some(format!("Questioner failed: {e:#}")); - state.step = Step::NewPlanInput; - } - } - } - TaskFinished::Planner(res) => { - pending = None; - match res { - Ok(plan) => { - state.generated_plan = Some(plan); - state.review_cursor = 0; - state.step = Step::PlanReview; - } - Err(e) => { - state.error = Some(format!("Planner failed: {e:#}")); - state.step = Step::NewPlanInput; - } - } - } - } - } - _ = tokio::time::sleep(TICK_INTERVAL) => { - state.tick = state.tick.wrapping_add(1); - } - } - } else { - tokio::select! { - ev = input.next() => { - match ev { - Some(Ok(CtEvent::Key(key))) if key.kind == KeyEventKind::Press => { - match on_key(&mut state, key.code, key.modifiers) { - LoopEv::Continue => {} - LoopEv::Quit => break None, - LoopEv::Done(o) => break Some(o), - LoopEv::StartQuestioner => { - // Expand paste placeholders before dispatching so the - // planner receives the actual text, not `[Pasted text #N]`. - if !state.pastes.is_empty() { - state.goal_input = expand_pastes(&state.goal_input, &state.pastes); - state.pastes.clear(); - state.goal_cursor = state.goal_input.chars().count(); - } - let cancel = CancellationToken::new(); - let (min, max) = state.selected_range().bounds(); - let goal = state.goal_input.trim().to_string(); - let handle = spawn_questioner( - agent.clone(), cfg.clone(), workspace.clone(), - goal, min, max, cancel.clone(), - ); - state.step = Step::InterviewThinking; - pending = Some(PendingTask::Questioner { cancel, handle }); - } - LoopEv::StartPlanner => { - if !state.pastes.is_empty() { - state.goal_input = expand_pastes(&state.goal_input, &state.pastes); - state.pastes.clear(); - state.goal_cursor = state.goal_input.chars().count(); - } - let cancel = CancellationToken::new(); - let spec = format_qa_spec(&state.answers); - let goal = goal_with_spec(state.goal_input.trim(), &spec); - let handle = spawn_planner( - agent.clone(), cfg.clone(), workspace.clone(), - goal, cancel.clone(), - ); - state.step = Step::PlanThinking; - pending = Some(PendingTask::Planner { cancel, handle }); - } - LoopEv::StartTerminate => { - if let Err(e) = - crate::cli::start::archive_plan_and_restore_seed(&workspace) - { - state.error = - Some(format!("Termination failed: {e:#}")); - } else { - break Some(IterationOutcome::NewPlanTerminated); - } - } - } - } - Some(Ok(CtEvent::Paste(ref text))) => { - handle_paste(&mut state, text); - } - Some(Ok(_)) => {} - Some(Err(e)) => return Err(e.into()), - None => break None, - } - } - _ = tokio::time::sleep(TICK_INTERVAL) => { - state.tick = state.tick.wrapping_add(1); - } - } - } - }; - - // Cancel any in-flight task before tearing down the terminal so we don't - // leak a spawned subprocess waiting on its output channel. - if let Some(task) = pending.take() { - task.cancel(); - } - - guard.restore()?; - Ok(result) -} - -enum TaskFinished { - Questioner(Result>), - Planner(Result), -} - -async fn poll_task(task: &mut PendingTask) -> TaskFinished { - match task { - PendingTask::Questioner { handle, .. } => { - let res = handle - .await - .unwrap_or_else(|e| Err(anyhow::anyhow!("questioner task panicked: {e}"))); - TaskFinished::Questioner(res) - } - PendingTask::Planner { handle, .. } => { - let res = handle - .await - .unwrap_or_else(|e| Err(anyhow::anyhow!("planner task panicked: {e}"))); - TaskFinished::Planner(res) - } - } -} - -fn spawn_questioner( - agent: SharedAgent, - cfg: Config, - workspace: std::path::PathBuf, - goal: String, - min: u32, - max: u32, - cancel: CancellationToken, -) -> JoinHandle>> { - tokio::spawn(async move { - let repo_summary = collect_summary_safe(&workspace); - dispatch_questioner_silent( - &workspace, - &cfg, - agent.as_ref(), - &goal, - &repo_summary, - min, - max, - cancel, - ) - .await - }) -} - -fn spawn_planner( - agent: SharedAgent, - cfg: Config, - workspace: std::path::PathBuf, - goal: String, - cancel: CancellationToken, -) -> JoinHandle> { - tokio::spawn(async move { - let repo_summary = collect_summary_safe(&workspace); - dispatch_planner_silent( - &workspace, - &cfg, - agent.as_ref(), - &goal, - &repo_summary, - cancel, - ) - .await - }) -} - -/// Collect the repo summary, silently degrading to a placeholder on error. -/// Failures here (permission denied, empty workspace) are non-fatal — the -/// agent dispatch continues with a reduced context rather than aborting the wizard. -fn collect_summary_safe(workspace: &Path) -> String { - crate::cli::plan::collect_repo_summary(workspace) - .unwrap_or_else(|_| "(repo summary unavailable)".to_string()) -} - -fn is_cancel(code: KeyCode, mods: KeyModifiers) -> bool { - if matches!(code, KeyCode::Char('c')) && mods.contains(KeyModifiers::CONTROL) { - return true; - } - matches!(code, KeyCode::Esc) -} - -fn on_key(s: &mut IterState, code: KeyCode, mods: KeyModifiers) -> LoopEv { - // Clear any error banner on the next keypress. - s.error = None; - - if matches!(code, KeyCode::Char('c')) && mods.contains(KeyModifiers::CONTROL) { - return LoopEv::Quit; - } - match s.step.clone() { - Step::Summary => summary_key(s, code), - Step::NewPlanInput => new_plan_key(s, code, mods), - Step::InterviewQA => interview_qa_key(s, code, mods), - Step::PlanReview => plan_review_key(s, code), - // Thinking screens accept only cancel; cancel is handled in the - // pending-task branch of the main loop, so we get nothing here. - Step::InterviewThinking | Step::PlanThinking => LoopEv::Continue, - } -} - -fn summary_key(s: &mut IterState, code: KeyCode) -> LoopEv { - match code { - KeyCode::Up => { - s.cursor = s.cursor.saturating_sub(1); - LoopEv::Continue - } - KeyCode::Down => { - if s.cursor + 1 < s.actions.len() { - s.cursor += 1; - } - LoopEv::Continue - } - KeyCode::Char(' ') => { - if !matches!(s.selected_action(), Action::NewPlan) { - s.reset_budget = !s.reset_budget; - } - LoopEv::Continue - } - KeyCode::Enter => match s.selected_action() { - Action::Continue => LoopEv::Done(IterationOutcome::Continue { - reset_budget: s.reset_budget, - }), - Action::Sweep => LoopEv::Done(IterationOutcome::Sweep { - reset_budget: s.reset_budget, - }), - Action::NewPlan => { - s.step = Step::NewPlanInput; - s.new_plan_field = NewPlanField::Goal; - LoopEv::Continue - } - }, - KeyCode::Char('q') => LoopEv::Quit, - _ => LoopEv::Continue, - } -} - -fn new_plan_key(s: &mut IterState, code: KeyCode, mods: KeyModifiers) -> LoopEv { - // Ctrl shortcuts for the goal field — handled before the main match so - // they don't fall through to the generic Char insertion arm. - if s.new_plan_field == NewPlanField::Goal && mods.contains(KeyModifiers::CONTROL) { - match code { - KeyCode::Char('a') => { - s.goal_cursor = 0; - s.goal_scroll = None; - return LoopEv::Continue; - } - KeyCode::Char('e') => { - s.goal_cursor = s.goal_input.chars().count(); - s.goal_scroll = None; - return LoopEv::Continue; - } - _ => return LoopEv::Continue, - } - } - - match (s.new_plan_field, code) { - // ── goal cursor movement ────────────────────────────────────────── - (NewPlanField::Goal, KeyCode::Left) => { - if s.goal_cursor > 0 { - s.goal_cursor -= 1; - } - LoopEv::Continue - } - (NewPlanField::Goal, KeyCode::Right) => { - let len = s.goal_input.chars().count(); - if s.goal_cursor < len { - s.goal_cursor += 1; - } - LoopEv::Continue - } - (NewPlanField::Goal, KeyCode::Home) => { - s.goal_cursor = 0; - s.goal_scroll = None; - LoopEv::Continue - } - (NewPlanField::Goal, KeyCode::End) => { - s.goal_cursor = s.goal_input.chars().count(); - s.goal_scroll = None; - LoopEv::Continue - } - // ── goal viewport scroll (manual override) ──────────────────────── - (NewPlanField::Goal, KeyCode::PageUp) => { - let baseline = s.goal_scroll.unwrap_or_else(|| { - cursor_auto_scroll(&s.goal_input, s.goal_cursor, GOAL_INNER_W, GOAL_INNER_H) - }); - s.goal_scroll = Some(baseline.saturating_sub(SCROLL_PAGE)); - LoopEv::Continue - } - (NewPlanField::Goal, KeyCode::PageDown) => { - let baseline = s.goal_scroll.unwrap_or_else(|| { - cursor_auto_scroll(&s.goal_input, s.goal_cursor, GOAL_INNER_W, GOAL_INNER_H) - }); - let max = cursor_auto_scroll( - &s.goal_input, - s.goal_input.chars().count(), - GOAL_INNER_W, - GOAL_INNER_H, - ); - s.goal_scroll = Some(baseline.saturating_add(SCROLL_PAGE).min(max)); - LoopEv::Continue - } - // ── global navigation ───────────────────────────────────────────── - (_, KeyCode::Enter) => { - if s.goal_input.trim().is_empty() { - return LoopEv::Continue; - } - if s.interview_enabled { - LoopEv::StartQuestioner - } else { - s.answers.clear(); - LoopEv::StartPlanner - } - } - (_, KeyCode::Esc) => { - s.step = Step::Summary; - LoopEv::Continue - } - // Tab / BackTab always cycle focus regardless of which field is active. - (_, KeyCode::Tab) => { - s.focus_advance(); - LoopEv::Continue - } - (_, KeyCode::BackTab) => { - s.focus_back(); - LoopEv::Continue - } - // Up/Down cycle focus only when the goal text box is NOT active, - // so they don't steal the cursor from the goal field. - (NewPlanField::InterviewToggle, KeyCode::Down) - | (NewPlanField::RangePicker, KeyCode::Down) => { - s.focus_advance(); - LoopEv::Continue - } - (NewPlanField::InterviewToggle, KeyCode::Up) | (NewPlanField::RangePicker, KeyCode::Up) => { - s.focus_back(); - LoopEv::Continue - } - // ── goal text editing ───────────────────────────────────────────── - (NewPlanField::Goal, KeyCode::Char(c)) => { - text_insert_char(&mut s.goal_input, &mut s.goal_cursor, c); - s.goal_scroll = None; - LoopEv::Continue - } - (NewPlanField::Goal, KeyCode::Backspace) => { - text_backspace(&mut s.goal_input, &mut s.goal_cursor); - s.goal_scroll = None; - LoopEv::Continue - } - (NewPlanField::Goal, KeyCode::Delete) => { - text_delete_forward(&mut s.goal_input, s.goal_cursor); - s.goal_scroll = None; - LoopEv::Continue - } - // ── interview toggle & range picker ─────────────────────────────── - (NewPlanField::InterviewToggle, KeyCode::Char(' ')) => { - s.interview_enabled = !s.interview_enabled; - if s.interview_enabled { - s.new_plan_field = NewPlanField::RangePicker; - } - LoopEv::Continue - } - (NewPlanField::RangePicker, KeyCode::Left) => { - s.range_cursor = s.range_cursor.saturating_sub(1); - LoopEv::Continue - } - (NewPlanField::RangePicker, KeyCode::Right) => { - if s.range_cursor + 1 < QuestionRange::all().len() { - s.range_cursor += 1; - } - LoopEv::Continue - } - _ => LoopEv::Continue, - } -} - -fn interview_qa_key(s: &mut IterState, code: KeyCode, mods: KeyModifiers) -> LoopEv { - // Ctrl shortcuts for cursor movement in the answer field. - if mods.contains(KeyModifiers::CONTROL) { - match code { - KeyCode::Char('a') => { - s.answer_cursor = 0; - s.answer_scroll = None; - return LoopEv::Continue; - } - KeyCode::Char('e') => { - s.answer_cursor = s.current_answer.chars().count(); - s.answer_scroll = None; - return LoopEv::Continue; - } - _ => return LoopEv::Continue, - } - } - - match code { - // ── cursor movement ─────────────────────────────────────────────── - KeyCode::Left => { - if s.answer_cursor > 0 { - s.answer_cursor -= 1; - } - LoopEv::Continue - } - KeyCode::Right => { - let len = s.current_answer.chars().count(); - if s.answer_cursor < len { - s.answer_cursor += 1; - } - LoopEv::Continue - } - KeyCode::Home => { - s.answer_cursor = 0; - s.answer_scroll = None; - LoopEv::Continue - } - KeyCode::End => { - s.answer_cursor = s.current_answer.chars().count(); - s.answer_scroll = None; - LoopEv::Continue - } - // ── viewport scroll ─────────────────────────────────────────────── - KeyCode::PageUp => { - let baseline = s.answer_scroll.unwrap_or_else(|| { - cursor_auto_scroll( - &s.current_answer, - s.answer_cursor, - GOAL_INNER_W, - GOAL_INNER_H, - ) - }); - s.answer_scroll = Some(baseline.saturating_sub(SCROLL_PAGE)); - LoopEv::Continue - } - KeyCode::PageDown => { - let baseline = s.answer_scroll.unwrap_or_else(|| { - cursor_auto_scroll( - &s.current_answer, - s.answer_cursor, - GOAL_INNER_W, - GOAL_INNER_H, - ) - }); - let max = cursor_auto_scroll( - &s.current_answer, - s.current_answer.chars().count(), - GOAL_INNER_W, - GOAL_INNER_H, - ); - s.answer_scroll = Some(baseline.saturating_add(SCROLL_PAGE).min(max)); - LoopEv::Continue - } - // ── answer submission & navigation ──────────────────────────────── - KeyCode::Enter => { - let q = s.questions[s.current_q].clone(); - let a = s.current_answer.trim().to_string(); - if !a.is_empty() { - s.answers.push((q, a)); - } - s.current_answer.clear(); - s.answer_cursor = 0; - s.answer_scroll = None; - s.current_q += 1; - if s.current_q >= s.questions.len() { - LoopEv::StartPlanner - } else { - LoopEv::Continue - } - } - KeyCode::Tab => { - s.current_answer.clear(); - s.answer_cursor = 0; - s.answer_scroll = None; - s.current_q += 1; - if s.current_q >= s.questions.len() { - LoopEv::StartPlanner - } else { - LoopEv::Continue - } - } - KeyCode::Esc => { - s.step = Step::NewPlanInput; - s.questions.clear(); - s.answers.clear(); - s.current_q = 0; - s.current_answer.clear(); - s.answer_cursor = 0; - s.answer_scroll = None; - LoopEv::Continue - } - // ── text editing ────────────────────────────────────────────────── - KeyCode::Char(c) => { - text_insert_char(&mut s.current_answer, &mut s.answer_cursor, c); - s.answer_scroll = None; - LoopEv::Continue - } - KeyCode::Backspace => { - text_backspace(&mut s.current_answer, &mut s.answer_cursor); - s.answer_scroll = None; - LoopEv::Continue - } - KeyCode::Delete => { - text_delete_forward(&mut s.current_answer, s.answer_cursor); - s.answer_scroll = None; - LoopEv::Continue - } - _ => LoopEv::Continue, - } -} - -fn plan_review_key(s: &mut IterState, code: KeyCode) -> LoopEv { - match code { - KeyCode::Up => { - s.review_cursor = s.review_cursor.saturating_sub(1); - LoopEv::Continue - } - KeyCode::Down => { - if s.review_cursor + 1 < PostPlanChoice::all().len() { - s.review_cursor += 1; - } - LoopEv::Continue - } - KeyCode::Enter => { - let choice = PostPlanChoice::all()[s.review_cursor]; - let plan = s.generated_plan.clone(); - match (choice, plan) { - (PostPlanChoice::LaunchPlay, Some(p)) => { - LoopEv::Done(IterationOutcome::NewPlanLaunchPlay { plan: p }) - } - (PostPlanChoice::WaitForLater, Some(p)) => { - LoopEv::Done(IterationOutcome::NewPlanWait { plan: p }) - } - (PostPlanChoice::Terminate, _) => LoopEv::StartTerminate, - _ => LoopEv::Continue, - } - } - KeyCode::Esc => { - // Esc on the review screen does nothing — the user must pick - // one of the three options. Pressing q quits without doing - // anything (the plan is already written). - LoopEv::Continue - } - KeyCode::Char('q') => LoopEv::Quit, - _ => LoopEv::Continue, - } -} - -// ── text-input helpers ──────────────────────────────────────────────────────── - -/// Convert a char index into a byte offset for the given string. -fn char_to_byte(text: &str, char_idx: usize) -> usize { - text.char_indices() - .nth(char_idx) - .map(|(b, _)| b) - .unwrap_or(text.len()) -} - -/// Insert `c` at the cursor position, then advance the cursor. -fn text_insert_char(text: &mut String, cursor: &mut usize, c: char) { - let pos = char_to_byte(text, *cursor); - text.insert(pos, c); - *cursor += 1; -} - -/// Insert the entire string `s` at the cursor position, then advance. -fn text_insert_str(text: &mut String, cursor: &mut usize, s: &str) { - let pos = char_to_byte(text, *cursor); - text.insert_str(pos, s); - *cursor += s.chars().count(); -} - -/// Delete the character before the cursor (backspace semantics). -fn text_backspace(text: &mut String, cursor: &mut usize) { - if *cursor > 0 { - *cursor -= 1; - let pos = char_to_byte(text, *cursor); - text.remove(pos); - } -} - -/// Delete the character at the cursor (forward-delete / Delete key semantics). -fn text_delete_forward(text: &mut String, cursor: usize) { - if cursor < text.chars().count() { - let pos = char_to_byte(text, cursor); - text.remove(pos); - } -} - -/// Build the display string for a focused text input, inserting the block -/// cursor `█` at `cursor` chars into `value`. -fn display_cursor(value: &str, cursor: usize) -> String { - let byte_pos = char_to_byte(value, cursor); - format!("{}█{}", &value[..byte_pos], &value[byte_pos..]) -} - -/// Compute the vertical scroll offset that keeps the cursor visible. -/// `inner_w`/`inner_h` are the usable dimensions inside the box borders. -fn cursor_auto_scroll(text: &str, cursor: usize, inner_w: u16, inner_h: u16) -> u16 { - let iw = inner_w.max(1) as usize; - let ih = inner_h.max(1) as usize; - let cursor_line = visual_cursor_line(text, cursor, iw); - let total = visual_line_count(text, iw); - let max_scroll = total.saturating_sub(ih) as u16; - (cursor_line.saturating_sub(ih.saturating_sub(1)) as u16).min(max_scroll) -} - -fn visual_line_count(text: &str, inner_w: usize) -> usize { - let iw = inner_w.max(1); - text.split('\n') - .map(|line| line.chars().count().div_ceil(iw).max(1)) - .sum::() - .max(1) -} - -fn visual_cursor_line(text: &str, cursor: usize, inner_w: usize) -> usize { - let iw = inner_w.max(1); - let mut remaining = cursor.min(text.chars().count()); - let mut line_index = 0usize; - - for line in text.split('\n') { - let len = line.chars().count(); - if remaining <= len { - return line_index + (remaining / iw); - } - line_index += len.div_ceil(iw).max(1); - remaining = remaining.saturating_sub(len + 1); - } - - line_index -} - -/// Expand `[Pasted text #N]` placeholders back to the original pasted text -/// before submitting the goal to the planner. -fn expand_pastes(text: &str, pastes: &[String]) -> String { - let mut result = text.to_string(); - for (i, actual) in pastes.iter().enumerate() { - result = result.replace(&format!("[Pasted text #{}]", i + 1), actual); - } - result -} - -/// Handle a bracketed-paste event. Large pastes (> 80 chars or multiline) are -/// compressed to a `[Pasted text #N]` placeholder; the actual text is stored -/// in `state.pastes` and expanded before dispatch. This mirrors the approach -/// Claude Code uses: the terminal wraps clipboard pastes in `ESC[?2004h` / -/// `ESC[200~…ESC[201~` sequences (bracketed-paste mode), the app receives a -/// single `Event::Paste(text)` rather than individual keystrokes, and can -/// decide whether to inline or compress based on size. -fn handle_paste(s: &mut IterState, text: &str) { - const THRESHOLD: usize = 80; - match (&s.step, s.new_plan_field) { - (Step::NewPlanInput, NewPlanField::Goal) => { - if text.len() > THRESHOLD || text.contains('\n') { - let n = s.pastes.len() + 1; - let placeholder = format!("[Pasted text #{}]", n); - s.pastes.push(text.to_string()); - text_insert_str(&mut s.goal_input, &mut s.goal_cursor, &placeholder); - } else { - text_insert_str(&mut s.goal_input, &mut s.goal_cursor, text); - } - s.goal_scroll = None; - } - (Step::InterviewQA, _) => { - text_insert_str(&mut s.current_answer, &mut s.answer_cursor, text); - s.answer_scroll = None; - } - _ => {} - } -} - -// ── rendering ──────────────────────────────────────────────────────────────── - -fn render(f: &mut ratatui::Frame<'_>, snapshot: &WorkspaceSnapshot, state: &IterState) { - let area = f.area(); - // Clear the entire screen first — fixes scrollback bleeding through the - // gaps outside the dialog rect (visible when the wizard is launched from - // a terminal that already has output on screen). - f.render_widget(Clear, area); - - let dialog = centered_rect(80, 30, area); - match state.step { - Step::Summary => render_summary(f, dialog, snapshot, state), - Step::NewPlanInput => render_new_plan(f, dialog, state), - Step::InterviewThinking => render_thinking( - f, - dialog, - "generating design questions", - "Asking the planner agent for targeted questions about your goal.", - state.tick, - ), - Step::InterviewQA => render_interview_qa(f, dialog, state), - Step::PlanThinking => render_thinking( - f, - dialog, - "building plan", - "The planner is turning your goal (and answers) into a phased plan.md.", - state.tick, - ), - Step::PlanReview => render_plan_review(f, dialog, state), - } - - if let Some(err) = &state.error { - // Small error banner pinned to the bottom of the dialog. - let banner_area = Rect { - x: dialog.x, - y: dialog.y + dialog.height.saturating_sub(3), - width: dialog.width, - height: 3, - }; - f.render_widget(Clear, banner_area); - f.render_widget( - Paragraph::new(vec![ - Line::from(Span::styled( - " ⚠ Something went wrong", - Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), - )), - Line::from(Span::styled( - format!(" {}", err), - Style::default().fg(Color::Red), - )), - ]) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Red)), - ), - banner_area, - ); - } -} - -fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { - let x = area.x + area.width.saturating_sub(width) / 2; - let y = area.y + area.height.saturating_sub(height) / 2; - Rect::new(x, y, width.min(area.width), height.min(area.height)) -} - -fn dialog_block(title: &str) -> Block<'_> { - Block::default() - .title(format!(" {} ", title)) - .title_style( - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ) - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::DarkGray)) -} - -fn hint(text: &'static str) -> Paragraph<'static> { - Paragraph::new(Line::from(vec![Span::styled( - text, - Style::default().fg(Color::DarkGray), - )])) -} - -/// Approximate inner width / height of a scrollable input box at the -/// wizard's 80-col dialog size. Used by the key handlers to compute -/// auto-scroll baseline values without needing access to the render area. -/// (Margins: 80 dialog − 2 block borders − 2 layout margin − 2 input -/// borders = 74 inner cols. The box height is 10 → 8 inner rows.) -const GOAL_INNER_W: u16 = 74; -const GOAL_INNER_H: u16 = 8; -/// One PageUp/PageDown press scrolls this many wrapped lines. -const SCROLL_PAGE: u16 = 4; - -/// Render a text input box. -/// -/// `cursor_pos` – `Some(char_index)` when the box is focused; the `█` cursor -/// is placed at that position and the viewport auto-scrolls to keep it -/// visible. `None` renders the box unfocused (gray border, no cursor). -/// `scroll_override` – a manually-set viewport offset (PageUp/PageDown); -/// takes precedence over the auto-scroll when `Some`. -fn text_input_widget( - value: &str, - cursor_pos: Option, - area: Rect, - scroll_override: Option, -) -> Paragraph<'_> { - let (display, focused) = match cursor_pos { - Some(pos) => (display_cursor(value, pos), true), - None => (value.to_string(), false), - }; - let border = if focused { - Color::Yellow - } else { - Color::DarkGray - }; - let inner_w = area.width.saturating_sub(2).max(1) as usize; - let inner_h = area.height.saturating_sub(2).max(1) as usize; - let total_lines = visual_line_count(&display, inner_w); - let max_scroll = total_lines.saturating_sub(inner_h) as u16; - let auto_scroll = match cursor_pos { - Some(pos) => { - let cursor_line = visual_cursor_line(value, pos, inner_w); - let min_scroll = cursor_line.saturating_sub(inner_h.saturating_sub(1)) as u16; - min_scroll.min(max_scroll) - } - None => max_scroll, - }; - let scroll = scroll_override - .map(|s| s.min(max_scroll)) - .unwrap_or(auto_scroll); - Paragraph::new(display) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(border)), - ) - .wrap(Wrap { trim: false }) - .scroll((scroll, 0)) -} - -fn fmt_usd(amount: f64) -> String { - format!("${:.2}", amount) -} - -fn fmt_tokens(n: u64) -> String { - let s = n.to_string(); - let mut out = String::new(); - for (i, ch) in s.chars().rev().enumerate() { - if i > 0 && i % 3 == 0 { - out.push(','); - } - out.push(ch); - } - out.chars().rev().collect() -} - -fn spinner_char(tick: u64) -> char { - const FRAMES: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; - FRAMES[(tick as usize) % FRAMES.len()] -} - -fn render_summary( - f: &mut ratatui::Frame<'_>, - area: Rect, - snapshot: &WorkspaceSnapshot, - state: &IterState, -) { - let block = dialog_block("pitboss start"); - let inner = block.inner(area); - f.render_widget(block, area); - - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(1) - .constraints([ - Constraint::Length(1), // header - Constraint::Length(1), // spacer - Constraint::Length(6), // status block - Constraint::Length(1), // spacer - Constraint::Length(1), // "What next?" label - Constraint::Min(3), // action list - Constraint::Length(1), // reset-budget toggle - Constraint::Length(1), // footer hint - ]) - .split(inner); - - let header = if let Some(branch) = &snapshot.branch { - format!("Workspace ready • branch: {}", branch) - } else if snapshot.no_state { - "Workspace configured • no run started yet".to_string() - } else { - "Workspace ready".to_string() - }; - f.render_widget( - Paragraph::new(Line::from(vec![Span::styled( - header, - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD), - )])), - chunks[0], - ); - - let phase_line = if snapshot.no_plan { - "Plan progress plan.md missing or unparseable".to_string() - } else { - let current = snapshot - .current_phase - .as_ref() - .map(|p| p.to_string()) - .unwrap_or_else(|| "—".to_string()); - format!( - "Plan progress {} / {} phases (current: {})", - snapshot.completed_count, snapshot.total_phases, current - ) - }; - let budget_line = match snapshot.usd_cap { - Some(cap) => format!( - "Budget {} used / {} cap", - fmt_usd(snapshot.usd_used), - fmt_usd(cap) - ), - None => format!( - "Budget {} used (no cap)", - fmt_usd(snapshot.usd_used) - ), - }; - let tokens_line = match snapshot.tokens_cap { - Some(cap) => format!( - "Tokens {} / {} tokens", - fmt_tokens(snapshot.tokens_used), - fmt_tokens(cap) - ), - None => format!( - "Tokens {} tokens (no cap)", - fmt_tokens(snapshot.tokens_used) - ), - }; - let deferred_line = format!("Deferred items {} pending", snapshot.deferred_count); - let models_line = format!( - "Models planner: {} · worker: {}", - snapshot.planner_model, snapshot.implementer_model - ); - - f.render_widget( - Paragraph::new(vec![ - Line::from(Span::styled(phase_line, Style::default().fg(Color::White))), - Line::from(Span::styled(budget_line, Style::default().fg(Color::White))), - Line::from(Span::styled(tokens_line, Style::default().fg(Color::Gray))), - Line::from(Span::styled( - deferred_line, - Style::default().fg(Color::White), - )), - Line::from(Span::styled(models_line, Style::default().fg(Color::Gray))), - ]), - chunks[2], - ); - - f.render_widget( - Paragraph::new(Line::from(vec![Span::styled( - "What next?", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - )])), - chunks[4], - ); - - let items: Vec = state - .actions - .iter() - .enumerate() - .map(|(i, action)| { - let label = match action { - Action::Continue => { - if snapshot.no_state { - "Start the run (no prior state)".to_string() - } else { - "Continue run".to_string() - } - } - Action::Sweep => { - format!("Run sweep ({} deferred items)", snapshot.deferred_count) - } - Action::NewPlan => "New plan (archives current state)".to_string(), - }; - let prefix = if i == state.cursor { "► " } else { " " }; - let style = if i == state.cursor { - Style::default().fg(Color::Yellow) - } else { - Style::default().fg(Color::White) - }; - ListItem::new(Line::from(vec![Span::styled( - format!("{}{}", prefix, label), - style, - )])) - }) - .collect(); - f.render_widget(List::new(items), chunks[5]); - - let toggle_visible = !matches!(state.selected_action(), Action::NewPlan); - let toggle_line = if toggle_visible { - let mark = if state.reset_budget { "[x]" } else { "[ ]" }; - Line::from(vec![Span::styled( - format!("{} Reset budget before launching", mark), - if state.reset_budget { - Style::default().fg(Color::Yellow) - } else { - Style::default().fg(Color::Gray) - }, - )]) - } else { - Line::from(vec![Span::styled( - " (budget is reset automatically when starting a new plan)", - Style::default().fg(Color::DarkGray), - )]) - }; - f.render_widget(Paragraph::new(toggle_line), chunks[6]); - - f.render_widget( - hint("[↑↓] select [Space] toggle reset [Enter] launch [q] quit"), - chunks[7], - ); -} - -fn render_new_plan(f: &mut ratatui::Frame<'_>, area: Rect, state: &IterState) { - let block = dialog_block("pitboss start › new plan"); - let inner = block.inner(area); - f.render_widget(block, area); - - // Auto-grow: start at 1 content line (3 total with borders); grow as the - // text wraps, capped at 8 content lines (10 total). - let visual_lines = state - .goal_input - .chars() - .count() - .div_ceil(GOAL_INNER_W as usize) - .max(1); - let goal_box_h = (visual_lines.min(8) + 2) as u16; - - let interview_rows = if state.interview_enabled { 6 } else { 0 }; - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(1) - .constraints([ - Constraint::Length(1), // label - Constraint::Length(1), // spacer - Constraint::Length(2), // archive notice - Constraint::Length(1), // spacer - Constraint::Length(1), // goal label - Constraint::Length(goal_box_h), // goal input (auto-grows, scrolls) - Constraint::Length(1), // spacer - Constraint::Length(1), // interview toggle - Constraint::Length(interview_rows), // range picker (collapses when off) - Constraint::Min(1), - Constraint::Length(1), // footer hint - ]) - .split(inner); - - f.render_widget( - Paragraph::new(Line::from(vec![Span::styled( - "Describe what you want pitboss to build next.", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - )])), - chunks[0], - ); - f.render_widget( - Paragraph::new(vec![ - Line::from(Span::styled( - "Current state.json will be archived to", - Style::default().fg(Color::Gray), - )), - Line::from(Span::styled( - " .pitboss/play/state..json.bak", - Style::default().fg(Color::Gray), - )), - ]), - chunks[2], - ); - f.render_widget( - Paragraph::new(Line::from(vec![Span::styled( - "Goal", - Style::default() - .fg(field_color(state.new_plan_field, NewPlanField::Goal)) - .add_modifier(Modifier::BOLD), - )])), - chunks[4], - ); - let goal_focused = state.new_plan_field == NewPlanField::Goal; - f.render_widget( - text_input_widget( - &state.goal_input, - if goal_focused { - Some(state.goal_cursor) - } else { - None - }, - chunks[5], - state.goal_scroll, - ), - chunks[5], - ); - - let toggle_mark = if state.interview_enabled { - "[x]" - } else { - "[ ]" - }; - let toggle_focused = state.new_plan_field == NewPlanField::InterviewToggle; - f.render_widget( - Paragraph::new(Line::from(vec![Span::styled( - format!("{} Run interview questions (Space to toggle)", toggle_mark), - Style::default().fg(field_color( - state.new_plan_field, - NewPlanField::InterviewToggle, - )), - )])) - .block( - Block::default() - .borders(Borders::NONE) - .style(if toggle_focused { - Style::default().add_modifier(Modifier::BOLD) - } else { - Style::default() - }), - ), - chunks[7], - ); - - if state.interview_enabled { - let picker_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(1), // label - Constraint::Length(1), // spacer - Constraint::Min(1), // list - ]) - .split(chunks[8]); - - f.render_widget( - Paragraph::new(Line::from(vec![Span::styled( - "How many questions?", - Style::default() - .fg(field_color(state.new_plan_field, NewPlanField::RangePicker)) - .add_modifier(Modifier::BOLD), - )])), - picker_chunks[0], - ); - let items: Vec = QuestionRange::all() - .iter() - .enumerate() - .map(|(i, range)| { - let prefix = if i == state.range_cursor { - "► " - } else { - " " - }; - let focused = state.new_plan_field == NewPlanField::RangePicker; - let style = if i == state.range_cursor && focused { - Style::default().fg(Color::Yellow) - } else if i == state.range_cursor { - Style::default().fg(Color::White) - } else { - Style::default().fg(Color::DarkGray) - }; - ListItem::new(Line::from(vec![Span::styled( - format!("{}{}", prefix, range.label()), - style, - )])) - }) - .collect(); - f.render_widget(List::new(items), picker_chunks[2]); - } - - f.render_widget( - hint("[←→] cursor [Tab] cycle [PgUp/PgDn] scroll [Enter] launch [Esc] back"), - chunks[10], - ); -} - -fn field_color(focused: NewPlanField, want: NewPlanField) -> Color { - if focused == want { - Color::Yellow - } else { - Color::Gray - } -} - -fn render_thinking(f: &mut ratatui::Frame<'_>, area: Rect, title: &str, body: &str, tick: u64) { - let title = format!("pitboss start › {}", title); - let block = dialog_block(&title); - let inner = block.inner(area); - f.render_widget(block, area); - - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(2) - .constraints([ - Constraint::Min(1), - Constraint::Length(3), // spinner + message - Constraint::Min(1), - Constraint::Length(1), // footer hint - ]) - .split(inner); - - let spinner = spinner_char(tick); - f.render_widget( - Paragraph::new(vec![ - Line::from(Span::styled( - format!(" {} {}", spinner, body), - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - )), - Line::from(""), - Line::from(Span::styled( - " This can take 30s–3m depending on the model.", - Style::default().fg(Color::DarkGray), - )), - ]), - chunks[1], - ); - f.render_widget(hint("[Esc] cancel [Ctrl+C] quit"), chunks[3]); -} - -fn render_interview_qa(f: &mut ratatui::Frame<'_>, area: Rect, state: &IterState) { - let title = format!( - "pitboss start › interview ({}/{})", - state.current_q + 1, - state.questions.len() - ); - let block = dialog_block(&title); - let inner = block.inner(area); - f.render_widget(block, area); - - let q = state - .questions - .get(state.current_q) - .cloned() - .unwrap_or_default(); - - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(1) - .constraints([ - Constraint::Length(1), // header - Constraint::Length(1), // spacer - Constraint::Length(4), // question text (up to ~3 wrapped lines) - Constraint::Length(1), // spacer - Constraint::Length(8), // answer input (6 visible lines + borders, scrolls) - Constraint::Min(1), - Constraint::Length(1), // footer - ]) - .split(inner); - - f.render_widget( - Paragraph::new(Line::from(vec![Span::styled( - format!( - "Question {} of {}", - state.current_q + 1, - state.questions.len() - ), - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - )])), - chunks[0], - ); - f.render_widget( - Paragraph::new(Span::styled(q, Style::default().fg(Color::White))) - .wrap(Wrap { trim: false }), - chunks[2], - ); - f.render_widget( - text_input_widget( - &state.current_answer, - Some(state.answer_cursor), - chunks[4], - state.answer_scroll, - ), - chunks[4], - ); - f.render_widget( - hint("[Enter] next [Tab] skip [PgUp/PgDn] scroll [Esc] cancel"), - chunks[6], - ); -} - -fn render_plan_review(f: &mut ratatui::Frame<'_>, area: Rect, state: &IterState) { - let block = dialog_block("pitboss start › plan ready"); - let inner = block.inner(area); - f.render_widget(block, area); - - let phases: Vec<&Phase> = state - .generated_plan - .as_ref() - .map(|p| p.phases.iter().collect()) - .unwrap_or_default(); - - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(1) - .constraints([ - Constraint::Length(1), // header - Constraint::Length(1), // spacer - Constraint::Min(3), // phase list - Constraint::Length(1), // spacer - Constraint::Length(1), // "What next?" label - Constraint::Length(5), // choices - Constraint::Length(1), // footer - ]) - .split(inner); - - let header = format!( - "Pitboss built a {}-phase plan from your interview.", - phases.len() - ); - f.render_widget( - Paragraph::new(Line::from(vec![Span::styled( - header, - Style::default() - .fg(Color::Green) - .add_modifier(Modifier::BOLD), - )])), - chunks[0], - ); - - let phase_items: Vec = phases - .iter() - .map(|p| { - ListItem::new(Line::from(vec![Span::styled( - format!(" Phase {}: {}", p.id, p.title), - Style::default().fg(Color::White), - )])) - }) - .collect(); - f.render_widget(List::new(phase_items), chunks[2]); - - f.render_widget( - Paragraph::new(Line::from(vec![Span::styled( - "What next?", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - )])), - chunks[4], - ); - - let choice_items: Vec = PostPlanChoice::all() - .iter() - .enumerate() - .map(|(i, c)| { - let prefix = if i == state.review_cursor { - "► " - } else { - " " - }; - let color = match (c, i == state.review_cursor) { - (PostPlanChoice::Terminate, true) => Color::Red, - (_, true) => Color::Yellow, - _ => Color::White, - }; - ListItem::new(Line::from(vec![Span::styled( - format!("{}{}", prefix, c.label()), - Style::default().fg(color), - )])) - }) - .collect(); - f.render_widget(List::new(choice_items), chunks[5]); - - f.render_widget( - hint("[↑↓] select [Enter] confirm [Ctrl+C] quit"), - chunks[6], - ); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn visual_line_count_handles_wrapping_and_newlines() { - assert_eq!(visual_line_count("abcd", 4), 1); - assert_eq!(visual_line_count("abcde", 4), 2); - assert_eq!(visual_line_count("ab\ncd", 4), 2); - assert_eq!(visual_line_count("12345\nx", 4), 3); - } - - #[test] - fn visual_cursor_line_accounts_for_explicit_newlines() { - assert_eq!(visual_cursor_line("ab\ncd", 2, 4), 0); - assert_eq!(visual_cursor_line("ab\ncd", 3, 4), 1); - assert_eq!(visual_cursor_line("1234\nx", 4, 4), 1); - } - - #[test] - fn cursor_auto_scroll_supports_multiline_paste_content() { - let scroll = cursor_auto_scroll("abcd\nefgh\nijkl", 14, 4, 2); - assert_eq!(scroll, 1); - } -} diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 826d990..24227b3 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -15,8 +15,6 @@ mod app; pub mod grind; -pub mod iteration; -pub mod wizard; pub use app::{Activity, AgentDisplay, App, PhaseStatus, UsageView, OUTPUT_BUFFER_LINES}; @@ -25,8 +23,8 @@ use std::time::Duration; use anyhow::{Context, Result}; use crossterm::event::{ - DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, - Event as CtEvent, EventStream, KeyCode, KeyEventKind, KeyModifiers, + DisableMouseCapture, EnableMouseCapture, Event as CtEvent, EventStream, KeyCode, KeyEventKind, + KeyModifiers, }; use crossterm::execute; use crossterm::terminal::{ @@ -187,12 +185,7 @@ impl TerminalGuard { pub(crate) fn setup() -> Result { enable_raw_mode()?; let mut stdout = io::stdout(); - if let Err(e) = execute!( - stdout, - EnterAlternateScreen, - EnableMouseCapture, - EnableBracketedPaste - ) { + if let Err(e) = execute!(stdout, EnterAlternateScreen, EnableMouseCapture) { let _ = disable_raw_mode(); return Err(e.into()); } @@ -222,8 +215,7 @@ impl TerminalGuard { execute!( self.terminal.backend_mut(), LeaveAlternateScreen, - DisableMouseCapture, - DisableBracketedPaste + DisableMouseCapture )?; self.terminal.show_cursor()?; self.active = false; @@ -241,8 +233,7 @@ impl Drop for TerminalGuard { let _ = execute!( self.terminal.backend_mut(), LeaveAlternateScreen, - DisableMouseCapture, - DisableBracketedPaste + DisableMouseCapture ); let _ = self.terminal.show_cursor(); } diff --git a/src/tui/wizard.rs b/src/tui/wizard.rs deleted file mode 100644 index 08227f3..0000000 --- a/src/tui/wizard.rs +++ /dev/null @@ -1,1318 +0,0 @@ -//! Interactive TUI wizard for `pitboss setup`. -//! -//! Walks new users through pitboss's main configuration knobs — model tier, -//! budget caps, sweep behavior, auditor, test command — with one explanatory -//! screen per topic so the user understands what each setting does. Most -//! screens have sensible defaults (Enter accepts). - -use std::path::Path; - -use anyhow::Result; -use crossterm::event::{Event as CtEvent, EventStream, KeyCode, KeyEventKind, KeyModifiers}; -use futures::StreamExt; -use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::style::{Color, Modifier, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap}; - -use crate::config::Config; -use crate::tui::{TerminalGuard, TICK_INTERVAL}; - -/// The result of a completed wizard run. No goal field — the wizard -/// collects configuration only; the plan goal is captured later via -/// `pitboss start` (iteration wizard's "New plan" path) or hand-written -/// into `plan.md`. -pub struct WizardResult { - pub model_preset: ModelPreset, - /// Token cap for `[budgets] max_total_tokens`. `None` = no cap. - pub max_run_tokens: Option, - /// USD cap for `[budgets] max_total_usd`. `None` = no cap. - pub max_total_usd: Option, - /// Whether `[sweep] enabled` is true (default true). - pub sweep_enabled: bool, - /// Override for `[sweep] trigger_min_items`. `None` = pitboss default (5). - pub sweep_threshold: Option, - /// Whether `[audit] enabled` is true (default true). - pub audit_enabled: bool, - /// Override for `[tests] command`. `None` = use auto-detection. - pub test_command_override: Option, -} - -#[derive(Clone, Copy, PartialEq, Default)] -pub enum ModelPreset { - #[default] - Quality, - Balanced, - Fast, -} - -impl ModelPreset { - pub fn planner(self) -> &'static str { - match self { - Self::Quality | Self::Balanced => "claude-opus-4-7", - Self::Fast => "claude-sonnet-4-6", - } - } - - pub fn worker(self) -> &'static str { - match self { - Self::Quality => "claude-opus-4-7", - Self::Balanced | Self::Fast => "claude-sonnet-4-6", - } - } - - fn label(self) -> &'static str { - match self { - Self::Quality => "Best quality (Opus 4.7 — all roles)", - Self::Balanced => "Balanced (Opus plan · Sonnet rest)", - Self::Fast => "Fastest (Sonnet 4.6 — all roles)", - } - } - - fn explainer(self) -> &'static str { - match self { - Self::Quality => { - "Opus everywhere. Strongest planning + implementation; highest cost." - } - Self::Balanced => { - "Opus drafts the plan, Sonnet executes phases + fixes + audits. Good cost/quality middle ground." - } - Self::Fast => "Sonnet everywhere. Cheapest, fastest, slightly weaker on complex phases.", - } - } -} - -#[derive(Clone, PartialEq)] -enum Step { - Welcome, - Models, - Budget, - Sweep, - Audit, - Tests, - Confirm, -} - -#[derive(Clone, Copy, PartialEq)] -enum BudgetField { - Usd, - Tokens, -} - -#[derive(Clone, Copy, PartialEq)] -enum SweepField { - Toggle, - Threshold, -} - -struct WizState { - step: Step, - /// When true, the wizard is editing an existing workspace's config — - /// skip Welcome, start on Confirm (acting as a summary), pre-populate - /// inputs from the loaded Config, and never touch plan.md / deferred.md. - existing_mode: bool, - - // Models - model_cursor: usize, - - // Budget - budget_usd_input: String, - budget_tokens_input: String, - budget_field: BudgetField, - - // Sweep - sweep_enabled: bool, - sweep_threshold_input: String, - sweep_field: SweepField, - - // Audit - audit_enabled: bool, - - // Tests - test_input: String, - detected_test: String, - - // Confirm - confirm_cursor: usize, -} - -impl WizState { - fn new(workspace: &Path) -> Self { - Self { - step: Step::Welcome, - existing_mode: false, - model_cursor: 0, - budget_usd_input: String::new(), - budget_tokens_input: String::new(), - budget_field: BudgetField::Usd, - sweep_enabled: true, - sweep_threshold_input: String::new(), - sweep_field: SweepField::Toggle, - audit_enabled: true, - test_input: String::new(), - detected_test: detect_test(workspace), - confirm_cursor: 0, - } - } - - /// Pre-populated from a loaded [`Config`]. Starts the wizard on the - /// Confirm step — that screen doubles as a summary for users coming in - /// via `pitboss config` on an existing workspace. - fn from_existing(workspace: &Path, cfg: &Config) -> Self { - // Match the user's current model selection back to one of the three - // preset slots. Anything off-preset (custom model names) falls back - // to Quality so the user can re-pick. - let model_cursor = match (cfg.models.planner.as_str(), cfg.models.implementer.as_str()) { - ("claude-opus-4-7", "claude-opus-4-7") => 0, - ("claude-opus-4-7", "claude-sonnet-4-6") => 1, - ("claude-sonnet-4-6", "claude-sonnet-4-6") => 2, - _ => 0, - }; - Self { - step: Step::Confirm, - existing_mode: true, - model_cursor, - budget_usd_input: cfg - .budgets - .max_total_usd - .map(|c| format!("{c:.2}")) - .unwrap_or_default(), - budget_tokens_input: cfg - .budgets - .max_total_tokens - .map(|c| c.to_string()) - .unwrap_or_default(), - budget_field: BudgetField::Usd, - sweep_enabled: cfg.sweep.enabled, - sweep_threshold_input: cfg.sweep.trigger_min_items.to_string(), - sweep_field: SweepField::Toggle, - audit_enabled: cfg.audit.enabled, - test_input: cfg.tests.command.clone().unwrap_or_default(), - detected_test: detect_test(workspace), - confirm_cursor: 0, - } - } - - fn model_preset(&self) -> ModelPreset { - match self.model_cursor { - 0 => ModelPreset::Quality, - 1 => ModelPreset::Balanced, - _ => ModelPreset::Fast, - } - } - - fn build_result(&self) -> WizardResult { - WizardResult { - model_preset: self.model_preset(), - max_run_tokens: self.budget_tokens_input.trim().parse::().ok(), - max_total_usd: self.budget_usd_input.trim().parse::().ok(), - sweep_enabled: self.sweep_enabled, - sweep_threshold: self.sweep_threshold_input.trim().parse::().ok(), - audit_enabled: self.audit_enabled, - test_command_override: { - let t = self.test_input.trim(); - if t.is_empty() { - None - } else { - Some(t.to_string()) - } - }, - } - } -} - -enum Ev { - Continue, - Quit, - Done(WizardResult), -} - -/// Probe for the most likely test runner based on project layout. Probe order -/// is "most common in pitboss's expected user base" (Rust → Node → Python → -/// Go). Returns a display hint shown on the Tests screen; `build_result` uses -/// `test_input` (the user override), not this value directly. -fn detect_test(workspace: &Path) -> String { - if workspace.join("Cargo.toml").is_file() { - "cargo test".into() - } else if workspace.join("package.json").is_file() { - "npm test".into() - } else if workspace.join("pyproject.toml").is_file() || workspace.join("setup.py").is_file() { - "pytest".into() - } else if workspace.join("go.mod").is_file() { - "go test ./...".into() - } else { - "not detected".into() - } -} - -/// Entry point for a fresh-workspace setup. Walks the user through every -/// step starting at Welcome. -pub async fn run_wizard(workspace: &Path) -> Result> { - run_wizard_inner(WizState::new(workspace)).await -} - -/// Entry point for editing an existing workspace's config. Pre-populates -/// every field from the loaded [`Config`] and starts the wizard on the -/// Confirm step so the user sees a summary first. -pub async fn run_wizard_existing(workspace: &Path, cfg: &Config) -> Result> { - run_wizard_inner(WizState::from_existing(workspace, cfg)).await -} - -async fn run_wizard_inner(initial_state: WizState) -> Result> { - let mut guard = TerminalGuard::setup()?; - // Force a real clear-screen escape. `Clear` widget alone is a no-op on - // first draw because the ratatui diff compares against an empty buffer — - // so prior terminal contents leak through the gaps around our centered - // dialog. `terminal.clear()` sends the actual `ED` sequence. - guard.terminal().clear()?; - let mut state = initial_state; - let mut input = EventStream::new(); - - let result = loop { - guard.terminal().draw(|f| render(f, &state))?; - - tokio::select! { - ev = input.next() => { - match ev { - Some(Ok(CtEvent::Key(key))) if key.kind == KeyEventKind::Press => { - match on_key(&mut state, key.code, key.modifiers) { - Ev::Continue => {} - Ev::Quit => break None, - Ev::Done(r) => break Some(r), - } - } - Some(Ok(CtEvent::Paste(text))) => match on_paste(&mut state, &text) { - Ev::Continue => {} - Ev::Quit => break None, - Ev::Done(r) => break Some(r), - }, - Some(Ok(_)) => {} - Some(Err(e)) => return Err(e.into()), - None => break None, - } - } - _ = tokio::time::sleep(TICK_INTERVAL) => {} - } - }; - - guard.restore()?; - Ok(result) -} - -fn on_key(s: &mut WizState, code: KeyCode, mods: KeyModifiers) -> Ev { - if matches!(code, KeyCode::Char('c')) && mods.contains(KeyModifiers::CONTROL) { - return Ev::Quit; - } - match s.step.clone() { - Step::Welcome => welcome_key(s, code), - Step::Models => models_key(s, code), - Step::Budget => budget_key(s, code), - Step::Sweep => sweep_key(s, code), - Step::Audit => audit_key(s, code), - Step::Tests => tests_key(s, code), - Step::Confirm => confirm_key(s, code), - } -} - -fn on_paste(s: &mut WizState, text: &str) -> Ev { - if !matches!(s.step, Step::Budget | Step::Sweep | Step::Tests) { - return Ev::Continue; - } - for ch in text.chars() { - match on_key(s, KeyCode::Char(ch), KeyModifiers::empty()) { - Ev::Continue => {} - Ev::Quit => return Ev::Quit, - Ev::Done(result) => return Ev::Done(result), - } - } - Ev::Continue -} - -fn welcome_key(s: &mut WizState, code: KeyCode) -> Ev { - match code { - KeyCode::Enter | KeyCode::Char(' ') => { - s.step = Step::Models; - Ev::Continue - } - KeyCode::Char('q') => Ev::Quit, - _ => Ev::Continue, - } -} - -fn models_key(s: &mut WizState, code: KeyCode) -> Ev { - match code { - KeyCode::Up => { - s.model_cursor = s.model_cursor.saturating_sub(1); - Ev::Continue - } - KeyCode::Down => { - if s.model_cursor < 2 { - s.model_cursor += 1; - } - Ev::Continue - } - KeyCode::Enter => { - s.step = Step::Budget; - Ev::Continue - } - KeyCode::Esc => { - // Edit mode entered the wizard at Confirm and only walks - // through config screens; Esc returns there. Create mode - // came from Welcome. - s.step = if s.existing_mode { - Step::Confirm - } else { - Step::Welcome - }; - Ev::Continue - } - _ => Ev::Continue, - } -} - -fn budget_key(s: &mut WizState, code: KeyCode) -> Ev { - match code { - KeyCode::Enter => { - s.step = Step::Sweep; - Ev::Continue - } - KeyCode::Esc => { - s.step = Step::Models; - Ev::Continue - } - KeyCode::Tab | KeyCode::Down => { - s.budget_field = match s.budget_field { - BudgetField::Usd => BudgetField::Tokens, - BudgetField::Tokens => BudgetField::Usd, - }; - Ev::Continue - } - KeyCode::BackTab | KeyCode::Up => { - s.budget_field = match s.budget_field { - BudgetField::Usd => BudgetField::Tokens, - BudgetField::Tokens => BudgetField::Usd, - }; - Ev::Continue - } - KeyCode::Char(c) => { - // Allow digits and a single '.' in USD; digits only in tokens. - let target = match s.budget_field { - BudgetField::Usd => &mut s.budget_usd_input, - BudgetField::Tokens => &mut s.budget_tokens_input, - }; - let ok = match s.budget_field { - BudgetField::Usd => c.is_ascii_digit() || (c == '.' && !target.contains('.')), - BudgetField::Tokens => c.is_ascii_digit(), - }; - if ok { - target.push(c); - } - Ev::Continue - } - KeyCode::Backspace => { - let target = match s.budget_field { - BudgetField::Usd => &mut s.budget_usd_input, - BudgetField::Tokens => &mut s.budget_tokens_input, - }; - target.pop(); - Ev::Continue - } - _ => Ev::Continue, - } -} - -fn sweep_key(s: &mut WizState, code: KeyCode) -> Ev { - match code { - KeyCode::Enter => { - s.step = Step::Audit; - Ev::Continue - } - KeyCode::Esc => { - s.step = Step::Budget; - Ev::Continue - } - KeyCode::Tab | KeyCode::Down | KeyCode::Up | KeyCode::BackTab => { - // Threshold field is only meaningful when sweeps are enabled; - // skip past it when disabled. - s.sweep_field = match (s.sweep_field, s.sweep_enabled) { - (SweepField::Toggle, true) => SweepField::Threshold, - _ => SweepField::Toggle, - }; - Ev::Continue - } - KeyCode::Char(' ') if s.sweep_field == SweepField::Toggle => { - s.sweep_enabled = !s.sweep_enabled; - Ev::Continue - } - KeyCode::Char(c) if s.sweep_field == SweepField::Threshold && c.is_ascii_digit() => { - s.sweep_threshold_input.push(c); - Ev::Continue - } - KeyCode::Backspace if s.sweep_field == SweepField::Threshold => { - s.sweep_threshold_input.pop(); - Ev::Continue - } - _ => Ev::Continue, - } -} - -fn audit_key(s: &mut WizState, code: KeyCode) -> Ev { - match code { - KeyCode::Enter => { - s.step = Step::Tests; - Ev::Continue - } - KeyCode::Esc => { - s.step = Step::Sweep; - Ev::Continue - } - KeyCode::Char(' ') => { - s.audit_enabled = !s.audit_enabled; - Ev::Continue - } - _ => Ev::Continue, - } -} - -fn tests_key(s: &mut WizState, code: KeyCode) -> Ev { - match code { - KeyCode::Enter => { - s.step = Step::Confirm; - Ev::Continue - } - KeyCode::Esc => { - s.step = Step::Audit; - Ev::Continue - } - KeyCode::Char(c) => { - s.test_input.push(c); - Ev::Continue - } - KeyCode::Backspace => { - s.test_input.pop(); - Ev::Continue - } - _ => Ev::Continue, - } -} - -fn confirm_key(s: &mut WizState, code: KeyCode) -> Ev { - match code { - KeyCode::Up => { - s.confirm_cursor = s.confirm_cursor.saturating_sub(1); - Ev::Continue - } - KeyCode::Down => { - if s.confirm_cursor < 1 { - s.confirm_cursor += 1; - } - Ev::Continue - } - KeyCode::Enter => match s.confirm_cursor { - 0 => Ev::Done(s.build_result()), - _ => { - // "Back to edit settings" — re-walk the config screens - // starting at Models. Same target in both modes since the - // wizard no longer collects a goal. - s.step = Step::Models; - Ev::Continue - } - }, - KeyCode::Esc => { - // Edit mode: Esc on the summary screen cancels the whole wizard - // (so the user can back out without changes). Create mode keeps - // its previous "go back to Tests" behavior. - if s.existing_mode { - return Ev::Quit; - } - s.step = Step::Tests; - Ev::Continue - } - KeyCode::Char('q') => Ev::Quit, - _ => Ev::Continue, - } -} - -// ── rendering ──────────────────────────────────────────────────────────────── - -fn render(f: &mut ratatui::Frame<'_>, state: &WizState) { - let area = f.area(); - // Clear the whole screen first so scrollback behind the alternate-screen - // buffer doesn't bleed through the gaps around the centered dialog. - f.render_widget(Clear, area); - - let dialog = centered_rect(80, 32, area); - match state.step { - Step::Welcome => render_welcome(f, dialog, state), - Step::Models => render_models(f, dialog, state), - Step::Budget => render_budget(f, dialog, state), - Step::Sweep => render_sweep(f, dialog, state), - Step::Audit => render_audit(f, dialog, state), - Step::Tests => render_tests(f, dialog, state), - Step::Confirm => render_confirm(f, dialog, state), - } -} - -fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { - let x = area.x + area.width.saturating_sub(width) / 2; - let y = area.y + area.height.saturating_sub(height) / 2; - Rect::new(x, y, width.min(area.width), height.min(area.height)) -} - -fn dialog_block(title: &str) -> Block<'_> { - Block::default() - .title(format!(" {} ", title)) - .title_style( - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ) - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::DarkGray)) -} - -fn hint(text: &'static str) -> Paragraph<'static> { - Paragraph::new(Line::from(vec![Span::styled( - text, - Style::default().fg(Color::DarkGray), - )])) -} - -fn text_input_widget(value: &str, focused: bool, area: Rect) -> Paragraph<'_> { - let display = if focused { - format!("{}█", value) - } else { - value.to_string() - }; - let scroll = compute_input_scroll(&display, area); - let border = if focused { - Color::Yellow - } else { - Color::DarkGray - }; - Paragraph::new(display) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(border)), - ) - .wrap(Wrap { trim: false }) - .scroll((scroll, 0)) -} - -/// Vertical scroll so the cursor stays visible. Simpler than -/// `tui::iteration::cursor_auto_scroll` because the wizard's inputs are -/// append-only — the cursor is always at the end of `display`, so scrolling -/// just needs to ensure the last line is in view. Kept local to avoid a -/// cross-module dependency for a three-line calculation. -fn compute_input_scroll(display: &str, area: Rect) -> u16 { - let inner_w = area.width.saturating_sub(2).max(1); - let inner_h = area.height.saturating_sub(2); - let total_chars = display.chars().count() as u16; - let needed = total_chars.max(1).div_ceil(inner_w); - needed.saturating_sub(inner_h) -} - -// Crumb shown at the top of every step so the user knows where they are. -// Edit mode has 5 config steps (Models→Tests) since Welcome and Goal are -// skipped; the summary screen sits outside the numbered flow. -fn step_crumb(state: &WizState) -> Line<'_> { - let text = if state.existing_mode { - match state.step { - Step::Models => "step 1/5 • models".to_string(), - Step::Budget => "step 2/5 • budget".to_string(), - Step::Sweep => "step 3/5 • sweeps".to_string(), - Step::Audit => "step 4/5 • auditor".to_string(), - Step::Tests => "step 5/5 • tests".to_string(), - Step::Confirm => "current configuration".to_string(), - // Welcome isn't reachable in edit mode, fall back gracefully. - Step::Welcome => "current configuration".to_string(), - } - } else { - match state.step { - Step::Welcome => "step 0/6 • welcome".to_string(), - Step::Models => "step 1/6 • models".to_string(), - Step::Budget => "step 2/6 • budget".to_string(), - Step::Sweep => "step 3/6 • sweeps".to_string(), - Step::Audit => "step 4/6 • auditor".to_string(), - Step::Tests => "step 5/6 • tests".to_string(), - Step::Confirm => "step 6/6 • ready".to_string(), - } - }; - Line::from(Span::styled(text, Style::default().fg(Color::DarkGray))) -} - -fn render_welcome(f: &mut ratatui::Frame<'_>, area: Rect, state: &WizState) { - let block = dialog_block("pitboss setup"); - let inner = block.inner(area); - f.render_widget(block, area); - - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(1) - .constraints([ - Constraint::Length(1), // crumb - Constraint::Length(1), // spacer - Constraint::Length(3), // tagline - Constraint::Length(1), // spacer - Constraint::Length(1), // "What we'll cover" label - Constraint::Min(1), // step list - Constraint::Length(1), // footer - ]) - .split(inner); - - f.render_widget(Paragraph::new(step_crumb(state)), chunks[0]); - - f.render_widget( - Paragraph::new(vec![ - Line::from(Span::styled( - "pitboss runs AI agents through a phased plan you write.", - Style::default().fg(Color::White), - )), - Line::from(Span::styled( - "Setup is a quick walkthrough of how the runner should behave —", - Style::default().fg(Color::White), - )), - Line::from(Span::styled( - "models, budget caps, sweeps, auditor, tests.", - Style::default().fg(Color::White), - )), - ]) - .wrap(Wrap { trim: false }), - chunks[2], - ); - - f.render_widget( - Paragraph::new(Line::from(vec![Span::styled( - "What we'll cover:", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - )])), - chunks[4], - ); - - let steps = vec![ - " 1. Models — quality vs. cost tradeoff", - " 2. Budget — USD + token caps (optional)", - " 3. Sweeps — how pitboss drains deferred work", - " 4. Auditor — reviews each phase's diff before commit", - " 5. Tests — the suite pitboss runs after every phase", - " 6. Ready — save your config and you're done", - "", - " Every screen has a sensible default — press Enter to accept.", - " Press Esc to go back to the previous step.", - "", - " (Describe what pitboss should build later via `pitboss start`,", - " or edit .pitboss/play/plan.md directly.)", - ]; - let lines: Vec = steps - .iter() - .map(|s| { - let style = if s.starts_with(" Every") || s.starts_with(" Press") { - Style::default().fg(Color::DarkGray) - } else { - Style::default().fg(Color::White) - }; - Line::from(Span::styled(*s, style)) - }) - .collect(); - f.render_widget(Paragraph::new(lines), chunks[5]); - - f.render_widget(hint("[Enter] start [q] quit"), chunks[6]); -} - -fn render_models(f: &mut ratatui::Frame<'_>, area: Rect, state: &WizState) { - let block = dialog_block("pitboss setup"); - let inner = block.inner(area); - f.render_widget(block, area); - - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(1) - .constraints([ - Constraint::Length(1), // crumb - Constraint::Length(1), // spacer - Constraint::Length(1), // label - Constraint::Length(2), // explanation - Constraint::Length(1), // spacer - Constraint::Length(5), // model list - Constraint::Length(1), // spacer - Constraint::Length(2), // selected explainer - Constraint::Min(1), - Constraint::Length(1), // footer - ]) - .split(inner); - - f.render_widget(Paragraph::new(step_crumb(state)), chunks[0]); - f.render_widget( - Paragraph::new(Line::from(vec![Span::styled( - "Pick a model tier", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - )])), - chunks[2], - ); - f.render_widget( - Paragraph::new(vec![ - Line::from(Span::styled( - "pitboss uses different models for planning, implementing, auditing,", - Style::default().fg(Color::Gray), - )), - Line::from(Span::styled( - "and fixing. The tier picks all four. You can edit individually later.", - Style::default().fg(Color::Gray), - )), - ]), - chunks[3], - ); - - let presets = [ - ModelPreset::Quality, - ModelPreset::Balanced, - ModelPreset::Fast, - ]; - let items: Vec = presets - .iter() - .enumerate() - .map(|(i, preset)| { - let prefix = if i == state.model_cursor { - "► " - } else { - " " - }; - let style = if i == state.model_cursor { - Style::default().fg(Color::Yellow) - } else { - Style::default().fg(Color::White) - }; - ListItem::new(Line::from(vec![Span::styled( - format!("{}{}", prefix, preset.label()), - style, - )])) - }) - .collect(); - f.render_widget(List::new(items), chunks[5]); - - f.render_widget( - Paragraph::new(Line::from(vec![Span::styled( - state.model_preset().explainer(), - Style::default().fg(Color::DarkGray), - )])) - .wrap(Wrap { trim: false }), - chunks[7], - ); - - f.render_widget(hint("[↑↓] select [Enter] next [Esc] back"), chunks[9]); -} - -fn render_budget(f: &mut ratatui::Frame<'_>, area: Rect, state: &WizState) { - let block = dialog_block("pitboss setup"); - let inner = block.inner(area); - f.render_widget(block, area); - - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(1) - .constraints([ - Constraint::Length(1), // crumb - Constraint::Length(1), // spacer - Constraint::Length(1), // label - Constraint::Length(3), // explanation - Constraint::Length(1), // spacer - Constraint::Length(1), // usd label - Constraint::Length(3), // usd input - Constraint::Length(1), // tokens label - Constraint::Length(3), // tokens input - Constraint::Min(1), - Constraint::Length(1), // footer - ]) - .split(inner); - - f.render_widget(Paragraph::new(step_crumb(state)), chunks[0]); - f.render_widget( - Paragraph::new(Line::from(vec![Span::styled( - "Budget caps (optional)", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - )])), - chunks[2], - ); - f.render_widget( - Paragraph::new(vec![ - Line::from(Span::styled( - "pitboss halts before the next dispatch that would exceed either", - Style::default().fg(Color::Gray), - )), - Line::from(Span::styled( - "cap. Leave either blank for no limit. `pitboss rebuy` resumes", - Style::default().fg(Color::Gray), - )), - Line::from(Span::styled( - "after you raise the cap.", - Style::default().fg(Color::Gray), - )), - ]), - chunks[3], - ); - - let usd_focused = state.budget_field == BudgetField::Usd; - let tokens_focused = state.budget_field == BudgetField::Tokens; - - f.render_widget( - Paragraph::new(Line::from(vec![Span::styled( - "Max USD spend (e.g. 5.00)", - Style::default().fg(if usd_focused { - Color::Yellow - } else { - Color::Gray - }), - )])), - chunks[5], - ); - f.render_widget( - text_input_widget(&state.budget_usd_input, usd_focused, chunks[6]), - chunks[6], - ); - f.render_widget( - Paragraph::new(Line::from(vec![Span::styled( - "Max tokens (e.g. 1000000)", - Style::default().fg(if tokens_focused { - Color::Yellow - } else { - Color::Gray - }), - )])), - chunks[7], - ); - f.render_widget( - text_input_widget(&state.budget_tokens_input, tokens_focused, chunks[8]), - chunks[8], - ); - - f.render_widget( - hint("[Tab] switch field [Enter] next [Esc] back"), - chunks[10], - ); -} - -fn render_sweep(f: &mut ratatui::Frame<'_>, area: Rect, state: &WizState) { - let block = dialog_block("pitboss setup"); - let inner = block.inner(area); - f.render_widget(block, area); - - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(1) - .constraints([ - Constraint::Length(1), // crumb - Constraint::Length(1), // spacer - Constraint::Length(1), // label - Constraint::Length(4), // explanation - Constraint::Length(1), // spacer - Constraint::Length(1), // toggle - Constraint::Length(1), // spacer - Constraint::Length(1), // threshold label - Constraint::Length(3), // threshold input - Constraint::Min(1), - Constraint::Length(1), // footer - ]) - .split(inner); - - f.render_widget(Paragraph::new(step_crumb(state)), chunks[0]); - f.render_widget( - Paragraph::new(Line::from(vec![Span::styled( - "Deferred-item sweeps", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - )])), - chunks[2], - ); - f.render_widget( - Paragraph::new(vec![ - Line::from(Span::styled( - "When agents can't finish something in a phase, it lands in", - Style::default().fg(Color::Gray), - )), - Line::from(Span::styled( - "deferred.md. A sweep is a side dispatch between phases that", - Style::default().fg(Color::Gray), - )), - Line::from(Span::styled( - "drains the backlog. Triggered when the unchecked count crosses", - Style::default().fg(Color::Gray), - )), - Line::from(Span::styled( - "the threshold. Recommended on.", - Style::default().fg(Color::Gray), - )), - ]), - chunks[3], - ); - - let toggle_focused = state.sweep_field == SweepField::Toggle; - let mark = if state.sweep_enabled { "[x]" } else { "[ ]" }; - f.render_widget( - Paragraph::new(Line::from(vec![Span::styled( - format!("{} Enable sweeps (Space to toggle)", mark), - Style::default().fg(if toggle_focused { - Color::Yellow - } else { - Color::White - }), - )])), - chunks[5], - ); - - let threshold_focused = state.sweep_field == SweepField::Threshold; - let threshold_color = if !state.sweep_enabled { - Color::DarkGray - } else if threshold_focused { - Color::Yellow - } else { - Color::Gray - }; - f.render_widget( - Paragraph::new(Line::from(vec![Span::styled( - "Trigger threshold (unchecked items before a sweep; default 5)", - Style::default().fg(threshold_color), - )])), - chunks[7], - ); - f.render_widget( - text_input_widget( - &state.sweep_threshold_input, - threshold_focused && state.sweep_enabled, - chunks[8], - ), - chunks[8], - ); - - f.render_widget( - hint("[Tab] switch [Space] toggle [Enter] next [Esc] back"), - chunks[10], - ); -} - -fn render_audit(f: &mut ratatui::Frame<'_>, area: Rect, state: &WizState) { - let block = dialog_block("pitboss setup"); - let inner = block.inner(area); - f.render_widget(block, area); - - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(1) - .constraints([ - Constraint::Length(1), // crumb - Constraint::Length(1), // spacer - Constraint::Length(1), // label - Constraint::Length(4), // explanation - Constraint::Length(1), // spacer - Constraint::Length(1), // toggle - Constraint::Min(1), - Constraint::Length(1), // footer - ]) - .split(inner); - - f.render_widget(Paragraph::new(step_crumb(state)), chunks[0]); - f.render_widget( - Paragraph::new(Line::from(vec![Span::styled( - "Auditor pass", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - )])), - chunks[2], - ); - f.render_widget( - Paragraph::new(vec![ - Line::from(Span::styled( - "After tests pass, the auditor reviews the staged diff. Small", - Style::default().fg(Color::Gray), - )), - Line::from(Span::styled( - "fixes are inlined; larger findings go to deferred.md for the", - Style::default().fg(Color::Gray), - )), - Line::from(Span::styled( - "next sweep. Adds quality at the cost of an extra agent call", - Style::default().fg(Color::Gray), - )), - Line::from(Span::styled( - "per phase. Recommended on.", - Style::default().fg(Color::Gray), - )), - ]), - chunks[3], - ); - - let mark = if state.audit_enabled { "[x]" } else { "[ ]" }; - f.render_widget( - Paragraph::new(Line::from(vec![Span::styled( - format!("{} Enable auditor (Space to toggle)", mark), - Style::default().fg(Color::Yellow), - )])), - chunks[5], - ); - - f.render_widget( - hint("[Space] toggle [Enter] next [Esc] back"), - chunks[7], - ); -} - -fn render_tests(f: &mut ratatui::Frame<'_>, area: Rect, state: &WizState) { - let block = dialog_block("pitboss setup"); - let inner = block.inner(area); - f.render_widget(block, area); - - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(1) - .constraints([ - Constraint::Length(1), // crumb - Constraint::Length(1), // spacer - Constraint::Length(1), // label - Constraint::Length(3), // explanation - Constraint::Length(1), // detected line - Constraint::Length(1), // spacer - Constraint::Length(1), // override label - Constraint::Length(3), // override input - Constraint::Min(1), - Constraint::Length(1), // footer - ]) - .split(inner); - - f.render_widget(Paragraph::new(step_crumb(state)), chunks[0]); - f.render_widget( - Paragraph::new(Line::from(vec![Span::styled( - "Test command", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - )])), - chunks[2], - ); - f.render_widget( - Paragraph::new(vec![ - Line::from(Span::styled( - "pitboss runs your test suite after every phase. If tests fail,", - Style::default().fg(Color::Gray), - )), - Line::from(Span::styled( - "the fixer agent is dispatched up to retries.fixer_max_attempts", - Style::default().fg(Color::Gray), - )), - Line::from(Span::styled( - "times before halting.", - Style::default().fg(Color::Gray), - )), - ]), - chunks[3], - ); - f.render_widget( - Paragraph::new(Line::from(vec![Span::styled( - format!("Auto-detected: {}", state.detected_test), - Style::default().fg(Color::Cyan), - )])), - chunks[4], - ); - f.render_widget( - Paragraph::new(Line::from(vec![Span::styled( - "Override (blank = keep detected):", - Style::default().fg(Color::Yellow), - )])), - chunks[6], - ); - f.render_widget( - text_input_widget(&state.test_input, true, chunks[7]), - chunks[7], - ); - f.render_widget(hint("[Enter] next [Esc] back"), chunks[9]); -} - -fn render_confirm(f: &mut ratatui::Frame<'_>, area: Rect, state: &WizState) { - let title = if state.existing_mode { - "pitboss config" - } else { - "pitboss config ✓" - }; - let block = dialog_block(title); - let inner = block.inner(area); - f.render_widget(block, area); - - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(1) - .constraints([ - Constraint::Length(1), // crumb - Constraint::Length(1), // spacer - Constraint::Length(1), // "Your settings:" label - Constraint::Length(8), // summary - Constraint::Length(1), // spacer - Constraint::Length(1), // "What next?" label - Constraint::Min(3), // choices - Constraint::Length(1), // footer - ]) - .split(inner); - - f.render_widget(Paragraph::new(step_crumb(state)), chunks[0]); - - let summary_header = if state.existing_mode { - "Current configuration:" - } else { - "Your settings:" - }; - f.render_widget( - Paragraph::new(Line::from(vec![Span::styled( - summary_header, - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - )])), - chunks[2], - ); - - let usd = if state.budget_usd_input.trim().is_empty() { - "no cap".to_string() - } else { - format!("${}", state.budget_usd_input.trim()) - }; - let tokens = if state.budget_tokens_input.trim().is_empty() { - "no cap".to_string() - } else { - format!("{} tokens", state.budget_tokens_input.trim()) - }; - let sweep_label = if state.sweep_enabled { - let threshold = if state.sweep_threshold_input.trim().is_empty() { - "5 (default)".to_string() - } else { - state.sweep_threshold_input.trim().to_string() - }; - format!("enabled (threshold: {})", threshold) - } else { - "disabled".to_string() - }; - let audit_label = if state.audit_enabled { - "enabled" - } else { - "disabled" - }; - let test_label = if state.test_input.trim().is_empty() { - format!("auto-detected ({})", state.detected_test) - } else { - state.test_input.trim().to_string() - }; - - // The wizard collects config only — the plan goal lives in plan.md and - // is captured by `pitboss start` (iteration wizard) or hand-edited. - let mut summary: Vec = Vec::new(); - summary.extend([ - Line::from(Span::styled( - format!( - " Models {} / {}", - state.model_preset().planner(), - state.model_preset().worker() - ), - Style::default().fg(Color::White), - )), - Line::from(Span::styled( - format!(" Budget {} USD • {}", usd, tokens), - Style::default().fg(Color::White), - )), - Line::from(Span::styled( - format!(" Sweeps {}", sweep_label), - Style::default().fg(Color::White), - )), - Line::from(Span::styled( - format!(" Auditor {}", audit_label), - Style::default().fg(Color::White), - )), - Line::from(Span::styled( - format!(" Tests {}", test_label), - Style::default().fg(Color::White), - )), - ]); - f.render_widget(Paragraph::new(summary), chunks[3]); - - f.render_widget( - Paragraph::new(Line::from(vec![Span::styled( - "What next?", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - )])), - chunks[5], - ); - - let choices: [&str; 2] = if state.existing_mode { - [ - "Save changes (rewrites .pitboss/config.toml)", - "Edit settings", - ] - } else { - [ - "Save settings (you'll write plan.md next)", - "Back to edit settings", - ] - }; - let items: Vec = choices - .iter() - .enumerate() - .map(|(i, label)| { - let prefix = if i == state.confirm_cursor { - "► " - } else { - " " - }; - let style = if i == state.confirm_cursor { - Style::default().fg(Color::Yellow) - } else { - Style::default().fg(Color::White) - }; - ListItem::new(Line::from(vec![Span::styled( - format!("{}{}", prefix, label), - style, - )])) - }) - .collect(); - f.render_widget(List::new(items), chunks[6]); - f.render_widget( - hint("[↑↓] select [Enter] confirm [Esc] back"), - chunks[7], - ); -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::tempdir; - - #[test] - fn paste_budget_input_respects_existing_field_rules() { - let dir = tempdir().unwrap(); - let mut state = WizState::new(dir.path()); - state.step = Step::Budget; - - state.budget_field = BudgetField::Usd; - on_paste(&mut state, "12a.3.4"); - assert_eq!(state.budget_usd_input, "12.34"); - - state.budget_field = BudgetField::Tokens; - on_paste(&mut state, "1x2y3"); - assert_eq!(state.budget_tokens_input, "123"); - } - - #[test] - fn paste_updates_test_command_field() { - let dir = tempdir().unwrap(); - let mut state = WizState::new(dir.path()); - state.step = Step::Tests; - - on_paste(&mut state, "cargo test --workspace"); - assert_eq!(state.test_input, "cargo test --workspace"); - } -}