From ae88fc219ffaf5f2c9b373a6161db57dc7746b83 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 24 May 2026 08:24:37 +0000 Subject: [PATCH 1/4] Initial plan From 384856b321a917da1432d65a97e7e84ba6d8e433 Mon Sep 17 00:00:00 2001 From: Justin Fermin Date: Thu, 21 May 2026 16:45:28 -0400 Subject: [PATCH 2/4] "Add `pitboss start` universal entry + iteration wizard - new-user branch routes to setup - existing-user branch shows snapshot (budget/phases/deferred) with continue/sweep/new-plan paths - new-plan flow drives in-TUI interview + planner agent (questioner_ranged.txt template), shows generated phase list with run/wait/terminate choices " Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com> --- .gitignore | 1 + src/cli/config.rs | 414 +++++ src/cli/init.rs | 26 + src/cli/mod.rs | 45 +- src/cli/nuke.rs | 127 ++ src/cli/plan.rs | 2 +- src/cli/start.rs | 572 ++++++ src/prompts/mod.rs | 25 + src/prompts/templates/questioner_ranged.txt | 26 + src/tui/iteration.rs | 1719 +++++++++++++++++++ src/tui/mod.rs | 19 +- src/tui/wizard.rs | 1268 ++++++++++++++ 12 files changed, 4237 insertions(+), 7 deletions(-) create mode 100644 src/cli/config.rs create mode 100644 src/cli/nuke.rs create mode 100644 src/cli/start.rs create mode 100644 src/prompts/templates/questioner_ranged.txt create mode 100644 src/tui/iteration.rs create mode 100644 src/tui/wizard.rs diff --git a/.gitignore b/.gitignore index 5b1fa12..460ebf6 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ Cargo.lock.bak .DS_Store /.idea .pitboss/ +pitboss-contributions.md diff --git a/src/cli/config.rs b/src/cli/config.rs new file mode 100644 index 0000000..2afa693 --- /dev/null +++ b/src/cli/config.rs @@ -0,0 +1,414 @@ +//! `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); + // 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 + ); + } + + /// 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 bc2eab0..1a92dde 100644 --- a/src/cli/init.rs +++ b/src/cli/init.rs @@ -175,6 +175,32 @@ 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 139e996..6bdfdfe 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -14,15 +14,18 @@ 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; @@ -66,12 +69,18 @@ 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(_) => false, + | Command::Prompts(_) + | Command::Nuke => false, } } } @@ -80,6 +89,28 @@ 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 chains into `play --tui`; 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. @@ -188,6 +219,18 @@ 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 new file mode 100644 index 0000000..bf51a96 --- /dev/null +++ b/src/cli/nuke.rs @@ -0,0 +1,127 @@ +//! `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")?; + let answer = line.trim().to_lowercase(); + Ok(answer == "y" || answer == "yes") +} + +#[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()); + } +} diff --git a/src/cli/plan.rs b/src/cli/plan.rs index be7da22..221ad2f 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"]; -fn collect_repo_summary(workspace: &Path) -> Result { +pub(crate) 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 new file mode 100644 index 0000000..be3e82d --- /dev/null +++ b/src/cli/start.rs @@ -0,0 +1,572 @@ +//! `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 a6c959c..29adb65 100644 --- a/src/prompts/mod.rs +++ b/src/prompts/mod.rs @@ -36,6 +36,7 @@ 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. /// @@ -120,6 +121,29 @@ 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( @@ -604,6 +628,7 @@ 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 new file mode 100644 index 0000000..2897302 --- /dev/null +++ b/src/prompts/templates/questioner_ranged.txt @@ -0,0 +1,26 @@ +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 new file mode 100644 index 0000000..923fbac --- /dev/null +++ b/src/tui/iteration.rs @@ -0,0 +1,1719 @@ +//! 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 = cursor / iw; + let total = (text.chars().count() + 1).div_ceil(iw).max(1); + let max_scroll = total.saturating_sub(ih) as u16; + (cursor_line.saturating_sub(ih.saturating_sub(1)) as u16).min(max_scroll) +} + +/// 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 = display.chars().count().div_ceil(inner_w).max(1); + let max_scroll = total_lines.saturating_sub(inner_h) as u16; + let auto_scroll = match cursor_pos { + Some(pos) => { + let cursor_line = 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], + ); +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 24227b3..826d990 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -15,6 +15,8 @@ mod app; pub mod grind; +pub mod iteration; +pub mod wizard; pub use app::{Activity, AgentDisplay, App, PhaseStatus, UsageView, OUTPUT_BUFFER_LINES}; @@ -23,8 +25,8 @@ use std::time::Duration; use anyhow::{Context, Result}; use crossterm::event::{ - DisableMouseCapture, EnableMouseCapture, Event as CtEvent, EventStream, KeyCode, KeyEventKind, - KeyModifiers, + DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, + Event as CtEvent, EventStream, KeyCode, KeyEventKind, KeyModifiers, }; use crossterm::execute; use crossterm::terminal::{ @@ -185,7 +187,12 @@ impl TerminalGuard { pub(crate) fn setup() -> Result { enable_raw_mode()?; let mut stdout = io::stdout(); - if let Err(e) = execute!(stdout, EnterAlternateScreen, EnableMouseCapture) { + if let Err(e) = execute!( + stdout, + EnterAlternateScreen, + EnableMouseCapture, + EnableBracketedPaste + ) { let _ = disable_raw_mode(); return Err(e.into()); } @@ -215,7 +222,8 @@ impl TerminalGuard { execute!( self.terminal.backend_mut(), LeaveAlternateScreen, - DisableMouseCapture + DisableMouseCapture, + DisableBracketedPaste )?; self.terminal.show_cursor()?; self.active = false; @@ -233,7 +241,8 @@ impl Drop for TerminalGuard { let _ = execute!( self.terminal.backend_mut(), LeaveAlternateScreen, - DisableMouseCapture + DisableMouseCapture, + DisableBracketedPaste ); let _ = self.terminal.show_cursor(); } diff --git a/src/tui/wizard.rs b/src/tui/wizard.rs new file mode 100644 index 0000000..902e07c --- /dev/null +++ b/src/tui/wizard.rs @@ -0,0 +1,1268 @@ +//! 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(_)) => {} + 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 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], + ); +} From d85ac691c564eeef96649741382ce1db4854255f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 24 May 2026 08:35:03 +0000 Subject: [PATCH 3/4] fix review thread issues for start/config/nuke and TUI paste/scroll handling Agent-Logs-Url: https://github.com/elicpeter/pitboss/sessions/0305b422-6afe-4025-892b-676ea6f451b6 Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com> --- src/cli/config.rs | 15 ++++++++++- src/cli/mod.rs | 4 +-- src/cli/nuke.rs | 18 ++++++++++++-- src/tui/iteration.rs | 59 +++++++++++++++++++++++++++++++++++++++++--- src/tui/wizard.rs | 50 +++++++++++++++++++++++++++++++++++++ 5 files changed, 137 insertions(+), 9 deletions(-) diff --git a/src/cli/config.rs b/src/cli/config.rs index 2afa693..1a79090 100644 --- a/src/cli/config.rs +++ b/src/cli/config.rs @@ -169,7 +169,7 @@ fn write_config(workspace: &Path, result: &WizardResult) -> Result<()> { let audit_enabled = result.audit_enabled; let sweep_enabled = result.sweep_enabled; - let sweep_min = result.sweep_threshold.unwrap_or(5); + 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 @@ -383,6 +383,19 @@ mod tests { ); } + #[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] diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 6bdfdfe..6d26207 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -103,8 +103,8 @@ pub enum Command { force: bool, }, /// Universal entry point. Auto-detects whether `.pitboss/` exists: - /// without it, runs the setup wizard and chains into `play --tui`; with - /// it, opens the iteration wizard (current budget, deferred items, and + /// 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 diff --git a/src/cli/nuke.rs b/src/cli/nuke.rs index bf51a96..16dbd3d 100644 --- a/src/cli/nuke.rs +++ b/src/cli/nuke.rs @@ -77,8 +77,11 @@ fn confirm() -> Result { std::io::stdin() .read_line(&mut line) .context("nuke: reading confirmation")?; - let answer = line.trim().to_lowercase(); - Ok(answer == "y" || answer == "yes") + Ok(is_yes_answer(&line)) +} + +fn is_yes_answer(answer: &str) -> bool { + answer.trim().to_ascii_lowercase().starts_with('y') } #[cfg(not(test))] @@ -124,4 +127,15 @@ mod tests { // 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/tui/iteration.rs b/src/tui/iteration.rs index 923fbac..18dafc4 100644 --- a/src/tui/iteration.rs +++ b/src/tui/iteration.rs @@ -992,12 +992,37 @@ fn display_cursor(value: &str, cursor: usize) -> String { 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 = cursor / iw; - let total = (text.chars().count() + 1).div_ceil(iw).max(1); + 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 { @@ -1157,11 +1182,11 @@ fn text_input_widget( }; 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 = display.chars().count().div_ceil(inner_w).max(1); + 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 = pos / inner_w; + 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) } @@ -1717,3 +1742,29 @@ fn render_plan_review(f: &mut ratatui::Frame<'_>, area: Rect, state: &IterState) 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/wizard.rs b/src/tui/wizard.rs index 902e07c..08227f3 100644 --- a/src/tui/wizard.rs +++ b/src/tui/wizard.rs @@ -281,6 +281,11 @@ async fn run_wizard_inner(initial_state: WizState) -> Result 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, @@ -309,6 +314,20 @@ fn on_key(s: &mut WizState, code: KeyCode, mods: KeyModifiers) -> Ev { } } +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(' ') => { @@ -1266,3 +1285,34 @@ fn render_confirm(f: &mut ratatui::Frame<'_>, area: Rect, state: &WizState) { 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"); + } +} From 30cd9874df0ec97cdb90b7132e50f678e20e069c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 25 May 2026 04:10:35 +0000 Subject: [PATCH 4/4] Fix rustdoc private intra-doc link in cli config module docs Agent-Logs-Url: https://github.com/elicpeter/pitboss/sessions/2348e334-ea63-4afb-9c9d-9ffd24df46db Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com> --- src/cli/config.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/config.rs b/src/cli/config.rs index 1a79090..f3bbc54 100644 --- a/src/cli/config.rs +++ b/src/cli/config.rs @@ -9,7 +9,7 @@ //! //! 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. +//! `run_config_wizard` so `pitboss start` can reuse the create flow. use std::io::IsTerminal; use std::path::{Path, PathBuf};