From 73cebf15a507803cf9c2cc4754398a594e4fe5f1 Mon Sep 17 00:00:00 2001 From: Ibrahim Hadzic Date: Mon, 2 Feb 2026 17:43:49 -0500 Subject: [PATCH] feat(tui): implement Claude Theater Mode TUI interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the TUI redesign specification for bmaduum with an interactive, visual interface inspired by Claude Code. New Features: - Header bar with step progress, story key, model, and elapsed time - Token-by-token text streaming animation - Tool visualization with ⏺ and ⎿ symbols - Thinking spinner during processing gaps - Auto-scroll to follow new content - Mouse wheel scrolling to view earlier output - Only Ctrl+C keyboard shortcut for quitting New Package: internal/tui/ - styles/: Color palette, symbols, and lipgloss styles - events/: Event message types for Bubble Tea - model.go: Main TUI model with state management - update.go: Event handling and animations - view.go: Rendering functions - runner.go: Integration with workflow execution CLI Changes: - Add --tui flag to story command for single-story execution - TUI mode works with the existing lifecycle executor - Graceful fallback to standard output when not using TUI Documentation: - Update CLI_REFERENCE.md with TUI mode documentation - Include TUI layout examples and control reference Dependencies: - Add github.com/charmbracelet/bubbletea - Add github.com/charmbracelet/bubbles - Add github.com/alecthomas/chroma/v2 Test Coverage: - Unit tests for all TUI components - Color palette and style validation - Model state management tests - Event adapter tests Co-Authored-By: Claude Sonnet 4.5 --- docs/CLI_REFERENCE.md | 473 +++++++++++++++++++------------- go.mod | 16 +- go.sum | 21 ++ internal/cli/story.go | 210 ++++++++++++++ internal/tui/events.go | 54 ++++ internal/tui/events/events.go | 84 ++++++ internal/tui/model.go | 273 ++++++++++++++++++ internal/tui/runner.go | 175 ++++++++++++ internal/tui/runner_test.go | 126 +++++++++ internal/tui/styles/colors.go | 55 ++++ internal/tui/styles/lipgloss.go | 106 +++++++ internal/tui/styles/symbols.go | 31 +++ internal/tui/tui_test.go | 220 +++++++++++++++ internal/tui/update.go | 176 ++++++++++++ internal/tui/view.go | 232 ++++++++++++++++ 15 files changed, 2055 insertions(+), 197 deletions(-) create mode 100644 internal/cli/story.go create mode 100644 internal/tui/events.go create mode 100644 internal/tui/events/events.go create mode 100644 internal/tui/model.go create mode 100644 internal/tui/runner.go create mode 100644 internal/tui/runner_test.go create mode 100644 internal/tui/styles/colors.go create mode 100644 internal/tui/styles/lipgloss.go create mode 100644 internal/tui/styles/symbols.go create mode 100644 internal/tui/tui_test.go create mode 100644 internal/tui/update.go create mode 100644 internal/tui/view.go diff --git a/docs/CLI_REFERENCE.md b/docs/CLI_REFERENCE.md index 1e23930..1e19839 100644 --- a/docs/CLI_REFERENCE.md +++ b/docs/CLI_REFERENCE.md @@ -25,321 +25,333 @@ All commands: ## Commands -### create-story +### story -Create a story definition from a story key. +Run full lifecycle for one or more stories from their current status to done. **Usage:** ```bash -bmaduum create-story +bmaduum story [--dry-run] [--auto-retry] [--tui] [story-key...] ``` **Arguments:** | Argument | Required | Description | |----------|----------|-------------| -| story-key | Yes | The story identifier (e.g., `PROJ-123`) | +| story-key | Yes (1+) | One or more story identifiers | -**Example:** +**Flags:** +| Flag | Description | +|------|-------------| +| `--dry-run` | Preview workflow sequence without execution | +| `--auto-retry` | Automatically retry on rate limit errors | +| `--tui` | Enable interactive TUI mode (single story only) | + +**Examples:** ```bash -bmaduum create-story PROJ-123 +# Run full lifecycle for a single story +bmaduum story PROJ-123 + +# Run full lifecycle for multiple stories +bmaduum story PROJ-123 PROJ-124 PROJ-125 + +# Preview what would run +bmaduum story --dry-run PROJ-123 PROJ-124 PROJ-125 + +# Enable TUI mode +bmaduum story --tui PROJ-123 ``` **Behavior:** -1. Loads `create-story` workflow prompt from configuration -2. Expands `{{.StoryKey}}` template with provided story key -3. Executes Claude with the expanded prompt -4. Displays streaming output - ---- +1. Processes each story through its **full lifecycle** to completion +2. Auto-updates status after each successful workflow step +3. Skips stories with status `done` +4. Stops on first failure +5. For multiple stories, shows progress indicators -### dev-story +**Lifecycle Routing:** -Implement a story by running the development workflow. +| Story Status | Remaining Lifecycle | +| --------------- | -------------------------------------------------------------- | +| `backlog` | create-story -> dev-story -> code-review -> git-commit -> done | +| `ready-for-dev` | dev-story -> code-review -> git-commit -> done | +| `in-progress` | dev-story -> code-review -> git-commit -> done | +| `review` | code-review -> git-commit -> done | +| `done` | No action (story already complete) | -**Usage:** +**Dry Run Output (single story):** -```bash -bmaduum dev-story +``` +Dry run for story PROJ-123: + 1. dev-story -> review + 2. code-review -> done + 3. git-commit -> done ``` -**Arguments:** -| Argument | Required | Description | -|----------|----------|-------------| -| story-key | Yes | The story identifier | - -**Example:** +**Dry Run Output (multiple stories):** -```bash -bmaduum dev-story PROJ-123 ``` +Dry run for 3 stories: -**Behavior:** +Story PROJ-123: + 1. dev-story -> review + 2. code-review -> done + 3. git-commit -> done -1. Loads `dev-story` workflow prompt -2. Executes Claude to implement the story -3. Claude runs tests after each implementation step +Story PROJ-124: + (already complete) + +Story PROJ-125: + 1. create-story -> ready-for-dev + 2. dev-story -> review + 3. code-review -> done + 4. git-commit -> done + +Total: 7 workflows across 2 stories (1 already complete) +``` --- -### code-review +### epic -Run code review on a story's changes. +Run full lifecycle for all stories in one or more epics, or all active epics. **Usage:** ```bash -bmaduum code-review +# Single or multiple epics +bmaduum epic [--dry-run] [--auto-retry] [epic-id...] + +# All active epics +bmaduum epic [--dry-run] [--auto-retry] all ``` **Arguments:** | Argument | Required | Description | |----------|----------|-------------| -| story-key | Yes | The story identifier | +| epic-id | Yes (1+) | One or more epic identifiers, or `all` for all active epics | -**Example:** +**Flags:** +| Flag | Description | +|------|-------------| +| `--dry-run` | Preview workflow sequence without execution | +| `--auto-retry` | Automatically retry on rate limit errors | -```bash -bmaduum code-review PROJ-123 -``` +**Examples:** -**Behavior:** +```bash +# Run full lifecycle for all stories in a single epic +bmaduum epic 05 -1. Loads `code-review` workflow prompt -2. Executes Claude to review code changes -3. Automatically applies fixes when issues are found +# Run multiple epics +bmaduum epic 02 04 06 ---- +# Run all active epics +bmaduum epic all -### git-commit +# Preview what would run +bmaduum epic --dry-run 02 04 06 +bmaduum epic --dry-run all +``` -Commit and push changes for a story. +**Story Discovery:** -**Usage:** +Stories are discovered from `sprint-status.yaml` using the pattern: -```bash -bmaduum git-commit +``` +{epic-id}-{story-number}-* ``` -**Arguments:** -| Argument | Required | Description | -|----------|----------|-------------| -| story-key | Yes | The story identifier | +For epic `05`, this matches: -**Example:** +- `05-01-implement-auth` +- `05-02-add-dashboard` +- `05-03-fix-navigation` -```bash -bmaduum git-commit PROJ-123 -``` +Stories are sorted by story number and processed in order. + +**When using `all`:** + +The `all` argument auto-discovers all epics with non-completed stories and processes them in numerical order. **Behavior:** -1. Loads `git-commit` workflow prompt -2. Executes Claude to create a commit with conventional commit format -3. Pushes to the current branch +1. Finds all stories matching the epic pattern(s) +2. Sorts by story number within each epic +3. Runs each story through its **full lifecycle** to completion +4. Auto-updates status after each successful workflow step +5. Stops on first failure +6. Processes multiple epics in the order specified --- -### run +### workflow (Advanced) -Execute the full lifecycle for a story from its current status to done. +Run individual BMAD workflow steps directly. These are the same workflow commands used in BMAD-METHOD and are automatically executed by `story` and `epic` commands. **Usage:** ```bash -bmaduum run [--dry-run] +bmaduum workflow +bmaduum workflow --help ``` -**Arguments:** -| Argument | Required | Description | -|----------|----------|-------------| -| story-key | Yes | The story identifier | +**Available workflows:** +- `create-story`: Create a story definition from backlog +- `dev-story`: Implement a story (ready-for-dev or in-progress) +- `code-review`: Review code changes (review status) +- `git-commit`: Commit and push changes after review + +**Subcommands:** +| Subcommand | Description | +|------------|-------------| +| `create-story` | Create a story definition from backlog | +| `dev-story` | Implement a story through development | +| `code-review` | Review code changes for a story | +| `git-commit` | Commit and push changes for a story | **Flags:** | Flag | Description | |------|-------------| -| `--dry-run` | Preview workflow sequence without execution | +| `--auto-retry` | Automatically retry on rate limit errors | -**Example:** +**Examples:** ```bash -# Run full lifecycle -bmaduum run PROJ-123 +# Using parent command syntax +bmaduum workflow create-story PROJ-123 +bmaduum workflow dev-story PROJ-123 +bmaduum workflow code-review PROJ-123 +bmaduum workflow git-commit PROJ-123 -# Preview what would run -bmaduum run --dry-run PROJ-123 +# Using subcommand syntax directly +bmaduum workflow create-story --help ``` -**Lifecycle Routing:** +**When to use:** -The `run` command executes all remaining workflows to completion: +- A workflow fails and you want to retry just that step +- You need to run a step out of the normal sequence for debugging +- You're testing or developing workflow prompts -| Story Status | Remaining Lifecycle | -| --------------- | -------------------------------------------------------------- | -| `backlog` | create-story -> dev-story -> code-review -> git-commit -> done | -| `ready-for-dev` | dev-story -> code-review -> git-commit -> done | -| `in-progress` | dev-story -> code-review -> git-commit -> done | -| `review` | code-review -> git-commit -> done | -| `done` | No action (story already complete) | - -**Behavior:** - -1. Reads story status from `_bmad-output/implementation-artifacts/sprint-status.yaml` -2. Determines remaining lifecycle steps based on status -3. Executes each workflow in sequence -4. Auto-updates status in `sprint-status.yaml` after each successful step -5. Stops at `done` or on first failure +**Note:** Most users should use `story` or `epic` commands instead, which automatically run the appropriate workflows based on story status. -**Dry Run Output:** +#### create-story -``` -Dry run for story PROJ-123: - 1. create-story -> ready-for-dev - 2. dev-story -> review - 3. code-review -> done - 4. git-commit -> done -``` - ---- - -### queue - -Run full lifecycle for multiple stories in batch. +Create a story definition from a story key. **Usage:** ```bash -bmaduum queue [--dry-run] [story-key...] +bmaduum workflow create-story ``` **Arguments:** | Argument | Required | Description | |----------|----------|-------------| -| story-key | Yes | One or more story identifiers | - -**Flags:** -| Flag | Description | -|------|-------------| -| `--dry-run` | Preview workflow sequence without execution | +| story-key | Yes | The story identifier (e.g., `PROJ-123`) | **Example:** ```bash -# Run full lifecycle for each story -bmaduum queue PROJ-123 PROJ-124 PROJ-125 - -# Preview what would run -bmaduum queue --dry-run PROJ-123 PROJ-124 PROJ-125 +bmaduum workflow create-story PROJ-123 ``` **Behavior:** -1. Processes each story through its **full lifecycle** to completion -2. Auto-updates status after each successful workflow step -3. Skips stories with status `done` -4. Stops on first failure -5. Displays summary with timing for each story +1. Loads `create-story` workflow prompt from configuration +2. Expands `{{.StoryKey}}` template with provided story key +3. Executes Claude with the expanded prompt +4. Displays streaming output -**Output:** +--- -``` -Queue: 3 stories [PROJ-123, PROJ-124, PROJ-125] +#### dev-story -[1/3] PROJ-123 - ... workflow output ... +Implement a story by running the development workflow. -[2/3] PROJ-124 - ... workflow output ... +**Usage:** -Summary: - PROJ-123 ✓ 1m 23s - PROJ-124 ✓ 2m 45s - PROJ-125 ○ skipped (done) +```bash +bmaduum workflow dev-story ``` -**Dry Run Output:** - -``` -Dry run for 3 stories: +**Arguments:** +| Argument | Required | Description | +|----------|----------|-------------| +| story-key | Yes | The story identifier | -Story PROJ-123: - 1. dev-story -> review - 2. code-review -> done - 3. git-commit -> done +**Example:** -Story PROJ-124: - (already complete) +```bash +bmaduum workflow dev-story PROJ-123 +``` -Story PROJ-125: - 1. create-story -> ready-for-dev - 2. dev-story -> review - 3. code-review -> done - 4. git-commit -> done +**Behavior:** -Total: 7 workflows across 2 stories (1 already complete) -``` +1. Loads `dev-story` workflow prompt +2. Executes Claude to implement the story +3. Claude runs tests after each implementation step --- -### epic +#### code-review -Run full lifecycle for all stories in one or more epics. +Run code review on a story's changes. **Usage:** ```bash -bmaduum epic [--dry-run] [epic-id...] +bmaduum workflow code-review ``` **Arguments:** | Argument | Required | Description | |----------|----------|-------------| -| epic-id | Yes (1+) | One or more epic identifiers | - -**Flags:** -| Flag | Description | -|------|-------------| -| `--dry-run` | Preview workflow sequence without execution | +| story-key | Yes | The story identifier | -**Examples:** +**Example:** ```bash -# Run full lifecycle for all stories in a single epic -bmaduum epic 05 +bmaduum workflow code-review PROJ-123 +``` -# Run multiple epics -bmaduum epic 02 04 06 +**Behavior:** -# Preview what would run -bmaduum epic --dry-run 02 04 06 -``` +1. Loads `code-review` workflow prompt +2. Executes Claude to review code changes +3. Automatically applies fixes when issues are found -**Story Discovery:** +--- -Stories are discovered from `sprint-status.yaml` using the pattern: +#### git-commit -``` -{epic-id}-{story-number}-* +Commit and push changes for a story. + +**Usage:** + +```bash +bmaduum workflow git-commit ``` -For epic `05`, this matches: +**Arguments:** +| Argument | Required | Description | +|----------|----------|-------------| +| story-key | Yes | The story identifier | -- `05-01-implement-auth` -- `05-02-add-dashboard` -- `05-03-fix-navigation` +**Example:** -Stories are sorted by story number and processed in order. +```bash +bmaduum workflow git-commit PROJ-123 +``` **Behavior:** -1. Finds all stories matching the epic pattern(s) -2. Sorts by story number within each epic -3. Runs each story through its **full lifecycle** to completion -4. Auto-updates status after each successful workflow step -5. Stops on first failure -6. Processes multiple epics in the order specified +1. Loads `git-commit` workflow prompt +2. Executes Claude to create a commit with conventional commit format +3. Pushes to the current branch --- @@ -373,6 +385,25 @@ bmaduum raw Explain the architecture of this codebase --- +### version + +Display version information. + +**Usage:** + +```bash +bmaduum version +``` + +**Example:** + +```bash +bmaduum version +# Output: bmaduum 1.0.0 (build: abc1234) +``` + +--- + ## Exit Codes | Code | Meaning | @@ -436,7 +467,7 @@ output: ## Sprint Status File -The `run`, `queue`, and `epic` commands read story status from: +The `story` and `epic` commands read story status from: ``` _bmad-output/implementation-artifacts/sprint-status.yaml @@ -500,33 +531,89 @@ The lifecycle executor persists execution state for error recovery. - The state file is optional - deleting it forces a fresh start from current status - State is written atomically (temp file + rename) to prevent corruption -- Each story has its own state; queue/epic commands process stories sequentially +- Each story has its own state; story/epic commands process stories sequentially --- ## Examples -### Basic Workflow +### TUI Mode + +The TUI (Terminal User Interface) mode provides an interactive, visual experience inspired by Claude Code's interface: ```bash -# Step-by-step workflow -bmaduum create-story PROJ-123 -bmaduum dev-story PROJ-123 -bmaduum code-review PROJ-123 -bmaduum git-commit PROJ-123 +# Run with interactive TUI +bmaduum story --tui PROJ-123 ``` -### Status-Based Automation +**TUI Features:** + +- **Header bar** - Shows current step, story key, model, and elapsed time +- **Token-by-token streaming** - Text appears character by character +- **Tool visualization** - Tool invocations shown with ⏺ symbol, results with ⎿ +- **Thinking indicator** - Animated spinner during processing gaps +- **Auto-scroll** - Content automatically scrolls to show latest output +- **Mouse scrolling** - Scroll back to see earlier content + +**TUI Controls:** + +| Key | Action | +|-----|--------| +| `Ctrl+C` | Quit the application | +| Mouse wheel | Scroll through output | + +**TUI Layout:** + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ ⚡ bmaduum │ Step 2/4: dev-story │ PROJ-123 │ claude-4 │ ⏱️ 02:34 │ +├──────────────────────────────────────────────────────────────────────┤ +│ │ +│ I'll implement the JWT authentication system. Let me start by │ +│ examining the current project structure... │ +│ │ +│ ⏺ Bash(ls -la src/) │ +│ ⎿ total 64 │ +│ drwxr-xr-x 10 user staff 320 Jan 1 00:00 . │ +│ │ +│ ⏺ Read(src/auth/types.ts) │ +│ ⎿ export interface AuthConfig { │ +│ tokenExpiry: number; │ +│ } │ +│ │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +**Notes:** + +- TUI mode is only available for single story execution +- The TUI requires a terminal that supports alternate screen mode +- All content is preserved and can be scrolled through after completion + +### Status-Based Automation (Recommended) ```bash -# Let the tool determine the right workflow -bmaduum run PROJ-123 +# Let the tool determine the right workflow for a single story +bmaduum story PROJ-123 # Process multiple stories -bmaduum queue PROJ-123 PROJ-124 PROJ-125 +bmaduum story PROJ-123 PROJ-124 PROJ-125 # Process an entire epic bmaduum epic 05 + +# Process all active epics +bmaduum epic all +``` + +### Individual Workflow Steps (Advanced) + +```bash +# Run a specific workflow step +bmaduum workflow create-story PROJ-123 +bmaduum workflow dev-story PROJ-123 +bmaduum workflow code-review PROJ-123 +bmaduum workflow git-commit PROJ-123 ``` ### Ad-Hoc Tasks @@ -541,8 +628,8 @@ bmaduum raw "Find all TODO comments" ```bash # Use custom config file -BMADUUM_CONFIG_PATH=/path/to/config.yaml bmaduum run PROJ-123 +BMADUUM_CONFIG_PATH=/path/to/config.yaml bmaduum story PROJ-123 # Use custom Claude binary -BMADUUM_CLAUDE_PATH=/usr/local/bin/claude bmaduum dev-story PROJ-123 +BMADUUM_CLAUDE_PATH=/usr/local/bin/claude bmaduum workflow dev-story PROJ-123 ``` diff --git a/go.mod b/go.mod index 6550efc..49bf9dc 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module bmad-automate +module bmaduum go 1.25.5 @@ -7,21 +7,30 @@ require ( github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 + gopkg.in/yaml.v3 v3.0.1 ) require ( + github.com/alecthomas/chroma/v2 v2.23.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/bubbles v0.21.0 // indirect + github.com/charmbracelet/bubbletea v1.3.10 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/ansi v0.8.0 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect @@ -34,7 +43,6 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/sys v0.30.0 // indirect + golang.org/x/sys v0.36.0 // indirect golang.org/x/text v0.28.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 4458e07..71992d3 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,19 @@ +github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY= +github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= @@ -13,6 +21,10 @@ github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNE github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -31,8 +43,14 @@ github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= @@ -70,9 +88,12 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/cli/story.go b/internal/cli/story.go new file mode 100644 index 0000000..b6bc7e5 --- /dev/null +++ b/internal/cli/story.go @@ -0,0 +1,210 @@ +package cli + +import ( + "context" + "errors" + "fmt" + + "github.com/spf13/cobra" + + "bmaduum/internal/lifecycle" + "bmaduum/internal/router" + "bmaduum/internal/tui" +) + +func newStoryCommand(app *App) *cobra.Command { + var dryRun bool + var autoRetry bool + var useTUI bool + + cmd := &cobra.Command{ + Use: "story [story-key...]", + Short: "Run the full story lifecycle to completion", + Long: `Run the complete lifecycle for one or more stories from their current status to done. + +Each story is run to completion before moving to the next. + +For each story, executes all remaining workflows based on its current status: + - backlog → create-story → dev-story → code-review → git-commit → done + - ready-for-dev → dev-story → code-review → git-commit → done + - in-progress → dev-story → code-review → git-commit → done + - review → code-review → git-commit → done + - done → skipped (story already complete) + +The command stops on the first failure. Done stories are skipped and do not cause failure. +Status is updated in sprint-status.yaml after each successful workflow. + +Use --dry-run to preview workflows without executing them. +Use --auto-retry to automatically retry on rate limit errors. +Use --tui to enable the interactive TUI mode. + +Examples: + bmaduum story 6-1 + bmaduum story 6-1 6-2 6-3 + bmaduum story --tui 6-1`, + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + storyKeys := args + + // Create lifecycle executor with app dependencies + executor := lifecycle.NewExecutor(app.Runner, app.StatusReader, app.StatusWriter) + + // Handle dry-run mode + if dryRun { + return runStoryDryRun(cmd, app, executor, storyKeys) + } + + // Handle TUI mode (only for single story) + if useTUI { + if len(storyKeys) > 1 { + return fmt.Errorf("TUI mode only supports single story execution") + } + return executeStoryTUI(ctx, app, executor, storyKeys[0]) + } + + // Execute full lifecycle for each story in order + for i, storyKey := range storyKeys { + // Show story progress for multiple stories + if len(storyKeys) > 1 { + fmt.Printf("─── Story %d of %d: %s\n", i+1, len(storyKeys), storyKey) + } + + err := executeWithRetry(ctx, executor, storyKey, autoRetry, 10, func(stepIndex, totalSteps int, workflow string) { + app.Printer.StepStart(stepIndex, totalSteps, workflow) + }) + if err != nil { + cmd.SilenceUsage = true + if errors.Is(err, router.ErrStoryComplete) { + fmt.Printf("Story %s is already complete, skipping\n", storyKey) + continue + } + fmt.Printf("Error running lifecycle for story %s: %v\n", storyKey, err) + return NewExitError(1) + } + + // Show completion message + if len(storyKeys) > 1 { + fmt.Printf("Story %s completed successfully\n\n", storyKey) + } + } + + if len(storyKeys) > 1 { + fmt.Printf("All %d stories processed\n", len(storyKeys)) + } + + return nil + }, + } + + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Preview workflows without executing them") + cmd.Flags().BoolVar(&autoRetry, "auto-retry", false, "Automatically retry on rate limit errors") + cmd.Flags().BoolVar(&useTUI, "tui", false, "Enable interactive TUI mode (single story only)") + + return cmd +} + +func runStoryDryRun(cmd *cobra.Command, app *App, executor *lifecycle.Executor, storyKeys []string) error { + // Single story dry-run - simpler output + if len(storyKeys) == 1 { + storyKey := storyKeys[0] + steps, err := executor.GetSteps(storyKey) + if err != nil { + cmd.SilenceUsage = true + if errors.Is(err, router.ErrStoryComplete) { + fmt.Printf("Story is already complete, no workflows to run\n") + return nil + } + fmt.Printf("Error: %v\n", err) + return NewExitError(1) + } + + fmt.Printf("Dry run for story %s:\n", storyKey) + for i, step := range steps { + modelInfo := "" + model := app.Config.GetModel(step.Workflow) + if model != "" { + modelInfo = fmt.Sprintf(" (%s)", model) + } + fmt.Printf(" %d. %s%s → %s\n", i+1, step.Workflow, modelInfo, step.NextStatus) + } + return nil + } + + // Multiple stories dry-run - detailed output + fmt.Printf("Dry run for %d stories:\n", len(storyKeys)) + + totalWorkflows := 0 + storiesWithWork := 0 + storiesComplete := 0 + + for _, storyKey := range storyKeys { + fmt.Println() + fmt.Printf("Story %s:\n", storyKey) + + steps, err := executor.GetSteps(storyKey) + if err != nil { + if errors.Is(err, router.ErrStoryComplete) { + fmt.Printf(" (already complete)\n") + storiesComplete++ + continue + } + cmd.SilenceUsage = true + fmt.Printf(" Error: %v\n", err) + return NewExitError(1) + } + + for i, step := range steps { + modelInfo := "" + model := app.Config.GetModel(step.Workflow) + if model != "" { + modelInfo = fmt.Sprintf(" (%s)", model) + } + fmt.Printf(" %d. %s%s → %s\n", i+1, step.Workflow, modelInfo, step.NextStatus) + } + totalWorkflows += len(steps) + storiesWithWork++ + } + + fmt.Println() + if storiesComplete > 0 { + fmt.Printf("Total: %d workflows across %d stories (%d already complete)\n", totalWorkflows, storiesWithWork, storiesComplete) + } else { + fmt.Printf("Total: %d workflows across %d stories\n", totalWorkflows, storiesWithWork) + } + + return nil +} + +// executeStoryTUI runs a story lifecycle using the TUI interface. +func executeStoryTUI(ctx context.Context, app *App, executor *lifecycle.Executor, storyKey string) error { + // Get the steps for this story + steps, err := executor.GetSteps(storyKey) + if err != nil { + if errors.Is(err, router.ErrStoryComplete) { + fmt.Printf("Story %s is already complete\n", storyKey) + return nil + } + return err + } + + // Convert lifecycle steps to TUI steps + tuiSteps := make([]tui.StepInfo, len(steps)) + for i, step := range steps { + tuiSteps[i] = tui.StepInfo{ + Name: step.Workflow, + StoryKey: storyKey, + NextStatus: string(step.NextStatus), + } + } + + // Create TUI runner and execute + tuiRunner := tui.NewRunner(app.Executor, app.Config) + exitCode := tuiRunner.RunMultiStep(ctx, tuiSteps, storyKey) + + if exitCode != 0 { + return NewExitError(exitCode) + } + + return nil +} diff --git a/internal/tui/events.go b/internal/tui/events.go new file mode 100644 index 0000000..04bd756 --- /dev/null +++ b/internal/tui/events.go @@ -0,0 +1,54 @@ +package tui + +import ( + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +// TextEvent indicates new text content has arrived. +type TextEvent struct{ Text string } + +// ToolUseEvent indicates a tool is being invoked. +type ToolUseEvent struct { + Name string + Description string + Command string + FilePath string +} + +// ToolResultEvent contains the result of a tool execution. +type ToolResultEvent struct { + Stdout string + Stderr string +} + +// SessionStartEvent indicates a Claude session has started. +type SessionStartEvent struct{} + +// StepStartMsg indicates a new workflow step has started. +type StepStartMsg struct { + Step int + Total int + StepName string + StoryKey string +} + +// StepCompleteMsg indicates the current step has completed. +type StepCompleteMsg struct { + Success bool + Duration time.Duration +} + +// CompleteMsg indicates the entire TUI workflow is complete. +type CompleteMsg struct { + ExitCode int +} + +// SpinnerUpdateMsg updates the spinner state. +type SpinnerUpdateMsg struct { + tea.Msg +} + +// SessionCompleteEvent indicates a Claude session has completed. +type SessionCompleteEvent struct{} diff --git a/internal/tui/events/events.go b/internal/tui/events/events.go new file mode 100644 index 0000000..679128f --- /dev/null +++ b/internal/tui/events/events.go @@ -0,0 +1,84 @@ +package events + +import ( + "time" + + "bmaduum/internal/claude" +) + +// Message types for Bubble Tea TUI updates. + +// ClaudeEventMsg wraps a Claude event for TUI consumption. +type ClaudeEventMsg struct { + Event claude.Event +} + +// TextContentMsg indicates new text content has arrived. +type TextContentMsg struct { + Text string +} + +// ToolUseMsg indicates a tool is being invoked. +type ToolUseMsg struct { + Name string + Description string + Command string + FilePath string +} + +// ToolResultMsg contains the result of a tool execution. +type ToolResultMsg struct { + Stdout string + Stderr string +} + +// StepStartMsg indicates a new workflow step has started. +type StepStartMsg struct { + Step int + Total int + StepName string + StoryKey string +} + +// StepCompleteMsg indicates the current step has completed. +type StepCompleteMsg struct { + Success bool + Duration time.Duration +} + +// SessionStartMsg indicates a Claude session has started. +type SessionStartMsg struct { + SessionID string +} + +// SessionCompleteMsg indicates a Claude session has completed. +type SessionCompleteMsg struct { + Success bool +} + +// CompleteMsg indicates the entire TUI workflow is complete. +type CompleteMsg struct { + ExitCode int +} + +// ThinkingMsg toggles the thinking spinner state. +type ThinkingMsg struct { + Thinking bool + Status string +} + +// ResizeMsg indicates the terminal has been resized. +type ResizeMsg struct { + Width int + Height int +} + +// TypewriterTickMsg is sent for each character animation frame. +type TypewriterTickMsg struct { + Time time.Time +} + +// SpinnerTickMsg is sent for each spinner animation frame. +type SpinnerTickMsg struct { + Time time.Time +} diff --git a/internal/tui/model.go b/internal/tui/model.go new file mode 100644 index 0000000..efe343e --- /dev/null +++ b/internal/tui/model.go @@ -0,0 +1,273 @@ +package tui + +import ( + "context" + "fmt" + "strings" + "time" + + "bmaduum/internal/claude" + "bmaduum/internal/config" + + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// OutputSection represents a single output block in the TUI. +type OutputSection struct { + ID string + Type SectionType + Content string + Lines []string + Rendered string + Language string // For syntax highlighting +} + +// SectionType identifies the kind of output section. +type SectionType int + +const ( + SectionText SectionType = iota + SectionToolUse + SectionToolResult + SectionDivider +) + +// TypewriterState manages character-by-character text animation. +type TypewriterState struct { + Buffer string + Displayed int + Pending []rune + Speed time.Duration + Active bool +} + +// Model is the main Bubble Tea model for the TUI. +type Model struct { + // Header state + CurrentStep int + TotalSteps int + StepName string + StoryKey string + ModelName string + StartTime time.Time + + // Content state + Sections []OutputSection + CurrentSection *OutputSection + ContentBuilder strings.Builder + + // Viewport for scrolling + Viewport viewport.Model + + // Animation state + Typewriter TypewriterState + Spinner spinner.Model + Thinking bool + ThinkText string + LastActivity time.Time + + // Runtime + Executor claude.Executor + Config *config.Config + Ctx context.Context + Cancel context.CancelFunc + + // Dimensions + Width int + Height int + + // State + Err error + Quitting bool + ExitCode int +} + +// NewModel creates a new TUI model with default settings. +func NewModel(executor claude.Executor, cfg *config.Config, storyKey, modelName string) *Model { + ctx, cancel := context.WithCancel(context.Background()) + + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#8B949E")) + + return &Model{ + StoryKey: storyKey, + ModelName: modelName, + StartTime: time.Now(), + Executor: executor, + Config: cfg, + Ctx: ctx, + Cancel: cancel, + Spinner: s, + LastActivity: time.Now(), + Typewriter: TypewriterState{ + Speed: 5 * time.Millisecond, + }, + Sections: make([]OutputSection, 0), + } +} + +// Init initializes the TUI model. +func (m *Model) Init() tea.Cmd { + return tea.Batch( + tea.EnterAltScreen, + m.Spinner.Tick, + typewriterCmd(), + thinkingDetectorCmd(), + ) +} + +// Helper function to create a typewriter tick command. +func typewriterCmd() tea.Cmd { + return tea.Tick(5*time.Millisecond, func(t time.Time) tea.Msg { + return typewriterTickMsg{Time: t} + }) +} + +type typewriterTickMsg struct { + Time time.Time +} + +// Helper function to detect when we should show thinking spinner. +func thinkingDetectorCmd() tea.Cmd { + return tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg { + return thinkingCheckMsg{Time: t} + }) +} + +type thinkingCheckMsg struct { + Time time.Time +} + +// SetStep updates the current step information. +func (m *Model) SetStep(step, total int, name string) { + m.CurrentStep = step + m.TotalSteps = total + m.StepName = name +} + +// AddTextSection adds a new text section for streaming content. +func (m *Model) AddTextSection() { + section := OutputSection{ + ID: fmt.Sprintf("text-%d", len(m.Sections)), + Type: SectionText, + Content: "", + } + m.Sections = append(m.Sections, section) + m.CurrentSection = &m.Sections[len(m.Sections)-1] + m.Typewriter.Active = true + m.Typewriter.Displayed = 0 +} + +// AddToolUseSection adds a tool invocation section. +func (m *Model) AddToolUseSection(name, description, command, filePath string) { + section := OutputSection{ + ID: fmt.Sprintf("tool-%d", len(m.Sections)), + Type: SectionToolUse, + Content: formatToolUse(name, description, command, filePath), + } + m.Sections = append(m.Sections, section) + m.CurrentSection = nil // Tool use sections are complete immediately + m.Typewriter.Active = false +} + +// AddToolResultSection adds a tool result section. +func (m *Model) AddToolResultSection(stdout, stderr string) { + section := OutputSection{ + ID: fmt.Sprintf("result-%d", len(m.Sections)), + Type: SectionToolResult, + Content: formatToolResult(stdout, stderr), + Lines: splitLines(stdout + stderr), + } + m.Sections = append(m.Sections, section) + m.CurrentSection = nil + m.Typewriter.Active = false +} + +// AppendText appends text to the current text section with typewriter animation. +func (m *Model) AppendText(text string) { + if m.CurrentSection == nil || m.CurrentSection.Type != SectionText { + m.AddTextSection() + } + + m.Typewriter.Buffer += text + m.Typewriter.Pending = []rune(m.Typewriter.Buffer[m.Typewriter.Displayed:]) + m.LastActivity = time.Now() + m.Thinking = false +} + +// CompleteText marks the current text as complete (disable typewriter). +func (m *Model) CompleteText() { + m.Typewriter.Active = false + m.Typewriter.Displayed = len([]rune(m.Typewriter.Buffer)) + m.Typewriter.Pending = nil +} + +// SetThinking sets the thinking state. +func (m *Model) SetThinking(thinking bool, status string) { + m.Thinking = thinking + m.ThinkText = status + if thinking { + m.LastActivity = time.Now() + } +} + +// formatToolUse formats a tool use display. +func formatToolUse(name, description, command, filePath string) string { + var parts []string + if name != "" { + parts = append(parts, name) + } + if command != "" { + parts = append(parts, fmt.Sprintf("(%s)", command)) + } else if filePath != "" { + parts = append(parts, fmt.Sprintf("(%s)", filePath)) + } else if description != "" { + parts = append(parts, fmt.Sprintf("(%s)", description)) + } + return strings.Join(parts, "") +} + +// formatToolResult formats a tool result display. +func formatToolResult(stdout, stderr string) string { + var result strings.Builder + if stdout != "" { + result.WriteString(stdout) + } + if stderr != "" { + if result.Len() > 0 { + result.WriteString("\n") + } + result.WriteString(stderr) + } + return result.String() +} + +// splitLines splits content into lines. +func splitLines(content string) []string { + if content == "" { + return nil + } + return strings.Split(content, "\n") +} + +// ElapsedTime returns the formatted elapsed time since start. +func (m *Model) ElapsedTime() string { + elapsed := time.Since(m.StartTime) + minutes := int(elapsed.Minutes()) + seconds := int(elapsed.Seconds()) % 60 + return fmt.Sprintf("%02d:%02d", minutes, seconds) +} + +// IsComplete returns true if the TUI session is complete. +func (m *Model) IsComplete() bool { + return m.Quitting +} + +// GetExitCode returns the final exit code. +func (m *Model) GetExitCode() int { + return m.ExitCode +} diff --git a/internal/tui/runner.go b/internal/tui/runner.go new file mode 100644 index 0000000..48f1edc --- /dev/null +++ b/internal/tui/runner.go @@ -0,0 +1,175 @@ +package tui + +import ( + "context" + "fmt" + + "bmaduum/internal/claude" + "bmaduum/internal/config" + + tea "github.com/charmbracelet/bubbletea" +) + +// Runner handles TUI-based workflow execution. +type Runner struct { + executor claude.Executor + config *config.Config +} + +// NewRunner creates a new TUI runner. +func NewRunner(executor claude.Executor, cfg *config.Config) *Runner { + return &Runner{ + executor: executor, + config: cfg, + } +} + +// Run executes a workflow with the TUI. +func (r *Runner) Run(ctx context.Context, workflowName, storyKey string) int { + prompt, err := r.config.GetPrompt(workflowName, storyKey) + if err != nil { + fmt.Printf("Error getting prompt: %v\n", err) + return 1 + } + + model := r.config.GetModel(workflowName) + + // Create TUI model + modelName := model + if modelName == "" { + modelName = "claude" + } + + tuiModel := NewModel(r.executor, r.config, storyKey, modelName) + tuiModel.SetStep(1, 1, workflowName) + + // Create Bubble Tea program + p := tea.NewProgram( + tuiModel, + tea.WithAltScreen(), + tea.WithMouseCellMotion(), + ) + + // Run Claude in background + go func() { + handler := func(event claude.Event) { + p.Send(ClaudeEventAdapter(event)) + } + + exitCode, _ := r.executor.ExecuteWithResult(ctx, prompt, handler, model) + p.Send(CompleteMsg{ExitCode: exitCode}) + }() + + // Run TUI (blocks until complete) + finalModel, err := p.Run() + if err != nil { + fmt.Printf("TUI error: %v\n", err) + return 1 + } + + m := finalModel.(*Model) + + // Print final summary in regular terminal mode + fmt.Print(m.RenderFinalSummary()) + + return m.GetExitCode() +} + +// RunMultiStep executes multiple workflow steps with the TUI. +func (r *Runner) RunMultiStep(ctx context.Context, steps []StepInfo, storyKey string) int { + // Create TUI model + tuiModel := NewModel(r.executor, r.config, storyKey, "claude") + + // Create Bubble Tea program + p := tea.NewProgram( + tuiModel, + tea.WithAltScreen(), + tea.WithMouseCellMotion(), + ) + + // Run workflows in background + go func() { + for i, step := range steps { + // Update step info + p.Send(StepStartMsg{ + Step: i + 1, + Total: len(steps), + StepName: step.Name, + StoryKey: storyKey, + }) + + prompt, err := r.config.GetPrompt(step.Name, storyKey) + if err != nil { + p.Send(CompleteMsg{ExitCode: 1}) + return + } + + model := r.config.GetModel(step.Name) + + handler := func(event claude.Event) { + p.Send(ClaudeEventAdapter(event)) + } + + exitCode, _ := r.executor.ExecuteWithResult(ctx, prompt, handler, model) + if exitCode != 0 { + p.Send(CompleteMsg{ExitCode: exitCode}) + return + } + + p.Send(StepCompleteMsg{Success: true}) + } + + p.Send(CompleteMsg{ExitCode: 0}) + }() + + // Run TUI + finalModel, err := p.Run() + if err != nil { + fmt.Printf("TUI error: %v\n", err) + return 1 + } + + m := finalModel.(*Model) + fmt.Print(m.RenderFinalSummary()) + + return m.GetExitCode() +} + +// StepInfo represents a single workflow step. +type StepInfo struct { + Name string + StoryKey string + NextStatus string +} + +// ClaudeEventAdapter converts a claude.Event to a tea.Msg. +func ClaudeEventAdapter(event claude.Event) tea.Msg { + switch { + case event.SessionStarted: + return SessionStartEvent{} + + case event.IsText(): + return TextEvent{Text: event.Text} + + case event.IsToolUse(): + return ToolUseEvent{ + Name: event.ToolName, + Description: event.ToolDescription, + Command: event.ToolCommand, + FilePath: event.ToolFilePath, + } + + case event.IsToolResult(): + return ToolResultEvent{ + Stdout: event.ToolStdout, + Stderr: event.ToolStderr, + } + + case event.SessionComplete: + return SessionCompleteEvent{} + + default: + return nil + } +} + diff --git a/internal/tui/runner_test.go b/internal/tui/runner_test.go new file mode 100644 index 0000000..d0538d1 --- /dev/null +++ b/internal/tui/runner_test.go @@ -0,0 +1,126 @@ +package tui + +import ( + "testing" + + "bmaduum/internal/claude" + "bmaduum/internal/config" + + "github.com/stretchr/testify/assert" +) + +func TestNewRunner(t *testing.T) { + cfg := &config.Config{} + executor := &claude.MockExecutor{} + + runner := NewRunner(executor, cfg) + + assert.NotNil(t, runner) + assert.Equal(t, executor, runner.executor) + assert.Equal(t, cfg, runner.config) +} + +func TestStepInfo(t *testing.T) { + step := StepInfo{ + Name: "dev-story", + StoryKey: "PROJ-123", + NextStatus: "review", + } + + assert.Equal(t, "dev-story", step.Name) + assert.Equal(t, "PROJ-123", step.StoryKey) + assert.Equal(t, "review", step.NextStatus) +} + +func TestClaudeEventAdapter(t *testing.T) { + tests := []struct { + name string + event claude.Event + expected interface{} + }{ + { + name: "session start", + event: claude.Event{ + SessionStarted: true, + }, + expected: SessionStartEvent{}, + }, + { + name: "text content", + event: claude.Event{ + Type: claude.EventTypeAssistant, + Text: "Hello world", + Raw: &claude.StreamEvent{ + Type: "assistant", + Message: &claude.MessageContent{ + Content: []claude.ContentBlock{ + {Type: "text", Text: "Hello world"}, + }, + }, + }, + }, + expected: TextEvent{Text: "Hello world"}, + }, + { + name: "tool use", + event: claude.Event{ + Type: claude.EventTypeAssistant, + ToolName: "Bash", + ToolCommand: "ls -la", + Raw: &claude.StreamEvent{ + Type: "assistant", + Message: &claude.MessageContent{ + Content: []claude.ContentBlock{ + {Type: "tool_use", Name: "Bash"}, + }, + }, + }, + }, + expected: ToolUseEvent{ + Name: "Bash", + Command: "ls -la", + }, + }, + { + name: "tool result", + event: claude.Event{ + Type: claude.EventTypeUser, + ToolStdout: "output", + ToolStderr: "error", + Raw: &claude.StreamEvent{ + Type: "user", + ToolUseResult: &claude.ToolResult{ + Stdout: "output", + Stderr: "error", + }, + }, + }, + expected: ToolResultEvent{ + Stdout: "output", + Stderr: "error", + }, + }, + { + name: "session complete", + event: claude.Event{ + Type: claude.EventTypeResult, + SessionComplete: true, + }, + expected: SessionCompleteEvent{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ClaudeEventAdapter(tt.event) + assert.IsType(t, tt.expected, result) + }) + } +} + +func TestClaudeEventAdapter_Unknown(t *testing.T) { + // Event with no recognized fields should return nil + event := claude.Event{} + result := ClaudeEventAdapter(event) + assert.Nil(t, result) +} diff --git a/internal/tui/styles/colors.go b/internal/tui/styles/colors.go new file mode 100644 index 0000000..08c383e --- /dev/null +++ b/internal/tui/styles/colors.go @@ -0,0 +1,55 @@ +package styles + +// ClaudeColors provides the authentic Claude Code color palette. +// These colors match the visual appearance of the official Claude CLI. +var ClaudeColors = struct { + // Primary brand + Primary string // #6B4EE6 (Anthropic purple) + PrimaryDim string // #5A3FD4 + + // Tool display + ToolIcon string // #58A6FF (Blue) - for ⏺ + OutputIcon string // #8B949E (Gray) - for ⎿ + + // Text + Text string // #E6EDF3 (Off-white) + TextMuted string // #8B949E (Gray) + TextDim string // #6E7681 (Dark gray) + + // Semantic + Success string // #3FB950 (Green) + Error string // #F85149 (Red) + Warning string // #D29922 (Orange) + Info string // #58A6FF (Blue) + + // Background/structure + Background string // Terminal default (transparent) + Border string // #30363D (Dark border) + Selection string // #264F78 (Selection blue) + + // Syntax highlighting (for code blocks) + Comment string // #8B949E + Keyword string // #FF7B72 + String string // #A5D6FF + Function string // #D2A8FF + Number string // #79C0FF +}{ + Primary: "#6B4EE6", + PrimaryDim: "#5A3FD4", + ToolIcon: "#58A6FF", + OutputIcon: "#8B949E", + Text: "#E6EDF3", + TextMuted: "#8B949E", + TextDim: "#6E7681", + Success: "#3FB950", + Error: "#F85149", + Warning: "#D29922", + Info: "#58A6FF", + Border: "#30363D", + Selection: "#264F78", + Comment: "#8B949E", + Keyword: "#FF7B72", + String: "#A5D6FF", + Function: "#D2A8FF", + Number: "#79C0FF", +} diff --git a/internal/tui/styles/lipgloss.go b/internal/tui/styles/lipgloss.go new file mode 100644 index 0000000..d635a1a --- /dev/null +++ b/internal/tui/styles/lipgloss.go @@ -0,0 +1,106 @@ +package styles + +import ( + "github.com/charmbracelet/lipgloss" +) + +// Styles provides pre-defined lipgloss styles for the TUI. +type Styles struct { + // Header styles + Header lipgloss.Style + HeaderText lipgloss.Style + HeaderMuted lipgloss.Style + HeaderActive lipgloss.Style + + // Content styles + Text lipgloss.Style + TextMuted lipgloss.Style + TextDim lipgloss.Style + ToolUse lipgloss.Style + ToolOutput lipgloss.Style + + // Symbol styles + ToolIcon lipgloss.Style + OutputIcon lipgloss.Style + Success lipgloss.Style + Error lipgloss.Style + Warning lipgloss.Style + Info lipgloss.Style + + // Section styles + SectionDivider lipgloss.Style +} + +// DefaultStyles returns the default Claude Code-inspired styles. +func DefaultStyles() Styles { + return Styles{ + // Header with purple background + Header: lipgloss.NewStyle(). + Background(lipgloss.Color(ClaudeColors.Primary)). + Foreground(lipgloss.Color("#FFFFFF")). + Bold(true). + Padding(0, 1), + + HeaderText: lipgloss.NewStyle(). + Background(lipgloss.Color(ClaudeColors.Primary)). + Foreground(lipgloss.Color("#FFFFFF")), + + HeaderMuted: lipgloss.NewStyle(). + Background(lipgloss.Color(ClaudeColors.Primary)). + Foreground(lipgloss.Color("#CCCCCC")), + + HeaderActive: lipgloss.NewStyle(). + Background(lipgloss.Color("#FFFFFF")). + Foreground(lipgloss.Color(ClaudeColors.Primary)). + Bold(true). + Padding(0, 1), + + // Content + Text: lipgloss.NewStyle(). + Foreground(lipgloss.Color(ClaudeColors.Text)), + + TextMuted: lipgloss.NewStyle(). + Foreground(lipgloss.Color(ClaudeColors.TextMuted)), + + TextDim: lipgloss.NewStyle(). + Foreground(lipgloss.Color(ClaudeColors.TextDim)), + + // Tool use display + ToolUse: lipgloss.NewStyle(). + Foreground(lipgloss.Color(ClaudeColors.Text)), + + ToolOutput: lipgloss.NewStyle(). + Foreground(lipgloss.Color(ClaudeColors.TextMuted)), + + // Symbols + ToolIcon: lipgloss.NewStyle(). + Foreground(lipgloss.Color(ClaudeColors.ToolIcon)). + Bold(true), + + OutputIcon: lipgloss.NewStyle(). + Foreground(lipgloss.Color(ClaudeColors.OutputIcon)), + + Success: lipgloss.NewStyle(). + Foreground(lipgloss.Color(ClaudeColors.Success)). + Bold(true), + + Error: lipgloss.NewStyle(). + Foreground(lipgloss.Color(ClaudeColors.Error)). + Bold(true), + + Warning: lipgloss.NewStyle(). + Foreground(lipgloss.Color(ClaudeColors.Warning)), + + Info: lipgloss.NewStyle(). + Foreground(lipgloss.Color(ClaudeColors.Info)), + + // Section divider + SectionDivider: lipgloss.NewStyle(). + Foreground(lipgloss.Color(ClaudeColors.Border)). + MarginTop(1). + MarginBottom(1), + } +} + +// Default returns the default styles instance for convenience. +var Default = DefaultStyles() diff --git a/internal/tui/styles/symbols.go b/internal/tui/styles/symbols.go new file mode 100644 index 0000000..d375ffe --- /dev/null +++ b/internal/tui/styles/symbols.go @@ -0,0 +1,31 @@ +package styles + +// Symbols provides Unicode characters used in the Claude Code interface. +var Symbols = struct { + ToolInvocation string // ⏺ U+23FA - Tool invocation marker + ToolOutput string // ⎿ U+23BF - Tool output marker + Success string // ✓ U+2713 - Success/completion marker + Error string // ✗ U+2717 - Error marker + Ellipsis string // … U+2026 - Ellipsis indicator + HeaderLogo string // ⚡ U+26A1 - bmaduum logo + Clock string // ⏱️ U+23F1 - Timer icon + NewContent string // ↓ U+2193 - Scroll indicator + Bullet string // • U+2022 - Bullet point + Divider string // ─ U+2500 - Horizontal line +}{ + ToolInvocation: "⏺", + ToolOutput: "⎿", + Success: "✓", + Error: "✗", + Ellipsis: "…", + HeaderLogo: "⚡", + Clock: "⏱️", + NewContent: "↓", + Bullet: "•", + Divider: "─", +} + +// SpinnerFrames provides the Braille pattern for the thinking spinner. +var SpinnerFrames = []string{ + "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", +} diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go new file mode 100644 index 0000000..0fd9eb5 --- /dev/null +++ b/internal/tui/tui_test.go @@ -0,0 +1,220 @@ +package tui + +import ( + "testing" + "time" + + "bmaduum/internal/tui/styles" + + "github.com/charmbracelet/lipgloss" + "github.com/stretchr/testify/assert" +) + +func TestClaudeColors(t *testing.T) { + // Verify color palette is properly defined + assert.Equal(t, "#6B4EE6", styles.ClaudeColors.Primary) + assert.Equal(t, "#58A6FF", styles.ClaudeColors.ToolIcon) + assert.Equal(t, "#8B949E", styles.ClaudeColors.OutputIcon) + assert.Equal(t, "#E6EDF3", styles.ClaudeColors.Text) + assert.Equal(t, "#3FB950", styles.ClaudeColors.Success) + assert.Equal(t, "#F85149", styles.ClaudeColors.Error) +} + +func TestSymbols(t *testing.T) { + // Verify symbols are properly defined + assert.Equal(t, "⏺", styles.Symbols.ToolInvocation) + assert.Equal(t, "⎿", styles.Symbols.ToolOutput) + assert.Equal(t, "✓", styles.Symbols.Success) + assert.Equal(t, "✗", styles.Symbols.Error) + assert.Equal(t, "⚡", styles.Symbols.HeaderLogo) + assert.Equal(t, "⏱️", styles.Symbols.Clock) +} + +func TestSpinnerFrames(t *testing.T) { + // Verify spinner frames are defined + assert.Len(t, styles.SpinnerFrames, 10) + assert.Equal(t, "⠋", styles.SpinnerFrames[0]) + assert.Equal(t, "⠏", styles.SpinnerFrames[9]) +} + +func TestDefaultStyles(t *testing.T) { + // Verify default styles are properly initialized + s := styles.DefaultStyles() + + // Header should have purple background + headerBg := s.Header.GetBackground() + assert.Equal(t, lipgloss.Color("#6B4EE6"), headerBg) + + // Success should have green foreground + successFg := s.Success.GetForeground() + assert.Equal(t, lipgloss.Color("#3FB950"), successFg) + + // Error should have red foreground + errorFg := s.Error.GetForeground() + assert.Equal(t, lipgloss.Color("#F85149"), errorFg) +} + +func TestModel_SetStep(t *testing.T) { + m := &Model{} + + m.SetStep(2, 4, "dev-story") + + assert.Equal(t, 2, m.CurrentStep) + assert.Equal(t, 4, m.TotalSteps) + assert.Equal(t, "dev-story", m.StepName) +} + +func TestModel_AddTextSection(t *testing.T) { + m := NewModel(nil, nil, "PROJ-123", "claude") + + m.AddTextSection() + + assert.Len(t, m.Sections, 1) + assert.Equal(t, SectionText, m.Sections[0].Type) + assert.NotNil(t, m.CurrentSection) + assert.True(t, m.Typewriter.Active) +} + +func TestModel_AddToolUseSection(t *testing.T) { + m := NewModel(nil, nil, "PROJ-123", "claude") + + m.AddToolUseSection("Bash", "", "ls -la", "") + + assert.Len(t, m.Sections, 1) + assert.Equal(t, SectionToolUse, m.Sections[0].Type) + assert.Contains(t, m.Sections[0].Content, "Bash") + assert.Contains(t, m.Sections[0].Content, "ls -la") +} + +func TestModel_AddToolResultSection(t *testing.T) { + m := NewModel(nil, nil, "PROJ-123", "claude") + + m.AddToolResultSection("output line 1\noutput line 2", "") + + assert.Len(t, m.Sections, 1) + assert.Equal(t, SectionToolResult, m.Sections[0].Type) + assert.Equal(t, "output line 1\noutput line 2", m.Sections[0].Content) + assert.Len(t, m.Sections[0].Lines, 2) +} + +func TestModel_AppendText(t *testing.T) { + m := NewModel(nil, nil, "PROJ-123", "claude") + m.AddTextSection() + + m.AppendText("Hello") + + assert.Equal(t, "Hello", m.Typewriter.Buffer) + assert.Equal(t, []rune("Hello"), m.Typewriter.Pending) + assert.True(t, m.Typewriter.Active) +} + +func TestModel_CompleteText(t *testing.T) { + m := NewModel(nil, nil, "PROJ-123", "claude") + m.AddTextSection() + m.AppendText("Hello World") + + m.CompleteText() + + assert.False(t, m.Typewriter.Active) + assert.Equal(t, 11, m.Typewriter.Displayed) // "Hello World" = 11 chars +} + +func TestModel_ElapsedTime(t *testing.T) { + m := NewModel(nil, nil, "PROJ-123", "claude") + m.StartTime = time.Now().Add(-2*time.Minute - 34*time.Second) + + elapsed := m.ElapsedTime() + + assert.Equal(t, "02:34", elapsed) +} + +func TestFormatToolUse(t *testing.T) { + tests := []struct { + name string + toolName string + description string + command string + filePath string + expected string + }{ + { + name: "command only", + toolName: "Bash", + command: "ls -la", + expected: "Bash(ls -la)", + }, + { + name: "filepath only", + toolName: "Read", + filePath: "src/main.go", + expected: "Read(src/main.go)", + }, + { + name: "description only", + toolName: "Edit", + description: "Update function", + expected: "Edit(Update function)", + }, + { + name: "name only", + toolName: "Think", + expected: "Think", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatToolUse(tt.toolName, tt.description, tt.command, tt.filePath) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestSplitLines(t *testing.T) { + tests := []struct { + name string + content string + expected []string + }{ + { + name: "multiple lines", + content: "line1\nline2\nline3", + expected: []string{"line1", "line2", "line3"}, + }, + { + name: "single line", + content: "single", + expected: []string{"single"}, + }, + { + name: "empty", + content: "", + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := splitLines(tt.content) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestModel_GetExitCode(t *testing.T) { + m := NewModel(nil, nil, "PROJ-123", "claude") + + assert.Equal(t, 0, m.GetExitCode()) + + m.ExitCode = 1 + assert.Equal(t, 1, m.GetExitCode()) +} + +func TestModel_IsComplete(t *testing.T) { + m := NewModel(nil, nil, "PROJ-123", "claude") + + assert.False(t, m.IsComplete()) + + m.Quitting = true + assert.True(t, m.IsComplete()) +} diff --git a/internal/tui/update.go b/internal/tui/update.go new file mode 100644 index 0000000..792640b --- /dev/null +++ b/internal/tui/update.go @@ -0,0 +1,176 @@ +package tui + +import ( + "time" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" +) + +// Update handles messages and updates the model. +func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + return m.handleKeyMsg(msg) + + case tea.MouseMsg: + // Forward mouse events to viewport for scrolling + var cmd tea.Cmd + m.Viewport, cmd = m.Viewport.Update(msg) + return m, cmd + + case tea.WindowSizeMsg: + return m.handleWindowSizeMsg(msg) + + case typewriterTickMsg: + return m.handleTypewriterTick() + + case thinkingCheckMsg: + return m.handleThinkingCheck() + + case spinner.TickMsg: + var cmd tea.Cmd + m.Spinner, cmd = m.Spinner.Update(msg) + return m, cmd + + case CompleteMsg: + m.ExitCode = msg.ExitCode + m.Quitting = true + return m, tea.Quit + + default: + // Handle custom event messages from the adapter + return m.handleEventMsg(msg) + } + + return m, tea.Batch(cmds...) +} + +// handleKeyMsg handles keyboard input. +func (m *Model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "ctrl+c", "q": + m.Quitting = true + m.Cancel() + return m, tea.Quit + default: + // Forward to viewport for any scrolling keys + var cmd tea.Cmd + m.Viewport, cmd = m.Viewport.Update(msg) + return m, cmd + } +} + +// handleWindowSizeMsg handles terminal resize events. +func (m *Model) handleWindowSizeMsg(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) { + m.Width = msg.Width + m.Height = msg.Height + + // Reserve 2 lines for header + viewportHeight := msg.Height - 2 + if viewportHeight < 1 { + viewportHeight = 1 + } + + m.Viewport.Width = msg.Width + m.Viewport.Height = viewportHeight + + // Re-render content + m.Viewport.SetContent(m.renderContent()) + + return m, nil +} + +// handleTypewriterTick processes typewriter animation. +func (m *Model) handleTypewriterTick() (tea.Model, tea.Cmd) { + if !m.Typewriter.Active || len(m.Typewriter.Pending) == 0 { + return m, typewriterCmd() + } + + // Display 3-5 characters per tick for efficiency + batchSize := 4 + if len(m.Typewriter.Pending) < batchSize { + batchSize = len(m.Typewriter.Pending) + } + + m.Typewriter.Displayed += batchSize + m.Typewriter.Pending = m.Typewriter.Pending[batchSize:] + + // Update the current section's rendered content + if m.CurrentSection != nil { + m.CurrentSection.Rendered = string([]rune(m.Typewriter.Buffer)[:m.Typewriter.Displayed]) + m.Viewport.SetContent(m.renderContent()) + m.Viewport.GotoBottom() + } + + return m, typewriterCmd() +} + +// handleThinkingCheck shows thinking spinner after inactivity. +func (m *Model) handleThinkingCheck() (tea.Model, tea.Cmd) { + // Show thinking spinner after 500ms of inactivity + if !m.Thinking && m.Typewriter.Active && len(m.Typewriter.Pending) == 0 { + inactive := time.Since(m.LastActivity) + if inactive > 500*time.Millisecond { + m.Thinking = true + m.ThinkText = "Claude is thinking..." + } + } + + return m, thinkingDetectorCmd() +} + +// Update handles the custom event messages from the adapter +func (m *Model) handleEventMsg(msg tea.Msg) (tea.Model, tea.Cmd) { + switch e := msg.(type) { + case TextEvent: + m.AppendText(e.Text) + m.LastActivity = time.Now() + m.Viewport.SetContent(m.renderContent()) + m.Viewport.GotoBottom() + return m, typewriterCmd() + + case ToolUseEvent: + m.CompleteText() + m.AddToolUseSection(e.Name, e.Description, e.Command, e.FilePath) + m.LastActivity = time.Now() + m.Viewport.SetContent(m.renderContent()) + m.Viewport.GotoBottom() + + case ToolResultEvent: + m.AddToolResultSection(e.Stdout, e.Stderr) + m.LastActivity = time.Now() + m.Viewport.SetContent(m.renderContent()) + m.Viewport.GotoBottom() + + case StepStartMsg: + m.SetStep(e.Step, e.Total, e.StepName) + // Add step divider + if e.Step > 1 { + m.Sections = append(m.Sections, OutputSection{ + ID: "divider", + Type: SectionDivider, + Content: "", + }) + } + + case SessionStartEvent: + m.StartTime = time.Now() + + case SessionCompleteEvent: + m.CompleteText() + m.Thinking = false + } + + return m, nil +} + +// Quit requests the TUI to quit with the given exit code. +func (m *Model) Quit(exitCode int) tea.Cmd { + m.ExitCode = exitCode + return func() tea.Msg { + return CompleteMsg{ExitCode: exitCode} + } +} diff --git a/internal/tui/view.go b/internal/tui/view.go new file mode 100644 index 0000000..90ef8fe --- /dev/null +++ b/internal/tui/view.go @@ -0,0 +1,232 @@ +package tui + +import ( + "fmt" + "strings" + + "bmaduum/internal/tui/styles" + + "github.com/charmbracelet/lipgloss" +) + +// View renders the TUI. +func (m *Model) View() string { + if m.Quitting { + return "" + } + + var sb strings.Builder + + // Render header + sb.WriteString(m.renderHeader()) + sb.WriteString("\n") + + // Render viewport content + sb.WriteString(m.Viewport.View()) + + return sb.String() +} + +// renderHeader renders the status header bar. +func (m *Model) renderHeader() string { + width := m.Width + if width == 0 { + width = 80 + } + + // Build header sections + var leftParts []string + leftParts = append(leftParts, styles.Symbols.HeaderLogo+" bmaduum") + + if m.StepName != "" { + stepInfo := fmt.Sprintf("Step %d/%d: %s", m.CurrentStep, m.TotalSteps, m.StepName) + leftParts = append(leftParts, stepInfo) + } + + if m.StoryKey != "" { + leftParts = append(leftParts, m.StoryKey) + } + + var rightParts []string + if m.ModelName != "" { + rightParts = append(rightParts, m.ModelName) + } + rightParts = append(rightParts, styles.Symbols.Clock+" "+m.ElapsedTime()) + + // Join sections + leftContent := strings.Join(leftParts, " │ ") + rightContent := strings.Join(rightParts, " │ ") + + // Calculate padding + separator := " │ " + totalContentLen := lipgloss.Width(leftContent) + lipgloss.Width(separator) + lipgloss.Width(rightContent) + padding := width - totalContentLen + + if padding < 1 { + padding = 1 + } + + // Build full header line + fullLine := leftContent + strings.Repeat(" ", padding) + rightContent + + // Apply header style + headerStyle := lipgloss.NewStyle(). + Background(lipgloss.Color(styles.ClaudeColors.Primary)). + Foreground(lipgloss.Color("#FFFFFF")). + Bold(true). + Width(width) + + return headerStyle.Render(fullLine) +} + +// renderContent renders all output sections. +func (m *Model) renderContent() string { + var sb strings.Builder + + for _, section := range m.Sections { + switch section.Type { + case SectionText: + sb.WriteString(m.renderTextSection(section)) + case SectionToolUse: + sb.WriteString(m.renderToolUseSection(section)) + case SectionToolResult: + sb.WriteString(m.renderToolResultSection(section)) + case SectionDivider: + sb.WriteString(m.renderDivider()) + } + sb.WriteString("\n") + } + + // Add thinking spinner if active + if m.Thinking { + sb.WriteString("\n") + spinnerLine := m.Spinner.View() + " " + styles.Default.TextMuted.Render(m.ThinkText) + sb.WriteString(spinnerLine) + } + + return sb.String() +} + +// renderTextSection renders a text content section. +func (m *Model) renderTextSection(section OutputSection) string { + content := section.Rendered + if content == "" && section.Content != "" { + content = section.Content + } + + // Wrap text to viewport width minus some margin + width := m.Width - 4 + if width < 40 { + width = 40 + } + + lines := wrapText(content, width) + return strings.Join(lines, "\n") +} + +// renderToolUseSection renders a tool invocation section. +func (m *Model) renderToolUseSection(section OutputSection) string { + var sb strings.Builder + + // Tool icon with name + toolIcon := styles.Default.ToolIcon.Render(styles.Symbols.ToolInvocation) + toolName := styles.Default.Text.Render(section.Content) + + sb.WriteString(toolIcon + " " + toolName) + + return sb.String() +} + +// renderToolResultSection renders a tool result section. +func (m *Model) renderToolResultSection(section OutputSection) string { + var sb strings.Builder + + if section.Content == "" { + return "" + } + + // Output icon + outputIcon := styles.Default.OutputIcon.Render(styles.Symbols.ToolOutput) + + // Process lines + lines := strings.Split(section.Content, "\n") + for i, line := range lines { + if i == 0 { + // First line has the icon + sb.WriteString(outputIcon + " " + styles.Default.ToolOutput.Render(line)) + } else { + // Subsequent lines are indented + indent := strings.Repeat(" ", 4) + sb.WriteString(indent + styles.Default.ToolOutput.Render(line)) + } + if i < len(lines)-1 { + sb.WriteString("\n") + } + } + + return sb.String() +} + +// renderDivider renders a step transition divider. +func (m *Model) renderDivider() string { + width := m.Width - 4 + if width < 20 { + width = 20 + } + + line := strings.Repeat(styles.Symbols.Divider, width/2) + divider := "── " + line + " ──" + + return styles.Default.SectionDivider.Render(divider) +} + +// wrapText wraps text to a maximum width. +func wrapText(text string, width int) []string { + var lines []string + paragraphs := strings.Split(text, "\n") + + for _, paragraph := range paragraphs { + if paragraph == "" { + lines = append(lines, "") + continue + } + + words := strings.Fields(paragraph) + var currentLine strings.Builder + + for _, word := range words { + if currentLine.Len()+len(word)+1 > width { + lines = append(lines, currentLine.String()) + currentLine.Reset() + } + if currentLine.Len() > 0 { + currentLine.WriteString(" ") + } + currentLine.WriteString(word) + } + + if currentLine.Len() > 0 { + lines = append(lines, currentLine.String()) + } + } + + return lines +} + +// RenderFinalSummary renders a final summary after the TUI exits. +func (m *Model) RenderFinalSummary() string { + var sb strings.Builder + + success := m.ExitCode == 0 + duration := m.ElapsedTime() + + sb.WriteString("\n") + if success { + sb.WriteString(styles.Default.Success.Render(styles.Symbols.Success + " Session complete")) + } else { + sb.WriteString(styles.Default.Error.Render(styles.Symbols.Error + " Session failed")) + } + sb.WriteString(" (" + duration + ")\n") + + return sb.String() +}