diff --git a/.github/workflows/vscode-extension.yaml b/.github/workflows/vscode-extension.yaml
index 3941252..7892556 100644
--- a/.github/workflows/vscode-extension.yaml
+++ b/.github/workflows/vscode-extension.yaml
@@ -169,6 +169,9 @@ jobs:
needs: build-vsix
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ defaults:
+ run:
+ working-directory: . # Override global default - publish job runs from repo root
steps:
- uses: actions/checkout@v4
diff --git a/bindings/GitConfig.ts b/bindings/GitConfig.ts
index 6765f82..21ba331 100644
--- a/bindings/GitConfig.ts
+++ b/bindings/GitConfig.ts
@@ -22,4 +22,9 @@ gitlab: GitLabConfig,
/**
* Branch naming format (e.g., "{type}/{ticket_id}-{slug}")
*/
-branch_format: string, };
+branch_format: string,
+/**
+ * Whether to use git worktrees for per-ticket isolation (default: false)
+ * When false, tickets work directly in the project directory with branches
+ */
+use_worktrees: boolean, };
diff --git a/docs/_config.yml b/docs/_config.yml
index 6e24106..e83ba64 100644
--- a/docs/_config.yml
+++ b/docs/_config.yml
@@ -5,7 +5,7 @@ url: "https://operator.untra.io"
baseurl: ""
# Social/SEO
-image: /assets/patricia_512.png
+image: /assets/img/patricia_512.png
twitter:
card: summary_large_image
diff --git a/docs/assets/css/main.css b/docs/assets/css/main.css
index 69e40da..a6138c5 100644
--- a/docs/assets/css/main.css
+++ b/docs/assets/css/main.css
@@ -74,7 +74,7 @@ body {
width: 120px;
height: 120px;
background-color: var(--color-white);
- background-image: url('../patricia_512.png');
+ background-image: url('../img/patricia_512.png');
background-repeat: no-repeat;
background-size: cover;
background-position: center;
diff --git a/docs/index.md b/docs/index.md
index cd02a46..2c6e424 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -4,7 +4,7 @@ description: "Operator! is an application for orchestrating LLM coding assist ag
layout: doc
---
-Welcome friend! Operator! is an application for orchestrating [Claude Code](/getting-started/agents/claude/) (and other [LLM coding agents](/getting-started/agents/)) across multi-repository codebases. It connects to [kanban-style ticket management software](/getting-started/kanban/) , and can spawn session from the [**VS Code** extension](/getting-started/sessions/vscode/) or from [`tmux` terminals](/getting-started/sessions/tmux/) to create a powerful workflow for managing AI-assisted software development.
+Welcome friend! Operator! is an application for orchestrating [Claude Code](/getting-started/agents/claude/) (and other [LLM coding agents](/getting-started/agents/)) across multi-repository codebases. It connects to [kanban-style ticket management software](/getting-started/kanban/) , and can spawn session from the official [VS Code extension](/getting-started/sessions/vscode/) or from [`tmux` terminals](/getting-started/sessions/tmux/) to create a powerful workflow for managing AI-assisted software development.
Operator! was designed _by Software Engineers for Software Engineers_. Most software development happens [multi-repo rather than mono-repo](https://www.thoughtworks.com/en-us/insights/blog/agile-engineering-practices/monorepo-vs-multirepo){:target="_blank"}, and succeeding with AI software development requires coordinating LLM assist coding agents work across many codebases, with modern feature development requiring 2+ pull requests across an organization. The API server runs in the directory containing your work code repositories, where it can synchronize and direct markdown defined work orders under a `.tickets/` directory which stores your kanban synchronized work tickets.
@@ -23,9 +23,9 @@ Welcome friend! Operator! is an application
1. Install Operator! (downloads page)
2. Configure your project management kanban workspaces
-3. Define your work shape issuetypes, and how AI combines them together
-3. Create tickets in `.tickets/queue/` (kanban connectors)
-4. Launch agents and watch them work (measuring success)
+3. Define your work shape issuetypes, and the steps AI follows to complete work
+3. Create markdown work tickets in `.tickets/queue/`
+4. Launch coding agents and watch them work, while measuring success
## Quick Links
diff --git a/docs/llm-tools/index.md b/docs/llm-tools/index.md
index 773635c..d2fe35b 100644
--- a/docs/llm-tools/index.md
+++ b/docs/llm-tools/index.md
@@ -59,6 +59,18 @@ max_concurrent = 4
path = "/Applications/Claude.app"
```
+## Known Limitations
+
+### JSON Schema for Structured Output (Temporarily Disabled)
+
+The `jsonSchema` and `jsonSchemaFile` step properties are currently disabled. These properties configure the `--json-schema` flag for Claude Code to enable structured output validation.
+
+**Issue**: Even when writing schemas to files (rather than passing inline JSON), the command line length can exceed OS limits when combined with other flags.
+
+**Workaround**: Until this is resolved, use Claude Code's native structured output capabilities without the `--json-schema` flag, or validate outputs manually in subsequent steps.
+
+**Tracking**: See `JSON_SCHEMA_ENABLED` constant in `src/agents/launcher/llm_command.rs`.
+
## Best Practices
1. **Clear tickets** - Write detailed ticket descriptions
diff --git a/docs/schemas/issuetype.md b/docs/schemas/issuetype.md
index d062b5c..71c2786 100644
--- a/docs/schemas/issuetype.md
+++ b/docs/schemas/issuetype.md
@@ -139,8 +139,8 @@ Schema for validating operator issuetype template configurations
| `permissions` | → `stepPermissions` | No | Provider-agnostic permissions for this step, merged additively with project settings |
| `cli_args` | → `providerCliArgs` | No | Arbitrary CLI arguments per provider |
| `permission_mode` | `string` | No | Preferred LLM permission mode for this step. Only applies to providers that support it (e.g., Claude). No-op for unsupported providers. |
-| `jsonSchema` | `object` | No | Inline JSON schema for structured output. Claude-specific: sets --json-schema flag. Takes precedence over jsonSchemaFile if both are defined. |
-| `jsonSchemaFile` | `string` | No | Path to a local JSON schema file for structured output, relative to the project root. Claude-specific: sets --json-schema flag. |
+| `jsonSchema` | `object` | No | Inline JSON schema for structured output. Claude-specific: sets --json-schema flag. Takes precedence over jsonSchemaFile if both are defined. **⚠️ Temporarily disabled** due to command line length issues. |
+| `jsonSchemaFile` | `string` | No | Path to a local JSON schema file for structured output, relative to the project root. Claude-specific: sets --json-schema flag. **⚠️ Temporarily disabled** due to command line length issues. |
### Definition: stepPermissions
diff --git a/opr8r/README.md b/opr8r/README.md
index c30b54e..64840c7 100644
--- a/opr8r/README.md
+++ b/opr8r/README.md
@@ -24,7 +24,7 @@ opr8r --ticket-id=FEAT-123 --step=plan -- claude --prompt 'Plan the feature'
| `--step` | Yes | Current step name (e.g., "plan", "build") |
| `--api-url` | No | Operator API URL (auto-discovers from `.tickets/operator/api-session.json`) |
| `--session-id` | No | Session ID for LLM session tracking |
-| `--json-schema` | No | Path to JSON schema file for output validation |
+| `--json-schema` | No | Path to JSON schema file for output validation (:x: temporarily disabled - causes command line length issues) |
| `--no-auto-proceed` | No | Disable automatic step transition |
| `--verbose` | No | Enable verbose logging to stderr |
| `--dry-run` | No | Show what would happen without executing |
diff --git a/src/agents/launcher/llm_command.rs b/src/agents/launcher/llm_command.rs
index 7b4c351..ce1e27e 100644
--- a/src/agents/launcher/llm_command.rs
+++ b/src/agents/launcher/llm_command.rs
@@ -3,6 +3,15 @@
use std::fs;
use std::path::PathBuf;
+/// TEMPORARILY DISABLED: JSON schema support causes command line length issues.
+/// Even when writing schemas to files (rather than inline), the --json-schema flag
+/// with large schema file paths can exceed OS command line limits.
+///
+/// TODO: Re-enable when Claude Code supports reading schema from a config file,
+/// environment variable, or stdin.
+#[allow(dead_code)]
+const JSON_SCHEMA_ENABLED: bool = false;
+
use anyhow::{Context, Result};
use crate::config::{Config, DetectedTool};
@@ -176,33 +185,40 @@ fn generate_config_flags(
cli_flags.push(mode_str.to_string());
}
- // Add worktrees base directory to allowed directories
- let worktrees_path = config.worktrees_path();
- cli_flags.push("--add-dir".to_string());
- cli_flags.push(worktrees_path.to_string_lossy().to_string());
+ // Add worktrees base directory to allowed directories (only if worktrees enabled)
+ if config.git.use_worktrees {
+ let worktrees_path = config.worktrees_path();
+ cli_flags.push("--add-dir".to_string());
+ cli_flags.push(worktrees_path.to_string_lossy().to_string());
+ }
- // Add the specific working directory (worktree path) to bypass CWD trust dialog
+ // Always add the working directory (project path or worktree path) to bypass CWD trust dialog
// Claude Code checks if the CWD is trusted, not just parent directories
cli_flags.push("--add-dir".to_string());
cli_flags.push(project_path.to_string());
- // Add JSON schema flag for structured output
+ // Add JSON schema flag for structured output (when enabled)
// Write schema to a file to avoid shell escaping issues with inline JSON
// Inline jsonSchema takes precedence over jsonSchemaFile
- if let Some(ref schema) = step_config.json_schema {
- // Write inline schema to session_dir/schema.json
- let schema_file_path = session_dir.join("schema.json");
- let schema_str =
- serde_json::to_string_pretty(schema).context("Failed to serialize JSON schema")?;
- fs::write(&schema_file_path, &schema_str).with_context(|| {
- format!("Failed to write JSON schema file: {:?}", schema_file_path)
- })?;
- cli_flags.push("--json-schema".to_string());
- cli_flags.push(schema_file_path.to_string_lossy().to_string());
- } else if let Some(ref schema_file) = step_config.json_schema_file {
- // Resolve path - .tickets/ paths are relative to tickets parent dir, others to project
- let schema_path =
- if schema_file.starts_with(".tickets/") || schema_file.starts_with(".tickets\\") {
+ //
+ // NOTE: JSON schema support is temporarily disabled due to command line length
+ // issues. See JSON_SCHEMA_ENABLED constant at the top of this file.
+ if JSON_SCHEMA_ENABLED {
+ if let Some(ref schema) = step_config.json_schema {
+ // Write inline schema to session_dir/schema.json
+ let schema_file_path = session_dir.join("schema.json");
+ let schema_str = serde_json::to_string_pretty(schema)
+ .context("Failed to serialize JSON schema")?;
+ fs::write(&schema_file_path, &schema_str).with_context(|| {
+ format!("Failed to write JSON schema file: {:?}", schema_file_path)
+ })?;
+ cli_flags.push("--json-schema".to_string());
+ cli_flags.push(schema_file_path.to_string_lossy().to_string());
+ } else if let Some(ref schema_file) = step_config.json_schema_file {
+ // Resolve path - .tickets/ paths are relative to tickets parent dir, others to project
+ let schema_path = if schema_file.starts_with(".tickets/")
+ || schema_file.starts_with(".tickets\\")
+ {
if let Some(parent) = config.tickets_path().parent() {
parent.join(schema_file)
} else {
@@ -211,12 +227,13 @@ fn generate_config_flags(
} else {
PathBuf::from(project_path).join(schema_file)
};
- // Verify schema file exists, then pass the path (not content)
- if !schema_path.exists() {
- anyhow::bail!("JSON schema file not found: {:?}", schema_path);
+ // Verify schema file exists, then pass the path (not content)
+ if !schema_path.exists() {
+ anyhow::bail!("JSON schema file not found: {:?}", schema_path);
+ }
+ cli_flags.push("--json-schema".to_string());
+ cli_flags.push(schema_path.to_string_lossy().to_string());
}
- cli_flags.push("--json-schema".to_string());
- cli_flags.push(schema_path.to_string_lossy().to_string());
}
}
@@ -934,6 +951,7 @@ mod tests {
/// Test helper: verify that a JSON schema file is written correctly
#[test]
+ #[ignore = "JSON schema flag temporarily disabled - see JSON_SCHEMA_ENABLED"]
fn test_json_schema_written_to_file_is_valid_json() {
use serde_json::json;
@@ -963,6 +981,7 @@ mod tests {
/// Test that file path string conversion preserves the path
#[test]
+ #[ignore = "JSON schema flag temporarily disabled - see JSON_SCHEMA_ENABLED"]
fn test_schema_file_path_to_string() {
let path = PathBuf::from("/tmp/test/.tickets/operator/sessions/TEST-001/schema.json");
let path_str = path.to_string_lossy().to_string();
@@ -974,6 +993,7 @@ mod tests {
/// Test that json_schema_file path existence check works
#[test]
+ #[ignore = "JSON schema flag temporarily disabled - see JSON_SCHEMA_ENABLED"]
fn test_schema_file_path_exists_check() {
let temp_dir = TempDir::new().unwrap();
@@ -991,6 +1011,7 @@ mod tests {
/// Test that a complex JSON schema with special characters is handled correctly
#[test]
+ #[ignore = "JSON schema flag temporarily disabled - see JSON_SCHEMA_ENABLED"]
fn test_complex_schema_with_special_chars() {
use serde_json::json;
diff --git a/src/agents/launcher/worktree_setup.rs b/src/agents/launcher/worktree_setup.rs
index 5aa9650..1645b55 100644
--- a/src/agents/launcher/worktree_setup.rs
+++ b/src/agents/launcher/worktree_setup.rs
@@ -56,6 +56,12 @@ pub async fn setup_worktree_for_ticket(
ticket: &mut Ticket,
project_path: &Path,
) -> Result {
+ // Check if worktrees are enabled in config
+ if !config.git.use_worktrees {
+ // Use branch-only workflow instead
+ return setup_branch_for_ticket(config, ticket, project_path).await;
+ }
+
// Check if already has a worktree (e.g., on relaunch)
if let Some(ref existing) = ticket.worktree_path {
let existing_path = PathBuf::from(existing);
@@ -126,6 +132,90 @@ pub async fn setup_worktree_for_ticket(
Ok(worktree_info.path)
}
+/// Setup a branch for a ticket directly in the project directory (no worktree)
+///
+/// This is the default workflow when `config.git.use_worktrees` is false.
+/// Creates or checks out the ticket's feature branch in the project directory.
+///
+/// # Arguments
+/// * `config` - Operator configuration (unused but kept for API consistency)
+/// * `ticket` - The ticket to create a branch for (will be mutated to set branch)
+/// * `project_path` - Path to the project directory
+///
+/// # Returns
+/// * `Ok(PathBuf)` - The project path (working directory is unchanged)
+/// * `Err` - If branch creation/checkout fails
+pub async fn setup_branch_for_ticket(
+ _config: &Config,
+ ticket: &mut Ticket,
+ project_path: &Path,
+) -> Result {
+ // Check if project is a git repository
+ let git_dir = project_path.join(".git");
+ if !git_dir.exists() {
+ debug!(
+ project = %project_path.display(),
+ "Project is not a git repository, skipping branch setup"
+ );
+ return Ok(project_path.to_path_buf());
+ }
+
+ // Generate branch name
+ let branch_name = branch_name_for_ticket(ticket);
+
+ // Determine target branch (use "main" or "master" by default)
+ let target_branch = detect_default_branch(project_path)
+ .await
+ .unwrap_or_else(|| "main".to_string());
+
+ info!(
+ project = %ticket.project,
+ ticket_id = %ticket.id,
+ branch = %branch_name,
+ base = %target_branch,
+ "Setting up branch for ticket (in-place, no worktree)"
+ );
+
+ // Check for uncommitted changes before switching branches
+ if GitCli::is_dirty(project_path).await? {
+ warn!(
+ project = %project_path.display(),
+ "Working directory has uncommitted changes, proceeding anyway"
+ );
+ // Note: We warn but don't abort - the agent may need to handle this
+ }
+
+ // Check if branch already exists
+ let branch_exists = GitCli::branch_exists(project_path, &branch_name).await?;
+
+ if branch_exists {
+ // Checkout existing branch
+ debug!(branch = %branch_name, "Branch exists, checking out");
+ GitCli::checkout(project_path, &branch_name)
+ .await
+ .context("Failed to checkout existing branch")?;
+ } else {
+ // Create and checkout new branch from target
+ debug!(branch = %branch_name, base = %target_branch, "Creating new branch");
+ GitCli::checkout_new_branch(project_path, &branch_name, &target_branch)
+ .await
+ .context("Failed to create and checkout new branch")?;
+ }
+
+ // Update ticket with branch info (no worktree_path - stays None)
+ ticket
+ .set_branch(&branch_name)
+ .context("Failed to update ticket branch")?;
+
+ info!(
+ branch = %branch_name,
+ project = %project_path.display(),
+ "Branch ready for ticket"
+ );
+
+ Ok(project_path.to_path_buf())
+}
+
/// Detect the default branch for a repository
async fn detect_default_branch(repo_path: &Path) -> Option {
// Try to get the HEAD reference
diff --git a/src/config.rs b/src/config.rs
index e925026..9d6b30c 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -1018,6 +1018,10 @@ pub struct GitConfig {
/// Branch naming format (e.g., "{type}/{ticket_id}-{slug}")
#[serde(default = "default_branch_format")]
pub branch_format: String,
+ /// Whether to use git worktrees for per-ticket isolation (default: false)
+ /// When false, tickets work directly in the project directory with branches
+ #[serde(default)]
+ pub use_worktrees: bool,
}
fn default_branch_format() -> String {
@@ -1031,6 +1035,7 @@ impl Default for GitConfig {
github: GitHubConfig::default(),
gitlab: GitLabConfig::default(),
branch_format: default_branch_format(),
+ use_worktrees: false,
}
}
}
diff --git a/src/git/cli.rs b/src/git/cli.rs
index fa50c86..59f701b 100644
--- a/src/git/cli.rs
+++ b/src/git/cli.rs
@@ -88,6 +88,25 @@ impl GitCli {
Self::run_git_silent(&["branch", branch, base], path).await
}
+ /// Check if a local branch exists
+ #[instrument(skip_all, fields(path = %path.display(), branch))]
+ pub async fn branch_exists(path: &Path, branch: &str) -> Result {
+ let result = Self::run_git(&["rev-parse", "--verify", branch], path).await;
+ Ok(result.is_ok())
+ }
+
+ /// Checkout an existing branch
+ #[instrument(skip_all, fields(path = %path.display(), branch))]
+ pub async fn checkout(path: &Path, branch: &str) -> Result<()> {
+ Self::run_git_silent(&["checkout", branch], path).await
+ }
+
+ /// Create and checkout a new branch from a base
+ #[instrument(skip_all, fields(path = %path.display(), branch, base))]
+ pub async fn checkout_new_branch(path: &Path, branch: &str, base: &str) -> Result<()> {
+ Self::run_git_silent(&["checkout", "-b", branch, base], path).await
+ }
+
/// Delete a branch (local)
#[instrument(skip_all, fields(path = %path.display(), branch, force))]
pub async fn delete_branch(path: &Path, branch: &str, force: bool) -> Result<()> {
diff --git a/src/setup.rs b/src/setup.rs
index f58e5fb..11aec58 100644
--- a/src/setup.rs
+++ b/src/setup.rs
@@ -33,6 +33,8 @@ pub struct SetupOptions {
pub kanban_provider: Option,
/// Preferred LLM tool: claude, codex, gemini
pub llm_tool: Option,
+ /// Whether to use git worktrees for per-ticket isolation (default: false)
+ pub use_worktrees: bool,
}
/// Result of setup operation
@@ -148,6 +150,9 @@ pub fn initialize_workspace(config: &mut Config, options: &SetupOptions) -> Resu
// Configure backstage if enabled
config.backstage.enabled = options.backstage_enabled;
+ // Configure git worktree preference
+ config.git.use_worktrees = options.use_worktrees;
+
// Generate tmux config
generate_tmux_config(config)?;
diff --git a/src/ui/setup.rs b/src/ui/setup.rs
deleted file mode 100644
index e9d55b5..0000000
--- a/src/ui/setup.rs
+++ /dev/null
@@ -1,2566 +0,0 @@
-//! Startup setup screen when .tickets/ directory is not found
-
-use std::collections::HashMap;
-
-use crate::agents::{SystemTmuxClient, TmuxClient, TmuxError};
-use crate::config::{CollectionPreset, SessionWrapperType};
-use crate::projects::TOOL_MARKERS;
-use ratatui::{
- layout::{Alignment, Constraint, Direction, Layout, Rect},
- style::{Color, Modifier, Style},
- text::{Line, Span},
- widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph},
- Frame,
-};
-
-/// Simplified tool info for display on the welcome screen
-#[derive(Debug, Clone)]
-pub struct DetectedToolInfo {
- pub name: String,
- pub version: String,
- pub model_count: usize,
-}
-
-/// Available issuetype collections (all known types)
-pub const ALL_ISSUE_TYPES: &[&str] = &["TASK", "FEAT", "FIX", "SPIKE", "INV"];
-
-/// Optional fields that can be configured for TASK (and propagated to other types)
-/// Note: 'summary' and 'description' remain required, 'id' is auto-generated
-pub const TASK_OPTIONAL_FIELDS: &[(&str, &str)] = &[
- ("priority", "Priority level (P0-critical to P3-low)"),
- ("points", "Story points estimate (0 or greater)"),
- ("user_story", "User story or background context"),
-];
-
-/// Collection source options shown in setup
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum CollectionSourceOption {
- Simple,
- DevKanban,
- DevopsKanban,
- ImportJira,
- ImportNotion,
- CustomSelection,
-}
-
-impl CollectionSourceOption {
- pub fn all() -> &'static [CollectionSourceOption] {
- &[
- CollectionSourceOption::Simple,
- CollectionSourceOption::DevKanban,
- CollectionSourceOption::DevopsKanban,
- CollectionSourceOption::ImportJira,
- CollectionSourceOption::ImportNotion,
- CollectionSourceOption::CustomSelection,
- ]
- }
-
- pub fn label(&self) -> &'static str {
- match self {
- CollectionSourceOption::Simple => "Simple",
- CollectionSourceOption::DevKanban => "Dev Kanban",
- CollectionSourceOption::DevopsKanban => "DevOps Kanban",
- CollectionSourceOption::ImportJira => "Import from Jira",
- CollectionSourceOption::ImportNotion => "Import from Notion",
- CollectionSourceOption::CustomSelection => "Custom Selection",
- }
- }
-
- pub fn description(&self) -> &'static str {
- match self {
- CollectionSourceOption::Simple => "Just TASK - minimal setup for general work",
- CollectionSourceOption::DevKanban => "3 issue types: TASK, FEAT, FIX",
- CollectionSourceOption::DevopsKanban => "5 issue types: TASK, SPIKE, INV, FEAT, FIX",
- CollectionSourceOption::ImportJira => "(Coming soon)",
- CollectionSourceOption::ImportNotion => "(Coming soon)",
- CollectionSourceOption::CustomSelection => "Choose individual issue types",
- }
- }
-
- pub fn is_unimplemented(&self) -> bool {
- matches!(
- self,
- CollectionSourceOption::ImportJira | CollectionSourceOption::ImportNotion
- )
- }
-}
-
-/// Result of setup screen actions
-#[derive(Debug, Clone)]
-pub enum SetupResult {
- /// Continue to next step
- Continue,
- /// Cancel/quit setup
- Cancel,
- /// Exit with unimplemented message
- ExitUnimplemented(String),
- /// Setup complete, initialize
- Initialize,
-}
-
-/// Setup screen shown when .tickets/ directory doesn't exist
-pub struct SetupScreen {
- /// Whether the screen is visible
- pub visible: bool,
- /// Current step in the setup process
- pub step: SetupStep,
- /// Current selection for confirmation: true = Initialize, false = Cancel
- pub confirm_selected: bool,
- /// Path where tickets directory will be created
- tickets_path: String,
- /// Detected LLM tools (from LlmToolsConfig)
- detected_tools: Vec,
- /// Projects grouped by tool
- projects_by_tool: HashMap>,
- /// Selected collection preset
- pub selected_preset: CollectionPreset,
- /// Custom issuetype collection (only used when preset is Custom)
- pub custom_collection: Vec,
- /// List state for collection source selection
- source_state: ListState,
- /// List state for custom collection selection
- collection_state: ListState,
- /// Whether we came from custom selection (for back navigation)
- from_custom: bool,
- /// Selected optional fields to include in TASK (and other types)
- pub task_optional_fields: Vec,
- /// List state for field configuration selection
- field_state: ListState,
- /// Startup ticket options (ASSESS, AGENT-SETUP, PROJECT-INIT)
- pub startup_ticket_options: Vec,
- /// List state for startup ticket selection
- startup_state: ListState,
- /// Acceptance criteria text (editable during setup)
- pub acceptance_criteria_text: String,
- // ─── Kanban Setup State ─────────────────────────────────────────────────────
- /// Detected kanban providers from environment variables
- pub detected_kanban_providers: Vec,
- /// Indices of providers with valid credentials
- pub valid_kanban_providers: Vec,
- /// Projects fetched from current provider being configured
- pub kanban_projects:
- super::paginated_list::PaginatedList,
- /// Issue types for the currently selected project
- pub kanban_issue_types: Vec,
- /// Member count for the currently selected project
- pub kanban_member_count: usize,
- /// Whether kanban detection/testing has run
- pub kanban_detection_complete: bool,
- /// Whether the user chose to skip kanban setup
- pub kanban_skipped: bool,
- // ─── Session Wrapper Setup State ────────────────────────────────────────────
- /// Selected session wrapper type
- pub selected_wrapper: SessionWrapperType,
- /// List state for wrapper selection
- wrapper_state: ListState,
- /// Tmux availability status (checked during TmuxOnboarding step)
- pub tmux_status: TmuxDetectionStatus,
- /// VS Code extension status (checked during VSCodeSetup step)
- pub vscode_status: VSCodeDetectionStatus,
-}
-
-/// Startup ticket options for project initialization
-#[derive(Debug, Clone)]
-pub struct StartupTicketOption {
- /// Key identifier for the ticket type (e.g., "assess", "agent_setup")
- pub key: &'static str,
- pub name: &'static str,
- pub description: &'static str,
- pub enabled: bool,
-}
-
-impl StartupTicketOption {
- pub fn all() -> Vec {
- vec![
- StartupTicketOption {
- key: "assess",
- name: "ASSESS tickets",
- description: "Scan projects for catalog-info.yaml, create if missing",
- enabled: true,
- },
- StartupTicketOption {
- key: "agent_setup",
- name: "AGENT-SETUP tickets",
- description: "Configure Claude agents for each project",
- enabled: false,
- },
- StartupTicketOption {
- key: "project_init",
- name: "PROJECT-INIT tickets",
- description: "Run both ASSESS and AGENT-SETUP for each project",
- enabled: false,
- },
- ]
- }
-}
-
-/// Tmux availability detection status
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub enum TmuxDetectionStatus {
- /// Not yet checked
- NotChecked,
- /// Tmux is available with the given version
- Available { version: String },
- /// Tmux is not installed
- NotInstalled,
- /// Tmux is installed but version is too old
- VersionTooOld { current: String, required: String },
-}
-
-impl Default for TmuxDetectionStatus {
- fn default() -> Self {
- Self::NotChecked
- }
-}
-
-/// VS Code extension detection status
-#[derive(Debug, Clone, PartialEq, Eq)]
-#[allow(dead_code)] // Variants will be used when VS Code extension support is implemented
-pub enum VSCodeDetectionStatus {
- /// Not yet checked
- NotChecked,
- /// Currently checking connection
- Checking,
- /// Connected to extension with the given version
- Connected { version: String },
- /// Extension not reachable
- NotReachable,
-}
-
-impl Default for VSCodeDetectionStatus {
- fn default() -> Self {
- Self::NotChecked
- }
-}
-
-/// Session wrapper options shown in setup
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum SessionWrapperOption {
- Tmux,
- VSCode,
-}
-
-impl SessionWrapperOption {
- pub fn all() -> &'static [SessionWrapperOption] {
- &[SessionWrapperOption::Tmux, SessionWrapperOption::VSCode]
- }
-
- pub fn label(&self) -> &'static str {
- match self {
- SessionWrapperOption::Tmux => "Tmux (default)",
- SessionWrapperOption::VSCode => "VS Code Integrated Terminal",
- }
- }
-
- pub fn description(&self) -> &'static str {
- match self {
- SessionWrapperOption::Tmux => "Run agents in standalone tmux sessions",
- SessionWrapperOption::VSCode => {
- "Run agents in VS Code terminal panels (requires extension)"
- }
- }
- }
-
- pub fn to_wrapper_type(self) -> SessionWrapperType {
- match self {
- SessionWrapperOption::Tmux => SessionWrapperType::Tmux,
- SessionWrapperOption::VSCode => SessionWrapperType::Vscode,
- }
- }
-}
-
-/// Steps in the setup process
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub enum SetupStep {
- /// Welcome splash screen with discovered projects
- Welcome,
- /// Select template collection source
- CollectionSource,
- /// Custom issuetype selection (optional)
- CustomCollection,
- /// Configure TASK optional fields
- TaskFieldConfig,
- /// Select session wrapper (tmux or vscode)
- SessionWrapperChoice,
- /// Tmux onboarding/help (only shown if tmux selected)
- TmuxOnboarding,
- /// VS Code extension setup (only shown if vscode selected)
- VSCodeSetup,
- /// Kanban integration info and provider detection
- KanbanInfo,
- /// Per-provider setup with project selection (index into valid_providers)
- KanbanProviderSetup { provider_index: usize },
- /// Review and configure acceptance criteria
- AcceptanceCriteria,
- /// Optional startup tickets creation
- StartupTickets,
- /// Confirm initialization
- Confirm,
-}
-
-impl SetupScreen {
- /// Create a new setup screen
- pub fn new(
- tickets_path: String,
- detected_tools: Vec,
- projects_by_tool: HashMap>,
- ) -> Self {
- let mut source_state = ListState::default();
- source_state.select(Some(0));
-
- let mut collection_state = ListState::default();
- collection_state.select(Some(0));
-
- let mut field_state = ListState::default();
- field_state.select(Some(0));
-
- let mut startup_state = ListState::default();
- startup_state.select(Some(0));
-
- let mut wrapper_state = ListState::default();
- wrapper_state.select(Some(0));
-
- Self {
- visible: true,
- step: SetupStep::Welcome,
- confirm_selected: true, // Default to Initialize
- tickets_path,
- detected_tools,
- projects_by_tool,
- selected_preset: CollectionPreset::DevopsKanban,
- custom_collection: ALL_ISSUE_TYPES.iter().map(|s| s.to_string()).collect(),
- source_state,
- collection_state,
- from_custom: false,
- // Default: all optional fields enabled
- task_optional_fields: TASK_OPTIONAL_FIELDS
- .iter()
- .map(|(name, _)| name.to_string())
- .collect(),
- field_state,
- startup_ticket_options: StartupTicketOption::all(),
- startup_state,
- acceptance_criteria_text: include_str!("../templates/ACCEPTANCE_CRITERIA.md")
- .to_string(),
- // Kanban setup state
- detected_kanban_providers: Vec::new(),
- valid_kanban_providers: Vec::new(),
- kanban_projects: super::paginated_list::PaginatedList::new(8),
- kanban_issue_types: Vec::new(),
- kanban_member_count: 0,
- kanban_detection_complete: false,
- kanban_skipped: false,
- // Session wrapper state
- selected_wrapper: SessionWrapperType::Tmux,
- wrapper_state,
- tmux_status: TmuxDetectionStatus::NotChecked,
- vscode_status: VSCodeDetectionStatus::NotChecked,
- }
- }
-
- /// Get the selected collection preset
- pub fn preset(&self) -> CollectionPreset {
- self.selected_preset
- }
-
- /// Get the effective issuetype collection based on preset
- pub fn collection(&self) -> Vec {
- match self.selected_preset {
- CollectionPreset::Custom => self.custom_collection.clone(),
- _ => self.selected_preset.issue_types(),
- }
- }
-
- /// Get the configured optional fields for TASK (and propagation to other types)
- pub fn configured_task_fields(&self) -> Vec {
- self.task_optional_fields.clone()
- }
-
- /// Get the selected startup ticket types to create
- pub fn selected_startup_tickets(&self) -> Vec {
- self.startup_ticket_options
- .iter()
- .filter(|opt| opt.enabled)
- .map(|opt| opt.key.to_string())
- .collect()
- }
-
- /// Get the currently selected source option
- fn selected_source(&self) -> Option {
- self.source_state
- .selected()
- .map(|i| CollectionSourceOption::all()[i])
- }
-
- /// Toggle selection (Space key)
- pub fn toggle_selection(&mut self) {
- match self.step {
- SetupStep::CustomCollection => {
- // Toggle the currently highlighted collection item
- if let Some(i) = self.collection_state.selected() {
- let types = ALL_ISSUE_TYPES;
- if i < types.len() {
- let type_str = types[i].to_string();
- if self.custom_collection.contains(&type_str) {
- self.custom_collection.retain(|t| t != &type_str);
- } else {
- self.custom_collection.push(type_str);
- }
- }
- }
- }
- SetupStep::TaskFieldConfig => {
- // Toggle the currently highlighted field
- if let Some(i) = self.field_state.selected() {
- if i < TASK_OPTIONAL_FIELDS.len() {
- let field_name = TASK_OPTIONAL_FIELDS[i].0.to_string();
- if self.task_optional_fields.contains(&field_name) {
- self.task_optional_fields.retain(|f| f != &field_name);
- } else {
- self.task_optional_fields.push(field_name);
- }
- }
- }
- }
- SetupStep::SessionWrapperChoice => {
- // Select the currently highlighted wrapper option
- if let Some(i) = self.wrapper_state.selected() {
- let options = SessionWrapperOption::all();
- if i < options.len() {
- self.selected_wrapper = options[i].to_wrapper_type();
- }
- }
- }
- SetupStep::StartupTickets => {
- // Toggle the currently highlighted startup ticket option
- if let Some(i) = self.startup_state.selected() {
- if i < self.startup_ticket_options.len() {
- self.startup_ticket_options[i].enabled =
- !self.startup_ticket_options[i].enabled;
- }
- }
- }
- SetupStep::Confirm => {
- self.confirm_selected = !self.confirm_selected;
- }
- _ => {}
- }
- }
-
- /// Move to next item in list
- pub fn select_next(&mut self) {
- match self.step {
- SetupStep::CollectionSource => {
- let len = CollectionSourceOption::all().len();
- let i = self.source_state.selected().map_or(0, |i| (i + 1) % len);
- self.source_state.select(Some(i));
- }
- SetupStep::CustomCollection => {
- let len = ALL_ISSUE_TYPES.len();
- let i = self
- .collection_state
- .selected()
- .map_or(0, |i| (i + 1) % len);
- self.collection_state.select(Some(i));
- }
- SetupStep::TaskFieldConfig => {
- let len = TASK_OPTIONAL_FIELDS.len();
- let i = self.field_state.selected().map_or(0, |i| (i + 1) % len);
- self.field_state.select(Some(i));
- }
- SetupStep::SessionWrapperChoice => {
- let len = SessionWrapperOption::all().len();
- let i = self.wrapper_state.selected().map_or(0, |i| (i + 1) % len);
- self.wrapper_state.select(Some(i));
- }
- SetupStep::StartupTickets => {
- let len = self.startup_ticket_options.len();
- let i = self.startup_state.selected().map_or(0, |i| (i + 1) % len);
- self.startup_state.select(Some(i));
- }
- _ => {}
- }
- }
-
- /// Move to previous item in list
- pub fn select_prev(&mut self) {
- match self.step {
- SetupStep::CollectionSource => {
- let len = CollectionSourceOption::all().len();
- let i =
- self.source_state
- .selected()
- .map_or(0, |i| if i == 0 { len - 1 } else { i - 1 });
- self.source_state.select(Some(i));
- }
- SetupStep::CustomCollection => {
- let len = ALL_ISSUE_TYPES.len();
- let i = self.collection_state.selected().map_or(0, |i| {
- if i == 0 {
- len - 1
- } else {
- i - 1
- }
- });
- self.collection_state.select(Some(i));
- }
- SetupStep::TaskFieldConfig => {
- let len = TASK_OPTIONAL_FIELDS.len();
- let i = self
- .field_state
- .selected()
- .map_or(0, |i| if i == 0 { len - 1 } else { i - 1 });
- self.field_state.select(Some(i));
- }
- SetupStep::SessionWrapperChoice => {
- let len = SessionWrapperOption::all().len();
- let i =
- self.wrapper_state
- .selected()
- .map_or(0, |i| if i == 0 { len - 1 } else { i - 1 });
- self.wrapper_state.select(Some(i));
- }
- SetupStep::StartupTickets => {
- let len = self.startup_ticket_options.len();
- let i =
- self.startup_state
- .selected()
- .map_or(0, |i| if i == 0 { len - 1 } else { i - 1 });
- self.startup_state.select(Some(i));
- }
- _ => {}
- }
- }
-
- /// Proceed to next step or confirm (Enter key)
- pub fn confirm(&mut self) -> SetupResult {
- match self.step {
- SetupStep::Welcome => {
- self.step = SetupStep::CollectionSource;
- SetupResult::Continue
- }
- SetupStep::CollectionSource => {
- if let Some(source) = self.selected_source() {
- match source {
- CollectionSourceOption::Simple => {
- self.selected_preset = CollectionPreset::Simple;
- self.from_custom = false;
- self.step = SetupStep::TaskFieldConfig;
- SetupResult::Continue
- }
- CollectionSourceOption::DevKanban => {
- self.selected_preset = CollectionPreset::DevKanban;
- self.from_custom = false;
- self.step = SetupStep::TaskFieldConfig;
- SetupResult::Continue
- }
- CollectionSourceOption::DevopsKanban => {
- self.selected_preset = CollectionPreset::DevopsKanban;
- self.from_custom = false;
- self.step = SetupStep::TaskFieldConfig;
- SetupResult::Continue
- }
- CollectionSourceOption::ImportJira => SetupResult::ExitUnimplemented(
- "Jira import is not yet implemented".to_string(),
- ),
- CollectionSourceOption::ImportNotion => SetupResult::ExitUnimplemented(
- "Notion import is not yet implemented".to_string(),
- ),
- CollectionSourceOption::CustomSelection => {
- self.selected_preset = CollectionPreset::Custom;
- self.from_custom = true;
- self.step = SetupStep::CustomCollection;
- SetupResult::Continue
- }
- }
- } else {
- SetupResult::Continue
- }
- }
- SetupStep::CustomCollection => {
- if !self.custom_collection.is_empty() {
- self.step = SetupStep::TaskFieldConfig;
- }
- SetupResult::Continue
- }
- SetupStep::TaskFieldConfig => {
- self.step = SetupStep::SessionWrapperChoice;
- SetupResult::Continue
- }
- SetupStep::SessionWrapperChoice => {
- // Select the wrapper from the highlighted option
- if let Some(i) = self.wrapper_state.selected() {
- let options = SessionWrapperOption::all();
- if i < options.len() {
- self.selected_wrapper = options[i].to_wrapper_type();
- }
- }
- // Navigate to the appropriate next step
- match self.selected_wrapper {
- SessionWrapperType::Tmux => {
- // Check tmux availability when entering TmuxOnboarding
- self.check_tmux_availability();
- self.step = SetupStep::TmuxOnboarding;
- }
- SessionWrapperType::Vscode => {
- self.step = SetupStep::VSCodeSetup;
- }
- }
- SetupResult::Continue
- }
- SetupStep::TmuxOnboarding => {
- // Only allow proceeding if tmux is available
- if matches!(self.tmux_status, TmuxDetectionStatus::Available { .. }) {
- // Detect kanban providers if not already done
- if !self.kanban_detection_complete {
- self.detected_kanban_providers =
- crate::api::providers::kanban::detect_kanban_env_vars();
- self.kanban_detection_complete = true;
- }
- self.step = SetupStep::KanbanInfo;
- }
- // If tmux not available, stay on this step (user must install or go back)
- SetupResult::Continue
- }
- SetupStep::VSCodeSetup => {
- // For now, allow proceeding (extension check will be added later)
- // Detect kanban providers if not already done
- if !self.kanban_detection_complete {
- self.detected_kanban_providers =
- crate::api::providers::kanban::detect_kanban_env_vars();
- self.kanban_detection_complete = true;
- }
- self.step = SetupStep::KanbanInfo;
- SetupResult::Continue
- }
- SetupStep::KanbanInfo => {
- // If no valid providers or skipped, go to acceptance criteria
- if self.valid_kanban_providers.is_empty() || self.kanban_skipped {
- self.step = SetupStep::AcceptanceCriteria;
- } else {
- // Start with first valid provider
- self.step = SetupStep::KanbanProviderSetup { provider_index: 0 };
- }
- SetupResult::Continue
- }
- SetupStep::KanbanProviderSetup { provider_index } => {
- // Move to next provider or acceptance criteria
- let next_index = provider_index + 1;
- if next_index < self.valid_kanban_providers.len() {
- self.step = SetupStep::KanbanProviderSetup {
- provider_index: next_index,
- };
- } else {
- self.step = SetupStep::AcceptanceCriteria;
- }
- SetupResult::Continue
- }
- SetupStep::AcceptanceCriteria => {
- self.step = SetupStep::StartupTickets;
- SetupResult::Continue
- }
- SetupStep::StartupTickets => {
- self.step = SetupStep::Confirm;
- SetupResult::Continue
- }
- SetupStep::Confirm => {
- if self.confirm_selected {
- SetupResult::Initialize
- } else {
- SetupResult::Cancel
- }
- }
- }
- }
-
- /// Go back to previous step (Esc key)
- pub fn go_back(&mut self) -> SetupResult {
- match self.step {
- SetupStep::Welcome => SetupResult::Cancel,
- SetupStep::CollectionSource => {
- self.step = SetupStep::Welcome;
- SetupResult::Continue
- }
- SetupStep::CustomCollection => {
- self.step = SetupStep::CollectionSource;
- SetupResult::Continue
- }
- SetupStep::TaskFieldConfig => {
- if self.from_custom {
- self.step = SetupStep::CustomCollection;
- } else {
- self.step = SetupStep::CollectionSource;
- }
- SetupResult::Continue
- }
- SetupStep::SessionWrapperChoice => {
- self.step = SetupStep::TaskFieldConfig;
- SetupResult::Continue
- }
- SetupStep::TmuxOnboarding => {
- self.step = SetupStep::SessionWrapperChoice;
- SetupResult::Continue
- }
- SetupStep::VSCodeSetup => {
- self.step = SetupStep::SessionWrapperChoice;
- SetupResult::Continue
- }
- SetupStep::KanbanInfo => {
- // Go back to the appropriate wrapper setup step
- match self.selected_wrapper {
- SessionWrapperType::Tmux => self.step = SetupStep::TmuxOnboarding,
- SessionWrapperType::Vscode => self.step = SetupStep::VSCodeSetup,
- }
- SetupResult::Continue
- }
- SetupStep::KanbanProviderSetup { provider_index } => {
- if provider_index > 0 {
- self.step = SetupStep::KanbanProviderSetup {
- provider_index: provider_index - 1,
- };
- } else {
- self.step = SetupStep::KanbanInfo;
- }
- SetupResult::Continue
- }
- SetupStep::AcceptanceCriteria => {
- // Go back to last kanban provider setup or kanban info
- if !self.valid_kanban_providers.is_empty() && !self.kanban_skipped {
- let last_index = self.valid_kanban_providers.len() - 1;
- self.step = SetupStep::KanbanProviderSetup {
- provider_index: last_index,
- };
- } else {
- self.step = SetupStep::KanbanInfo;
- }
- SetupResult::Continue
- }
- SetupStep::StartupTickets => {
- self.step = SetupStep::AcceptanceCriteria;
- SetupResult::Continue
- }
- SetupStep::Confirm => {
- self.step = SetupStep::StartupTickets;
- SetupResult::Continue
- }
- }
- }
-
- /// Render the setup screen
- pub fn render(&mut self, frame: &mut Frame) {
- if !self.visible {
- return;
- }
-
- match self.step.clone() {
- SetupStep::Welcome => self.render_welcome_step(frame),
- SetupStep::CollectionSource => self.render_collection_source_step(frame),
- SetupStep::CustomCollection => self.render_custom_collection_step(frame),
- SetupStep::TaskFieldConfig => self.render_task_field_config_step(frame),
- SetupStep::SessionWrapperChoice => self.render_session_wrapper_choice_step(frame),
- SetupStep::TmuxOnboarding => self.render_tmux_onboarding_step(frame),
- SetupStep::VSCodeSetup => self.render_vscode_setup_step(frame),
- SetupStep::KanbanInfo => self.render_kanban_info_step(frame),
- SetupStep::KanbanProviderSetup { provider_index } => {
- self.render_kanban_provider_setup_step(frame, provider_index)
- }
- SetupStep::AcceptanceCriteria => self.render_acceptance_criteria_step(frame),
- SetupStep::StartupTickets => self.render_startup_tickets_step(frame),
- SetupStep::Confirm => self.render_confirm_step(frame),
- }
- }
-
- /// Check tmux availability and update status
- pub fn check_tmux_availability(&mut self) {
- let client = SystemTmuxClient::new();
- match client.check_available() {
- Ok(version) => {
- // Minimum version 2.1 for the features we use
- const MIN_MAJOR: u32 = 2;
- const MIN_MINOR: u32 = 1;
-
- if version.meets_minimum(MIN_MAJOR, MIN_MINOR) {
- self.tmux_status = TmuxDetectionStatus::Available {
- version: version.raw,
- };
- } else {
- self.tmux_status = TmuxDetectionStatus::VersionTooOld {
- current: version.raw,
- required: format!("{}.{}", MIN_MAJOR, MIN_MINOR),
- };
- }
- }
- Err(TmuxError::NotInstalled) => {
- self.tmux_status = TmuxDetectionStatus::NotInstalled;
- }
- Err(_) => {
- self.tmux_status = TmuxDetectionStatus::NotInstalled;
- }
- }
- }
-
- /// Re-check tmux availability (for [R] key binding)
- #[allow(dead_code)] // Will be connected to [R] key handler in app event loop
- pub fn recheck_tmux(&mut self) {
- self.check_tmux_availability();
- }
-
- fn render_welcome_step(&self, frame: &mut Frame) {
- let area = centered_rect(70, 80, frame.area());
- frame.render_widget(Clear, area);
-
- let block = Block::default()
- .title(Line::from(vec![
- Span::raw(" "),
- Span::styled(
- "Operator!",
- Style::default()
- .fg(Color::LightRed)
- .add_modifier(Modifier::BOLD),
- ),
- Span::raw(" Workspace Setup "),
- ]))
- .borders(Borders::ALL)
- .border_style(Style::default().fg(Color::Cyan));
-
- let inner = block.inner(area);
- frame.render_widget(block, area);
-
- let chunks = Layout::default()
- .direction(Direction::Vertical)
- .margin(2)
- .constraints([
- Constraint::Length(3), // Title
- Constraint::Length(1), // Spacer
- Constraint::Length(2), // Description
- Constraint::Length(1), // Spacer
- Constraint::Length(6), // Detected LLM Tools
- Constraint::Length(1), // Spacer
- Constraint::Min(6), // Discovered projects by tool
- Constraint::Length(1), // Spacer
- Constraint::Length(2), // Path info
- Constraint::Length(3), // Footer
- ])
- .split(inner);
-
- // Title
- let title = Paragraph::new(Line::from(vec![Span::styled(
- "Operator!",
- Style::default()
- .fg(Color::LightRed)
- .add_modifier(Modifier::BOLD),
- )]))
- .alignment(Alignment::Center);
- frame.render_widget(title, chunks[0]);
-
- // Description
- let desc = Paragraph::new(vec![Line::from("A TUI for orchestrating LLM Code agents.")])
- .alignment(Alignment::Center);
- frame.render_widget(desc, chunks[2]);
-
- // Detected LLM Tools
- let mut tools_text = vec![Line::from(Span::styled(
- "Detected LLM Tools:",
- Style::default()
- .fg(Color::Yellow)
- .add_modifier(Modifier::BOLD),
- ))];
-
- // Show each known tool with detection status
- for (tool_name, _marker) in TOOL_MARKERS {
- let detected = self.detected_tools.iter().find(|t| t.name == *tool_name);
-
- let line = if let Some(tool) = detected {
- Line::from(vec![
- Span::styled(" + ", Style::default().fg(Color::Green)),
- Span::styled(
- tool_name.to_string(),
- Style::default()
- .fg(Color::Green)
- .add_modifier(Modifier::BOLD),
- ),
- Span::styled(
- format!(" (v{}) - {} models", tool.version, tool.model_count),
- Style::default().fg(Color::DarkGray),
- ),
- ])
- } else {
- Line::from(vec![
- Span::styled(" - ", Style::default().fg(Color::DarkGray)),
- Span::styled(tool_name.to_string(), Style::default().fg(Color::DarkGray)),
- Span::styled(" - not installed", Style::default().fg(Color::DarkGray)),
- ])
- };
- tools_text.push(line);
- }
- frame.render_widget(Paragraph::new(tools_text), chunks[4]);
-
- // Discovered projects by tool
- let mut projects_text = vec![Line::from(Span::styled(
- "Discovered Projects:",
- Style::default()
- .fg(Color::Yellow)
- .add_modifier(Modifier::BOLD),
- ))];
-
- let mut has_any_projects = false;
- for (tool_name, _marker) in TOOL_MARKERS {
- if let Some(projects) = self.projects_by_tool.get(*tool_name) {
- if !projects.is_empty() {
- has_any_projects = true;
- let project_list = projects.join(", ");
- projects_text.push(Line::from(vec![
- Span::styled(
- format!(" {}: ", tool_name),
- Style::default().fg(Color::Cyan),
- ),
- Span::styled(project_list, Style::default().fg(Color::Green)),
- ]));
- }
- }
- }
-
- if !has_any_projects {
- projects_text.push(Line::from(Span::styled(
- " (no projects with marker files found)",
- Style::default().fg(Color::DarkGray),
- )));
- }
- frame.render_widget(Paragraph::new(projects_text), chunks[6]);
-
- // Path info
- let path_info = Paragraph::new(Line::from(vec![
- Span::styled("Path: ", Style::default().fg(Color::Gray)),
- Span::styled(&self.tickets_path, Style::default().fg(Color::White)),
- ]))
- .alignment(Alignment::Center);
- frame.render_widget(path_info, chunks[8]);
-
- // Footer
- let footer = Paragraph::new(Line::from(vec![
- Span::styled("Enter", Style::default().fg(Color::Yellow)),
- Span::raw(" continue "),
- Span::styled("Esc", Style::default().fg(Color::Yellow)),
- Span::raw(" cancel"),
- ]))
- .alignment(Alignment::Center);
- frame.render_widget(footer, chunks[9]);
- }
-
- fn render_collection_source_step(&mut self, frame: &mut Frame) {
- let area = centered_rect(60, 60, frame.area());
- frame.render_widget(Clear, area);
-
- let block = Block::default()
- .title(" Select Template Collection ")
- .borders(Borders::ALL)
- .border_style(Style::default().fg(Color::Cyan));
-
- let inner = block.inner(area);
- frame.render_widget(block, area);
-
- let chunks = Layout::default()
- .direction(Direction::Vertical)
- .margin(2)
- .constraints([
- Constraint::Length(3), // Title
- Constraint::Length(2), // Instructions
- Constraint::Min(8), // Options list
- Constraint::Length(2), // Footer
- ])
- .split(inner);
-
- // Title
- let title = Paragraph::new(Line::from(vec![Span::styled(
- "Choose Template Source",
- Style::default()
- .fg(Color::LightRed)
- .add_modifier(Modifier::BOLD),
- )]))
- .alignment(Alignment::Center);
- frame.render_widget(title, chunks[0]);
-
- // Instructions
- let instructions =
- Paragraph::new(vec![Line::from("Use arrows to navigate, Enter to select")])
- .alignment(Alignment::Center)
- .style(Style::default().fg(Color::Gray));
- frame.render_widget(instructions, chunks[1]);
-
- // Options list
- let items: Vec = CollectionSourceOption::all()
- .iter()
- .map(|opt| {
- let style = if opt.is_unimplemented() {
- Style::default().fg(Color::DarkGray)
- } else {
- Style::default()
- };
-
- ListItem::new(vec![
- Line::from(vec![Span::styled(
- opt.label(),
- style.add_modifier(Modifier::BOLD),
- )]),
- Line::from(vec![
- Span::raw(" "),
- Span::styled(opt.description(), Style::default().fg(Color::DarkGray)),
- ]),
- ])
- })
- .collect();
-
- let list = List::new(items)
- .highlight_style(Style::default().add_modifier(Modifier::REVERSED))
- .highlight_symbol("> ");
-
- frame.render_stateful_widget(list, chunks[2], &mut self.source_state);
-
- // Footer
- let footer = Paragraph::new(Line::from(vec![
- Span::styled("Enter", Style::default().fg(Color::Yellow)),
- Span::raw(" select "),
- Span::styled("Esc", Style::default().fg(Color::Yellow)),
- Span::raw(" back"),
- ]))
- .alignment(Alignment::Center);
- frame.render_widget(footer, chunks[3]);
- }
-
- fn render_custom_collection_step(&mut self, frame: &mut Frame) {
- let area = centered_rect(60, 60, frame.area());
- frame.render_widget(Clear, area);
-
- let block = Block::default()
- .title(Line::from(vec![
- Span::raw(" "),
- Span::styled(
- "Operator!",
- Style::default()
- .fg(Color::LightRed)
- .add_modifier(Modifier::BOLD),
- ),
- Span::raw(" Setup - Issue Types "),
- ]))
- .borders(Borders::ALL)
- .border_style(Style::default().fg(Color::Cyan));
-
- let inner = block.inner(area);
- frame.render_widget(block, area);
-
- let chunks = Layout::default()
- .direction(Direction::Vertical)
- .margin(2)
- .constraints([
- Constraint::Length(3), // Title
- Constraint::Length(2), // Instructions
- Constraint::Min(8), // Collection list
- Constraint::Length(2), // Footer
- ])
- .split(inner);
-
- // Title
- let title = Paragraph::new(Line::from(vec![Span::styled(
- "Select Issue Types",
- Style::default()
- .fg(Color::LightRed)
- .add_modifier(Modifier::BOLD),
- )]))
- .alignment(Alignment::Center);
- frame.render_widget(title, chunks[0]);
-
- // Instructions
- let instructions = Paragraph::new(vec![Line::from(
- "Use arrows to navigate, Space to toggle, Enter to continue",
- )])
- .alignment(Alignment::Center)
- .style(Style::default().fg(Color::Gray));
- frame.render_widget(instructions, chunks[1]);
-
- // Collection list
- let items: Vec = ALL_ISSUE_TYPES
- .iter()
- .map(|t| {
- let is_selected = self.custom_collection.contains(&t.to_string());
- let checkbox = if is_selected { "[x]" } else { "[ ]" };
- let description = match *t {
- "TASK" => "Focused task that executes one specific thing",
- "FEAT" => "New feature or enhancement",
- "FIX" => "Bug fix, follow-up work, tech debt",
- "SPIKE" => "Research or exploration (paired mode)",
- "INV" => "Incident investigation (paired mode)",
- _ => "",
- };
- ListItem::new(vec![
- Line::from(vec![
- Span::styled(
- checkbox,
- Style::default().fg(if is_selected {
- Color::Green
- } else {
- Color::DarkGray
- }),
- ),
- Span::raw(" "),
- Span::styled(
- *t,
- Style::default()
- .add_modifier(Modifier::BOLD)
- .fg(if is_selected {
- Color::White
- } else {
- Color::Gray
- }),
- ),
- ]),
- Line::from(vec![
- Span::raw(" "),
- Span::styled(description, Style::default().fg(Color::DarkGray)),
- ]),
- ])
- })
- .collect();
-
- let list = List::new(items)
- .highlight_style(Style::default().add_modifier(Modifier::REVERSED))
- .highlight_symbol("> ");
-
- frame.render_stateful_widget(list, chunks[2], &mut self.collection_state);
-
- // Footer
- let selected_count = self.custom_collection.len();
- let footer = Paragraph::new(Line::from(vec![
- Span::styled(
- format!("{} selected", selected_count),
- Style::default().fg(if selected_count > 0 {
- Color::Green
- } else {
- Color::Red
- }),
- ),
- Span::raw(" | "),
- Span::styled("Enter", Style::default().fg(Color::Yellow)),
- Span::raw(" continue "),
- Span::styled("Esc", Style::default().fg(Color::Yellow)),
- Span::raw(" back"),
- ]))
- .alignment(Alignment::Center);
- frame.render_widget(footer, chunks[3]);
- }
-
- fn render_task_field_config_step(&mut self, frame: &mut Frame) {
- let area = centered_rect(70, 60, frame.area());
- frame.render_widget(Clear, area);
-
- let block = Block::default()
- .title(Line::from(vec![
- Span::raw(" "),
- Span::styled(
- "Operator!",
- Style::default()
- .fg(Color::LightRed)
- .add_modifier(Modifier::BOLD),
- ),
- Span::raw(" Setup - Configure TASK Fields "),
- ]))
- .borders(Borders::ALL)
- .border_style(Style::default().fg(Color::Cyan));
-
- let inner = block.inner(area);
- frame.render_widget(block, area);
-
- let chunks = Layout::default()
- .direction(Direction::Vertical)
- .margin(2)
- .constraints([
- Constraint::Length(3), // Title
- Constraint::Length(3), // Explanation
- Constraint::Length(2), // Instructions
- Constraint::Min(6), // Field list
- Constraint::Length(2), // Footer
- ])
- .split(inner);
-
- // Title
- let title = Paragraph::new(Line::from(vec![Span::styled(
- "Configure TASK Fields",
- Style::default()
- .fg(Color::LightRed)
- .add_modifier(Modifier::BOLD),
- )]))
- .alignment(Alignment::Center);
- frame.render_widget(title, chunks[0]);
-
- // Explanation
- let explanation = Paragraph::new(vec![
- Line::from("TASK is the foundational issuetype. Configure which optional"),
- Line::from("fields to include. These choices will propagate to other types."),
- ])
- .alignment(Alignment::Center)
- .style(Style::default().fg(Color::Gray));
- frame.render_widget(explanation, chunks[1]);
-
- // Instructions
- let instructions = Paragraph::new(vec![Line::from(
- "Use arrows to navigate, Space to toggle, Enter to continue",
- )])
- .alignment(Alignment::Center)
- .style(Style::default().fg(Color::DarkGray));
- frame.render_widget(instructions, chunks[2]);
-
- // Field list
- let items: Vec = TASK_OPTIONAL_FIELDS
- .iter()
- .map(|(name, description)| {
- let is_selected = self.task_optional_fields.contains(&name.to_string());
- let checkbox = if is_selected { "[x]" } else { "[ ]" };
- ListItem::new(vec![
- Line::from(vec![
- Span::styled(
- checkbox,
- Style::default().fg(if is_selected {
- Color::Green
- } else {
- Color::DarkGray
- }),
- ),
- Span::raw(" "),
- Span::styled(
- *name,
- Style::default()
- .add_modifier(Modifier::BOLD)
- .fg(if is_selected {
- Color::White
- } else {
- Color::Gray
- }),
- ),
- ]),
- Line::from(vec![
- Span::raw(" "),
- Span::styled(*description, Style::default().fg(Color::DarkGray)),
- ]),
- ])
- })
- .collect();
-
- let list = List::new(items)
- .highlight_style(Style::default().add_modifier(Modifier::REVERSED))
- .highlight_symbol("> ");
-
- frame.render_stateful_widget(list, chunks[3], &mut self.field_state);
-
- // Footer
- let selected_count = self.task_optional_fields.len();
- let footer = Paragraph::new(Line::from(vec![
- Span::styled(
- format!(
- "{}/{} fields enabled",
- selected_count,
- TASK_OPTIONAL_FIELDS.len()
- ),
- Style::default().fg(Color::Cyan),
- ),
- Span::raw(" | "),
- Span::styled("Enter", Style::default().fg(Color::Yellow)),
- Span::raw(" continue "),
- Span::styled("Esc", Style::default().fg(Color::Yellow)),
- Span::raw(" back"),
- ]))
- .alignment(Alignment::Center);
- frame.render_widget(footer, chunks[4]);
- }
-
- fn render_session_wrapper_choice_step(&mut self, frame: &mut Frame) {
- let area = centered_rect(70, 60, frame.area());
- frame.render_widget(Clear, area);
-
- let block = Block::default()
- .title(Line::from(vec![
- Span::raw(" "),
- Span::styled(
- "Operator!",
- Style::default()
- .fg(Color::LightRed)
- .add_modifier(Modifier::BOLD),
- ),
- Span::raw(" Setup - Session Wrapper "),
- ]))
- .borders(Borders::ALL)
- .border_style(Style::default().fg(Color::Cyan));
-
- let inner = block.inner(area);
- frame.render_widget(block, area);
-
- let chunks = Layout::default()
- .direction(Direction::Vertical)
- .margin(2)
- .constraints([
- Constraint::Length(3), // Title
- Constraint::Length(3), // Description
- Constraint::Length(2), // Instructions
- Constraint::Min(8), // Options list
- Constraint::Length(2), // Footer
- ])
- .split(inner);
-
- // Title
- let title = Paragraph::new(Line::from(vec![Span::styled(
- "Session Wrapper Configuration",
- Style::default()
- .fg(Color::LightRed)
- .add_modifier(Modifier::BOLD),
- )]))
- .alignment(Alignment::Center);
- frame.render_widget(title, chunks[0]);
-
- // Description
- let description = Paragraph::new(vec![
- Line::from("Operator runs agents in terminal sessions."),
- Line::from("Select your preferred wrapper:"),
- ])
- .alignment(Alignment::Center)
- .style(Style::default().fg(Color::Gray));
- frame.render_widget(description, chunks[1]);
-
- // Instructions
- let instructions =
- Paragraph::new(vec![Line::from("Use arrows to navigate, Enter to select")])
- .alignment(Alignment::Center)
- .style(Style::default().fg(Color::DarkGray));
- frame.render_widget(instructions, chunks[2]);
-
- // Options list
- let items: Vec = SessionWrapperOption::all()
- .iter()
- .map(|opt| {
- let is_selected = opt.to_wrapper_type() == self.selected_wrapper;
- let radio = if is_selected { "(o)" } else { "( )" };
- ListItem::new(vec![
- Line::from(vec![
- Span::styled(
- radio,
- Style::default().fg(if is_selected {
- Color::Green
- } else {
- Color::DarkGray
- }),
- ),
- Span::raw(" "),
- Span::styled(
- opt.label(),
- Style::default()
- .add_modifier(Modifier::BOLD)
- .fg(if is_selected {
- Color::White
- } else {
- Color::Gray
- }),
- ),
- ]),
- Line::from(vec![
- Span::raw(" "),
- Span::styled(opt.description(), Style::default().fg(Color::DarkGray)),
- ]),
- ])
- })
- .collect();
-
- let list = List::new(items)
- .highlight_style(Style::default().add_modifier(Modifier::REVERSED))
- .highlight_symbol("> ");
-
- frame.render_stateful_widget(list, chunks[3], &mut self.wrapper_state);
-
- // Footer
- let footer = Paragraph::new(Line::from(vec![
- Span::styled("Enter", Style::default().fg(Color::Yellow)),
- Span::raw(" select "),
- Span::styled("Esc", Style::default().fg(Color::Yellow)),
- Span::raw(" back"),
- ]))
- .alignment(Alignment::Center);
- frame.render_widget(footer, chunks[4]);
- }
-
- fn render_tmux_onboarding_step(&self, frame: &mut Frame) {
- let area = centered_rect(70, 75, frame.area());
- frame.render_widget(Clear, area);
-
- let block = Block::default()
- .title(" Tmux Configuration ")
- .borders(Borders::ALL)
- .border_style(Style::default().fg(Color::Cyan));
-
- let inner = block.inner(area);
- frame.render_widget(block, area);
-
- let chunks = Layout::default()
- .direction(Direction::Vertical)
- .margin(2)
- .constraints([
- Constraint::Length(3), // Title
- Constraint::Length(1), // Spacer
- Constraint::Length(3), // Status
- Constraint::Length(1), // Spacer
- Constraint::Min(12), // Help text or install instructions
- Constraint::Length(3), // Footer
- ])
- .split(inner);
-
- // Title
- let title = Paragraph::new(Line::from(vec![Span::styled(
- "Tmux Session Configuration",
- Style::default()
- .fg(Color::LightRed)
- .add_modifier(Modifier::BOLD),
- )]))
- .alignment(Alignment::Center);
- frame.render_widget(title, chunks[0]);
-
- // Status indicator
- let status_line = match &self.tmux_status {
- TmuxDetectionStatus::NotChecked => Line::from(vec![
- Span::styled("Tmux status: ", Style::default().fg(Color::Gray)),
- Span::styled("[?] ", Style::default().fg(Color::Yellow)),
- Span::styled("Not checked", Style::default().fg(Color::Yellow)),
- ]),
- TmuxDetectionStatus::Available { version } => Line::from(vec![
- Span::styled("Tmux status: ", Style::default().fg(Color::Gray)),
- Span::styled("[+] ", Style::default().fg(Color::Green)),
- Span::styled(
- format!("Available (v{})", version),
- Style::default().fg(Color::Green),
- ),
- ]),
- TmuxDetectionStatus::NotInstalled => Line::from(vec![
- Span::styled("Tmux status: ", Style::default().fg(Color::Gray)),
- Span::styled("[x] ", Style::default().fg(Color::Red)),
- Span::styled("Not installed", Style::default().fg(Color::Red)),
- ]),
- TmuxDetectionStatus::VersionTooOld { current, required } => Line::from(vec![
- Span::styled("Tmux status: ", Style::default().fg(Color::Gray)),
- Span::styled("[x] ", Style::default().fg(Color::Red)),
- Span::styled(
- format!("Version too old (v{}, need {}+)", current, required),
- Style::default().fg(Color::Red),
- ),
- ]),
- };
- let status = Paragraph::new(vec![status_line]).alignment(Alignment::Center);
- frame.render_widget(status, chunks[2]);
-
- // Help text or install instructions
- let help_text = if matches!(self.tmux_status, TmuxDetectionStatus::Available { .. }) {
- vec![
- Line::from(Span::styled(
- "Essential Commands:",
- Style::default()
- .fg(Color::Yellow)
- .add_modifier(Modifier::BOLD),
- )),
- Line::from(""),
- Line::from(vec![
- Span::styled(" Detach from session: ", Style::default().fg(Color::Gray)),
- Span::styled(
- "Ctrl+a",
- Style::default()
- .fg(Color::Cyan)
- .add_modifier(Modifier::BOLD),
- ),
- Span::styled(
- " (quick, no prefix needed!)",
- Style::default().fg(Color::DarkGray),
- ),
- ]),
- Line::from(vec![
- Span::styled(" Fallback detach: ", Style::default().fg(Color::Gray)),
- Span::styled("Ctrl+b", Style::default().fg(Color::Cyan)),
- Span::styled(" then ", Style::default().fg(Color::Gray)),
- Span::styled("d", Style::default().fg(Color::Cyan)),
- ]),
- Line::from(""),
- Line::from(vec![
- Span::styled(" List sessions: ", Style::default().fg(Color::Gray)),
- Span::styled("tmux ls", Style::default().fg(Color::Green)),
- ]),
- Line::from(vec![
- Span::styled(" Attach to session: ", Style::default().fg(Color::Gray)),
- Span::styled("tmux attach -t ", Style::default().fg(Color::Green)),
- Span::styled("", Style::default().fg(Color::DarkGray)),
- ]),
- Line::from(""),
- Line::from(Span::styled(
- "Operator session names start with 'op-'",
- Style::default().fg(Color::DarkGray),
- )),
- ]
- } else {
- vec![
- Line::from(Span::styled(
- "Install tmux:",
- Style::default()
- .fg(Color::Yellow)
- .add_modifier(Modifier::BOLD),
- )),
- Line::from(""),
- Line::from(vec![
- Span::styled(" macOS: ", Style::default().fg(Color::Gray)),
- Span::styled("brew install tmux", Style::default().fg(Color::Green)),
- ]),
- Line::from(vec![
- Span::styled(" Ubuntu/Debian: ", Style::default().fg(Color::Gray)),
- Span::styled("sudo apt install tmux", Style::default().fg(Color::Green)),
- ]),
- Line::from(vec![
- Span::styled(" Fedora/RHEL: ", Style::default().fg(Color::Gray)),
- Span::styled("sudo dnf install tmux", Style::default().fg(Color::Green)),
- ]),
- Line::from(vec![
- Span::styled(" Arch: ", Style::default().fg(Color::Gray)),
- Span::styled("sudo pacman -S tmux", Style::default().fg(Color::Green)),
- ]),
- Line::from(""),
- Line::from(Span::styled(
- "After installing, press [R] to re-check",
- Style::default().fg(Color::DarkGray),
- )),
- ]
- };
- frame.render_widget(Paragraph::new(help_text), chunks[4]);
-
- // Footer - different depending on status
- let footer = if matches!(self.tmux_status, TmuxDetectionStatus::Available { .. }) {
- Paragraph::new(Line::from(vec![
- Span::styled("[R]", Style::default().fg(Color::Yellow)),
- Span::raw(" re-check "),
- Span::styled("Enter", Style::default().fg(Color::Yellow)),
- Span::raw(" continue "),
- Span::styled("Esc", Style::default().fg(Color::Yellow)),
- Span::raw(" back"),
- ]))
- } else {
- Paragraph::new(Line::from(vec![
- Span::styled("[R]", Style::default().fg(Color::Green)),
- Span::raw(" re-check tmux "),
- Span::styled("Esc", Style::default().fg(Color::Yellow)),
- Span::raw(" back"),
- ]))
- };
- frame.render_widget(footer.alignment(Alignment::Center), chunks[5]);
- }
-
- fn render_vscode_setup_step(&self, frame: &mut Frame) {
- let area = centered_rect(70, 70, frame.area());
- frame.render_widget(Clear, area);
-
- let block = Block::default()
- .title(" VS Code Extension Setup ")
- .borders(Borders::ALL)
- .border_style(Style::default().fg(Color::Cyan));
-
- let inner = block.inner(area);
- frame.render_widget(block, area);
-
- let chunks = Layout::default()
- .direction(Direction::Vertical)
- .margin(2)
- .constraints([
- Constraint::Length(3), // Title
- Constraint::Length(1), // Spacer
- Constraint::Length(3), // Status
- Constraint::Length(1), // Spacer
- Constraint::Min(12), // Instructions
- Constraint::Length(3), // Footer
- ])
- .split(inner);
-
- // Title
- let title = Paragraph::new(Line::from(vec![Span::styled(
- "VS Code Integration Setup",
- Style::default()
- .fg(Color::LightRed)
- .add_modifier(Modifier::BOLD),
- )]))
- .alignment(Alignment::Center);
- frame.render_widget(title, chunks[0]);
-
- // Status indicator
- let status_line = match &self.vscode_status {
- VSCodeDetectionStatus::NotChecked => Line::from(vec![
- Span::styled("Extension status: ", Style::default().fg(Color::Gray)),
- Span::styled("[?] ", Style::default().fg(Color::Yellow)),
- Span::styled("Not checked", Style::default().fg(Color::Yellow)),
- ]),
- VSCodeDetectionStatus::Checking => Line::from(vec![
- Span::styled("Extension status: ", Style::default().fg(Color::Gray)),
- Span::styled("[~] ", Style::default().fg(Color::Yellow)),
- Span::styled("Checking...", Style::default().fg(Color::Yellow)),
- ]),
- VSCodeDetectionStatus::Connected { version } => Line::from(vec![
- Span::styled("Extension status: ", Style::default().fg(Color::Gray)),
- Span::styled("[+] ", Style::default().fg(Color::Green)),
- Span::styled(
- format!("Connected (v{})", version),
- Style::default().fg(Color::Green),
- ),
- ]),
- VSCodeDetectionStatus::NotReachable => Line::from(vec![
- Span::styled("Extension status: ", Style::default().fg(Color::Gray)),
- Span::styled("[x] ", Style::default().fg(Color::Red)),
- Span::styled("Not detected", Style::default().fg(Color::Red)),
- ]),
- };
- let status = Paragraph::new(vec![status_line]).alignment(Alignment::Center);
- frame.render_widget(status, chunks[2]);
-
- // Instructions
- let instructions = vec![
- Line::from(Span::styled(
- "To use VS Code integration:",
- Style::default()
- .fg(Color::Yellow)
- .add_modifier(Modifier::BOLD),
- )),
- Line::from(""),
- Line::from(vec![
- Span::styled(" 1. ", Style::default().fg(Color::Cyan)),
- Span::raw("Install the Operator extension from:"),
- ]),
- Line::from(vec![
- Span::raw(" "),
- Span::styled(
- "https://operator.untra.io/vscode",
- Style::default()
- .fg(Color::Blue)
- .add_modifier(Modifier::UNDERLINED),
- ),
- ]),
- Line::from(""),
- Line::from(vec![
- Span::styled(" 2. ", Style::default().fg(Color::Cyan)),
- Span::raw("Restart VS Code after installation"),
- ]),
- Line::from(""),
- Line::from(vec![
- Span::styled(" 3. ", Style::default().fg(Color::Cyan)),
- Span::raw("The extension will start automatically on port 7009"),
- ]),
- Line::from(""),
- Line::from(Span::styled(
- "Note: VS Code extension support is coming soon!",
- Style::default().fg(Color::DarkGray),
- )),
- ];
- frame.render_widget(Paragraph::new(instructions), chunks[4]);
-
- // Footer
- let footer = Paragraph::new(Line::from(vec![
- Span::styled("[T]", Style::default().fg(Color::Yellow)),
- Span::raw(" test connection "),
- Span::styled("Enter", Style::default().fg(Color::Yellow)),
- Span::raw(" continue "),
- Span::styled("Esc", Style::default().fg(Color::Yellow)),
- Span::raw(" back"),
- ]))
- .alignment(Alignment::Center);
- frame.render_widget(footer, chunks[5]);
- }
-
- fn render_acceptance_criteria_step(&self, frame: &mut Frame) {
- let area = centered_rect(70, 70, frame.area());
- frame.render_widget(Clear, area);
-
- let block = Block::default()
- .title(Line::from(vec![
- Span::raw(" "),
- Span::styled(
- "Operator!",
- Style::default()
- .fg(Color::LightRed)
- .add_modifier(Modifier::BOLD),
- ),
- Span::raw(" Setup - Acceptance Criteria "),
- ]))
- .borders(Borders::ALL)
- .border_style(Style::default().fg(Color::Cyan));
-
- let inner = block.inner(area);
- frame.render_widget(block, area);
-
- let chunks = Layout::default()
- .direction(Direction::Vertical)
- .margin(2)
- .constraints([
- Constraint::Length(3), // Title
- Constraint::Length(2), // Description
- Constraint::Min(8), // Acceptance criteria content
- Constraint::Length(3), // Footer
- ])
- .split(inner);
-
- // Title
- let title = Paragraph::new(Line::from(vec![Span::styled(
- "Review Acceptance Criteria",
- Style::default()
- .fg(Color::Yellow)
- .add_modifier(Modifier::BOLD),
- )]))
- .alignment(Alignment::Center);
- frame.render_widget(title, chunks[0]);
-
- // Description
- let desc = Paragraph::new(vec![
- Line::from("These criteria will be used to validate completed work."),
- Line::from("Other template files (Definition of Done, Definition of Ready) will be written from defaults."),
- ])
- .alignment(Alignment::Center);
- frame.render_widget(desc, chunks[1]);
-
- // Acceptance criteria content (read-only preview)
- let content_block = Block::default()
- .title(" Acceptance Criteria Template ")
- .borders(Borders::ALL)
- .border_style(Style::default().fg(Color::DarkGray));
-
- let content = Paragraph::new(self.acceptance_criteria_text.as_str())
- .block(content_block)
- .wrap(ratatui::widgets::Wrap { trim: false });
- frame.render_widget(content, chunks[2]);
-
- // Footer with key hints
- let footer = Paragraph::new(Line::from(vec![
- Span::styled("Enter", Style::default().fg(Color::Green)),
- Span::raw(" accept | "),
- Span::styled("Esc", Style::default().fg(Color::Yellow)),
- Span::raw(" back"),
- ]))
- .alignment(Alignment::Center);
- frame.render_widget(footer, chunks[3]);
- }
-
- fn render_startup_tickets_step(&mut self, frame: &mut Frame) {
- let area = centered_rect(70, 60, frame.area());
- frame.render_widget(Clear, area);
-
- let block = Block::default()
- .title(Line::from(vec![
- Span::raw(" "),
- Span::styled(
- "Operator!",
- Style::default()
- .fg(Color::LightRed)
- .add_modifier(Modifier::BOLD),
- ),
- Span::raw(" Setup - Startup Tickets "),
- ]))
- .borders(Borders::ALL)
- .border_style(Style::default().fg(Color::Cyan));
-
- let inner = block.inner(area);
- frame.render_widget(block, area);
-
- let chunks = Layout::default()
- .direction(Direction::Vertical)
- .margin(2)
- .constraints([
- Constraint::Length(3), // Title
- Constraint::Length(3), // Explanation
- Constraint::Length(2), // Instructions
- Constraint::Min(8), // Options list
- Constraint::Length(2), // Footer
- ])
- .split(inner);
-
- // Title
- let title = Paragraph::new(Line::from(vec![Span::styled(
- "Create Startup Tickets",
- Style::default()
- .fg(Color::LightRed)
- .add_modifier(Modifier::BOLD),
- )]))
- .alignment(Alignment::Center);
- frame.render_widget(title, chunks[0]);
-
- // Explanation
- let explanation = Paragraph::new(vec![
- Line::from("Optionally create tickets to bootstrap your projects."),
- Line::from("These help set up catalog entries and agent configurations."),
- ])
- .alignment(Alignment::Center)
- .style(Style::default().fg(Color::Gray));
- frame.render_widget(explanation, chunks[1]);
-
- // Instructions
- let instructions = Paragraph::new(vec![Line::from(
- "Use arrows to navigate, Space to toggle, Enter to continue",
- )])
- .alignment(Alignment::Center)
- .style(Style::default().fg(Color::DarkGray));
- frame.render_widget(instructions, chunks[2]);
-
- // Options list
- let items: Vec = self
- .startup_ticket_options
- .iter()
- .map(|opt| {
- let checkbox = if opt.enabled { "[x]" } else { "[ ]" };
- ListItem::new(vec![
- Line::from(vec![
- Span::styled(
- checkbox,
- Style::default().fg(if opt.enabled {
- Color::Green
- } else {
- Color::DarkGray
- }),
- ),
- Span::raw(" "),
- Span::styled(
- opt.name,
- Style::default()
- .add_modifier(Modifier::BOLD)
- .fg(if opt.enabled {
- Color::White
- } else {
- Color::Gray
- }),
- ),
- ]),
- Line::from(vec![
- Span::raw(" "),
- Span::styled(opt.description, Style::default().fg(Color::DarkGray)),
- ]),
- ])
- })
- .collect();
-
- let list = List::new(items)
- .highlight_style(Style::default().add_modifier(Modifier::REVERSED))
- .highlight_symbol("> ");
-
- frame.render_stateful_widget(list, chunks[3], &mut self.startup_state);
-
- // Footer
- let selected_count = self
- .startup_ticket_options
- .iter()
- .filter(|o| o.enabled)
- .count();
- let footer = Paragraph::new(Line::from(vec![
- Span::styled(
- format!("{} ticket types selected", selected_count),
- Style::default().fg(if selected_count > 0 {
- Color::Green
- } else {
- Color::Gray
- }),
- ),
- Span::raw(" | "),
- Span::styled("Enter", Style::default().fg(Color::Yellow)),
- Span::raw(" continue "),
- Span::styled("Esc", Style::default().fg(Color::Yellow)),
- Span::raw(" back"),
- ]))
- .alignment(Alignment::Center);
- frame.render_widget(footer, chunks[4]);
- }
-
- fn render_confirm_step(&self, frame: &mut Frame) {
- let area = centered_rect(70, 70, frame.area());
- frame.render_widget(Clear, area);
-
- let block = Block::default()
- .title(" Confirm Setup ")
- .borders(Borders::ALL)
- .border_style(Style::default().fg(Color::Cyan));
-
- let inner = block.inner(area);
- frame.render_widget(block, area);
-
- let chunks = Layout::default()
- .direction(Direction::Vertical)
- .margin(2)
- .constraints([
- Constraint::Length(3), // Title
- Constraint::Length(2), // Spacer
- Constraint::Length(3), // Description
- Constraint::Length(2), // Spacer
- Constraint::Length(3), // Path info
- Constraint::Length(2), // Spacer
- Constraint::Length(3), // Selected collection
- Constraint::Min(4), // What will be created
- Constraint::Length(3), // Buttons
- ])
- .split(inner);
-
- // Title
- let title = Paragraph::new(Line::from(vec![Span::styled(
- "Ready to Initialize",
- Style::default()
- .fg(Color::LightRed)
- .add_modifier(Modifier::BOLD),
- )]))
- .alignment(Alignment::Center);
- frame.render_widget(title, chunks[0]);
-
- // Description
- let desc = Paragraph::new(vec![Line::from(
- "Would you like to initialize the ticket queue with these settings?",
- )])
- .alignment(Alignment::Center);
- frame.render_widget(desc, chunks[2]);
-
- // Path info
- let path_info = Paragraph::new(Line::from(vec![
- Span::styled("Path: ", Style::default().fg(Color::Gray)),
- Span::styled(&self.tickets_path, Style::default().fg(Color::White)),
- ]))
- .alignment(Alignment::Center);
- frame.render_widget(path_info, chunks[4]);
-
- // Selected collection
- let effective_collection = self.collection();
- let collection_text = vec![
- Line::from(Span::styled(
- format!(
- "Selected issue types ({}):",
- self.selected_preset.display_name()
- ),
- Style::default().fg(Color::Yellow),
- )),
- Line::from(vec![
- Span::raw(" "),
- Span::styled(
- effective_collection.join(", "),
- Style::default().fg(Color::Cyan),
- ),
- ]),
- ];
- frame.render_widget(Paragraph::new(collection_text), chunks[6]);
-
- // What will be created
- let will_create = vec![
- Line::from(Span::styled(
- "This will create:",
- Style::default().fg(Color::Gray),
- )),
- Line::from(" .tickets/queue/ .tickets/in-progress/ .tickets/completed/"),
- Line::from(Span::styled(
- " .tickets/templates/ (with selected issue type templates)",
- Style::default().fg(Color::DarkGray),
- )),
- ];
- frame.render_widget(Paragraph::new(will_create), chunks[7]);
-
- // Buttons
- let init_style = if self.confirm_selected {
- Style::default()
- .fg(Color::Black)
- .bg(Color::Green)
- .add_modifier(Modifier::BOLD)
- } else {
- Style::default().fg(Color::Green)
- };
-
- let cancel_style = if !self.confirm_selected {
- Style::default()
- .fg(Color::Black)
- .bg(Color::Red)
- .add_modifier(Modifier::BOLD)
- } else {
- Style::default().fg(Color::Red)
- };
-
- let buttons = Line::from(vec![
- Span::raw(" "),
- Span::styled(" [I]nitialize ", init_style),
- Span::raw(" "),
- Span::styled(" [C]ancel ", cancel_style),
- ]);
-
- let buttons_para = Paragraph::new(buttons).alignment(Alignment::Center);
- frame.render_widget(buttons_para, chunks[8]);
- }
-
- // ─── Kanban Setup Render Methods ────────────────────────────────────────────
-
- fn render_kanban_info_step(&self, frame: &mut Frame) {
- use crate::api::providers::kanban::{KanbanProviderType, ProviderStatus};
-
- let area = centered_rect(70, 80, frame.area());
- frame.render_widget(Clear, area);
-
- let block = Block::default()
- .title(" Kanban Integration ")
- .borders(Borders::ALL)
- .border_style(Style::default().fg(Color::Cyan));
-
- let inner = block.inner(area);
- frame.render_widget(block, area);
-
- let chunks = Layout::default()
- .direction(Direction::Vertical)
- .margin(2)
- .constraints([
- Constraint::Length(3), // Title
- Constraint::Length(2), // Description
- Constraint::Length(1), // Spacer
- Constraint::Length(3), // Supported providers header
- Constraint::Length(4), // Supported providers list
- Constraint::Length(1), // Spacer
- Constraint::Length(2), // Detected header
- Constraint::Min(6), // Detected providers list
- Constraint::Length(2), // Footer/help
- ])
- .split(inner);
-
- // Title
- let title = Paragraph::new(vec![Line::from(vec![
- Span::styled(
- "Kanban",
- Style::default()
- .fg(Color::Cyan)
- .add_modifier(Modifier::BOLD),
- ),
- Span::raw(" Integration Setup"),
- ])])
- .alignment(Alignment::Center);
- frame.render_widget(title, chunks[0]);
-
- // Description
- let desc = Paragraph::new("Operator can sync issues from external kanban providers.")
- .style(Style::default().fg(Color::Gray))
- .alignment(Alignment::Center);
- frame.render_widget(desc, chunks[1]);
-
- // Supported providers header
- let supported_header = Paragraph::new(Line::from(vec![Span::styled(
- "Supported Providers:",
- Style::default().fg(Color::Yellow),
- )]));
- frame.render_widget(supported_header, chunks[3]);
-
- // Supported providers list
- let supported = Paragraph::new(vec![
- Line::from(vec![
- Span::raw(" • "),
- Span::styled("Jira Cloud", Style::default().fg(Color::White)),
- Span::raw(" ("),
- Span::styled(
- "OPERATOR_JIRA_API_KEY",
- Style::default().fg(Color::DarkGray),
- ),
- Span::raw(")"),
- ]),
- Line::from(vec![
- Span::raw(" • "),
- Span::styled("Linear", Style::default().fg(Color::White)),
- Span::raw(" ("),
- Span::styled(
- "OPERATOR_LINEAR_API_KEY",
- Style::default().fg(Color::DarkGray),
- ),
- Span::raw(")"),
- ]),
- ]);
- frame.render_widget(supported, chunks[4]);
-
- // Detected header
- let detected_header = Paragraph::new(Line::from(vec![Span::styled(
- "Detected Providers:",
- Style::default().fg(Color::Yellow),
- )]));
- frame.render_widget(detected_header, chunks[6]);
-
- // Detected providers list
- let mut detected_lines = Vec::new();
- if self.detected_kanban_providers.is_empty() {
- detected_lines.push(Line::from(vec![Span::styled(
- " No providers detected from environment variables",
- Style::default().fg(Color::DarkGray),
- )]));
- } else {
- for (i, provider) in self.detected_kanban_providers.iter().enumerate() {
- let is_valid = self.valid_kanban_providers.contains(&i);
- let (icon, icon_color) = match &provider.status {
- ProviderStatus::Untested => ("?", Color::Yellow),
- ProviderStatus::Testing => ("~", Color::Yellow),
- ProviderStatus::Valid => ("✓", Color::Green),
- ProviderStatus::Failed { .. } => ("✗", Color::Red),
- };
-
- let provider_name = match provider.provider_type {
- KanbanProviderType::Jira => "Jira",
- KanbanProviderType::Linear => "Linear",
- };
-
- let status_text = match &provider.status {
- ProviderStatus::Untested => "not tested".to_string(),
- ProviderStatus::Testing => "testing...".to_string(),
- ProviderStatus::Valid => "valid".to_string(),
- ProviderStatus::Failed { error } => {
- format!("failed: {}", error.chars().take(30).collect::())
- }
- };
-
- detected_lines.push(Line::from(vec![
- Span::raw(" ["),
- Span::styled(icon, Style::default().fg(icon_color)),
- Span::raw("] "),
- Span::styled(
- provider_name,
- Style::default().fg(if is_valid {
- Color::White
- } else {
- Color::DarkGray
- }),
- ),
- Span::raw(" - "),
- Span::styled(&provider.domain, Style::default().fg(Color::Cyan)),
- Span::raw(" ("),
- Span::styled(status_text, Style::default().fg(icon_color)),
- Span::raw(")"),
- ]));
- }
- }
- let detected_list = Paragraph::new(detected_lines);
- frame.render_widget(detected_list, chunks[7]);
-
- // Footer
- let footer = if self.valid_kanban_providers.is_empty() {
- Line::from(vec![
- Span::styled("[Enter]", Style::default().fg(Color::Yellow)),
- Span::raw(" Continue "),
- Span::styled("[Esc]", Style::default().fg(Color::Yellow)),
- Span::raw(" Back"),
- ])
- } else {
- Line::from(vec![
- Span::styled("[Enter]", Style::default().fg(Color::Yellow)),
- Span::raw(" Configure providers "),
- Span::styled("[S]", Style::default().fg(Color::Yellow)),
- Span::raw(" Skip "),
- Span::styled("[Esc]", Style::default().fg(Color::Yellow)),
- Span::raw(" Back"),
- ])
- };
- let footer_para = Paragraph::new(footer).alignment(Alignment::Center);
- frame.render_widget(footer_para, chunks[8]);
- }
-
- fn render_kanban_provider_setup_step(&mut self, frame: &mut Frame, provider_index: usize) {
- use crate::api::providers::kanban::KanbanProviderType;
-
- let area = centered_rect(70, 80, frame.area());
- frame.render_widget(Clear, area);
-
- // Get the provider being configured
- let provider_idx = self
- .valid_kanban_providers
- .get(provider_index)
- .copied()
- .unwrap_or(0);
- let provider = self.detected_kanban_providers.get(provider_idx);
-
- let title = if let Some(p) = provider {
- let provider_name = match p.provider_type {
- KanbanProviderType::Jira => "Jira",
- KanbanProviderType::Linear => "Linear",
- };
- format!(" Setup: {} - {} ", provider_name, p.domain)
- } else {
- " Kanban Provider Setup ".to_string()
- };
-
- let block = Block::default()
- .title(title)
- .borders(Borders::ALL)
- .border_style(Style::default().fg(Color::Cyan));
-
- let inner = block.inner(area);
- frame.render_widget(block, area);
-
- let chunks = Layout::default()
- .direction(Direction::Vertical)
- .margin(2)
- .constraints([
- Constraint::Length(2), // Instructions
- Constraint::Length(1), // Spacer
- Constraint::Min(10), // Project list
- Constraint::Length(1), // Spacer
- Constraint::Length(3), // Preview info
- Constraint::Length(2), // Footer
- ])
- .split(inner);
-
- // Instructions
- let instructions =
- Paragraph::new("Select a project to sync:").style(Style::default().fg(Color::Gray));
- frame.render_widget(instructions, chunks[0]);
-
- // Project list
- if self.kanban_projects.is_empty() {
- let loading = Paragraph::new(vec![
- Line::from(""),
- Line::from(vec![Span::styled(
- "Loading projects...",
- Style::default().fg(Color::Yellow),
- )]),
- Line::from(""),
- Line::from(vec![Span::styled(
- "(Projects will be fetched when you enter this step)",
- Style::default().fg(Color::DarkGray),
- )]),
- ])
- .alignment(Alignment::Center);
- frame.render_widget(loading, chunks[2]);
- } else {
- super::paginated_list::render_paginated_list(
- frame,
- chunks[2],
- &mut self.kanban_projects,
- "Projects",
- |project, _selected| {
- ratatui::widgets::ListItem::new(Line::from(vec![
- Span::styled(
- format!("{:8}", project.key),
- Style::default()
- .fg(Color::Cyan)
- .add_modifier(Modifier::BOLD),
- ),
- Span::raw(" - "),
- Span::styled(project.name.clone(), Style::default().fg(Color::White)),
- ]))
- },
- );
- }
-
- // Preview info
- let preview = if !self.kanban_issue_types.is_empty() {
- Line::from(vec![
- Span::styled("Issue Types: ", Style::default().fg(Color::Yellow)),
- Span::styled(
- self.kanban_issue_types.join(", "),
- Style::default().fg(Color::White),
- ),
- Span::raw(" | "),
- Span::styled("Members: ", Style::default().fg(Color::Yellow)),
- Span::styled(
- self.kanban_member_count.to_string(),
- Style::default().fg(Color::White),
- ),
- ])
- } else {
- Line::from(vec![Span::styled(
- "Select a project to see details",
- Style::default().fg(Color::DarkGray),
- )])
- };
- let preview_para = Paragraph::new(preview);
- frame.render_widget(preview_para, chunks[4]);
-
- // Footer
- let footer = Line::from(vec![
- Span::styled("[Enter]", Style::default().fg(Color::Yellow)),
- Span::raw(" Select "),
- Span::styled("[n/p]", Style::default().fg(Color::Yellow)),
- Span::raw(" Page "),
- Span::styled("[Esc]", Style::default().fg(Color::Yellow)),
- Span::raw(" Skip provider"),
- ]);
- let footer_para = Paragraph::new(footer).alignment(Alignment::Center);
- frame.render_widget(footer_para, chunks[5]);
- }
-
- // ─── Kanban Setup Helper Methods ────────────────────────────────────────────
- // These methods are called from app.rs during async credential testing
- // and project fetching. They are infrastructure for the full kanban setup flow.
-
- /// Skip kanban setup entirely
- #[allow(dead_code)]
- pub fn skip_kanban(&mut self) {
- self.kanban_skipped = true;
- }
-
- /// Mark a provider as valid after testing
- #[allow(dead_code)]
- pub fn mark_provider_valid(&mut self, index: usize) {
- use crate::api::providers::kanban::ProviderStatus;
-
- if let Some(provider) = self.detected_kanban_providers.get_mut(index) {
- provider.status = ProviderStatus::Valid;
- if !self.valid_kanban_providers.contains(&index) {
- self.valid_kanban_providers.push(index);
- }
- }
- }
-
- /// Mark a provider as failed after testing
- #[allow(dead_code)]
- pub fn mark_provider_failed(&mut self, index: usize, error: String) {
- use crate::api::providers::kanban::ProviderStatus;
-
- if let Some(provider) = self.detected_kanban_providers.get_mut(index) {
- provider.status = ProviderStatus::Failed { error };
- }
- }
-
- /// Set the projects for the current kanban provider
- #[allow(dead_code)]
- pub fn set_kanban_projects(
- &mut self,
- projects: Vec,
- ) {
- self.kanban_projects.set_items(projects);
- }
-
- /// Get the selected kanban project
- #[allow(dead_code)]
- pub fn selected_kanban_project(&self) -> Option<&crate::api::providers::kanban::ProjectInfo> {
- self.kanban_projects.selected_item()
- }
-
- /// Set the preview info for the selected project
- #[allow(dead_code)]
- pub fn set_kanban_preview(&mut self, issue_types: Vec, member_count: usize) {
- self.kanban_issue_types = issue_types;
- self.kanban_member_count = member_count;
- }
-}
-
-/// Helper to create a centered rect
-fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
- let popup_layout = Layout::default()
- .direction(Direction::Vertical)
- .constraints([
- Constraint::Percentage((100 - percent_y) / 2),
- Constraint::Percentage(percent_y),
- Constraint::Percentage((100 - percent_y) / 2),
- ])
- .split(r);
-
- Layout::default()
- .direction(Direction::Horizontal)
- .constraints([
- Constraint::Percentage((100 - percent_x) / 2),
- Constraint::Percentage(percent_x),
- Constraint::Percentage((100 - percent_x) / 2),
- ])
- .split(popup_layout[1])[1]
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn test_detected_tool_info_creation() {
- let info = DetectedToolInfo {
- name: "claude".to_string(),
- version: "2.0.76".to_string(),
- model_count: 3,
- };
- assert_eq!(info.name, "claude");
- assert_eq!(info.version, "2.0.76");
- assert_eq!(info.model_count, 3);
- }
-
- #[test]
- fn test_setup_screen_new_with_detected_tools() {
- let tools = vec![DetectedToolInfo {
- name: "claude".to_string(),
- version: "2.0.76".to_string(),
- model_count: 3,
- }];
- let mut projects = HashMap::new();
- projects.insert("claude".to_string(), vec!["project-a".to_string()]);
-
- let screen = SetupScreen::new(".tickets".to_string(), tools, projects);
-
- assert!(screen.visible);
- assert_eq!(screen.step, SetupStep::Welcome);
- }
-
- #[test]
- fn test_setup_screen_with_no_detected_tools() {
- let screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new());
- assert!(screen.visible);
- assert_eq!(screen.step, SetupStep::Welcome);
- }
-
- #[test]
- fn test_setup_screen_with_multiple_tools() {
- let tools = vec![
- DetectedToolInfo {
- name: "claude".to_string(),
- version: "2.0.0".to_string(),
- model_count: 3,
- },
- DetectedToolInfo {
- name: "gemini".to_string(),
- version: "1.0.0".to_string(),
- model_count: 2,
- },
- ];
- let mut projects = HashMap::new();
- projects.insert(
- "claude".to_string(),
- vec!["api".to_string(), "web".to_string()],
- );
- projects.insert("gemini".to_string(), vec!["api".to_string()]);
-
- let screen = SetupScreen::new(".tickets".to_string(), tools, projects);
- assert!(screen.visible);
- assert_eq!(screen.step, SetupStep::Welcome);
- }
-
- // ─── Session Wrapper Selection Tests ────────────────────────────────────────
-
- #[test]
- fn test_setup_default_wrapper_is_tmux() {
- let screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new());
- assert_eq!(screen.selected_wrapper, SessionWrapperType::Tmux);
- }
-
- #[test]
- fn test_setup_tmux_status_default_is_not_checked() {
- let screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new());
- assert_eq!(screen.tmux_status, TmuxDetectionStatus::NotChecked);
- }
-
- #[test]
- fn test_setup_vscode_status_default_is_not_checked() {
- let screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new());
- assert_eq!(screen.vscode_status, VSCodeDetectionStatus::NotChecked);
- }
-
- #[test]
- fn test_setup_wrapper_navigation_flow() {
- let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new());
-
- // Navigate to SessionWrapperChoice
- screen.step = SetupStep::TaskFieldConfig;
- screen.confirm(); // Should go to SessionWrapperChoice
- assert_eq!(screen.step, SetupStep::SessionWrapperChoice);
- }
-
- #[test]
- fn test_setup_navigation_tmux_path() {
- let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new());
- screen.step = SetupStep::SessionWrapperChoice;
- screen.selected_wrapper = SessionWrapperType::Tmux;
- screen.wrapper_state.select(Some(0)); // Select tmux
-
- screen.confirm();
- assert_eq!(screen.step, SetupStep::TmuxOnboarding);
- }
-
- #[test]
- fn test_setup_navigation_vscode_path() {
- let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new());
- screen.step = SetupStep::SessionWrapperChoice;
- screen.wrapper_state.select(Some(1)); // Select vscode (index 1)
-
- screen.confirm();
- assert_eq!(screen.step, SetupStep::VSCodeSetup);
- assert_eq!(screen.selected_wrapper, SessionWrapperType::Vscode);
- }
-
- #[test]
- fn test_setup_tmux_onboarding_go_back() {
- let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new());
- screen.step = SetupStep::TmuxOnboarding;
-
- screen.go_back();
- assert_eq!(screen.step, SetupStep::SessionWrapperChoice);
- }
-
- #[test]
- fn test_setup_vscode_setup_go_back() {
- let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new());
- screen.step = SetupStep::VSCodeSetup;
-
- screen.go_back();
- assert_eq!(screen.step, SetupStep::SessionWrapperChoice);
- }
-
- #[test]
- fn test_setup_wrapper_selection_toggle() {
- let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new());
- screen.step = SetupStep::SessionWrapperChoice;
-
- // Start at tmux (default)
- assert_eq!(screen.selected_wrapper, SessionWrapperType::Tmux);
-
- // Navigate down to vscode
- screen.select_next();
- assert_eq!(screen.wrapper_state.selected(), Some(1));
-
- // Toggle selection
- screen.toggle_selection();
- assert_eq!(screen.selected_wrapper, SessionWrapperType::Vscode);
- }
-
- #[test]
- fn test_tmux_onboarding_blocks_if_not_available() {
- let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new());
- screen.step = SetupStep::TmuxOnboarding;
- screen.tmux_status = TmuxDetectionStatus::NotInstalled;
-
- // Should stay on TmuxOnboarding because tmux isn't available
- screen.confirm();
- assert_eq!(screen.step, SetupStep::TmuxOnboarding);
- }
-
- #[test]
- fn test_tmux_onboarding_proceeds_if_available() {
- let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new());
- screen.step = SetupStep::TmuxOnboarding;
- screen.tmux_status = TmuxDetectionStatus::Available {
- version: "3.3a".to_string(),
- };
-
- // Should proceed to KanbanInfo because tmux is available
- screen.confirm();
- assert_eq!(screen.step, SetupStep::KanbanInfo);
- }
-
- #[test]
- fn test_kanban_info_go_back_respects_wrapper_choice() {
- let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new());
-
- // Test tmux path
- screen.step = SetupStep::KanbanInfo;
- screen.selected_wrapper = SessionWrapperType::Tmux;
- screen.go_back();
- assert_eq!(screen.step, SetupStep::TmuxOnboarding);
-
- // Test vscode path
- screen.step = SetupStep::KanbanInfo;
- screen.selected_wrapper = SessionWrapperType::Vscode;
- screen.go_back();
- assert_eq!(screen.step, SetupStep::VSCodeSetup);
- }
-
- #[test]
- fn test_session_wrapper_option_labels() {
- assert_eq!(SessionWrapperOption::Tmux.label(), "Tmux (default)");
- assert_eq!(
- SessionWrapperOption::VSCode.label(),
- "VS Code Integrated Terminal"
- );
- }
-
- #[test]
- fn test_session_wrapper_option_to_wrapper_type() {
- assert_eq!(
- SessionWrapperOption::Tmux.to_wrapper_type(),
- SessionWrapperType::Tmux
- );
- assert_eq!(
- SessionWrapperOption::VSCode.to_wrapper_type(),
- SessionWrapperType::Vscode
- );
- }
-}
diff --git a/src/ui/setup/mod.rs b/src/ui/setup/mod.rs
new file mode 100644
index 0000000..0d2f0c1
--- /dev/null
+++ b/src/ui/setup/mod.rs
@@ -0,0 +1,689 @@
+//! Startup setup screen when .tickets/ directory is not found
+
+use std::collections::HashMap;
+
+use crate::agents::{SystemTmuxClient, TmuxClient, TmuxError};
+use crate::config::{CollectionPreset, SessionWrapperType};
+use ratatui::{widgets::ListState, Frame};
+
+pub mod steps;
+pub mod types;
+
+pub use types::*;
+
+#[cfg(test)]
+mod tests;
+
+/// Setup screen shown when .tickets/ directory doesn't exist
+pub struct SetupScreen {
+ /// Whether the screen is visible
+ pub visible: bool,
+ /// Current step in the setup process
+ pub step: SetupStep,
+ /// Current selection for confirmation: true = Initialize, false = Cancel
+ pub confirm_selected: bool,
+ /// Path where tickets directory will be created
+ pub(crate) tickets_path: String,
+ /// Detected LLM tools (from LlmToolsConfig)
+ pub(crate) detected_tools: Vec,
+ /// Projects grouped by tool
+ pub(crate) projects_by_tool: HashMap>,
+ /// Selected collection preset
+ pub selected_preset: CollectionPreset,
+ /// Custom issuetype collection (only used when preset is Custom)
+ pub custom_collection: Vec,
+ /// List state for collection source selection
+ pub(crate) source_state: ListState,
+ /// List state for custom collection selection
+ pub(crate) collection_state: ListState,
+ /// Whether we came from custom selection (for back navigation)
+ pub(crate) from_custom: bool,
+ /// Selected optional fields to include in TASK (and other types)
+ pub task_optional_fields: Vec,
+ /// List state for field configuration selection
+ pub(crate) field_state: ListState,
+ /// Startup ticket options (ASSESS, AGENT-SETUP, PROJECT-INIT)
+ pub startup_ticket_options: Vec,
+ /// List state for startup ticket selection
+ pub(crate) startup_state: ListState,
+ /// Acceptance criteria text (editable during setup)
+ pub acceptance_criteria_text: String,
+ // ─── Kanban Setup State ─────────────────────────────────────────────────────
+ /// Detected kanban providers from environment variables
+ pub detected_kanban_providers: Vec,
+ /// Indices of providers with valid credentials
+ pub valid_kanban_providers: Vec,
+ /// Projects fetched from current provider being configured
+ pub kanban_projects:
+ super::paginated_list::PaginatedList,
+ /// Issue types for the currently selected project
+ pub kanban_issue_types: Vec,
+ /// Member count for the currently selected project
+ pub kanban_member_count: usize,
+ /// Whether kanban detection/testing has run
+ pub kanban_detection_complete: bool,
+ /// Whether the user chose to skip kanban setup
+ pub kanban_skipped: bool,
+ // ─── Session Wrapper Setup State ────────────────────────────────────────────
+ /// Selected session wrapper type
+ pub selected_wrapper: SessionWrapperType,
+ /// List state for wrapper selection
+ pub(crate) wrapper_state: ListState,
+ /// Tmux availability status (checked during TmuxOnboarding step)
+ pub tmux_status: TmuxDetectionStatus,
+ /// VS Code extension status (checked during VSCodeSetup step)
+ pub vscode_status: VSCodeDetectionStatus,
+ // ─── Git Worktree Setup State ──────────────────────────────────────────────
+ /// Whether to use git worktrees for ticket isolation (default: false)
+ pub use_worktrees: bool,
+ /// List state for worktree option selection
+ pub(crate) worktree_state: ListState,
+}
+
+impl SetupScreen {
+ /// Create a new setup screen
+ pub fn new(
+ tickets_path: String,
+ detected_tools: Vec,
+ projects_by_tool: HashMap>,
+ ) -> Self {
+ let mut source_state = ListState::default();
+ source_state.select(Some(0));
+
+ let mut collection_state = ListState::default();
+ collection_state.select(Some(0));
+
+ let mut field_state = ListState::default();
+ field_state.select(Some(0));
+
+ let mut startup_state = ListState::default();
+ startup_state.select(Some(0));
+
+ let mut wrapper_state = ListState::default();
+ wrapper_state.select(Some(0));
+
+ let mut worktree_state = ListState::default();
+ worktree_state.select(Some(0));
+
+ Self {
+ visible: true,
+ step: SetupStep::Welcome,
+ confirm_selected: true, // Default to Initialize
+ tickets_path,
+ detected_tools,
+ projects_by_tool,
+ selected_preset: CollectionPreset::DevopsKanban,
+ custom_collection: ALL_ISSUE_TYPES.iter().map(|s| s.to_string()).collect(),
+ source_state,
+ collection_state,
+ from_custom: false,
+ // Default: all optional fields enabled
+ task_optional_fields: TASK_OPTIONAL_FIELDS
+ .iter()
+ .map(|(name, _)| name.to_string())
+ .collect(),
+ field_state,
+ startup_ticket_options: StartupTicketOption::all(),
+ startup_state,
+ acceptance_criteria_text: include_str!("../../templates/ACCEPTANCE_CRITERIA.md")
+ .to_string(),
+ // Kanban setup state
+ detected_kanban_providers: Vec::new(),
+ valid_kanban_providers: Vec::new(),
+ kanban_projects: super::paginated_list::PaginatedList::new(8),
+ kanban_issue_types: Vec::new(),
+ kanban_member_count: 0,
+ kanban_detection_complete: false,
+ kanban_skipped: false,
+ // Session wrapper state
+ selected_wrapper: SessionWrapperType::Tmux,
+ wrapper_state,
+ tmux_status: TmuxDetectionStatus::NotChecked,
+ vscode_status: VSCodeDetectionStatus::NotChecked,
+ // Git worktree state
+ use_worktrees: false,
+ worktree_state,
+ }
+ }
+
+ /// Get the selected collection preset
+ pub fn preset(&self) -> CollectionPreset {
+ self.selected_preset
+ }
+
+ /// Get the effective issuetype collection based on preset
+ pub fn collection(&self) -> Vec {
+ match self.selected_preset {
+ CollectionPreset::Custom => self.custom_collection.clone(),
+ _ => self.selected_preset.issue_types(),
+ }
+ }
+
+ /// Get the configured optional fields for TASK (and propagation to other types)
+ pub fn configured_task_fields(&self) -> Vec {
+ self.task_optional_fields.clone()
+ }
+
+ /// Get the selected startup ticket types to create
+ pub fn selected_startup_tickets(&self) -> Vec {
+ self.startup_ticket_options
+ .iter()
+ .filter(|opt| opt.enabled)
+ .map(|opt| opt.key.to_string())
+ .collect()
+ }
+
+ /// Get the currently selected source option
+ fn selected_source(&self) -> Option {
+ self.source_state
+ .selected()
+ .map(|i| CollectionSourceOption::all()[i])
+ }
+
+ /// Toggle selection (Space key)
+ pub fn toggle_selection(&mut self) {
+ match self.step {
+ SetupStep::CustomCollection => {
+ // Toggle the currently highlighted collection item
+ if let Some(i) = self.collection_state.selected() {
+ let types = ALL_ISSUE_TYPES;
+ if i < types.len() {
+ let type_str = types[i].to_string();
+ if self.custom_collection.contains(&type_str) {
+ self.custom_collection.retain(|t| t != &type_str);
+ } else {
+ self.custom_collection.push(type_str);
+ }
+ }
+ }
+ }
+ SetupStep::TaskFieldConfig => {
+ // Toggle the currently highlighted field
+ if let Some(i) = self.field_state.selected() {
+ if i < TASK_OPTIONAL_FIELDS.len() {
+ let field_name = TASK_OPTIONAL_FIELDS[i].0.to_string();
+ if self.task_optional_fields.contains(&field_name) {
+ self.task_optional_fields.retain(|f| f != &field_name);
+ } else {
+ self.task_optional_fields.push(field_name);
+ }
+ }
+ }
+ }
+ SetupStep::SessionWrapperChoice => {
+ // Select the currently highlighted wrapper option
+ if let Some(i) = self.wrapper_state.selected() {
+ let options = SessionWrapperOption::all();
+ if i < options.len() {
+ self.selected_wrapper = options[i].to_wrapper_type();
+ }
+ }
+ }
+ SetupStep::WorktreePreference => {
+ // Select the currently highlighted worktree option
+ if let Some(i) = self.worktree_state.selected() {
+ let options = WorktreeOption::all();
+ if i < options.len() {
+ self.use_worktrees = options[i].to_use_worktrees();
+ }
+ }
+ }
+ SetupStep::StartupTickets => {
+ // Toggle the currently highlighted startup ticket option
+ if let Some(i) = self.startup_state.selected() {
+ if i < self.startup_ticket_options.len() {
+ self.startup_ticket_options[i].enabled =
+ !self.startup_ticket_options[i].enabled;
+ }
+ }
+ }
+ SetupStep::Confirm => {
+ self.confirm_selected = !self.confirm_selected;
+ }
+ _ => {}
+ }
+ }
+
+ /// Move to next item in list
+ pub fn select_next(&mut self) {
+ match self.step {
+ SetupStep::CollectionSource => {
+ let len = CollectionSourceOption::all().len();
+ let i = self.source_state.selected().map_or(0, |i| (i + 1) % len);
+ self.source_state.select(Some(i));
+ }
+ SetupStep::CustomCollection => {
+ let len = ALL_ISSUE_TYPES.len();
+ let i = self
+ .collection_state
+ .selected()
+ .map_or(0, |i| (i + 1) % len);
+ self.collection_state.select(Some(i));
+ }
+ SetupStep::TaskFieldConfig => {
+ let len = TASK_OPTIONAL_FIELDS.len();
+ let i = self.field_state.selected().map_or(0, |i| (i + 1) % len);
+ self.field_state.select(Some(i));
+ }
+ SetupStep::SessionWrapperChoice => {
+ let len = SessionWrapperOption::all().len();
+ let i = self.wrapper_state.selected().map_or(0, |i| (i + 1) % len);
+ self.wrapper_state.select(Some(i));
+ }
+ SetupStep::WorktreePreference => {
+ let len = WorktreeOption::all().len();
+ let i = self.worktree_state.selected().map_or(0, |i| (i + 1) % len);
+ self.worktree_state.select(Some(i));
+ }
+ SetupStep::StartupTickets => {
+ let len = self.startup_ticket_options.len();
+ let i = self.startup_state.selected().map_or(0, |i| (i + 1) % len);
+ self.startup_state.select(Some(i));
+ }
+ _ => {}
+ }
+ }
+
+ /// Move to previous item in list
+ pub fn select_prev(&mut self) {
+ match self.step {
+ SetupStep::CollectionSource => {
+ let len = CollectionSourceOption::all().len();
+ let i =
+ self.source_state
+ .selected()
+ .map_or(0, |i| if i == 0 { len - 1 } else { i - 1 });
+ self.source_state.select(Some(i));
+ }
+ SetupStep::CustomCollection => {
+ let len = ALL_ISSUE_TYPES.len();
+ let i = self.collection_state.selected().map_or(0, |i| {
+ if i == 0 {
+ len - 1
+ } else {
+ i - 1
+ }
+ });
+ self.collection_state.select(Some(i));
+ }
+ SetupStep::TaskFieldConfig => {
+ let len = TASK_OPTIONAL_FIELDS.len();
+ let i = self
+ .field_state
+ .selected()
+ .map_or(0, |i| if i == 0 { len - 1 } else { i - 1 });
+ self.field_state.select(Some(i));
+ }
+ SetupStep::SessionWrapperChoice => {
+ let len = SessionWrapperOption::all().len();
+ let i =
+ self.wrapper_state
+ .selected()
+ .map_or(0, |i| if i == 0 { len - 1 } else { i - 1 });
+ self.wrapper_state.select(Some(i));
+ }
+ SetupStep::WorktreePreference => {
+ let len = WorktreeOption::all().len();
+ let i =
+ self.worktree_state
+ .selected()
+ .map_or(0, |i| if i == 0 { len - 1 } else { i - 1 });
+ self.worktree_state.select(Some(i));
+ }
+ SetupStep::StartupTickets => {
+ let len = self.startup_ticket_options.len();
+ let i =
+ self.startup_state
+ .selected()
+ .map_or(0, |i| if i == 0 { len - 1 } else { i - 1 });
+ self.startup_state.select(Some(i));
+ }
+ _ => {}
+ }
+ }
+
+ /// Proceed to next step or confirm (Enter key)
+ pub fn confirm(&mut self) -> SetupResult {
+ match self.step {
+ SetupStep::Welcome => {
+ self.step = SetupStep::CollectionSource;
+ SetupResult::Continue
+ }
+ SetupStep::CollectionSource => {
+ if let Some(source) = self.selected_source() {
+ match source {
+ CollectionSourceOption::Simple => {
+ self.selected_preset = CollectionPreset::Simple;
+ self.from_custom = false;
+ self.step = SetupStep::TaskFieldConfig;
+ SetupResult::Continue
+ }
+ CollectionSourceOption::DevKanban => {
+ self.selected_preset = CollectionPreset::DevKanban;
+ self.from_custom = false;
+ self.step = SetupStep::TaskFieldConfig;
+ SetupResult::Continue
+ }
+ CollectionSourceOption::DevopsKanban => {
+ self.selected_preset = CollectionPreset::DevopsKanban;
+ self.from_custom = false;
+ self.step = SetupStep::TaskFieldConfig;
+ SetupResult::Continue
+ }
+ CollectionSourceOption::ImportJira => SetupResult::ExitUnimplemented(
+ "Jira import is not yet implemented".to_string(),
+ ),
+ CollectionSourceOption::ImportNotion => SetupResult::ExitUnimplemented(
+ "Notion import is not yet implemented".to_string(),
+ ),
+ CollectionSourceOption::CustomSelection => {
+ self.selected_preset = CollectionPreset::Custom;
+ self.from_custom = true;
+ self.step = SetupStep::CustomCollection;
+ SetupResult::Continue
+ }
+ }
+ } else {
+ SetupResult::Continue
+ }
+ }
+ SetupStep::CustomCollection => {
+ if !self.custom_collection.is_empty() {
+ self.step = SetupStep::TaskFieldConfig;
+ }
+ SetupResult::Continue
+ }
+ SetupStep::TaskFieldConfig => {
+ self.step = SetupStep::SessionWrapperChoice;
+ SetupResult::Continue
+ }
+ SetupStep::SessionWrapperChoice => {
+ // Select the wrapper from the highlighted option
+ if let Some(i) = self.wrapper_state.selected() {
+ let options = SessionWrapperOption::all();
+ if i < options.len() {
+ self.selected_wrapper = options[i].to_wrapper_type();
+ }
+ }
+ // Navigate to worktree preference step
+ self.step = SetupStep::WorktreePreference;
+ SetupResult::Continue
+ }
+ SetupStep::WorktreePreference => {
+ // Select the worktree option from the highlighted option
+ if let Some(i) = self.worktree_state.selected() {
+ let options = WorktreeOption::all();
+ if i < options.len() {
+ self.use_worktrees = options[i].to_use_worktrees();
+ }
+ }
+ // Navigate to the appropriate next step based on wrapper choice
+ match self.selected_wrapper {
+ SessionWrapperType::Tmux => {
+ // Check tmux availability when entering TmuxOnboarding
+ self.check_tmux_availability();
+ self.step = SetupStep::TmuxOnboarding;
+ }
+ SessionWrapperType::Vscode => {
+ self.step = SetupStep::VSCodeSetup;
+ }
+ }
+ SetupResult::Continue
+ }
+ SetupStep::TmuxOnboarding => {
+ // Only allow proceeding if tmux is available
+ if matches!(self.tmux_status, TmuxDetectionStatus::Available { .. }) {
+ // Detect kanban providers if not already done
+ if !self.kanban_detection_complete {
+ self.detected_kanban_providers =
+ crate::api::providers::kanban::detect_kanban_env_vars();
+ self.kanban_detection_complete = true;
+ }
+ self.step = SetupStep::KanbanInfo;
+ }
+ // If tmux not available, stay on this step (user must install or go back)
+ SetupResult::Continue
+ }
+ SetupStep::VSCodeSetup => {
+ // For now, allow proceeding (extension check will be added later)
+ // Detect kanban providers if not already done
+ if !self.kanban_detection_complete {
+ self.detected_kanban_providers =
+ crate::api::providers::kanban::detect_kanban_env_vars();
+ self.kanban_detection_complete = true;
+ }
+ self.step = SetupStep::KanbanInfo;
+ SetupResult::Continue
+ }
+ SetupStep::KanbanInfo => {
+ // If no valid providers or skipped, go to acceptance criteria
+ if self.valid_kanban_providers.is_empty() || self.kanban_skipped {
+ self.step = SetupStep::AcceptanceCriteria;
+ } else {
+ // Start with first valid provider
+ self.step = SetupStep::KanbanProviderSetup { provider_index: 0 };
+ }
+ SetupResult::Continue
+ }
+ SetupStep::KanbanProviderSetup { provider_index } => {
+ // Move to next provider or acceptance criteria
+ let next_index = provider_index + 1;
+ if next_index < self.valid_kanban_providers.len() {
+ self.step = SetupStep::KanbanProviderSetup {
+ provider_index: next_index,
+ };
+ } else {
+ self.step = SetupStep::AcceptanceCriteria;
+ }
+ SetupResult::Continue
+ }
+ SetupStep::AcceptanceCriteria => {
+ self.step = SetupStep::StartupTickets;
+ SetupResult::Continue
+ }
+ SetupStep::StartupTickets => {
+ self.step = SetupStep::Confirm;
+ SetupResult::Continue
+ }
+ SetupStep::Confirm => {
+ if self.confirm_selected {
+ SetupResult::Initialize
+ } else {
+ SetupResult::Cancel
+ }
+ }
+ }
+ }
+
+ /// Go back to previous step (Esc key)
+ pub fn go_back(&mut self) -> SetupResult {
+ match self.step {
+ SetupStep::Welcome => SetupResult::Cancel,
+ SetupStep::CollectionSource => {
+ self.step = SetupStep::Welcome;
+ SetupResult::Continue
+ }
+ SetupStep::CustomCollection => {
+ self.step = SetupStep::CollectionSource;
+ SetupResult::Continue
+ }
+ SetupStep::TaskFieldConfig => {
+ if self.from_custom {
+ self.step = SetupStep::CustomCollection;
+ } else {
+ self.step = SetupStep::CollectionSource;
+ }
+ SetupResult::Continue
+ }
+ SetupStep::SessionWrapperChoice => {
+ self.step = SetupStep::TaskFieldConfig;
+ SetupResult::Continue
+ }
+ SetupStep::WorktreePreference => {
+ self.step = SetupStep::SessionWrapperChoice;
+ SetupResult::Continue
+ }
+ SetupStep::TmuxOnboarding => {
+ self.step = SetupStep::WorktreePreference;
+ SetupResult::Continue
+ }
+ SetupStep::VSCodeSetup => {
+ self.step = SetupStep::WorktreePreference;
+ SetupResult::Continue
+ }
+ SetupStep::KanbanInfo => {
+ // Go back to the appropriate wrapper setup step
+ match self.selected_wrapper {
+ SessionWrapperType::Tmux => self.step = SetupStep::TmuxOnboarding,
+ SessionWrapperType::Vscode => self.step = SetupStep::VSCodeSetup,
+ }
+ SetupResult::Continue
+ }
+ SetupStep::KanbanProviderSetup { provider_index } => {
+ if provider_index > 0 {
+ self.step = SetupStep::KanbanProviderSetup {
+ provider_index: provider_index - 1,
+ };
+ } else {
+ self.step = SetupStep::KanbanInfo;
+ }
+ SetupResult::Continue
+ }
+ SetupStep::AcceptanceCriteria => {
+ // Go back to last kanban provider setup or kanban info
+ if !self.valid_kanban_providers.is_empty() && !self.kanban_skipped {
+ let last_index = self.valid_kanban_providers.len() - 1;
+ self.step = SetupStep::KanbanProviderSetup {
+ provider_index: last_index,
+ };
+ } else {
+ self.step = SetupStep::KanbanInfo;
+ }
+ SetupResult::Continue
+ }
+ SetupStep::StartupTickets => {
+ self.step = SetupStep::AcceptanceCriteria;
+ SetupResult::Continue
+ }
+ SetupStep::Confirm => {
+ self.step = SetupStep::StartupTickets;
+ SetupResult::Continue
+ }
+ }
+ }
+
+ /// Render the setup screen
+ pub fn render(&mut self, frame: &mut Frame) {
+ if !self.visible {
+ return;
+ }
+
+ match self.step.clone() {
+ SetupStep::Welcome => self.render_welcome_step(frame),
+ SetupStep::CollectionSource => self.render_collection_source_step(frame),
+ SetupStep::CustomCollection => self.render_custom_collection_step(frame),
+ SetupStep::TaskFieldConfig => self.render_task_field_config_step(frame),
+ SetupStep::SessionWrapperChoice => self.render_session_wrapper_choice_step(frame),
+ SetupStep::WorktreePreference => self.render_worktree_preference_step(frame),
+ SetupStep::TmuxOnboarding => self.render_tmux_onboarding_step(frame),
+ SetupStep::VSCodeSetup => self.render_vscode_setup_step(frame),
+ SetupStep::KanbanInfo => self.render_kanban_info_step(frame),
+ SetupStep::KanbanProviderSetup { provider_index } => {
+ self.render_kanban_provider_setup_step(frame, provider_index)
+ }
+ SetupStep::AcceptanceCriteria => self.render_acceptance_criteria_step(frame),
+ SetupStep::StartupTickets => self.render_startup_tickets_step(frame),
+ SetupStep::Confirm => self.render_confirm_step(frame),
+ }
+ }
+
+ /// Check tmux availability and update status
+ pub fn check_tmux_availability(&mut self) {
+ let client = SystemTmuxClient::new();
+ match client.check_available() {
+ Ok(version) => {
+ // Minimum version 2.1 for the features we use
+ const MIN_MAJOR: u32 = 2;
+ const MIN_MINOR: u32 = 1;
+
+ if version.meets_minimum(MIN_MAJOR, MIN_MINOR) {
+ self.tmux_status = TmuxDetectionStatus::Available {
+ version: version.raw,
+ };
+ } else {
+ self.tmux_status = TmuxDetectionStatus::VersionTooOld {
+ current: version.raw,
+ required: format!("{}.{}", MIN_MAJOR, MIN_MINOR),
+ };
+ }
+ }
+ Err(TmuxError::NotInstalled) => {
+ self.tmux_status = TmuxDetectionStatus::NotInstalled;
+ }
+ Err(_) => {
+ self.tmux_status = TmuxDetectionStatus::NotInstalled;
+ }
+ }
+ }
+
+ /// Re-check tmux availability (for [R] key binding)
+ #[allow(dead_code)] // Will be connected to [R] key handler in app event loop
+ pub fn recheck_tmux(&mut self) {
+ self.check_tmux_availability();
+ }
+
+ // ─── Kanban Setup Helper Methods ────────────────────────────────────────────
+ // These methods are called from app.rs during async credential testing
+ // and project fetching. They are infrastructure for the full kanban setup flow.
+
+ /// Skip kanban setup entirely
+ #[allow(dead_code)]
+ pub fn skip_kanban(&mut self) {
+ self.kanban_skipped = true;
+ }
+
+ /// Mark a provider as valid after testing
+ #[allow(dead_code)]
+ pub fn mark_provider_valid(&mut self, index: usize) {
+ use crate::api::providers::kanban::ProviderStatus;
+
+ if let Some(provider) = self.detected_kanban_providers.get_mut(index) {
+ provider.status = ProviderStatus::Valid;
+ if !self.valid_kanban_providers.contains(&index) {
+ self.valid_kanban_providers.push(index);
+ }
+ }
+ }
+
+ /// Mark a provider as failed after testing
+ #[allow(dead_code)]
+ pub fn mark_provider_failed(&mut self, index: usize, error: String) {
+ use crate::api::providers::kanban::ProviderStatus;
+
+ if let Some(provider) = self.detected_kanban_providers.get_mut(index) {
+ provider.status = ProviderStatus::Failed { error };
+ }
+ }
+
+ /// Set the projects for the current kanban provider
+ #[allow(dead_code)]
+ pub fn set_kanban_projects(
+ &mut self,
+ projects: Vec,
+ ) {
+ self.kanban_projects.set_items(projects);
+ }
+
+ /// Get the selected kanban project
+ #[allow(dead_code)]
+ pub fn selected_kanban_project(&self) -> Option<&crate::api::providers::kanban::ProjectInfo> {
+ self.kanban_projects.selected_item()
+ }
+
+ /// Set the preview info for the selected project
+ #[allow(dead_code)]
+ pub fn set_kanban_preview(&mut self, issue_types: Vec, member_count: usize) {
+ self.kanban_issue_types = issue_types;
+ self.kanban_member_count = member_count;
+ }
+}
diff --git a/src/ui/setup/steps/acceptance.rs b/src/ui/setup/steps/acceptance.rs
new file mode 100644
index 0000000..5f9c41d
--- /dev/null
+++ b/src/ui/setup/steps/acceptance.rs
@@ -0,0 +1,85 @@
+//! Acceptance criteria step rendering
+
+use crate::ui::dialogs::centered_rect;
+use crate::ui::setup::SetupScreen;
+use ratatui::{
+ layout::{Alignment, Constraint, Direction, Layout},
+ style::{Color, Modifier, Style},
+ text::{Line, Span},
+ widgets::{Block, Borders, Clear, Paragraph},
+ Frame,
+};
+
+impl SetupScreen {
+ pub(crate) fn render_acceptance_criteria_step(&self, frame: &mut Frame) {
+ let area = centered_rect(70, 70, frame.area());
+ frame.render_widget(Clear, area);
+
+ let block = Block::default()
+ .title(Line::from(vec![
+ Span::raw(" "),
+ Span::styled(
+ "Operator!",
+ Style::default()
+ .fg(Color::LightRed)
+ .add_modifier(Modifier::BOLD),
+ ),
+ Span::raw(" Setup - Acceptance Criteria "),
+ ]))
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(Color::Cyan));
+
+ let inner = block.inner(area);
+ frame.render_widget(block, area);
+
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .margin(2)
+ .constraints([
+ Constraint::Length(3), // Title
+ Constraint::Length(2), // Description
+ Constraint::Min(8), // Acceptance criteria content
+ Constraint::Length(3), // Footer
+ ])
+ .split(inner);
+
+ // Title
+ let title = Paragraph::new(Line::from(vec![Span::styled(
+ "Review Acceptance Criteria",
+ Style::default()
+ .fg(Color::Yellow)
+ .add_modifier(Modifier::BOLD),
+ )]))
+ .alignment(Alignment::Center);
+ frame.render_widget(title, chunks[0]);
+
+ // Description
+ let desc = Paragraph::new(vec![
+ Line::from("These criteria will be used to validate completed work."),
+ Line::from("Other template files (Definition of Done, Definition of Ready) will be written from defaults."),
+ ])
+ .alignment(Alignment::Center);
+ frame.render_widget(desc, chunks[1]);
+
+ // Acceptance criteria content (read-only preview)
+ let content_block = Block::default()
+ .title(" Acceptance Criteria Template ")
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(Color::DarkGray));
+
+ let content = Paragraph::new(self.acceptance_criteria_text.as_str())
+ .block(content_block)
+ .wrap(ratatui::widgets::Wrap { trim: false });
+ frame.render_widget(content, chunks[2]);
+
+ // Footer with key hints
+ let footer = Paragraph::new(Line::from(vec![
+ Span::styled("Enter", Style::default().fg(Color::Green)),
+ Span::raw(" accept | "),
+ Span::styled("Esc", Style::default().fg(Color::Yellow)),
+ Span::raw(" back"),
+ ]))
+ .alignment(Alignment::Center);
+ frame.render_widget(footer, chunks[3]);
+ }
+}
diff --git a/src/ui/setup/steps/collection.rs b/src/ui/setup/steps/collection.rs
new file mode 100644
index 0000000..9cbcfcb
--- /dev/null
+++ b/src/ui/setup/steps/collection.rs
@@ -0,0 +1,215 @@
+//! Collection source and custom collection step rendering
+
+use crate::ui::dialogs::centered_rect;
+use crate::ui::setup::types::{CollectionSourceOption, ALL_ISSUE_TYPES};
+use crate::ui::setup::SetupScreen;
+use ratatui::{
+ layout::{Alignment, Constraint, Direction, Layout},
+ style::{Color, Modifier, Style},
+ text::{Line, Span},
+ widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
+ Frame,
+};
+
+impl SetupScreen {
+ pub(crate) fn render_collection_source_step(&mut self, frame: &mut Frame) {
+ let area = centered_rect(60, 60, frame.area());
+ frame.render_widget(Clear, area);
+
+ let block = Block::default()
+ .title(" Select Template Collection ")
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(Color::Cyan));
+
+ let inner = block.inner(area);
+ frame.render_widget(block, area);
+
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .margin(2)
+ .constraints([
+ Constraint::Length(3), // Title
+ Constraint::Length(2), // Instructions
+ Constraint::Min(8), // Options list
+ Constraint::Length(2), // Footer
+ ])
+ .split(inner);
+
+ // Title
+ let title = Paragraph::new(Line::from(vec![Span::styled(
+ "Choose Template Source",
+ Style::default()
+ .fg(Color::LightRed)
+ .add_modifier(Modifier::BOLD),
+ )]))
+ .alignment(Alignment::Center);
+ frame.render_widget(title, chunks[0]);
+
+ // Instructions
+ let instructions =
+ Paragraph::new(vec![Line::from("Use arrows to navigate, Enter to select")])
+ .alignment(Alignment::Center)
+ .style(Style::default().fg(Color::Gray));
+ frame.render_widget(instructions, chunks[1]);
+
+ // Options list
+ let items: Vec = CollectionSourceOption::all()
+ .iter()
+ .map(|opt| {
+ let style = if opt.is_unimplemented() {
+ Style::default().fg(Color::DarkGray)
+ } else {
+ Style::default()
+ };
+
+ ListItem::new(vec![
+ Line::from(vec![Span::styled(
+ opt.label(),
+ style.add_modifier(Modifier::BOLD),
+ )]),
+ Line::from(vec![
+ Span::raw(" "),
+ Span::styled(opt.description(), Style::default().fg(Color::DarkGray)),
+ ]),
+ ])
+ })
+ .collect();
+
+ let list = List::new(items)
+ .highlight_style(Style::default().add_modifier(Modifier::REVERSED))
+ .highlight_symbol("> ");
+
+ frame.render_stateful_widget(list, chunks[2], &mut self.source_state);
+
+ // Footer
+ let footer = Paragraph::new(Line::from(vec![
+ Span::styled("Enter", Style::default().fg(Color::Yellow)),
+ Span::raw(" select "),
+ Span::styled("Esc", Style::default().fg(Color::Yellow)),
+ Span::raw(" back"),
+ ]))
+ .alignment(Alignment::Center);
+ frame.render_widget(footer, chunks[3]);
+ }
+
+ pub(crate) fn render_custom_collection_step(&mut self, frame: &mut Frame) {
+ let area = centered_rect(60, 60, frame.area());
+ frame.render_widget(Clear, area);
+
+ let block = Block::default()
+ .title(Line::from(vec![
+ Span::raw(" "),
+ Span::styled(
+ "Operator!",
+ Style::default()
+ .fg(Color::LightRed)
+ .add_modifier(Modifier::BOLD),
+ ),
+ Span::raw(" Setup - Issue Types "),
+ ]))
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(Color::Cyan));
+
+ let inner = block.inner(area);
+ frame.render_widget(block, area);
+
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .margin(2)
+ .constraints([
+ Constraint::Length(3), // Title
+ Constraint::Length(2), // Instructions
+ Constraint::Min(8), // Collection list
+ Constraint::Length(2), // Footer
+ ])
+ .split(inner);
+
+ // Title
+ let title = Paragraph::new(Line::from(vec![Span::styled(
+ "Select Issue Types",
+ Style::default()
+ .fg(Color::LightRed)
+ .add_modifier(Modifier::BOLD),
+ )]))
+ .alignment(Alignment::Center);
+ frame.render_widget(title, chunks[0]);
+
+ // Instructions
+ let instructions = Paragraph::new(vec![Line::from(
+ "Use arrows to navigate, Space to toggle, Enter to continue",
+ )])
+ .alignment(Alignment::Center)
+ .style(Style::default().fg(Color::Gray));
+ frame.render_widget(instructions, chunks[1]);
+
+ // Collection list
+ let items: Vec = ALL_ISSUE_TYPES
+ .iter()
+ .map(|t| {
+ let is_selected = self.custom_collection.contains(&t.to_string());
+ let checkbox = if is_selected { "[x]" } else { "[ ]" };
+ let description = match *t {
+ "TASK" => "Focused task that executes one specific thing",
+ "FEAT" => "New feature or enhancement",
+ "FIX" => "Bug fix, follow-up work, tech debt",
+ "SPIKE" => "Research or exploration (paired mode)",
+ "INV" => "Incident investigation (paired mode)",
+ _ => "",
+ };
+ ListItem::new(vec![
+ Line::from(vec![
+ Span::styled(
+ checkbox,
+ Style::default().fg(if is_selected {
+ Color::Green
+ } else {
+ Color::DarkGray
+ }),
+ ),
+ Span::raw(" "),
+ Span::styled(
+ *t,
+ Style::default()
+ .add_modifier(Modifier::BOLD)
+ .fg(if is_selected {
+ Color::White
+ } else {
+ Color::Gray
+ }),
+ ),
+ ]),
+ Line::from(vec![
+ Span::raw(" "),
+ Span::styled(description, Style::default().fg(Color::DarkGray)),
+ ]),
+ ])
+ })
+ .collect();
+
+ let list = List::new(items)
+ .highlight_style(Style::default().add_modifier(Modifier::REVERSED))
+ .highlight_symbol("> ");
+
+ frame.render_stateful_widget(list, chunks[2], &mut self.collection_state);
+
+ // Footer
+ let selected_count = self.custom_collection.len();
+ let footer = Paragraph::new(Line::from(vec![
+ Span::styled(
+ format!("{} selected", selected_count),
+ Style::default().fg(if selected_count > 0 {
+ Color::Green
+ } else {
+ Color::Red
+ }),
+ ),
+ Span::raw(" | "),
+ Span::styled("Enter", Style::default().fg(Color::Yellow)),
+ Span::raw(" continue "),
+ Span::styled("Esc", Style::default().fg(Color::Yellow)),
+ Span::raw(" back"),
+ ]))
+ .alignment(Alignment::Center);
+ frame.render_widget(footer, chunks[3]);
+ }
+}
diff --git a/src/ui/setup/steps/confirm.rs b/src/ui/setup/steps/confirm.rs
new file mode 100644
index 0000000..2b85d39
--- /dev/null
+++ b/src/ui/setup/steps/confirm.rs
@@ -0,0 +1,130 @@
+//! Confirm step rendering
+
+use crate::ui::dialogs::centered_rect;
+use crate::ui::setup::SetupScreen;
+use ratatui::{
+ layout::{Alignment, Constraint, Direction, Layout},
+ style::{Color, Modifier, Style},
+ text::{Line, Span},
+ widgets::{Block, Borders, Clear, Paragraph},
+ Frame,
+};
+
+impl SetupScreen {
+ pub(crate) fn render_confirm_step(&self, frame: &mut Frame) {
+ let area = centered_rect(70, 70, frame.area());
+ frame.render_widget(Clear, area);
+
+ let block = Block::default()
+ .title(" Confirm Setup ")
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(Color::Cyan));
+
+ let inner = block.inner(area);
+ frame.render_widget(block, area);
+
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .margin(2)
+ .constraints([
+ Constraint::Length(3), // Title
+ Constraint::Length(2), // Spacer
+ Constraint::Length(3), // Description
+ Constraint::Length(2), // Spacer
+ Constraint::Length(3), // Path info
+ Constraint::Length(2), // Spacer
+ Constraint::Length(3), // Selected collection
+ Constraint::Min(4), // What will be created
+ Constraint::Length(3), // Buttons
+ ])
+ .split(inner);
+
+ // Title
+ let title = Paragraph::new(Line::from(vec![Span::styled(
+ "Ready to Initialize",
+ Style::default()
+ .fg(Color::LightRed)
+ .add_modifier(Modifier::BOLD),
+ )]))
+ .alignment(Alignment::Center);
+ frame.render_widget(title, chunks[0]);
+
+ // Description
+ let desc = Paragraph::new(vec![Line::from(
+ "Would you like to initialize the ticket queue with these settings?",
+ )])
+ .alignment(Alignment::Center);
+ frame.render_widget(desc, chunks[2]);
+
+ // Path info
+ let path_info = Paragraph::new(Line::from(vec![
+ Span::styled("Path: ", Style::default().fg(Color::Gray)),
+ Span::styled(&self.tickets_path, Style::default().fg(Color::White)),
+ ]))
+ .alignment(Alignment::Center);
+ frame.render_widget(path_info, chunks[4]);
+
+ // Selected collection
+ let effective_collection = self.collection();
+ let collection_text = vec![
+ Line::from(Span::styled(
+ format!(
+ "Selected issue types ({}):",
+ self.selected_preset.display_name()
+ ),
+ Style::default().fg(Color::Yellow),
+ )),
+ Line::from(vec![
+ Span::raw(" "),
+ Span::styled(
+ effective_collection.join(", "),
+ Style::default().fg(Color::Cyan),
+ ),
+ ]),
+ ];
+ frame.render_widget(Paragraph::new(collection_text), chunks[6]);
+
+ // What will be created
+ let will_create = vec![
+ Line::from(Span::styled(
+ "This will create:",
+ Style::default().fg(Color::Gray),
+ )),
+ Line::from(" .tickets/queue/ .tickets/in-progress/ .tickets/completed/"),
+ Line::from(Span::styled(
+ " .tickets/templates/ (with selected issue type templates)",
+ Style::default().fg(Color::DarkGray),
+ )),
+ ];
+ frame.render_widget(Paragraph::new(will_create), chunks[7]);
+
+ // Buttons
+ let init_style = if self.confirm_selected {
+ Style::default()
+ .fg(Color::Black)
+ .bg(Color::Green)
+ .add_modifier(Modifier::BOLD)
+ } else {
+ Style::default().fg(Color::Green)
+ };
+
+ let cancel_style = if !self.confirm_selected {
+ Style::default()
+ .fg(Color::Black)
+ .bg(Color::Red)
+ .add_modifier(Modifier::BOLD)
+ } else {
+ Style::default().fg(Color::Red)
+ };
+
+ let buttons = Line::from(vec![
+ Span::raw(" "),
+ Span::styled(" [I]nitialize ", init_style),
+ Span::raw(" "),
+ Span::styled(" [C]ancel ", cancel_style),
+ ]);
+
+ let buttons_para = Paragraph::new(buttons).alignment(Alignment::Center);
+ frame.render_widget(buttons_para, chunks[8]);
+ }
+}
diff --git a/src/ui/setup/steps/kanban.rs b/src/ui/setup/steps/kanban.rs
new file mode 100644
index 0000000..83811ed
--- /dev/null
+++ b/src/ui/setup/steps/kanban.rs
@@ -0,0 +1,302 @@
+//! Kanban integration step rendering
+
+use crate::api::providers::kanban::{KanbanProviderType, ProviderStatus};
+use crate::ui::dialogs::centered_rect;
+use crate::ui::setup::SetupScreen;
+use ratatui::{
+ layout::{Alignment, Constraint, Direction, Layout},
+ style::{Color, Modifier, Style},
+ text::{Line, Span},
+ widgets::{Block, Borders, Clear, Paragraph},
+ Frame,
+};
+
+impl SetupScreen {
+ pub(crate) fn render_kanban_info_step(&self, frame: &mut Frame) {
+ let area = centered_rect(70, 80, frame.area());
+ frame.render_widget(Clear, area);
+
+ let block = Block::default()
+ .title(" Kanban Integration ")
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(Color::Cyan));
+
+ let inner = block.inner(area);
+ frame.render_widget(block, area);
+
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .margin(2)
+ .constraints([
+ Constraint::Length(3), // Title
+ Constraint::Length(2), // Description
+ Constraint::Length(1), // Spacer
+ Constraint::Length(3), // Supported providers header
+ Constraint::Length(4), // Supported providers list
+ Constraint::Length(1), // Spacer
+ Constraint::Length(2), // Detected header
+ Constraint::Min(6), // Detected providers list
+ Constraint::Length(2), // Footer/help
+ ])
+ .split(inner);
+
+ // Title
+ let title = Paragraph::new(vec![Line::from(vec![
+ Span::styled(
+ "Kanban",
+ Style::default()
+ .fg(Color::Cyan)
+ .add_modifier(Modifier::BOLD),
+ ),
+ Span::raw(" Integration Setup"),
+ ])])
+ .alignment(Alignment::Center);
+ frame.render_widget(title, chunks[0]);
+
+ // Description
+ let desc = Paragraph::new("Operator can sync issues from external kanban providers.")
+ .style(Style::default().fg(Color::Gray))
+ .alignment(Alignment::Center);
+ frame.render_widget(desc, chunks[1]);
+
+ // Supported providers header
+ let supported_header = Paragraph::new(Line::from(vec![Span::styled(
+ "Supported Providers:",
+ Style::default().fg(Color::Yellow),
+ )]));
+ frame.render_widget(supported_header, chunks[3]);
+
+ // Supported providers list
+ let supported = Paragraph::new(vec![
+ Line::from(vec![
+ Span::raw(" • "),
+ Span::styled("Jira Cloud", Style::default().fg(Color::White)),
+ Span::raw(" ("),
+ Span::styled(
+ "OPERATOR_JIRA_API_KEY",
+ Style::default().fg(Color::DarkGray),
+ ),
+ Span::raw(")"),
+ ]),
+ Line::from(vec![
+ Span::raw(" • "),
+ Span::styled("Linear", Style::default().fg(Color::White)),
+ Span::raw(" ("),
+ Span::styled(
+ "OPERATOR_LINEAR_API_KEY",
+ Style::default().fg(Color::DarkGray),
+ ),
+ Span::raw(")"),
+ ]),
+ ]);
+ frame.render_widget(supported, chunks[4]);
+
+ // Detected header
+ let detected_header = Paragraph::new(Line::from(vec![Span::styled(
+ "Detected Providers:",
+ Style::default().fg(Color::Yellow),
+ )]));
+ frame.render_widget(detected_header, chunks[6]);
+
+ // Detected providers list
+ let mut detected_lines = Vec::new();
+ if self.detected_kanban_providers.is_empty() {
+ detected_lines.push(Line::from(vec![Span::styled(
+ " No providers detected from environment variables",
+ Style::default().fg(Color::DarkGray),
+ )]));
+ } else {
+ for (i, provider) in self.detected_kanban_providers.iter().enumerate() {
+ let is_valid = self.valid_kanban_providers.contains(&i);
+ let (icon, icon_color) = match &provider.status {
+ ProviderStatus::Untested => ("?", Color::Yellow),
+ ProviderStatus::Testing => ("~", Color::Yellow),
+ ProviderStatus::Valid => ("✓", Color::Green),
+ ProviderStatus::Failed { .. } => ("✗", Color::Red),
+ };
+
+ let provider_name = match provider.provider_type {
+ KanbanProviderType::Jira => "Jira",
+ KanbanProviderType::Linear => "Linear",
+ };
+
+ let status_text = match &provider.status {
+ ProviderStatus::Untested => "not tested".to_string(),
+ ProviderStatus::Testing => "testing...".to_string(),
+ ProviderStatus::Valid => "valid".to_string(),
+ ProviderStatus::Failed { error } => {
+ format!("failed: {}", error.chars().take(30).collect::())
+ }
+ };
+
+ detected_lines.push(Line::from(vec![
+ Span::raw(" ["),
+ Span::styled(icon, Style::default().fg(icon_color)),
+ Span::raw("] "),
+ Span::styled(
+ provider_name,
+ Style::default().fg(if is_valid {
+ Color::White
+ } else {
+ Color::DarkGray
+ }),
+ ),
+ Span::raw(" - "),
+ Span::styled(&provider.domain, Style::default().fg(Color::Cyan)),
+ Span::raw(" ("),
+ Span::styled(status_text, Style::default().fg(icon_color)),
+ Span::raw(")"),
+ ]));
+ }
+ }
+ let detected_list = Paragraph::new(detected_lines);
+ frame.render_widget(detected_list, chunks[7]);
+
+ // Footer
+ let footer = if self.valid_kanban_providers.is_empty() {
+ Line::from(vec![
+ Span::styled("[Enter]", Style::default().fg(Color::Yellow)),
+ Span::raw(" Continue "),
+ Span::styled("[Esc]", Style::default().fg(Color::Yellow)),
+ Span::raw(" Back"),
+ ])
+ } else {
+ Line::from(vec![
+ Span::styled("[Enter]", Style::default().fg(Color::Yellow)),
+ Span::raw(" Configure providers "),
+ Span::styled("[S]", Style::default().fg(Color::Yellow)),
+ Span::raw(" Skip "),
+ Span::styled("[Esc]", Style::default().fg(Color::Yellow)),
+ Span::raw(" Back"),
+ ])
+ };
+ let footer_para = Paragraph::new(footer).alignment(Alignment::Center);
+ frame.render_widget(footer_para, chunks[8]);
+ }
+
+ pub(crate) fn render_kanban_provider_setup_step(
+ &mut self,
+ frame: &mut Frame,
+ provider_index: usize,
+ ) {
+ let area = centered_rect(70, 80, frame.area());
+ frame.render_widget(Clear, area);
+
+ // Get the provider being configured
+ let provider_idx = self
+ .valid_kanban_providers
+ .get(provider_index)
+ .copied()
+ .unwrap_or(0);
+ let provider = self.detected_kanban_providers.get(provider_idx);
+
+ let title = if let Some(p) = provider {
+ let provider_name = match p.provider_type {
+ KanbanProviderType::Jira => "Jira",
+ KanbanProviderType::Linear => "Linear",
+ };
+ format!(" Setup: {} - {} ", provider_name, p.domain)
+ } else {
+ " Kanban Provider Setup ".to_string()
+ };
+
+ let block = Block::default()
+ .title(title)
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(Color::Cyan));
+
+ let inner = block.inner(area);
+ frame.render_widget(block, area);
+
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .margin(2)
+ .constraints([
+ Constraint::Length(2), // Instructions
+ Constraint::Length(1), // Spacer
+ Constraint::Min(10), // Project list
+ Constraint::Length(1), // Spacer
+ Constraint::Length(3), // Preview info
+ Constraint::Length(2), // Footer
+ ])
+ .split(inner);
+
+ // Instructions
+ let instructions =
+ Paragraph::new("Select a project to sync:").style(Style::default().fg(Color::Gray));
+ frame.render_widget(instructions, chunks[0]);
+
+ // Project list
+ if self.kanban_projects.is_empty() {
+ let loading = Paragraph::new(vec![
+ Line::from(""),
+ Line::from(vec![Span::styled(
+ "Loading projects...",
+ Style::default().fg(Color::Yellow),
+ )]),
+ Line::from(""),
+ Line::from(vec![Span::styled(
+ "(Projects will be fetched when you enter this step)",
+ Style::default().fg(Color::DarkGray),
+ )]),
+ ])
+ .alignment(Alignment::Center);
+ frame.render_widget(loading, chunks[2]);
+ } else {
+ crate::ui::paginated_list::render_paginated_list(
+ frame,
+ chunks[2],
+ &mut self.kanban_projects,
+ "Projects",
+ |project, _selected| {
+ ratatui::widgets::ListItem::new(Line::from(vec![
+ Span::styled(
+ format!("{:8}", project.key),
+ Style::default()
+ .fg(Color::Cyan)
+ .add_modifier(Modifier::BOLD),
+ ),
+ Span::raw(" - "),
+ Span::styled(project.name.clone(), Style::default().fg(Color::White)),
+ ]))
+ },
+ );
+ }
+
+ // Preview info
+ let preview = if !self.kanban_issue_types.is_empty() {
+ Line::from(vec![
+ Span::styled("Issue Types: ", Style::default().fg(Color::Yellow)),
+ Span::styled(
+ self.kanban_issue_types.join(", "),
+ Style::default().fg(Color::White),
+ ),
+ Span::raw(" | "),
+ Span::styled("Members: ", Style::default().fg(Color::Yellow)),
+ Span::styled(
+ self.kanban_member_count.to_string(),
+ Style::default().fg(Color::White),
+ ),
+ ])
+ } else {
+ Line::from(vec![Span::styled(
+ "Select a project to see details",
+ Style::default().fg(Color::DarkGray),
+ )])
+ };
+ let preview_para = Paragraph::new(preview);
+ frame.render_widget(preview_para, chunks[4]);
+
+ // Footer
+ let footer = Line::from(vec![
+ Span::styled("[Enter]", Style::default().fg(Color::Yellow)),
+ Span::raw(" Select "),
+ Span::styled("[n/p]", Style::default().fg(Color::Yellow)),
+ Span::raw(" Page "),
+ Span::styled("[Esc]", Style::default().fg(Color::Yellow)),
+ Span::raw(" Skip provider"),
+ ]);
+ let footer_para = Paragraph::new(footer).alignment(Alignment::Center);
+ frame.render_widget(footer_para, chunks[5]);
+ }
+}
diff --git a/src/ui/setup/steps/mod.rs b/src/ui/setup/steps/mod.rs
new file mode 100644
index 0000000..4f14a3d
--- /dev/null
+++ b/src/ui/setup/steps/mod.rs
@@ -0,0 +1,19 @@
+//! Render methods for each setup step
+
+mod acceptance;
+mod collection;
+mod confirm;
+mod kanban;
+mod startup;
+mod task_fields;
+mod welcome;
+mod wrapper;
+
+pub use acceptance::*;
+pub use collection::*;
+pub use confirm::*;
+pub use kanban::*;
+pub use startup::*;
+pub use task_fields::*;
+pub use welcome::*;
+pub use wrapper::*;
diff --git a/src/ui/setup/steps/startup.rs b/src/ui/setup/steps/startup.rs
new file mode 100644
index 0000000..ad89286
--- /dev/null
+++ b/src/ui/setup/steps/startup.rs
@@ -0,0 +1,140 @@
+//! Startup tickets step rendering
+
+use crate::ui::dialogs::centered_rect;
+use crate::ui::setup::SetupScreen;
+use ratatui::{
+ layout::{Alignment, Constraint, Direction, Layout},
+ style::{Color, Modifier, Style},
+ text::{Line, Span},
+ widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
+ Frame,
+};
+
+impl SetupScreen {
+ pub(crate) fn render_startup_tickets_step(&mut self, frame: &mut Frame) {
+ let area = centered_rect(70, 60, frame.area());
+ frame.render_widget(Clear, area);
+
+ let block = Block::default()
+ .title(Line::from(vec![
+ Span::raw(" "),
+ Span::styled(
+ "Operator!",
+ Style::default()
+ .fg(Color::LightRed)
+ .add_modifier(Modifier::BOLD),
+ ),
+ Span::raw(" Setup - Startup Tickets "),
+ ]))
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(Color::Cyan));
+
+ let inner = block.inner(area);
+ frame.render_widget(block, area);
+
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .margin(2)
+ .constraints([
+ Constraint::Length(3), // Title
+ Constraint::Length(3), // Explanation
+ Constraint::Length(2), // Instructions
+ Constraint::Min(8), // Options list
+ Constraint::Length(2), // Footer
+ ])
+ .split(inner);
+
+ // Title
+ let title = Paragraph::new(Line::from(vec![Span::styled(
+ "Create Startup Tickets",
+ Style::default()
+ .fg(Color::LightRed)
+ .add_modifier(Modifier::BOLD),
+ )]))
+ .alignment(Alignment::Center);
+ frame.render_widget(title, chunks[0]);
+
+ // Explanation
+ let explanation = Paragraph::new(vec![
+ Line::from("Optionally create tickets to bootstrap your projects."),
+ Line::from("These help set up catalog entries and agent configurations."),
+ ])
+ .alignment(Alignment::Center)
+ .style(Style::default().fg(Color::Gray));
+ frame.render_widget(explanation, chunks[1]);
+
+ // Instructions
+ let instructions = Paragraph::new(vec![Line::from(
+ "Use arrows to navigate, Space to toggle, Enter to continue",
+ )])
+ .alignment(Alignment::Center)
+ .style(Style::default().fg(Color::DarkGray));
+ frame.render_widget(instructions, chunks[2]);
+
+ // Options list
+ let items: Vec = self
+ .startup_ticket_options
+ .iter()
+ .map(|opt| {
+ let checkbox = if opt.enabled { "[x]" } else { "[ ]" };
+ ListItem::new(vec![
+ Line::from(vec![
+ Span::styled(
+ checkbox,
+ Style::default().fg(if opt.enabled {
+ Color::Green
+ } else {
+ Color::DarkGray
+ }),
+ ),
+ Span::raw(" "),
+ Span::styled(
+ opt.name,
+ Style::default()
+ .add_modifier(Modifier::BOLD)
+ .fg(if opt.enabled {
+ Color::White
+ } else {
+ Color::Gray
+ }),
+ ),
+ ]),
+ Line::from(vec![
+ Span::raw(" "),
+ Span::styled(opt.description, Style::default().fg(Color::DarkGray)),
+ ]),
+ ])
+ })
+ .collect();
+
+ let list = List::new(items)
+ .highlight_style(Style::default().add_modifier(Modifier::REVERSED))
+ .highlight_symbol("> ");
+
+ frame.render_stateful_widget(list, chunks[3], &mut self.startup_state);
+
+ // Footer
+ let selected_count = self
+ .startup_ticket_options
+ .iter()
+ .filter(|o| o.enabled)
+ .count();
+ let footer = Paragraph::new(Line::from(vec![
+ Span::styled(
+ format!("{} ticket types selected", selected_count),
+ Style::default().fg(if selected_count > 0 {
+ Color::Green
+ } else {
+ Color::Gray
+ }),
+ ),
+ Span::raw(" | "),
+ Span::styled("Enter", Style::default().fg(Color::Yellow)),
+ Span::raw(" continue "),
+ Span::styled("Esc", Style::default().fg(Color::Yellow)),
+ Span::raw(" back"),
+ ]))
+ .alignment(Alignment::Center);
+ frame.render_widget(footer, chunks[4]);
+ }
+}
diff --git a/src/ui/setup/steps/task_fields.rs b/src/ui/setup/steps/task_fields.rs
new file mode 100644
index 0000000..6107931
--- /dev/null
+++ b/src/ui/setup/steps/task_fields.rs
@@ -0,0 +1,137 @@
+//! Task field configuration step rendering
+
+use crate::ui::dialogs::centered_rect;
+use crate::ui::setup::types::TASK_OPTIONAL_FIELDS;
+use crate::ui::setup::SetupScreen;
+use ratatui::{
+ layout::{Alignment, Constraint, Direction, Layout},
+ style::{Color, Modifier, Style},
+ text::{Line, Span},
+ widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
+ Frame,
+};
+
+impl SetupScreen {
+ pub(crate) fn render_task_field_config_step(&mut self, frame: &mut Frame) {
+ let area = centered_rect(70, 60, frame.area());
+ frame.render_widget(Clear, area);
+
+ let block = Block::default()
+ .title(Line::from(vec![
+ Span::raw(" "),
+ Span::styled(
+ "Operator!",
+ Style::default()
+ .fg(Color::LightRed)
+ .add_modifier(Modifier::BOLD),
+ ),
+ Span::raw(" Setup - Configure TASK Fields "),
+ ]))
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(Color::Cyan));
+
+ let inner = block.inner(area);
+ frame.render_widget(block, area);
+
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .margin(2)
+ .constraints([
+ Constraint::Length(3), // Title
+ Constraint::Length(3), // Explanation
+ Constraint::Length(2), // Instructions
+ Constraint::Min(6), // Field list
+ Constraint::Length(2), // Footer
+ ])
+ .split(inner);
+
+ // Title
+ let title = Paragraph::new(Line::from(vec![Span::styled(
+ "Configure TASK Fields",
+ Style::default()
+ .fg(Color::LightRed)
+ .add_modifier(Modifier::BOLD),
+ )]))
+ .alignment(Alignment::Center);
+ frame.render_widget(title, chunks[0]);
+
+ // Explanation
+ let explanation = Paragraph::new(vec![
+ Line::from("TASK is the foundational issuetype. Configure which optional"),
+ Line::from("fields to include. These choices will propagate to other types."),
+ ])
+ .alignment(Alignment::Center)
+ .style(Style::default().fg(Color::Gray));
+ frame.render_widget(explanation, chunks[1]);
+
+ // Instructions
+ let instructions = Paragraph::new(vec![Line::from(
+ "Use arrows to navigate, Space to toggle, Enter to continue",
+ )])
+ .alignment(Alignment::Center)
+ .style(Style::default().fg(Color::DarkGray));
+ frame.render_widget(instructions, chunks[2]);
+
+ // Field list
+ let items: Vec = TASK_OPTIONAL_FIELDS
+ .iter()
+ .map(|(name, description)| {
+ let is_selected = self.task_optional_fields.contains(&name.to_string());
+ let checkbox = if is_selected { "[x]" } else { "[ ]" };
+ ListItem::new(vec![
+ Line::from(vec![
+ Span::styled(
+ checkbox,
+ Style::default().fg(if is_selected {
+ Color::Green
+ } else {
+ Color::DarkGray
+ }),
+ ),
+ Span::raw(" "),
+ Span::styled(
+ *name,
+ Style::default()
+ .add_modifier(Modifier::BOLD)
+ .fg(if is_selected {
+ Color::White
+ } else {
+ Color::Gray
+ }),
+ ),
+ ]),
+ Line::from(vec![
+ Span::raw(" "),
+ Span::styled(*description, Style::default().fg(Color::DarkGray)),
+ ]),
+ ])
+ })
+ .collect();
+
+ let list = List::new(items)
+ .highlight_style(Style::default().add_modifier(Modifier::REVERSED))
+ .highlight_symbol("> ");
+
+ frame.render_stateful_widget(list, chunks[3], &mut self.field_state);
+
+ // Footer
+ let selected_count = self.task_optional_fields.len();
+ let footer = Paragraph::new(Line::from(vec![
+ Span::styled(
+ format!(
+ "{}/{} fields enabled",
+ selected_count,
+ TASK_OPTIONAL_FIELDS.len()
+ ),
+ Style::default().fg(Color::Cyan),
+ ),
+ Span::raw(" | "),
+ Span::styled("Enter", Style::default().fg(Color::Yellow)),
+ Span::raw(" continue "),
+ Span::styled("Esc", Style::default().fg(Color::Yellow)),
+ Span::raw(" back"),
+ ]))
+ .alignment(Alignment::Center);
+ frame.render_widget(footer, chunks[4]);
+ }
+}
diff --git a/src/ui/setup/steps/welcome.rs b/src/ui/setup/steps/welcome.rs
new file mode 100644
index 0000000..613d89a
--- /dev/null
+++ b/src/ui/setup/steps/welcome.rs
@@ -0,0 +1,156 @@
+//! Welcome step rendering
+
+use crate::projects::TOOL_MARKERS;
+use crate::ui::dialogs::centered_rect;
+use crate::ui::setup::SetupScreen;
+use ratatui::{
+ layout::{Alignment, Constraint, Direction, Layout},
+ style::{Color, Modifier, Style},
+ text::{Line, Span},
+ widgets::{Block, Borders, Clear, Paragraph},
+ Frame,
+};
+
+impl SetupScreen {
+ pub(crate) fn render_welcome_step(&self, frame: &mut Frame) {
+ let area = centered_rect(70, 80, frame.area());
+ frame.render_widget(Clear, area);
+
+ let block = Block::default()
+ .title(Line::from(vec![
+ Span::raw(" "),
+ Span::styled(
+ "Operator!",
+ Style::default()
+ .fg(Color::LightRed)
+ .add_modifier(Modifier::BOLD),
+ ),
+ Span::raw(" Workspace Setup "),
+ ]))
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(Color::Cyan));
+
+ let inner = block.inner(area);
+ frame.render_widget(block, area);
+
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .margin(2)
+ .constraints([
+ Constraint::Length(3), // Title
+ Constraint::Length(1), // Spacer
+ Constraint::Length(2), // Description
+ Constraint::Length(1), // Spacer
+ Constraint::Length(6), // Detected LLM Tools
+ Constraint::Length(1), // Spacer
+ Constraint::Min(6), // Discovered projects by tool
+ Constraint::Length(1), // Spacer
+ Constraint::Length(2), // Path info
+ Constraint::Length(3), // Footer
+ ])
+ .split(inner);
+
+ // Title
+ let title = Paragraph::new(Line::from(vec![Span::styled(
+ "Operator!",
+ Style::default()
+ .fg(Color::LightRed)
+ .add_modifier(Modifier::BOLD),
+ )]))
+ .alignment(Alignment::Center);
+ frame.render_widget(title, chunks[0]);
+
+ // Description
+ let desc = Paragraph::new(vec![Line::from("A TUI for orchestrating LLM Code agents.")])
+ .alignment(Alignment::Center);
+ frame.render_widget(desc, chunks[2]);
+
+ // Detected LLM Tools
+ let mut tools_text = vec![Line::from(Span::styled(
+ "Detected LLM Tools:",
+ Style::default()
+ .fg(Color::Yellow)
+ .add_modifier(Modifier::BOLD),
+ ))];
+
+ // Show each known tool with detection status
+ for (tool_name, _marker) in TOOL_MARKERS {
+ let detected = self.detected_tools.iter().find(|t| t.name == *tool_name);
+
+ let line = if let Some(tool) = detected {
+ Line::from(vec![
+ Span::styled(" + ", Style::default().fg(Color::Green)),
+ Span::styled(
+ tool_name.to_string(),
+ Style::default()
+ .fg(Color::Green)
+ .add_modifier(Modifier::BOLD),
+ ),
+ Span::styled(
+ format!(" (v{}) - {} models", tool.version, tool.model_count),
+ Style::default().fg(Color::DarkGray),
+ ),
+ ])
+ } else {
+ Line::from(vec![
+ Span::styled(" - ", Style::default().fg(Color::DarkGray)),
+ Span::styled(tool_name.to_string(), Style::default().fg(Color::DarkGray)),
+ Span::styled(" - not installed", Style::default().fg(Color::DarkGray)),
+ ])
+ };
+ tools_text.push(line);
+ }
+ frame.render_widget(Paragraph::new(tools_text), chunks[4]);
+
+ // Discovered projects by tool
+ let mut projects_text = vec![Line::from(Span::styled(
+ "Discovered Projects:",
+ Style::default()
+ .fg(Color::Yellow)
+ .add_modifier(Modifier::BOLD),
+ ))];
+
+ let mut has_any_projects = false;
+ for (tool_name, _marker) in TOOL_MARKERS {
+ if let Some(projects) = self.projects_by_tool.get(*tool_name) {
+ if !projects.is_empty() {
+ has_any_projects = true;
+ let project_list = projects.join(", ");
+ projects_text.push(Line::from(vec![
+ Span::styled(
+ format!(" {}: ", tool_name),
+ Style::default().fg(Color::Cyan),
+ ),
+ Span::styled(project_list, Style::default().fg(Color::Green)),
+ ]));
+ }
+ }
+ }
+
+ if !has_any_projects {
+ projects_text.push(Line::from(Span::styled(
+ " (no projects with marker files found)",
+ Style::default().fg(Color::DarkGray),
+ )));
+ }
+ frame.render_widget(Paragraph::new(projects_text), chunks[6]);
+
+ // Path info
+ let path_info = Paragraph::new(Line::from(vec![
+ Span::styled("Path: ", Style::default().fg(Color::Gray)),
+ Span::styled(&self.tickets_path, Style::default().fg(Color::White)),
+ ]))
+ .alignment(Alignment::Center);
+ frame.render_widget(path_info, chunks[8]);
+
+ // Footer
+ let footer = Paragraph::new(Line::from(vec![
+ Span::styled("Enter", Style::default().fg(Color::Yellow)),
+ Span::raw(" continue "),
+ Span::styled("Esc", Style::default().fg(Color::Yellow)),
+ Span::raw(" cancel"),
+ ]))
+ .alignment(Alignment::Center);
+ frame.render_widget(footer, chunks[9]);
+ }
+}
diff --git a/src/ui/setup/steps/wrapper.rs b/src/ui/setup/steps/wrapper.rs
new file mode 100644
index 0000000..30cfbbd
--- /dev/null
+++ b/src/ui/setup/steps/wrapper.rs
@@ -0,0 +1,525 @@
+//! Session wrapper choice, worktree preference, tmux onboarding, and vscode setup step rendering
+
+use crate::ui::dialogs::centered_rect;
+use crate::ui::setup::types::{
+ SessionWrapperOption, TmuxDetectionStatus, VSCodeDetectionStatus, WorktreeOption,
+};
+use crate::ui::setup::SetupScreen;
+use ratatui::{
+ layout::{Alignment, Constraint, Direction, Layout},
+ style::{Color, Modifier, Style},
+ text::{Line, Span},
+ widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
+ Frame,
+};
+
+impl SetupScreen {
+ pub(crate) fn render_session_wrapper_choice_step(&mut self, frame: &mut Frame) {
+ let area = centered_rect(70, 60, frame.area());
+ frame.render_widget(Clear, area);
+
+ let block = Block::default()
+ .title(Line::from(vec![
+ Span::raw(" "),
+ Span::styled(
+ "Operator!",
+ Style::default()
+ .fg(Color::LightRed)
+ .add_modifier(Modifier::BOLD),
+ ),
+ Span::raw(" Setup - Session Wrapper "),
+ ]))
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(Color::Cyan));
+
+ let inner = block.inner(area);
+ frame.render_widget(block, area);
+
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .margin(2)
+ .constraints([
+ Constraint::Length(3), // Title
+ Constraint::Length(3), // Description
+ Constraint::Length(2), // Instructions
+ Constraint::Min(8), // Options list
+ Constraint::Length(2), // Footer
+ ])
+ .split(inner);
+
+ // Title
+ let title = Paragraph::new(Line::from(vec![Span::styled(
+ "Session Wrapper Configuration",
+ Style::default()
+ .fg(Color::LightRed)
+ .add_modifier(Modifier::BOLD),
+ )]))
+ .alignment(Alignment::Center);
+ frame.render_widget(title, chunks[0]);
+
+ // Description
+ let description = Paragraph::new(vec![
+ Line::from("Operator runs agents in terminal sessions."),
+ Line::from("Select your preferred wrapper:"),
+ ])
+ .alignment(Alignment::Center)
+ .style(Style::default().fg(Color::Gray));
+ frame.render_widget(description, chunks[1]);
+
+ // Instructions
+ let instructions =
+ Paragraph::new(vec![Line::from("Use arrows to navigate, Enter to select")])
+ .alignment(Alignment::Center)
+ .style(Style::default().fg(Color::DarkGray));
+ frame.render_widget(instructions, chunks[2]);
+
+ // Options list
+ let items: Vec = SessionWrapperOption::all()
+ .iter()
+ .map(|opt| {
+ let is_selected = opt.to_wrapper_type() == self.selected_wrapper;
+ let radio = if is_selected { "(o)" } else { "( )" };
+ ListItem::new(vec![
+ Line::from(vec![
+ Span::styled(
+ radio,
+ Style::default().fg(if is_selected {
+ Color::Green
+ } else {
+ Color::DarkGray
+ }),
+ ),
+ Span::raw(" "),
+ Span::styled(
+ opt.label(),
+ Style::default()
+ .add_modifier(Modifier::BOLD)
+ .fg(if is_selected {
+ Color::White
+ } else {
+ Color::Gray
+ }),
+ ),
+ ]),
+ Line::from(vec![
+ Span::raw(" "),
+ Span::styled(opt.description(), Style::default().fg(Color::DarkGray)),
+ ]),
+ ])
+ })
+ .collect();
+
+ let list = List::new(items)
+ .highlight_style(Style::default().add_modifier(Modifier::REVERSED))
+ .highlight_symbol("> ");
+
+ frame.render_stateful_widget(list, chunks[3], &mut self.wrapper_state);
+
+ // Footer
+ let footer = Paragraph::new(Line::from(vec![
+ Span::styled("Enter", Style::default().fg(Color::Yellow)),
+ Span::raw(" select "),
+ Span::styled("Esc", Style::default().fg(Color::Yellow)),
+ Span::raw(" back"),
+ ]))
+ .alignment(Alignment::Center);
+ frame.render_widget(footer, chunks[4]);
+ }
+
+ pub(crate) fn render_tmux_onboarding_step(&self, frame: &mut Frame) {
+ let area = centered_rect(70, 75, frame.area());
+ frame.render_widget(Clear, area);
+
+ let block = Block::default()
+ .title(" Tmux Configuration ")
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(Color::Cyan));
+
+ let inner = block.inner(area);
+ frame.render_widget(block, area);
+
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .margin(2)
+ .constraints([
+ Constraint::Length(3), // Title
+ Constraint::Length(1), // Spacer
+ Constraint::Length(3), // Status
+ Constraint::Length(1), // Spacer
+ Constraint::Min(12), // Help text or install instructions
+ Constraint::Length(3), // Footer
+ ])
+ .split(inner);
+
+ // Title
+ let title = Paragraph::new(Line::from(vec![Span::styled(
+ "Tmux Session Configuration",
+ Style::default()
+ .fg(Color::LightRed)
+ .add_modifier(Modifier::BOLD),
+ )]))
+ .alignment(Alignment::Center);
+ frame.render_widget(title, chunks[0]);
+
+ // Status indicator
+ let status_line = match &self.tmux_status {
+ TmuxDetectionStatus::NotChecked => Line::from(vec![
+ Span::styled("Tmux status: ", Style::default().fg(Color::Gray)),
+ Span::styled("[?] ", Style::default().fg(Color::Yellow)),
+ Span::styled("Not checked", Style::default().fg(Color::Yellow)),
+ ]),
+ TmuxDetectionStatus::Available { version } => Line::from(vec![
+ Span::styled("Tmux status: ", Style::default().fg(Color::Gray)),
+ Span::styled("[+] ", Style::default().fg(Color::Green)),
+ Span::styled(
+ format!("Available (v{})", version),
+ Style::default().fg(Color::Green),
+ ),
+ ]),
+ TmuxDetectionStatus::NotInstalled => Line::from(vec![
+ Span::styled("Tmux status: ", Style::default().fg(Color::Gray)),
+ Span::styled("[x] ", Style::default().fg(Color::Red)),
+ Span::styled("Not installed", Style::default().fg(Color::Red)),
+ ]),
+ TmuxDetectionStatus::VersionTooOld { current, required } => Line::from(vec![
+ Span::styled("Tmux status: ", Style::default().fg(Color::Gray)),
+ Span::styled("[x] ", Style::default().fg(Color::Red)),
+ Span::styled(
+ format!("Version too old (v{}, need {}+)", current, required),
+ Style::default().fg(Color::Red),
+ ),
+ ]),
+ };
+ let status = Paragraph::new(vec![status_line]).alignment(Alignment::Center);
+ frame.render_widget(status, chunks[2]);
+
+ // Help text or install instructions
+ let help_text = if matches!(self.tmux_status, TmuxDetectionStatus::Available { .. }) {
+ vec![
+ Line::from(Span::styled(
+ "Essential Commands:",
+ Style::default()
+ .fg(Color::Yellow)
+ .add_modifier(Modifier::BOLD),
+ )),
+ Line::from(""),
+ Line::from(vec![
+ Span::styled(" Detach from session: ", Style::default().fg(Color::Gray)),
+ Span::styled(
+ "Ctrl+a",
+ Style::default()
+ .fg(Color::Cyan)
+ .add_modifier(Modifier::BOLD),
+ ),
+ Span::styled(
+ " (quick, no prefix needed!)",
+ Style::default().fg(Color::DarkGray),
+ ),
+ ]),
+ Line::from(vec![
+ Span::styled(" Fallback detach: ", Style::default().fg(Color::Gray)),
+ Span::styled("Ctrl+b", Style::default().fg(Color::Cyan)),
+ Span::styled(" then ", Style::default().fg(Color::Gray)),
+ Span::styled("d", Style::default().fg(Color::Cyan)),
+ ]),
+ Line::from(""),
+ Line::from(vec![
+ Span::styled(" List sessions: ", Style::default().fg(Color::Gray)),
+ Span::styled("tmux ls", Style::default().fg(Color::Green)),
+ ]),
+ Line::from(vec![
+ Span::styled(" Attach to session: ", Style::default().fg(Color::Gray)),
+ Span::styled("tmux attach -t ", Style::default().fg(Color::Green)),
+ Span::styled("", Style::default().fg(Color::DarkGray)),
+ ]),
+ Line::from(""),
+ Line::from(Span::styled(
+ "Operator session names start with 'op-'",
+ Style::default().fg(Color::DarkGray),
+ )),
+ ]
+ } else {
+ vec![
+ Line::from(Span::styled(
+ "Install tmux:",
+ Style::default()
+ .fg(Color::Yellow)
+ .add_modifier(Modifier::BOLD),
+ )),
+ Line::from(""),
+ Line::from(vec![
+ Span::styled(" macOS: ", Style::default().fg(Color::Gray)),
+ Span::styled("brew install tmux", Style::default().fg(Color::Green)),
+ ]),
+ Line::from(vec![
+ Span::styled(" Ubuntu/Debian: ", Style::default().fg(Color::Gray)),
+ Span::styled("sudo apt install tmux", Style::default().fg(Color::Green)),
+ ]),
+ Line::from(vec![
+ Span::styled(" Fedora/RHEL: ", Style::default().fg(Color::Gray)),
+ Span::styled("sudo dnf install tmux", Style::default().fg(Color::Green)),
+ ]),
+ Line::from(vec![
+ Span::styled(" Arch: ", Style::default().fg(Color::Gray)),
+ Span::styled("sudo pacman -S tmux", Style::default().fg(Color::Green)),
+ ]),
+ Line::from(""),
+ Line::from(Span::styled(
+ "After installing, press [R] to re-check",
+ Style::default().fg(Color::DarkGray),
+ )),
+ ]
+ };
+ frame.render_widget(Paragraph::new(help_text), chunks[4]);
+
+ // Footer - different depending on status
+ let footer = if matches!(self.tmux_status, TmuxDetectionStatus::Available { .. }) {
+ Paragraph::new(Line::from(vec![
+ Span::styled("[R]", Style::default().fg(Color::Yellow)),
+ Span::raw(" re-check "),
+ Span::styled("Enter", Style::default().fg(Color::Yellow)),
+ Span::raw(" continue "),
+ Span::styled("Esc", Style::default().fg(Color::Yellow)),
+ Span::raw(" back"),
+ ]))
+ } else {
+ Paragraph::new(Line::from(vec![
+ Span::styled("[R]", Style::default().fg(Color::Green)),
+ Span::raw(" re-check tmux "),
+ Span::styled("Esc", Style::default().fg(Color::Yellow)),
+ Span::raw(" back"),
+ ]))
+ };
+ frame.render_widget(footer.alignment(Alignment::Center), chunks[5]);
+ }
+
+ pub(crate) fn render_vscode_setup_step(&self, frame: &mut Frame) {
+ let area = centered_rect(70, 70, frame.area());
+ frame.render_widget(Clear, area);
+
+ let block = Block::default()
+ .title(" VS Code Extension Setup ")
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(Color::Cyan));
+
+ let inner = block.inner(area);
+ frame.render_widget(block, area);
+
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .margin(2)
+ .constraints([
+ Constraint::Length(3), // Title
+ Constraint::Length(1), // Spacer
+ Constraint::Length(3), // Status
+ Constraint::Length(1), // Spacer
+ Constraint::Min(12), // Instructions
+ Constraint::Length(3), // Footer
+ ])
+ .split(inner);
+
+ // Title
+ let title = Paragraph::new(Line::from(vec![Span::styled(
+ "VS Code Integration Setup",
+ Style::default()
+ .fg(Color::LightRed)
+ .add_modifier(Modifier::BOLD),
+ )]))
+ .alignment(Alignment::Center);
+ frame.render_widget(title, chunks[0]);
+
+ // Status indicator
+ let status_line = match &self.vscode_status {
+ VSCodeDetectionStatus::NotChecked => Line::from(vec![
+ Span::styled("Extension status: ", Style::default().fg(Color::Gray)),
+ Span::styled("[?] ", Style::default().fg(Color::Yellow)),
+ Span::styled("Not checked", Style::default().fg(Color::Yellow)),
+ ]),
+ VSCodeDetectionStatus::Checking => Line::from(vec![
+ Span::styled("Extension status: ", Style::default().fg(Color::Gray)),
+ Span::styled("[~] ", Style::default().fg(Color::Yellow)),
+ Span::styled("Checking...", Style::default().fg(Color::Yellow)),
+ ]),
+ VSCodeDetectionStatus::Connected { version } => Line::from(vec![
+ Span::styled("Extension status: ", Style::default().fg(Color::Gray)),
+ Span::styled("[+] ", Style::default().fg(Color::Green)),
+ Span::styled(
+ format!("Connected (v{})", version),
+ Style::default().fg(Color::Green),
+ ),
+ ]),
+ VSCodeDetectionStatus::NotReachable => Line::from(vec![
+ Span::styled("Extension status: ", Style::default().fg(Color::Gray)),
+ Span::styled("[x] ", Style::default().fg(Color::Red)),
+ Span::styled("Not detected", Style::default().fg(Color::Red)),
+ ]),
+ };
+ let status = Paragraph::new(vec![status_line]).alignment(Alignment::Center);
+ frame.render_widget(status, chunks[2]);
+
+ // Instructions
+ let instructions = vec![
+ Line::from(Span::styled(
+ "To use VS Code integration:",
+ Style::default()
+ .fg(Color::Yellow)
+ .add_modifier(Modifier::BOLD),
+ )),
+ Line::from(""),
+ Line::from(vec![
+ Span::styled(" 1. ", Style::default().fg(Color::Cyan)),
+ Span::raw("Install the Operator extension from:"),
+ ]),
+ Line::from(vec![
+ Span::raw(" "),
+ Span::styled(
+ "https://operator.untra.io/vscode",
+ Style::default()
+ .fg(Color::Blue)
+ .add_modifier(Modifier::UNDERLINED),
+ ),
+ ]),
+ Line::from(""),
+ Line::from(vec![
+ Span::styled(" 2. ", Style::default().fg(Color::Cyan)),
+ Span::raw("Restart VS Code after installation"),
+ ]),
+ Line::from(""),
+ Line::from(vec![
+ Span::styled(" 3. ", Style::default().fg(Color::Cyan)),
+ Span::raw("The extension will start automatically on port 7009"),
+ ]),
+ Line::from(""),
+ Line::from(Span::styled(
+ "Note: VS Code extension support is coming soon!",
+ Style::default().fg(Color::DarkGray),
+ )),
+ ];
+ frame.render_widget(Paragraph::new(instructions), chunks[4]);
+
+ // Footer
+ let footer = Paragraph::new(Line::from(vec![
+ Span::styled("[T]", Style::default().fg(Color::Yellow)),
+ Span::raw(" test connection "),
+ Span::styled("Enter", Style::default().fg(Color::Yellow)),
+ Span::raw(" continue "),
+ Span::styled("Esc", Style::default().fg(Color::Yellow)),
+ Span::raw(" back"),
+ ]))
+ .alignment(Alignment::Center);
+ frame.render_widget(footer, chunks[5]);
+ }
+
+ pub(crate) fn render_worktree_preference_step(&mut self, frame: &mut Frame) {
+ let area = centered_rect(70, 60, frame.area());
+ frame.render_widget(Clear, area);
+
+ let block = Block::default()
+ .title(Line::from(vec![
+ Span::raw(" "),
+ Span::styled(
+ "Operator!",
+ Style::default()
+ .fg(Color::LightRed)
+ .add_modifier(Modifier::BOLD),
+ ),
+ Span::raw(" Setup - Git Workflow "),
+ ]))
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(Color::Cyan));
+
+ let inner = block.inner(area);
+ frame.render_widget(block, area);
+
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .margin(2)
+ .constraints([
+ Constraint::Length(3), // Title
+ Constraint::Length(4), // Description
+ Constraint::Length(2), // Instructions
+ Constraint::Min(10), // Options list
+ Constraint::Length(2), // Footer
+ ])
+ .split(inner);
+
+ // Title
+ let title = Paragraph::new(Line::from(vec![Span::styled(
+ "Git Workflow Preference",
+ Style::default()
+ .fg(Color::LightRed)
+ .add_modifier(Modifier::BOLD),
+ )]))
+ .alignment(Alignment::Center);
+ frame.render_widget(title, chunks[0]);
+
+ // Description
+ let description = Paragraph::new(vec![
+ Line::from("Choose how Operator manages branches for tickets."),
+ Line::from("This affects how agents work in your repositories."),
+ ])
+ .alignment(Alignment::Center)
+ .style(Style::default().fg(Color::Gray));
+ frame.render_widget(description, chunks[1]);
+
+ // Instructions
+ let instructions =
+ Paragraph::new(vec![Line::from("Use arrows to navigate, Enter to select")])
+ .alignment(Alignment::Center)
+ .style(Style::default().fg(Color::DarkGray));
+ frame.render_widget(instructions, chunks[2]);
+
+ // Options list
+ let items: Vec = WorktreeOption::all()
+ .iter()
+ .map(|opt| {
+ let is_selected = opt.to_use_worktrees() == self.use_worktrees;
+ let radio = if is_selected { "(o)" } else { "( )" };
+ ListItem::new(vec![
+ Line::from(vec![
+ Span::styled(
+ radio,
+ Style::default().fg(if is_selected {
+ Color::Green
+ } else {
+ Color::DarkGray
+ }),
+ ),
+ Span::raw(" "),
+ Span::styled(
+ opt.label(),
+ Style::default()
+ .add_modifier(Modifier::BOLD)
+ .fg(if is_selected {
+ Color::White
+ } else {
+ Color::Gray
+ }),
+ ),
+ ]),
+ Line::from(vec![
+ Span::raw(" "),
+ Span::styled(opt.description(), Style::default().fg(Color::DarkGray)),
+ ]),
+ Line::from(""), // Empty line for spacing between options
+ ])
+ })
+ .collect();
+
+ let list = List::new(items)
+ .highlight_style(Style::default().add_modifier(Modifier::REVERSED))
+ .highlight_symbol("> ");
+
+ frame.render_stateful_widget(list, chunks[3], &mut self.worktree_state);
+
+ // Footer
+ let footer = Paragraph::new(Line::from(vec![
+ Span::styled("Enter", Style::default().fg(Color::Yellow)),
+ Span::raw(" select "),
+ Span::styled("Esc", Style::default().fg(Color::Yellow)),
+ Span::raw(" back"),
+ ]))
+ .alignment(Alignment::Center);
+ frame.render_widget(footer, chunks[4]);
+ }
+}
diff --git a/src/ui/setup/tests.rs b/src/ui/setup/tests.rs
new file mode 100644
index 0000000..8400002
--- /dev/null
+++ b/src/ui/setup/tests.rs
@@ -0,0 +1,291 @@
+//! Tests for the setup wizard
+
+use super::types::*;
+use super::SetupScreen;
+use crate::config::SessionWrapperType;
+use std::collections::HashMap;
+
+#[test]
+fn test_detected_tool_info_creation() {
+ let info = DetectedToolInfo {
+ name: "claude".to_string(),
+ version: "2.0.76".to_string(),
+ model_count: 3,
+ };
+ assert_eq!(info.name, "claude");
+ assert_eq!(info.version, "2.0.76");
+ assert_eq!(info.model_count, 3);
+}
+
+#[test]
+fn test_setup_screen_new_with_detected_tools() {
+ let tools = vec![DetectedToolInfo {
+ name: "claude".to_string(),
+ version: "2.0.76".to_string(),
+ model_count: 3,
+ }];
+ let mut projects = HashMap::new();
+ projects.insert("claude".to_string(), vec!["project-a".to_string()]);
+
+ let screen = SetupScreen::new(".tickets".to_string(), tools, projects);
+
+ assert!(screen.visible);
+ assert_eq!(screen.step, SetupStep::Welcome);
+}
+
+#[test]
+fn test_setup_screen_with_no_detected_tools() {
+ let screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new());
+ assert!(screen.visible);
+ assert_eq!(screen.step, SetupStep::Welcome);
+}
+
+#[test]
+fn test_setup_screen_with_multiple_tools() {
+ let tools = vec![
+ DetectedToolInfo {
+ name: "claude".to_string(),
+ version: "2.0.0".to_string(),
+ model_count: 3,
+ },
+ DetectedToolInfo {
+ name: "gemini".to_string(),
+ version: "1.0.0".to_string(),
+ model_count: 2,
+ },
+ ];
+ let mut projects = HashMap::new();
+ projects.insert(
+ "claude".to_string(),
+ vec!["api".to_string(), "web".to_string()],
+ );
+ projects.insert("gemini".to_string(), vec!["api".to_string()]);
+
+ let screen = SetupScreen::new(".tickets".to_string(), tools, projects);
+ assert!(screen.visible);
+ assert_eq!(screen.step, SetupStep::Welcome);
+}
+
+// ─── Session Wrapper Selection Tests ────────────────────────────────────────
+
+#[test]
+fn test_setup_default_wrapper_is_tmux() {
+ let screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new());
+ assert_eq!(screen.selected_wrapper, SessionWrapperType::Tmux);
+}
+
+#[test]
+fn test_setup_tmux_status_default_is_not_checked() {
+ let screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new());
+ assert_eq!(screen.tmux_status, TmuxDetectionStatus::NotChecked);
+}
+
+#[test]
+fn test_setup_vscode_status_default_is_not_checked() {
+ let screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new());
+ assert_eq!(screen.vscode_status, VSCodeDetectionStatus::NotChecked);
+}
+
+#[test]
+fn test_setup_wrapper_navigation_flow() {
+ let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new());
+
+ // Navigate to SessionWrapperChoice
+ screen.step = SetupStep::TaskFieldConfig;
+ screen.confirm(); // Should go to SessionWrapperChoice
+ assert_eq!(screen.step, SetupStep::SessionWrapperChoice);
+}
+
+#[test]
+fn test_setup_navigation_to_worktree_preference() {
+ let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new());
+ screen.step = SetupStep::SessionWrapperChoice;
+ screen.selected_wrapper = SessionWrapperType::Tmux;
+ screen.wrapper_state.select(Some(0)); // Select tmux
+
+ // SessionWrapperChoice -> WorktreePreference
+ screen.confirm();
+ assert_eq!(screen.step, SetupStep::WorktreePreference);
+}
+
+#[test]
+fn test_setup_navigation_tmux_path() {
+ let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new());
+ screen.step = SetupStep::WorktreePreference;
+ screen.selected_wrapper = SessionWrapperType::Tmux;
+
+ // WorktreePreference -> TmuxOnboarding (when tmux selected)
+ screen.confirm();
+ assert_eq!(screen.step, SetupStep::TmuxOnboarding);
+}
+
+#[test]
+fn test_setup_navigation_vscode_path() {
+ let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new());
+ screen.step = SetupStep::WorktreePreference;
+ screen.selected_wrapper = SessionWrapperType::Vscode;
+
+ // WorktreePreference -> VSCodeSetup (when vscode selected)
+ screen.confirm();
+ assert_eq!(screen.step, SetupStep::VSCodeSetup);
+}
+
+#[test]
+fn test_setup_worktree_preference_go_back() {
+ let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new());
+ screen.step = SetupStep::WorktreePreference;
+
+ screen.go_back();
+ assert_eq!(screen.step, SetupStep::SessionWrapperChoice);
+}
+
+#[test]
+fn test_setup_tmux_onboarding_go_back() {
+ let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new());
+ screen.step = SetupStep::TmuxOnboarding;
+
+ // TmuxOnboarding -> WorktreePreference
+ screen.go_back();
+ assert_eq!(screen.step, SetupStep::WorktreePreference);
+}
+
+#[test]
+fn test_setup_vscode_setup_go_back() {
+ let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new());
+ screen.step = SetupStep::VSCodeSetup;
+
+ // VSCodeSetup -> WorktreePreference
+ screen.go_back();
+ assert_eq!(screen.step, SetupStep::WorktreePreference);
+}
+
+#[test]
+fn test_setup_wrapper_selection_toggle() {
+ let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new());
+ screen.step = SetupStep::SessionWrapperChoice;
+
+ // Start at tmux (default)
+ assert_eq!(screen.selected_wrapper, SessionWrapperType::Tmux);
+
+ // Navigate down to vscode
+ screen.select_next();
+ assert_eq!(screen.wrapper_state.selected(), Some(1));
+
+ // Toggle selection
+ screen.toggle_selection();
+ assert_eq!(screen.selected_wrapper, SessionWrapperType::Vscode);
+}
+
+#[test]
+fn test_tmux_onboarding_blocks_if_not_available() {
+ let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new());
+ screen.step = SetupStep::TmuxOnboarding;
+ screen.tmux_status = TmuxDetectionStatus::NotInstalled;
+
+ // Should stay on TmuxOnboarding because tmux isn't available
+ screen.confirm();
+ assert_eq!(screen.step, SetupStep::TmuxOnboarding);
+}
+
+#[test]
+fn test_tmux_onboarding_proceeds_if_available() {
+ let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new());
+ screen.step = SetupStep::TmuxOnboarding;
+ screen.tmux_status = TmuxDetectionStatus::Available {
+ version: "3.3a".to_string(),
+ };
+
+ // Should proceed to KanbanInfo because tmux is available
+ screen.confirm();
+ assert_eq!(screen.step, SetupStep::KanbanInfo);
+}
+
+#[test]
+fn test_kanban_info_go_back_respects_wrapper_choice() {
+ let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new());
+
+ // Test tmux path
+ screen.step = SetupStep::KanbanInfo;
+ screen.selected_wrapper = SessionWrapperType::Tmux;
+ screen.go_back();
+ assert_eq!(screen.step, SetupStep::TmuxOnboarding);
+
+ // Test vscode path
+ screen.step = SetupStep::KanbanInfo;
+ screen.selected_wrapper = SessionWrapperType::Vscode;
+ screen.go_back();
+ assert_eq!(screen.step, SetupStep::VSCodeSetup);
+}
+
+// ─── Worktree Preference Tests ────────────────────────────────────────────────
+
+#[test]
+fn test_setup_default_worktrees_is_false() {
+ let screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new());
+ assert!(!screen.use_worktrees);
+}
+
+#[test]
+fn test_setup_worktree_selection_toggle() {
+ let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new());
+ screen.step = SetupStep::WorktreePreference;
+
+ // Start with worktrees disabled (default)
+ assert!(!screen.use_worktrees);
+
+ // Navigate down to worktrees option
+ screen.select_next();
+ assert_eq!(screen.worktree_state.selected(), Some(1));
+
+ // Toggle selection
+ screen.toggle_selection();
+ assert!(screen.use_worktrees);
+}
+
+#[test]
+fn test_worktree_option_labels() {
+ assert_eq!(
+ WorktreeOption::InPlace.label(),
+ "Work in project directory (recommended)"
+ );
+ assert_eq!(WorktreeOption::Worktrees.label(), "Use isolated worktrees");
+}
+
+#[test]
+fn test_worktree_option_to_use_worktrees() {
+ assert!(!WorktreeOption::InPlace.to_use_worktrees());
+ assert!(WorktreeOption::Worktrees.to_use_worktrees());
+}
+
+#[test]
+fn test_worktree_option_from_use_worktrees() {
+ assert_eq!(
+ WorktreeOption::from_use_worktrees(false),
+ WorktreeOption::InPlace
+ );
+ assert_eq!(
+ WorktreeOption::from_use_worktrees(true),
+ WorktreeOption::Worktrees
+ );
+}
+
+#[test]
+fn test_session_wrapper_option_labels() {
+ assert_eq!(SessionWrapperOption::Tmux.label(), "Tmux (default)");
+ assert_eq!(
+ SessionWrapperOption::VSCode.label(),
+ "VS Code Integrated Terminal"
+ );
+}
+
+#[test]
+fn test_session_wrapper_option_to_wrapper_type() {
+ assert_eq!(
+ SessionWrapperOption::Tmux.to_wrapper_type(),
+ SessionWrapperType::Tmux
+ );
+ assert_eq!(
+ SessionWrapperOption::VSCode.to_wrapper_type(),
+ SessionWrapperType::Vscode
+ );
+}
diff --git a/src/ui/setup/types.rs b/src/ui/setup/types.rs
new file mode 100644
index 0000000..24a951c
--- /dev/null
+++ b/src/ui/setup/types.rs
@@ -0,0 +1,280 @@
+//! Type definitions for the setup wizard
+
+use crate::config::{CollectionPreset, SessionWrapperType};
+
+/// Simplified tool info for display on the welcome screen
+#[derive(Debug, Clone)]
+pub struct DetectedToolInfo {
+ pub name: String,
+ pub version: String,
+ pub model_count: usize,
+}
+
+/// Available issuetype collections (all known types)
+pub const ALL_ISSUE_TYPES: &[&str] = &["TASK", "FEAT", "FIX", "SPIKE", "INV"];
+
+/// Optional fields that can be configured for TASK (and propagated to other types)
+/// Note: 'summary' and 'description' remain required, 'id' is auto-generated
+pub const TASK_OPTIONAL_FIELDS: &[(&str, &str)] = &[
+ ("priority", "Priority level (P0-critical to P3-low)"),
+ ("points", "Story points estimate (0 or greater)"),
+ ("user_story", "User story or background context"),
+];
+
+/// Collection source options shown in setup
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum CollectionSourceOption {
+ Simple,
+ DevKanban,
+ DevopsKanban,
+ ImportJira,
+ ImportNotion,
+ CustomSelection,
+}
+
+impl CollectionSourceOption {
+ pub fn all() -> &'static [CollectionSourceOption] {
+ &[
+ CollectionSourceOption::Simple,
+ CollectionSourceOption::DevKanban,
+ CollectionSourceOption::DevopsKanban,
+ CollectionSourceOption::ImportJira,
+ CollectionSourceOption::ImportNotion,
+ CollectionSourceOption::CustomSelection,
+ ]
+ }
+
+ pub fn label(&self) -> &'static str {
+ match self {
+ CollectionSourceOption::Simple => "Simple",
+ CollectionSourceOption::DevKanban => "Dev Kanban",
+ CollectionSourceOption::DevopsKanban => "DevOps Kanban",
+ CollectionSourceOption::ImportJira => "Import from Jira",
+ CollectionSourceOption::ImportNotion => "Import from Notion",
+ CollectionSourceOption::CustomSelection => "Custom Selection",
+ }
+ }
+
+ pub fn description(&self) -> &'static str {
+ match self {
+ CollectionSourceOption::Simple => "Just TASK - minimal setup for general work",
+ CollectionSourceOption::DevKanban => "3 issue types: TASK, FEAT, FIX",
+ CollectionSourceOption::DevopsKanban => "5 issue types: TASK, SPIKE, INV, FEAT, FIX",
+ CollectionSourceOption::ImportJira => "(Coming soon)",
+ CollectionSourceOption::ImportNotion => "(Coming soon)",
+ CollectionSourceOption::CustomSelection => "Choose individual issue types",
+ }
+ }
+
+ pub fn is_unimplemented(&self) -> bool {
+ matches!(
+ self,
+ CollectionSourceOption::ImportJira | CollectionSourceOption::ImportNotion
+ )
+ }
+}
+
+/// Result of setup screen actions
+#[derive(Debug, Clone)]
+pub enum SetupResult {
+ /// Continue to next step
+ Continue,
+ /// Cancel/quit setup
+ Cancel,
+ /// Exit with unimplemented message
+ ExitUnimplemented(String),
+ /// Setup complete, initialize
+ Initialize,
+}
+
+/// Startup ticket options for project initialization
+#[derive(Debug, Clone)]
+pub struct StartupTicketOption {
+ /// Key identifier for the ticket type (e.g., "assess", "agent_setup")
+ pub key: &'static str,
+ pub name: &'static str,
+ pub description: &'static str,
+ pub enabled: bool,
+}
+
+impl StartupTicketOption {
+ pub fn all() -> Vec {
+ vec![
+ StartupTicketOption {
+ key: "assess",
+ name: "ASSESS tickets",
+ description: "Scan projects for catalog-info.yaml, create if missing",
+ enabled: true,
+ },
+ StartupTicketOption {
+ key: "agent_setup",
+ name: "AGENT-SETUP tickets",
+ description: "Configure Claude agents for each project",
+ enabled: false,
+ },
+ StartupTicketOption {
+ key: "project_init",
+ name: "PROJECT-INIT tickets",
+ description: "Run both ASSESS and AGENT-SETUP for each project",
+ enabled: false,
+ },
+ ]
+ }
+}
+
+/// Tmux availability detection status
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum TmuxDetectionStatus {
+ /// Not yet checked
+ NotChecked,
+ /// Tmux is available with the given version
+ Available { version: String },
+ /// Tmux is not installed
+ NotInstalled,
+ /// Tmux is installed but version is too old
+ VersionTooOld { current: String, required: String },
+}
+
+impl Default for TmuxDetectionStatus {
+ fn default() -> Self {
+ Self::NotChecked
+ }
+}
+
+/// VS Code extension detection status
+#[derive(Debug, Clone, PartialEq, Eq)]
+#[allow(dead_code)] // Variants will be used when VS Code extension support is implemented
+pub enum VSCodeDetectionStatus {
+ /// Not yet checked
+ NotChecked,
+ /// Currently checking connection
+ Checking,
+ /// Connected to extension with the given version
+ Connected { version: String },
+ /// Extension not reachable
+ NotReachable,
+}
+
+impl Default for VSCodeDetectionStatus {
+ fn default() -> Self {
+ Self::NotChecked
+ }
+}
+
+/// Session wrapper options shown in setup
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum SessionWrapperOption {
+ Tmux,
+ VSCode,
+}
+
+impl SessionWrapperOption {
+ pub fn all() -> &'static [SessionWrapperOption] {
+ &[SessionWrapperOption::Tmux, SessionWrapperOption::VSCode]
+ }
+
+ pub fn label(&self) -> &'static str {
+ match self {
+ SessionWrapperOption::Tmux => "Tmux (default)",
+ SessionWrapperOption::VSCode => "VS Code Integrated Terminal",
+ }
+ }
+
+ pub fn description(&self) -> &'static str {
+ match self {
+ SessionWrapperOption::Tmux => "Run agents in standalone tmux sessions",
+ SessionWrapperOption::VSCode => {
+ "Run agents in VS Code terminal panels (requires extension)"
+ }
+ }
+ }
+
+ pub fn to_wrapper_type(self) -> SessionWrapperType {
+ match self {
+ SessionWrapperOption::Tmux => SessionWrapperType::Tmux,
+ SessionWrapperOption::VSCode => SessionWrapperType::Vscode,
+ }
+ }
+}
+
+/// Git workflow options shown in setup
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum WorktreeOption {
+ /// Work directly in the project directory with feature branches (default)
+ InPlace,
+ /// Use isolated git worktrees for parallel development
+ Worktrees,
+}
+
+impl WorktreeOption {
+ pub fn all() -> &'static [WorktreeOption] {
+ &[WorktreeOption::InPlace, WorktreeOption::Worktrees]
+ }
+
+ pub fn label(&self) -> &'static str {
+ match self {
+ WorktreeOption::InPlace => "Work in project directory (recommended)",
+ WorktreeOption::Worktrees => "Use isolated worktrees",
+ }
+ }
+
+ pub fn description(&self) -> &'static str {
+ match self {
+ WorktreeOption::InPlace => {
+ "Creates feature branches directly in the project. Simpler setup, no trust dialogs."
+ }
+ WorktreeOption::Worktrees => {
+ "Creates isolated worktrees per ticket. Enables parallel work but requires trust approval."
+ }
+ }
+ }
+
+ /// Convert to the config boolean value
+ pub fn to_use_worktrees(self) -> bool {
+ match self {
+ WorktreeOption::InPlace => false,
+ WorktreeOption::Worktrees => true,
+ }
+ }
+
+ /// Create from config boolean value
+ #[allow(dead_code)] // Useful for future config-to-UI state conversion
+ pub fn from_use_worktrees(use_worktrees: bool) -> Self {
+ if use_worktrees {
+ WorktreeOption::Worktrees
+ } else {
+ WorktreeOption::InPlace
+ }
+ }
+}
+
+/// Steps in the setup process
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum SetupStep {
+ /// Welcome splash screen with discovered projects
+ Welcome,
+ /// Select template collection source
+ CollectionSource,
+ /// Custom issuetype selection (optional)
+ CustomCollection,
+ /// Configure TASK optional fields
+ TaskFieldConfig,
+ /// Select session wrapper (tmux or vscode)
+ SessionWrapperChoice,
+ /// Git worktree preference (use worktrees vs in-place branches)
+ WorktreePreference,
+ /// Tmux onboarding/help (only shown if tmux selected)
+ TmuxOnboarding,
+ /// VS Code extension setup (only shown if vscode selected)
+ VSCodeSetup,
+ /// Kanban integration info and provider detection
+ KanbanInfo,
+ /// Per-provider setup with project selection (index into valid_providers)
+ KanbanProviderSetup { provider_index: usize },
+ /// Review and configure acceptance criteria
+ AcceptanceCriteria,
+ /// Optional startup tickets creation
+ StartupTickets,
+ /// Confirm initialization
+ Confirm,
+}
diff --git a/vscode-extension/.vscodeignore b/vscode-extension/.vscodeignore
index af738d7..0199378 100644
--- a/vscode-extension/.vscodeignore
+++ b/vscode-extension/.vscodeignore
@@ -9,4 +9,21 @@ tsconfig.json
**/*.map
node_modules/**
*.vsix
-coverage/
\ No newline at end of file
+coverage/
+
+# Test configuration and output
+.nycrc.json
+.vscode-test.mjs
+test-output.txt
+
+# Local/dev settings
+.claude/**
+
+# Compiled tests (not needed at runtime)
+out/test/**
+
+# Build scripts
+scripts/**
+
+# Git placeholders
+bin/.gitkeep
\ No newline at end of file