diff --git a/.claude/handoff-debug-changes-view.md b/.claude/handoff-debug-changes-view.md new file mode 100644 index 00000000..a3e0d9a2 --- /dev/null +++ b/.claude/handoff-debug-changes-view.md @@ -0,0 +1,96 @@ +# Handoff: Debug ChangesView escape and d key + +## Problem +In the Crush TUI (bubbletea v2), the ChangesView has two bugs: +1. **Pressing `d` on a change is a visual noop** — the code runs, `InstallPromptMsg` is received by root model, toast cmd is returned, but nothing renders on screen +2. **Pressing `esc` is a noop** — the key reaches `handleKeyPressMsg` with state=4 (uiSmithersView) and key="esc", but PopViewMsg never appears in the debug log, meaning the ChangesView's escape handler at line 500 is not being reached + +## What we know from debug logging (via tmux) + +Debug log output (`/tmp/crush-keys.log` with `CRUSH_DEBUG_KEYS=1`): +``` +KEY state=5 key="6" focus=2 # dashboard, press 6 for Changes tab +KEY state=5 key="enter" focus=2 # dashboard, press Enter +MSG DashboardNavigateMsg view=changes # navigates to ChangesView (works!) +KEY state=4 key="d" focus=2 # in ChangesView, press d +MSG InstallPromptMsg cmd=jjhub change diff --no-color 'xmnvlrl...' # msg IS received +KEY state=4 key="esc" focus=2 # press esc — NO PopViewMsg follows +``` + +State 4 = `uiSmithersView`, State 5 = `uiSmithersDashboard` + +## Bug 1: `d` key — InstallPromptMsg received but toast doesn't show + +The `InstallPromptMsg` handler in `internal/ui/model/ui.go` (around line 1144) creates a `ShowToastMsg` cmd. But the toast never appears. Possible causes: +- `m.toasts` might be nil +- The toast ShowToastMsg might be swallowed by message forwarding before reaching the toast manager +- The toast manager's Update is called at the top of root Update (line ~628) but the ShowToastMsg is returned as a cmd that produces the msg on the NEXT Update cycle — need to verify the msg reaches the toast manager on that next cycle + +## Bug 2: `esc` key — never reaches ChangesView escape handler + +The key goes through `handleKeyPressMsg` → state switch → `uiSmithersView` case → `viewRouter.Update(msg)`. The router calls `ChangesView.Update()`. But the escape handler at `changes.go:500` is not reached. + +**Likely cause**: Look at `changes.go:529-538`. After the key switch (498-528), if no case matches, the code falls through to `v.splitPane.Update(msg)` at line 531-538. The SplitPane's Update handles `tab`/`shift+tab` but forwards ALL other keys to the focused pane. The focused pane (changeListPane or changePreviewPane) might be consuming the escape key. + +**BUT** — the switch at line 499 SHOULD match `"esc"` before we ever reach line 529. Unless `key.Matches` is failing for some reason, or the key arrives as something other than what we expect. + +Debug logging was added at line 473: `debugLogView("ChangesView key=%q ...")` — but the `debugLogView` function doesn't exist yet (was about to add it when the session was interrupted). Add it and rebuild to see if the ChangesView even receives the key. + +## Key files + +- `internal/ui/model/ui.go` — root model, handleKeyPressMsg (line ~2160), main Update switch (line ~644), message forwarding (line ~1230) +- `internal/ui/views/changes.go` — ChangesView.Update (line 441), escape handler (line 499-500), d handler (line 526) +- `internal/ui/views/router.go` — Router.Update forwards to current view +- `internal/ui/components/splitpane.go` — SplitPane.Update handles tab, forwards other keys +- `internal/ui/diffnav/launch.go` — LaunchDiffnavWithCommand returns InstallPromptMsg when diffnav not installed +- `internal/ui/components/toast.go` — ShowToastMsg type and toast manager + +## Debug helper already in place + +`debugLog()` function in `internal/ui/model/ui.go` (around line 2148) writes to `/tmp/crush-keys.log` when `CRUSH_DEBUG_KEYS=1`. + +Need to add equivalent `debugLogView()` in `internal/ui/views/changes.go` (partially added, function not defined yet). + +## How to test (IMPORTANT) + +You CANNOT run the TUI binary directly — bubbletea needs a real PTY. Use tmux: + +```bash +# Build +go build -o tests/smithers-tui . + +# Run in tmux with debug logging +rm -f /tmp/crush-keys.log +SESSION="test-$$" +tmux new-session -d -s "$SESSION" -x 120 -y 40 "export CRUSH_DEBUG_KEYS=1; ./tests/smithers-tui" +sleep 3 + +# Send keys +tmux send-keys -t "$SESSION" "6" # Changes tab +sleep 1 +tmux send-keys -t "$SESSION" Enter # Open ChangesView +sleep 2 +tmux send-keys -t "$SESSION" "d" # Try diff +sleep 1 +tmux send-keys -t "$SESSION" Escape # Try escape +sleep 1 + +# Capture screen +tmux capture-pane -t "$SESSION" -p + +# Read debug log +cat /tmp/crush-keys.log + +# Cleanup +tmux kill-session -t "$SESSION" +``` + +## What to do + +1. Add `debugLogView()` function to `changes.go` (same pattern as `debugLog` in ui.go) +2. Add debug logging at key points in ChangesView.Update to trace exactly where the key goes +3. Rebuild, run via tmux, read the log +4. Fix whatever is swallowing the keys +5. Verify fix via tmux +6. Write the fix as an e2e test using tmux (see CLAUDE.md for pattern) +7. Remove all debug logging when done diff --git a/.claude/skills/bubbletea-designer/.claude-plugin/marketplace.json b/.claude/skills/bubbletea-designer/.claude-plugin/marketplace.json new file mode 100644 index 00000000..d9edf646 --- /dev/null +++ b/.claude/skills/bubbletea-designer/.claude-plugin/marketplace.json @@ -0,0 +1,21 @@ +{ + "name": "bubbletea-designer", + "owner": { + "name": "Agent Creator", + "email": "noreply@example.com" + }, + "metadata": { + "description": "Bubble Tea TUI Design Automation Agent", + "version": "1.0.0", + "created": "2025-10-18" + }, + "plugins": [ + { + "name": "bubbletea-designer-plugin", + "description": "Automates Bubble Tea TUI design by analyzing requirements, mapping to appropriate components from the Charmbracelet ecosystem, generating component architecture, and creating implementation workflows. Use when designing terminal UIs, planning Bubble Tea applications, selecting components, or needing design guidance for TUI development.", + "source": "./", + "strict": false, + "skills": ["./"] + } + ] +} diff --git a/.claude/skills/bubbletea-designer/.claude-plugin/plugin.json b/.claude/skills/bubbletea-designer/.claude-plugin/plugin.json new file mode 100644 index 00000000..fd3c9a25 --- /dev/null +++ b/.claude/skills/bubbletea-designer/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "bubbletea-designer", + "description": "Bubble Tea TUI Design Automation Agent", + "author": { + "name": "Agent Creator", + "email": "noreply@example.com" + } +} diff --git a/.claude/skills/bubbletea-designer/.skillfish.json b/.claude/skills/bubbletea-designer/.skillfish.json new file mode 100644 index 00000000..ffd7dd27 --- /dev/null +++ b/.claude/skills/bubbletea-designer/.skillfish.json @@ -0,0 +1,10 @@ +{ + "version": 2, + "name": "bubbletea-designer", + "owner": "human-frontier-labs-inc", + "repo": "human-frontier-labs-marketplace", + "path": "plugins/bubbletea-designer", + "branch": "master", + "sha": "84dc8d26c0a4351c01f6a1669617607645addb66", + "source": "manual" +} \ No newline at end of file diff --git a/.claude/skills/bubbletea-designer/CHANGELOG.md b/.claude/skills/bubbletea-designer/CHANGELOG.md new file mode 100644 index 00000000..f12dcf86 --- /dev/null +++ b/.claude/skills/bubbletea-designer/CHANGELOG.md @@ -0,0 +1,96 @@ +# Changelog + +All notable changes to Bubble Tea Designer will be documented here. + +Format based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +Versioning follows [Semantic Versioning](https://semver.org/). + +## [1.0.0] - 2025-10-18 + +### Added + +**Core Functionality:** +- `comprehensive_tui_design_report()` - All-in-one design generation +- `extract_requirements()` - Natural language requirement parsing +- `map_to_components()` - Intelligent component selection +- `select_relevant_patterns()` - Example pattern matching +- `design_architecture()` - Architecture generation with diagrams +- `generate_implementation_workflow()` - Step-by-step implementation plans + +**Data Sources:** +- charm-examples-inventory integration (46 examples) +- Component taxonomy with 14 components +- Pattern templates for 5 common archetypes +- Comprehensive keyword database + +**Analysis Capabilities:** +- TUI archetype classification (9 types) +- Feature extraction from descriptions +- Component scoring algorithm (0-100) +- Pattern relevance ranking +- Architecture diagram generation (ASCII) +- Time estimation for implementation + +**Utilities:** +- Inventory loader with automatic path detection +- Component matcher with keyword scoring +- Template generator for Go code scaffolding +- ASCII diagram generator for architecture visualization +- Requirement validator +- Design validator + +**Documentation:** +- Complete SKILL.md (7,200 words) +- Component guide with 14 components +- Design patterns reference (10 patterns) +- Architecture best practices +- Example designs (5 complete examples) +- Installation guide +- Architecture decisions documentation + +### Data Coverage + +**Components Supported:** +- Input: textinput, textarea, filepicker, autocomplete +- Display: viewport, table, list, pager, paginator +- Feedback: spinner, progress, timer, stopwatch +- Navigation: tabs, help +- Layout: lipgloss + +**Archetypes Recognized:** +- file-manager, installer, dashboard, form, viewer +- chat, table-viewer, menu, editor + +**Patterns Available:** +- Single-view, multi-view, master-detail +- Progress tracker, composable views, form flow + +### Known Limitations + +- Requires charm-examples-inventory for full pattern matching (works without but reduced functionality) +- Archetype classification may need refinement for complex hybrid TUIs +- Code scaffolding is basic (Init/Update/View skeletons only) +- No live preview or interactive refinement yet + +### Planned for v2.0 + +- Interactive requirement refinement +- Full code generation (not just scaffolding) +- Custom component definitions +- Integration with Go toolchain (go mod init, etc.) +- Design session save/load +- Live TUI preview + +## [Unreleased] + +### Planned + +- Add support for custom components +- Improve archetype classification accuracy +- Expand pattern library +- Add code completion features +- Performance optimizations for large inventories + +--- + +**Generated with Claude Code agent-creator skill on 2025-10-18** diff --git a/.claude/skills/bubbletea-designer/DECISIONS.md b/.claude/skills/bubbletea-designer/DECISIONS.md new file mode 100644 index 00000000..3dcb33b1 --- /dev/null +++ b/.claude/skills/bubbletea-designer/DECISIONS.md @@ -0,0 +1,158 @@ +# Architecture Decisions + +Documentation of key design decisions for Bubble Tea Designer skill. + +## Data Source Decision + +**Decision**: Use local charm-examples-inventory instead of API +**Rationale**: +- ✅ No rate limits or authentication needed +- ✅ Fast lookups (local file system) +- ✅ Complete control over inventory structure +- ✅ Offline capability +- ✅ Inventory can be updated independently + +**Alternatives Considered**: +- GitHub API: Rate limits, requires authentication +- Web scraping: Fragile, slow, unreliable +- Embedded database: Adds complexity, harder to update + +**Trade-offs**: +- User needs to have inventory locally (optional but recommended) +- Updates require re-cloning repository + +## Analysis Approach + +**Decision**: 6 separate analysis functions + 1 comprehensive orchestrator +**Rationale**: +- ✅ Modularity - each function has single responsibility +- ✅ Testability - easy to test individual components +- ✅ Flexibility - users can call specific analyses +- ✅ Composability - orchestrator combines as needed + +**Structure**: +1. analyze_requirements() - NLP requirement extraction +2. map_components() - Component scoring and selection +3. select_patterns() - Example file matching +4. design_architecture() - Structure generation +5. generate_workflow() - Implementation planning +6. comprehensive_tui_design_report() - All-in-one + +## Component Matching Algorithm + +**Decision**: Keyword-based scoring with manual taxonomy +**Rationale**: +- ✅ Transparent - users can see why components selected +- ✅ Predictable - consistent results +- ✅ Fast - O(n) search with indexing +- ✅ Maintainable - easy to add new components + +**Alternatives Considered**: +- ML-based matching: Overkill, requires training data +- Fuzzy matching: Less accurate for technical terms +- Rule-based expert system: Too rigid + +**Scoring System**: +- Keyword match: 60 points max +- Use case match: 40 points max +- Total: 0-100 score per component + +## Architecture Generation Strategy + +**Decision**: Template-based with customization +**Rationale**: +- ✅ Generates working code immediately +- ✅ Follows Bubble Tea best practices +- ✅ Customizable per archetype +- ✅ Educational - shows proper patterns + +**Templates Include**: +- Model struct with components +- Init() with proper initialization +- Update() skeleton with message routing +- View() with component rendering + +## Validation Strategy + +**Decision**: Multi-layer validation (requirements, components, architecture, workflow) +**Rationale**: +- ✅ Early error detection +- ✅ Quality assurance +- ✅ Helpful feedback to users +- ✅ Catches incomplete designs + +**Validation Levels**: +- CRITICAL: Must fix (empty description, no components) +- WARNING: Should review (low coverage, many components) +- INFO: Optional improvements + +## File Organization + +**Decision**: Modular scripts with shared utilities +**Rationale**: +- ✅ Clear separation of concerns +- ✅ Reusable utilities +- ✅ Easy to test +- ✅ Maintainable codebase + +**Structure**: +``` +scripts/ + main analysis scripts (6) + utils/ + shared utilities + validators/ + validation logic +``` + +## Pattern Matching Approach + +**Decision**: Inventory-based with ranking +**Rationale**: +- ✅ Leverages existing examples +- ✅ Provides concrete references +- ✅ Study order optimization +- ✅ Realistic time estimates + +**Ranking Factors**: +- Component usage overlap +- Complexity match +- Code quality/clarity + +## Documentation Strategy + +**Decision**: Comprehensive references with patterns and best practices +**Rationale**: +- ✅ Educational value +- ✅ Self-contained skill +- ✅ Reduces external documentation dependency +- ✅ Examples for every pattern + +**References Created**: +- Component guide (what each component does) +- Design patterns (common architectures) +- Best practices (dos and don'ts) +- Example designs (complete real-world cases) + +## Performance Considerations + +**Optimizations**: +- Inventory loaded once, cached in memory +- Pre-computed component taxonomy +- Fast keyword matching (no regex) +- Minimal allocations in hot paths + +**Trade-offs**: +- Memory usage: ~5MB for loaded inventory +- Startup time: ~100ms for inventory loading +- Analysis time: <1 second for complete report + +## Future Enhancements + +Potential improvements for v2.0: +- Interactive mode for requirement refinement +- Code generation (full implementation, not just scaffolding) +- Live preview of designs +- Integration with Go module initialization +- Custom component definitions +- Save/load design sessions diff --git a/.claude/skills/bubbletea-designer/INSTALLATION.md b/.claude/skills/bubbletea-designer/INSTALLATION.md new file mode 100644 index 00000000..c0c00be9 --- /dev/null +++ b/.claude/skills/bubbletea-designer/INSTALLATION.md @@ -0,0 +1,109 @@ +# Installation Guide + +Step-by-step installation for Bubble Tea Designer skill. + +## Prerequisites + +- Claude Code CLI installed +- Python 3.8+ +- charm-examples-inventory (optional but recommended) + +## Installation + +### Step 1: Install the Skill + +```bash +/plugin marketplace add /path/to/bubbletea-designer +``` + +Or if you're in the directory containing bubbletea-designer: + +```bash +/plugin marketplace add ./bubbletea-designer +``` + +### Step 2: Verify Installation + +The skill should now be active. Test it with: + +``` +"Design a simple TUI for viewing log files" +``` + +You should see Claude activate the skill and generate a design report. + +## Optional: Install charm-examples-inventory + +For full pattern matching capabilities: + +```bash +cd ~/charmtuitemplate/vinw # Or your preferred location +git clone https://github.com/charmbracelet/bubbletea charm-examples-inventory +``` + +The skill will automatically search common locations: +- `./charm-examples-inventory` +- `../charm-examples-inventory` +- `~/charmtuitemplate/vinw/charm-examples-inventory` + +## Verification + +Run test scripts to verify everything works: + +```bash +cd /path/to/bubbletea-designer +python3 scripts/analyze_requirements.py +python3 scripts/map_components.py +``` + +You should see test outputs with ✅ marks indicating success. + +## Troubleshooting + +### Skill Not Activating + +**Issue**: Skill doesn't activate when you mention Bubble Tea +**Solution**: +- Check skill is installed: `/plugin list` +- Try explicit keywords: "design a bubbletea TUI" +- Restart Claude Code + +### Inventory Not Found + +**Issue**: "Cannot locate charm-examples-inventory" +**Solution**: +- Install inventory to a standard location (see Step 2 above) +- Or specify custom path when needed +- Skill works without inventory but with reduced pattern matching + +### Import Errors + +**Issue**: Python import errors when running scripts +**Solution**: +- Verify Python 3.8+ installed: `python3 --version` +- Scripts use relative imports, run from project directory + +## Usage + +Once installed, activate by mentioning: +- "Design a TUI for..." +- "Create a Bubble Tea interface..." +- "Which components should I use for..." +- "Plan architecture for a terminal UI..." + +The skill activates automatically and generates comprehensive design reports. + +## Uninstallation + +To remove the skill: + +```bash +/plugin marketplace remove bubbletea-designer +``` + +## Next Steps + +- Read SKILL.md for complete documentation +- Try example queries from README.md +- Explore references/ for design patterns +- Study generated designs for your use cases diff --git a/.claude/skills/bubbletea-designer/README.md b/.claude/skills/bubbletea-designer/README.md new file mode 100644 index 00000000..2a7b8f82 --- /dev/null +++ b/.claude/skills/bubbletea-designer/README.md @@ -0,0 +1,174 @@ +# Bubble Tea TUI Designer + +Automate the design process for Bubble Tea terminal user interfaces with intelligent component mapping, architecture generation, and implementation planning. + +## What It Does + +This skill helps you design Bubble Tea TUIs by: + +1. **Analyzing requirements** from natural language descriptions +2. **Mapping to components** from the Charmbracelet ecosystem +3. **Generating architecture** with component hierarchy and message flow +4. **Creating workflows** with step-by-step implementation plans +5. **Providing scaffolding** with boilerplate code to get started + +## Features + +- ✅ Intelligent component selection based on requirements +- ✅ Pattern matching against 46 Bubble Tea examples +- ✅ ASCII architecture diagrams +- ✅ Complete implementation workflows +- ✅ Code scaffolding generation +- ✅ Design validation and suggestions + +## Installation + +```bash +/plugin marketplace add ./bubbletea-designer +``` + +## Quick Start + +Simply describe your TUI and the skill will generate a complete design: + +``` +"Design a log viewer with search and highlighting" +``` + +The skill will automatically: +- Classify it as a "viewer" archetype +- Select viewport.Model and textinput.Model +- Generate architecture diagram +- Create step-by-step implementation workflow +- Provide code scaffolding + +## Usage Examples + +### Example 1: Simple Log Viewer +``` +"Build a TUI for viewing log files with search" +``` + +### Example 2: File Manager +``` +"Create a file manager with three-column view showing parent directory, current directory, and file preview" +``` + +### Example 3: Package Installer +``` +"Design an installer UI with progress bars for sequential package installation" +``` + +### Example 4: Configuration Wizard +``` +"Build a multi-step configuration wizard with form validation" +``` + +## How It Works + +The designer follows a systematic process: + +1. **Requirement Analysis**: Extract structured requirements from your description +2. **Component Mapping**: Match requirements to Bubble Tea components +3. **Pattern Selection**: Find relevant examples from inventory +4. **Architecture Design**: Create component hierarchy and message flow +5. **Workflow Generation**: Generate ordered implementation steps +6. **Design Report**: Combine all analyses into comprehensive document + +## Output Structure + +The comprehensive design report includes: + +- **Executive Summary**: TUI type, components, time estimate +- **Requirements**: Parsed features, interactions, data types +- **Components**: Selected components with justifications +- **Patterns**: Relevant example files to study +- **Architecture**: Model struct, diagrams, message handlers +- **Workflow**: Phase-by-phase implementation plan +- **Code Scaffolding**: Basic main.go template +- **Next Steps**: What to do first + +## Dependencies + +The skill references the charm-examples-inventory for pattern matching. + +Default search locations: +- `./charm-examples-inventory` +- `../charm-examples-inventory` +- `~/charmtuitemplate/vinw/charm-examples-inventory` + +You can also specify a custom path: +```python +report = comprehensive_tui_design_report( + "your description", + inventory_path="/custom/path/to/inventory" +) +``` + +## Testing + +Run the comprehensive test suite: + +```bash +cd bubbletea-designer/tests +python3 test_integration.py +``` + +Individual script tests: +```bash +python3 scripts/analyze_requirements.py +python3 scripts/map_components.py +python3 scripts/design_tui.py "Build a log viewer" +``` + +## Files Structure + +``` +bubbletea-designer/ +├── SKILL.md # Skill documentation +├── scripts/ +│ ├── design_tui.py # Main orchestrator +│ ├── analyze_requirements.py +│ ├── map_components.py +│ ├── select_patterns.py +│ ├── design_architecture.py +│ ├── generate_workflow.py +│ └── utils/ +│ ├── inventory_loader.py +│ ├── component_matcher.py +│ ├── template_generator.py +│ ├── ascii_diagram.py +│ └── validators/ +├── references/ +│ ├── bubbletea-components-guide.md +│ ├── design-patterns.md +│ ├── architecture-best-practices.md +│ └── example-designs.md +├── assets/ +│ ├── component-taxonomy.json +│ ├── pattern-templates.json +│ └── keywords.json +└── tests/ + └── test_integration.py +``` + +## Resources + +- [Bubble Tea Documentation](https://github.com/charmbracelet/bubbletea) +- [Lipgloss Styling](https://github.com/charmbracelet/lipgloss) +- [Bubbles Components](https://github.com/charmbracelet/bubbles) +- [Charm Community](https://charm.sh/chat) + +## License + +MIT + +## Contributing + +Contributions welcome! This is an automated agent created by the agent-creator skill. + +## Version + +1.0.0 - Initial release + +**Generated with Claude Code agent-creator skill** diff --git a/.claude/skills/bubbletea-designer/SKILL.md b/.claude/skills/bubbletea-designer/SKILL.md new file mode 100644 index 00000000..5c1bb363 --- /dev/null +++ b/.claude/skills/bubbletea-designer/SKILL.md @@ -0,0 +1,1537 @@ +--- +name: bubbletea-designer +description: Automates Bubble Tea TUI design by analyzing requirements, mapping to appropriate components from the Charmbracelet ecosystem, generating component architecture, and creating implementation workflows. Use when designing terminal UIs, planning Bubble Tea applications, selecting components, or needing design guidance for TUI development. +--- + +# Bubble Tea TUI Designer + +Automate the design process for Bubble Tea terminal user interfaces with intelligent component mapping, architecture generation, and implementation planning. + +## When to Use This Skill + +This skill automatically activates when you need help designing, planning, or structuring Bubble Tea TUI applications: + +### Design & Planning + +Use this skill when you: +- **Design a new TUI application** from requirements +- **Plan component architecture** for terminal interfaces +- **Select appropriate Bubble Tea components** for your use case +- **Generate implementation workflows** with step-by-step guides +- **Map user requirements to Charmbracelet ecosystem** components + +### Typical Activation Phrases + +The skill responds to questions like: +- "Design a TUI for [use case]" +- "Create a file manager interface" +- "Build an installation progress tracker" +- "Which Bubble Tea components should I use for [feature]?" +- "Plan a multi-view dashboard TUI" +- "Generate architecture for a configuration wizard" +- "Automate TUI design for [application]" + +### TUI Types Supported + +- **File Managers**: Navigation, selection, preview +- **Installers/Package Managers**: Progress tracking, step indication +- **Dashboards**: Multi-view, tabs, real-time updates +- **Forms & Wizards**: Multi-step input, validation +- **Data Viewers**: Tables, lists, pagination +- **Log/Text Viewers**: Scrolling, searching, highlighting +- **Chat Interfaces**: Input + message display +- **Configuration Tools**: Interactive settings +- **Monitoring Tools**: Real-time data, charts +- **Menu Systems**: Selection, navigation + +## How It Works + +The Bubble Tea Designer follows a systematic 6-step design process: + +### 1. Requirement Analysis + +**Purpose**: Extract structured requirements from natural language descriptions + +**Process**: +- Parse user description +- Identify core features +- Extract interaction patterns +- Determine data types +- Classify TUI archetype + +**Output**: Structured requirements dictionary with: +- Features list +- Interaction types (keyboard, mouse, both) +- Data types (files, text, tabular, streaming) +- View requirements (single, multi-view, tabs) +- Special requirements (validation, progress, real-time) + +### 2. Component Mapping + +**Purpose**: Map requirements to appropriate Bubble Tea components + +**Process**: +- Match features to component capabilities +- Consider component combinations +- Evaluate alternatives +- Justify selections based on requirements + +**Output**: Component recommendations with: +- Primary components (core functionality) +- Supporting components (enhancements) +- Styling components (Lipgloss) +- Justification for each selection +- Alternative options considered + +### 3. Pattern Selection + +**Purpose**: Identify relevant example files from charm-examples-inventory + +**Process**: +- Search CONTEXTUAL-INVENTORY.md for matching patterns +- Filter by capability category +- Rank by relevance to requirements +- Select 3-5 most relevant examples + +**Output**: List of example files to reference: +- File path in charm-examples-inventory +- Capability category +- Key patterns to extract +- Specific lines or functions to study + +### 4. Architecture Design + +**Purpose**: Create component hierarchy and interaction model + +**Process**: +- Design model structure (what state to track) +- Plan Init() function (initialization commands) +- Design Update() function (message handling) +- Plan View() function (rendering strategy) +- Create component composition diagram + +**Output**: Architecture specification with: +- Model struct definition +- Component hierarchy (ASCII diagram) +- Message flow diagram +- State management plan +- Rendering strategy + +### 5. Workflow Generation + +**Purpose**: Create ordered implementation steps + +**Process**: +- Determine dependency order +- Break into logical phases +- Reference specific example files +- Include testing checkpoints + +**Output**: Step-by-step implementation plan: +- Phase breakdown (setup, components, integration, polish) +- Ordered tasks with dependencies +- File references for each step +- Testing milestones +- Estimated time per phase + +### 6. Comprehensive Design Report + +**Purpose**: Generate complete design document combining all analyses + +**Process**: +- Execute all 5 previous analyses +- Combine into unified document +- Add implementation guidance +- Include code scaffolding templates +- Generate README outline + +**Output**: Complete TUI design specification with: +- Executive summary +- All analysis results (requirements, components, patterns, architecture, workflow) +- Code scaffolding (model struct, basic Init/Update/View) +- File structure recommendation +- Next steps and resources + +## Data Source: Charm Examples Inventory + +This skill references a curated inventory of 46 Bubble Tea examples from the Charmbracelet ecosystem. + +### Inventory Structure + +**Location**: `charm-examples-inventory/bubbletea/examples/` + +**Index File**: `CONTEXTUAL-INVENTORY.md` + +**Categories** (11 capability groups): +1. Installation & Progress Tracking +2. Form Input & Validation +3. Data Display & Selection +4. Content Viewing +5. View Management & Navigation +6. Loading & Status Indicators +7. Time-Based Operations +8. Network & External Operations +9. Real-Time & Event Handling +10. Screen & Terminal Management +11. Input & Interaction + +### Component Coverage + +**Input Components**: +- `textinput` - Single-line text input +- `textarea` - Multi-line text editing +- `textinputs` - Multiple inputs with focus management +- `filepicker` - File system navigation and selection +- `autocomplete` - Text input with suggestions + +**Display Components**: +- `table` - Tabular data with row selection +- `list` - Filterable, paginated lists +- `viewport` - Scrollable content area +- `pager` - Document viewer +- `paginator` - Page-based navigation + +**Feedback Components**: +- `spinner` - Loading indicator +- `progress` - Progress bar (animated & static) +- `timer` - Countdown timer +- `stopwatch` - Elapsed time tracker + +**Layout Components**: +- `views` - Multiple screen states +- `composable-views` - Composed bubble models +- `tabs` - Tab-based navigation +- `help` - Help menu system + +**Utility Patterns**: +- HTTP requests (`http`) +- External commands (`exec`) +- Real-time events (`realtime`) +- Alt screen buffer (`altscreen-toggle`) +- Mouse support (`mouse`) +- Window resize (`window-size`) + +### Pattern Recognition + +The skill uses pattern matching to identify: + +**By Feature**: +- "progress tracking" → `progress`, `spinner`, `package-manager` +- "form with validation" → `credit-card-form`, `textinputs` +- "table display" → `table`, `table-resize` +- "file selection" → `file-picker`, `list-default` +- "multi-step process" → `views`, `package-manager` + +**By Interaction**: +- "keyboard navigation" → Most examples, especially `help` +- "mouse support" → `mouse`, `table` +- "scrolling" → `viewport`, `pager` +- "filtering" → `list-default`, `autocomplete` + +**By Architecture**: +- "single view" → `simple`, `spinner`, `textinput` +- "multi-view state machine" → `views` +- "composed models" → `composable-views` +- "tab navigation" → `tabs` + +## Workflow Details + +### Workflow 1: Quick Component Lookup + +**User Query**: "Which component should I use for [feature]?" + +**Process**: +1. Parse feature description +2. Search component taxonomy +3. Return top 3 matches with justification + +**Example**: +``` +User: "Which component for file selection?" + +Response: +Top recommendations: +1. filepicker.Model - Dedicated file system navigation + - Built-in directory traversal + - File type filtering + - Selection callbacks + Reference: examples/file-picker/main.go + +2. list.Model - Custom file list + - More control over display + - Custom item rendering + - Can add file metadata + Reference: examples/list-fancy/main.go + +3. table.Model - Tabular file display + - Show multiple file attributes + - Sortable columns + - Good for detailed views + Reference: examples/table/main.go +``` + +### Workflow 2: Design from Description + +**User Query**: "Design a TUI for [application]" + +**Process**: +1. Run `requirement_analysis(description)` +2. Run `component_mapping(requirements)` +3. Run `pattern_selection(components)` +4. Run `architecture_design(components, patterns)` +5. Run `workflow_generation(architecture)` +6. Combine into design report + +**Example**: +``` +User: "Design a TUI for a log file viewer with search and highlighting" + +Response: [Comprehensive design report including]: + +REQUIREMENTS: +- View: Scrollable text display +- Interaction: Search, highlight matches, navigate results +- Data: Log file lines (streaming or loaded) + +COMPONENTS: +- viewport.Model (scrollable display) +- textinput.Model (search query) +- Custom highlighter (Lipgloss styles) + +ARCHITECTURE: +model struct { + viewport viewport.Model + searchInput textinput.Model + logLines []string + searchMode bool + matches []int + currentMatch int +} + +WORKFLOW: +Phase 1: Basic viewer (viewport + file loading) +Phase 2: Add search input +Phase 3: Implement highlighting +Phase 4: Add navigation between matches +Phase 5: Polish (status bar, help text) + +[Full implementation steps with file references...] +``` + +### Workflow 3: Architecture Planning + +**User Query**: "Plan architecture for [complex TUI]" + +**Process**: +1. Identify sub-components +2. Design model composition +3. Create message flow diagram +4. Plan state management +5. Generate ASCII architecture diagram + +**Example**: +``` +User: "Plan architecture for a package manager TUI with installation queue and progress" + +Response: + +ARCHITECTURE DIAGRAM: +┌─────────────────────────────────────┐ +│ Main Model │ +├─────────────────────────────────────┤ +│ view: currentView │ +│ packages: []PackageItem │ +│ queue: InstallQueue │ +│ progress: ProgressState │ +└────────────┬───────────────┬────────┘ + │ │ + ┌────▼────┐ ┌───▼─────┐ + │ List │ │Progress │ + │ Model │ │ Model │ + └─────────┘ └─────────┘ + +STATE MANAGEMENT: +- currentView: enum (selectView, installView, completeView) +- packages: list of selectable items +- queue: ordered installation queue +- progress: current package progress (0-100) + +MESSAGE FLOW: +User Input → Update() → Route to current view handler +Install Start → Switch view + Init progress +Package Complete → Update progress + Next package +All Complete → Switch to complete view + +[Detailed implementation guidance...] +``` + +### Workflow 4: Pattern Selection + +**User Query**: "Show me examples for [capability]" + +**Process**: +1. Search CONTEXTUAL-INVENTORY.md +2. Filter by capability +3. Return sorted by relevance +4. Include key patterns to study + +**Example**: +``` +User: "Show me examples for progress tracking" + +Response: + +PROGRESS TRACKING EXAMPLES: + +1. package-manager (examples/package-manager/main.go) + Category: Installation & Progress Tracking + Key Patterns: + - Combined spinner + progress bar + - Sequential task execution + - Tea.Println for persistent output above TUI + - Custom completion messages + +2. progress-animated (examples/progress-animated/main.go) + Category: Loading & Status Indicators + Key Patterns: + - Gradient progress styling + - Smooth animation with FrameMsg + - Indeterminate/determinate modes + +3. progress-download (examples/progress-download/main.go) + Category: Loading & Status Indicators + Key Patterns: + - Network operation tracking + - Real-time percentage updates + - HTTP integration + +Study these in order: +1. progress-animated (learn basics) +2. package-manager (see real-world usage) +3. progress-download (network-specific) +``` + +## Available Scripts + +All scripts are in `scripts/` directory and can be run independently or through the main orchestrator. + +### Main Orchestrator + +**`design_tui.py`** + +Comprehensive design report generator - combines all analyses. + +**Usage**: +```python +from scripts.design_tui import comprehensive_tui_design_report + +report = comprehensive_tui_design_report( + description="Log viewer with search and highlighting", + inventory_path="/path/to/charm-examples-inventory" +) + +print(report['summary']) +print(report['architecture']) +print(report['workflow']) +``` + +**Parameters**: +- `description` (str): Natural language TUI description +- `inventory_path` (str): Path to charm-examples-inventory directory +- `include_sections` (List[str], optional): Which sections to include +- `detail_level` (str): "summary" | "detailed" | "complete" + +**Returns**: +```python +{ + 'description': str, + 'generated_at': str (ISO timestamp), + 'sections': { + 'requirements': {...}, + 'components': {...}, + 'patterns': {...}, + 'architecture': {...}, + 'workflow': {...} + }, + 'summary': str, + 'scaffolding': str (code template), + 'next_steps': List[str] +} +``` + +### Analysis Scripts + +**`analyze_requirements.py`** + +Extract structured requirements from natural language. + +**Functions**: +- `extract_requirements(description)` - Parse description +- `classify_tui_type(requirements)` - Determine archetype +- `identify_interactions(requirements)` - Find interaction patterns + +**`map_components.py`** + +Map requirements to Bubble Tea components. + +**Functions**: +- `map_to_components(requirements, inventory)` - Main mapping +- `find_alternatives(component)` - Alternative suggestions +- `justify_selection(component, requirement)` - Explain choice + +**`select_patterns.py`** + +Select relevant example files from inventory. + +**Functions**: +- `search_inventory(capability, inventory)` - Search by capability +- `rank_by_relevance(examples, requirements)` - Relevance scoring +- `extract_key_patterns(example_file)` - Identify key code patterns + +**`design_architecture.py`** + +Generate component architecture and structure. + +**Functions**: +- `design_model_struct(components)` - Create model definition +- `plan_message_handlers(interactions)` - Design Update() logic +- `generate_architecture_diagram(structure)` - ASCII diagram + +**`generate_workflow.py`** + +Create ordered implementation steps. + +**Functions**: +- `break_into_phases(architecture)` - Phase planning +- `order_tasks_by_dependency(tasks)` - Dependency sorting +- `estimate_time(task)` - Time estimation +- `generate_workflow_document(phases)` - Formatted output + +### Utility Scripts + +**`utils/inventory_loader.py`** + +Load and parse the examples inventory. + +**Functions**: +- `load_inventory(path)` - Load CONTEXTUAL-INVENTORY.md +- `parse_inventory_markdown(content)` - Parse structure +- `build_capability_index(inventory)` - Index by capability +- `search_by_keyword(keyword, inventory)` - Keyword search + +**`utils/component_matcher.py`** + +Component matching and scoring logic. + +**Functions**: +- `match_score(requirement, component)` - Relevance score +- `find_best_match(requirements, components)` - Top match +- `suggest_combinations(requirements)` - Component combos + +**`utils/template_generator.py`** + +Generate code templates and scaffolding. + +**Functions**: +- `generate_model_struct(components)` - Model struct code +- `generate_init_function(components)` - Init() implementation +- `generate_update_skeleton(messages)` - Update() skeleton +- `generate_view_skeleton(layout)` - View() skeleton + +**`utils/ascii_diagram.py`** + +Create ASCII architecture diagrams. + +**Functions**: +- `draw_component_tree(structure)` - Tree diagram +- `draw_message_flow(flow)` - Flow diagram +- `draw_state_machine(states)` - State diagram + +### Validator Scripts + +**`utils/validators/requirement_validator.py`** + +Validate requirement extraction quality. + +**Functions**: +- `validate_description_clarity(description)` - Check clarity +- `validate_requirements_completeness(requirements)` - Completeness +- `suggest_clarifications(requirements)` - Ask for missing info + +**`utils/validators/design_validator.py`** + +Validate design outputs. + +**Functions**: +- `validate_component_selection(components, requirements)` - Check fit +- `validate_architecture(architecture)` - Structural validation +- `validate_workflow_completeness(workflow)` - Ensure all steps + +## Available Analyses + +### 1. Requirement Analysis + +**Function**: `extract_requirements(description)` + +**Purpose**: Convert natural language to structured requirements + +**Methodology**: +1. Tokenize description +2. Extract nouns (features, data types) +3. Extract verbs (interactions, actions) +4. Identify patterns (multi-view, progress, etc.) +5. Classify TUI archetype + +**Output Structure**: +```python +{ + 'archetype': str, # file-manager, installer, dashboard, etc. + 'features': List[str], # [navigation, selection, preview, ...] + 'interactions': { + 'keyboard': List[str], # [arrow keys, enter, search, ...] + 'mouse': List[str] # [click, drag, ...] + }, + 'data_types': List[str], # [files, text, tabular, streaming, ...] + 'views': str, # single, multi, tabbed + 'special_requirements': List[str] # [validation, progress, real-time, ...] +} +``` + +**Interpretation**: +- Archetype determines recommended starting template +- Features map directly to component selection +- Interactions affect component configuration +- Data types influence model structure + +**Validations**: +- Description not empty +- At least 1 feature identified +- Archetype successfully classified + +### 2. Component Mapping + +**Function**: `map_to_components(requirements, inventory)` + +**Purpose**: Map requirements to specific Bubble Tea components + +**Methodology**: +1. Match features to component capabilities +2. Score each component by relevance (0-100) +3. Select top matches (score > 70) +4. Identify component combinations +5. Provide alternatives for each selection + +**Output Structure**: +```python +{ + 'primary_components': [ + { + 'component': 'viewport.Model', + 'score': 95, + 'justification': 'Scrollable display for log content', + 'example_file': 'examples/pager/main.go', + 'key_patterns': ['viewport scrolling', 'content loading'] + } + ], + 'supporting_components': [...], + 'styling': ['lipgloss for highlighting'], + 'alternatives': { + 'viewport.Model': ['pager package', 'custom viewport'] + } +} +``` + +**Scoring Criteria**: +- Feature coverage: Does component provide required features? +- Complexity match: Is component appropriate for requirement complexity? +- Common usage: Is this the typical choice for this use case? +- Ecosystem fit: Does it work well with other selected components? + +**Validations**: +- At least 1 component selected +- All requirements covered by components +- No conflicting components + +### 3. Pattern Selection + +**Function**: `select_relevant_patterns(components, inventory)` + +**Purpose**: Find most relevant example files to study + +**Methodology**: +1. Search inventory by component usage +2. Filter by capability category +3. Rank by pattern complexity (simple → complex) +4. Select 3-5 most relevant +5. Extract specific code patterns to study + +**Output Structure**: +```python +{ + 'examples': [ + { + 'file': 'examples/pager/main.go', + 'capability': 'Content Viewing', + 'relevance_score': 90, + 'key_patterns': [ + 'viewport.Model initialization', + 'content scrolling (lines 45-67)', + 'keyboard navigation (lines 80-95)' + ], + 'study_order': 1, + 'estimated_study_time': '15 minutes' + } + ], + 'recommended_study_order': [1, 2, 3], + 'total_study_time': '45 minutes' +} +``` + +**Ranking Factors**: +- Component usage match +- Complexity appropriate to skill level +- Code quality and clarity +- Completeness of example + +**Validations**: +- At least 2 examples selected +- Examples cover all selected components +- Study order is logical (simple → complex) + +### 4. Architecture Design + +**Function**: `design_architecture(components, patterns, requirements)` + +**Purpose**: Create complete component architecture + +**Methodology**: +1. Design model struct (state to track) +2. Plan Init() (initialization) +3. Design Update() message handling +4. Plan View() rendering +5. Create component hierarchy diagram +6. Design message flow + +**Output Structure**: +```python +{ + 'model_struct': str, # Go code + 'init_logic': str, # Initialization steps + 'message_handlers': { + 'tea.KeyMsg': str, # Keyboard handling + 'tea.WindowSizeMsg': str, # Resize handling + # Custom messages... + }, + 'view_logic': str, # Rendering strategy + 'diagrams': { + 'component_hierarchy': str, # ASCII tree + 'message_flow': str, # Flow diagram + 'state_machine': str # State transitions (if multi-view) + } +} +``` + +**Design Patterns Applied**: +- **Single Responsibility**: Each component handles one concern +- **Composition**: Complex UIs built from simple components +- **Message Passing**: All communication via tea.Msg +- **Elm Architecture**: Model-Update-View separation + +**Validations**: +- Model struct includes all component instances +- All user interactions have message handlers +- View logic renders all components +- No circular dependencies + +### 5. Workflow Generation + +**Function**: `generate_implementation_workflow(architecture, patterns)` + +**Purpose**: Create step-by-step implementation plan + +**Methodology**: +1. Break into phases (Setup, Core, Polish, Test) +2. Identify tasks per phase +3. Order by dependency +4. Reference specific example files per task +5. Add testing checkpoints +6. Estimate time per phase + +**Output Structure**: +```python +{ + 'phases': [ + { + 'name': 'Phase 1: Setup', + 'tasks': [ + { + 'task': 'Initialize Go module', + 'reference': None, + 'dependencies': [], + 'estimated_time': '2 minutes' + }, + { + 'task': 'Install dependencies (bubbletea, lipgloss)', + 'reference': 'See README in any example', + 'dependencies': ['Initialize Go module'], + 'estimated_time': '3 minutes' + } + ], + 'total_time': '5 minutes' + }, + # More phases... + ], + 'total_estimated_time': '2-3 hours', + 'testing_checkpoints': [ + 'After Phase 1: go build succeeds', + 'After Phase 2: Basic display working', + # ... + ] +} +``` + +**Phase Breakdown**: +1. **Setup**: Project initialization, dependencies +2. **Core Components**: Implement main functionality +3. **Integration**: Connect components, message passing +4. **Polish**: Styling, help text, error handling +5. **Testing**: Comprehensive testing, edge cases + +**Validations**: +- All tasks have clear descriptions +- Dependencies are acyclic +- Time estimates are realistic +- Testing checkpoints at each phase + +### 6. Comprehensive Design Report + +**Function**: `comprehensive_tui_design_report(description, inventory_path)` + +**Purpose**: Generate complete TUI design combining all analyses + +**Process**: +1. Execute requirement_analysis(description) +2. Execute component_mapping(requirements) +3. Execute pattern_selection(components) +4. Execute architecture_design(components, patterns) +5. Execute workflow_generation(architecture) +6. Generate code scaffolding +7. Create README outline +8. Compile comprehensive report + +**Output Structure**: +```python +{ + 'description': str, + 'generated_at': str, + 'tui_type': str, + 'summary': str, # Executive summary + 'sections': { + 'requirements': {...}, + 'components': {...}, + 'patterns': {...}, + 'architecture': {...}, + 'workflow': {...} + }, + 'scaffolding': { + 'main_go': str, # Basic main.go template + 'model_go': str, # Model struct + Init/Update/View + 'readme_md': str # README outline + }, + 'file_structure': { + 'recommended': [ + 'main.go', + 'model.go', + 'view.go', + 'messages.go', + 'go.mod' + ] + }, + 'next_steps': [ + '1. Review architecture diagram', + '2. Study recommended examples', + '3. Implement Phase 1 tasks', + # ... + ], + 'resources': { + 'documentation': [...], + 'tutorials': [...], + 'community': [...] + } +} +``` + +**Report Sections**: + +**Executive Summary** (auto-generated): +- TUI type and purpose +- Key components selected +- Estimated implementation time +- Complexity assessment + +**Requirements Analysis**: +- Parsed requirements +- TUI archetype +- Feature list + +**Component Selection**: +- Primary components with justification +- Alternatives considered +- Component interaction diagram + +**Pattern References**: +- Example files to study +- Key patterns highlighted +- Recommended study order + +**Architecture**: +- Model struct design +- Init/Update/View logic +- Message flow +- ASCII diagrams + +**Implementation Workflow**: +- Phase-by-phase breakdown +- Detailed tasks with references +- Testing checkpoints +- Time estimates + +**Code Scaffolding**: +- Basic `main.go` template +- Model struct skeleton +- Init/Update/View stubs + +**Next Steps**: +- Immediate actions +- Learning resources +- Community links + +**Validation Report**: +- Design completeness check +- Potential issues identified +- Recommendations + +## Error Handling + +### Missing Inventory + +**Error**: Cannot locate charm-examples-inventory + +**Cause**: Inventory path not provided or incorrect + +**Resolution**: +1. Verify inventory path: `~/charmtuitemplate/vinw/charm-examples-inventory` +2. If missing, clone examples: `git clone https://github.com/charmbracelet/bubbletea examples` +3. Generate CONTEXTUAL-INVENTORY.md if missing + +**Fallback**: Use minimal built-in component knowledge (less detailed) + +### Unclear Requirements + +**Error**: Cannot extract clear requirements from description + +**Cause**: Description too vague or ambiguous + +**Resolution**: +1. Validator identifies missing information +2. Generate clarifying questions +3. User provides additional details + +**Clarification Questions**: +- "What type of data will the TUI display?" +- "Should it be single-view or multi-view?" +- "What are the main user interactions?" +- "Any specific visual requirements?" + +**Fallback**: Make reasonable assumptions, note them in report + +### No Matching Components + +**Error**: No components found for requirements + +**Cause**: Requirements very specific or unusual + +**Resolution**: +1. Relax matching criteria +2. Suggest custom component development +3. Recommend closest alternatives + +**Alternative Suggestions**: +- Break down into smaller requirements +- Use generic components (viewport, textinput) +- Suggest combining multiple components + +### Invalid Architecture + +**Error**: Generated architecture has structural issues + +**Cause**: Conflicting component requirements or circular dependencies + +**Resolution**: +1. Validator detects issue +2. Suggest architectural modifications +3. Provide alternative structures + +**Common Issues**: +- **Circular dependencies**: Suggest message passing +- **Too many components**: Recommend simplification +- **Missing state**: Add required fields to model + +## Mandatory Validations + +All analyses include automatic validation. Reports include validation sections. + +### Requirement Validation + +**Checks**: +- ✅ Description is not empty +- ✅ At least 1 feature identified +- ✅ TUI archetype classified +- ✅ Interaction patterns detected + +**Output**: +```python +{ + 'validation': { + 'passed': True/False, + 'checks': [ + {'name': 'description_not_empty', 'passed': True}, + {'name': 'features_found', 'passed': True, 'count': 5}, + # ... + ], + 'warnings': [ + 'No mouse interactions specified - assuming keyboard only' + ] + } +} +``` + +### Component Validation + +**Checks**: +- ✅ At least 1 component selected +- ✅ All requirements covered +- ✅ No conflicting components +- ✅ Reasonable complexity + +**Warnings**: +- "Multiple similar components selected - may be redundant" +- "High complexity - consider breaking into smaller UIs" + +### Architecture Validation + +**Checks**: +- ✅ Model struct includes all components +- ✅ No circular dependencies +- ✅ All interactions have handlers +- ✅ View renders all components + +**Errors**: +- "Missing message handler for [interaction]" +- "Circular dependency detected: A → B → A" +- "Unused component: [component] not rendered in View()" + +### Workflow Validation + +**Checks**: +- ✅ All phases have tasks +- ✅ Dependencies are acyclic +- ✅ Testing checkpoints present +- ✅ Time estimates reasonable + +**Warnings**: +- "No testing checkpoint after Phase [N]" +- "Task [X] has no dependencies but should come after [Y]" + +## Performance & Caching + +### Inventory Loading + +**Strategy**: Load once, cache in memory + +- Load CONTEXTUAL-INVENTORY.md on first use +- Build search indices (by capability, component, keyword) +- Cache for session duration + +**Performance**: O(1) lookup after initial O(n) indexing + +### Component Matching + +**Strategy**: Pre-computed similarity scores + +- Build component-feature mapping at initialization +- Score calculations cached +- Incremental updates only + +**Performance**: O(log n) search with indexing + +### Diagram Generation + +**Strategy**: Template-based with caching + +- Use pre-built ASCII templates +- Cache generated diagrams +- Regenerate only on structure changes + +**Performance**: O(n) where n = number of components + +## Automatic Detection Keywords + +This skill activates when you mention: + +**Technologies**: +- Bubble Tea, bubbletea, charm, charmbracelet +- TUI, terminal UI, terminal interface +- Lipgloss, Tea Program + +**Components**: +- spinner, progress, table, list, viewport, textarea, textinput +- pager, paginator, timer, stopwatch, filepicker, help +- tabs, views, autocomplete + +**Design Actions**: +- design TUI, create TUI, build TUI, architect TUI, plan TUI +- automate TUI design, generate TUI, scaffold TUI +- design interface, create interface, build interface +- map components, select components + +**TUI Types**: +- file manager, installer, package manager, dashboard +- form, wizard, configuration tool +- chat interface, log viewer, data viewer, text viewer +- monitoring tool, menu system, selector + +**Patterns**: +- multi-view, composable views, tabbed interface +- progress tracking, form validation, data display +- keyboard navigation, mouse support +- real-time updates, streaming data + +**Questions**: +- "how to design", "how to build", "how to structure" +- "which components", "what patterns", "best practices" +- "design a TUI for", "create interface for" +- "automate design process" + +## Usage Examples + +### Example 1: Simple Log Viewer + +**User**: "Design a TUI for viewing log files with search" + +**Response**: + +``` +TUI DESIGN: Log File Viewer + +ARCHETYPE: Text Viewer + +REQUIREMENTS ANALYSIS: +- View: Scrollable text display +- Interaction: Search, navigate matches +- Data: Text file (log lines) + +COMPONENT SELECTION: + +Primary Components: +1. viewport.Model - Scrollable text display + Justification: Built for viewing large text content + Example: examples/pager/main.go + +2. textinput.Model - Search query input + Justification: Standard text input for search + Example: examples/textinput/main.go + +Supporting: +- Lipgloss styles for match highlighting + +ARCHITECTURE: + +Model Struct: +```go +type model struct { + viewport viewport.Model + searchInput textinput.Model + content []string + searchMode bool + matches []int + currentMatch int + ready bool +} +``` + +WORKFLOW: + +Phase 1: Basic Viewer (30min) +- Load file into string array +- Initialize viewport +- Implement basic scrolling + +Phase 2: Search (45min) +- Add textinput for search +- Toggle search mode with '/' +- Highlight matches + +Phase 3: Navigation (20min) +- Navigate matches with n/N +- Show match counter + +Total: ~2 hours + +NEXT STEPS: +1. Study examples/pager/main.go for viewport usage +2. Study examples/textinput/main.go for input handling +3. Implement Phase 1 +4. Test scrolling with large files +5. Add search in Phase 2 +``` + +### Example 2: Installation Progress Tracker + +**User**: "Create a package installer UI with progress bars" + +**Response**: + +``` +TUI DESIGN: Package Installer + +ARCHETYPE: Installer/Progress Tracker + +REQUIREMENTS ANALYSIS: +- View: Progress indication for multiple packages +- Interaction: Minimal (automated process) +- Data: Package list, progress per package +- Special: Sequential installation, visual feedback + +COMPONENT SELECTION: + +Primary Components: +1. progress.Model - Individual package progress + Justification: Animated progress visualization + Example: examples/progress-animated/main.go + +2. spinner.Model - Loading indicator + Justification: Feedback during package download + Example: examples/spinner/main.go + +Reference Pattern: +- examples/package-manager/main.go - Complete implementation + +ARCHITECTURE: + +Model Struct: +```go +type model struct { + packages []Package + currentIdx int + progress progress.Model + spinner spinner.Model + installed []string + err error +} + +type Package struct { + name string + status string // pending, installing, done +} +``` + +Message Flow: +Install Start → Download Package → Update Progress → Package Done → Next Package + +WORKFLOW: + +Phase 1: Package List (20min) +- Define Package struct +- Initialize list of packages +- Display package names + +Phase 2: Add Progress (30min) +- Initialize progress.Model per package +- Update on install progress messages +- Handle completion + +Phase 3: Add Spinner (15min) +- Add spinner during download +- Stop when installation starts + +Phase 4: Sequential Logic (30min) +- Implement install queue +- Sequential execution +- Completion handling + +Total: ~2 hours + +REFERENCE STUDY: +1. examples/package-manager/main.go (main reference) + - Lines 50-80: Progress + spinner combo + - Lines 120-145: Sequential installation + - Lines 200-230: Completion handling + +NEXT STEPS: +1. Clone package-manager example structure +2. Customize for your package list +3. Add package-specific install logic +4. Test with mock installations +``` + +### Example 3: Configuration Wizard + +**User**: "Build a multi-step configuration wizard with validation" + +**Response**: + +``` +TUI DESIGN: Configuration Wizard + +ARCHETYPE: Multi-Step Form + +REQUIREMENTS ANALYSIS: +- View: Multi-step (multiple screens) +- Interaction: Form input, validation, navigation +- Data: Configuration fields (text, selections) +- Special: Validation per field, step navigation + +COMPONENT SELECTION: + +Primary Components: +1. textinput.Model (array) - Multiple input fields + Justification: Standard form inputs + Example: examples/textinputs/main.go + +2. State machine for steps + Justification: Multi-step workflow + Example: examples/views/main.go + +Reference Pattern: +- examples/credit-card-form/main.go - Form validation + +ARCHITECTURE: + +Model Struct: +```go +type model struct { + currentStep int + steps []Step + inputs []textinput.Model + focusIdx int + err error +} + +type Step struct { + name string + fields []Field + valid bool +} +``` + +State Machine: +Step 1 (Basic Info) → Step 2 (Advanced) → Step 3 (Confirm) → Done + +WORKFLOW: + +Phase 1: Basic Form (45min) +- Create input array +- Implement focus management +- Basic key handling (tab, enter) + +Phase 2: Add Steps (30min) +- Define Step struct +- Implement step navigation +- Show current step indicator + +Phase 3: Validation (45min) +- Add validators to each input +- Show validation errors +- Prevent next step if invalid + +Phase 4: Polish (30min) +- Add progress indicator +- Help text per step +- Confirmation screen + +Total: ~2.5 hours + +REFERENCE STUDY ORDER: +1. examples/textinputs/main.go (focus management) +2. examples/credit-card-form/main.go (validation pattern) +3. examples/views/main.go (multi-view state machine) + +NEXT STEPS: +1. Start with textinputs example +2. Add step state management +3. Implement validators +4. Build confirmation view +``` + +### Example 4: File Manager + +**User**: "Design a file manager TUI like ranger" + +**Response**: + +``` +TUI DESIGN: File Manager + +ARCHETYPE: File Manager + +REQUIREMENTS ANALYSIS: +- View: Three-column (parent, current, preview) +- Interaction: Keyboard navigation, selection +- Data: File system hierarchy +- Special: File preview, directory traversal + +COMPONENT SELECTION: + +Primary Components: +1. filepicker.Model - File navigation + Justification: Built-in file system navigation + Example: examples/file-picker/main.go + Alternative: Custom with list.Model for more control + +2. viewport.Model - File preview + Justification: Preview pane for selected file + Example: examples/pager/main.go + +3. Lipgloss layout - Three-column layout + Justification: Side-by-side panels + Example: examples/split-editors/main.go (two-pane reference) + +ARCHITECTURE: + +Model Struct: +```go +type model struct { + parentList list.Model // Parent directory + currentList list.Model // Current directory + preview viewport.Model // File preview + currentDir string + selectedFile string + width int + height int +} +``` + +Layout: +┌────────────┬─────────────┬──────────────┐ +│ Parent │ Current │ Preview │ +│ Dir │ Dir │ Pane │ +│ │ │ │ +│ ../ │ > file1.txt │ File content │ +│ docs/ │ file2.go │ ... │ +│ src/ │ dir/ │ │ +└────────────┴─────────────┴──────────────┘ + +WORKFLOW: + +Phase 1: Single Dir Nav (1h) +- Implement current directory listing +- Arrow key navigation +- Directory entry/exit + +Phase 2: Three-Pane Layout (1h) +- Add parent directory pane +- Add preview pane +- Lipgloss layout + +Phase 3: File Preview (45min) +- Load file content on selection +- Show in preview pane +- Handle large files + +Phase 4: Polish (45min) +- File type icons/colors +- Status bar +- Help overlay + +Total: ~3.5 hours + +REFERENCE STUDY: +1. examples/file-picker/main.go - File system navigation +2. examples/list-fancy/main.go - Custom list styling +3. examples/pager/main.go - Content preview +4. examples/split-editors/main.go - Multi-pane layout + +NEXT STEPS: +1. Start with file-picker for navigation logic +2. Build three-pane layout +3. Add preview functionality +4. Style with Lipgloss +``` + +### Example 5: Quick Component Lookup + +**User**: "Which component for a dropdown menu?" + +**Response**: + +``` +COMPONENT RECOMMENDATIONS: Dropdown Menu + +Since Bubble Tea doesn't have a built-in dropdown component, here are the best approaches: + +1. list.Model (Recommended) + - Display as temporary list overlay + - Filter on keypress + - Select with Enter + - Example: examples/list-simple/main.go + - Customization: Use ItemDelegate for dropdown styling + +2. autocomplete + - Text input with suggestions below + - Good for searchable dropdowns + - Example: examples/autocomplete/main.go + - Use case: Large option lists + +3. Custom with viewport + - Full control over appearance + - Scrollable for many options + - Example: examples/viewport/main.go (for scrolling) + - Build: Overlay viewport on trigger + +IMPLEMENTATION PATTERN: + +```go +type model struct { + dropdownOpen bool + dropdownList list.Model + selectedValue string +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "enter" && !m.dropdownOpen { + // Open dropdown + m.dropdownOpen = true + return m, nil + } + if m.dropdownOpen { + // Delegate to list + var cmd tea.Cmd + m.dropdownList, cmd = m.dropdownList.Update(msg) + return m, cmd + } + } + return m, nil +} +``` + +RECOMMENDED APPROACH: +Use list.Model in "dropdown mode" - render as overlay when open, hide when closed. + +Study: examples/list-simple/main.go +``` + +--- + +**Total Word Count**: ~7,200 words + +This comprehensive skill documentation provides: +- Clear activation criteria +- Complete workflow explanations +- Detailed function documentation +- Architecture patterns +- Error handling guidance +- Extensive usage examples +- Integration with charm-examples-inventory diff --git a/.claude/skills/bubbletea-designer/VERSION b/.claude/skills/bubbletea-designer/VERSION new file mode 100644 index 00000000..3eefcb9d --- /dev/null +++ b/.claude/skills/bubbletea-designer/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/.claude/skills/bubbletea-designer/assets/component-taxonomy.json b/.claude/skills/bubbletea-designer/assets/component-taxonomy.json new file mode 100644 index 00000000..d96a120e --- /dev/null +++ b/.claude/skills/bubbletea-designer/assets/component-taxonomy.json @@ -0,0 +1,40 @@ +{ + "categories": { + "input": { + "description": "User input components", + "components": ["textinput", "textarea", "filepicker", "autocomplete"] + }, + "display": { + "description": "Content display components", + "components": ["viewport", "table", "list", "pager", "paginator"] + }, + "feedback": { + "description": "Status and progress indicators", + "components": ["spinner", "progress", "timer", "stopwatch"] + }, + "navigation": { + "description": "View and navigation management", + "components": ["tabs", "help"] + }, + "layout": { + "description": "Layout and styling", + "components": ["lipgloss"] + } + }, + "relationships": { + "common_pairs": [ + ["viewport", "textinput"], + ["list", "viewport"], + ["progress", "spinner"], + ["table", "paginator"], + ["textarea", "viewport"] + ], + "archetypes": { + "file-manager": ["filepicker", "viewport", "list"], + "installer": ["progress", "spinner", "list"], + "viewer": ["viewport", "paginator", "textinput"], + "form": ["textinput", "textarea", "help"], + "dashboard": ["tabs", "viewport", "table"] + } + } +} diff --git a/.claude/skills/bubbletea-designer/assets/keywords.json b/.claude/skills/bubbletea-designer/assets/keywords.json new file mode 100644 index 00000000..5fbe7e42 --- /dev/null +++ b/.claude/skills/bubbletea-designer/assets/keywords.json @@ -0,0 +1,74 @@ +{ + "activation_keywords": { + "technologies": [ + "bubble tea", + "bubbletea", + "charm", + "charmbracelet", + "lipgloss", + "tui", + "terminal ui", + "tea.Program" + ], + "components": [ + "viewport", + "textinput", + "textarea", + "table", + "list", + "spinner", + "progress", + "filepicker", + "paginator", + "timer", + "stopwatch", + "tabs", + "help", + "autocomplete" + ], + "actions": [ + "design tui", + "create tui", + "build tui", + "architect tui", + "plan tui", + "automate tui design", + "generate tui", + "scaffold tui", + "map components", + "select components" + ], + "tui_types": [ + "file manager", + "installer", + "package manager", + "dashboard", + "form", + "wizard", + "chat interface", + "log viewer", + "text viewer", + "configuration tool", + "menu system" + ], + "patterns": [ + "multi-view", + "tabbed interface", + "progress tracking", + "form validation", + "keyboard navigation", + "mouse support", + "real-time updates" + ] + }, + "negative_scope": [ + "web ui", + "gui", + "graphical interface", + "react", + "vue", + "angular", + "html", + "css" + ] +} diff --git a/.claude/skills/bubbletea-designer/assets/pattern-templates.json b/.claude/skills/bubbletea-designer/assets/pattern-templates.json new file mode 100644 index 00000000..314a3473 --- /dev/null +++ b/.claude/skills/bubbletea-designer/assets/pattern-templates.json @@ -0,0 +1,44 @@ +{ + "templates": { + "single-view": { + "name": "Single View Application", + "complexity": "low", + "components": 1, + "views": 1, + "time_estimate": "1-2 hours", + "use_cases": ["Simple viewer", "Single-purpose tool"] + }, + "multi-view": { + "name": "Multi-View State Machine", + "complexity": "medium", + "components": 3, + "views": 3, + "time_estimate": "2-4 hours", + "use_cases": ["Wizard", "Multi-step process"] + }, + "master-detail": { + "name": "Master-Detail Layout", + "complexity": "medium", + "components": 2, + "views": 1, + "time_estimate": "2-3 hours", + "use_cases": ["File manager", "Email client"] + }, + "progress-tracker": { + "name": "Progress Tracker", + "complexity": "medium", + "components": 3, + "views": 2, + "time_estimate": "2-3 hours", + "use_cases": ["Installer", "Batch processor"] + }, + "dashboard": { + "name": "Dashboard", + "complexity": "high", + "components": 5, + "views": 4, + "time_estimate": "4-6 hours", + "use_cases": ["Monitoring tool", "Multi-panel app"] + } + } +} diff --git a/.claude/skills/bubbletea-designer/references/architecture-best-practices.md b/.claude/skills/bubbletea-designer/references/architecture-best-practices.md new file mode 100644 index 00000000..7a2f7f36 --- /dev/null +++ b/.claude/skills/bubbletea-designer/references/architecture-best-practices.md @@ -0,0 +1,168 @@ +# Bubble Tea Architecture Best Practices + +## Model Design + +### Keep State Flat +❌ Avoid: Deeply nested state +✅ Prefer: Flat structure with clear fields + +```go +// Good +type model struct { + items []Item + cursor int + selected map[int]bool +} + +// Avoid +type model struct { + state struct { + data struct { + items []Item + } + } +} +``` + +### Separate Concerns +- UI state in model +- Business logic in separate functions +- Network/IO in commands + +### Component Ownership +Each component owns its state. Don't reach into component internals. + +## Update Function + +### Message Routing +Route messages to appropriate handlers: + +```go +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + return m.handleKeyboard(msg) + case tea.WindowSizeMsg: + return m.handleResize(msg) + } + return m.updateComponents(msg) +} +``` + +### Command Batching +Batch multiple commands: + +```go +var cmds []tea.Cmd +cmds = append(cmds, cmd1, cmd2, cmd3) +return m, tea.Batch(cmds...) +``` + +## View Function + +### Cache Expensive Renders +Don't recompute on every View() call: + +```go +type model struct { + cachedView string + dirty bool +} + +func (m model) View() string { + if m.dirty { + m.cachedView = m.render() + m.dirty = false + } + return m.cachedView +} +``` + +### Responsive Layouts +Adapt to terminal size: + +```go +if m.width < 80 { + // Compact layout +} else { + // Full layout +} +``` + +## Performance + +### Minimize Allocations +Reuse slices and strings where possible + +### Defer Heavy Operations +Move slow operations to commands (async) + +### Debounce Rapid Updates +Don't update on every keystroke for expensive operations + +## Error Handling + +### User-Friendly Errors +Show actionable error messages + +### Graceful Degradation +Fallback when features unavailable + +### Error Recovery +Allow user to retry or cancel + +## Testing + +### Test Pure Functions +Extract business logic for easy testing + +### Mock Commands +Test Update() without side effects + +### Snapshot Views +Compare View() output for visual regression + +## Accessibility + +### Keyboard-First +All features accessible via keyboard + +### Clear Indicators +Show current focus, selection state + +### Help Text +Provide discoverable help (? key) + +## Code Organization + +### File Structure +``` +main.go - Entry point, model definition +update.go - Update handlers +view.go - View rendering +commands.go - Command definitions +messages.go - Custom message types +``` + +### Component Encapsulation +One component per file for complex TUIs + +## Debugging + +### Log to File +```go +f, _ := os.OpenFile("debug.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) +log.SetOutput(f) +log.Printf("Debug: %+v", msg) +``` + +### Debug Mode +Toggle debug view with key binding + +## Common Pitfalls + +1. **Forgetting tea.Batch**: Returns only last command +2. **Not handling WindowSizeMsg**: Fixed-size components +3. **Blocking in Update()**: Freezes UI - use commands +4. **Direct terminal writes**: Use tea.Println for above-TUI output +5. **Ignoring ready state**: Rendering before initialization complete diff --git a/.claude/skills/bubbletea-designer/references/bubbletea-components-guide.md b/.claude/skills/bubbletea-designer/references/bubbletea-components-guide.md new file mode 100644 index 00000000..6370aac1 --- /dev/null +++ b/.claude/skills/bubbletea-designer/references/bubbletea-components-guide.md @@ -0,0 +1,141 @@ +# Bubble Tea Components Guide + +Complete reference for Bubble Tea ecosystem components. + +## Core Input Components + +### textinput.Model +**Purpose**: Single-line text input +**Use Cases**: Search boxes, single field forms, command input +**Key Methods**: +- `Focus()` / `Blur()` - Focus management +- `SetValue(string)` - Set text programmatically +- `Value()` - Get current text + +**Example Pattern**: +```go +input := textinput.New() +input.Placeholder = "Search..." +input.Focus() +``` + +### textarea.Model +**Purpose**: Multi-line text editing +**Use Cases**: Message composition, text editing, large text input +**Key Features**: Line wrapping, scrolling, cursor management + +### filepicker.Model +**Purpose**: File system navigation +**Use Cases**: File selection, file browsers +**Key Features**: Directory traversal, file type filtering, path resolution + +## Display Components + +### viewport.Model +**Purpose**: Scrollable content display +**Use Cases**: Log viewers, document readers, large text display +**Key Methods**: +- `SetContent(string)` - Set viewable content +- `GotoTop()` / `GotoBottom()` - Navigation +- `LineUp()` / `LineDown()` - Scroll control + +### table.Model +**Purpose**: Tabular data display +**Use Cases**: Data tables, structured information +**Key Features**: Column definitions, row selection, styling + +### list.Model +**Purpose**: Filterable, navigable lists +**Use Cases**: Item selection, menus, file lists +**Key Features**: Filtering, pagination, custom item delegates + +### paginator.Model +**Purpose**: Page-based navigation +**Use Cases**: Paginated content, chunked display + +## Feedback Components + +### spinner.Model +**Purpose**: Loading/waiting indicator +**Styles**: Dot, Line, Minidot, Jump, Pulse, Points, Globe, Moon, Monkey + +### progress.Model +**Purpose**: Progress indication +**Modes**: Determinate (0-100%), Indeterminate +**Styling**: Gradient, solid color, custom + +### timer.Model +**Purpose**: Countdown timer +**Use Cases**: Timeouts, timed operations + +### stopwatch.Model +**Purpose**: Elapsed time tracking +**Use Cases**: Duration measurement, time tracking + +## Navigation Components + +### tabs +**Purpose**: Tab-based view switching +**Pattern**: Lipgloss-based tab rendering + +### help.Model +**Purpose**: Help text and keyboard shortcuts +**Modes**: Short (inline), Full (overlay) + +## Layout with Lipgloss + +**JoinVertical**: Stack components vertically +**JoinHorizontal**: Place components side-by-side +**Place**: Position with alignment +**Border**: Add borders and padding + +## Component Initialization Pattern + +```go +type model struct { + component1 component1.Model + component2 component2.Model +} + +func (m model) Init() tea.Cmd { + return tea.Batch( + m.component1.Init(), + m.component2.Init(), + ) +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + // Update each component + var cmd tea.Cmd + m.component1, cmd = m.component1.Update(msg) + cmds = append(cmds, cmd) + + m.component2, cmd = m.component2.Update(msg) + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} +``` + +## Message Handling + +**Standard Messages**: +- `tea.KeyMsg` - Keyboard input +- `tea.MouseMsg` - Mouse events +- `tea.WindowSizeMsg` - Terminal resize +- `tea.QuitMsg` - Quit signal + +**Component Messages**: +- `progress.FrameMsg` - Progress/spinner animation +- `spinner.TickMsg` - Spinner tick +- `textinput.ErrMsg` - Input errors + +## Best Practices + +1. **Always delegate**: Let components handle their own messages +2. **Batch commands**: Use `tea.Batch()` for multiple commands +3. **Focus management**: Only one component focused at a time +4. **Dimension tracking**: Update component sizes on `WindowSizeMsg` +5. **State separation**: Keep UI state in model, business logic separate diff --git a/.claude/skills/bubbletea-designer/references/design-patterns.md b/.claude/skills/bubbletea-designer/references/design-patterns.md new file mode 100644 index 00000000..2345ee11 --- /dev/null +++ b/.claude/skills/bubbletea-designer/references/design-patterns.md @@ -0,0 +1,214 @@ +# Bubble Tea Design Patterns + +Common architectural patterns for TUI development. + +## Pattern 1: Single-View Application + +**When**: Simple, focused TUIs with one main view +**Components**: 1-3 components, single model struct +**Complexity**: Low + +```go +type model struct { + mainComponent component.Model + ready bool +} +``` + +## Pattern 2: Multi-View State Machine + +**When**: Multiple distinct screens (setup, main, done) +**Components**: State enum + view-specific components +**Complexity**: Medium + +```go +type view int +const ( + setupView view = iota + mainView + doneView +) + +type model struct { + currentView view + // Components for each view +} +``` + +## Pattern 3: Composable Views + +**When**: Complex UIs with reusable sub-components +**Pattern**: Embed multiple bubble models +**Example**: Dashboard with multiple panels + +```go +type model struct { + panel1 Panel1Model + panel2 Panel2Model + panel3 Panel3Model +} + +// Each panel is itself a Bubble Tea model +``` + +## Pattern 4: Master-Detail + +**When**: Selection in one pane affects display in another +**Example**: File list + preview, Email list + content +**Layout**: Two-pane or three-pane + +```go +type model struct { + list list.Model + detail viewport.Model + selectedItem int +} +``` + +## Pattern 5: Form Flow + +**When**: Multi-step data collection +**Pattern**: Array of inputs + focus management +**Example**: Configuration wizard + +```go +type model struct { + inputs []textinput.Model + focusIndex int + step int +} +``` + +## Pattern 6: Progress Tracker + +**When**: Long-running sequential operations +**Pattern**: Queue + progress per item +**Example**: Installation, download manager + +```go +type model struct { + items []Item + currentIndex int + progress progress.Model + spinner spinner.Model +} +``` + +## Layout Patterns + +### Vertical Stack +```go +lipgloss.JoinVertical(lipgloss.Left, + header, + content, + footer, +) +``` + +### Horizontal Panels +```go +lipgloss.JoinHorizontal(lipgloss.Top, + leftPanel, + separator, + rightPanel, +) +``` + +### Three-Column (File Manager Style) +```go +lipgloss.JoinHorizontal(lipgloss.Top, + parentDir, // 25% width + currentDir, // 35% width + preview, // 40% width +) +``` + +## Message Passing Patterns + +### Custom Messages +```go +type myCustomMsg struct { + data string +} + +func doSomethingCmd() tea.Msg { + return myCustomMsg{data: "result"} +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case myCustomMsg: + // Handle custom message + } +} +``` + +### Async Operations +```go +func fetchDataCmd() tea.Cmd { + return func() tea.Msg { + // Do async work + data := fetchFromAPI() + return dataFetchedMsg{data} + } +} +``` + +## Error Handling Pattern + +```go +type errMsg struct{ err error } + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case errMsg: + m.err = msg.err + m.errVisible = true + return m, nil + } +} +``` + +## Keyboard Navigation Pattern + +```go +case tea.KeyMsg: + switch msg.String() { + case "up", "k": + m.cursor-- + case "down", "j": + m.cursor++ + case "enter": + m.selectCurrent() + case "q", "ctrl+c": + return m, tea.Quit + } +``` + +## Responsive Layout Pattern + +```go +case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + + // Update component dimensions + m.viewport.Width = msg.Width + m.viewport.Height = msg.Height - 5 // Reserve space for header/footer +``` + +## Help Overlay Pattern + +```go +type model struct { + showHelp bool + help help.Model +} + +func (m model) View() string { + if m.showHelp { + return m.help.View() + } + return m.mainView() +} +``` diff --git a/.claude/skills/bubbletea-designer/references/example-designs.md b/.claude/skills/bubbletea-designer/references/example-designs.md new file mode 100644 index 00000000..ca1b96de --- /dev/null +++ b/.claude/skills/bubbletea-designer/references/example-designs.md @@ -0,0 +1,98 @@ +# Example TUI Designs + +Real-world design examples with component selections. + +## Example 1: Log Viewer + +**Requirements**: View large log files, search, navigate +**Archetype**: Viewer +**Components**: +- viewport.Model - Main log display +- textinput.Model - Search input +- help.Model - Keyboard shortcuts + +**Architecture**: +```go +type model struct { + viewport viewport.Model + searchInput textinput.Model + searchMode bool + matches []int + currentMatch int +} +``` + +**Key Features**: +- Toggle search with `/` +- Navigate matches with n/N +- Highlight matches in viewport + +## Example 2: File Manager + +**Requirements**: Three-column navigation, preview +**Archetype**: File Manager +**Components**: +- list.Model (x2) - Parent + current directory +- viewport.Model - File preview +- filepicker.Model - Alternative approach + +**Layout**: Horizontal three-pane +**Complexity**: Medium-High + +## Example 3: Package Installer + +**Requirements**: Sequential installation with progress +**Archetype**: Installer +**Components**: +- list.Model - Package list +- progress.Model - Per-package progress +- spinner.Model - Download indicator + +**Pattern**: Progress Tracker +**Workflow**: Queue-based sequential processing + +## Example 4: Configuration Wizard + +**Requirements**: Multi-step form with validation +**Archetype**: Form +**Components**: +- textinput.Model array - Multiple inputs +- help.Model - Per-step help +- progress/indicator - Step progress + +**Pattern**: Form Flow +**Navigation**: Tab between fields, Enter to next step + +## Example 5: Dashboard + +**Requirements**: Multiple views, real-time updates +**Archetype**: Dashboard +**Components**: +- tabs - View switching +- table.Model - Data display +- viewport.Model - Log panel + +**Pattern**: Composable Views +**Layout**: Tabbed with multiple panels per tab + +## Component Selection Guide + +| Use Case | Primary Component | Alternative | Supporting | +|----------|------------------|-------------|-----------| +| Log viewing | viewport | pager | textinput (search) | +| File selection | filepicker | list | viewport (preview) | +| Data table | table | list | paginator | +| Text editing | textarea | textinput | viewport | +| Progress | progress | spinner | - | +| Multi-step | views | tabs | help | +| Search/Filter | textinput | autocomplete | list | + +## Complexity Matrix + +| TUI Type | Components | Views | Estimated Time | +|----------|-----------|-------|----------------| +| Simple viewer | 1-2 | 1 | 1-2 hours | +| File manager | 3-4 | 1 | 3-4 hours | +| Installer | 3-4 | 3 | 2-3 hours | +| Dashboard | 4-6 | 3+ | 4-6 hours | +| Editor | 2-3 | 1-2 | 3-4 hours | diff --git a/.claude/skills/bubbletea-designer/scripts/__pycache__/analyze_requirements.cpython-311.pyc b/.claude/skills/bubbletea-designer/scripts/__pycache__/analyze_requirements.cpython-311.pyc new file mode 100644 index 00000000..8a9a6296 Binary files /dev/null and b/.claude/skills/bubbletea-designer/scripts/__pycache__/analyze_requirements.cpython-311.pyc differ diff --git a/.claude/skills/bubbletea-designer/scripts/__pycache__/design_architecture.cpython-311.pyc b/.claude/skills/bubbletea-designer/scripts/__pycache__/design_architecture.cpython-311.pyc new file mode 100644 index 00000000..18ce7d4d Binary files /dev/null and b/.claude/skills/bubbletea-designer/scripts/__pycache__/design_architecture.cpython-311.pyc differ diff --git a/.claude/skills/bubbletea-designer/scripts/__pycache__/design_tui.cpython-311.pyc b/.claude/skills/bubbletea-designer/scripts/__pycache__/design_tui.cpython-311.pyc new file mode 100644 index 00000000..bab50ecc Binary files /dev/null and b/.claude/skills/bubbletea-designer/scripts/__pycache__/design_tui.cpython-311.pyc differ diff --git a/.claude/skills/bubbletea-designer/scripts/__pycache__/generate_workflow.cpython-311.pyc b/.claude/skills/bubbletea-designer/scripts/__pycache__/generate_workflow.cpython-311.pyc new file mode 100644 index 00000000..47eaeb00 Binary files /dev/null and b/.claude/skills/bubbletea-designer/scripts/__pycache__/generate_workflow.cpython-311.pyc differ diff --git a/.claude/skills/bubbletea-designer/scripts/__pycache__/map_components.cpython-311.pyc b/.claude/skills/bubbletea-designer/scripts/__pycache__/map_components.cpython-311.pyc new file mode 100644 index 00000000..b351add3 Binary files /dev/null and b/.claude/skills/bubbletea-designer/scripts/__pycache__/map_components.cpython-311.pyc differ diff --git a/.claude/skills/bubbletea-designer/scripts/__pycache__/select_patterns.cpython-311.pyc b/.claude/skills/bubbletea-designer/scripts/__pycache__/select_patterns.cpython-311.pyc new file mode 100644 index 00000000..a7dc037b Binary files /dev/null and b/.claude/skills/bubbletea-designer/scripts/__pycache__/select_patterns.cpython-311.pyc differ diff --git a/.claude/skills/bubbletea-designer/scripts/analyze_requirements.py b/.claude/skills/bubbletea-designer/scripts/analyze_requirements.py new file mode 100644 index 00000000..e1ab722b --- /dev/null +++ b/.claude/skills/bubbletea-designer/scripts/analyze_requirements.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +""" +Requirement analyzer for Bubble Tea TUIs. +Extracts structured requirements from natural language. +""" + +import re +from typing import Dict, List +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +from utils.validators import RequirementValidator + + +# TUI archetype keywords +ARCHETYPE_KEYWORDS = { + 'file-manager': ['file', 'directory', 'browse', 'navigator', 'ranger', 'three-column'], + 'installer': ['install', 'package', 'progress', 'setup', 'installation'], + 'dashboard': ['dashboard', 'monitor', 'real-time', 'metrics', 'status'], + 'form': ['form', 'input', 'wizard', 'configuration', 'settings'], + 'viewer': ['view', 'display', 'log', 'text', 'document', 'reader'], + 'chat': ['chat', 'message', 'conversation', 'messaging'], + 'table-viewer': ['table', 'data', 'spreadsheet', 'grid'], + 'menu': ['menu', 'select', 'choose', 'options'], + 'editor': ['edit', 'editor', 'compose', 'write'] +} + + +def extract_requirements(description: str) -> Dict: + """ + Extract structured requirements from description. + + Args: + description: Natural language TUI description + + Returns: + Dictionary with structured requirements + + Example: + >>> reqs = extract_requirements("Build a log viewer with search") + >>> reqs['archetype'] + 'viewer' + """ + # Validate input + validator = RequirementValidator() + validation = validator.validate_description(description) + + desc_lower = description.lower() + + # Extract archetype + archetype = classify_tui_type(description) + + # Extract features + features = identify_features(description) + + # Extract interactions + interactions = identify_interactions(description) + + # Extract data types + data_types = identify_data_types(description) + + # Determine view type + views = determine_view_type(description) + + # Special requirements + special = identify_special_requirements(description) + + requirements = { + 'archetype': archetype, + 'features': features, + 'interactions': interactions, + 'data_types': data_types, + 'views': views, + 'special_requirements': special, + 'original_description': description, + 'validation': validation.to_dict() + } + + return requirements + + +def classify_tui_type(description: str) -> str: + """Classify TUI archetype from description.""" + desc_lower = description.lower() + + # Score each archetype + scores = {} + for archetype, keywords in ARCHETYPE_KEYWORDS.items(): + score = sum(1 for kw in keywords if kw in desc_lower) + if score > 0: + scores[archetype] = score + + if not scores: + return 'general' + + # Return highest scoring archetype + return max(scores.items(), key=lambda x: x[1])[0] + + +def identify_features(description: str) -> List[str]: + """Identify features from description.""" + features = [] + desc_lower = description.lower() + + feature_keywords = { + 'navigation': ['navigate', 'move', 'browse', 'arrow'], + 'selection': ['select', 'choose', 'pick'], + 'search': ['search', 'find', 'filter', 'query'], + 'editing': ['edit', 'modify', 'change', 'update'], + 'display': ['display', 'show', 'view', 'render'], + 'input': ['input', 'enter', 'type'], + 'progress': ['progress', 'loading', 'install'], + 'preview': ['preview', 'peek', 'preview pane'], + 'scrolling': ['scroll', 'scrollable'], + 'sorting': ['sort', 'order', 'rank'], + 'filtering': ['filter', 'narrow'], + 'highlighting': ['highlight', 'emphasize', 'mark'] + } + + for feature, keywords in feature_keywords.items(): + if any(kw in desc_lower for kw in keywords): + features.append(feature) + + return features if features else ['display'] + + +def identify_interactions(description: str) -> Dict[str, List[str]]: + """Identify user interaction types.""" + desc_lower = description.lower() + + keyboard = [] + mouse = [] + + # Keyboard interactions + kbd_keywords = { + 'navigation': ['arrow', 'hjkl', 'navigate', 'move'], + 'selection': ['enter', 'select', 'choose'], + 'search': ['/', 'search', 'find'], + 'quit': ['q', 'quit', 'exit', 'esc'], + 'help': ['?', 'help'] + } + + for interaction, keywords in kbd_keywords.items(): + if any(kw in desc_lower for kw in keywords): + keyboard.append(interaction) + + # Default keyboard interactions + if not keyboard: + keyboard = ['navigation', 'selection', 'quit'] + + # Mouse interactions + if any(word in desc_lower for word in ['mouse', 'click', 'drag']): + mouse = ['click', 'scroll'] + + return { + 'keyboard': keyboard, + 'mouse': mouse + } + + +def identify_data_types(description: str) -> List[str]: + """Identify data types being displayed.""" + desc_lower = description.lower() + + data_type_keywords = { + 'files': ['file', 'directory', 'folder'], + 'text': ['text', 'log', 'document'], + 'tabular': ['table', 'data', 'rows', 'columns'], + 'messages': ['message', 'chat', 'conversation'], + 'packages': ['package', 'dependency', 'module'], + 'metrics': ['metric', 'stat', 'data point'], + 'config': ['config', 'setting', 'option'] + } + + data_types = [] + for dtype, keywords in data_type_keywords.items(): + if any(kw in desc_lower for kw in keywords): + data_types.append(dtype) + + return data_types if data_types else ['text'] + + +def determine_view_type(description: str) -> str: + """Determine if single or multi-view.""" + desc_lower = description.lower() + + multi_keywords = ['multi-view', 'multiple view', 'tabs', 'tabbed', 'switch', 'views'] + three_pane_keywords = ['three', 'three-column', 'three pane'] + + if any(kw in desc_lower for kw in three_pane_keywords): + return 'three-pane' + elif any(kw in desc_lower for kw in multi_keywords): + return 'multi' + else: + return 'single' + + +def identify_special_requirements(description: str) -> List[str]: + """Identify special requirements.""" + desc_lower = description.lower() + special = [] + + special_keywords = { + 'validation': ['validate', 'validation', 'check'], + 'real-time': ['real-time', 'live', 'streaming'], + 'async': ['async', 'background', 'concurrent'], + 'persistence': ['save', 'persist', 'store'], + 'theming': ['theme', 'color', 'style'] + } + + for req, keywords in special_keywords.items(): + if any(kw in desc_lower for kw in keywords): + special.append(req) + + return special + + +def main(): + """Test requirement analyzer.""" + print("Testing Requirement Analyzer\n" + "=" * 50) + + test_cases = [ + "Build a log viewer with search and highlighting", + "Create a file manager with three-column view", + "Design an installer with progress bars", + "Make a form wizard with validation" + ] + + for i, desc in enumerate(test_cases, 1): + print(f"\n{i}. Testing: '{desc}'") + reqs = extract_requirements(desc) + print(f" Archetype: {reqs['archetype']}") + print(f" Features: {', '.join(reqs['features'])}") + print(f" Data types: {', '.join(reqs['data_types'])}") + print(f" View type: {reqs['views']}") + print(f" Validation: {reqs['validation']['summary']}") + + print("\n✅ All tests passed!") + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/bubbletea-designer/scripts/design_architecture.py b/.claude/skills/bubbletea-designer/scripts/design_architecture.py new file mode 100644 index 00000000..9402dadb --- /dev/null +++ b/.claude/skills/bubbletea-designer/scripts/design_architecture.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Architecture designer for Bubble Tea TUIs.""" + +import sys +from pathlib import Path +from typing import Dict, List + +sys.path.insert(0, str(Path(__file__).parent)) + +from utils.template_generator import ( + generate_model_struct, + generate_init_function, + generate_update_skeleton, + generate_view_skeleton +) +from utils.ascii_diagram import ( + draw_component_tree, + draw_message_flow, + draw_state_machine +) +from utils.validators import DesignValidator + + +def design_architecture(components: Dict, patterns: Dict, requirements: Dict) -> Dict: + """Design TUI architecture.""" + primary = components.get('primary_components', []) + comp_names = [c['component'].replace('.Model', '') for c in primary] + archetype = requirements.get('archetype', 'general') + views = requirements.get('views', 'single') + + # Generate code structures + model_struct = generate_model_struct(comp_names, archetype) + init_logic = generate_init_function(comp_names) + message_handlers = { + 'tea.KeyMsg': 'Handle keyboard input (arrows, enter, q, etc.)', + 'tea.WindowSizeMsg': 'Handle window resize, update component dimensions' + } + + # Add component-specific handlers + if 'progress' in comp_names or 'spinner' in comp_names: + message_handlers['progress.FrameMsg'] = 'Update progress/spinner animation' + + view_logic = generate_view_skeleton(comp_names) + + # Generate diagrams + diagrams = { + 'component_hierarchy': draw_component_tree(comp_names, archetype), + 'message_flow': draw_message_flow(list(message_handlers.keys())) + } + + if views == 'multi': + diagrams['state_machine'] = draw_state_machine(['View 1', 'View 2', 'View 3']) + + architecture = { + 'model_struct': model_struct, + 'init_logic': init_logic, + 'message_handlers': message_handlers, + 'view_logic': view_logic, + 'diagrams': diagrams + } + + # Validate + validator = DesignValidator() + validation = validator.validate_architecture(architecture) + architecture['validation'] = validation.to_dict() + + return architecture diff --git a/.claude/skills/bubbletea-designer/scripts/design_tui.py b/.claude/skills/bubbletea-designer/scripts/design_tui.py new file mode 100644 index 00000000..6bd28443 --- /dev/null +++ b/.claude/skills/bubbletea-designer/scripts/design_tui.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +""" +Main TUI designer orchestrator. +Combines all analyses into comprehensive design report. +""" + +import sys +import argparse +from pathlib import Path +from typing import Dict, Optional, List + +sys.path.insert(0, str(Path(__file__).parent)) + +from analyze_requirements import extract_requirements +from map_components import map_to_components +from select_patterns import select_relevant_patterns +from design_architecture import design_architecture +from generate_workflow import generate_implementation_workflow +from utils.helpers import get_timestamp +from utils.template_generator import generate_main_go +from utils.validators import DesignValidator + + +def comprehensive_tui_design_report( + description: str, + inventory_path: Optional[str] = None, + include_sections: Optional[List[str]] = None, + detail_level: str = "complete" +) -> Dict: + """ + Generate comprehensive TUI design report. + + This is the all-in-one function that combines all design analyses. + + Args: + description: Natural language TUI description + inventory_path: Path to charm-examples-inventory + include_sections: Which sections to include (None = all) + detail_level: "summary" | "detailed" | "complete" + + Returns: + Complete design report dictionary with all sections + + Example: + >>> report = comprehensive_tui_design_report( + ... "Build a log viewer with search" + ... ) + >>> print(report['summary']) + "TUI Design: Log Viewer..." + """ + if include_sections is None: + include_sections = ['requirements', 'components', 'patterns', 'architecture', 'workflow'] + + report = { + 'description': description, + 'generated_at': get_timestamp(), + 'sections': {} + } + + # Phase 1: Requirements Analysis + if 'requirements' in include_sections: + requirements = extract_requirements(description) + report['sections']['requirements'] = requirements + report['tui_type'] = requirements['archetype'] + else: + requirements = extract_requirements(description) + report['tui_type'] = requirements.get('archetype', 'general') + + # Phase 2: Component Mapping + if 'components' in include_sections: + components = map_to_components(requirements) + report['sections']['components'] = components + else: + components = map_to_components(requirements) + + # Phase 3: Pattern Selection + if 'patterns' in include_sections: + patterns = select_relevant_patterns(components, inventory_path) + report['sections']['patterns'] = patterns + else: + patterns = {'examples': []} + + # Phase 4: Architecture Design + if 'architecture' in include_sections: + architecture = design_architecture(components, patterns, requirements) + report['sections']['architecture'] = architecture + else: + architecture = design_architecture(components, patterns, requirements) + + # Phase 5: Workflow Generation + if 'workflow' in include_sections: + workflow = generate_implementation_workflow(architecture, patterns) + report['sections']['workflow'] = workflow + + # Generate summary + report['summary'] = _generate_summary(report, requirements, components) + + # Generate code scaffolding + if detail_level == "complete": + primary_comps = [ + c['component'].replace('.Model', '') + for c in components.get('primary_components', [])[:3] + ] + report['scaffolding'] = { + 'main_go': generate_main_go(primary_comps, requirements.get('archetype', 'general')) + } + + # File structure recommendation + report['file_structure'] = { + 'recommended': ['main.go', 'go.mod', 'README.md'] + } + + # Next steps + report['next_steps'] = _generate_next_steps(patterns, workflow if 'workflow' in report['sections'] else None) + + # Resources + report['resources'] = { + 'documentation': [ + 'https://github.com/charmbracelet/bubbletea', + 'https://github.com/charmbracelet/lipgloss' + ], + 'tutorials': [ + 'Bubble Tea tutorial: https://github.com/charmbracelet/bubbletea/tree/master/tutorials' + ], + 'community': [ + 'Charm Discord: https://charm.sh/chat' + ] + } + + # Overall validation + validator = DesignValidator() + validation = validator.validate_design_report(report) + report['validation'] = validation.to_dict() + + return report + + +def _generate_summary(report: Dict, requirements: Dict, components: Dict) -> str: + """Generate executive summary.""" + tui_type = requirements.get('archetype', 'general') + features = requirements.get('features', []) + primary = components.get('primary_components', []) + + summary_parts = [ + f"TUI Design: {tui_type.replace('-', ' ').title()}", + f"\nPurpose: {report.get('description', 'N/A')}", + f"\nKey Features: {', '.join(features)}", + f"\nPrimary Components: {', '.join([c['component'] for c in primary[:3]])}", + ] + + if 'workflow' in report.get('sections', {}): + summary_parts.append( + f"\nEstimated Implementation Time: {report['sections']['workflow'].get('total_estimated_time', 'N/A')}" + ) + + return '\n'.join(summary_parts) + + +def _generate_next_steps(patterns: Dict, workflow: Optional[Dict]) -> List[str]: + """Generate next steps list.""" + steps = ['1. Review the architecture diagram and component selection'] + + examples = patterns.get('examples', []) + if examples: + steps.append(f'2. Study example files: {examples[0]["file"]}') + + if workflow: + steps.append('3. Follow the implementation workflow starting with Phase 1') + steps.append('4. Test at each checkpoint') + + steps.append('5. Refer to Bubble Tea documentation for component details') + + return steps + + +def main(): + """CLI for TUI designer.""" + parser = argparse.ArgumentParser(description='Bubble Tea TUI Designer') + parser.add_argument('description', help='TUI description') + parser.add_argument('--inventory', help='Path to charm-examples-inventory') + parser.add_argument('--detail', choices=['summary', 'detailed', 'complete'], default='complete') + + args = parser.parse_args() + + print("=" * 60) + print("Bubble Tea TUI Designer") + print("=" * 60) + + report = comprehensive_tui_design_report( + args.description, + inventory_path=args.inventory, + detail_level=args.detail + ) + + print(f"\n{report['summary']}") + + if 'architecture' in report['sections']: + print("\n" + "=" * 60) + print("ARCHITECTURE") + print("=" * 60) + print(report['sections']['architecture']['diagrams']['component_hierarchy']) + + if 'workflow' in report['sections']: + print("\n" + "=" * 60) + print("IMPLEMENTATION WORKFLOW") + print("=" * 60) + for phase in report['sections']['workflow']['phases']: + print(f"\n{phase['name']} ({phase['total_time']})") + for task in phase['tasks']: + print(f" - {task['task']}") + + print("\n" + "=" * 60) + print("NEXT STEPS") + print("=" * 60) + for step in report['next_steps']: + print(step) + + print("\n" + "=" * 60) + print(f"Validation: {report['validation']['summary']}") + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/bubbletea-designer/scripts/generate_workflow.py b/.claude/skills/bubbletea-designer/scripts/generate_workflow.py new file mode 100644 index 00000000..55ec43be --- /dev/null +++ b/.claude/skills/bubbletea-designer/scripts/generate_workflow.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Workflow generator for TUI implementation.""" + +import sys +from pathlib import Path +from typing import Dict, List + +sys.path.insert(0, str(Path(__file__).parent)) + +from utils.helpers import estimate_complexity +from utils.validators import DesignValidator + + +def generate_implementation_workflow(architecture: Dict, patterns: Dict) -> Dict: + """Generate step-by-step implementation workflow.""" + comp_count = len(architecture.get('model_struct', '').split('\n')) // 2 + examples = patterns.get('examples', []) + + phases = [ + { + 'name': 'Phase 1: Setup', + 'tasks': [ + {'task': 'Initialize Go module', 'estimated_time': '2 minutes'}, + {'task': 'Install Bubble Tea and dependencies', 'estimated_time': '3 minutes'}, + {'task': 'Create main.go with basic structure', 'estimated_time': '5 minutes'} + ], + 'total_time': '10 minutes' + }, + { + 'name': 'Phase 2: Core Components', + 'tasks': [ + {'task': 'Implement model struct', 'estimated_time': '15 minutes'}, + {'task': 'Add Init() function', 'estimated_time': '10 minutes'}, + {'task': 'Implement basic Update() handler', 'estimated_time': '20 minutes'}, + {'task': 'Create basic View()', 'estimated_time': '15 minutes'} + ], + 'total_time': '60 minutes' + }, + { + 'name': 'Phase 3: Integration', + 'tasks': [ + {'task': 'Connect components', 'estimated_time': '30 minutes'}, + {'task': 'Add message passing', 'estimated_time': '20 minutes'}, + {'task': 'Implement full keyboard handling', 'estimated_time': '20 minutes'} + ], + 'total_time': '70 minutes' + }, + { + 'name': 'Phase 4: Polish', + 'tasks': [ + {'task': 'Add Lipgloss styling', 'estimated_time': '30 minutes'}, + {'task': 'Add help text', 'estimated_time': '15 minutes'}, + {'task': 'Error handling', 'estimated_time': '15 minutes'} + ], + 'total_time': '60 minutes' + } + ] + + testing_checkpoints = [ + 'After Phase 1: go build succeeds', + 'After Phase 2: Basic TUI renders', + 'After Phase 3: All interactions work', + 'After Phase 4: Production ready' + ] + + workflow = { + 'phases': phases, + 'testing_checkpoints': testing_checkpoints, + 'total_estimated_time': estimate_complexity(comp_count) + } + + # Validate + validator = DesignValidator() + validation = validator.validate_workflow_completeness(workflow) + workflow['validation'] = validation.to_dict() + + return workflow diff --git a/.claude/skills/bubbletea-designer/scripts/map_components.py b/.claude/skills/bubbletea-designer/scripts/map_components.py new file mode 100644 index 00000000..4b4a03d3 --- /dev/null +++ b/.claude/skills/bubbletea-designer/scripts/map_components.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +""" +Component mapper for Bubble Tea TUIs. +Maps requirements to appropriate components. +""" + +import sys +from pathlib import Path +from typing import Dict, List + +sys.path.insert(0, str(Path(__file__).parent)) + +from utils.component_matcher import ( + match_score, + find_best_match, + get_alternatives, + explain_match, + rank_components_by_relevance +) +from utils.validators import DesignValidator + + +def map_to_components(requirements: Dict, inventory=None) -> Dict: + """ + Map requirements to Bubble Tea components. + + Args: + requirements: Structured requirements from analyze_requirements + inventory: Optional inventory object (unused for now) + + Returns: + Dictionary with component recommendations + + Example: + >>> components = map_to_components(reqs) + >>> components['primary_components'][0]['component'] + 'viewport.Model' + """ + features = requirements.get('features', []) + archetype = requirements.get('archetype', 'general') + data_types = requirements.get('data_types', []) + views = requirements.get('views', 'single') + + # Get ranked components + ranked = rank_components_by_relevance(features, min_score=50) + + # Build primary components list + primary_components = [] + for component, score, matching_features in ranked[:5]: # Top 5 + justification = explain_match(component, ' '.join(matching_features), score) + + primary_components.append({ + 'component': f'{component}.Model', + 'score': score, + 'justification': justification, + 'example_file': f'examples/{component}/main.go', + 'key_patterns': [f'{component} usage', 'initialization', 'message handling'] + }) + + # Add archetype-specific components + archetype_components = _get_archetype_components(archetype) + for comp in archetype_components: + if not any(c['component'].startswith(comp) for c in primary_components): + primary_components.append({ + 'component': f'{comp}.Model', + 'score': 70, + 'justification': f'Standard component for {archetype} TUIs', + 'example_file': f'examples/{comp}/main.go', + 'key_patterns': [f'{comp} patterns'] + }) + + # Supporting components + supporting = _get_supporting_components(features, views) + + # Styling + styling = ['lipgloss for layout and styling'] + if 'highlighting' in features: + styling.append('lipgloss for text highlighting') + + # Alternatives + alternatives = {} + for comp in primary_components[:3]: + comp_name = comp['component'].replace('.Model', '') + alts = get_alternatives(comp_name) + if alts: + alternatives[comp['component']] = [f'{alt}.Model' for alt in alts] + + result = { + 'primary_components': primary_components, + 'supporting_components': supporting, + 'styling': styling, + 'alternatives': alternatives + } + + # Validate + validator = DesignValidator() + validation = validator.validate_component_selection(result, requirements) + + result['validation'] = validation.to_dict() + + return result + + +def _get_archetype_components(archetype: str) -> List[str]: + """Get standard components for archetype.""" + archetype_map = { + 'file-manager': ['filepicker', 'viewport', 'list'], + 'installer': ['progress', 'spinner', 'list'], + 'dashboard': ['tabs', 'viewport', 'table'], + 'form': ['textinput', 'textarea', 'help'], + 'viewer': ['viewport', 'paginator', 'textinput'], + 'chat': ['viewport', 'textarea', 'textinput'], + 'table-viewer': ['table', 'paginator'], + 'menu': ['list'], + 'editor': ['textarea', 'viewport'] + } + return archetype_map.get(archetype, []) + + +def _get_supporting_components(features: List[str], views: str) -> List[str]: + """Get supporting components based on features.""" + supporting = [] + + if views in ['multi', 'three-pane']: + supporting.append('Multiple viewports for multi-pane layout') + + if 'help' not in features: + supporting.append('help.Model for keyboard shortcuts') + + if views == 'multi': + supporting.append('tabs.Model or state machine for view switching') + + return supporting + + +def main(): + """Test component mapper.""" + print("Testing Component Mapper\n" + "=" * 50) + + # Mock requirements + requirements = { + 'archetype': 'viewer', + 'features': ['display', 'search', 'scrolling'], + 'data_types': ['text'], + 'views': 'single' + } + + print("\n1. Testing map_to_components()...") + components = map_to_components(requirements) + + print(f" Primary components: {len(components['primary_components'])}") + for comp in components['primary_components'][:3]: + print(f" - {comp['component']} (score: {comp['score']})") + + print(f"\n Validation: {components['validation']['summary']}") + + print("\n✅ Tests passed!") + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/bubbletea-designer/scripts/select_patterns.py b/.claude/skills/bubbletea-designer/scripts/select_patterns.py new file mode 100644 index 00000000..acc8c1b9 --- /dev/null +++ b/.claude/skills/bubbletea-designer/scripts/select_patterns.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Pattern selector - finds relevant example files.""" + +import sys +from pathlib import Path +from typing import Dict, List, Optional + +sys.path.insert(0, str(Path(__file__).parent)) + +from utils.inventory_loader import load_inventory, Inventory + + +def select_relevant_patterns(components: Dict, inventory_path: Optional[str] = None) -> Dict: + """Select relevant example files.""" + try: + inventory = load_inventory(inventory_path) + except Exception as e: + return {'examples': [], 'error': str(e)} + + primary_components = components.get('primary_components', []) + examples = [] + + for comp_info in primary_components[:3]: + comp_name = comp_info['component'].replace('.Model', '') + comp_examples = inventory.get_by_component(comp_name) + + for ex in comp_examples[:2]: + examples.append({ + 'file': ex.file_path, + 'capability': ex.capability, + 'relevance_score': comp_info['score'], + 'key_patterns': ex.key_patterns, + 'study_order': len(examples) + 1 + }) + + return { + 'examples': examples, + 'recommended_study_order': list(range(1, len(examples) + 1)), + 'total_study_time': f"{len(examples) * 15} minutes" + } diff --git a/.claude/skills/bubbletea-designer/scripts/utils/__pycache__/ascii_diagram.cpython-311.pyc b/.claude/skills/bubbletea-designer/scripts/utils/__pycache__/ascii_diagram.cpython-311.pyc new file mode 100644 index 00000000..1d4cdeea Binary files /dev/null and b/.claude/skills/bubbletea-designer/scripts/utils/__pycache__/ascii_diagram.cpython-311.pyc differ diff --git a/.claude/skills/bubbletea-designer/scripts/utils/__pycache__/component_matcher.cpython-311.pyc b/.claude/skills/bubbletea-designer/scripts/utils/__pycache__/component_matcher.cpython-311.pyc new file mode 100644 index 00000000..25a955b6 Binary files /dev/null and b/.claude/skills/bubbletea-designer/scripts/utils/__pycache__/component_matcher.cpython-311.pyc differ diff --git a/.claude/skills/bubbletea-designer/scripts/utils/__pycache__/helpers.cpython-311.pyc b/.claude/skills/bubbletea-designer/scripts/utils/__pycache__/helpers.cpython-311.pyc new file mode 100644 index 00000000..aabb9531 Binary files /dev/null and b/.claude/skills/bubbletea-designer/scripts/utils/__pycache__/helpers.cpython-311.pyc differ diff --git a/.claude/skills/bubbletea-designer/scripts/utils/__pycache__/inventory_loader.cpython-311.pyc b/.claude/skills/bubbletea-designer/scripts/utils/__pycache__/inventory_loader.cpython-311.pyc new file mode 100644 index 00000000..d0ad0673 Binary files /dev/null and b/.claude/skills/bubbletea-designer/scripts/utils/__pycache__/inventory_loader.cpython-311.pyc differ diff --git a/.claude/skills/bubbletea-designer/scripts/utils/__pycache__/template_generator.cpython-311.pyc b/.claude/skills/bubbletea-designer/scripts/utils/__pycache__/template_generator.cpython-311.pyc new file mode 100644 index 00000000..0034d1ba Binary files /dev/null and b/.claude/skills/bubbletea-designer/scripts/utils/__pycache__/template_generator.cpython-311.pyc differ diff --git a/.claude/skills/bubbletea-designer/scripts/utils/ascii_diagram.py b/.claude/skills/bubbletea-designer/scripts/utils/ascii_diagram.py new file mode 100644 index 00000000..3545d471 --- /dev/null +++ b/.claude/skills/bubbletea-designer/scripts/utils/ascii_diagram.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +""" +ASCII diagram generator for architecture visualization. +""" + +from typing import List, Dict + + +def draw_component_tree(components: List[str], archetype: str) -> str: + """Draw component hierarchy as ASCII tree.""" + lines = [ + "┌─────────────────────────────────────┐", + "│ Main Model │", + "├─────────────────────────────────────┤" + ] + + # Add state fields + lines.append("│ Components: │") + for comp in components: + lines.append(f"│ - {comp:<30} │") + + lines.append("└────────────┬───────────────┬────────┘") + + # Add component boxes below + if len(components) >= 2: + comp_boxes = [] + for comp in components[:3]: # Show max 3 + comp_boxes.append(f" ┌────▼────┐") + comp_boxes.append(f" │ {comp:<7} │") + comp_boxes.append(f" └─────────┘") + return "\n".join(lines) + "\n" + "\n".join(comp_boxes) + + return "\n".join(lines) + + +def draw_message_flow(messages: List[str]) -> str: + """Draw message flow diagram.""" + flow = ["Message Flow:"] + flow.append("") + flow.append("User Input → tea.KeyMsg → Update() →") + for msg in messages: + flow.append(f" {msg} →") + flow.append(" Model Updated → View() → Render") + return "\n".join(flow) + + +def draw_state_machine(states: List[str]) -> str: + """Draw state machine diagram.""" + if not states or len(states) < 2: + return "Single-state application (no state machine)" + + diagram = ["State Machine:", ""] + for i, state in enumerate(states): + if i < len(states) - 1: + diagram.append(f"{state} → {states[i+1]}") + else: + diagram.append(f"{state} → Done") + + return "\n".join(diagram) diff --git a/.claude/skills/bubbletea-designer/scripts/utils/component_matcher.py b/.claude/skills/bubbletea-designer/scripts/utils/component_matcher.py new file mode 100644 index 00000000..c192da7b --- /dev/null +++ b/.claude/skills/bubbletea-designer/scripts/utils/component_matcher.py @@ -0,0 +1,379 @@ +#!/usr/bin/env python3 +""" +Component matching logic for Bubble Tea Designer. +Scores and ranks components based on requirements. +""" + +from typing import Dict, List, Tuple +import logging + +logger = logging.getLogger(__name__) + + +# Component capability definitions +COMPONENT_CAPABILITIES = { + 'viewport': { + 'keywords': ['scroll', 'view', 'display', 'content', 'pager', 'document'], + 'use_cases': ['viewing large text', 'log viewer', 'document reader'], + 'complexity': 'medium' + }, + 'textinput': { + 'keywords': ['input', 'text', 'search', 'query', 'single-line'], + 'use_cases': ['search box', 'text input', 'single field'], + 'complexity': 'low' + }, + 'textarea': { + 'keywords': ['edit', 'multi-line', 'text area', 'editor', 'compose'], + 'use_cases': ['text editing', 'message composition', 'multi-line input'], + 'complexity': 'medium' + }, + 'table': { + 'keywords': ['table', 'tabular', 'rows', 'columns', 'grid', 'data display'], + 'use_cases': ['data table', 'spreadsheet view', 'structured data'], + 'complexity': 'medium' + }, + 'list': { + 'keywords': ['list', 'items', 'select', 'choose', 'menu', 'options'], + 'use_cases': ['item selection', 'menu', 'file list'], + 'complexity': 'medium' + }, + 'progress': { + 'keywords': ['progress', 'loading', 'installation', 'percent', 'bar'], + 'use_cases': ['progress indication', 'loading', 'installation progress'], + 'complexity': 'low' + }, + 'spinner': { + 'keywords': ['loading', 'spinner', 'wait', 'processing', 'busy'], + 'use_cases': ['loading indicator', 'waiting', 'processing'], + 'complexity': 'low' + }, + 'filepicker': { + 'keywords': ['file', 'select file', 'choose file', 'file system', 'browse'], + 'use_cases': ['file selection', 'file browser', 'file chooser'], + 'complexity': 'medium' + }, + 'paginator': { + 'keywords': ['page', 'pagination', 'pages', 'navigate pages'], + 'use_cases': ['page navigation', 'chunked content', 'paged display'], + 'complexity': 'low' + }, + 'timer': { + 'keywords': ['timer', 'countdown', 'timeout', 'time limit'], + 'use_cases': ['countdown', 'timeout', 'timed operation'], + 'complexity': 'low' + }, + 'stopwatch': { + 'keywords': ['stopwatch', 'elapsed', 'time tracking', 'duration'], + 'use_cases': ['time tracking', 'elapsed time', 'duration measurement'], + 'complexity': 'low' + }, + 'help': { + 'keywords': ['help', 'shortcuts', 'keybindings', 'documentation'], + 'use_cases': ['help menu', 'keyboard shortcuts', 'documentation'], + 'complexity': 'low' + }, + 'tabs': { + 'keywords': ['tabs', 'tabbed', 'switch views', 'navigation'], + 'use_cases': ['tab navigation', 'multiple views', 'view switching'], + 'complexity': 'medium' + }, + 'autocomplete': { + 'keywords': ['autocomplete', 'suggestions', 'completion', 'dropdown'], + 'use_cases': ['autocomplete', 'suggestions', 'smart input'], + 'complexity': 'medium' + } +} + + +def match_score(requirement: str, component: str) -> int: + """ + Calculate relevance score for component given requirement. + + Args: + requirement: Feature requirement description + component: Component name + + Returns: + Score from 0-100 (higher = better match) + + Example: + >>> match_score("scrollable log display", "viewport") + 95 + """ + if component not in COMPONENT_CAPABILITIES: + return 0 + + score = 0 + requirement_lower = requirement.lower() + comp_info = COMPONENT_CAPABILITIES[component] + + # Keyword matching (60 points max) + keywords = comp_info['keywords'] + keyword_matches = sum(1 for kw in keywords if kw in requirement_lower) + keyword_score = min(60, (keyword_matches / len(keywords)) * 60) + score += keyword_score + + # Use case matching (40 points max) + use_cases = comp_info['use_cases'] + use_case_matches = sum(1 for uc in use_cases if any( + word in requirement_lower for word in uc.split() + )) + use_case_score = min(40, (use_case_matches / len(use_cases)) * 40) + score += use_case_score + + return int(score) + + +def find_best_match(requirement: str, components: List[str] = None) -> Tuple[str, int]: + """ + Find best matching component for requirement. + + Args: + requirement: Feature requirement + components: List of component names to consider (None = all) + + Returns: + Tuple of (best_component, score) + + Example: + >>> find_best_match("need to show progress while installing") + ('progress', 85) + """ + if components is None: + components = list(COMPONENT_CAPABILITIES.keys()) + + best_component = None + best_score = 0 + + for component in components: + score = match_score(requirement, component) + if score > best_score: + best_score = score + best_component = component + + return best_component, best_score + + +def suggest_combinations(requirements: List[str]) -> List[List[str]]: + """ + Suggest component combinations for multiple requirements. + + Args: + requirements: List of feature requirements + + Returns: + List of component combinations (each is a list of components) + + Example: + >>> suggest_combinations(["display logs", "search logs"]) + [['viewport', 'textinput']] + """ + combinations = [] + + # Find best match for each requirement + selected_components = [] + for req in requirements: + component, score = find_best_match(req) + if score > 50 and component not in selected_components: + selected_components.append(component) + + if selected_components: + combinations.append(selected_components) + + # Common patterns + patterns = { + 'file_manager': ['filepicker', 'viewport', 'list'], + 'installer': ['progress', 'spinner', 'list'], + 'form': ['textinput', 'textarea', 'help'], + 'viewer': ['viewport', 'paginator', 'textinput'], + 'dashboard': ['tabs', 'viewport', 'table'] + } + + # Check if requirements match any patterns + req_text = ' '.join(requirements).lower() + for pattern_name, pattern_components in patterns.items(): + if pattern_name.replace('_', ' ') in req_text: + combinations.append(pattern_components) + + return combinations if combinations else [selected_components] + + +def get_alternatives(component: str) -> List[str]: + """ + Get alternative components that serve similar purposes. + + Args: + component: Component name + + Returns: + List of alternative component names + + Example: + >>> get_alternatives('viewport') + ['pager', 'textarea'] + """ + alternatives = { + 'viewport': ['pager'], + 'textinput': ['textarea', 'autocomplete'], + 'textarea': ['textinput', 'viewport'], + 'table': ['list'], + 'list': ['table', 'filepicker'], + 'progress': ['spinner'], + 'spinner': ['progress'], + 'filepicker': ['list'], + 'paginator': ['viewport'], + 'tabs': ['composable-views'] + } + + return alternatives.get(component, []) + + +def explain_match(component: str, requirement: str, score: int) -> str: + """ + Generate explanation for why component matches requirement. + + Args: + component: Component name + requirement: Requirement description + score: Match score + + Returns: + Human-readable explanation + + Example: + >>> explain_match("viewport", "scrollable display", 90) + "viewport is a strong match (90/100) for 'scrollable display' because..." + """ + if component not in COMPONENT_CAPABILITIES: + return f"{component} is not a known component" + + comp_info = COMPONENT_CAPABILITIES[component] + requirement_lower = requirement.lower() + + # Find which keywords matched + matched_keywords = [kw for kw in comp_info['keywords'] if kw in requirement_lower] + + explanation_parts = [] + + if score >= 80: + explanation_parts.append(f"{component} is a strong match ({score}/100)") + elif score >= 50: + explanation_parts.append(f"{component} is a good match ({score}/100)") + else: + explanation_parts.append(f"{component} is a weak match ({score}/100)") + + explanation_parts.append(f"for '{requirement}'") + + if matched_keywords: + explanation_parts.append(f"because it handles: {', '.join(matched_keywords)}") + + # Add use case + explanation_parts.append(f"Common use cases: {', '.join(comp_info['use_cases'])}") + + return " ".join(explanation_parts) + "." + + +def rank_components_by_relevance( + requirements: List[str], + min_score: int = 50 +) -> List[Tuple[str, int, List[str]]]: + """ + Rank all components by relevance to requirements. + + Args: + requirements: List of feature requirements + min_score: Minimum score to include (default: 50) + + Returns: + List of tuples: (component, total_score, matching_requirements) + Sorted by total_score descending + + Example: + >>> rank_components_by_relevance(["scroll", "display text"]) + [('viewport', 180, ['scroll', 'display text']), ...] + """ + component_scores = {} + component_matches = {} + + all_components = list(COMPONENT_CAPABILITIES.keys()) + + for component in all_components: + total_score = 0 + matching_reqs = [] + + for req in requirements: + score = match_score(req, component) + if score >= min_score: + total_score += score + matching_reqs.append(req) + + if total_score > 0: + component_scores[component] = total_score + component_matches[component] = matching_reqs + + # Sort by score + ranked = sorted( + component_scores.items(), + key=lambda x: x[1], + reverse=True + ) + + return [(comp, score, component_matches[comp]) for comp, score in ranked] + + +def main(): + """Test component matcher.""" + print("Testing Component Matcher\n" + "=" * 50) + + # Test 1: Match score + print("\n1. Testing match_score()...") + score = match_score("scrollable log display", "viewport") + print(f" Score for 'scrollable log display' + viewport: {score}") + assert score > 50, "Should have good score" + print(" ✓ Match scoring works") + + # Test 2: Find best match + print("\n2. Testing find_best_match()...") + component, score = find_best_match("need to show progress while installing") + print(f" Best match: {component} ({score})") + assert component in ['progress', 'spinner'], "Should match progress-related component" + print(" ✓ Best match finding works") + + # Test 3: Suggest combinations + print("\n3. Testing suggest_combinations()...") + combos = suggest_combinations(["display logs", "search logs", "scroll through logs"]) + print(f" Suggested combinations: {combos}") + assert len(combos) > 0, "Should suggest at least one combination" + print(" ✓ Combination suggestion works") + + # Test 4: Get alternatives + print("\n4. Testing get_alternatives()...") + alts = get_alternatives('viewport') + print(f" Alternatives to viewport: {alts}") + assert 'pager' in alts, "Should include pager as alternative" + print(" ✓ Alternative suggestions work") + + # Test 5: Explain match + print("\n5. Testing explain_match()...") + explanation = explain_match("viewport", "scrollable display", 90) + print(f" Explanation: {explanation}") + assert "strong match" in explanation, "Should indicate strong match" + print(" ✓ Match explanation works") + + # Test 6: Rank components + print("\n6. Testing rank_components_by_relevance()...") + ranked = rank_components_by_relevance( + ["scroll", "display", "text", "search"], + min_score=40 + ) + print(f" Top 3 components:") + for i, (comp, score, reqs) in enumerate(ranked[:3], 1): + print(f" {i}. {comp} (score: {score}) - matches: {reqs}") + assert len(ranked) > 0, "Should rank some components" + print(" ✓ Component ranking works") + + print("\n✅ All tests passed!") + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/bubbletea-designer/scripts/utils/helpers.py b/.claude/skills/bubbletea-designer/scripts/utils/helpers.py new file mode 100644 index 00000000..1a74f8e2 --- /dev/null +++ b/.claude/skills/bubbletea-designer/scripts/utils/helpers.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +""" +General helper utilities for Bubble Tea Designer. +""" + +from datetime import datetime +from typing import Optional + + +def get_timestamp() -> str: + """Get current timestamp in ISO format.""" + return datetime.now().isoformat() + + +def format_list_markdown(items: list, ordered: bool = False) -> str: + """Format list as markdown.""" + if not items: + return "" + + if ordered: + return "\n".join(f"{i}. {item}" for i, item in enumerate(items, 1)) + else: + return "\n".join(f"- {item}" for item in items) + + +def truncate_text(text: str, max_length: int = 100) -> str: + """Truncate text to max length with ellipsis.""" + if len(text) <= max_length: + return text + return text[:max_length-3] + "..." + + +def estimate_complexity(num_components: int, num_views: int = 1) -> str: + """Estimate implementation complexity.""" + if num_components <= 2 and num_views == 1: + return "Simple (1-2 hours)" + elif num_components <= 4 and num_views <= 2: + return "Medium (2-4 hours)" + else: + return "Complex (4+ hours)" diff --git a/.claude/skills/bubbletea-designer/scripts/utils/inventory_loader.py b/.claude/skills/bubbletea-designer/scripts/utils/inventory_loader.py new file mode 100644 index 00000000..7385229b --- /dev/null +++ b/.claude/skills/bubbletea-designer/scripts/utils/inventory_loader.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 +""" +Inventory loader for Bubble Tea examples. +Loads and parses CONTEXTUAL-INVENTORY.md from charm-examples-inventory. +""" + +import os +import re +from typing import Dict, List, Optional, Tuple +from pathlib import Path +import logging + +logger = logging.getLogger(__name__) + + +class InventoryLoadError(Exception): + """Raised when inventory cannot be loaded.""" + pass + + +class Example: + """Represents a single Bubble Tea example.""" + + def __init__(self, name: str, file_path: str, capability: str): + self.name = name + self.file_path = file_path + self.capability = capability + self.key_patterns: List[str] = [] + self.components: List[str] = [] + self.use_cases: List[str] = [] + + def __repr__(self): + return f"Example({self.name}, {self.capability})" + + +class Inventory: + """Bubble Tea examples inventory.""" + + def __init__(self, base_path: str): + self.base_path = base_path + self.examples: Dict[str, Example] = {} + self.capabilities: Dict[str, List[Example]] = {} + self.components: Dict[str, List[Example]] = {} + + def add_example(self, example: Example): + """Add example to inventory.""" + self.examples[example.name] = example + + # Index by capability + if example.capability not in self.capabilities: + self.capabilities[example.capability] = [] + self.capabilities[example.capability].append(example) + + # Index by components + for component in example.components: + if component not in self.components: + self.components[component] = [] + self.components[component].append(example) + + def search_by_keyword(self, keyword: str) -> List[Example]: + """Search examples by keyword in name or patterns.""" + keyword_lower = keyword.lower() + results = [] + + for example in self.examples.values(): + if keyword_lower in example.name.lower(): + results.append(example) + continue + + for pattern in example.key_patterns: + if keyword_lower in pattern.lower(): + results.append(example) + break + + return results + + def get_by_capability(self, capability: str) -> List[Example]: + """Get all examples for a capability.""" + return self.capabilities.get(capability, []) + + def get_by_component(self, component: str) -> List[Example]: + """Get all examples using a component.""" + return self.components.get(component, []) + + +def load_inventory(inventory_path: Optional[str] = None) -> Inventory: + """ + Load Bubble Tea examples inventory from CONTEXTUAL-INVENTORY.md. + + Args: + inventory_path: Path to charm-examples-inventory directory + If None, tries to find it automatically + + Returns: + Loaded Inventory object + + Raises: + InventoryLoadError: If inventory cannot be loaded + + Example: + >>> inv = load_inventory("/path/to/charm-examples-inventory") + >>> examples = inv.search_by_keyword("progress") + """ + if inventory_path is None: + inventory_path = _find_inventory_path() + + inventory_file = Path(inventory_path) / "bubbletea" / "examples" / "CONTEXTUAL-INVENTORY.md" + + if not inventory_file.exists(): + raise InventoryLoadError( + f"Inventory file not found: {inventory_file}\n" + f"Expected at: {inventory_path}/bubbletea/examples/CONTEXTUAL-INVENTORY.md" + ) + + logger.info(f"Loading inventory from: {inventory_file}") + + with open(inventory_file, 'r') as f: + content = f.read() + + inventory = parse_inventory_markdown(content, str(inventory_path)) + + logger.info(f"Loaded {len(inventory.examples)} examples") + logger.info(f"Categories: {len(inventory.capabilities)}") + + return inventory + + +def parse_inventory_markdown(content: str, base_path: str) -> Inventory: + """ + Parse CONTEXTUAL-INVENTORY.md markdown content. + + Args: + content: Markdown content + base_path: Base path for example files + + Returns: + Inventory object with parsed examples + """ + inventory = Inventory(base_path) + + # Parse quick reference table + table_matches = re.finditer( + r'\|\s*(.+?)\s*\|\s*`(.+?)`\s*\|', + content + ) + + need_to_file = {} + for match in table_matches: + need = match.group(1).strip() + file_path = match.group(2).strip() + need_to_file[need] = file_path + + # Parse detailed sections (## Examples by Capability) + capability_pattern = r'### (.+?)\n\n\*\*Use (.+?) when you need:\*\*(.+?)(?=\n\n\*\*|### |\Z)' + + capability_sections = re.finditer(capability_pattern, content, re.DOTALL) + + for section in capability_sections: + capability = section.group(1).strip() + example_name = section.group(2).strip() + description = section.group(3).strip() + + # Extract file path and key patterns + file_match = re.search(r'\*\*File\*\*: `(.+?)`', description) + patterns_match = re.search(r'\*\*Key patterns\*\*: (.+?)(?=\n|$)', description) + + if file_match: + file_path = file_match.group(1).strip() + example = Example(example_name, file_path, capability) + + if patterns_match: + patterns_text = patterns_match.group(1).strip() + example.key_patterns = [p.strip() for p in patterns_text.split(',')] + + # Extract components from file name and patterns + example.components = _extract_components(example_name, example.key_patterns) + + inventory.add_example(example) + + return inventory + + +def _extract_components(name: str, patterns: List[str]) -> List[str]: + """Extract component names from example name and patterns.""" + components = [] + + # Common component keywords + component_keywords = [ + 'textinput', 'textarea', 'viewport', 'table', 'list', 'pager', + 'paginator', 'spinner', 'progress', 'timer', 'stopwatch', + 'filepicker', 'help', 'tabs', 'autocomplete' + ] + + name_lower = name.lower() + for keyword in component_keywords: + if keyword in name_lower: + components.append(keyword) + + for pattern in patterns: + pattern_lower = pattern.lower() + for keyword in component_keywords: + if keyword in pattern_lower and keyword not in components: + components.append(keyword) + + return components + + +def _find_inventory_path() -> str: + """ + Try to find charm-examples-inventory automatically. + + Searches in common locations: + - ./charm-examples-inventory + - ../charm-examples-inventory + - ~/charmtuitemplate/vinw/charm-examples-inventory + + Returns: + Path to inventory directory + + Raises: + InventoryLoadError: If not found + """ + search_paths = [ + Path.cwd() / "charm-examples-inventory", + Path.cwd().parent / "charm-examples-inventory", + Path.home() / "charmtuitemplate" / "vinw" / "charm-examples-inventory" + ] + + for path in search_paths: + if (path / "bubbletea" / "examples" / "CONTEXTUAL-INVENTORY.md").exists(): + logger.info(f"Found inventory at: {path}") + return str(path) + + raise InventoryLoadError( + "Could not find charm-examples-inventory automatically.\n" + f"Searched: {[str(p) for p in search_paths]}\n" + "Please provide inventory_path parameter." + ) + + +def build_capability_index(inventory: Inventory) -> Dict[str, List[str]]: + """ + Build index of capabilities to example names. + + Args: + inventory: Loaded inventory + + Returns: + Dict mapping capability names to example names + """ + index = {} + for capability, examples in inventory.capabilities.items(): + index[capability] = [ex.name for ex in examples] + return index + + +def build_component_index(inventory: Inventory) -> Dict[str, List[str]]: + """ + Build index of components to example names. + + Args: + inventory: Loaded inventory + + Returns: + Dict mapping component names to example names + """ + index = {} + for component, examples in inventory.components.items(): + index[component] = [ex.name for ex in examples] + return index + + +def get_example_details(inventory: Inventory, example_name: str) -> Optional[Example]: + """ + Get detailed information about a specific example. + + Args: + inventory: Loaded inventory + example_name: Name of example to look up + + Returns: + Example object or None if not found + """ + return inventory.examples.get(example_name) + + +def main(): + """Test inventory loader.""" + logging.basicConfig(level=logging.INFO) + + print("Testing Inventory Loader\n" + "=" * 50) + + try: + # Load inventory + print("\n1. Loading inventory...") + inventory = load_inventory() + print(f"✓ Loaded {len(inventory.examples)} examples") + print(f"✓ {len(inventory.capabilities)} capability categories") + + # Test search + print("\n2. Testing keyword search...") + results = inventory.search_by_keyword("progress") + print(f"✓ Found {len(results)} examples for 'progress':") + for ex in results[:3]: + print(f" - {ex.name} ({ex.capability})") + + # Test capability lookup + print("\n3. Testing capability lookup...") + cap_examples = inventory.get_by_capability("Installation and Progress Tracking") + print(f"✓ Found {len(cap_examples)} installation examples") + + # Test component lookup + print("\n4. Testing component lookup...") + comp_examples = inventory.get_by_component("spinner") + print(f"✓ Found {len(comp_examples)} examples using 'spinner'") + + # Test indices + print("\n5. Building indices...") + cap_index = build_capability_index(inventory) + comp_index = build_component_index(inventory) + print(f"✓ Capability index: {len(cap_index)} categories") + print(f"✓ Component index: {len(comp_index)} components") + + print("\n✅ All tests passed!") + + except InventoryLoadError as e: + print(f"\n❌ Error loading inventory: {e}") + return 1 + + return 0 + + +if __name__ == "__main__": + exit(main()) diff --git a/.claude/skills/bubbletea-designer/scripts/utils/template_generator.py b/.claude/skills/bubbletea-designer/scripts/utils/template_generator.py new file mode 100644 index 00000000..9a1f8e8d --- /dev/null +++ b/.claude/skills/bubbletea-designer/scripts/utils/template_generator.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +""" +Template generator for Bubble Tea TUIs. +Generates code scaffolding and boilerplate. +""" + +from typing import List, Dict + + +def generate_model_struct(components: List[str], archetype: str) -> str: + """Generate model struct with components.""" + component_fields = { + 'viewport': ' viewport viewport.Model', + 'textinput': ' textInput textinput.Model', + 'textarea': ' textArea textarea.Model', + 'table': ' table table.Model', + 'list': ' list list.Model', + 'progress': ' progress progress.Model', + 'spinner': ' spinner spinner.Model' + } + + fields = [] + for comp in components: + if comp in component_fields: + fields.append(component_fields[comp]) + + # Add common fields + fields.extend([ + ' width int', + ' height int', + ' ready bool' + ]) + + return f"""type model struct {{ +{chr(10).join(fields)} +}}""" + + +def generate_init_function(components: List[str]) -> str: + """Generate Init() function.""" + inits = [] + for comp in components: + if comp == 'viewport': + inits.append(' m.viewport = viewport.New(80, 20)') + elif comp == 'textinput': + inits.append(' m.textInput = textinput.New()') + inits.append(' m.textInput.Focus()') + elif comp == 'spinner': + inits.append(' m.spinner = spinner.New()') + inits.append(' m.spinner.Spinner = spinner.Dot') + elif comp == 'progress': + inits.append(' m.progress = progress.New(progress.WithDefaultGradient())') + + init_cmds = ', '.join([f'{c}.Init()' for c in components if c != 'viewport']) + + return f"""func (m model) Init() tea.Cmd {{ +{chr(10).join(inits) if inits else ' // Initialize components'} + return tea.Batch({init_cmds if init_cmds else 'nil'}) +}}""" + + +def generate_update_skeleton(interactions: Dict) -> str: + """Generate Update() skeleton.""" + return """func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + } + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.ready = true + } + + // Update components + // TODO: Add component update logic + + return m, nil +}""" + + +def generate_view_skeleton(components: List[str]) -> str: + """Generate View() skeleton.""" + renders = [] + for comp in components: + renders.append(f' // Render {comp}') + renders.append(f' // views = append(views, m.{comp}.View())') + + return f"""func (m model) View() string {{ + if !m.ready {{ + return "Loading..." + }} + + var views []string + +{chr(10).join(renders)} + + return lipgloss.JoinVertical(lipgloss.Left, views...) +}}""" + + +def generate_main_go(components: List[str], archetype: str) -> str: + """Generate complete main.go scaffold.""" + imports = ['github.com/charmbracelet/bubbletea'] + + if 'viewport' in components: + imports.append('github.com/charmbracelet/bubbles/viewport') + if 'textinput' in components: + imports.append('github.com/charmbracelet/bubbles/textinput') + if any(c in components for c in ['table', 'list', 'spinner', 'progress']): + imports.append('github.com/charmbracelet/bubbles/' + components[0]) + + imports.append('github.com/charmbracelet/lipgloss') + + import_block = '\n '.join(f'"{imp}"' for imp in imports) + + return f"""package main + +import ( + {import_block} +) + +{generate_model_struct(components, archetype)} + +{generate_init_function(components)} + +{generate_update_skeleton({})} + +{generate_view_skeleton(components)} + +func main() {{ + p := tea.NewProgram(model{{}}, tea.WithAltScreen()) + if _, err := p.Run(); err != nil {{ + panic(err) + }} +}} +""" diff --git a/.claude/skills/bubbletea-designer/scripts/utils/validators/__init__.py b/.claude/skills/bubbletea-designer/scripts/utils/validators/__init__.py new file mode 100644 index 00000000..367a1228 --- /dev/null +++ b/.claude/skills/bubbletea-designer/scripts/utils/validators/__init__.py @@ -0,0 +1,26 @@ +"""Validators for Bubble Tea Designer.""" + +from .requirement_validator import ( + RequirementValidator, + validate_description_clarity, + validate_requirements_completeness, + ValidationReport, + ValidationResult, + ValidationLevel +) + +from .design_validator import ( + DesignValidator, + validate_component_fit +) + +__all__ = [ + 'RequirementValidator', + 'validate_description_clarity', + 'validate_requirements_completeness', + 'DesignValidator', + 'validate_component_fit', + 'ValidationReport', + 'ValidationResult', + 'ValidationLevel' +] diff --git a/.claude/skills/bubbletea-designer/scripts/utils/validators/__pycache__/__init__.cpython-311.pyc b/.claude/skills/bubbletea-designer/scripts/utils/validators/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 00000000..9fc2ea6c Binary files /dev/null and b/.claude/skills/bubbletea-designer/scripts/utils/validators/__pycache__/__init__.cpython-311.pyc differ diff --git a/.claude/skills/bubbletea-designer/scripts/utils/validators/__pycache__/design_validator.cpython-311.pyc b/.claude/skills/bubbletea-designer/scripts/utils/validators/__pycache__/design_validator.cpython-311.pyc new file mode 100644 index 00000000..0a8b954a Binary files /dev/null and b/.claude/skills/bubbletea-designer/scripts/utils/validators/__pycache__/design_validator.cpython-311.pyc differ diff --git a/.claude/skills/bubbletea-designer/scripts/utils/validators/__pycache__/requirement_validator.cpython-311.pyc b/.claude/skills/bubbletea-designer/scripts/utils/validators/__pycache__/requirement_validator.cpython-311.pyc new file mode 100644 index 00000000..b5fa0665 Binary files /dev/null and b/.claude/skills/bubbletea-designer/scripts/utils/validators/__pycache__/requirement_validator.cpython-311.pyc differ diff --git a/.claude/skills/bubbletea-designer/scripts/utils/validators/design_validator.py b/.claude/skills/bubbletea-designer/scripts/utils/validators/design_validator.py new file mode 100644 index 00000000..ca451dba --- /dev/null +++ b/.claude/skills/bubbletea-designer/scripts/utils/validators/design_validator.py @@ -0,0 +1,425 @@ +#!/usr/bin/env python3 +""" +Design validators for Bubble Tea Designer. +Validates design outputs (component selections, architecture, workflows). +""" + +from typing import Dict, List, Optional +from .requirement_validator import ValidationReport, ValidationResult, ValidationLevel + + +class DesignValidator: + """Validates TUI design outputs.""" + + def validate_component_selection( + self, + components: Dict, + requirements: Dict + ) -> ValidationReport: + """ + Validate component selection against requirements. + + Args: + components: Selected components dict + requirements: Original requirements + + Returns: + ValidationReport + """ + report = ValidationReport() + + # Check 1: At least one component selected + primary = components.get('primary_components', []) + has_components = len(primary) > 0 + + report.add(ValidationResult( + check_name="has_components", + level=ValidationLevel.CRITICAL, + passed=has_components, + message=f"Primary components selected: {len(primary)}" + )) + + # Check 2: Components cover requirements + features = set(requirements.get('features', [])) + if features and primary: + # Check if components mention required features + covered_features = set() + for comp in primary: + justification = comp.get('justification', '').lower() + for feature in features: + if feature.lower() in justification: + covered_features.add(feature) + + coverage = len(covered_features) / len(features) * 100 if features else 0 + report.add(ValidationResult( + check_name="feature_coverage", + level=ValidationLevel.WARNING, + passed=coverage >= 50, + message=f"Feature coverage: {coverage:.0f}% ({len(covered_features)}/{len(features)})" + )) + + # Check 3: No duplicate components + comp_names = [c.get('component', '') for c in primary] + duplicates = [name for name in comp_names if comp_names.count(name) > 1] + + report.add(ValidationResult( + check_name="no_duplicates", + level=ValidationLevel.WARNING, + passed=len(duplicates) == 0, + message="No duplicate components" if not duplicates else + f"Duplicate components: {set(duplicates)}" + )) + + # Check 4: Reasonable number of components (not too many) + reasonable_count = len(primary) <= 6 + report.add(ValidationResult( + check_name="reasonable_count", + level=ValidationLevel.INFO, + passed=reasonable_count, + message=f"Component count: {len(primary)} ({'reasonable' if reasonable_count else 'may be too many'})" + )) + + # Check 5: Each component has justification + all_justified = all('justification' in c for c in primary) + report.add(ValidationResult( + check_name="all_justified", + level=ValidationLevel.INFO, + passed=all_justified, + message="All components justified" if all_justified else + "Some components missing justification" + )) + + return report + + def validate_architecture(self, architecture: Dict) -> ValidationReport: + """ + Validate architecture design. + + Args: + architecture: Architecture specification + + Returns: + ValidationReport + """ + report = ValidationReport() + + # Check 1: Has model struct + has_model = 'model_struct' in architecture and architecture['model_struct'] + report.add(ValidationResult( + check_name="has_model_struct", + level=ValidationLevel.CRITICAL, + passed=has_model, + message="Model struct defined" if has_model else "Missing model struct" + )) + + # Check 2: Has message handlers + handlers = architecture.get('message_handlers', {}) + has_handlers = len(handlers) > 0 + + report.add(ValidationResult( + check_name="has_message_handlers", + level=ValidationLevel.CRITICAL, + passed=has_handlers, + message=f"Message handlers defined: {len(handlers)}" + )) + + # Check 3: Has key message handler (keyboard) + has_key_handler = 'tea.KeyMsg' in handlers or 'KeyMsg' in handlers + + report.add(ValidationResult( + check_name="has_keyboard_handler", + level=ValidationLevel.WARNING, + passed=has_key_handler, + message="Keyboard handler present" if has_key_handler else + "Missing keyboard handler (tea.KeyMsg)" + )) + + # Check 4: Has view logic + has_view = 'view_logic' in architecture and architecture['view_logic'] + report.add(ValidationResult( + check_name="has_view_logic", + level=ValidationLevel.CRITICAL, + passed=has_view, + message="View logic defined" if has_view else "Missing view logic" + )) + + # Check 5: Has diagrams + diagrams = architecture.get('diagrams', {}) + has_diagrams = len(diagrams) > 0 + + report.add(ValidationResult( + check_name="has_diagrams", + level=ValidationLevel.INFO, + passed=has_diagrams, + message=f"Architecture diagrams: {len(diagrams)}" + )) + + return report + + def validate_workflow_completeness(self, workflow: Dict) -> ValidationReport: + """ + Validate workflow has all necessary phases and tasks. + + Args: + workflow: Workflow specification + + Returns: + ValidationReport + """ + report = ValidationReport() + + # Check 1: Has phases + phases = workflow.get('phases', []) + has_phases = len(phases) > 0 + + report.add(ValidationResult( + check_name="has_phases", + level=ValidationLevel.CRITICAL, + passed=has_phases, + message=f"Workflow phases: {len(phases)}" + )) + + if not phases: + return report + + # Check 2: Each phase has tasks + all_have_tasks = all(len(phase.get('tasks', [])) > 0 for phase in phases) + + report.add(ValidationResult( + check_name="all_phases_have_tasks", + level=ValidationLevel.WARNING, + passed=all_have_tasks, + message="All phases have tasks" if all_have_tasks else + "Some phases are missing tasks" + )) + + # Check 3: Has testing checkpoints + checkpoints = workflow.get('testing_checkpoints', []) + has_testing = len(checkpoints) > 0 + + report.add(ValidationResult( + check_name="has_testing", + level=ValidationLevel.WARNING, + passed=has_testing, + message=f"Testing checkpoints: {len(checkpoints)}" + )) + + # Check 4: Reasonable phase count (2-6 phases) + reasonable_phases = 2 <= len(phases) <= 6 + + report.add(ValidationResult( + check_name="reasonable_phases", + level=ValidationLevel.INFO, + passed=reasonable_phases, + message=f"Phase count: {len(phases)} ({'good' if reasonable_phases else 'unusual'})" + )) + + # Check 5: Has time estimates + total_time = workflow.get('total_estimated_time') + has_estimate = bool(total_time) + + report.add(ValidationResult( + check_name="has_time_estimate", + level=ValidationLevel.INFO, + passed=has_estimate, + message=f"Time estimate: {total_time or 'missing'}" + )) + + return report + + def validate_design_report(self, report_data: Dict) -> ValidationReport: + """ + Validate complete design report. + + Args: + report_data: Complete design report + + Returns: + ValidationReport + """ + report = ValidationReport() + + # Check all required sections present + required_sections = ['requirements', 'components', 'patterns', 'architecture', 'workflow'] + sections = report_data.get('sections', {}) + + for section in required_sections: + has_section = section in sections and sections[section] + report.add(ValidationResult( + check_name=f"has_{section}_section", + level=ValidationLevel.CRITICAL, + passed=has_section, + message=f"Section '{section}': {'present' if has_section else 'MISSING'}" + )) + + # Check has summary + has_summary = 'summary' in report_data and report_data['summary'] + report.add(ValidationResult( + check_name="has_summary", + level=ValidationLevel.WARNING, + passed=has_summary, + message="Summary present" if has_summary else "Missing summary" + )) + + # Check has scaffolding + has_scaffolding = 'scaffolding' in report_data and report_data['scaffolding'] + report.add(ValidationResult( + check_name="has_scaffolding", + level=ValidationLevel.INFO, + passed=has_scaffolding, + message="Code scaffolding included" if has_scaffolding else + "No code scaffolding" + )) + + # Check has next steps + next_steps = report_data.get('next_steps', []) + has_next_steps = len(next_steps) > 0 + + report.add(ValidationResult( + check_name="has_next_steps", + level=ValidationLevel.INFO, + passed=has_next_steps, + message=f"Next steps: {len(next_steps)}" + )) + + return report + + +def validate_component_fit(component: str, requirement: str) -> bool: + """ + Quick check if component fits requirement. + + Args: + component: Component name (e.g., "viewport.Model") + requirement: Requirement description + + Returns: + True if component appears suitable + """ + component_lower = component.lower() + requirement_lower = requirement.lower() + + # Simple keyword matching + keyword_map = { + 'viewport': ['scroll', 'view', 'display', 'content'], + 'textinput': ['input', 'text', 'search', 'query'], + 'textarea': ['edit', 'multi-line', 'text area'], + 'table': ['table', 'tabular', 'rows', 'columns'], + 'list': ['list', 'items', 'select', 'choose'], + 'progress': ['progress', 'loading', 'installation'], + 'spinner': ['loading', 'spinner', 'wait'], + 'filepicker': ['file', 'select file', 'choose file'] + } + + for comp_key, keywords in keyword_map.items(): + if comp_key in component_lower: + return any(kw in requirement_lower for kw in keywords) + + return False + + +def main(): + """Test design validator.""" + print("Testing Design Validator\n" + "=" * 50) + + validator = DesignValidator() + + # Test 1: Component selection validation + print("\n1. Testing component selection validation...") + components = { + 'primary_components': [ + { + 'component': 'viewport.Model', + 'score': 95, + 'justification': 'Scrollable display for log content' + }, + { + 'component': 'textinput.Model', + 'score': 90, + 'justification': 'Search query input' + } + ] + } + requirements = { + 'features': ['display', 'search', 'scroll'] + } + report = validator.validate_component_selection(components, requirements) + print(f" {report.get_summary()}") + assert not report.has_critical_issues(), "Should pass for valid components" + print(" ✓ Component selection validated") + + # Test 2: Architecture validation + print("\n2. Testing architecture validation...") + architecture = { + 'model_struct': 'type model struct {...}', + 'message_handlers': { + 'tea.KeyMsg': 'handle keyboard', + 'tea.WindowSizeMsg': 'handle resize' + }, + 'view_logic': 'func (m model) View() string {...}', + 'diagrams': { + 'component_hierarchy': '...' + } + } + report = validator.validate_architecture(architecture) + print(f" {report.get_summary()}") + assert report.all_passed(), "Should pass for complete architecture" + print(" ✓ Architecture validated") + + # Test 3: Workflow validation + print("\n3. Testing workflow validation...") + workflow = { + 'phases': [ + { + 'name': 'Phase 1: Setup', + 'tasks': [ + {'task': 'Initialize project'}, + {'task': 'Install dependencies'} + ] + }, + { + 'name': 'Phase 2: Core', + 'tasks': [ + {'task': 'Implement viewport'} + ] + } + ], + 'testing_checkpoints': ['After Phase 1', 'After Phase 2'], + 'total_estimated_time': '2 hours' + } + report = validator.validate_workflow_completeness(workflow) + print(f" {report.get_summary()}") + assert report.all_passed(), "Should pass for complete workflow" + print(" ✓ Workflow validated") + + # Test 4: Complete design report validation + print("\n4. Testing complete design report validation...") + design_report = { + 'sections': { + 'requirements': {...}, + 'components': {...}, + 'patterns': {...}, + 'architecture': {...}, + 'workflow': {...} + }, + 'summary': 'TUI design for log viewer', + 'scaffolding': 'package main...', + 'next_steps': ['Step 1', 'Step 2'] + } + report = validator.validate_design_report(design_report) + print(f" {report.get_summary()}") + assert report.all_passed(), "Should pass for complete report" + print(" ✓ Design report validated") + + # Test 5: Component fit check + print("\n5. Testing component fit check...") + assert validate_component_fit("viewport.Model", "scrollable log display") + assert validate_component_fit("textinput.Model", "search query input") + assert not validate_component_fit("spinner.Model", "text input field") + print(" ✓ Component fit checks working") + + print("\n✅ All tests passed!") + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/bubbletea-designer/scripts/utils/validators/requirement_validator.py b/.claude/skills/bubbletea-designer/scripts/utils/validators/requirement_validator.py new file mode 100644 index 00000000..3adb2c1a --- /dev/null +++ b/.claude/skills/bubbletea-designer/scripts/utils/validators/requirement_validator.py @@ -0,0 +1,393 @@ +#!/usr/bin/env python3 +""" +Requirement validators for Bubble Tea Designer. +Validates user input and extracted requirements. +""" + +from typing import Dict, List, Optional, Tuple +from dataclasses import dataclass +from enum import Enum + + +class ValidationLevel(Enum): + """Severity levels for validation results.""" + CRITICAL = "critical" + WARNING = "warning" + INFO = "info" + + +@dataclass +class ValidationResult: + """Single validation check result.""" + check_name: str + level: ValidationLevel + passed: bool + message: str + details: Optional[Dict] = None + + +class ValidationReport: + """Collection of validation results.""" + + def __init__(self): + self.results: List[ValidationResult] = [] + + def add(self, result: ValidationResult): + """Add validation result.""" + self.results.append(result) + + def has_critical_issues(self) -> bool: + """Check if any critical issues found.""" + return any( + r.level == ValidationLevel.CRITICAL and not r.passed + for r in self.results + ) + + def all_passed(self) -> bool: + """Check if all validations passed.""" + return all(r.passed for r in self.results) + + def get_warnings(self) -> List[str]: + """Get all warning messages.""" + return [ + r.message for r in self.results + if r.level == ValidationLevel.WARNING and not r.passed + ] + + def get_summary(self) -> str: + """Get summary of validation results.""" + total = len(self.results) + passed = sum(1 for r in self.results if r.passed) + critical = sum( + 1 for r in self.results + if r.level == ValidationLevel.CRITICAL and not r.passed + ) + + return ( + f"Validation: {passed}/{total} passed " + f"({critical} critical issues)" + ) + + def to_dict(self) -> Dict: + """Convert to dictionary.""" + return { + 'passed': self.all_passed(), + 'summary': self.get_summary(), + 'warnings': self.get_warnings(), + 'critical_issues': [ + r.message for r in self.results + if r.level == ValidationLevel.CRITICAL and not r.passed + ], + 'all_results': [ + { + 'check': r.check_name, + 'level': r.level.value, + 'passed': r.passed, + 'message': r.message + } + for r in self.results + ] + } + + +class RequirementValidator: + """Validates TUI requirements and descriptions.""" + + def validate_description(self, description: str) -> ValidationReport: + """ + Validate user-provided description. + + Args: + description: Natural language TUI description + + Returns: + ValidationReport with results + """ + report = ValidationReport() + + # Check 1: Not empty + report.add(ValidationResult( + check_name="not_empty", + level=ValidationLevel.CRITICAL, + passed=bool(description and description.strip()), + message="Description is empty" if not description else "Description provided" + )) + + if not description: + return report + + # Check 2: Minimum length (at least 10 words) + words = description.split() + min_words = 10 + has_min_length = len(words) >= min_words + + report.add(ValidationResult( + check_name="minimum_length", + level=ValidationLevel.WARNING, + passed=has_min_length, + message=f"Description has {len(words)} words (recommended: ≥{min_words})" + )) + + # Check 3: Contains actionable verbs + action_verbs = ['show', 'display', 'view', 'create', 'select', 'navigate', + 'edit', 'input', 'track', 'monitor', 'search', 'filter'] + has_action = any(verb in description.lower() for verb in action_verbs) + + report.add(ValidationResult( + check_name="has_actions", + level=ValidationLevel.WARNING, + passed=has_action, + message="Description contains action verbs" if has_action else + "Consider adding action verbs (show, select, edit, etc.)" + )) + + # Check 4: Contains data type mentions + data_types = ['file', 'text', 'data', 'table', 'list', 'log', 'config', + 'message', 'package', 'item', 'entry'] + has_data = any(dtype in description.lower() for dtype in data_types) + + report.add(ValidationResult( + check_name="has_data_types", + level=ValidationLevel.INFO, + passed=has_data, + message="Data types mentioned" if has_data else + "No explicit data types mentioned" + )) + + return report + + def validate_requirements(self, requirements: Dict) -> ValidationReport: + """ + Validate extracted requirements structure. + + Args: + requirements: Structured requirements dict + + Returns: + ValidationReport + """ + report = ValidationReport() + + # Check 1: Has archetype + has_archetype = 'archetype' in requirements and requirements['archetype'] + report.add(ValidationResult( + check_name="has_archetype", + level=ValidationLevel.CRITICAL, + passed=has_archetype, + message=f"TUI archetype: {requirements.get('archetype', 'MISSING')}" + )) + + # Check 2: Has features + features = requirements.get('features', []) + has_features = len(features) > 0 + report.add(ValidationResult( + check_name="has_features", + level=ValidationLevel.CRITICAL, + passed=has_features, + message=f"Features identified: {len(features)}" + )) + + # Check 3: Has interactions + interactions = requirements.get('interactions', {}) + keyboard_interactions = interactions.get('keyboard', []) + has_interactions = len(keyboard_interactions) > 0 + + report.add(ValidationResult( + check_name="has_interactions", + level=ValidationLevel.WARNING, + passed=has_interactions, + message=f"Keyboard interactions: {len(keyboard_interactions)}" + )) + + # Check 4: Has view specification + views = requirements.get('views', '') + has_views = bool(views) + report.add(ValidationResult( + check_name="has_view_spec", + level=ValidationLevel.WARNING, + passed=has_views, + message=f"View type: {views or 'unspecified'}" + )) + + # Check 5: Completeness (has all expected keys) + expected_keys = ['archetype', 'features', 'interactions', 'data_types', 'views'] + missing_keys = set(expected_keys) - set(requirements.keys()) + + report.add(ValidationResult( + check_name="completeness", + level=ValidationLevel.INFO, + passed=len(missing_keys) == 0, + message=f"Complete structure" if not missing_keys else + f"Missing keys: {missing_keys}" + )) + + return report + + def suggest_clarifications(self, requirements: Dict) -> List[str]: + """ + Suggest clarifying questions based on incomplete requirements. + + Args: + requirements: Extracted requirements + + Returns: + List of clarifying questions to ask user + """ + questions = [] + + # Check if archetype is unclear + if not requirements.get('archetype') or requirements['archetype'] == 'general': + questions.append( + "What type of TUI is this? (file manager, installer, dashboard, " + "form, viewer, etc.)" + ) + + # Check if features are vague + features = requirements.get('features', []) + if len(features) < 2: + questions.append( + "What are the main features/capabilities needed? " + "(e.g., navigation, selection, editing, search, filtering)" + ) + + # Check if data type is unspecified + data_types = requirements.get('data_types', []) + if not data_types: + questions.append( + "What type of data will the TUI display? " + "(files, text, tabular data, logs, etc.)" + ) + + # Check if interaction is unspecified + interactions = requirements.get('interactions', {}) + if not interactions.get('keyboard') and not interactions.get('mouse'): + questions.append( + "How should users interact? Keyboard only, or mouse support needed?" + ) + + # Check if view type is unspecified + if not requirements.get('views'): + questions.append( + "Should this be single-view or multi-view? Need tabs or navigation?" + ) + + return questions + + +def validate_description_clarity(description: str) -> Tuple[bool, str]: + """ + Quick validation of description clarity. + + Args: + description: User description + + Returns: + Tuple of (is_clear, message) + """ + validator = RequirementValidator() + report = validator.validate_description(description) + + if report.has_critical_issues(): + return False, "Description has critical issues: " + report.get_summary() + + warnings = report.get_warnings() + if warnings: + return True, "Description OK with suggestions: " + "; ".join(warnings) + + return True, "Description is clear" + + +def validate_requirements_completeness(requirements: Dict) -> Tuple[bool, str]: + """ + Quick validation of requirements completeness. + + Args: + requirements: Extracted requirements dict + + Returns: + Tuple of (is_complete, message) + """ + validator = RequirementValidator() + report = validator.validate_requirements(requirements) + + if report.has_critical_issues(): + return False, "Requirements incomplete: " + report.get_summary() + + warnings = report.get_warnings() + if warnings: + return True, "Requirements OK with warnings: " + "; ".join(warnings) + + return True, "Requirements complete" + + +def main(): + """Test requirement validator.""" + print("Testing Requirement Validator\n" + "=" * 50) + + validator = RequirementValidator() + + # Test 1: Empty description + print("\n1. Testing empty description...") + report = validator.validate_description("") + print(f" {report.get_summary()}") + assert report.has_critical_issues(), "Should fail for empty description" + print(" ✓ Correctly detected empty description") + + # Test 2: Good description + print("\n2. Testing good description...") + good_desc = "Create a file manager TUI with three-column view showing parent directory, current directory, and file preview" + report = validator.validate_description(good_desc) + print(f" {report.get_summary()}") + print(" ✓ Good description validated") + + # Test 3: Vague description + print("\n3. Testing vague description...") + vague_desc = "Build a TUI" + report = validator.validate_description(vague_desc) + print(f" {report.get_summary()}") + warnings = report.get_warnings() + if warnings: + print(f" Warnings: {warnings}") + print(" ✓ Vague description detected") + + # Test 4: Requirements validation + print("\n4. Testing requirements validation...") + requirements = { + 'archetype': 'file-manager', + 'features': ['navigation', 'selection', 'preview'], + 'interactions': { + 'keyboard': ['arrows', 'enter', 'backspace'], + 'mouse': [] + }, + 'data_types': ['files', 'directories'], + 'views': 'multi' + } + report = validator.validate_requirements(requirements) + print(f" {report.get_summary()}") + assert report.all_passed(), "Should pass for complete requirements" + print(" ✓ Complete requirements validated") + + # Test 5: Incomplete requirements + print("\n5. Testing incomplete requirements...") + incomplete = { + 'archetype': '', + 'features': [] + } + report = validator.validate_requirements(incomplete) + print(f" {report.get_summary()}") + assert report.has_critical_issues(), "Should fail for incomplete requirements" + print(" ✓ Incomplete requirements detected") + + # Test 6: Clarification suggestions + print("\n6. Testing clarification suggestions...") + questions = validator.suggest_clarifications(incomplete) + print(f" Generated {len(questions)} clarifying questions:") + for i, q in enumerate(questions, 1): + print(f" {i}. {q}") + print(" ✓ Clarifications generated") + + print("\n✅ All tests passed!") + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/bubbletea-designer/skills/bubbletea-designer/SKILL.md b/.claude/skills/bubbletea-designer/skills/bubbletea-designer/SKILL.md new file mode 100644 index 00000000..5c1bb363 --- /dev/null +++ b/.claude/skills/bubbletea-designer/skills/bubbletea-designer/SKILL.md @@ -0,0 +1,1537 @@ +--- +name: bubbletea-designer +description: Automates Bubble Tea TUI design by analyzing requirements, mapping to appropriate components from the Charmbracelet ecosystem, generating component architecture, and creating implementation workflows. Use when designing terminal UIs, planning Bubble Tea applications, selecting components, or needing design guidance for TUI development. +--- + +# Bubble Tea TUI Designer + +Automate the design process for Bubble Tea terminal user interfaces with intelligent component mapping, architecture generation, and implementation planning. + +## When to Use This Skill + +This skill automatically activates when you need help designing, planning, or structuring Bubble Tea TUI applications: + +### Design & Planning + +Use this skill when you: +- **Design a new TUI application** from requirements +- **Plan component architecture** for terminal interfaces +- **Select appropriate Bubble Tea components** for your use case +- **Generate implementation workflows** with step-by-step guides +- **Map user requirements to Charmbracelet ecosystem** components + +### Typical Activation Phrases + +The skill responds to questions like: +- "Design a TUI for [use case]" +- "Create a file manager interface" +- "Build an installation progress tracker" +- "Which Bubble Tea components should I use for [feature]?" +- "Plan a multi-view dashboard TUI" +- "Generate architecture for a configuration wizard" +- "Automate TUI design for [application]" + +### TUI Types Supported + +- **File Managers**: Navigation, selection, preview +- **Installers/Package Managers**: Progress tracking, step indication +- **Dashboards**: Multi-view, tabs, real-time updates +- **Forms & Wizards**: Multi-step input, validation +- **Data Viewers**: Tables, lists, pagination +- **Log/Text Viewers**: Scrolling, searching, highlighting +- **Chat Interfaces**: Input + message display +- **Configuration Tools**: Interactive settings +- **Monitoring Tools**: Real-time data, charts +- **Menu Systems**: Selection, navigation + +## How It Works + +The Bubble Tea Designer follows a systematic 6-step design process: + +### 1. Requirement Analysis + +**Purpose**: Extract structured requirements from natural language descriptions + +**Process**: +- Parse user description +- Identify core features +- Extract interaction patterns +- Determine data types +- Classify TUI archetype + +**Output**: Structured requirements dictionary with: +- Features list +- Interaction types (keyboard, mouse, both) +- Data types (files, text, tabular, streaming) +- View requirements (single, multi-view, tabs) +- Special requirements (validation, progress, real-time) + +### 2. Component Mapping + +**Purpose**: Map requirements to appropriate Bubble Tea components + +**Process**: +- Match features to component capabilities +- Consider component combinations +- Evaluate alternatives +- Justify selections based on requirements + +**Output**: Component recommendations with: +- Primary components (core functionality) +- Supporting components (enhancements) +- Styling components (Lipgloss) +- Justification for each selection +- Alternative options considered + +### 3. Pattern Selection + +**Purpose**: Identify relevant example files from charm-examples-inventory + +**Process**: +- Search CONTEXTUAL-INVENTORY.md for matching patterns +- Filter by capability category +- Rank by relevance to requirements +- Select 3-5 most relevant examples + +**Output**: List of example files to reference: +- File path in charm-examples-inventory +- Capability category +- Key patterns to extract +- Specific lines or functions to study + +### 4. Architecture Design + +**Purpose**: Create component hierarchy and interaction model + +**Process**: +- Design model structure (what state to track) +- Plan Init() function (initialization commands) +- Design Update() function (message handling) +- Plan View() function (rendering strategy) +- Create component composition diagram + +**Output**: Architecture specification with: +- Model struct definition +- Component hierarchy (ASCII diagram) +- Message flow diagram +- State management plan +- Rendering strategy + +### 5. Workflow Generation + +**Purpose**: Create ordered implementation steps + +**Process**: +- Determine dependency order +- Break into logical phases +- Reference specific example files +- Include testing checkpoints + +**Output**: Step-by-step implementation plan: +- Phase breakdown (setup, components, integration, polish) +- Ordered tasks with dependencies +- File references for each step +- Testing milestones +- Estimated time per phase + +### 6. Comprehensive Design Report + +**Purpose**: Generate complete design document combining all analyses + +**Process**: +- Execute all 5 previous analyses +- Combine into unified document +- Add implementation guidance +- Include code scaffolding templates +- Generate README outline + +**Output**: Complete TUI design specification with: +- Executive summary +- All analysis results (requirements, components, patterns, architecture, workflow) +- Code scaffolding (model struct, basic Init/Update/View) +- File structure recommendation +- Next steps and resources + +## Data Source: Charm Examples Inventory + +This skill references a curated inventory of 46 Bubble Tea examples from the Charmbracelet ecosystem. + +### Inventory Structure + +**Location**: `charm-examples-inventory/bubbletea/examples/` + +**Index File**: `CONTEXTUAL-INVENTORY.md` + +**Categories** (11 capability groups): +1. Installation & Progress Tracking +2. Form Input & Validation +3. Data Display & Selection +4. Content Viewing +5. View Management & Navigation +6. Loading & Status Indicators +7. Time-Based Operations +8. Network & External Operations +9. Real-Time & Event Handling +10. Screen & Terminal Management +11. Input & Interaction + +### Component Coverage + +**Input Components**: +- `textinput` - Single-line text input +- `textarea` - Multi-line text editing +- `textinputs` - Multiple inputs with focus management +- `filepicker` - File system navigation and selection +- `autocomplete` - Text input with suggestions + +**Display Components**: +- `table` - Tabular data with row selection +- `list` - Filterable, paginated lists +- `viewport` - Scrollable content area +- `pager` - Document viewer +- `paginator` - Page-based navigation + +**Feedback Components**: +- `spinner` - Loading indicator +- `progress` - Progress bar (animated & static) +- `timer` - Countdown timer +- `stopwatch` - Elapsed time tracker + +**Layout Components**: +- `views` - Multiple screen states +- `composable-views` - Composed bubble models +- `tabs` - Tab-based navigation +- `help` - Help menu system + +**Utility Patterns**: +- HTTP requests (`http`) +- External commands (`exec`) +- Real-time events (`realtime`) +- Alt screen buffer (`altscreen-toggle`) +- Mouse support (`mouse`) +- Window resize (`window-size`) + +### Pattern Recognition + +The skill uses pattern matching to identify: + +**By Feature**: +- "progress tracking" → `progress`, `spinner`, `package-manager` +- "form with validation" → `credit-card-form`, `textinputs` +- "table display" → `table`, `table-resize` +- "file selection" → `file-picker`, `list-default` +- "multi-step process" → `views`, `package-manager` + +**By Interaction**: +- "keyboard navigation" → Most examples, especially `help` +- "mouse support" → `mouse`, `table` +- "scrolling" → `viewport`, `pager` +- "filtering" → `list-default`, `autocomplete` + +**By Architecture**: +- "single view" → `simple`, `spinner`, `textinput` +- "multi-view state machine" → `views` +- "composed models" → `composable-views` +- "tab navigation" → `tabs` + +## Workflow Details + +### Workflow 1: Quick Component Lookup + +**User Query**: "Which component should I use for [feature]?" + +**Process**: +1. Parse feature description +2. Search component taxonomy +3. Return top 3 matches with justification + +**Example**: +``` +User: "Which component for file selection?" + +Response: +Top recommendations: +1. filepicker.Model - Dedicated file system navigation + - Built-in directory traversal + - File type filtering + - Selection callbacks + Reference: examples/file-picker/main.go + +2. list.Model - Custom file list + - More control over display + - Custom item rendering + - Can add file metadata + Reference: examples/list-fancy/main.go + +3. table.Model - Tabular file display + - Show multiple file attributes + - Sortable columns + - Good for detailed views + Reference: examples/table/main.go +``` + +### Workflow 2: Design from Description + +**User Query**: "Design a TUI for [application]" + +**Process**: +1. Run `requirement_analysis(description)` +2. Run `component_mapping(requirements)` +3. Run `pattern_selection(components)` +4. Run `architecture_design(components, patterns)` +5. Run `workflow_generation(architecture)` +6. Combine into design report + +**Example**: +``` +User: "Design a TUI for a log file viewer with search and highlighting" + +Response: [Comprehensive design report including]: + +REQUIREMENTS: +- View: Scrollable text display +- Interaction: Search, highlight matches, navigate results +- Data: Log file lines (streaming or loaded) + +COMPONENTS: +- viewport.Model (scrollable display) +- textinput.Model (search query) +- Custom highlighter (Lipgloss styles) + +ARCHITECTURE: +model struct { + viewport viewport.Model + searchInput textinput.Model + logLines []string + searchMode bool + matches []int + currentMatch int +} + +WORKFLOW: +Phase 1: Basic viewer (viewport + file loading) +Phase 2: Add search input +Phase 3: Implement highlighting +Phase 4: Add navigation between matches +Phase 5: Polish (status bar, help text) + +[Full implementation steps with file references...] +``` + +### Workflow 3: Architecture Planning + +**User Query**: "Plan architecture for [complex TUI]" + +**Process**: +1. Identify sub-components +2. Design model composition +3. Create message flow diagram +4. Plan state management +5. Generate ASCII architecture diagram + +**Example**: +``` +User: "Plan architecture for a package manager TUI with installation queue and progress" + +Response: + +ARCHITECTURE DIAGRAM: +┌─────────────────────────────────────┐ +│ Main Model │ +├─────────────────────────────────────┤ +│ view: currentView │ +│ packages: []PackageItem │ +│ queue: InstallQueue │ +│ progress: ProgressState │ +└────────────┬───────────────┬────────┘ + │ │ + ┌────▼────┐ ┌───▼─────┐ + │ List │ │Progress │ + │ Model │ │ Model │ + └─────────┘ └─────────┘ + +STATE MANAGEMENT: +- currentView: enum (selectView, installView, completeView) +- packages: list of selectable items +- queue: ordered installation queue +- progress: current package progress (0-100) + +MESSAGE FLOW: +User Input → Update() → Route to current view handler +Install Start → Switch view + Init progress +Package Complete → Update progress + Next package +All Complete → Switch to complete view + +[Detailed implementation guidance...] +``` + +### Workflow 4: Pattern Selection + +**User Query**: "Show me examples for [capability]" + +**Process**: +1. Search CONTEXTUAL-INVENTORY.md +2. Filter by capability +3. Return sorted by relevance +4. Include key patterns to study + +**Example**: +``` +User: "Show me examples for progress tracking" + +Response: + +PROGRESS TRACKING EXAMPLES: + +1. package-manager (examples/package-manager/main.go) + Category: Installation & Progress Tracking + Key Patterns: + - Combined spinner + progress bar + - Sequential task execution + - Tea.Println for persistent output above TUI + - Custom completion messages + +2. progress-animated (examples/progress-animated/main.go) + Category: Loading & Status Indicators + Key Patterns: + - Gradient progress styling + - Smooth animation with FrameMsg + - Indeterminate/determinate modes + +3. progress-download (examples/progress-download/main.go) + Category: Loading & Status Indicators + Key Patterns: + - Network operation tracking + - Real-time percentage updates + - HTTP integration + +Study these in order: +1. progress-animated (learn basics) +2. package-manager (see real-world usage) +3. progress-download (network-specific) +``` + +## Available Scripts + +All scripts are in `scripts/` directory and can be run independently or through the main orchestrator. + +### Main Orchestrator + +**`design_tui.py`** + +Comprehensive design report generator - combines all analyses. + +**Usage**: +```python +from scripts.design_tui import comprehensive_tui_design_report + +report = comprehensive_tui_design_report( + description="Log viewer with search and highlighting", + inventory_path="/path/to/charm-examples-inventory" +) + +print(report['summary']) +print(report['architecture']) +print(report['workflow']) +``` + +**Parameters**: +- `description` (str): Natural language TUI description +- `inventory_path` (str): Path to charm-examples-inventory directory +- `include_sections` (List[str], optional): Which sections to include +- `detail_level` (str): "summary" | "detailed" | "complete" + +**Returns**: +```python +{ + 'description': str, + 'generated_at': str (ISO timestamp), + 'sections': { + 'requirements': {...}, + 'components': {...}, + 'patterns': {...}, + 'architecture': {...}, + 'workflow': {...} + }, + 'summary': str, + 'scaffolding': str (code template), + 'next_steps': List[str] +} +``` + +### Analysis Scripts + +**`analyze_requirements.py`** + +Extract structured requirements from natural language. + +**Functions**: +- `extract_requirements(description)` - Parse description +- `classify_tui_type(requirements)` - Determine archetype +- `identify_interactions(requirements)` - Find interaction patterns + +**`map_components.py`** + +Map requirements to Bubble Tea components. + +**Functions**: +- `map_to_components(requirements, inventory)` - Main mapping +- `find_alternatives(component)` - Alternative suggestions +- `justify_selection(component, requirement)` - Explain choice + +**`select_patterns.py`** + +Select relevant example files from inventory. + +**Functions**: +- `search_inventory(capability, inventory)` - Search by capability +- `rank_by_relevance(examples, requirements)` - Relevance scoring +- `extract_key_patterns(example_file)` - Identify key code patterns + +**`design_architecture.py`** + +Generate component architecture and structure. + +**Functions**: +- `design_model_struct(components)` - Create model definition +- `plan_message_handlers(interactions)` - Design Update() logic +- `generate_architecture_diagram(structure)` - ASCII diagram + +**`generate_workflow.py`** + +Create ordered implementation steps. + +**Functions**: +- `break_into_phases(architecture)` - Phase planning +- `order_tasks_by_dependency(tasks)` - Dependency sorting +- `estimate_time(task)` - Time estimation +- `generate_workflow_document(phases)` - Formatted output + +### Utility Scripts + +**`utils/inventory_loader.py`** + +Load and parse the examples inventory. + +**Functions**: +- `load_inventory(path)` - Load CONTEXTUAL-INVENTORY.md +- `parse_inventory_markdown(content)` - Parse structure +- `build_capability_index(inventory)` - Index by capability +- `search_by_keyword(keyword, inventory)` - Keyword search + +**`utils/component_matcher.py`** + +Component matching and scoring logic. + +**Functions**: +- `match_score(requirement, component)` - Relevance score +- `find_best_match(requirements, components)` - Top match +- `suggest_combinations(requirements)` - Component combos + +**`utils/template_generator.py`** + +Generate code templates and scaffolding. + +**Functions**: +- `generate_model_struct(components)` - Model struct code +- `generate_init_function(components)` - Init() implementation +- `generate_update_skeleton(messages)` - Update() skeleton +- `generate_view_skeleton(layout)` - View() skeleton + +**`utils/ascii_diagram.py`** + +Create ASCII architecture diagrams. + +**Functions**: +- `draw_component_tree(structure)` - Tree diagram +- `draw_message_flow(flow)` - Flow diagram +- `draw_state_machine(states)` - State diagram + +### Validator Scripts + +**`utils/validators/requirement_validator.py`** + +Validate requirement extraction quality. + +**Functions**: +- `validate_description_clarity(description)` - Check clarity +- `validate_requirements_completeness(requirements)` - Completeness +- `suggest_clarifications(requirements)` - Ask for missing info + +**`utils/validators/design_validator.py`** + +Validate design outputs. + +**Functions**: +- `validate_component_selection(components, requirements)` - Check fit +- `validate_architecture(architecture)` - Structural validation +- `validate_workflow_completeness(workflow)` - Ensure all steps + +## Available Analyses + +### 1. Requirement Analysis + +**Function**: `extract_requirements(description)` + +**Purpose**: Convert natural language to structured requirements + +**Methodology**: +1. Tokenize description +2. Extract nouns (features, data types) +3. Extract verbs (interactions, actions) +4. Identify patterns (multi-view, progress, etc.) +5. Classify TUI archetype + +**Output Structure**: +```python +{ + 'archetype': str, # file-manager, installer, dashboard, etc. + 'features': List[str], # [navigation, selection, preview, ...] + 'interactions': { + 'keyboard': List[str], # [arrow keys, enter, search, ...] + 'mouse': List[str] # [click, drag, ...] + }, + 'data_types': List[str], # [files, text, tabular, streaming, ...] + 'views': str, # single, multi, tabbed + 'special_requirements': List[str] # [validation, progress, real-time, ...] +} +``` + +**Interpretation**: +- Archetype determines recommended starting template +- Features map directly to component selection +- Interactions affect component configuration +- Data types influence model structure + +**Validations**: +- Description not empty +- At least 1 feature identified +- Archetype successfully classified + +### 2. Component Mapping + +**Function**: `map_to_components(requirements, inventory)` + +**Purpose**: Map requirements to specific Bubble Tea components + +**Methodology**: +1. Match features to component capabilities +2. Score each component by relevance (0-100) +3. Select top matches (score > 70) +4. Identify component combinations +5. Provide alternatives for each selection + +**Output Structure**: +```python +{ + 'primary_components': [ + { + 'component': 'viewport.Model', + 'score': 95, + 'justification': 'Scrollable display for log content', + 'example_file': 'examples/pager/main.go', + 'key_patterns': ['viewport scrolling', 'content loading'] + } + ], + 'supporting_components': [...], + 'styling': ['lipgloss for highlighting'], + 'alternatives': { + 'viewport.Model': ['pager package', 'custom viewport'] + } +} +``` + +**Scoring Criteria**: +- Feature coverage: Does component provide required features? +- Complexity match: Is component appropriate for requirement complexity? +- Common usage: Is this the typical choice for this use case? +- Ecosystem fit: Does it work well with other selected components? + +**Validations**: +- At least 1 component selected +- All requirements covered by components +- No conflicting components + +### 3. Pattern Selection + +**Function**: `select_relevant_patterns(components, inventory)` + +**Purpose**: Find most relevant example files to study + +**Methodology**: +1. Search inventory by component usage +2. Filter by capability category +3. Rank by pattern complexity (simple → complex) +4. Select 3-5 most relevant +5. Extract specific code patterns to study + +**Output Structure**: +```python +{ + 'examples': [ + { + 'file': 'examples/pager/main.go', + 'capability': 'Content Viewing', + 'relevance_score': 90, + 'key_patterns': [ + 'viewport.Model initialization', + 'content scrolling (lines 45-67)', + 'keyboard navigation (lines 80-95)' + ], + 'study_order': 1, + 'estimated_study_time': '15 minutes' + } + ], + 'recommended_study_order': [1, 2, 3], + 'total_study_time': '45 minutes' +} +``` + +**Ranking Factors**: +- Component usage match +- Complexity appropriate to skill level +- Code quality and clarity +- Completeness of example + +**Validations**: +- At least 2 examples selected +- Examples cover all selected components +- Study order is logical (simple → complex) + +### 4. Architecture Design + +**Function**: `design_architecture(components, patterns, requirements)` + +**Purpose**: Create complete component architecture + +**Methodology**: +1. Design model struct (state to track) +2. Plan Init() (initialization) +3. Design Update() message handling +4. Plan View() rendering +5. Create component hierarchy diagram +6. Design message flow + +**Output Structure**: +```python +{ + 'model_struct': str, # Go code + 'init_logic': str, # Initialization steps + 'message_handlers': { + 'tea.KeyMsg': str, # Keyboard handling + 'tea.WindowSizeMsg': str, # Resize handling + # Custom messages... + }, + 'view_logic': str, # Rendering strategy + 'diagrams': { + 'component_hierarchy': str, # ASCII tree + 'message_flow': str, # Flow diagram + 'state_machine': str # State transitions (if multi-view) + } +} +``` + +**Design Patterns Applied**: +- **Single Responsibility**: Each component handles one concern +- **Composition**: Complex UIs built from simple components +- **Message Passing**: All communication via tea.Msg +- **Elm Architecture**: Model-Update-View separation + +**Validations**: +- Model struct includes all component instances +- All user interactions have message handlers +- View logic renders all components +- No circular dependencies + +### 5. Workflow Generation + +**Function**: `generate_implementation_workflow(architecture, patterns)` + +**Purpose**: Create step-by-step implementation plan + +**Methodology**: +1. Break into phases (Setup, Core, Polish, Test) +2. Identify tasks per phase +3. Order by dependency +4. Reference specific example files per task +5. Add testing checkpoints +6. Estimate time per phase + +**Output Structure**: +```python +{ + 'phases': [ + { + 'name': 'Phase 1: Setup', + 'tasks': [ + { + 'task': 'Initialize Go module', + 'reference': None, + 'dependencies': [], + 'estimated_time': '2 minutes' + }, + { + 'task': 'Install dependencies (bubbletea, lipgloss)', + 'reference': 'See README in any example', + 'dependencies': ['Initialize Go module'], + 'estimated_time': '3 minutes' + } + ], + 'total_time': '5 minutes' + }, + # More phases... + ], + 'total_estimated_time': '2-3 hours', + 'testing_checkpoints': [ + 'After Phase 1: go build succeeds', + 'After Phase 2: Basic display working', + # ... + ] +} +``` + +**Phase Breakdown**: +1. **Setup**: Project initialization, dependencies +2. **Core Components**: Implement main functionality +3. **Integration**: Connect components, message passing +4. **Polish**: Styling, help text, error handling +5. **Testing**: Comprehensive testing, edge cases + +**Validations**: +- All tasks have clear descriptions +- Dependencies are acyclic +- Time estimates are realistic +- Testing checkpoints at each phase + +### 6. Comprehensive Design Report + +**Function**: `comprehensive_tui_design_report(description, inventory_path)` + +**Purpose**: Generate complete TUI design combining all analyses + +**Process**: +1. Execute requirement_analysis(description) +2. Execute component_mapping(requirements) +3. Execute pattern_selection(components) +4. Execute architecture_design(components, patterns) +5. Execute workflow_generation(architecture) +6. Generate code scaffolding +7. Create README outline +8. Compile comprehensive report + +**Output Structure**: +```python +{ + 'description': str, + 'generated_at': str, + 'tui_type': str, + 'summary': str, # Executive summary + 'sections': { + 'requirements': {...}, + 'components': {...}, + 'patterns': {...}, + 'architecture': {...}, + 'workflow': {...} + }, + 'scaffolding': { + 'main_go': str, # Basic main.go template + 'model_go': str, # Model struct + Init/Update/View + 'readme_md': str # README outline + }, + 'file_structure': { + 'recommended': [ + 'main.go', + 'model.go', + 'view.go', + 'messages.go', + 'go.mod' + ] + }, + 'next_steps': [ + '1. Review architecture diagram', + '2. Study recommended examples', + '3. Implement Phase 1 tasks', + # ... + ], + 'resources': { + 'documentation': [...], + 'tutorials': [...], + 'community': [...] + } +} +``` + +**Report Sections**: + +**Executive Summary** (auto-generated): +- TUI type and purpose +- Key components selected +- Estimated implementation time +- Complexity assessment + +**Requirements Analysis**: +- Parsed requirements +- TUI archetype +- Feature list + +**Component Selection**: +- Primary components with justification +- Alternatives considered +- Component interaction diagram + +**Pattern References**: +- Example files to study +- Key patterns highlighted +- Recommended study order + +**Architecture**: +- Model struct design +- Init/Update/View logic +- Message flow +- ASCII diagrams + +**Implementation Workflow**: +- Phase-by-phase breakdown +- Detailed tasks with references +- Testing checkpoints +- Time estimates + +**Code Scaffolding**: +- Basic `main.go` template +- Model struct skeleton +- Init/Update/View stubs + +**Next Steps**: +- Immediate actions +- Learning resources +- Community links + +**Validation Report**: +- Design completeness check +- Potential issues identified +- Recommendations + +## Error Handling + +### Missing Inventory + +**Error**: Cannot locate charm-examples-inventory + +**Cause**: Inventory path not provided or incorrect + +**Resolution**: +1. Verify inventory path: `~/charmtuitemplate/vinw/charm-examples-inventory` +2. If missing, clone examples: `git clone https://github.com/charmbracelet/bubbletea examples` +3. Generate CONTEXTUAL-INVENTORY.md if missing + +**Fallback**: Use minimal built-in component knowledge (less detailed) + +### Unclear Requirements + +**Error**: Cannot extract clear requirements from description + +**Cause**: Description too vague or ambiguous + +**Resolution**: +1. Validator identifies missing information +2. Generate clarifying questions +3. User provides additional details + +**Clarification Questions**: +- "What type of data will the TUI display?" +- "Should it be single-view or multi-view?" +- "What are the main user interactions?" +- "Any specific visual requirements?" + +**Fallback**: Make reasonable assumptions, note them in report + +### No Matching Components + +**Error**: No components found for requirements + +**Cause**: Requirements very specific or unusual + +**Resolution**: +1. Relax matching criteria +2. Suggest custom component development +3. Recommend closest alternatives + +**Alternative Suggestions**: +- Break down into smaller requirements +- Use generic components (viewport, textinput) +- Suggest combining multiple components + +### Invalid Architecture + +**Error**: Generated architecture has structural issues + +**Cause**: Conflicting component requirements or circular dependencies + +**Resolution**: +1. Validator detects issue +2. Suggest architectural modifications +3. Provide alternative structures + +**Common Issues**: +- **Circular dependencies**: Suggest message passing +- **Too many components**: Recommend simplification +- **Missing state**: Add required fields to model + +## Mandatory Validations + +All analyses include automatic validation. Reports include validation sections. + +### Requirement Validation + +**Checks**: +- ✅ Description is not empty +- ✅ At least 1 feature identified +- ✅ TUI archetype classified +- ✅ Interaction patterns detected + +**Output**: +```python +{ + 'validation': { + 'passed': True/False, + 'checks': [ + {'name': 'description_not_empty', 'passed': True}, + {'name': 'features_found', 'passed': True, 'count': 5}, + # ... + ], + 'warnings': [ + 'No mouse interactions specified - assuming keyboard only' + ] + } +} +``` + +### Component Validation + +**Checks**: +- ✅ At least 1 component selected +- ✅ All requirements covered +- ✅ No conflicting components +- ✅ Reasonable complexity + +**Warnings**: +- "Multiple similar components selected - may be redundant" +- "High complexity - consider breaking into smaller UIs" + +### Architecture Validation + +**Checks**: +- ✅ Model struct includes all components +- ✅ No circular dependencies +- ✅ All interactions have handlers +- ✅ View renders all components + +**Errors**: +- "Missing message handler for [interaction]" +- "Circular dependency detected: A → B → A" +- "Unused component: [component] not rendered in View()" + +### Workflow Validation + +**Checks**: +- ✅ All phases have tasks +- ✅ Dependencies are acyclic +- ✅ Testing checkpoints present +- ✅ Time estimates reasonable + +**Warnings**: +- "No testing checkpoint after Phase [N]" +- "Task [X] has no dependencies but should come after [Y]" + +## Performance & Caching + +### Inventory Loading + +**Strategy**: Load once, cache in memory + +- Load CONTEXTUAL-INVENTORY.md on first use +- Build search indices (by capability, component, keyword) +- Cache for session duration + +**Performance**: O(1) lookup after initial O(n) indexing + +### Component Matching + +**Strategy**: Pre-computed similarity scores + +- Build component-feature mapping at initialization +- Score calculations cached +- Incremental updates only + +**Performance**: O(log n) search with indexing + +### Diagram Generation + +**Strategy**: Template-based with caching + +- Use pre-built ASCII templates +- Cache generated diagrams +- Regenerate only on structure changes + +**Performance**: O(n) where n = number of components + +## Automatic Detection Keywords + +This skill activates when you mention: + +**Technologies**: +- Bubble Tea, bubbletea, charm, charmbracelet +- TUI, terminal UI, terminal interface +- Lipgloss, Tea Program + +**Components**: +- spinner, progress, table, list, viewport, textarea, textinput +- pager, paginator, timer, stopwatch, filepicker, help +- tabs, views, autocomplete + +**Design Actions**: +- design TUI, create TUI, build TUI, architect TUI, plan TUI +- automate TUI design, generate TUI, scaffold TUI +- design interface, create interface, build interface +- map components, select components + +**TUI Types**: +- file manager, installer, package manager, dashboard +- form, wizard, configuration tool +- chat interface, log viewer, data viewer, text viewer +- monitoring tool, menu system, selector + +**Patterns**: +- multi-view, composable views, tabbed interface +- progress tracking, form validation, data display +- keyboard navigation, mouse support +- real-time updates, streaming data + +**Questions**: +- "how to design", "how to build", "how to structure" +- "which components", "what patterns", "best practices" +- "design a TUI for", "create interface for" +- "automate design process" + +## Usage Examples + +### Example 1: Simple Log Viewer + +**User**: "Design a TUI for viewing log files with search" + +**Response**: + +``` +TUI DESIGN: Log File Viewer + +ARCHETYPE: Text Viewer + +REQUIREMENTS ANALYSIS: +- View: Scrollable text display +- Interaction: Search, navigate matches +- Data: Text file (log lines) + +COMPONENT SELECTION: + +Primary Components: +1. viewport.Model - Scrollable text display + Justification: Built for viewing large text content + Example: examples/pager/main.go + +2. textinput.Model - Search query input + Justification: Standard text input for search + Example: examples/textinput/main.go + +Supporting: +- Lipgloss styles for match highlighting + +ARCHITECTURE: + +Model Struct: +```go +type model struct { + viewport viewport.Model + searchInput textinput.Model + content []string + searchMode bool + matches []int + currentMatch int + ready bool +} +``` + +WORKFLOW: + +Phase 1: Basic Viewer (30min) +- Load file into string array +- Initialize viewport +- Implement basic scrolling + +Phase 2: Search (45min) +- Add textinput for search +- Toggle search mode with '/' +- Highlight matches + +Phase 3: Navigation (20min) +- Navigate matches with n/N +- Show match counter + +Total: ~2 hours + +NEXT STEPS: +1. Study examples/pager/main.go for viewport usage +2. Study examples/textinput/main.go for input handling +3. Implement Phase 1 +4. Test scrolling with large files +5. Add search in Phase 2 +``` + +### Example 2: Installation Progress Tracker + +**User**: "Create a package installer UI with progress bars" + +**Response**: + +``` +TUI DESIGN: Package Installer + +ARCHETYPE: Installer/Progress Tracker + +REQUIREMENTS ANALYSIS: +- View: Progress indication for multiple packages +- Interaction: Minimal (automated process) +- Data: Package list, progress per package +- Special: Sequential installation, visual feedback + +COMPONENT SELECTION: + +Primary Components: +1. progress.Model - Individual package progress + Justification: Animated progress visualization + Example: examples/progress-animated/main.go + +2. spinner.Model - Loading indicator + Justification: Feedback during package download + Example: examples/spinner/main.go + +Reference Pattern: +- examples/package-manager/main.go - Complete implementation + +ARCHITECTURE: + +Model Struct: +```go +type model struct { + packages []Package + currentIdx int + progress progress.Model + spinner spinner.Model + installed []string + err error +} + +type Package struct { + name string + status string // pending, installing, done +} +``` + +Message Flow: +Install Start → Download Package → Update Progress → Package Done → Next Package + +WORKFLOW: + +Phase 1: Package List (20min) +- Define Package struct +- Initialize list of packages +- Display package names + +Phase 2: Add Progress (30min) +- Initialize progress.Model per package +- Update on install progress messages +- Handle completion + +Phase 3: Add Spinner (15min) +- Add spinner during download +- Stop when installation starts + +Phase 4: Sequential Logic (30min) +- Implement install queue +- Sequential execution +- Completion handling + +Total: ~2 hours + +REFERENCE STUDY: +1. examples/package-manager/main.go (main reference) + - Lines 50-80: Progress + spinner combo + - Lines 120-145: Sequential installation + - Lines 200-230: Completion handling + +NEXT STEPS: +1. Clone package-manager example structure +2. Customize for your package list +3. Add package-specific install logic +4. Test with mock installations +``` + +### Example 3: Configuration Wizard + +**User**: "Build a multi-step configuration wizard with validation" + +**Response**: + +``` +TUI DESIGN: Configuration Wizard + +ARCHETYPE: Multi-Step Form + +REQUIREMENTS ANALYSIS: +- View: Multi-step (multiple screens) +- Interaction: Form input, validation, navigation +- Data: Configuration fields (text, selections) +- Special: Validation per field, step navigation + +COMPONENT SELECTION: + +Primary Components: +1. textinput.Model (array) - Multiple input fields + Justification: Standard form inputs + Example: examples/textinputs/main.go + +2. State machine for steps + Justification: Multi-step workflow + Example: examples/views/main.go + +Reference Pattern: +- examples/credit-card-form/main.go - Form validation + +ARCHITECTURE: + +Model Struct: +```go +type model struct { + currentStep int + steps []Step + inputs []textinput.Model + focusIdx int + err error +} + +type Step struct { + name string + fields []Field + valid bool +} +``` + +State Machine: +Step 1 (Basic Info) → Step 2 (Advanced) → Step 3 (Confirm) → Done + +WORKFLOW: + +Phase 1: Basic Form (45min) +- Create input array +- Implement focus management +- Basic key handling (tab, enter) + +Phase 2: Add Steps (30min) +- Define Step struct +- Implement step navigation +- Show current step indicator + +Phase 3: Validation (45min) +- Add validators to each input +- Show validation errors +- Prevent next step if invalid + +Phase 4: Polish (30min) +- Add progress indicator +- Help text per step +- Confirmation screen + +Total: ~2.5 hours + +REFERENCE STUDY ORDER: +1. examples/textinputs/main.go (focus management) +2. examples/credit-card-form/main.go (validation pattern) +3. examples/views/main.go (multi-view state machine) + +NEXT STEPS: +1. Start with textinputs example +2. Add step state management +3. Implement validators +4. Build confirmation view +``` + +### Example 4: File Manager + +**User**: "Design a file manager TUI like ranger" + +**Response**: + +``` +TUI DESIGN: File Manager + +ARCHETYPE: File Manager + +REQUIREMENTS ANALYSIS: +- View: Three-column (parent, current, preview) +- Interaction: Keyboard navigation, selection +- Data: File system hierarchy +- Special: File preview, directory traversal + +COMPONENT SELECTION: + +Primary Components: +1. filepicker.Model - File navigation + Justification: Built-in file system navigation + Example: examples/file-picker/main.go + Alternative: Custom with list.Model for more control + +2. viewport.Model - File preview + Justification: Preview pane for selected file + Example: examples/pager/main.go + +3. Lipgloss layout - Three-column layout + Justification: Side-by-side panels + Example: examples/split-editors/main.go (two-pane reference) + +ARCHITECTURE: + +Model Struct: +```go +type model struct { + parentList list.Model // Parent directory + currentList list.Model // Current directory + preview viewport.Model // File preview + currentDir string + selectedFile string + width int + height int +} +``` + +Layout: +┌────────────┬─────────────┬──────────────┐ +│ Parent │ Current │ Preview │ +│ Dir │ Dir │ Pane │ +│ │ │ │ +│ ../ │ > file1.txt │ File content │ +│ docs/ │ file2.go │ ... │ +│ src/ │ dir/ │ │ +└────────────┴─────────────┴──────────────┘ + +WORKFLOW: + +Phase 1: Single Dir Nav (1h) +- Implement current directory listing +- Arrow key navigation +- Directory entry/exit + +Phase 2: Three-Pane Layout (1h) +- Add parent directory pane +- Add preview pane +- Lipgloss layout + +Phase 3: File Preview (45min) +- Load file content on selection +- Show in preview pane +- Handle large files + +Phase 4: Polish (45min) +- File type icons/colors +- Status bar +- Help overlay + +Total: ~3.5 hours + +REFERENCE STUDY: +1. examples/file-picker/main.go - File system navigation +2. examples/list-fancy/main.go - Custom list styling +3. examples/pager/main.go - Content preview +4. examples/split-editors/main.go - Multi-pane layout + +NEXT STEPS: +1. Start with file-picker for navigation logic +2. Build three-pane layout +3. Add preview functionality +4. Style with Lipgloss +``` + +### Example 5: Quick Component Lookup + +**User**: "Which component for a dropdown menu?" + +**Response**: + +``` +COMPONENT RECOMMENDATIONS: Dropdown Menu + +Since Bubble Tea doesn't have a built-in dropdown component, here are the best approaches: + +1. list.Model (Recommended) + - Display as temporary list overlay + - Filter on keypress + - Select with Enter + - Example: examples/list-simple/main.go + - Customization: Use ItemDelegate for dropdown styling + +2. autocomplete + - Text input with suggestions below + - Good for searchable dropdowns + - Example: examples/autocomplete/main.go + - Use case: Large option lists + +3. Custom with viewport + - Full control over appearance + - Scrollable for many options + - Example: examples/viewport/main.go (for scrolling) + - Build: Overlay viewport on trigger + +IMPLEMENTATION PATTERN: + +```go +type model struct { + dropdownOpen bool + dropdownList list.Model + selectedValue string +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "enter" && !m.dropdownOpen { + // Open dropdown + m.dropdownOpen = true + return m, nil + } + if m.dropdownOpen { + // Delegate to list + var cmd tea.Cmd + m.dropdownList, cmd = m.dropdownList.Update(msg) + return m, cmd + } + } + return m, nil +} +``` + +RECOMMENDED APPROACH: +Use list.Model in "dropdown mode" - render as overlay when open, hide when closed. + +Study: examples/list-simple/main.go +``` + +--- + +**Total Word Count**: ~7,200 words + +This comprehensive skill documentation provides: +- Clear activation criteria +- Complete workflow explanations +- Detailed function documentation +- Architecture patterns +- Error handling guidance +- Extensive usage examples +- Integration with charm-examples-inventory diff --git a/.claude/skills/bubbletea-designer/skills/bubbletea-designer/references/architecture-best-practices.md b/.claude/skills/bubbletea-designer/skills/bubbletea-designer/references/architecture-best-practices.md new file mode 100644 index 00000000..7a2f7f36 --- /dev/null +++ b/.claude/skills/bubbletea-designer/skills/bubbletea-designer/references/architecture-best-practices.md @@ -0,0 +1,168 @@ +# Bubble Tea Architecture Best Practices + +## Model Design + +### Keep State Flat +❌ Avoid: Deeply nested state +✅ Prefer: Flat structure with clear fields + +```go +// Good +type model struct { + items []Item + cursor int + selected map[int]bool +} + +// Avoid +type model struct { + state struct { + data struct { + items []Item + } + } +} +``` + +### Separate Concerns +- UI state in model +- Business logic in separate functions +- Network/IO in commands + +### Component Ownership +Each component owns its state. Don't reach into component internals. + +## Update Function + +### Message Routing +Route messages to appropriate handlers: + +```go +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + return m.handleKeyboard(msg) + case tea.WindowSizeMsg: + return m.handleResize(msg) + } + return m.updateComponents(msg) +} +``` + +### Command Batching +Batch multiple commands: + +```go +var cmds []tea.Cmd +cmds = append(cmds, cmd1, cmd2, cmd3) +return m, tea.Batch(cmds...) +``` + +## View Function + +### Cache Expensive Renders +Don't recompute on every View() call: + +```go +type model struct { + cachedView string + dirty bool +} + +func (m model) View() string { + if m.dirty { + m.cachedView = m.render() + m.dirty = false + } + return m.cachedView +} +``` + +### Responsive Layouts +Adapt to terminal size: + +```go +if m.width < 80 { + // Compact layout +} else { + // Full layout +} +``` + +## Performance + +### Minimize Allocations +Reuse slices and strings where possible + +### Defer Heavy Operations +Move slow operations to commands (async) + +### Debounce Rapid Updates +Don't update on every keystroke for expensive operations + +## Error Handling + +### User-Friendly Errors +Show actionable error messages + +### Graceful Degradation +Fallback when features unavailable + +### Error Recovery +Allow user to retry or cancel + +## Testing + +### Test Pure Functions +Extract business logic for easy testing + +### Mock Commands +Test Update() without side effects + +### Snapshot Views +Compare View() output for visual regression + +## Accessibility + +### Keyboard-First +All features accessible via keyboard + +### Clear Indicators +Show current focus, selection state + +### Help Text +Provide discoverable help (? key) + +## Code Organization + +### File Structure +``` +main.go - Entry point, model definition +update.go - Update handlers +view.go - View rendering +commands.go - Command definitions +messages.go - Custom message types +``` + +### Component Encapsulation +One component per file for complex TUIs + +## Debugging + +### Log to File +```go +f, _ := os.OpenFile("debug.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) +log.SetOutput(f) +log.Printf("Debug: %+v", msg) +``` + +### Debug Mode +Toggle debug view with key binding + +## Common Pitfalls + +1. **Forgetting tea.Batch**: Returns only last command +2. **Not handling WindowSizeMsg**: Fixed-size components +3. **Blocking in Update()**: Freezes UI - use commands +4. **Direct terminal writes**: Use tea.Println for above-TUI output +5. **Ignoring ready state**: Rendering before initialization complete diff --git a/.claude/skills/bubbletea-designer/skills/bubbletea-designer/references/bubbletea-components-guide.md b/.claude/skills/bubbletea-designer/skills/bubbletea-designer/references/bubbletea-components-guide.md new file mode 100644 index 00000000..6370aac1 --- /dev/null +++ b/.claude/skills/bubbletea-designer/skills/bubbletea-designer/references/bubbletea-components-guide.md @@ -0,0 +1,141 @@ +# Bubble Tea Components Guide + +Complete reference for Bubble Tea ecosystem components. + +## Core Input Components + +### textinput.Model +**Purpose**: Single-line text input +**Use Cases**: Search boxes, single field forms, command input +**Key Methods**: +- `Focus()` / `Blur()` - Focus management +- `SetValue(string)` - Set text programmatically +- `Value()` - Get current text + +**Example Pattern**: +```go +input := textinput.New() +input.Placeholder = "Search..." +input.Focus() +``` + +### textarea.Model +**Purpose**: Multi-line text editing +**Use Cases**: Message composition, text editing, large text input +**Key Features**: Line wrapping, scrolling, cursor management + +### filepicker.Model +**Purpose**: File system navigation +**Use Cases**: File selection, file browsers +**Key Features**: Directory traversal, file type filtering, path resolution + +## Display Components + +### viewport.Model +**Purpose**: Scrollable content display +**Use Cases**: Log viewers, document readers, large text display +**Key Methods**: +- `SetContent(string)` - Set viewable content +- `GotoTop()` / `GotoBottom()` - Navigation +- `LineUp()` / `LineDown()` - Scroll control + +### table.Model +**Purpose**: Tabular data display +**Use Cases**: Data tables, structured information +**Key Features**: Column definitions, row selection, styling + +### list.Model +**Purpose**: Filterable, navigable lists +**Use Cases**: Item selection, menus, file lists +**Key Features**: Filtering, pagination, custom item delegates + +### paginator.Model +**Purpose**: Page-based navigation +**Use Cases**: Paginated content, chunked display + +## Feedback Components + +### spinner.Model +**Purpose**: Loading/waiting indicator +**Styles**: Dot, Line, Minidot, Jump, Pulse, Points, Globe, Moon, Monkey + +### progress.Model +**Purpose**: Progress indication +**Modes**: Determinate (0-100%), Indeterminate +**Styling**: Gradient, solid color, custom + +### timer.Model +**Purpose**: Countdown timer +**Use Cases**: Timeouts, timed operations + +### stopwatch.Model +**Purpose**: Elapsed time tracking +**Use Cases**: Duration measurement, time tracking + +## Navigation Components + +### tabs +**Purpose**: Tab-based view switching +**Pattern**: Lipgloss-based tab rendering + +### help.Model +**Purpose**: Help text and keyboard shortcuts +**Modes**: Short (inline), Full (overlay) + +## Layout with Lipgloss + +**JoinVertical**: Stack components vertically +**JoinHorizontal**: Place components side-by-side +**Place**: Position with alignment +**Border**: Add borders and padding + +## Component Initialization Pattern + +```go +type model struct { + component1 component1.Model + component2 component2.Model +} + +func (m model) Init() tea.Cmd { + return tea.Batch( + m.component1.Init(), + m.component2.Init(), + ) +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + // Update each component + var cmd tea.Cmd + m.component1, cmd = m.component1.Update(msg) + cmds = append(cmds, cmd) + + m.component2, cmd = m.component2.Update(msg) + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} +``` + +## Message Handling + +**Standard Messages**: +- `tea.KeyMsg` - Keyboard input +- `tea.MouseMsg` - Mouse events +- `tea.WindowSizeMsg` - Terminal resize +- `tea.QuitMsg` - Quit signal + +**Component Messages**: +- `progress.FrameMsg` - Progress/spinner animation +- `spinner.TickMsg` - Spinner tick +- `textinput.ErrMsg` - Input errors + +## Best Practices + +1. **Always delegate**: Let components handle their own messages +2. **Batch commands**: Use `tea.Batch()` for multiple commands +3. **Focus management**: Only one component focused at a time +4. **Dimension tracking**: Update component sizes on `WindowSizeMsg` +5. **State separation**: Keep UI state in model, business logic separate diff --git a/.claude/skills/bubbletea-designer/skills/bubbletea-designer/references/design-patterns.md b/.claude/skills/bubbletea-designer/skills/bubbletea-designer/references/design-patterns.md new file mode 100644 index 00000000..2345ee11 --- /dev/null +++ b/.claude/skills/bubbletea-designer/skills/bubbletea-designer/references/design-patterns.md @@ -0,0 +1,214 @@ +# Bubble Tea Design Patterns + +Common architectural patterns for TUI development. + +## Pattern 1: Single-View Application + +**When**: Simple, focused TUIs with one main view +**Components**: 1-3 components, single model struct +**Complexity**: Low + +```go +type model struct { + mainComponent component.Model + ready bool +} +``` + +## Pattern 2: Multi-View State Machine + +**When**: Multiple distinct screens (setup, main, done) +**Components**: State enum + view-specific components +**Complexity**: Medium + +```go +type view int +const ( + setupView view = iota + mainView + doneView +) + +type model struct { + currentView view + // Components for each view +} +``` + +## Pattern 3: Composable Views + +**When**: Complex UIs with reusable sub-components +**Pattern**: Embed multiple bubble models +**Example**: Dashboard with multiple panels + +```go +type model struct { + panel1 Panel1Model + panel2 Panel2Model + panel3 Panel3Model +} + +// Each panel is itself a Bubble Tea model +``` + +## Pattern 4: Master-Detail + +**When**: Selection in one pane affects display in another +**Example**: File list + preview, Email list + content +**Layout**: Two-pane or three-pane + +```go +type model struct { + list list.Model + detail viewport.Model + selectedItem int +} +``` + +## Pattern 5: Form Flow + +**When**: Multi-step data collection +**Pattern**: Array of inputs + focus management +**Example**: Configuration wizard + +```go +type model struct { + inputs []textinput.Model + focusIndex int + step int +} +``` + +## Pattern 6: Progress Tracker + +**When**: Long-running sequential operations +**Pattern**: Queue + progress per item +**Example**: Installation, download manager + +```go +type model struct { + items []Item + currentIndex int + progress progress.Model + spinner spinner.Model +} +``` + +## Layout Patterns + +### Vertical Stack +```go +lipgloss.JoinVertical(lipgloss.Left, + header, + content, + footer, +) +``` + +### Horizontal Panels +```go +lipgloss.JoinHorizontal(lipgloss.Top, + leftPanel, + separator, + rightPanel, +) +``` + +### Three-Column (File Manager Style) +```go +lipgloss.JoinHorizontal(lipgloss.Top, + parentDir, // 25% width + currentDir, // 35% width + preview, // 40% width +) +``` + +## Message Passing Patterns + +### Custom Messages +```go +type myCustomMsg struct { + data string +} + +func doSomethingCmd() tea.Msg { + return myCustomMsg{data: "result"} +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case myCustomMsg: + // Handle custom message + } +} +``` + +### Async Operations +```go +func fetchDataCmd() tea.Cmd { + return func() tea.Msg { + // Do async work + data := fetchFromAPI() + return dataFetchedMsg{data} + } +} +``` + +## Error Handling Pattern + +```go +type errMsg struct{ err error } + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case errMsg: + m.err = msg.err + m.errVisible = true + return m, nil + } +} +``` + +## Keyboard Navigation Pattern + +```go +case tea.KeyMsg: + switch msg.String() { + case "up", "k": + m.cursor-- + case "down", "j": + m.cursor++ + case "enter": + m.selectCurrent() + case "q", "ctrl+c": + return m, tea.Quit + } +``` + +## Responsive Layout Pattern + +```go +case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + + // Update component dimensions + m.viewport.Width = msg.Width + m.viewport.Height = msg.Height - 5 // Reserve space for header/footer +``` + +## Help Overlay Pattern + +```go +type model struct { + showHelp bool + help help.Model +} + +func (m model) View() string { + if m.showHelp { + return m.help.View() + } + return m.mainView() +} +``` diff --git a/.claude/skills/bubbletea-designer/skills/bubbletea-designer/references/example-designs.md b/.claude/skills/bubbletea-designer/skills/bubbletea-designer/references/example-designs.md new file mode 100644 index 00000000..ca1b96de --- /dev/null +++ b/.claude/skills/bubbletea-designer/skills/bubbletea-designer/references/example-designs.md @@ -0,0 +1,98 @@ +# Example TUI Designs + +Real-world design examples with component selections. + +## Example 1: Log Viewer + +**Requirements**: View large log files, search, navigate +**Archetype**: Viewer +**Components**: +- viewport.Model - Main log display +- textinput.Model - Search input +- help.Model - Keyboard shortcuts + +**Architecture**: +```go +type model struct { + viewport viewport.Model + searchInput textinput.Model + searchMode bool + matches []int + currentMatch int +} +``` + +**Key Features**: +- Toggle search with `/` +- Navigate matches with n/N +- Highlight matches in viewport + +## Example 2: File Manager + +**Requirements**: Three-column navigation, preview +**Archetype**: File Manager +**Components**: +- list.Model (x2) - Parent + current directory +- viewport.Model - File preview +- filepicker.Model - Alternative approach + +**Layout**: Horizontal three-pane +**Complexity**: Medium-High + +## Example 3: Package Installer + +**Requirements**: Sequential installation with progress +**Archetype**: Installer +**Components**: +- list.Model - Package list +- progress.Model - Per-package progress +- spinner.Model - Download indicator + +**Pattern**: Progress Tracker +**Workflow**: Queue-based sequential processing + +## Example 4: Configuration Wizard + +**Requirements**: Multi-step form with validation +**Archetype**: Form +**Components**: +- textinput.Model array - Multiple inputs +- help.Model - Per-step help +- progress/indicator - Step progress + +**Pattern**: Form Flow +**Navigation**: Tab between fields, Enter to next step + +## Example 5: Dashboard + +**Requirements**: Multiple views, real-time updates +**Archetype**: Dashboard +**Components**: +- tabs - View switching +- table.Model - Data display +- viewport.Model - Log panel + +**Pattern**: Composable Views +**Layout**: Tabbed with multiple panels per tab + +## Component Selection Guide + +| Use Case | Primary Component | Alternative | Supporting | +|----------|------------------|-------------|-----------| +| Log viewing | viewport | pager | textinput (search) | +| File selection | filepicker | list | viewport (preview) | +| Data table | table | list | paginator | +| Text editing | textarea | textinput | viewport | +| Progress | progress | spinner | - | +| Multi-step | views | tabs | help | +| Search/Filter | textinput | autocomplete | list | + +## Complexity Matrix + +| TUI Type | Components | Views | Estimated Time | +|----------|-----------|-------|----------------| +| Simple viewer | 1-2 | 1 | 1-2 hours | +| File manager | 3-4 | 1 | 3-4 hours | +| Installer | 3-4 | 3 | 2-3 hours | +| Dashboard | 4-6 | 3+ | 4-6 hours | +| Editor | 2-3 | 1-2 | 3-4 hours | diff --git a/.claude/skills/bubbletea-designer/tests/test_integration.py b/.claude/skills/bubbletea-designer/tests/test_integration.py new file mode 100644 index 00000000..67417de7 --- /dev/null +++ b/.claude/skills/bubbletea-designer/tests/test_integration.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +""" +Integration tests for Bubble Tea Designer. +Tests complete workflows from description to design report. +""" + +import sys +from pathlib import Path + +# Add scripts to path +sys.path.insert(0, str(Path(__file__).parent.parent / 'scripts')) + +from analyze_requirements import extract_requirements +from map_components import map_to_components +from design_architecture import design_architecture +from generate_workflow import generate_implementation_workflow +from design_tui import comprehensive_tui_design_report + + +def test_analyze_requirements_basic(): + """Test requirement extraction from simple description.""" + print("\n✓ Testing extract_requirements()...") + + description = "Build a log viewer with search and highlighting" + result = extract_requirements(description) + + # Validations + assert 'archetype' in result, "Missing 'archetype' in result" + assert 'features' in result, "Missing 'features'" + assert result['archetype'] == 'viewer', f"Expected 'viewer', got {result['archetype']}" + assert 'search' in result['features'], "Should identify 'search' feature" + + print(f" ✓ Archetype: {result['archetype']}") + print(f" ✓ Features: {', '.join(result['features'])}") + print(f" ✓ Validation: {result['validation']['summary']}") + + return True + + +def test_map_components_viewer(): + """Test component mapping for viewer archetype.""" + print("\n✓ Testing map_to_components()...") + + requirements = { + 'archetype': 'viewer', + 'features': ['display', 'search', 'scrolling'], + 'data_types': ['text'], + 'views': 'single' + } + + result = map_to_components(requirements) + + # Validations + assert 'primary_components' in result, "Missing 'primary_components'" + assert len(result['primary_components']) > 0, "No components selected" + + # Should include viewport for viewing + comp_names = [c['component'] for c in result['primary_components']] + has_viewport = any('viewport' in name.lower() for name in comp_names) + + print(f" ✓ Components selected: {len(result['primary_components'])}") + print(f" ✓ Top component: {result['primary_components'][0]['component']}") + print(f" ✓ Has viewport: {has_viewport}") + + return True + + +def test_design_architecture(): + """Test architecture generation.""" + print("\n✓ Testing design_architecture()...") + + components = { + 'primary_components': [ + {'component': 'viewport.Model', 'score': 90}, + {'component': 'textinput.Model', 'score': 85} + ] + } + + requirements = { + 'archetype': 'viewer', + 'views': 'single' + } + + result = design_architecture(components, {}, requirements) + + # Validations + assert 'model_struct' in result, "Missing 'model_struct'" + assert 'message_handlers' in result, "Missing 'message_handlers'" + assert 'diagrams' in result, "Missing 'diagrams'" + assert 'tea.KeyMsg' in result['message_handlers'], "Missing keyboard handler" + + print(f" ✓ Model struct generated: {len(result['model_struct'])} chars") + print(f" ✓ Message handlers: {len(result['message_handlers'])}") + print(f" ✓ Diagrams: {len(result['diagrams'])}") + + return True + + +def test_generate_workflow(): + """Test workflow generation.""" + print("\n✓ Testing generate_implementation_workflow()...") + + architecture = { + 'model_struct': 'type model struct { ... }', + 'message_handlers': {'tea.KeyMsg': '...'} + } + + result = generate_implementation_workflow(architecture, {}) + + # Validations + assert 'phases' in result, "Missing 'phases'" + assert 'testing_checkpoints' in result, "Missing 'testing_checkpoints'" + assert len(result['phases']) >= 2, "Should have multiple phases" + + print(f" ✓ Workflow phases: {len(result['phases'])}") + print(f" ✓ Testing checkpoints: {len(result['testing_checkpoints'])}") + print(f" ✓ Estimated time: {result.get('total_estimated_time', 'N/A')}") + + return True + + +def test_comprehensive_report_log_viewer(): + """Test comprehensive report for log viewer.""" + print("\n✓ Testing comprehensive_tui_design_report() - Log Viewer...") + + description = "Build a log viewer with search and highlighting" + result = comprehensive_tui_design_report(description) + + # Validations + assert 'description' in result, "Missing 'description'" + assert 'summary' in result, "Missing 'summary'" + assert 'sections' in result, "Missing 'sections'" + + sections = result['sections'] + assert 'requirements' in sections, "Missing 'requirements' section" + assert 'components' in sections, "Missing 'components' section" + assert 'architecture' in sections, "Missing 'architecture' section" + assert 'workflow' in sections, "Missing 'workflow' section" + + print(f" ✓ TUI type: {result.get('tui_type', 'N/A')}") + print(f" ✓ Sections: {len(sections)}") + print(f" ✓ Summary: {result['summary'][:100]}...") + print(f" ✓ Validation: {result['validation']['summary']}") + + return True + + +def test_comprehensive_report_file_manager(): + """Test comprehensive report for file manager.""" + print("\n✓ Testing comprehensive_tui_design_report() - File Manager...") + + description = "Create a file manager with three-column view" + result = comprehensive_tui_design_report(description) + + # Validations + assert result.get('tui_type') == 'file-manager', f"Expected 'file-manager', got {result.get('tui_type')}" + + reqs = result['sections']['requirements'] + assert 'filepicker' in str(reqs).lower() or 'list' in str(reqs).lower(), \ + "Should suggest file-related components" + + print(f" ✓ TUI type: {result['tui_type']}") + print(f" ✓ Archetype correct") + + return True + + +def test_comprehensive_report_installer(): + """Test comprehensive report for installer.""" + print("\n✓ Testing comprehensive_tui_design_report() - Installer...") + + description = "Design an installer with progress bars for packages" + result = comprehensive_tui_design_report(description) + + # Validations + assert result.get('tui_type') == 'installer', f"Expected 'installer', got {result.get('tui_type')}" + + components = result['sections']['components'] + comp_names = str([c['component'] for c in components.get('primary_components', [])]) + assert 'progress' in comp_names.lower() or 'spinner' in comp_names.lower(), \ + "Should suggest progress components" + + print(f" ✓ TUI type: {result['tui_type']}") + print(f" ✓ Progress components suggested") + + return True + + +def test_validation_integration(): + """Test that validation is integrated in all functions.""" + print("\n✓ Testing validation integration...") + + description = "Build a log viewer" + result = comprehensive_tui_design_report(description) + + # Check each section has validation + sections = result['sections'] + + if 'requirements' in sections: + assert 'validation' in sections['requirements'], "Requirements should have validation" + print(" ✓ Requirements validated") + + if 'components' in sections: + assert 'validation' in sections['components'], "Components should have validation" + print(" ✓ Components validated") + + if 'architecture' in sections: + assert 'validation' in sections['architecture'], "Architecture should have validation" + print(" ✓ Architecture validated") + + if 'workflow' in sections: + assert 'validation' in sections['workflow'], "Workflow should have validation" + print(" ✓ Workflow validated") + + # Overall validation + assert 'validation' in result, "Report should have overall validation" + print(" ✓ Overall report validated") + + return True + + +def test_code_scaffolding(): + """Test code scaffolding generation.""" + print("\n✓ Testing code scaffolding generation...") + + description = "Simple log viewer" + result = comprehensive_tui_design_report(description, detail_level="complete") + + # Validations + assert 'scaffolding' in result, "Missing 'scaffolding'" + assert 'main_go' in result['scaffolding'], "Missing 'main_go' scaffold" + + main_go = result['scaffolding']['main_go'] + assert 'package main' in main_go, "Should have package main" + assert 'type model struct' in main_go, "Should have model struct" + assert 'func main()' in main_go, "Should have main function" + + print(f" ✓ Scaffolding generated: {len(main_go)} chars") + print(" ✓ Contains package main") + print(" ✓ Contains model struct") + print(" ✓ Contains main function") + + return True + + +def main(): + """Run all integration tests.""" + print("=" * 70) + print("INTEGRATION TESTS - Bubble Tea Designer") + print("=" * 70) + + tests = [ + ("Requirement extraction", test_analyze_requirements_basic), + ("Component mapping", test_map_components_viewer), + ("Architecture design", test_design_architecture), + ("Workflow generation", test_generate_workflow), + ("Comprehensive report - Log Viewer", test_comprehensive_report_log_viewer), + ("Comprehensive report - File Manager", test_comprehensive_report_file_manager), + ("Comprehensive report - Installer", test_comprehensive_report_installer), + ("Validation integration", test_validation_integration), + ("Code scaffolding", test_code_scaffolding), + ] + + results = [] + for test_name, test_func in tests: + try: + passed = test_func() + results.append((test_name, passed)) + except Exception as e: + print(f"\n ❌ FAILED: {e}") + import traceback + traceback.print_exc() + results.append((test_name, False)) + + # Summary + print("\n" + "=" * 70) + print("SUMMARY") + print("=" * 70) + + for test_name, passed in results: + status = "✅ PASS" if passed else "❌ FAIL" + print(f"{status}: {test_name}") + + passed_count = sum(1 for _, p in results if p) + total_count = len(results) + + print(f"\nResults: {passed_count}/{total_count} passed") + + return passed_count == total_count + + +if __name__ == "__main__": + success = main() + sys.exit(0 if success else 1) diff --git a/.claude/skills/bubbletea-maintenance/.claude-plugin/marketplace.json b/.claude/skills/bubbletea-maintenance/.claude-plugin/marketplace.json new file mode 100644 index 00000000..eaa7b1dc --- /dev/null +++ b/.claude/skills/bubbletea-maintenance/.claude-plugin/marketplace.json @@ -0,0 +1,22 @@ +{ + "name": "bubbletea-maintenance", + "owner": { + "name": "William VanSickle III", + "email": "noreply@example.com" + }, + "metadata": { + "description": "Expert Bubble Tea maintenance and debugging agent - diagnoses issues, applies best practices, and enhances existing Go/Bubble Tea TUI applications", + "version": "1.0.0", + "created": "2025-10-19", + "tags": ["bubble-tea", "go", "tui", "debugging", "maintenance", "performance", "bubbletea", "lipgloss"] + }, + "plugins": [ + { + "name": "bubbletea-maintenance-plugin", + "description": "Expert Bubble Tea maintenance and debugging agent specializing in diagnosing issues, applying best practices, and enhancing existing Go/Bubble Tea TUI applications. Helps developers maintain, debug, and improve their terminal user interfaces built with the Bubble Tea framework.", + "source": "./", + "strict": false, + "skills": ["./"] + } + ] +} diff --git a/.claude/skills/bubbletea-maintenance/.claude-plugin/plugin.json b/.claude/skills/bubbletea-maintenance/.claude-plugin/plugin.json new file mode 100644 index 00000000..f6718410 --- /dev/null +++ b/.claude/skills/bubbletea-maintenance/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "bubbletea-maintenance", + "description": "Expert Bubble Tea maintenance and debugging agent - diagnoses issues, applies best practices, and enhances existing Go/Bubble Tea TUI applications", + "author": { + "name": "William VanSickle III", + "email": "noreply@example.com" + } +} diff --git a/.claude/skills/bubbletea-maintenance/.skillfish.json b/.claude/skills/bubbletea-maintenance/.skillfish.json new file mode 100644 index 00000000..313a393e --- /dev/null +++ b/.claude/skills/bubbletea-maintenance/.skillfish.json @@ -0,0 +1,10 @@ +{ + "version": 2, + "name": "bubbletea-maintenance", + "owner": "human-frontier-labs-inc", + "repo": "human-frontier-labs-marketplace", + "path": "plugins/bubbletea-maintenance", + "branch": "master", + "sha": "04c70e5e715955691670c1797a8fb96b8e6155bc", + "source": "manual" +} \ No newline at end of file diff --git a/.claude/skills/bubbletea-maintenance/CHANGELOG.md b/.claude/skills/bubbletea-maintenance/CHANGELOG.md new file mode 100644 index 00000000..5f6a767e --- /dev/null +++ b/.claude/skills/bubbletea-maintenance/CHANGELOG.md @@ -0,0 +1,141 @@ +# Changelog + +All notable changes to Bubble Tea Maintenance Agent will be documented here. + +Format based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +Versioning follows [Semantic Versioning](https://semver.org/). + +## [1.0.0] - 2025-10-19 + +### Added + +**Core Functionality:** +- `diagnose_issue()` - Comprehensive issue diagnosis for Bubble Tea apps +- `apply_best_practices()` - Validation against 11 expert tips +- `debug_performance()` - Performance bottleneck identification +- `suggest_architecture()` - Architecture pattern recommendations +- `fix_layout_issues()` - Lipgloss layout problem solving +- `comprehensive_bubbletea_analysis()` - All-in-one health check orchestrator + +**Issue Detection:** +- Blocking operations in Update() and View() +- Hardcoded terminal dimensions +- Missing terminal recovery code +- Message ordering assumptions +- Model complexity analysis +- Goroutine leak detection +- Layout arithmetic errors +- String concatenation inefficiencies +- Regex compilation in hot paths +- Memory allocation patterns + +**Best Practices Validation:** +- Tip 1: Fast event loop validation +- Tip 2: Debug message dumping capability check +- Tip 3: Live reload setup detection +- Tip 4: Receiver method pattern validation +- Tip 5: Message ordering handling +- Tip 6: Model tree architecture analysis +- Tip 7: Layout arithmetic best practices +- Tip 8: Terminal recovery implementation +- Tip 9: teatest usage +- Tip 10: VHS demo presence +- Tip 11: Additional resources reference + +**Performance Analysis:** +- Update() execution time estimation +- View() rendering complexity analysis +- String operation optimization suggestions +- Loop efficiency checking +- Allocation pattern detection +- Concurrent operation safety validation +- I/O operation placement verification + +**Architecture Recommendations:** +- Pattern detection (flat, multi-view, model tree, component-based, state machine) +- Complexity scoring (0-100) +- Refactoring step generation +- Code template provision for recommended patterns +- Model tree, multi-view, and state machine examples + +**Layout Fixes:** +- Hardcoded dimension detection and fixes +- Padding/border accounting +- Terminal resize handling +- Overflow prevention +- lipgloss.Height()/Width() usage validation + +**Utilities:** +- Go code parser for model, Update(), View(), Init() extraction +- Custom message type detection +- tea.Cmd function analysis +- Bubble Tea component usage finder +- State machine enum extraction +- Comprehensive validation framework + +**Documentation:** +- Complete SKILL.md (8,000+ words) +- README with usage examples +- Common issues reference +- Performance optimization guide +- Layout best practices guide +- Architecture patterns catalog +- Installation guide +- Decision documentation + +**Testing:** +- Unit tests for all 6 core functions +- Integration test suite +- Validation test coverage +- Test fixtures for common scenarios + +### Data Coverage + +**Issue Categories:** +- Performance (7 checks) +- Layout (6 checks) +- Reliability (3 checks) +- Architecture (2 checks) +- Memory (2 checks) + +**Best Practice Tips:** +- 11 expert tips from tip-bubbltea-apps.md +- Compliance scoring +- Recommendation generation + +**Performance Thresholds:** +- Update() target: <16ms +- View() target: <3ms +- Goroutine leak detection +- Memory allocation analysis + +### Known Limitations + +- Requires local tip-bubbltea-apps.md file for full best practices validation +- Go code parsing uses regex (not AST) for speed +- Performance estimates are based on patterns, not actual profiling +- Architecture suggestions are heuristic-based + +### Planned for v2.0 + +- AST-based Go parsing for more accurate analysis +- Integration with pprof for actual performance data +- Automated fix application (not just suggestions) +- Custom best practices rule definitions +- Visual reports with charts/graphs +- CI/CD integration for automated checks + +## [Unreleased] + +### Planned + +- Support for Bubble Tea v1.0+ features +- More architecture patterns (event sourcing, CQRS) +- Performance regression detection +- Code complexity metrics (cyclomatic complexity) +- Dependency analysis +- Security vulnerability checks + +--- + +**Generated with Claude Code agent-creator skill on 2025-10-19** diff --git a/.claude/skills/bubbletea-maintenance/DECISIONS.md b/.claude/skills/bubbletea-maintenance/DECISIONS.md new file mode 100644 index 00000000..cc3ec98f --- /dev/null +++ b/.claude/skills/bubbletea-maintenance/DECISIONS.md @@ -0,0 +1,323 @@ +# Architecture Decisions + +Documentation of key design decisions for Bubble Tea Maintenance Agent. + +## Core Purpose Decision + +**Decision**: Focus on maintenance/debugging of existing Bubble Tea apps, not design + +**Rationale**: +- ✅ Complements `bubbletea-designer` agent (design) with maintenance agent (upkeep) +- ✅ Different problem space: diagnosis vs creation +- ✅ Users have existing apps that need optimization +- ✅ Maintenance is ongoing, design is one-time + +**Alternatives Considered**: +- Combined design+maintenance agent: Too broad, conflicting concerns +- Generic Go linter: Misses Bubble Tea-specific patterns + +--- + +## Data Source Decision + +**Decision**: Use local tip-bubbltea-apps.md file as knowledge base + +**Rationale**: +- ✅ No internet required (offline capability) +- ✅ Fast access (local file system) +- ✅ Expert-curated knowledge (leg100.github.io) +- ✅ 11 specific, actionable tips +- ✅ Can be updated independently + +**Alternatives Considered**: +- Web scraping: Fragile, requires internet, slow +- Embedded knowledge: Hard to update, limited +- API: Rate limits, auth, network dependency + +**Trade-offs**: +- User needs to have tip file locally +- Updates require manual file replacement + +--- + +## Analysis Approach Decision + +**Decision**: 6 separate specialized functions + 1 orchestrator + +**Rationale**: +- ✅ Single Responsibility Principle +- ✅ Composable - can use individually or together +- ✅ Testable - each function independently tested +- ✅ Flexible - run quick diagnosis or deep analysis + +**Structure**: +1. `diagnose_issue()` - General problem identification +2. `apply_best_practices()` - Validate against 11 tips +3. `debug_performance()` - Performance bottleneck detection +4. `suggest_architecture()` - Refactoring recommendations +5. `fix_layout_issues()` - Lipgloss layout fixes +6. `comprehensive_analysis()` - Orchestrates all 5 + +**Alternatives Considered**: +- Single monolithic function: Hard to test, maintain, customize +- 20 micro-functions: Too granular, confusing +- Plugin architecture: Over-engineered for v1.0 + +--- + +## Code Parsing Strategy + +**Decision**: Regex-based parsing instead of AST + +**Rationale**: +- ✅ Fast - no parse tree construction +- ✅ Simple - easy to understand, maintain +- ✅ Good enough - catches 90% of issues +- ✅ No external dependencies (go/parser) +- ✅ Cross-platform - pure Python + +**Alternatives Considered**: +- AST parsing (go/parser): More accurate but slow, complex +- Token-based: Middle ground, still complex +- LLM-based: Overkill, slow, requires API + +**Trade-offs**: +- May miss edge cases (rare nested structures) +- Can't detect all semantic issues +- Good for pattern matching, not deep analysis + +**When to upgrade to AST**: +- v2.0 if accuracy becomes critical +- If false positive rate >5% +- If complex refactoring automation is added + +--- + +## Validation Strategy + +**Decision**: Multi-layer validation with severity levels + +**Rationale**: +- ✅ Early error detection +- ✅ Clear prioritization (CRITICAL > WARNING > INFO) +- ✅ Actionable feedback +- ✅ User can triage fixes + +**Severity Levels**: +- **CRITICAL**: Breaks UI, must fix immediately +- **HIGH**: Significant performance/reliability impact +- **MEDIUM**: Noticeable but not critical +- **WARNING**: Best practice violation +- **LOW**: Minor optimization +- **INFO**: Suggestions, not problems + +**Validation Layers**: +1. Input validation (paths exist, files readable) +2. Structure validation (result format correct) +3. Content validation (scores in range, fields present) +4. Semantic validation (recommendations make sense) + +--- + +## Performance Threshold Decision + +**Decision**: Update() <16ms, View() <3ms targets + +**Rationale**: +- 16ms = 60 FPS (1000ms / 60 = 16.67ms) +- View() should be faster (called more often) +- Based on Bubble Tea best practices +- Leaves budget for framework overhead + +**Measurement**: +- Static analysis (pattern detection, not timing) +- Identifies blocking operations +- Estimates based on operation type: + - HTTP request: 50-200ms + - File I/O: 1-100ms + - Regex compile: 1-10ms + - String concat: 0.1-1ms per operation + +**Future**: v2.0 could integrate pprof for actual measurements + +--- + +## Architecture Pattern Decision + +**Decision**: Heuristic-based pattern detection and recommendations + +**Rationale**: +- ✅ Works without user input +- ✅ Based on complexity metrics +- ✅ Provides concrete steps +- ✅ Includes code templates + +**Complexity Scoring** (0-100): +- File count (10 points max) +- Model field count (20 points) +- Update() case count (20 points) +- View() line count (15 points) +- Custom message count (10 points) +- View function count (15 points) +- Concurrency usage (10 points) + +**Pattern Recommendations**: +- <30: flat_model (simple) +- 30-70: multi_view or component_based (medium) +- 70+: model_tree (complex) + +--- + +## Best Practices Integration + +**Decision**: Map each of 11 tips to automated checks + +**Rationale**: +- ✅ Leverages expert knowledge +- ✅ Specific, actionable tips +- ✅ Comprehensive coverage +- ✅ Education + validation + +**Tip Mapping**: +1. Fast event loop → Check for blocking ops in Update() +2. Debug dumping → Look for spew/io.Writer +3. Live reload → Check for air config +4. Receiver methods → Validate Update() receiver type +5. Message ordering → Check for state tracking +6. Model tree → Analyze model complexity +7. Layout arithmetic → Validate lipgloss.Height() usage +8. Terminal recovery → Check for defer/recover +9. teatest → Look for test files +10. VHS → Check for .tape files +11. Resources → Info-only + +--- + +## Error Handling Strategy + +**Decision**: Return errors in result dict, never raise exceptions + +**Rationale**: +- ✅ Graceful degradation +- ✅ Partial results still useful +- ✅ Easy to aggregate errors +- ✅ Doesn't break orchestrator + +**Format**: +```python +{ + "error": "Description", + "validation": { + "status": "error", + "summary": "What went wrong" + } +} +``` + +**Philosophy**: +- Better to return partial analysis than fail completely +- User can act on what was found +- Errors are just another data point + +--- + +## Report Format Decision + +**Decision**: JSON output with CLI-friendly summary + +**Rationale**: +- ✅ Machine-readable (JSON for tools) +- ✅ Human-readable (CLI summary) +- ✅ Composable (can pipe to jq, etc.) +- ✅ Saveable (file output) + +**Structure**: +```python +{ + "overall_health": 75, + "sections": { + "issues": {...}, + "best_practices": {...}, + "performance": {...}, + "architecture": {...}, + "layout": {...} + }, + "priority_fixes": [...], + "summary": "Executive summary", + "estimated_fix_time": "2-4 hours", + "validation": {...} +} +``` + +--- + +## Testing Strategy + +**Decision**: Unit tests per function + integration tests + +**Rationale**: +- ✅ Each function independently tested +- ✅ Integration tests verify orchestration +- ✅ Test fixtures for common scenarios +- ✅ ~90% code coverage target + +**Test Structure**: +``` +tests/ +├── test_diagnose_issue.py # diagnose_issue() tests +├── test_best_practices.py # apply_best_practices() tests +├── test_performance.py # debug_performance() tests +├── test_architecture.py # suggest_architecture() tests +├── test_layout.py # fix_layout_issues() tests +└── test_integration.py # End-to-end tests +``` + +**Test Coverage**: +- Happy path (valid code) +- Edge cases (empty files, no functions) +- Error cases (invalid paths, bad Go code) +- Integration (orchestrator combines correctly) + +--- + +## Documentation Strategy + +**Decision**: Comprehensive SKILL.md + reference docs + +**Rationale**: +- ✅ Self-contained (agent doesn't need external docs) +- ✅ Examples for every pattern +- ✅ Education + automation +- ✅ Quick reference guides + +**Documentation Files**: +1. **SKILL.md** - Complete agent instructions (8,000 words) +2. **README.md** - Quick start guide +3. **common_issues.md** - Problem/solution catalog +4. **CHANGELOG.md** - Version history +5. **DECISIONS.md** - This file +6. **INSTALLATION.md** - Setup guide + +--- + +## Future Enhancements + +**v2.0 Ideas**: +- AST-based parsing for higher accuracy +- Integration with pprof for actual profiling data +- Automated fix application (not just suggestions) +- Custom rule definitions +- Visual reports +- CI/CD integration +- GitHub Action for PR checks +- VSCode extension integration + +**Criteria for v2.0**: +- User feedback indicates accuracy issues +- False positive rate >5% +- Users request automated fixes +- Adoption reaches 100+ users + +--- + +**Built with Claude Code agent-creator on 2025-10-19** diff --git a/.claude/skills/bubbletea-maintenance/INSTALLATION.md b/.claude/skills/bubbletea-maintenance/INSTALLATION.md new file mode 100644 index 00000000..b421c527 --- /dev/null +++ b/.claude/skills/bubbletea-maintenance/INSTALLATION.md @@ -0,0 +1,332 @@ +# Installation Guide + +Step-by-step guide to installing and using the Bubble Tea Maintenance Agent. + +--- + +## Prerequisites + +**Required:** +- Python 3.8+ +- Claude Code CLI installed + +**Optional (for full functionality):** +- `/Users/williamvansickleiii/charmtuitemplate/charm-tui-template/tip-bubbltea-apps.md` +- `/Users/williamvansickleiii/charmtuitemplate/charm-tui-template/lipgloss-readme.md` + +--- + +## Installation Steps + +### 1. Navigate to Agent Directory + +```bash +cd /Users/williamvansickleiii/charmtuitemplate/vinw/bubbletea-designer/bubbletea-maintenance +``` + +### 2. Verify Files + +Check that all required files exist: + +```bash +ls -la +``` + +You should see: +- `.claude-plugin/marketplace.json` +- `SKILL.md` +- `README.md` +- `scripts/` directory +- `references/` directory +- `tests/` directory + +### 3. Install the Agent + +```bash +/plugin marketplace add . +``` + +Or from within Claude Code: + +``` +/plugin marketplace add /Users/williamvansickleiii/charmtuitemplate/vinw/bubbletea-designer/bubbletea-maintenance +``` + +### 4. Verify Installation + +The agent should now appear in your Claude Code plugins list: + +``` +/plugin list +``` + +Look for: `bubbletea-maintenance` + +--- + +## Testing the Installation + +### Quick Test + +Ask Claude Code: + +``` +"Analyze my Bubble Tea app at /path/to/your/app" +``` + +The agent should activate and run a comprehensive analysis. + +### Detailed Test + +Run the test suite: + +```bash +cd /Users/williamvansickleiii/charmtuitemplate/vinw/bubbletea-designer/bubbletea-maintenance +python3 -m pytest tests/ -v +``` + +Expected output: +``` +tests/test_diagnose_issue.py ✓✓✓✓ +tests/test_best_practices.py ✓✓✓✓ +tests/test_performance.py ✓✓✓✓ +tests/test_architecture.py ✓✓✓✓ +tests/test_layout.py ✓✓✓✓ +tests/test_integration.py ✓✓✓ + +======================== XX passed in X.XXs ======================== +``` + +--- + +## Configuration + +### Setting Up Local References + +For full best practices validation, ensure these files exist: + +1. **tip-bubbltea-apps.md** + ```bash + ls /Users/williamvansickleiii/charmtuitemplate/charm-tui-template/tip-bubbltea-apps.md + ``` + + If missing, the agent will still work but best practices validation will be limited. + +2. **lipgloss-readme.md** + ```bash + ls /Users/williamvansickleiii/charmtuitemplate/charm-tui-template/lipgloss-readme.md + ``` + +### Customizing Paths + +If your reference files are in different locations, update paths in: +- `scripts/apply_best_practices.py` (line 16: `TIPS_FILE`) + +--- + +## Usage Examples + +### Example 1: Diagnose Issues + +``` +User: "My Bubble Tea app is slow, diagnose issues" + +Agent: [Runs diagnose_issue()] +Found 3 issues: +1. CRITICAL: Blocking HTTP request in Update() (main.go:45) +2. WARNING: Hardcoded terminal width (main.go:89) +3. INFO: Consider model tree pattern for 18 fields + +[Provides fixes for each] +``` + +### Example 2: Check Best Practices + +``` +User: "Check if my TUI follows best practices" + +Agent: [Runs apply_best_practices()] +Overall Score: 75/100 + +✅ PASS: Fast event loop +✅ PASS: Terminal recovery +⚠️ FAIL: No debug message dumping +⚠️ FAIL: No tests with teatest +INFO: No VHS demos (optional) + +[Provides recommendations] +``` + +### Example 3: Comprehensive Analysis + +``` +User: "Run full analysis on ./myapp" + +Agent: [Runs comprehensive_bubbletea_analysis()] + +================================================================= +COMPREHENSIVE BUBBLE TEA ANALYSIS +================================================================= + +Overall Health: 78/100 +Summary: Good health. Some improvements recommended. + +Priority Fixes (5): + +🔴 CRITICAL (1): + 1. [Performance] Blocking HTTP request in Update() (main.go:45) + +⚠️ WARNINGS (2): + 2. [Best Practices] Missing debug message dumping + 3. [Layout] Hardcoded dimensions in View() + +💡 INFO (2): + 4. [Architecture] Consider model tree pattern + 5. [Performance] Cache lipgloss styles + +Estimated Fix Time: 2-4 hours + +Full report saved to: ./bubbletea_analysis_report.json +``` + +--- + +## Troubleshooting + +### Issue: Agent Not Activating + +**Solution 1: Check Installation** +```bash +/plugin list +``` + +If not listed, reinstall: +```bash +/plugin marketplace add /path/to/bubbletea-maintenance +``` + +**Solution 2: Use Explicit Activation** + +Instead of: +``` +"Analyze my Bubble Tea app" +``` + +Try: +``` +"Use the bubbletea-maintenance agent to analyze my app" +``` + +### Issue: "No .go files found" + +**Cause**: Wrong path provided + +**Solution**: Use absolute path or verify path exists: +```bash +ls /path/to/your/app +``` + +### Issue: "tip-bubbltea-apps.md not found" + +**Impact**: Best practices validation will be limited + +**Solutions**: + +1. **Get the file**: + ```bash + # If you have charm-tui-template + ls /Users/williamvansickleiii/charmtuitemplate/charm-tui-template/tip-bubbltea-apps.md + ``` + +2. **Update path** in `scripts/apply_best_practices.py`: + ```python + TIPS_FILE = Path("/your/custom/path/tip-bubbltea-apps.md") + ``` + +3. **Or skip best practices**: + The other 5 functions still work without it. + +### Issue: Tests Failing + +**Check Python Version**: +```bash +python3 --version # Should be 3.8+ +``` + +**Install Test Dependencies**: +```bash +pip3 install pytest +``` + +**Run Individual Tests**: +```bash +python3 tests/test_diagnose_issue.py +``` + +### Issue: Permission Denied + +**Solution**: Make scripts executable: +```bash +chmod +x scripts/*.py +``` + +--- + +## Uninstallation + +To remove the agent: + +```bash +/plugin marketplace remove bubbletea-maintenance +``` + +Or manually delete the plugin directory: +```bash +rm -rf /path/to/bubbletea-maintenance +``` + +--- + +## Upgrading + +### To v1.0.1+ + +1. **Backup your config** (if you customized paths) +2. **Remove old version**: + ```bash + /plugin marketplace remove bubbletea-maintenance + ``` +3. **Install new version**: + ```bash + cd /path/to/new/bubbletea-maintenance + /plugin marketplace add . + ``` +4. **Verify**: + ```bash + cat VERSION # Should show new version + ``` + +--- + +## Support + +**Issues**: Check SKILL.md for detailed documentation + +**Questions**: +- Read `references/common_issues.md` for solutions +- Check CHANGELOG.md for known limitations + +--- + +## Next Steps + +After installation: + +1. **Try it out**: Analyze one of your Bubble Tea apps +2. **Read documentation**: Check references/ for guides +3. **Run tests**: Ensure everything works +4. **Customize**: Update paths if needed + +--- + +**Built with Claude Code agent-creator on 2025-10-19** diff --git a/.claude/skills/bubbletea-maintenance/README.md b/.claude/skills/bubbletea-maintenance/README.md new file mode 100644 index 00000000..bc7d0a18 --- /dev/null +++ b/.claude/skills/bubbletea-maintenance/README.md @@ -0,0 +1,320 @@ +# Bubble Tea Maintenance & Debugging Agent + +Expert agent for diagnosing, fixing, and optimizing existing Bubble Tea TUI applications. + +**Version:** 1.0.0 +**Created:** 2025-10-19 + +--- + +## What This Agent Does + +This agent helps you maintain and improve existing Go/Bubble Tea applications by: + +✅ **Diagnosing Issues** - Identifies performance bottlenecks, layout problems, memory leaks +✅ **Validating Best Practices** - Checks against 11 expert tips from tip-bubbltea-apps.md +✅ **Optimizing Performance** - Finds slow operations in Update() and View() +✅ **Suggesting Architecture** - Recommends refactoring to model tree, multi-view patterns +✅ **Fixing Layout Issues** - Solves Lipgloss dimension, padding, overflow problems +✅ **Comprehensive Analysis** - Complete health check with prioritized fixes + +--- + +## Installation + +```bash +cd /path/to/bubbletea-maintenance +/plugin marketplace add . +``` + +The agent will be available in your Claude Code session. + +--- + +## Quick Start + +**Analyze your Bubble Tea app:** + +"Analyze my Bubble Tea application at ./myapp" + +The agent will perform a comprehensive health check and provide: +- Overall health score (0-100) +- Critical issues requiring immediate attention +- Performance bottlenecks +- Layout problems +- Architecture recommendations +- Estimated fix time + +--- + +## Core Functions + +### 1. diagnose_issue() + +Identifies common Bubble Tea problems: +- Blocking operations in event loop +- Hardcoded terminal dimensions +- Missing terminal recovery +- Message ordering issues +- Model complexity problems + +**Usage:** +``` +"Diagnose issues in ./myapp/main.go" +``` + +### 2. apply_best_practices() + +Validates against 11 expert tips: +1. Fast event loop (no blocking) +2. Debug message dumping +3. Live reload setup +4. Proper receiver methods +5. Message ordering handling +6. Model tree architecture +7. Layout arithmetic +8. Terminal recovery +9. teatest usage +10. VHS demos +11. Additional resources + +**Usage:** +``` +"Check best practices for ./myapp" +``` + +### 3. debug_performance() + +Finds performance bottlenecks: +- Slow Update() operations +- Expensive View() rendering +- String concatenation issues +- Regex compilation in functions +- Nested loops +- Memory allocations +- Goroutine leaks + +**Usage:** +``` +"Debug performance of my TUI" +``` + +### 4. suggest_architecture() + +Recommends patterns based on complexity: +- **Simple** (< 30): Flat model +- **Medium** (30-70): Multi-view or component-based +- **Complex** (70+): Model tree + +Provides: +- Current pattern detection +- Complexity score +- Recommended pattern +- Step-by-step refactoring guide +- Code templates + +**Usage:** +``` +"Suggest architecture improvements for ./myapp" +``` + +### 5. fix_layout_issues() + +Solves Lipgloss layout problems: +- Hardcoded dimensions → dynamic sizing +- Manual calculations → lipgloss.Height()/Width() +- Missing padding accounting → GetHorizontalPadding() +- Text overflow → wordwrap or truncate +- No resize handling → tea.WindowSizeMsg +- Border sizing → GetHorizontalBorderSize() + +**Usage:** +``` +"Fix layout issues in my TUI" +``` + +### 6. comprehensive_analysis() + +All-in-one health check combining all functions. + +Outputs: +- Overall health score +- Priority fixes (CRITICAL → WARNING → INFO) +- Estimated fix time +- Detailed report per section + +**Usage:** +``` +"Run comprehensive analysis on ./myapp" +``` + +--- + +## Example Session + +``` +User: "My log viewer TUI is laggy when scrolling" + +Agent: Let me analyze your application... + +[Runs debug_performance()] + +Found the issue! Your View() function: +1. Re-reads the entire log file on every render (200ms) +2. Processes all lines instead of just the visible viewport +3. Uses string concatenation with + operator + +Here's the fix: + +// Cache file content +type model struct { + logLines []string // Cache in model + viewport viewport.Model +} + +// Load once +func loadLogFile() tea.Msg { + content, _ := os.ReadFile("app.log") + lines := strings.Split(string(content), "\\n") + return logLoadedMsg{lines: lines} +} + +// Update only when needed +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + case logLoadedMsg: + m.logLines = msg.lines + m.viewport.SetContent(strings.Join(m.logLines, "\\n")) +} + +// View just renders viewport +func (m model) View() string { + return m.viewport.View() // Fast! No I/O +} + +This will reduce render time from 200ms to ~2ms. +``` + +--- + +## Activation Keywords + +The agent activates on phrases like: + +- "debug my bubble tea app" +- "fix this TUI issue" +- "optimize bubbletea performance" +- "why is my TUI slow" +- "refactor bubble tea code" +- "apply bubbletea best practices" +- "fix layout issues" +- "lipgloss styling problem" +- "improve my TUI" +- "bubbletea architecture help" + +--- + +## Reference Files + +Located in `references/`: + +- **common_issues.md** - Solutions to frequent problems +- **performance_guide.md** - Optimization strategies +- **layout_guide.md** - Lipgloss layout best practices +- **architecture_patterns.md** - Model tree, multi-view, state machine patterns + +--- + +## Local Knowledge Sources + +The agent uses these local files (no internet required): + +- `/Users/williamvansickleiii/charmtuitemplate/charm-tui-template/tip-bubbltea-apps.md` - 11 expert tips +- `/Users/williamvansickleiii/charmtuitemplate/charm-tui-template/lipgloss-readme.md` - Lipgloss docs +- `/Users/williamvansickleiii/charmtuitemplate/vinw/` - Real-world example app +- `/Users/williamvansickleiii/charmtuitemplate/charm-examples-inventory/` - Pattern library + +--- + +## Testing + +Run the test suite: + +```bash +cd bubbletea-maintenance +python3 -m pytest tests/ -v +``` + +Or run individual test files: + +```bash +python3 tests/test_diagnose_issue.py +python3 tests/test_best_practices.py +python3 tests/test_performance.py +``` + +--- + +## Architecture + +``` +bubbletea-maintenance/ +├── SKILL.md # Agent instructions (8,000 words) +├── README.md # This file +├── scripts/ +│ ├── diagnose_issue.py # Issue diagnosis +│ ├── apply_best_practices.py # Best practices validation +│ ├── debug_performance.py # Performance analysis +│ ├── suggest_architecture.py # Architecture recommendations +│ ├── fix_layout_issues.py # Layout fixes +│ ├── comprehensive_analysis.py # All-in-one orchestrator +│ └── utils/ +│ ├── go_parser.py # Go code parsing +│ └── validators/ +│ └── common.py # Validation utilities +├── references/ +│ ├── common_issues.md # Issue reference +│ ├── performance_guide.md # Performance tips +│ ├── layout_guide.md # Layout guide +│ └── architecture_patterns.md # Pattern catalog +├── assets/ +│ ├── issue_categories.json # Issue taxonomy +│ ├── best_practices_tips.json # Tips database +│ └── performance_thresholds.json # Performance targets +└── tests/ + ├── test_diagnose_issue.py + ├── test_best_practices.py + ├── test_performance.py + ├── test_architecture.py + ├── test_layout.py + └── test_integration.py +``` + +--- + +## Limitations + +This agent focuses on **maintenance and debugging**, NOT: + +- ❌ Designing new TUIs from scratch (use `bubbletea-designer` for that) +- ❌ Non-Bubble Tea Go code +- ❌ Terminal emulator issues +- ❌ OS-specific problems + +--- + +## Version History + +**v1.0.0** (2025-10-19) +- Initial release +- 6 core analysis functions +- Integration with tip-bubbltea-apps.md +- Comprehensive diagnostic capabilities +- Layout issue detection and fixing +- Performance profiling +- Architecture recommendations + +--- + +**Built with Claude Code agent-creator on 2025-10-19** + +Questions or issues? Check SKILL.md for detailed documentation. diff --git a/.claude/skills/bubbletea-maintenance/SKILL.md b/.claude/skills/bubbletea-maintenance/SKILL.md new file mode 100644 index 00000000..d244af0d --- /dev/null +++ b/.claude/skills/bubbletea-maintenance/SKILL.md @@ -0,0 +1,724 @@ +# Bubble Tea Maintenance & Debugging Agent + +**Version**: 1.0.0 +**Created**: 2025-10-19 +**Type**: Maintenance & Debugging Agent +**Focus**: Existing Go/Bubble Tea TUI Applications + +--- + +## Overview + +You are an expert Bubble Tea maintenance and debugging agent specializing in diagnosing issues, applying best practices, and enhancing existing Go/Bubble Tea TUI applications. You help developers maintain, debug, and improve their terminal user interfaces built with the Bubble Tea framework. + +## When to Use This Agent + +This agent should be activated when users: +- Experience bugs or issues in existing Bubble Tea applications +- Want to optimize performance of their TUI +- Need to refactor or improve their Bubble Tea code +- Want to apply best practices to their codebase +- Are debugging layout or rendering issues +- Need help with Lipgloss styling problems +- Want to add features to existing Bubble Tea apps +- Have questions about Bubble Tea architecture patterns + +## Activation Keywords + +This agent activates on phrases like: +- "debug my bubble tea app" +- "fix this TUI issue" +- "optimize bubbletea performance" +- "why is my TUI slow" +- "refactor bubble tea code" +- "apply bubbletea best practices" +- "fix layout issues" +- "lipgloss styling problem" +- "improve my TUI" +- "bubbletea architecture help" +- "message handling issues" +- "event loop problems" +- "model tree refactoring" + +## Core Capabilities + +### 1. Issue Diagnosis + +**Function**: `diagnose_issue(code_path, description="")` + +Analyzes existing Bubble Tea code to identify common issues: + +**Common Issues Detected**: +- **Slow Event Loop**: Blocking operations in Update() or View() +- **Memory Leaks**: Unreleased resources, goroutine leaks +- **Message Ordering**: Incorrect assumptions about concurrent messages +- **Layout Arithmetic**: Hardcoded dimensions, incorrect lipgloss calculations +- **Model Architecture**: Flat models that should be hierarchical +- **Terminal Recovery**: Missing panic recovery +- **Testing Gaps**: No teatest coverage + +**Analysis Process**: +1. Parse Go code to extract Model, Update, View functions +2. Check for blocking operations in event loop +3. Identify hardcoded layout values +4. Analyze message handler patterns +5. Check for concurrent command usage +6. Validate terminal cleanup code +7. Generate diagnostic report with severity levels + +**Output Format**: +```python +{ + "issues": [ + { + "severity": "CRITICAL", # CRITICAL, WARNING, INFO + "category": "performance", + "issue": "Blocking sleep in Update() function", + "location": "main.go:45", + "explanation": "time.Sleep blocks the event loop", + "fix": "Move to tea.Cmd goroutine" + } + ], + "summary": "Found 3 critical issues, 5 warnings", + "health_score": 65 # 0-100 +} +``` + +### 2. Best Practices Validation + +**Function**: `apply_best_practices(code_path, tips_file)` + +Validates code against the 11 expert tips from `tip-bubbltea-apps.md`: + +**Tip 1: Keep Event Loop Fast** +- ✅ Check: Update() completes in < 16ms +- ✅ Check: No blocking I/O in Update() or View() +- ✅ Check: Long operations wrapped in tea.Cmd + +**Tip 2: Debug Message Dumping** +- ✅ Check: Has debug message dumping capability +- ✅ Check: Uses spew or similar for message inspection + +**Tip 3: Live Reload** +- ✅ Check: Development workflow supports live reload +- ✅ Check: Uses air or similar tools + +**Tip 4: Receiver Methods** +- ✅ Check: Appropriate use of pointer vs value receivers +- ✅ Check: Update() uses value receiver (standard pattern) + +**Tip 5: Message Ordering** +- ✅ Check: No assumptions about concurrent message order +- ✅ Check: State machine handles out-of-order messages + +**Tip 6: Model Tree** +- ✅ Check: Complex apps use hierarchical models +- ✅ Check: Child models handle their own messages + +**Tip 7: Layout Arithmetic** +- ✅ Check: Uses lipgloss.Height() and lipgloss.Width() +- ✅ Check: No hardcoded dimensions + +**Tip 8: Terminal Recovery** +- ✅ Check: Has panic recovery with tea.EnableMouseAllMotion cleanup +- ✅ Check: Restores terminal on crash + +**Tip 9: Testing with teatest** +- ✅ Check: Has teatest test coverage +- ✅ Check: Tests key interactions + +**Tip 10: VHS Demos** +- ✅ Check: Has VHS demo files for documentation + +**Output Format**: +```python +{ + "compliance": { + "tip_1_fast_event_loop": {"status": "pass", "score": 100}, + "tip_2_debug_dumping": {"status": "fail", "score": 0}, + "tip_3_live_reload": {"status": "warning", "score": 50}, + # ... all 11 tips + }, + "overall_score": 75, + "recommendations": [ + "Add debug message dumping capability", + "Replace hardcoded dimensions with lipgloss calculations" + ] +} +``` + +### 3. Performance Debugging + +**Function**: `debug_performance(code_path, profile_data="")` + +Identifies performance bottlenecks in Bubble Tea applications: + +**Analysis Areas**: +1. **Event Loop Profiling** + - Measure Update() execution time + - Identify slow message handlers + - Check for blocking operations + +2. **View Rendering** + - Measure View() execution time + - Identify expensive string operations + - Check for unnecessary re-renders + +3. **Memory Allocation** + - Identify allocation hotspots + - Check for string concatenation issues + - Validate efficient use of strings.Builder + +4. **Concurrent Commands** + - Check for goroutine leaks + - Validate proper command cleanup + - Identify race conditions + +**Output Format**: +```python +{ + "bottlenecks": [ + { + "function": "Update", + "location": "main.go:67", + "time_ms": 45, + "threshold_ms": 16, + "issue": "HTTP request blocks event loop", + "fix": "Move to tea.Cmd goroutine" + } + ], + "metrics": { + "avg_update_time": "12ms", + "avg_view_time": "3ms", + "memory_allocations": 1250, + "goroutines": 8 + }, + "recommendations": [ + "Move HTTP calls to background commands", + "Use strings.Builder for View() composition", + "Cache expensive lipgloss styles" + ] +} +``` + +### 4. Architecture Suggestions + +**Function**: `suggest_architecture(code_path, complexity_level)` + +Recommends architectural improvements for Bubble Tea applications: + +**Pattern Recognition**: +1. **Flat Model → Model Tree** + - Detect when single model becomes too complex + - Suggest splitting into child models + - Provide refactoring template + +2. **Single View → Multi-View** + - Identify state-based view switching + - Suggest view router pattern + - Provide navigation template + +3. **Monolithic → Composable** + - Detect tight coupling + - Suggest component extraction + - Provide composable model pattern + +**Refactoring Templates**: + +**Model Tree Pattern**: +```go +type ParentModel struct { + activeView int + listModel list.Model + formModel form.Model + viewerModel viewer.Model +} + +func (m ParentModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + // Route to active child + switch m.activeView { + case 0: + m.listModel, cmd = m.listModel.Update(msg) + case 1: + m.formModel, cmd = m.formModel.Update(msg) + case 2: + m.viewerModel, cmd = m.viewerModel.Update(msg) + } + + return m, cmd +} +``` + +**Output Format**: +```python +{ + "current_pattern": "flat_model", + "complexity_score": 85, # 0-100, higher = more complex + "recommended_pattern": "model_tree", + "refactoring_steps": [ + "Extract list functionality to separate model", + "Extract form functionality to separate model", + "Create parent router model", + "Implement message routing" + ], + "code_templates": { + "parent_model": "...", + "child_models": "...", + "message_routing": "..." + } +} +``` + +### 5. Layout Issue Fixes + +**Function**: `fix_layout_issues(code_path, description="")` + +Diagnoses and fixes common Lipgloss layout problems: + +**Common Layout Issues**: + +1. **Hardcoded Dimensions** + ```go + // ❌ BAD + content := lipgloss.NewStyle().Width(80).Height(24).Render(text) + + // ✅ GOOD + termWidth, termHeight, _ := term.GetSize(int(os.Stdout.Fd())) + content := lipgloss.NewStyle(). + Width(termWidth). + Height(termHeight - 2). // Leave room for status bar + Render(text) + ``` + +2. **Incorrect Height Calculation** + ```go + // ❌ BAD + availableHeight := 24 - 3 // Hardcoded + + // ✅ GOOD + statusBarHeight := lipgloss.Height(m.renderStatusBar()) + availableHeight := m.termHeight - statusBarHeight + ``` + +3. **Missing Margin/Padding Accounting** + ```go + // ❌ BAD + content := lipgloss.NewStyle(). + Padding(2). + Width(80). + Render(text) // Text area is 76, not 80! + + // ✅ GOOD + style := lipgloss.NewStyle().Padding(2) + contentWidth := 80 - style.GetHorizontalPadding() + content := style.Width(80).Render( + lipgloss.NewStyle().Width(contentWidth).Render(text) + ) + ``` + +4. **Overflow Issues** + ```go + // ❌ BAD + content := longText // Can exceed terminal width + + // ✅ GOOD + import "github.com/muesli/reflow/wordwrap" + content := wordwrap.String(longText, m.termWidth) + ``` + +**Output Format**: +```python +{ + "layout_issues": [ + { + "type": "hardcoded_dimensions", + "location": "main.go:89", + "current_code": "Width(80).Height(24)", + "fixed_code": "Width(m.termWidth).Height(m.termHeight - statusHeight)", + "explanation": "Terminal size may vary, use dynamic sizing" + } + ], + "lipgloss_improvements": [ + "Use GetHorizontalPadding() for nested styles", + "Calculate available space with lipgloss.Height()", + "Handle terminal resize with tea.WindowSizeMsg" + ] +} +``` + +### 6. Comprehensive Analysis + +**Function**: `comprehensive_bubbletea_analysis(code_path)` + +Performs complete health check of Bubble Tea application: + +**Analysis Sections**: +1. Issue diagnosis (from diagnose_issue) +2. Best practices compliance (from apply_best_practices) +3. Performance analysis (from debug_performance) +4. Architecture recommendations (from suggest_architecture) +5. Layout validation (from fix_layout_issues) + +**Output Format**: +```python +{ + "overall_health": 78, # 0-100 + "sections": { + "issues": {...}, + "best_practices": {...}, + "performance": {...}, + "architecture": {...}, + "layout": {...} + }, + "summary": "Application is in good health with minor performance improvements needed", + "priority_fixes": [ + "CRITICAL: Fix blocking operation in Update() (main.go:45)", + "WARNING: Add terminal recovery code", + "INFO: Consider refactoring to model tree pattern" + ], + "estimated_fix_time": "2-4 hours" +} +``` + +## Workflow for Users + +### Typical Usage Flow: + +1. **User reports issue**: "My TUI is slow when typing" +2. **Agent diagnoses**: Runs `debug_performance()` to identify bottleneck +3. **Agent explains**: "Found blocking regex operation in Update() taking 45ms" +4. **Agent fixes**: Provides code to move operation to tea.Cmd +5. **Agent validates**: Runs `comprehensive_analysis()` to confirm fix + +### Example Session: + +``` +User: "My log viewer is lagging when I scroll" + +Agent: Let me analyze your code... + +[Runs debug_performance()] + +Found the issue! Your View() function is: +1. Re-reading the entire log file on every render (200ms) +2. Processing all lines instead of visible viewport + +Here's the fix: +- Cache log lines in model, only update when file changes +- Use viewport.Model to handle scrolling efficiently +- Only render visible lines (viewport.YOffset to YOffset + Height) + +[Provides code diff] + +This should reduce render time from 200ms to ~2ms. +``` + +## Technical Knowledge Base + +### Bubble Tea Architecture + +**The Elm Architecture**: +``` +┌─────────────┐ +│ Model │ ← Your application state +└─────────────┘ + ↓ +┌─────────────┐ +│ Update │ ← Message handler (events → state changes) +└─────────────┘ + ↓ +┌─────────────┐ +│ View │ ← Render function (state → string) +└─────────────┘ + ↓ + Terminal +``` + +**Event Loop**: +```go +1. User presses key → tea.KeyMsg +2. Update(tea.KeyMsg) → new model + tea.Cmd +3. tea.Cmd executes → returns new msg +4. Update(new msg) → new model +5. View() renders new model → terminal +``` + +**Performance Rule**: Update() and View() must be FAST (<16ms for 60fps) + +### Common Patterns + +**1. Loading Data Pattern**: +```go +type model struct { + loading bool + data []string + err error +} + +func loadData() tea.Msg { + // This runs in goroutine, not in event loop + data, err := fetchData() + return dataLoadedMsg{data: data, err: err} +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "r" { + m.loading = true + return m, loadData // Return command, don't block + } + case dataLoadedMsg: + m.loading = false + m.data = msg.data + m.err = msg.err + } + return m, nil +} +``` + +**2. Model Tree Pattern**: +```go +type appModel struct { + activeView int + + // Child models manage themselves + listView listModel + detailView detailModel + searchView searchModel +} + +func (m appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // Global keys (navigation) + if key, ok := msg.(tea.KeyMsg); ok { + switch key.String() { + case "1": m.activeView = 0; return m, nil + case "2": m.activeView = 1; return m, nil + case "3": m.activeView = 2; return m, nil + } + } + + // Route to active child + var cmd tea.Cmd + switch m.activeView { + case 0: + m.listView, cmd = m.listView.Update(msg) + case 1: + m.detailView, cmd = m.detailView.Update(msg) + case 2: + m.searchView, cmd = m.searchView.Update(msg) + } + + return m, cmd +} + +func (m appModel) View() string { + switch m.activeView { + case 0: return m.listView.View() + case 1: return m.detailView.View() + case 2: return m.searchView.View() + } + return "" +} +``` + +**3. Message Passing Between Models**: +```go +type itemSelectedMsg struct { + itemID string +} + +// Parent routes message to all children +func (m appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case itemSelectedMsg: + // List sent this, detail needs to know + m.detailView.LoadItem(msg.itemID) + m.activeView = 1 // Switch to detail view + } + + // Update all children + var cmds []tea.Cmd + m.listView, cmd := m.listView.Update(msg) + cmds = append(cmds, cmd) + m.detailView, cmd = m.detailView.Update(msg) + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} +``` + +**4. Dynamic Layout Pattern**: +```go +func (m model) View() string { + // Always use current terminal size + headerHeight := lipgloss.Height(m.renderHeader()) + footerHeight := lipgloss.Height(m.renderFooter()) + + availableHeight := m.termHeight - headerHeight - footerHeight + + content := lipgloss.NewStyle(). + Width(m.termWidth). + Height(availableHeight). + Render(m.renderContent()) + + return lipgloss.JoinVertical( + lipgloss.Left, + m.renderHeader(), + content, + m.renderFooter(), + ) +} +``` + +## Integration with Local Resources + +This agent uses local knowledge sources: + +### Primary Reference +**`/Users/williamvansickleiii/charmtuitemplate/charm-tui-template/tip-bubbltea-apps.md`** +- 11 expert tips from leg100.github.io +- Core best practices validation + +### Example Codebases +**`/Users/williamvansickleiii/charmtuitemplate/vinw/`** +- Real-world Bubble Tea application +- Pattern examples + +**`/Users/williamvansickleiii/charmtuitemplate/charm-examples-inventory/`** +- Collection of Charm examples +- Component usage patterns + +### Styling Reference +**`/Users/williamvansickleiii/charmtuitemplate/charm-tui-template/lipgloss-readme.md`** +- Lipgloss API documentation +- Styling patterns + +## Troubleshooting Guide + +### Issue: Slow/Laggy TUI +**Diagnosis Steps**: +1. Profile Update() execution time +2. Profile View() execution time +3. Check for blocking I/O +4. Check for expensive string operations + +**Common Fixes**: +- Move I/O to tea.Cmd goroutines +- Use strings.Builder in View() +- Cache expensive lipgloss styles +- Reduce re-renders with smart diffing + +### Issue: Terminal Gets Messed Up +**Diagnosis Steps**: +1. Check for panic recovery +2. Check for tea.EnableMouseAllMotion cleanup +3. Validate proper program.Run() usage + +**Fix Template**: +```go +func main() { + defer func() { + if r := recover(); r != nil { + // Restore terminal + tea.DisableMouseAllMotion() + tea.ShowCursor() + fmt.Println("Panic:", r) + os.Exit(1) + } + }() + + p := tea.NewProgram(initialModel()) + if err := p.Start(); err != nil { + fmt.Println("Error:", err) + os.Exit(1) + } +} +``` + +### Issue: Layout Overflow/Clipping +**Diagnosis Steps**: +1. Check for hardcoded dimensions +2. Check lipgloss padding/margin accounting +3. Verify terminal resize handling + +**Fix Checklist**: +- [ ] Use dynamic terminal size from tea.WindowSizeMsg +- [ ] Use lipgloss.Height() and lipgloss.Width() for calculations +- [ ] Account for padding with GetHorizontalPadding()/GetVerticalPadding() +- [ ] Use wordwrap for long text +- [ ] Test with small terminal sizes + +### Issue: Messages Arriving Out of Order +**Diagnosis Steps**: +1. Check for concurrent tea.Cmd usage +2. Check for state assumptions about message order +3. Validate state machine handles any order + +**Fix**: +- Use state machine with explicit states +- Don't assume operation A completes before B +- Use message types to track operation identity + +```go +type model struct { + operations map[string]bool // Track concurrent ops +} + +type operationStartMsg struct { id string } +type operationDoneMsg struct { id string, result string } + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case operationStartMsg: + m.operations[msg.id] = true + case operationDoneMsg: + delete(m.operations, msg.id) + // Handle result + } + return m, nil +} +``` + +## Validation and Quality Checks + +After applying fixes, the agent validates: +1. ✅ Code compiles successfully +2. ✅ No new issues introduced +3. ✅ Performance improved (if applicable) +4. ✅ Best practices compliance increased +5. ✅ Tests pass (if present) + +## Limitations + +This agent focuses on maintenance and debugging, NOT: +- Designing new TUIs from scratch (use bubbletea-designer for that) +- Non-Bubble Tea Go code +- Terminal emulator issues +- Operating system specific problems + +## Success Metrics + +A successful maintenance session results in: +- ✅ Issue identified and explained clearly +- ✅ Fix provided with code examples +- ✅ Best practices applied +- ✅ Performance improved (if applicable) +- ✅ User understands the fix and can apply it + +## Version History + +**v1.0.0** (2025-10-19) +- Initial release +- 6 core analysis functions +- Integration with tip-bubbltea-apps.md +- Comprehensive diagnostic capabilities +- Layout issue detection and fixing +- Performance profiling +- Architecture recommendations + +--- + +**Built with Claude Code agent-creator on 2025-10-19** diff --git a/.claude/skills/bubbletea-maintenance/VERSION b/.claude/skills/bubbletea-maintenance/VERSION new file mode 100644 index 00000000..3eefcb9d --- /dev/null +++ b/.claude/skills/bubbletea-maintenance/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/.claude/skills/bubbletea-maintenance/references/common_issues.md b/.claude/skills/bubbletea-maintenance/references/common_issues.md new file mode 100644 index 00000000..12d5365d --- /dev/null +++ b/.claude/skills/bubbletea-maintenance/references/common_issues.md @@ -0,0 +1,567 @@ +# Common Bubble Tea Issues and Solutions + +Reference guide for diagnosing and fixing common problems in Bubble Tea applications. + +## Performance Issues + +### Issue: Slow/Laggy UI + +**Symptoms:** +- UI freezes when typing +- Delayed response to key presses +- Stuttering animations + +**Common Causes:** + +1. **Blocking Operations in Update()** + ```go + // ❌ BAD + func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + data := http.Get("https://api.example.com") // BLOCKS! + m.data = data + } + return m, nil + } + + // ✅ GOOD + func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + return m, fetchDataCmd // Non-blocking + case dataFetchedMsg: + m.data = msg.data + } + return m, nil + } + + func fetchDataCmd() tea.Msg { + data := http.Get("https://api.example.com") // Runs in goroutine + return dataFetchedMsg{data: data} + } + ``` + +2. **Heavy Processing in View()** + ```go + // ❌ BAD + func (m model) View() string { + content, _ := os.ReadFile("large_file.txt") // EVERY RENDER! + return string(content) + } + + // ✅ GOOD + type model struct { + cachedContent string + } + + func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case fileLoadedMsg: + m.cachedContent = msg.content // Cache it + } + return m, nil + } + + func (m model) View() string { + return m.cachedContent // Just return cached data + } + ``` + +3. **String Concatenation with +** + ```go + // ❌ BAD - Allocates many temp strings + func (m model) View() string { + s := "" + for _, line := range m.lines { + s += line + "\\n" // Expensive! + } + return s + } + + // ✅ GOOD - Single allocation + func (m model) View() string { + var b strings.Builder + for _, line := range m.lines { + b.WriteString(line) + b.WriteString("\\n") + } + return b.String() + } + ``` + +**Performance Target:** Update() should complete in <16ms (60 FPS) + +--- + +## Layout Issues + +### Issue: Content Overflows Terminal + +**Symptoms:** +- Text wraps unexpectedly +- Content gets clipped +- Layout breaks on different terminal sizes + +**Common Causes:** + +1. **Hardcoded Dimensions** + ```go + // ❌ BAD + content := lipgloss.NewStyle(). + Width(80). // What if terminal is 120 wide? + Height(24). // What if terminal is 40 tall? + Render(text) + + // ✅ GOOD + type model struct { + termWidth int + termHeight int + } + + func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.termWidth = msg.Width + m.termHeight = msg.Height + } + return m, nil + } + + func (m model) View() string { + content := lipgloss.NewStyle(). + Width(m.termWidth). + Height(m.termHeight - 2). // Leave room for status bar + Render(text) + return content + } + ``` + +2. **Not Accounting for Padding/Borders** + ```go + // ❌ BAD + style := lipgloss.NewStyle(). + Padding(2). + Border(lipgloss.RoundedBorder()). + Width(80) + content := style.Render(text) + // Text area is 76 (80 - 2*2 padding), NOT 80! + + // ✅ GOOD + style := lipgloss.NewStyle(). + Padding(2). + Border(lipgloss.RoundedBorder()) + + contentWidth := 80 - style.GetHorizontalPadding() - style.GetHorizontalBorderSize() + innerContent := lipgloss.NewStyle().Width(contentWidth).Render(text) + result := style.Width(80).Render(innerContent) + ``` + +3. **Manual Height Calculations** + ```go + // ❌ BAD - Magic numbers + availableHeight := 24 - 3 // Where did 3 come from? + + // ✅ GOOD - Calculated + headerHeight := lipgloss.Height(m.renderHeader()) + footerHeight := lipgloss.Height(m.renderFooter()) + availableHeight := m.termHeight - headerHeight - footerHeight + ``` + +--- + +## Message Handling Issues + +### Issue: Messages Arrive Out of Order + +**Symptoms:** +- State becomes inconsistent +- Operations complete in wrong order +- Race conditions + +**Cause:** Concurrent tea.Cmd messages aren't guaranteed to arrive in order + +**Solution: Use State Tracking** + +```go +// ❌ BAD - Assumes order +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "r" { + return m, tea.Batch( + fetchUsersCmd, // Might complete second + fetchPostsCmd, // Might complete first + ) + } + case usersLoadedMsg: + m.users = msg.users + case postsLoadedMsg: + m.posts = msg.posts + // Assumes users are loaded! May not be! + } + return m, nil +} + +// ✅ GOOD - Track operations +type model struct { + operations map[string]bool + users []User + posts []Post +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "r" { + m.operations["users"] = true + m.operations["posts"] = true + return m, tea.Batch(fetchUsersCmd, fetchPostsCmd) + } + case usersLoadedMsg: + m.users = msg.users + delete(m.operations, "users") + return m, m.checkAllLoaded() + case postsLoadedMsg: + m.posts = msg.posts + delete(m.operations, "posts") + return m, m.checkAllLoaded() + } + return m, nil +} + +func (m model) checkAllLoaded() tea.Cmd { + if len(m.operations) == 0 { + // All operations complete, can proceed + return m.processData + } + return nil +} +``` + +--- + +## Terminal Recovery Issues + +### Issue: Terminal Gets Messed Up After Crash + +**Symptoms:** +- Cursor disappears +- Mouse mode still active +- Terminal looks corrupted + +**Solution: Add Panic Recovery** + +```go +func main() { + defer func() { + if r := recover(); r != nil { + // Restore terminal state + tea.DisableMouseAllMotion() + tea.ShowCursor() + fmt.Printf("Panic: %v\\n", r) + debug.PrintStack() + os.Exit(1) + } + }() + + p := tea.NewProgram(initialModel()) + if err := p.Start(); err != nil { + fmt.Printf("Error: %v\\n", err) + os.Exit(1) + } +} +``` + +--- + +## Architecture Issues + +### Issue: Model Too Complex + +**Symptoms:** +- Model struct has 20+ fields +- Update() is hundreds of lines +- Hard to maintain + +**Solution: Use Model Tree Pattern** + +```go +// ❌ BAD - Flat model +type model struct { + // List view fields + listItems []string + listCursor int + listFilter string + + // Detail view fields + detailItem string + detailHTML string + detailScroll int + + // Search view fields + searchQuery string + searchResults []string + searchCursor int + + // ... 15 more fields +} + +// ✅ GOOD - Model tree +type appModel struct { + activeView int + listView listViewModel + detailView detailViewModel + searchView searchViewModel +} + +type listViewModel struct { + items []string + cursor int + filter string +} + +func (m listViewModel) Update(msg tea.Msg) (listViewModel, tea.Cmd) { + // Only handles list-specific messages + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "up": + m.cursor-- + case "down": + m.cursor++ + case "enter": + return m, func() tea.Msg { + return itemSelectedMsg{itemID: m.items[m.cursor]} + } + } + } + return m, nil +} + +// Parent routes messages +func (m appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // Handle global messages + switch msg := msg.(type) { + case itemSelectedMsg: + m.detailView.LoadItem(msg.itemID) + m.activeView = 1 // Switch to detail + return m, nil + } + + // Route to active child + var cmd tea.Cmd + switch m.activeView { + case 0: + m.listView, cmd = m.listView.Update(msg) + case 1: + m.detailView, cmd = m.detailView.Update(msg) + case 2: + m.searchView, cmd = m.searchView.Update(msg) + } + + return m, cmd +} +``` + +--- + +## Memory Issues + +### Issue: Memory Leak / Growing Memory Usage + +**Symptoms:** +- Memory usage increases over time +- Never gets garbage collected + +**Common Causes:** + +1. **Goroutine Leaks** + ```go + // ❌ BAD - Goroutines never stop + func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "s" { + return m, func() tea.Msg { + go func() { + for { // INFINITE LOOP! + time.Sleep(time.Second) + // Do something + } + }() + return nil + } + } + } + return m, nil + } + + // ✅ GOOD - Use context for cancellation + type model struct { + ctx context.Context + cancel context.CancelFunc + } + + func initialModel() model { + ctx, cancel := context.WithCancel(context.Background()) + return model{ctx: ctx, cancel: cancel} + } + + func worker(ctx context.Context) tea.Msg { + for { + select { + case <-ctx.Done(): + return nil // Stop gracefully + case <-time.After(time.Second): + // Do work + } + } + } + + func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "q" { + m.cancel() // Stop all workers + return m, tea.Quit + } + } + return m, nil + } + ``` + +2. **Unreleased Resources** + ```go + // ❌ BAD + func loadFile() tea.Msg { + file, _ := os.Open("data.txt") + // Never closed! + data, _ := io.ReadAll(file) + return dataMsg{data: data} + } + + // ✅ GOOD + func loadFile() tea.Msg { + file, err := os.Open("data.txt") + if err != nil { + return errorMsg{err: err} + } + defer file.Close() // Always close + + data, err := io.ReadAll(file) + return dataMsg{data: data, err: err} + } + ``` + +--- + +## Testing Issues + +### Issue: Hard to Test TUI + +**Solution: Use teatest** + +```go +import ( + "testing" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/bubbletea/teatest" +) + +func TestNavigation(t *testing.T) { + m := initialModel() + + // Create test program + tm := teatest.NewTestModel(t, m) + + // Send key presses + tm.Send(tea.KeyMsg{Type: tea.KeyDown}) + tm.Send(tea.KeyMsg{Type: tea.KeyDown}) + + // Wait for program to process + teatest.WaitFor( + t, tm.Output(), + func(bts []byte) bool { + return bytes.Contains(bts, []byte("Item 2")) + }, + teatest.WithCheckInterval(time.Millisecond*100), + teatest.WithDuration(time.Second*3), + ) + + // Verify state + finalModel := tm.FinalModel(t).(model) + if finalModel.cursor != 2 { + t.Errorf("Expected cursor at 2, got %d", finalModel.cursor) + } +} +``` + +--- + +## Debugging Tips + +### Enable Message Dumping + +```go +import "github.com/davecgh/go-spew/spew" + +type model struct { + dump io.Writer +} + +func main() { + // Create debug file + f, _ := os.Create("debug.log") + defer f.Close() + + m := model{dump: f} + p := tea.NewProgram(m) + p.Start() +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // Dump every message + if m.dump != nil { + spew.Fdump(m.dump, msg) + } + + // ... rest of Update() + return m, nil +} +``` + +### Live Reload with Air + +`.air.toml`: +```toml +[build] + cmd = "go build -o ./tmp/main ." + bin = "tmp/main" + include_ext = ["go"] + exclude_dir = ["tmp"] + delay = 1000 +``` + +Run: `air` + +--- + +## Quick Checklist + +Before deploying your Bubble Tea app: + +- [ ] No blocking operations in Update() or View() +- [ ] Terminal resize handled (tea.WindowSizeMsg) +- [ ] Panic recovery with terminal cleanup +- [ ] Dynamic layout (no hardcoded dimensions) +- [ ] Lipgloss padding/borders accounted for +- [ ] String operations use strings.Builder +- [ ] Goroutines have cancellation (context) +- [ ] Resources properly closed (defer) +- [ ] State machine handles message ordering +- [ ] Tests with teatest for key interactions + +--- + +**Generated for Bubble Tea Maintenance Agent v1.0.0** diff --git a/.claude/skills/bubbletea-maintenance/scripts/__pycache__/apply_best_practices.cpython-311.pyc b/.claude/skills/bubbletea-maintenance/scripts/__pycache__/apply_best_practices.cpython-311.pyc new file mode 100644 index 00000000..a69d7e40 Binary files /dev/null and b/.claude/skills/bubbletea-maintenance/scripts/__pycache__/apply_best_practices.cpython-311.pyc differ diff --git a/.claude/skills/bubbletea-maintenance/scripts/__pycache__/comprehensive_bubbletea_analysis.cpython-311.pyc b/.claude/skills/bubbletea-maintenance/scripts/__pycache__/comprehensive_bubbletea_analysis.cpython-311.pyc new file mode 100644 index 00000000..afc6c25e Binary files /dev/null and b/.claude/skills/bubbletea-maintenance/scripts/__pycache__/comprehensive_bubbletea_analysis.cpython-311.pyc differ diff --git a/.claude/skills/bubbletea-maintenance/scripts/__pycache__/debug_performance.cpython-311.pyc b/.claude/skills/bubbletea-maintenance/scripts/__pycache__/debug_performance.cpython-311.pyc new file mode 100644 index 00000000..e546f44f Binary files /dev/null and b/.claude/skills/bubbletea-maintenance/scripts/__pycache__/debug_performance.cpython-311.pyc differ diff --git a/.claude/skills/bubbletea-maintenance/scripts/__pycache__/diagnose_issue.cpython-311.pyc b/.claude/skills/bubbletea-maintenance/scripts/__pycache__/diagnose_issue.cpython-311.pyc new file mode 100644 index 00000000..f149ca94 Binary files /dev/null and b/.claude/skills/bubbletea-maintenance/scripts/__pycache__/diagnose_issue.cpython-311.pyc differ diff --git a/.claude/skills/bubbletea-maintenance/scripts/__pycache__/fix_layout_issues.cpython-311.pyc b/.claude/skills/bubbletea-maintenance/scripts/__pycache__/fix_layout_issues.cpython-311.pyc new file mode 100644 index 00000000..e1860e2a Binary files /dev/null and b/.claude/skills/bubbletea-maintenance/scripts/__pycache__/fix_layout_issues.cpython-311.pyc differ diff --git a/.claude/skills/bubbletea-maintenance/scripts/__pycache__/suggest_architecture.cpython-311.pyc b/.claude/skills/bubbletea-maintenance/scripts/__pycache__/suggest_architecture.cpython-311.pyc new file mode 100644 index 00000000..5530afc7 Binary files /dev/null and b/.claude/skills/bubbletea-maintenance/scripts/__pycache__/suggest_architecture.cpython-311.pyc differ diff --git a/.claude/skills/bubbletea-maintenance/scripts/apply_best_practices.py b/.claude/skills/bubbletea-maintenance/scripts/apply_best_practices.py new file mode 100644 index 00000000..2af1ff08 --- /dev/null +++ b/.claude/skills/bubbletea-maintenance/scripts/apply_best_practices.py @@ -0,0 +1,506 @@ +#!/usr/bin/env python3 +""" +Apply Bubble Tea best practices validation. +Validates code against 11 expert tips from tip-bubbltea-apps.md. +""" + +import os +import re +import json +from pathlib import Path +from typing import Dict, List, Any, Tuple + + +# Path to tips reference +TIPS_FILE = Path("/Users/williamvansickleiii/charmtuitemplate/charm-tui-template/tip-bubbltea-apps.md") + + +def apply_best_practices(code_path: str, tips_file: str = None) -> Dict[str, Any]: + """ + Validate Bubble Tea code against best practices from tip-bubbltea-apps.md. + + Args: + code_path: Path to Go file or directory + tips_file: Optional path to tips file (defaults to standard location) + + Returns: + Dictionary containing: + - compliance: Status for each of 11 tips + - overall_score: 0-100 + - recommendations: List of improvements + - validation: Validation report + """ + path = Path(code_path) + + if not path.exists(): + return { + "error": f"Path not found: {code_path}", + "validation": {"status": "error", "summary": "Invalid path"} + } + + # Collect all .go files + go_files = [] + if path.is_file(): + if path.suffix == '.go': + go_files = [path] + else: + go_files = list(path.glob('**/*.go')) + + if not go_files: + return { + "error": "No .go files found", + "validation": {"status": "error", "summary": "No Go files"} + } + + # Read all Go code + all_content = "" + for go_file in go_files: + try: + all_content += go_file.read_text() + "\n" + except Exception: + pass + + # Check each tip + compliance = {} + + compliance["tip_1_fast_event_loop"] = _check_tip_1_fast_event_loop(all_content, go_files) + compliance["tip_2_debug_dumping"] = _check_tip_2_debug_dumping(all_content, go_files) + compliance["tip_3_live_reload"] = _check_tip_3_live_reload(path) + compliance["tip_4_receiver_methods"] = _check_tip_4_receiver_methods(all_content, go_files) + compliance["tip_5_message_ordering"] = _check_tip_5_message_ordering(all_content, go_files) + compliance["tip_6_model_tree"] = _check_tip_6_model_tree(all_content, go_files) + compliance["tip_7_layout_arithmetic"] = _check_tip_7_layout_arithmetic(all_content, go_files) + compliance["tip_8_terminal_recovery"] = _check_tip_8_terminal_recovery(all_content, go_files) + compliance["tip_9_teatest"] = _check_tip_9_teatest(path) + compliance["tip_10_vhs"] = _check_tip_10_vhs(path) + compliance["tip_11_resources"] = {"status": "info", "score": 100, "message": "Check leg100.github.io for more tips"} + + # Calculate overall score + scores = [tip["score"] for tip in compliance.values()] + overall_score = int(sum(scores) / len(scores)) + + # Generate recommendations + recommendations = [] + for tip_name, tip_data in compliance.items(): + if tip_data["status"] == "fail": + recommendations.append(tip_data.get("recommendation", f"Implement {tip_name}")) + + # Summary + if overall_score >= 90: + summary = f"✅ Excellent! Score: {overall_score}/100. Following best practices." + elif overall_score >= 70: + summary = f"✓ Good. Score: {overall_score}/100. Some improvements possible." + elif overall_score >= 50: + summary = f"⚠️ Fair. Score: {overall_score}/100. Several best practices missing." + else: + summary = f"❌ Poor. Score: {overall_score}/100. Many best practices not followed." + + # Validation + validation = { + "status": "pass" if overall_score >= 70 else "warning" if overall_score >= 50 else "fail", + "summary": summary, + "checks": { + "fast_event_loop": compliance["tip_1_fast_event_loop"]["status"] == "pass", + "has_debugging": compliance["tip_2_debug_dumping"]["status"] == "pass", + "proper_layout": compliance["tip_7_layout_arithmetic"]["status"] == "pass", + "has_recovery": compliance["tip_8_terminal_recovery"]["status"] == "pass" + } + } + + return { + "compliance": compliance, + "overall_score": overall_score, + "recommendations": recommendations, + "summary": summary, + "files_analyzed": len(go_files), + "validation": validation + } + + +def _check_tip_1_fast_event_loop(content: str, files: List[Path]) -> Dict[str, Any]: + """Tip 1: Keep the event loop fast.""" + # Check for blocking operations in Update() or View() + blocking_patterns = [ + r'\btime\.Sleep\s*\(', + r'\bhttp\.(Get|Post|Do)\s*\(', + r'\bos\.Open\s*\(', + r'\bio\.ReadAll\s*\(', + r'\bexec\.Command\([^)]+\)\.Run\(\)', + ] + + has_blocking = any(re.search(pattern, content) for pattern in blocking_patterns) + has_tea_cmd = bool(re.search(r'tea\.Cmd', content)) + + if has_blocking and not has_tea_cmd: + return { + "status": "fail", + "score": 0, + "message": "Blocking operations found in event loop without tea.Cmd", + "recommendation": "Move blocking operations to tea.Cmd goroutines", + "explanation": "Blocking ops in Update()/View() freeze the UI. Use tea.Cmd for I/O." + } + elif has_blocking and has_tea_cmd: + return { + "status": "warning", + "score": 50, + "message": "Blocking operations present but tea.Cmd is used", + "recommendation": "Verify all blocking ops are in tea.Cmd, not Update()/View()", + "explanation": "Review code to ensure blocking operations are properly wrapped" + } + else: + return { + "status": "pass", + "score": 100, + "message": "No blocking operations detected in event loop", + "explanation": "Event loop appears to be non-blocking" + } + + +def _check_tip_2_debug_dumping(content: str, files: List[Path]) -> Dict[str, Any]: + """Tip 2: Dump messages to a file for debugging.""" + has_spew = bool(re.search(r'github\.com/davecgh/go-spew', content)) + has_debug_write = bool(re.search(r'(dump|debug|log)\s+io\.Writer', content)) + has_fmt_fprintf = bool(re.search(r'fmt\.Fprintf', content)) + + if has_spew or has_debug_write: + return { + "status": "pass", + "score": 100, + "message": "Debug message dumping capability detected", + "explanation": "Using spew or debug writer for message inspection" + } + elif has_fmt_fprintf: + return { + "status": "warning", + "score": 60, + "message": "Basic logging present, but no structured message dumping", + "recommendation": "Add spew.Fdump for detailed message inspection", + "explanation": "fmt.Fprintf works but spew provides better message structure" + } + else: + return { + "status": "fail", + "score": 0, + "message": "No debug message dumping detected", + "recommendation": "Add message dumping with go-spew:\n" + + "import \"github.com/davecgh/go-spew/spew\"\n" + + "type model struct { dump io.Writer }\n" + + "func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {\n" + + " if m.dump != nil { spew.Fdump(m.dump, msg) }\n" + + " // ... rest of Update()\n" + + "}", + "explanation": "Message dumping helps debug complex message flows" + } + + +def _check_tip_3_live_reload(path: Path) -> Dict[str, Any]: + """Tip 3: Live reload code changes.""" + # Check for air config or similar + has_air_config = (path / ".air.toml").exists() + has_makefile_watch = False + + if (path / "Makefile").exists(): + makefile = (path / "Makefile").read_text() + has_makefile_watch = bool(re.search(r'watch:|live:', makefile)) + + if has_air_config: + return { + "status": "pass", + "score": 100, + "message": "Live reload configured with air", + "explanation": "Found .air.toml configuration" + } + elif has_makefile_watch: + return { + "status": "pass", + "score": 100, + "message": "Live reload configured in Makefile", + "explanation": "Found watch/live target in Makefile" + } + else: + return { + "status": "info", + "score": 100, + "message": "No live reload detected (optional)", + "recommendation": "Consider adding air for live reload during development", + "explanation": "Live reload improves development speed but is optional" + } + + +def _check_tip_4_receiver_methods(content: str, files: List[Path]) -> Dict[str, Any]: + """Tip 4: Use pointer vs value receivers judiciously.""" + # Check Update() receiver type (should be value receiver) + update_value_receiver = bool(re.search(r'func\s+\(m\s+\w+\)\s+Update\s*\(', content)) + update_pointer_receiver = bool(re.search(r'func\s+\(m\s+\*\w+\)\s+Update\s*\(', content)) + + if update_pointer_receiver: + return { + "status": "warning", + "score": 60, + "message": "Update() uses pointer receiver (uncommon pattern)", + "recommendation": "Consider value receiver for Update() (standard pattern)", + "explanation": "Value receiver is standard for Update() in Bubble Tea" + } + elif update_value_receiver: + return { + "status": "pass", + "score": 100, + "message": "Update() uses value receiver (correct)", + "explanation": "Following standard Bubble Tea pattern" + } + else: + return { + "status": "info", + "score": 100, + "message": "No Update() method found or unable to detect", + "explanation": "Could not determine receiver type" + } + + +def _check_tip_5_message_ordering(content: str, files: List[Path]) -> Dict[str, Any]: + """Tip 5: Messages from concurrent commands not guaranteed in order.""" + has_batch = bool(re.search(r'tea\.Batch\s*\(', content)) + has_concurrent_cmds = bool(re.search(r'go\s+func\s*\(', content)) + has_state_tracking = bool(re.search(r'type\s+\w*State\s+(int|string)', content)) or \ + bool(re.search(r'operations\s+map\[string\]', content)) + + if (has_batch or has_concurrent_cmds) and not has_state_tracking: + return { + "status": "warning", + "score": 50, + "message": "Concurrent commands without explicit state tracking", + "recommendation": "Add state machine to track concurrent operations", + "explanation": "tea.Batch messages arrive in unpredictable order" + } + elif has_batch or has_concurrent_cmds: + return { + "status": "pass", + "score": 100, + "message": "Concurrent commands with state tracking", + "explanation": "Proper handling of message ordering" + } + else: + return { + "status": "pass", + "score": 100, + "message": "No concurrent commands detected", + "explanation": "Message ordering is deterministic" + } + + +def _check_tip_6_model_tree(content: str, files: List[Path]) -> Dict[str, Any]: + """Tip 6: Build a tree of models for complex apps.""" + # Count model fields + model_match = re.search(r'type\s+(\w*[Mm]odel)\s+struct\s*\{([^}]+)\}', content, re.DOTALL) + if not model_match: + return { + "status": "info", + "score": 100, + "message": "No model struct found", + "explanation": "Could not analyze model structure" + } + + model_body = model_match.group(2) + field_count = len([line for line in model_body.split('\n') if line.strip() and not line.strip().startswith('//')]) + + # Check for child models + has_child_models = bool(re.search(r'\w+Model\s+\w+Model', content)) + + if field_count > 20 and not has_child_models: + return { + "status": "warning", + "score": 40, + "message": f"Large model ({field_count} fields) without child models", + "recommendation": "Consider refactoring to model tree pattern", + "explanation": "Large models are hard to maintain. Split into child models." + } + elif field_count > 15 and not has_child_models: + return { + "status": "info", + "score": 70, + "message": f"Medium model ({field_count} fields)", + "recommendation": "Consider model tree if complexity increases", + "explanation": "Model is getting large, monitor complexity" + } + elif has_child_models: + return { + "status": "pass", + "score": 100, + "message": "Using model tree pattern with child models", + "explanation": "Good architecture for complex apps" + } + else: + return { + "status": "pass", + "score": 100, + "message": f"Simple model ({field_count} fields)", + "explanation": "Model size is appropriate" + } + + +def _check_tip_7_layout_arithmetic(content: str, files: List[Path]) -> Dict[str, Any]: + """Tip 7: Layout arithmetic is error-prone.""" + uses_lipgloss = bool(re.search(r'github\.com/charmbracelet/lipgloss', content)) + has_lipgloss_helpers = bool(re.search(r'lipgloss\.(Height|Width|GetVertical|GetHorizontal)', content)) + has_hardcoded_dimensions = bool(re.search(r'\.(Width|Height)\s*\(\s*\d{2,}\s*\)', content)) + + if uses_lipgloss and has_lipgloss_helpers and not has_hardcoded_dimensions: + return { + "status": "pass", + "score": 100, + "message": "Using lipgloss helpers for dynamic layout", + "explanation": "Correct use of lipgloss.Height()/Width()" + } + elif uses_lipgloss and has_hardcoded_dimensions: + return { + "status": "warning", + "score": 40, + "message": "Hardcoded dimensions detected", + "recommendation": "Use lipgloss.Height() and lipgloss.Width() for calculations", + "explanation": "Hardcoded dimensions don't adapt to terminal size" + } + elif uses_lipgloss: + return { + "status": "warning", + "score": 60, + "message": "Using lipgloss but unclear if using helpers", + "recommendation": "Use lipgloss.Height() and lipgloss.Width() for layout", + "explanation": "Avoid manual height/width calculations" + } + else: + return { + "status": "info", + "score": 100, + "message": "Not using lipgloss", + "explanation": "Layout tip applies when using lipgloss" + } + + +def _check_tip_8_terminal_recovery(content: str, files: List[Path]) -> Dict[str, Any]: + """Tip 8: Recover your terminal after panics.""" + has_defer_recover = bool(re.search(r'defer\s+func\s*\(\s*\)\s*\{[^}]*recover\(\)', content, re.DOTALL)) + has_main = bool(re.search(r'func\s+main\s*\(\s*\)', content)) + has_disable_mouse = bool(re.search(r'tea\.DisableMouseAllMotion', content)) + + if has_main and has_defer_recover and has_disable_mouse: + return { + "status": "pass", + "score": 100, + "message": "Panic recovery with terminal cleanup", + "explanation": "Proper defer recover() with DisableMouseAllMotion" + } + elif has_main and has_defer_recover: + return { + "status": "warning", + "score": 70, + "message": "Panic recovery but missing DisableMouseAllMotion", + "recommendation": "Add tea.DisableMouseAllMotion() in panic handler", + "explanation": "Need to cleanup mouse mode on panic" + } + elif has_main: + return { + "status": "fail", + "score": 0, + "message": "Missing panic recovery in main()", + "recommendation": "Add defer recover() with terminal cleanup", + "explanation": "Panics can leave terminal in broken state" + } + else: + return { + "status": "info", + "score": 100, + "message": "No main() found (library code?)", + "explanation": "Recovery applies to main applications" + } + + +def _check_tip_9_teatest(path: Path) -> Dict[str, Any]: + """Tip 9: Use teatest for end-to-end tests.""" + # Look for test files using teatest + test_files = list(path.glob('**/*_test.go')) + has_teatest = False + + for test_file in test_files: + try: + content = test_file.read_text() + if 'teatest' in content or 'tea/teatest' in content: + has_teatest = True + break + except Exception: + pass + + if has_teatest: + return { + "status": "pass", + "score": 100, + "message": "Using teatest for testing", + "explanation": "Found teatest in test files" + } + elif test_files: + return { + "status": "warning", + "score": 60, + "message": "Has tests but not using teatest", + "recommendation": "Consider using teatest for TUI integration tests", + "explanation": "teatest enables end-to-end TUI testing" + } + else: + return { + "status": "fail", + "score": 0, + "message": "No tests found", + "recommendation": "Add teatest tests for key interactions", + "explanation": "Testing improves reliability" + } + + +def _check_tip_10_vhs(path: Path) -> Dict[str, Any]: + """Tip 10: Use VHS to record demos.""" + # Look for .tape files (VHS) + vhs_files = list(path.glob('**/*.tape')) + + if vhs_files: + return { + "status": "pass", + "score": 100, + "message": f"Found {len(vhs_files)} VHS demo file(s)", + "explanation": "Using VHS for documentation" + } + else: + return { + "status": "info", + "score": 100, + "message": "No VHS demos found (optional)", + "recommendation": "Consider adding VHS demos for documentation", + "explanation": "VHS creates great animated demos but is optional" + } + + +def validate_best_practices(result: Dict[str, Any]) -> Dict[str, Any]: + """Validate best practices result.""" + if 'error' in result: + return {"status": "error", "summary": result['error']} + + overall_score = result.get('overall_score', 0) + status = "pass" if overall_score >= 70 else "warning" if overall_score >= 50 else "fail" + + return { + "status": status, + "summary": result.get('summary', 'Best practices check complete'), + "score": overall_score, + "valid": True + } + + +if __name__ == "__main__": + import sys + + if len(sys.argv) < 2: + print("Usage: apply_best_practices.py [tips_file]") + sys.exit(1) + + code_path = sys.argv[1] + tips_file = sys.argv[2] if len(sys.argv) > 2 else None + + result = apply_best_practices(code_path, tips_file) + print(json.dumps(result, indent=2)) diff --git a/.claude/skills/bubbletea-maintenance/scripts/comprehensive_bubbletea_analysis.py b/.claude/skills/bubbletea-maintenance/scripts/comprehensive_bubbletea_analysis.py new file mode 100644 index 00000000..c15f36ca --- /dev/null +++ b/.claude/skills/bubbletea-maintenance/scripts/comprehensive_bubbletea_analysis.py @@ -0,0 +1,433 @@ +#!/usr/bin/env python3 +""" +Comprehensive Bubble Tea application analysis. +Orchestrates all analysis functions for complete health check. +""" + +import sys +import json +from pathlib import Path +from typing import Dict, List, Any + +# Import all analysis functions +sys.path.insert(0, str(Path(__file__).parent)) + +from diagnose_issue import diagnose_issue +from apply_best_practices import apply_best_practices +from debug_performance import debug_performance +from suggest_architecture import suggest_architecture +from fix_layout_issues import fix_layout_issues + + +def comprehensive_bubbletea_analysis(code_path: str, detail_level: str = "standard") -> Dict[str, Any]: + """ + Perform complete health check of Bubble Tea application. + + Args: + code_path: Path to Go file or directory containing Bubble Tea code + detail_level: "quick", "standard", or "deep" + + Returns: + Dictionary containing: + - overall_health: 0-100 score + - sections: Results from each analysis function + - summary: Executive summary + - priority_fixes: Ordered list of critical/high-priority issues + - estimated_fix_time: Time estimate for addressing issues + - validation: Overall validation report + """ + path = Path(code_path) + + if not path.exists(): + return { + "error": f"Path not found: {code_path}", + "validation": {"status": "error", "summary": "Invalid path"} + } + + print(f"\n{'='*70}") + print(f"COMPREHENSIVE BUBBLE TEA ANALYSIS") + print(f"{'='*70}") + print(f"Analyzing: {path}") + print(f"Detail level: {detail_level}\n") + + sections = {} + + # Section 1: Issue Diagnosis + print("🔍 [1/5] Diagnosing issues...") + try: + sections['issues'] = diagnose_issue(str(path)) + print(f" ✓ Found {len(sections['issues'].get('issues', []))} issue(s)") + except Exception as e: + sections['issues'] = {"error": str(e)} + print(f" ✗ Error: {e}") + + # Section 2: Best Practices Compliance + print("📋 [2/5] Checking best practices...") + try: + sections['best_practices'] = apply_best_practices(str(path)) + score = sections['best_practices'].get('overall_score', 0) + print(f" ✓ Score: {score}/100") + except Exception as e: + sections['best_practices'] = {"error": str(e)} + print(f" ✗ Error: {e}") + + # Section 3: Performance Analysis + print("⚡ [3/5] Analyzing performance...") + try: + sections['performance'] = debug_performance(str(path)) + bottleneck_count = len(sections['performance'].get('bottlenecks', [])) + print(f" ✓ Found {bottleneck_count} bottleneck(s)") + except Exception as e: + sections['performance'] = {"error": str(e)} + print(f" ✗ Error: {e}") + + # Section 4: Architecture Recommendations + if detail_level in ["standard", "deep"]: + print("🏗️ [4/5] Analyzing architecture...") + try: + sections['architecture'] = suggest_architecture(str(path)) + current = sections['architecture'].get('current_pattern', 'unknown') + recommended = sections['architecture'].get('recommended_pattern', 'unknown') + print(f" ✓ Current: {current}, Recommended: {recommended}") + except Exception as e: + sections['architecture'] = {"error": str(e)} + print(f" ✗ Error: {e}") + else: + print("🏗️ [4/5] Skipping architecture (quick mode)") + sections['architecture'] = {"skipped": "quick mode"} + + # Section 5: Layout Validation + print("📐 [5/5] Checking layout...") + try: + sections['layout'] = fix_layout_issues(str(path)) + issue_count = len(sections['layout'].get('layout_issues', [])) + print(f" ✓ Found {issue_count} layout issue(s)") + except Exception as e: + sections['layout'] = {"error": str(e)} + print(f" ✗ Error: {e}") + + print() + + # Calculate overall health + overall_health = _calculate_overall_health(sections) + + # Extract priority fixes + priority_fixes = _extract_priority_fixes(sections) + + # Estimate fix time + estimated_fix_time = _estimate_fix_time(priority_fixes) + + # Generate summary + summary = _generate_summary(overall_health, sections, priority_fixes) + + # Overall validation + validation = { + "status": _determine_status(overall_health), + "summary": summary, + "overall_health": overall_health, + "sections_completed": len([s for s in sections.values() if 'error' not in s and 'skipped' not in s]), + "total_sections": 5 + } + + # Print summary + _print_summary_report(overall_health, summary, priority_fixes, estimated_fix_time) + + return { + "overall_health": overall_health, + "sections": sections, + "summary": summary, + "priority_fixes": priority_fixes, + "estimated_fix_time": estimated_fix_time, + "validation": validation, + "detail_level": detail_level, + "analyzed_path": str(path) + } + + +def _calculate_overall_health(sections: Dict[str, Any]) -> int: + """Calculate overall health score (0-100).""" + + scores = [] + weights = { + 'issues': 0.25, + 'best_practices': 0.25, + 'performance': 0.20, + 'architecture': 0.15, + 'layout': 0.15 + } + + # Issues score (inverse of health_score from diagnose_issue) + if 'issues' in sections and 'health_score' in sections['issues']: + scores.append((sections['issues']['health_score'], weights['issues'])) + + # Best practices score + if 'best_practices' in sections and 'overall_score' in sections['best_practices']: + scores.append((sections['best_practices']['overall_score'], weights['best_practices'])) + + # Performance score (derive from bottlenecks) + if 'performance' in sections and 'bottlenecks' in sections['performance']: + bottlenecks = sections['performance']['bottlenecks'] + critical = sum(1 for b in bottlenecks if b['severity'] == 'CRITICAL') + high = sum(1 for b in bottlenecks if b['severity'] == 'HIGH') + perf_score = max(0, 100 - (critical * 20) - (high * 10)) + scores.append((perf_score, weights['performance'])) + + # Architecture score (based on complexity vs pattern appropriateness) + if 'architecture' in sections and 'complexity_score' in sections['architecture']: + arch_data = sections['architecture'] + # Good if recommended == current, or if complexity is low + if arch_data.get('recommended_pattern') == arch_data.get('current_pattern'): + arch_score = 100 + elif arch_data.get('complexity_score', 0) < 40: + arch_score = 80 # Simple app, pattern less critical + else: + arch_score = 60 # Should refactor + scores.append((arch_score, weights['architecture'])) + + # Layout score (inverse of issues) + if 'layout' in sections and 'layout_issues' in sections['layout']: + layout_issues = sections['layout']['layout_issues'] + critical = sum(1 for i in layout_issues if i['severity'] == 'CRITICAL') + warning = sum(1 for i in layout_issues if i['severity'] == 'WARNING') + layout_score = max(0, 100 - (critical * 15) - (warning * 5)) + scores.append((layout_score, weights['layout'])) + + # Weighted average + if not scores: + return 50 # No data + + weighted_sum = sum(score * weight for score, weight in scores) + total_weight = sum(weight for _, weight in scores) + + return int(weighted_sum / total_weight) + + +def _extract_priority_fixes(sections: Dict[str, Any]) -> List[str]: + """Extract priority fixes across all sections.""" + + fixes = [] + + # Critical issues + if 'issues' in sections and 'issues' in sections['issues']: + critical = [i for i in sections['issues']['issues'] if i['severity'] == 'CRITICAL'] + for issue in critical: + fixes.append({ + "priority": "CRITICAL", + "source": "Issues", + "description": f"{issue['issue']} ({issue['location']})", + "fix": issue.get('fix', 'See issue details') + }) + + # Critical performance bottlenecks + if 'performance' in sections and 'bottlenecks' in sections['performance']: + critical = [b for b in sections['performance']['bottlenecks'] if b['severity'] == 'CRITICAL'] + for bottleneck in critical: + fixes.append({ + "priority": "CRITICAL", + "source": "Performance", + "description": f"{bottleneck['issue']} ({bottleneck['location']})", + "fix": bottleneck.get('fix', 'See bottleneck details') + }) + + # Critical layout issues + if 'layout' in sections and 'layout_issues' in sections['layout']: + critical = [i for i in sections['layout']['layout_issues'] if i['severity'] == 'CRITICAL'] + for issue in critical: + fixes.append({ + "priority": "CRITICAL", + "source": "Layout", + "description": f"{issue['issue']} ({issue['location']})", + "fix": issue.get('explanation', 'See layout details') + }) + + # Best practice failures + if 'best_practices' in sections and 'compliance' in sections['best_practices']: + compliance = sections['best_practices']['compliance'] + failures = [tip for tip, data in compliance.items() if data['status'] == 'fail'] + for tip in failures[:3]: # Top 3 + fixes.append({ + "priority": "WARNING", + "source": "Best Practices", + "description": f"Missing {tip.replace('_', ' ')}", + "fix": compliance[tip].get('recommendation', 'See best practices') + }) + + # Architecture recommendations (if significant refactoring needed) + if 'architecture' in sections and 'complexity_score' in sections['architecture']: + arch_data = sections['architecture'] + if arch_data.get('complexity_score', 0) > 70: + if arch_data.get('recommended_pattern') != arch_data.get('current_pattern'): + fixes.append({ + "priority": "INFO", + "source": "Architecture", + "description": f"Consider refactoring to {arch_data.get('recommended_pattern')}", + "fix": f"See architecture recommendations for {len(arch_data.get('refactoring_steps', []))} steps" + }) + + return fixes + + +def _estimate_fix_time(priority_fixes: List[Dict[str, str]]) -> str: + """Estimate time to address priority fixes.""" + + critical_count = sum(1 for f in priority_fixes if f['priority'] == 'CRITICAL') + warning_count = sum(1 for f in priority_fixes if f['priority'] == 'WARNING') + info_count = sum(1 for f in priority_fixes if f['priority'] == 'INFO') + + # Time estimates (in hours) + critical_time = critical_count * 0.5 # 30 min each + warning_time = warning_count * 0.25 # 15 min each + info_time = info_count * 1.0 # 1 hour each (refactoring) + + total_hours = critical_time + warning_time + info_time + + if total_hours == 0: + return "No fixes needed" + elif total_hours < 1: + return f"{int(total_hours * 60)} minutes" + elif total_hours < 2: + return f"1-2 hours" + elif total_hours < 4: + return f"2-4 hours" + elif total_hours < 8: + return f"4-8 hours" + else: + return f"{int(total_hours)} hours (1-2 days)" + + +def _generate_summary(health: int, sections: Dict[str, Any], fixes: List[Dict[str, str]]) -> str: + """Generate executive summary.""" + + if health >= 90: + health_desc = "Excellent" + emoji = "✅" + elif health >= 75: + health_desc = "Good" + emoji = "✓" + elif health >= 60: + health_desc = "Fair" + emoji = "⚠️" + elif health >= 40: + health_desc = "Poor" + emoji = "❌" + else: + health_desc = "Critical" + emoji = "🚨" + + critical_count = sum(1 for f in fixes if f['priority'] == 'CRITICAL') + + if health >= 80: + summary = f"{emoji} {health_desc} health ({health}/100). Application follows most best practices." + elif health >= 60: + summary = f"{emoji} {health_desc} health ({health}/100). Some improvements recommended." + elif health >= 40: + summary = f"{emoji} {health_desc} health ({health}/100). Several issues need attention." + else: + summary = f"{emoji} {health_desc} health ({health}/100). Multiple critical issues require immediate fixes." + + if critical_count > 0: + summary += f" {critical_count} critical issue(s) found." + + return summary + + +def _determine_status(health: int) -> str: + """Determine overall status from health score.""" + if health >= 80: + return "pass" + elif health >= 60: + return "warning" + else: + return "critical" + + +def _print_summary_report(health: int, summary: str, fixes: List[Dict[str, str]], fix_time: str): + """Print formatted summary report.""" + + print(f"{'='*70}") + print(f"ANALYSIS COMPLETE") + print(f"{'='*70}\n") + + print(f"Overall Health: {health}/100") + print(f"Summary: {summary}\n") + + if fixes: + print(f"Priority Fixes ({len(fixes)}):") + print(f"{'-'*70}") + + # Group by priority + critical = [f for f in fixes if f['priority'] == 'CRITICAL'] + warnings = [f for f in fixes if f['priority'] == 'WARNING'] + info = [f for f in fixes if f['priority'] == 'INFO'] + + if critical: + print(f"\n🔴 CRITICAL ({len(critical)}):") + for i, fix in enumerate(critical, 1): + print(f" {i}. [{fix['source']}] {fix['description']}") + + if warnings: + print(f"\n⚠️ WARNINGS ({len(warnings)}):") + for i, fix in enumerate(warnings, 1): + print(f" {i}. [{fix['source']}] {fix['description']}") + + if info: + print(f"\n💡 INFO ({len(info)}):") + for i, fix in enumerate(info, 1): + print(f" {i}. [{fix['source']}] {fix['description']}") + + else: + print("✅ No priority fixes needed!") + + print(f"\n{'-'*70}") + print(f"Estimated Fix Time: {fix_time}") + print(f"{'='*70}\n") + + +def validate_comprehensive_analysis(result: Dict[str, Any]) -> Dict[str, Any]: + """Validate comprehensive analysis result.""" + if 'error' in result: + return {"status": "error", "summary": result['error']} + + validation = result.get('validation', {}) + status = validation.get('status', 'unknown') + summary = validation.get('summary', 'Analysis complete') + + checks = [ + (result.get('overall_health') is not None, "Health score calculated"), + (result.get('sections') is not None, "Sections analyzed"), + (result.get('priority_fixes') is not None, "Priority fixes extracted"), + (result.get('summary') is not None, "Summary generated"), + ] + + all_pass = all(check[0] for check in checks) + + return { + "status": status, + "summary": summary, + "checks": {check[1]: check[0] for check in checks}, + "valid": all_pass + } + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: comprehensive_bubbletea_analysis.py [detail_level]") + print(" detail_level: quick, standard (default), or deep") + sys.exit(1) + + code_path = sys.argv[1] + detail_level = sys.argv[2] if len(sys.argv) > 2 else "standard" + + if detail_level not in ["quick", "standard", "deep"]: + print(f"Invalid detail_level: {detail_level}") + print("Must be: quick, standard, or deep") + sys.exit(1) + + result = comprehensive_bubbletea_analysis(code_path, detail_level) + + # Save to file + output_file = Path(code_path).parent / "bubbletea_analysis_report.json" + with open(output_file, 'w') as f: + json.dump(result, f, indent=2) + + print(f"Full report saved to: {output_file}\n") diff --git a/.claude/skills/bubbletea-maintenance/scripts/debug_performance.py b/.claude/skills/bubbletea-maintenance/scripts/debug_performance.py new file mode 100644 index 00000000..6e477ef7 --- /dev/null +++ b/.claude/skills/bubbletea-maintenance/scripts/debug_performance.py @@ -0,0 +1,731 @@ +#!/usr/bin/env python3 +""" +Debug performance issues in Bubble Tea applications. +Identifies bottlenecks in Update(), View(), and concurrent operations. +""" + +import os +import re +import json +from pathlib import Path +from typing import Dict, List, Any, Tuple, Optional + + +def debug_performance(code_path: str, profile_data: str = "") -> Dict[str, Any]: + """ + Identify performance bottlenecks in Bubble Tea application. + + Args: + code_path: Path to Go file or directory + profile_data: Optional profiling data (pprof output, benchmark results) + + Returns: + Dictionary containing: + - bottlenecks: List of performance issues with locations and fixes + - metrics: Performance metrics (if available) + - recommendations: Prioritized optimization suggestions + - validation: Validation report + """ + path = Path(code_path) + + if not path.exists(): + return { + "error": f"Path not found: {code_path}", + "validation": {"status": "error", "summary": "Invalid path"} + } + + # Collect all .go files + go_files = [] + if path.is_file(): + if path.suffix == '.go': + go_files = [path] + else: + go_files = list(path.glob('**/*.go')) + + if not go_files: + return { + "error": "No .go files found", + "validation": {"status": "error", "summary": "No Go files"} + } + + # Analyze performance for each file + all_bottlenecks = [] + for go_file in go_files: + bottlenecks = _analyze_performance(go_file) + all_bottlenecks.extend(bottlenecks) + + # Sort by severity + severity_order = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3} + all_bottlenecks.sort(key=lambda x: severity_order.get(x['severity'], 999)) + + # Generate recommendations + recommendations = _generate_performance_recommendations(all_bottlenecks) + + # Estimate metrics + metrics = _estimate_metrics(all_bottlenecks, go_files) + + # Summary + critical_count = sum(1 for b in all_bottlenecks if b['severity'] == 'CRITICAL') + high_count = sum(1 for b in all_bottlenecks if b['severity'] == 'HIGH') + + if critical_count > 0: + summary = f"⚠️ Found {critical_count} critical performance issue(s)" + elif high_count > 0: + summary = f"⚠️ Found {high_count} high-priority performance issue(s)" + elif all_bottlenecks: + summary = f"Found {len(all_bottlenecks)} potential optimization(s)" + else: + summary = "✅ No major performance issues detected" + + # Validation + validation = { + "status": "critical" if critical_count > 0 else "warning" if high_count > 0 else "pass", + "summary": summary, + "checks": { + "fast_update": critical_count == 0, + "fast_view": high_count == 0, + "no_memory_leaks": not any(b['category'] == 'memory' for b in all_bottlenecks), + "efficient_rendering": not any(b['category'] == 'rendering' for b in all_bottlenecks) + } + } + + return { + "bottlenecks": all_bottlenecks, + "metrics": metrics, + "recommendations": recommendations, + "summary": summary, + "profile_data": profile_data if profile_data else None, + "validation": validation + } + + +def _analyze_performance(file_path: Path) -> List[Dict[str, Any]]: + """Analyze a single Go file for performance issues.""" + bottlenecks = [] + + try: + content = file_path.read_text() + except Exception as e: + return [] + + lines = content.split('\n') + rel_path = file_path.name + + # Performance checks + bottlenecks.extend(_check_update_performance(content, lines, rel_path)) + bottlenecks.extend(_check_view_performance(content, lines, rel_path)) + bottlenecks.extend(_check_string_operations(content, lines, rel_path)) + bottlenecks.extend(_check_regex_performance(content, lines, rel_path)) + bottlenecks.extend(_check_loop_efficiency(content, lines, rel_path)) + bottlenecks.extend(_check_allocation_patterns(content, lines, rel_path)) + bottlenecks.extend(_check_concurrent_operations(content, lines, rel_path)) + bottlenecks.extend(_check_io_operations(content, lines, rel_path)) + + return bottlenecks + + +def _check_update_performance(content: str, lines: List[str], file_path: str) -> List[Dict[str, Any]]: + """Check Update() function for performance issues.""" + bottlenecks = [] + + # Find Update() function + update_start = -1 + update_end = -1 + brace_count = 0 + + for i, line in enumerate(lines): + if re.search(r'func\s+\([^)]+\)\s+Update\s*\(', line): + update_start = i + brace_count = line.count('{') - line.count('}') + elif update_start >= 0: + brace_count += line.count('{') - line.count('}') + if brace_count == 0: + update_end = i + break + + if update_start < 0: + return bottlenecks + + update_lines = lines[update_start:update_end+1] if update_end > 0 else lines[update_start:] + update_code = '\n'.join(update_lines) + + # Check 1: Blocking I/O in Update() + blocking_patterns = [ + (r'\bhttp\.(Get|Post|Do)\s*\(', "HTTP request", "CRITICAL"), + (r'\btime\.Sleep\s*\(', "Sleep call", "CRITICAL"), + (r'\bos\.(Open|Read|Write)', "File I/O", "CRITICAL"), + (r'\bio\.ReadAll\s*\(', "ReadAll", "CRITICAL"), + (r'\bexec\.Command\([^)]+\)\.Run\(\)', "Command execution", "CRITICAL"), + (r'\bdb\.(Query|Exec)', "Database operation", "CRITICAL"), + ] + + for pattern, operation, severity in blocking_patterns: + matches = re.finditer(pattern, update_code) + for match in matches: + # Find line number within Update() + line_offset = update_code[:match.start()].count('\n') + actual_line = update_start + line_offset + + bottlenecks.append({ + "severity": severity, + "category": "performance", + "issue": f"Blocking {operation} in Update()", + "location": f"{file_path}:{actual_line+1}", + "time_impact": "Blocks event loop (16ms+ delay)", + "explanation": f"{operation} blocks the event loop, freezing the UI", + "fix": f"Move to tea.Cmd goroutine:\n\n" + + f"func fetch{operation.replace(' ', '')}() tea.Msg {{\n" + + f" // Runs in background, doesn't block\n" + + f" result, err := /* your {operation.lower()} */\n" + + f" return resultMsg{{data: result, err: err}}\n" + + f"}}\n\n" + + f"// In Update():\n" + + f"case tea.KeyMsg:\n" + + f" if key.String() == \"r\" {{\n" + + f" return m, fetch{operation.replace(' ', '')} // Non-blocking\n" + + f" }}", + "code_example": f"return m, fetch{operation.replace(' ', '')}" + }) + + # Check 2: Heavy computation in Update() + computation_patterns = [ + (r'for\s+.*range\s+\w+\s*\{[^}]{100,}\}', "Large loop", "HIGH"), + (r'json\.(Marshal|Unmarshal)', "JSON processing", "MEDIUM"), + (r'regexp\.MustCompile\s*\(', "Regex compilation", "HIGH"), + ] + + for pattern, operation, severity in computation_patterns: + matches = re.finditer(pattern, update_code, re.DOTALL) + for match in matches: + line_offset = update_code[:match.start()].count('\n') + actual_line = update_start + line_offset + + bottlenecks.append({ + "severity": severity, + "category": "performance", + "issue": f"Heavy {operation} in Update()", + "location": f"{file_path}:{actual_line+1}", + "time_impact": "May exceed 16ms budget", + "explanation": f"{operation} can be expensive, consider optimizing", + "fix": "Optimize:\n" + + "- Cache compiled regexes (compile once, reuse)\n" + + "- Move heavy processing to tea.Cmd\n" + + "- Use incremental updates instead of full recalculation", + "code_example": "var cachedRegex = regexp.MustCompile(`pattern`) // Outside Update()" + }) + + return bottlenecks + + +def _check_view_performance(content: str, lines: List[str], file_path: str) -> List[Dict[str, Any]]: + """Check View() function for performance issues.""" + bottlenecks = [] + + # Find View() function + view_start = -1 + view_end = -1 + brace_count = 0 + + for i, line in enumerate(lines): + if re.search(r'func\s+\([^)]+\)\s+View\s*\(', line): + view_start = i + brace_count = line.count('{') - line.count('}') + elif view_start >= 0: + brace_count += line.count('{') - line.count('}') + if brace_count == 0: + view_end = i + break + + if view_start < 0: + return bottlenecks + + view_lines = lines[view_start:view_end+1] if view_end > 0 else lines[view_start:] + view_code = '\n'.join(view_lines) + + # Check 1: String concatenation with + + string_concat_pattern = r'(\w+\s*\+\s*"[^"]*"\s*\+\s*\w+|\w+\s*\+=\s*"[^"]*")' + if re.search(string_concat_pattern, view_code): + matches = list(re.finditer(string_concat_pattern, view_code)) + if len(matches) > 5: # Multiple concatenations + bottlenecks.append({ + "severity": "HIGH", + "category": "rendering", + "issue": f"String concatenation with + operator ({len(matches)} occurrences)", + "location": f"{file_path}:{view_start+1} (View function)", + "time_impact": "Allocates many temporary strings", + "explanation": "Using + for strings creates many allocations. Use strings.Builder.", + "fix": "Replace with strings.Builder:\n\n" + + "import \"strings\"\n\n" + + "func (m model) View() string {\n" + + " var b strings.Builder\n" + + " b.WriteString(\"header\")\n" + + " b.WriteString(m.content)\n" + + " b.WriteString(\"footer\")\n" + + " return b.String()\n" + + "}", + "code_example": "var b strings.Builder; b.WriteString(...)" + }) + + # Check 2: Recompiling lipgloss styles + style_in_view = re.findall(r'lipgloss\.NewStyle\(\)', view_code) + if len(style_in_view) > 3: + bottlenecks.append({ + "severity": "MEDIUM", + "category": "rendering", + "issue": f"Creating lipgloss styles in View() ({len(style_in_view)} times)", + "location": f"{file_path}:{view_start+1} (View function)", + "time_impact": "Recreates styles on every render", + "explanation": "Style creation is relatively expensive. Cache styles in model.", + "fix": "Cache styles in model:\n\n" + + "type model struct {\n" + + " // ... other fields\n" + + " headerStyle lipgloss.Style\n" + + " contentStyle lipgloss.Style\n" + + "}\n\n" + + "func initialModel() model {\n" + + " return model{\n" + + " headerStyle: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(\"#FF00FF\")),\n" + + " contentStyle: lipgloss.NewStyle().Padding(1),\n" + + " }\n" + + "}\n\n" + + "func (m model) View() string {\n" + + " return m.headerStyle.Render(\"Header\") + m.contentStyle.Render(m.content)\n" + + "}", + "code_example": "m.headerStyle.Render(...) // Use cached style" + }) + + # Check 3: Reading files in View() + if re.search(r'\b(os\.ReadFile|ioutil\.ReadFile|os\.Open)', view_code): + bottlenecks.append({ + "severity": "CRITICAL", + "category": "rendering", + "issue": "File I/O in View() function", + "location": f"{file_path}:{view_start+1} (View function)", + "time_impact": "Massive delay (1-100ms per render)", + "explanation": "View() is called frequently. File I/O blocks rendering.", + "fix": "Load file in Update(), cache in model:\n\n" + + "type model struct {\n" + + " fileContent string\n" + + "}\n\n" + + "func loadFile() tea.Msg {\n" + + " content, err := os.ReadFile(\"file.txt\")\n" + + " return fileLoadedMsg{content: string(content), err: err}\n" + + "}\n\n" + + "// In Update():\n" + + "case fileLoadedMsg:\n" + + " m.fileContent = msg.content\n\n" + + "// In View():\n" + + "return m.fileContent // Just return cached data", + "code_example": "return m.cachedContent // No I/O in View()" + }) + + # Check 4: Expensive lipgloss operations + join_vertical_count = len(re.findall(r'lipgloss\.JoinVertical', view_code)) + if join_vertical_count > 10: + bottlenecks.append({ + "severity": "LOW", + "category": "rendering", + "issue": f"Many lipgloss.JoinVertical calls ({join_vertical_count})", + "location": f"{file_path}:{view_start+1} (View function)", + "time_impact": "Accumulates string operations", + "explanation": "Many join operations can add up. Consider batching.", + "fix": "Batch related joins:\n\n" + + "// Instead of many small joins:\n" + + "// line1 := lipgloss.JoinHorizontal(...)\n" + + "// line2 := lipgloss.JoinHorizontal(...)\n" + + "// ...\n\n" + + "// Build all lines first, join once:\n" + + "lines := []string{\n" + + " lipgloss.JoinHorizontal(...),\n" + + " lipgloss.JoinHorizontal(...),\n" + + " lipgloss.JoinHorizontal(...),\n" + + "}\n" + + "return lipgloss.JoinVertical(lipgloss.Left, lines...)", + "code_example": "lipgloss.JoinVertical(lipgloss.Left, lines...)" + }) + + return bottlenecks + + +def _check_string_operations(content: str, lines: List[str], file_path: str) -> List[Dict[str, Any]]: + """Check for inefficient string operations.""" + bottlenecks = [] + + # Check for fmt.Sprintf in loops + for i, line in enumerate(lines): + if 'for' in line: + # Check next 20 lines for fmt.Sprintf + for j in range(i, min(i+20, len(lines))): + if 'fmt.Sprintf' in lines[j] and 'result' in lines[j]: + bottlenecks.append({ + "severity": "MEDIUM", + "category": "performance", + "issue": "fmt.Sprintf in loop", + "location": f"{file_path}:{j+1}", + "time_impact": "Allocations on every iteration", + "explanation": "fmt.Sprintf allocates. Use strings.Builder or fmt.Fprintf.", + "fix": "Use strings.Builder:\n\n" + + "var b strings.Builder\n" + + "for _, item := range items {\n" + + " fmt.Fprintf(&b, \"Item: %s\\n\", item)\n" + + "}\n" + + "result := b.String()", + "code_example": "fmt.Fprintf(&builder, ...)" + }) + break + + return bottlenecks + + +def _check_regex_performance(content: str, lines: List[str], file_path: str) -> List[Dict[str, Any]]: + """Check for regex performance issues.""" + bottlenecks = [] + + # Check for regexp.MustCompile in functions (not at package level) + in_function = False + for i, line in enumerate(lines): + if re.match(r'^\s*func\s+', line): + in_function = True + elif in_function and re.match(r'^\s*$', line): + in_function = False + + if in_function and 'regexp.MustCompile' in line: + bottlenecks.append({ + "severity": "HIGH", + "category": "performance", + "issue": "Compiling regex in function", + "location": f"{file_path}:{i+1}", + "time_impact": "Compiles on every call (1-10ms)", + "explanation": "Regex compilation is expensive. Compile once at package level.", + "fix": "Move to package level:\n\n" + + "// At package level (outside functions)\n" + + "var (\n" + + " emailRegex = regexp.MustCompile(`^[a-z]+@[a-z]+\\.[a-z]+$`)\n" + + " phoneRegex = regexp.MustCompile(`^\\d{3}-\\d{3}-\\d{4}$`)\n" + + ")\n\n" + + "// In function\n" + + "func validate(email string) bool {\n" + + " return emailRegex.MatchString(email) // Reuse compiled regex\n" + + "}", + "code_example": "var emailRegex = regexp.MustCompile(...) // Package level" + }) + + return bottlenecks + + +def _check_loop_efficiency(content: str, lines: List[str], file_path: str) -> List[Dict[str, Any]]: + """Check for inefficient loops.""" + bottlenecks = [] + + # Check for nested loops over large data + for i, line in enumerate(lines): + if re.search(r'for\s+.*range', line): + # Look for nested loop within 30 lines + for j in range(i+1, min(i+30, len(lines))): + if re.search(r'for\s+.*range', lines[j]): + # Check indentation (nested) + if len(lines[j]) - len(lines[j].lstrip()) > len(line) - len(line.lstrip()): + bottlenecks.append({ + "severity": "MEDIUM", + "category": "performance", + "issue": "Nested loops detected", + "location": f"{file_path}:{i+1}", + "time_impact": "O(n²) complexity", + "explanation": "Nested loops can be slow. Consider optimization.", + "fix": "Optimization strategies:\n" + + "1. Use map/set for O(1) lookups instead of nested loop\n" + + "2. Break early when possible\n" + + "3. Process data once, cache results\n" + + "4. Use channels/goroutines for parallel processing\n\n" + + "Example with map:\n" + + "// Instead of:\n" + + "for _, a := range listA {\n" + + " for _, b := range listB {\n" + + " if a.id == b.id { found = true }\n" + + " }\n" + + "}\n\n" + + "// Use map:\n" + + "mapB := make(map[string]bool)\n" + + "for _, b := range listB {\n" + + " mapB[b.id] = true\n" + + "}\n" + + "for _, a := range listA {\n" + + " if mapB[a.id] { found = true }\n" + + "}", + "code_example": "Use map for O(1) lookup" + }) + break + + return bottlenecks + + +def _check_allocation_patterns(content: str, lines: List[str], file_path: str) -> List[Dict[str, Any]]: + """Check for excessive allocations.""" + bottlenecks = [] + + # Check for slice append in loops without pre-allocation + for i, line in enumerate(lines): + if re.search(r'for\s+.*range', line): + # Check next 20 lines for append without make + has_append = False + for j in range(i, min(i+20, len(lines))): + if 'append(' in lines[j]: + has_append = True + break + + # Check if slice was pre-allocated + has_make = False + for j in range(max(0, i-10), i): + if 'make(' in lines[j] and 'len(' in lines[j]: + has_make = True + break + + if has_append and not has_make: + bottlenecks.append({ + "severity": "LOW", + "category": "memory", + "issue": "Slice append in loop without pre-allocation", + "location": f"{file_path}:{i+1}", + "time_impact": "Multiple reallocations", + "explanation": "Appending without pre-allocation causes slice to grow, reallocate.", + "fix": "Pre-allocate slice:\n\n" + + "// Instead of:\n" + + "var results []string\n" + + "for _, item := range items {\n" + + " results = append(results, process(item))\n" + + "}\n\n" + + "// Pre-allocate:\n" + + "results := make([]string, 0, len(items)) // Pre-allocate capacity\n" + + "for _, item := range items {\n" + + " results = append(results, process(item)) // No reallocation\n" + + "}", + "code_example": "results := make([]string, 0, len(items))" + }) + + return bottlenecks + + +def _check_concurrent_operations(content: str, lines: List[str], file_path: str) -> List[Dict[str, Any]]: + """Check for concurrency issues.""" + bottlenecks = [] + + # Check for goroutine leaks + has_goroutines = bool(re.search(r'\bgo\s+func', content)) + has_context = bool(re.search(r'context\.', content)) + has_waitgroup = bool(re.search(r'sync\.WaitGroup', content)) + + if has_goroutines and not (has_context or has_waitgroup): + bottlenecks.append({ + "severity": "HIGH", + "category": "memory", + "issue": "Goroutines without lifecycle management", + "location": file_path, + "time_impact": "Goroutine leaks consume memory", + "explanation": "Goroutines need proper cleanup to prevent leaks.", + "fix": "Use context for cancellation:\n\n" + + "type model struct {\n" + + " ctx context.Context\n" + + " cancel context.CancelFunc\n" + + "}\n\n" + + "func initialModel() model {\n" + + " ctx, cancel := context.WithCancel(context.Background())\n" + + " return model{ctx: ctx, cancel: cancel}\n" + + "}\n\n" + + "func worker(ctx context.Context) tea.Msg {\n" + + " for {\n" + + " select {\n" + + " case <-ctx.Done():\n" + + " return nil // Stop goroutine\n" + + " case <-time.After(time.Second):\n" + + " // Do work\n" + + " }\n" + + " }\n" + + "}\n\n" + + "// In Update() on quit:\n" + + "m.cancel() // Stops all goroutines", + "code_example": "ctx, cancel := context.WithCancel(context.Background())" + }) + + return bottlenecks + + +def _check_io_operations(content: str, lines: List[str], file_path: str) -> List[Dict[str, Any]]: + """Check for I/O operations that should be async.""" + bottlenecks = [] + + # Check for synchronous file reads + file_ops = [ + (r'os\.ReadFile', "os.ReadFile"), + (r'ioutil\.ReadFile', "ioutil.ReadFile"), + (r'os\.Open', "os.Open"), + (r'io\.ReadAll', "io.ReadAll"), + ] + + for pattern, op_name in file_ops: + matches = list(re.finditer(pattern, content)) + if matches: + # Check if in tea.Cmd (good) or in Update/View (bad) + for match in matches: + # Find which function this is in + line_num = content[:match.start()].count('\n') + context_lines = content.split('\n')[max(0, line_num-10):line_num+1] + context_text = '\n'.join(context_lines) + + in_cmd = bool(re.search(r'func\s+\w+\(\s*\)\s+tea\.Msg', context_text)) + in_update = bool(re.search(r'func\s+\([^)]+\)\s+Update', context_text)) + in_view = bool(re.search(r'func\s+\([^)]+\)\s+View', context_text)) + + if (in_update or in_view) and not in_cmd: + severity = "CRITICAL" if in_view else "HIGH" + func_name = "View()" if in_view else "Update()" + + bottlenecks.append({ + "severity": severity, + "category": "io", + "issue": f"Synchronous {op_name} in {func_name}", + "location": f"{file_path}:{line_num+1}", + "time_impact": "1-100ms per call", + "explanation": f"{op_name} blocks the event loop", + "fix": f"Move to tea.Cmd:\n\n" + + f"func loadFileCmd() tea.Msg {{\n" + + f" data, err := {op_name}(\"file.txt\")\n" + + f" return fileLoadedMsg{{data: data, err: err}}\n" + + f"}}\n\n" + + f"// In Update():\n" + + f"case tea.KeyMsg:\n" + + f" if key.String() == \"o\" {{\n" + + f" return m, loadFileCmd // Non-blocking\n" + + f" }}", + "code_example": "return m, loadFileCmd // Async I/O" + }) + + return bottlenecks + + +def _generate_performance_recommendations(bottlenecks: List[Dict[str, Any]]) -> List[str]: + """Generate prioritized performance recommendations.""" + recommendations = [] + + # Group by category + categories = {} + for b in bottlenecks: + cat = b['category'] + if cat not in categories: + categories[cat] = [] + categories[cat].append(b) + + # Priority recommendations + if 'performance' in categories: + critical = [b for b in categories['performance'] if b['severity'] == 'CRITICAL'] + if critical: + recommendations.append( + f"🔴 CRITICAL: Move {len(critical)} blocking operation(s) to tea.Cmd goroutines" + ) + + if 'rendering' in categories: + recommendations.append( + f"⚡ Optimize View() rendering: Found {len(categories['rendering'])} issue(s)" + ) + + if 'memory' in categories: + recommendations.append( + f"💾 Fix memory issues: Found {len(categories['memory'])} potential leak(s)" + ) + + if 'io' in categories: + recommendations.append( + f"💿 Make I/O async: Found {len(categories['io'])} synchronous I/O call(s)" + ) + + # General recommendations + recommendations.extend([ + "Profile with pprof to get precise measurements", + "Use benchmarks to validate optimizations", + "Monitor with runtime.ReadMemStats() for memory usage", + "Test with large datasets to reveal performance issues" + ]) + + return recommendations + + +def _estimate_metrics(bottlenecks: List[Dict[str, Any]], files: List[Path]) -> Dict[str, Any]: + """Estimate performance metrics based on analysis.""" + + # Estimate Update() time + critical_in_update = sum(1 for b in bottlenecks + if 'Update()' in b.get('issue', '') and b['severity'] == 'CRITICAL') + high_in_update = sum(1 for b in bottlenecks + if 'Update()' in b.get('issue', '') and b['severity'] == 'HIGH') + + estimated_update_time = "2-5ms (good)" + if critical_in_update > 0: + estimated_update_time = "50-200ms (critical - UI freezing)" + elif high_in_update > 0: + estimated_update_time = "20-50ms (slow - noticeable lag)" + + # Estimate View() time + critical_in_view = sum(1 for b in bottlenecks + if 'View()' in b.get('issue', '') and b['severity'] == 'CRITICAL') + high_in_view = sum(1 for b in bottlenecks + if 'View()' in b.get('issue', '') and b['severity'] == 'HIGH') + + estimated_view_time = "1-3ms (good)" + if critical_in_view > 0: + estimated_view_time = "100-500ms (critical - very slow)" + elif high_in_view > 0: + estimated_view_time = "10-30ms (slow)" + + # Memory estimate + goroutine_leaks = sum(1 for b in bottlenecks if 'leak' in b.get('issue', '').lower()) + memory_status = "stable" + if goroutine_leaks > 0: + memory_status = "growing (leaks detected)" + + return { + "estimated_update_time": estimated_update_time, + "estimated_view_time": estimated_view_time, + "memory_status": memory_status, + "total_bottlenecks": len(bottlenecks), + "critical_issues": sum(1 for b in bottlenecks if b['severity'] == 'CRITICAL'), + "files_analyzed": len(files), + "note": "Run actual profiling (pprof, benchmarks) for precise measurements" + } + + +def validate_performance_debug(result: Dict[str, Any]) -> Dict[str, Any]: + """Validate performance debug result.""" + if 'error' in result: + return {"status": "error", "summary": result['error']} + + validation = result.get('validation', {}) + status = validation.get('status', 'unknown') + summary = validation.get('summary', 'Performance analysis complete') + + checks = [ + (result.get('bottlenecks') is not None, "Has bottlenecks list"), + (result.get('metrics') is not None, "Has metrics"), + (result.get('recommendations') is not None, "Has recommendations"), + ] + + all_pass = all(check[0] for check in checks) + + return { + "status": status, + "summary": summary, + "checks": {check[1]: check[0] for check in checks}, + "valid": all_pass + } + + +if __name__ == "__main__": + import sys + + if len(sys.argv) < 2: + print("Usage: debug_performance.py [profile_data]") + sys.exit(1) + + code_path = sys.argv[1] + profile_data = sys.argv[2] if len(sys.argv) > 2 else "" + + result = debug_performance(code_path, profile_data) + print(json.dumps(result, indent=2)) diff --git a/.claude/skills/bubbletea-maintenance/scripts/diagnose_issue.py b/.claude/skills/bubbletea-maintenance/scripts/diagnose_issue.py new file mode 100644 index 00000000..5f7bb723 --- /dev/null +++ b/.claude/skills/bubbletea-maintenance/scripts/diagnose_issue.py @@ -0,0 +1,441 @@ +#!/usr/bin/env python3 +""" +Diagnose issues in existing Bubble Tea applications. +Identifies common problems: slow event loop, layout issues, memory leaks, etc. +""" + +import os +import re +import json +from pathlib import Path +from typing import Dict, List, Any + + +def diagnose_issue(code_path: str, description: str = "") -> Dict[str, Any]: + """ + Analyze Bubble Tea code to identify common issues. + + Args: + code_path: Path to Go file or directory containing Bubble Tea code + description: Optional user description of the problem + + Returns: + Dictionary containing: + - issues: List of identified issues with severity, location, fix + - summary: High-level summary + - health_score: 0-100 score (higher is better) + - validation: Validation report + """ + path = Path(code_path) + + if not path.exists(): + return { + "error": f"Path not found: {code_path}", + "validation": {"status": "error", "summary": "Invalid path"} + } + + # Collect all .go files + go_files = [] + if path.is_file(): + if path.suffix == '.go': + go_files = [path] + else: + go_files = list(path.glob('**/*.go')) + + if not go_files: + return { + "error": "No .go files found", + "validation": {"status": "error", "summary": "No Go files"} + } + + # Analyze all files + all_issues = [] + for go_file in go_files: + issues = _analyze_go_file(go_file) + all_issues.extend(issues) + + # Calculate health score + critical_count = sum(1 for i in all_issues if i['severity'] == 'CRITICAL') + warning_count = sum(1 for i in all_issues if i['severity'] == 'WARNING') + info_count = sum(1 for i in all_issues if i['severity'] == 'INFO') + + health_score = max(0, 100 - (critical_count * 20) - (warning_count * 5) - (info_count * 1)) + + # Generate summary + if critical_count == 0 and warning_count == 0: + summary = "✅ No critical issues found. Application appears healthy." + elif critical_count > 0: + summary = f"❌ Found {critical_count} critical issue(s) requiring immediate attention" + else: + summary = f"⚠️ Found {warning_count} warning(s) that should be addressed" + + # Validation + validation = { + "status": "critical" if critical_count > 0 else "warning" if warning_count > 0 else "pass", + "summary": summary, + "checks": { + "has_blocking_operations": critical_count > 0, + "has_layout_issues": any(i['category'] == 'layout' for i in all_issues), + "has_performance_issues": any(i['category'] == 'performance' for i in all_issues), + "has_architecture_issues": any(i['category'] == 'architecture' for i in all_issues) + } + } + + return { + "issues": all_issues, + "summary": summary, + "health_score": health_score, + "statistics": { + "total_issues": len(all_issues), + "critical": critical_count, + "warnings": warning_count, + "info": info_count, + "files_analyzed": len(go_files) + }, + "validation": validation, + "user_description": description + } + + +def _analyze_go_file(file_path: Path) -> List[Dict[str, Any]]: + """Analyze a single Go file for issues.""" + issues = [] + + try: + content = file_path.read_text() + except Exception as e: + return [{ + "severity": "WARNING", + "category": "system", + "issue": f"Could not read file: {e}", + "location": str(file_path), + "explanation": "File access error", + "fix": "Check file permissions" + }] + + lines = content.split('\n') + rel_path = file_path.name + + # Check 1: Blocking operations in Update() or View() + issues.extend(_check_blocking_operations(content, lines, rel_path)) + + # Check 2: Hardcoded dimensions + issues.extend(_check_hardcoded_dimensions(content, lines, rel_path)) + + # Check 3: Missing terminal recovery + issues.extend(_check_terminal_recovery(content, lines, rel_path)) + + # Check 4: Message ordering assumptions + issues.extend(_check_message_ordering(content, lines, rel_path)) + + # Check 5: Model complexity + issues.extend(_check_model_complexity(content, lines, rel_path)) + + # Check 6: Memory leaks (goroutine leaks) + issues.extend(_check_goroutine_leaks(content, lines, rel_path)) + + # Check 7: Layout arithmetic issues + issues.extend(_check_layout_arithmetic(content, lines, rel_path)) + + return issues + + +def _check_blocking_operations(content: str, lines: List[str], file_path: str) -> List[Dict[str, Any]]: + """Check for blocking operations in Update() or View().""" + issues = [] + + # Find Update() and View() function boundaries + in_update = False + in_view = False + func_start_line = 0 + + blocking_patterns = [ + (r'\btime\.Sleep\s*\(', "time.Sleep"), + (r'\bhttp\.(Get|Post|Do)\s*\(', "HTTP request"), + (r'\bos\.Open\s*\(', "File I/O"), + (r'\bio\.ReadAll\s*\(', "Blocking read"), + (r'\bexec\.Command\([^)]+\)\.Run\(\)', "Command execution"), + (r'\bdb\.Query\s*\(', "Database query"), + ] + + for i, line in enumerate(lines): + # Track function boundaries + if re.search(r'func\s+\([^)]+\)\s+Update\s*\(', line): + in_update = True + func_start_line = i + elif re.search(r'func\s+\([^)]+\)\s+View\s*\(', line): + in_view = True + func_start_line = i + elif in_update or in_view: + if line.strip().startswith('func '): + in_update = False + in_view = False + + # Check for blocking operations + if in_update or in_view: + for pattern, operation in blocking_patterns: + if re.search(pattern, line): + func_type = "Update()" if in_update else "View()" + issues.append({ + "severity": "CRITICAL", + "category": "performance", + "issue": f"Blocking {operation} in {func_type}", + "location": f"{file_path}:{i+1}", + "code_snippet": line.strip(), + "explanation": f"{operation} blocks the event loop, causing UI to freeze", + "fix": f"Move {operation} to tea.Cmd goroutine:\n\n" + + f"func load{operation.replace(' ', '')}() tea.Msg {{\n" + + f" // Your {operation} here\n" + + f" return resultMsg{{}}\n" + + f"}}\n\n" + + f"// In Update():\n" + + f"return m, load{operation.replace(' ', '')}" + }) + + return issues + + +def _check_hardcoded_dimensions(content: str, lines: List[str], file_path: str) -> List[Dict[str, Any]]: + """Check for hardcoded terminal dimensions.""" + issues = [] + + # Look for hardcoded width/height values + patterns = [ + (r'\.Width\s*\(\s*(\d{2,})\s*\)', "width"), + (r'\.Height\s*\(\s*(\d{2,})\s*\)', "height"), + (r'MaxWidth\s*:\s*(\d{2,})', "MaxWidth"), + (r'MaxHeight\s*:\s*(\d{2,})', "MaxHeight"), + ] + + for i, line in enumerate(lines): + for pattern, dimension in patterns: + matches = re.finditer(pattern, line) + for match in matches: + value = match.group(1) + if int(value) >= 20: # Likely a terminal dimension, not small padding + issues.append({ + "severity": "WARNING", + "category": "layout", + "issue": f"Hardcoded {dimension} value: {value}", + "location": f"{file_path}:{i+1}", + "code_snippet": line.strip(), + "explanation": "Hardcoded dimensions don't adapt to terminal size", + "fix": f"Use dynamic terminal size from tea.WindowSizeMsg:\n\n" + + f"type model struct {{\n" + + f" termWidth int\n" + + f" termHeight int\n" + + f"}}\n\n" + + f"func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {{\n" + + f" switch msg := msg.(type) {{\n" + + f" case tea.WindowSizeMsg:\n" + + f" m.termWidth = msg.Width\n" + + f" m.termHeight = msg.Height\n" + + f" }}\n" + + f" return m, nil\n" + + f"}}" + }) + + return issues + + +def _check_terminal_recovery(content: str, lines: List[str], file_path: str) -> List[Dict[str, Any]]: + """Check for panic recovery and terminal cleanup.""" + issues = [] + + has_defer_recover = bool(re.search(r'defer\s+func\s*\(\s*\)\s*\{[^}]*recover\(\)', content, re.DOTALL)) + has_main = bool(re.search(r'func\s+main\s*\(\s*\)', content)) + + if has_main and not has_defer_recover: + issues.append({ + "severity": "WARNING", + "category": "reliability", + "issue": "Missing panic recovery in main()", + "location": file_path, + "explanation": "Panics can leave terminal in broken state (mouse mode enabled, cursor hidden)", + "fix": "Add defer recovery:\n\n" + + "func main() {\n" + + " defer func() {\n" + + " if r := recover(); r != nil {\n" + + " tea.DisableMouseAllMotion()\n" + + " tea.ShowCursor()\n" + + " fmt.Println(\"Panic:\", r)\n" + + " os.Exit(1)\n" + + " }\n" + + " }()\n\n" + + " // Your program logic\n" + + "}" + }) + + return issues + + +def _check_message_ordering(content: str, lines: List[str], file_path: str) -> List[Dict[str, Any]]: + """Check for assumptions about message ordering from concurrent commands.""" + issues = [] + + # Look for concurrent command patterns without order handling + has_batch = bool(re.search(r'tea\.Batch\s*\(', content)) + has_state_machine = bool(re.search(r'type\s+\w+State\s+(int|string)', content)) + + if has_batch and not has_state_machine: + issues.append({ + "severity": "INFO", + "category": "architecture", + "issue": "Using tea.Batch without explicit state tracking", + "location": file_path, + "explanation": "Messages from tea.Batch arrive in unpredictable order", + "fix": "Use state machine to track operations:\n\n" + + "type model struct {\n" + + " operations map[string]bool // Track active operations\n" + + "}\n\n" + + "type opStartMsg struct { id string }\n" + + "type opDoneMsg struct { id string, result string }\n\n" + + "func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {\n" + + " switch msg := msg.(type) {\n" + + " case opStartMsg:\n" + + " m.operations[msg.id] = true\n" + + " case opDoneMsg:\n" + + " delete(m.operations, msg.id)\n" + + " }\n" + + " return m, nil\n" + + "}" + }) + + return issues + + +def _check_model_complexity(content: str, lines: List[str], file_path: str) -> List[Dict[str, Any]]: + """Check if model is too complex and should use model tree.""" + issues = [] + + # Count fields in model struct + model_match = re.search(r'type\s+(\w*[Mm]odel)\s+struct\s*\{([^}]+)\}', content, re.DOTALL) + if model_match: + model_body = model_match.group(2) + field_count = len([line for line in model_body.split('\n') if line.strip() and not line.strip().startswith('//')]) + + if field_count > 15: + issues.append({ + "severity": "INFO", + "category": "architecture", + "issue": f"Model has {field_count} fields (complex)", + "location": file_path, + "explanation": "Large models are hard to maintain. Consider model tree pattern.", + "fix": "Refactor to model tree:\n\n" + + "type appModel struct {\n" + + " activeView int\n" + + " listView listModel\n" + + " detailView detailModel\n" + + "}\n\n" + + "func (m appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {\n" + + " switch m.activeView {\n" + + " case 0:\n" + + " m.listView, cmd = m.listView.Update(msg)\n" + + " case 1:\n" + + " m.detailView, cmd = m.detailView.Update(msg)\n" + + " }\n" + + " return m, cmd\n" + + "}" + }) + + return issues + + +def _check_goroutine_leaks(content: str, lines: List[str], file_path: str) -> List[Dict[str, Any]]: + """Check for potential goroutine leaks.""" + issues = [] + + # Look for goroutines without cleanup + has_go_statements = bool(re.search(r'\bgo\s+', content)) + has_context_cancel = bool(re.search(r'ctx,\s*cancel\s*:=\s*context\.', content)) + + if has_go_statements and not has_context_cancel: + issues.append({ + "severity": "WARNING", + "category": "reliability", + "issue": "Goroutines without context cancellation", + "location": file_path, + "explanation": "Goroutines may leak if not properly cancelled", + "fix": "Use context for goroutine lifecycle:\n\n" + + "type model struct {\n" + + " ctx context.Context\n" + + " cancel context.CancelFunc\n" + + "}\n\n" + + "func initialModel() model {\n" + + " ctx, cancel := context.WithCancel(context.Background())\n" + + " return model{ctx: ctx, cancel: cancel}\n" + + "}\n\n" + + "// In Update() on quit:\n" + + "m.cancel() // Stops all goroutines" + }) + + return issues + + +def _check_layout_arithmetic(content: str, lines: List[str], file_path: str) -> List[Dict[str, Any]]: + """Check for layout arithmetic issues.""" + issues = [] + + # Look for manual height/width calculations instead of lipgloss helpers + uses_lipgloss = bool(re.search(r'"github\.com/charmbracelet/lipgloss"', content)) + has_manual_calc = bool(re.search(r'(height|width)\s*[-+]\s*\d+', content, re.IGNORECASE)) + has_lipgloss_helpers = bool(re.search(r'lipgloss\.(Height|Width|GetVertical|GetHorizontal)', content)) + + if uses_lipgloss and has_manual_calc and not has_lipgloss_helpers: + issues.append({ + "severity": "WARNING", + "category": "layout", + "issue": "Manual layout calculations without lipgloss helpers", + "location": file_path, + "explanation": "Manual calculations are error-prone. Use lipgloss.Height() and lipgloss.Width()", + "fix": "Use lipgloss helpers:\n\n" + + "// ❌ BAD:\n" + + "availableHeight := termHeight - 5 // Magic number!\n\n" + + "// ✅ GOOD:\n" + + "headerHeight := lipgloss.Height(header)\n" + + "footerHeight := lipgloss.Height(footer)\n" + + "availableHeight := termHeight - headerHeight - footerHeight" + }) + + return issues + + +# Validation function +def validate_diagnosis(result: Dict[str, Any]) -> Dict[str, Any]: + """Validate diagnosis result.""" + if 'error' in result: + return {"status": "error", "summary": result['error']} + + validation = result.get('validation', {}) + status = validation.get('status', 'unknown') + summary = validation.get('summary', 'Diagnosis complete') + + checks = [ + (result.get('issues') is not None, "Has issues list"), + (result.get('health_score') is not None, "Has health score"), + (result.get('summary') is not None, "Has summary"), + (len(result.get('issues', [])) >= 0, "Issues analyzed"), + ] + + all_pass = all(check[0] for check in checks) + + return { + "status": status, + "summary": summary, + "checks": {check[1]: check[0] for check in checks}, + "valid": all_pass + } + + +if __name__ == "__main__": + import sys + + if len(sys.argv) < 2: + print("Usage: diagnose_issue.py [description]") + sys.exit(1) + + code_path = sys.argv[1] + description = sys.argv[2] if len(sys.argv) > 2 else "" + + result = diagnose_issue(code_path, description) + print(json.dumps(result, indent=2)) diff --git a/.claude/skills/bubbletea-maintenance/scripts/fix_layout_issues.py b/.claude/skills/bubbletea-maintenance/scripts/fix_layout_issues.py new file mode 100644 index 00000000..c69a48b3 --- /dev/null +++ b/.claude/skills/bubbletea-maintenance/scripts/fix_layout_issues.py @@ -0,0 +1,578 @@ +#!/usr/bin/env python3 +""" +Fix Lipgloss layout issues in Bubble Tea applications. +Identifies hardcoded dimensions, incorrect calculations, overflow issues, etc. +""" + +import os +import re +import json +from pathlib import Path +from typing import Dict, List, Any, Tuple, Optional + + +def fix_layout_issues(code_path: str, description: str = "") -> Dict[str, Any]: + """ + Diagnose and fix common Lipgloss layout problems. + + Args: + code_path: Path to Go file or directory + description: Optional user description of layout issue + + Returns: + Dictionary containing: + - layout_issues: List of identified layout problems with fixes + - lipgloss_improvements: General recommendations + - code_fixes: Concrete code changes to apply + - validation: Validation report + """ + path = Path(code_path) + + if not path.exists(): + return { + "error": f"Path not found: {code_path}", + "validation": {"status": "error", "summary": "Invalid path"} + } + + # Collect all .go files + go_files = [] + if path.is_file(): + if path.suffix == '.go': + go_files = [path] + else: + go_files = list(path.glob('**/*.go')) + + if not go_files: + return { + "error": "No .go files found", + "validation": {"status": "error", "summary": "No Go files"} + } + + # Analyze all files for layout issues + all_layout_issues = [] + all_code_fixes = [] + + for go_file in go_files: + issues, fixes = _analyze_layout_issues(go_file) + all_layout_issues.extend(issues) + all_code_fixes.extend(fixes) + + # Generate improvement recommendations + lipgloss_improvements = _generate_improvements(all_layout_issues) + + # Summary + critical_count = sum(1 for i in all_layout_issues if i['severity'] == 'CRITICAL') + warning_count = sum(1 for i in all_layout_issues if i['severity'] == 'WARNING') + + if critical_count > 0: + summary = f"🚨 Found {critical_count} critical layout issue(s)" + elif warning_count > 0: + summary = f"⚠️ Found {warning_count} layout issue(s) to address" + elif all_layout_issues: + summary = f"Found {len(all_layout_issues)} minor layout improvement(s)" + else: + summary = "✅ No major layout issues detected" + + # Validation + validation = { + "status": "critical" if critical_count > 0 else "warning" if warning_count > 0 else "pass", + "summary": summary, + "checks": { + "no_hardcoded_dimensions": not any(i['type'] == 'hardcoded_dimensions' for i in all_layout_issues), + "proper_height_calc": not any(i['type'] == 'incorrect_height' for i in all_layout_issues), + "handles_padding": not any(i['type'] == 'missing_padding_calc' for i in all_layout_issues), + "handles_overflow": not any(i['type'] == 'overflow' for i in all_layout_issues) + } + } + + return { + "layout_issues": all_layout_issues, + "lipgloss_improvements": lipgloss_improvements, + "code_fixes": all_code_fixes, + "summary": summary, + "user_description": description, + "files_analyzed": len(go_files), + "validation": validation + } + + +def _analyze_layout_issues(file_path: Path) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + """Analyze a single Go file for layout issues.""" + layout_issues = [] + code_fixes = [] + + try: + content = file_path.read_text() + except Exception as e: + return layout_issues, code_fixes + + lines = content.split('\n') + rel_path = file_path.name + + # Check if file uses lipgloss + uses_lipgloss = bool(re.search(r'"github\.com/charmbracelet/lipgloss"', content)) + + if not uses_lipgloss: + return layout_issues, code_fixes + + # Issue checks + issues, fixes = _check_hardcoded_dimensions(content, lines, rel_path) + layout_issues.extend(issues) + code_fixes.extend(fixes) + + issues, fixes = _check_incorrect_height_calculations(content, lines, rel_path) + layout_issues.extend(issues) + code_fixes.extend(fixes) + + issues, fixes = _check_missing_padding_accounting(content, lines, rel_path) + layout_issues.extend(issues) + code_fixes.extend(fixes) + + issues, fixes = _check_overflow_issues(content, lines, rel_path) + layout_issues.extend(issues) + code_fixes.extend(fixes) + + issues, fixes = _check_terminal_resize_handling(content, lines, rel_path) + layout_issues.extend(issues) + code_fixes.extend(fixes) + + issues, fixes = _check_border_accounting(content, lines, rel_path) + layout_issues.extend(issues) + code_fixes.extend(fixes) + + return layout_issues, code_fixes + + +def _check_hardcoded_dimensions(content: str, lines: List[str], file_path: str) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + """Check for hardcoded width/height values.""" + issues = [] + fixes = [] + + # Pattern: .Width(80), .Height(24), etc. + dimension_pattern = r'\.(Width|Height|MaxWidth|MaxHeight)\s*\(\s*(\d{2,})\s*\)' + + for i, line in enumerate(lines): + matches = re.finditer(dimension_pattern, line) + for match in matches: + dimension_type = match.group(1) + value = int(match.group(2)) + + # Likely a terminal dimension if >= 20 + if value >= 20: + issues.append({ + "severity": "WARNING", + "type": "hardcoded_dimensions", + "issue": f"Hardcoded {dimension_type}: {value}", + "location": f"{file_path}:{i+1}", + "current_code": line.strip(), + "explanation": f"Hardcoded {dimension_type} of {value} won't adapt to different terminal sizes", + "impact": "Layout breaks on smaller/larger terminals" + }) + + # Generate fix + if dimension_type in ["Width", "MaxWidth"]: + fixed_code = re.sub( + rf'\.{dimension_type}\s*\(\s*{value}\s*\)', + f'.{dimension_type}(m.termWidth)', + line.strip() + ) + else: # Height, MaxHeight + fixed_code = re.sub( + rf'\.{dimension_type}\s*\(\s*{value}\s*\)', + f'.{dimension_type}(m.termHeight)', + line.strip() + ) + + fixes.append({ + "location": f"{file_path}:{i+1}", + "original": line.strip(), + "fixed": fixed_code, + "explanation": f"Use dynamic terminal size from model (m.termWidth/m.termHeight)", + "requires": [ + "Add termWidth and termHeight fields to model", + "Handle tea.WindowSizeMsg in Update()" + ], + "code_example": '''// In model: +type model struct { + termWidth int + termHeight int +} + +// In Update(): +case tea.WindowSizeMsg: + m.termWidth = msg.Width + m.termHeight = msg.Height''' + }) + + return issues, fixes + + +def _check_incorrect_height_calculations(content: str, lines: List[str], file_path: str) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + """Check for manual height calculations instead of lipgloss.Height().""" + issues = [] + fixes = [] + + # Check View() function for manual calculations + view_start = -1 + for i, line in enumerate(lines): + if re.search(r'func\s+\([^)]+\)\s+View\s*\(', line): + view_start = i + break + + if view_start < 0: + return issues, fixes + + # Look for manual arithmetic like "height - 5", "24 - headerHeight" + manual_calc_pattern = r'(height|Height|termHeight)\s*[-+]\s*\d+' + + for i in range(view_start, min(view_start + 200, len(lines))): + if re.search(manual_calc_pattern, lines[i], re.IGNORECASE): + # Check if lipgloss.Height() is used in the vicinity + context = '\n'.join(lines[max(0, i-5):i+5]) + uses_lipgloss_height = bool(re.search(r'lipgloss\.Height\s*\(', context)) + + if not uses_lipgloss_height: + issues.append({ + "severity": "WARNING", + "type": "incorrect_height", + "issue": "Manual height calculation without lipgloss.Height()", + "location": f"{file_path}:{i+1}", + "current_code": lines[i].strip(), + "explanation": "Manual calculations don't account for actual rendered height", + "impact": "Incorrect spacing, overflow, or clipping" + }) + + # Generate fix + fixed_code = lines[i].strip().replace( + "height - ", "m.termHeight - lipgloss.Height(" + ).replace("termHeight - ", "m.termHeight - lipgloss.Height(") + + fixes.append({ + "location": f"{file_path}:{i+1}", + "original": lines[i].strip(), + "fixed": "Use lipgloss.Height() to get actual rendered height", + "explanation": "lipgloss.Height() accounts for padding, borders, margins", + "code_example": '''// ❌ BAD: +availableHeight := termHeight - 5 // Magic number! + +// ✅ GOOD: +headerHeight := lipgloss.Height(m.renderHeader()) +footerHeight := lipgloss.Height(m.renderFooter()) +availableHeight := m.termHeight - headerHeight - footerHeight''' + }) + + return issues, fixes + + +def _check_missing_padding_accounting(content: str, lines: List[str], file_path: str) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + """Check for nested styles without padding/margin accounting.""" + issues = [] + fixes = [] + + # Look for nested styles with padding + # Pattern: Style().Padding(X).Width(Y).Render(content) + nested_style_pattern = r'\.Padding\s*\([^)]+\).*\.Width\s*\(\s*(\w+)\s*\).*\.Render\s*\(' + + for i, line in enumerate(lines): + matches = re.finditer(nested_style_pattern, line) + for match in matches: + width_var = match.group(1) + + # Check if GetHorizontalPadding is used + context = '\n'.join(lines[max(0, i-10):min(i+10, len(lines))]) + uses_get_padding = bool(re.search(r'GetHorizontalPadding\s*\(\s*\)', context)) + + if not uses_get_padding and width_var != 'm.termWidth': + issues.append({ + "severity": "CRITICAL", + "type": "missing_padding_calc", + "issue": "Padding not accounted for in nested width calculation", + "location": f"{file_path}:{i+1}", + "current_code": line.strip(), + "explanation": "Setting Width() then Padding() makes content area smaller than expected", + "impact": "Content gets clipped or wrapped incorrectly" + }) + + fixes.append({ + "location": f"{file_path}:{i+1}", + "original": line.strip(), + "fixed": "Account for padding using GetHorizontalPadding()", + "explanation": "Padding reduces available content area", + "code_example": '''// ❌ BAD: +style := lipgloss.NewStyle(). + Padding(2). + Width(80). + Render(text) // Text area is 76, not 80! + +// ✅ GOOD: +style := lipgloss.NewStyle().Padding(2) +contentWidth := 80 - style.GetHorizontalPadding() +content := lipgloss.NewStyle().Width(contentWidth).Render(text) +result := style.Width(80).Render(content)''' + }) + + return issues, fixes + + +def _check_overflow_issues(content: str, lines: List[str], file_path: str) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + """Check for potential text overflow.""" + issues = [] + fixes = [] + + # Check for long strings without wrapping + has_wordwrap = bool(re.search(r'"github\.com/muesli/reflow/wordwrap"', content)) + has_wrap_or_truncate = bool(re.search(r'(wordwrap|truncate|Truncate)', content, re.IGNORECASE)) + + # Look for string rendering without width constraints + render_pattern = r'\.Render\s*\(\s*(\w+)\s*\)' + + for i, line in enumerate(lines): + matches = re.finditer(render_pattern, line) + for match in matches: + var_name = match.group(1) + + # Check if there's width control + has_width_control = bool(re.search(r'\.Width\s*\(', line)) + + if not has_width_control and not has_wrap_or_truncate and len(line) > 40: + issues.append({ + "severity": "WARNING", + "type": "overflow", + "issue": f"Rendering '{var_name}' without width constraint", + "location": f"{file_path}:{i+1}", + "current_code": line.strip(), + "explanation": "Long content can exceed terminal width", + "impact": "Text wraps unexpectedly or overflows" + }) + + fixes.append({ + "location": f"{file_path}:{i+1}", + "original": line.strip(), + "fixed": "Add wordwrap or width constraint", + "explanation": "Constrain content to terminal width", + "code_example": '''// Option 1: Use wordwrap +import "github.com/muesli/reflow/wordwrap" + +content := wordwrap.String(longText, m.termWidth) + +// Option 2: Use lipgloss Width + truncate +style := lipgloss.NewStyle().Width(m.termWidth) +content := style.Render(longText) + +// Option 3: Manual truncate +import "github.com/muesli/reflow/truncate" + +content := truncate.StringWithTail(longText, uint(m.termWidth), "...")''' + }) + + return issues, fixes + + +def _check_terminal_resize_handling(content: str, lines: List[str], file_path: str) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + """Check for proper terminal resize handling.""" + issues = [] + fixes = [] + + # Check if WindowSizeMsg is handled + handles_resize = bool(re.search(r'case\s+tea\.WindowSizeMsg:', content)) + + # Check if model stores term dimensions + has_term_fields = bool(re.search(r'(termWidth|termHeight|width|height)\s+int', content)) + + if not handles_resize and uses_lipgloss(content): + issues.append({ + "severity": "CRITICAL", + "type": "missing_resize_handling", + "issue": "No tea.WindowSizeMsg handling detected", + "location": file_path, + "explanation": "Layout won't adapt when terminal is resized", + "impact": "Content clipped or misaligned after resize" + }) + + fixes.append({ + "location": file_path, + "original": "N/A", + "fixed": "Add WindowSizeMsg handler", + "explanation": "Store terminal dimensions and update on resize", + "code_example": '''// In model: +type model struct { + termWidth int + termHeight int +} + +// In Update(): +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.termWidth = msg.Width + m.termHeight = msg.Height + + // Update child components with new size + m.viewport.Width = msg.Width + m.viewport.Height = msg.Height - 2 // Leave room for header + } + return m, nil +} + +// In View(): +func (m model) View() string { + // Use m.termWidth and m.termHeight for dynamic layout + content := lipgloss.NewStyle(). + Width(m.termWidth). + Height(m.termHeight). + Render(m.content) + return content +}''' + }) + + elif handles_resize and not has_term_fields: + issues.append({ + "severity": "WARNING", + "type": "resize_not_stored", + "issue": "WindowSizeMsg handled but dimensions not stored", + "location": file_path, + "explanation": "Handling resize but not storing dimensions for later use", + "impact": "Can't use current terminal size in View()" + }) + + return issues, fixes + + +def _check_border_accounting(content: str, lines: List[str], file_path: str) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + """Check for border accounting in layout calculations.""" + issues = [] + fixes = [] + + # Check for borders without proper accounting + has_border = bool(re.search(r'\.Border\s*\(', content)) + has_border_width_calc = bool(re.search(r'GetHorizontalBorderSize|GetVerticalBorderSize', content)) + + if has_border and not has_border_width_calc: + # Find border usage lines + for i, line in enumerate(lines): + if '.Border(' in line: + issues.append({ + "severity": "WARNING", + "type": "missing_border_calc", + "issue": "Border used without accounting for border size", + "location": f"{file_path}:{i+1}", + "current_code": line.strip(), + "explanation": "Borders take space (2 chars horizontal, 2 chars vertical)", + "impact": "Content area smaller than expected" + }) + + fixes.append({ + "location": f"{file_path}:{i+1}", + "original": line.strip(), + "fixed": "Account for border size", + "explanation": "Use GetHorizontalBorderSize() and GetVerticalBorderSize()", + "code_example": '''// With border: +style := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + Width(80) + +// Calculate content area: +contentWidth := 80 - style.GetHorizontalBorderSize() +contentHeight := 24 - style.GetVerticalBorderSize() + +// Use for inner content: +innerContent := lipgloss.NewStyle(). + Width(contentWidth). + Height(contentHeight). + Render(text) + +result := style.Render(innerContent)''' + }) + + return issues, fixes + + +def uses_lipgloss(content: str) -> bool: + """Check if file uses lipgloss.""" + return bool(re.search(r'"github\.com/charmbracelet/lipgloss"', content)) + + +def _generate_improvements(issues: List[Dict[str, Any]]) -> List[str]: + """Generate general improvement recommendations.""" + improvements = [] + + issue_types = set(issue['type'] for issue in issues) + + if 'hardcoded_dimensions' in issue_types: + improvements.append( + "🎯 Use dynamic terminal sizing: Store termWidth/termHeight in model, update from tea.WindowSizeMsg" + ) + + if 'incorrect_height' in issue_types: + improvements.append( + "📏 Use lipgloss.Height() and lipgloss.Width() for accurate measurements" + ) + + if 'missing_padding_calc' in issue_types: + improvements.append( + "📐 Account for padding with GetHorizontalPadding() and GetVerticalPadding()" + ) + + if 'overflow' in issue_types: + improvements.append( + "📝 Use wordwrap or truncate to prevent text overflow" + ) + + if 'missing_resize_handling' in issue_types: + improvements.append( + "🔄 Handle tea.WindowSizeMsg to support terminal resizing" + ) + + if 'missing_border_calc' in issue_types: + improvements.append( + "🔲 Account for borders with GetHorizontalBorderSize() and GetVerticalBorderSize()" + ) + + # General best practices + improvements.extend([ + "✨ Test your TUI at various terminal sizes (80x24, 120x40, 200x50)", + "🔍 Use lipgloss debugging: Print style.String() to see computed dimensions", + "📦 Cache computed styles in model to avoid recreation on every render", + "🎨 Use PlaceHorizontal/PlaceVertical for alignment instead of manual padding" + ]) + + return improvements + + +def validate_layout_fixes(result: Dict[str, Any]) -> Dict[str, Any]: + """Validate layout fixes result.""" + if 'error' in result: + return {"status": "error", "summary": result['error']} + + validation = result.get('validation', {}) + status = validation.get('status', 'unknown') + summary = validation.get('summary', 'Layout analysis complete') + + checks = [ + (result.get('layout_issues') is not None, "Has issues list"), + (result.get('lipgloss_improvements') is not None, "Has improvements"), + (result.get('code_fixes') is not None, "Has code fixes"), + ] + + all_pass = all(check[0] for check in checks) + + return { + "status": status, + "summary": summary, + "checks": {check[1]: check[0] for check in checks}, + "valid": all_pass + } + + +if __name__ == "__main__": + import sys + + if len(sys.argv) < 2: + print("Usage: fix_layout_issues.py [description]") + sys.exit(1) + + code_path = sys.argv[1] + description = sys.argv[2] if len(sys.argv) > 2 else "" + + result = fix_layout_issues(code_path, description) + print(json.dumps(result, indent=2)) diff --git a/.claude/skills/bubbletea-maintenance/scripts/suggest_architecture.py b/.claude/skills/bubbletea-maintenance/scripts/suggest_architecture.py new file mode 100644 index 00000000..b5576f5d --- /dev/null +++ b/.claude/skills/bubbletea-maintenance/scripts/suggest_architecture.py @@ -0,0 +1,736 @@ +#!/usr/bin/env python3 +""" +Suggest architectural improvements for Bubble Tea applications. +Analyzes complexity and recommends patterns like model trees, composable views, etc. +""" + +import os +import re +import json +from pathlib import Path +from typing import Dict, List, Any, Tuple, Optional + + +def suggest_architecture(code_path: str, complexity_level: str = "auto") -> Dict[str, Any]: + """ + Analyze code and suggest architectural improvements. + + Args: + code_path: Path to Go file or directory + complexity_level: "auto" (detect), "simple", "medium", "complex" + + Returns: + Dictionary containing: + - current_pattern: Detected architectural pattern + - complexity_score: 0-100 (higher = more complex) + - recommended_pattern: Suggested pattern for improvement + - refactoring_steps: List of steps to implement + - code_templates: Example code for new pattern + - validation: Validation report + """ + path = Path(code_path) + + if not path.exists(): + return { + "error": f"Path not found: {code_path}", + "validation": {"status": "error", "summary": "Invalid path"} + } + + # Collect all .go files + go_files = [] + if path.is_file(): + if path.suffix == '.go': + go_files = [path] + else: + go_files = list(path.glob('**/*.go')) + + if not go_files: + return { + "error": "No .go files found", + "validation": {"status": "error", "summary": "No Go files"} + } + + # Read all code + all_content = "" + for go_file in go_files: + try: + all_content += go_file.read_text() + "\n" + except Exception: + pass + + # Analyze current architecture + current_pattern = _detect_current_pattern(all_content) + complexity_score = _calculate_complexity(all_content, go_files) + + # Auto-detect complexity level if needed + if complexity_level == "auto": + if complexity_score < 30: + complexity_level = "simple" + elif complexity_score < 70: + complexity_level = "medium" + else: + complexity_level = "complex" + + # Generate recommendations + recommended_pattern = _recommend_pattern(current_pattern, complexity_score, complexity_level) + refactoring_steps = _generate_refactoring_steps(current_pattern, recommended_pattern, all_content) + code_templates = _generate_code_templates(recommended_pattern, all_content) + + # Summary + if recommended_pattern == current_pattern: + summary = f"✅ Current architecture ({current_pattern}) is appropriate for complexity level" + else: + summary = f"💡 Recommend refactoring from {current_pattern} to {recommended_pattern}" + + # Validation + validation = { + "status": "pass" if recommended_pattern == current_pattern else "info", + "summary": summary, + "checks": { + "complexity_analyzed": complexity_score >= 0, + "pattern_detected": current_pattern != "unknown", + "has_recommendations": len(refactoring_steps) > 0, + "has_templates": len(code_templates) > 0 + } + } + + return { + "current_pattern": current_pattern, + "complexity_score": complexity_score, + "complexity_level": complexity_level, + "recommended_pattern": recommended_pattern, + "refactoring_steps": refactoring_steps, + "code_templates": code_templates, + "summary": summary, + "analysis": { + "files_analyzed": len(go_files), + "model_count": _count_models(all_content), + "view_functions": _count_view_functions(all_content), + "state_fields": _count_state_fields(all_content) + }, + "validation": validation + } + + +def _detect_current_pattern(content: str) -> str: + """Detect the current architectural pattern.""" + + # Check for various patterns + patterns_detected = [] + + # Pattern 1: Flat Model (single model struct, no child models) + has_model = bool(re.search(r'type\s+\w*[Mm]odel\s+struct', content)) + has_child_models = bool(re.search(r'\w+Model\s+\w+Model', content)) + + if has_model and not has_child_models: + patterns_detected.append("flat_model") + + # Pattern 2: Model Tree (parent model with child models) + if has_child_models: + patterns_detected.append("model_tree") + + # Pattern 3: Multi-view (multiple view rendering based on state) + has_view_switcher = bool(re.search(r'switch\s+m\.\w*(view|mode|screen|state)', content, re.IGNORECASE)) + if has_view_switcher: + patterns_detected.append("multi_view") + + # Pattern 4: Component-based (using Bubble Tea components like list, viewport, etc.) + bubbletea_components = [ + 'list.Model', + 'viewport.Model', + 'textinput.Model', + 'textarea.Model', + 'table.Model', + 'progress.Model', + 'spinner.Model' + ] + component_count = sum(1 for comp in bubbletea_components if comp in content) + + if component_count >= 3: + patterns_detected.append("component_based") + elif component_count >= 1: + patterns_detected.append("uses_components") + + # Pattern 5: State Machine (explicit state enums/constants) + has_state_enum = bool(re.search(r'type\s+\w*State\s+(int|string)', content)) + has_iota_states = bool(re.search(r'const\s+\(\s*\w+State\s+\w*State\s+=\s+iota', content)) + + if has_state_enum or has_iota_states: + patterns_detected.append("state_machine") + + # Pattern 6: Event-driven (heavy use of custom messages) + custom_msg_count = len(re.findall(r'type\s+\w+Msg\s+struct', content)) + if custom_msg_count >= 5: + patterns_detected.append("event_driven") + + # Return the most dominant pattern + if "model_tree" in patterns_detected: + return "model_tree" + elif "state_machine" in patterns_detected and "multi_view" in patterns_detected: + return "state_machine_multi_view" + elif "component_based" in patterns_detected: + return "component_based" + elif "multi_view" in patterns_detected: + return "multi_view" + elif "flat_model" in patterns_detected: + return "flat_model" + elif has_model: + return "basic_model" + else: + return "unknown" + + +def _calculate_complexity(content: str, files: List[Path]) -> int: + """Calculate complexity score (0-100).""" + + score = 0 + + # Factor 1: Number of files (10 points max) + file_count = len(files) + score += min(10, file_count * 2) + + # Factor 2: Model field count (20 points max) + model_match = re.search(r'type\s+(\w*[Mm]odel)\s+struct\s*\{([^}]+)\}', content, re.DOTALL) + if model_match: + model_body = model_match.group(2) + field_count = len([line for line in model_body.split('\n') + if line.strip() and not line.strip().startswith('//')]) + score += min(20, field_count) + + # Factor 3: Number of Update() branches (20 points max) + update_match = re.search(r'func\s+\([^)]+\)\s+Update\s*\([^)]+\)\s*\([^)]+\)\s*\{(.+?)^func\s', + content, re.DOTALL | re.MULTILINE) + if update_match: + update_body = update_match.group(1) + case_count = len(re.findall(r'case\s+', update_body)) + score += min(20, case_count * 2) + + # Factor 4: View() complexity (15 points max) + view_match = re.search(r'func\s+\([^)]+\)\s+View\s*\(\s*\)\s+string\s*\{(.+?)^func\s', + content, re.DOTALL | re.MULTILINE) + if view_match: + view_body = view_match.group(1) + view_lines = len(view_body.split('\n')) + score += min(15, view_lines // 2) + + # Factor 5: Custom message types (10 points max) + custom_msg_count = len(re.findall(r'type\s+\w+Msg\s+struct', content)) + score += min(10, custom_msg_count * 2) + + # Factor 6: Number of views/screens (15 points max) + view_count = len(re.findall(r'func\s+\([^)]+\)\s+render\w+', content, re.IGNORECASE)) + score += min(15, view_count * 3) + + # Factor 7: Use of channels/goroutines (10 points max) + has_channels = len(re.findall(r'make\s*\(\s*chan\s+', content)) + has_goroutines = len(re.findall(r'\bgo\s+func', content)) + score += min(10, (has_channels + has_goroutines) * 2) + + return min(100, score) + + +def _recommend_pattern(current: str, complexity: int, level: str) -> str: + """Recommend architectural pattern based on current state and complexity.""" + + # Simple apps (< 30 complexity) + if complexity < 30: + if current in ["unknown", "basic_model"]: + return "flat_model" # Simple flat model is fine + return current # Keep current pattern + + # Medium complexity (30-70) + elif complexity < 70: + if current == "flat_model": + return "multi_view" # Evolve to multi-view + elif current == "basic_model": + return "component_based" # Start using components + return current + + # High complexity (70+) + else: + if current in ["flat_model", "multi_view"]: + return "model_tree" # Need hierarchy + elif current == "component_based": + return "model_tree_with_components" # Combine patterns + return current + + +def _count_models(content: str) -> int: + """Count model structs.""" + return len(re.findall(r'type\s+\w*[Mm]odel\s+struct', content)) + + +def _count_view_functions(content: str) -> int: + """Count view rendering functions.""" + return len(re.findall(r'func\s+\([^)]+\)\s+(View|render\w+)', content, re.IGNORECASE)) + + +def _count_state_fields(content: str) -> int: + """Count state fields in model.""" + model_match = re.search(r'type\s+(\w*[Mm]odel)\s+struct\s*\{([^}]+)\}', content, re.DOTALL) + if not model_match: + return 0 + + model_body = model_match.group(2) + return len([line for line in model_body.split('\n') + if line.strip() and not line.strip().startswith('//')]) + + +def _generate_refactoring_steps(current: str, recommended: str, content: str) -> List[str]: + """Generate step-by-step refactoring guide.""" + + if current == recommended: + return ["No refactoring needed - current architecture is appropriate"] + + steps = [] + + # Flat Model → Multi-view + if current == "flat_model" and recommended == "multi_view": + steps = [ + "1. Add view state enum to model", + "2. Create separate render functions for each view", + "3. Add view switching logic in Update()", + "4. Implement switch statement in View() to route to render functions", + "5. Add keyboard shortcuts for view navigation" + ] + + # Flat Model → Model Tree + elif current == "flat_model" and recommended == "model_tree": + steps = [ + "1. Identify logical groupings of fields in current model", + "2. Create child model structs for each grouping", + "3. Add Init() methods to child models", + "4. Create parent model with child model fields", + "5. Implement message routing in parent's Update()", + "6. Delegate rendering to child models in View()", + "7. Test each child model independently" + ] + + # Multi-view → Model Tree + elif current == "multi_view" and recommended == "model_tree": + steps = [ + "1. Convert each view into a separate child model", + "2. Extract view-specific state into child models", + "3. Create parent router model with activeView field", + "4. Implement message routing based on activeView", + "5. Move view rendering logic into child models", + "6. Add inter-model communication via custom messages" + ] + + # Component-based → Model Tree with Components + elif current == "component_based" and recommended == "model_tree_with_components": + steps = [ + "1. Group related components into logical views", + "2. Create view models that own related components", + "3. Create parent model to manage view models", + "4. Implement message routing to active view", + "5. Keep component updates within their view models", + "6. Compose final view from view model renders" + ] + + # Basic Model → Component-based + elif current == "basic_model" and recommended == "component_based": + steps = [ + "1. Identify UI patterns that match Bubble Tea components", + "2. Replace custom text input with textinput.Model", + "3. Replace custom list with list.Model", + "4. Replace custom scrolling with viewport.Model", + "5. Update Init() to initialize components", + "6. Route messages to components in Update()", + "7. Compose View() using component.View() calls" + ] + + # Generic fallback + else: + steps = [ + f"1. Analyze current {current} pattern", + f"2. Study {recommended} pattern examples", + "3. Plan gradual migration strategy", + "4. Implement incrementally with tests", + "5. Validate each step before proceeding" + ] + + return steps + + +def _generate_code_templates(pattern: str, existing_code: str) -> Dict[str, str]: + """Generate code templates for recommended pattern.""" + + templates = {} + + if pattern == "model_tree": + templates["parent_model"] = '''// Parent model manages child models +type appModel struct { + activeView int + + // Child models + listView listViewModel + detailView detailViewModel + searchView searchViewModel +} + +func (m appModel) Init() tea.Cmd { + return tea.Batch( + m.listView.Init(), + m.detailView.Init(), + m.searchView.Init(), + ) +} + +func (m appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + // Global navigation + if key, ok := msg.(tea.KeyMsg); ok { + switch key.String() { + case "1": + m.activeView = 0 + return m, nil + case "2": + m.activeView = 1 + return m, nil + case "3": + m.activeView = 2 + return m, nil + } + } + + // Route to active child + switch m.activeView { + case 0: + m.listView, cmd = m.listView.Update(msg) + case 1: + m.detailView, cmd = m.detailView.Update(msg) + case 2: + m.searchView, cmd = m.searchView.Update(msg) + } + + return m, cmd +} + +func (m appModel) View() string { + switch m.activeView { + case 0: + return m.listView.View() + case 1: + return m.detailView.View() + case 2: + return m.searchView.View() + } + return "" +}''' + + templates["child_model"] = '''// Child model handles its own state and rendering +type listViewModel struct { + items []string + cursor int + selected map[int]bool +} + +func (m listViewModel) Init() tea.Cmd { + return nil +} + +func (m listViewModel) Update(msg tea.Msg) (listViewModel, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(m.items)-1 { + m.cursor++ + } + case " ": + m.selected[m.cursor] = !m.selected[m.cursor] + } + } + return m, nil +} + +func (m listViewModel) View() string { + s := "Select items:\\n\\n" + for i, item := range m.items { + cursor := " " + if m.cursor == i { + cursor = ">" + } + checked := " " + if m.selected[i] { + checked = "x" + } + s += fmt.Sprintf("%s [%s] %s\\n", cursor, checked, item) + } + return s +}''' + + templates["message_passing"] = '''// Custom message for inter-model communication +type itemSelectedMsg struct { + itemID string +} + +// In listViewModel: +func (m listViewModel) Update(msg tea.Msg) (listViewModel, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "enter" { + // Send message to parent (who routes to detail view) + return m, func() tea.Msg { + return itemSelectedMsg{itemID: m.items[m.cursor]} + } + } + } + return m, nil +} + +// In appModel: +func (m appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case itemSelectedMsg: + // List selected item, switch to detail view + m.detailView.LoadItem(msg.itemID) + m.activeView = 1 // Switch to detail + return m, nil + } + + // Route to children... + return m, nil +}''' + + elif pattern == "multi_view": + templates["view_state"] = '''type viewState int + +const ( + listView viewState = iota + detailView + searchView +) + +type model struct { + currentView viewState + + // View-specific state + listItems []string + listCursor int + detailItem string + searchQuery string +}''' + + templates["view_switching"] = '''func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + // Global navigation + switch msg.String() { + case "1": + m.currentView = listView + return m, nil + case "2": + m.currentView = detailView + return m, nil + case "3": + m.currentView = searchView + return m, nil + } + + // View-specific handling + switch m.currentView { + case listView: + return m.updateListView(msg) + case detailView: + return m.updateDetailView(msg) + case searchView: + return m.updateSearchView(msg) + } + } + return m, nil +} + +func (m model) View() string { + switch m.currentView { + case listView: + return m.renderListView() + case detailView: + return m.renderDetailView() + case searchView: + return m.renderSearchView() + } + return "" +}''' + + elif pattern == "component_based": + templates["using_components"] = '''import ( + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" +) + +type model struct { + list list.Model + search textinput.Model + viewer viewport.Model + activeComponent int +} + +func initialModel() model { + // Initialize components + items := []list.Item{ + item{title: "Item 1", desc: "Description"}, + item{title: "Item 2", desc: "Description"}, + } + + l := list.New(items, list.NewDefaultDelegate(), 20, 10) + l.Title = "Items" + + ti := textinput.New() + ti.Placeholder = "Search..." + ti.Focus() + + vp := viewport.New(80, 20) + + return model{ + list: l, + search: ti, + viewer: vp, + activeComponent: 0, + } +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + // Route to active component + switch m.activeComponent { + case 0: + m.list, cmd = m.list.Update(msg) + case 1: + m.search, cmd = m.search.Update(msg) + case 2: + m.viewer, cmd = m.viewer.Update(msg) + } + + return m, cmd +} + +func (m model) View() string { + return lipgloss.JoinVertical( + lipgloss.Left, + m.search.View(), + m.list.View(), + m.viewer.View(), + ) +}''' + + elif pattern == "state_machine_multi_view": + templates["state_machine"] = '''type appState int + +const ( + loadingState appState = iota + listState + detailState + errorState +) + +type model struct { + state appState + prevState appState + + // State data + items []string + selected string + error error +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case itemsLoadedMsg: + m.items = msg.items + m.state = listState + return m, nil + + case itemSelectedMsg: + m.selected = msg.item + m.state = detailState + return m, loadItemDetails + + case errorMsg: + m.prevState = m.state + m.state = errorState + m.error = msg.err + return m, nil + + case tea.KeyMsg: + if msg.String() == "esc" && m.state == errorState { + m.state = m.prevState // Return to previous state + return m, nil + } + } + + // State-specific update + switch m.state { + case listState: + return m.updateList(msg) + case detailState: + return m.updateDetail(msg) + } + + return m, nil +} + +func (m model) View() string { + switch m.state { + case loadingState: + return "Loading..." + case listState: + return m.renderList() + case detailState: + return m.renderDetail() + case errorState: + return fmt.Sprintf("Error: %v\\nPress ESC to continue", m.error) + } + return "" +}''' + + return templates + + +def validate_architecture_suggestion(result: Dict[str, Any]) -> Dict[str, Any]: + """Validate architecture suggestion result.""" + if 'error' in result: + return {"status": "error", "summary": result['error']} + + validation = result.get('validation', {}) + status = validation.get('status', 'unknown') + summary = validation.get('summary', 'Architecture analysis complete') + + checks = [ + (result.get('current_pattern') is not None, "Pattern detected"), + (result.get('complexity_score') is not None, "Complexity calculated"), + (result.get('recommended_pattern') is not None, "Recommendation generated"), + (len(result.get('refactoring_steps', [])) > 0, "Has refactoring steps"), + ] + + all_pass = all(check[0] for check in checks) + + return { + "status": status, + "summary": summary, + "checks": {check[1]: check[0] for check in checks}, + "valid": all_pass + } + + +if __name__ == "__main__": + import sys + + if len(sys.argv) < 2: + print("Usage: suggest_architecture.py [complexity_level]") + sys.exit(1) + + code_path = sys.argv[1] + complexity_level = sys.argv[2] if len(sys.argv) > 2 else "auto" + + result = suggest_architecture(code_path, complexity_level) + print(json.dumps(result, indent=2)) diff --git a/.claude/skills/bubbletea-maintenance/scripts/utils/__init__.py b/.claude/skills/bubbletea-maintenance/scripts/utils/__init__.py new file mode 100644 index 00000000..72f2e1c7 --- /dev/null +++ b/.claude/skills/bubbletea-maintenance/scripts/utils/__init__.py @@ -0,0 +1 @@ +# Utility modules for Bubble Tea maintenance agent diff --git a/.claude/skills/bubbletea-maintenance/scripts/utils/go_parser.py b/.claude/skills/bubbletea-maintenance/scripts/utils/go_parser.py new file mode 100644 index 00000000..44342bd0 --- /dev/null +++ b/.claude/skills/bubbletea-maintenance/scripts/utils/go_parser.py @@ -0,0 +1,328 @@ +#!/usr/bin/env python3 +""" +Go code parser utilities for Bubble Tea maintenance agent. +Extracts models, functions, types, and code structure. +""" + +import re +from typing import Dict, List, Tuple, Optional +from pathlib import Path + + +def extract_model_struct(content: str) -> Optional[Dict[str, any]]: + """Extract the main model struct from Go code.""" + + # Pattern: type XxxModel struct { ... } + pattern = r'type\s+(\w*[Mm]odel)\s+struct\s*\{([^}]+)\}' + match = re.search(pattern, content, re.DOTALL) + + if not match: + return None + + model_name = match.group(1) + model_body = match.group(2) + + # Parse fields + fields = [] + for line in model_body.split('\n'): + line = line.strip() + if not line or line.startswith('//'): + continue + + # Parse field: name type [tag] + field_match = re.match(r'(\w+)\s+([^\s`]+)(?:\s+`([^`]+)`)?', line) + if field_match: + fields.append({ + "name": field_match.group(1), + "type": field_match.group(2), + "tag": field_match.group(3) if field_match.group(3) else None + }) + + return { + "name": model_name, + "fields": fields, + "field_count": len(fields), + "raw_body": model_body + } + + +def extract_update_function(content: str) -> Optional[Dict[str, any]]: + """Extract the Update() function.""" + + # Find Update function + pattern = r'func\s+\((\w+)\s+(\*?)(\w+)\)\s+Update\s*\([^)]*\)\s*\([^)]*\)\s*\{(.+?)(?=\nfunc\s|\Z)' + match = re.search(pattern, content, re.DOTALL | re.MULTILINE) + + if not match: + return None + + receiver_name = match.group(1) + is_pointer = match.group(2) == '*' + receiver_type = match.group(3) + function_body = match.group(4) + + # Count cases in switch statements + case_count = len(re.findall(r'\bcase\s+', function_body)) + + # Find message types handled + handled_messages = re.findall(r'case\s+(\w+\.?\w*):', function_body) + + return { + "receiver_name": receiver_name, + "receiver_type": receiver_type, + "is_pointer_receiver": is_pointer, + "body_lines": len(function_body.split('\n')), + "case_count": case_count, + "handled_messages": list(set(handled_messages)), + "raw_body": function_body + } + + +def extract_view_function(content: str) -> Optional[Dict[str, any]]: + """Extract the View() function.""" + + pattern = r'func\s+\((\w+)\s+(\*?)(\w+)\)\s+View\s*\(\s*\)\s+string\s*\{(.+?)(?=\nfunc\s|\Z)' + match = re.search(pattern, content, re.DOTALL | re.MULTILINE) + + if not match: + return None + + receiver_name = match.group(1) + is_pointer = match.group(2) == '*' + receiver_type = match.group(3) + function_body = match.group(4) + + # Analyze complexity + string_concat_count = len(re.findall(r'\+\s*"', function_body)) + lipgloss_calls = len(re.findall(r'lipgloss\.', function_body)) + + return { + "receiver_name": receiver_name, + "receiver_type": receiver_type, + "is_pointer_receiver": is_pointer, + "body_lines": len(function_body.split('\n')), + "string_concatenations": string_concat_count, + "lipgloss_calls": lipgloss_calls, + "raw_body": function_body + } + + +def extract_init_function(content: str) -> Optional[Dict[str, any]]: + """Extract the Init() function.""" + + pattern = r'func\s+\((\w+)\s+(\*?)(\w+)\)\s+Init\s*\(\s*\)\s+tea\.Cmd\s*\{(.+?)(?=\nfunc\s|\Z)' + match = re.search(pattern, content, re.DOTALL | re.MULTILINE) + + if not match: + return None + + receiver_name = match.group(1) + is_pointer = match.group(2) == '*' + receiver_type = match.group(3) + function_body = match.group(4) + + return { + "receiver_name": receiver_name, + "receiver_type": receiver_type, + "is_pointer_receiver": is_pointer, + "body_lines": len(function_body.split('\n')), + "raw_body": function_body + } + + +def extract_custom_messages(content: str) -> List[Dict[str, any]]: + """Extract custom message type definitions.""" + + # Pattern: type xxxMsg struct { ... } + pattern = r'type\s+(\w+Msg)\s+struct\s*\{([^}]*)\}' + matches = re.finditer(pattern, content, re.DOTALL) + + messages = [] + for match in matches: + msg_name = match.group(1) + msg_body = match.group(2) + + # Parse fields + fields = [] + for line in msg_body.split('\n'): + line = line.strip() + if not line or line.startswith('//'): + continue + + field_match = re.match(r'(\w+)\s+([^\s]+)', line) + if field_match: + fields.append({ + "name": field_match.group(1), + "type": field_match.group(2) + }) + + messages.append({ + "name": msg_name, + "fields": fields, + "field_count": len(fields) + }) + + return messages + + +def extract_tea_commands(content: str) -> List[Dict[str, any]]: + """Extract tea.Cmd functions.""" + + # Pattern: func xxxCmd() tea.Msg { ... } + pattern = r'func\s+(\w+)\s*\(\s*\)\s+tea\.Msg\s*\{(.+?)^\}' + matches = re.finditer(pattern, content, re.DOTALL | re.MULTILINE) + + commands = [] + for match in matches: + cmd_name = match.group(1) + cmd_body = match.group(2) + + # Check for blocking operations + has_http = bool(re.search(r'\bhttp\.(Get|Post|Do)', cmd_body)) + has_sleep = bool(re.search(r'time\.Sleep', cmd_body)) + has_io = bool(re.search(r'\bos\.(Open|Read|Write)', cmd_body)) + + commands.append({ + "name": cmd_name, + "body_lines": len(cmd_body.split('\n')), + "has_http": has_http, + "has_sleep": has_sleep, + "has_io": has_io, + "is_blocking": has_http or has_io # sleep is expected in commands + }) + + return commands + + +def extract_imports(content: str) -> List[str]: + """Extract import statements.""" + + imports = [] + + # Single import + single_pattern = r'import\s+"([^"]+)"' + imports.extend(re.findall(single_pattern, content)) + + # Multi-line import block + block_pattern = r'import\s+\(([^)]+)\)' + block_matches = re.finditer(block_pattern, content, re.DOTALL) + for match in block_matches: + block_content = match.group(1) + # Extract quoted imports + quoted = re.findall(r'"([^"]+)"', block_content) + imports.extend(quoted) + + return list(set(imports)) + + +def find_bubbletea_components(content: str) -> List[Dict[str, any]]: + """Find usage of Bubble Tea components (list, viewport, etc.).""" + + components = [] + + component_patterns = { + "list": r'list\.Model', + "viewport": r'viewport\.Model', + "textinput": r'textinput\.Model', + "textarea": r'textarea\.Model', + "table": r'table\.Model', + "progress": r'progress\.Model', + "spinner": r'spinner\.Model', + "timer": r'timer\.Model', + "stopwatch": r'stopwatch\.Model', + "filepicker": r'filepicker\.Model', + "paginator": r'paginator\.Model', + } + + for comp_name, pattern in component_patterns.items(): + if re.search(pattern, content): + # Count occurrences + count = len(re.findall(pattern, content)) + components.append({ + "component": comp_name, + "occurrences": count + }) + + return components + + +def analyze_code_structure(file_path: Path) -> Dict[str, any]: + """Comprehensive code structure analysis.""" + + try: + content = file_path.read_text() + except Exception as e: + return {"error": str(e)} + + return { + "model": extract_model_struct(content), + "update": extract_update_function(content), + "view": extract_view_function(content), + "init": extract_init_function(content), + "custom_messages": extract_custom_messages(content), + "tea_commands": extract_tea_commands(content), + "imports": extract_imports(content), + "components": find_bubbletea_components(content), + "file_size": len(content), + "line_count": len(content.split('\n')), + "uses_lipgloss": '"github.com/charmbracelet/lipgloss"' in content, + "uses_bubbletea": '"github.com/charmbracelet/bubbletea"' in content + } + + +def find_function_by_name(content: str, func_name: str) -> Optional[str]: + """Find a specific function by name and return its body.""" + + pattern = rf'func\s+(?:\([^)]+\)\s+)?{func_name}\s*\([^)]*\)[^{{]*\{{(.+?)(?=\nfunc\s|\Z)' + match = re.search(pattern, content, re.DOTALL | re.MULTILINE) + + if match: + return match.group(1) + return None + + +def extract_state_machine_states(content: str) -> Optional[Dict[str, any]]: + """Extract state machine enum if present.""" + + # Pattern: type xxxState int; const ( state1 state2 = iota ... ) + state_type_pattern = r'type\s+(\w+State)\s+(int|string)' + state_type_match = re.search(state_type_pattern, content) + + if not state_type_match: + return None + + state_type = state_type_match.group(1) + + # Find const block with iota + const_pattern = rf'const\s+\(([^)]+)\)' + const_matches = re.finditer(const_pattern, content, re.DOTALL) + + states = [] + for const_match in const_matches: + const_body = const_match.group(1) + if state_type in const_body and 'iota' in const_body: + # Extract state names + state_names = re.findall(rf'(\w+)\s+{state_type}', const_body) + states = state_names + break + + return { + "type": state_type, + "states": states, + "count": len(states) + } + + +# Example usage and testing +if __name__ == "__main__": + import sys + + if len(sys.argv) < 2: + print("Usage: go_parser.py ") + sys.exit(1) + + file_path = Path(sys.argv[1]) + result = analyze_code_structure(file_path) + + import json + print(json.dumps(result, indent=2)) diff --git a/.claude/skills/bubbletea-maintenance/scripts/utils/validators/__init__.py b/.claude/skills/bubbletea-maintenance/scripts/utils/validators/__init__.py new file mode 100644 index 00000000..19d18f39 --- /dev/null +++ b/.claude/skills/bubbletea-maintenance/scripts/utils/validators/__init__.py @@ -0,0 +1 @@ +# Validator modules for Bubble Tea maintenance agent diff --git a/.claude/skills/bubbletea-maintenance/scripts/utils/validators/common.py b/.claude/skills/bubbletea-maintenance/scripts/utils/validators/common.py new file mode 100644 index 00000000..3a6c2fcb --- /dev/null +++ b/.claude/skills/bubbletea-maintenance/scripts/utils/validators/common.py @@ -0,0 +1,349 @@ +#!/usr/bin/env python3 +""" +Common validation utilities for Bubble Tea maintenance agent. +""" + +from typing import Dict, List, Any, Optional + + +def validate_result_structure(result: Dict[str, Any], required_keys: List[str]) -> Dict[str, Any]: + """ + Validate that a result dictionary has required keys. + + Args: + result: Result dictionary to validate + required_keys: List of required key names + + Returns: + Validation dict with status, summary, and checks + """ + if 'error' in result: + return { + "status": "error", + "summary": result['error'], + "valid": False + } + + checks = {} + for key in required_keys: + checks[f"has_{key}"] = key in result and result[key] is not None + + all_pass = all(checks.values()) + + status = "pass" if all_pass else "fail" + summary = "Validation passed" if all_pass else f"Missing required keys: {[k for k, v in checks.items() if not v]}" + + return { + "status": status, + "summary": summary, + "checks": checks, + "valid": all_pass + } + + +def validate_issue_list(issues: List[Dict[str, Any]]) -> Dict[str, Any]: + """ + Validate a list of issues has proper structure. + + Expected issue structure: + - severity: CRITICAL, HIGH, WARNING, or INFO + - category: performance, layout, reliability, etc. + - issue: Description + - location: File path and line number + - explanation: Why it's a problem + - fix: How to fix it + """ + if not isinstance(issues, list): + return { + "status": "error", + "summary": "Issues must be a list", + "valid": False + } + + required_fields = ["severity", "issue", "location", "explanation"] + valid_severities = ["CRITICAL", "HIGH", "MEDIUM", "WARNING", "LOW", "INFO"] + + checks = { + "is_list": True, + "all_have_severity": True, + "valid_severity_values": True, + "all_have_issue": True, + "all_have_location": True, + "all_have_explanation": True + } + + for issue in issues: + if not isinstance(issue, dict): + checks["is_list"] = False + continue + + if "severity" not in issue: + checks["all_have_severity"] = False + elif issue["severity"] not in valid_severities: + checks["valid_severity_values"] = False + + if "issue" not in issue or not issue["issue"]: + checks["all_have_issue"] = False + + if "location" not in issue or not issue["location"]: + checks["all_have_location"] = False + + if "explanation" not in issue or not issue["explanation"]: + checks["all_have_explanation"] = False + + all_pass = all(checks.values()) + status = "pass" if all_pass else "warning" + + failed = [k for k, v in checks.items() if not v] + summary = "All issues properly structured" if all_pass else f"Issues have problems: {failed}" + + return { + "status": status, + "summary": summary, + "checks": checks, + "valid": all_pass, + "issue_count": len(issues) + } + + +def validate_score(score: int, min_val: int = 0, max_val: int = 100) -> bool: + """Validate a numeric score is in range.""" + return isinstance(score, (int, float)) and min_val <= score <= max_val + + +def validate_health_score(health_score: int) -> Dict[str, Any]: + """Validate health score and categorize.""" + if not validate_score(health_score): + return { + "status": "error", + "summary": "Invalid health score", + "valid": False + } + + if health_score >= 90: + category = "excellent" + status = "pass" + elif health_score >= 75: + category = "good" + status = "pass" + elif health_score >= 60: + category = "fair" + status = "warning" + elif health_score >= 40: + category = "poor" + status = "warning" + else: + category = "critical" + status = "critical" + + return { + "status": status, + "summary": f"{category.capitalize()} health ({health_score}/100)", + "category": category, + "valid": True, + "score": health_score + } + + +def validate_file_path(file_path: str) -> bool: + """Validate file path format.""" + from pathlib import Path + try: + path = Path(file_path) + return path.exists() + except Exception: + return False + + +def validate_best_practices_compliance(compliance: Dict[str, Dict[str, Any]]) -> Dict[str, Any]: + """Validate best practices compliance structure.""" + if not isinstance(compliance, dict): + return { + "status": "error", + "summary": "Compliance must be a dictionary", + "valid": False + } + + required_tip_fields = ["status", "score", "message"] + valid_statuses = ["pass", "fail", "warning", "info"] + + checks = { + "has_tips": len(compliance) > 0, + "all_tips_valid": True, + "valid_statuses": True, + "valid_scores": True + } + + for tip_name, tip_data in compliance.items(): + if not isinstance(tip_data, dict): + checks["all_tips_valid"] = False + continue + + for field in required_tip_fields: + if field not in tip_data: + checks["all_tips_valid"] = False + + if tip_data.get("status") not in valid_statuses: + checks["valid_statuses"] = False + + if not validate_score(tip_data.get("score", -1)): + checks["valid_scores"] = False + + all_pass = all(checks.values()) + status = "pass" if all_pass else "warning" + + return { + "status": status, + "summary": f"Validated {len(compliance)} tips", + "checks": checks, + "valid": all_pass, + "tip_count": len(compliance) + } + + +def validate_bottlenecks(bottlenecks: List[Dict[str, Any]]) -> Dict[str, Any]: + """Validate performance bottleneck list.""" + if not isinstance(bottlenecks, list): + return { + "status": "error", + "summary": "Bottlenecks must be a list", + "valid": False + } + + required_fields = ["severity", "category", "issue", "location", "explanation", "fix"] + valid_severities = ["CRITICAL", "HIGH", "MEDIUM", "LOW"] + valid_categories = ["performance", "memory", "io", "rendering"] + + checks = { + "is_list": True, + "all_have_severity": True, + "valid_severities": True, + "all_have_category": True, + "valid_categories": True, + "all_have_fix": True + } + + for bottleneck in bottlenecks: + if not isinstance(bottleneck, dict): + checks["is_list"] = False + continue + + if "severity" not in bottleneck: + checks["all_have_severity"] = False + elif bottleneck["severity"] not in valid_severities: + checks["valid_severities"] = False + + if "category" not in bottleneck: + checks["all_have_category"] = False + elif bottleneck["category"] not in valid_categories: + checks["valid_categories"] = False + + if "fix" not in bottleneck or not bottleneck["fix"]: + checks["all_have_fix"] = False + + all_pass = all(checks.values()) + status = "pass" if all_pass else "warning" + + return { + "status": status, + "summary": f"Validated {len(bottlenecks)} bottlenecks", + "checks": checks, + "valid": all_pass, + "bottleneck_count": len(bottlenecks) + } + + +def validate_architecture_analysis(result: Dict[str, Any]) -> Dict[str, Any]: + """Validate architecture analysis result.""" + required_keys = ["current_pattern", "complexity_score", "recommended_pattern", "refactoring_steps"] + + checks = {} + for key in required_keys: + checks[f"has_{key}"] = key in result and result[key] is not None + + # Validate complexity score + if "complexity_score" in result: + checks["valid_complexity_score"] = validate_score(result["complexity_score"]) + else: + checks["valid_complexity_score"] = False + + # Validate refactoring steps + if "refactoring_steps" in result: + checks["has_refactoring_steps"] = isinstance(result["refactoring_steps"], list) and len(result["refactoring_steps"]) > 0 + else: + checks["has_refactoring_steps"] = False + + all_pass = all(checks.values()) + status = "pass" if all_pass else "warning" + + return { + "status": status, + "summary": "Architecture analysis validated" if all_pass else "Architecture analysis incomplete", + "checks": checks, + "valid": all_pass + } + + +def validate_layout_fixes(fixes: List[Dict[str, Any]]) -> Dict[str, Any]: + """Validate layout fix list.""" + if not isinstance(fixes, list): + return { + "status": "error", + "summary": "Fixes must be a list", + "valid": False + } + + required_fields = ["location", "original", "fixed", "explanation"] + + checks = { + "is_list": True, + "all_have_location": True, + "all_have_explanation": True, + "all_have_fix": True + } + + for fix in fixes: + if not isinstance(fix, dict): + checks["is_list"] = False + continue + + if "location" not in fix or not fix["location"]: + checks["all_have_location"] = False + + if "explanation" not in fix or not fix["explanation"]: + checks["all_have_explanation"] = False + + if "fixed" not in fix or not fix["fixed"]: + checks["all_have_fix"] = False + + all_pass = all(checks.values()) + status = "pass" if all_pass else "warning" + + return { + "status": status, + "summary": f"Validated {len(fixes)} fixes", + "checks": checks, + "valid": all_pass, + "fix_count": len(fixes) + } + + +# Example usage +if __name__ == "__main__": + # Test validation functions + test_issues = [ + { + "severity": "CRITICAL", + "category": "performance", + "issue": "Blocking operation", + "location": "main.go:45", + "explanation": "HTTP call blocks event loop", + "fix": "Move to tea.Cmd" + } + ] + + result = validate_issue_list(test_issues) + print(f"Issue validation: {result}") + + health_result = validate_health_score(75) + print(f"Health validation: {health_result}") diff --git a/.claude/skills/bubbletea-maintenance/skills/bubbletea-maintenance/SKILL.md b/.claude/skills/bubbletea-maintenance/skills/bubbletea-maintenance/SKILL.md new file mode 100644 index 00000000..01e3899d --- /dev/null +++ b/.claude/skills/bubbletea-maintenance/skills/bubbletea-maintenance/SKILL.md @@ -0,0 +1,729 @@ +--- +name: bubbletea-maintenance +description: Expert Bubble Tea maintenance and debugging agent - diagnoses issues, applies best practices, and enhances existing Go/Bubble Tea TUI applications +--- + +# Bubble Tea Maintenance & Debugging Agent + +**Version**: 1.0.0 +**Created**: 2025-10-19 +**Type**: Maintenance & Debugging Agent +**Focus**: Existing Go/Bubble Tea TUI Applications + +--- + +## Overview + +You are an expert Bubble Tea maintenance and debugging agent specializing in diagnosing issues, applying best practices, and enhancing existing Go/Bubble Tea TUI applications. You help developers maintain, debug, and improve their terminal user interfaces built with the Bubble Tea framework. + +## When to Use This Agent + +This agent should be activated when users: +- Experience bugs or issues in existing Bubble Tea applications +- Want to optimize performance of their TUI +- Need to refactor or improve their Bubble Tea code +- Want to apply best practices to their codebase +- Are debugging layout or rendering issues +- Need help with Lipgloss styling problems +- Want to add features to existing Bubble Tea apps +- Have questions about Bubble Tea architecture patterns + +## Activation Keywords + +This agent activates on phrases like: +- "debug my bubble tea app" +- "fix this TUI issue" +- "optimize bubbletea performance" +- "why is my TUI slow" +- "refactor bubble tea code" +- "apply bubbletea best practices" +- "fix layout issues" +- "lipgloss styling problem" +- "improve my TUI" +- "bubbletea architecture help" +- "message handling issues" +- "event loop problems" +- "model tree refactoring" + +## Core Capabilities + +### 1. Issue Diagnosis + +**Function**: `diagnose_issue(code_path, description="")` + +Analyzes existing Bubble Tea code to identify common issues: + +**Common Issues Detected**: +- **Slow Event Loop**: Blocking operations in Update() or View() +- **Memory Leaks**: Unreleased resources, goroutine leaks +- **Message Ordering**: Incorrect assumptions about concurrent messages +- **Layout Arithmetic**: Hardcoded dimensions, incorrect lipgloss calculations +- **Model Architecture**: Flat models that should be hierarchical +- **Terminal Recovery**: Missing panic recovery +- **Testing Gaps**: No teatest coverage + +**Analysis Process**: +1. Parse Go code to extract Model, Update, View functions +2. Check for blocking operations in event loop +3. Identify hardcoded layout values +4. Analyze message handler patterns +5. Check for concurrent command usage +6. Validate terminal cleanup code +7. Generate diagnostic report with severity levels + +**Output Format**: +```python +{ + "issues": [ + { + "severity": "CRITICAL", # CRITICAL, WARNING, INFO + "category": "performance", + "issue": "Blocking sleep in Update() function", + "location": "main.go:45", + "explanation": "time.Sleep blocks the event loop", + "fix": "Move to tea.Cmd goroutine" + } + ], + "summary": "Found 3 critical issues, 5 warnings", + "health_score": 65 # 0-100 +} +``` + +### 2. Best Practices Validation + +**Function**: `apply_best_practices(code_path, tips_file)` + +Validates code against the 11 expert tips from `tip-bubbltea-apps.md`: + +**Tip 1: Keep Event Loop Fast** +- ✅ Check: Update() completes in < 16ms +- ✅ Check: No blocking I/O in Update() or View() +- ✅ Check: Long operations wrapped in tea.Cmd + +**Tip 2: Debug Message Dumping** +- ✅ Check: Has debug message dumping capability +- ✅ Check: Uses spew or similar for message inspection + +**Tip 3: Live Reload** +- ✅ Check: Development workflow supports live reload +- ✅ Check: Uses air or similar tools + +**Tip 4: Receiver Methods** +- ✅ Check: Appropriate use of pointer vs value receivers +- ✅ Check: Update() uses value receiver (standard pattern) + +**Tip 5: Message Ordering** +- ✅ Check: No assumptions about concurrent message order +- ✅ Check: State machine handles out-of-order messages + +**Tip 6: Model Tree** +- ✅ Check: Complex apps use hierarchical models +- ✅ Check: Child models handle their own messages + +**Tip 7: Layout Arithmetic** +- ✅ Check: Uses lipgloss.Height() and lipgloss.Width() +- ✅ Check: No hardcoded dimensions + +**Tip 8: Terminal Recovery** +- ✅ Check: Has panic recovery with tea.EnableMouseAllMotion cleanup +- ✅ Check: Restores terminal on crash + +**Tip 9: Testing with teatest** +- ✅ Check: Has teatest test coverage +- ✅ Check: Tests key interactions + +**Tip 10: VHS Demos** +- ✅ Check: Has VHS demo files for documentation + +**Output Format**: +```python +{ + "compliance": { + "tip_1_fast_event_loop": {"status": "pass", "score": 100}, + "tip_2_debug_dumping": {"status": "fail", "score": 0}, + "tip_3_live_reload": {"status": "warning", "score": 50}, + # ... all 11 tips + }, + "overall_score": 75, + "recommendations": [ + "Add debug message dumping capability", + "Replace hardcoded dimensions with lipgloss calculations" + ] +} +``` + +### 3. Performance Debugging + +**Function**: `debug_performance(code_path, profile_data="")` + +Identifies performance bottlenecks in Bubble Tea applications: + +**Analysis Areas**: +1. **Event Loop Profiling** + - Measure Update() execution time + - Identify slow message handlers + - Check for blocking operations + +2. **View Rendering** + - Measure View() execution time + - Identify expensive string operations + - Check for unnecessary re-renders + +3. **Memory Allocation** + - Identify allocation hotspots + - Check for string concatenation issues + - Validate efficient use of strings.Builder + +4. **Concurrent Commands** + - Check for goroutine leaks + - Validate proper command cleanup + - Identify race conditions + +**Output Format**: +```python +{ + "bottlenecks": [ + { + "function": "Update", + "location": "main.go:67", + "time_ms": 45, + "threshold_ms": 16, + "issue": "HTTP request blocks event loop", + "fix": "Move to tea.Cmd goroutine" + } + ], + "metrics": { + "avg_update_time": "12ms", + "avg_view_time": "3ms", + "memory_allocations": 1250, + "goroutines": 8 + }, + "recommendations": [ + "Move HTTP calls to background commands", + "Use strings.Builder for View() composition", + "Cache expensive lipgloss styles" + ] +} +``` + +### 4. Architecture Suggestions + +**Function**: `suggest_architecture(code_path, complexity_level)` + +Recommends architectural improvements for Bubble Tea applications: + +**Pattern Recognition**: +1. **Flat Model → Model Tree** + - Detect when single model becomes too complex + - Suggest splitting into child models + - Provide refactoring template + +2. **Single View → Multi-View** + - Identify state-based view switching + - Suggest view router pattern + - Provide navigation template + +3. **Monolithic → Composable** + - Detect tight coupling + - Suggest component extraction + - Provide composable model pattern + +**Refactoring Templates**: + +**Model Tree Pattern**: +```go +type ParentModel struct { + activeView int + listModel list.Model + formModel form.Model + viewerModel viewer.Model +} + +func (m ParentModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + // Route to active child + switch m.activeView { + case 0: + m.listModel, cmd = m.listModel.Update(msg) + case 1: + m.formModel, cmd = m.formModel.Update(msg) + case 2: + m.viewerModel, cmd = m.viewerModel.Update(msg) + } + + return m, cmd +} +``` + +**Output Format**: +```python +{ + "current_pattern": "flat_model", + "complexity_score": 85, # 0-100, higher = more complex + "recommended_pattern": "model_tree", + "refactoring_steps": [ + "Extract list functionality to separate model", + "Extract form functionality to separate model", + "Create parent router model", + "Implement message routing" + ], + "code_templates": { + "parent_model": "...", + "child_models": "...", + "message_routing": "..." + } +} +``` + +### 5. Layout Issue Fixes + +**Function**: `fix_layout_issues(code_path, description="")` + +Diagnoses and fixes common Lipgloss layout problems: + +**Common Layout Issues**: + +1. **Hardcoded Dimensions** + ```go + // ❌ BAD + content := lipgloss.NewStyle().Width(80).Height(24).Render(text) + + // ✅ GOOD + termWidth, termHeight, _ := term.GetSize(int(os.Stdout.Fd())) + content := lipgloss.NewStyle(). + Width(termWidth). + Height(termHeight - 2). // Leave room for status bar + Render(text) + ``` + +2. **Incorrect Height Calculation** + ```go + // ❌ BAD + availableHeight := 24 - 3 // Hardcoded + + // ✅ GOOD + statusBarHeight := lipgloss.Height(m.renderStatusBar()) + availableHeight := m.termHeight - statusBarHeight + ``` + +3. **Missing Margin/Padding Accounting** + ```go + // ❌ BAD + content := lipgloss.NewStyle(). + Padding(2). + Width(80). + Render(text) // Text area is 76, not 80! + + // ✅ GOOD + style := lipgloss.NewStyle().Padding(2) + contentWidth := 80 - style.GetHorizontalPadding() + content := style.Width(80).Render( + lipgloss.NewStyle().Width(contentWidth).Render(text) + ) + ``` + +4. **Overflow Issues** + ```go + // ❌ BAD + content := longText // Can exceed terminal width + + // ✅ GOOD + import "github.com/muesli/reflow/wordwrap" + content := wordwrap.String(longText, m.termWidth) + ``` + +**Output Format**: +```python +{ + "layout_issues": [ + { + "type": "hardcoded_dimensions", + "location": "main.go:89", + "current_code": "Width(80).Height(24)", + "fixed_code": "Width(m.termWidth).Height(m.termHeight - statusHeight)", + "explanation": "Terminal size may vary, use dynamic sizing" + } + ], + "lipgloss_improvements": [ + "Use GetHorizontalPadding() for nested styles", + "Calculate available space with lipgloss.Height()", + "Handle terminal resize with tea.WindowSizeMsg" + ] +} +``` + +### 6. Comprehensive Analysis + +**Function**: `comprehensive_bubbletea_analysis(code_path)` + +Performs complete health check of Bubble Tea application: + +**Analysis Sections**: +1. Issue diagnosis (from diagnose_issue) +2. Best practices compliance (from apply_best_practices) +3. Performance analysis (from debug_performance) +4. Architecture recommendations (from suggest_architecture) +5. Layout validation (from fix_layout_issues) + +**Output Format**: +```python +{ + "overall_health": 78, # 0-100 + "sections": { + "issues": {...}, + "best_practices": {...}, + "performance": {...}, + "architecture": {...}, + "layout": {...} + }, + "summary": "Application is in good health with minor performance improvements needed", + "priority_fixes": [ + "CRITICAL: Fix blocking operation in Update() (main.go:45)", + "WARNING: Add terminal recovery code", + "INFO: Consider refactoring to model tree pattern" + ], + "estimated_fix_time": "2-4 hours" +} +``` + +## Workflow for Users + +### Typical Usage Flow: + +1. **User reports issue**: "My TUI is slow when typing" +2. **Agent diagnoses**: Runs `debug_performance()` to identify bottleneck +3. **Agent explains**: "Found blocking regex operation in Update() taking 45ms" +4. **Agent fixes**: Provides code to move operation to tea.Cmd +5. **Agent validates**: Runs `comprehensive_analysis()` to confirm fix + +### Example Session: + +``` +User: "My log viewer is lagging when I scroll" + +Agent: Let me analyze your code... + +[Runs debug_performance()] + +Found the issue! Your View() function is: +1. Re-reading the entire log file on every render (200ms) +2. Processing all lines instead of visible viewport + +Here's the fix: +- Cache log lines in model, only update when file changes +- Use viewport.Model to handle scrolling efficiently +- Only render visible lines (viewport.YOffset to YOffset + Height) + +[Provides code diff] + +This should reduce render time from 200ms to ~2ms. +``` + +## Technical Knowledge Base + +### Bubble Tea Architecture + +**The Elm Architecture**: +``` +┌─────────────┐ +│ Model │ ← Your application state +└─────────────┘ + ↓ +┌─────────────┐ +│ Update │ ← Message handler (events → state changes) +└─────────────┘ + ↓ +┌─────────────┐ +│ View │ ← Render function (state → string) +└─────────────┘ + ↓ + Terminal +``` + +**Event Loop**: +```go +1. User presses key → tea.KeyMsg +2. Update(tea.KeyMsg) → new model + tea.Cmd +3. tea.Cmd executes → returns new msg +4. Update(new msg) → new model +5. View() renders new model → terminal +``` + +**Performance Rule**: Update() and View() must be FAST (<16ms for 60fps) + +### Common Patterns + +**1. Loading Data Pattern**: +```go +type model struct { + loading bool + data []string + err error +} + +func loadData() tea.Msg { + // This runs in goroutine, not in event loop + data, err := fetchData() + return dataLoadedMsg{data: data, err: err} +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "r" { + m.loading = true + return m, loadData // Return command, don't block + } + case dataLoadedMsg: + m.loading = false + m.data = msg.data + m.err = msg.err + } + return m, nil +} +``` + +**2. Model Tree Pattern**: +```go +type appModel struct { + activeView int + + // Child models manage themselves + listView listModel + detailView detailModel + searchView searchModel +} + +func (m appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // Global keys (navigation) + if key, ok := msg.(tea.KeyMsg); ok { + switch key.String() { + case "1": m.activeView = 0; return m, nil + case "2": m.activeView = 1; return m, nil + case "3": m.activeView = 2; return m, nil + } + } + + // Route to active child + var cmd tea.Cmd + switch m.activeView { + case 0: + m.listView, cmd = m.listView.Update(msg) + case 1: + m.detailView, cmd = m.detailView.Update(msg) + case 2: + m.searchView, cmd = m.searchView.Update(msg) + } + + return m, cmd +} + +func (m appModel) View() string { + switch m.activeView { + case 0: return m.listView.View() + case 1: return m.detailView.View() + case 2: return m.searchView.View() + } + return "" +} +``` + +**3. Message Passing Between Models**: +```go +type itemSelectedMsg struct { + itemID string +} + +// Parent routes message to all children +func (m appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case itemSelectedMsg: + // List sent this, detail needs to know + m.detailView.LoadItem(msg.itemID) + m.activeView = 1 // Switch to detail view + } + + // Update all children + var cmds []tea.Cmd + m.listView, cmd := m.listView.Update(msg) + cmds = append(cmds, cmd) + m.detailView, cmd = m.detailView.Update(msg) + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} +``` + +**4. Dynamic Layout Pattern**: +```go +func (m model) View() string { + // Always use current terminal size + headerHeight := lipgloss.Height(m.renderHeader()) + footerHeight := lipgloss.Height(m.renderFooter()) + + availableHeight := m.termHeight - headerHeight - footerHeight + + content := lipgloss.NewStyle(). + Width(m.termWidth). + Height(availableHeight). + Render(m.renderContent()) + + return lipgloss.JoinVertical( + lipgloss.Left, + m.renderHeader(), + content, + m.renderFooter(), + ) +} +``` + +## Integration with Local Resources + +This agent uses local knowledge sources: + +### Primary Reference +**`/Users/williamvansickleiii/charmtuitemplate/charm-tui-template/tip-bubbltea-apps.md`** +- 11 expert tips from leg100.github.io +- Core best practices validation + +### Example Codebases +**`/Users/williamvansickleiii/charmtuitemplate/vinw/`** +- Real-world Bubble Tea application +- Pattern examples + +**`/Users/williamvansickleiii/charmtuitemplate/charm-examples-inventory/`** +- Collection of Charm examples +- Component usage patterns + +### Styling Reference +**`/Users/williamvansickleiii/charmtuitemplate/charm-tui-template/lipgloss-readme.md`** +- Lipgloss API documentation +- Styling patterns + +## Troubleshooting Guide + +### Issue: Slow/Laggy TUI +**Diagnosis Steps**: +1. Profile Update() execution time +2. Profile View() execution time +3. Check for blocking I/O +4. Check for expensive string operations + +**Common Fixes**: +- Move I/O to tea.Cmd goroutines +- Use strings.Builder in View() +- Cache expensive lipgloss styles +- Reduce re-renders with smart diffing + +### Issue: Terminal Gets Messed Up +**Diagnosis Steps**: +1. Check for panic recovery +2. Check for tea.EnableMouseAllMotion cleanup +3. Validate proper program.Run() usage + +**Fix Template**: +```go +func main() { + defer func() { + if r := recover(); r != nil { + // Restore terminal + tea.DisableMouseAllMotion() + tea.ShowCursor() + fmt.Println("Panic:", r) + os.Exit(1) + } + }() + + p := tea.NewProgram(initialModel()) + if err := p.Start(); err != nil { + fmt.Println("Error:", err) + os.Exit(1) + } +} +``` + +### Issue: Layout Overflow/Clipping +**Diagnosis Steps**: +1. Check for hardcoded dimensions +2. Check lipgloss padding/margin accounting +3. Verify terminal resize handling + +**Fix Checklist**: +- [ ] Use dynamic terminal size from tea.WindowSizeMsg +- [ ] Use lipgloss.Height() and lipgloss.Width() for calculations +- [ ] Account for padding with GetHorizontalPadding()/GetVerticalPadding() +- [ ] Use wordwrap for long text +- [ ] Test with small terminal sizes + +### Issue: Messages Arriving Out of Order +**Diagnosis Steps**: +1. Check for concurrent tea.Cmd usage +2. Check for state assumptions about message order +3. Validate state machine handles any order + +**Fix**: +- Use state machine with explicit states +- Don't assume operation A completes before B +- Use message types to track operation identity + +```go +type model struct { + operations map[string]bool // Track concurrent ops +} + +type operationStartMsg struct { id string } +type operationDoneMsg struct { id string, result string } + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case operationStartMsg: + m.operations[msg.id] = true + case operationDoneMsg: + delete(m.operations, msg.id) + // Handle result + } + return m, nil +} +``` + +## Validation and Quality Checks + +After applying fixes, the agent validates: +1. ✅ Code compiles successfully +2. ✅ No new issues introduced +3. ✅ Performance improved (if applicable) +4. ✅ Best practices compliance increased +5. ✅ Tests pass (if present) + +## Limitations + +This agent focuses on maintenance and debugging, NOT: +- Designing new TUIs from scratch (use bubbletea-designer for that) +- Non-Bubble Tea Go code +- Terminal emulator issues +- Operating system specific problems + +## Success Metrics + +A successful maintenance session results in: +- ✅ Issue identified and explained clearly +- ✅ Fix provided with code examples +- ✅ Best practices applied +- ✅ Performance improved (if applicable) +- ✅ User understands the fix and can apply it + +## Version History + +**v1.0.0** (2025-10-19) +- Initial release +- 6 core analysis functions +- Integration with tip-bubbltea-apps.md +- Comprehensive diagnostic capabilities +- Layout issue detection and fixing +- Performance profiling +- Architecture recommendations + +--- + +**Built with Claude Code agent-creator on 2025-10-19** diff --git a/.claude/skills/bubbletea-maintenance/skills/bubbletea-maintenance/references/common_issues.md b/.claude/skills/bubbletea-maintenance/skills/bubbletea-maintenance/references/common_issues.md new file mode 100644 index 00000000..12d5365d --- /dev/null +++ b/.claude/skills/bubbletea-maintenance/skills/bubbletea-maintenance/references/common_issues.md @@ -0,0 +1,567 @@ +# Common Bubble Tea Issues and Solutions + +Reference guide for diagnosing and fixing common problems in Bubble Tea applications. + +## Performance Issues + +### Issue: Slow/Laggy UI + +**Symptoms:** +- UI freezes when typing +- Delayed response to key presses +- Stuttering animations + +**Common Causes:** + +1. **Blocking Operations in Update()** + ```go + // ❌ BAD + func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + data := http.Get("https://api.example.com") // BLOCKS! + m.data = data + } + return m, nil + } + + // ✅ GOOD + func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + return m, fetchDataCmd // Non-blocking + case dataFetchedMsg: + m.data = msg.data + } + return m, nil + } + + func fetchDataCmd() tea.Msg { + data := http.Get("https://api.example.com") // Runs in goroutine + return dataFetchedMsg{data: data} + } + ``` + +2. **Heavy Processing in View()** + ```go + // ❌ BAD + func (m model) View() string { + content, _ := os.ReadFile("large_file.txt") // EVERY RENDER! + return string(content) + } + + // ✅ GOOD + type model struct { + cachedContent string + } + + func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case fileLoadedMsg: + m.cachedContent = msg.content // Cache it + } + return m, nil + } + + func (m model) View() string { + return m.cachedContent // Just return cached data + } + ``` + +3. **String Concatenation with +** + ```go + // ❌ BAD - Allocates many temp strings + func (m model) View() string { + s := "" + for _, line := range m.lines { + s += line + "\\n" // Expensive! + } + return s + } + + // ✅ GOOD - Single allocation + func (m model) View() string { + var b strings.Builder + for _, line := range m.lines { + b.WriteString(line) + b.WriteString("\\n") + } + return b.String() + } + ``` + +**Performance Target:** Update() should complete in <16ms (60 FPS) + +--- + +## Layout Issues + +### Issue: Content Overflows Terminal + +**Symptoms:** +- Text wraps unexpectedly +- Content gets clipped +- Layout breaks on different terminal sizes + +**Common Causes:** + +1. **Hardcoded Dimensions** + ```go + // ❌ BAD + content := lipgloss.NewStyle(). + Width(80). // What if terminal is 120 wide? + Height(24). // What if terminal is 40 tall? + Render(text) + + // ✅ GOOD + type model struct { + termWidth int + termHeight int + } + + func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.termWidth = msg.Width + m.termHeight = msg.Height + } + return m, nil + } + + func (m model) View() string { + content := lipgloss.NewStyle(). + Width(m.termWidth). + Height(m.termHeight - 2). // Leave room for status bar + Render(text) + return content + } + ``` + +2. **Not Accounting for Padding/Borders** + ```go + // ❌ BAD + style := lipgloss.NewStyle(). + Padding(2). + Border(lipgloss.RoundedBorder()). + Width(80) + content := style.Render(text) + // Text area is 76 (80 - 2*2 padding), NOT 80! + + // ✅ GOOD + style := lipgloss.NewStyle(). + Padding(2). + Border(lipgloss.RoundedBorder()) + + contentWidth := 80 - style.GetHorizontalPadding() - style.GetHorizontalBorderSize() + innerContent := lipgloss.NewStyle().Width(contentWidth).Render(text) + result := style.Width(80).Render(innerContent) + ``` + +3. **Manual Height Calculations** + ```go + // ❌ BAD - Magic numbers + availableHeight := 24 - 3 // Where did 3 come from? + + // ✅ GOOD - Calculated + headerHeight := lipgloss.Height(m.renderHeader()) + footerHeight := lipgloss.Height(m.renderFooter()) + availableHeight := m.termHeight - headerHeight - footerHeight + ``` + +--- + +## Message Handling Issues + +### Issue: Messages Arrive Out of Order + +**Symptoms:** +- State becomes inconsistent +- Operations complete in wrong order +- Race conditions + +**Cause:** Concurrent tea.Cmd messages aren't guaranteed to arrive in order + +**Solution: Use State Tracking** + +```go +// ❌ BAD - Assumes order +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "r" { + return m, tea.Batch( + fetchUsersCmd, // Might complete second + fetchPostsCmd, // Might complete first + ) + } + case usersLoadedMsg: + m.users = msg.users + case postsLoadedMsg: + m.posts = msg.posts + // Assumes users are loaded! May not be! + } + return m, nil +} + +// ✅ GOOD - Track operations +type model struct { + operations map[string]bool + users []User + posts []Post +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "r" { + m.operations["users"] = true + m.operations["posts"] = true + return m, tea.Batch(fetchUsersCmd, fetchPostsCmd) + } + case usersLoadedMsg: + m.users = msg.users + delete(m.operations, "users") + return m, m.checkAllLoaded() + case postsLoadedMsg: + m.posts = msg.posts + delete(m.operations, "posts") + return m, m.checkAllLoaded() + } + return m, nil +} + +func (m model) checkAllLoaded() tea.Cmd { + if len(m.operations) == 0 { + // All operations complete, can proceed + return m.processData + } + return nil +} +``` + +--- + +## Terminal Recovery Issues + +### Issue: Terminal Gets Messed Up After Crash + +**Symptoms:** +- Cursor disappears +- Mouse mode still active +- Terminal looks corrupted + +**Solution: Add Panic Recovery** + +```go +func main() { + defer func() { + if r := recover(); r != nil { + // Restore terminal state + tea.DisableMouseAllMotion() + tea.ShowCursor() + fmt.Printf("Panic: %v\\n", r) + debug.PrintStack() + os.Exit(1) + } + }() + + p := tea.NewProgram(initialModel()) + if err := p.Start(); err != nil { + fmt.Printf("Error: %v\\n", err) + os.Exit(1) + } +} +``` + +--- + +## Architecture Issues + +### Issue: Model Too Complex + +**Symptoms:** +- Model struct has 20+ fields +- Update() is hundreds of lines +- Hard to maintain + +**Solution: Use Model Tree Pattern** + +```go +// ❌ BAD - Flat model +type model struct { + // List view fields + listItems []string + listCursor int + listFilter string + + // Detail view fields + detailItem string + detailHTML string + detailScroll int + + // Search view fields + searchQuery string + searchResults []string + searchCursor int + + // ... 15 more fields +} + +// ✅ GOOD - Model tree +type appModel struct { + activeView int + listView listViewModel + detailView detailViewModel + searchView searchViewModel +} + +type listViewModel struct { + items []string + cursor int + filter string +} + +func (m listViewModel) Update(msg tea.Msg) (listViewModel, tea.Cmd) { + // Only handles list-specific messages + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "up": + m.cursor-- + case "down": + m.cursor++ + case "enter": + return m, func() tea.Msg { + return itemSelectedMsg{itemID: m.items[m.cursor]} + } + } + } + return m, nil +} + +// Parent routes messages +func (m appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // Handle global messages + switch msg := msg.(type) { + case itemSelectedMsg: + m.detailView.LoadItem(msg.itemID) + m.activeView = 1 // Switch to detail + return m, nil + } + + // Route to active child + var cmd tea.Cmd + switch m.activeView { + case 0: + m.listView, cmd = m.listView.Update(msg) + case 1: + m.detailView, cmd = m.detailView.Update(msg) + case 2: + m.searchView, cmd = m.searchView.Update(msg) + } + + return m, cmd +} +``` + +--- + +## Memory Issues + +### Issue: Memory Leak / Growing Memory Usage + +**Symptoms:** +- Memory usage increases over time +- Never gets garbage collected + +**Common Causes:** + +1. **Goroutine Leaks** + ```go + // ❌ BAD - Goroutines never stop + func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "s" { + return m, func() tea.Msg { + go func() { + for { // INFINITE LOOP! + time.Sleep(time.Second) + // Do something + } + }() + return nil + } + } + } + return m, nil + } + + // ✅ GOOD - Use context for cancellation + type model struct { + ctx context.Context + cancel context.CancelFunc + } + + func initialModel() model { + ctx, cancel := context.WithCancel(context.Background()) + return model{ctx: ctx, cancel: cancel} + } + + func worker(ctx context.Context) tea.Msg { + for { + select { + case <-ctx.Done(): + return nil // Stop gracefully + case <-time.After(time.Second): + // Do work + } + } + } + + func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "q" { + m.cancel() // Stop all workers + return m, tea.Quit + } + } + return m, nil + } + ``` + +2. **Unreleased Resources** + ```go + // ❌ BAD + func loadFile() tea.Msg { + file, _ := os.Open("data.txt") + // Never closed! + data, _ := io.ReadAll(file) + return dataMsg{data: data} + } + + // ✅ GOOD + func loadFile() tea.Msg { + file, err := os.Open("data.txt") + if err != nil { + return errorMsg{err: err} + } + defer file.Close() // Always close + + data, err := io.ReadAll(file) + return dataMsg{data: data, err: err} + } + ``` + +--- + +## Testing Issues + +### Issue: Hard to Test TUI + +**Solution: Use teatest** + +```go +import ( + "testing" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/bubbletea/teatest" +) + +func TestNavigation(t *testing.T) { + m := initialModel() + + // Create test program + tm := teatest.NewTestModel(t, m) + + // Send key presses + tm.Send(tea.KeyMsg{Type: tea.KeyDown}) + tm.Send(tea.KeyMsg{Type: tea.KeyDown}) + + // Wait for program to process + teatest.WaitFor( + t, tm.Output(), + func(bts []byte) bool { + return bytes.Contains(bts, []byte("Item 2")) + }, + teatest.WithCheckInterval(time.Millisecond*100), + teatest.WithDuration(time.Second*3), + ) + + // Verify state + finalModel := tm.FinalModel(t).(model) + if finalModel.cursor != 2 { + t.Errorf("Expected cursor at 2, got %d", finalModel.cursor) + } +} +``` + +--- + +## Debugging Tips + +### Enable Message Dumping + +```go +import "github.com/davecgh/go-spew/spew" + +type model struct { + dump io.Writer +} + +func main() { + // Create debug file + f, _ := os.Create("debug.log") + defer f.Close() + + m := model{dump: f} + p := tea.NewProgram(m) + p.Start() +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // Dump every message + if m.dump != nil { + spew.Fdump(m.dump, msg) + } + + // ... rest of Update() + return m, nil +} +``` + +### Live Reload with Air + +`.air.toml`: +```toml +[build] + cmd = "go build -o ./tmp/main ." + bin = "tmp/main" + include_ext = ["go"] + exclude_dir = ["tmp"] + delay = 1000 +``` + +Run: `air` + +--- + +## Quick Checklist + +Before deploying your Bubble Tea app: + +- [ ] No blocking operations in Update() or View() +- [ ] Terminal resize handled (tea.WindowSizeMsg) +- [ ] Panic recovery with terminal cleanup +- [ ] Dynamic layout (no hardcoded dimensions) +- [ ] Lipgloss padding/borders accounted for +- [ ] String operations use strings.Builder +- [ ] Goroutines have cancellation (context) +- [ ] Resources properly closed (defer) +- [ ] State machine handles message ordering +- [ ] Tests with teatest for key interactions + +--- + +**Generated for Bubble Tea Maintenance Agent v1.0.0** diff --git a/.claude/skills/bubbletea-maintenance/tests/test_diagnose_issue.py b/.claude/skills/bubbletea-maintenance/tests/test_diagnose_issue.py new file mode 100644 index 00000000..1f90a500 --- /dev/null +++ b/.claude/skills/bubbletea-maintenance/tests/test_diagnose_issue.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +""" +Tests for diagnose_issue.py +""" + +import sys +from pathlib import Path + +# Add scripts to path +sys.path.insert(0, str(Path(__file__).parent.parent / 'scripts')) + +from diagnose_issue import diagnose_issue, _check_blocking_operations, _check_hardcoded_dimensions + + +def test_diagnose_issue_basic(): + """Test basic issue diagnosis.""" + print("\n✓ Testing diagnose_issue()...") + + # Create test Go file + test_code = ''' +package main + +import tea "github.com/charmbracelet/bubbletea" + +type model struct { + width int + height int +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + return m, nil +} + +func (m model) View() string { + return "Hello" +} +''' + + test_file = Path("/tmp/test_bubbletea_app.go") + test_file.write_text(test_code) + + result = diagnose_issue(str(test_file)) + + assert 'issues' in result, "Missing 'issues' key" + assert 'health_score' in result, "Missing 'health_score' key" + assert 'summary' in result, "Missing 'summary' key" + assert isinstance(result['issues'], list), "Issues should be a list" + assert isinstance(result['health_score'], int), "Health score should be int" + + print(f" ✓ Found {len(result['issues'])} issue(s)") + print(f" ✓ Health score: {result['health_score']}/100") + + # Cleanup + test_file.unlink() + + return True + + +def test_blocking_operations_detection(): + """Test detection of blocking operations.""" + print("\n✓ Testing blocking operation detection...") + + test_code = ''' +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + data, _ := http.Get("https://example.com") // BLOCKING! + m.data = data + } + return m, nil +} +''' + + lines = test_code.split('\n') + issues = _check_blocking_operations(test_code, lines, "test.go") + + assert len(issues) > 0, "Should detect blocking HTTP request" + assert issues[0]['severity'] == 'CRITICAL', "Should be CRITICAL severity" + assert 'HTTP request' in issues[0]['issue'], "Should identify HTTP as issue" + + print(f" ✓ Detected {len(issues)} blocking operation(s)") + print(f" ✓ Severity: {issues[0]['severity']}") + + return True + + +def test_hardcoded_dimensions_detection(): + """Test detection of hardcoded dimensions.""" + print("\n✓ Testing hardcoded dimensions detection...") + + test_code = ''' +func (m model) View() string { + content := lipgloss.NewStyle(). + Width(80). + Height(24). + Render(m.content) + return content +} +''' + + lines = test_code.split('\n') + issues = _check_hardcoded_dimensions(test_code, lines, "test.go") + + assert len(issues) >= 2, "Should detect both Width and Height" + assert any('Width' in i['issue'] for i in issues), "Should detect hardcoded Width" + assert any('Height' in i['issue'] for i in issues), "Should detect hardcoded Height" + + print(f" ✓ Detected {len(issues)} hardcoded dimension(s)") + + return True + + +def test_no_issues_clean_code(): + """Test with clean code that has no issues.""" + print("\n✓ Testing with clean code...") + + test_code = ''' +package main + +import tea "github.com/charmbracelet/bubbletea" + +type model struct { + termWidth int + termHeight int +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.termWidth = msg.Width + m.termHeight = msg.Height + case tea.KeyMsg: + return m, fetchDataCmd // Non-blocking + } + return m, nil +} + +func (m model) View() string { + return lipgloss.NewStyle(). + Width(m.termWidth). + Height(m.termHeight). + Render("Clean!") +} + +func fetchDataCmd() tea.Msg { + // Runs in background + return dataMsg{} +} +''' + + test_file = Path("/tmp/test_clean_app.go") + test_file.write_text(test_code) + + result = diagnose_issue(str(test_file)) + + assert result['health_score'] >= 80, "Clean code should have high health score" + print(f" ✓ Health score: {result['health_score']}/100 (expected >=80)") + + # Cleanup + test_file.unlink() + + return True + + +def test_invalid_path(): + """Test with invalid file path.""" + print("\n✓ Testing with invalid path...") + + result = diagnose_issue("/nonexistent/path/file.go") + + assert 'error' in result, "Should return error for invalid path" + assert result['validation']['status'] == 'error', "Validation should be error" + + print(" ✓ Correctly handled invalid path") + + return True + + +def main(): + """Run all tests.""" + print("="*70) + print("UNIT TESTS - diagnose_issue.py") + print("="*70) + + tests = [ + ("Basic diagnosis", test_diagnose_issue_basic), + ("Blocking operations", test_blocking_operations_detection), + ("Hardcoded dimensions", test_hardcoded_dimensions_detection), + ("Clean code", test_no_issues_clean_code), + ("Invalid path", test_invalid_path), + ] + + results = [] + for test_name, test_func in tests: + try: + passed = test_func() + results.append((test_name, passed)) + except Exception as e: + print(f"\n ❌ FAILED: {e}") + import traceback + traceback.print_exc() + results.append((test_name, False)) + + # Summary + print("\n" + "="*70) + print("SUMMARY") + print("="*70) + + for test_name, passed in results: + status = "✅ PASS" if passed else "❌ FAIL" + print(f"{status}: {test_name}") + + passed_count = sum(1 for _, p in results if p) + total_count = len(results) + + print(f"\nResults: {passed_count}/{total_count} passed") + + return passed_count == total_count + + +if __name__ == "__main__": + success = main() + sys.exit(0 if success else 1) diff --git a/.claude/skills/bubbletea-maintenance/tests/test_integration.py b/.claude/skills/bubbletea-maintenance/tests/test_integration.py new file mode 100644 index 00000000..4649d1ad --- /dev/null +++ b/.claude/skills/bubbletea-maintenance/tests/test_integration.py @@ -0,0 +1,350 @@ +#!/usr/bin/env python3 +""" +Integration tests for Bubble Tea Maintenance Agent. +Tests complete workflows combining multiple functions. +""" + +import sys +from pathlib import Path + +# Add scripts to path +sys.path.insert(0, str(Path(__file__).parent.parent / 'scripts')) + +from diagnose_issue import diagnose_issue +from apply_best_practices import apply_best_practices +from debug_performance import debug_performance +from suggest_architecture import suggest_architecture +from fix_layout_issues import fix_layout_issues +from comprehensive_bubbletea_analysis import comprehensive_bubbletea_analysis + + +# Test fixture: Complete Bubble Tea app +TEST_APP_CODE = ''' +package main + +import ( + "fmt" + "net/http" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type model struct { + items []string + cursor int + data string +} + +func initialModel() model { + return model{ + items: []string{"Item 1", "Item 2", "Item 3"}, + cursor: 0, + } +} + +func (m model) Init() tea.Cmd { + return nil +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q": + return m, tea.Quit + case "up": + if m.cursor > 0 { + m.cursor-- + } + case "down": + if m.cursor < len(m.items)-1 { + m.cursor++ + } + case "r": + // ISSUE: Blocking HTTP request! + resp, _ := http.Get("https://example.com") + m.data = resp.Status + } + } + return m, nil +} + +func (m model) View() string { + // ISSUE: Hardcoded dimensions + style := lipgloss.NewStyle(). + Width(80). + Height(24) + + s := "Select an item:\\n\\n" + for i, item := range m.items { + cursor := " " + if m.cursor == i { + cursor = ">" + } + // ISSUE: String concatenation + s += fmt.Sprintf("%s %s\\n", cursor, item) + } + + return style.Render(s) +} + +func main() { + // ISSUE: No panic recovery! + p := tea.NewProgram(initialModel()) + p.Start() +} +''' + + +def test_full_workflow(): + """Test complete analysis workflow.""" + print("\n✓ Testing complete analysis workflow...") + + # Create test app + test_dir = Path("/tmp/test_bubbletea_app") + test_dir.mkdir(exist_ok=True) + test_file = test_dir / "main.go" + test_file.write_text(TEST_APP_CODE) + + # Run comprehensive analysis + result = comprehensive_bubbletea_analysis(str(test_dir), detail_level="standard") + + # Validations + assert 'overall_health' in result, "Missing overall_health" + assert 'sections' in result, "Missing sections" + assert 'priority_fixes' in result, "Missing priority_fixes" + assert 'summary' in result, "Missing summary" + + # Check each section + sections = result['sections'] + assert 'issues' in sections, "Missing issues section" + assert 'best_practices' in sections, "Missing best_practices section" + assert 'performance' in sections, "Missing performance section" + assert 'architecture' in sections, "Missing architecture section" + assert 'layout' in sections, "Missing layout section" + + # Should find issues in test code + assert len(result.get('priority_fixes', [])) > 0, "Should find priority fixes" + + health = result['overall_health'] + assert 0 <= health <= 100, f"Health score {health} out of range" + + print(f" ✓ Overall health: {health}/100") + print(f" ✓ Sections analyzed: {len(sections)}") + print(f" ✓ Priority fixes: {len(result['priority_fixes'])}") + + # Cleanup + test_file.unlink() + test_dir.rmdir() + + return True + + +def test_issue_diagnosis_finds_problems(): + """Test that diagnosis finds the known issues.""" + print("\n✓ Testing issue diagnosis...") + + test_dir = Path("/tmp/test_diagnosis") + test_dir.mkdir(exist_ok=True) + test_file = test_dir / "main.go" + test_file.write_text(TEST_APP_CODE) + + result = diagnose_issue(str(test_dir)) + + # Should find: + # 1. Blocking HTTP request in Update() + # 2. Hardcoded dimensions (80, 24) + # (Note: Not all detections may trigger depending on pattern matching) + + issues = result.get('issues', []) + assert len(issues) >= 1, f"Expected at least 1 issue, found {len(issues)}" + + # Check that HTTP blocking issue was found + issue_texts = ' '.join([i['issue'] for i in issues]) + assert 'HTTP' in issue_texts or 'http' in issue_texts.lower(), "Should find HTTP blocking issue" + + print(f" ✓ Found {len(issues)} issue(s)") + print(f" ✓ Health score: {result['health_score']}/100") + + # Cleanup + test_file.unlink() + test_dir.rmdir() + + return True + + +def test_performance_finds_bottlenecks(): + """Test that performance analysis finds bottlenecks.""" + print("\n✓ Testing performance analysis...") + + test_dir = Path("/tmp/test_performance") + test_dir.mkdir(exist_ok=True) + test_file = test_dir / "main.go" + test_file.write_text(TEST_APP_CODE) + + result = debug_performance(str(test_dir)) + + # Should find: + # 1. Blocking HTTP in Update() + # (Other bottlenecks may be detected depending on patterns) + + bottlenecks = result.get('bottlenecks', []) + assert len(bottlenecks) >= 1, f"Expected at least 1 bottleneck, found {len(bottlenecks)}" + + # Check for critical bottlenecks + critical = [b for b in bottlenecks if b['severity'] == 'CRITICAL'] + assert len(critical) > 0, "Should find CRITICAL bottlenecks" + + print(f" ✓ Found {len(bottlenecks)} bottleneck(s)") + print(f" ✓ Critical: {len(critical)}") + + # Cleanup + test_file.unlink() + test_dir.rmdir() + + return True + + +def test_layout_finds_issues(): + """Test that layout analysis finds issues.""" + print("\n✓ Testing layout analysis...") + + test_dir = Path("/tmp/test_layout") + test_dir.mkdir(exist_ok=True) + test_file = test_dir / "main.go" + test_file.write_text(TEST_APP_CODE) + + result = fix_layout_issues(str(test_dir)) + + # Should find: + # 1. Hardcoded dimensions or missing resize handling + + layout_issues = result.get('layout_issues', []) + assert len(layout_issues) >= 1, f"Expected at least 1 layout issue, found {len(layout_issues)}" + + # Check for layout-related issues + issue_types = [i['type'] for i in layout_issues] + has_layout_issue = any(t in ['hardcoded_dimensions', 'missing_resize_handling'] for t in issue_types) + assert has_layout_issue, "Should find layout issues" + + print(f" ✓ Found {len(layout_issues)} layout issue(s)") + + # Cleanup + test_file.unlink() + test_dir.rmdir() + + return True + + +def test_architecture_analysis(): + """Test architecture pattern detection.""" + print("\n✓ Testing architecture analysis...") + + test_dir = Path("/tmp/test_arch") + test_dir.mkdir(exist_ok=True) + test_file = test_dir / "main.go" + test_file.write_text(TEST_APP_CODE) + + result = suggest_architecture(str(test_dir)) + + # Should detect pattern and provide recommendations + assert 'current_pattern' in result, "Missing current_pattern" + assert 'complexity_score' in result, "Missing complexity_score" + assert 'recommended_pattern' in result, "Missing recommended_pattern" + assert 'refactoring_steps' in result, "Missing refactoring_steps" + + complexity = result['complexity_score'] + assert 0 <= complexity <= 100, f"Complexity {complexity} out of range" + + print(f" ✓ Current pattern: {result['current_pattern']}") + print(f" ✓ Complexity: {complexity}/100") + print(f" ✓ Recommended: {result['recommended_pattern']}") + + # Cleanup + test_file.unlink() + test_dir.rmdir() + + return True + + +def test_all_functions_return_valid_structure(): + """Test that all functions return valid result structures.""" + print("\n✓ Testing result structure validity...") + + test_dir = Path("/tmp/test_structure") + test_dir.mkdir(exist_ok=True) + test_file = test_dir / "main.go" + test_file.write_text(TEST_APP_CODE) + + # Test all functions + results = { + "diagnose_issue": diagnose_issue(str(test_dir)), + "apply_best_practices": apply_best_practices(str(test_dir)), + "debug_performance": debug_performance(str(test_dir)), + "suggest_architecture": suggest_architecture(str(test_dir)), + "fix_layout_issues": fix_layout_issues(str(test_dir)), + } + + for func_name, result in results.items(): + # Each should have validation + assert 'validation' in result, f"{func_name}: Missing validation" + assert 'status' in result['validation'], f"{func_name}: Missing validation status" + assert 'summary' in result['validation'], f"{func_name}: Missing validation summary" + + print(f" ✓ {func_name}: Valid structure") + + # Cleanup + test_file.unlink() + test_dir.rmdir() + + return True + + +def main(): + """Run all integration tests.""" + print("="*70) + print("INTEGRATION TESTS - Bubble Tea Maintenance Agent") + print("="*70) + + tests = [ + ("Full workflow", test_full_workflow), + ("Issue diagnosis", test_issue_diagnosis_finds_problems), + ("Performance analysis", test_performance_finds_bottlenecks), + ("Layout analysis", test_layout_finds_issues), + ("Architecture analysis", test_architecture_analysis), + ("Result structure validity", test_all_functions_return_valid_structure), + ] + + results = [] + for test_name, test_func in tests: + try: + passed = test_func() + results.append((test_name, passed)) + except Exception as e: + print(f"\n ❌ FAILED: {e}") + import traceback + traceback.print_exc() + results.append((test_name, False)) + + # Summary + print("\n" + "="*70) + print("SUMMARY") + print("="*70) + + for test_name, passed in results: + status = "✅ PASS" if passed else "❌ FAIL" + print(f"{status}: {test_name}") + + passed_count = sum(1 for _, p in results if p) + total_count = len(results) + + print(f"\nResults: {passed_count}/{total_count} passed") + + return passed_count == total_count + + +if __name__ == "__main__": + success = main() + sys.exit(0 if success else 1) diff --git a/.crush/skills/bubbletea-designer/.claude-plugin/marketplace.json b/.crush/skills/bubbletea-designer/.claude-plugin/marketplace.json new file mode 100644 index 00000000..d9edf646 --- /dev/null +++ b/.crush/skills/bubbletea-designer/.claude-plugin/marketplace.json @@ -0,0 +1,21 @@ +{ + "name": "bubbletea-designer", + "owner": { + "name": "Agent Creator", + "email": "noreply@example.com" + }, + "metadata": { + "description": "Bubble Tea TUI Design Automation Agent", + "version": "1.0.0", + "created": "2025-10-18" + }, + "plugins": [ + { + "name": "bubbletea-designer-plugin", + "description": "Automates Bubble Tea TUI design by analyzing requirements, mapping to appropriate components from the Charmbracelet ecosystem, generating component architecture, and creating implementation workflows. Use when designing terminal UIs, planning Bubble Tea applications, selecting components, or needing design guidance for TUI development.", + "source": "./", + "strict": false, + "skills": ["./"] + } + ] +} diff --git a/.crush/skills/bubbletea-designer/.claude-plugin/plugin.json b/.crush/skills/bubbletea-designer/.claude-plugin/plugin.json new file mode 100644 index 00000000..fd3c9a25 --- /dev/null +++ b/.crush/skills/bubbletea-designer/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "bubbletea-designer", + "description": "Bubble Tea TUI Design Automation Agent", + "author": { + "name": "Agent Creator", + "email": "noreply@example.com" + } +} diff --git a/.crush/skills/bubbletea-designer/.skillfish.json b/.crush/skills/bubbletea-designer/.skillfish.json new file mode 100644 index 00000000..ffd7dd27 --- /dev/null +++ b/.crush/skills/bubbletea-designer/.skillfish.json @@ -0,0 +1,10 @@ +{ + "version": 2, + "name": "bubbletea-designer", + "owner": "human-frontier-labs-inc", + "repo": "human-frontier-labs-marketplace", + "path": "plugins/bubbletea-designer", + "branch": "master", + "sha": "84dc8d26c0a4351c01f6a1669617607645addb66", + "source": "manual" +} \ No newline at end of file diff --git a/.crush/skills/bubbletea-designer/CHANGELOG.md b/.crush/skills/bubbletea-designer/CHANGELOG.md new file mode 100644 index 00000000..f12dcf86 --- /dev/null +++ b/.crush/skills/bubbletea-designer/CHANGELOG.md @@ -0,0 +1,96 @@ +# Changelog + +All notable changes to Bubble Tea Designer will be documented here. + +Format based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +Versioning follows [Semantic Versioning](https://semver.org/). + +## [1.0.0] - 2025-10-18 + +### Added + +**Core Functionality:** +- `comprehensive_tui_design_report()` - All-in-one design generation +- `extract_requirements()` - Natural language requirement parsing +- `map_to_components()` - Intelligent component selection +- `select_relevant_patterns()` - Example pattern matching +- `design_architecture()` - Architecture generation with diagrams +- `generate_implementation_workflow()` - Step-by-step implementation plans + +**Data Sources:** +- charm-examples-inventory integration (46 examples) +- Component taxonomy with 14 components +- Pattern templates for 5 common archetypes +- Comprehensive keyword database + +**Analysis Capabilities:** +- TUI archetype classification (9 types) +- Feature extraction from descriptions +- Component scoring algorithm (0-100) +- Pattern relevance ranking +- Architecture diagram generation (ASCII) +- Time estimation for implementation + +**Utilities:** +- Inventory loader with automatic path detection +- Component matcher with keyword scoring +- Template generator for Go code scaffolding +- ASCII diagram generator for architecture visualization +- Requirement validator +- Design validator + +**Documentation:** +- Complete SKILL.md (7,200 words) +- Component guide with 14 components +- Design patterns reference (10 patterns) +- Architecture best practices +- Example designs (5 complete examples) +- Installation guide +- Architecture decisions documentation + +### Data Coverage + +**Components Supported:** +- Input: textinput, textarea, filepicker, autocomplete +- Display: viewport, table, list, pager, paginator +- Feedback: spinner, progress, timer, stopwatch +- Navigation: tabs, help +- Layout: lipgloss + +**Archetypes Recognized:** +- file-manager, installer, dashboard, form, viewer +- chat, table-viewer, menu, editor + +**Patterns Available:** +- Single-view, multi-view, master-detail +- Progress tracker, composable views, form flow + +### Known Limitations + +- Requires charm-examples-inventory for full pattern matching (works without but reduced functionality) +- Archetype classification may need refinement for complex hybrid TUIs +- Code scaffolding is basic (Init/Update/View skeletons only) +- No live preview or interactive refinement yet + +### Planned for v2.0 + +- Interactive requirement refinement +- Full code generation (not just scaffolding) +- Custom component definitions +- Integration with Go toolchain (go mod init, etc.) +- Design session save/load +- Live TUI preview + +## [Unreleased] + +### Planned + +- Add support for custom components +- Improve archetype classification accuracy +- Expand pattern library +- Add code completion features +- Performance optimizations for large inventories + +--- + +**Generated with Claude Code agent-creator skill on 2025-10-18** diff --git a/.crush/skills/bubbletea-designer/DECISIONS.md b/.crush/skills/bubbletea-designer/DECISIONS.md new file mode 100644 index 00000000..3dcb33b1 --- /dev/null +++ b/.crush/skills/bubbletea-designer/DECISIONS.md @@ -0,0 +1,158 @@ +# Architecture Decisions + +Documentation of key design decisions for Bubble Tea Designer skill. + +## Data Source Decision + +**Decision**: Use local charm-examples-inventory instead of API +**Rationale**: +- ✅ No rate limits or authentication needed +- ✅ Fast lookups (local file system) +- ✅ Complete control over inventory structure +- ✅ Offline capability +- ✅ Inventory can be updated independently + +**Alternatives Considered**: +- GitHub API: Rate limits, requires authentication +- Web scraping: Fragile, slow, unreliable +- Embedded database: Adds complexity, harder to update + +**Trade-offs**: +- User needs to have inventory locally (optional but recommended) +- Updates require re-cloning repository + +## Analysis Approach + +**Decision**: 6 separate analysis functions + 1 comprehensive orchestrator +**Rationale**: +- ✅ Modularity - each function has single responsibility +- ✅ Testability - easy to test individual components +- ✅ Flexibility - users can call specific analyses +- ✅ Composability - orchestrator combines as needed + +**Structure**: +1. analyze_requirements() - NLP requirement extraction +2. map_components() - Component scoring and selection +3. select_patterns() - Example file matching +4. design_architecture() - Structure generation +5. generate_workflow() - Implementation planning +6. comprehensive_tui_design_report() - All-in-one + +## Component Matching Algorithm + +**Decision**: Keyword-based scoring with manual taxonomy +**Rationale**: +- ✅ Transparent - users can see why components selected +- ✅ Predictable - consistent results +- ✅ Fast - O(n) search with indexing +- ✅ Maintainable - easy to add new components + +**Alternatives Considered**: +- ML-based matching: Overkill, requires training data +- Fuzzy matching: Less accurate for technical terms +- Rule-based expert system: Too rigid + +**Scoring System**: +- Keyword match: 60 points max +- Use case match: 40 points max +- Total: 0-100 score per component + +## Architecture Generation Strategy + +**Decision**: Template-based with customization +**Rationale**: +- ✅ Generates working code immediately +- ✅ Follows Bubble Tea best practices +- ✅ Customizable per archetype +- ✅ Educational - shows proper patterns + +**Templates Include**: +- Model struct with components +- Init() with proper initialization +- Update() skeleton with message routing +- View() with component rendering + +## Validation Strategy + +**Decision**: Multi-layer validation (requirements, components, architecture, workflow) +**Rationale**: +- ✅ Early error detection +- ✅ Quality assurance +- ✅ Helpful feedback to users +- ✅ Catches incomplete designs + +**Validation Levels**: +- CRITICAL: Must fix (empty description, no components) +- WARNING: Should review (low coverage, many components) +- INFO: Optional improvements + +## File Organization + +**Decision**: Modular scripts with shared utilities +**Rationale**: +- ✅ Clear separation of concerns +- ✅ Reusable utilities +- ✅ Easy to test +- ✅ Maintainable codebase + +**Structure**: +``` +scripts/ + main analysis scripts (6) + utils/ + shared utilities + validators/ + validation logic +``` + +## Pattern Matching Approach + +**Decision**: Inventory-based with ranking +**Rationale**: +- ✅ Leverages existing examples +- ✅ Provides concrete references +- ✅ Study order optimization +- ✅ Realistic time estimates + +**Ranking Factors**: +- Component usage overlap +- Complexity match +- Code quality/clarity + +## Documentation Strategy + +**Decision**: Comprehensive references with patterns and best practices +**Rationale**: +- ✅ Educational value +- ✅ Self-contained skill +- ✅ Reduces external documentation dependency +- ✅ Examples for every pattern + +**References Created**: +- Component guide (what each component does) +- Design patterns (common architectures) +- Best practices (dos and don'ts) +- Example designs (complete real-world cases) + +## Performance Considerations + +**Optimizations**: +- Inventory loaded once, cached in memory +- Pre-computed component taxonomy +- Fast keyword matching (no regex) +- Minimal allocations in hot paths + +**Trade-offs**: +- Memory usage: ~5MB for loaded inventory +- Startup time: ~100ms for inventory loading +- Analysis time: <1 second for complete report + +## Future Enhancements + +Potential improvements for v2.0: +- Interactive mode for requirement refinement +- Code generation (full implementation, not just scaffolding) +- Live preview of designs +- Integration with Go module initialization +- Custom component definitions +- Save/load design sessions diff --git a/.crush/skills/bubbletea-designer/INSTALLATION.md b/.crush/skills/bubbletea-designer/INSTALLATION.md new file mode 100644 index 00000000..c0c00be9 --- /dev/null +++ b/.crush/skills/bubbletea-designer/INSTALLATION.md @@ -0,0 +1,109 @@ +# Installation Guide + +Step-by-step installation for Bubble Tea Designer skill. + +## Prerequisites + +- Claude Code CLI installed +- Python 3.8+ +- charm-examples-inventory (optional but recommended) + +## Installation + +### Step 1: Install the Skill + +```bash +/plugin marketplace add /path/to/bubbletea-designer +``` + +Or if you're in the directory containing bubbletea-designer: + +```bash +/plugin marketplace add ./bubbletea-designer +``` + +### Step 2: Verify Installation + +The skill should now be active. Test it with: + +``` +"Design a simple TUI for viewing log files" +``` + +You should see Claude activate the skill and generate a design report. + +## Optional: Install charm-examples-inventory + +For full pattern matching capabilities: + +```bash +cd ~/charmtuitemplate/vinw # Or your preferred location +git clone https://github.com/charmbracelet/bubbletea charm-examples-inventory +``` + +The skill will automatically search common locations: +- `./charm-examples-inventory` +- `../charm-examples-inventory` +- `~/charmtuitemplate/vinw/charm-examples-inventory` + +## Verification + +Run test scripts to verify everything works: + +```bash +cd /path/to/bubbletea-designer +python3 scripts/analyze_requirements.py +python3 scripts/map_components.py +``` + +You should see test outputs with ✅ marks indicating success. + +## Troubleshooting + +### Skill Not Activating + +**Issue**: Skill doesn't activate when you mention Bubble Tea +**Solution**: +- Check skill is installed: `/plugin list` +- Try explicit keywords: "design a bubbletea TUI" +- Restart Claude Code + +### Inventory Not Found + +**Issue**: "Cannot locate charm-examples-inventory" +**Solution**: +- Install inventory to a standard location (see Step 2 above) +- Or specify custom path when needed +- Skill works without inventory but with reduced pattern matching + +### Import Errors + +**Issue**: Python import errors when running scripts +**Solution**: +- Verify Python 3.8+ installed: `python3 --version` +- Scripts use relative imports, run from project directory + +## Usage + +Once installed, activate by mentioning: +- "Design a TUI for..." +- "Create a Bubble Tea interface..." +- "Which components should I use for..." +- "Plan architecture for a terminal UI..." + +The skill activates automatically and generates comprehensive design reports. + +## Uninstallation + +To remove the skill: + +```bash +/plugin marketplace remove bubbletea-designer +``` + +## Next Steps + +- Read SKILL.md for complete documentation +- Try example queries from README.md +- Explore references/ for design patterns +- Study generated designs for your use cases diff --git a/.crush/skills/bubbletea-designer/README.md b/.crush/skills/bubbletea-designer/README.md new file mode 100644 index 00000000..2a7b8f82 --- /dev/null +++ b/.crush/skills/bubbletea-designer/README.md @@ -0,0 +1,174 @@ +# Bubble Tea TUI Designer + +Automate the design process for Bubble Tea terminal user interfaces with intelligent component mapping, architecture generation, and implementation planning. + +## What It Does + +This skill helps you design Bubble Tea TUIs by: + +1. **Analyzing requirements** from natural language descriptions +2. **Mapping to components** from the Charmbracelet ecosystem +3. **Generating architecture** with component hierarchy and message flow +4. **Creating workflows** with step-by-step implementation plans +5. **Providing scaffolding** with boilerplate code to get started + +## Features + +- ✅ Intelligent component selection based on requirements +- ✅ Pattern matching against 46 Bubble Tea examples +- ✅ ASCII architecture diagrams +- ✅ Complete implementation workflows +- ✅ Code scaffolding generation +- ✅ Design validation and suggestions + +## Installation + +```bash +/plugin marketplace add ./bubbletea-designer +``` + +## Quick Start + +Simply describe your TUI and the skill will generate a complete design: + +``` +"Design a log viewer with search and highlighting" +``` + +The skill will automatically: +- Classify it as a "viewer" archetype +- Select viewport.Model and textinput.Model +- Generate architecture diagram +- Create step-by-step implementation workflow +- Provide code scaffolding + +## Usage Examples + +### Example 1: Simple Log Viewer +``` +"Build a TUI for viewing log files with search" +``` + +### Example 2: File Manager +``` +"Create a file manager with three-column view showing parent directory, current directory, and file preview" +``` + +### Example 3: Package Installer +``` +"Design an installer UI with progress bars for sequential package installation" +``` + +### Example 4: Configuration Wizard +``` +"Build a multi-step configuration wizard with form validation" +``` + +## How It Works + +The designer follows a systematic process: + +1. **Requirement Analysis**: Extract structured requirements from your description +2. **Component Mapping**: Match requirements to Bubble Tea components +3. **Pattern Selection**: Find relevant examples from inventory +4. **Architecture Design**: Create component hierarchy and message flow +5. **Workflow Generation**: Generate ordered implementation steps +6. **Design Report**: Combine all analyses into comprehensive document + +## Output Structure + +The comprehensive design report includes: + +- **Executive Summary**: TUI type, components, time estimate +- **Requirements**: Parsed features, interactions, data types +- **Components**: Selected components with justifications +- **Patterns**: Relevant example files to study +- **Architecture**: Model struct, diagrams, message handlers +- **Workflow**: Phase-by-phase implementation plan +- **Code Scaffolding**: Basic main.go template +- **Next Steps**: What to do first + +## Dependencies + +The skill references the charm-examples-inventory for pattern matching. + +Default search locations: +- `./charm-examples-inventory` +- `../charm-examples-inventory` +- `~/charmtuitemplate/vinw/charm-examples-inventory` + +You can also specify a custom path: +```python +report = comprehensive_tui_design_report( + "your description", + inventory_path="/custom/path/to/inventory" +) +``` + +## Testing + +Run the comprehensive test suite: + +```bash +cd bubbletea-designer/tests +python3 test_integration.py +``` + +Individual script tests: +```bash +python3 scripts/analyze_requirements.py +python3 scripts/map_components.py +python3 scripts/design_tui.py "Build a log viewer" +``` + +## Files Structure + +``` +bubbletea-designer/ +├── SKILL.md # Skill documentation +├── scripts/ +│ ├── design_tui.py # Main orchestrator +│ ├── analyze_requirements.py +│ ├── map_components.py +│ ├── select_patterns.py +│ ├── design_architecture.py +│ ├── generate_workflow.py +│ └── utils/ +│ ├── inventory_loader.py +│ ├── component_matcher.py +│ ├── template_generator.py +│ ├── ascii_diagram.py +│ └── validators/ +├── references/ +│ ├── bubbletea-components-guide.md +│ ├── design-patterns.md +│ ├── architecture-best-practices.md +│ └── example-designs.md +├── assets/ +│ ├── component-taxonomy.json +│ ├── pattern-templates.json +│ └── keywords.json +└── tests/ + └── test_integration.py +``` + +## Resources + +- [Bubble Tea Documentation](https://github.com/charmbracelet/bubbletea) +- [Lipgloss Styling](https://github.com/charmbracelet/lipgloss) +- [Bubbles Components](https://github.com/charmbracelet/bubbles) +- [Charm Community](https://charm.sh/chat) + +## License + +MIT + +## Contributing + +Contributions welcome! This is an automated agent created by the agent-creator skill. + +## Version + +1.0.0 - Initial release + +**Generated with Claude Code agent-creator skill** diff --git a/.crush/skills/bubbletea-designer/SKILL.md b/.crush/skills/bubbletea-designer/SKILL.md new file mode 100644 index 00000000..5c1bb363 --- /dev/null +++ b/.crush/skills/bubbletea-designer/SKILL.md @@ -0,0 +1,1537 @@ +--- +name: bubbletea-designer +description: Automates Bubble Tea TUI design by analyzing requirements, mapping to appropriate components from the Charmbracelet ecosystem, generating component architecture, and creating implementation workflows. Use when designing terminal UIs, planning Bubble Tea applications, selecting components, or needing design guidance for TUI development. +--- + +# Bubble Tea TUI Designer + +Automate the design process for Bubble Tea terminal user interfaces with intelligent component mapping, architecture generation, and implementation planning. + +## When to Use This Skill + +This skill automatically activates when you need help designing, planning, or structuring Bubble Tea TUI applications: + +### Design & Planning + +Use this skill when you: +- **Design a new TUI application** from requirements +- **Plan component architecture** for terminal interfaces +- **Select appropriate Bubble Tea components** for your use case +- **Generate implementation workflows** with step-by-step guides +- **Map user requirements to Charmbracelet ecosystem** components + +### Typical Activation Phrases + +The skill responds to questions like: +- "Design a TUI for [use case]" +- "Create a file manager interface" +- "Build an installation progress tracker" +- "Which Bubble Tea components should I use for [feature]?" +- "Plan a multi-view dashboard TUI" +- "Generate architecture for a configuration wizard" +- "Automate TUI design for [application]" + +### TUI Types Supported + +- **File Managers**: Navigation, selection, preview +- **Installers/Package Managers**: Progress tracking, step indication +- **Dashboards**: Multi-view, tabs, real-time updates +- **Forms & Wizards**: Multi-step input, validation +- **Data Viewers**: Tables, lists, pagination +- **Log/Text Viewers**: Scrolling, searching, highlighting +- **Chat Interfaces**: Input + message display +- **Configuration Tools**: Interactive settings +- **Monitoring Tools**: Real-time data, charts +- **Menu Systems**: Selection, navigation + +## How It Works + +The Bubble Tea Designer follows a systematic 6-step design process: + +### 1. Requirement Analysis + +**Purpose**: Extract structured requirements from natural language descriptions + +**Process**: +- Parse user description +- Identify core features +- Extract interaction patterns +- Determine data types +- Classify TUI archetype + +**Output**: Structured requirements dictionary with: +- Features list +- Interaction types (keyboard, mouse, both) +- Data types (files, text, tabular, streaming) +- View requirements (single, multi-view, tabs) +- Special requirements (validation, progress, real-time) + +### 2. Component Mapping + +**Purpose**: Map requirements to appropriate Bubble Tea components + +**Process**: +- Match features to component capabilities +- Consider component combinations +- Evaluate alternatives +- Justify selections based on requirements + +**Output**: Component recommendations with: +- Primary components (core functionality) +- Supporting components (enhancements) +- Styling components (Lipgloss) +- Justification for each selection +- Alternative options considered + +### 3. Pattern Selection + +**Purpose**: Identify relevant example files from charm-examples-inventory + +**Process**: +- Search CONTEXTUAL-INVENTORY.md for matching patterns +- Filter by capability category +- Rank by relevance to requirements +- Select 3-5 most relevant examples + +**Output**: List of example files to reference: +- File path in charm-examples-inventory +- Capability category +- Key patterns to extract +- Specific lines or functions to study + +### 4. Architecture Design + +**Purpose**: Create component hierarchy and interaction model + +**Process**: +- Design model structure (what state to track) +- Plan Init() function (initialization commands) +- Design Update() function (message handling) +- Plan View() function (rendering strategy) +- Create component composition diagram + +**Output**: Architecture specification with: +- Model struct definition +- Component hierarchy (ASCII diagram) +- Message flow diagram +- State management plan +- Rendering strategy + +### 5. Workflow Generation + +**Purpose**: Create ordered implementation steps + +**Process**: +- Determine dependency order +- Break into logical phases +- Reference specific example files +- Include testing checkpoints + +**Output**: Step-by-step implementation plan: +- Phase breakdown (setup, components, integration, polish) +- Ordered tasks with dependencies +- File references for each step +- Testing milestones +- Estimated time per phase + +### 6. Comprehensive Design Report + +**Purpose**: Generate complete design document combining all analyses + +**Process**: +- Execute all 5 previous analyses +- Combine into unified document +- Add implementation guidance +- Include code scaffolding templates +- Generate README outline + +**Output**: Complete TUI design specification with: +- Executive summary +- All analysis results (requirements, components, patterns, architecture, workflow) +- Code scaffolding (model struct, basic Init/Update/View) +- File structure recommendation +- Next steps and resources + +## Data Source: Charm Examples Inventory + +This skill references a curated inventory of 46 Bubble Tea examples from the Charmbracelet ecosystem. + +### Inventory Structure + +**Location**: `charm-examples-inventory/bubbletea/examples/` + +**Index File**: `CONTEXTUAL-INVENTORY.md` + +**Categories** (11 capability groups): +1. Installation & Progress Tracking +2. Form Input & Validation +3. Data Display & Selection +4. Content Viewing +5. View Management & Navigation +6. Loading & Status Indicators +7. Time-Based Operations +8. Network & External Operations +9. Real-Time & Event Handling +10. Screen & Terminal Management +11. Input & Interaction + +### Component Coverage + +**Input Components**: +- `textinput` - Single-line text input +- `textarea` - Multi-line text editing +- `textinputs` - Multiple inputs with focus management +- `filepicker` - File system navigation and selection +- `autocomplete` - Text input with suggestions + +**Display Components**: +- `table` - Tabular data with row selection +- `list` - Filterable, paginated lists +- `viewport` - Scrollable content area +- `pager` - Document viewer +- `paginator` - Page-based navigation + +**Feedback Components**: +- `spinner` - Loading indicator +- `progress` - Progress bar (animated & static) +- `timer` - Countdown timer +- `stopwatch` - Elapsed time tracker + +**Layout Components**: +- `views` - Multiple screen states +- `composable-views` - Composed bubble models +- `tabs` - Tab-based navigation +- `help` - Help menu system + +**Utility Patterns**: +- HTTP requests (`http`) +- External commands (`exec`) +- Real-time events (`realtime`) +- Alt screen buffer (`altscreen-toggle`) +- Mouse support (`mouse`) +- Window resize (`window-size`) + +### Pattern Recognition + +The skill uses pattern matching to identify: + +**By Feature**: +- "progress tracking" → `progress`, `spinner`, `package-manager` +- "form with validation" → `credit-card-form`, `textinputs` +- "table display" → `table`, `table-resize` +- "file selection" → `file-picker`, `list-default` +- "multi-step process" → `views`, `package-manager` + +**By Interaction**: +- "keyboard navigation" → Most examples, especially `help` +- "mouse support" → `mouse`, `table` +- "scrolling" → `viewport`, `pager` +- "filtering" → `list-default`, `autocomplete` + +**By Architecture**: +- "single view" → `simple`, `spinner`, `textinput` +- "multi-view state machine" → `views` +- "composed models" → `composable-views` +- "tab navigation" → `tabs` + +## Workflow Details + +### Workflow 1: Quick Component Lookup + +**User Query**: "Which component should I use for [feature]?" + +**Process**: +1. Parse feature description +2. Search component taxonomy +3. Return top 3 matches with justification + +**Example**: +``` +User: "Which component for file selection?" + +Response: +Top recommendations: +1. filepicker.Model - Dedicated file system navigation + - Built-in directory traversal + - File type filtering + - Selection callbacks + Reference: examples/file-picker/main.go + +2. list.Model - Custom file list + - More control over display + - Custom item rendering + - Can add file metadata + Reference: examples/list-fancy/main.go + +3. table.Model - Tabular file display + - Show multiple file attributes + - Sortable columns + - Good for detailed views + Reference: examples/table/main.go +``` + +### Workflow 2: Design from Description + +**User Query**: "Design a TUI for [application]" + +**Process**: +1. Run `requirement_analysis(description)` +2. Run `component_mapping(requirements)` +3. Run `pattern_selection(components)` +4. Run `architecture_design(components, patterns)` +5. Run `workflow_generation(architecture)` +6. Combine into design report + +**Example**: +``` +User: "Design a TUI for a log file viewer with search and highlighting" + +Response: [Comprehensive design report including]: + +REQUIREMENTS: +- View: Scrollable text display +- Interaction: Search, highlight matches, navigate results +- Data: Log file lines (streaming or loaded) + +COMPONENTS: +- viewport.Model (scrollable display) +- textinput.Model (search query) +- Custom highlighter (Lipgloss styles) + +ARCHITECTURE: +model struct { + viewport viewport.Model + searchInput textinput.Model + logLines []string + searchMode bool + matches []int + currentMatch int +} + +WORKFLOW: +Phase 1: Basic viewer (viewport + file loading) +Phase 2: Add search input +Phase 3: Implement highlighting +Phase 4: Add navigation between matches +Phase 5: Polish (status bar, help text) + +[Full implementation steps with file references...] +``` + +### Workflow 3: Architecture Planning + +**User Query**: "Plan architecture for [complex TUI]" + +**Process**: +1. Identify sub-components +2. Design model composition +3. Create message flow diagram +4. Plan state management +5. Generate ASCII architecture diagram + +**Example**: +``` +User: "Plan architecture for a package manager TUI with installation queue and progress" + +Response: + +ARCHITECTURE DIAGRAM: +┌─────────────────────────────────────┐ +│ Main Model │ +├─────────────────────────────────────┤ +│ view: currentView │ +│ packages: []PackageItem │ +│ queue: InstallQueue │ +│ progress: ProgressState │ +└────────────┬───────────────┬────────┘ + │ │ + ┌────▼────┐ ┌───▼─────┐ + │ List │ │Progress │ + │ Model │ │ Model │ + └─────────┘ └─────────┘ + +STATE MANAGEMENT: +- currentView: enum (selectView, installView, completeView) +- packages: list of selectable items +- queue: ordered installation queue +- progress: current package progress (0-100) + +MESSAGE FLOW: +User Input → Update() → Route to current view handler +Install Start → Switch view + Init progress +Package Complete → Update progress + Next package +All Complete → Switch to complete view + +[Detailed implementation guidance...] +``` + +### Workflow 4: Pattern Selection + +**User Query**: "Show me examples for [capability]" + +**Process**: +1. Search CONTEXTUAL-INVENTORY.md +2. Filter by capability +3. Return sorted by relevance +4. Include key patterns to study + +**Example**: +``` +User: "Show me examples for progress tracking" + +Response: + +PROGRESS TRACKING EXAMPLES: + +1. package-manager (examples/package-manager/main.go) + Category: Installation & Progress Tracking + Key Patterns: + - Combined spinner + progress bar + - Sequential task execution + - Tea.Println for persistent output above TUI + - Custom completion messages + +2. progress-animated (examples/progress-animated/main.go) + Category: Loading & Status Indicators + Key Patterns: + - Gradient progress styling + - Smooth animation with FrameMsg + - Indeterminate/determinate modes + +3. progress-download (examples/progress-download/main.go) + Category: Loading & Status Indicators + Key Patterns: + - Network operation tracking + - Real-time percentage updates + - HTTP integration + +Study these in order: +1. progress-animated (learn basics) +2. package-manager (see real-world usage) +3. progress-download (network-specific) +``` + +## Available Scripts + +All scripts are in `scripts/` directory and can be run independently or through the main orchestrator. + +### Main Orchestrator + +**`design_tui.py`** + +Comprehensive design report generator - combines all analyses. + +**Usage**: +```python +from scripts.design_tui import comprehensive_tui_design_report + +report = comprehensive_tui_design_report( + description="Log viewer with search and highlighting", + inventory_path="/path/to/charm-examples-inventory" +) + +print(report['summary']) +print(report['architecture']) +print(report['workflow']) +``` + +**Parameters**: +- `description` (str): Natural language TUI description +- `inventory_path` (str): Path to charm-examples-inventory directory +- `include_sections` (List[str], optional): Which sections to include +- `detail_level` (str): "summary" | "detailed" | "complete" + +**Returns**: +```python +{ + 'description': str, + 'generated_at': str (ISO timestamp), + 'sections': { + 'requirements': {...}, + 'components': {...}, + 'patterns': {...}, + 'architecture': {...}, + 'workflow': {...} + }, + 'summary': str, + 'scaffolding': str (code template), + 'next_steps': List[str] +} +``` + +### Analysis Scripts + +**`analyze_requirements.py`** + +Extract structured requirements from natural language. + +**Functions**: +- `extract_requirements(description)` - Parse description +- `classify_tui_type(requirements)` - Determine archetype +- `identify_interactions(requirements)` - Find interaction patterns + +**`map_components.py`** + +Map requirements to Bubble Tea components. + +**Functions**: +- `map_to_components(requirements, inventory)` - Main mapping +- `find_alternatives(component)` - Alternative suggestions +- `justify_selection(component, requirement)` - Explain choice + +**`select_patterns.py`** + +Select relevant example files from inventory. + +**Functions**: +- `search_inventory(capability, inventory)` - Search by capability +- `rank_by_relevance(examples, requirements)` - Relevance scoring +- `extract_key_patterns(example_file)` - Identify key code patterns + +**`design_architecture.py`** + +Generate component architecture and structure. + +**Functions**: +- `design_model_struct(components)` - Create model definition +- `plan_message_handlers(interactions)` - Design Update() logic +- `generate_architecture_diagram(structure)` - ASCII diagram + +**`generate_workflow.py`** + +Create ordered implementation steps. + +**Functions**: +- `break_into_phases(architecture)` - Phase planning +- `order_tasks_by_dependency(tasks)` - Dependency sorting +- `estimate_time(task)` - Time estimation +- `generate_workflow_document(phases)` - Formatted output + +### Utility Scripts + +**`utils/inventory_loader.py`** + +Load and parse the examples inventory. + +**Functions**: +- `load_inventory(path)` - Load CONTEXTUAL-INVENTORY.md +- `parse_inventory_markdown(content)` - Parse structure +- `build_capability_index(inventory)` - Index by capability +- `search_by_keyword(keyword, inventory)` - Keyword search + +**`utils/component_matcher.py`** + +Component matching and scoring logic. + +**Functions**: +- `match_score(requirement, component)` - Relevance score +- `find_best_match(requirements, components)` - Top match +- `suggest_combinations(requirements)` - Component combos + +**`utils/template_generator.py`** + +Generate code templates and scaffolding. + +**Functions**: +- `generate_model_struct(components)` - Model struct code +- `generate_init_function(components)` - Init() implementation +- `generate_update_skeleton(messages)` - Update() skeleton +- `generate_view_skeleton(layout)` - View() skeleton + +**`utils/ascii_diagram.py`** + +Create ASCII architecture diagrams. + +**Functions**: +- `draw_component_tree(structure)` - Tree diagram +- `draw_message_flow(flow)` - Flow diagram +- `draw_state_machine(states)` - State diagram + +### Validator Scripts + +**`utils/validators/requirement_validator.py`** + +Validate requirement extraction quality. + +**Functions**: +- `validate_description_clarity(description)` - Check clarity +- `validate_requirements_completeness(requirements)` - Completeness +- `suggest_clarifications(requirements)` - Ask for missing info + +**`utils/validators/design_validator.py`** + +Validate design outputs. + +**Functions**: +- `validate_component_selection(components, requirements)` - Check fit +- `validate_architecture(architecture)` - Structural validation +- `validate_workflow_completeness(workflow)` - Ensure all steps + +## Available Analyses + +### 1. Requirement Analysis + +**Function**: `extract_requirements(description)` + +**Purpose**: Convert natural language to structured requirements + +**Methodology**: +1. Tokenize description +2. Extract nouns (features, data types) +3. Extract verbs (interactions, actions) +4. Identify patterns (multi-view, progress, etc.) +5. Classify TUI archetype + +**Output Structure**: +```python +{ + 'archetype': str, # file-manager, installer, dashboard, etc. + 'features': List[str], # [navigation, selection, preview, ...] + 'interactions': { + 'keyboard': List[str], # [arrow keys, enter, search, ...] + 'mouse': List[str] # [click, drag, ...] + }, + 'data_types': List[str], # [files, text, tabular, streaming, ...] + 'views': str, # single, multi, tabbed + 'special_requirements': List[str] # [validation, progress, real-time, ...] +} +``` + +**Interpretation**: +- Archetype determines recommended starting template +- Features map directly to component selection +- Interactions affect component configuration +- Data types influence model structure + +**Validations**: +- Description not empty +- At least 1 feature identified +- Archetype successfully classified + +### 2. Component Mapping + +**Function**: `map_to_components(requirements, inventory)` + +**Purpose**: Map requirements to specific Bubble Tea components + +**Methodology**: +1. Match features to component capabilities +2. Score each component by relevance (0-100) +3. Select top matches (score > 70) +4. Identify component combinations +5. Provide alternatives for each selection + +**Output Structure**: +```python +{ + 'primary_components': [ + { + 'component': 'viewport.Model', + 'score': 95, + 'justification': 'Scrollable display for log content', + 'example_file': 'examples/pager/main.go', + 'key_patterns': ['viewport scrolling', 'content loading'] + } + ], + 'supporting_components': [...], + 'styling': ['lipgloss for highlighting'], + 'alternatives': { + 'viewport.Model': ['pager package', 'custom viewport'] + } +} +``` + +**Scoring Criteria**: +- Feature coverage: Does component provide required features? +- Complexity match: Is component appropriate for requirement complexity? +- Common usage: Is this the typical choice for this use case? +- Ecosystem fit: Does it work well with other selected components? + +**Validations**: +- At least 1 component selected +- All requirements covered by components +- No conflicting components + +### 3. Pattern Selection + +**Function**: `select_relevant_patterns(components, inventory)` + +**Purpose**: Find most relevant example files to study + +**Methodology**: +1. Search inventory by component usage +2. Filter by capability category +3. Rank by pattern complexity (simple → complex) +4. Select 3-5 most relevant +5. Extract specific code patterns to study + +**Output Structure**: +```python +{ + 'examples': [ + { + 'file': 'examples/pager/main.go', + 'capability': 'Content Viewing', + 'relevance_score': 90, + 'key_patterns': [ + 'viewport.Model initialization', + 'content scrolling (lines 45-67)', + 'keyboard navigation (lines 80-95)' + ], + 'study_order': 1, + 'estimated_study_time': '15 minutes' + } + ], + 'recommended_study_order': [1, 2, 3], + 'total_study_time': '45 minutes' +} +``` + +**Ranking Factors**: +- Component usage match +- Complexity appropriate to skill level +- Code quality and clarity +- Completeness of example + +**Validations**: +- At least 2 examples selected +- Examples cover all selected components +- Study order is logical (simple → complex) + +### 4. Architecture Design + +**Function**: `design_architecture(components, patterns, requirements)` + +**Purpose**: Create complete component architecture + +**Methodology**: +1. Design model struct (state to track) +2. Plan Init() (initialization) +3. Design Update() message handling +4. Plan View() rendering +5. Create component hierarchy diagram +6. Design message flow + +**Output Structure**: +```python +{ + 'model_struct': str, # Go code + 'init_logic': str, # Initialization steps + 'message_handlers': { + 'tea.KeyMsg': str, # Keyboard handling + 'tea.WindowSizeMsg': str, # Resize handling + # Custom messages... + }, + 'view_logic': str, # Rendering strategy + 'diagrams': { + 'component_hierarchy': str, # ASCII tree + 'message_flow': str, # Flow diagram + 'state_machine': str # State transitions (if multi-view) + } +} +``` + +**Design Patterns Applied**: +- **Single Responsibility**: Each component handles one concern +- **Composition**: Complex UIs built from simple components +- **Message Passing**: All communication via tea.Msg +- **Elm Architecture**: Model-Update-View separation + +**Validations**: +- Model struct includes all component instances +- All user interactions have message handlers +- View logic renders all components +- No circular dependencies + +### 5. Workflow Generation + +**Function**: `generate_implementation_workflow(architecture, patterns)` + +**Purpose**: Create step-by-step implementation plan + +**Methodology**: +1. Break into phases (Setup, Core, Polish, Test) +2. Identify tasks per phase +3. Order by dependency +4. Reference specific example files per task +5. Add testing checkpoints +6. Estimate time per phase + +**Output Structure**: +```python +{ + 'phases': [ + { + 'name': 'Phase 1: Setup', + 'tasks': [ + { + 'task': 'Initialize Go module', + 'reference': None, + 'dependencies': [], + 'estimated_time': '2 minutes' + }, + { + 'task': 'Install dependencies (bubbletea, lipgloss)', + 'reference': 'See README in any example', + 'dependencies': ['Initialize Go module'], + 'estimated_time': '3 minutes' + } + ], + 'total_time': '5 minutes' + }, + # More phases... + ], + 'total_estimated_time': '2-3 hours', + 'testing_checkpoints': [ + 'After Phase 1: go build succeeds', + 'After Phase 2: Basic display working', + # ... + ] +} +``` + +**Phase Breakdown**: +1. **Setup**: Project initialization, dependencies +2. **Core Components**: Implement main functionality +3. **Integration**: Connect components, message passing +4. **Polish**: Styling, help text, error handling +5. **Testing**: Comprehensive testing, edge cases + +**Validations**: +- All tasks have clear descriptions +- Dependencies are acyclic +- Time estimates are realistic +- Testing checkpoints at each phase + +### 6. Comprehensive Design Report + +**Function**: `comprehensive_tui_design_report(description, inventory_path)` + +**Purpose**: Generate complete TUI design combining all analyses + +**Process**: +1. Execute requirement_analysis(description) +2. Execute component_mapping(requirements) +3. Execute pattern_selection(components) +4. Execute architecture_design(components, patterns) +5. Execute workflow_generation(architecture) +6. Generate code scaffolding +7. Create README outline +8. Compile comprehensive report + +**Output Structure**: +```python +{ + 'description': str, + 'generated_at': str, + 'tui_type': str, + 'summary': str, # Executive summary + 'sections': { + 'requirements': {...}, + 'components': {...}, + 'patterns': {...}, + 'architecture': {...}, + 'workflow': {...} + }, + 'scaffolding': { + 'main_go': str, # Basic main.go template + 'model_go': str, # Model struct + Init/Update/View + 'readme_md': str # README outline + }, + 'file_structure': { + 'recommended': [ + 'main.go', + 'model.go', + 'view.go', + 'messages.go', + 'go.mod' + ] + }, + 'next_steps': [ + '1. Review architecture diagram', + '2. Study recommended examples', + '3. Implement Phase 1 tasks', + # ... + ], + 'resources': { + 'documentation': [...], + 'tutorials': [...], + 'community': [...] + } +} +``` + +**Report Sections**: + +**Executive Summary** (auto-generated): +- TUI type and purpose +- Key components selected +- Estimated implementation time +- Complexity assessment + +**Requirements Analysis**: +- Parsed requirements +- TUI archetype +- Feature list + +**Component Selection**: +- Primary components with justification +- Alternatives considered +- Component interaction diagram + +**Pattern References**: +- Example files to study +- Key patterns highlighted +- Recommended study order + +**Architecture**: +- Model struct design +- Init/Update/View logic +- Message flow +- ASCII diagrams + +**Implementation Workflow**: +- Phase-by-phase breakdown +- Detailed tasks with references +- Testing checkpoints +- Time estimates + +**Code Scaffolding**: +- Basic `main.go` template +- Model struct skeleton +- Init/Update/View stubs + +**Next Steps**: +- Immediate actions +- Learning resources +- Community links + +**Validation Report**: +- Design completeness check +- Potential issues identified +- Recommendations + +## Error Handling + +### Missing Inventory + +**Error**: Cannot locate charm-examples-inventory + +**Cause**: Inventory path not provided or incorrect + +**Resolution**: +1. Verify inventory path: `~/charmtuitemplate/vinw/charm-examples-inventory` +2. If missing, clone examples: `git clone https://github.com/charmbracelet/bubbletea examples` +3. Generate CONTEXTUAL-INVENTORY.md if missing + +**Fallback**: Use minimal built-in component knowledge (less detailed) + +### Unclear Requirements + +**Error**: Cannot extract clear requirements from description + +**Cause**: Description too vague or ambiguous + +**Resolution**: +1. Validator identifies missing information +2. Generate clarifying questions +3. User provides additional details + +**Clarification Questions**: +- "What type of data will the TUI display?" +- "Should it be single-view or multi-view?" +- "What are the main user interactions?" +- "Any specific visual requirements?" + +**Fallback**: Make reasonable assumptions, note them in report + +### No Matching Components + +**Error**: No components found for requirements + +**Cause**: Requirements very specific or unusual + +**Resolution**: +1. Relax matching criteria +2. Suggest custom component development +3. Recommend closest alternatives + +**Alternative Suggestions**: +- Break down into smaller requirements +- Use generic components (viewport, textinput) +- Suggest combining multiple components + +### Invalid Architecture + +**Error**: Generated architecture has structural issues + +**Cause**: Conflicting component requirements or circular dependencies + +**Resolution**: +1. Validator detects issue +2. Suggest architectural modifications +3. Provide alternative structures + +**Common Issues**: +- **Circular dependencies**: Suggest message passing +- **Too many components**: Recommend simplification +- **Missing state**: Add required fields to model + +## Mandatory Validations + +All analyses include automatic validation. Reports include validation sections. + +### Requirement Validation + +**Checks**: +- ✅ Description is not empty +- ✅ At least 1 feature identified +- ✅ TUI archetype classified +- ✅ Interaction patterns detected + +**Output**: +```python +{ + 'validation': { + 'passed': True/False, + 'checks': [ + {'name': 'description_not_empty', 'passed': True}, + {'name': 'features_found', 'passed': True, 'count': 5}, + # ... + ], + 'warnings': [ + 'No mouse interactions specified - assuming keyboard only' + ] + } +} +``` + +### Component Validation + +**Checks**: +- ✅ At least 1 component selected +- ✅ All requirements covered +- ✅ No conflicting components +- ✅ Reasonable complexity + +**Warnings**: +- "Multiple similar components selected - may be redundant" +- "High complexity - consider breaking into smaller UIs" + +### Architecture Validation + +**Checks**: +- ✅ Model struct includes all components +- ✅ No circular dependencies +- ✅ All interactions have handlers +- ✅ View renders all components + +**Errors**: +- "Missing message handler for [interaction]" +- "Circular dependency detected: A → B → A" +- "Unused component: [component] not rendered in View()" + +### Workflow Validation + +**Checks**: +- ✅ All phases have tasks +- ✅ Dependencies are acyclic +- ✅ Testing checkpoints present +- ✅ Time estimates reasonable + +**Warnings**: +- "No testing checkpoint after Phase [N]" +- "Task [X] has no dependencies but should come after [Y]" + +## Performance & Caching + +### Inventory Loading + +**Strategy**: Load once, cache in memory + +- Load CONTEXTUAL-INVENTORY.md on first use +- Build search indices (by capability, component, keyword) +- Cache for session duration + +**Performance**: O(1) lookup after initial O(n) indexing + +### Component Matching + +**Strategy**: Pre-computed similarity scores + +- Build component-feature mapping at initialization +- Score calculations cached +- Incremental updates only + +**Performance**: O(log n) search with indexing + +### Diagram Generation + +**Strategy**: Template-based with caching + +- Use pre-built ASCII templates +- Cache generated diagrams +- Regenerate only on structure changes + +**Performance**: O(n) where n = number of components + +## Automatic Detection Keywords + +This skill activates when you mention: + +**Technologies**: +- Bubble Tea, bubbletea, charm, charmbracelet +- TUI, terminal UI, terminal interface +- Lipgloss, Tea Program + +**Components**: +- spinner, progress, table, list, viewport, textarea, textinput +- pager, paginator, timer, stopwatch, filepicker, help +- tabs, views, autocomplete + +**Design Actions**: +- design TUI, create TUI, build TUI, architect TUI, plan TUI +- automate TUI design, generate TUI, scaffold TUI +- design interface, create interface, build interface +- map components, select components + +**TUI Types**: +- file manager, installer, package manager, dashboard +- form, wizard, configuration tool +- chat interface, log viewer, data viewer, text viewer +- monitoring tool, menu system, selector + +**Patterns**: +- multi-view, composable views, tabbed interface +- progress tracking, form validation, data display +- keyboard navigation, mouse support +- real-time updates, streaming data + +**Questions**: +- "how to design", "how to build", "how to structure" +- "which components", "what patterns", "best practices" +- "design a TUI for", "create interface for" +- "automate design process" + +## Usage Examples + +### Example 1: Simple Log Viewer + +**User**: "Design a TUI for viewing log files with search" + +**Response**: + +``` +TUI DESIGN: Log File Viewer + +ARCHETYPE: Text Viewer + +REQUIREMENTS ANALYSIS: +- View: Scrollable text display +- Interaction: Search, navigate matches +- Data: Text file (log lines) + +COMPONENT SELECTION: + +Primary Components: +1. viewport.Model - Scrollable text display + Justification: Built for viewing large text content + Example: examples/pager/main.go + +2. textinput.Model - Search query input + Justification: Standard text input for search + Example: examples/textinput/main.go + +Supporting: +- Lipgloss styles for match highlighting + +ARCHITECTURE: + +Model Struct: +```go +type model struct { + viewport viewport.Model + searchInput textinput.Model + content []string + searchMode bool + matches []int + currentMatch int + ready bool +} +``` + +WORKFLOW: + +Phase 1: Basic Viewer (30min) +- Load file into string array +- Initialize viewport +- Implement basic scrolling + +Phase 2: Search (45min) +- Add textinput for search +- Toggle search mode with '/' +- Highlight matches + +Phase 3: Navigation (20min) +- Navigate matches with n/N +- Show match counter + +Total: ~2 hours + +NEXT STEPS: +1. Study examples/pager/main.go for viewport usage +2. Study examples/textinput/main.go for input handling +3. Implement Phase 1 +4. Test scrolling with large files +5. Add search in Phase 2 +``` + +### Example 2: Installation Progress Tracker + +**User**: "Create a package installer UI with progress bars" + +**Response**: + +``` +TUI DESIGN: Package Installer + +ARCHETYPE: Installer/Progress Tracker + +REQUIREMENTS ANALYSIS: +- View: Progress indication for multiple packages +- Interaction: Minimal (automated process) +- Data: Package list, progress per package +- Special: Sequential installation, visual feedback + +COMPONENT SELECTION: + +Primary Components: +1. progress.Model - Individual package progress + Justification: Animated progress visualization + Example: examples/progress-animated/main.go + +2. spinner.Model - Loading indicator + Justification: Feedback during package download + Example: examples/spinner/main.go + +Reference Pattern: +- examples/package-manager/main.go - Complete implementation + +ARCHITECTURE: + +Model Struct: +```go +type model struct { + packages []Package + currentIdx int + progress progress.Model + spinner spinner.Model + installed []string + err error +} + +type Package struct { + name string + status string // pending, installing, done +} +``` + +Message Flow: +Install Start → Download Package → Update Progress → Package Done → Next Package + +WORKFLOW: + +Phase 1: Package List (20min) +- Define Package struct +- Initialize list of packages +- Display package names + +Phase 2: Add Progress (30min) +- Initialize progress.Model per package +- Update on install progress messages +- Handle completion + +Phase 3: Add Spinner (15min) +- Add spinner during download +- Stop when installation starts + +Phase 4: Sequential Logic (30min) +- Implement install queue +- Sequential execution +- Completion handling + +Total: ~2 hours + +REFERENCE STUDY: +1. examples/package-manager/main.go (main reference) + - Lines 50-80: Progress + spinner combo + - Lines 120-145: Sequential installation + - Lines 200-230: Completion handling + +NEXT STEPS: +1. Clone package-manager example structure +2. Customize for your package list +3. Add package-specific install logic +4. Test with mock installations +``` + +### Example 3: Configuration Wizard + +**User**: "Build a multi-step configuration wizard with validation" + +**Response**: + +``` +TUI DESIGN: Configuration Wizard + +ARCHETYPE: Multi-Step Form + +REQUIREMENTS ANALYSIS: +- View: Multi-step (multiple screens) +- Interaction: Form input, validation, navigation +- Data: Configuration fields (text, selections) +- Special: Validation per field, step navigation + +COMPONENT SELECTION: + +Primary Components: +1. textinput.Model (array) - Multiple input fields + Justification: Standard form inputs + Example: examples/textinputs/main.go + +2. State machine for steps + Justification: Multi-step workflow + Example: examples/views/main.go + +Reference Pattern: +- examples/credit-card-form/main.go - Form validation + +ARCHITECTURE: + +Model Struct: +```go +type model struct { + currentStep int + steps []Step + inputs []textinput.Model + focusIdx int + err error +} + +type Step struct { + name string + fields []Field + valid bool +} +``` + +State Machine: +Step 1 (Basic Info) → Step 2 (Advanced) → Step 3 (Confirm) → Done + +WORKFLOW: + +Phase 1: Basic Form (45min) +- Create input array +- Implement focus management +- Basic key handling (tab, enter) + +Phase 2: Add Steps (30min) +- Define Step struct +- Implement step navigation +- Show current step indicator + +Phase 3: Validation (45min) +- Add validators to each input +- Show validation errors +- Prevent next step if invalid + +Phase 4: Polish (30min) +- Add progress indicator +- Help text per step +- Confirmation screen + +Total: ~2.5 hours + +REFERENCE STUDY ORDER: +1. examples/textinputs/main.go (focus management) +2. examples/credit-card-form/main.go (validation pattern) +3. examples/views/main.go (multi-view state machine) + +NEXT STEPS: +1. Start with textinputs example +2. Add step state management +3. Implement validators +4. Build confirmation view +``` + +### Example 4: File Manager + +**User**: "Design a file manager TUI like ranger" + +**Response**: + +``` +TUI DESIGN: File Manager + +ARCHETYPE: File Manager + +REQUIREMENTS ANALYSIS: +- View: Three-column (parent, current, preview) +- Interaction: Keyboard navigation, selection +- Data: File system hierarchy +- Special: File preview, directory traversal + +COMPONENT SELECTION: + +Primary Components: +1. filepicker.Model - File navigation + Justification: Built-in file system navigation + Example: examples/file-picker/main.go + Alternative: Custom with list.Model for more control + +2. viewport.Model - File preview + Justification: Preview pane for selected file + Example: examples/pager/main.go + +3. Lipgloss layout - Three-column layout + Justification: Side-by-side panels + Example: examples/split-editors/main.go (two-pane reference) + +ARCHITECTURE: + +Model Struct: +```go +type model struct { + parentList list.Model // Parent directory + currentList list.Model // Current directory + preview viewport.Model // File preview + currentDir string + selectedFile string + width int + height int +} +``` + +Layout: +┌────────────┬─────────────┬──────────────┐ +│ Parent │ Current │ Preview │ +│ Dir │ Dir │ Pane │ +│ │ │ │ +│ ../ │ > file1.txt │ File content │ +│ docs/ │ file2.go │ ... │ +│ src/ │ dir/ │ │ +└────────────┴─────────────┴──────────────┘ + +WORKFLOW: + +Phase 1: Single Dir Nav (1h) +- Implement current directory listing +- Arrow key navigation +- Directory entry/exit + +Phase 2: Three-Pane Layout (1h) +- Add parent directory pane +- Add preview pane +- Lipgloss layout + +Phase 3: File Preview (45min) +- Load file content on selection +- Show in preview pane +- Handle large files + +Phase 4: Polish (45min) +- File type icons/colors +- Status bar +- Help overlay + +Total: ~3.5 hours + +REFERENCE STUDY: +1. examples/file-picker/main.go - File system navigation +2. examples/list-fancy/main.go - Custom list styling +3. examples/pager/main.go - Content preview +4. examples/split-editors/main.go - Multi-pane layout + +NEXT STEPS: +1. Start with file-picker for navigation logic +2. Build three-pane layout +3. Add preview functionality +4. Style with Lipgloss +``` + +### Example 5: Quick Component Lookup + +**User**: "Which component for a dropdown menu?" + +**Response**: + +``` +COMPONENT RECOMMENDATIONS: Dropdown Menu + +Since Bubble Tea doesn't have a built-in dropdown component, here are the best approaches: + +1. list.Model (Recommended) + - Display as temporary list overlay + - Filter on keypress + - Select with Enter + - Example: examples/list-simple/main.go + - Customization: Use ItemDelegate for dropdown styling + +2. autocomplete + - Text input with suggestions below + - Good for searchable dropdowns + - Example: examples/autocomplete/main.go + - Use case: Large option lists + +3. Custom with viewport + - Full control over appearance + - Scrollable for many options + - Example: examples/viewport/main.go (for scrolling) + - Build: Overlay viewport on trigger + +IMPLEMENTATION PATTERN: + +```go +type model struct { + dropdownOpen bool + dropdownList list.Model + selectedValue string +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "enter" && !m.dropdownOpen { + // Open dropdown + m.dropdownOpen = true + return m, nil + } + if m.dropdownOpen { + // Delegate to list + var cmd tea.Cmd + m.dropdownList, cmd = m.dropdownList.Update(msg) + return m, cmd + } + } + return m, nil +} +``` + +RECOMMENDED APPROACH: +Use list.Model in "dropdown mode" - render as overlay when open, hide when closed. + +Study: examples/list-simple/main.go +``` + +--- + +**Total Word Count**: ~7,200 words + +This comprehensive skill documentation provides: +- Clear activation criteria +- Complete workflow explanations +- Detailed function documentation +- Architecture patterns +- Error handling guidance +- Extensive usage examples +- Integration with charm-examples-inventory diff --git a/.crush/skills/bubbletea-designer/VERSION b/.crush/skills/bubbletea-designer/VERSION new file mode 100644 index 00000000..3eefcb9d --- /dev/null +++ b/.crush/skills/bubbletea-designer/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/.crush/skills/bubbletea-designer/assets/component-taxonomy.json b/.crush/skills/bubbletea-designer/assets/component-taxonomy.json new file mode 100644 index 00000000..d96a120e --- /dev/null +++ b/.crush/skills/bubbletea-designer/assets/component-taxonomy.json @@ -0,0 +1,40 @@ +{ + "categories": { + "input": { + "description": "User input components", + "components": ["textinput", "textarea", "filepicker", "autocomplete"] + }, + "display": { + "description": "Content display components", + "components": ["viewport", "table", "list", "pager", "paginator"] + }, + "feedback": { + "description": "Status and progress indicators", + "components": ["spinner", "progress", "timer", "stopwatch"] + }, + "navigation": { + "description": "View and navigation management", + "components": ["tabs", "help"] + }, + "layout": { + "description": "Layout and styling", + "components": ["lipgloss"] + } + }, + "relationships": { + "common_pairs": [ + ["viewport", "textinput"], + ["list", "viewport"], + ["progress", "spinner"], + ["table", "paginator"], + ["textarea", "viewport"] + ], + "archetypes": { + "file-manager": ["filepicker", "viewport", "list"], + "installer": ["progress", "spinner", "list"], + "viewer": ["viewport", "paginator", "textinput"], + "form": ["textinput", "textarea", "help"], + "dashboard": ["tabs", "viewport", "table"] + } + } +} diff --git a/.crush/skills/bubbletea-designer/assets/keywords.json b/.crush/skills/bubbletea-designer/assets/keywords.json new file mode 100644 index 00000000..5fbe7e42 --- /dev/null +++ b/.crush/skills/bubbletea-designer/assets/keywords.json @@ -0,0 +1,74 @@ +{ + "activation_keywords": { + "technologies": [ + "bubble tea", + "bubbletea", + "charm", + "charmbracelet", + "lipgloss", + "tui", + "terminal ui", + "tea.Program" + ], + "components": [ + "viewport", + "textinput", + "textarea", + "table", + "list", + "spinner", + "progress", + "filepicker", + "paginator", + "timer", + "stopwatch", + "tabs", + "help", + "autocomplete" + ], + "actions": [ + "design tui", + "create tui", + "build tui", + "architect tui", + "plan tui", + "automate tui design", + "generate tui", + "scaffold tui", + "map components", + "select components" + ], + "tui_types": [ + "file manager", + "installer", + "package manager", + "dashboard", + "form", + "wizard", + "chat interface", + "log viewer", + "text viewer", + "configuration tool", + "menu system" + ], + "patterns": [ + "multi-view", + "tabbed interface", + "progress tracking", + "form validation", + "keyboard navigation", + "mouse support", + "real-time updates" + ] + }, + "negative_scope": [ + "web ui", + "gui", + "graphical interface", + "react", + "vue", + "angular", + "html", + "css" + ] +} diff --git a/.crush/skills/bubbletea-designer/assets/pattern-templates.json b/.crush/skills/bubbletea-designer/assets/pattern-templates.json new file mode 100644 index 00000000..314a3473 --- /dev/null +++ b/.crush/skills/bubbletea-designer/assets/pattern-templates.json @@ -0,0 +1,44 @@ +{ + "templates": { + "single-view": { + "name": "Single View Application", + "complexity": "low", + "components": 1, + "views": 1, + "time_estimate": "1-2 hours", + "use_cases": ["Simple viewer", "Single-purpose tool"] + }, + "multi-view": { + "name": "Multi-View State Machine", + "complexity": "medium", + "components": 3, + "views": 3, + "time_estimate": "2-4 hours", + "use_cases": ["Wizard", "Multi-step process"] + }, + "master-detail": { + "name": "Master-Detail Layout", + "complexity": "medium", + "components": 2, + "views": 1, + "time_estimate": "2-3 hours", + "use_cases": ["File manager", "Email client"] + }, + "progress-tracker": { + "name": "Progress Tracker", + "complexity": "medium", + "components": 3, + "views": 2, + "time_estimate": "2-3 hours", + "use_cases": ["Installer", "Batch processor"] + }, + "dashboard": { + "name": "Dashboard", + "complexity": "high", + "components": 5, + "views": 4, + "time_estimate": "4-6 hours", + "use_cases": ["Monitoring tool", "Multi-panel app"] + } + } +} diff --git a/.crush/skills/bubbletea-designer/references/architecture-best-practices.md b/.crush/skills/bubbletea-designer/references/architecture-best-practices.md new file mode 100644 index 00000000..7a2f7f36 --- /dev/null +++ b/.crush/skills/bubbletea-designer/references/architecture-best-practices.md @@ -0,0 +1,168 @@ +# Bubble Tea Architecture Best Practices + +## Model Design + +### Keep State Flat +❌ Avoid: Deeply nested state +✅ Prefer: Flat structure with clear fields + +```go +// Good +type model struct { + items []Item + cursor int + selected map[int]bool +} + +// Avoid +type model struct { + state struct { + data struct { + items []Item + } + } +} +``` + +### Separate Concerns +- UI state in model +- Business logic in separate functions +- Network/IO in commands + +### Component Ownership +Each component owns its state. Don't reach into component internals. + +## Update Function + +### Message Routing +Route messages to appropriate handlers: + +```go +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + return m.handleKeyboard(msg) + case tea.WindowSizeMsg: + return m.handleResize(msg) + } + return m.updateComponents(msg) +} +``` + +### Command Batching +Batch multiple commands: + +```go +var cmds []tea.Cmd +cmds = append(cmds, cmd1, cmd2, cmd3) +return m, tea.Batch(cmds...) +``` + +## View Function + +### Cache Expensive Renders +Don't recompute on every View() call: + +```go +type model struct { + cachedView string + dirty bool +} + +func (m model) View() string { + if m.dirty { + m.cachedView = m.render() + m.dirty = false + } + return m.cachedView +} +``` + +### Responsive Layouts +Adapt to terminal size: + +```go +if m.width < 80 { + // Compact layout +} else { + // Full layout +} +``` + +## Performance + +### Minimize Allocations +Reuse slices and strings where possible + +### Defer Heavy Operations +Move slow operations to commands (async) + +### Debounce Rapid Updates +Don't update on every keystroke for expensive operations + +## Error Handling + +### User-Friendly Errors +Show actionable error messages + +### Graceful Degradation +Fallback when features unavailable + +### Error Recovery +Allow user to retry or cancel + +## Testing + +### Test Pure Functions +Extract business logic for easy testing + +### Mock Commands +Test Update() without side effects + +### Snapshot Views +Compare View() output for visual regression + +## Accessibility + +### Keyboard-First +All features accessible via keyboard + +### Clear Indicators +Show current focus, selection state + +### Help Text +Provide discoverable help (? key) + +## Code Organization + +### File Structure +``` +main.go - Entry point, model definition +update.go - Update handlers +view.go - View rendering +commands.go - Command definitions +messages.go - Custom message types +``` + +### Component Encapsulation +One component per file for complex TUIs + +## Debugging + +### Log to File +```go +f, _ := os.OpenFile("debug.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) +log.SetOutput(f) +log.Printf("Debug: %+v", msg) +``` + +### Debug Mode +Toggle debug view with key binding + +## Common Pitfalls + +1. **Forgetting tea.Batch**: Returns only last command +2. **Not handling WindowSizeMsg**: Fixed-size components +3. **Blocking in Update()**: Freezes UI - use commands +4. **Direct terminal writes**: Use tea.Println for above-TUI output +5. **Ignoring ready state**: Rendering before initialization complete diff --git a/.crush/skills/bubbletea-designer/references/bubbletea-components-guide.md b/.crush/skills/bubbletea-designer/references/bubbletea-components-guide.md new file mode 100644 index 00000000..6370aac1 --- /dev/null +++ b/.crush/skills/bubbletea-designer/references/bubbletea-components-guide.md @@ -0,0 +1,141 @@ +# Bubble Tea Components Guide + +Complete reference for Bubble Tea ecosystem components. + +## Core Input Components + +### textinput.Model +**Purpose**: Single-line text input +**Use Cases**: Search boxes, single field forms, command input +**Key Methods**: +- `Focus()` / `Blur()` - Focus management +- `SetValue(string)` - Set text programmatically +- `Value()` - Get current text + +**Example Pattern**: +```go +input := textinput.New() +input.Placeholder = "Search..." +input.Focus() +``` + +### textarea.Model +**Purpose**: Multi-line text editing +**Use Cases**: Message composition, text editing, large text input +**Key Features**: Line wrapping, scrolling, cursor management + +### filepicker.Model +**Purpose**: File system navigation +**Use Cases**: File selection, file browsers +**Key Features**: Directory traversal, file type filtering, path resolution + +## Display Components + +### viewport.Model +**Purpose**: Scrollable content display +**Use Cases**: Log viewers, document readers, large text display +**Key Methods**: +- `SetContent(string)` - Set viewable content +- `GotoTop()` / `GotoBottom()` - Navigation +- `LineUp()` / `LineDown()` - Scroll control + +### table.Model +**Purpose**: Tabular data display +**Use Cases**: Data tables, structured information +**Key Features**: Column definitions, row selection, styling + +### list.Model +**Purpose**: Filterable, navigable lists +**Use Cases**: Item selection, menus, file lists +**Key Features**: Filtering, pagination, custom item delegates + +### paginator.Model +**Purpose**: Page-based navigation +**Use Cases**: Paginated content, chunked display + +## Feedback Components + +### spinner.Model +**Purpose**: Loading/waiting indicator +**Styles**: Dot, Line, Minidot, Jump, Pulse, Points, Globe, Moon, Monkey + +### progress.Model +**Purpose**: Progress indication +**Modes**: Determinate (0-100%), Indeterminate +**Styling**: Gradient, solid color, custom + +### timer.Model +**Purpose**: Countdown timer +**Use Cases**: Timeouts, timed operations + +### stopwatch.Model +**Purpose**: Elapsed time tracking +**Use Cases**: Duration measurement, time tracking + +## Navigation Components + +### tabs +**Purpose**: Tab-based view switching +**Pattern**: Lipgloss-based tab rendering + +### help.Model +**Purpose**: Help text and keyboard shortcuts +**Modes**: Short (inline), Full (overlay) + +## Layout with Lipgloss + +**JoinVertical**: Stack components vertically +**JoinHorizontal**: Place components side-by-side +**Place**: Position with alignment +**Border**: Add borders and padding + +## Component Initialization Pattern + +```go +type model struct { + component1 component1.Model + component2 component2.Model +} + +func (m model) Init() tea.Cmd { + return tea.Batch( + m.component1.Init(), + m.component2.Init(), + ) +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + // Update each component + var cmd tea.Cmd + m.component1, cmd = m.component1.Update(msg) + cmds = append(cmds, cmd) + + m.component2, cmd = m.component2.Update(msg) + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} +``` + +## Message Handling + +**Standard Messages**: +- `tea.KeyMsg` - Keyboard input +- `tea.MouseMsg` - Mouse events +- `tea.WindowSizeMsg` - Terminal resize +- `tea.QuitMsg` - Quit signal + +**Component Messages**: +- `progress.FrameMsg` - Progress/spinner animation +- `spinner.TickMsg` - Spinner tick +- `textinput.ErrMsg` - Input errors + +## Best Practices + +1. **Always delegate**: Let components handle their own messages +2. **Batch commands**: Use `tea.Batch()` for multiple commands +3. **Focus management**: Only one component focused at a time +4. **Dimension tracking**: Update component sizes on `WindowSizeMsg` +5. **State separation**: Keep UI state in model, business logic separate diff --git a/.crush/skills/bubbletea-designer/references/design-patterns.md b/.crush/skills/bubbletea-designer/references/design-patterns.md new file mode 100644 index 00000000..2345ee11 --- /dev/null +++ b/.crush/skills/bubbletea-designer/references/design-patterns.md @@ -0,0 +1,214 @@ +# Bubble Tea Design Patterns + +Common architectural patterns for TUI development. + +## Pattern 1: Single-View Application + +**When**: Simple, focused TUIs with one main view +**Components**: 1-3 components, single model struct +**Complexity**: Low + +```go +type model struct { + mainComponent component.Model + ready bool +} +``` + +## Pattern 2: Multi-View State Machine + +**When**: Multiple distinct screens (setup, main, done) +**Components**: State enum + view-specific components +**Complexity**: Medium + +```go +type view int +const ( + setupView view = iota + mainView + doneView +) + +type model struct { + currentView view + // Components for each view +} +``` + +## Pattern 3: Composable Views + +**When**: Complex UIs with reusable sub-components +**Pattern**: Embed multiple bubble models +**Example**: Dashboard with multiple panels + +```go +type model struct { + panel1 Panel1Model + panel2 Panel2Model + panel3 Panel3Model +} + +// Each panel is itself a Bubble Tea model +``` + +## Pattern 4: Master-Detail + +**When**: Selection in one pane affects display in another +**Example**: File list + preview, Email list + content +**Layout**: Two-pane or three-pane + +```go +type model struct { + list list.Model + detail viewport.Model + selectedItem int +} +``` + +## Pattern 5: Form Flow + +**When**: Multi-step data collection +**Pattern**: Array of inputs + focus management +**Example**: Configuration wizard + +```go +type model struct { + inputs []textinput.Model + focusIndex int + step int +} +``` + +## Pattern 6: Progress Tracker + +**When**: Long-running sequential operations +**Pattern**: Queue + progress per item +**Example**: Installation, download manager + +```go +type model struct { + items []Item + currentIndex int + progress progress.Model + spinner spinner.Model +} +``` + +## Layout Patterns + +### Vertical Stack +```go +lipgloss.JoinVertical(lipgloss.Left, + header, + content, + footer, +) +``` + +### Horizontal Panels +```go +lipgloss.JoinHorizontal(lipgloss.Top, + leftPanel, + separator, + rightPanel, +) +``` + +### Three-Column (File Manager Style) +```go +lipgloss.JoinHorizontal(lipgloss.Top, + parentDir, // 25% width + currentDir, // 35% width + preview, // 40% width +) +``` + +## Message Passing Patterns + +### Custom Messages +```go +type myCustomMsg struct { + data string +} + +func doSomethingCmd() tea.Msg { + return myCustomMsg{data: "result"} +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case myCustomMsg: + // Handle custom message + } +} +``` + +### Async Operations +```go +func fetchDataCmd() tea.Cmd { + return func() tea.Msg { + // Do async work + data := fetchFromAPI() + return dataFetchedMsg{data} + } +} +``` + +## Error Handling Pattern + +```go +type errMsg struct{ err error } + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case errMsg: + m.err = msg.err + m.errVisible = true + return m, nil + } +} +``` + +## Keyboard Navigation Pattern + +```go +case tea.KeyMsg: + switch msg.String() { + case "up", "k": + m.cursor-- + case "down", "j": + m.cursor++ + case "enter": + m.selectCurrent() + case "q", "ctrl+c": + return m, tea.Quit + } +``` + +## Responsive Layout Pattern + +```go +case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + + // Update component dimensions + m.viewport.Width = msg.Width + m.viewport.Height = msg.Height - 5 // Reserve space for header/footer +``` + +## Help Overlay Pattern + +```go +type model struct { + showHelp bool + help help.Model +} + +func (m model) View() string { + if m.showHelp { + return m.help.View() + } + return m.mainView() +} +``` diff --git a/.crush/skills/bubbletea-designer/references/example-designs.md b/.crush/skills/bubbletea-designer/references/example-designs.md new file mode 100644 index 00000000..ca1b96de --- /dev/null +++ b/.crush/skills/bubbletea-designer/references/example-designs.md @@ -0,0 +1,98 @@ +# Example TUI Designs + +Real-world design examples with component selections. + +## Example 1: Log Viewer + +**Requirements**: View large log files, search, navigate +**Archetype**: Viewer +**Components**: +- viewport.Model - Main log display +- textinput.Model - Search input +- help.Model - Keyboard shortcuts + +**Architecture**: +```go +type model struct { + viewport viewport.Model + searchInput textinput.Model + searchMode bool + matches []int + currentMatch int +} +``` + +**Key Features**: +- Toggle search with `/` +- Navigate matches with n/N +- Highlight matches in viewport + +## Example 2: File Manager + +**Requirements**: Three-column navigation, preview +**Archetype**: File Manager +**Components**: +- list.Model (x2) - Parent + current directory +- viewport.Model - File preview +- filepicker.Model - Alternative approach + +**Layout**: Horizontal three-pane +**Complexity**: Medium-High + +## Example 3: Package Installer + +**Requirements**: Sequential installation with progress +**Archetype**: Installer +**Components**: +- list.Model - Package list +- progress.Model - Per-package progress +- spinner.Model - Download indicator + +**Pattern**: Progress Tracker +**Workflow**: Queue-based sequential processing + +## Example 4: Configuration Wizard + +**Requirements**: Multi-step form with validation +**Archetype**: Form +**Components**: +- textinput.Model array - Multiple inputs +- help.Model - Per-step help +- progress/indicator - Step progress + +**Pattern**: Form Flow +**Navigation**: Tab between fields, Enter to next step + +## Example 5: Dashboard + +**Requirements**: Multiple views, real-time updates +**Archetype**: Dashboard +**Components**: +- tabs - View switching +- table.Model - Data display +- viewport.Model - Log panel + +**Pattern**: Composable Views +**Layout**: Tabbed with multiple panels per tab + +## Component Selection Guide + +| Use Case | Primary Component | Alternative | Supporting | +|----------|------------------|-------------|-----------| +| Log viewing | viewport | pager | textinput (search) | +| File selection | filepicker | list | viewport (preview) | +| Data table | table | list | paginator | +| Text editing | textarea | textinput | viewport | +| Progress | progress | spinner | - | +| Multi-step | views | tabs | help | +| Search/Filter | textinput | autocomplete | list | + +## Complexity Matrix + +| TUI Type | Components | Views | Estimated Time | +|----------|-----------|-------|----------------| +| Simple viewer | 1-2 | 1 | 1-2 hours | +| File manager | 3-4 | 1 | 3-4 hours | +| Installer | 3-4 | 3 | 2-3 hours | +| Dashboard | 4-6 | 3+ | 4-6 hours | +| Editor | 2-3 | 1-2 | 3-4 hours | diff --git a/.crush/skills/bubbletea-designer/scripts/__pycache__/analyze_requirements.cpython-311.pyc b/.crush/skills/bubbletea-designer/scripts/__pycache__/analyze_requirements.cpython-311.pyc new file mode 100644 index 00000000..8a9a6296 Binary files /dev/null and b/.crush/skills/bubbletea-designer/scripts/__pycache__/analyze_requirements.cpython-311.pyc differ diff --git a/.crush/skills/bubbletea-designer/scripts/__pycache__/design_architecture.cpython-311.pyc b/.crush/skills/bubbletea-designer/scripts/__pycache__/design_architecture.cpython-311.pyc new file mode 100644 index 00000000..18ce7d4d Binary files /dev/null and b/.crush/skills/bubbletea-designer/scripts/__pycache__/design_architecture.cpython-311.pyc differ diff --git a/.crush/skills/bubbletea-designer/scripts/__pycache__/design_tui.cpython-311.pyc b/.crush/skills/bubbletea-designer/scripts/__pycache__/design_tui.cpython-311.pyc new file mode 100644 index 00000000..bab50ecc Binary files /dev/null and b/.crush/skills/bubbletea-designer/scripts/__pycache__/design_tui.cpython-311.pyc differ diff --git a/.crush/skills/bubbletea-designer/scripts/__pycache__/generate_workflow.cpython-311.pyc b/.crush/skills/bubbletea-designer/scripts/__pycache__/generate_workflow.cpython-311.pyc new file mode 100644 index 00000000..47eaeb00 Binary files /dev/null and b/.crush/skills/bubbletea-designer/scripts/__pycache__/generate_workflow.cpython-311.pyc differ diff --git a/.crush/skills/bubbletea-designer/scripts/__pycache__/map_components.cpython-311.pyc b/.crush/skills/bubbletea-designer/scripts/__pycache__/map_components.cpython-311.pyc new file mode 100644 index 00000000..b351add3 Binary files /dev/null and b/.crush/skills/bubbletea-designer/scripts/__pycache__/map_components.cpython-311.pyc differ diff --git a/.crush/skills/bubbletea-designer/scripts/__pycache__/select_patterns.cpython-311.pyc b/.crush/skills/bubbletea-designer/scripts/__pycache__/select_patterns.cpython-311.pyc new file mode 100644 index 00000000..a7dc037b Binary files /dev/null and b/.crush/skills/bubbletea-designer/scripts/__pycache__/select_patterns.cpython-311.pyc differ diff --git a/.crush/skills/bubbletea-designer/scripts/analyze_requirements.py b/.crush/skills/bubbletea-designer/scripts/analyze_requirements.py new file mode 100644 index 00000000..e1ab722b --- /dev/null +++ b/.crush/skills/bubbletea-designer/scripts/analyze_requirements.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +""" +Requirement analyzer for Bubble Tea TUIs. +Extracts structured requirements from natural language. +""" + +import re +from typing import Dict, List +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +from utils.validators import RequirementValidator + + +# TUI archetype keywords +ARCHETYPE_KEYWORDS = { + 'file-manager': ['file', 'directory', 'browse', 'navigator', 'ranger', 'three-column'], + 'installer': ['install', 'package', 'progress', 'setup', 'installation'], + 'dashboard': ['dashboard', 'monitor', 'real-time', 'metrics', 'status'], + 'form': ['form', 'input', 'wizard', 'configuration', 'settings'], + 'viewer': ['view', 'display', 'log', 'text', 'document', 'reader'], + 'chat': ['chat', 'message', 'conversation', 'messaging'], + 'table-viewer': ['table', 'data', 'spreadsheet', 'grid'], + 'menu': ['menu', 'select', 'choose', 'options'], + 'editor': ['edit', 'editor', 'compose', 'write'] +} + + +def extract_requirements(description: str) -> Dict: + """ + Extract structured requirements from description. + + Args: + description: Natural language TUI description + + Returns: + Dictionary with structured requirements + + Example: + >>> reqs = extract_requirements("Build a log viewer with search") + >>> reqs['archetype'] + 'viewer' + """ + # Validate input + validator = RequirementValidator() + validation = validator.validate_description(description) + + desc_lower = description.lower() + + # Extract archetype + archetype = classify_tui_type(description) + + # Extract features + features = identify_features(description) + + # Extract interactions + interactions = identify_interactions(description) + + # Extract data types + data_types = identify_data_types(description) + + # Determine view type + views = determine_view_type(description) + + # Special requirements + special = identify_special_requirements(description) + + requirements = { + 'archetype': archetype, + 'features': features, + 'interactions': interactions, + 'data_types': data_types, + 'views': views, + 'special_requirements': special, + 'original_description': description, + 'validation': validation.to_dict() + } + + return requirements + + +def classify_tui_type(description: str) -> str: + """Classify TUI archetype from description.""" + desc_lower = description.lower() + + # Score each archetype + scores = {} + for archetype, keywords in ARCHETYPE_KEYWORDS.items(): + score = sum(1 for kw in keywords if kw in desc_lower) + if score > 0: + scores[archetype] = score + + if not scores: + return 'general' + + # Return highest scoring archetype + return max(scores.items(), key=lambda x: x[1])[0] + + +def identify_features(description: str) -> List[str]: + """Identify features from description.""" + features = [] + desc_lower = description.lower() + + feature_keywords = { + 'navigation': ['navigate', 'move', 'browse', 'arrow'], + 'selection': ['select', 'choose', 'pick'], + 'search': ['search', 'find', 'filter', 'query'], + 'editing': ['edit', 'modify', 'change', 'update'], + 'display': ['display', 'show', 'view', 'render'], + 'input': ['input', 'enter', 'type'], + 'progress': ['progress', 'loading', 'install'], + 'preview': ['preview', 'peek', 'preview pane'], + 'scrolling': ['scroll', 'scrollable'], + 'sorting': ['sort', 'order', 'rank'], + 'filtering': ['filter', 'narrow'], + 'highlighting': ['highlight', 'emphasize', 'mark'] + } + + for feature, keywords in feature_keywords.items(): + if any(kw in desc_lower for kw in keywords): + features.append(feature) + + return features if features else ['display'] + + +def identify_interactions(description: str) -> Dict[str, List[str]]: + """Identify user interaction types.""" + desc_lower = description.lower() + + keyboard = [] + mouse = [] + + # Keyboard interactions + kbd_keywords = { + 'navigation': ['arrow', 'hjkl', 'navigate', 'move'], + 'selection': ['enter', 'select', 'choose'], + 'search': ['/', 'search', 'find'], + 'quit': ['q', 'quit', 'exit', 'esc'], + 'help': ['?', 'help'] + } + + for interaction, keywords in kbd_keywords.items(): + if any(kw in desc_lower for kw in keywords): + keyboard.append(interaction) + + # Default keyboard interactions + if not keyboard: + keyboard = ['navigation', 'selection', 'quit'] + + # Mouse interactions + if any(word in desc_lower for word in ['mouse', 'click', 'drag']): + mouse = ['click', 'scroll'] + + return { + 'keyboard': keyboard, + 'mouse': mouse + } + + +def identify_data_types(description: str) -> List[str]: + """Identify data types being displayed.""" + desc_lower = description.lower() + + data_type_keywords = { + 'files': ['file', 'directory', 'folder'], + 'text': ['text', 'log', 'document'], + 'tabular': ['table', 'data', 'rows', 'columns'], + 'messages': ['message', 'chat', 'conversation'], + 'packages': ['package', 'dependency', 'module'], + 'metrics': ['metric', 'stat', 'data point'], + 'config': ['config', 'setting', 'option'] + } + + data_types = [] + for dtype, keywords in data_type_keywords.items(): + if any(kw in desc_lower for kw in keywords): + data_types.append(dtype) + + return data_types if data_types else ['text'] + + +def determine_view_type(description: str) -> str: + """Determine if single or multi-view.""" + desc_lower = description.lower() + + multi_keywords = ['multi-view', 'multiple view', 'tabs', 'tabbed', 'switch', 'views'] + three_pane_keywords = ['three', 'three-column', 'three pane'] + + if any(kw in desc_lower for kw in three_pane_keywords): + return 'three-pane' + elif any(kw in desc_lower for kw in multi_keywords): + return 'multi' + else: + return 'single' + + +def identify_special_requirements(description: str) -> List[str]: + """Identify special requirements.""" + desc_lower = description.lower() + special = [] + + special_keywords = { + 'validation': ['validate', 'validation', 'check'], + 'real-time': ['real-time', 'live', 'streaming'], + 'async': ['async', 'background', 'concurrent'], + 'persistence': ['save', 'persist', 'store'], + 'theming': ['theme', 'color', 'style'] + } + + for req, keywords in special_keywords.items(): + if any(kw in desc_lower for kw in keywords): + special.append(req) + + return special + + +def main(): + """Test requirement analyzer.""" + print("Testing Requirement Analyzer\n" + "=" * 50) + + test_cases = [ + "Build a log viewer with search and highlighting", + "Create a file manager with three-column view", + "Design an installer with progress bars", + "Make a form wizard with validation" + ] + + for i, desc in enumerate(test_cases, 1): + print(f"\n{i}. Testing: '{desc}'") + reqs = extract_requirements(desc) + print(f" Archetype: {reqs['archetype']}") + print(f" Features: {', '.join(reqs['features'])}") + print(f" Data types: {', '.join(reqs['data_types'])}") + print(f" View type: {reqs['views']}") + print(f" Validation: {reqs['validation']['summary']}") + + print("\n✅ All tests passed!") + + +if __name__ == "__main__": + main() diff --git a/.crush/skills/bubbletea-designer/scripts/design_architecture.py b/.crush/skills/bubbletea-designer/scripts/design_architecture.py new file mode 100644 index 00000000..9402dadb --- /dev/null +++ b/.crush/skills/bubbletea-designer/scripts/design_architecture.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Architecture designer for Bubble Tea TUIs.""" + +import sys +from pathlib import Path +from typing import Dict, List + +sys.path.insert(0, str(Path(__file__).parent)) + +from utils.template_generator import ( + generate_model_struct, + generate_init_function, + generate_update_skeleton, + generate_view_skeleton +) +from utils.ascii_diagram import ( + draw_component_tree, + draw_message_flow, + draw_state_machine +) +from utils.validators import DesignValidator + + +def design_architecture(components: Dict, patterns: Dict, requirements: Dict) -> Dict: + """Design TUI architecture.""" + primary = components.get('primary_components', []) + comp_names = [c['component'].replace('.Model', '') for c in primary] + archetype = requirements.get('archetype', 'general') + views = requirements.get('views', 'single') + + # Generate code structures + model_struct = generate_model_struct(comp_names, archetype) + init_logic = generate_init_function(comp_names) + message_handlers = { + 'tea.KeyMsg': 'Handle keyboard input (arrows, enter, q, etc.)', + 'tea.WindowSizeMsg': 'Handle window resize, update component dimensions' + } + + # Add component-specific handlers + if 'progress' in comp_names or 'spinner' in comp_names: + message_handlers['progress.FrameMsg'] = 'Update progress/spinner animation' + + view_logic = generate_view_skeleton(comp_names) + + # Generate diagrams + diagrams = { + 'component_hierarchy': draw_component_tree(comp_names, archetype), + 'message_flow': draw_message_flow(list(message_handlers.keys())) + } + + if views == 'multi': + diagrams['state_machine'] = draw_state_machine(['View 1', 'View 2', 'View 3']) + + architecture = { + 'model_struct': model_struct, + 'init_logic': init_logic, + 'message_handlers': message_handlers, + 'view_logic': view_logic, + 'diagrams': diagrams + } + + # Validate + validator = DesignValidator() + validation = validator.validate_architecture(architecture) + architecture['validation'] = validation.to_dict() + + return architecture diff --git a/.crush/skills/bubbletea-designer/scripts/design_tui.py b/.crush/skills/bubbletea-designer/scripts/design_tui.py new file mode 100644 index 00000000..6bd28443 --- /dev/null +++ b/.crush/skills/bubbletea-designer/scripts/design_tui.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +""" +Main TUI designer orchestrator. +Combines all analyses into comprehensive design report. +""" + +import sys +import argparse +from pathlib import Path +from typing import Dict, Optional, List + +sys.path.insert(0, str(Path(__file__).parent)) + +from analyze_requirements import extract_requirements +from map_components import map_to_components +from select_patterns import select_relevant_patterns +from design_architecture import design_architecture +from generate_workflow import generate_implementation_workflow +from utils.helpers import get_timestamp +from utils.template_generator import generate_main_go +from utils.validators import DesignValidator + + +def comprehensive_tui_design_report( + description: str, + inventory_path: Optional[str] = None, + include_sections: Optional[List[str]] = None, + detail_level: str = "complete" +) -> Dict: + """ + Generate comprehensive TUI design report. + + This is the all-in-one function that combines all design analyses. + + Args: + description: Natural language TUI description + inventory_path: Path to charm-examples-inventory + include_sections: Which sections to include (None = all) + detail_level: "summary" | "detailed" | "complete" + + Returns: + Complete design report dictionary with all sections + + Example: + >>> report = comprehensive_tui_design_report( + ... "Build a log viewer with search" + ... ) + >>> print(report['summary']) + "TUI Design: Log Viewer..." + """ + if include_sections is None: + include_sections = ['requirements', 'components', 'patterns', 'architecture', 'workflow'] + + report = { + 'description': description, + 'generated_at': get_timestamp(), + 'sections': {} + } + + # Phase 1: Requirements Analysis + if 'requirements' in include_sections: + requirements = extract_requirements(description) + report['sections']['requirements'] = requirements + report['tui_type'] = requirements['archetype'] + else: + requirements = extract_requirements(description) + report['tui_type'] = requirements.get('archetype', 'general') + + # Phase 2: Component Mapping + if 'components' in include_sections: + components = map_to_components(requirements) + report['sections']['components'] = components + else: + components = map_to_components(requirements) + + # Phase 3: Pattern Selection + if 'patterns' in include_sections: + patterns = select_relevant_patterns(components, inventory_path) + report['sections']['patterns'] = patterns + else: + patterns = {'examples': []} + + # Phase 4: Architecture Design + if 'architecture' in include_sections: + architecture = design_architecture(components, patterns, requirements) + report['sections']['architecture'] = architecture + else: + architecture = design_architecture(components, patterns, requirements) + + # Phase 5: Workflow Generation + if 'workflow' in include_sections: + workflow = generate_implementation_workflow(architecture, patterns) + report['sections']['workflow'] = workflow + + # Generate summary + report['summary'] = _generate_summary(report, requirements, components) + + # Generate code scaffolding + if detail_level == "complete": + primary_comps = [ + c['component'].replace('.Model', '') + for c in components.get('primary_components', [])[:3] + ] + report['scaffolding'] = { + 'main_go': generate_main_go(primary_comps, requirements.get('archetype', 'general')) + } + + # File structure recommendation + report['file_structure'] = { + 'recommended': ['main.go', 'go.mod', 'README.md'] + } + + # Next steps + report['next_steps'] = _generate_next_steps(patterns, workflow if 'workflow' in report['sections'] else None) + + # Resources + report['resources'] = { + 'documentation': [ + 'https://github.com/charmbracelet/bubbletea', + 'https://github.com/charmbracelet/lipgloss' + ], + 'tutorials': [ + 'Bubble Tea tutorial: https://github.com/charmbracelet/bubbletea/tree/master/tutorials' + ], + 'community': [ + 'Charm Discord: https://charm.sh/chat' + ] + } + + # Overall validation + validator = DesignValidator() + validation = validator.validate_design_report(report) + report['validation'] = validation.to_dict() + + return report + + +def _generate_summary(report: Dict, requirements: Dict, components: Dict) -> str: + """Generate executive summary.""" + tui_type = requirements.get('archetype', 'general') + features = requirements.get('features', []) + primary = components.get('primary_components', []) + + summary_parts = [ + f"TUI Design: {tui_type.replace('-', ' ').title()}", + f"\nPurpose: {report.get('description', 'N/A')}", + f"\nKey Features: {', '.join(features)}", + f"\nPrimary Components: {', '.join([c['component'] for c in primary[:3]])}", + ] + + if 'workflow' in report.get('sections', {}): + summary_parts.append( + f"\nEstimated Implementation Time: {report['sections']['workflow'].get('total_estimated_time', 'N/A')}" + ) + + return '\n'.join(summary_parts) + + +def _generate_next_steps(patterns: Dict, workflow: Optional[Dict]) -> List[str]: + """Generate next steps list.""" + steps = ['1. Review the architecture diagram and component selection'] + + examples = patterns.get('examples', []) + if examples: + steps.append(f'2. Study example files: {examples[0]["file"]}') + + if workflow: + steps.append('3. Follow the implementation workflow starting with Phase 1') + steps.append('4. Test at each checkpoint') + + steps.append('5. Refer to Bubble Tea documentation for component details') + + return steps + + +def main(): + """CLI for TUI designer.""" + parser = argparse.ArgumentParser(description='Bubble Tea TUI Designer') + parser.add_argument('description', help='TUI description') + parser.add_argument('--inventory', help='Path to charm-examples-inventory') + parser.add_argument('--detail', choices=['summary', 'detailed', 'complete'], default='complete') + + args = parser.parse_args() + + print("=" * 60) + print("Bubble Tea TUI Designer") + print("=" * 60) + + report = comprehensive_tui_design_report( + args.description, + inventory_path=args.inventory, + detail_level=args.detail + ) + + print(f"\n{report['summary']}") + + if 'architecture' in report['sections']: + print("\n" + "=" * 60) + print("ARCHITECTURE") + print("=" * 60) + print(report['sections']['architecture']['diagrams']['component_hierarchy']) + + if 'workflow' in report['sections']: + print("\n" + "=" * 60) + print("IMPLEMENTATION WORKFLOW") + print("=" * 60) + for phase in report['sections']['workflow']['phases']: + print(f"\n{phase['name']} ({phase['total_time']})") + for task in phase['tasks']: + print(f" - {task['task']}") + + print("\n" + "=" * 60) + print("NEXT STEPS") + print("=" * 60) + for step in report['next_steps']: + print(step) + + print("\n" + "=" * 60) + print(f"Validation: {report['validation']['summary']}") + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/.crush/skills/bubbletea-designer/scripts/generate_workflow.py b/.crush/skills/bubbletea-designer/scripts/generate_workflow.py new file mode 100644 index 00000000..55ec43be --- /dev/null +++ b/.crush/skills/bubbletea-designer/scripts/generate_workflow.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Workflow generator for TUI implementation.""" + +import sys +from pathlib import Path +from typing import Dict, List + +sys.path.insert(0, str(Path(__file__).parent)) + +from utils.helpers import estimate_complexity +from utils.validators import DesignValidator + + +def generate_implementation_workflow(architecture: Dict, patterns: Dict) -> Dict: + """Generate step-by-step implementation workflow.""" + comp_count = len(architecture.get('model_struct', '').split('\n')) // 2 + examples = patterns.get('examples', []) + + phases = [ + { + 'name': 'Phase 1: Setup', + 'tasks': [ + {'task': 'Initialize Go module', 'estimated_time': '2 minutes'}, + {'task': 'Install Bubble Tea and dependencies', 'estimated_time': '3 minutes'}, + {'task': 'Create main.go with basic structure', 'estimated_time': '5 minutes'} + ], + 'total_time': '10 minutes' + }, + { + 'name': 'Phase 2: Core Components', + 'tasks': [ + {'task': 'Implement model struct', 'estimated_time': '15 minutes'}, + {'task': 'Add Init() function', 'estimated_time': '10 minutes'}, + {'task': 'Implement basic Update() handler', 'estimated_time': '20 minutes'}, + {'task': 'Create basic View()', 'estimated_time': '15 minutes'} + ], + 'total_time': '60 minutes' + }, + { + 'name': 'Phase 3: Integration', + 'tasks': [ + {'task': 'Connect components', 'estimated_time': '30 minutes'}, + {'task': 'Add message passing', 'estimated_time': '20 minutes'}, + {'task': 'Implement full keyboard handling', 'estimated_time': '20 minutes'} + ], + 'total_time': '70 minutes' + }, + { + 'name': 'Phase 4: Polish', + 'tasks': [ + {'task': 'Add Lipgloss styling', 'estimated_time': '30 minutes'}, + {'task': 'Add help text', 'estimated_time': '15 minutes'}, + {'task': 'Error handling', 'estimated_time': '15 minutes'} + ], + 'total_time': '60 minutes' + } + ] + + testing_checkpoints = [ + 'After Phase 1: go build succeeds', + 'After Phase 2: Basic TUI renders', + 'After Phase 3: All interactions work', + 'After Phase 4: Production ready' + ] + + workflow = { + 'phases': phases, + 'testing_checkpoints': testing_checkpoints, + 'total_estimated_time': estimate_complexity(comp_count) + } + + # Validate + validator = DesignValidator() + validation = validator.validate_workflow_completeness(workflow) + workflow['validation'] = validation.to_dict() + + return workflow diff --git a/.crush/skills/bubbletea-designer/scripts/map_components.py b/.crush/skills/bubbletea-designer/scripts/map_components.py new file mode 100644 index 00000000..4b4a03d3 --- /dev/null +++ b/.crush/skills/bubbletea-designer/scripts/map_components.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +""" +Component mapper for Bubble Tea TUIs. +Maps requirements to appropriate components. +""" + +import sys +from pathlib import Path +from typing import Dict, List + +sys.path.insert(0, str(Path(__file__).parent)) + +from utils.component_matcher import ( + match_score, + find_best_match, + get_alternatives, + explain_match, + rank_components_by_relevance +) +from utils.validators import DesignValidator + + +def map_to_components(requirements: Dict, inventory=None) -> Dict: + """ + Map requirements to Bubble Tea components. + + Args: + requirements: Structured requirements from analyze_requirements + inventory: Optional inventory object (unused for now) + + Returns: + Dictionary with component recommendations + + Example: + >>> components = map_to_components(reqs) + >>> components['primary_components'][0]['component'] + 'viewport.Model' + """ + features = requirements.get('features', []) + archetype = requirements.get('archetype', 'general') + data_types = requirements.get('data_types', []) + views = requirements.get('views', 'single') + + # Get ranked components + ranked = rank_components_by_relevance(features, min_score=50) + + # Build primary components list + primary_components = [] + for component, score, matching_features in ranked[:5]: # Top 5 + justification = explain_match(component, ' '.join(matching_features), score) + + primary_components.append({ + 'component': f'{component}.Model', + 'score': score, + 'justification': justification, + 'example_file': f'examples/{component}/main.go', + 'key_patterns': [f'{component} usage', 'initialization', 'message handling'] + }) + + # Add archetype-specific components + archetype_components = _get_archetype_components(archetype) + for comp in archetype_components: + if not any(c['component'].startswith(comp) for c in primary_components): + primary_components.append({ + 'component': f'{comp}.Model', + 'score': 70, + 'justification': f'Standard component for {archetype} TUIs', + 'example_file': f'examples/{comp}/main.go', + 'key_patterns': [f'{comp} patterns'] + }) + + # Supporting components + supporting = _get_supporting_components(features, views) + + # Styling + styling = ['lipgloss for layout and styling'] + if 'highlighting' in features: + styling.append('lipgloss for text highlighting') + + # Alternatives + alternatives = {} + for comp in primary_components[:3]: + comp_name = comp['component'].replace('.Model', '') + alts = get_alternatives(comp_name) + if alts: + alternatives[comp['component']] = [f'{alt}.Model' for alt in alts] + + result = { + 'primary_components': primary_components, + 'supporting_components': supporting, + 'styling': styling, + 'alternatives': alternatives + } + + # Validate + validator = DesignValidator() + validation = validator.validate_component_selection(result, requirements) + + result['validation'] = validation.to_dict() + + return result + + +def _get_archetype_components(archetype: str) -> List[str]: + """Get standard components for archetype.""" + archetype_map = { + 'file-manager': ['filepicker', 'viewport', 'list'], + 'installer': ['progress', 'spinner', 'list'], + 'dashboard': ['tabs', 'viewport', 'table'], + 'form': ['textinput', 'textarea', 'help'], + 'viewer': ['viewport', 'paginator', 'textinput'], + 'chat': ['viewport', 'textarea', 'textinput'], + 'table-viewer': ['table', 'paginator'], + 'menu': ['list'], + 'editor': ['textarea', 'viewport'] + } + return archetype_map.get(archetype, []) + + +def _get_supporting_components(features: List[str], views: str) -> List[str]: + """Get supporting components based on features.""" + supporting = [] + + if views in ['multi', 'three-pane']: + supporting.append('Multiple viewports for multi-pane layout') + + if 'help' not in features: + supporting.append('help.Model for keyboard shortcuts') + + if views == 'multi': + supporting.append('tabs.Model or state machine for view switching') + + return supporting + + +def main(): + """Test component mapper.""" + print("Testing Component Mapper\n" + "=" * 50) + + # Mock requirements + requirements = { + 'archetype': 'viewer', + 'features': ['display', 'search', 'scrolling'], + 'data_types': ['text'], + 'views': 'single' + } + + print("\n1. Testing map_to_components()...") + components = map_to_components(requirements) + + print(f" Primary components: {len(components['primary_components'])}") + for comp in components['primary_components'][:3]: + print(f" - {comp['component']} (score: {comp['score']})") + + print(f"\n Validation: {components['validation']['summary']}") + + print("\n✅ Tests passed!") + + +if __name__ == "__main__": + main() diff --git a/.crush/skills/bubbletea-designer/scripts/select_patterns.py b/.crush/skills/bubbletea-designer/scripts/select_patterns.py new file mode 100644 index 00000000..acc8c1b9 --- /dev/null +++ b/.crush/skills/bubbletea-designer/scripts/select_patterns.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Pattern selector - finds relevant example files.""" + +import sys +from pathlib import Path +from typing import Dict, List, Optional + +sys.path.insert(0, str(Path(__file__).parent)) + +from utils.inventory_loader import load_inventory, Inventory + + +def select_relevant_patterns(components: Dict, inventory_path: Optional[str] = None) -> Dict: + """Select relevant example files.""" + try: + inventory = load_inventory(inventory_path) + except Exception as e: + return {'examples': [], 'error': str(e)} + + primary_components = components.get('primary_components', []) + examples = [] + + for comp_info in primary_components[:3]: + comp_name = comp_info['component'].replace('.Model', '') + comp_examples = inventory.get_by_component(comp_name) + + for ex in comp_examples[:2]: + examples.append({ + 'file': ex.file_path, + 'capability': ex.capability, + 'relevance_score': comp_info['score'], + 'key_patterns': ex.key_patterns, + 'study_order': len(examples) + 1 + }) + + return { + 'examples': examples, + 'recommended_study_order': list(range(1, len(examples) + 1)), + 'total_study_time': f"{len(examples) * 15} minutes" + } diff --git a/.crush/skills/bubbletea-designer/scripts/utils/__pycache__/ascii_diagram.cpython-311.pyc b/.crush/skills/bubbletea-designer/scripts/utils/__pycache__/ascii_diagram.cpython-311.pyc new file mode 100644 index 00000000..1d4cdeea Binary files /dev/null and b/.crush/skills/bubbletea-designer/scripts/utils/__pycache__/ascii_diagram.cpython-311.pyc differ diff --git a/.crush/skills/bubbletea-designer/scripts/utils/__pycache__/component_matcher.cpython-311.pyc b/.crush/skills/bubbletea-designer/scripts/utils/__pycache__/component_matcher.cpython-311.pyc new file mode 100644 index 00000000..25a955b6 Binary files /dev/null and b/.crush/skills/bubbletea-designer/scripts/utils/__pycache__/component_matcher.cpython-311.pyc differ diff --git a/.crush/skills/bubbletea-designer/scripts/utils/__pycache__/helpers.cpython-311.pyc b/.crush/skills/bubbletea-designer/scripts/utils/__pycache__/helpers.cpython-311.pyc new file mode 100644 index 00000000..aabb9531 Binary files /dev/null and b/.crush/skills/bubbletea-designer/scripts/utils/__pycache__/helpers.cpython-311.pyc differ diff --git a/.crush/skills/bubbletea-designer/scripts/utils/__pycache__/inventory_loader.cpython-311.pyc b/.crush/skills/bubbletea-designer/scripts/utils/__pycache__/inventory_loader.cpython-311.pyc new file mode 100644 index 00000000..d0ad0673 Binary files /dev/null and b/.crush/skills/bubbletea-designer/scripts/utils/__pycache__/inventory_loader.cpython-311.pyc differ diff --git a/.crush/skills/bubbletea-designer/scripts/utils/__pycache__/template_generator.cpython-311.pyc b/.crush/skills/bubbletea-designer/scripts/utils/__pycache__/template_generator.cpython-311.pyc new file mode 100644 index 00000000..0034d1ba Binary files /dev/null and b/.crush/skills/bubbletea-designer/scripts/utils/__pycache__/template_generator.cpython-311.pyc differ diff --git a/.crush/skills/bubbletea-designer/scripts/utils/ascii_diagram.py b/.crush/skills/bubbletea-designer/scripts/utils/ascii_diagram.py new file mode 100644 index 00000000..3545d471 --- /dev/null +++ b/.crush/skills/bubbletea-designer/scripts/utils/ascii_diagram.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +""" +ASCII diagram generator for architecture visualization. +""" + +from typing import List, Dict + + +def draw_component_tree(components: List[str], archetype: str) -> str: + """Draw component hierarchy as ASCII tree.""" + lines = [ + "┌─────────────────────────────────────┐", + "│ Main Model │", + "├─────────────────────────────────────┤" + ] + + # Add state fields + lines.append("│ Components: │") + for comp in components: + lines.append(f"│ - {comp:<30} │") + + lines.append("└────────────┬───────────────┬────────┘") + + # Add component boxes below + if len(components) >= 2: + comp_boxes = [] + for comp in components[:3]: # Show max 3 + comp_boxes.append(f" ┌────▼────┐") + comp_boxes.append(f" │ {comp:<7} │") + comp_boxes.append(f" └─────────┘") + return "\n".join(lines) + "\n" + "\n".join(comp_boxes) + + return "\n".join(lines) + + +def draw_message_flow(messages: List[str]) -> str: + """Draw message flow diagram.""" + flow = ["Message Flow:"] + flow.append("") + flow.append("User Input → tea.KeyMsg → Update() →") + for msg in messages: + flow.append(f" {msg} →") + flow.append(" Model Updated → View() → Render") + return "\n".join(flow) + + +def draw_state_machine(states: List[str]) -> str: + """Draw state machine diagram.""" + if not states or len(states) < 2: + return "Single-state application (no state machine)" + + diagram = ["State Machine:", ""] + for i, state in enumerate(states): + if i < len(states) - 1: + diagram.append(f"{state} → {states[i+1]}") + else: + diagram.append(f"{state} → Done") + + return "\n".join(diagram) diff --git a/.crush/skills/bubbletea-designer/scripts/utils/component_matcher.py b/.crush/skills/bubbletea-designer/scripts/utils/component_matcher.py new file mode 100644 index 00000000..c192da7b --- /dev/null +++ b/.crush/skills/bubbletea-designer/scripts/utils/component_matcher.py @@ -0,0 +1,379 @@ +#!/usr/bin/env python3 +""" +Component matching logic for Bubble Tea Designer. +Scores and ranks components based on requirements. +""" + +from typing import Dict, List, Tuple +import logging + +logger = logging.getLogger(__name__) + + +# Component capability definitions +COMPONENT_CAPABILITIES = { + 'viewport': { + 'keywords': ['scroll', 'view', 'display', 'content', 'pager', 'document'], + 'use_cases': ['viewing large text', 'log viewer', 'document reader'], + 'complexity': 'medium' + }, + 'textinput': { + 'keywords': ['input', 'text', 'search', 'query', 'single-line'], + 'use_cases': ['search box', 'text input', 'single field'], + 'complexity': 'low' + }, + 'textarea': { + 'keywords': ['edit', 'multi-line', 'text area', 'editor', 'compose'], + 'use_cases': ['text editing', 'message composition', 'multi-line input'], + 'complexity': 'medium' + }, + 'table': { + 'keywords': ['table', 'tabular', 'rows', 'columns', 'grid', 'data display'], + 'use_cases': ['data table', 'spreadsheet view', 'structured data'], + 'complexity': 'medium' + }, + 'list': { + 'keywords': ['list', 'items', 'select', 'choose', 'menu', 'options'], + 'use_cases': ['item selection', 'menu', 'file list'], + 'complexity': 'medium' + }, + 'progress': { + 'keywords': ['progress', 'loading', 'installation', 'percent', 'bar'], + 'use_cases': ['progress indication', 'loading', 'installation progress'], + 'complexity': 'low' + }, + 'spinner': { + 'keywords': ['loading', 'spinner', 'wait', 'processing', 'busy'], + 'use_cases': ['loading indicator', 'waiting', 'processing'], + 'complexity': 'low' + }, + 'filepicker': { + 'keywords': ['file', 'select file', 'choose file', 'file system', 'browse'], + 'use_cases': ['file selection', 'file browser', 'file chooser'], + 'complexity': 'medium' + }, + 'paginator': { + 'keywords': ['page', 'pagination', 'pages', 'navigate pages'], + 'use_cases': ['page navigation', 'chunked content', 'paged display'], + 'complexity': 'low' + }, + 'timer': { + 'keywords': ['timer', 'countdown', 'timeout', 'time limit'], + 'use_cases': ['countdown', 'timeout', 'timed operation'], + 'complexity': 'low' + }, + 'stopwatch': { + 'keywords': ['stopwatch', 'elapsed', 'time tracking', 'duration'], + 'use_cases': ['time tracking', 'elapsed time', 'duration measurement'], + 'complexity': 'low' + }, + 'help': { + 'keywords': ['help', 'shortcuts', 'keybindings', 'documentation'], + 'use_cases': ['help menu', 'keyboard shortcuts', 'documentation'], + 'complexity': 'low' + }, + 'tabs': { + 'keywords': ['tabs', 'tabbed', 'switch views', 'navigation'], + 'use_cases': ['tab navigation', 'multiple views', 'view switching'], + 'complexity': 'medium' + }, + 'autocomplete': { + 'keywords': ['autocomplete', 'suggestions', 'completion', 'dropdown'], + 'use_cases': ['autocomplete', 'suggestions', 'smart input'], + 'complexity': 'medium' + } +} + + +def match_score(requirement: str, component: str) -> int: + """ + Calculate relevance score for component given requirement. + + Args: + requirement: Feature requirement description + component: Component name + + Returns: + Score from 0-100 (higher = better match) + + Example: + >>> match_score("scrollable log display", "viewport") + 95 + """ + if component not in COMPONENT_CAPABILITIES: + return 0 + + score = 0 + requirement_lower = requirement.lower() + comp_info = COMPONENT_CAPABILITIES[component] + + # Keyword matching (60 points max) + keywords = comp_info['keywords'] + keyword_matches = sum(1 for kw in keywords if kw in requirement_lower) + keyword_score = min(60, (keyword_matches / len(keywords)) * 60) + score += keyword_score + + # Use case matching (40 points max) + use_cases = comp_info['use_cases'] + use_case_matches = sum(1 for uc in use_cases if any( + word in requirement_lower for word in uc.split() + )) + use_case_score = min(40, (use_case_matches / len(use_cases)) * 40) + score += use_case_score + + return int(score) + + +def find_best_match(requirement: str, components: List[str] = None) -> Tuple[str, int]: + """ + Find best matching component for requirement. + + Args: + requirement: Feature requirement + components: List of component names to consider (None = all) + + Returns: + Tuple of (best_component, score) + + Example: + >>> find_best_match("need to show progress while installing") + ('progress', 85) + """ + if components is None: + components = list(COMPONENT_CAPABILITIES.keys()) + + best_component = None + best_score = 0 + + for component in components: + score = match_score(requirement, component) + if score > best_score: + best_score = score + best_component = component + + return best_component, best_score + + +def suggest_combinations(requirements: List[str]) -> List[List[str]]: + """ + Suggest component combinations for multiple requirements. + + Args: + requirements: List of feature requirements + + Returns: + List of component combinations (each is a list of components) + + Example: + >>> suggest_combinations(["display logs", "search logs"]) + [['viewport', 'textinput']] + """ + combinations = [] + + # Find best match for each requirement + selected_components = [] + for req in requirements: + component, score = find_best_match(req) + if score > 50 and component not in selected_components: + selected_components.append(component) + + if selected_components: + combinations.append(selected_components) + + # Common patterns + patterns = { + 'file_manager': ['filepicker', 'viewport', 'list'], + 'installer': ['progress', 'spinner', 'list'], + 'form': ['textinput', 'textarea', 'help'], + 'viewer': ['viewport', 'paginator', 'textinput'], + 'dashboard': ['tabs', 'viewport', 'table'] + } + + # Check if requirements match any patterns + req_text = ' '.join(requirements).lower() + for pattern_name, pattern_components in patterns.items(): + if pattern_name.replace('_', ' ') in req_text: + combinations.append(pattern_components) + + return combinations if combinations else [selected_components] + + +def get_alternatives(component: str) -> List[str]: + """ + Get alternative components that serve similar purposes. + + Args: + component: Component name + + Returns: + List of alternative component names + + Example: + >>> get_alternatives('viewport') + ['pager', 'textarea'] + """ + alternatives = { + 'viewport': ['pager'], + 'textinput': ['textarea', 'autocomplete'], + 'textarea': ['textinput', 'viewport'], + 'table': ['list'], + 'list': ['table', 'filepicker'], + 'progress': ['spinner'], + 'spinner': ['progress'], + 'filepicker': ['list'], + 'paginator': ['viewport'], + 'tabs': ['composable-views'] + } + + return alternatives.get(component, []) + + +def explain_match(component: str, requirement: str, score: int) -> str: + """ + Generate explanation for why component matches requirement. + + Args: + component: Component name + requirement: Requirement description + score: Match score + + Returns: + Human-readable explanation + + Example: + >>> explain_match("viewport", "scrollable display", 90) + "viewport is a strong match (90/100) for 'scrollable display' because..." + """ + if component not in COMPONENT_CAPABILITIES: + return f"{component} is not a known component" + + comp_info = COMPONENT_CAPABILITIES[component] + requirement_lower = requirement.lower() + + # Find which keywords matched + matched_keywords = [kw for kw in comp_info['keywords'] if kw in requirement_lower] + + explanation_parts = [] + + if score >= 80: + explanation_parts.append(f"{component} is a strong match ({score}/100)") + elif score >= 50: + explanation_parts.append(f"{component} is a good match ({score}/100)") + else: + explanation_parts.append(f"{component} is a weak match ({score}/100)") + + explanation_parts.append(f"for '{requirement}'") + + if matched_keywords: + explanation_parts.append(f"because it handles: {', '.join(matched_keywords)}") + + # Add use case + explanation_parts.append(f"Common use cases: {', '.join(comp_info['use_cases'])}") + + return " ".join(explanation_parts) + "." + + +def rank_components_by_relevance( + requirements: List[str], + min_score: int = 50 +) -> List[Tuple[str, int, List[str]]]: + """ + Rank all components by relevance to requirements. + + Args: + requirements: List of feature requirements + min_score: Minimum score to include (default: 50) + + Returns: + List of tuples: (component, total_score, matching_requirements) + Sorted by total_score descending + + Example: + >>> rank_components_by_relevance(["scroll", "display text"]) + [('viewport', 180, ['scroll', 'display text']), ...] + """ + component_scores = {} + component_matches = {} + + all_components = list(COMPONENT_CAPABILITIES.keys()) + + for component in all_components: + total_score = 0 + matching_reqs = [] + + for req in requirements: + score = match_score(req, component) + if score >= min_score: + total_score += score + matching_reqs.append(req) + + if total_score > 0: + component_scores[component] = total_score + component_matches[component] = matching_reqs + + # Sort by score + ranked = sorted( + component_scores.items(), + key=lambda x: x[1], + reverse=True + ) + + return [(comp, score, component_matches[comp]) for comp, score in ranked] + + +def main(): + """Test component matcher.""" + print("Testing Component Matcher\n" + "=" * 50) + + # Test 1: Match score + print("\n1. Testing match_score()...") + score = match_score("scrollable log display", "viewport") + print(f" Score for 'scrollable log display' + viewport: {score}") + assert score > 50, "Should have good score" + print(" ✓ Match scoring works") + + # Test 2: Find best match + print("\n2. Testing find_best_match()...") + component, score = find_best_match("need to show progress while installing") + print(f" Best match: {component} ({score})") + assert component in ['progress', 'spinner'], "Should match progress-related component" + print(" ✓ Best match finding works") + + # Test 3: Suggest combinations + print("\n3. Testing suggest_combinations()...") + combos = suggest_combinations(["display logs", "search logs", "scroll through logs"]) + print(f" Suggested combinations: {combos}") + assert len(combos) > 0, "Should suggest at least one combination" + print(" ✓ Combination suggestion works") + + # Test 4: Get alternatives + print("\n4. Testing get_alternatives()...") + alts = get_alternatives('viewport') + print(f" Alternatives to viewport: {alts}") + assert 'pager' in alts, "Should include pager as alternative" + print(" ✓ Alternative suggestions work") + + # Test 5: Explain match + print("\n5. Testing explain_match()...") + explanation = explain_match("viewport", "scrollable display", 90) + print(f" Explanation: {explanation}") + assert "strong match" in explanation, "Should indicate strong match" + print(" ✓ Match explanation works") + + # Test 6: Rank components + print("\n6. Testing rank_components_by_relevance()...") + ranked = rank_components_by_relevance( + ["scroll", "display", "text", "search"], + min_score=40 + ) + print(f" Top 3 components:") + for i, (comp, score, reqs) in enumerate(ranked[:3], 1): + print(f" {i}. {comp} (score: {score}) - matches: {reqs}") + assert len(ranked) > 0, "Should rank some components" + print(" ✓ Component ranking works") + + print("\n✅ All tests passed!") + + +if __name__ == "__main__": + main() diff --git a/.crush/skills/bubbletea-designer/scripts/utils/helpers.py b/.crush/skills/bubbletea-designer/scripts/utils/helpers.py new file mode 100644 index 00000000..1a74f8e2 --- /dev/null +++ b/.crush/skills/bubbletea-designer/scripts/utils/helpers.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +""" +General helper utilities for Bubble Tea Designer. +""" + +from datetime import datetime +from typing import Optional + + +def get_timestamp() -> str: + """Get current timestamp in ISO format.""" + return datetime.now().isoformat() + + +def format_list_markdown(items: list, ordered: bool = False) -> str: + """Format list as markdown.""" + if not items: + return "" + + if ordered: + return "\n".join(f"{i}. {item}" for i, item in enumerate(items, 1)) + else: + return "\n".join(f"- {item}" for item in items) + + +def truncate_text(text: str, max_length: int = 100) -> str: + """Truncate text to max length with ellipsis.""" + if len(text) <= max_length: + return text + return text[:max_length-3] + "..." + + +def estimate_complexity(num_components: int, num_views: int = 1) -> str: + """Estimate implementation complexity.""" + if num_components <= 2 and num_views == 1: + return "Simple (1-2 hours)" + elif num_components <= 4 and num_views <= 2: + return "Medium (2-4 hours)" + else: + return "Complex (4+ hours)" diff --git a/.crush/skills/bubbletea-designer/scripts/utils/inventory_loader.py b/.crush/skills/bubbletea-designer/scripts/utils/inventory_loader.py new file mode 100644 index 00000000..7385229b --- /dev/null +++ b/.crush/skills/bubbletea-designer/scripts/utils/inventory_loader.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 +""" +Inventory loader for Bubble Tea examples. +Loads and parses CONTEXTUAL-INVENTORY.md from charm-examples-inventory. +""" + +import os +import re +from typing import Dict, List, Optional, Tuple +from pathlib import Path +import logging + +logger = logging.getLogger(__name__) + + +class InventoryLoadError(Exception): + """Raised when inventory cannot be loaded.""" + pass + + +class Example: + """Represents a single Bubble Tea example.""" + + def __init__(self, name: str, file_path: str, capability: str): + self.name = name + self.file_path = file_path + self.capability = capability + self.key_patterns: List[str] = [] + self.components: List[str] = [] + self.use_cases: List[str] = [] + + def __repr__(self): + return f"Example({self.name}, {self.capability})" + + +class Inventory: + """Bubble Tea examples inventory.""" + + def __init__(self, base_path: str): + self.base_path = base_path + self.examples: Dict[str, Example] = {} + self.capabilities: Dict[str, List[Example]] = {} + self.components: Dict[str, List[Example]] = {} + + def add_example(self, example: Example): + """Add example to inventory.""" + self.examples[example.name] = example + + # Index by capability + if example.capability not in self.capabilities: + self.capabilities[example.capability] = [] + self.capabilities[example.capability].append(example) + + # Index by components + for component in example.components: + if component not in self.components: + self.components[component] = [] + self.components[component].append(example) + + def search_by_keyword(self, keyword: str) -> List[Example]: + """Search examples by keyword in name or patterns.""" + keyword_lower = keyword.lower() + results = [] + + for example in self.examples.values(): + if keyword_lower in example.name.lower(): + results.append(example) + continue + + for pattern in example.key_patterns: + if keyword_lower in pattern.lower(): + results.append(example) + break + + return results + + def get_by_capability(self, capability: str) -> List[Example]: + """Get all examples for a capability.""" + return self.capabilities.get(capability, []) + + def get_by_component(self, component: str) -> List[Example]: + """Get all examples using a component.""" + return self.components.get(component, []) + + +def load_inventory(inventory_path: Optional[str] = None) -> Inventory: + """ + Load Bubble Tea examples inventory from CONTEXTUAL-INVENTORY.md. + + Args: + inventory_path: Path to charm-examples-inventory directory + If None, tries to find it automatically + + Returns: + Loaded Inventory object + + Raises: + InventoryLoadError: If inventory cannot be loaded + + Example: + >>> inv = load_inventory("/path/to/charm-examples-inventory") + >>> examples = inv.search_by_keyword("progress") + """ + if inventory_path is None: + inventory_path = _find_inventory_path() + + inventory_file = Path(inventory_path) / "bubbletea" / "examples" / "CONTEXTUAL-INVENTORY.md" + + if not inventory_file.exists(): + raise InventoryLoadError( + f"Inventory file not found: {inventory_file}\n" + f"Expected at: {inventory_path}/bubbletea/examples/CONTEXTUAL-INVENTORY.md" + ) + + logger.info(f"Loading inventory from: {inventory_file}") + + with open(inventory_file, 'r') as f: + content = f.read() + + inventory = parse_inventory_markdown(content, str(inventory_path)) + + logger.info(f"Loaded {len(inventory.examples)} examples") + logger.info(f"Categories: {len(inventory.capabilities)}") + + return inventory + + +def parse_inventory_markdown(content: str, base_path: str) -> Inventory: + """ + Parse CONTEXTUAL-INVENTORY.md markdown content. + + Args: + content: Markdown content + base_path: Base path for example files + + Returns: + Inventory object with parsed examples + """ + inventory = Inventory(base_path) + + # Parse quick reference table + table_matches = re.finditer( + r'\|\s*(.+?)\s*\|\s*`(.+?)`\s*\|', + content + ) + + need_to_file = {} + for match in table_matches: + need = match.group(1).strip() + file_path = match.group(2).strip() + need_to_file[need] = file_path + + # Parse detailed sections (## Examples by Capability) + capability_pattern = r'### (.+?)\n\n\*\*Use (.+?) when you need:\*\*(.+?)(?=\n\n\*\*|### |\Z)' + + capability_sections = re.finditer(capability_pattern, content, re.DOTALL) + + for section in capability_sections: + capability = section.group(1).strip() + example_name = section.group(2).strip() + description = section.group(3).strip() + + # Extract file path and key patterns + file_match = re.search(r'\*\*File\*\*: `(.+?)`', description) + patterns_match = re.search(r'\*\*Key patterns\*\*: (.+?)(?=\n|$)', description) + + if file_match: + file_path = file_match.group(1).strip() + example = Example(example_name, file_path, capability) + + if patterns_match: + patterns_text = patterns_match.group(1).strip() + example.key_patterns = [p.strip() for p in patterns_text.split(',')] + + # Extract components from file name and patterns + example.components = _extract_components(example_name, example.key_patterns) + + inventory.add_example(example) + + return inventory + + +def _extract_components(name: str, patterns: List[str]) -> List[str]: + """Extract component names from example name and patterns.""" + components = [] + + # Common component keywords + component_keywords = [ + 'textinput', 'textarea', 'viewport', 'table', 'list', 'pager', + 'paginator', 'spinner', 'progress', 'timer', 'stopwatch', + 'filepicker', 'help', 'tabs', 'autocomplete' + ] + + name_lower = name.lower() + for keyword in component_keywords: + if keyword in name_lower: + components.append(keyword) + + for pattern in patterns: + pattern_lower = pattern.lower() + for keyword in component_keywords: + if keyword in pattern_lower and keyword not in components: + components.append(keyword) + + return components + + +def _find_inventory_path() -> str: + """ + Try to find charm-examples-inventory automatically. + + Searches in common locations: + - ./charm-examples-inventory + - ../charm-examples-inventory + - ~/charmtuitemplate/vinw/charm-examples-inventory + + Returns: + Path to inventory directory + + Raises: + InventoryLoadError: If not found + """ + search_paths = [ + Path.cwd() / "charm-examples-inventory", + Path.cwd().parent / "charm-examples-inventory", + Path.home() / "charmtuitemplate" / "vinw" / "charm-examples-inventory" + ] + + for path in search_paths: + if (path / "bubbletea" / "examples" / "CONTEXTUAL-INVENTORY.md").exists(): + logger.info(f"Found inventory at: {path}") + return str(path) + + raise InventoryLoadError( + "Could not find charm-examples-inventory automatically.\n" + f"Searched: {[str(p) for p in search_paths]}\n" + "Please provide inventory_path parameter." + ) + + +def build_capability_index(inventory: Inventory) -> Dict[str, List[str]]: + """ + Build index of capabilities to example names. + + Args: + inventory: Loaded inventory + + Returns: + Dict mapping capability names to example names + """ + index = {} + for capability, examples in inventory.capabilities.items(): + index[capability] = [ex.name for ex in examples] + return index + + +def build_component_index(inventory: Inventory) -> Dict[str, List[str]]: + """ + Build index of components to example names. + + Args: + inventory: Loaded inventory + + Returns: + Dict mapping component names to example names + """ + index = {} + for component, examples in inventory.components.items(): + index[component] = [ex.name for ex in examples] + return index + + +def get_example_details(inventory: Inventory, example_name: str) -> Optional[Example]: + """ + Get detailed information about a specific example. + + Args: + inventory: Loaded inventory + example_name: Name of example to look up + + Returns: + Example object or None if not found + """ + return inventory.examples.get(example_name) + + +def main(): + """Test inventory loader.""" + logging.basicConfig(level=logging.INFO) + + print("Testing Inventory Loader\n" + "=" * 50) + + try: + # Load inventory + print("\n1. Loading inventory...") + inventory = load_inventory() + print(f"✓ Loaded {len(inventory.examples)} examples") + print(f"✓ {len(inventory.capabilities)} capability categories") + + # Test search + print("\n2. Testing keyword search...") + results = inventory.search_by_keyword("progress") + print(f"✓ Found {len(results)} examples for 'progress':") + for ex in results[:3]: + print(f" - {ex.name} ({ex.capability})") + + # Test capability lookup + print("\n3. Testing capability lookup...") + cap_examples = inventory.get_by_capability("Installation and Progress Tracking") + print(f"✓ Found {len(cap_examples)} installation examples") + + # Test component lookup + print("\n4. Testing component lookup...") + comp_examples = inventory.get_by_component("spinner") + print(f"✓ Found {len(comp_examples)} examples using 'spinner'") + + # Test indices + print("\n5. Building indices...") + cap_index = build_capability_index(inventory) + comp_index = build_component_index(inventory) + print(f"✓ Capability index: {len(cap_index)} categories") + print(f"✓ Component index: {len(comp_index)} components") + + print("\n✅ All tests passed!") + + except InventoryLoadError as e: + print(f"\n❌ Error loading inventory: {e}") + return 1 + + return 0 + + +if __name__ == "__main__": + exit(main()) diff --git a/.crush/skills/bubbletea-designer/scripts/utils/template_generator.py b/.crush/skills/bubbletea-designer/scripts/utils/template_generator.py new file mode 100644 index 00000000..9a1f8e8d --- /dev/null +++ b/.crush/skills/bubbletea-designer/scripts/utils/template_generator.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +""" +Template generator for Bubble Tea TUIs. +Generates code scaffolding and boilerplate. +""" + +from typing import List, Dict + + +def generate_model_struct(components: List[str], archetype: str) -> str: + """Generate model struct with components.""" + component_fields = { + 'viewport': ' viewport viewport.Model', + 'textinput': ' textInput textinput.Model', + 'textarea': ' textArea textarea.Model', + 'table': ' table table.Model', + 'list': ' list list.Model', + 'progress': ' progress progress.Model', + 'spinner': ' spinner spinner.Model' + } + + fields = [] + for comp in components: + if comp in component_fields: + fields.append(component_fields[comp]) + + # Add common fields + fields.extend([ + ' width int', + ' height int', + ' ready bool' + ]) + + return f"""type model struct {{ +{chr(10).join(fields)} +}}""" + + +def generate_init_function(components: List[str]) -> str: + """Generate Init() function.""" + inits = [] + for comp in components: + if comp == 'viewport': + inits.append(' m.viewport = viewport.New(80, 20)') + elif comp == 'textinput': + inits.append(' m.textInput = textinput.New()') + inits.append(' m.textInput.Focus()') + elif comp == 'spinner': + inits.append(' m.spinner = spinner.New()') + inits.append(' m.spinner.Spinner = spinner.Dot') + elif comp == 'progress': + inits.append(' m.progress = progress.New(progress.WithDefaultGradient())') + + init_cmds = ', '.join([f'{c}.Init()' for c in components if c != 'viewport']) + + return f"""func (m model) Init() tea.Cmd {{ +{chr(10).join(inits) if inits else ' // Initialize components'} + return tea.Batch({init_cmds if init_cmds else 'nil'}) +}}""" + + +def generate_update_skeleton(interactions: Dict) -> str: + """Generate Update() skeleton.""" + return """func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + } + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.ready = true + } + + // Update components + // TODO: Add component update logic + + return m, nil +}""" + + +def generate_view_skeleton(components: List[str]) -> str: + """Generate View() skeleton.""" + renders = [] + for comp in components: + renders.append(f' // Render {comp}') + renders.append(f' // views = append(views, m.{comp}.View())') + + return f"""func (m model) View() string {{ + if !m.ready {{ + return "Loading..." + }} + + var views []string + +{chr(10).join(renders)} + + return lipgloss.JoinVertical(lipgloss.Left, views...) +}}""" + + +def generate_main_go(components: List[str], archetype: str) -> str: + """Generate complete main.go scaffold.""" + imports = ['github.com/charmbracelet/bubbletea'] + + if 'viewport' in components: + imports.append('github.com/charmbracelet/bubbles/viewport') + if 'textinput' in components: + imports.append('github.com/charmbracelet/bubbles/textinput') + if any(c in components for c in ['table', 'list', 'spinner', 'progress']): + imports.append('github.com/charmbracelet/bubbles/' + components[0]) + + imports.append('github.com/charmbracelet/lipgloss') + + import_block = '\n '.join(f'"{imp}"' for imp in imports) + + return f"""package main + +import ( + {import_block} +) + +{generate_model_struct(components, archetype)} + +{generate_init_function(components)} + +{generate_update_skeleton({})} + +{generate_view_skeleton(components)} + +func main() {{ + p := tea.NewProgram(model{{}}, tea.WithAltScreen()) + if _, err := p.Run(); err != nil {{ + panic(err) + }} +}} +""" diff --git a/.crush/skills/bubbletea-designer/scripts/utils/validators/__init__.py b/.crush/skills/bubbletea-designer/scripts/utils/validators/__init__.py new file mode 100644 index 00000000..367a1228 --- /dev/null +++ b/.crush/skills/bubbletea-designer/scripts/utils/validators/__init__.py @@ -0,0 +1,26 @@ +"""Validators for Bubble Tea Designer.""" + +from .requirement_validator import ( + RequirementValidator, + validate_description_clarity, + validate_requirements_completeness, + ValidationReport, + ValidationResult, + ValidationLevel +) + +from .design_validator import ( + DesignValidator, + validate_component_fit +) + +__all__ = [ + 'RequirementValidator', + 'validate_description_clarity', + 'validate_requirements_completeness', + 'DesignValidator', + 'validate_component_fit', + 'ValidationReport', + 'ValidationResult', + 'ValidationLevel' +] diff --git a/.crush/skills/bubbletea-designer/scripts/utils/validators/__pycache__/__init__.cpython-311.pyc b/.crush/skills/bubbletea-designer/scripts/utils/validators/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 00000000..9fc2ea6c Binary files /dev/null and b/.crush/skills/bubbletea-designer/scripts/utils/validators/__pycache__/__init__.cpython-311.pyc differ diff --git a/.crush/skills/bubbletea-designer/scripts/utils/validators/__pycache__/design_validator.cpython-311.pyc b/.crush/skills/bubbletea-designer/scripts/utils/validators/__pycache__/design_validator.cpython-311.pyc new file mode 100644 index 00000000..0a8b954a Binary files /dev/null and b/.crush/skills/bubbletea-designer/scripts/utils/validators/__pycache__/design_validator.cpython-311.pyc differ diff --git a/.crush/skills/bubbletea-designer/scripts/utils/validators/__pycache__/requirement_validator.cpython-311.pyc b/.crush/skills/bubbletea-designer/scripts/utils/validators/__pycache__/requirement_validator.cpython-311.pyc new file mode 100644 index 00000000..b5fa0665 Binary files /dev/null and b/.crush/skills/bubbletea-designer/scripts/utils/validators/__pycache__/requirement_validator.cpython-311.pyc differ diff --git a/.crush/skills/bubbletea-designer/scripts/utils/validators/design_validator.py b/.crush/skills/bubbletea-designer/scripts/utils/validators/design_validator.py new file mode 100644 index 00000000..ca451dba --- /dev/null +++ b/.crush/skills/bubbletea-designer/scripts/utils/validators/design_validator.py @@ -0,0 +1,425 @@ +#!/usr/bin/env python3 +""" +Design validators for Bubble Tea Designer. +Validates design outputs (component selections, architecture, workflows). +""" + +from typing import Dict, List, Optional +from .requirement_validator import ValidationReport, ValidationResult, ValidationLevel + + +class DesignValidator: + """Validates TUI design outputs.""" + + def validate_component_selection( + self, + components: Dict, + requirements: Dict + ) -> ValidationReport: + """ + Validate component selection against requirements. + + Args: + components: Selected components dict + requirements: Original requirements + + Returns: + ValidationReport + """ + report = ValidationReport() + + # Check 1: At least one component selected + primary = components.get('primary_components', []) + has_components = len(primary) > 0 + + report.add(ValidationResult( + check_name="has_components", + level=ValidationLevel.CRITICAL, + passed=has_components, + message=f"Primary components selected: {len(primary)}" + )) + + # Check 2: Components cover requirements + features = set(requirements.get('features', [])) + if features and primary: + # Check if components mention required features + covered_features = set() + for comp in primary: + justification = comp.get('justification', '').lower() + for feature in features: + if feature.lower() in justification: + covered_features.add(feature) + + coverage = len(covered_features) / len(features) * 100 if features else 0 + report.add(ValidationResult( + check_name="feature_coverage", + level=ValidationLevel.WARNING, + passed=coverage >= 50, + message=f"Feature coverage: {coverage:.0f}% ({len(covered_features)}/{len(features)})" + )) + + # Check 3: No duplicate components + comp_names = [c.get('component', '') for c in primary] + duplicates = [name for name in comp_names if comp_names.count(name) > 1] + + report.add(ValidationResult( + check_name="no_duplicates", + level=ValidationLevel.WARNING, + passed=len(duplicates) == 0, + message="No duplicate components" if not duplicates else + f"Duplicate components: {set(duplicates)}" + )) + + # Check 4: Reasonable number of components (not too many) + reasonable_count = len(primary) <= 6 + report.add(ValidationResult( + check_name="reasonable_count", + level=ValidationLevel.INFO, + passed=reasonable_count, + message=f"Component count: {len(primary)} ({'reasonable' if reasonable_count else 'may be too many'})" + )) + + # Check 5: Each component has justification + all_justified = all('justification' in c for c in primary) + report.add(ValidationResult( + check_name="all_justified", + level=ValidationLevel.INFO, + passed=all_justified, + message="All components justified" if all_justified else + "Some components missing justification" + )) + + return report + + def validate_architecture(self, architecture: Dict) -> ValidationReport: + """ + Validate architecture design. + + Args: + architecture: Architecture specification + + Returns: + ValidationReport + """ + report = ValidationReport() + + # Check 1: Has model struct + has_model = 'model_struct' in architecture and architecture['model_struct'] + report.add(ValidationResult( + check_name="has_model_struct", + level=ValidationLevel.CRITICAL, + passed=has_model, + message="Model struct defined" if has_model else "Missing model struct" + )) + + # Check 2: Has message handlers + handlers = architecture.get('message_handlers', {}) + has_handlers = len(handlers) > 0 + + report.add(ValidationResult( + check_name="has_message_handlers", + level=ValidationLevel.CRITICAL, + passed=has_handlers, + message=f"Message handlers defined: {len(handlers)}" + )) + + # Check 3: Has key message handler (keyboard) + has_key_handler = 'tea.KeyMsg' in handlers or 'KeyMsg' in handlers + + report.add(ValidationResult( + check_name="has_keyboard_handler", + level=ValidationLevel.WARNING, + passed=has_key_handler, + message="Keyboard handler present" if has_key_handler else + "Missing keyboard handler (tea.KeyMsg)" + )) + + # Check 4: Has view logic + has_view = 'view_logic' in architecture and architecture['view_logic'] + report.add(ValidationResult( + check_name="has_view_logic", + level=ValidationLevel.CRITICAL, + passed=has_view, + message="View logic defined" if has_view else "Missing view logic" + )) + + # Check 5: Has diagrams + diagrams = architecture.get('diagrams', {}) + has_diagrams = len(diagrams) > 0 + + report.add(ValidationResult( + check_name="has_diagrams", + level=ValidationLevel.INFO, + passed=has_diagrams, + message=f"Architecture diagrams: {len(diagrams)}" + )) + + return report + + def validate_workflow_completeness(self, workflow: Dict) -> ValidationReport: + """ + Validate workflow has all necessary phases and tasks. + + Args: + workflow: Workflow specification + + Returns: + ValidationReport + """ + report = ValidationReport() + + # Check 1: Has phases + phases = workflow.get('phases', []) + has_phases = len(phases) > 0 + + report.add(ValidationResult( + check_name="has_phases", + level=ValidationLevel.CRITICAL, + passed=has_phases, + message=f"Workflow phases: {len(phases)}" + )) + + if not phases: + return report + + # Check 2: Each phase has tasks + all_have_tasks = all(len(phase.get('tasks', [])) > 0 for phase in phases) + + report.add(ValidationResult( + check_name="all_phases_have_tasks", + level=ValidationLevel.WARNING, + passed=all_have_tasks, + message="All phases have tasks" if all_have_tasks else + "Some phases are missing tasks" + )) + + # Check 3: Has testing checkpoints + checkpoints = workflow.get('testing_checkpoints', []) + has_testing = len(checkpoints) > 0 + + report.add(ValidationResult( + check_name="has_testing", + level=ValidationLevel.WARNING, + passed=has_testing, + message=f"Testing checkpoints: {len(checkpoints)}" + )) + + # Check 4: Reasonable phase count (2-6 phases) + reasonable_phases = 2 <= len(phases) <= 6 + + report.add(ValidationResult( + check_name="reasonable_phases", + level=ValidationLevel.INFO, + passed=reasonable_phases, + message=f"Phase count: {len(phases)} ({'good' if reasonable_phases else 'unusual'})" + )) + + # Check 5: Has time estimates + total_time = workflow.get('total_estimated_time') + has_estimate = bool(total_time) + + report.add(ValidationResult( + check_name="has_time_estimate", + level=ValidationLevel.INFO, + passed=has_estimate, + message=f"Time estimate: {total_time or 'missing'}" + )) + + return report + + def validate_design_report(self, report_data: Dict) -> ValidationReport: + """ + Validate complete design report. + + Args: + report_data: Complete design report + + Returns: + ValidationReport + """ + report = ValidationReport() + + # Check all required sections present + required_sections = ['requirements', 'components', 'patterns', 'architecture', 'workflow'] + sections = report_data.get('sections', {}) + + for section in required_sections: + has_section = section in sections and sections[section] + report.add(ValidationResult( + check_name=f"has_{section}_section", + level=ValidationLevel.CRITICAL, + passed=has_section, + message=f"Section '{section}': {'present' if has_section else 'MISSING'}" + )) + + # Check has summary + has_summary = 'summary' in report_data and report_data['summary'] + report.add(ValidationResult( + check_name="has_summary", + level=ValidationLevel.WARNING, + passed=has_summary, + message="Summary present" if has_summary else "Missing summary" + )) + + # Check has scaffolding + has_scaffolding = 'scaffolding' in report_data and report_data['scaffolding'] + report.add(ValidationResult( + check_name="has_scaffolding", + level=ValidationLevel.INFO, + passed=has_scaffolding, + message="Code scaffolding included" if has_scaffolding else + "No code scaffolding" + )) + + # Check has next steps + next_steps = report_data.get('next_steps', []) + has_next_steps = len(next_steps) > 0 + + report.add(ValidationResult( + check_name="has_next_steps", + level=ValidationLevel.INFO, + passed=has_next_steps, + message=f"Next steps: {len(next_steps)}" + )) + + return report + + +def validate_component_fit(component: str, requirement: str) -> bool: + """ + Quick check if component fits requirement. + + Args: + component: Component name (e.g., "viewport.Model") + requirement: Requirement description + + Returns: + True if component appears suitable + """ + component_lower = component.lower() + requirement_lower = requirement.lower() + + # Simple keyword matching + keyword_map = { + 'viewport': ['scroll', 'view', 'display', 'content'], + 'textinput': ['input', 'text', 'search', 'query'], + 'textarea': ['edit', 'multi-line', 'text area'], + 'table': ['table', 'tabular', 'rows', 'columns'], + 'list': ['list', 'items', 'select', 'choose'], + 'progress': ['progress', 'loading', 'installation'], + 'spinner': ['loading', 'spinner', 'wait'], + 'filepicker': ['file', 'select file', 'choose file'] + } + + for comp_key, keywords in keyword_map.items(): + if comp_key in component_lower: + return any(kw in requirement_lower for kw in keywords) + + return False + + +def main(): + """Test design validator.""" + print("Testing Design Validator\n" + "=" * 50) + + validator = DesignValidator() + + # Test 1: Component selection validation + print("\n1. Testing component selection validation...") + components = { + 'primary_components': [ + { + 'component': 'viewport.Model', + 'score': 95, + 'justification': 'Scrollable display for log content' + }, + { + 'component': 'textinput.Model', + 'score': 90, + 'justification': 'Search query input' + } + ] + } + requirements = { + 'features': ['display', 'search', 'scroll'] + } + report = validator.validate_component_selection(components, requirements) + print(f" {report.get_summary()}") + assert not report.has_critical_issues(), "Should pass for valid components" + print(" ✓ Component selection validated") + + # Test 2: Architecture validation + print("\n2. Testing architecture validation...") + architecture = { + 'model_struct': 'type model struct {...}', + 'message_handlers': { + 'tea.KeyMsg': 'handle keyboard', + 'tea.WindowSizeMsg': 'handle resize' + }, + 'view_logic': 'func (m model) View() string {...}', + 'diagrams': { + 'component_hierarchy': '...' + } + } + report = validator.validate_architecture(architecture) + print(f" {report.get_summary()}") + assert report.all_passed(), "Should pass for complete architecture" + print(" ✓ Architecture validated") + + # Test 3: Workflow validation + print("\n3. Testing workflow validation...") + workflow = { + 'phases': [ + { + 'name': 'Phase 1: Setup', + 'tasks': [ + {'task': 'Initialize project'}, + {'task': 'Install dependencies'} + ] + }, + { + 'name': 'Phase 2: Core', + 'tasks': [ + {'task': 'Implement viewport'} + ] + } + ], + 'testing_checkpoints': ['After Phase 1', 'After Phase 2'], + 'total_estimated_time': '2 hours' + } + report = validator.validate_workflow_completeness(workflow) + print(f" {report.get_summary()}") + assert report.all_passed(), "Should pass for complete workflow" + print(" ✓ Workflow validated") + + # Test 4: Complete design report validation + print("\n4. Testing complete design report validation...") + design_report = { + 'sections': { + 'requirements': {...}, + 'components': {...}, + 'patterns': {...}, + 'architecture': {...}, + 'workflow': {...} + }, + 'summary': 'TUI design for log viewer', + 'scaffolding': 'package main...', + 'next_steps': ['Step 1', 'Step 2'] + } + report = validator.validate_design_report(design_report) + print(f" {report.get_summary()}") + assert report.all_passed(), "Should pass for complete report" + print(" ✓ Design report validated") + + # Test 5: Component fit check + print("\n5. Testing component fit check...") + assert validate_component_fit("viewport.Model", "scrollable log display") + assert validate_component_fit("textinput.Model", "search query input") + assert not validate_component_fit("spinner.Model", "text input field") + print(" ✓ Component fit checks working") + + print("\n✅ All tests passed!") + + +if __name__ == "__main__": + main() diff --git a/.crush/skills/bubbletea-designer/scripts/utils/validators/requirement_validator.py b/.crush/skills/bubbletea-designer/scripts/utils/validators/requirement_validator.py new file mode 100644 index 00000000..3adb2c1a --- /dev/null +++ b/.crush/skills/bubbletea-designer/scripts/utils/validators/requirement_validator.py @@ -0,0 +1,393 @@ +#!/usr/bin/env python3 +""" +Requirement validators for Bubble Tea Designer. +Validates user input and extracted requirements. +""" + +from typing import Dict, List, Optional, Tuple +from dataclasses import dataclass +from enum import Enum + + +class ValidationLevel(Enum): + """Severity levels for validation results.""" + CRITICAL = "critical" + WARNING = "warning" + INFO = "info" + + +@dataclass +class ValidationResult: + """Single validation check result.""" + check_name: str + level: ValidationLevel + passed: bool + message: str + details: Optional[Dict] = None + + +class ValidationReport: + """Collection of validation results.""" + + def __init__(self): + self.results: List[ValidationResult] = [] + + def add(self, result: ValidationResult): + """Add validation result.""" + self.results.append(result) + + def has_critical_issues(self) -> bool: + """Check if any critical issues found.""" + return any( + r.level == ValidationLevel.CRITICAL and not r.passed + for r in self.results + ) + + def all_passed(self) -> bool: + """Check if all validations passed.""" + return all(r.passed for r in self.results) + + def get_warnings(self) -> List[str]: + """Get all warning messages.""" + return [ + r.message for r in self.results + if r.level == ValidationLevel.WARNING and not r.passed + ] + + def get_summary(self) -> str: + """Get summary of validation results.""" + total = len(self.results) + passed = sum(1 for r in self.results if r.passed) + critical = sum( + 1 for r in self.results + if r.level == ValidationLevel.CRITICAL and not r.passed + ) + + return ( + f"Validation: {passed}/{total} passed " + f"({critical} critical issues)" + ) + + def to_dict(self) -> Dict: + """Convert to dictionary.""" + return { + 'passed': self.all_passed(), + 'summary': self.get_summary(), + 'warnings': self.get_warnings(), + 'critical_issues': [ + r.message for r in self.results + if r.level == ValidationLevel.CRITICAL and not r.passed + ], + 'all_results': [ + { + 'check': r.check_name, + 'level': r.level.value, + 'passed': r.passed, + 'message': r.message + } + for r in self.results + ] + } + + +class RequirementValidator: + """Validates TUI requirements and descriptions.""" + + def validate_description(self, description: str) -> ValidationReport: + """ + Validate user-provided description. + + Args: + description: Natural language TUI description + + Returns: + ValidationReport with results + """ + report = ValidationReport() + + # Check 1: Not empty + report.add(ValidationResult( + check_name="not_empty", + level=ValidationLevel.CRITICAL, + passed=bool(description and description.strip()), + message="Description is empty" if not description else "Description provided" + )) + + if not description: + return report + + # Check 2: Minimum length (at least 10 words) + words = description.split() + min_words = 10 + has_min_length = len(words) >= min_words + + report.add(ValidationResult( + check_name="minimum_length", + level=ValidationLevel.WARNING, + passed=has_min_length, + message=f"Description has {len(words)} words (recommended: ≥{min_words})" + )) + + # Check 3: Contains actionable verbs + action_verbs = ['show', 'display', 'view', 'create', 'select', 'navigate', + 'edit', 'input', 'track', 'monitor', 'search', 'filter'] + has_action = any(verb in description.lower() for verb in action_verbs) + + report.add(ValidationResult( + check_name="has_actions", + level=ValidationLevel.WARNING, + passed=has_action, + message="Description contains action verbs" if has_action else + "Consider adding action verbs (show, select, edit, etc.)" + )) + + # Check 4: Contains data type mentions + data_types = ['file', 'text', 'data', 'table', 'list', 'log', 'config', + 'message', 'package', 'item', 'entry'] + has_data = any(dtype in description.lower() for dtype in data_types) + + report.add(ValidationResult( + check_name="has_data_types", + level=ValidationLevel.INFO, + passed=has_data, + message="Data types mentioned" if has_data else + "No explicit data types mentioned" + )) + + return report + + def validate_requirements(self, requirements: Dict) -> ValidationReport: + """ + Validate extracted requirements structure. + + Args: + requirements: Structured requirements dict + + Returns: + ValidationReport + """ + report = ValidationReport() + + # Check 1: Has archetype + has_archetype = 'archetype' in requirements and requirements['archetype'] + report.add(ValidationResult( + check_name="has_archetype", + level=ValidationLevel.CRITICAL, + passed=has_archetype, + message=f"TUI archetype: {requirements.get('archetype', 'MISSING')}" + )) + + # Check 2: Has features + features = requirements.get('features', []) + has_features = len(features) > 0 + report.add(ValidationResult( + check_name="has_features", + level=ValidationLevel.CRITICAL, + passed=has_features, + message=f"Features identified: {len(features)}" + )) + + # Check 3: Has interactions + interactions = requirements.get('interactions', {}) + keyboard_interactions = interactions.get('keyboard', []) + has_interactions = len(keyboard_interactions) > 0 + + report.add(ValidationResult( + check_name="has_interactions", + level=ValidationLevel.WARNING, + passed=has_interactions, + message=f"Keyboard interactions: {len(keyboard_interactions)}" + )) + + # Check 4: Has view specification + views = requirements.get('views', '') + has_views = bool(views) + report.add(ValidationResult( + check_name="has_view_spec", + level=ValidationLevel.WARNING, + passed=has_views, + message=f"View type: {views or 'unspecified'}" + )) + + # Check 5: Completeness (has all expected keys) + expected_keys = ['archetype', 'features', 'interactions', 'data_types', 'views'] + missing_keys = set(expected_keys) - set(requirements.keys()) + + report.add(ValidationResult( + check_name="completeness", + level=ValidationLevel.INFO, + passed=len(missing_keys) == 0, + message=f"Complete structure" if not missing_keys else + f"Missing keys: {missing_keys}" + )) + + return report + + def suggest_clarifications(self, requirements: Dict) -> List[str]: + """ + Suggest clarifying questions based on incomplete requirements. + + Args: + requirements: Extracted requirements + + Returns: + List of clarifying questions to ask user + """ + questions = [] + + # Check if archetype is unclear + if not requirements.get('archetype') or requirements['archetype'] == 'general': + questions.append( + "What type of TUI is this? (file manager, installer, dashboard, " + "form, viewer, etc.)" + ) + + # Check if features are vague + features = requirements.get('features', []) + if len(features) < 2: + questions.append( + "What are the main features/capabilities needed? " + "(e.g., navigation, selection, editing, search, filtering)" + ) + + # Check if data type is unspecified + data_types = requirements.get('data_types', []) + if not data_types: + questions.append( + "What type of data will the TUI display? " + "(files, text, tabular data, logs, etc.)" + ) + + # Check if interaction is unspecified + interactions = requirements.get('interactions', {}) + if not interactions.get('keyboard') and not interactions.get('mouse'): + questions.append( + "How should users interact? Keyboard only, or mouse support needed?" + ) + + # Check if view type is unspecified + if not requirements.get('views'): + questions.append( + "Should this be single-view or multi-view? Need tabs or navigation?" + ) + + return questions + + +def validate_description_clarity(description: str) -> Tuple[bool, str]: + """ + Quick validation of description clarity. + + Args: + description: User description + + Returns: + Tuple of (is_clear, message) + """ + validator = RequirementValidator() + report = validator.validate_description(description) + + if report.has_critical_issues(): + return False, "Description has critical issues: " + report.get_summary() + + warnings = report.get_warnings() + if warnings: + return True, "Description OK with suggestions: " + "; ".join(warnings) + + return True, "Description is clear" + + +def validate_requirements_completeness(requirements: Dict) -> Tuple[bool, str]: + """ + Quick validation of requirements completeness. + + Args: + requirements: Extracted requirements dict + + Returns: + Tuple of (is_complete, message) + """ + validator = RequirementValidator() + report = validator.validate_requirements(requirements) + + if report.has_critical_issues(): + return False, "Requirements incomplete: " + report.get_summary() + + warnings = report.get_warnings() + if warnings: + return True, "Requirements OK with warnings: " + "; ".join(warnings) + + return True, "Requirements complete" + + +def main(): + """Test requirement validator.""" + print("Testing Requirement Validator\n" + "=" * 50) + + validator = RequirementValidator() + + # Test 1: Empty description + print("\n1. Testing empty description...") + report = validator.validate_description("") + print(f" {report.get_summary()}") + assert report.has_critical_issues(), "Should fail for empty description" + print(" ✓ Correctly detected empty description") + + # Test 2: Good description + print("\n2. Testing good description...") + good_desc = "Create a file manager TUI with three-column view showing parent directory, current directory, and file preview" + report = validator.validate_description(good_desc) + print(f" {report.get_summary()}") + print(" ✓ Good description validated") + + # Test 3: Vague description + print("\n3. Testing vague description...") + vague_desc = "Build a TUI" + report = validator.validate_description(vague_desc) + print(f" {report.get_summary()}") + warnings = report.get_warnings() + if warnings: + print(f" Warnings: {warnings}") + print(" ✓ Vague description detected") + + # Test 4: Requirements validation + print("\n4. Testing requirements validation...") + requirements = { + 'archetype': 'file-manager', + 'features': ['navigation', 'selection', 'preview'], + 'interactions': { + 'keyboard': ['arrows', 'enter', 'backspace'], + 'mouse': [] + }, + 'data_types': ['files', 'directories'], + 'views': 'multi' + } + report = validator.validate_requirements(requirements) + print(f" {report.get_summary()}") + assert report.all_passed(), "Should pass for complete requirements" + print(" ✓ Complete requirements validated") + + # Test 5: Incomplete requirements + print("\n5. Testing incomplete requirements...") + incomplete = { + 'archetype': '', + 'features': [] + } + report = validator.validate_requirements(incomplete) + print(f" {report.get_summary()}") + assert report.has_critical_issues(), "Should fail for incomplete requirements" + print(" ✓ Incomplete requirements detected") + + # Test 6: Clarification suggestions + print("\n6. Testing clarification suggestions...") + questions = validator.suggest_clarifications(incomplete) + print(f" Generated {len(questions)} clarifying questions:") + for i, q in enumerate(questions, 1): + print(f" {i}. {q}") + print(" ✓ Clarifications generated") + + print("\n✅ All tests passed!") + + +if __name__ == "__main__": + main() diff --git a/.crush/skills/bubbletea-designer/skills/bubbletea-designer/SKILL.md b/.crush/skills/bubbletea-designer/skills/bubbletea-designer/SKILL.md new file mode 100644 index 00000000..5c1bb363 --- /dev/null +++ b/.crush/skills/bubbletea-designer/skills/bubbletea-designer/SKILL.md @@ -0,0 +1,1537 @@ +--- +name: bubbletea-designer +description: Automates Bubble Tea TUI design by analyzing requirements, mapping to appropriate components from the Charmbracelet ecosystem, generating component architecture, and creating implementation workflows. Use when designing terminal UIs, planning Bubble Tea applications, selecting components, or needing design guidance for TUI development. +--- + +# Bubble Tea TUI Designer + +Automate the design process for Bubble Tea terminal user interfaces with intelligent component mapping, architecture generation, and implementation planning. + +## When to Use This Skill + +This skill automatically activates when you need help designing, planning, or structuring Bubble Tea TUI applications: + +### Design & Planning + +Use this skill when you: +- **Design a new TUI application** from requirements +- **Plan component architecture** for terminal interfaces +- **Select appropriate Bubble Tea components** for your use case +- **Generate implementation workflows** with step-by-step guides +- **Map user requirements to Charmbracelet ecosystem** components + +### Typical Activation Phrases + +The skill responds to questions like: +- "Design a TUI for [use case]" +- "Create a file manager interface" +- "Build an installation progress tracker" +- "Which Bubble Tea components should I use for [feature]?" +- "Plan a multi-view dashboard TUI" +- "Generate architecture for a configuration wizard" +- "Automate TUI design for [application]" + +### TUI Types Supported + +- **File Managers**: Navigation, selection, preview +- **Installers/Package Managers**: Progress tracking, step indication +- **Dashboards**: Multi-view, tabs, real-time updates +- **Forms & Wizards**: Multi-step input, validation +- **Data Viewers**: Tables, lists, pagination +- **Log/Text Viewers**: Scrolling, searching, highlighting +- **Chat Interfaces**: Input + message display +- **Configuration Tools**: Interactive settings +- **Monitoring Tools**: Real-time data, charts +- **Menu Systems**: Selection, navigation + +## How It Works + +The Bubble Tea Designer follows a systematic 6-step design process: + +### 1. Requirement Analysis + +**Purpose**: Extract structured requirements from natural language descriptions + +**Process**: +- Parse user description +- Identify core features +- Extract interaction patterns +- Determine data types +- Classify TUI archetype + +**Output**: Structured requirements dictionary with: +- Features list +- Interaction types (keyboard, mouse, both) +- Data types (files, text, tabular, streaming) +- View requirements (single, multi-view, tabs) +- Special requirements (validation, progress, real-time) + +### 2. Component Mapping + +**Purpose**: Map requirements to appropriate Bubble Tea components + +**Process**: +- Match features to component capabilities +- Consider component combinations +- Evaluate alternatives +- Justify selections based on requirements + +**Output**: Component recommendations with: +- Primary components (core functionality) +- Supporting components (enhancements) +- Styling components (Lipgloss) +- Justification for each selection +- Alternative options considered + +### 3. Pattern Selection + +**Purpose**: Identify relevant example files from charm-examples-inventory + +**Process**: +- Search CONTEXTUAL-INVENTORY.md for matching patterns +- Filter by capability category +- Rank by relevance to requirements +- Select 3-5 most relevant examples + +**Output**: List of example files to reference: +- File path in charm-examples-inventory +- Capability category +- Key patterns to extract +- Specific lines or functions to study + +### 4. Architecture Design + +**Purpose**: Create component hierarchy and interaction model + +**Process**: +- Design model structure (what state to track) +- Plan Init() function (initialization commands) +- Design Update() function (message handling) +- Plan View() function (rendering strategy) +- Create component composition diagram + +**Output**: Architecture specification with: +- Model struct definition +- Component hierarchy (ASCII diagram) +- Message flow diagram +- State management plan +- Rendering strategy + +### 5. Workflow Generation + +**Purpose**: Create ordered implementation steps + +**Process**: +- Determine dependency order +- Break into logical phases +- Reference specific example files +- Include testing checkpoints + +**Output**: Step-by-step implementation plan: +- Phase breakdown (setup, components, integration, polish) +- Ordered tasks with dependencies +- File references for each step +- Testing milestones +- Estimated time per phase + +### 6. Comprehensive Design Report + +**Purpose**: Generate complete design document combining all analyses + +**Process**: +- Execute all 5 previous analyses +- Combine into unified document +- Add implementation guidance +- Include code scaffolding templates +- Generate README outline + +**Output**: Complete TUI design specification with: +- Executive summary +- All analysis results (requirements, components, patterns, architecture, workflow) +- Code scaffolding (model struct, basic Init/Update/View) +- File structure recommendation +- Next steps and resources + +## Data Source: Charm Examples Inventory + +This skill references a curated inventory of 46 Bubble Tea examples from the Charmbracelet ecosystem. + +### Inventory Structure + +**Location**: `charm-examples-inventory/bubbletea/examples/` + +**Index File**: `CONTEXTUAL-INVENTORY.md` + +**Categories** (11 capability groups): +1. Installation & Progress Tracking +2. Form Input & Validation +3. Data Display & Selection +4. Content Viewing +5. View Management & Navigation +6. Loading & Status Indicators +7. Time-Based Operations +8. Network & External Operations +9. Real-Time & Event Handling +10. Screen & Terminal Management +11. Input & Interaction + +### Component Coverage + +**Input Components**: +- `textinput` - Single-line text input +- `textarea` - Multi-line text editing +- `textinputs` - Multiple inputs with focus management +- `filepicker` - File system navigation and selection +- `autocomplete` - Text input with suggestions + +**Display Components**: +- `table` - Tabular data with row selection +- `list` - Filterable, paginated lists +- `viewport` - Scrollable content area +- `pager` - Document viewer +- `paginator` - Page-based navigation + +**Feedback Components**: +- `spinner` - Loading indicator +- `progress` - Progress bar (animated & static) +- `timer` - Countdown timer +- `stopwatch` - Elapsed time tracker + +**Layout Components**: +- `views` - Multiple screen states +- `composable-views` - Composed bubble models +- `tabs` - Tab-based navigation +- `help` - Help menu system + +**Utility Patterns**: +- HTTP requests (`http`) +- External commands (`exec`) +- Real-time events (`realtime`) +- Alt screen buffer (`altscreen-toggle`) +- Mouse support (`mouse`) +- Window resize (`window-size`) + +### Pattern Recognition + +The skill uses pattern matching to identify: + +**By Feature**: +- "progress tracking" → `progress`, `spinner`, `package-manager` +- "form with validation" → `credit-card-form`, `textinputs` +- "table display" → `table`, `table-resize` +- "file selection" → `file-picker`, `list-default` +- "multi-step process" → `views`, `package-manager` + +**By Interaction**: +- "keyboard navigation" → Most examples, especially `help` +- "mouse support" → `mouse`, `table` +- "scrolling" → `viewport`, `pager` +- "filtering" → `list-default`, `autocomplete` + +**By Architecture**: +- "single view" → `simple`, `spinner`, `textinput` +- "multi-view state machine" → `views` +- "composed models" → `composable-views` +- "tab navigation" → `tabs` + +## Workflow Details + +### Workflow 1: Quick Component Lookup + +**User Query**: "Which component should I use for [feature]?" + +**Process**: +1. Parse feature description +2. Search component taxonomy +3. Return top 3 matches with justification + +**Example**: +``` +User: "Which component for file selection?" + +Response: +Top recommendations: +1. filepicker.Model - Dedicated file system navigation + - Built-in directory traversal + - File type filtering + - Selection callbacks + Reference: examples/file-picker/main.go + +2. list.Model - Custom file list + - More control over display + - Custom item rendering + - Can add file metadata + Reference: examples/list-fancy/main.go + +3. table.Model - Tabular file display + - Show multiple file attributes + - Sortable columns + - Good for detailed views + Reference: examples/table/main.go +``` + +### Workflow 2: Design from Description + +**User Query**: "Design a TUI for [application]" + +**Process**: +1. Run `requirement_analysis(description)` +2. Run `component_mapping(requirements)` +3. Run `pattern_selection(components)` +4. Run `architecture_design(components, patterns)` +5. Run `workflow_generation(architecture)` +6. Combine into design report + +**Example**: +``` +User: "Design a TUI for a log file viewer with search and highlighting" + +Response: [Comprehensive design report including]: + +REQUIREMENTS: +- View: Scrollable text display +- Interaction: Search, highlight matches, navigate results +- Data: Log file lines (streaming or loaded) + +COMPONENTS: +- viewport.Model (scrollable display) +- textinput.Model (search query) +- Custom highlighter (Lipgloss styles) + +ARCHITECTURE: +model struct { + viewport viewport.Model + searchInput textinput.Model + logLines []string + searchMode bool + matches []int + currentMatch int +} + +WORKFLOW: +Phase 1: Basic viewer (viewport + file loading) +Phase 2: Add search input +Phase 3: Implement highlighting +Phase 4: Add navigation between matches +Phase 5: Polish (status bar, help text) + +[Full implementation steps with file references...] +``` + +### Workflow 3: Architecture Planning + +**User Query**: "Plan architecture for [complex TUI]" + +**Process**: +1. Identify sub-components +2. Design model composition +3. Create message flow diagram +4. Plan state management +5. Generate ASCII architecture diagram + +**Example**: +``` +User: "Plan architecture for a package manager TUI with installation queue and progress" + +Response: + +ARCHITECTURE DIAGRAM: +┌─────────────────────────────────────┐ +│ Main Model │ +├─────────────────────────────────────┤ +│ view: currentView │ +│ packages: []PackageItem │ +│ queue: InstallQueue │ +│ progress: ProgressState │ +└────────────┬───────────────┬────────┘ + │ │ + ┌────▼────┐ ┌───▼─────┐ + │ List │ │Progress │ + │ Model │ │ Model │ + └─────────┘ └─────────┘ + +STATE MANAGEMENT: +- currentView: enum (selectView, installView, completeView) +- packages: list of selectable items +- queue: ordered installation queue +- progress: current package progress (0-100) + +MESSAGE FLOW: +User Input → Update() → Route to current view handler +Install Start → Switch view + Init progress +Package Complete → Update progress + Next package +All Complete → Switch to complete view + +[Detailed implementation guidance...] +``` + +### Workflow 4: Pattern Selection + +**User Query**: "Show me examples for [capability]" + +**Process**: +1. Search CONTEXTUAL-INVENTORY.md +2. Filter by capability +3. Return sorted by relevance +4. Include key patterns to study + +**Example**: +``` +User: "Show me examples for progress tracking" + +Response: + +PROGRESS TRACKING EXAMPLES: + +1. package-manager (examples/package-manager/main.go) + Category: Installation & Progress Tracking + Key Patterns: + - Combined spinner + progress bar + - Sequential task execution + - Tea.Println for persistent output above TUI + - Custom completion messages + +2. progress-animated (examples/progress-animated/main.go) + Category: Loading & Status Indicators + Key Patterns: + - Gradient progress styling + - Smooth animation with FrameMsg + - Indeterminate/determinate modes + +3. progress-download (examples/progress-download/main.go) + Category: Loading & Status Indicators + Key Patterns: + - Network operation tracking + - Real-time percentage updates + - HTTP integration + +Study these in order: +1. progress-animated (learn basics) +2. package-manager (see real-world usage) +3. progress-download (network-specific) +``` + +## Available Scripts + +All scripts are in `scripts/` directory and can be run independently or through the main orchestrator. + +### Main Orchestrator + +**`design_tui.py`** + +Comprehensive design report generator - combines all analyses. + +**Usage**: +```python +from scripts.design_tui import comprehensive_tui_design_report + +report = comprehensive_tui_design_report( + description="Log viewer with search and highlighting", + inventory_path="/path/to/charm-examples-inventory" +) + +print(report['summary']) +print(report['architecture']) +print(report['workflow']) +``` + +**Parameters**: +- `description` (str): Natural language TUI description +- `inventory_path` (str): Path to charm-examples-inventory directory +- `include_sections` (List[str], optional): Which sections to include +- `detail_level` (str): "summary" | "detailed" | "complete" + +**Returns**: +```python +{ + 'description': str, + 'generated_at': str (ISO timestamp), + 'sections': { + 'requirements': {...}, + 'components': {...}, + 'patterns': {...}, + 'architecture': {...}, + 'workflow': {...} + }, + 'summary': str, + 'scaffolding': str (code template), + 'next_steps': List[str] +} +``` + +### Analysis Scripts + +**`analyze_requirements.py`** + +Extract structured requirements from natural language. + +**Functions**: +- `extract_requirements(description)` - Parse description +- `classify_tui_type(requirements)` - Determine archetype +- `identify_interactions(requirements)` - Find interaction patterns + +**`map_components.py`** + +Map requirements to Bubble Tea components. + +**Functions**: +- `map_to_components(requirements, inventory)` - Main mapping +- `find_alternatives(component)` - Alternative suggestions +- `justify_selection(component, requirement)` - Explain choice + +**`select_patterns.py`** + +Select relevant example files from inventory. + +**Functions**: +- `search_inventory(capability, inventory)` - Search by capability +- `rank_by_relevance(examples, requirements)` - Relevance scoring +- `extract_key_patterns(example_file)` - Identify key code patterns + +**`design_architecture.py`** + +Generate component architecture and structure. + +**Functions**: +- `design_model_struct(components)` - Create model definition +- `plan_message_handlers(interactions)` - Design Update() logic +- `generate_architecture_diagram(structure)` - ASCII diagram + +**`generate_workflow.py`** + +Create ordered implementation steps. + +**Functions**: +- `break_into_phases(architecture)` - Phase planning +- `order_tasks_by_dependency(tasks)` - Dependency sorting +- `estimate_time(task)` - Time estimation +- `generate_workflow_document(phases)` - Formatted output + +### Utility Scripts + +**`utils/inventory_loader.py`** + +Load and parse the examples inventory. + +**Functions**: +- `load_inventory(path)` - Load CONTEXTUAL-INVENTORY.md +- `parse_inventory_markdown(content)` - Parse structure +- `build_capability_index(inventory)` - Index by capability +- `search_by_keyword(keyword, inventory)` - Keyword search + +**`utils/component_matcher.py`** + +Component matching and scoring logic. + +**Functions**: +- `match_score(requirement, component)` - Relevance score +- `find_best_match(requirements, components)` - Top match +- `suggest_combinations(requirements)` - Component combos + +**`utils/template_generator.py`** + +Generate code templates and scaffolding. + +**Functions**: +- `generate_model_struct(components)` - Model struct code +- `generate_init_function(components)` - Init() implementation +- `generate_update_skeleton(messages)` - Update() skeleton +- `generate_view_skeleton(layout)` - View() skeleton + +**`utils/ascii_diagram.py`** + +Create ASCII architecture diagrams. + +**Functions**: +- `draw_component_tree(structure)` - Tree diagram +- `draw_message_flow(flow)` - Flow diagram +- `draw_state_machine(states)` - State diagram + +### Validator Scripts + +**`utils/validators/requirement_validator.py`** + +Validate requirement extraction quality. + +**Functions**: +- `validate_description_clarity(description)` - Check clarity +- `validate_requirements_completeness(requirements)` - Completeness +- `suggest_clarifications(requirements)` - Ask for missing info + +**`utils/validators/design_validator.py`** + +Validate design outputs. + +**Functions**: +- `validate_component_selection(components, requirements)` - Check fit +- `validate_architecture(architecture)` - Structural validation +- `validate_workflow_completeness(workflow)` - Ensure all steps + +## Available Analyses + +### 1. Requirement Analysis + +**Function**: `extract_requirements(description)` + +**Purpose**: Convert natural language to structured requirements + +**Methodology**: +1. Tokenize description +2. Extract nouns (features, data types) +3. Extract verbs (interactions, actions) +4. Identify patterns (multi-view, progress, etc.) +5. Classify TUI archetype + +**Output Structure**: +```python +{ + 'archetype': str, # file-manager, installer, dashboard, etc. + 'features': List[str], # [navigation, selection, preview, ...] + 'interactions': { + 'keyboard': List[str], # [arrow keys, enter, search, ...] + 'mouse': List[str] # [click, drag, ...] + }, + 'data_types': List[str], # [files, text, tabular, streaming, ...] + 'views': str, # single, multi, tabbed + 'special_requirements': List[str] # [validation, progress, real-time, ...] +} +``` + +**Interpretation**: +- Archetype determines recommended starting template +- Features map directly to component selection +- Interactions affect component configuration +- Data types influence model structure + +**Validations**: +- Description not empty +- At least 1 feature identified +- Archetype successfully classified + +### 2. Component Mapping + +**Function**: `map_to_components(requirements, inventory)` + +**Purpose**: Map requirements to specific Bubble Tea components + +**Methodology**: +1. Match features to component capabilities +2. Score each component by relevance (0-100) +3. Select top matches (score > 70) +4. Identify component combinations +5. Provide alternatives for each selection + +**Output Structure**: +```python +{ + 'primary_components': [ + { + 'component': 'viewport.Model', + 'score': 95, + 'justification': 'Scrollable display for log content', + 'example_file': 'examples/pager/main.go', + 'key_patterns': ['viewport scrolling', 'content loading'] + } + ], + 'supporting_components': [...], + 'styling': ['lipgloss for highlighting'], + 'alternatives': { + 'viewport.Model': ['pager package', 'custom viewport'] + } +} +``` + +**Scoring Criteria**: +- Feature coverage: Does component provide required features? +- Complexity match: Is component appropriate for requirement complexity? +- Common usage: Is this the typical choice for this use case? +- Ecosystem fit: Does it work well with other selected components? + +**Validations**: +- At least 1 component selected +- All requirements covered by components +- No conflicting components + +### 3. Pattern Selection + +**Function**: `select_relevant_patterns(components, inventory)` + +**Purpose**: Find most relevant example files to study + +**Methodology**: +1. Search inventory by component usage +2. Filter by capability category +3. Rank by pattern complexity (simple → complex) +4. Select 3-5 most relevant +5. Extract specific code patterns to study + +**Output Structure**: +```python +{ + 'examples': [ + { + 'file': 'examples/pager/main.go', + 'capability': 'Content Viewing', + 'relevance_score': 90, + 'key_patterns': [ + 'viewport.Model initialization', + 'content scrolling (lines 45-67)', + 'keyboard navigation (lines 80-95)' + ], + 'study_order': 1, + 'estimated_study_time': '15 minutes' + } + ], + 'recommended_study_order': [1, 2, 3], + 'total_study_time': '45 minutes' +} +``` + +**Ranking Factors**: +- Component usage match +- Complexity appropriate to skill level +- Code quality and clarity +- Completeness of example + +**Validations**: +- At least 2 examples selected +- Examples cover all selected components +- Study order is logical (simple → complex) + +### 4. Architecture Design + +**Function**: `design_architecture(components, patterns, requirements)` + +**Purpose**: Create complete component architecture + +**Methodology**: +1. Design model struct (state to track) +2. Plan Init() (initialization) +3. Design Update() message handling +4. Plan View() rendering +5. Create component hierarchy diagram +6. Design message flow + +**Output Structure**: +```python +{ + 'model_struct': str, # Go code + 'init_logic': str, # Initialization steps + 'message_handlers': { + 'tea.KeyMsg': str, # Keyboard handling + 'tea.WindowSizeMsg': str, # Resize handling + # Custom messages... + }, + 'view_logic': str, # Rendering strategy + 'diagrams': { + 'component_hierarchy': str, # ASCII tree + 'message_flow': str, # Flow diagram + 'state_machine': str # State transitions (if multi-view) + } +} +``` + +**Design Patterns Applied**: +- **Single Responsibility**: Each component handles one concern +- **Composition**: Complex UIs built from simple components +- **Message Passing**: All communication via tea.Msg +- **Elm Architecture**: Model-Update-View separation + +**Validations**: +- Model struct includes all component instances +- All user interactions have message handlers +- View logic renders all components +- No circular dependencies + +### 5. Workflow Generation + +**Function**: `generate_implementation_workflow(architecture, patterns)` + +**Purpose**: Create step-by-step implementation plan + +**Methodology**: +1. Break into phases (Setup, Core, Polish, Test) +2. Identify tasks per phase +3. Order by dependency +4. Reference specific example files per task +5. Add testing checkpoints +6. Estimate time per phase + +**Output Structure**: +```python +{ + 'phases': [ + { + 'name': 'Phase 1: Setup', + 'tasks': [ + { + 'task': 'Initialize Go module', + 'reference': None, + 'dependencies': [], + 'estimated_time': '2 minutes' + }, + { + 'task': 'Install dependencies (bubbletea, lipgloss)', + 'reference': 'See README in any example', + 'dependencies': ['Initialize Go module'], + 'estimated_time': '3 minutes' + } + ], + 'total_time': '5 minutes' + }, + # More phases... + ], + 'total_estimated_time': '2-3 hours', + 'testing_checkpoints': [ + 'After Phase 1: go build succeeds', + 'After Phase 2: Basic display working', + # ... + ] +} +``` + +**Phase Breakdown**: +1. **Setup**: Project initialization, dependencies +2. **Core Components**: Implement main functionality +3. **Integration**: Connect components, message passing +4. **Polish**: Styling, help text, error handling +5. **Testing**: Comprehensive testing, edge cases + +**Validations**: +- All tasks have clear descriptions +- Dependencies are acyclic +- Time estimates are realistic +- Testing checkpoints at each phase + +### 6. Comprehensive Design Report + +**Function**: `comprehensive_tui_design_report(description, inventory_path)` + +**Purpose**: Generate complete TUI design combining all analyses + +**Process**: +1. Execute requirement_analysis(description) +2. Execute component_mapping(requirements) +3. Execute pattern_selection(components) +4. Execute architecture_design(components, patterns) +5. Execute workflow_generation(architecture) +6. Generate code scaffolding +7. Create README outline +8. Compile comprehensive report + +**Output Structure**: +```python +{ + 'description': str, + 'generated_at': str, + 'tui_type': str, + 'summary': str, # Executive summary + 'sections': { + 'requirements': {...}, + 'components': {...}, + 'patterns': {...}, + 'architecture': {...}, + 'workflow': {...} + }, + 'scaffolding': { + 'main_go': str, # Basic main.go template + 'model_go': str, # Model struct + Init/Update/View + 'readme_md': str # README outline + }, + 'file_structure': { + 'recommended': [ + 'main.go', + 'model.go', + 'view.go', + 'messages.go', + 'go.mod' + ] + }, + 'next_steps': [ + '1. Review architecture diagram', + '2. Study recommended examples', + '3. Implement Phase 1 tasks', + # ... + ], + 'resources': { + 'documentation': [...], + 'tutorials': [...], + 'community': [...] + } +} +``` + +**Report Sections**: + +**Executive Summary** (auto-generated): +- TUI type and purpose +- Key components selected +- Estimated implementation time +- Complexity assessment + +**Requirements Analysis**: +- Parsed requirements +- TUI archetype +- Feature list + +**Component Selection**: +- Primary components with justification +- Alternatives considered +- Component interaction diagram + +**Pattern References**: +- Example files to study +- Key patterns highlighted +- Recommended study order + +**Architecture**: +- Model struct design +- Init/Update/View logic +- Message flow +- ASCII diagrams + +**Implementation Workflow**: +- Phase-by-phase breakdown +- Detailed tasks with references +- Testing checkpoints +- Time estimates + +**Code Scaffolding**: +- Basic `main.go` template +- Model struct skeleton +- Init/Update/View stubs + +**Next Steps**: +- Immediate actions +- Learning resources +- Community links + +**Validation Report**: +- Design completeness check +- Potential issues identified +- Recommendations + +## Error Handling + +### Missing Inventory + +**Error**: Cannot locate charm-examples-inventory + +**Cause**: Inventory path not provided or incorrect + +**Resolution**: +1. Verify inventory path: `~/charmtuitemplate/vinw/charm-examples-inventory` +2. If missing, clone examples: `git clone https://github.com/charmbracelet/bubbletea examples` +3. Generate CONTEXTUAL-INVENTORY.md if missing + +**Fallback**: Use minimal built-in component knowledge (less detailed) + +### Unclear Requirements + +**Error**: Cannot extract clear requirements from description + +**Cause**: Description too vague or ambiguous + +**Resolution**: +1. Validator identifies missing information +2. Generate clarifying questions +3. User provides additional details + +**Clarification Questions**: +- "What type of data will the TUI display?" +- "Should it be single-view or multi-view?" +- "What are the main user interactions?" +- "Any specific visual requirements?" + +**Fallback**: Make reasonable assumptions, note them in report + +### No Matching Components + +**Error**: No components found for requirements + +**Cause**: Requirements very specific or unusual + +**Resolution**: +1. Relax matching criteria +2. Suggest custom component development +3. Recommend closest alternatives + +**Alternative Suggestions**: +- Break down into smaller requirements +- Use generic components (viewport, textinput) +- Suggest combining multiple components + +### Invalid Architecture + +**Error**: Generated architecture has structural issues + +**Cause**: Conflicting component requirements or circular dependencies + +**Resolution**: +1. Validator detects issue +2. Suggest architectural modifications +3. Provide alternative structures + +**Common Issues**: +- **Circular dependencies**: Suggest message passing +- **Too many components**: Recommend simplification +- **Missing state**: Add required fields to model + +## Mandatory Validations + +All analyses include automatic validation. Reports include validation sections. + +### Requirement Validation + +**Checks**: +- ✅ Description is not empty +- ✅ At least 1 feature identified +- ✅ TUI archetype classified +- ✅ Interaction patterns detected + +**Output**: +```python +{ + 'validation': { + 'passed': True/False, + 'checks': [ + {'name': 'description_not_empty', 'passed': True}, + {'name': 'features_found', 'passed': True, 'count': 5}, + # ... + ], + 'warnings': [ + 'No mouse interactions specified - assuming keyboard only' + ] + } +} +``` + +### Component Validation + +**Checks**: +- ✅ At least 1 component selected +- ✅ All requirements covered +- ✅ No conflicting components +- ✅ Reasonable complexity + +**Warnings**: +- "Multiple similar components selected - may be redundant" +- "High complexity - consider breaking into smaller UIs" + +### Architecture Validation + +**Checks**: +- ✅ Model struct includes all components +- ✅ No circular dependencies +- ✅ All interactions have handlers +- ✅ View renders all components + +**Errors**: +- "Missing message handler for [interaction]" +- "Circular dependency detected: A → B → A" +- "Unused component: [component] not rendered in View()" + +### Workflow Validation + +**Checks**: +- ✅ All phases have tasks +- ✅ Dependencies are acyclic +- ✅ Testing checkpoints present +- ✅ Time estimates reasonable + +**Warnings**: +- "No testing checkpoint after Phase [N]" +- "Task [X] has no dependencies but should come after [Y]" + +## Performance & Caching + +### Inventory Loading + +**Strategy**: Load once, cache in memory + +- Load CONTEXTUAL-INVENTORY.md on first use +- Build search indices (by capability, component, keyword) +- Cache for session duration + +**Performance**: O(1) lookup after initial O(n) indexing + +### Component Matching + +**Strategy**: Pre-computed similarity scores + +- Build component-feature mapping at initialization +- Score calculations cached +- Incremental updates only + +**Performance**: O(log n) search with indexing + +### Diagram Generation + +**Strategy**: Template-based with caching + +- Use pre-built ASCII templates +- Cache generated diagrams +- Regenerate only on structure changes + +**Performance**: O(n) where n = number of components + +## Automatic Detection Keywords + +This skill activates when you mention: + +**Technologies**: +- Bubble Tea, bubbletea, charm, charmbracelet +- TUI, terminal UI, terminal interface +- Lipgloss, Tea Program + +**Components**: +- spinner, progress, table, list, viewport, textarea, textinput +- pager, paginator, timer, stopwatch, filepicker, help +- tabs, views, autocomplete + +**Design Actions**: +- design TUI, create TUI, build TUI, architect TUI, plan TUI +- automate TUI design, generate TUI, scaffold TUI +- design interface, create interface, build interface +- map components, select components + +**TUI Types**: +- file manager, installer, package manager, dashboard +- form, wizard, configuration tool +- chat interface, log viewer, data viewer, text viewer +- monitoring tool, menu system, selector + +**Patterns**: +- multi-view, composable views, tabbed interface +- progress tracking, form validation, data display +- keyboard navigation, mouse support +- real-time updates, streaming data + +**Questions**: +- "how to design", "how to build", "how to structure" +- "which components", "what patterns", "best practices" +- "design a TUI for", "create interface for" +- "automate design process" + +## Usage Examples + +### Example 1: Simple Log Viewer + +**User**: "Design a TUI for viewing log files with search" + +**Response**: + +``` +TUI DESIGN: Log File Viewer + +ARCHETYPE: Text Viewer + +REQUIREMENTS ANALYSIS: +- View: Scrollable text display +- Interaction: Search, navigate matches +- Data: Text file (log lines) + +COMPONENT SELECTION: + +Primary Components: +1. viewport.Model - Scrollable text display + Justification: Built for viewing large text content + Example: examples/pager/main.go + +2. textinput.Model - Search query input + Justification: Standard text input for search + Example: examples/textinput/main.go + +Supporting: +- Lipgloss styles for match highlighting + +ARCHITECTURE: + +Model Struct: +```go +type model struct { + viewport viewport.Model + searchInput textinput.Model + content []string + searchMode bool + matches []int + currentMatch int + ready bool +} +``` + +WORKFLOW: + +Phase 1: Basic Viewer (30min) +- Load file into string array +- Initialize viewport +- Implement basic scrolling + +Phase 2: Search (45min) +- Add textinput for search +- Toggle search mode with '/' +- Highlight matches + +Phase 3: Navigation (20min) +- Navigate matches with n/N +- Show match counter + +Total: ~2 hours + +NEXT STEPS: +1. Study examples/pager/main.go for viewport usage +2. Study examples/textinput/main.go for input handling +3. Implement Phase 1 +4. Test scrolling with large files +5. Add search in Phase 2 +``` + +### Example 2: Installation Progress Tracker + +**User**: "Create a package installer UI with progress bars" + +**Response**: + +``` +TUI DESIGN: Package Installer + +ARCHETYPE: Installer/Progress Tracker + +REQUIREMENTS ANALYSIS: +- View: Progress indication for multiple packages +- Interaction: Minimal (automated process) +- Data: Package list, progress per package +- Special: Sequential installation, visual feedback + +COMPONENT SELECTION: + +Primary Components: +1. progress.Model - Individual package progress + Justification: Animated progress visualization + Example: examples/progress-animated/main.go + +2. spinner.Model - Loading indicator + Justification: Feedback during package download + Example: examples/spinner/main.go + +Reference Pattern: +- examples/package-manager/main.go - Complete implementation + +ARCHITECTURE: + +Model Struct: +```go +type model struct { + packages []Package + currentIdx int + progress progress.Model + spinner spinner.Model + installed []string + err error +} + +type Package struct { + name string + status string // pending, installing, done +} +``` + +Message Flow: +Install Start → Download Package → Update Progress → Package Done → Next Package + +WORKFLOW: + +Phase 1: Package List (20min) +- Define Package struct +- Initialize list of packages +- Display package names + +Phase 2: Add Progress (30min) +- Initialize progress.Model per package +- Update on install progress messages +- Handle completion + +Phase 3: Add Spinner (15min) +- Add spinner during download +- Stop when installation starts + +Phase 4: Sequential Logic (30min) +- Implement install queue +- Sequential execution +- Completion handling + +Total: ~2 hours + +REFERENCE STUDY: +1. examples/package-manager/main.go (main reference) + - Lines 50-80: Progress + spinner combo + - Lines 120-145: Sequential installation + - Lines 200-230: Completion handling + +NEXT STEPS: +1. Clone package-manager example structure +2. Customize for your package list +3. Add package-specific install logic +4. Test with mock installations +``` + +### Example 3: Configuration Wizard + +**User**: "Build a multi-step configuration wizard with validation" + +**Response**: + +``` +TUI DESIGN: Configuration Wizard + +ARCHETYPE: Multi-Step Form + +REQUIREMENTS ANALYSIS: +- View: Multi-step (multiple screens) +- Interaction: Form input, validation, navigation +- Data: Configuration fields (text, selections) +- Special: Validation per field, step navigation + +COMPONENT SELECTION: + +Primary Components: +1. textinput.Model (array) - Multiple input fields + Justification: Standard form inputs + Example: examples/textinputs/main.go + +2. State machine for steps + Justification: Multi-step workflow + Example: examples/views/main.go + +Reference Pattern: +- examples/credit-card-form/main.go - Form validation + +ARCHITECTURE: + +Model Struct: +```go +type model struct { + currentStep int + steps []Step + inputs []textinput.Model + focusIdx int + err error +} + +type Step struct { + name string + fields []Field + valid bool +} +``` + +State Machine: +Step 1 (Basic Info) → Step 2 (Advanced) → Step 3 (Confirm) → Done + +WORKFLOW: + +Phase 1: Basic Form (45min) +- Create input array +- Implement focus management +- Basic key handling (tab, enter) + +Phase 2: Add Steps (30min) +- Define Step struct +- Implement step navigation +- Show current step indicator + +Phase 3: Validation (45min) +- Add validators to each input +- Show validation errors +- Prevent next step if invalid + +Phase 4: Polish (30min) +- Add progress indicator +- Help text per step +- Confirmation screen + +Total: ~2.5 hours + +REFERENCE STUDY ORDER: +1. examples/textinputs/main.go (focus management) +2. examples/credit-card-form/main.go (validation pattern) +3. examples/views/main.go (multi-view state machine) + +NEXT STEPS: +1. Start with textinputs example +2. Add step state management +3. Implement validators +4. Build confirmation view +``` + +### Example 4: File Manager + +**User**: "Design a file manager TUI like ranger" + +**Response**: + +``` +TUI DESIGN: File Manager + +ARCHETYPE: File Manager + +REQUIREMENTS ANALYSIS: +- View: Three-column (parent, current, preview) +- Interaction: Keyboard navigation, selection +- Data: File system hierarchy +- Special: File preview, directory traversal + +COMPONENT SELECTION: + +Primary Components: +1. filepicker.Model - File navigation + Justification: Built-in file system navigation + Example: examples/file-picker/main.go + Alternative: Custom with list.Model for more control + +2. viewport.Model - File preview + Justification: Preview pane for selected file + Example: examples/pager/main.go + +3. Lipgloss layout - Three-column layout + Justification: Side-by-side panels + Example: examples/split-editors/main.go (two-pane reference) + +ARCHITECTURE: + +Model Struct: +```go +type model struct { + parentList list.Model // Parent directory + currentList list.Model // Current directory + preview viewport.Model // File preview + currentDir string + selectedFile string + width int + height int +} +``` + +Layout: +┌────────────┬─────────────┬──────────────┐ +│ Parent │ Current │ Preview │ +│ Dir │ Dir │ Pane │ +│ │ │ │ +│ ../ │ > file1.txt │ File content │ +│ docs/ │ file2.go │ ... │ +│ src/ │ dir/ │ │ +└────────────┴─────────────┴──────────────┘ + +WORKFLOW: + +Phase 1: Single Dir Nav (1h) +- Implement current directory listing +- Arrow key navigation +- Directory entry/exit + +Phase 2: Three-Pane Layout (1h) +- Add parent directory pane +- Add preview pane +- Lipgloss layout + +Phase 3: File Preview (45min) +- Load file content on selection +- Show in preview pane +- Handle large files + +Phase 4: Polish (45min) +- File type icons/colors +- Status bar +- Help overlay + +Total: ~3.5 hours + +REFERENCE STUDY: +1. examples/file-picker/main.go - File system navigation +2. examples/list-fancy/main.go - Custom list styling +3. examples/pager/main.go - Content preview +4. examples/split-editors/main.go - Multi-pane layout + +NEXT STEPS: +1. Start with file-picker for navigation logic +2. Build three-pane layout +3. Add preview functionality +4. Style with Lipgloss +``` + +### Example 5: Quick Component Lookup + +**User**: "Which component for a dropdown menu?" + +**Response**: + +``` +COMPONENT RECOMMENDATIONS: Dropdown Menu + +Since Bubble Tea doesn't have a built-in dropdown component, here are the best approaches: + +1. list.Model (Recommended) + - Display as temporary list overlay + - Filter on keypress + - Select with Enter + - Example: examples/list-simple/main.go + - Customization: Use ItemDelegate for dropdown styling + +2. autocomplete + - Text input with suggestions below + - Good for searchable dropdowns + - Example: examples/autocomplete/main.go + - Use case: Large option lists + +3. Custom with viewport + - Full control over appearance + - Scrollable for many options + - Example: examples/viewport/main.go (for scrolling) + - Build: Overlay viewport on trigger + +IMPLEMENTATION PATTERN: + +```go +type model struct { + dropdownOpen bool + dropdownList list.Model + selectedValue string +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "enter" && !m.dropdownOpen { + // Open dropdown + m.dropdownOpen = true + return m, nil + } + if m.dropdownOpen { + // Delegate to list + var cmd tea.Cmd + m.dropdownList, cmd = m.dropdownList.Update(msg) + return m, cmd + } + } + return m, nil +} +``` + +RECOMMENDED APPROACH: +Use list.Model in "dropdown mode" - render as overlay when open, hide when closed. + +Study: examples/list-simple/main.go +``` + +--- + +**Total Word Count**: ~7,200 words + +This comprehensive skill documentation provides: +- Clear activation criteria +- Complete workflow explanations +- Detailed function documentation +- Architecture patterns +- Error handling guidance +- Extensive usage examples +- Integration with charm-examples-inventory diff --git a/.crush/skills/bubbletea-designer/skills/bubbletea-designer/references/architecture-best-practices.md b/.crush/skills/bubbletea-designer/skills/bubbletea-designer/references/architecture-best-practices.md new file mode 100644 index 00000000..7a2f7f36 --- /dev/null +++ b/.crush/skills/bubbletea-designer/skills/bubbletea-designer/references/architecture-best-practices.md @@ -0,0 +1,168 @@ +# Bubble Tea Architecture Best Practices + +## Model Design + +### Keep State Flat +❌ Avoid: Deeply nested state +✅ Prefer: Flat structure with clear fields + +```go +// Good +type model struct { + items []Item + cursor int + selected map[int]bool +} + +// Avoid +type model struct { + state struct { + data struct { + items []Item + } + } +} +``` + +### Separate Concerns +- UI state in model +- Business logic in separate functions +- Network/IO in commands + +### Component Ownership +Each component owns its state. Don't reach into component internals. + +## Update Function + +### Message Routing +Route messages to appropriate handlers: + +```go +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + return m.handleKeyboard(msg) + case tea.WindowSizeMsg: + return m.handleResize(msg) + } + return m.updateComponents(msg) +} +``` + +### Command Batching +Batch multiple commands: + +```go +var cmds []tea.Cmd +cmds = append(cmds, cmd1, cmd2, cmd3) +return m, tea.Batch(cmds...) +``` + +## View Function + +### Cache Expensive Renders +Don't recompute on every View() call: + +```go +type model struct { + cachedView string + dirty bool +} + +func (m model) View() string { + if m.dirty { + m.cachedView = m.render() + m.dirty = false + } + return m.cachedView +} +``` + +### Responsive Layouts +Adapt to terminal size: + +```go +if m.width < 80 { + // Compact layout +} else { + // Full layout +} +``` + +## Performance + +### Minimize Allocations +Reuse slices and strings where possible + +### Defer Heavy Operations +Move slow operations to commands (async) + +### Debounce Rapid Updates +Don't update on every keystroke for expensive operations + +## Error Handling + +### User-Friendly Errors +Show actionable error messages + +### Graceful Degradation +Fallback when features unavailable + +### Error Recovery +Allow user to retry or cancel + +## Testing + +### Test Pure Functions +Extract business logic for easy testing + +### Mock Commands +Test Update() without side effects + +### Snapshot Views +Compare View() output for visual regression + +## Accessibility + +### Keyboard-First +All features accessible via keyboard + +### Clear Indicators +Show current focus, selection state + +### Help Text +Provide discoverable help (? key) + +## Code Organization + +### File Structure +``` +main.go - Entry point, model definition +update.go - Update handlers +view.go - View rendering +commands.go - Command definitions +messages.go - Custom message types +``` + +### Component Encapsulation +One component per file for complex TUIs + +## Debugging + +### Log to File +```go +f, _ := os.OpenFile("debug.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) +log.SetOutput(f) +log.Printf("Debug: %+v", msg) +``` + +### Debug Mode +Toggle debug view with key binding + +## Common Pitfalls + +1. **Forgetting tea.Batch**: Returns only last command +2. **Not handling WindowSizeMsg**: Fixed-size components +3. **Blocking in Update()**: Freezes UI - use commands +4. **Direct terminal writes**: Use tea.Println for above-TUI output +5. **Ignoring ready state**: Rendering before initialization complete diff --git a/.crush/skills/bubbletea-designer/skills/bubbletea-designer/references/bubbletea-components-guide.md b/.crush/skills/bubbletea-designer/skills/bubbletea-designer/references/bubbletea-components-guide.md new file mode 100644 index 00000000..6370aac1 --- /dev/null +++ b/.crush/skills/bubbletea-designer/skills/bubbletea-designer/references/bubbletea-components-guide.md @@ -0,0 +1,141 @@ +# Bubble Tea Components Guide + +Complete reference for Bubble Tea ecosystem components. + +## Core Input Components + +### textinput.Model +**Purpose**: Single-line text input +**Use Cases**: Search boxes, single field forms, command input +**Key Methods**: +- `Focus()` / `Blur()` - Focus management +- `SetValue(string)` - Set text programmatically +- `Value()` - Get current text + +**Example Pattern**: +```go +input := textinput.New() +input.Placeholder = "Search..." +input.Focus() +``` + +### textarea.Model +**Purpose**: Multi-line text editing +**Use Cases**: Message composition, text editing, large text input +**Key Features**: Line wrapping, scrolling, cursor management + +### filepicker.Model +**Purpose**: File system navigation +**Use Cases**: File selection, file browsers +**Key Features**: Directory traversal, file type filtering, path resolution + +## Display Components + +### viewport.Model +**Purpose**: Scrollable content display +**Use Cases**: Log viewers, document readers, large text display +**Key Methods**: +- `SetContent(string)` - Set viewable content +- `GotoTop()` / `GotoBottom()` - Navigation +- `LineUp()` / `LineDown()` - Scroll control + +### table.Model +**Purpose**: Tabular data display +**Use Cases**: Data tables, structured information +**Key Features**: Column definitions, row selection, styling + +### list.Model +**Purpose**: Filterable, navigable lists +**Use Cases**: Item selection, menus, file lists +**Key Features**: Filtering, pagination, custom item delegates + +### paginator.Model +**Purpose**: Page-based navigation +**Use Cases**: Paginated content, chunked display + +## Feedback Components + +### spinner.Model +**Purpose**: Loading/waiting indicator +**Styles**: Dot, Line, Minidot, Jump, Pulse, Points, Globe, Moon, Monkey + +### progress.Model +**Purpose**: Progress indication +**Modes**: Determinate (0-100%), Indeterminate +**Styling**: Gradient, solid color, custom + +### timer.Model +**Purpose**: Countdown timer +**Use Cases**: Timeouts, timed operations + +### stopwatch.Model +**Purpose**: Elapsed time tracking +**Use Cases**: Duration measurement, time tracking + +## Navigation Components + +### tabs +**Purpose**: Tab-based view switching +**Pattern**: Lipgloss-based tab rendering + +### help.Model +**Purpose**: Help text and keyboard shortcuts +**Modes**: Short (inline), Full (overlay) + +## Layout with Lipgloss + +**JoinVertical**: Stack components vertically +**JoinHorizontal**: Place components side-by-side +**Place**: Position with alignment +**Border**: Add borders and padding + +## Component Initialization Pattern + +```go +type model struct { + component1 component1.Model + component2 component2.Model +} + +func (m model) Init() tea.Cmd { + return tea.Batch( + m.component1.Init(), + m.component2.Init(), + ) +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + // Update each component + var cmd tea.Cmd + m.component1, cmd = m.component1.Update(msg) + cmds = append(cmds, cmd) + + m.component2, cmd = m.component2.Update(msg) + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} +``` + +## Message Handling + +**Standard Messages**: +- `tea.KeyMsg` - Keyboard input +- `tea.MouseMsg` - Mouse events +- `tea.WindowSizeMsg` - Terminal resize +- `tea.QuitMsg` - Quit signal + +**Component Messages**: +- `progress.FrameMsg` - Progress/spinner animation +- `spinner.TickMsg` - Spinner tick +- `textinput.ErrMsg` - Input errors + +## Best Practices + +1. **Always delegate**: Let components handle their own messages +2. **Batch commands**: Use `tea.Batch()` for multiple commands +3. **Focus management**: Only one component focused at a time +4. **Dimension tracking**: Update component sizes on `WindowSizeMsg` +5. **State separation**: Keep UI state in model, business logic separate diff --git a/.crush/skills/bubbletea-designer/skills/bubbletea-designer/references/design-patterns.md b/.crush/skills/bubbletea-designer/skills/bubbletea-designer/references/design-patterns.md new file mode 100644 index 00000000..2345ee11 --- /dev/null +++ b/.crush/skills/bubbletea-designer/skills/bubbletea-designer/references/design-patterns.md @@ -0,0 +1,214 @@ +# Bubble Tea Design Patterns + +Common architectural patterns for TUI development. + +## Pattern 1: Single-View Application + +**When**: Simple, focused TUIs with one main view +**Components**: 1-3 components, single model struct +**Complexity**: Low + +```go +type model struct { + mainComponent component.Model + ready bool +} +``` + +## Pattern 2: Multi-View State Machine + +**When**: Multiple distinct screens (setup, main, done) +**Components**: State enum + view-specific components +**Complexity**: Medium + +```go +type view int +const ( + setupView view = iota + mainView + doneView +) + +type model struct { + currentView view + // Components for each view +} +``` + +## Pattern 3: Composable Views + +**When**: Complex UIs with reusable sub-components +**Pattern**: Embed multiple bubble models +**Example**: Dashboard with multiple panels + +```go +type model struct { + panel1 Panel1Model + panel2 Panel2Model + panel3 Panel3Model +} + +// Each panel is itself a Bubble Tea model +``` + +## Pattern 4: Master-Detail + +**When**: Selection in one pane affects display in another +**Example**: File list + preview, Email list + content +**Layout**: Two-pane or three-pane + +```go +type model struct { + list list.Model + detail viewport.Model + selectedItem int +} +``` + +## Pattern 5: Form Flow + +**When**: Multi-step data collection +**Pattern**: Array of inputs + focus management +**Example**: Configuration wizard + +```go +type model struct { + inputs []textinput.Model + focusIndex int + step int +} +``` + +## Pattern 6: Progress Tracker + +**When**: Long-running sequential operations +**Pattern**: Queue + progress per item +**Example**: Installation, download manager + +```go +type model struct { + items []Item + currentIndex int + progress progress.Model + spinner spinner.Model +} +``` + +## Layout Patterns + +### Vertical Stack +```go +lipgloss.JoinVertical(lipgloss.Left, + header, + content, + footer, +) +``` + +### Horizontal Panels +```go +lipgloss.JoinHorizontal(lipgloss.Top, + leftPanel, + separator, + rightPanel, +) +``` + +### Three-Column (File Manager Style) +```go +lipgloss.JoinHorizontal(lipgloss.Top, + parentDir, // 25% width + currentDir, // 35% width + preview, // 40% width +) +``` + +## Message Passing Patterns + +### Custom Messages +```go +type myCustomMsg struct { + data string +} + +func doSomethingCmd() tea.Msg { + return myCustomMsg{data: "result"} +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case myCustomMsg: + // Handle custom message + } +} +``` + +### Async Operations +```go +func fetchDataCmd() tea.Cmd { + return func() tea.Msg { + // Do async work + data := fetchFromAPI() + return dataFetchedMsg{data} + } +} +``` + +## Error Handling Pattern + +```go +type errMsg struct{ err error } + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case errMsg: + m.err = msg.err + m.errVisible = true + return m, nil + } +} +``` + +## Keyboard Navigation Pattern + +```go +case tea.KeyMsg: + switch msg.String() { + case "up", "k": + m.cursor-- + case "down", "j": + m.cursor++ + case "enter": + m.selectCurrent() + case "q", "ctrl+c": + return m, tea.Quit + } +``` + +## Responsive Layout Pattern + +```go +case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + + // Update component dimensions + m.viewport.Width = msg.Width + m.viewport.Height = msg.Height - 5 // Reserve space for header/footer +``` + +## Help Overlay Pattern + +```go +type model struct { + showHelp bool + help help.Model +} + +func (m model) View() string { + if m.showHelp { + return m.help.View() + } + return m.mainView() +} +``` diff --git a/.crush/skills/bubbletea-designer/skills/bubbletea-designer/references/example-designs.md b/.crush/skills/bubbletea-designer/skills/bubbletea-designer/references/example-designs.md new file mode 100644 index 00000000..ca1b96de --- /dev/null +++ b/.crush/skills/bubbletea-designer/skills/bubbletea-designer/references/example-designs.md @@ -0,0 +1,98 @@ +# Example TUI Designs + +Real-world design examples with component selections. + +## Example 1: Log Viewer + +**Requirements**: View large log files, search, navigate +**Archetype**: Viewer +**Components**: +- viewport.Model - Main log display +- textinput.Model - Search input +- help.Model - Keyboard shortcuts + +**Architecture**: +```go +type model struct { + viewport viewport.Model + searchInput textinput.Model + searchMode bool + matches []int + currentMatch int +} +``` + +**Key Features**: +- Toggle search with `/` +- Navigate matches with n/N +- Highlight matches in viewport + +## Example 2: File Manager + +**Requirements**: Three-column navigation, preview +**Archetype**: File Manager +**Components**: +- list.Model (x2) - Parent + current directory +- viewport.Model - File preview +- filepicker.Model - Alternative approach + +**Layout**: Horizontal three-pane +**Complexity**: Medium-High + +## Example 3: Package Installer + +**Requirements**: Sequential installation with progress +**Archetype**: Installer +**Components**: +- list.Model - Package list +- progress.Model - Per-package progress +- spinner.Model - Download indicator + +**Pattern**: Progress Tracker +**Workflow**: Queue-based sequential processing + +## Example 4: Configuration Wizard + +**Requirements**: Multi-step form with validation +**Archetype**: Form +**Components**: +- textinput.Model array - Multiple inputs +- help.Model - Per-step help +- progress/indicator - Step progress + +**Pattern**: Form Flow +**Navigation**: Tab between fields, Enter to next step + +## Example 5: Dashboard + +**Requirements**: Multiple views, real-time updates +**Archetype**: Dashboard +**Components**: +- tabs - View switching +- table.Model - Data display +- viewport.Model - Log panel + +**Pattern**: Composable Views +**Layout**: Tabbed with multiple panels per tab + +## Component Selection Guide + +| Use Case | Primary Component | Alternative | Supporting | +|----------|------------------|-------------|-----------| +| Log viewing | viewport | pager | textinput (search) | +| File selection | filepicker | list | viewport (preview) | +| Data table | table | list | paginator | +| Text editing | textarea | textinput | viewport | +| Progress | progress | spinner | - | +| Multi-step | views | tabs | help | +| Search/Filter | textinput | autocomplete | list | + +## Complexity Matrix + +| TUI Type | Components | Views | Estimated Time | +|----------|-----------|-------|----------------| +| Simple viewer | 1-2 | 1 | 1-2 hours | +| File manager | 3-4 | 1 | 3-4 hours | +| Installer | 3-4 | 3 | 2-3 hours | +| Dashboard | 4-6 | 3+ | 4-6 hours | +| Editor | 2-3 | 1-2 | 3-4 hours | diff --git a/.crush/skills/bubbletea-designer/tests/test_integration.py b/.crush/skills/bubbletea-designer/tests/test_integration.py new file mode 100644 index 00000000..67417de7 --- /dev/null +++ b/.crush/skills/bubbletea-designer/tests/test_integration.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +""" +Integration tests for Bubble Tea Designer. +Tests complete workflows from description to design report. +""" + +import sys +from pathlib import Path + +# Add scripts to path +sys.path.insert(0, str(Path(__file__).parent.parent / 'scripts')) + +from analyze_requirements import extract_requirements +from map_components import map_to_components +from design_architecture import design_architecture +from generate_workflow import generate_implementation_workflow +from design_tui import comprehensive_tui_design_report + + +def test_analyze_requirements_basic(): + """Test requirement extraction from simple description.""" + print("\n✓ Testing extract_requirements()...") + + description = "Build a log viewer with search and highlighting" + result = extract_requirements(description) + + # Validations + assert 'archetype' in result, "Missing 'archetype' in result" + assert 'features' in result, "Missing 'features'" + assert result['archetype'] == 'viewer', f"Expected 'viewer', got {result['archetype']}" + assert 'search' in result['features'], "Should identify 'search' feature" + + print(f" ✓ Archetype: {result['archetype']}") + print(f" ✓ Features: {', '.join(result['features'])}") + print(f" ✓ Validation: {result['validation']['summary']}") + + return True + + +def test_map_components_viewer(): + """Test component mapping for viewer archetype.""" + print("\n✓ Testing map_to_components()...") + + requirements = { + 'archetype': 'viewer', + 'features': ['display', 'search', 'scrolling'], + 'data_types': ['text'], + 'views': 'single' + } + + result = map_to_components(requirements) + + # Validations + assert 'primary_components' in result, "Missing 'primary_components'" + assert len(result['primary_components']) > 0, "No components selected" + + # Should include viewport for viewing + comp_names = [c['component'] for c in result['primary_components']] + has_viewport = any('viewport' in name.lower() for name in comp_names) + + print(f" ✓ Components selected: {len(result['primary_components'])}") + print(f" ✓ Top component: {result['primary_components'][0]['component']}") + print(f" ✓ Has viewport: {has_viewport}") + + return True + + +def test_design_architecture(): + """Test architecture generation.""" + print("\n✓ Testing design_architecture()...") + + components = { + 'primary_components': [ + {'component': 'viewport.Model', 'score': 90}, + {'component': 'textinput.Model', 'score': 85} + ] + } + + requirements = { + 'archetype': 'viewer', + 'views': 'single' + } + + result = design_architecture(components, {}, requirements) + + # Validations + assert 'model_struct' in result, "Missing 'model_struct'" + assert 'message_handlers' in result, "Missing 'message_handlers'" + assert 'diagrams' in result, "Missing 'diagrams'" + assert 'tea.KeyMsg' in result['message_handlers'], "Missing keyboard handler" + + print(f" ✓ Model struct generated: {len(result['model_struct'])} chars") + print(f" ✓ Message handlers: {len(result['message_handlers'])}") + print(f" ✓ Diagrams: {len(result['diagrams'])}") + + return True + + +def test_generate_workflow(): + """Test workflow generation.""" + print("\n✓ Testing generate_implementation_workflow()...") + + architecture = { + 'model_struct': 'type model struct { ... }', + 'message_handlers': {'tea.KeyMsg': '...'} + } + + result = generate_implementation_workflow(architecture, {}) + + # Validations + assert 'phases' in result, "Missing 'phases'" + assert 'testing_checkpoints' in result, "Missing 'testing_checkpoints'" + assert len(result['phases']) >= 2, "Should have multiple phases" + + print(f" ✓ Workflow phases: {len(result['phases'])}") + print(f" ✓ Testing checkpoints: {len(result['testing_checkpoints'])}") + print(f" ✓ Estimated time: {result.get('total_estimated_time', 'N/A')}") + + return True + + +def test_comprehensive_report_log_viewer(): + """Test comprehensive report for log viewer.""" + print("\n✓ Testing comprehensive_tui_design_report() - Log Viewer...") + + description = "Build a log viewer with search and highlighting" + result = comprehensive_tui_design_report(description) + + # Validations + assert 'description' in result, "Missing 'description'" + assert 'summary' in result, "Missing 'summary'" + assert 'sections' in result, "Missing 'sections'" + + sections = result['sections'] + assert 'requirements' in sections, "Missing 'requirements' section" + assert 'components' in sections, "Missing 'components' section" + assert 'architecture' in sections, "Missing 'architecture' section" + assert 'workflow' in sections, "Missing 'workflow' section" + + print(f" ✓ TUI type: {result.get('tui_type', 'N/A')}") + print(f" ✓ Sections: {len(sections)}") + print(f" ✓ Summary: {result['summary'][:100]}...") + print(f" ✓ Validation: {result['validation']['summary']}") + + return True + + +def test_comprehensive_report_file_manager(): + """Test comprehensive report for file manager.""" + print("\n✓ Testing comprehensive_tui_design_report() - File Manager...") + + description = "Create a file manager with three-column view" + result = comprehensive_tui_design_report(description) + + # Validations + assert result.get('tui_type') == 'file-manager', f"Expected 'file-manager', got {result.get('tui_type')}" + + reqs = result['sections']['requirements'] + assert 'filepicker' in str(reqs).lower() or 'list' in str(reqs).lower(), \ + "Should suggest file-related components" + + print(f" ✓ TUI type: {result['tui_type']}") + print(f" ✓ Archetype correct") + + return True + + +def test_comprehensive_report_installer(): + """Test comprehensive report for installer.""" + print("\n✓ Testing comprehensive_tui_design_report() - Installer...") + + description = "Design an installer with progress bars for packages" + result = comprehensive_tui_design_report(description) + + # Validations + assert result.get('tui_type') == 'installer', f"Expected 'installer', got {result.get('tui_type')}" + + components = result['sections']['components'] + comp_names = str([c['component'] for c in components.get('primary_components', [])]) + assert 'progress' in comp_names.lower() or 'spinner' in comp_names.lower(), \ + "Should suggest progress components" + + print(f" ✓ TUI type: {result['tui_type']}") + print(f" ✓ Progress components suggested") + + return True + + +def test_validation_integration(): + """Test that validation is integrated in all functions.""" + print("\n✓ Testing validation integration...") + + description = "Build a log viewer" + result = comprehensive_tui_design_report(description) + + # Check each section has validation + sections = result['sections'] + + if 'requirements' in sections: + assert 'validation' in sections['requirements'], "Requirements should have validation" + print(" ✓ Requirements validated") + + if 'components' in sections: + assert 'validation' in sections['components'], "Components should have validation" + print(" ✓ Components validated") + + if 'architecture' in sections: + assert 'validation' in sections['architecture'], "Architecture should have validation" + print(" ✓ Architecture validated") + + if 'workflow' in sections: + assert 'validation' in sections['workflow'], "Workflow should have validation" + print(" ✓ Workflow validated") + + # Overall validation + assert 'validation' in result, "Report should have overall validation" + print(" ✓ Overall report validated") + + return True + + +def test_code_scaffolding(): + """Test code scaffolding generation.""" + print("\n✓ Testing code scaffolding generation...") + + description = "Simple log viewer" + result = comprehensive_tui_design_report(description, detail_level="complete") + + # Validations + assert 'scaffolding' in result, "Missing 'scaffolding'" + assert 'main_go' in result['scaffolding'], "Missing 'main_go' scaffold" + + main_go = result['scaffolding']['main_go'] + assert 'package main' in main_go, "Should have package main" + assert 'type model struct' in main_go, "Should have model struct" + assert 'func main()' in main_go, "Should have main function" + + print(f" ✓ Scaffolding generated: {len(main_go)} chars") + print(" ✓ Contains package main") + print(" ✓ Contains model struct") + print(" ✓ Contains main function") + + return True + + +def main(): + """Run all integration tests.""" + print("=" * 70) + print("INTEGRATION TESTS - Bubble Tea Designer") + print("=" * 70) + + tests = [ + ("Requirement extraction", test_analyze_requirements_basic), + ("Component mapping", test_map_components_viewer), + ("Architecture design", test_design_architecture), + ("Workflow generation", test_generate_workflow), + ("Comprehensive report - Log Viewer", test_comprehensive_report_log_viewer), + ("Comprehensive report - File Manager", test_comprehensive_report_file_manager), + ("Comprehensive report - Installer", test_comprehensive_report_installer), + ("Validation integration", test_validation_integration), + ("Code scaffolding", test_code_scaffolding), + ] + + results = [] + for test_name, test_func in tests: + try: + passed = test_func() + results.append((test_name, passed)) + except Exception as e: + print(f"\n ❌ FAILED: {e}") + import traceback + traceback.print_exc() + results.append((test_name, False)) + + # Summary + print("\n" + "=" * 70) + print("SUMMARY") + print("=" * 70) + + for test_name, passed in results: + status = "✅ PASS" if passed else "❌ FAIL" + print(f"{status}: {test_name}") + + passed_count = sum(1 for _, p in results if p) + total_count = len(results) + + print(f"\nResults: {passed_count}/{total_count} passed") + + return passed_count == total_count + + +if __name__ == "__main__": + success = main() + sys.exit(0 if success else 1) diff --git a/.crush/skills/bubbletea-maintenance/.claude-plugin/marketplace.json b/.crush/skills/bubbletea-maintenance/.claude-plugin/marketplace.json new file mode 100644 index 00000000..eaa7b1dc --- /dev/null +++ b/.crush/skills/bubbletea-maintenance/.claude-plugin/marketplace.json @@ -0,0 +1,22 @@ +{ + "name": "bubbletea-maintenance", + "owner": { + "name": "William VanSickle III", + "email": "noreply@example.com" + }, + "metadata": { + "description": "Expert Bubble Tea maintenance and debugging agent - diagnoses issues, applies best practices, and enhances existing Go/Bubble Tea TUI applications", + "version": "1.0.0", + "created": "2025-10-19", + "tags": ["bubble-tea", "go", "tui", "debugging", "maintenance", "performance", "bubbletea", "lipgloss"] + }, + "plugins": [ + { + "name": "bubbletea-maintenance-plugin", + "description": "Expert Bubble Tea maintenance and debugging agent specializing in diagnosing issues, applying best practices, and enhancing existing Go/Bubble Tea TUI applications. Helps developers maintain, debug, and improve their terminal user interfaces built with the Bubble Tea framework.", + "source": "./", + "strict": false, + "skills": ["./"] + } + ] +} diff --git a/.crush/skills/bubbletea-maintenance/.claude-plugin/plugin.json b/.crush/skills/bubbletea-maintenance/.claude-plugin/plugin.json new file mode 100644 index 00000000..f6718410 --- /dev/null +++ b/.crush/skills/bubbletea-maintenance/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "bubbletea-maintenance", + "description": "Expert Bubble Tea maintenance and debugging agent - diagnoses issues, applies best practices, and enhances existing Go/Bubble Tea TUI applications", + "author": { + "name": "William VanSickle III", + "email": "noreply@example.com" + } +} diff --git a/.crush/skills/bubbletea-maintenance/.skillfish.json b/.crush/skills/bubbletea-maintenance/.skillfish.json new file mode 100644 index 00000000..313a393e --- /dev/null +++ b/.crush/skills/bubbletea-maintenance/.skillfish.json @@ -0,0 +1,10 @@ +{ + "version": 2, + "name": "bubbletea-maintenance", + "owner": "human-frontier-labs-inc", + "repo": "human-frontier-labs-marketplace", + "path": "plugins/bubbletea-maintenance", + "branch": "master", + "sha": "04c70e5e715955691670c1797a8fb96b8e6155bc", + "source": "manual" +} \ No newline at end of file diff --git a/.crush/skills/bubbletea-maintenance/CHANGELOG.md b/.crush/skills/bubbletea-maintenance/CHANGELOG.md new file mode 100644 index 00000000..5f6a767e --- /dev/null +++ b/.crush/skills/bubbletea-maintenance/CHANGELOG.md @@ -0,0 +1,141 @@ +# Changelog + +All notable changes to Bubble Tea Maintenance Agent will be documented here. + +Format based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +Versioning follows [Semantic Versioning](https://semver.org/). + +## [1.0.0] - 2025-10-19 + +### Added + +**Core Functionality:** +- `diagnose_issue()` - Comprehensive issue diagnosis for Bubble Tea apps +- `apply_best_practices()` - Validation against 11 expert tips +- `debug_performance()` - Performance bottleneck identification +- `suggest_architecture()` - Architecture pattern recommendations +- `fix_layout_issues()` - Lipgloss layout problem solving +- `comprehensive_bubbletea_analysis()` - All-in-one health check orchestrator + +**Issue Detection:** +- Blocking operations in Update() and View() +- Hardcoded terminal dimensions +- Missing terminal recovery code +- Message ordering assumptions +- Model complexity analysis +- Goroutine leak detection +- Layout arithmetic errors +- String concatenation inefficiencies +- Regex compilation in hot paths +- Memory allocation patterns + +**Best Practices Validation:** +- Tip 1: Fast event loop validation +- Tip 2: Debug message dumping capability check +- Tip 3: Live reload setup detection +- Tip 4: Receiver method pattern validation +- Tip 5: Message ordering handling +- Tip 6: Model tree architecture analysis +- Tip 7: Layout arithmetic best practices +- Tip 8: Terminal recovery implementation +- Tip 9: teatest usage +- Tip 10: VHS demo presence +- Tip 11: Additional resources reference + +**Performance Analysis:** +- Update() execution time estimation +- View() rendering complexity analysis +- String operation optimization suggestions +- Loop efficiency checking +- Allocation pattern detection +- Concurrent operation safety validation +- I/O operation placement verification + +**Architecture Recommendations:** +- Pattern detection (flat, multi-view, model tree, component-based, state machine) +- Complexity scoring (0-100) +- Refactoring step generation +- Code template provision for recommended patterns +- Model tree, multi-view, and state machine examples + +**Layout Fixes:** +- Hardcoded dimension detection and fixes +- Padding/border accounting +- Terminal resize handling +- Overflow prevention +- lipgloss.Height()/Width() usage validation + +**Utilities:** +- Go code parser for model, Update(), View(), Init() extraction +- Custom message type detection +- tea.Cmd function analysis +- Bubble Tea component usage finder +- State machine enum extraction +- Comprehensive validation framework + +**Documentation:** +- Complete SKILL.md (8,000+ words) +- README with usage examples +- Common issues reference +- Performance optimization guide +- Layout best practices guide +- Architecture patterns catalog +- Installation guide +- Decision documentation + +**Testing:** +- Unit tests for all 6 core functions +- Integration test suite +- Validation test coverage +- Test fixtures for common scenarios + +### Data Coverage + +**Issue Categories:** +- Performance (7 checks) +- Layout (6 checks) +- Reliability (3 checks) +- Architecture (2 checks) +- Memory (2 checks) + +**Best Practice Tips:** +- 11 expert tips from tip-bubbltea-apps.md +- Compliance scoring +- Recommendation generation + +**Performance Thresholds:** +- Update() target: <16ms +- View() target: <3ms +- Goroutine leak detection +- Memory allocation analysis + +### Known Limitations + +- Requires local tip-bubbltea-apps.md file for full best practices validation +- Go code parsing uses regex (not AST) for speed +- Performance estimates are based on patterns, not actual profiling +- Architecture suggestions are heuristic-based + +### Planned for v2.0 + +- AST-based Go parsing for more accurate analysis +- Integration with pprof for actual performance data +- Automated fix application (not just suggestions) +- Custom best practices rule definitions +- Visual reports with charts/graphs +- CI/CD integration for automated checks + +## [Unreleased] + +### Planned + +- Support for Bubble Tea v1.0+ features +- More architecture patterns (event sourcing, CQRS) +- Performance regression detection +- Code complexity metrics (cyclomatic complexity) +- Dependency analysis +- Security vulnerability checks + +--- + +**Generated with Claude Code agent-creator skill on 2025-10-19** diff --git a/.crush/skills/bubbletea-maintenance/DECISIONS.md b/.crush/skills/bubbletea-maintenance/DECISIONS.md new file mode 100644 index 00000000..cc3ec98f --- /dev/null +++ b/.crush/skills/bubbletea-maintenance/DECISIONS.md @@ -0,0 +1,323 @@ +# Architecture Decisions + +Documentation of key design decisions for Bubble Tea Maintenance Agent. + +## Core Purpose Decision + +**Decision**: Focus on maintenance/debugging of existing Bubble Tea apps, not design + +**Rationale**: +- ✅ Complements `bubbletea-designer` agent (design) with maintenance agent (upkeep) +- ✅ Different problem space: diagnosis vs creation +- ✅ Users have existing apps that need optimization +- ✅ Maintenance is ongoing, design is one-time + +**Alternatives Considered**: +- Combined design+maintenance agent: Too broad, conflicting concerns +- Generic Go linter: Misses Bubble Tea-specific patterns + +--- + +## Data Source Decision + +**Decision**: Use local tip-bubbltea-apps.md file as knowledge base + +**Rationale**: +- ✅ No internet required (offline capability) +- ✅ Fast access (local file system) +- ✅ Expert-curated knowledge (leg100.github.io) +- ✅ 11 specific, actionable tips +- ✅ Can be updated independently + +**Alternatives Considered**: +- Web scraping: Fragile, requires internet, slow +- Embedded knowledge: Hard to update, limited +- API: Rate limits, auth, network dependency + +**Trade-offs**: +- User needs to have tip file locally +- Updates require manual file replacement + +--- + +## Analysis Approach Decision + +**Decision**: 6 separate specialized functions + 1 orchestrator + +**Rationale**: +- ✅ Single Responsibility Principle +- ✅ Composable - can use individually or together +- ✅ Testable - each function independently tested +- ✅ Flexible - run quick diagnosis or deep analysis + +**Structure**: +1. `diagnose_issue()` - General problem identification +2. `apply_best_practices()` - Validate against 11 tips +3. `debug_performance()` - Performance bottleneck detection +4. `suggest_architecture()` - Refactoring recommendations +5. `fix_layout_issues()` - Lipgloss layout fixes +6. `comprehensive_analysis()` - Orchestrates all 5 + +**Alternatives Considered**: +- Single monolithic function: Hard to test, maintain, customize +- 20 micro-functions: Too granular, confusing +- Plugin architecture: Over-engineered for v1.0 + +--- + +## Code Parsing Strategy + +**Decision**: Regex-based parsing instead of AST + +**Rationale**: +- ✅ Fast - no parse tree construction +- ✅ Simple - easy to understand, maintain +- ✅ Good enough - catches 90% of issues +- ✅ No external dependencies (go/parser) +- ✅ Cross-platform - pure Python + +**Alternatives Considered**: +- AST parsing (go/parser): More accurate but slow, complex +- Token-based: Middle ground, still complex +- LLM-based: Overkill, slow, requires API + +**Trade-offs**: +- May miss edge cases (rare nested structures) +- Can't detect all semantic issues +- Good for pattern matching, not deep analysis + +**When to upgrade to AST**: +- v2.0 if accuracy becomes critical +- If false positive rate >5% +- If complex refactoring automation is added + +--- + +## Validation Strategy + +**Decision**: Multi-layer validation with severity levels + +**Rationale**: +- ✅ Early error detection +- ✅ Clear prioritization (CRITICAL > WARNING > INFO) +- ✅ Actionable feedback +- ✅ User can triage fixes + +**Severity Levels**: +- **CRITICAL**: Breaks UI, must fix immediately +- **HIGH**: Significant performance/reliability impact +- **MEDIUM**: Noticeable but not critical +- **WARNING**: Best practice violation +- **LOW**: Minor optimization +- **INFO**: Suggestions, not problems + +**Validation Layers**: +1. Input validation (paths exist, files readable) +2. Structure validation (result format correct) +3. Content validation (scores in range, fields present) +4. Semantic validation (recommendations make sense) + +--- + +## Performance Threshold Decision + +**Decision**: Update() <16ms, View() <3ms targets + +**Rationale**: +- 16ms = 60 FPS (1000ms / 60 = 16.67ms) +- View() should be faster (called more often) +- Based on Bubble Tea best practices +- Leaves budget for framework overhead + +**Measurement**: +- Static analysis (pattern detection, not timing) +- Identifies blocking operations +- Estimates based on operation type: + - HTTP request: 50-200ms + - File I/O: 1-100ms + - Regex compile: 1-10ms + - String concat: 0.1-1ms per operation + +**Future**: v2.0 could integrate pprof for actual measurements + +--- + +## Architecture Pattern Decision + +**Decision**: Heuristic-based pattern detection and recommendations + +**Rationale**: +- ✅ Works without user input +- ✅ Based on complexity metrics +- ✅ Provides concrete steps +- ✅ Includes code templates + +**Complexity Scoring** (0-100): +- File count (10 points max) +- Model field count (20 points) +- Update() case count (20 points) +- View() line count (15 points) +- Custom message count (10 points) +- View function count (15 points) +- Concurrency usage (10 points) + +**Pattern Recommendations**: +- <30: flat_model (simple) +- 30-70: multi_view or component_based (medium) +- 70+: model_tree (complex) + +--- + +## Best Practices Integration + +**Decision**: Map each of 11 tips to automated checks + +**Rationale**: +- ✅ Leverages expert knowledge +- ✅ Specific, actionable tips +- ✅ Comprehensive coverage +- ✅ Education + validation + +**Tip Mapping**: +1. Fast event loop → Check for blocking ops in Update() +2. Debug dumping → Look for spew/io.Writer +3. Live reload → Check for air config +4. Receiver methods → Validate Update() receiver type +5. Message ordering → Check for state tracking +6. Model tree → Analyze model complexity +7. Layout arithmetic → Validate lipgloss.Height() usage +8. Terminal recovery → Check for defer/recover +9. teatest → Look for test files +10. VHS → Check for .tape files +11. Resources → Info-only + +--- + +## Error Handling Strategy + +**Decision**: Return errors in result dict, never raise exceptions + +**Rationale**: +- ✅ Graceful degradation +- ✅ Partial results still useful +- ✅ Easy to aggregate errors +- ✅ Doesn't break orchestrator + +**Format**: +```python +{ + "error": "Description", + "validation": { + "status": "error", + "summary": "What went wrong" + } +} +``` + +**Philosophy**: +- Better to return partial analysis than fail completely +- User can act on what was found +- Errors are just another data point + +--- + +## Report Format Decision + +**Decision**: JSON output with CLI-friendly summary + +**Rationale**: +- ✅ Machine-readable (JSON for tools) +- ✅ Human-readable (CLI summary) +- ✅ Composable (can pipe to jq, etc.) +- ✅ Saveable (file output) + +**Structure**: +```python +{ + "overall_health": 75, + "sections": { + "issues": {...}, + "best_practices": {...}, + "performance": {...}, + "architecture": {...}, + "layout": {...} + }, + "priority_fixes": [...], + "summary": "Executive summary", + "estimated_fix_time": "2-4 hours", + "validation": {...} +} +``` + +--- + +## Testing Strategy + +**Decision**: Unit tests per function + integration tests + +**Rationale**: +- ✅ Each function independently tested +- ✅ Integration tests verify orchestration +- ✅ Test fixtures for common scenarios +- ✅ ~90% code coverage target + +**Test Structure**: +``` +tests/ +├── test_diagnose_issue.py # diagnose_issue() tests +├── test_best_practices.py # apply_best_practices() tests +├── test_performance.py # debug_performance() tests +├── test_architecture.py # suggest_architecture() tests +├── test_layout.py # fix_layout_issues() tests +└── test_integration.py # End-to-end tests +``` + +**Test Coverage**: +- Happy path (valid code) +- Edge cases (empty files, no functions) +- Error cases (invalid paths, bad Go code) +- Integration (orchestrator combines correctly) + +--- + +## Documentation Strategy + +**Decision**: Comprehensive SKILL.md + reference docs + +**Rationale**: +- ✅ Self-contained (agent doesn't need external docs) +- ✅ Examples for every pattern +- ✅ Education + automation +- ✅ Quick reference guides + +**Documentation Files**: +1. **SKILL.md** - Complete agent instructions (8,000 words) +2. **README.md** - Quick start guide +3. **common_issues.md** - Problem/solution catalog +4. **CHANGELOG.md** - Version history +5. **DECISIONS.md** - This file +6. **INSTALLATION.md** - Setup guide + +--- + +## Future Enhancements + +**v2.0 Ideas**: +- AST-based parsing for higher accuracy +- Integration with pprof for actual profiling data +- Automated fix application (not just suggestions) +- Custom rule definitions +- Visual reports +- CI/CD integration +- GitHub Action for PR checks +- VSCode extension integration + +**Criteria for v2.0**: +- User feedback indicates accuracy issues +- False positive rate >5% +- Users request automated fixes +- Adoption reaches 100+ users + +--- + +**Built with Claude Code agent-creator on 2025-10-19** diff --git a/.crush/skills/bubbletea-maintenance/INSTALLATION.md b/.crush/skills/bubbletea-maintenance/INSTALLATION.md new file mode 100644 index 00000000..b421c527 --- /dev/null +++ b/.crush/skills/bubbletea-maintenance/INSTALLATION.md @@ -0,0 +1,332 @@ +# Installation Guide + +Step-by-step guide to installing and using the Bubble Tea Maintenance Agent. + +--- + +## Prerequisites + +**Required:** +- Python 3.8+ +- Claude Code CLI installed + +**Optional (for full functionality):** +- `/Users/williamvansickleiii/charmtuitemplate/charm-tui-template/tip-bubbltea-apps.md` +- `/Users/williamvansickleiii/charmtuitemplate/charm-tui-template/lipgloss-readme.md` + +--- + +## Installation Steps + +### 1. Navigate to Agent Directory + +```bash +cd /Users/williamvansickleiii/charmtuitemplate/vinw/bubbletea-designer/bubbletea-maintenance +``` + +### 2. Verify Files + +Check that all required files exist: + +```bash +ls -la +``` + +You should see: +- `.claude-plugin/marketplace.json` +- `SKILL.md` +- `README.md` +- `scripts/` directory +- `references/` directory +- `tests/` directory + +### 3. Install the Agent + +```bash +/plugin marketplace add . +``` + +Or from within Claude Code: + +``` +/plugin marketplace add /Users/williamvansickleiii/charmtuitemplate/vinw/bubbletea-designer/bubbletea-maintenance +``` + +### 4. Verify Installation + +The agent should now appear in your Claude Code plugins list: + +``` +/plugin list +``` + +Look for: `bubbletea-maintenance` + +--- + +## Testing the Installation + +### Quick Test + +Ask Claude Code: + +``` +"Analyze my Bubble Tea app at /path/to/your/app" +``` + +The agent should activate and run a comprehensive analysis. + +### Detailed Test + +Run the test suite: + +```bash +cd /Users/williamvansickleiii/charmtuitemplate/vinw/bubbletea-designer/bubbletea-maintenance +python3 -m pytest tests/ -v +``` + +Expected output: +``` +tests/test_diagnose_issue.py ✓✓✓✓ +tests/test_best_practices.py ✓✓✓✓ +tests/test_performance.py ✓✓✓✓ +tests/test_architecture.py ✓✓✓✓ +tests/test_layout.py ✓✓✓✓ +tests/test_integration.py ✓✓✓ + +======================== XX passed in X.XXs ======================== +``` + +--- + +## Configuration + +### Setting Up Local References + +For full best practices validation, ensure these files exist: + +1. **tip-bubbltea-apps.md** + ```bash + ls /Users/williamvansickleiii/charmtuitemplate/charm-tui-template/tip-bubbltea-apps.md + ``` + + If missing, the agent will still work but best practices validation will be limited. + +2. **lipgloss-readme.md** + ```bash + ls /Users/williamvansickleiii/charmtuitemplate/charm-tui-template/lipgloss-readme.md + ``` + +### Customizing Paths + +If your reference files are in different locations, update paths in: +- `scripts/apply_best_practices.py` (line 16: `TIPS_FILE`) + +--- + +## Usage Examples + +### Example 1: Diagnose Issues + +``` +User: "My Bubble Tea app is slow, diagnose issues" + +Agent: [Runs diagnose_issue()] +Found 3 issues: +1. CRITICAL: Blocking HTTP request in Update() (main.go:45) +2. WARNING: Hardcoded terminal width (main.go:89) +3. INFO: Consider model tree pattern for 18 fields + +[Provides fixes for each] +``` + +### Example 2: Check Best Practices + +``` +User: "Check if my TUI follows best practices" + +Agent: [Runs apply_best_practices()] +Overall Score: 75/100 + +✅ PASS: Fast event loop +✅ PASS: Terminal recovery +⚠️ FAIL: No debug message dumping +⚠️ FAIL: No tests with teatest +INFO: No VHS demos (optional) + +[Provides recommendations] +``` + +### Example 3: Comprehensive Analysis + +``` +User: "Run full analysis on ./myapp" + +Agent: [Runs comprehensive_bubbletea_analysis()] + +================================================================= +COMPREHENSIVE BUBBLE TEA ANALYSIS +================================================================= + +Overall Health: 78/100 +Summary: Good health. Some improvements recommended. + +Priority Fixes (5): + +🔴 CRITICAL (1): + 1. [Performance] Blocking HTTP request in Update() (main.go:45) + +⚠️ WARNINGS (2): + 2. [Best Practices] Missing debug message dumping + 3. [Layout] Hardcoded dimensions in View() + +💡 INFO (2): + 4. [Architecture] Consider model tree pattern + 5. [Performance] Cache lipgloss styles + +Estimated Fix Time: 2-4 hours + +Full report saved to: ./bubbletea_analysis_report.json +``` + +--- + +## Troubleshooting + +### Issue: Agent Not Activating + +**Solution 1: Check Installation** +```bash +/plugin list +``` + +If not listed, reinstall: +```bash +/plugin marketplace add /path/to/bubbletea-maintenance +``` + +**Solution 2: Use Explicit Activation** + +Instead of: +``` +"Analyze my Bubble Tea app" +``` + +Try: +``` +"Use the bubbletea-maintenance agent to analyze my app" +``` + +### Issue: "No .go files found" + +**Cause**: Wrong path provided + +**Solution**: Use absolute path or verify path exists: +```bash +ls /path/to/your/app +``` + +### Issue: "tip-bubbltea-apps.md not found" + +**Impact**: Best practices validation will be limited + +**Solutions**: + +1. **Get the file**: + ```bash + # If you have charm-tui-template + ls /Users/williamvansickleiii/charmtuitemplate/charm-tui-template/tip-bubbltea-apps.md + ``` + +2. **Update path** in `scripts/apply_best_practices.py`: + ```python + TIPS_FILE = Path("/your/custom/path/tip-bubbltea-apps.md") + ``` + +3. **Or skip best practices**: + The other 5 functions still work without it. + +### Issue: Tests Failing + +**Check Python Version**: +```bash +python3 --version # Should be 3.8+ +``` + +**Install Test Dependencies**: +```bash +pip3 install pytest +``` + +**Run Individual Tests**: +```bash +python3 tests/test_diagnose_issue.py +``` + +### Issue: Permission Denied + +**Solution**: Make scripts executable: +```bash +chmod +x scripts/*.py +``` + +--- + +## Uninstallation + +To remove the agent: + +```bash +/plugin marketplace remove bubbletea-maintenance +``` + +Or manually delete the plugin directory: +```bash +rm -rf /path/to/bubbletea-maintenance +``` + +--- + +## Upgrading + +### To v1.0.1+ + +1. **Backup your config** (if you customized paths) +2. **Remove old version**: + ```bash + /plugin marketplace remove bubbletea-maintenance + ``` +3. **Install new version**: + ```bash + cd /path/to/new/bubbletea-maintenance + /plugin marketplace add . + ``` +4. **Verify**: + ```bash + cat VERSION # Should show new version + ``` + +--- + +## Support + +**Issues**: Check SKILL.md for detailed documentation + +**Questions**: +- Read `references/common_issues.md` for solutions +- Check CHANGELOG.md for known limitations + +--- + +## Next Steps + +After installation: + +1. **Try it out**: Analyze one of your Bubble Tea apps +2. **Read documentation**: Check references/ for guides +3. **Run tests**: Ensure everything works +4. **Customize**: Update paths if needed + +--- + +**Built with Claude Code agent-creator on 2025-10-19** diff --git a/.crush/skills/bubbletea-maintenance/README.md b/.crush/skills/bubbletea-maintenance/README.md new file mode 100644 index 00000000..bc7d0a18 --- /dev/null +++ b/.crush/skills/bubbletea-maintenance/README.md @@ -0,0 +1,320 @@ +# Bubble Tea Maintenance & Debugging Agent + +Expert agent for diagnosing, fixing, and optimizing existing Bubble Tea TUI applications. + +**Version:** 1.0.0 +**Created:** 2025-10-19 + +--- + +## What This Agent Does + +This agent helps you maintain and improve existing Go/Bubble Tea applications by: + +✅ **Diagnosing Issues** - Identifies performance bottlenecks, layout problems, memory leaks +✅ **Validating Best Practices** - Checks against 11 expert tips from tip-bubbltea-apps.md +✅ **Optimizing Performance** - Finds slow operations in Update() and View() +✅ **Suggesting Architecture** - Recommends refactoring to model tree, multi-view patterns +✅ **Fixing Layout Issues** - Solves Lipgloss dimension, padding, overflow problems +✅ **Comprehensive Analysis** - Complete health check with prioritized fixes + +--- + +## Installation + +```bash +cd /path/to/bubbletea-maintenance +/plugin marketplace add . +``` + +The agent will be available in your Claude Code session. + +--- + +## Quick Start + +**Analyze your Bubble Tea app:** + +"Analyze my Bubble Tea application at ./myapp" + +The agent will perform a comprehensive health check and provide: +- Overall health score (0-100) +- Critical issues requiring immediate attention +- Performance bottlenecks +- Layout problems +- Architecture recommendations +- Estimated fix time + +--- + +## Core Functions + +### 1. diagnose_issue() + +Identifies common Bubble Tea problems: +- Blocking operations in event loop +- Hardcoded terminal dimensions +- Missing terminal recovery +- Message ordering issues +- Model complexity problems + +**Usage:** +``` +"Diagnose issues in ./myapp/main.go" +``` + +### 2. apply_best_practices() + +Validates against 11 expert tips: +1. Fast event loop (no blocking) +2. Debug message dumping +3. Live reload setup +4. Proper receiver methods +5. Message ordering handling +6. Model tree architecture +7. Layout arithmetic +8. Terminal recovery +9. teatest usage +10. VHS demos +11. Additional resources + +**Usage:** +``` +"Check best practices for ./myapp" +``` + +### 3. debug_performance() + +Finds performance bottlenecks: +- Slow Update() operations +- Expensive View() rendering +- String concatenation issues +- Regex compilation in functions +- Nested loops +- Memory allocations +- Goroutine leaks + +**Usage:** +``` +"Debug performance of my TUI" +``` + +### 4. suggest_architecture() + +Recommends patterns based on complexity: +- **Simple** (< 30): Flat model +- **Medium** (30-70): Multi-view or component-based +- **Complex** (70+): Model tree + +Provides: +- Current pattern detection +- Complexity score +- Recommended pattern +- Step-by-step refactoring guide +- Code templates + +**Usage:** +``` +"Suggest architecture improvements for ./myapp" +``` + +### 5. fix_layout_issues() + +Solves Lipgloss layout problems: +- Hardcoded dimensions → dynamic sizing +- Manual calculations → lipgloss.Height()/Width() +- Missing padding accounting → GetHorizontalPadding() +- Text overflow → wordwrap or truncate +- No resize handling → tea.WindowSizeMsg +- Border sizing → GetHorizontalBorderSize() + +**Usage:** +``` +"Fix layout issues in my TUI" +``` + +### 6. comprehensive_analysis() + +All-in-one health check combining all functions. + +Outputs: +- Overall health score +- Priority fixes (CRITICAL → WARNING → INFO) +- Estimated fix time +- Detailed report per section + +**Usage:** +``` +"Run comprehensive analysis on ./myapp" +``` + +--- + +## Example Session + +``` +User: "My log viewer TUI is laggy when scrolling" + +Agent: Let me analyze your application... + +[Runs debug_performance()] + +Found the issue! Your View() function: +1. Re-reads the entire log file on every render (200ms) +2. Processes all lines instead of just the visible viewport +3. Uses string concatenation with + operator + +Here's the fix: + +// Cache file content +type model struct { + logLines []string // Cache in model + viewport viewport.Model +} + +// Load once +func loadLogFile() tea.Msg { + content, _ := os.ReadFile("app.log") + lines := strings.Split(string(content), "\\n") + return logLoadedMsg{lines: lines} +} + +// Update only when needed +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + case logLoadedMsg: + m.logLines = msg.lines + m.viewport.SetContent(strings.Join(m.logLines, "\\n")) +} + +// View just renders viewport +func (m model) View() string { + return m.viewport.View() // Fast! No I/O +} + +This will reduce render time from 200ms to ~2ms. +``` + +--- + +## Activation Keywords + +The agent activates on phrases like: + +- "debug my bubble tea app" +- "fix this TUI issue" +- "optimize bubbletea performance" +- "why is my TUI slow" +- "refactor bubble tea code" +- "apply bubbletea best practices" +- "fix layout issues" +- "lipgloss styling problem" +- "improve my TUI" +- "bubbletea architecture help" + +--- + +## Reference Files + +Located in `references/`: + +- **common_issues.md** - Solutions to frequent problems +- **performance_guide.md** - Optimization strategies +- **layout_guide.md** - Lipgloss layout best practices +- **architecture_patterns.md** - Model tree, multi-view, state machine patterns + +--- + +## Local Knowledge Sources + +The agent uses these local files (no internet required): + +- `/Users/williamvansickleiii/charmtuitemplate/charm-tui-template/tip-bubbltea-apps.md` - 11 expert tips +- `/Users/williamvansickleiii/charmtuitemplate/charm-tui-template/lipgloss-readme.md` - Lipgloss docs +- `/Users/williamvansickleiii/charmtuitemplate/vinw/` - Real-world example app +- `/Users/williamvansickleiii/charmtuitemplate/charm-examples-inventory/` - Pattern library + +--- + +## Testing + +Run the test suite: + +```bash +cd bubbletea-maintenance +python3 -m pytest tests/ -v +``` + +Or run individual test files: + +```bash +python3 tests/test_diagnose_issue.py +python3 tests/test_best_practices.py +python3 tests/test_performance.py +``` + +--- + +## Architecture + +``` +bubbletea-maintenance/ +├── SKILL.md # Agent instructions (8,000 words) +├── README.md # This file +├── scripts/ +│ ├── diagnose_issue.py # Issue diagnosis +│ ├── apply_best_practices.py # Best practices validation +│ ├── debug_performance.py # Performance analysis +│ ├── suggest_architecture.py # Architecture recommendations +│ ├── fix_layout_issues.py # Layout fixes +│ ├── comprehensive_analysis.py # All-in-one orchestrator +│ └── utils/ +│ ├── go_parser.py # Go code parsing +│ └── validators/ +│ └── common.py # Validation utilities +├── references/ +│ ├── common_issues.md # Issue reference +│ ├── performance_guide.md # Performance tips +│ ├── layout_guide.md # Layout guide +│ └── architecture_patterns.md # Pattern catalog +├── assets/ +│ ├── issue_categories.json # Issue taxonomy +│ ├── best_practices_tips.json # Tips database +│ └── performance_thresholds.json # Performance targets +└── tests/ + ├── test_diagnose_issue.py + ├── test_best_practices.py + ├── test_performance.py + ├── test_architecture.py + ├── test_layout.py + └── test_integration.py +``` + +--- + +## Limitations + +This agent focuses on **maintenance and debugging**, NOT: + +- ❌ Designing new TUIs from scratch (use `bubbletea-designer` for that) +- ❌ Non-Bubble Tea Go code +- ❌ Terminal emulator issues +- ❌ OS-specific problems + +--- + +## Version History + +**v1.0.0** (2025-10-19) +- Initial release +- 6 core analysis functions +- Integration with tip-bubbltea-apps.md +- Comprehensive diagnostic capabilities +- Layout issue detection and fixing +- Performance profiling +- Architecture recommendations + +--- + +**Built with Claude Code agent-creator on 2025-10-19** + +Questions or issues? Check SKILL.md for detailed documentation. diff --git a/.crush/skills/bubbletea-maintenance/SKILL.md b/.crush/skills/bubbletea-maintenance/SKILL.md new file mode 100644 index 00000000..d244af0d --- /dev/null +++ b/.crush/skills/bubbletea-maintenance/SKILL.md @@ -0,0 +1,724 @@ +# Bubble Tea Maintenance & Debugging Agent + +**Version**: 1.0.0 +**Created**: 2025-10-19 +**Type**: Maintenance & Debugging Agent +**Focus**: Existing Go/Bubble Tea TUI Applications + +--- + +## Overview + +You are an expert Bubble Tea maintenance and debugging agent specializing in diagnosing issues, applying best practices, and enhancing existing Go/Bubble Tea TUI applications. You help developers maintain, debug, and improve their terminal user interfaces built with the Bubble Tea framework. + +## When to Use This Agent + +This agent should be activated when users: +- Experience bugs or issues in existing Bubble Tea applications +- Want to optimize performance of their TUI +- Need to refactor or improve their Bubble Tea code +- Want to apply best practices to their codebase +- Are debugging layout or rendering issues +- Need help with Lipgloss styling problems +- Want to add features to existing Bubble Tea apps +- Have questions about Bubble Tea architecture patterns + +## Activation Keywords + +This agent activates on phrases like: +- "debug my bubble tea app" +- "fix this TUI issue" +- "optimize bubbletea performance" +- "why is my TUI slow" +- "refactor bubble tea code" +- "apply bubbletea best practices" +- "fix layout issues" +- "lipgloss styling problem" +- "improve my TUI" +- "bubbletea architecture help" +- "message handling issues" +- "event loop problems" +- "model tree refactoring" + +## Core Capabilities + +### 1. Issue Diagnosis + +**Function**: `diagnose_issue(code_path, description="")` + +Analyzes existing Bubble Tea code to identify common issues: + +**Common Issues Detected**: +- **Slow Event Loop**: Blocking operations in Update() or View() +- **Memory Leaks**: Unreleased resources, goroutine leaks +- **Message Ordering**: Incorrect assumptions about concurrent messages +- **Layout Arithmetic**: Hardcoded dimensions, incorrect lipgloss calculations +- **Model Architecture**: Flat models that should be hierarchical +- **Terminal Recovery**: Missing panic recovery +- **Testing Gaps**: No teatest coverage + +**Analysis Process**: +1. Parse Go code to extract Model, Update, View functions +2. Check for blocking operations in event loop +3. Identify hardcoded layout values +4. Analyze message handler patterns +5. Check for concurrent command usage +6. Validate terminal cleanup code +7. Generate diagnostic report with severity levels + +**Output Format**: +```python +{ + "issues": [ + { + "severity": "CRITICAL", # CRITICAL, WARNING, INFO + "category": "performance", + "issue": "Blocking sleep in Update() function", + "location": "main.go:45", + "explanation": "time.Sleep blocks the event loop", + "fix": "Move to tea.Cmd goroutine" + } + ], + "summary": "Found 3 critical issues, 5 warnings", + "health_score": 65 # 0-100 +} +``` + +### 2. Best Practices Validation + +**Function**: `apply_best_practices(code_path, tips_file)` + +Validates code against the 11 expert tips from `tip-bubbltea-apps.md`: + +**Tip 1: Keep Event Loop Fast** +- ✅ Check: Update() completes in < 16ms +- ✅ Check: No blocking I/O in Update() or View() +- ✅ Check: Long operations wrapped in tea.Cmd + +**Tip 2: Debug Message Dumping** +- ✅ Check: Has debug message dumping capability +- ✅ Check: Uses spew or similar for message inspection + +**Tip 3: Live Reload** +- ✅ Check: Development workflow supports live reload +- ✅ Check: Uses air or similar tools + +**Tip 4: Receiver Methods** +- ✅ Check: Appropriate use of pointer vs value receivers +- ✅ Check: Update() uses value receiver (standard pattern) + +**Tip 5: Message Ordering** +- ✅ Check: No assumptions about concurrent message order +- ✅ Check: State machine handles out-of-order messages + +**Tip 6: Model Tree** +- ✅ Check: Complex apps use hierarchical models +- ✅ Check: Child models handle their own messages + +**Tip 7: Layout Arithmetic** +- ✅ Check: Uses lipgloss.Height() and lipgloss.Width() +- ✅ Check: No hardcoded dimensions + +**Tip 8: Terminal Recovery** +- ✅ Check: Has panic recovery with tea.EnableMouseAllMotion cleanup +- ✅ Check: Restores terminal on crash + +**Tip 9: Testing with teatest** +- ✅ Check: Has teatest test coverage +- ✅ Check: Tests key interactions + +**Tip 10: VHS Demos** +- ✅ Check: Has VHS demo files for documentation + +**Output Format**: +```python +{ + "compliance": { + "tip_1_fast_event_loop": {"status": "pass", "score": 100}, + "tip_2_debug_dumping": {"status": "fail", "score": 0}, + "tip_3_live_reload": {"status": "warning", "score": 50}, + # ... all 11 tips + }, + "overall_score": 75, + "recommendations": [ + "Add debug message dumping capability", + "Replace hardcoded dimensions with lipgloss calculations" + ] +} +``` + +### 3. Performance Debugging + +**Function**: `debug_performance(code_path, profile_data="")` + +Identifies performance bottlenecks in Bubble Tea applications: + +**Analysis Areas**: +1. **Event Loop Profiling** + - Measure Update() execution time + - Identify slow message handlers + - Check for blocking operations + +2. **View Rendering** + - Measure View() execution time + - Identify expensive string operations + - Check for unnecessary re-renders + +3. **Memory Allocation** + - Identify allocation hotspots + - Check for string concatenation issues + - Validate efficient use of strings.Builder + +4. **Concurrent Commands** + - Check for goroutine leaks + - Validate proper command cleanup + - Identify race conditions + +**Output Format**: +```python +{ + "bottlenecks": [ + { + "function": "Update", + "location": "main.go:67", + "time_ms": 45, + "threshold_ms": 16, + "issue": "HTTP request blocks event loop", + "fix": "Move to tea.Cmd goroutine" + } + ], + "metrics": { + "avg_update_time": "12ms", + "avg_view_time": "3ms", + "memory_allocations": 1250, + "goroutines": 8 + }, + "recommendations": [ + "Move HTTP calls to background commands", + "Use strings.Builder for View() composition", + "Cache expensive lipgloss styles" + ] +} +``` + +### 4. Architecture Suggestions + +**Function**: `suggest_architecture(code_path, complexity_level)` + +Recommends architectural improvements for Bubble Tea applications: + +**Pattern Recognition**: +1. **Flat Model → Model Tree** + - Detect when single model becomes too complex + - Suggest splitting into child models + - Provide refactoring template + +2. **Single View → Multi-View** + - Identify state-based view switching + - Suggest view router pattern + - Provide navigation template + +3. **Monolithic → Composable** + - Detect tight coupling + - Suggest component extraction + - Provide composable model pattern + +**Refactoring Templates**: + +**Model Tree Pattern**: +```go +type ParentModel struct { + activeView int + listModel list.Model + formModel form.Model + viewerModel viewer.Model +} + +func (m ParentModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + // Route to active child + switch m.activeView { + case 0: + m.listModel, cmd = m.listModel.Update(msg) + case 1: + m.formModel, cmd = m.formModel.Update(msg) + case 2: + m.viewerModel, cmd = m.viewerModel.Update(msg) + } + + return m, cmd +} +``` + +**Output Format**: +```python +{ + "current_pattern": "flat_model", + "complexity_score": 85, # 0-100, higher = more complex + "recommended_pattern": "model_tree", + "refactoring_steps": [ + "Extract list functionality to separate model", + "Extract form functionality to separate model", + "Create parent router model", + "Implement message routing" + ], + "code_templates": { + "parent_model": "...", + "child_models": "...", + "message_routing": "..." + } +} +``` + +### 5. Layout Issue Fixes + +**Function**: `fix_layout_issues(code_path, description="")` + +Diagnoses and fixes common Lipgloss layout problems: + +**Common Layout Issues**: + +1. **Hardcoded Dimensions** + ```go + // ❌ BAD + content := lipgloss.NewStyle().Width(80).Height(24).Render(text) + + // ✅ GOOD + termWidth, termHeight, _ := term.GetSize(int(os.Stdout.Fd())) + content := lipgloss.NewStyle(). + Width(termWidth). + Height(termHeight - 2). // Leave room for status bar + Render(text) + ``` + +2. **Incorrect Height Calculation** + ```go + // ❌ BAD + availableHeight := 24 - 3 // Hardcoded + + // ✅ GOOD + statusBarHeight := lipgloss.Height(m.renderStatusBar()) + availableHeight := m.termHeight - statusBarHeight + ``` + +3. **Missing Margin/Padding Accounting** + ```go + // ❌ BAD + content := lipgloss.NewStyle(). + Padding(2). + Width(80). + Render(text) // Text area is 76, not 80! + + // ✅ GOOD + style := lipgloss.NewStyle().Padding(2) + contentWidth := 80 - style.GetHorizontalPadding() + content := style.Width(80).Render( + lipgloss.NewStyle().Width(contentWidth).Render(text) + ) + ``` + +4. **Overflow Issues** + ```go + // ❌ BAD + content := longText // Can exceed terminal width + + // ✅ GOOD + import "github.com/muesli/reflow/wordwrap" + content := wordwrap.String(longText, m.termWidth) + ``` + +**Output Format**: +```python +{ + "layout_issues": [ + { + "type": "hardcoded_dimensions", + "location": "main.go:89", + "current_code": "Width(80).Height(24)", + "fixed_code": "Width(m.termWidth).Height(m.termHeight - statusHeight)", + "explanation": "Terminal size may vary, use dynamic sizing" + } + ], + "lipgloss_improvements": [ + "Use GetHorizontalPadding() for nested styles", + "Calculate available space with lipgloss.Height()", + "Handle terminal resize with tea.WindowSizeMsg" + ] +} +``` + +### 6. Comprehensive Analysis + +**Function**: `comprehensive_bubbletea_analysis(code_path)` + +Performs complete health check of Bubble Tea application: + +**Analysis Sections**: +1. Issue diagnosis (from diagnose_issue) +2. Best practices compliance (from apply_best_practices) +3. Performance analysis (from debug_performance) +4. Architecture recommendations (from suggest_architecture) +5. Layout validation (from fix_layout_issues) + +**Output Format**: +```python +{ + "overall_health": 78, # 0-100 + "sections": { + "issues": {...}, + "best_practices": {...}, + "performance": {...}, + "architecture": {...}, + "layout": {...} + }, + "summary": "Application is in good health with minor performance improvements needed", + "priority_fixes": [ + "CRITICAL: Fix blocking operation in Update() (main.go:45)", + "WARNING: Add terminal recovery code", + "INFO: Consider refactoring to model tree pattern" + ], + "estimated_fix_time": "2-4 hours" +} +``` + +## Workflow for Users + +### Typical Usage Flow: + +1. **User reports issue**: "My TUI is slow when typing" +2. **Agent diagnoses**: Runs `debug_performance()` to identify bottleneck +3. **Agent explains**: "Found blocking regex operation in Update() taking 45ms" +4. **Agent fixes**: Provides code to move operation to tea.Cmd +5. **Agent validates**: Runs `comprehensive_analysis()` to confirm fix + +### Example Session: + +``` +User: "My log viewer is lagging when I scroll" + +Agent: Let me analyze your code... + +[Runs debug_performance()] + +Found the issue! Your View() function is: +1. Re-reading the entire log file on every render (200ms) +2. Processing all lines instead of visible viewport + +Here's the fix: +- Cache log lines in model, only update when file changes +- Use viewport.Model to handle scrolling efficiently +- Only render visible lines (viewport.YOffset to YOffset + Height) + +[Provides code diff] + +This should reduce render time from 200ms to ~2ms. +``` + +## Technical Knowledge Base + +### Bubble Tea Architecture + +**The Elm Architecture**: +``` +┌─────────────┐ +│ Model │ ← Your application state +└─────────────┘ + ↓ +┌─────────────┐ +│ Update │ ← Message handler (events → state changes) +└─────────────┘ + ↓ +┌─────────────┐ +│ View │ ← Render function (state → string) +└─────────────┘ + ↓ + Terminal +``` + +**Event Loop**: +```go +1. User presses key → tea.KeyMsg +2. Update(tea.KeyMsg) → new model + tea.Cmd +3. tea.Cmd executes → returns new msg +4. Update(new msg) → new model +5. View() renders new model → terminal +``` + +**Performance Rule**: Update() and View() must be FAST (<16ms for 60fps) + +### Common Patterns + +**1. Loading Data Pattern**: +```go +type model struct { + loading bool + data []string + err error +} + +func loadData() tea.Msg { + // This runs in goroutine, not in event loop + data, err := fetchData() + return dataLoadedMsg{data: data, err: err} +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "r" { + m.loading = true + return m, loadData // Return command, don't block + } + case dataLoadedMsg: + m.loading = false + m.data = msg.data + m.err = msg.err + } + return m, nil +} +``` + +**2. Model Tree Pattern**: +```go +type appModel struct { + activeView int + + // Child models manage themselves + listView listModel + detailView detailModel + searchView searchModel +} + +func (m appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // Global keys (navigation) + if key, ok := msg.(tea.KeyMsg); ok { + switch key.String() { + case "1": m.activeView = 0; return m, nil + case "2": m.activeView = 1; return m, nil + case "3": m.activeView = 2; return m, nil + } + } + + // Route to active child + var cmd tea.Cmd + switch m.activeView { + case 0: + m.listView, cmd = m.listView.Update(msg) + case 1: + m.detailView, cmd = m.detailView.Update(msg) + case 2: + m.searchView, cmd = m.searchView.Update(msg) + } + + return m, cmd +} + +func (m appModel) View() string { + switch m.activeView { + case 0: return m.listView.View() + case 1: return m.detailView.View() + case 2: return m.searchView.View() + } + return "" +} +``` + +**3. Message Passing Between Models**: +```go +type itemSelectedMsg struct { + itemID string +} + +// Parent routes message to all children +func (m appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case itemSelectedMsg: + // List sent this, detail needs to know + m.detailView.LoadItem(msg.itemID) + m.activeView = 1 // Switch to detail view + } + + // Update all children + var cmds []tea.Cmd + m.listView, cmd := m.listView.Update(msg) + cmds = append(cmds, cmd) + m.detailView, cmd = m.detailView.Update(msg) + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} +``` + +**4. Dynamic Layout Pattern**: +```go +func (m model) View() string { + // Always use current terminal size + headerHeight := lipgloss.Height(m.renderHeader()) + footerHeight := lipgloss.Height(m.renderFooter()) + + availableHeight := m.termHeight - headerHeight - footerHeight + + content := lipgloss.NewStyle(). + Width(m.termWidth). + Height(availableHeight). + Render(m.renderContent()) + + return lipgloss.JoinVertical( + lipgloss.Left, + m.renderHeader(), + content, + m.renderFooter(), + ) +} +``` + +## Integration with Local Resources + +This agent uses local knowledge sources: + +### Primary Reference +**`/Users/williamvansickleiii/charmtuitemplate/charm-tui-template/tip-bubbltea-apps.md`** +- 11 expert tips from leg100.github.io +- Core best practices validation + +### Example Codebases +**`/Users/williamvansickleiii/charmtuitemplate/vinw/`** +- Real-world Bubble Tea application +- Pattern examples + +**`/Users/williamvansickleiii/charmtuitemplate/charm-examples-inventory/`** +- Collection of Charm examples +- Component usage patterns + +### Styling Reference +**`/Users/williamvansickleiii/charmtuitemplate/charm-tui-template/lipgloss-readme.md`** +- Lipgloss API documentation +- Styling patterns + +## Troubleshooting Guide + +### Issue: Slow/Laggy TUI +**Diagnosis Steps**: +1. Profile Update() execution time +2. Profile View() execution time +3. Check for blocking I/O +4. Check for expensive string operations + +**Common Fixes**: +- Move I/O to tea.Cmd goroutines +- Use strings.Builder in View() +- Cache expensive lipgloss styles +- Reduce re-renders with smart diffing + +### Issue: Terminal Gets Messed Up +**Diagnosis Steps**: +1. Check for panic recovery +2. Check for tea.EnableMouseAllMotion cleanup +3. Validate proper program.Run() usage + +**Fix Template**: +```go +func main() { + defer func() { + if r := recover(); r != nil { + // Restore terminal + tea.DisableMouseAllMotion() + tea.ShowCursor() + fmt.Println("Panic:", r) + os.Exit(1) + } + }() + + p := tea.NewProgram(initialModel()) + if err := p.Start(); err != nil { + fmt.Println("Error:", err) + os.Exit(1) + } +} +``` + +### Issue: Layout Overflow/Clipping +**Diagnosis Steps**: +1. Check for hardcoded dimensions +2. Check lipgloss padding/margin accounting +3. Verify terminal resize handling + +**Fix Checklist**: +- [ ] Use dynamic terminal size from tea.WindowSizeMsg +- [ ] Use lipgloss.Height() and lipgloss.Width() for calculations +- [ ] Account for padding with GetHorizontalPadding()/GetVerticalPadding() +- [ ] Use wordwrap for long text +- [ ] Test with small terminal sizes + +### Issue: Messages Arriving Out of Order +**Diagnosis Steps**: +1. Check for concurrent tea.Cmd usage +2. Check for state assumptions about message order +3. Validate state machine handles any order + +**Fix**: +- Use state machine with explicit states +- Don't assume operation A completes before B +- Use message types to track operation identity + +```go +type model struct { + operations map[string]bool // Track concurrent ops +} + +type operationStartMsg struct { id string } +type operationDoneMsg struct { id string, result string } + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case operationStartMsg: + m.operations[msg.id] = true + case operationDoneMsg: + delete(m.operations, msg.id) + // Handle result + } + return m, nil +} +``` + +## Validation and Quality Checks + +After applying fixes, the agent validates: +1. ✅ Code compiles successfully +2. ✅ No new issues introduced +3. ✅ Performance improved (if applicable) +4. ✅ Best practices compliance increased +5. ✅ Tests pass (if present) + +## Limitations + +This agent focuses on maintenance and debugging, NOT: +- Designing new TUIs from scratch (use bubbletea-designer for that) +- Non-Bubble Tea Go code +- Terminal emulator issues +- Operating system specific problems + +## Success Metrics + +A successful maintenance session results in: +- ✅ Issue identified and explained clearly +- ✅ Fix provided with code examples +- ✅ Best practices applied +- ✅ Performance improved (if applicable) +- ✅ User understands the fix and can apply it + +## Version History + +**v1.0.0** (2025-10-19) +- Initial release +- 6 core analysis functions +- Integration with tip-bubbltea-apps.md +- Comprehensive diagnostic capabilities +- Layout issue detection and fixing +- Performance profiling +- Architecture recommendations + +--- + +**Built with Claude Code agent-creator on 2025-10-19** diff --git a/.crush/skills/bubbletea-maintenance/VERSION b/.crush/skills/bubbletea-maintenance/VERSION new file mode 100644 index 00000000..3eefcb9d --- /dev/null +++ b/.crush/skills/bubbletea-maintenance/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/.crush/skills/bubbletea-maintenance/references/common_issues.md b/.crush/skills/bubbletea-maintenance/references/common_issues.md new file mode 100644 index 00000000..12d5365d --- /dev/null +++ b/.crush/skills/bubbletea-maintenance/references/common_issues.md @@ -0,0 +1,567 @@ +# Common Bubble Tea Issues and Solutions + +Reference guide for diagnosing and fixing common problems in Bubble Tea applications. + +## Performance Issues + +### Issue: Slow/Laggy UI + +**Symptoms:** +- UI freezes when typing +- Delayed response to key presses +- Stuttering animations + +**Common Causes:** + +1. **Blocking Operations in Update()** + ```go + // ❌ BAD + func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + data := http.Get("https://api.example.com") // BLOCKS! + m.data = data + } + return m, nil + } + + // ✅ GOOD + func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + return m, fetchDataCmd // Non-blocking + case dataFetchedMsg: + m.data = msg.data + } + return m, nil + } + + func fetchDataCmd() tea.Msg { + data := http.Get("https://api.example.com") // Runs in goroutine + return dataFetchedMsg{data: data} + } + ``` + +2. **Heavy Processing in View()** + ```go + // ❌ BAD + func (m model) View() string { + content, _ := os.ReadFile("large_file.txt") // EVERY RENDER! + return string(content) + } + + // ✅ GOOD + type model struct { + cachedContent string + } + + func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case fileLoadedMsg: + m.cachedContent = msg.content // Cache it + } + return m, nil + } + + func (m model) View() string { + return m.cachedContent // Just return cached data + } + ``` + +3. **String Concatenation with +** + ```go + // ❌ BAD - Allocates many temp strings + func (m model) View() string { + s := "" + for _, line := range m.lines { + s += line + "\\n" // Expensive! + } + return s + } + + // ✅ GOOD - Single allocation + func (m model) View() string { + var b strings.Builder + for _, line := range m.lines { + b.WriteString(line) + b.WriteString("\\n") + } + return b.String() + } + ``` + +**Performance Target:** Update() should complete in <16ms (60 FPS) + +--- + +## Layout Issues + +### Issue: Content Overflows Terminal + +**Symptoms:** +- Text wraps unexpectedly +- Content gets clipped +- Layout breaks on different terminal sizes + +**Common Causes:** + +1. **Hardcoded Dimensions** + ```go + // ❌ BAD + content := lipgloss.NewStyle(). + Width(80). // What if terminal is 120 wide? + Height(24). // What if terminal is 40 tall? + Render(text) + + // ✅ GOOD + type model struct { + termWidth int + termHeight int + } + + func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.termWidth = msg.Width + m.termHeight = msg.Height + } + return m, nil + } + + func (m model) View() string { + content := lipgloss.NewStyle(). + Width(m.termWidth). + Height(m.termHeight - 2). // Leave room for status bar + Render(text) + return content + } + ``` + +2. **Not Accounting for Padding/Borders** + ```go + // ❌ BAD + style := lipgloss.NewStyle(). + Padding(2). + Border(lipgloss.RoundedBorder()). + Width(80) + content := style.Render(text) + // Text area is 76 (80 - 2*2 padding), NOT 80! + + // ✅ GOOD + style := lipgloss.NewStyle(). + Padding(2). + Border(lipgloss.RoundedBorder()) + + contentWidth := 80 - style.GetHorizontalPadding() - style.GetHorizontalBorderSize() + innerContent := lipgloss.NewStyle().Width(contentWidth).Render(text) + result := style.Width(80).Render(innerContent) + ``` + +3. **Manual Height Calculations** + ```go + // ❌ BAD - Magic numbers + availableHeight := 24 - 3 // Where did 3 come from? + + // ✅ GOOD - Calculated + headerHeight := lipgloss.Height(m.renderHeader()) + footerHeight := lipgloss.Height(m.renderFooter()) + availableHeight := m.termHeight - headerHeight - footerHeight + ``` + +--- + +## Message Handling Issues + +### Issue: Messages Arrive Out of Order + +**Symptoms:** +- State becomes inconsistent +- Operations complete in wrong order +- Race conditions + +**Cause:** Concurrent tea.Cmd messages aren't guaranteed to arrive in order + +**Solution: Use State Tracking** + +```go +// ❌ BAD - Assumes order +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "r" { + return m, tea.Batch( + fetchUsersCmd, // Might complete second + fetchPostsCmd, // Might complete first + ) + } + case usersLoadedMsg: + m.users = msg.users + case postsLoadedMsg: + m.posts = msg.posts + // Assumes users are loaded! May not be! + } + return m, nil +} + +// ✅ GOOD - Track operations +type model struct { + operations map[string]bool + users []User + posts []Post +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "r" { + m.operations["users"] = true + m.operations["posts"] = true + return m, tea.Batch(fetchUsersCmd, fetchPostsCmd) + } + case usersLoadedMsg: + m.users = msg.users + delete(m.operations, "users") + return m, m.checkAllLoaded() + case postsLoadedMsg: + m.posts = msg.posts + delete(m.operations, "posts") + return m, m.checkAllLoaded() + } + return m, nil +} + +func (m model) checkAllLoaded() tea.Cmd { + if len(m.operations) == 0 { + // All operations complete, can proceed + return m.processData + } + return nil +} +``` + +--- + +## Terminal Recovery Issues + +### Issue: Terminal Gets Messed Up After Crash + +**Symptoms:** +- Cursor disappears +- Mouse mode still active +- Terminal looks corrupted + +**Solution: Add Panic Recovery** + +```go +func main() { + defer func() { + if r := recover(); r != nil { + // Restore terminal state + tea.DisableMouseAllMotion() + tea.ShowCursor() + fmt.Printf("Panic: %v\\n", r) + debug.PrintStack() + os.Exit(1) + } + }() + + p := tea.NewProgram(initialModel()) + if err := p.Start(); err != nil { + fmt.Printf("Error: %v\\n", err) + os.Exit(1) + } +} +``` + +--- + +## Architecture Issues + +### Issue: Model Too Complex + +**Symptoms:** +- Model struct has 20+ fields +- Update() is hundreds of lines +- Hard to maintain + +**Solution: Use Model Tree Pattern** + +```go +// ❌ BAD - Flat model +type model struct { + // List view fields + listItems []string + listCursor int + listFilter string + + // Detail view fields + detailItem string + detailHTML string + detailScroll int + + // Search view fields + searchQuery string + searchResults []string + searchCursor int + + // ... 15 more fields +} + +// ✅ GOOD - Model tree +type appModel struct { + activeView int + listView listViewModel + detailView detailViewModel + searchView searchViewModel +} + +type listViewModel struct { + items []string + cursor int + filter string +} + +func (m listViewModel) Update(msg tea.Msg) (listViewModel, tea.Cmd) { + // Only handles list-specific messages + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "up": + m.cursor-- + case "down": + m.cursor++ + case "enter": + return m, func() tea.Msg { + return itemSelectedMsg{itemID: m.items[m.cursor]} + } + } + } + return m, nil +} + +// Parent routes messages +func (m appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // Handle global messages + switch msg := msg.(type) { + case itemSelectedMsg: + m.detailView.LoadItem(msg.itemID) + m.activeView = 1 // Switch to detail + return m, nil + } + + // Route to active child + var cmd tea.Cmd + switch m.activeView { + case 0: + m.listView, cmd = m.listView.Update(msg) + case 1: + m.detailView, cmd = m.detailView.Update(msg) + case 2: + m.searchView, cmd = m.searchView.Update(msg) + } + + return m, cmd +} +``` + +--- + +## Memory Issues + +### Issue: Memory Leak / Growing Memory Usage + +**Symptoms:** +- Memory usage increases over time +- Never gets garbage collected + +**Common Causes:** + +1. **Goroutine Leaks** + ```go + // ❌ BAD - Goroutines never stop + func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "s" { + return m, func() tea.Msg { + go func() { + for { // INFINITE LOOP! + time.Sleep(time.Second) + // Do something + } + }() + return nil + } + } + } + return m, nil + } + + // ✅ GOOD - Use context for cancellation + type model struct { + ctx context.Context + cancel context.CancelFunc + } + + func initialModel() model { + ctx, cancel := context.WithCancel(context.Background()) + return model{ctx: ctx, cancel: cancel} + } + + func worker(ctx context.Context) tea.Msg { + for { + select { + case <-ctx.Done(): + return nil // Stop gracefully + case <-time.After(time.Second): + // Do work + } + } + } + + func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "q" { + m.cancel() // Stop all workers + return m, tea.Quit + } + } + return m, nil + } + ``` + +2. **Unreleased Resources** + ```go + // ❌ BAD + func loadFile() tea.Msg { + file, _ := os.Open("data.txt") + // Never closed! + data, _ := io.ReadAll(file) + return dataMsg{data: data} + } + + // ✅ GOOD + func loadFile() tea.Msg { + file, err := os.Open("data.txt") + if err != nil { + return errorMsg{err: err} + } + defer file.Close() // Always close + + data, err := io.ReadAll(file) + return dataMsg{data: data, err: err} + } + ``` + +--- + +## Testing Issues + +### Issue: Hard to Test TUI + +**Solution: Use teatest** + +```go +import ( + "testing" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/bubbletea/teatest" +) + +func TestNavigation(t *testing.T) { + m := initialModel() + + // Create test program + tm := teatest.NewTestModel(t, m) + + // Send key presses + tm.Send(tea.KeyMsg{Type: tea.KeyDown}) + tm.Send(tea.KeyMsg{Type: tea.KeyDown}) + + // Wait for program to process + teatest.WaitFor( + t, tm.Output(), + func(bts []byte) bool { + return bytes.Contains(bts, []byte("Item 2")) + }, + teatest.WithCheckInterval(time.Millisecond*100), + teatest.WithDuration(time.Second*3), + ) + + // Verify state + finalModel := tm.FinalModel(t).(model) + if finalModel.cursor != 2 { + t.Errorf("Expected cursor at 2, got %d", finalModel.cursor) + } +} +``` + +--- + +## Debugging Tips + +### Enable Message Dumping + +```go +import "github.com/davecgh/go-spew/spew" + +type model struct { + dump io.Writer +} + +func main() { + // Create debug file + f, _ := os.Create("debug.log") + defer f.Close() + + m := model{dump: f} + p := tea.NewProgram(m) + p.Start() +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // Dump every message + if m.dump != nil { + spew.Fdump(m.dump, msg) + } + + // ... rest of Update() + return m, nil +} +``` + +### Live Reload with Air + +`.air.toml`: +```toml +[build] + cmd = "go build -o ./tmp/main ." + bin = "tmp/main" + include_ext = ["go"] + exclude_dir = ["tmp"] + delay = 1000 +``` + +Run: `air` + +--- + +## Quick Checklist + +Before deploying your Bubble Tea app: + +- [ ] No blocking operations in Update() or View() +- [ ] Terminal resize handled (tea.WindowSizeMsg) +- [ ] Panic recovery with terminal cleanup +- [ ] Dynamic layout (no hardcoded dimensions) +- [ ] Lipgloss padding/borders accounted for +- [ ] String operations use strings.Builder +- [ ] Goroutines have cancellation (context) +- [ ] Resources properly closed (defer) +- [ ] State machine handles message ordering +- [ ] Tests with teatest for key interactions + +--- + +**Generated for Bubble Tea Maintenance Agent v1.0.0** diff --git a/.crush/skills/bubbletea-maintenance/scripts/__pycache__/apply_best_practices.cpython-311.pyc b/.crush/skills/bubbletea-maintenance/scripts/__pycache__/apply_best_practices.cpython-311.pyc new file mode 100644 index 00000000..a69d7e40 Binary files /dev/null and b/.crush/skills/bubbletea-maintenance/scripts/__pycache__/apply_best_practices.cpython-311.pyc differ diff --git a/.crush/skills/bubbletea-maintenance/scripts/__pycache__/comprehensive_bubbletea_analysis.cpython-311.pyc b/.crush/skills/bubbletea-maintenance/scripts/__pycache__/comprehensive_bubbletea_analysis.cpython-311.pyc new file mode 100644 index 00000000..afc6c25e Binary files /dev/null and b/.crush/skills/bubbletea-maintenance/scripts/__pycache__/comprehensive_bubbletea_analysis.cpython-311.pyc differ diff --git a/.crush/skills/bubbletea-maintenance/scripts/__pycache__/debug_performance.cpython-311.pyc b/.crush/skills/bubbletea-maintenance/scripts/__pycache__/debug_performance.cpython-311.pyc new file mode 100644 index 00000000..e546f44f Binary files /dev/null and b/.crush/skills/bubbletea-maintenance/scripts/__pycache__/debug_performance.cpython-311.pyc differ diff --git a/.crush/skills/bubbletea-maintenance/scripts/__pycache__/diagnose_issue.cpython-311.pyc b/.crush/skills/bubbletea-maintenance/scripts/__pycache__/diagnose_issue.cpython-311.pyc new file mode 100644 index 00000000..f149ca94 Binary files /dev/null and b/.crush/skills/bubbletea-maintenance/scripts/__pycache__/diagnose_issue.cpython-311.pyc differ diff --git a/.crush/skills/bubbletea-maintenance/scripts/__pycache__/fix_layout_issues.cpython-311.pyc b/.crush/skills/bubbletea-maintenance/scripts/__pycache__/fix_layout_issues.cpython-311.pyc new file mode 100644 index 00000000..e1860e2a Binary files /dev/null and b/.crush/skills/bubbletea-maintenance/scripts/__pycache__/fix_layout_issues.cpython-311.pyc differ diff --git a/.crush/skills/bubbletea-maintenance/scripts/__pycache__/suggest_architecture.cpython-311.pyc b/.crush/skills/bubbletea-maintenance/scripts/__pycache__/suggest_architecture.cpython-311.pyc new file mode 100644 index 00000000..5530afc7 Binary files /dev/null and b/.crush/skills/bubbletea-maintenance/scripts/__pycache__/suggest_architecture.cpython-311.pyc differ diff --git a/.crush/skills/bubbletea-maintenance/scripts/apply_best_practices.py b/.crush/skills/bubbletea-maintenance/scripts/apply_best_practices.py new file mode 100644 index 00000000..2af1ff08 --- /dev/null +++ b/.crush/skills/bubbletea-maintenance/scripts/apply_best_practices.py @@ -0,0 +1,506 @@ +#!/usr/bin/env python3 +""" +Apply Bubble Tea best practices validation. +Validates code against 11 expert tips from tip-bubbltea-apps.md. +""" + +import os +import re +import json +from pathlib import Path +from typing import Dict, List, Any, Tuple + + +# Path to tips reference +TIPS_FILE = Path("/Users/williamvansickleiii/charmtuitemplate/charm-tui-template/tip-bubbltea-apps.md") + + +def apply_best_practices(code_path: str, tips_file: str = None) -> Dict[str, Any]: + """ + Validate Bubble Tea code against best practices from tip-bubbltea-apps.md. + + Args: + code_path: Path to Go file or directory + tips_file: Optional path to tips file (defaults to standard location) + + Returns: + Dictionary containing: + - compliance: Status for each of 11 tips + - overall_score: 0-100 + - recommendations: List of improvements + - validation: Validation report + """ + path = Path(code_path) + + if not path.exists(): + return { + "error": f"Path not found: {code_path}", + "validation": {"status": "error", "summary": "Invalid path"} + } + + # Collect all .go files + go_files = [] + if path.is_file(): + if path.suffix == '.go': + go_files = [path] + else: + go_files = list(path.glob('**/*.go')) + + if not go_files: + return { + "error": "No .go files found", + "validation": {"status": "error", "summary": "No Go files"} + } + + # Read all Go code + all_content = "" + for go_file in go_files: + try: + all_content += go_file.read_text() + "\n" + except Exception: + pass + + # Check each tip + compliance = {} + + compliance["tip_1_fast_event_loop"] = _check_tip_1_fast_event_loop(all_content, go_files) + compliance["tip_2_debug_dumping"] = _check_tip_2_debug_dumping(all_content, go_files) + compliance["tip_3_live_reload"] = _check_tip_3_live_reload(path) + compliance["tip_4_receiver_methods"] = _check_tip_4_receiver_methods(all_content, go_files) + compliance["tip_5_message_ordering"] = _check_tip_5_message_ordering(all_content, go_files) + compliance["tip_6_model_tree"] = _check_tip_6_model_tree(all_content, go_files) + compliance["tip_7_layout_arithmetic"] = _check_tip_7_layout_arithmetic(all_content, go_files) + compliance["tip_8_terminal_recovery"] = _check_tip_8_terminal_recovery(all_content, go_files) + compliance["tip_9_teatest"] = _check_tip_9_teatest(path) + compliance["tip_10_vhs"] = _check_tip_10_vhs(path) + compliance["tip_11_resources"] = {"status": "info", "score": 100, "message": "Check leg100.github.io for more tips"} + + # Calculate overall score + scores = [tip["score"] for tip in compliance.values()] + overall_score = int(sum(scores) / len(scores)) + + # Generate recommendations + recommendations = [] + for tip_name, tip_data in compliance.items(): + if tip_data["status"] == "fail": + recommendations.append(tip_data.get("recommendation", f"Implement {tip_name}")) + + # Summary + if overall_score >= 90: + summary = f"✅ Excellent! Score: {overall_score}/100. Following best practices." + elif overall_score >= 70: + summary = f"✓ Good. Score: {overall_score}/100. Some improvements possible." + elif overall_score >= 50: + summary = f"⚠️ Fair. Score: {overall_score}/100. Several best practices missing." + else: + summary = f"❌ Poor. Score: {overall_score}/100. Many best practices not followed." + + # Validation + validation = { + "status": "pass" if overall_score >= 70 else "warning" if overall_score >= 50 else "fail", + "summary": summary, + "checks": { + "fast_event_loop": compliance["tip_1_fast_event_loop"]["status"] == "pass", + "has_debugging": compliance["tip_2_debug_dumping"]["status"] == "pass", + "proper_layout": compliance["tip_7_layout_arithmetic"]["status"] == "pass", + "has_recovery": compliance["tip_8_terminal_recovery"]["status"] == "pass" + } + } + + return { + "compliance": compliance, + "overall_score": overall_score, + "recommendations": recommendations, + "summary": summary, + "files_analyzed": len(go_files), + "validation": validation + } + + +def _check_tip_1_fast_event_loop(content: str, files: List[Path]) -> Dict[str, Any]: + """Tip 1: Keep the event loop fast.""" + # Check for blocking operations in Update() or View() + blocking_patterns = [ + r'\btime\.Sleep\s*\(', + r'\bhttp\.(Get|Post|Do)\s*\(', + r'\bos\.Open\s*\(', + r'\bio\.ReadAll\s*\(', + r'\bexec\.Command\([^)]+\)\.Run\(\)', + ] + + has_blocking = any(re.search(pattern, content) for pattern in blocking_patterns) + has_tea_cmd = bool(re.search(r'tea\.Cmd', content)) + + if has_blocking and not has_tea_cmd: + return { + "status": "fail", + "score": 0, + "message": "Blocking operations found in event loop without tea.Cmd", + "recommendation": "Move blocking operations to tea.Cmd goroutines", + "explanation": "Blocking ops in Update()/View() freeze the UI. Use tea.Cmd for I/O." + } + elif has_blocking and has_tea_cmd: + return { + "status": "warning", + "score": 50, + "message": "Blocking operations present but tea.Cmd is used", + "recommendation": "Verify all blocking ops are in tea.Cmd, not Update()/View()", + "explanation": "Review code to ensure blocking operations are properly wrapped" + } + else: + return { + "status": "pass", + "score": 100, + "message": "No blocking operations detected in event loop", + "explanation": "Event loop appears to be non-blocking" + } + + +def _check_tip_2_debug_dumping(content: str, files: List[Path]) -> Dict[str, Any]: + """Tip 2: Dump messages to a file for debugging.""" + has_spew = bool(re.search(r'github\.com/davecgh/go-spew', content)) + has_debug_write = bool(re.search(r'(dump|debug|log)\s+io\.Writer', content)) + has_fmt_fprintf = bool(re.search(r'fmt\.Fprintf', content)) + + if has_spew or has_debug_write: + return { + "status": "pass", + "score": 100, + "message": "Debug message dumping capability detected", + "explanation": "Using spew or debug writer for message inspection" + } + elif has_fmt_fprintf: + return { + "status": "warning", + "score": 60, + "message": "Basic logging present, but no structured message dumping", + "recommendation": "Add spew.Fdump for detailed message inspection", + "explanation": "fmt.Fprintf works but spew provides better message structure" + } + else: + return { + "status": "fail", + "score": 0, + "message": "No debug message dumping detected", + "recommendation": "Add message dumping with go-spew:\n" + + "import \"github.com/davecgh/go-spew/spew\"\n" + + "type model struct { dump io.Writer }\n" + + "func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {\n" + + " if m.dump != nil { spew.Fdump(m.dump, msg) }\n" + + " // ... rest of Update()\n" + + "}", + "explanation": "Message dumping helps debug complex message flows" + } + + +def _check_tip_3_live_reload(path: Path) -> Dict[str, Any]: + """Tip 3: Live reload code changes.""" + # Check for air config or similar + has_air_config = (path / ".air.toml").exists() + has_makefile_watch = False + + if (path / "Makefile").exists(): + makefile = (path / "Makefile").read_text() + has_makefile_watch = bool(re.search(r'watch:|live:', makefile)) + + if has_air_config: + return { + "status": "pass", + "score": 100, + "message": "Live reload configured with air", + "explanation": "Found .air.toml configuration" + } + elif has_makefile_watch: + return { + "status": "pass", + "score": 100, + "message": "Live reload configured in Makefile", + "explanation": "Found watch/live target in Makefile" + } + else: + return { + "status": "info", + "score": 100, + "message": "No live reload detected (optional)", + "recommendation": "Consider adding air for live reload during development", + "explanation": "Live reload improves development speed but is optional" + } + + +def _check_tip_4_receiver_methods(content: str, files: List[Path]) -> Dict[str, Any]: + """Tip 4: Use pointer vs value receivers judiciously.""" + # Check Update() receiver type (should be value receiver) + update_value_receiver = bool(re.search(r'func\s+\(m\s+\w+\)\s+Update\s*\(', content)) + update_pointer_receiver = bool(re.search(r'func\s+\(m\s+\*\w+\)\s+Update\s*\(', content)) + + if update_pointer_receiver: + return { + "status": "warning", + "score": 60, + "message": "Update() uses pointer receiver (uncommon pattern)", + "recommendation": "Consider value receiver for Update() (standard pattern)", + "explanation": "Value receiver is standard for Update() in Bubble Tea" + } + elif update_value_receiver: + return { + "status": "pass", + "score": 100, + "message": "Update() uses value receiver (correct)", + "explanation": "Following standard Bubble Tea pattern" + } + else: + return { + "status": "info", + "score": 100, + "message": "No Update() method found or unable to detect", + "explanation": "Could not determine receiver type" + } + + +def _check_tip_5_message_ordering(content: str, files: List[Path]) -> Dict[str, Any]: + """Tip 5: Messages from concurrent commands not guaranteed in order.""" + has_batch = bool(re.search(r'tea\.Batch\s*\(', content)) + has_concurrent_cmds = bool(re.search(r'go\s+func\s*\(', content)) + has_state_tracking = bool(re.search(r'type\s+\w*State\s+(int|string)', content)) or \ + bool(re.search(r'operations\s+map\[string\]', content)) + + if (has_batch or has_concurrent_cmds) and not has_state_tracking: + return { + "status": "warning", + "score": 50, + "message": "Concurrent commands without explicit state tracking", + "recommendation": "Add state machine to track concurrent operations", + "explanation": "tea.Batch messages arrive in unpredictable order" + } + elif has_batch or has_concurrent_cmds: + return { + "status": "pass", + "score": 100, + "message": "Concurrent commands with state tracking", + "explanation": "Proper handling of message ordering" + } + else: + return { + "status": "pass", + "score": 100, + "message": "No concurrent commands detected", + "explanation": "Message ordering is deterministic" + } + + +def _check_tip_6_model_tree(content: str, files: List[Path]) -> Dict[str, Any]: + """Tip 6: Build a tree of models for complex apps.""" + # Count model fields + model_match = re.search(r'type\s+(\w*[Mm]odel)\s+struct\s*\{([^}]+)\}', content, re.DOTALL) + if not model_match: + return { + "status": "info", + "score": 100, + "message": "No model struct found", + "explanation": "Could not analyze model structure" + } + + model_body = model_match.group(2) + field_count = len([line for line in model_body.split('\n') if line.strip() and not line.strip().startswith('//')]) + + # Check for child models + has_child_models = bool(re.search(r'\w+Model\s+\w+Model', content)) + + if field_count > 20 and not has_child_models: + return { + "status": "warning", + "score": 40, + "message": f"Large model ({field_count} fields) without child models", + "recommendation": "Consider refactoring to model tree pattern", + "explanation": "Large models are hard to maintain. Split into child models." + } + elif field_count > 15 and not has_child_models: + return { + "status": "info", + "score": 70, + "message": f"Medium model ({field_count} fields)", + "recommendation": "Consider model tree if complexity increases", + "explanation": "Model is getting large, monitor complexity" + } + elif has_child_models: + return { + "status": "pass", + "score": 100, + "message": "Using model tree pattern with child models", + "explanation": "Good architecture for complex apps" + } + else: + return { + "status": "pass", + "score": 100, + "message": f"Simple model ({field_count} fields)", + "explanation": "Model size is appropriate" + } + + +def _check_tip_7_layout_arithmetic(content: str, files: List[Path]) -> Dict[str, Any]: + """Tip 7: Layout arithmetic is error-prone.""" + uses_lipgloss = bool(re.search(r'github\.com/charmbracelet/lipgloss', content)) + has_lipgloss_helpers = bool(re.search(r'lipgloss\.(Height|Width|GetVertical|GetHorizontal)', content)) + has_hardcoded_dimensions = bool(re.search(r'\.(Width|Height)\s*\(\s*\d{2,}\s*\)', content)) + + if uses_lipgloss and has_lipgloss_helpers and not has_hardcoded_dimensions: + return { + "status": "pass", + "score": 100, + "message": "Using lipgloss helpers for dynamic layout", + "explanation": "Correct use of lipgloss.Height()/Width()" + } + elif uses_lipgloss and has_hardcoded_dimensions: + return { + "status": "warning", + "score": 40, + "message": "Hardcoded dimensions detected", + "recommendation": "Use lipgloss.Height() and lipgloss.Width() for calculations", + "explanation": "Hardcoded dimensions don't adapt to terminal size" + } + elif uses_lipgloss: + return { + "status": "warning", + "score": 60, + "message": "Using lipgloss but unclear if using helpers", + "recommendation": "Use lipgloss.Height() and lipgloss.Width() for layout", + "explanation": "Avoid manual height/width calculations" + } + else: + return { + "status": "info", + "score": 100, + "message": "Not using lipgloss", + "explanation": "Layout tip applies when using lipgloss" + } + + +def _check_tip_8_terminal_recovery(content: str, files: List[Path]) -> Dict[str, Any]: + """Tip 8: Recover your terminal after panics.""" + has_defer_recover = bool(re.search(r'defer\s+func\s*\(\s*\)\s*\{[^}]*recover\(\)', content, re.DOTALL)) + has_main = bool(re.search(r'func\s+main\s*\(\s*\)', content)) + has_disable_mouse = bool(re.search(r'tea\.DisableMouseAllMotion', content)) + + if has_main and has_defer_recover and has_disable_mouse: + return { + "status": "pass", + "score": 100, + "message": "Panic recovery with terminal cleanup", + "explanation": "Proper defer recover() with DisableMouseAllMotion" + } + elif has_main and has_defer_recover: + return { + "status": "warning", + "score": 70, + "message": "Panic recovery but missing DisableMouseAllMotion", + "recommendation": "Add tea.DisableMouseAllMotion() in panic handler", + "explanation": "Need to cleanup mouse mode on panic" + } + elif has_main: + return { + "status": "fail", + "score": 0, + "message": "Missing panic recovery in main()", + "recommendation": "Add defer recover() with terminal cleanup", + "explanation": "Panics can leave terminal in broken state" + } + else: + return { + "status": "info", + "score": 100, + "message": "No main() found (library code?)", + "explanation": "Recovery applies to main applications" + } + + +def _check_tip_9_teatest(path: Path) -> Dict[str, Any]: + """Tip 9: Use teatest for end-to-end tests.""" + # Look for test files using teatest + test_files = list(path.glob('**/*_test.go')) + has_teatest = False + + for test_file in test_files: + try: + content = test_file.read_text() + if 'teatest' in content or 'tea/teatest' in content: + has_teatest = True + break + except Exception: + pass + + if has_teatest: + return { + "status": "pass", + "score": 100, + "message": "Using teatest for testing", + "explanation": "Found teatest in test files" + } + elif test_files: + return { + "status": "warning", + "score": 60, + "message": "Has tests but not using teatest", + "recommendation": "Consider using teatest for TUI integration tests", + "explanation": "teatest enables end-to-end TUI testing" + } + else: + return { + "status": "fail", + "score": 0, + "message": "No tests found", + "recommendation": "Add teatest tests for key interactions", + "explanation": "Testing improves reliability" + } + + +def _check_tip_10_vhs(path: Path) -> Dict[str, Any]: + """Tip 10: Use VHS to record demos.""" + # Look for .tape files (VHS) + vhs_files = list(path.glob('**/*.tape')) + + if vhs_files: + return { + "status": "pass", + "score": 100, + "message": f"Found {len(vhs_files)} VHS demo file(s)", + "explanation": "Using VHS for documentation" + } + else: + return { + "status": "info", + "score": 100, + "message": "No VHS demos found (optional)", + "recommendation": "Consider adding VHS demos for documentation", + "explanation": "VHS creates great animated demos but is optional" + } + + +def validate_best_practices(result: Dict[str, Any]) -> Dict[str, Any]: + """Validate best practices result.""" + if 'error' in result: + return {"status": "error", "summary": result['error']} + + overall_score = result.get('overall_score', 0) + status = "pass" if overall_score >= 70 else "warning" if overall_score >= 50 else "fail" + + return { + "status": status, + "summary": result.get('summary', 'Best practices check complete'), + "score": overall_score, + "valid": True + } + + +if __name__ == "__main__": + import sys + + if len(sys.argv) < 2: + print("Usage: apply_best_practices.py [tips_file]") + sys.exit(1) + + code_path = sys.argv[1] + tips_file = sys.argv[2] if len(sys.argv) > 2 else None + + result = apply_best_practices(code_path, tips_file) + print(json.dumps(result, indent=2)) diff --git a/.crush/skills/bubbletea-maintenance/scripts/comprehensive_bubbletea_analysis.py b/.crush/skills/bubbletea-maintenance/scripts/comprehensive_bubbletea_analysis.py new file mode 100644 index 00000000..c15f36ca --- /dev/null +++ b/.crush/skills/bubbletea-maintenance/scripts/comprehensive_bubbletea_analysis.py @@ -0,0 +1,433 @@ +#!/usr/bin/env python3 +""" +Comprehensive Bubble Tea application analysis. +Orchestrates all analysis functions for complete health check. +""" + +import sys +import json +from pathlib import Path +from typing import Dict, List, Any + +# Import all analysis functions +sys.path.insert(0, str(Path(__file__).parent)) + +from diagnose_issue import diagnose_issue +from apply_best_practices import apply_best_practices +from debug_performance import debug_performance +from suggest_architecture import suggest_architecture +from fix_layout_issues import fix_layout_issues + + +def comprehensive_bubbletea_analysis(code_path: str, detail_level: str = "standard") -> Dict[str, Any]: + """ + Perform complete health check of Bubble Tea application. + + Args: + code_path: Path to Go file or directory containing Bubble Tea code + detail_level: "quick", "standard", or "deep" + + Returns: + Dictionary containing: + - overall_health: 0-100 score + - sections: Results from each analysis function + - summary: Executive summary + - priority_fixes: Ordered list of critical/high-priority issues + - estimated_fix_time: Time estimate for addressing issues + - validation: Overall validation report + """ + path = Path(code_path) + + if not path.exists(): + return { + "error": f"Path not found: {code_path}", + "validation": {"status": "error", "summary": "Invalid path"} + } + + print(f"\n{'='*70}") + print(f"COMPREHENSIVE BUBBLE TEA ANALYSIS") + print(f"{'='*70}") + print(f"Analyzing: {path}") + print(f"Detail level: {detail_level}\n") + + sections = {} + + # Section 1: Issue Diagnosis + print("🔍 [1/5] Diagnosing issues...") + try: + sections['issues'] = diagnose_issue(str(path)) + print(f" ✓ Found {len(sections['issues'].get('issues', []))} issue(s)") + except Exception as e: + sections['issues'] = {"error": str(e)} + print(f" ✗ Error: {e}") + + # Section 2: Best Practices Compliance + print("📋 [2/5] Checking best practices...") + try: + sections['best_practices'] = apply_best_practices(str(path)) + score = sections['best_practices'].get('overall_score', 0) + print(f" ✓ Score: {score}/100") + except Exception as e: + sections['best_practices'] = {"error": str(e)} + print(f" ✗ Error: {e}") + + # Section 3: Performance Analysis + print("⚡ [3/5] Analyzing performance...") + try: + sections['performance'] = debug_performance(str(path)) + bottleneck_count = len(sections['performance'].get('bottlenecks', [])) + print(f" ✓ Found {bottleneck_count} bottleneck(s)") + except Exception as e: + sections['performance'] = {"error": str(e)} + print(f" ✗ Error: {e}") + + # Section 4: Architecture Recommendations + if detail_level in ["standard", "deep"]: + print("🏗️ [4/5] Analyzing architecture...") + try: + sections['architecture'] = suggest_architecture(str(path)) + current = sections['architecture'].get('current_pattern', 'unknown') + recommended = sections['architecture'].get('recommended_pattern', 'unknown') + print(f" ✓ Current: {current}, Recommended: {recommended}") + except Exception as e: + sections['architecture'] = {"error": str(e)} + print(f" ✗ Error: {e}") + else: + print("🏗️ [4/5] Skipping architecture (quick mode)") + sections['architecture'] = {"skipped": "quick mode"} + + # Section 5: Layout Validation + print("📐 [5/5] Checking layout...") + try: + sections['layout'] = fix_layout_issues(str(path)) + issue_count = len(sections['layout'].get('layout_issues', [])) + print(f" ✓ Found {issue_count} layout issue(s)") + except Exception as e: + sections['layout'] = {"error": str(e)} + print(f" ✗ Error: {e}") + + print() + + # Calculate overall health + overall_health = _calculate_overall_health(sections) + + # Extract priority fixes + priority_fixes = _extract_priority_fixes(sections) + + # Estimate fix time + estimated_fix_time = _estimate_fix_time(priority_fixes) + + # Generate summary + summary = _generate_summary(overall_health, sections, priority_fixes) + + # Overall validation + validation = { + "status": _determine_status(overall_health), + "summary": summary, + "overall_health": overall_health, + "sections_completed": len([s for s in sections.values() if 'error' not in s and 'skipped' not in s]), + "total_sections": 5 + } + + # Print summary + _print_summary_report(overall_health, summary, priority_fixes, estimated_fix_time) + + return { + "overall_health": overall_health, + "sections": sections, + "summary": summary, + "priority_fixes": priority_fixes, + "estimated_fix_time": estimated_fix_time, + "validation": validation, + "detail_level": detail_level, + "analyzed_path": str(path) + } + + +def _calculate_overall_health(sections: Dict[str, Any]) -> int: + """Calculate overall health score (0-100).""" + + scores = [] + weights = { + 'issues': 0.25, + 'best_practices': 0.25, + 'performance': 0.20, + 'architecture': 0.15, + 'layout': 0.15 + } + + # Issues score (inverse of health_score from diagnose_issue) + if 'issues' in sections and 'health_score' in sections['issues']: + scores.append((sections['issues']['health_score'], weights['issues'])) + + # Best practices score + if 'best_practices' in sections and 'overall_score' in sections['best_practices']: + scores.append((sections['best_practices']['overall_score'], weights['best_practices'])) + + # Performance score (derive from bottlenecks) + if 'performance' in sections and 'bottlenecks' in sections['performance']: + bottlenecks = sections['performance']['bottlenecks'] + critical = sum(1 for b in bottlenecks if b['severity'] == 'CRITICAL') + high = sum(1 for b in bottlenecks if b['severity'] == 'HIGH') + perf_score = max(0, 100 - (critical * 20) - (high * 10)) + scores.append((perf_score, weights['performance'])) + + # Architecture score (based on complexity vs pattern appropriateness) + if 'architecture' in sections and 'complexity_score' in sections['architecture']: + arch_data = sections['architecture'] + # Good if recommended == current, or if complexity is low + if arch_data.get('recommended_pattern') == arch_data.get('current_pattern'): + arch_score = 100 + elif arch_data.get('complexity_score', 0) < 40: + arch_score = 80 # Simple app, pattern less critical + else: + arch_score = 60 # Should refactor + scores.append((arch_score, weights['architecture'])) + + # Layout score (inverse of issues) + if 'layout' in sections and 'layout_issues' in sections['layout']: + layout_issues = sections['layout']['layout_issues'] + critical = sum(1 for i in layout_issues if i['severity'] == 'CRITICAL') + warning = sum(1 for i in layout_issues if i['severity'] == 'WARNING') + layout_score = max(0, 100 - (critical * 15) - (warning * 5)) + scores.append((layout_score, weights['layout'])) + + # Weighted average + if not scores: + return 50 # No data + + weighted_sum = sum(score * weight for score, weight in scores) + total_weight = sum(weight for _, weight in scores) + + return int(weighted_sum / total_weight) + + +def _extract_priority_fixes(sections: Dict[str, Any]) -> List[str]: + """Extract priority fixes across all sections.""" + + fixes = [] + + # Critical issues + if 'issues' in sections and 'issues' in sections['issues']: + critical = [i for i in sections['issues']['issues'] if i['severity'] == 'CRITICAL'] + for issue in critical: + fixes.append({ + "priority": "CRITICAL", + "source": "Issues", + "description": f"{issue['issue']} ({issue['location']})", + "fix": issue.get('fix', 'See issue details') + }) + + # Critical performance bottlenecks + if 'performance' in sections and 'bottlenecks' in sections['performance']: + critical = [b for b in sections['performance']['bottlenecks'] if b['severity'] == 'CRITICAL'] + for bottleneck in critical: + fixes.append({ + "priority": "CRITICAL", + "source": "Performance", + "description": f"{bottleneck['issue']} ({bottleneck['location']})", + "fix": bottleneck.get('fix', 'See bottleneck details') + }) + + # Critical layout issues + if 'layout' in sections and 'layout_issues' in sections['layout']: + critical = [i for i in sections['layout']['layout_issues'] if i['severity'] == 'CRITICAL'] + for issue in critical: + fixes.append({ + "priority": "CRITICAL", + "source": "Layout", + "description": f"{issue['issue']} ({issue['location']})", + "fix": issue.get('explanation', 'See layout details') + }) + + # Best practice failures + if 'best_practices' in sections and 'compliance' in sections['best_practices']: + compliance = sections['best_practices']['compliance'] + failures = [tip for tip, data in compliance.items() if data['status'] == 'fail'] + for tip in failures[:3]: # Top 3 + fixes.append({ + "priority": "WARNING", + "source": "Best Practices", + "description": f"Missing {tip.replace('_', ' ')}", + "fix": compliance[tip].get('recommendation', 'See best practices') + }) + + # Architecture recommendations (if significant refactoring needed) + if 'architecture' in sections and 'complexity_score' in sections['architecture']: + arch_data = sections['architecture'] + if arch_data.get('complexity_score', 0) > 70: + if arch_data.get('recommended_pattern') != arch_data.get('current_pattern'): + fixes.append({ + "priority": "INFO", + "source": "Architecture", + "description": f"Consider refactoring to {arch_data.get('recommended_pattern')}", + "fix": f"See architecture recommendations for {len(arch_data.get('refactoring_steps', []))} steps" + }) + + return fixes + + +def _estimate_fix_time(priority_fixes: List[Dict[str, str]]) -> str: + """Estimate time to address priority fixes.""" + + critical_count = sum(1 for f in priority_fixes if f['priority'] == 'CRITICAL') + warning_count = sum(1 for f in priority_fixes if f['priority'] == 'WARNING') + info_count = sum(1 for f in priority_fixes if f['priority'] == 'INFO') + + # Time estimates (in hours) + critical_time = critical_count * 0.5 # 30 min each + warning_time = warning_count * 0.25 # 15 min each + info_time = info_count * 1.0 # 1 hour each (refactoring) + + total_hours = critical_time + warning_time + info_time + + if total_hours == 0: + return "No fixes needed" + elif total_hours < 1: + return f"{int(total_hours * 60)} minutes" + elif total_hours < 2: + return f"1-2 hours" + elif total_hours < 4: + return f"2-4 hours" + elif total_hours < 8: + return f"4-8 hours" + else: + return f"{int(total_hours)} hours (1-2 days)" + + +def _generate_summary(health: int, sections: Dict[str, Any], fixes: List[Dict[str, str]]) -> str: + """Generate executive summary.""" + + if health >= 90: + health_desc = "Excellent" + emoji = "✅" + elif health >= 75: + health_desc = "Good" + emoji = "✓" + elif health >= 60: + health_desc = "Fair" + emoji = "⚠️" + elif health >= 40: + health_desc = "Poor" + emoji = "❌" + else: + health_desc = "Critical" + emoji = "🚨" + + critical_count = sum(1 for f in fixes if f['priority'] == 'CRITICAL') + + if health >= 80: + summary = f"{emoji} {health_desc} health ({health}/100). Application follows most best practices." + elif health >= 60: + summary = f"{emoji} {health_desc} health ({health}/100). Some improvements recommended." + elif health >= 40: + summary = f"{emoji} {health_desc} health ({health}/100). Several issues need attention." + else: + summary = f"{emoji} {health_desc} health ({health}/100). Multiple critical issues require immediate fixes." + + if critical_count > 0: + summary += f" {critical_count} critical issue(s) found." + + return summary + + +def _determine_status(health: int) -> str: + """Determine overall status from health score.""" + if health >= 80: + return "pass" + elif health >= 60: + return "warning" + else: + return "critical" + + +def _print_summary_report(health: int, summary: str, fixes: List[Dict[str, str]], fix_time: str): + """Print formatted summary report.""" + + print(f"{'='*70}") + print(f"ANALYSIS COMPLETE") + print(f"{'='*70}\n") + + print(f"Overall Health: {health}/100") + print(f"Summary: {summary}\n") + + if fixes: + print(f"Priority Fixes ({len(fixes)}):") + print(f"{'-'*70}") + + # Group by priority + critical = [f for f in fixes if f['priority'] == 'CRITICAL'] + warnings = [f for f in fixes if f['priority'] == 'WARNING'] + info = [f for f in fixes if f['priority'] == 'INFO'] + + if critical: + print(f"\n🔴 CRITICAL ({len(critical)}):") + for i, fix in enumerate(critical, 1): + print(f" {i}. [{fix['source']}] {fix['description']}") + + if warnings: + print(f"\n⚠️ WARNINGS ({len(warnings)}):") + for i, fix in enumerate(warnings, 1): + print(f" {i}. [{fix['source']}] {fix['description']}") + + if info: + print(f"\n💡 INFO ({len(info)}):") + for i, fix in enumerate(info, 1): + print(f" {i}. [{fix['source']}] {fix['description']}") + + else: + print("✅ No priority fixes needed!") + + print(f"\n{'-'*70}") + print(f"Estimated Fix Time: {fix_time}") + print(f"{'='*70}\n") + + +def validate_comprehensive_analysis(result: Dict[str, Any]) -> Dict[str, Any]: + """Validate comprehensive analysis result.""" + if 'error' in result: + return {"status": "error", "summary": result['error']} + + validation = result.get('validation', {}) + status = validation.get('status', 'unknown') + summary = validation.get('summary', 'Analysis complete') + + checks = [ + (result.get('overall_health') is not None, "Health score calculated"), + (result.get('sections') is not None, "Sections analyzed"), + (result.get('priority_fixes') is not None, "Priority fixes extracted"), + (result.get('summary') is not None, "Summary generated"), + ] + + all_pass = all(check[0] for check in checks) + + return { + "status": status, + "summary": summary, + "checks": {check[1]: check[0] for check in checks}, + "valid": all_pass + } + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: comprehensive_bubbletea_analysis.py [detail_level]") + print(" detail_level: quick, standard (default), or deep") + sys.exit(1) + + code_path = sys.argv[1] + detail_level = sys.argv[2] if len(sys.argv) > 2 else "standard" + + if detail_level not in ["quick", "standard", "deep"]: + print(f"Invalid detail_level: {detail_level}") + print("Must be: quick, standard, or deep") + sys.exit(1) + + result = comprehensive_bubbletea_analysis(code_path, detail_level) + + # Save to file + output_file = Path(code_path).parent / "bubbletea_analysis_report.json" + with open(output_file, 'w') as f: + json.dump(result, f, indent=2) + + print(f"Full report saved to: {output_file}\n") diff --git a/.crush/skills/bubbletea-maintenance/scripts/debug_performance.py b/.crush/skills/bubbletea-maintenance/scripts/debug_performance.py new file mode 100644 index 00000000..6e477ef7 --- /dev/null +++ b/.crush/skills/bubbletea-maintenance/scripts/debug_performance.py @@ -0,0 +1,731 @@ +#!/usr/bin/env python3 +""" +Debug performance issues in Bubble Tea applications. +Identifies bottlenecks in Update(), View(), and concurrent operations. +""" + +import os +import re +import json +from pathlib import Path +from typing import Dict, List, Any, Tuple, Optional + + +def debug_performance(code_path: str, profile_data: str = "") -> Dict[str, Any]: + """ + Identify performance bottlenecks in Bubble Tea application. + + Args: + code_path: Path to Go file or directory + profile_data: Optional profiling data (pprof output, benchmark results) + + Returns: + Dictionary containing: + - bottlenecks: List of performance issues with locations and fixes + - metrics: Performance metrics (if available) + - recommendations: Prioritized optimization suggestions + - validation: Validation report + """ + path = Path(code_path) + + if not path.exists(): + return { + "error": f"Path not found: {code_path}", + "validation": {"status": "error", "summary": "Invalid path"} + } + + # Collect all .go files + go_files = [] + if path.is_file(): + if path.suffix == '.go': + go_files = [path] + else: + go_files = list(path.glob('**/*.go')) + + if not go_files: + return { + "error": "No .go files found", + "validation": {"status": "error", "summary": "No Go files"} + } + + # Analyze performance for each file + all_bottlenecks = [] + for go_file in go_files: + bottlenecks = _analyze_performance(go_file) + all_bottlenecks.extend(bottlenecks) + + # Sort by severity + severity_order = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3} + all_bottlenecks.sort(key=lambda x: severity_order.get(x['severity'], 999)) + + # Generate recommendations + recommendations = _generate_performance_recommendations(all_bottlenecks) + + # Estimate metrics + metrics = _estimate_metrics(all_bottlenecks, go_files) + + # Summary + critical_count = sum(1 for b in all_bottlenecks if b['severity'] == 'CRITICAL') + high_count = sum(1 for b in all_bottlenecks if b['severity'] == 'HIGH') + + if critical_count > 0: + summary = f"⚠️ Found {critical_count} critical performance issue(s)" + elif high_count > 0: + summary = f"⚠️ Found {high_count} high-priority performance issue(s)" + elif all_bottlenecks: + summary = f"Found {len(all_bottlenecks)} potential optimization(s)" + else: + summary = "✅ No major performance issues detected" + + # Validation + validation = { + "status": "critical" if critical_count > 0 else "warning" if high_count > 0 else "pass", + "summary": summary, + "checks": { + "fast_update": critical_count == 0, + "fast_view": high_count == 0, + "no_memory_leaks": not any(b['category'] == 'memory' for b in all_bottlenecks), + "efficient_rendering": not any(b['category'] == 'rendering' for b in all_bottlenecks) + } + } + + return { + "bottlenecks": all_bottlenecks, + "metrics": metrics, + "recommendations": recommendations, + "summary": summary, + "profile_data": profile_data if profile_data else None, + "validation": validation + } + + +def _analyze_performance(file_path: Path) -> List[Dict[str, Any]]: + """Analyze a single Go file for performance issues.""" + bottlenecks = [] + + try: + content = file_path.read_text() + except Exception as e: + return [] + + lines = content.split('\n') + rel_path = file_path.name + + # Performance checks + bottlenecks.extend(_check_update_performance(content, lines, rel_path)) + bottlenecks.extend(_check_view_performance(content, lines, rel_path)) + bottlenecks.extend(_check_string_operations(content, lines, rel_path)) + bottlenecks.extend(_check_regex_performance(content, lines, rel_path)) + bottlenecks.extend(_check_loop_efficiency(content, lines, rel_path)) + bottlenecks.extend(_check_allocation_patterns(content, lines, rel_path)) + bottlenecks.extend(_check_concurrent_operations(content, lines, rel_path)) + bottlenecks.extend(_check_io_operations(content, lines, rel_path)) + + return bottlenecks + + +def _check_update_performance(content: str, lines: List[str], file_path: str) -> List[Dict[str, Any]]: + """Check Update() function for performance issues.""" + bottlenecks = [] + + # Find Update() function + update_start = -1 + update_end = -1 + brace_count = 0 + + for i, line in enumerate(lines): + if re.search(r'func\s+\([^)]+\)\s+Update\s*\(', line): + update_start = i + brace_count = line.count('{') - line.count('}') + elif update_start >= 0: + brace_count += line.count('{') - line.count('}') + if brace_count == 0: + update_end = i + break + + if update_start < 0: + return bottlenecks + + update_lines = lines[update_start:update_end+1] if update_end > 0 else lines[update_start:] + update_code = '\n'.join(update_lines) + + # Check 1: Blocking I/O in Update() + blocking_patterns = [ + (r'\bhttp\.(Get|Post|Do)\s*\(', "HTTP request", "CRITICAL"), + (r'\btime\.Sleep\s*\(', "Sleep call", "CRITICAL"), + (r'\bos\.(Open|Read|Write)', "File I/O", "CRITICAL"), + (r'\bio\.ReadAll\s*\(', "ReadAll", "CRITICAL"), + (r'\bexec\.Command\([^)]+\)\.Run\(\)', "Command execution", "CRITICAL"), + (r'\bdb\.(Query|Exec)', "Database operation", "CRITICAL"), + ] + + for pattern, operation, severity in blocking_patterns: + matches = re.finditer(pattern, update_code) + for match in matches: + # Find line number within Update() + line_offset = update_code[:match.start()].count('\n') + actual_line = update_start + line_offset + + bottlenecks.append({ + "severity": severity, + "category": "performance", + "issue": f"Blocking {operation} in Update()", + "location": f"{file_path}:{actual_line+1}", + "time_impact": "Blocks event loop (16ms+ delay)", + "explanation": f"{operation} blocks the event loop, freezing the UI", + "fix": f"Move to tea.Cmd goroutine:\n\n" + + f"func fetch{operation.replace(' ', '')}() tea.Msg {{\n" + + f" // Runs in background, doesn't block\n" + + f" result, err := /* your {operation.lower()} */\n" + + f" return resultMsg{{data: result, err: err}}\n" + + f"}}\n\n" + + f"// In Update():\n" + + f"case tea.KeyMsg:\n" + + f" if key.String() == \"r\" {{\n" + + f" return m, fetch{operation.replace(' ', '')} // Non-blocking\n" + + f" }}", + "code_example": f"return m, fetch{operation.replace(' ', '')}" + }) + + # Check 2: Heavy computation in Update() + computation_patterns = [ + (r'for\s+.*range\s+\w+\s*\{[^}]{100,}\}', "Large loop", "HIGH"), + (r'json\.(Marshal|Unmarshal)', "JSON processing", "MEDIUM"), + (r'regexp\.MustCompile\s*\(', "Regex compilation", "HIGH"), + ] + + for pattern, operation, severity in computation_patterns: + matches = re.finditer(pattern, update_code, re.DOTALL) + for match in matches: + line_offset = update_code[:match.start()].count('\n') + actual_line = update_start + line_offset + + bottlenecks.append({ + "severity": severity, + "category": "performance", + "issue": f"Heavy {operation} in Update()", + "location": f"{file_path}:{actual_line+1}", + "time_impact": "May exceed 16ms budget", + "explanation": f"{operation} can be expensive, consider optimizing", + "fix": "Optimize:\n" + + "- Cache compiled regexes (compile once, reuse)\n" + + "- Move heavy processing to tea.Cmd\n" + + "- Use incremental updates instead of full recalculation", + "code_example": "var cachedRegex = regexp.MustCompile(`pattern`) // Outside Update()" + }) + + return bottlenecks + + +def _check_view_performance(content: str, lines: List[str], file_path: str) -> List[Dict[str, Any]]: + """Check View() function for performance issues.""" + bottlenecks = [] + + # Find View() function + view_start = -1 + view_end = -1 + brace_count = 0 + + for i, line in enumerate(lines): + if re.search(r'func\s+\([^)]+\)\s+View\s*\(', line): + view_start = i + brace_count = line.count('{') - line.count('}') + elif view_start >= 0: + brace_count += line.count('{') - line.count('}') + if brace_count == 0: + view_end = i + break + + if view_start < 0: + return bottlenecks + + view_lines = lines[view_start:view_end+1] if view_end > 0 else lines[view_start:] + view_code = '\n'.join(view_lines) + + # Check 1: String concatenation with + + string_concat_pattern = r'(\w+\s*\+\s*"[^"]*"\s*\+\s*\w+|\w+\s*\+=\s*"[^"]*")' + if re.search(string_concat_pattern, view_code): + matches = list(re.finditer(string_concat_pattern, view_code)) + if len(matches) > 5: # Multiple concatenations + bottlenecks.append({ + "severity": "HIGH", + "category": "rendering", + "issue": f"String concatenation with + operator ({len(matches)} occurrences)", + "location": f"{file_path}:{view_start+1} (View function)", + "time_impact": "Allocates many temporary strings", + "explanation": "Using + for strings creates many allocations. Use strings.Builder.", + "fix": "Replace with strings.Builder:\n\n" + + "import \"strings\"\n\n" + + "func (m model) View() string {\n" + + " var b strings.Builder\n" + + " b.WriteString(\"header\")\n" + + " b.WriteString(m.content)\n" + + " b.WriteString(\"footer\")\n" + + " return b.String()\n" + + "}", + "code_example": "var b strings.Builder; b.WriteString(...)" + }) + + # Check 2: Recompiling lipgloss styles + style_in_view = re.findall(r'lipgloss\.NewStyle\(\)', view_code) + if len(style_in_view) > 3: + bottlenecks.append({ + "severity": "MEDIUM", + "category": "rendering", + "issue": f"Creating lipgloss styles in View() ({len(style_in_view)} times)", + "location": f"{file_path}:{view_start+1} (View function)", + "time_impact": "Recreates styles on every render", + "explanation": "Style creation is relatively expensive. Cache styles in model.", + "fix": "Cache styles in model:\n\n" + + "type model struct {\n" + + " // ... other fields\n" + + " headerStyle lipgloss.Style\n" + + " contentStyle lipgloss.Style\n" + + "}\n\n" + + "func initialModel() model {\n" + + " return model{\n" + + " headerStyle: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(\"#FF00FF\")),\n" + + " contentStyle: lipgloss.NewStyle().Padding(1),\n" + + " }\n" + + "}\n\n" + + "func (m model) View() string {\n" + + " return m.headerStyle.Render(\"Header\") + m.contentStyle.Render(m.content)\n" + + "}", + "code_example": "m.headerStyle.Render(...) // Use cached style" + }) + + # Check 3: Reading files in View() + if re.search(r'\b(os\.ReadFile|ioutil\.ReadFile|os\.Open)', view_code): + bottlenecks.append({ + "severity": "CRITICAL", + "category": "rendering", + "issue": "File I/O in View() function", + "location": f"{file_path}:{view_start+1} (View function)", + "time_impact": "Massive delay (1-100ms per render)", + "explanation": "View() is called frequently. File I/O blocks rendering.", + "fix": "Load file in Update(), cache in model:\n\n" + + "type model struct {\n" + + " fileContent string\n" + + "}\n\n" + + "func loadFile() tea.Msg {\n" + + " content, err := os.ReadFile(\"file.txt\")\n" + + " return fileLoadedMsg{content: string(content), err: err}\n" + + "}\n\n" + + "// In Update():\n" + + "case fileLoadedMsg:\n" + + " m.fileContent = msg.content\n\n" + + "// In View():\n" + + "return m.fileContent // Just return cached data", + "code_example": "return m.cachedContent // No I/O in View()" + }) + + # Check 4: Expensive lipgloss operations + join_vertical_count = len(re.findall(r'lipgloss\.JoinVertical', view_code)) + if join_vertical_count > 10: + bottlenecks.append({ + "severity": "LOW", + "category": "rendering", + "issue": f"Many lipgloss.JoinVertical calls ({join_vertical_count})", + "location": f"{file_path}:{view_start+1} (View function)", + "time_impact": "Accumulates string operations", + "explanation": "Many join operations can add up. Consider batching.", + "fix": "Batch related joins:\n\n" + + "// Instead of many small joins:\n" + + "// line1 := lipgloss.JoinHorizontal(...)\n" + + "// line2 := lipgloss.JoinHorizontal(...)\n" + + "// ...\n\n" + + "// Build all lines first, join once:\n" + + "lines := []string{\n" + + " lipgloss.JoinHorizontal(...),\n" + + " lipgloss.JoinHorizontal(...),\n" + + " lipgloss.JoinHorizontal(...),\n" + + "}\n" + + "return lipgloss.JoinVertical(lipgloss.Left, lines...)", + "code_example": "lipgloss.JoinVertical(lipgloss.Left, lines...)" + }) + + return bottlenecks + + +def _check_string_operations(content: str, lines: List[str], file_path: str) -> List[Dict[str, Any]]: + """Check for inefficient string operations.""" + bottlenecks = [] + + # Check for fmt.Sprintf in loops + for i, line in enumerate(lines): + if 'for' in line: + # Check next 20 lines for fmt.Sprintf + for j in range(i, min(i+20, len(lines))): + if 'fmt.Sprintf' in lines[j] and 'result' in lines[j]: + bottlenecks.append({ + "severity": "MEDIUM", + "category": "performance", + "issue": "fmt.Sprintf in loop", + "location": f"{file_path}:{j+1}", + "time_impact": "Allocations on every iteration", + "explanation": "fmt.Sprintf allocates. Use strings.Builder or fmt.Fprintf.", + "fix": "Use strings.Builder:\n\n" + + "var b strings.Builder\n" + + "for _, item := range items {\n" + + " fmt.Fprintf(&b, \"Item: %s\\n\", item)\n" + + "}\n" + + "result := b.String()", + "code_example": "fmt.Fprintf(&builder, ...)" + }) + break + + return bottlenecks + + +def _check_regex_performance(content: str, lines: List[str], file_path: str) -> List[Dict[str, Any]]: + """Check for regex performance issues.""" + bottlenecks = [] + + # Check for regexp.MustCompile in functions (not at package level) + in_function = False + for i, line in enumerate(lines): + if re.match(r'^\s*func\s+', line): + in_function = True + elif in_function and re.match(r'^\s*$', line): + in_function = False + + if in_function and 'regexp.MustCompile' in line: + bottlenecks.append({ + "severity": "HIGH", + "category": "performance", + "issue": "Compiling regex in function", + "location": f"{file_path}:{i+1}", + "time_impact": "Compiles on every call (1-10ms)", + "explanation": "Regex compilation is expensive. Compile once at package level.", + "fix": "Move to package level:\n\n" + + "// At package level (outside functions)\n" + + "var (\n" + + " emailRegex = regexp.MustCompile(`^[a-z]+@[a-z]+\\.[a-z]+$`)\n" + + " phoneRegex = regexp.MustCompile(`^\\d{3}-\\d{3}-\\d{4}$`)\n" + + ")\n\n" + + "// In function\n" + + "func validate(email string) bool {\n" + + " return emailRegex.MatchString(email) // Reuse compiled regex\n" + + "}", + "code_example": "var emailRegex = regexp.MustCompile(...) // Package level" + }) + + return bottlenecks + + +def _check_loop_efficiency(content: str, lines: List[str], file_path: str) -> List[Dict[str, Any]]: + """Check for inefficient loops.""" + bottlenecks = [] + + # Check for nested loops over large data + for i, line in enumerate(lines): + if re.search(r'for\s+.*range', line): + # Look for nested loop within 30 lines + for j in range(i+1, min(i+30, len(lines))): + if re.search(r'for\s+.*range', lines[j]): + # Check indentation (nested) + if len(lines[j]) - len(lines[j].lstrip()) > len(line) - len(line.lstrip()): + bottlenecks.append({ + "severity": "MEDIUM", + "category": "performance", + "issue": "Nested loops detected", + "location": f"{file_path}:{i+1}", + "time_impact": "O(n²) complexity", + "explanation": "Nested loops can be slow. Consider optimization.", + "fix": "Optimization strategies:\n" + + "1. Use map/set for O(1) lookups instead of nested loop\n" + + "2. Break early when possible\n" + + "3. Process data once, cache results\n" + + "4. Use channels/goroutines for parallel processing\n\n" + + "Example with map:\n" + + "// Instead of:\n" + + "for _, a := range listA {\n" + + " for _, b := range listB {\n" + + " if a.id == b.id { found = true }\n" + + " }\n" + + "}\n\n" + + "// Use map:\n" + + "mapB := make(map[string]bool)\n" + + "for _, b := range listB {\n" + + " mapB[b.id] = true\n" + + "}\n" + + "for _, a := range listA {\n" + + " if mapB[a.id] { found = true }\n" + + "}", + "code_example": "Use map for O(1) lookup" + }) + break + + return bottlenecks + + +def _check_allocation_patterns(content: str, lines: List[str], file_path: str) -> List[Dict[str, Any]]: + """Check for excessive allocations.""" + bottlenecks = [] + + # Check for slice append in loops without pre-allocation + for i, line in enumerate(lines): + if re.search(r'for\s+.*range', line): + # Check next 20 lines for append without make + has_append = False + for j in range(i, min(i+20, len(lines))): + if 'append(' in lines[j]: + has_append = True + break + + # Check if slice was pre-allocated + has_make = False + for j in range(max(0, i-10), i): + if 'make(' in lines[j] and 'len(' in lines[j]: + has_make = True + break + + if has_append and not has_make: + bottlenecks.append({ + "severity": "LOW", + "category": "memory", + "issue": "Slice append in loop without pre-allocation", + "location": f"{file_path}:{i+1}", + "time_impact": "Multiple reallocations", + "explanation": "Appending without pre-allocation causes slice to grow, reallocate.", + "fix": "Pre-allocate slice:\n\n" + + "// Instead of:\n" + + "var results []string\n" + + "for _, item := range items {\n" + + " results = append(results, process(item))\n" + + "}\n\n" + + "// Pre-allocate:\n" + + "results := make([]string, 0, len(items)) // Pre-allocate capacity\n" + + "for _, item := range items {\n" + + " results = append(results, process(item)) // No reallocation\n" + + "}", + "code_example": "results := make([]string, 0, len(items))" + }) + + return bottlenecks + + +def _check_concurrent_operations(content: str, lines: List[str], file_path: str) -> List[Dict[str, Any]]: + """Check for concurrency issues.""" + bottlenecks = [] + + # Check for goroutine leaks + has_goroutines = bool(re.search(r'\bgo\s+func', content)) + has_context = bool(re.search(r'context\.', content)) + has_waitgroup = bool(re.search(r'sync\.WaitGroup', content)) + + if has_goroutines and not (has_context or has_waitgroup): + bottlenecks.append({ + "severity": "HIGH", + "category": "memory", + "issue": "Goroutines without lifecycle management", + "location": file_path, + "time_impact": "Goroutine leaks consume memory", + "explanation": "Goroutines need proper cleanup to prevent leaks.", + "fix": "Use context for cancellation:\n\n" + + "type model struct {\n" + + " ctx context.Context\n" + + " cancel context.CancelFunc\n" + + "}\n\n" + + "func initialModel() model {\n" + + " ctx, cancel := context.WithCancel(context.Background())\n" + + " return model{ctx: ctx, cancel: cancel}\n" + + "}\n\n" + + "func worker(ctx context.Context) tea.Msg {\n" + + " for {\n" + + " select {\n" + + " case <-ctx.Done():\n" + + " return nil // Stop goroutine\n" + + " case <-time.After(time.Second):\n" + + " // Do work\n" + + " }\n" + + " }\n" + + "}\n\n" + + "// In Update() on quit:\n" + + "m.cancel() // Stops all goroutines", + "code_example": "ctx, cancel := context.WithCancel(context.Background())" + }) + + return bottlenecks + + +def _check_io_operations(content: str, lines: List[str], file_path: str) -> List[Dict[str, Any]]: + """Check for I/O operations that should be async.""" + bottlenecks = [] + + # Check for synchronous file reads + file_ops = [ + (r'os\.ReadFile', "os.ReadFile"), + (r'ioutil\.ReadFile', "ioutil.ReadFile"), + (r'os\.Open', "os.Open"), + (r'io\.ReadAll', "io.ReadAll"), + ] + + for pattern, op_name in file_ops: + matches = list(re.finditer(pattern, content)) + if matches: + # Check if in tea.Cmd (good) or in Update/View (bad) + for match in matches: + # Find which function this is in + line_num = content[:match.start()].count('\n') + context_lines = content.split('\n')[max(0, line_num-10):line_num+1] + context_text = '\n'.join(context_lines) + + in_cmd = bool(re.search(r'func\s+\w+\(\s*\)\s+tea\.Msg', context_text)) + in_update = bool(re.search(r'func\s+\([^)]+\)\s+Update', context_text)) + in_view = bool(re.search(r'func\s+\([^)]+\)\s+View', context_text)) + + if (in_update or in_view) and not in_cmd: + severity = "CRITICAL" if in_view else "HIGH" + func_name = "View()" if in_view else "Update()" + + bottlenecks.append({ + "severity": severity, + "category": "io", + "issue": f"Synchronous {op_name} in {func_name}", + "location": f"{file_path}:{line_num+1}", + "time_impact": "1-100ms per call", + "explanation": f"{op_name} blocks the event loop", + "fix": f"Move to tea.Cmd:\n\n" + + f"func loadFileCmd() tea.Msg {{\n" + + f" data, err := {op_name}(\"file.txt\")\n" + + f" return fileLoadedMsg{{data: data, err: err}}\n" + + f"}}\n\n" + + f"// In Update():\n" + + f"case tea.KeyMsg:\n" + + f" if key.String() == \"o\" {{\n" + + f" return m, loadFileCmd // Non-blocking\n" + + f" }}", + "code_example": "return m, loadFileCmd // Async I/O" + }) + + return bottlenecks + + +def _generate_performance_recommendations(bottlenecks: List[Dict[str, Any]]) -> List[str]: + """Generate prioritized performance recommendations.""" + recommendations = [] + + # Group by category + categories = {} + for b in bottlenecks: + cat = b['category'] + if cat not in categories: + categories[cat] = [] + categories[cat].append(b) + + # Priority recommendations + if 'performance' in categories: + critical = [b for b in categories['performance'] if b['severity'] == 'CRITICAL'] + if critical: + recommendations.append( + f"🔴 CRITICAL: Move {len(critical)} blocking operation(s) to tea.Cmd goroutines" + ) + + if 'rendering' in categories: + recommendations.append( + f"⚡ Optimize View() rendering: Found {len(categories['rendering'])} issue(s)" + ) + + if 'memory' in categories: + recommendations.append( + f"💾 Fix memory issues: Found {len(categories['memory'])} potential leak(s)" + ) + + if 'io' in categories: + recommendations.append( + f"💿 Make I/O async: Found {len(categories['io'])} synchronous I/O call(s)" + ) + + # General recommendations + recommendations.extend([ + "Profile with pprof to get precise measurements", + "Use benchmarks to validate optimizations", + "Monitor with runtime.ReadMemStats() for memory usage", + "Test with large datasets to reveal performance issues" + ]) + + return recommendations + + +def _estimate_metrics(bottlenecks: List[Dict[str, Any]], files: List[Path]) -> Dict[str, Any]: + """Estimate performance metrics based on analysis.""" + + # Estimate Update() time + critical_in_update = sum(1 for b in bottlenecks + if 'Update()' in b.get('issue', '') and b['severity'] == 'CRITICAL') + high_in_update = sum(1 for b in bottlenecks + if 'Update()' in b.get('issue', '') and b['severity'] == 'HIGH') + + estimated_update_time = "2-5ms (good)" + if critical_in_update > 0: + estimated_update_time = "50-200ms (critical - UI freezing)" + elif high_in_update > 0: + estimated_update_time = "20-50ms (slow - noticeable lag)" + + # Estimate View() time + critical_in_view = sum(1 for b in bottlenecks + if 'View()' in b.get('issue', '') and b['severity'] == 'CRITICAL') + high_in_view = sum(1 for b in bottlenecks + if 'View()' in b.get('issue', '') and b['severity'] == 'HIGH') + + estimated_view_time = "1-3ms (good)" + if critical_in_view > 0: + estimated_view_time = "100-500ms (critical - very slow)" + elif high_in_view > 0: + estimated_view_time = "10-30ms (slow)" + + # Memory estimate + goroutine_leaks = sum(1 for b in bottlenecks if 'leak' in b.get('issue', '').lower()) + memory_status = "stable" + if goroutine_leaks > 0: + memory_status = "growing (leaks detected)" + + return { + "estimated_update_time": estimated_update_time, + "estimated_view_time": estimated_view_time, + "memory_status": memory_status, + "total_bottlenecks": len(bottlenecks), + "critical_issues": sum(1 for b in bottlenecks if b['severity'] == 'CRITICAL'), + "files_analyzed": len(files), + "note": "Run actual profiling (pprof, benchmarks) for precise measurements" + } + + +def validate_performance_debug(result: Dict[str, Any]) -> Dict[str, Any]: + """Validate performance debug result.""" + if 'error' in result: + return {"status": "error", "summary": result['error']} + + validation = result.get('validation', {}) + status = validation.get('status', 'unknown') + summary = validation.get('summary', 'Performance analysis complete') + + checks = [ + (result.get('bottlenecks') is not None, "Has bottlenecks list"), + (result.get('metrics') is not None, "Has metrics"), + (result.get('recommendations') is not None, "Has recommendations"), + ] + + all_pass = all(check[0] for check in checks) + + return { + "status": status, + "summary": summary, + "checks": {check[1]: check[0] for check in checks}, + "valid": all_pass + } + + +if __name__ == "__main__": + import sys + + if len(sys.argv) < 2: + print("Usage: debug_performance.py [profile_data]") + sys.exit(1) + + code_path = sys.argv[1] + profile_data = sys.argv[2] if len(sys.argv) > 2 else "" + + result = debug_performance(code_path, profile_data) + print(json.dumps(result, indent=2)) diff --git a/.crush/skills/bubbletea-maintenance/scripts/diagnose_issue.py b/.crush/skills/bubbletea-maintenance/scripts/diagnose_issue.py new file mode 100644 index 00000000..5f7bb723 --- /dev/null +++ b/.crush/skills/bubbletea-maintenance/scripts/diagnose_issue.py @@ -0,0 +1,441 @@ +#!/usr/bin/env python3 +""" +Diagnose issues in existing Bubble Tea applications. +Identifies common problems: slow event loop, layout issues, memory leaks, etc. +""" + +import os +import re +import json +from pathlib import Path +from typing import Dict, List, Any + + +def diagnose_issue(code_path: str, description: str = "") -> Dict[str, Any]: + """ + Analyze Bubble Tea code to identify common issues. + + Args: + code_path: Path to Go file or directory containing Bubble Tea code + description: Optional user description of the problem + + Returns: + Dictionary containing: + - issues: List of identified issues with severity, location, fix + - summary: High-level summary + - health_score: 0-100 score (higher is better) + - validation: Validation report + """ + path = Path(code_path) + + if not path.exists(): + return { + "error": f"Path not found: {code_path}", + "validation": {"status": "error", "summary": "Invalid path"} + } + + # Collect all .go files + go_files = [] + if path.is_file(): + if path.suffix == '.go': + go_files = [path] + else: + go_files = list(path.glob('**/*.go')) + + if not go_files: + return { + "error": "No .go files found", + "validation": {"status": "error", "summary": "No Go files"} + } + + # Analyze all files + all_issues = [] + for go_file in go_files: + issues = _analyze_go_file(go_file) + all_issues.extend(issues) + + # Calculate health score + critical_count = sum(1 for i in all_issues if i['severity'] == 'CRITICAL') + warning_count = sum(1 for i in all_issues if i['severity'] == 'WARNING') + info_count = sum(1 for i in all_issues if i['severity'] == 'INFO') + + health_score = max(0, 100 - (critical_count * 20) - (warning_count * 5) - (info_count * 1)) + + # Generate summary + if critical_count == 0 and warning_count == 0: + summary = "✅ No critical issues found. Application appears healthy." + elif critical_count > 0: + summary = f"❌ Found {critical_count} critical issue(s) requiring immediate attention" + else: + summary = f"⚠️ Found {warning_count} warning(s) that should be addressed" + + # Validation + validation = { + "status": "critical" if critical_count > 0 else "warning" if warning_count > 0 else "pass", + "summary": summary, + "checks": { + "has_blocking_operations": critical_count > 0, + "has_layout_issues": any(i['category'] == 'layout' for i in all_issues), + "has_performance_issues": any(i['category'] == 'performance' for i in all_issues), + "has_architecture_issues": any(i['category'] == 'architecture' for i in all_issues) + } + } + + return { + "issues": all_issues, + "summary": summary, + "health_score": health_score, + "statistics": { + "total_issues": len(all_issues), + "critical": critical_count, + "warnings": warning_count, + "info": info_count, + "files_analyzed": len(go_files) + }, + "validation": validation, + "user_description": description + } + + +def _analyze_go_file(file_path: Path) -> List[Dict[str, Any]]: + """Analyze a single Go file for issues.""" + issues = [] + + try: + content = file_path.read_text() + except Exception as e: + return [{ + "severity": "WARNING", + "category": "system", + "issue": f"Could not read file: {e}", + "location": str(file_path), + "explanation": "File access error", + "fix": "Check file permissions" + }] + + lines = content.split('\n') + rel_path = file_path.name + + # Check 1: Blocking operations in Update() or View() + issues.extend(_check_blocking_operations(content, lines, rel_path)) + + # Check 2: Hardcoded dimensions + issues.extend(_check_hardcoded_dimensions(content, lines, rel_path)) + + # Check 3: Missing terminal recovery + issues.extend(_check_terminal_recovery(content, lines, rel_path)) + + # Check 4: Message ordering assumptions + issues.extend(_check_message_ordering(content, lines, rel_path)) + + # Check 5: Model complexity + issues.extend(_check_model_complexity(content, lines, rel_path)) + + # Check 6: Memory leaks (goroutine leaks) + issues.extend(_check_goroutine_leaks(content, lines, rel_path)) + + # Check 7: Layout arithmetic issues + issues.extend(_check_layout_arithmetic(content, lines, rel_path)) + + return issues + + +def _check_blocking_operations(content: str, lines: List[str], file_path: str) -> List[Dict[str, Any]]: + """Check for blocking operations in Update() or View().""" + issues = [] + + # Find Update() and View() function boundaries + in_update = False + in_view = False + func_start_line = 0 + + blocking_patterns = [ + (r'\btime\.Sleep\s*\(', "time.Sleep"), + (r'\bhttp\.(Get|Post|Do)\s*\(', "HTTP request"), + (r'\bos\.Open\s*\(', "File I/O"), + (r'\bio\.ReadAll\s*\(', "Blocking read"), + (r'\bexec\.Command\([^)]+\)\.Run\(\)', "Command execution"), + (r'\bdb\.Query\s*\(', "Database query"), + ] + + for i, line in enumerate(lines): + # Track function boundaries + if re.search(r'func\s+\([^)]+\)\s+Update\s*\(', line): + in_update = True + func_start_line = i + elif re.search(r'func\s+\([^)]+\)\s+View\s*\(', line): + in_view = True + func_start_line = i + elif in_update or in_view: + if line.strip().startswith('func '): + in_update = False + in_view = False + + # Check for blocking operations + if in_update or in_view: + for pattern, operation in blocking_patterns: + if re.search(pattern, line): + func_type = "Update()" if in_update else "View()" + issues.append({ + "severity": "CRITICAL", + "category": "performance", + "issue": f"Blocking {operation} in {func_type}", + "location": f"{file_path}:{i+1}", + "code_snippet": line.strip(), + "explanation": f"{operation} blocks the event loop, causing UI to freeze", + "fix": f"Move {operation} to tea.Cmd goroutine:\n\n" + + f"func load{operation.replace(' ', '')}() tea.Msg {{\n" + + f" // Your {operation} here\n" + + f" return resultMsg{{}}\n" + + f"}}\n\n" + + f"// In Update():\n" + + f"return m, load{operation.replace(' ', '')}" + }) + + return issues + + +def _check_hardcoded_dimensions(content: str, lines: List[str], file_path: str) -> List[Dict[str, Any]]: + """Check for hardcoded terminal dimensions.""" + issues = [] + + # Look for hardcoded width/height values + patterns = [ + (r'\.Width\s*\(\s*(\d{2,})\s*\)', "width"), + (r'\.Height\s*\(\s*(\d{2,})\s*\)', "height"), + (r'MaxWidth\s*:\s*(\d{2,})', "MaxWidth"), + (r'MaxHeight\s*:\s*(\d{2,})', "MaxHeight"), + ] + + for i, line in enumerate(lines): + for pattern, dimension in patterns: + matches = re.finditer(pattern, line) + for match in matches: + value = match.group(1) + if int(value) >= 20: # Likely a terminal dimension, not small padding + issues.append({ + "severity": "WARNING", + "category": "layout", + "issue": f"Hardcoded {dimension} value: {value}", + "location": f"{file_path}:{i+1}", + "code_snippet": line.strip(), + "explanation": "Hardcoded dimensions don't adapt to terminal size", + "fix": f"Use dynamic terminal size from tea.WindowSizeMsg:\n\n" + + f"type model struct {{\n" + + f" termWidth int\n" + + f" termHeight int\n" + + f"}}\n\n" + + f"func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {{\n" + + f" switch msg := msg.(type) {{\n" + + f" case tea.WindowSizeMsg:\n" + + f" m.termWidth = msg.Width\n" + + f" m.termHeight = msg.Height\n" + + f" }}\n" + + f" return m, nil\n" + + f"}}" + }) + + return issues + + +def _check_terminal_recovery(content: str, lines: List[str], file_path: str) -> List[Dict[str, Any]]: + """Check for panic recovery and terminal cleanup.""" + issues = [] + + has_defer_recover = bool(re.search(r'defer\s+func\s*\(\s*\)\s*\{[^}]*recover\(\)', content, re.DOTALL)) + has_main = bool(re.search(r'func\s+main\s*\(\s*\)', content)) + + if has_main and not has_defer_recover: + issues.append({ + "severity": "WARNING", + "category": "reliability", + "issue": "Missing panic recovery in main()", + "location": file_path, + "explanation": "Panics can leave terminal in broken state (mouse mode enabled, cursor hidden)", + "fix": "Add defer recovery:\n\n" + + "func main() {\n" + + " defer func() {\n" + + " if r := recover(); r != nil {\n" + + " tea.DisableMouseAllMotion()\n" + + " tea.ShowCursor()\n" + + " fmt.Println(\"Panic:\", r)\n" + + " os.Exit(1)\n" + + " }\n" + + " }()\n\n" + + " // Your program logic\n" + + "}" + }) + + return issues + + +def _check_message_ordering(content: str, lines: List[str], file_path: str) -> List[Dict[str, Any]]: + """Check for assumptions about message ordering from concurrent commands.""" + issues = [] + + # Look for concurrent command patterns without order handling + has_batch = bool(re.search(r'tea\.Batch\s*\(', content)) + has_state_machine = bool(re.search(r'type\s+\w+State\s+(int|string)', content)) + + if has_batch and not has_state_machine: + issues.append({ + "severity": "INFO", + "category": "architecture", + "issue": "Using tea.Batch without explicit state tracking", + "location": file_path, + "explanation": "Messages from tea.Batch arrive in unpredictable order", + "fix": "Use state machine to track operations:\n\n" + + "type model struct {\n" + + " operations map[string]bool // Track active operations\n" + + "}\n\n" + + "type opStartMsg struct { id string }\n" + + "type opDoneMsg struct { id string, result string }\n\n" + + "func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {\n" + + " switch msg := msg.(type) {\n" + + " case opStartMsg:\n" + + " m.operations[msg.id] = true\n" + + " case opDoneMsg:\n" + + " delete(m.operations, msg.id)\n" + + " }\n" + + " return m, nil\n" + + "}" + }) + + return issues + + +def _check_model_complexity(content: str, lines: List[str], file_path: str) -> List[Dict[str, Any]]: + """Check if model is too complex and should use model tree.""" + issues = [] + + # Count fields in model struct + model_match = re.search(r'type\s+(\w*[Mm]odel)\s+struct\s*\{([^}]+)\}', content, re.DOTALL) + if model_match: + model_body = model_match.group(2) + field_count = len([line for line in model_body.split('\n') if line.strip() and not line.strip().startswith('//')]) + + if field_count > 15: + issues.append({ + "severity": "INFO", + "category": "architecture", + "issue": f"Model has {field_count} fields (complex)", + "location": file_path, + "explanation": "Large models are hard to maintain. Consider model tree pattern.", + "fix": "Refactor to model tree:\n\n" + + "type appModel struct {\n" + + " activeView int\n" + + " listView listModel\n" + + " detailView detailModel\n" + + "}\n\n" + + "func (m appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {\n" + + " switch m.activeView {\n" + + " case 0:\n" + + " m.listView, cmd = m.listView.Update(msg)\n" + + " case 1:\n" + + " m.detailView, cmd = m.detailView.Update(msg)\n" + + " }\n" + + " return m, cmd\n" + + "}" + }) + + return issues + + +def _check_goroutine_leaks(content: str, lines: List[str], file_path: str) -> List[Dict[str, Any]]: + """Check for potential goroutine leaks.""" + issues = [] + + # Look for goroutines without cleanup + has_go_statements = bool(re.search(r'\bgo\s+', content)) + has_context_cancel = bool(re.search(r'ctx,\s*cancel\s*:=\s*context\.', content)) + + if has_go_statements and not has_context_cancel: + issues.append({ + "severity": "WARNING", + "category": "reliability", + "issue": "Goroutines without context cancellation", + "location": file_path, + "explanation": "Goroutines may leak if not properly cancelled", + "fix": "Use context for goroutine lifecycle:\n\n" + + "type model struct {\n" + + " ctx context.Context\n" + + " cancel context.CancelFunc\n" + + "}\n\n" + + "func initialModel() model {\n" + + " ctx, cancel := context.WithCancel(context.Background())\n" + + " return model{ctx: ctx, cancel: cancel}\n" + + "}\n\n" + + "// In Update() on quit:\n" + + "m.cancel() // Stops all goroutines" + }) + + return issues + + +def _check_layout_arithmetic(content: str, lines: List[str], file_path: str) -> List[Dict[str, Any]]: + """Check for layout arithmetic issues.""" + issues = [] + + # Look for manual height/width calculations instead of lipgloss helpers + uses_lipgloss = bool(re.search(r'"github\.com/charmbracelet/lipgloss"', content)) + has_manual_calc = bool(re.search(r'(height|width)\s*[-+]\s*\d+', content, re.IGNORECASE)) + has_lipgloss_helpers = bool(re.search(r'lipgloss\.(Height|Width|GetVertical|GetHorizontal)', content)) + + if uses_lipgloss and has_manual_calc and not has_lipgloss_helpers: + issues.append({ + "severity": "WARNING", + "category": "layout", + "issue": "Manual layout calculations without lipgloss helpers", + "location": file_path, + "explanation": "Manual calculations are error-prone. Use lipgloss.Height() and lipgloss.Width()", + "fix": "Use lipgloss helpers:\n\n" + + "// ❌ BAD:\n" + + "availableHeight := termHeight - 5 // Magic number!\n\n" + + "// ✅ GOOD:\n" + + "headerHeight := lipgloss.Height(header)\n" + + "footerHeight := lipgloss.Height(footer)\n" + + "availableHeight := termHeight - headerHeight - footerHeight" + }) + + return issues + + +# Validation function +def validate_diagnosis(result: Dict[str, Any]) -> Dict[str, Any]: + """Validate diagnosis result.""" + if 'error' in result: + return {"status": "error", "summary": result['error']} + + validation = result.get('validation', {}) + status = validation.get('status', 'unknown') + summary = validation.get('summary', 'Diagnosis complete') + + checks = [ + (result.get('issues') is not None, "Has issues list"), + (result.get('health_score') is not None, "Has health score"), + (result.get('summary') is not None, "Has summary"), + (len(result.get('issues', [])) >= 0, "Issues analyzed"), + ] + + all_pass = all(check[0] for check in checks) + + return { + "status": status, + "summary": summary, + "checks": {check[1]: check[0] for check in checks}, + "valid": all_pass + } + + +if __name__ == "__main__": + import sys + + if len(sys.argv) < 2: + print("Usage: diagnose_issue.py [description]") + sys.exit(1) + + code_path = sys.argv[1] + description = sys.argv[2] if len(sys.argv) > 2 else "" + + result = diagnose_issue(code_path, description) + print(json.dumps(result, indent=2)) diff --git a/.crush/skills/bubbletea-maintenance/scripts/fix_layout_issues.py b/.crush/skills/bubbletea-maintenance/scripts/fix_layout_issues.py new file mode 100644 index 00000000..c69a48b3 --- /dev/null +++ b/.crush/skills/bubbletea-maintenance/scripts/fix_layout_issues.py @@ -0,0 +1,578 @@ +#!/usr/bin/env python3 +""" +Fix Lipgloss layout issues in Bubble Tea applications. +Identifies hardcoded dimensions, incorrect calculations, overflow issues, etc. +""" + +import os +import re +import json +from pathlib import Path +from typing import Dict, List, Any, Tuple, Optional + + +def fix_layout_issues(code_path: str, description: str = "") -> Dict[str, Any]: + """ + Diagnose and fix common Lipgloss layout problems. + + Args: + code_path: Path to Go file or directory + description: Optional user description of layout issue + + Returns: + Dictionary containing: + - layout_issues: List of identified layout problems with fixes + - lipgloss_improvements: General recommendations + - code_fixes: Concrete code changes to apply + - validation: Validation report + """ + path = Path(code_path) + + if not path.exists(): + return { + "error": f"Path not found: {code_path}", + "validation": {"status": "error", "summary": "Invalid path"} + } + + # Collect all .go files + go_files = [] + if path.is_file(): + if path.suffix == '.go': + go_files = [path] + else: + go_files = list(path.glob('**/*.go')) + + if not go_files: + return { + "error": "No .go files found", + "validation": {"status": "error", "summary": "No Go files"} + } + + # Analyze all files for layout issues + all_layout_issues = [] + all_code_fixes = [] + + for go_file in go_files: + issues, fixes = _analyze_layout_issues(go_file) + all_layout_issues.extend(issues) + all_code_fixes.extend(fixes) + + # Generate improvement recommendations + lipgloss_improvements = _generate_improvements(all_layout_issues) + + # Summary + critical_count = sum(1 for i in all_layout_issues if i['severity'] == 'CRITICAL') + warning_count = sum(1 for i in all_layout_issues if i['severity'] == 'WARNING') + + if critical_count > 0: + summary = f"🚨 Found {critical_count} critical layout issue(s)" + elif warning_count > 0: + summary = f"⚠️ Found {warning_count} layout issue(s) to address" + elif all_layout_issues: + summary = f"Found {len(all_layout_issues)} minor layout improvement(s)" + else: + summary = "✅ No major layout issues detected" + + # Validation + validation = { + "status": "critical" if critical_count > 0 else "warning" if warning_count > 0 else "pass", + "summary": summary, + "checks": { + "no_hardcoded_dimensions": not any(i['type'] == 'hardcoded_dimensions' for i in all_layout_issues), + "proper_height_calc": not any(i['type'] == 'incorrect_height' for i in all_layout_issues), + "handles_padding": not any(i['type'] == 'missing_padding_calc' for i in all_layout_issues), + "handles_overflow": not any(i['type'] == 'overflow' for i in all_layout_issues) + } + } + + return { + "layout_issues": all_layout_issues, + "lipgloss_improvements": lipgloss_improvements, + "code_fixes": all_code_fixes, + "summary": summary, + "user_description": description, + "files_analyzed": len(go_files), + "validation": validation + } + + +def _analyze_layout_issues(file_path: Path) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + """Analyze a single Go file for layout issues.""" + layout_issues = [] + code_fixes = [] + + try: + content = file_path.read_text() + except Exception as e: + return layout_issues, code_fixes + + lines = content.split('\n') + rel_path = file_path.name + + # Check if file uses lipgloss + uses_lipgloss = bool(re.search(r'"github\.com/charmbracelet/lipgloss"', content)) + + if not uses_lipgloss: + return layout_issues, code_fixes + + # Issue checks + issues, fixes = _check_hardcoded_dimensions(content, lines, rel_path) + layout_issues.extend(issues) + code_fixes.extend(fixes) + + issues, fixes = _check_incorrect_height_calculations(content, lines, rel_path) + layout_issues.extend(issues) + code_fixes.extend(fixes) + + issues, fixes = _check_missing_padding_accounting(content, lines, rel_path) + layout_issues.extend(issues) + code_fixes.extend(fixes) + + issues, fixes = _check_overflow_issues(content, lines, rel_path) + layout_issues.extend(issues) + code_fixes.extend(fixes) + + issues, fixes = _check_terminal_resize_handling(content, lines, rel_path) + layout_issues.extend(issues) + code_fixes.extend(fixes) + + issues, fixes = _check_border_accounting(content, lines, rel_path) + layout_issues.extend(issues) + code_fixes.extend(fixes) + + return layout_issues, code_fixes + + +def _check_hardcoded_dimensions(content: str, lines: List[str], file_path: str) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + """Check for hardcoded width/height values.""" + issues = [] + fixes = [] + + # Pattern: .Width(80), .Height(24), etc. + dimension_pattern = r'\.(Width|Height|MaxWidth|MaxHeight)\s*\(\s*(\d{2,})\s*\)' + + for i, line in enumerate(lines): + matches = re.finditer(dimension_pattern, line) + for match in matches: + dimension_type = match.group(1) + value = int(match.group(2)) + + # Likely a terminal dimension if >= 20 + if value >= 20: + issues.append({ + "severity": "WARNING", + "type": "hardcoded_dimensions", + "issue": f"Hardcoded {dimension_type}: {value}", + "location": f"{file_path}:{i+1}", + "current_code": line.strip(), + "explanation": f"Hardcoded {dimension_type} of {value} won't adapt to different terminal sizes", + "impact": "Layout breaks on smaller/larger terminals" + }) + + # Generate fix + if dimension_type in ["Width", "MaxWidth"]: + fixed_code = re.sub( + rf'\.{dimension_type}\s*\(\s*{value}\s*\)', + f'.{dimension_type}(m.termWidth)', + line.strip() + ) + else: # Height, MaxHeight + fixed_code = re.sub( + rf'\.{dimension_type}\s*\(\s*{value}\s*\)', + f'.{dimension_type}(m.termHeight)', + line.strip() + ) + + fixes.append({ + "location": f"{file_path}:{i+1}", + "original": line.strip(), + "fixed": fixed_code, + "explanation": f"Use dynamic terminal size from model (m.termWidth/m.termHeight)", + "requires": [ + "Add termWidth and termHeight fields to model", + "Handle tea.WindowSizeMsg in Update()" + ], + "code_example": '''// In model: +type model struct { + termWidth int + termHeight int +} + +// In Update(): +case tea.WindowSizeMsg: + m.termWidth = msg.Width + m.termHeight = msg.Height''' + }) + + return issues, fixes + + +def _check_incorrect_height_calculations(content: str, lines: List[str], file_path: str) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + """Check for manual height calculations instead of lipgloss.Height().""" + issues = [] + fixes = [] + + # Check View() function for manual calculations + view_start = -1 + for i, line in enumerate(lines): + if re.search(r'func\s+\([^)]+\)\s+View\s*\(', line): + view_start = i + break + + if view_start < 0: + return issues, fixes + + # Look for manual arithmetic like "height - 5", "24 - headerHeight" + manual_calc_pattern = r'(height|Height|termHeight)\s*[-+]\s*\d+' + + for i in range(view_start, min(view_start + 200, len(lines))): + if re.search(manual_calc_pattern, lines[i], re.IGNORECASE): + # Check if lipgloss.Height() is used in the vicinity + context = '\n'.join(lines[max(0, i-5):i+5]) + uses_lipgloss_height = bool(re.search(r'lipgloss\.Height\s*\(', context)) + + if not uses_lipgloss_height: + issues.append({ + "severity": "WARNING", + "type": "incorrect_height", + "issue": "Manual height calculation without lipgloss.Height()", + "location": f"{file_path}:{i+1}", + "current_code": lines[i].strip(), + "explanation": "Manual calculations don't account for actual rendered height", + "impact": "Incorrect spacing, overflow, or clipping" + }) + + # Generate fix + fixed_code = lines[i].strip().replace( + "height - ", "m.termHeight - lipgloss.Height(" + ).replace("termHeight - ", "m.termHeight - lipgloss.Height(") + + fixes.append({ + "location": f"{file_path}:{i+1}", + "original": lines[i].strip(), + "fixed": "Use lipgloss.Height() to get actual rendered height", + "explanation": "lipgloss.Height() accounts for padding, borders, margins", + "code_example": '''// ❌ BAD: +availableHeight := termHeight - 5 // Magic number! + +// ✅ GOOD: +headerHeight := lipgloss.Height(m.renderHeader()) +footerHeight := lipgloss.Height(m.renderFooter()) +availableHeight := m.termHeight - headerHeight - footerHeight''' + }) + + return issues, fixes + + +def _check_missing_padding_accounting(content: str, lines: List[str], file_path: str) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + """Check for nested styles without padding/margin accounting.""" + issues = [] + fixes = [] + + # Look for nested styles with padding + # Pattern: Style().Padding(X).Width(Y).Render(content) + nested_style_pattern = r'\.Padding\s*\([^)]+\).*\.Width\s*\(\s*(\w+)\s*\).*\.Render\s*\(' + + for i, line in enumerate(lines): + matches = re.finditer(nested_style_pattern, line) + for match in matches: + width_var = match.group(1) + + # Check if GetHorizontalPadding is used + context = '\n'.join(lines[max(0, i-10):min(i+10, len(lines))]) + uses_get_padding = bool(re.search(r'GetHorizontalPadding\s*\(\s*\)', context)) + + if not uses_get_padding and width_var != 'm.termWidth': + issues.append({ + "severity": "CRITICAL", + "type": "missing_padding_calc", + "issue": "Padding not accounted for in nested width calculation", + "location": f"{file_path}:{i+1}", + "current_code": line.strip(), + "explanation": "Setting Width() then Padding() makes content area smaller than expected", + "impact": "Content gets clipped or wrapped incorrectly" + }) + + fixes.append({ + "location": f"{file_path}:{i+1}", + "original": line.strip(), + "fixed": "Account for padding using GetHorizontalPadding()", + "explanation": "Padding reduces available content area", + "code_example": '''// ❌ BAD: +style := lipgloss.NewStyle(). + Padding(2). + Width(80). + Render(text) // Text area is 76, not 80! + +// ✅ GOOD: +style := lipgloss.NewStyle().Padding(2) +contentWidth := 80 - style.GetHorizontalPadding() +content := lipgloss.NewStyle().Width(contentWidth).Render(text) +result := style.Width(80).Render(content)''' + }) + + return issues, fixes + + +def _check_overflow_issues(content: str, lines: List[str], file_path: str) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + """Check for potential text overflow.""" + issues = [] + fixes = [] + + # Check for long strings without wrapping + has_wordwrap = bool(re.search(r'"github\.com/muesli/reflow/wordwrap"', content)) + has_wrap_or_truncate = bool(re.search(r'(wordwrap|truncate|Truncate)', content, re.IGNORECASE)) + + # Look for string rendering without width constraints + render_pattern = r'\.Render\s*\(\s*(\w+)\s*\)' + + for i, line in enumerate(lines): + matches = re.finditer(render_pattern, line) + for match in matches: + var_name = match.group(1) + + # Check if there's width control + has_width_control = bool(re.search(r'\.Width\s*\(', line)) + + if not has_width_control and not has_wrap_or_truncate and len(line) > 40: + issues.append({ + "severity": "WARNING", + "type": "overflow", + "issue": f"Rendering '{var_name}' without width constraint", + "location": f"{file_path}:{i+1}", + "current_code": line.strip(), + "explanation": "Long content can exceed terminal width", + "impact": "Text wraps unexpectedly or overflows" + }) + + fixes.append({ + "location": f"{file_path}:{i+1}", + "original": line.strip(), + "fixed": "Add wordwrap or width constraint", + "explanation": "Constrain content to terminal width", + "code_example": '''// Option 1: Use wordwrap +import "github.com/muesli/reflow/wordwrap" + +content := wordwrap.String(longText, m.termWidth) + +// Option 2: Use lipgloss Width + truncate +style := lipgloss.NewStyle().Width(m.termWidth) +content := style.Render(longText) + +// Option 3: Manual truncate +import "github.com/muesli/reflow/truncate" + +content := truncate.StringWithTail(longText, uint(m.termWidth), "...")''' + }) + + return issues, fixes + + +def _check_terminal_resize_handling(content: str, lines: List[str], file_path: str) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + """Check for proper terminal resize handling.""" + issues = [] + fixes = [] + + # Check if WindowSizeMsg is handled + handles_resize = bool(re.search(r'case\s+tea\.WindowSizeMsg:', content)) + + # Check if model stores term dimensions + has_term_fields = bool(re.search(r'(termWidth|termHeight|width|height)\s+int', content)) + + if not handles_resize and uses_lipgloss(content): + issues.append({ + "severity": "CRITICAL", + "type": "missing_resize_handling", + "issue": "No tea.WindowSizeMsg handling detected", + "location": file_path, + "explanation": "Layout won't adapt when terminal is resized", + "impact": "Content clipped or misaligned after resize" + }) + + fixes.append({ + "location": file_path, + "original": "N/A", + "fixed": "Add WindowSizeMsg handler", + "explanation": "Store terminal dimensions and update on resize", + "code_example": '''// In model: +type model struct { + termWidth int + termHeight int +} + +// In Update(): +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.termWidth = msg.Width + m.termHeight = msg.Height + + // Update child components with new size + m.viewport.Width = msg.Width + m.viewport.Height = msg.Height - 2 // Leave room for header + } + return m, nil +} + +// In View(): +func (m model) View() string { + // Use m.termWidth and m.termHeight for dynamic layout + content := lipgloss.NewStyle(). + Width(m.termWidth). + Height(m.termHeight). + Render(m.content) + return content +}''' + }) + + elif handles_resize and not has_term_fields: + issues.append({ + "severity": "WARNING", + "type": "resize_not_stored", + "issue": "WindowSizeMsg handled but dimensions not stored", + "location": file_path, + "explanation": "Handling resize but not storing dimensions for later use", + "impact": "Can't use current terminal size in View()" + }) + + return issues, fixes + + +def _check_border_accounting(content: str, lines: List[str], file_path: str) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + """Check for border accounting in layout calculations.""" + issues = [] + fixes = [] + + # Check for borders without proper accounting + has_border = bool(re.search(r'\.Border\s*\(', content)) + has_border_width_calc = bool(re.search(r'GetHorizontalBorderSize|GetVerticalBorderSize', content)) + + if has_border and not has_border_width_calc: + # Find border usage lines + for i, line in enumerate(lines): + if '.Border(' in line: + issues.append({ + "severity": "WARNING", + "type": "missing_border_calc", + "issue": "Border used without accounting for border size", + "location": f"{file_path}:{i+1}", + "current_code": line.strip(), + "explanation": "Borders take space (2 chars horizontal, 2 chars vertical)", + "impact": "Content area smaller than expected" + }) + + fixes.append({ + "location": f"{file_path}:{i+1}", + "original": line.strip(), + "fixed": "Account for border size", + "explanation": "Use GetHorizontalBorderSize() and GetVerticalBorderSize()", + "code_example": '''// With border: +style := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + Width(80) + +// Calculate content area: +contentWidth := 80 - style.GetHorizontalBorderSize() +contentHeight := 24 - style.GetVerticalBorderSize() + +// Use for inner content: +innerContent := lipgloss.NewStyle(). + Width(contentWidth). + Height(contentHeight). + Render(text) + +result := style.Render(innerContent)''' + }) + + return issues, fixes + + +def uses_lipgloss(content: str) -> bool: + """Check if file uses lipgloss.""" + return bool(re.search(r'"github\.com/charmbracelet/lipgloss"', content)) + + +def _generate_improvements(issues: List[Dict[str, Any]]) -> List[str]: + """Generate general improvement recommendations.""" + improvements = [] + + issue_types = set(issue['type'] for issue in issues) + + if 'hardcoded_dimensions' in issue_types: + improvements.append( + "🎯 Use dynamic terminal sizing: Store termWidth/termHeight in model, update from tea.WindowSizeMsg" + ) + + if 'incorrect_height' in issue_types: + improvements.append( + "📏 Use lipgloss.Height() and lipgloss.Width() for accurate measurements" + ) + + if 'missing_padding_calc' in issue_types: + improvements.append( + "📐 Account for padding with GetHorizontalPadding() and GetVerticalPadding()" + ) + + if 'overflow' in issue_types: + improvements.append( + "📝 Use wordwrap or truncate to prevent text overflow" + ) + + if 'missing_resize_handling' in issue_types: + improvements.append( + "🔄 Handle tea.WindowSizeMsg to support terminal resizing" + ) + + if 'missing_border_calc' in issue_types: + improvements.append( + "🔲 Account for borders with GetHorizontalBorderSize() and GetVerticalBorderSize()" + ) + + # General best practices + improvements.extend([ + "✨ Test your TUI at various terminal sizes (80x24, 120x40, 200x50)", + "🔍 Use lipgloss debugging: Print style.String() to see computed dimensions", + "📦 Cache computed styles in model to avoid recreation on every render", + "🎨 Use PlaceHorizontal/PlaceVertical for alignment instead of manual padding" + ]) + + return improvements + + +def validate_layout_fixes(result: Dict[str, Any]) -> Dict[str, Any]: + """Validate layout fixes result.""" + if 'error' in result: + return {"status": "error", "summary": result['error']} + + validation = result.get('validation', {}) + status = validation.get('status', 'unknown') + summary = validation.get('summary', 'Layout analysis complete') + + checks = [ + (result.get('layout_issues') is not None, "Has issues list"), + (result.get('lipgloss_improvements') is not None, "Has improvements"), + (result.get('code_fixes') is not None, "Has code fixes"), + ] + + all_pass = all(check[0] for check in checks) + + return { + "status": status, + "summary": summary, + "checks": {check[1]: check[0] for check in checks}, + "valid": all_pass + } + + +if __name__ == "__main__": + import sys + + if len(sys.argv) < 2: + print("Usage: fix_layout_issues.py [description]") + sys.exit(1) + + code_path = sys.argv[1] + description = sys.argv[2] if len(sys.argv) > 2 else "" + + result = fix_layout_issues(code_path, description) + print(json.dumps(result, indent=2)) diff --git a/.crush/skills/bubbletea-maintenance/scripts/suggest_architecture.py b/.crush/skills/bubbletea-maintenance/scripts/suggest_architecture.py new file mode 100644 index 00000000..b5576f5d --- /dev/null +++ b/.crush/skills/bubbletea-maintenance/scripts/suggest_architecture.py @@ -0,0 +1,736 @@ +#!/usr/bin/env python3 +""" +Suggest architectural improvements for Bubble Tea applications. +Analyzes complexity and recommends patterns like model trees, composable views, etc. +""" + +import os +import re +import json +from pathlib import Path +from typing import Dict, List, Any, Tuple, Optional + + +def suggest_architecture(code_path: str, complexity_level: str = "auto") -> Dict[str, Any]: + """ + Analyze code and suggest architectural improvements. + + Args: + code_path: Path to Go file or directory + complexity_level: "auto" (detect), "simple", "medium", "complex" + + Returns: + Dictionary containing: + - current_pattern: Detected architectural pattern + - complexity_score: 0-100 (higher = more complex) + - recommended_pattern: Suggested pattern for improvement + - refactoring_steps: List of steps to implement + - code_templates: Example code for new pattern + - validation: Validation report + """ + path = Path(code_path) + + if not path.exists(): + return { + "error": f"Path not found: {code_path}", + "validation": {"status": "error", "summary": "Invalid path"} + } + + # Collect all .go files + go_files = [] + if path.is_file(): + if path.suffix == '.go': + go_files = [path] + else: + go_files = list(path.glob('**/*.go')) + + if not go_files: + return { + "error": "No .go files found", + "validation": {"status": "error", "summary": "No Go files"} + } + + # Read all code + all_content = "" + for go_file in go_files: + try: + all_content += go_file.read_text() + "\n" + except Exception: + pass + + # Analyze current architecture + current_pattern = _detect_current_pattern(all_content) + complexity_score = _calculate_complexity(all_content, go_files) + + # Auto-detect complexity level if needed + if complexity_level == "auto": + if complexity_score < 30: + complexity_level = "simple" + elif complexity_score < 70: + complexity_level = "medium" + else: + complexity_level = "complex" + + # Generate recommendations + recommended_pattern = _recommend_pattern(current_pattern, complexity_score, complexity_level) + refactoring_steps = _generate_refactoring_steps(current_pattern, recommended_pattern, all_content) + code_templates = _generate_code_templates(recommended_pattern, all_content) + + # Summary + if recommended_pattern == current_pattern: + summary = f"✅ Current architecture ({current_pattern}) is appropriate for complexity level" + else: + summary = f"💡 Recommend refactoring from {current_pattern} to {recommended_pattern}" + + # Validation + validation = { + "status": "pass" if recommended_pattern == current_pattern else "info", + "summary": summary, + "checks": { + "complexity_analyzed": complexity_score >= 0, + "pattern_detected": current_pattern != "unknown", + "has_recommendations": len(refactoring_steps) > 0, + "has_templates": len(code_templates) > 0 + } + } + + return { + "current_pattern": current_pattern, + "complexity_score": complexity_score, + "complexity_level": complexity_level, + "recommended_pattern": recommended_pattern, + "refactoring_steps": refactoring_steps, + "code_templates": code_templates, + "summary": summary, + "analysis": { + "files_analyzed": len(go_files), + "model_count": _count_models(all_content), + "view_functions": _count_view_functions(all_content), + "state_fields": _count_state_fields(all_content) + }, + "validation": validation + } + + +def _detect_current_pattern(content: str) -> str: + """Detect the current architectural pattern.""" + + # Check for various patterns + patterns_detected = [] + + # Pattern 1: Flat Model (single model struct, no child models) + has_model = bool(re.search(r'type\s+\w*[Mm]odel\s+struct', content)) + has_child_models = bool(re.search(r'\w+Model\s+\w+Model', content)) + + if has_model and not has_child_models: + patterns_detected.append("flat_model") + + # Pattern 2: Model Tree (parent model with child models) + if has_child_models: + patterns_detected.append("model_tree") + + # Pattern 3: Multi-view (multiple view rendering based on state) + has_view_switcher = bool(re.search(r'switch\s+m\.\w*(view|mode|screen|state)', content, re.IGNORECASE)) + if has_view_switcher: + patterns_detected.append("multi_view") + + # Pattern 4: Component-based (using Bubble Tea components like list, viewport, etc.) + bubbletea_components = [ + 'list.Model', + 'viewport.Model', + 'textinput.Model', + 'textarea.Model', + 'table.Model', + 'progress.Model', + 'spinner.Model' + ] + component_count = sum(1 for comp in bubbletea_components if comp in content) + + if component_count >= 3: + patterns_detected.append("component_based") + elif component_count >= 1: + patterns_detected.append("uses_components") + + # Pattern 5: State Machine (explicit state enums/constants) + has_state_enum = bool(re.search(r'type\s+\w*State\s+(int|string)', content)) + has_iota_states = bool(re.search(r'const\s+\(\s*\w+State\s+\w*State\s+=\s+iota', content)) + + if has_state_enum or has_iota_states: + patterns_detected.append("state_machine") + + # Pattern 6: Event-driven (heavy use of custom messages) + custom_msg_count = len(re.findall(r'type\s+\w+Msg\s+struct', content)) + if custom_msg_count >= 5: + patterns_detected.append("event_driven") + + # Return the most dominant pattern + if "model_tree" in patterns_detected: + return "model_tree" + elif "state_machine" in patterns_detected and "multi_view" in patterns_detected: + return "state_machine_multi_view" + elif "component_based" in patterns_detected: + return "component_based" + elif "multi_view" in patterns_detected: + return "multi_view" + elif "flat_model" in patterns_detected: + return "flat_model" + elif has_model: + return "basic_model" + else: + return "unknown" + + +def _calculate_complexity(content: str, files: List[Path]) -> int: + """Calculate complexity score (0-100).""" + + score = 0 + + # Factor 1: Number of files (10 points max) + file_count = len(files) + score += min(10, file_count * 2) + + # Factor 2: Model field count (20 points max) + model_match = re.search(r'type\s+(\w*[Mm]odel)\s+struct\s*\{([^}]+)\}', content, re.DOTALL) + if model_match: + model_body = model_match.group(2) + field_count = len([line for line in model_body.split('\n') + if line.strip() and not line.strip().startswith('//')]) + score += min(20, field_count) + + # Factor 3: Number of Update() branches (20 points max) + update_match = re.search(r'func\s+\([^)]+\)\s+Update\s*\([^)]+\)\s*\([^)]+\)\s*\{(.+?)^func\s', + content, re.DOTALL | re.MULTILINE) + if update_match: + update_body = update_match.group(1) + case_count = len(re.findall(r'case\s+', update_body)) + score += min(20, case_count * 2) + + # Factor 4: View() complexity (15 points max) + view_match = re.search(r'func\s+\([^)]+\)\s+View\s*\(\s*\)\s+string\s*\{(.+?)^func\s', + content, re.DOTALL | re.MULTILINE) + if view_match: + view_body = view_match.group(1) + view_lines = len(view_body.split('\n')) + score += min(15, view_lines // 2) + + # Factor 5: Custom message types (10 points max) + custom_msg_count = len(re.findall(r'type\s+\w+Msg\s+struct', content)) + score += min(10, custom_msg_count * 2) + + # Factor 6: Number of views/screens (15 points max) + view_count = len(re.findall(r'func\s+\([^)]+\)\s+render\w+', content, re.IGNORECASE)) + score += min(15, view_count * 3) + + # Factor 7: Use of channels/goroutines (10 points max) + has_channels = len(re.findall(r'make\s*\(\s*chan\s+', content)) + has_goroutines = len(re.findall(r'\bgo\s+func', content)) + score += min(10, (has_channels + has_goroutines) * 2) + + return min(100, score) + + +def _recommend_pattern(current: str, complexity: int, level: str) -> str: + """Recommend architectural pattern based on current state and complexity.""" + + # Simple apps (< 30 complexity) + if complexity < 30: + if current in ["unknown", "basic_model"]: + return "flat_model" # Simple flat model is fine + return current # Keep current pattern + + # Medium complexity (30-70) + elif complexity < 70: + if current == "flat_model": + return "multi_view" # Evolve to multi-view + elif current == "basic_model": + return "component_based" # Start using components + return current + + # High complexity (70+) + else: + if current in ["flat_model", "multi_view"]: + return "model_tree" # Need hierarchy + elif current == "component_based": + return "model_tree_with_components" # Combine patterns + return current + + +def _count_models(content: str) -> int: + """Count model structs.""" + return len(re.findall(r'type\s+\w*[Mm]odel\s+struct', content)) + + +def _count_view_functions(content: str) -> int: + """Count view rendering functions.""" + return len(re.findall(r'func\s+\([^)]+\)\s+(View|render\w+)', content, re.IGNORECASE)) + + +def _count_state_fields(content: str) -> int: + """Count state fields in model.""" + model_match = re.search(r'type\s+(\w*[Mm]odel)\s+struct\s*\{([^}]+)\}', content, re.DOTALL) + if not model_match: + return 0 + + model_body = model_match.group(2) + return len([line for line in model_body.split('\n') + if line.strip() and not line.strip().startswith('//')]) + + +def _generate_refactoring_steps(current: str, recommended: str, content: str) -> List[str]: + """Generate step-by-step refactoring guide.""" + + if current == recommended: + return ["No refactoring needed - current architecture is appropriate"] + + steps = [] + + # Flat Model → Multi-view + if current == "flat_model" and recommended == "multi_view": + steps = [ + "1. Add view state enum to model", + "2. Create separate render functions for each view", + "3. Add view switching logic in Update()", + "4. Implement switch statement in View() to route to render functions", + "5. Add keyboard shortcuts for view navigation" + ] + + # Flat Model → Model Tree + elif current == "flat_model" and recommended == "model_tree": + steps = [ + "1. Identify logical groupings of fields in current model", + "2. Create child model structs for each grouping", + "3. Add Init() methods to child models", + "4. Create parent model with child model fields", + "5. Implement message routing in parent's Update()", + "6. Delegate rendering to child models in View()", + "7. Test each child model independently" + ] + + # Multi-view → Model Tree + elif current == "multi_view" and recommended == "model_tree": + steps = [ + "1. Convert each view into a separate child model", + "2. Extract view-specific state into child models", + "3. Create parent router model with activeView field", + "4. Implement message routing based on activeView", + "5. Move view rendering logic into child models", + "6. Add inter-model communication via custom messages" + ] + + # Component-based → Model Tree with Components + elif current == "component_based" and recommended == "model_tree_with_components": + steps = [ + "1. Group related components into logical views", + "2. Create view models that own related components", + "3. Create parent model to manage view models", + "4. Implement message routing to active view", + "5. Keep component updates within their view models", + "6. Compose final view from view model renders" + ] + + # Basic Model → Component-based + elif current == "basic_model" and recommended == "component_based": + steps = [ + "1. Identify UI patterns that match Bubble Tea components", + "2. Replace custom text input with textinput.Model", + "3. Replace custom list with list.Model", + "4. Replace custom scrolling with viewport.Model", + "5. Update Init() to initialize components", + "6. Route messages to components in Update()", + "7. Compose View() using component.View() calls" + ] + + # Generic fallback + else: + steps = [ + f"1. Analyze current {current} pattern", + f"2. Study {recommended} pattern examples", + "3. Plan gradual migration strategy", + "4. Implement incrementally with tests", + "5. Validate each step before proceeding" + ] + + return steps + + +def _generate_code_templates(pattern: str, existing_code: str) -> Dict[str, str]: + """Generate code templates for recommended pattern.""" + + templates = {} + + if pattern == "model_tree": + templates["parent_model"] = '''// Parent model manages child models +type appModel struct { + activeView int + + // Child models + listView listViewModel + detailView detailViewModel + searchView searchViewModel +} + +func (m appModel) Init() tea.Cmd { + return tea.Batch( + m.listView.Init(), + m.detailView.Init(), + m.searchView.Init(), + ) +} + +func (m appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + // Global navigation + if key, ok := msg.(tea.KeyMsg); ok { + switch key.String() { + case "1": + m.activeView = 0 + return m, nil + case "2": + m.activeView = 1 + return m, nil + case "3": + m.activeView = 2 + return m, nil + } + } + + // Route to active child + switch m.activeView { + case 0: + m.listView, cmd = m.listView.Update(msg) + case 1: + m.detailView, cmd = m.detailView.Update(msg) + case 2: + m.searchView, cmd = m.searchView.Update(msg) + } + + return m, cmd +} + +func (m appModel) View() string { + switch m.activeView { + case 0: + return m.listView.View() + case 1: + return m.detailView.View() + case 2: + return m.searchView.View() + } + return "" +}''' + + templates["child_model"] = '''// Child model handles its own state and rendering +type listViewModel struct { + items []string + cursor int + selected map[int]bool +} + +func (m listViewModel) Init() tea.Cmd { + return nil +} + +func (m listViewModel) Update(msg tea.Msg) (listViewModel, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(m.items)-1 { + m.cursor++ + } + case " ": + m.selected[m.cursor] = !m.selected[m.cursor] + } + } + return m, nil +} + +func (m listViewModel) View() string { + s := "Select items:\\n\\n" + for i, item := range m.items { + cursor := " " + if m.cursor == i { + cursor = ">" + } + checked := " " + if m.selected[i] { + checked = "x" + } + s += fmt.Sprintf("%s [%s] %s\\n", cursor, checked, item) + } + return s +}''' + + templates["message_passing"] = '''// Custom message for inter-model communication +type itemSelectedMsg struct { + itemID string +} + +// In listViewModel: +func (m listViewModel) Update(msg tea.Msg) (listViewModel, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "enter" { + // Send message to parent (who routes to detail view) + return m, func() tea.Msg { + return itemSelectedMsg{itemID: m.items[m.cursor]} + } + } + } + return m, nil +} + +// In appModel: +func (m appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case itemSelectedMsg: + // List selected item, switch to detail view + m.detailView.LoadItem(msg.itemID) + m.activeView = 1 // Switch to detail + return m, nil + } + + // Route to children... + return m, nil +}''' + + elif pattern == "multi_view": + templates["view_state"] = '''type viewState int + +const ( + listView viewState = iota + detailView + searchView +) + +type model struct { + currentView viewState + + // View-specific state + listItems []string + listCursor int + detailItem string + searchQuery string +}''' + + templates["view_switching"] = '''func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + // Global navigation + switch msg.String() { + case "1": + m.currentView = listView + return m, nil + case "2": + m.currentView = detailView + return m, nil + case "3": + m.currentView = searchView + return m, nil + } + + // View-specific handling + switch m.currentView { + case listView: + return m.updateListView(msg) + case detailView: + return m.updateDetailView(msg) + case searchView: + return m.updateSearchView(msg) + } + } + return m, nil +} + +func (m model) View() string { + switch m.currentView { + case listView: + return m.renderListView() + case detailView: + return m.renderDetailView() + case searchView: + return m.renderSearchView() + } + return "" +}''' + + elif pattern == "component_based": + templates["using_components"] = '''import ( + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" +) + +type model struct { + list list.Model + search textinput.Model + viewer viewport.Model + activeComponent int +} + +func initialModel() model { + // Initialize components + items := []list.Item{ + item{title: "Item 1", desc: "Description"}, + item{title: "Item 2", desc: "Description"}, + } + + l := list.New(items, list.NewDefaultDelegate(), 20, 10) + l.Title = "Items" + + ti := textinput.New() + ti.Placeholder = "Search..." + ti.Focus() + + vp := viewport.New(80, 20) + + return model{ + list: l, + search: ti, + viewer: vp, + activeComponent: 0, + } +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + // Route to active component + switch m.activeComponent { + case 0: + m.list, cmd = m.list.Update(msg) + case 1: + m.search, cmd = m.search.Update(msg) + case 2: + m.viewer, cmd = m.viewer.Update(msg) + } + + return m, cmd +} + +func (m model) View() string { + return lipgloss.JoinVertical( + lipgloss.Left, + m.search.View(), + m.list.View(), + m.viewer.View(), + ) +}''' + + elif pattern == "state_machine_multi_view": + templates["state_machine"] = '''type appState int + +const ( + loadingState appState = iota + listState + detailState + errorState +) + +type model struct { + state appState + prevState appState + + // State data + items []string + selected string + error error +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case itemsLoadedMsg: + m.items = msg.items + m.state = listState + return m, nil + + case itemSelectedMsg: + m.selected = msg.item + m.state = detailState + return m, loadItemDetails + + case errorMsg: + m.prevState = m.state + m.state = errorState + m.error = msg.err + return m, nil + + case tea.KeyMsg: + if msg.String() == "esc" && m.state == errorState { + m.state = m.prevState // Return to previous state + return m, nil + } + } + + // State-specific update + switch m.state { + case listState: + return m.updateList(msg) + case detailState: + return m.updateDetail(msg) + } + + return m, nil +} + +func (m model) View() string { + switch m.state { + case loadingState: + return "Loading..." + case listState: + return m.renderList() + case detailState: + return m.renderDetail() + case errorState: + return fmt.Sprintf("Error: %v\\nPress ESC to continue", m.error) + } + return "" +}''' + + return templates + + +def validate_architecture_suggestion(result: Dict[str, Any]) -> Dict[str, Any]: + """Validate architecture suggestion result.""" + if 'error' in result: + return {"status": "error", "summary": result['error']} + + validation = result.get('validation', {}) + status = validation.get('status', 'unknown') + summary = validation.get('summary', 'Architecture analysis complete') + + checks = [ + (result.get('current_pattern') is not None, "Pattern detected"), + (result.get('complexity_score') is not None, "Complexity calculated"), + (result.get('recommended_pattern') is not None, "Recommendation generated"), + (len(result.get('refactoring_steps', [])) > 0, "Has refactoring steps"), + ] + + all_pass = all(check[0] for check in checks) + + return { + "status": status, + "summary": summary, + "checks": {check[1]: check[0] for check in checks}, + "valid": all_pass + } + + +if __name__ == "__main__": + import sys + + if len(sys.argv) < 2: + print("Usage: suggest_architecture.py [complexity_level]") + sys.exit(1) + + code_path = sys.argv[1] + complexity_level = sys.argv[2] if len(sys.argv) > 2 else "auto" + + result = suggest_architecture(code_path, complexity_level) + print(json.dumps(result, indent=2)) diff --git a/.crush/skills/bubbletea-maintenance/scripts/utils/__init__.py b/.crush/skills/bubbletea-maintenance/scripts/utils/__init__.py new file mode 100644 index 00000000..72f2e1c7 --- /dev/null +++ b/.crush/skills/bubbletea-maintenance/scripts/utils/__init__.py @@ -0,0 +1 @@ +# Utility modules for Bubble Tea maintenance agent diff --git a/.crush/skills/bubbletea-maintenance/scripts/utils/go_parser.py b/.crush/skills/bubbletea-maintenance/scripts/utils/go_parser.py new file mode 100644 index 00000000..44342bd0 --- /dev/null +++ b/.crush/skills/bubbletea-maintenance/scripts/utils/go_parser.py @@ -0,0 +1,328 @@ +#!/usr/bin/env python3 +""" +Go code parser utilities for Bubble Tea maintenance agent. +Extracts models, functions, types, and code structure. +""" + +import re +from typing import Dict, List, Tuple, Optional +from pathlib import Path + + +def extract_model_struct(content: str) -> Optional[Dict[str, any]]: + """Extract the main model struct from Go code.""" + + # Pattern: type XxxModel struct { ... } + pattern = r'type\s+(\w*[Mm]odel)\s+struct\s*\{([^}]+)\}' + match = re.search(pattern, content, re.DOTALL) + + if not match: + return None + + model_name = match.group(1) + model_body = match.group(2) + + # Parse fields + fields = [] + for line in model_body.split('\n'): + line = line.strip() + if not line or line.startswith('//'): + continue + + # Parse field: name type [tag] + field_match = re.match(r'(\w+)\s+([^\s`]+)(?:\s+`([^`]+)`)?', line) + if field_match: + fields.append({ + "name": field_match.group(1), + "type": field_match.group(2), + "tag": field_match.group(3) if field_match.group(3) else None + }) + + return { + "name": model_name, + "fields": fields, + "field_count": len(fields), + "raw_body": model_body + } + + +def extract_update_function(content: str) -> Optional[Dict[str, any]]: + """Extract the Update() function.""" + + # Find Update function + pattern = r'func\s+\((\w+)\s+(\*?)(\w+)\)\s+Update\s*\([^)]*\)\s*\([^)]*\)\s*\{(.+?)(?=\nfunc\s|\Z)' + match = re.search(pattern, content, re.DOTALL | re.MULTILINE) + + if not match: + return None + + receiver_name = match.group(1) + is_pointer = match.group(2) == '*' + receiver_type = match.group(3) + function_body = match.group(4) + + # Count cases in switch statements + case_count = len(re.findall(r'\bcase\s+', function_body)) + + # Find message types handled + handled_messages = re.findall(r'case\s+(\w+\.?\w*):', function_body) + + return { + "receiver_name": receiver_name, + "receiver_type": receiver_type, + "is_pointer_receiver": is_pointer, + "body_lines": len(function_body.split('\n')), + "case_count": case_count, + "handled_messages": list(set(handled_messages)), + "raw_body": function_body + } + + +def extract_view_function(content: str) -> Optional[Dict[str, any]]: + """Extract the View() function.""" + + pattern = r'func\s+\((\w+)\s+(\*?)(\w+)\)\s+View\s*\(\s*\)\s+string\s*\{(.+?)(?=\nfunc\s|\Z)' + match = re.search(pattern, content, re.DOTALL | re.MULTILINE) + + if not match: + return None + + receiver_name = match.group(1) + is_pointer = match.group(2) == '*' + receiver_type = match.group(3) + function_body = match.group(4) + + # Analyze complexity + string_concat_count = len(re.findall(r'\+\s*"', function_body)) + lipgloss_calls = len(re.findall(r'lipgloss\.', function_body)) + + return { + "receiver_name": receiver_name, + "receiver_type": receiver_type, + "is_pointer_receiver": is_pointer, + "body_lines": len(function_body.split('\n')), + "string_concatenations": string_concat_count, + "lipgloss_calls": lipgloss_calls, + "raw_body": function_body + } + + +def extract_init_function(content: str) -> Optional[Dict[str, any]]: + """Extract the Init() function.""" + + pattern = r'func\s+\((\w+)\s+(\*?)(\w+)\)\s+Init\s*\(\s*\)\s+tea\.Cmd\s*\{(.+?)(?=\nfunc\s|\Z)' + match = re.search(pattern, content, re.DOTALL | re.MULTILINE) + + if not match: + return None + + receiver_name = match.group(1) + is_pointer = match.group(2) == '*' + receiver_type = match.group(3) + function_body = match.group(4) + + return { + "receiver_name": receiver_name, + "receiver_type": receiver_type, + "is_pointer_receiver": is_pointer, + "body_lines": len(function_body.split('\n')), + "raw_body": function_body + } + + +def extract_custom_messages(content: str) -> List[Dict[str, any]]: + """Extract custom message type definitions.""" + + # Pattern: type xxxMsg struct { ... } + pattern = r'type\s+(\w+Msg)\s+struct\s*\{([^}]*)\}' + matches = re.finditer(pattern, content, re.DOTALL) + + messages = [] + for match in matches: + msg_name = match.group(1) + msg_body = match.group(2) + + # Parse fields + fields = [] + for line in msg_body.split('\n'): + line = line.strip() + if not line or line.startswith('//'): + continue + + field_match = re.match(r'(\w+)\s+([^\s]+)', line) + if field_match: + fields.append({ + "name": field_match.group(1), + "type": field_match.group(2) + }) + + messages.append({ + "name": msg_name, + "fields": fields, + "field_count": len(fields) + }) + + return messages + + +def extract_tea_commands(content: str) -> List[Dict[str, any]]: + """Extract tea.Cmd functions.""" + + # Pattern: func xxxCmd() tea.Msg { ... } + pattern = r'func\s+(\w+)\s*\(\s*\)\s+tea\.Msg\s*\{(.+?)^\}' + matches = re.finditer(pattern, content, re.DOTALL | re.MULTILINE) + + commands = [] + for match in matches: + cmd_name = match.group(1) + cmd_body = match.group(2) + + # Check for blocking operations + has_http = bool(re.search(r'\bhttp\.(Get|Post|Do)', cmd_body)) + has_sleep = bool(re.search(r'time\.Sleep', cmd_body)) + has_io = bool(re.search(r'\bos\.(Open|Read|Write)', cmd_body)) + + commands.append({ + "name": cmd_name, + "body_lines": len(cmd_body.split('\n')), + "has_http": has_http, + "has_sleep": has_sleep, + "has_io": has_io, + "is_blocking": has_http or has_io # sleep is expected in commands + }) + + return commands + + +def extract_imports(content: str) -> List[str]: + """Extract import statements.""" + + imports = [] + + # Single import + single_pattern = r'import\s+"([^"]+)"' + imports.extend(re.findall(single_pattern, content)) + + # Multi-line import block + block_pattern = r'import\s+\(([^)]+)\)' + block_matches = re.finditer(block_pattern, content, re.DOTALL) + for match in block_matches: + block_content = match.group(1) + # Extract quoted imports + quoted = re.findall(r'"([^"]+)"', block_content) + imports.extend(quoted) + + return list(set(imports)) + + +def find_bubbletea_components(content: str) -> List[Dict[str, any]]: + """Find usage of Bubble Tea components (list, viewport, etc.).""" + + components = [] + + component_patterns = { + "list": r'list\.Model', + "viewport": r'viewport\.Model', + "textinput": r'textinput\.Model', + "textarea": r'textarea\.Model', + "table": r'table\.Model', + "progress": r'progress\.Model', + "spinner": r'spinner\.Model', + "timer": r'timer\.Model', + "stopwatch": r'stopwatch\.Model', + "filepicker": r'filepicker\.Model', + "paginator": r'paginator\.Model', + } + + for comp_name, pattern in component_patterns.items(): + if re.search(pattern, content): + # Count occurrences + count = len(re.findall(pattern, content)) + components.append({ + "component": comp_name, + "occurrences": count + }) + + return components + + +def analyze_code_structure(file_path: Path) -> Dict[str, any]: + """Comprehensive code structure analysis.""" + + try: + content = file_path.read_text() + except Exception as e: + return {"error": str(e)} + + return { + "model": extract_model_struct(content), + "update": extract_update_function(content), + "view": extract_view_function(content), + "init": extract_init_function(content), + "custom_messages": extract_custom_messages(content), + "tea_commands": extract_tea_commands(content), + "imports": extract_imports(content), + "components": find_bubbletea_components(content), + "file_size": len(content), + "line_count": len(content.split('\n')), + "uses_lipgloss": '"github.com/charmbracelet/lipgloss"' in content, + "uses_bubbletea": '"github.com/charmbracelet/bubbletea"' in content + } + + +def find_function_by_name(content: str, func_name: str) -> Optional[str]: + """Find a specific function by name and return its body.""" + + pattern = rf'func\s+(?:\([^)]+\)\s+)?{func_name}\s*\([^)]*\)[^{{]*\{{(.+?)(?=\nfunc\s|\Z)' + match = re.search(pattern, content, re.DOTALL | re.MULTILINE) + + if match: + return match.group(1) + return None + + +def extract_state_machine_states(content: str) -> Optional[Dict[str, any]]: + """Extract state machine enum if present.""" + + # Pattern: type xxxState int; const ( state1 state2 = iota ... ) + state_type_pattern = r'type\s+(\w+State)\s+(int|string)' + state_type_match = re.search(state_type_pattern, content) + + if not state_type_match: + return None + + state_type = state_type_match.group(1) + + # Find const block with iota + const_pattern = rf'const\s+\(([^)]+)\)' + const_matches = re.finditer(const_pattern, content, re.DOTALL) + + states = [] + for const_match in const_matches: + const_body = const_match.group(1) + if state_type in const_body and 'iota' in const_body: + # Extract state names + state_names = re.findall(rf'(\w+)\s+{state_type}', const_body) + states = state_names + break + + return { + "type": state_type, + "states": states, + "count": len(states) + } + + +# Example usage and testing +if __name__ == "__main__": + import sys + + if len(sys.argv) < 2: + print("Usage: go_parser.py ") + sys.exit(1) + + file_path = Path(sys.argv[1]) + result = analyze_code_structure(file_path) + + import json + print(json.dumps(result, indent=2)) diff --git a/.crush/skills/bubbletea-maintenance/scripts/utils/validators/__init__.py b/.crush/skills/bubbletea-maintenance/scripts/utils/validators/__init__.py new file mode 100644 index 00000000..19d18f39 --- /dev/null +++ b/.crush/skills/bubbletea-maintenance/scripts/utils/validators/__init__.py @@ -0,0 +1 @@ +# Validator modules for Bubble Tea maintenance agent diff --git a/.crush/skills/bubbletea-maintenance/scripts/utils/validators/common.py b/.crush/skills/bubbletea-maintenance/scripts/utils/validators/common.py new file mode 100644 index 00000000..3a6c2fcb --- /dev/null +++ b/.crush/skills/bubbletea-maintenance/scripts/utils/validators/common.py @@ -0,0 +1,349 @@ +#!/usr/bin/env python3 +""" +Common validation utilities for Bubble Tea maintenance agent. +""" + +from typing import Dict, List, Any, Optional + + +def validate_result_structure(result: Dict[str, Any], required_keys: List[str]) -> Dict[str, Any]: + """ + Validate that a result dictionary has required keys. + + Args: + result: Result dictionary to validate + required_keys: List of required key names + + Returns: + Validation dict with status, summary, and checks + """ + if 'error' in result: + return { + "status": "error", + "summary": result['error'], + "valid": False + } + + checks = {} + for key in required_keys: + checks[f"has_{key}"] = key in result and result[key] is not None + + all_pass = all(checks.values()) + + status = "pass" if all_pass else "fail" + summary = "Validation passed" if all_pass else f"Missing required keys: {[k for k, v in checks.items() if not v]}" + + return { + "status": status, + "summary": summary, + "checks": checks, + "valid": all_pass + } + + +def validate_issue_list(issues: List[Dict[str, Any]]) -> Dict[str, Any]: + """ + Validate a list of issues has proper structure. + + Expected issue structure: + - severity: CRITICAL, HIGH, WARNING, or INFO + - category: performance, layout, reliability, etc. + - issue: Description + - location: File path and line number + - explanation: Why it's a problem + - fix: How to fix it + """ + if not isinstance(issues, list): + return { + "status": "error", + "summary": "Issues must be a list", + "valid": False + } + + required_fields = ["severity", "issue", "location", "explanation"] + valid_severities = ["CRITICAL", "HIGH", "MEDIUM", "WARNING", "LOW", "INFO"] + + checks = { + "is_list": True, + "all_have_severity": True, + "valid_severity_values": True, + "all_have_issue": True, + "all_have_location": True, + "all_have_explanation": True + } + + for issue in issues: + if not isinstance(issue, dict): + checks["is_list"] = False + continue + + if "severity" not in issue: + checks["all_have_severity"] = False + elif issue["severity"] not in valid_severities: + checks["valid_severity_values"] = False + + if "issue" not in issue or not issue["issue"]: + checks["all_have_issue"] = False + + if "location" not in issue or not issue["location"]: + checks["all_have_location"] = False + + if "explanation" not in issue or not issue["explanation"]: + checks["all_have_explanation"] = False + + all_pass = all(checks.values()) + status = "pass" if all_pass else "warning" + + failed = [k for k, v in checks.items() if not v] + summary = "All issues properly structured" if all_pass else f"Issues have problems: {failed}" + + return { + "status": status, + "summary": summary, + "checks": checks, + "valid": all_pass, + "issue_count": len(issues) + } + + +def validate_score(score: int, min_val: int = 0, max_val: int = 100) -> bool: + """Validate a numeric score is in range.""" + return isinstance(score, (int, float)) and min_val <= score <= max_val + + +def validate_health_score(health_score: int) -> Dict[str, Any]: + """Validate health score and categorize.""" + if not validate_score(health_score): + return { + "status": "error", + "summary": "Invalid health score", + "valid": False + } + + if health_score >= 90: + category = "excellent" + status = "pass" + elif health_score >= 75: + category = "good" + status = "pass" + elif health_score >= 60: + category = "fair" + status = "warning" + elif health_score >= 40: + category = "poor" + status = "warning" + else: + category = "critical" + status = "critical" + + return { + "status": status, + "summary": f"{category.capitalize()} health ({health_score}/100)", + "category": category, + "valid": True, + "score": health_score + } + + +def validate_file_path(file_path: str) -> bool: + """Validate file path format.""" + from pathlib import Path + try: + path = Path(file_path) + return path.exists() + except Exception: + return False + + +def validate_best_practices_compliance(compliance: Dict[str, Dict[str, Any]]) -> Dict[str, Any]: + """Validate best practices compliance structure.""" + if not isinstance(compliance, dict): + return { + "status": "error", + "summary": "Compliance must be a dictionary", + "valid": False + } + + required_tip_fields = ["status", "score", "message"] + valid_statuses = ["pass", "fail", "warning", "info"] + + checks = { + "has_tips": len(compliance) > 0, + "all_tips_valid": True, + "valid_statuses": True, + "valid_scores": True + } + + for tip_name, tip_data in compliance.items(): + if not isinstance(tip_data, dict): + checks["all_tips_valid"] = False + continue + + for field in required_tip_fields: + if field not in tip_data: + checks["all_tips_valid"] = False + + if tip_data.get("status") not in valid_statuses: + checks["valid_statuses"] = False + + if not validate_score(tip_data.get("score", -1)): + checks["valid_scores"] = False + + all_pass = all(checks.values()) + status = "pass" if all_pass else "warning" + + return { + "status": status, + "summary": f"Validated {len(compliance)} tips", + "checks": checks, + "valid": all_pass, + "tip_count": len(compliance) + } + + +def validate_bottlenecks(bottlenecks: List[Dict[str, Any]]) -> Dict[str, Any]: + """Validate performance bottleneck list.""" + if not isinstance(bottlenecks, list): + return { + "status": "error", + "summary": "Bottlenecks must be a list", + "valid": False + } + + required_fields = ["severity", "category", "issue", "location", "explanation", "fix"] + valid_severities = ["CRITICAL", "HIGH", "MEDIUM", "LOW"] + valid_categories = ["performance", "memory", "io", "rendering"] + + checks = { + "is_list": True, + "all_have_severity": True, + "valid_severities": True, + "all_have_category": True, + "valid_categories": True, + "all_have_fix": True + } + + for bottleneck in bottlenecks: + if not isinstance(bottleneck, dict): + checks["is_list"] = False + continue + + if "severity" not in bottleneck: + checks["all_have_severity"] = False + elif bottleneck["severity"] not in valid_severities: + checks["valid_severities"] = False + + if "category" not in bottleneck: + checks["all_have_category"] = False + elif bottleneck["category"] not in valid_categories: + checks["valid_categories"] = False + + if "fix" not in bottleneck or not bottleneck["fix"]: + checks["all_have_fix"] = False + + all_pass = all(checks.values()) + status = "pass" if all_pass else "warning" + + return { + "status": status, + "summary": f"Validated {len(bottlenecks)} bottlenecks", + "checks": checks, + "valid": all_pass, + "bottleneck_count": len(bottlenecks) + } + + +def validate_architecture_analysis(result: Dict[str, Any]) -> Dict[str, Any]: + """Validate architecture analysis result.""" + required_keys = ["current_pattern", "complexity_score", "recommended_pattern", "refactoring_steps"] + + checks = {} + for key in required_keys: + checks[f"has_{key}"] = key in result and result[key] is not None + + # Validate complexity score + if "complexity_score" in result: + checks["valid_complexity_score"] = validate_score(result["complexity_score"]) + else: + checks["valid_complexity_score"] = False + + # Validate refactoring steps + if "refactoring_steps" in result: + checks["has_refactoring_steps"] = isinstance(result["refactoring_steps"], list) and len(result["refactoring_steps"]) > 0 + else: + checks["has_refactoring_steps"] = False + + all_pass = all(checks.values()) + status = "pass" if all_pass else "warning" + + return { + "status": status, + "summary": "Architecture analysis validated" if all_pass else "Architecture analysis incomplete", + "checks": checks, + "valid": all_pass + } + + +def validate_layout_fixes(fixes: List[Dict[str, Any]]) -> Dict[str, Any]: + """Validate layout fix list.""" + if not isinstance(fixes, list): + return { + "status": "error", + "summary": "Fixes must be a list", + "valid": False + } + + required_fields = ["location", "original", "fixed", "explanation"] + + checks = { + "is_list": True, + "all_have_location": True, + "all_have_explanation": True, + "all_have_fix": True + } + + for fix in fixes: + if not isinstance(fix, dict): + checks["is_list"] = False + continue + + if "location" not in fix or not fix["location"]: + checks["all_have_location"] = False + + if "explanation" not in fix or not fix["explanation"]: + checks["all_have_explanation"] = False + + if "fixed" not in fix or not fix["fixed"]: + checks["all_have_fix"] = False + + all_pass = all(checks.values()) + status = "pass" if all_pass else "warning" + + return { + "status": status, + "summary": f"Validated {len(fixes)} fixes", + "checks": checks, + "valid": all_pass, + "fix_count": len(fixes) + } + + +# Example usage +if __name__ == "__main__": + # Test validation functions + test_issues = [ + { + "severity": "CRITICAL", + "category": "performance", + "issue": "Blocking operation", + "location": "main.go:45", + "explanation": "HTTP call blocks event loop", + "fix": "Move to tea.Cmd" + } + ] + + result = validate_issue_list(test_issues) + print(f"Issue validation: {result}") + + health_result = validate_health_score(75) + print(f"Health validation: {health_result}") diff --git a/.crush/skills/bubbletea-maintenance/skills/bubbletea-maintenance/SKILL.md b/.crush/skills/bubbletea-maintenance/skills/bubbletea-maintenance/SKILL.md new file mode 100644 index 00000000..01e3899d --- /dev/null +++ b/.crush/skills/bubbletea-maintenance/skills/bubbletea-maintenance/SKILL.md @@ -0,0 +1,729 @@ +--- +name: bubbletea-maintenance +description: Expert Bubble Tea maintenance and debugging agent - diagnoses issues, applies best practices, and enhances existing Go/Bubble Tea TUI applications +--- + +# Bubble Tea Maintenance & Debugging Agent + +**Version**: 1.0.0 +**Created**: 2025-10-19 +**Type**: Maintenance & Debugging Agent +**Focus**: Existing Go/Bubble Tea TUI Applications + +--- + +## Overview + +You are an expert Bubble Tea maintenance and debugging agent specializing in diagnosing issues, applying best practices, and enhancing existing Go/Bubble Tea TUI applications. You help developers maintain, debug, and improve their terminal user interfaces built with the Bubble Tea framework. + +## When to Use This Agent + +This agent should be activated when users: +- Experience bugs or issues in existing Bubble Tea applications +- Want to optimize performance of their TUI +- Need to refactor or improve their Bubble Tea code +- Want to apply best practices to their codebase +- Are debugging layout or rendering issues +- Need help with Lipgloss styling problems +- Want to add features to existing Bubble Tea apps +- Have questions about Bubble Tea architecture patterns + +## Activation Keywords + +This agent activates on phrases like: +- "debug my bubble tea app" +- "fix this TUI issue" +- "optimize bubbletea performance" +- "why is my TUI slow" +- "refactor bubble tea code" +- "apply bubbletea best practices" +- "fix layout issues" +- "lipgloss styling problem" +- "improve my TUI" +- "bubbletea architecture help" +- "message handling issues" +- "event loop problems" +- "model tree refactoring" + +## Core Capabilities + +### 1. Issue Diagnosis + +**Function**: `diagnose_issue(code_path, description="")` + +Analyzes existing Bubble Tea code to identify common issues: + +**Common Issues Detected**: +- **Slow Event Loop**: Blocking operations in Update() or View() +- **Memory Leaks**: Unreleased resources, goroutine leaks +- **Message Ordering**: Incorrect assumptions about concurrent messages +- **Layout Arithmetic**: Hardcoded dimensions, incorrect lipgloss calculations +- **Model Architecture**: Flat models that should be hierarchical +- **Terminal Recovery**: Missing panic recovery +- **Testing Gaps**: No teatest coverage + +**Analysis Process**: +1. Parse Go code to extract Model, Update, View functions +2. Check for blocking operations in event loop +3. Identify hardcoded layout values +4. Analyze message handler patterns +5. Check for concurrent command usage +6. Validate terminal cleanup code +7. Generate diagnostic report with severity levels + +**Output Format**: +```python +{ + "issues": [ + { + "severity": "CRITICAL", # CRITICAL, WARNING, INFO + "category": "performance", + "issue": "Blocking sleep in Update() function", + "location": "main.go:45", + "explanation": "time.Sleep blocks the event loop", + "fix": "Move to tea.Cmd goroutine" + } + ], + "summary": "Found 3 critical issues, 5 warnings", + "health_score": 65 # 0-100 +} +``` + +### 2. Best Practices Validation + +**Function**: `apply_best_practices(code_path, tips_file)` + +Validates code against the 11 expert tips from `tip-bubbltea-apps.md`: + +**Tip 1: Keep Event Loop Fast** +- ✅ Check: Update() completes in < 16ms +- ✅ Check: No blocking I/O in Update() or View() +- ✅ Check: Long operations wrapped in tea.Cmd + +**Tip 2: Debug Message Dumping** +- ✅ Check: Has debug message dumping capability +- ✅ Check: Uses spew or similar for message inspection + +**Tip 3: Live Reload** +- ✅ Check: Development workflow supports live reload +- ✅ Check: Uses air or similar tools + +**Tip 4: Receiver Methods** +- ✅ Check: Appropriate use of pointer vs value receivers +- ✅ Check: Update() uses value receiver (standard pattern) + +**Tip 5: Message Ordering** +- ✅ Check: No assumptions about concurrent message order +- ✅ Check: State machine handles out-of-order messages + +**Tip 6: Model Tree** +- ✅ Check: Complex apps use hierarchical models +- ✅ Check: Child models handle their own messages + +**Tip 7: Layout Arithmetic** +- ✅ Check: Uses lipgloss.Height() and lipgloss.Width() +- ✅ Check: No hardcoded dimensions + +**Tip 8: Terminal Recovery** +- ✅ Check: Has panic recovery with tea.EnableMouseAllMotion cleanup +- ✅ Check: Restores terminal on crash + +**Tip 9: Testing with teatest** +- ✅ Check: Has teatest test coverage +- ✅ Check: Tests key interactions + +**Tip 10: VHS Demos** +- ✅ Check: Has VHS demo files for documentation + +**Output Format**: +```python +{ + "compliance": { + "tip_1_fast_event_loop": {"status": "pass", "score": 100}, + "tip_2_debug_dumping": {"status": "fail", "score": 0}, + "tip_3_live_reload": {"status": "warning", "score": 50}, + # ... all 11 tips + }, + "overall_score": 75, + "recommendations": [ + "Add debug message dumping capability", + "Replace hardcoded dimensions with lipgloss calculations" + ] +} +``` + +### 3. Performance Debugging + +**Function**: `debug_performance(code_path, profile_data="")` + +Identifies performance bottlenecks in Bubble Tea applications: + +**Analysis Areas**: +1. **Event Loop Profiling** + - Measure Update() execution time + - Identify slow message handlers + - Check for blocking operations + +2. **View Rendering** + - Measure View() execution time + - Identify expensive string operations + - Check for unnecessary re-renders + +3. **Memory Allocation** + - Identify allocation hotspots + - Check for string concatenation issues + - Validate efficient use of strings.Builder + +4. **Concurrent Commands** + - Check for goroutine leaks + - Validate proper command cleanup + - Identify race conditions + +**Output Format**: +```python +{ + "bottlenecks": [ + { + "function": "Update", + "location": "main.go:67", + "time_ms": 45, + "threshold_ms": 16, + "issue": "HTTP request blocks event loop", + "fix": "Move to tea.Cmd goroutine" + } + ], + "metrics": { + "avg_update_time": "12ms", + "avg_view_time": "3ms", + "memory_allocations": 1250, + "goroutines": 8 + }, + "recommendations": [ + "Move HTTP calls to background commands", + "Use strings.Builder for View() composition", + "Cache expensive lipgloss styles" + ] +} +``` + +### 4. Architecture Suggestions + +**Function**: `suggest_architecture(code_path, complexity_level)` + +Recommends architectural improvements for Bubble Tea applications: + +**Pattern Recognition**: +1. **Flat Model → Model Tree** + - Detect when single model becomes too complex + - Suggest splitting into child models + - Provide refactoring template + +2. **Single View → Multi-View** + - Identify state-based view switching + - Suggest view router pattern + - Provide navigation template + +3. **Monolithic → Composable** + - Detect tight coupling + - Suggest component extraction + - Provide composable model pattern + +**Refactoring Templates**: + +**Model Tree Pattern**: +```go +type ParentModel struct { + activeView int + listModel list.Model + formModel form.Model + viewerModel viewer.Model +} + +func (m ParentModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + // Route to active child + switch m.activeView { + case 0: + m.listModel, cmd = m.listModel.Update(msg) + case 1: + m.formModel, cmd = m.formModel.Update(msg) + case 2: + m.viewerModel, cmd = m.viewerModel.Update(msg) + } + + return m, cmd +} +``` + +**Output Format**: +```python +{ + "current_pattern": "flat_model", + "complexity_score": 85, # 0-100, higher = more complex + "recommended_pattern": "model_tree", + "refactoring_steps": [ + "Extract list functionality to separate model", + "Extract form functionality to separate model", + "Create parent router model", + "Implement message routing" + ], + "code_templates": { + "parent_model": "...", + "child_models": "...", + "message_routing": "..." + } +} +``` + +### 5. Layout Issue Fixes + +**Function**: `fix_layout_issues(code_path, description="")` + +Diagnoses and fixes common Lipgloss layout problems: + +**Common Layout Issues**: + +1. **Hardcoded Dimensions** + ```go + // ❌ BAD + content := lipgloss.NewStyle().Width(80).Height(24).Render(text) + + // ✅ GOOD + termWidth, termHeight, _ := term.GetSize(int(os.Stdout.Fd())) + content := lipgloss.NewStyle(). + Width(termWidth). + Height(termHeight - 2). // Leave room for status bar + Render(text) + ``` + +2. **Incorrect Height Calculation** + ```go + // ❌ BAD + availableHeight := 24 - 3 // Hardcoded + + // ✅ GOOD + statusBarHeight := lipgloss.Height(m.renderStatusBar()) + availableHeight := m.termHeight - statusBarHeight + ``` + +3. **Missing Margin/Padding Accounting** + ```go + // ❌ BAD + content := lipgloss.NewStyle(). + Padding(2). + Width(80). + Render(text) // Text area is 76, not 80! + + // ✅ GOOD + style := lipgloss.NewStyle().Padding(2) + contentWidth := 80 - style.GetHorizontalPadding() + content := style.Width(80).Render( + lipgloss.NewStyle().Width(contentWidth).Render(text) + ) + ``` + +4. **Overflow Issues** + ```go + // ❌ BAD + content := longText // Can exceed terminal width + + // ✅ GOOD + import "github.com/muesli/reflow/wordwrap" + content := wordwrap.String(longText, m.termWidth) + ``` + +**Output Format**: +```python +{ + "layout_issues": [ + { + "type": "hardcoded_dimensions", + "location": "main.go:89", + "current_code": "Width(80).Height(24)", + "fixed_code": "Width(m.termWidth).Height(m.termHeight - statusHeight)", + "explanation": "Terminal size may vary, use dynamic sizing" + } + ], + "lipgloss_improvements": [ + "Use GetHorizontalPadding() for nested styles", + "Calculate available space with lipgloss.Height()", + "Handle terminal resize with tea.WindowSizeMsg" + ] +} +``` + +### 6. Comprehensive Analysis + +**Function**: `comprehensive_bubbletea_analysis(code_path)` + +Performs complete health check of Bubble Tea application: + +**Analysis Sections**: +1. Issue diagnosis (from diagnose_issue) +2. Best practices compliance (from apply_best_practices) +3. Performance analysis (from debug_performance) +4. Architecture recommendations (from suggest_architecture) +5. Layout validation (from fix_layout_issues) + +**Output Format**: +```python +{ + "overall_health": 78, # 0-100 + "sections": { + "issues": {...}, + "best_practices": {...}, + "performance": {...}, + "architecture": {...}, + "layout": {...} + }, + "summary": "Application is in good health with minor performance improvements needed", + "priority_fixes": [ + "CRITICAL: Fix blocking operation in Update() (main.go:45)", + "WARNING: Add terminal recovery code", + "INFO: Consider refactoring to model tree pattern" + ], + "estimated_fix_time": "2-4 hours" +} +``` + +## Workflow for Users + +### Typical Usage Flow: + +1. **User reports issue**: "My TUI is slow when typing" +2. **Agent diagnoses**: Runs `debug_performance()` to identify bottleneck +3. **Agent explains**: "Found blocking regex operation in Update() taking 45ms" +4. **Agent fixes**: Provides code to move operation to tea.Cmd +5. **Agent validates**: Runs `comprehensive_analysis()` to confirm fix + +### Example Session: + +``` +User: "My log viewer is lagging when I scroll" + +Agent: Let me analyze your code... + +[Runs debug_performance()] + +Found the issue! Your View() function is: +1. Re-reading the entire log file on every render (200ms) +2. Processing all lines instead of visible viewport + +Here's the fix: +- Cache log lines in model, only update when file changes +- Use viewport.Model to handle scrolling efficiently +- Only render visible lines (viewport.YOffset to YOffset + Height) + +[Provides code diff] + +This should reduce render time from 200ms to ~2ms. +``` + +## Technical Knowledge Base + +### Bubble Tea Architecture + +**The Elm Architecture**: +``` +┌─────────────┐ +│ Model │ ← Your application state +└─────────────┘ + ↓ +┌─────────────┐ +│ Update │ ← Message handler (events → state changes) +└─────────────┘ + ↓ +┌─────────────┐ +│ View │ ← Render function (state → string) +└─────────────┘ + ↓ + Terminal +``` + +**Event Loop**: +```go +1. User presses key → tea.KeyMsg +2. Update(tea.KeyMsg) → new model + tea.Cmd +3. tea.Cmd executes → returns new msg +4. Update(new msg) → new model +5. View() renders new model → terminal +``` + +**Performance Rule**: Update() and View() must be FAST (<16ms for 60fps) + +### Common Patterns + +**1. Loading Data Pattern**: +```go +type model struct { + loading bool + data []string + err error +} + +func loadData() tea.Msg { + // This runs in goroutine, not in event loop + data, err := fetchData() + return dataLoadedMsg{data: data, err: err} +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "r" { + m.loading = true + return m, loadData // Return command, don't block + } + case dataLoadedMsg: + m.loading = false + m.data = msg.data + m.err = msg.err + } + return m, nil +} +``` + +**2. Model Tree Pattern**: +```go +type appModel struct { + activeView int + + // Child models manage themselves + listView listModel + detailView detailModel + searchView searchModel +} + +func (m appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // Global keys (navigation) + if key, ok := msg.(tea.KeyMsg); ok { + switch key.String() { + case "1": m.activeView = 0; return m, nil + case "2": m.activeView = 1; return m, nil + case "3": m.activeView = 2; return m, nil + } + } + + // Route to active child + var cmd tea.Cmd + switch m.activeView { + case 0: + m.listView, cmd = m.listView.Update(msg) + case 1: + m.detailView, cmd = m.detailView.Update(msg) + case 2: + m.searchView, cmd = m.searchView.Update(msg) + } + + return m, cmd +} + +func (m appModel) View() string { + switch m.activeView { + case 0: return m.listView.View() + case 1: return m.detailView.View() + case 2: return m.searchView.View() + } + return "" +} +``` + +**3. Message Passing Between Models**: +```go +type itemSelectedMsg struct { + itemID string +} + +// Parent routes message to all children +func (m appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case itemSelectedMsg: + // List sent this, detail needs to know + m.detailView.LoadItem(msg.itemID) + m.activeView = 1 // Switch to detail view + } + + // Update all children + var cmds []tea.Cmd + m.listView, cmd := m.listView.Update(msg) + cmds = append(cmds, cmd) + m.detailView, cmd = m.detailView.Update(msg) + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} +``` + +**4. Dynamic Layout Pattern**: +```go +func (m model) View() string { + // Always use current terminal size + headerHeight := lipgloss.Height(m.renderHeader()) + footerHeight := lipgloss.Height(m.renderFooter()) + + availableHeight := m.termHeight - headerHeight - footerHeight + + content := lipgloss.NewStyle(). + Width(m.termWidth). + Height(availableHeight). + Render(m.renderContent()) + + return lipgloss.JoinVertical( + lipgloss.Left, + m.renderHeader(), + content, + m.renderFooter(), + ) +} +``` + +## Integration with Local Resources + +This agent uses local knowledge sources: + +### Primary Reference +**`/Users/williamvansickleiii/charmtuitemplate/charm-tui-template/tip-bubbltea-apps.md`** +- 11 expert tips from leg100.github.io +- Core best practices validation + +### Example Codebases +**`/Users/williamvansickleiii/charmtuitemplate/vinw/`** +- Real-world Bubble Tea application +- Pattern examples + +**`/Users/williamvansickleiii/charmtuitemplate/charm-examples-inventory/`** +- Collection of Charm examples +- Component usage patterns + +### Styling Reference +**`/Users/williamvansickleiii/charmtuitemplate/charm-tui-template/lipgloss-readme.md`** +- Lipgloss API documentation +- Styling patterns + +## Troubleshooting Guide + +### Issue: Slow/Laggy TUI +**Diagnosis Steps**: +1. Profile Update() execution time +2. Profile View() execution time +3. Check for blocking I/O +4. Check for expensive string operations + +**Common Fixes**: +- Move I/O to tea.Cmd goroutines +- Use strings.Builder in View() +- Cache expensive lipgloss styles +- Reduce re-renders with smart diffing + +### Issue: Terminal Gets Messed Up +**Diagnosis Steps**: +1. Check for panic recovery +2. Check for tea.EnableMouseAllMotion cleanup +3. Validate proper program.Run() usage + +**Fix Template**: +```go +func main() { + defer func() { + if r := recover(); r != nil { + // Restore terminal + tea.DisableMouseAllMotion() + tea.ShowCursor() + fmt.Println("Panic:", r) + os.Exit(1) + } + }() + + p := tea.NewProgram(initialModel()) + if err := p.Start(); err != nil { + fmt.Println("Error:", err) + os.Exit(1) + } +} +``` + +### Issue: Layout Overflow/Clipping +**Diagnosis Steps**: +1. Check for hardcoded dimensions +2. Check lipgloss padding/margin accounting +3. Verify terminal resize handling + +**Fix Checklist**: +- [ ] Use dynamic terminal size from tea.WindowSizeMsg +- [ ] Use lipgloss.Height() and lipgloss.Width() for calculations +- [ ] Account for padding with GetHorizontalPadding()/GetVerticalPadding() +- [ ] Use wordwrap for long text +- [ ] Test with small terminal sizes + +### Issue: Messages Arriving Out of Order +**Diagnosis Steps**: +1. Check for concurrent tea.Cmd usage +2. Check for state assumptions about message order +3. Validate state machine handles any order + +**Fix**: +- Use state machine with explicit states +- Don't assume operation A completes before B +- Use message types to track operation identity + +```go +type model struct { + operations map[string]bool // Track concurrent ops +} + +type operationStartMsg struct { id string } +type operationDoneMsg struct { id string, result string } + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case operationStartMsg: + m.operations[msg.id] = true + case operationDoneMsg: + delete(m.operations, msg.id) + // Handle result + } + return m, nil +} +``` + +## Validation and Quality Checks + +After applying fixes, the agent validates: +1. ✅ Code compiles successfully +2. ✅ No new issues introduced +3. ✅ Performance improved (if applicable) +4. ✅ Best practices compliance increased +5. ✅ Tests pass (if present) + +## Limitations + +This agent focuses on maintenance and debugging, NOT: +- Designing new TUIs from scratch (use bubbletea-designer for that) +- Non-Bubble Tea Go code +- Terminal emulator issues +- Operating system specific problems + +## Success Metrics + +A successful maintenance session results in: +- ✅ Issue identified and explained clearly +- ✅ Fix provided with code examples +- ✅ Best practices applied +- ✅ Performance improved (if applicable) +- ✅ User understands the fix and can apply it + +## Version History + +**v1.0.0** (2025-10-19) +- Initial release +- 6 core analysis functions +- Integration with tip-bubbltea-apps.md +- Comprehensive diagnostic capabilities +- Layout issue detection and fixing +- Performance profiling +- Architecture recommendations + +--- + +**Built with Claude Code agent-creator on 2025-10-19** diff --git a/.crush/skills/bubbletea-maintenance/skills/bubbletea-maintenance/references/common_issues.md b/.crush/skills/bubbletea-maintenance/skills/bubbletea-maintenance/references/common_issues.md new file mode 100644 index 00000000..12d5365d --- /dev/null +++ b/.crush/skills/bubbletea-maintenance/skills/bubbletea-maintenance/references/common_issues.md @@ -0,0 +1,567 @@ +# Common Bubble Tea Issues and Solutions + +Reference guide for diagnosing and fixing common problems in Bubble Tea applications. + +## Performance Issues + +### Issue: Slow/Laggy UI + +**Symptoms:** +- UI freezes when typing +- Delayed response to key presses +- Stuttering animations + +**Common Causes:** + +1. **Blocking Operations in Update()** + ```go + // ❌ BAD + func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + data := http.Get("https://api.example.com") // BLOCKS! + m.data = data + } + return m, nil + } + + // ✅ GOOD + func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + return m, fetchDataCmd // Non-blocking + case dataFetchedMsg: + m.data = msg.data + } + return m, nil + } + + func fetchDataCmd() tea.Msg { + data := http.Get("https://api.example.com") // Runs in goroutine + return dataFetchedMsg{data: data} + } + ``` + +2. **Heavy Processing in View()** + ```go + // ❌ BAD + func (m model) View() string { + content, _ := os.ReadFile("large_file.txt") // EVERY RENDER! + return string(content) + } + + // ✅ GOOD + type model struct { + cachedContent string + } + + func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case fileLoadedMsg: + m.cachedContent = msg.content // Cache it + } + return m, nil + } + + func (m model) View() string { + return m.cachedContent // Just return cached data + } + ``` + +3. **String Concatenation with +** + ```go + // ❌ BAD - Allocates many temp strings + func (m model) View() string { + s := "" + for _, line := range m.lines { + s += line + "\\n" // Expensive! + } + return s + } + + // ✅ GOOD - Single allocation + func (m model) View() string { + var b strings.Builder + for _, line := range m.lines { + b.WriteString(line) + b.WriteString("\\n") + } + return b.String() + } + ``` + +**Performance Target:** Update() should complete in <16ms (60 FPS) + +--- + +## Layout Issues + +### Issue: Content Overflows Terminal + +**Symptoms:** +- Text wraps unexpectedly +- Content gets clipped +- Layout breaks on different terminal sizes + +**Common Causes:** + +1. **Hardcoded Dimensions** + ```go + // ❌ BAD + content := lipgloss.NewStyle(). + Width(80). // What if terminal is 120 wide? + Height(24). // What if terminal is 40 tall? + Render(text) + + // ✅ GOOD + type model struct { + termWidth int + termHeight int + } + + func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.termWidth = msg.Width + m.termHeight = msg.Height + } + return m, nil + } + + func (m model) View() string { + content := lipgloss.NewStyle(). + Width(m.termWidth). + Height(m.termHeight - 2). // Leave room for status bar + Render(text) + return content + } + ``` + +2. **Not Accounting for Padding/Borders** + ```go + // ❌ BAD + style := lipgloss.NewStyle(). + Padding(2). + Border(lipgloss.RoundedBorder()). + Width(80) + content := style.Render(text) + // Text area is 76 (80 - 2*2 padding), NOT 80! + + // ✅ GOOD + style := lipgloss.NewStyle(). + Padding(2). + Border(lipgloss.RoundedBorder()) + + contentWidth := 80 - style.GetHorizontalPadding() - style.GetHorizontalBorderSize() + innerContent := lipgloss.NewStyle().Width(contentWidth).Render(text) + result := style.Width(80).Render(innerContent) + ``` + +3. **Manual Height Calculations** + ```go + // ❌ BAD - Magic numbers + availableHeight := 24 - 3 // Where did 3 come from? + + // ✅ GOOD - Calculated + headerHeight := lipgloss.Height(m.renderHeader()) + footerHeight := lipgloss.Height(m.renderFooter()) + availableHeight := m.termHeight - headerHeight - footerHeight + ``` + +--- + +## Message Handling Issues + +### Issue: Messages Arrive Out of Order + +**Symptoms:** +- State becomes inconsistent +- Operations complete in wrong order +- Race conditions + +**Cause:** Concurrent tea.Cmd messages aren't guaranteed to arrive in order + +**Solution: Use State Tracking** + +```go +// ❌ BAD - Assumes order +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "r" { + return m, tea.Batch( + fetchUsersCmd, // Might complete second + fetchPostsCmd, // Might complete first + ) + } + case usersLoadedMsg: + m.users = msg.users + case postsLoadedMsg: + m.posts = msg.posts + // Assumes users are loaded! May not be! + } + return m, nil +} + +// ✅ GOOD - Track operations +type model struct { + operations map[string]bool + users []User + posts []Post +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "r" { + m.operations["users"] = true + m.operations["posts"] = true + return m, tea.Batch(fetchUsersCmd, fetchPostsCmd) + } + case usersLoadedMsg: + m.users = msg.users + delete(m.operations, "users") + return m, m.checkAllLoaded() + case postsLoadedMsg: + m.posts = msg.posts + delete(m.operations, "posts") + return m, m.checkAllLoaded() + } + return m, nil +} + +func (m model) checkAllLoaded() tea.Cmd { + if len(m.operations) == 0 { + // All operations complete, can proceed + return m.processData + } + return nil +} +``` + +--- + +## Terminal Recovery Issues + +### Issue: Terminal Gets Messed Up After Crash + +**Symptoms:** +- Cursor disappears +- Mouse mode still active +- Terminal looks corrupted + +**Solution: Add Panic Recovery** + +```go +func main() { + defer func() { + if r := recover(); r != nil { + // Restore terminal state + tea.DisableMouseAllMotion() + tea.ShowCursor() + fmt.Printf("Panic: %v\\n", r) + debug.PrintStack() + os.Exit(1) + } + }() + + p := tea.NewProgram(initialModel()) + if err := p.Start(); err != nil { + fmt.Printf("Error: %v\\n", err) + os.Exit(1) + } +} +``` + +--- + +## Architecture Issues + +### Issue: Model Too Complex + +**Symptoms:** +- Model struct has 20+ fields +- Update() is hundreds of lines +- Hard to maintain + +**Solution: Use Model Tree Pattern** + +```go +// ❌ BAD - Flat model +type model struct { + // List view fields + listItems []string + listCursor int + listFilter string + + // Detail view fields + detailItem string + detailHTML string + detailScroll int + + // Search view fields + searchQuery string + searchResults []string + searchCursor int + + // ... 15 more fields +} + +// ✅ GOOD - Model tree +type appModel struct { + activeView int + listView listViewModel + detailView detailViewModel + searchView searchViewModel +} + +type listViewModel struct { + items []string + cursor int + filter string +} + +func (m listViewModel) Update(msg tea.Msg) (listViewModel, tea.Cmd) { + // Only handles list-specific messages + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "up": + m.cursor-- + case "down": + m.cursor++ + case "enter": + return m, func() tea.Msg { + return itemSelectedMsg{itemID: m.items[m.cursor]} + } + } + } + return m, nil +} + +// Parent routes messages +func (m appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // Handle global messages + switch msg := msg.(type) { + case itemSelectedMsg: + m.detailView.LoadItem(msg.itemID) + m.activeView = 1 // Switch to detail + return m, nil + } + + // Route to active child + var cmd tea.Cmd + switch m.activeView { + case 0: + m.listView, cmd = m.listView.Update(msg) + case 1: + m.detailView, cmd = m.detailView.Update(msg) + case 2: + m.searchView, cmd = m.searchView.Update(msg) + } + + return m, cmd +} +``` + +--- + +## Memory Issues + +### Issue: Memory Leak / Growing Memory Usage + +**Symptoms:** +- Memory usage increases over time +- Never gets garbage collected + +**Common Causes:** + +1. **Goroutine Leaks** + ```go + // ❌ BAD - Goroutines never stop + func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "s" { + return m, func() tea.Msg { + go func() { + for { // INFINITE LOOP! + time.Sleep(time.Second) + // Do something + } + }() + return nil + } + } + } + return m, nil + } + + // ✅ GOOD - Use context for cancellation + type model struct { + ctx context.Context + cancel context.CancelFunc + } + + func initialModel() model { + ctx, cancel := context.WithCancel(context.Background()) + return model{ctx: ctx, cancel: cancel} + } + + func worker(ctx context.Context) tea.Msg { + for { + select { + case <-ctx.Done(): + return nil // Stop gracefully + case <-time.After(time.Second): + // Do work + } + } + } + + func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "q" { + m.cancel() // Stop all workers + return m, tea.Quit + } + } + return m, nil + } + ``` + +2. **Unreleased Resources** + ```go + // ❌ BAD + func loadFile() tea.Msg { + file, _ := os.Open("data.txt") + // Never closed! + data, _ := io.ReadAll(file) + return dataMsg{data: data} + } + + // ✅ GOOD + func loadFile() tea.Msg { + file, err := os.Open("data.txt") + if err != nil { + return errorMsg{err: err} + } + defer file.Close() // Always close + + data, err := io.ReadAll(file) + return dataMsg{data: data, err: err} + } + ``` + +--- + +## Testing Issues + +### Issue: Hard to Test TUI + +**Solution: Use teatest** + +```go +import ( + "testing" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/bubbletea/teatest" +) + +func TestNavigation(t *testing.T) { + m := initialModel() + + // Create test program + tm := teatest.NewTestModel(t, m) + + // Send key presses + tm.Send(tea.KeyMsg{Type: tea.KeyDown}) + tm.Send(tea.KeyMsg{Type: tea.KeyDown}) + + // Wait for program to process + teatest.WaitFor( + t, tm.Output(), + func(bts []byte) bool { + return bytes.Contains(bts, []byte("Item 2")) + }, + teatest.WithCheckInterval(time.Millisecond*100), + teatest.WithDuration(time.Second*3), + ) + + // Verify state + finalModel := tm.FinalModel(t).(model) + if finalModel.cursor != 2 { + t.Errorf("Expected cursor at 2, got %d", finalModel.cursor) + } +} +``` + +--- + +## Debugging Tips + +### Enable Message Dumping + +```go +import "github.com/davecgh/go-spew/spew" + +type model struct { + dump io.Writer +} + +func main() { + // Create debug file + f, _ := os.Create("debug.log") + defer f.Close() + + m := model{dump: f} + p := tea.NewProgram(m) + p.Start() +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // Dump every message + if m.dump != nil { + spew.Fdump(m.dump, msg) + } + + // ... rest of Update() + return m, nil +} +``` + +### Live Reload with Air + +`.air.toml`: +```toml +[build] + cmd = "go build -o ./tmp/main ." + bin = "tmp/main" + include_ext = ["go"] + exclude_dir = ["tmp"] + delay = 1000 +``` + +Run: `air` + +--- + +## Quick Checklist + +Before deploying your Bubble Tea app: + +- [ ] No blocking operations in Update() or View() +- [ ] Terminal resize handled (tea.WindowSizeMsg) +- [ ] Panic recovery with terminal cleanup +- [ ] Dynamic layout (no hardcoded dimensions) +- [ ] Lipgloss padding/borders accounted for +- [ ] String operations use strings.Builder +- [ ] Goroutines have cancellation (context) +- [ ] Resources properly closed (defer) +- [ ] State machine handles message ordering +- [ ] Tests with teatest for key interactions + +--- + +**Generated for Bubble Tea Maintenance Agent v1.0.0** diff --git a/.crush/skills/bubbletea-maintenance/tests/test_diagnose_issue.py b/.crush/skills/bubbletea-maintenance/tests/test_diagnose_issue.py new file mode 100644 index 00000000..1f90a500 --- /dev/null +++ b/.crush/skills/bubbletea-maintenance/tests/test_diagnose_issue.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +""" +Tests for diagnose_issue.py +""" + +import sys +from pathlib import Path + +# Add scripts to path +sys.path.insert(0, str(Path(__file__).parent.parent / 'scripts')) + +from diagnose_issue import diagnose_issue, _check_blocking_operations, _check_hardcoded_dimensions + + +def test_diagnose_issue_basic(): + """Test basic issue diagnosis.""" + print("\n✓ Testing diagnose_issue()...") + + # Create test Go file + test_code = ''' +package main + +import tea "github.com/charmbracelet/bubbletea" + +type model struct { + width int + height int +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + return m, nil +} + +func (m model) View() string { + return "Hello" +} +''' + + test_file = Path("/tmp/test_bubbletea_app.go") + test_file.write_text(test_code) + + result = diagnose_issue(str(test_file)) + + assert 'issues' in result, "Missing 'issues' key" + assert 'health_score' in result, "Missing 'health_score' key" + assert 'summary' in result, "Missing 'summary' key" + assert isinstance(result['issues'], list), "Issues should be a list" + assert isinstance(result['health_score'], int), "Health score should be int" + + print(f" ✓ Found {len(result['issues'])} issue(s)") + print(f" ✓ Health score: {result['health_score']}/100") + + # Cleanup + test_file.unlink() + + return True + + +def test_blocking_operations_detection(): + """Test detection of blocking operations.""" + print("\n✓ Testing blocking operation detection...") + + test_code = ''' +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + data, _ := http.Get("https://example.com") // BLOCKING! + m.data = data + } + return m, nil +} +''' + + lines = test_code.split('\n') + issues = _check_blocking_operations(test_code, lines, "test.go") + + assert len(issues) > 0, "Should detect blocking HTTP request" + assert issues[0]['severity'] == 'CRITICAL', "Should be CRITICAL severity" + assert 'HTTP request' in issues[0]['issue'], "Should identify HTTP as issue" + + print(f" ✓ Detected {len(issues)} blocking operation(s)") + print(f" ✓ Severity: {issues[0]['severity']}") + + return True + + +def test_hardcoded_dimensions_detection(): + """Test detection of hardcoded dimensions.""" + print("\n✓ Testing hardcoded dimensions detection...") + + test_code = ''' +func (m model) View() string { + content := lipgloss.NewStyle(). + Width(80). + Height(24). + Render(m.content) + return content +} +''' + + lines = test_code.split('\n') + issues = _check_hardcoded_dimensions(test_code, lines, "test.go") + + assert len(issues) >= 2, "Should detect both Width and Height" + assert any('Width' in i['issue'] for i in issues), "Should detect hardcoded Width" + assert any('Height' in i['issue'] for i in issues), "Should detect hardcoded Height" + + print(f" ✓ Detected {len(issues)} hardcoded dimension(s)") + + return True + + +def test_no_issues_clean_code(): + """Test with clean code that has no issues.""" + print("\n✓ Testing with clean code...") + + test_code = ''' +package main + +import tea "github.com/charmbracelet/bubbletea" + +type model struct { + termWidth int + termHeight int +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.termWidth = msg.Width + m.termHeight = msg.Height + case tea.KeyMsg: + return m, fetchDataCmd // Non-blocking + } + return m, nil +} + +func (m model) View() string { + return lipgloss.NewStyle(). + Width(m.termWidth). + Height(m.termHeight). + Render("Clean!") +} + +func fetchDataCmd() tea.Msg { + // Runs in background + return dataMsg{} +} +''' + + test_file = Path("/tmp/test_clean_app.go") + test_file.write_text(test_code) + + result = diagnose_issue(str(test_file)) + + assert result['health_score'] >= 80, "Clean code should have high health score" + print(f" ✓ Health score: {result['health_score']}/100 (expected >=80)") + + # Cleanup + test_file.unlink() + + return True + + +def test_invalid_path(): + """Test with invalid file path.""" + print("\n✓ Testing with invalid path...") + + result = diagnose_issue("/nonexistent/path/file.go") + + assert 'error' in result, "Should return error for invalid path" + assert result['validation']['status'] == 'error', "Validation should be error" + + print(" ✓ Correctly handled invalid path") + + return True + + +def main(): + """Run all tests.""" + print("="*70) + print("UNIT TESTS - diagnose_issue.py") + print("="*70) + + tests = [ + ("Basic diagnosis", test_diagnose_issue_basic), + ("Blocking operations", test_blocking_operations_detection), + ("Hardcoded dimensions", test_hardcoded_dimensions_detection), + ("Clean code", test_no_issues_clean_code), + ("Invalid path", test_invalid_path), + ] + + results = [] + for test_name, test_func in tests: + try: + passed = test_func() + results.append((test_name, passed)) + except Exception as e: + print(f"\n ❌ FAILED: {e}") + import traceback + traceback.print_exc() + results.append((test_name, False)) + + # Summary + print("\n" + "="*70) + print("SUMMARY") + print("="*70) + + for test_name, passed in results: + status = "✅ PASS" if passed else "❌ FAIL" + print(f"{status}: {test_name}") + + passed_count = sum(1 for _, p in results if p) + total_count = len(results) + + print(f"\nResults: {passed_count}/{total_count} passed") + + return passed_count == total_count + + +if __name__ == "__main__": + success = main() + sys.exit(0 if success else 1) diff --git a/.crush/skills/bubbletea-maintenance/tests/test_integration.py b/.crush/skills/bubbletea-maintenance/tests/test_integration.py new file mode 100644 index 00000000..4649d1ad --- /dev/null +++ b/.crush/skills/bubbletea-maintenance/tests/test_integration.py @@ -0,0 +1,350 @@ +#!/usr/bin/env python3 +""" +Integration tests for Bubble Tea Maintenance Agent. +Tests complete workflows combining multiple functions. +""" + +import sys +from pathlib import Path + +# Add scripts to path +sys.path.insert(0, str(Path(__file__).parent.parent / 'scripts')) + +from diagnose_issue import diagnose_issue +from apply_best_practices import apply_best_practices +from debug_performance import debug_performance +from suggest_architecture import suggest_architecture +from fix_layout_issues import fix_layout_issues +from comprehensive_bubbletea_analysis import comprehensive_bubbletea_analysis + + +# Test fixture: Complete Bubble Tea app +TEST_APP_CODE = ''' +package main + +import ( + "fmt" + "net/http" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type model struct { + items []string + cursor int + data string +} + +func initialModel() model { + return model{ + items: []string{"Item 1", "Item 2", "Item 3"}, + cursor: 0, + } +} + +func (m model) Init() tea.Cmd { + return nil +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q": + return m, tea.Quit + case "up": + if m.cursor > 0 { + m.cursor-- + } + case "down": + if m.cursor < len(m.items)-1 { + m.cursor++ + } + case "r": + // ISSUE: Blocking HTTP request! + resp, _ := http.Get("https://example.com") + m.data = resp.Status + } + } + return m, nil +} + +func (m model) View() string { + // ISSUE: Hardcoded dimensions + style := lipgloss.NewStyle(). + Width(80). + Height(24) + + s := "Select an item:\\n\\n" + for i, item := range m.items { + cursor := " " + if m.cursor == i { + cursor = ">" + } + // ISSUE: String concatenation + s += fmt.Sprintf("%s %s\\n", cursor, item) + } + + return style.Render(s) +} + +func main() { + // ISSUE: No panic recovery! + p := tea.NewProgram(initialModel()) + p.Start() +} +''' + + +def test_full_workflow(): + """Test complete analysis workflow.""" + print("\n✓ Testing complete analysis workflow...") + + # Create test app + test_dir = Path("/tmp/test_bubbletea_app") + test_dir.mkdir(exist_ok=True) + test_file = test_dir / "main.go" + test_file.write_text(TEST_APP_CODE) + + # Run comprehensive analysis + result = comprehensive_bubbletea_analysis(str(test_dir), detail_level="standard") + + # Validations + assert 'overall_health' in result, "Missing overall_health" + assert 'sections' in result, "Missing sections" + assert 'priority_fixes' in result, "Missing priority_fixes" + assert 'summary' in result, "Missing summary" + + # Check each section + sections = result['sections'] + assert 'issues' in sections, "Missing issues section" + assert 'best_practices' in sections, "Missing best_practices section" + assert 'performance' in sections, "Missing performance section" + assert 'architecture' in sections, "Missing architecture section" + assert 'layout' in sections, "Missing layout section" + + # Should find issues in test code + assert len(result.get('priority_fixes', [])) > 0, "Should find priority fixes" + + health = result['overall_health'] + assert 0 <= health <= 100, f"Health score {health} out of range" + + print(f" ✓ Overall health: {health}/100") + print(f" ✓ Sections analyzed: {len(sections)}") + print(f" ✓ Priority fixes: {len(result['priority_fixes'])}") + + # Cleanup + test_file.unlink() + test_dir.rmdir() + + return True + + +def test_issue_diagnosis_finds_problems(): + """Test that diagnosis finds the known issues.""" + print("\n✓ Testing issue diagnosis...") + + test_dir = Path("/tmp/test_diagnosis") + test_dir.mkdir(exist_ok=True) + test_file = test_dir / "main.go" + test_file.write_text(TEST_APP_CODE) + + result = diagnose_issue(str(test_dir)) + + # Should find: + # 1. Blocking HTTP request in Update() + # 2. Hardcoded dimensions (80, 24) + # (Note: Not all detections may trigger depending on pattern matching) + + issues = result.get('issues', []) + assert len(issues) >= 1, f"Expected at least 1 issue, found {len(issues)}" + + # Check that HTTP blocking issue was found + issue_texts = ' '.join([i['issue'] for i in issues]) + assert 'HTTP' in issue_texts or 'http' in issue_texts.lower(), "Should find HTTP blocking issue" + + print(f" ✓ Found {len(issues)} issue(s)") + print(f" ✓ Health score: {result['health_score']}/100") + + # Cleanup + test_file.unlink() + test_dir.rmdir() + + return True + + +def test_performance_finds_bottlenecks(): + """Test that performance analysis finds bottlenecks.""" + print("\n✓ Testing performance analysis...") + + test_dir = Path("/tmp/test_performance") + test_dir.mkdir(exist_ok=True) + test_file = test_dir / "main.go" + test_file.write_text(TEST_APP_CODE) + + result = debug_performance(str(test_dir)) + + # Should find: + # 1. Blocking HTTP in Update() + # (Other bottlenecks may be detected depending on patterns) + + bottlenecks = result.get('bottlenecks', []) + assert len(bottlenecks) >= 1, f"Expected at least 1 bottleneck, found {len(bottlenecks)}" + + # Check for critical bottlenecks + critical = [b for b in bottlenecks if b['severity'] == 'CRITICAL'] + assert len(critical) > 0, "Should find CRITICAL bottlenecks" + + print(f" ✓ Found {len(bottlenecks)} bottleneck(s)") + print(f" ✓ Critical: {len(critical)}") + + # Cleanup + test_file.unlink() + test_dir.rmdir() + + return True + + +def test_layout_finds_issues(): + """Test that layout analysis finds issues.""" + print("\n✓ Testing layout analysis...") + + test_dir = Path("/tmp/test_layout") + test_dir.mkdir(exist_ok=True) + test_file = test_dir / "main.go" + test_file.write_text(TEST_APP_CODE) + + result = fix_layout_issues(str(test_dir)) + + # Should find: + # 1. Hardcoded dimensions or missing resize handling + + layout_issues = result.get('layout_issues', []) + assert len(layout_issues) >= 1, f"Expected at least 1 layout issue, found {len(layout_issues)}" + + # Check for layout-related issues + issue_types = [i['type'] for i in layout_issues] + has_layout_issue = any(t in ['hardcoded_dimensions', 'missing_resize_handling'] for t in issue_types) + assert has_layout_issue, "Should find layout issues" + + print(f" ✓ Found {len(layout_issues)} layout issue(s)") + + # Cleanup + test_file.unlink() + test_dir.rmdir() + + return True + + +def test_architecture_analysis(): + """Test architecture pattern detection.""" + print("\n✓ Testing architecture analysis...") + + test_dir = Path("/tmp/test_arch") + test_dir.mkdir(exist_ok=True) + test_file = test_dir / "main.go" + test_file.write_text(TEST_APP_CODE) + + result = suggest_architecture(str(test_dir)) + + # Should detect pattern and provide recommendations + assert 'current_pattern' in result, "Missing current_pattern" + assert 'complexity_score' in result, "Missing complexity_score" + assert 'recommended_pattern' in result, "Missing recommended_pattern" + assert 'refactoring_steps' in result, "Missing refactoring_steps" + + complexity = result['complexity_score'] + assert 0 <= complexity <= 100, f"Complexity {complexity} out of range" + + print(f" ✓ Current pattern: {result['current_pattern']}") + print(f" ✓ Complexity: {complexity}/100") + print(f" ✓ Recommended: {result['recommended_pattern']}") + + # Cleanup + test_file.unlink() + test_dir.rmdir() + + return True + + +def test_all_functions_return_valid_structure(): + """Test that all functions return valid result structures.""" + print("\n✓ Testing result structure validity...") + + test_dir = Path("/tmp/test_structure") + test_dir.mkdir(exist_ok=True) + test_file = test_dir / "main.go" + test_file.write_text(TEST_APP_CODE) + + # Test all functions + results = { + "diagnose_issue": diagnose_issue(str(test_dir)), + "apply_best_practices": apply_best_practices(str(test_dir)), + "debug_performance": debug_performance(str(test_dir)), + "suggest_architecture": suggest_architecture(str(test_dir)), + "fix_layout_issues": fix_layout_issues(str(test_dir)), + } + + for func_name, result in results.items(): + # Each should have validation + assert 'validation' in result, f"{func_name}: Missing validation" + assert 'status' in result['validation'], f"{func_name}: Missing validation status" + assert 'summary' in result['validation'], f"{func_name}: Missing validation summary" + + print(f" ✓ {func_name}: Valid structure") + + # Cleanup + test_file.unlink() + test_dir.rmdir() + + return True + + +def main(): + """Run all integration tests.""" + print("="*70) + print("INTEGRATION TESTS - Bubble Tea Maintenance Agent") + print("="*70) + + tests = [ + ("Full workflow", test_full_workflow), + ("Issue diagnosis", test_issue_diagnosis_finds_problems), + ("Performance analysis", test_performance_finds_bottlenecks), + ("Layout analysis", test_layout_finds_issues), + ("Architecture analysis", test_architecture_analysis), + ("Result structure validity", test_all_functions_return_valid_structure), + ] + + results = [] + for test_name, test_func in tests: + try: + passed = test_func() + results.append((test_name, passed)) + except Exception as e: + print(f"\n ❌ FAILED: {e}") + import traceback + traceback.print_exc() + results.append((test_name, False)) + + # Summary + print("\n" + "="*70) + print("SUMMARY") + print("="*70) + + for test_name, passed in results: + status = "✅ PASS" if passed else "❌ FAIL" + print(f"{status}: {test_name}") + + passed_count = sum(1 for _, p in results if p) + total_count = len(results) + + print(f"\nResults: {passed_count}/{total_count} passed") + + return passed_count == total_count + + +if __name__ == "__main__": + success = main() + sys.exit(0 if success else 1) diff --git a/.gitignore b/.gitignore index df33cf27..edbeffbb 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,12 @@ tests/.tui-test/ jjhub-tui /poc/jjhub-tui/jjhub-tui smithers.db* + +# Smithers runtime artifacts +.smithers/db/ +.smithers/logs/ +.smithers/node_modules/ +.smithers/bun.lock + +# jj colocated +.jj/ diff --git a/.smithers/package.json b/.smithers/package.json index 5372dfe8..228326ec 100644 --- a/.smithers/package.json +++ b/.smithers/package.json @@ -13,7 +13,7 @@ "zod": "4.0.0" }, "devDependencies": { - "typescript": "5.0.0", + "typescript": "5.8.3", "@types/react": "19.0.0", "@types/react-dom": "19.0.0", "@types/mdx": "2.0.0" diff --git a/.smithers/specs/engineering/chat-pending-approval-summary.md b/.smithers/specs/engineering/chat-pending-approval-summary.md new file mode 100644 index 00000000..e69de29b diff --git a/.smithers/specs/implementation/chat-active-run-summary.md b/.smithers/specs/implementation/chat-active-run-summary.md new file mode 100644 index 00000000..e69de29b diff --git a/.smithers/specs/implementation/chat-mcp-connection-status.md b/.smithers/specs/implementation/chat-mcp-connection-status.md new file mode 100644 index 00000000..e69de29b diff --git a/.smithers/specs/plans/chat-pending-approval-summary.md b/.smithers/specs/plans/chat-pending-approval-summary.md new file mode 100644 index 00000000..e69de29b diff --git a/.smithers/specs/research/chat-pending-approval-summary.md b/.smithers/specs/research/chat-pending-approval-summary.md new file mode 100644 index 00000000..e69de29b diff --git a/.smithers/specs/reviews/implement-chat-active-run-summary-iteration-1.md b/.smithers/specs/reviews/implement-chat-active-run-summary-iteration-1.md new file mode 100644 index 00000000..e69de29b diff --git a/.smithers/specs/reviews/implement-chat-default-console-iteration-1.md b/.smithers/specs/reviews/implement-chat-default-console-iteration-1.md new file mode 100644 index 00000000..e69de29b diff --git a/.smithers/specs/reviews/implement-chat-mcp-connection-status-iteration-1.md b/.smithers/specs/reviews/implement-chat-mcp-connection-status-iteration-1.md new file mode 100644 index 00000000..e69de29b diff --git a/.smithers/specs/reviews/implement-chat-pending-approval-summary-iteration-1.md b/.smithers/specs/reviews/implement-chat-pending-approval-summary-iteration-1.md new file mode 100644 index 00000000..5a876fa0 --- /dev/null +++ b/.smithers/specs/reviews/implement-chat-pending-approval-summary-iteration-1.md @@ -0,0 +1 @@ +No implementation exists. The branch is on main with zero source code changes. The ticket requires extending renderSmithersStatus() in internal/ui/model/header.go to display pending approval counts with a warning indicator, but no code was written, no tests were added, and no commits were made for this feature. \ No newline at end of file diff --git a/.smithers/specs/reviews/implement-platform-split-pane-iteration-1.md b/.smithers/specs/reviews/implement-platform-split-pane-iteration-1.md new file mode 100644 index 00000000..e69de29b diff --git a/.smithers/specs/reviews/plan-chat-pending-approval-summary-iteration-1.md b/.smithers/specs/reviews/plan-chat-pending-approval-summary-iteration-1.md new file mode 100644 index 00000000..e69de29b diff --git a/.smithers/specs/reviews/plan-platform-split-pane-iteration-1.md b/.smithers/specs/reviews/plan-platform-split-pane-iteration-1.md new file mode 100644 index 00000000..e69de29b diff --git a/.smithers/specs/reviews/research-chat-pending-approval-summary-iteration-1.md b/.smithers/specs/reviews/research-chat-pending-approval-summary-iteration-1.md new file mode 100644 index 00000000..e69de29b diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..60d821e0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,56 @@ +# CLAUDE.md + +## TUI E2E Testing + +The TUI binary requires a real PTY to start (bubbletea v2 opens `/dev/tty`). Claude Code runs without a controlling terminal, so the binary crashes if launched directly or via pipes. + +### How to run TUI E2E tests from Claude Code + +Use `tmux` to allocate a real PTY: + +```bash +# Build the binary first +go build -o tests/smithers-tui . + +# Launch in a detached tmux session with a real PTY +SESSION="crush-e2e-$$" +tmux new-session -d -s "$SESSION" -x 120 -y 40 "./tests/smithers-tui" +sleep 3 + +# Capture what's on screen +tmux capture-pane -t "$SESSION" -p + +# Send keystrokes +tmux send-keys -t "$SESSION" "6" # press 6 +tmux send-keys -t "$SESSION" Enter # press Enter +tmux send-keys -t "$SESSION" Escape # press Escape +tmux send-keys -t "$SESSION" "d" # press d +tmux send-keys -t "$SESSION" C-c # ctrl+c + +# Capture screen after each action +sleep 1 +tmux capture-pane -t "$SESSION" -p + +# Clean up +tmux kill-session -t "$SESSION" 2>/dev/null +``` + +### Why tmux works + +- `tmux new-session -d` allocates a fresh PTY via `openpty()` even when the parent process has no controlling terminal +- The child process (smithers-tui) gets a real `/dev/tty` so bubbletea starts normally +- `capture-pane -p` dumps the virtual screen buffer as plain text for assertions +- `send-keys` sends real terminal input through the PTY + +### Why other approaches fail + +- **Direct `exec.Command` with pipes**: bubbletea ignores stdin/stdout pipes and opens `/dev/tty` directly, which fails without a controlling terminal +- **`expect`**: Allocates a PTY but only captures sequential output, not cursor-addressed screen rendering (bubbletea v2 uses `Draw()` which writes to specific screen positions) +- **`script` command**: Fails with "Operation not supported on socket" when called from a subprocess +- **`node-pty`**: Fails with "posix_spawnp failed" in sandboxed environments +- **`pyte` (Python terminal emulator)**: Crashes on modern escape sequences bubbletea v2 emits + +### Current test harness + +- Go e2e tests live in `internal/e2e/` and launch the compiled TUI inside a detached `tmux` session. +- Use `CRUSH_TUI_E2E=1 SMITHERS_TUI_E2E=1 go test ./internal/e2e -run '' -v` when you want deterministic terminal E2E coverage from the CLI. diff --git a/go.mod b/go.mod index ea8bc5a0..09631403 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.26.1 require ( charm.land/bubbles/v2 v2.1.0 charm.land/bubbletea/v2 v2.0.2 - charm.land/catwalk v0.33.2 + charm.land/catwalk v0.34.4 charm.land/fang/v2 v2.0.1 charm.land/fantasy v0.17.1 charm.land/glamour/v2 v2.0.0 @@ -41,7 +41,7 @@ require ( github.com/disintegration/imaging v1.6.2 github.com/dustin/go-humanize v1.0.1 github.com/gen2brain/beeep v0.11.2 - github.com/go-git/go-git/v5 v5.17.1 + github.com/go-git/go-git/v5 v5.17.2 github.com/google/uuid v1.6.0 github.com/invopop/jsonschema v0.13.0 github.com/joho/godotenv v1.5.1 @@ -72,9 +72,9 @@ require ( golang.org/x/text v0.35.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 - modernc.org/sqlite v1.48.0 + modernc.org/sqlite v1.48.1 mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5 - mvdan.cc/sh/v3 v3.13.0 + mvdan.cc/sh/v3 v3.13.1 ) require ( diff --git a/go.sum b/go.sum index 868eca13..5f3de364 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g= charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY= charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= -charm.land/catwalk v0.33.2 h1:Z6EtzewRcAkUvIH0vIduAhVDC4lwUe4AAD6GTlT78fk= -charm.land/catwalk v0.33.2/go.mod h1:+fqw/6YGNtvapvPy9vhwA/fAMxVjD2K8hVIKYov8Vhg= +charm.land/catwalk v0.34.4 h1:MbNJm1J67Q0mBq2XfcwVU8i1D/QtAZtdpDgzkszOut0= +charm.land/catwalk v0.34.4/go.mod h1:+fqw/6YGNtvapvPy9vhwA/fAMxVjD2K8hVIKYov8Vhg= charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY= charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII= charm.land/fantasy v0.17.1 h1:SQzfnyJPDuQWt6e//KKmQmEEXdqHMC0IZz10XwkLcEM= @@ -182,8 +182,8 @@ github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66D github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0= github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY= -github.com/go-git/go-git/v5 v5.17.1 h1:WnljyxIzSj9BRRUlnmAU35ohDsjRK0EKmL0evDqi5Jk= -github.com/go-git/go-git/v5 v5.17.1/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo= +github.com/go-git/go-git/v5 v5.17.2 h1:B+nkdlxdYrvyFK4GPXVU8w1U+YkbsgciIR7f2sZJ104= +github.com/go-git/go-git/v5 v5.17.2/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo= github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao= github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= @@ -605,13 +605,13 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.48.0 h1:ElZyLop3Q2mHYk5IFPPXADejZrlHu7APbpB0sF78bq4= -modernc.org/sqlite v1.48.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= +modernc.org/sqlite v1.48.1 h1:S85iToyU6cgeojybE2XJlSbcsvcWkQ6qqNXJHtW5hWA= +modernc.org/sqlite v1.48.1/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5 h1:mO2lyKtGwu4mGQ+Qqjx0+fd5UU5BXhX/rslFmxd5aco= mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5/go.mod h1:Of9PCedbLDYT8b3EyiYG64rNnx5nOp27OLCVdDrjJyo= -mvdan.cc/sh/v3 v3.13.0 h1:dSfq/MVsY4w0Vsi6Lbs0IcQquMVqLdKLESAOZjuHdLg= -mvdan.cc/sh/v3 v3.13.0/go.mod h1:KV1GByGPc/Ho0X1E6Uz9euhsIQEj4hwyKnodLlFLoDM= +mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk= +mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0= diff --git a/internal/e2e/agents_view_test.go b/internal/e2e/agents_view_test.go new file mode 100644 index 00000000..0784b3bf --- /dev/null +++ b/internal/e2e/agents_view_test.go @@ -0,0 +1,60 @@ +package e2e_test + +import ( + "os" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// TestAgentsView_Navigation exercises the full agents view lifecycle: +// - Opening the command palette and navigating to the agents view. +// - Verifying the "SMITHERS › Agents" header and agent groupings are visible. +// - Moving the cursor with j/k. +// - Pressing Esc to return to the chat view. +// +// Set SMITHERS_TUI_E2E=1 to run this test (it spawns a real TUI process). +func TestAgentsView_Navigation(t *testing.T) { + if os.Getenv("SMITHERS_TUI_E2E") != "1" { + t.Skip("set SMITHERS_TUI_E2E=1 to run terminal E2E tests") + } + + tui := launchTUI(t) + defer tui.Terminate() + + // Wait for the TUI to start. + require.NoError(t, tui.WaitForText("SMITHERS", 15*time.Second)) + + openCommandsPalette(t, tui) + tui.SendKeys("agents") + require.NoError(t, tui.WaitForText("Agents", 5*time.Second)) + + // Navigate to the agents view. + tui.SendKeys("\r") + require.NoError(t, tui.WaitForText("SMITHERS \u203a Agents", 5*time.Second)) + + // Agents should be grouped. At least one section header should be visible. + // The view shows either "Available" or "Not Detected" depending on what's + // installed on the test machine. + snap := tui.Snapshot() + hasAvailable := tui.matchesText("Available") + hasNotDetected := tui.matchesText("Not Detected") + _ = snap + require.True(t, hasAvailable || hasNotDetected, + "agents view should show at least one group section") + + // Move cursor down then up — should not crash. + tui.SendKeys("j") + time.Sleep(100 * time.Millisecond) + tui.SendKeys("k") + time.Sleep(100 * time.Millisecond) + + // Refresh. + tui.SendKeys("r") + require.NoError(t, tui.WaitForText("SMITHERS \u203a Agents", 5*time.Second)) + + // Escape should return to the chat/console view. + tui.SendKeys("\x1b") + require.NoError(t, tui.WaitForNoText("SMITHERS \u203a Agents", 5*time.Second)) +} diff --git a/internal/e2e/approvals_actions_test.go b/internal/e2e/approvals_actions_test.go new file mode 100644 index 00000000..9340923d --- /dev/null +++ b/internal/e2e/approvals_actions_test.go @@ -0,0 +1,389 @@ +package e2e_test + +// approvals_actions_test.go — eng-approvals-e2e-tests +// +// Tests the approve / deny actions in the approvals queue and verifies that +// acting on a pending approval removes it from the list. Also covers the Tab +// toggle between the pending queue and the recent decisions view using a mock +// Smithers HTTP server. +// +// Set SMITHERS_TUI_E2E=1 to run these tests. + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// TestApprovalsApproveAction_RemovesItemFromQueue launches the TUI against a +// mock server with two pending approvals, opens the approvals view, and presses +// 'a' to approve the first item. The approved item should disappear from the +// list, leaving only the second approval visible. +func TestApprovalsApproveAction_RemovesItemFromQueue(t *testing.T) { + if os.Getenv("SMITHERS_TUI_E2E") != "1" { + t.Skip("set SMITHERS_TUI_E2E=1 to run terminal E2E tests") + } + + var ( + mu sync.Mutex + approvals = []mockApproval{ + {ID: "appr-1", RunID: "run-abc", NodeID: "deploy", Gate: "Deploy to staging", Status: "pending"}, + {ID: "appr-2", RunID: "run-xyz", NodeID: "notify", Gate: "Send notification", Status: "pending"}, + } + approvedIDs []string + ) + + mux := http.NewServeMux() + + // Health endpoint. + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + // Approvals list — returns current state of the slice. + mux.HandleFunc("/approval/list", func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + snapshot := make([]mockApproval, len(approvals)) + copy(snapshot, approvals) + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "data": snapshot}) + }) + + // Approve endpoint — POST /v1/runs/:runID/nodes/:nodeID/approve + mux.HandleFunc("/v1/runs/", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + // Extract the approvalID from the path and mark it as approved. + // Path shape: /v1/runs//nodes//approve|deny + parts := splitPath(r.URL.Path) + if len(parts) < 5 { + http.NotFound(w, r) + return + } + action := parts[len(parts)-1] // "approve" or "deny" + runID := parts[2] + + mu.Lock() + for i, a := range approvals { + if a.RunID == runID { + if action == "approve" { + approvedIDs = append(approvedIDs, a.ID) + approvals = append(approvals[:i], approvals[i+1:]...) + } else if action == "deny" { + approvals = append(approvals[:i], approvals[i+1:]...) + } + break + } + } + mu.Unlock() + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true}) + }) + + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + configDir := t.TempDir() + dataDir := t.TempDir() + writeGlobalConfig(t, configDir, `{ + "smithers": { + "apiUrl": "`+srv.URL+`", + "dbPath": ".smithers/smithers.db", + "workflowDir": ".smithers/workflows" + } +}`) + t.Setenv("SMITHERS_TUI_GLOBAL_CONFIG", configDir) + t.Setenv("SMITHERS_TUI_GLOBAL_DATA", dataDir) + + tui := launchTUI(t) + defer tui.Terminate() + + // Wait for the TUI to start. + require.NoError(t, tui.WaitForText("SMITHERS", 15*time.Second)) + + // Open approvals view via Ctrl+A. + tui.SendKeys("\x01") // ctrl+a + require.NoError(t, tui.WaitForText("SMITHERS \u203a Approvals", 5*time.Second), + "approvals header must appear; buffer:\n%s", tui.Snapshot()) + + // Both pending approvals should be visible. + require.NoError(t, tui.WaitForText("Deploy to staging", 5*time.Second), + "first approval must be visible; buffer:\n%s", tui.Snapshot()) + require.NoError(t, tui.WaitForText("Send notification", 5*time.Second), + "second approval must be visible; buffer:\n%s", tui.Snapshot()) + + // Approve the first item with 'a'. + tui.SendKeys("a") + + // After approval the first item should disappear; second should remain. + require.NoError(t, tui.WaitForNoText("Deploy to staging", 8*time.Second), + "approved item must be removed from queue; buffer:\n%s", tui.Snapshot()) + require.NoError(t, tui.WaitForText("Send notification", 5*time.Second), + "remaining approval must still be visible; buffer:\n%s", tui.Snapshot()) + + // Escape returns to chat. + tui.SendKeys("\x1b") + require.NoError(t, tui.WaitForNoText("SMITHERS \u203a Approvals", 5*time.Second)) +} + +// TestApprovalsDenyAction_RemovesItemFromQueue launches the TUI against a mock +// server with one pending approval and verifies that pressing 'd' to deny +// removes the item and shows the empty-queue message. +func TestApprovalsDenyAction_RemovesItemFromQueue(t *testing.T) { + if os.Getenv("SMITHERS_TUI_E2E") != "1" { + t.Skip("set SMITHERS_TUI_E2E=1 to run terminal E2E tests") + } + + var ( + mu sync.Mutex + pending = true + ) + + mux := http.NewServeMux() + + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + mux.HandleFunc("/approval/list", func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + isPending := pending + mu.Unlock() + + w.Header().Set("Content-Type", "application/json") + var data []mockApproval + if isPending { + data = []mockApproval{ + {ID: "appr-deny-1", RunID: "run-deny", NodeID: "rm-data", Gate: "Delete user records", Status: "pending"}, + } + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "data": data}) + }) + + // Deny endpoint. + mux.HandleFunc("/v1/runs/", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + mu.Lock() + pending = false + mu.Unlock() + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true}) + }) + + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + configDir := t.TempDir() + dataDir := t.TempDir() + writeGlobalConfig(t, configDir, `{ + "smithers": { + "apiUrl": "`+srv.URL+`", + "dbPath": ".smithers/smithers.db", + "workflowDir": ".smithers/workflows" + } +}`) + t.Setenv("SMITHERS_TUI_GLOBAL_CONFIG", configDir) + t.Setenv("SMITHERS_TUI_GLOBAL_DATA", dataDir) + + tui := launchTUI(t) + defer tui.Terminate() + + require.NoError(t, tui.WaitForText("SMITHERS", 15*time.Second)) + + tui.SendKeys("\x01") // ctrl+a + require.NoError(t, tui.WaitForText("SMITHERS \u203a Approvals", 5*time.Second), + "approvals header; buffer:\n%s", tui.Snapshot()) + + require.NoError(t, tui.WaitForText("Delete user records", 5*time.Second), + "pending approval must render; buffer:\n%s", tui.Snapshot()) + + // Deny with 'd'. + tui.SendKeys("d") + + // After denial the item must disappear and the empty-queue message should show. + require.NoError(t, tui.WaitForNoText("Delete user records", 8*time.Second), + "denied item must be removed; buffer:\n%s", tui.Snapshot()) + + // Empty state message should appear. + require.NoError(t, tui.WaitForText("No pending approvals", 5*time.Second), + "empty queue state must show after denial; buffer:\n%s", tui.Snapshot()) + + tui.SendKeys("\x1b") + require.NoError(t, tui.WaitForNoText("SMITHERS \u203a Approvals", 5*time.Second)) +} + +// TestApprovalsTabToggle_QueueToRecentAndBack verifies the full Tab-toggle +// lifecycle: pending queue → recent decisions → back to pending. +// The mock server provides both pending approvals and recent decisions. +func TestApprovalsTabToggle_QueueToRecentAndBack(t *testing.T) { + if os.Getenv("SMITHERS_TUI_E2E") != "1" { + t.Skip("set SMITHERS_TUI_E2E=1 to run terminal E2E tests") + } + + now := time.Now().UnixMilli() + recentDecision := map[string]interface{}{ + "id": "dec-1", + "runId": "run-rec", + "nodeId": "build", + "gate": "Build artifact", + "decision": "approved", + "decidedAt": now - 60000, + "requestedAt": now - 120000, + } + + mux := http.NewServeMux() + + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + mux.HandleFunc("/approval/list", func(w http.ResponseWriter, r *http.Request) { + data := []mockApproval{ + {ID: "appr-tab-1", RunID: "run-tab", NodeID: "test", Gate: "Run test suite", Status: "pending"}, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "data": data}) + }) + + mux.HandleFunc("/approval/decisions", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "data": []interface{}{recentDecision}}) + }) + + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + configDir := t.TempDir() + dataDir := t.TempDir() + writeGlobalConfig(t, configDir, `{ + "smithers": { + "apiUrl": "`+srv.URL+`", + "dbPath": ".smithers/smithers.db", + "workflowDir": ".smithers/workflows" + } +}`) + t.Setenv("SMITHERS_TUI_GLOBAL_CONFIG", configDir) + t.Setenv("SMITHERS_TUI_GLOBAL_DATA", dataDir) + + tui := launchTUI(t) + defer tui.Terminate() + + require.NoError(t, tui.WaitForText("SMITHERS", 15*time.Second)) + + tui.SendKeys("\x01") // ctrl+a + require.NoError(t, tui.WaitForText("SMITHERS \u203a Approvals", 5*time.Second)) + + // Pending queue should be shown first. + require.NoError(t, tui.WaitForText("Run test suite", 5*time.Second), + "pending approval must render initially; buffer:\n%s", tui.Snapshot()) + + // Tab should switch to recent decisions. + tui.SendKeys("\t") + require.NoError(t, tui.WaitForText("RECENT DECISIONS", 5*time.Second), + "Tab must switch to recent decisions; buffer:\n%s", tui.Snapshot()) + + // The mode hint should advertise the pending queue as the way back. + require.NoError(t, tui.WaitForText("Pending", 3*time.Second), + "mode hint should mention Pending; buffer:\n%s", tui.Snapshot()) + + // Navigate in recent decisions (should not crash even if list is short). + tui.SendKeys("j") + time.Sleep(100 * time.Millisecond) + tui.SendKeys("k") + time.Sleep(100 * time.Millisecond) + + // Refresh recent decisions. + tui.SendKeys("r") + require.NoError(t, tui.WaitForText("RECENT DECISIONS", 5*time.Second), + "refresh must keep recent decisions view; buffer:\n%s", tui.Snapshot()) + + // Tab again → back to pending queue. + tui.SendKeys("\t") + require.NoError(t, tui.WaitForNoText("RECENT DECISIONS", 3*time.Second), + "second Tab must return to pending queue; buffer:\n%s", tui.Snapshot()) + + // Escape to chat. + tui.SendKeys("\x1b") + require.NoError(t, tui.WaitForNoText("SMITHERS \u203a Approvals", 5*time.Second)) +} + +// TestApprovalsQueue_EmptyState verifies the empty-queue message when the mock +// server returns no pending approvals. +func TestApprovalsQueue_EmptyState(t *testing.T) { + if os.Getenv("SMITHERS_TUI_E2E") != "1" { + t.Skip("set SMITHERS_TUI_E2E=1 to run terminal E2E tests") + } + + mux := http.NewServeMux() + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("/approval/list", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "data": []mockApproval{}}) + }) + + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + configDir := t.TempDir() + dataDir := t.TempDir() + writeGlobalConfig(t, configDir, `{ + "smithers": { + "apiUrl": "`+srv.URL+`", + "dbPath": ".smithers/smithers.db", + "workflowDir": ".smithers/workflows" + } +}`) + t.Setenv("SMITHERS_TUI_GLOBAL_CONFIG", configDir) + t.Setenv("SMITHERS_TUI_GLOBAL_DATA", dataDir) + + tui := launchTUI(t) + defer tui.Terminate() + + require.NoError(t, tui.WaitForText("SMITHERS", 15*time.Second)) + + tui.SendKeys("\x01") // ctrl+a + require.NoError(t, tui.WaitForText("SMITHERS \u203a Approvals", 5*time.Second)) + + require.NoError(t, tui.WaitForText("No pending approvals", 5*time.Second), + "empty state message must appear; buffer:\n%s", tui.Snapshot()) + + tui.SendKeys("\x1b") + require.NoError(t, tui.WaitForNoText("SMITHERS \u203a Approvals", 5*time.Second)) +} + +// splitPath splits a URL path on "/" and returns non-empty segments. +func splitPath(p string) []string { + var parts []string + for _, s := range splitSlash(p) { + if s != "" { + parts = append(parts, s) + } + } + return parts +} + +// splitSlash splits s on "/" without importing strings in the test file. +func splitSlash(s string) []string { + var result []string + start := 0 + for i := 0; i <= len(s); i++ { + if i == len(s) || s[i] == '/' { + result = append(result, s[start:i]) + start = i + 1 + } + } + return result +} diff --git a/internal/e2e/approvals_queue_test.go b/internal/e2e/approvals_queue_test.go new file mode 100644 index 00000000..b64f0b63 --- /dev/null +++ b/internal/e2e/approvals_queue_test.go @@ -0,0 +1,198 @@ +package e2e_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// TestApprovalsQueue_Navigation exercises the full approvals queue lifecycle: +// - Opening the approvals view via Ctrl+A. +// - Verifying the "SMITHERS › Approvals" header and pending approvals are visible. +// - Moving the cursor with j/k. +// - Pressing r to refresh. +// - Pressing Esc to return to the chat view. +// +// Set SMITHERS_TUI_E2E=1 to run this test (it spawns a real TUI process). +func TestApprovalsQueue_Navigation(t *testing.T) { + if os.Getenv("SMITHERS_TUI_E2E") != "1" { + t.Skip("set SMITHERS_TUI_E2E=1 to run terminal E2E tests") + } + + tui := launchTUI(t) + defer tui.Terminate() + + // Wait for the TUI to start. + require.NoError(t, tui.WaitForText("SMITHERS", 15*time.Second)) + + // Open approvals view via Ctrl+A. + tui.SendKeys("\x01") // ctrl+a + require.NoError(t, tui.WaitForText("SMITHERS \u203a Approvals", 5*time.Second)) + + // Move cursor down then up — should not crash. + tui.SendKeys("j") + time.Sleep(100 * time.Millisecond) + tui.SendKeys("k") + time.Sleep(100 * time.Millisecond) + + // Refresh. + tui.SendKeys("r") + require.NoError(t, tui.WaitForText("SMITHERS \u203a Approvals", 5*time.Second)) + + // Escape should return to the chat/console view. + tui.SendKeys("\x1b") + require.NoError(t, tui.WaitForNoText("SMITHERS \u203a Approvals", 5*time.Second)) +} + +// TestApprovalsQueue_WithMockServer exercises the approvals queue against a +// mock Smithers HTTP server that returns two pending approvals. +// +// Set SMITHERS_TUI_E2E=1 to run this test. +func TestApprovalsQueue_WithMockServer(t *testing.T) { + if os.Getenv("SMITHERS_TUI_E2E") != "1" { + t.Skip("set SMITHERS_TUI_E2E=1 to run terminal E2E tests") + } + + // Set up a mock Smithers HTTP server. + mockServer := newMockSmithersServer(t, []mockApproval{ + {ID: "appr-1", RunID: "run-abc", NodeID: "deploy", Gate: "Deploy to staging", Status: "pending"}, + {ID: "appr-2", RunID: "run-xyz", NodeID: "delete", Gate: "Delete user data", Status: "pending"}, + }) + defer mockServer.Close() + + configDir := t.TempDir() + dataDir := t.TempDir() + writeGlobalConfig(t, configDir, `{ + "smithers": { + "apiUrl": "`+mockServer.URL+`", + "dbPath": ".smithers/smithers.db", + "workflowDir": ".smithers/workflows" + } +}`) + t.Setenv("SMITHERS_TUI_GLOBAL_CONFIG", configDir) + t.Setenv("SMITHERS_TUI_GLOBAL_DATA", dataDir) + + tui := launchTUI(t) + defer tui.Terminate() + + // Wait for the TUI to start. + require.NoError(t, tui.WaitForText("SMITHERS", 15*time.Second)) + + // Open approvals view via Ctrl+A. + tui.SendKeys("\x01") // ctrl+a + require.NoError(t, tui.WaitForText("SMITHERS \u203a Approvals", 5*time.Second), + "should show approvals header; buffer: %s", tui.Snapshot()) + + require.NoError(t, tui.WaitForText("Pending", 5*time.Second), + "should show the pending approvals section; buffer: %s", tui.Snapshot()) + require.NoError(t, tui.WaitForText("Deploy to staging", 5*time.Second), + "should show first approval label; buffer: %s", tui.Snapshot()) + + require.NoError(t, tui.WaitForText("Delete user data", 5*time.Second), + "should show second approval label; buffer: %s", tui.Snapshot()) + + // Navigate with j (down) — should not crash. + tui.SendKeys("j") + time.Sleep(100 * time.Millisecond) + + // Refresh — list should re-render. + tui.SendKeys("r") + require.NoError(t, tui.WaitForText("Deploy to staging", 5*time.Second), + "refresh should re-render list; buffer: %s", tui.Snapshot()) + + // Escape should return to chat. + tui.SendKeys("\x1b") + require.NoError(t, tui.WaitForNoText("SMITHERS \u203a Approvals", 5*time.Second), + "esc should return to chat; buffer: %s", tui.Snapshot()) +} + +// TestApprovalsQueue_OpenViaCommandPalette opens the approvals view via the +// command palette rather than Ctrl+A. +// +// Set SMITHERS_TUI_E2E=1 to run this test. +func TestApprovalsQueue_OpenViaCommandPalette(t *testing.T) { + if os.Getenv("SMITHERS_TUI_E2E") != "1" { + t.Skip("set SMITHERS_TUI_E2E=1 to run terminal E2E tests") + } + + tui := launchTUI(t) + defer tui.Terminate() + + // Wait for the TUI to start. + require.NoError(t, tui.WaitForText("SMITHERS", 15*time.Second)) + + // Open command palette and navigate to approvals. + openCommandsPalette(t, tui) + tui.SendKeys("approvals") + require.NoError(t, tui.WaitForText("Approvals", 5*time.Second)) + + tui.SendKeys("\r") + require.NoError(t, tui.WaitForText("SMITHERS \u203a Approvals", 5*time.Second), + "should show approvals header via command palette; buffer: %s", tui.Snapshot()) + + // Should show loading or a state (no crash). + snap := tui.Snapshot() + _ = snap + + // Exit coverage lives in the direct Ctrl+A path; this slice only verifies + // that the command palette reaches the approvals view successfully. +} + +// --- Mock server helpers --- + +// mockApproval is a simplified approval record for the mock server. +type mockApproval struct { + ID string `json:"id"` + RunID string `json:"runId"` + NodeID string `json:"nodeId"` + WorkflowPath string `json:"workflowPath"` + Gate string `json:"gate"` + Status string `json:"status"` + RequestedAt int64 `json:"requestedAt"` +} + +// newMockSmithersServer creates an httptest.Server that mimics the Smithers +// HTTP API, returning the given approvals from GET /approval/list. +func newMockSmithersServer(t *testing.T, approvals []mockApproval) *httptest.Server { + t.Helper() + + // Set RequestedAt to "now" for approvals that don't specify it. + now := time.Now().UnixMilli() + for i := range approvals { + if approvals[i].RequestedAt == 0 { + approvals[i].RequestedAt = now + } + } + + mux := http.NewServeMux() + + // Health endpoint — used by isServerAvailable(). + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + // Approvals list endpoint. + mux.HandleFunc("/approval/list", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + resp := map[string]interface{}{ + "ok": true, + "data": approvals, + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(resp); err != nil { + t.Errorf("encode approvals response: %v", err) + } + }) + + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} diff --git a/internal/e2e/approvals_recent_decisions_test.go b/internal/e2e/approvals_recent_decisions_test.go new file mode 100644 index 00000000..af4265bd --- /dev/null +++ b/internal/e2e/approvals_recent_decisions_test.go @@ -0,0 +1,68 @@ +package e2e_test + +import ( + "os" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// TestApprovalsRecentDecisions_TUI exercises the approvals view recent decisions flow: +// - Opening the command palette and navigating to the approvals view. +// - Verifying the "SMITHERS › Approvals" header is visible. +// - Pressing Tab to switch to the recent decisions view. +// - Verifying the "RECENT DECISIONS" section header appears. +// - Pressing Tab again to return to the pending queue. +// - Pressing Esc to leave the view. +// +// Set SMITHERS_TUI_E2E=1 to run this test (it spawns a real TUI process). +func TestApprovalsRecentDecisions_TUI(t *testing.T) { + if os.Getenv("SMITHERS_TUI_E2E") != "1" { + t.Skip("set SMITHERS_TUI_E2E=1 to run terminal E2E tests") + } + + tui := launchTUI(t) + defer tui.Terminate() + + // Wait for the TUI to start. + require.NoError(t, tui.WaitForText("SMITHERS", 15*time.Second)) + + openCommandsPalette(t, tui) + tui.SendKeys("approvals") + require.NoError(t, tui.WaitForText("Approvals", 5*time.Second)) + + // Navigate to the approvals view. + tui.SendKeys("\r") + require.NoError(t, tui.WaitForText("SMITHERS \u203a Approvals", 5*time.Second)) + + // The pending queue is displayed first. The mode hint should mention [Tab] History. + snap := tui.Snapshot() + hasPendingMode := tui.matchesText("History") || tui.matchesText("Tab") + _ = snap + require.True(t, hasPendingMode, "approvals view should show tab/history hint in pending mode") + + // Press Tab to switch to recent decisions. + tui.SendKeys("\t") + require.NoError(t, tui.WaitForText("RECENT DECISIONS", 5*time.Second)) + + // The mode hint should now mention the pending queue. + require.NoError(t, tui.WaitForText("Pending", 3*time.Second)) + + // Navigate down/up in the decisions list (should not crash even if empty). + tui.SendKeys("j") + time.Sleep(100 * time.Millisecond) + tui.SendKeys("k") + time.Sleep(100 * time.Millisecond) + + // Refresh the decisions list. + tui.SendKeys("r") + require.NoError(t, tui.WaitForText("RECENT DECISIONS", 5*time.Second)) + + // Press Tab again to return to pending queue. + tui.SendKeys("\t") + require.NoError(t, tui.WaitForNoText("RECENT DECISIONS", 3*time.Second)) + + // The direct-shortcut tests cover leaving the approvals view; this slice is + // scoped to the command-palette entry point and the recent-decisions toggle. +} diff --git a/internal/e2e/changes_diff_tmux_test.go b/internal/e2e/changes_diff_tmux_test.go new file mode 100644 index 00000000..391e9884 --- /dev/null +++ b/internal/e2e/changes_diff_tmux_test.go @@ -0,0 +1,436 @@ +package e2e_test + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type tmuxSession struct { + name string + t *testing.T +} + +func repoRoot(t *testing.T) string { + t.Helper() + + root, err := filepath.Abs(filepath.Join("..", "..")) + require.NoError(t, err) + return root +} + +func launchTmuxSession(t *testing.T, binary, workingDir, configDir, dataDir, fakeBin string) *tmuxSession { + return launchTmuxSessionWithEnv(t, binary, workingDir, configDir, dataDir, fakeBin, nil) +} + +func launchTmuxSessionWithEnv( + t *testing.T, + binary, + workingDir, + configDir, + dataDir, + fakeBin string, + extraEnv map[string]string, +) *tmuxSession { + t.Helper() + + session := fmt.Sprintf("crush-changes-%d", time.Now().UnixNano()) + scriptPath := filepath.Join(t.TempDir(), "launch.sh") + var envBuilder strings.Builder + for key, value := range extraEnv { + envBuilder.WriteString(fmt.Sprintf("export %s=%q\n", key, value)) + } + script := fmt.Sprintf(`#!/bin/sh +cd %q +export TERM=xterm-256color +export COLORTERM=truecolor +export LANG=en_US.UTF-8 +export CRUSH_GLOBAL_CONFIG=%q +export CRUSH_GLOBAL_DATA=%q +export PATH=%q +%s +exec %q +`, workingDir, configDir, dataDir, fakeBin+":/usr/bin:/bin", envBuilder.String(), binary) + require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o755)) + + cmd := exec.Command("tmux", "new-session", "-d", "-s", session, "-x", "120", "-y", "40", scriptPath) + require.NoError(t, cmd.Run()) + + return &tmuxSession{name: session, t: t} +} + +func (s *tmuxSession) SendKeys(keys ...string) { + s.t.Helper() + args := append([]string{"send-keys", "-t", s.name}, keys...) + require.NoError(s.t, exec.Command("tmux", args...).Run()) +} + +func (s *tmuxSession) Capture() string { + s.t.Helper() + out, err := exec.Command("tmux", "capture-pane", "-t", s.name, "-p").CombinedOutput() + require.NoError(s.t, err) + return strings.ReplaceAll(string(out), "\r", "") +} + +func (s *tmuxSession) WaitForText(text string, timeout time.Duration) { + s.t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if strings.Contains(s.Capture(), text) { + return + } + time.Sleep(100 * time.Millisecond) + } + s.t.Fatalf("waitForText: %q not found within %s\nPane:\n%s", text, timeout, s.Capture()) +} + +func (s *tmuxSession) WaitForNoText(text string, timeout time.Duration) { + s.t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if !strings.Contains(s.Capture(), text) { + return + } + time.Sleep(100 * time.Millisecond) + } + s.t.Fatalf("waitForNoText: %q still present after %s\nPane:\n%s", text, timeout, s.Capture()) +} + +func (s *tmuxSession) Close() { + s.t.Helper() + _ = exec.Command("tmux", "kill-session", "-t", s.name).Run() +} + +func buildTUIBinary(t *testing.T) string { + t.Helper() + + binary := filepath.Join(t.TempDir(), "smithers-tui") + cmd := exec.Command("go", "build", "-o", binary, "./main.go") + cmd.Dir = repoRoot(t) + require.NoError(t, cmd.Run()) + return binary +} + +func writeFakeJJHub(t *testing.T) string { + t.Helper() + + binDir := t.TempDir() + jjhubPath := filepath.Join(binDir, "jjhub") + jjhubScript := `#!/bin/sh +case "$1 $2" in + "change list") + cat <<'EOF' +[{"change_id":"abc123","commit_id":"deadbeef12345678","description":"Test change for tmux e2e","author":{"name":"Test User","email":"test@example.com"},"timestamp":"2025-01-15T12:34:56Z","is_empty":false,"is_working_copy":true,"bookmarks":["main"]}] +EOF + ;; + "change diff") + cat <<'EOF' +diff --git a/example.txt b/example.txt +index 1111111..2222222 100644 +--- a/example.txt ++++ b/example.txt +@@ -1 +1 @@ +-before ++after +EOF + ;; + "land list"|"issue list"|"workspace list") + printf '[]\n' + ;; + "repo view") + cat <<'EOF' +{"name":"demo","full_name":"demo/repo"} +EOF + ;; + *) + printf '[]\n' + ;; +esac +` + require.NoError(t, os.WriteFile(jjhubPath, []byte(jjhubScript), 0o755)) + + jjPath := filepath.Join(binDir, "jj") + jjScript := `#!/bin/sh +if [ "$1" = "diff" ]; then + cat <<'EOF' +diff --git a/example.txt b/example.txt +index 1111111..2222222 100644 +--- a/example.txt ++++ b/example.txt +@@ -1 +1 @@ +-before ++after +EOF + exit 0 +fi + +printf 'unsupported jj invocation: %s\n' "$*" >&2 +exit 1 +` + require.NoError(t, os.WriteFile(jjPath, []byte(jjScript), 0o755)) + return binDir +} + +func writeFakeDiffnav(t *testing.T, binDir string) (string, string) { + t.Helper() + + argsFile := filepath.Join(binDir, "diffnav-args.txt") + stdinFile := filepath.Join(binDir, "diffnav-stdin.patch") + scriptPath := filepath.Join(binDir, "diffnav") + script := fmt.Sprintf(`#!/bin/sh +printf '%%s\n' "$@" > %q +cat > %q +`, argsFile, stdinFile) + require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o755)) + return argsFile, stdinFile +} + +func writeFakeFailingDiffnav(t *testing.T, binDir string) { + t.Helper() + + scriptPath := filepath.Join(binDir, "diffnav") + script := `#!/bin/sh +cat >/dev/null +printf 'Caught panic: divide by zero\n' >&2 +exit 1 +` + require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o755)) +} + +func writeFakePager(t *testing.T, binDir string) (string, string, string) { + t.Helper() + + pathFile := filepath.Join(binDir, "pager-path.txt") + contentFile := filepath.Join(binDir, "pager-content.diff") + scriptPath := filepath.Join(binDir, "fake-pager") + script := fmt.Sprintf(`#!/bin/sh +printf '%%s\n' "$1" > %q +cat "$1" > %q +`, pathFile, contentFile) + require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o755)) + return scriptPath, pathFile, contentFile +} + +func TestChangesView_DiffPromptAndEscape_TmuxE2E(t *testing.T) { + if os.Getenv("CRUSH_TUI_E2E") != "1" { + t.Skip("set CRUSH_TUI_E2E=1 to run terminal E2E tests") + } + if _, err := exec.LookPath("tmux"); err != nil { + t.Skip("tmux is required for this e2e test") + } + + configDir := t.TempDir() + dataDir := t.TempDir() + writeGlobalConfig(t, configDir, `{ + "options": { + "disable_default_providers": true + }, + "providers": { + "test": { + "api_key": "test-key", + "base_url": "https://example.invalid/v1", + "models": [ + { + "id": "test-model" + } + ] + } + }, + "models": { + "large": { + "provider": "test", + "model": "test-model" + }, + "small": { + "provider": "test", + "model": "test-model" + } + }, + "smithers": { + "dbPath": ".smithers/smithers.db", + "workflowDir": ".smithers/workflows" + } +}`) + + binary := buildTUIBinary(t) + fakeBin := writeFakeJJHub(t) + workingDir := repoRoot(t) + + session := launchTmuxSession(t, binary, workingDir, configDir, dataDir, fakeBin) + defer session.Close() + + session.WaitForText("SMITHERS", 15*time.Second) + session.SendKeys("6") + time.Sleep(300 * time.Millisecond) + session.SendKeys("Enter") + session.WaitForText("JJHub › Changes", 10*time.Second) + session.WaitForText("Test change for tmux e2e", 10*time.Second) + + session.SendKeys("d") + session.WaitForText("diffnav not installed", 5*time.Second) + + session.SendKeys("Escape") + session.WaitForNoText("JJHub › Changes", 5*time.Second) + session.WaitForText("SMITHERS", 5*time.Second) +} + +func TestChangesView_InstalledDiffnavUsesStdin_TmuxE2E(t *testing.T) { + if os.Getenv("CRUSH_TUI_E2E") != "1" { + t.Skip("set CRUSH_TUI_E2E=1 to run terminal E2E tests") + } + if _, err := exec.LookPath("tmux"); err != nil { + t.Skip("tmux is required for this e2e test") + } + + configDir := t.TempDir() + dataDir := t.TempDir() + writeGlobalConfig(t, configDir, `{ + "options": { + "disable_default_providers": true + }, + "providers": { + "test": { + "api_key": "test-key", + "base_url": "https://example.invalid/v1", + "models": [ + { + "id": "test-model" + } + ] + } + }, + "models": { + "large": { + "provider": "test", + "model": "test-model" + }, + "small": { + "provider": "test", + "model": "test-model" + } + }, + "smithers": { + "dbPath": ".smithers/smithers.db", + "workflowDir": ".smithers/workflows" + } +}`) + + binary := buildTUIBinary(t) + fakeBin := writeFakeJJHub(t) + argsFile, stdinFile := writeFakeDiffnav(t, fakeBin) + workingDir := repoRoot(t) + + session := launchTmuxSession(t, binary, workingDir, configDir, dataDir, fakeBin) + defer session.Close() + + session.WaitForText("SMITHERS", 15*time.Second) + session.SendKeys("6") + time.Sleep(300 * time.Millisecond) + session.SendKeys("Enter") + session.WaitForText("JJHub › Changes", 10*time.Second) + session.WaitForText("Test change for tmux e2e", 10*time.Second) + session.SendKeys("d") + + require.Eventually(t, func() bool { + _, err := os.Stat(stdinFile) + return err == nil + }, 5*time.Second, 100*time.Millisecond) + + args, err := os.ReadFile(argsFile) + require.NoError(t, err) + require.Equal(t, "", strings.TrimSpace(string(args))) + + patch, err := os.ReadFile(stdinFile) + require.NoError(t, err) + require.Contains(t, string(patch), "diff --git a/example.txt b/example.txt") + require.Contains(t, string(patch), "+after") +} + +func TestChangesView_DiffnavFailureFallsBackToPager_TmuxE2E(t *testing.T) { + if os.Getenv("CRUSH_TUI_E2E") != "1" { + t.Skip("set CRUSH_TUI_E2E=1 to run terminal E2E tests") + } + if _, err := exec.LookPath("tmux"); err != nil { + t.Skip("tmux is required for this e2e test") + } + + configDir := t.TempDir() + dataDir := t.TempDir() + writeGlobalConfig(t, configDir, `{ + "options": { + "disable_default_providers": true + }, + "providers": { + "test": { + "api_key": "test-key", + "base_url": "https://example.invalid/v1", + "models": [ + { + "id": "test-model" + } + ] + } + }, + "models": { + "large": { + "provider": "test", + "model": "test-model" + }, + "small": { + "provider": "test", + "model": "test-model" + } + }, + "smithers": { + "dbPath": ".smithers/smithers.db", + "workflowDir": ".smithers/workflows" + } +}`) + + binary := buildTUIBinary(t) + fakeBin := writeFakeJJHub(t) + writeFakeFailingDiffnav(t, fakeBin) + pagerPath, pagerPathFile, pagerContentFile := writeFakePager(t, fakeBin) + workingDir := repoRoot(t) + + session := launchTmuxSessionWithEnv(t, binary, workingDir, configDir, dataDir, fakeBin, map[string]string{ + "PAGER": pagerPath, + }) + defer session.Close() + + session.WaitForText("SMITHERS", 15*time.Second) + session.SendKeys("6") + time.Sleep(300 * time.Millisecond) + session.SendKeys("Enter") + session.WaitForText("JJHub › Changes", 10*time.Second) + session.WaitForText("Test change for tmux e2e", 10*time.Second) + session.SendKeys("d") + + require.Eventually(t, func() bool { + _, err := os.Stat(pagerContentFile) + return err == nil + }, 5*time.Second, 100*time.Millisecond) + + content, err := os.ReadFile(pagerContentFile) + require.NoError(t, err) + require.Contains(t, string(content), "diff --git a/example.txt b/example.txt") + require.Contains(t, string(content), "+after") + + pathBytes, err := os.ReadFile(pagerPathFile) + require.NoError(t, err) + diffPath := strings.TrimSpace(string(pathBytes)) + require.NotEmpty(t, diffPath) + + require.Eventually(t, func() bool { + _, err := os.Stat(diffPath) + return os.IsNotExist(err) + }, 5*time.Second, 100*time.Millisecond) + + session.WaitForText("JJHub › Changes", 5*time.Second) +} diff --git a/internal/e2e/chat_active_run_summary_test.go b/internal/e2e/chat_active_run_summary_test.go new file mode 100644 index 00000000..f55387f3 --- /dev/null +++ b/internal/e2e/chat_active_run_summary_test.go @@ -0,0 +1,64 @@ +package e2e_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestChatActiveRunSummary_TUI(t *testing.T) { + if os.Getenv("SMITHERS_TUI_E2E") != "1" { + t.Skip("set SMITHERS_TUI_E2E=1 to run terminal E2E tests") + } + + type run struct { + RunID string `json:"runId"` + WorkflowName string `json:"workflowName"` + Status string `json:"status"` + } + + // Serve a minimal Smithers API that returns 2 active runs. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/health": + w.WriteHeader(http.StatusOK) + case "/v1/runs": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]run{ + {RunID: "r1", WorkflowName: "code-review", Status: "running"}, + {RunID: "r2", WorkflowName: "deploy", Status: "running"}, + }) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + configDir := t.TempDir() + dataDir := t.TempDir() + writeGlobalConfig(t, configDir, `{ + "smithers": { + "apiUrl": "`+srv.URL+`" + } +}`) + t.Setenv("SMITHERS_TUI_GLOBAL_CONFIG", configDir) + t.Setenv("SMITHERS_TUI_GLOBAL_DATA", dataDir) + + tui := launchTUI(t) + defer tui.Terminate() + + // Header branding must appear first. + require.NoError(t, tui.WaitForText("SMITHERS", 15*time.Second)) + + // Active run count must appear within two poll cycles (≤ 25 s). + // The startup fetch fires before the 10-second tick, so the count + // should appear within a few seconds of launch. + require.NoError(t, tui.WaitForText("2 active", 25*time.Second)) + + tui.SendKeys("\x03") +} diff --git a/internal/e2e/chat_default_console_test.go b/internal/e2e/chat_default_console_test.go index 1fbe69ef..953ecf81 100644 --- a/internal/e2e/chat_default_console_test.go +++ b/internal/e2e/chat_default_console_test.go @@ -2,62 +2,41 @@ package e2e_test import ( "os" - "path/filepath" - "strings" "testing" "time" + + "github.com/stretchr/testify/require" ) -// TestChatDefaultConsole verifies that chat is the default view on startup -// when Smithers config is present. +// TestChatDefaultConsole verifies that a Smithers-configured launch skips the +// generic landing view and opens on the Smithers dashboard. func TestChatDefaultConsole(t *testing.T) { if os.Getenv("CRUSH_TUI_E2E") == "" { t.Skip("Skipping E2E test: set CRUSH_TUI_E2E=1 to run") } - // Create a temporary directory for the test config and data - tmpDir := t.TempDir() - dataDir := filepath.Join(tmpDir, "data") - if err := os.Mkdir(dataDir, 0755); err != nil { - t.Fatalf("create data dir: %v", err) - } - - // Create a minimal crush.json with Smithers config - configPath := filepath.Join(tmpDir, "crush.json") - configContent := `{ - "defaultModel": "claude-opus-4-6", + configDir := t.TempDir() + dataDir := t.TempDir() + writeGlobalConfig(t, configDir, `{ "smithers": { - "dbPath": ".smithers/smithers.db", "apiUrl": "http://localhost:7331", + "dbPath": ".smithers/smithers.db", "workflowDir": ".smithers/workflows" } -}` - if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { - t.Fatalf("write config: %v", err) - } +}`) - // Launch TUI with test config - tui := launchTUI(t, - "--config", configPath, - "--data-dir", dataDir, - "--skip-version-check", - ) - defer tui.Terminate() - - // Wait for initial render and verify chat prompt is visible - if err := tui.WaitForText("Ready", 5*time.Second); err != nil { - t.Logf("Initial render snapshot:\n%s", tui.Snapshot()) - t.Errorf("expected chat prompt 'Ready' at startup: %v", err) - } + t.Setenv("CRUSH_GLOBAL_CONFIG", configDir) + t.Setenv("CRUSH_GLOBAL_DATA", dataDir) - text := tui.bufferText() + tui := launchTUI(t) + defer tui.Terminate() - // Verify no landing view elements are present (Smithers mode should skip landing) - // Landing view shows model information and LSP/MCP status in columns - if strings.Contains(text, "LSP") && strings.Contains(text, "MCP") { - t.Logf("Unexpected landing view detected:\n%s", text) - // This is informational; landing might appear during init, but should transition to chat - } + require.NoError(t, tui.WaitForText("SMITHERS", 15*time.Second)) + require.NoError(t, tui.WaitForText("At a Glance", 10*time.Second), + "smithers dashboard should render at startup; buffer:\n%s", tui.Snapshot()) + require.NoError(t, tui.WaitForText("Start Chat", 5*time.Second), + "dashboard quick actions should be visible; buffer:\n%s", tui.Snapshot()) + require.NoError(t, tui.WaitForNoText("Unknown flag", 2*time.Second)) } // TestEscReturnsToChat verifies that Esc from a pushed view returns to chat. @@ -70,7 +49,7 @@ func TestEscReturnsToChat(t *testing.T) { // This test is a placeholder that would require: // 1. Mocking Smithers client to return agents - // 2. Sending key presses to open agents view (Ctrl+P, then /agents) + // 2. Sending key presses to open agents view (Ctrl+P, then agents) // 3. Verifying agents view is displayed // 4. Sending Esc key // 5. Verifying return to chat console diff --git a/internal/e2e/chat_domain_system_prompt_test.go b/internal/e2e/chat_domain_system_prompt_test.go index 6f2075f6..1ac57094 100644 --- a/internal/e2e/chat_domain_system_prompt_test.go +++ b/internal/e2e/chat_domain_system_prompt_test.go @@ -36,22 +36,31 @@ func TestSmithersDomainSystemPrompt_TUI(t *testing.T) { defer tui.Terminate() require.NoError(t, tui.WaitForText("SMITHERS", 15*time.Second)) + require.NoError(t, tui.WaitForText("Run Dashboard", 10*time.Second)) + require.NoError(t, tui.WaitForText("Workflows", 5*time.Second)) + require.NoError(t, tui.WaitForNoText("Init Smithers", 3*time.Second)) } -func TestSmithersDomainSystemPrompt_CoderFallback_TUI(t *testing.T) { +// TestCoderAgentFallback_TUI verifies that the TUI still loads normally when no +// Smithers config block is provided, and that Smithers-specific UI labels are +// absent so the user is not misled about the active agent. +func TestCoderAgentFallback_TUI(t *testing.T) { if os.Getenv("SMITHERS_TUI_E2E") != "1" { t.Skip("set SMITHERS_TUI_E2E=1 to run terminal E2E tests") } configDir := t.TempDir() dataDir := t.TempDir() + projectDir := t.TempDir() writeGlobalConfig(t, configDir, `{}`) t.Setenv("SMITHERS_TUI_GLOBAL_CONFIG", configDir) t.Setenv("SMITHERS_TUI_GLOBAL_DATA", dataDir) - tui := launchTUI(t) + tui := launchTUIWithOptions(t, tuiLaunchOptions{workingDir: projectDir}) defer tui.Terminate() require.NoError(t, tui.WaitForText("SMITHERS", 15*time.Second)) + require.NoError(t, tui.WaitForText("Init Smithers", 10*time.Second)) + require.NoError(t, tui.WaitForNoText("Run Dashboard", 3*time.Second)) } diff --git a/internal/e2e/chat_mcp_connection_status_test.go b/internal/e2e/chat_mcp_connection_status_test.go new file mode 100644 index 00000000..029b5f3f --- /dev/null +++ b/internal/e2e/chat_mcp_connection_status_test.go @@ -0,0 +1,130 @@ +package e2e_test + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// TestChatMCPConnectionStatus_TUI verifies that the Smithers TUI header shows +// MCP connection status and updates dynamically. +// +// Set SMITHERS_TUI_E2E=1 to run. +func TestChatMCPConnectionStatus_TUI(t *testing.T) { + if os.Getenv("SMITHERS_TUI_E2E") != "1" { + t.Skip("set SMITHERS_TUI_E2E=1 to run terminal E2E tests") + } + + mockBin := buildMockMCPServer(t) + + configDir := t.TempDir() + dataDir := t.TempDir() + + // Write a global config that wires the mock MCP binary as the "smithers" MCP. + cfg := map[string]any{ + "mcp": map[string]any{ + "smithers": map[string]any{ + "type": "stdio", + "command": mockBin, + "args": []string{}, + }, + }, + } + cfgBytes, err := json.MarshalIndent(cfg, "", " ") + require.NoError(t, err) + writeGlobalConfig(t, configDir, string(cfgBytes)) + + t.Setenv("SMITHERS_TUI_GLOBAL_CONFIG", configDir) + t.Setenv("SMITHERS_TUI_GLOBAL_DATA", dataDir) + + tui := launchTUI(t) + defer tui.Terminate() + + // TUI must show SMITHERS branding. + require.NoError(t, tui.WaitForText("SMITHERS", 15*time.Second)) + openStartChatFromDashboard(t, tui) + + // The landing view must surface the connected MCP entry and tool count. + require.NoError(t, tui.WaitForText("smithers", 20*time.Second), + "MCP section should render the smithers entry after handshake\nSnapshot:\n%s", tui.Snapshot()) + require.NoError(t, tui.WaitForText("3 tools", 10*time.Second), + "MCP section should show the discovered tool count\nSnapshot:\n%s", tui.Snapshot()) + + tui.SendKeys("\x03") // ctrl+c +} + +// TestChatMCPConnectionStatus_DisconnectedOnStart_TUI verifies that when no +// Smithers MCP is configured the header shows "smithers disconnected". +// +// Set SMITHERS_TUI_E2E=1 to run. +func TestChatMCPConnectionStatus_DisconnectedOnStart_TUI(t *testing.T) { + if os.Getenv("SMITHERS_TUI_E2E") != "1" { + t.Skip("set SMITHERS_TUI_E2E=1 to run terminal E2E tests") + } + + configDir := t.TempDir() + dataDir := t.TempDir() + + // Config that configures a smithers MCP pointing at a command that doesn't + // exist so the MCP reaches StateError / stays disconnected. + cfg := map[string]any{ + "mcp": map[string]any{ + "smithers": map[string]any{ + "type": "stdio", + "command": "/nonexistent/smithers-binary", + "args": []string{}, + }, + }, + } + cfgBytes, err := json.MarshalIndent(cfg, "", " ") + require.NoError(t, err) + writeGlobalConfig(t, configDir, string(cfgBytes)) + + t.Setenv("SMITHERS_TUI_GLOBAL_CONFIG", configDir) + t.Setenv("SMITHERS_TUI_GLOBAL_DATA", dataDir) + + tui := launchTUI(t) + defer tui.Terminate() + + require.NoError(t, tui.WaitForText("SMITHERS", 15*time.Second)) + openStartChatFromDashboard(t, tui) + + require.NoError(t, tui.WaitForText("smithers", 20*time.Second), + "MCP section should still render the smithers entry when startup fails\nSnapshot:\n%s", tui.Snapshot()) + require.NoError(t, tui.WaitForText("error:", 10*time.Second), + "MCP section should show an error state when the command is missing\nSnapshot:\n%s", tui.Snapshot()) + require.NoError(t, tui.WaitForNoText("3 tools", 3*time.Second), + "tool count must not appear when the MCP command fails\nSnapshot:\n%s", tui.Snapshot()) + + tui.SendKeys("\x03") +} + +// buildMockMCPServer compiles the mock Smithers MCP server binary and returns +// its path. The binary is placed in a t.TempDir() so it is cleaned up after +// the test completes. +func buildMockMCPServer(t *testing.T) string { + t.Helper() + + repoRoot, err := filepath.Abs(filepath.Join("..", "..")) + require.NoError(t, err) + + srcPkg := filepath.Join(repoRoot, "internal", "e2e", "testdata", "mock_smithers_mcp") + binPath := filepath.Join(t.TempDir(), "mock_smithers_mcp") + + cmd := exec.Command("go", "build", "-o", binPath, ".") + cmd.Dir = srcPkg + out, err := cmd.CombinedOutput() + require.NoError(t, err, "build mock MCP server: %s", string(out)) + + if _, err := os.Stat(binPath); err != nil { + t.Fatalf("mock MCP binary not found at %s: %v", binPath, err) + } + fmt.Printf("mock MCP server built at %s\n", binPath) + return binPath +} diff --git a/internal/e2e/chat_workspace_context_test.go b/internal/e2e/chat_workspace_context_test.go new file mode 100644 index 00000000..39ff0b0d --- /dev/null +++ b/internal/e2e/chat_workspace_context_test.go @@ -0,0 +1,106 @@ +package e2e_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// TestSmithersWorkspaceContext_TUI launches the TUI with a mock Smithers HTTP +// server that returns active runs and verifies that the prompt template +// rendered the workspace context into the session (by observing the TUI boot +// up without crashing). +// +// Run with SMITHERS_TUI_E2E=1 to execute this test. +func TestSmithersWorkspaceContext_TUI(t *testing.T) { + if os.Getenv("SMITHERS_TUI_E2E") != "1" { + t.Skip("set SMITHERS_TUI_E2E=1 to run terminal E2E tests") + } + + // Spin up a local Smithers API mock. + srv := startWorkspaceContextMockServer(t) + defer srv.Close() + + configDir := t.TempDir() + dataDir := t.TempDir() + + cfg := map[string]interface{}{ + "smithers": map[string]interface{}{ + "apiUrl": srv.URL, + "workflowDir": ".smithers/workflows", + }, + } + cfgBytes, err := json.Marshal(cfg) + require.NoError(t, err) + + require.NoError(t, os.MkdirAll(configDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(configDir, "smithers-tui.json"), cfgBytes, 0o644)) + + t.Setenv("SMITHERS_TUI_GLOBAL_CONFIG", configDir) + t.Setenv("SMITHERS_TUI_GLOBAL_DATA", dataDir) + + tui := launchTUI(t) + defer tui.Terminate() + + // Wait for the TUI to start and show the SMITHERS header. + require.NoError(t, tui.WaitForText("SMITHERS", 20*time.Second)) +} + +// startWorkspaceContextMockServer creates a minimal Smithers HTTP mock that +// handles /health and /v1/runs endpoint stubs needed for workspace context +// pre-fetch. +func startWorkspaceContextMockServer(t *testing.T) *httptest.Server { + t.Helper() + + type runSummary struct { + RunID string `json:"runId"` + WorkflowName string `json:"workflowName"` + WorkflowPath string `json:"workflowPath"` + Status string `json:"status"` + } + + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch r.URL.Path { + case "/health": + w.WriteHeader(http.StatusOK) + case "/v1/runs": + status := r.URL.Query().Get("status") + var runs []runSummary + switch status { + case "running": + runs = []runSummary{ + { + RunID: "run-e2e-1", + WorkflowName: "ci-check", + WorkflowPath: ".smithers/workflows/ci.tsx", + Status: "running", + }, + } + case "waiting-approval": + runs = []runSummary{ + { + RunID: "run-e2e-2", + WorkflowName: "deploy-staging", + WorkflowPath: ".smithers/workflows/deploy.tsx", + Status: "waiting-approval", + }, + } + default: + runs = []runSummary{} + } + if err := json.NewEncoder(w).Encode(runs); err != nil { + http.Error(w, "encode error", http.StatusInternalServerError) + } + default: + http.NotFound(w, r) + } + })) +} diff --git a/internal/e2e/helpbar_shortcuts_test.go b/internal/e2e/helpbar_shortcuts_test.go index f57d7e71..8e43feeb 100644 --- a/internal/e2e/helpbar_shortcuts_test.go +++ b/internal/e2e/helpbar_shortcuts_test.go @@ -35,12 +35,14 @@ func TestHelpbarShortcuts_TUI(t *testing.T) { require.NoError(t, tui.WaitForText("approvals", 15*time.Second)) tui.SendKeys("\x12") // ctrl+r - require.NoError(t, tui.WaitForText("runs view coming soon", 10*time.Second)) + require.NoError(t, tui.WaitForText("SMITHERS \u203a Runs", 10*time.Second)) + + tui.SendKeys("\x1b") // esc + require.NoError(t, tui.WaitForNoText("SMITHERS \u203a Runs", 10*time.Second)) tui.SendKeys("\x01") // ctrl+a - require.NoError(t, tui.WaitForText("approvals view coming soon", 10*time.Second)) + require.NoError(t, tui.WaitForText("SMITHERS \u203a Approvals", 10*time.Second)) - tui.SendKeys("\x07") // ctrl+g - require.NoError(t, tui.WaitForText("ctrl+r", 10*time.Second)) - require.NoError(t, tui.WaitForText("ctrl+a", 10*time.Second)) + tui.SendKeys("\x1b") // esc + require.NoError(t, tui.WaitForNoText("SMITHERS \u203a Approvals", 10*time.Second)) } diff --git a/internal/e2e/live_chat_test.go b/internal/e2e/live_chat_test.go new file mode 100644 index 00000000..ffe97464 --- /dev/null +++ b/internal/e2e/live_chat_test.go @@ -0,0 +1,497 @@ +package e2e_test + +// live_chat_test.go — eng-live-chat-e2e-testing +// +// Tests the Live Chat Viewer view: opening the view via the command palette, +// verifying that messages stream in from a mock SSE server, that follow mode +// works, and that attempt navigation keys are rendered. +// +// Set SMITHERS_TUI_E2E=1 to run these tests. + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// mockChatBlock is a simplified chat block shape for JSON encoding in the mock +// SSE server. +type mockChatBlock struct { + ID string `json:"id,omitempty"` + RunID string `json:"runId"` + NodeID string `json:"nodeId,omitempty"` + Attempt int `json:"attempt"` + Role string `json:"role"` + Content string `json:"content"` + TimestampMs int64 `json:"timestampMs"` +} + +// newMockLiveChatServer creates a test HTTP server that provides: +// - GET /health — 200 OK +// - GET /v1/runs/:id — returns minimal run metadata +// - GET /v1/runs/:id/chat — returns snapshot blocks +// - GET /v1/runs/:id/chat/stream — sends blocks over SSE then closes +// - GET /v1/runs — returns the run in the list +func newMockLiveChatServer(t *testing.T, runID string, blocks []mockChatBlock) *httptest.Server { + t.Helper() + + now := time.Now().UnixMilli() + for i := range blocks { + if blocks[i].RunID == "" { + blocks[i].RunID = runID + } + if blocks[i].TimestampMs == 0 { + blocks[i].TimestampMs = now + int64(i*1000) + } + } + + mux := http.NewServeMux() + + // Health. + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + // Runs list (for the runs dashboard). + mux.HandleFunc("/v1/runs", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]map[string]interface{}{ + { + "runId": runID, + "workflowName": "e2e-test-workflow", + "status": "running", + "startedAtMs": now - 30000, + "summary": map[string]int{"finished": 1, "failed": 0, "total": 2}, + }, + }) + }) + + // Single run metadata. + mux.HandleFunc("/v1/runs/"+runID, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "runId": runID, + "workflowName": "e2e-test-workflow", + "status": "running", + "startedAtMs": now - 30000, + }) + }) + + // Chat snapshot — returns all blocks at once. + mux.HandleFunc("/v1/runs/"+runID+"/chat", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(blocks) + }) + + // Chat SSE stream — sends each block as an SSE event then closes. + mux.HandleFunc("/v1/runs/"+runID+"/chat/stream", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("X-Accel-Buffering", "no") + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming not supported", http.StatusInternalServerError) + return + } + + for _, block := range blocks { + data, err := json.Marshal(block) + if err != nil { + continue + } + fmt.Fprintf(w, "data: %s\n\n", data) + flusher.Flush() + time.Sleep(20 * time.Millisecond) + } + // Heartbeat then close. + fmt.Fprintf(w, ": done\n\n") + flusher.Flush() + }) + + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +func writeFakeSmithersCLI(t *testing.T, run map[string]any, blocks []mockChatBlock) string { + t.Helper() + + runJSON, err := json.Marshal(run) + require.NoError(t, err) + + blocksJSON, err := json.Marshal(blocks) + require.NoError(t, err) + + binDir := t.TempDir() + scriptPath := filepath.Join(binDir, "smithers") + script := fmt.Sprintf(`#!/bin/sh +case "$1 $2" in + "run get") + cat <<'EOF' +%s +EOF + ;; + "run chat") + cat <<'EOF' +%s +EOF + ;; + *) + printf 'unsupported smithers invocation: %%s\n' "$*" >&2 + exit 1 + ;; +esac +`, string(runJSON), string(blocksJSON)) + require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o755)) + + return binDir +} + +// openLiveChatViaCommandPalette navigates to the Live Chat view via Ctrl+P. +// Returns after the "SMITHERS › Chat" header is visible. +func openLiveChatViaCommandPalette(t *testing.T, tui *TUITestInstance) { + t.Helper() + + openCommandsPalette(t, tui) + tui.SendKeys("live") + require.NoError(t, tui.WaitForText("Live Chat", 5*time.Second), + "command palette must show Live Chat entry; buffer:\n%s", tui.Snapshot()) + tui.SendKeys("\r") + require.NoError(t, tui.WaitForText("SMITHERS > Chat >", 5*time.Second), + "live chat header must appear; buffer:\n%s", tui.Snapshot()) +} + +// TestLiveChat_OpenViaCommandPaletteAndRender verifies that the live chat view +// can be opened from the command palette, that the header "SMITHERS › Chat" +// appears, and that the help bar shows the expected bindings. +func TestLiveChat_OpenViaCommandPaletteAndRender(t *testing.T) { + if os.Getenv("SMITHERS_TUI_E2E") != "1" { + t.Skip("set SMITHERS_TUI_E2E=1 to run terminal E2E tests") + } + + const runID = "livechat-e2e-run" + blocks := []mockChatBlock{ + {RunID: runID, NodeID: "task1", Attempt: 0, Role: "user", Content: "Please deploy the service"}, + {RunID: runID, NodeID: "task1", Attempt: 0, Role: "assistant", Content: "Starting deployment sequence"}, + {RunID: runID, NodeID: "task1", Attempt: 0, Role: "tool", Content: "deploy_service({env:staging})"}, + } + + srv := newMockLiveChatServer(t, runID, blocks) + + configDir := t.TempDir() + dataDir := t.TempDir() + writeGlobalConfig(t, configDir, `{ + "smithers": { + "apiUrl": "`+srv.URL+`", + "dbPath": ".smithers/smithers.db", + "workflowDir": ".smithers/workflows" + } +}`) + t.Setenv("SMITHERS_TUI_GLOBAL_CONFIG", configDir) + t.Setenv("SMITHERS_TUI_GLOBAL_DATA", dataDir) + + tui := launchTUI(t) + defer tui.Terminate() + + require.NoError(t, tui.WaitForText("SMITHERS", 15*time.Second)) + + openLiveChatViaCommandPalette(t, tui) + + // The help bar must show the follow binding. + require.NoError(t, tui.WaitForText("follow", 3*time.Second), + "help bar must show follow binding; buffer:\n%s", tui.Snapshot()) + + // The help bar must show the hijack binding. + require.NoError(t, tui.WaitForText("hijack", 3*time.Second), + "help bar must show hijack binding; buffer:\n%s", tui.Snapshot()) + + // Escape returns to the previous view. + tui.SendKeys("\x1b") + require.NoError(t, tui.WaitForNoText("SMITHERS > Chat >", 5*time.Second), + "Esc must pop the live chat view; buffer:\n%s", tui.Snapshot()) +} + +// TestLiveChat_MessagesStreamIn verifies that when the TUI opens the live chat +// view for a run that has messages, those messages appear in the viewport. +func TestLiveChat_MessagesStreamIn(t *testing.T) { + if os.Getenv("SMITHERS_TUI_E2E") != "1" { + t.Skip("set SMITHERS_TUI_E2E=1 to run terminal E2E tests") + } + + const runID = "stream-in-run" + blocks := []mockChatBlock{ + {RunID: runID, NodeID: "n1", Attempt: 0, Role: "user", Content: "Hello from E2E test"}, + {RunID: runID, NodeID: "n1", Attempt: 0, Role: "assistant", Content: "E2E response received"}, + } + fakeBin := writeFakeSmithersCLI(t, map[string]any{ + "runId": runID, + "workflowName": "e2e-test-workflow", + "status": "running", + }, blocks) + + configDir := t.TempDir() + dataDir := t.TempDir() + projectDir := t.TempDir() + writeGlobalConfig(t, configDir, `{ + "smithers": { + "dbPath": ".smithers/smithers.db", + "workflowDir": ".smithers/workflows" + } +}`) + t.Setenv("SMITHERS_TUI_GLOBAL_CONFIG", configDir) + t.Setenv("SMITHERS_TUI_GLOBAL_DATA", dataDir) + + tui := launchTUIWithOptions(t, tuiLaunchOptions{ + pathPrefixes: []string{fakeBin}, + workingDir: projectDir, + }) + defer tui.Terminate() + + require.NoError(t, tui.WaitForText("SMITHERS", 15*time.Second)) + openLiveChatViaCommandPalette(t, tui) + + // Wait for the message content to appear. + require.NoError(t, tui.WaitForText("Hello from E2E test", 10*time.Second), + "user message must render; buffer:\n%s", tui.Snapshot()) + require.NoError(t, tui.WaitForText("E2E response received", 10*time.Second), + "assistant message must render; buffer:\n%s", tui.Snapshot()) + + tui.SendKeys("\x1b") + require.NoError(t, tui.WaitForNoText("SMITHERS > Chat >", 5*time.Second)) +} + +// TestLiveChat_FollowModeToggle verifies that pressing 'f' toggles follow mode +// in the help bar between "follow: on" and "follow: off". +func TestLiveChat_FollowModeToggle(t *testing.T) { + if os.Getenv("SMITHERS_TUI_E2E") != "1" { + t.Skip("set SMITHERS_TUI_E2E=1 to run terminal E2E tests") + } + + const runID = "follow-mode-run" + blocks := []mockChatBlock{ + {RunID: runID, NodeID: "n1", Attempt: 0, Role: "assistant", Content: "Agent is working..."}, + } + + srv := newMockLiveChatServer(t, runID, blocks) + + configDir := t.TempDir() + dataDir := t.TempDir() + writeGlobalConfig(t, configDir, `{ + "smithers": { + "apiUrl": "`+srv.URL+`", + "dbPath": ".smithers/smithers.db", + "workflowDir": ".smithers/workflows" + } +}`) + t.Setenv("SMITHERS_TUI_GLOBAL_CONFIG", configDir) + t.Setenv("SMITHERS_TUI_GLOBAL_DATA", dataDir) + + tui := launchTUI(t) + defer tui.Terminate() + + require.NoError(t, tui.WaitForText("SMITHERS", 15*time.Second)) + openLiveChatViaCommandPalette(t, tui) + + // Follow mode should be ON by default. + require.NoError(t, tui.WaitForText("follow: on", 5*time.Second), + "follow mode must default to on; buffer:\n%s", tui.Snapshot()) + + // Press 'f' — follow mode should turn off. + tui.SendKeys("f") + require.NoError(t, tui.WaitForText("follow: off", 3*time.Second), + "follow mode must turn off after 'f'; buffer:\n%s", tui.Snapshot()) + + // Press 'f' again — follow mode should turn on. + tui.SendKeys("f") + require.NoError(t, tui.WaitForText("follow: on", 3*time.Second), + "follow mode must turn on after second 'f'; buffer:\n%s", tui.Snapshot()) + + tui.SendKeys("\x1b") + require.NoError(t, tui.WaitForNoText("SMITHERS > Chat >", 5*time.Second)) +} + +// TestLiveChat_UpArrowDisablesFollowMode verifies that pressing the Up arrow +// while follow mode is on disables it (the user is manually scrolling). +func TestLiveChat_UpArrowDisablesFollowMode(t *testing.T) { + if os.Getenv("SMITHERS_TUI_E2E") != "1" { + t.Skip("set SMITHERS_TUI_E2E=1 to run terminal E2E tests") + } + + mux := http.NewServeMux() + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]interface{}{}) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + configDir := t.TempDir() + dataDir := t.TempDir() + writeGlobalConfig(t, configDir, `{ + "smithers": { + "apiUrl": "`+srv.URL+`" + } +}`) + t.Setenv("SMITHERS_TUI_GLOBAL_CONFIG", configDir) + t.Setenv("SMITHERS_TUI_GLOBAL_DATA", dataDir) + + tui := launchTUI(t) + defer tui.Terminate() + + require.NoError(t, tui.WaitForText("SMITHERS", 15*time.Second)) + openLiveChatViaCommandPalette(t, tui) + + require.NoError(t, tui.WaitForText("follow: on", 5*time.Second), + "follow mode must default to on; buffer:\n%s", tui.Snapshot()) + + // Pressing Up should disable follow. + tui.SendKeys("\x1b[A") // ANSI Up arrow + require.NoError(t, tui.WaitForText("follow: off", 3*time.Second), + "Up arrow must disable follow mode; buffer:\n%s", tui.Snapshot()) + + tui.SendKeys("\x1b") +} + +// TestLiveChat_AttemptNavigation verifies that when multiple attempts exist, +// the '[' and ']' attempt navigation hints appear in the help bar and navigate +// between attempts. +func TestLiveChat_AttemptNavigation(t *testing.T) { + if os.Getenv("SMITHERS_TUI_E2E") != "1" { + t.Skip("set SMITHERS_TUI_E2E=1 to run terminal E2E tests") + } + + const runID = "attempt-nav-run" + // Two attempts for the same run. + blocks := []mockChatBlock{ + {RunID: runID, NodeID: "n1", Attempt: 0, Role: "assistant", Content: "First attempt output"}, + {RunID: runID, NodeID: "n1", Attempt: 1, Role: "assistant", Content: "Second attempt output"}, + } + fakeBin := writeFakeSmithersCLI(t, map[string]any{ + "runId": runID, + "workflowName": "e2e-test-workflow", + "status": "running", + }, blocks) + + configDir := t.TempDir() + dataDir := t.TempDir() + projectDir := t.TempDir() + writeGlobalConfig(t, configDir, `{ + "smithers": { + "dbPath": ".smithers/smithers.db", + "workflowDir": ".smithers/workflows" + } +}`) + t.Setenv("SMITHERS_TUI_GLOBAL_CONFIG", configDir) + t.Setenv("SMITHERS_TUI_GLOBAL_DATA", dataDir) + + tui := launchTUIWithOptions(t, tuiLaunchOptions{ + pathPrefixes: []string{fakeBin}, + workingDir: projectDir, + }) + defer tui.Terminate() + + require.NoError(t, tui.WaitForText("SMITHERS", 15*time.Second)) + openLiveChatViaCommandPalette(t, tui) + + // Wait for blocks to load — the latest (attempt 1) is shown by default. + require.NoError(t, tui.WaitForText("Second attempt output", 10*time.Second), + "latest attempt content must render; buffer:\n%s", tui.Snapshot()) + + // With multiple attempts, the attempt nav hint must appear. + require.NoError(t, tui.WaitForText("attempt", 5*time.Second), + "attempt navigation hint must appear; buffer:\n%s", tui.Snapshot()) + + // Also verify the sub-header shows the attempt indicator. + require.NoError(t, tui.WaitForText("Attempt", 3*time.Second), + "sub-header must show attempt indicator; buffer:\n%s", tui.Snapshot()) + + // Navigate to previous attempt with '['. + tui.SendKeys("[") + require.NoError(t, tui.WaitForText("First attempt output", 5*time.Second), + "'[' must navigate to previous attempt; buffer:\n%s", tui.Snapshot()) + + // Navigate back to latest attempt with ']'. + tui.SendKeys("]") + require.NoError(t, tui.WaitForText("Second attempt output", 5*time.Second), + "']' must navigate to next attempt; buffer:\n%s", tui.Snapshot()) + + tui.SendKeys("q") + require.NoError(t, tui.WaitForNoText("SMITHERS > Chat >", 5*time.Second)) +} + +// TestLiveChat_QKeyPopsView verifies that pressing 'q' pops the live chat view, +// same as Esc. +func TestLiveChat_QKeyPopsView(t *testing.T) { + if os.Getenv("SMITHERS_TUI_E2E") != "1" { + t.Skip("set SMITHERS_TUI_E2E=1 to run terminal E2E tests") + } + + mux := http.NewServeMux() + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]interface{}{}) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + configDir := t.TempDir() + dataDir := t.TempDir() + writeGlobalConfig(t, configDir, `{ + "smithers": { + "apiUrl": "`+srv.URL+`" + } +}`) + t.Setenv("SMITHERS_TUI_GLOBAL_CONFIG", configDir) + t.Setenv("SMITHERS_TUI_GLOBAL_DATA", dataDir) + + tui := launchTUI(t) + defer tui.Terminate() + + require.NoError(t, tui.WaitForText("SMITHERS", 15*time.Second)) + openLiveChatViaCommandPalette(t, tui) + + // Press 'q' — same effect as Esc. + tui.SendKeys("q") + require.NoError(t, tui.WaitForNoText("SMITHERS > Chat >", 5*time.Second), + "'q' must pop the live chat view; buffer:\n%s", tui.Snapshot()) +} + +// TestLiveChat_NoServerFallback verifies that when no Smithers server is +// configured, the live chat view still opens and shows an error/empty state +// rather than crashing. +func TestLiveChat_NoServerFallback(t *testing.T) { + if os.Getenv("SMITHERS_TUI_E2E") != "1" { + t.Skip("set SMITHERS_TUI_E2E=1 to run terminal E2E tests") + } + + configDir := t.TempDir() + dataDir := t.TempDir() + // Config with no apiUrl so the client has no server to reach. + writeGlobalConfig(t, configDir, `{}`) + t.Setenv("SMITHERS_TUI_GLOBAL_CONFIG", configDir) + t.Setenv("SMITHERS_TUI_GLOBAL_DATA", dataDir) + + tui := launchTUI(t) + defer tui.Terminate() + + require.NoError(t, tui.WaitForText("SMITHERS", 15*time.Second)) + openLiveChatViaCommandPalette(t, tui) + + require.NoError(t, tui.WaitForText("Error loading run", 8*time.Second), + "live chat must show an error state instead of crashing\nBuffer:\n%s", tui.Snapshot()) + + tui.SendKeys("\x1b") + require.NoError(t, tui.WaitForNoText("SMITHERS > Chat >", 5*time.Second)) +} diff --git a/internal/e2e/mcp_integration_test.go b/internal/e2e/mcp_integration_test.go new file mode 100644 index 00000000..cb9c9eb8 --- /dev/null +++ b/internal/e2e/mcp_integration_test.go @@ -0,0 +1,259 @@ +package e2e_test + +// mcp_integration_test.go — eng-mcp-integration-tests +// +// Tests that verify MCP tool discovery and tool-call rendering in the TUI. +// +// - On startup with a mock MCP server, the header should show the "smithers +// connected" status with a non-zero tool count. +// - Sending a message that triggers a Smithers MCP tool call (via the mock +// MCP server) should render the tool-call block in the chat. +// - When the MCP server is deliberately misconfigured the header shows +// "smithers disconnected". +// +// Set SMITHERS_TUI_E2E=1 to run these tests. + +import ( + "encoding/json" + "os" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// TestMCPIntegration_ToolsDiscoveredOnStartup verifies that when a Smithers MCP +// server is configured and connects successfully, the TUI header shows +// "smithers connected" and a non-zero tool count within 20 s of launch. +// +// This test reuses the buildMockMCPServer helper from +// chat_mcp_connection_status_test.go which compiles the mock binary from +// internal/e2e/testdata/mock_smithers_mcp/main.go. +func TestMCPIntegration_ToolsDiscoveredOnStartup(t *testing.T) { + if os.Getenv("SMITHERS_TUI_E2E") != "1" { + t.Skip("set SMITHERS_TUI_E2E=1 to run terminal E2E tests") + } + + mockBin := buildMockMCPServer(t) + + configDir := t.TempDir() + dataDir := t.TempDir() + + cfg := map[string]any{ + "mcp": map[string]any{ + "smithers": map[string]any{ + "type": "stdio", + "command": mockBin, + "args": []string{}, + }, + }, + } + cfgBytes, err := json.MarshalIndent(cfg, "", " ") + require.NoError(t, err) + writeGlobalConfig(t, configDir, string(cfgBytes)) + + t.Setenv("SMITHERS_TUI_GLOBAL_CONFIG", configDir) + t.Setenv("SMITHERS_TUI_GLOBAL_DATA", dataDir) + + tui := launchTUI(t) + defer tui.Terminate() + + require.NoError(t, tui.WaitForText("SMITHERS", 15*time.Second)) + openStartChatFromDashboard(t, tui) + + require.NoError(t, tui.WaitForText("smithers", 20*time.Second), + "MCP section must show the smithers entry\nSnapshot:\n%s", tui.Snapshot()) + require.NoError(t, tui.WaitForText("tools", 5*time.Second), + "MCP section must show tool count after handshake\nSnapshot:\n%s", tui.Snapshot()) + + tui.SendKeys("\x03") // ctrl+c +} + +// TestMCPIntegration_ToolCountShownInHeader verifies that the exact tool count +// reported by the mock MCP server appears in the header. The mock binary +// exposes exactly 3 tools: list_workflows, run_workflow, get_run_status. +func TestMCPIntegration_ToolCountShownInHeader(t *testing.T) { + if os.Getenv("SMITHERS_TUI_E2E") != "1" { + t.Skip("set SMITHERS_TUI_E2E=1 to run terminal E2E tests") + } + + mockBin := buildMockMCPServer(t) + + configDir := t.TempDir() + dataDir := t.TempDir() + + cfg := map[string]any{ + "mcp": map[string]any{ + "smithers": map[string]any{ + "type": "stdio", + "command": mockBin, + "args": []string{}, + }, + }, + } + cfgBytes, err := json.MarshalIndent(cfg, "", " ") + require.NoError(t, err) + writeGlobalConfig(t, configDir, string(cfgBytes)) + + t.Setenv("SMITHERS_TUI_GLOBAL_CONFIG", configDir) + t.Setenv("SMITHERS_TUI_GLOBAL_DATA", dataDir) + + tui := launchTUI(t) + defer tui.Terminate() + + require.NoError(t, tui.WaitForText("SMITHERS", 15*time.Second)) + openStartChatFromDashboard(t, tui) + require.NoError(t, tui.WaitForText("smithers", 20*time.Second), + "smithers MCP entry must appear\nSnapshot:\n%s", tui.Snapshot()) + + // Mock exposes 3 tools: list_workflows, run_workflow, get_run_status. + require.NoError(t, tui.WaitForText("3 tools", 5*time.Second), + "MCP section must report 3 tools\nSnapshot:\n%s", tui.Snapshot()) + + tui.SendKeys("\x03") +} + +// TestMCPIntegration_DelayedConnection verifies that the TUI shows +// "smithers disconnected" initially and then transitions to "smithers connected" +// once the MCP server completes its startup delay. +func TestMCPIntegration_DelayedConnection(t *testing.T) { + if os.Getenv("SMITHERS_TUI_E2E") != "1" { + t.Skip("set SMITHERS_TUI_E2E=1 to run terminal E2E tests") + } + + mockBin := buildMockMCPServer(t) + + configDir := t.TempDir() + dataDir := t.TempDir() + + cfg := map[string]any{ + "mcp": map[string]any{ + "smithers": map[string]any{ + "type": "stdio", + "command": mockBin, + "args": []string{}, + "env": map[string]string{ + // Add a 2-second startup delay so we can observe the + // disconnected → connected transition. + "MOCK_MCP_STARTUP_DELAY_MS": "2000", + }, + }, + }, + } + cfgBytes, err := json.MarshalIndent(cfg, "", " ") + require.NoError(t, err) + writeGlobalConfig(t, configDir, string(cfgBytes)) + + t.Setenv("SMITHERS_TUI_GLOBAL_CONFIG", configDir) + t.Setenv("SMITHERS_TUI_GLOBAL_DATA", dataDir) + + tui := launchTUI(t) + defer tui.Terminate() + + require.NoError(t, tui.WaitForText("SMITHERS", 15*time.Second)) + openStartChatFromDashboard(t, tui) + + require.NoError(t, tui.WaitForText("3 tools", 25*time.Second), + "tool count should appear once the delayed MCP server connects\nSnapshot:\n%s", tui.Snapshot()) + + tui.SendKeys("\x03") +} + +// TestMCPIntegration_DisconnectedState verifies that when no Smithers MCP is +// configured the header shows "smithers disconnected". +func TestMCPIntegration_DisconnectedState(t *testing.T) { + if os.Getenv("SMITHERS_TUI_E2E") != "1" { + t.Skip("set SMITHERS_TUI_E2E=1 to run terminal E2E tests") + } + + configDir := t.TempDir() + dataDir := t.TempDir() + + cfg := map[string]any{ + "mcp": map[string]any{ + "smithers": map[string]any{ + "type": "stdio", + "command": "/nonexistent/smithers-mcp-binary", + "args": []string{}, + }, + }, + } + cfgBytes, err := json.MarshalIndent(cfg, "", " ") + require.NoError(t, err) + writeGlobalConfig(t, configDir, string(cfgBytes)) + + t.Setenv("SMITHERS_TUI_GLOBAL_CONFIG", configDir) + t.Setenv("SMITHERS_TUI_GLOBAL_DATA", dataDir) + + tui := launchTUI(t) + defer tui.Terminate() + + require.NoError(t, tui.WaitForText("SMITHERS", 15*time.Second)) + openStartChatFromDashboard(t, tui) + + require.NoError(t, tui.WaitForText("smithers", 20*time.Second), + "MCP section must show the smithers entry when startup fails\nSnapshot:\n%s", tui.Snapshot()) + require.NoError(t, tui.WaitForText("error:", 10*time.Second), + "MCP section must show an error state when the binary is missing\nSnapshot:\n%s", tui.Snapshot()) + + require.NoError(t, tui.WaitForNoText("3 tools", 3*time.Second), + "tool count must not appear when the MCP binary is missing\nSnapshot:\n%s", tui.Snapshot()) + + tui.SendKeys("\x03") +} + +// TestMCPIntegration_ToolCallRenderingInChat verifies that when the TUI sends a +// message that results in a Smithers MCP tool call, the tool-call block is +// rendered in the chat view with the expected prefix. +// +// This test requires that the mock MCP server is connected and that the LLM +// backend is bypassed via the SMITHERS_TUI_TEST_RESPONSE env var (or similar +// hook). Because a full LLM-bypass hook may not yet exist, this test verifies +// the rendering path at the unit boundary and marks the condition as a skip when +// the response injection env var is not set. +func TestMCPIntegration_ToolCallRenderingInChat(t *testing.T) { + if os.Getenv("SMITHERS_TUI_E2E") != "1" { + t.Skip("set SMITHERS_TUI_E2E=1 to run terminal E2E tests") + } + if os.Getenv("SMITHERS_TUI_INJECT_TOOL_CALL") != "1" { + t.Skip("set SMITHERS_TUI_INJECT_TOOL_CALL=1 to run tool-call rendering E2E test") + } + + mockBin := buildMockMCPServer(t) + + configDir := t.TempDir() + dataDir := t.TempDir() + + cfg := map[string]any{ + "mcp": map[string]any{ + "smithers": map[string]any{ + "type": "stdio", + "command": mockBin, + "args": []string{}, + }, + }, + } + cfgBytes, err := json.MarshalIndent(cfg, "", " ") + require.NoError(t, err) + writeGlobalConfig(t, configDir, string(cfgBytes)) + + t.Setenv("SMITHERS_TUI_GLOBAL_CONFIG", configDir) + t.Setenv("SMITHERS_TUI_GLOBAL_DATA", dataDir) + + tui := launchTUI(t) + defer tui.Terminate() + + require.NoError(t, tui.WaitForText("SMITHERS", 15*time.Second)) + require.NoError(t, tui.WaitForText("smithers connected", 20*time.Second)) + + // Send a message that the mock LLM will respond to with a Smithers tool call. + tui.SendKeys("list workflows\r") + + // The tool-call rendering should show the mcp_smithers_ tool name or the + // human-readable label. list_workflows maps to "List Runs" / "list_workflows" + // in the SmithersToolLabels map. + require.NoError(t, tui.WaitForText("list_workflows", 15*time.Second), + "Smithers tool call must render in chat\nSnapshot:\n%s", tui.Snapshot()) + + tui.SendKeys("\x03") +} diff --git a/internal/e2e/prompts_list_test.go b/internal/e2e/prompts_list_test.go new file mode 100644 index 00000000..9bf48895 --- /dev/null +++ b/internal/e2e/prompts_list_test.go @@ -0,0 +1,88 @@ +package e2e_test + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestPromptsListView_TUI(t *testing.T) { + if os.Getenv("SMITHERS_TUI_E2E") != "1" { + t.Skip("set SMITHERS_TUI_E2E=1 to run terminal E2E tests") + } + + // Create a temp project root with fixture .mdx prompts. + projectRoot := t.TempDir() + promptsDir := filepath.Join(projectRoot, ".smithers", "prompts") + require.NoError(t, os.MkdirAll(promptsDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(projectRoot, "AGENTS.md"), []byte("# Test project\n"), 0o644)) + + // Fixture 1: test-review.mdx — two props: lang, focus + require.NoError(t, os.WriteFile( + filepath.Join(promptsDir, "test-review.mdx"), + []byte("# Review\n\nReview {props.lang} code for {props.focus}.\n"), + 0o644, + )) + + // Fixture 2: test-deploy.mdx — three props: service, env, schema + require.NoError(t, os.WriteFile( + filepath.Join(promptsDir, "test-deploy.mdx"), + []byte("# Deploy\n\nDeploy {props.service} to {props.env}.\n\nREQUIRED OUTPUT:\n{props.schema}\n"), + 0o644, + )) + + // Create a minimal global config. + configDir := t.TempDir() + dataDir := t.TempDir() + writeGlobalConfig(t, configDir, `{ + "smithers": { + "dbPath": ".smithers/smithers.db", + "workflowDir": ".smithers/workflows" + } +}`) + + t.Setenv("SMITHERS_TUI_GLOBAL_CONFIG", configDir) + t.Setenv("SMITHERS_TUI_GLOBAL_DATA", dataDir) + + // Launch TUI with the temp project root as CWD so that + // listPromptsFromFS() finds the fixture .mdx files. + tui := launchTUI(t, "--cwd", projectRoot) + defer tui.Terminate() + + // 1. Wait for the TUI to fully start. + require.NoError(t, tui.WaitForText("SMITHERS", 15*time.Second)) + + // 2. Open the command palette and filter to "Prompt Templates". + openCommandsPalette(t, tui) + tui.SendKeys("Prompt") + time.Sleep(300 * time.Millisecond) + tui.SendKeys("\r") + + // 3. Verify the prompts view header appears. + require.NoError(t, tui.WaitForText("Prompts", 5*time.Second)) + + // 4. Verify that at least one fixture prompt ID appears in the list. + require.NoError(t, tui.WaitForText("test-review", 5*time.Second)) + + // 5. Navigate down to the second prompt. + tui.SendKeys("j") + time.Sleep(300 * time.Millisecond) + require.NoError(t, tui.WaitForText("test-deploy", 3*time.Second)) + + // 6. Navigate back up to the first prompt. + tui.SendKeys("k") + time.Sleep(300 * time.Millisecond) + + // 7. The source pane should show the "Source" section header once loaded. + require.NoError(t, tui.WaitForText("Source", 3*time.Second)) + + // 8. Verify a prop from test-review appears in the Inputs section. + require.NoError(t, tui.WaitForText("lang", 3*time.Second)) + + // 9. Press Escape to return to the previous view. + tui.SendKeys("\x1b") + require.NoError(t, tui.WaitForNoText("Prompts", 3*time.Second)) +} diff --git a/internal/e2e/runs_dashboard_test.go b/internal/e2e/runs_dashboard_test.go new file mode 100644 index 00000000..7050f6f4 --- /dev/null +++ b/internal/e2e/runs_dashboard_test.go @@ -0,0 +1,253 @@ +package e2e_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// mockRunsResponse is the canned JSON response returned by the mock Smithers server. +// Matches the design doc wireframe with 3 representative runs. +var mockRunsPayload = []map[string]interface{}{ + { + "runId": "abc12345", + "workflowName": "code-review", + "workflowPath": ".smithers/workflows/code-review.ts", + "status": "running", + "startedAtMs": time.Now().Add(-2*time.Minute - 14*time.Second).UnixMilli(), + "summary": map[string]int{ + "finished": 3, + "failed": 0, + "total": 5, + }, + }, + { + "runId": "def67890", + "workflowName": "deploy-staging", + "workflowPath": ".smithers/workflows/deploy-staging.ts", + "status": "waiting-approval", + "startedAtMs": time.Now().Add(-8*time.Minute - 2*time.Second).UnixMilli(), + "summary": map[string]int{ + "finished": 4, + "failed": 0, + "total": 6, + }, + }, + { + "runId": "ghi11223", + "workflowName": "test-suite", + "workflowPath": ".smithers/workflows/test-suite.ts", + "status": "running", + "startedAtMs": time.Now().Add(-30 * time.Second).UnixMilli(), + "summary": map[string]int{ + "finished": 1, + "failed": 0, + "total": 3, + }, + }, +} + +// startMockSmithersServer starts a local HTTP test server that simulates the +// Smithers API for the runs dashboard. It returns canned run data on GET /v1/runs. +func startMockSmithersServer(t *testing.T) *httptest.Server { + t.Helper() + + mux := http.NewServeMux() + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("/v1/runs", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(mockRunsPayload) + }) + + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +// TestRunsDashboard_NavigateWithCtrlR verifies that pressing Ctrl+R navigates +// to the runs dashboard view and displays run data from a mock server. +func TestRunsDashboard_NavigateWithCtrlR(t *testing.T) { + if os.Getenv("SMITHERS_TUI_E2E") != "1" { + t.Skip("set SMITHERS_TUI_E2E=1 to run terminal E2E tests") + } + + // Start mock Smithers HTTP server. + srv := startMockSmithersServer(t) + + configDir := t.TempDir() + dataDir := t.TempDir() + + // Write config pointing at the mock server. + writeGlobalConfig(t, configDir, `{ + "smithers": { + "apiUrl": "`+srv.URL+`", + "dbPath": ".smithers/smithers.db", + "workflowDir": ".smithers/workflows" + } +}`) + + t.Setenv("SMITHERS_TUI_GLOBAL_CONFIG", configDir) + t.Setenv("SMITHERS_TUI_GLOBAL_DATA", dataDir) + + tui := launchTUI(t) + defer tui.Terminate() + + // 1. Wait for TUI to start and show SMITHERS branding. + require.NoError(t, tui.WaitForText("SMITHERS", 15*time.Second)) + + // 2. Send Ctrl+R to navigate to the runs dashboard. + tui.SendKeys("\x12") // Ctrl+R + + // 3. Verify the runs view header is rendered. + require.NoError(t, tui.WaitForText("Runs", 10*time.Second)) + + // 4. Verify table column headers are displayed. + require.NoError(t, tui.WaitForText("Workflow", 5*time.Second)) + require.NoError(t, tui.WaitForText("Status", 5*time.Second)) + + // 5. Verify run data from mock server appears in the table. + require.NoError(t, tui.WaitForText("code-review", 10*time.Second)) + require.NoError(t, tui.WaitForText("running", 5*time.Second)) + require.NoError(t, tui.WaitForText("deploy-staging", 5*time.Second)) + require.NoError(t, tui.WaitForText("test-suite", 5*time.Second)) + + // 6. Verify the cursor indicator is present. + require.NoError(t, tui.WaitForText("▸", 5*time.Second)) + + // 7. Send Down arrow to move cursor. + tui.SendKeys("\x1b[B") // Down arrow + time.Sleep(200 * time.Millisecond) + snapshot := tui.Snapshot() + // After pressing down, the cursor should have moved (▸ should still be visible). + require.Contains(t, snapshot, "▸") + + // 8. Send Esc to return to chat. + tui.SendKeys("\x1b") // Esc + require.NoError(t, tui.WaitForText("SMITHERS", 5*time.Second)) +} + +// TestRunsDashboard_NavigateViaCommandPalette verifies that typing "/runs" in +// the command palette navigates to the runs dashboard. +func TestRunsDashboard_NavigateViaCommandPalette(t *testing.T) { + if os.Getenv("SMITHERS_TUI_E2E") != "1" { + t.Skip("set SMITHERS_TUI_E2E=1 to run terminal E2E tests") + } + + srv := startMockSmithersServer(t) + + configDir := t.TempDir() + dataDir := t.TempDir() + writeGlobalConfig(t, configDir, `{ + "smithers": { + "apiUrl": "`+srv.URL+`", + "dbPath": ".smithers/smithers.db", + "workflowDir": ".smithers/workflows" + } +}`) + + t.Setenv("SMITHERS_TUI_GLOBAL_CONFIG", configDir) + t.Setenv("SMITHERS_TUI_GLOBAL_DATA", dataDir) + + tui := launchTUI(t) + defer tui.Terminate() + + // Wait for TUI to start. + require.NoError(t, tui.WaitForText("SMITHERS", 15*time.Second)) + + // Open command palette (Ctrl+P or /). + tui.SendKeys("\x10") // Ctrl+P + time.Sleep(500 * time.Millisecond) + + // Type "runs" to filter to the Runs entry. + tui.SendKeys("runs") + time.Sleep(300 * time.Millisecond) + + // Verify the Run Dashboard entry appears in the palette. + require.NoError(t, tui.WaitForText("Run Dashboard", 5*time.Second)) + + // Press Enter to select it. + tui.SendKeys("\r") // Enter + require.NoError(t, tui.WaitForText("Runs", 10*time.Second)) +} + +// TestRunsDashboard_EmptyState verifies the "No runs found" message when the +// mock server returns an empty runs list. +func TestRunsDashboard_EmptyState(t *testing.T) { + if os.Getenv("SMITHERS_TUI_E2E") != "1" { + t.Skip("set SMITHERS_TUI_E2E=1 to run terminal E2E tests") + } + + // Start a mock server that returns an empty list. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("[]")) + })) + defer srv.Close() + + configDir := t.TempDir() + dataDir := t.TempDir() + writeGlobalConfig(t, configDir, `{ + "smithers": { + "apiUrl": "`+srv.URL+`", + "dbPath": ".smithers/smithers.db", + "workflowDir": ".smithers/workflows" + } +}`) + + t.Setenv("SMITHERS_TUI_GLOBAL_CONFIG", configDir) + t.Setenv("SMITHERS_TUI_GLOBAL_DATA", dataDir) + + tui := launchTUI(t) + defer tui.Terminate() + + require.NoError(t, tui.WaitForText("SMITHERS", 15*time.Second)) + + tui.SendKeys("\x12") // Ctrl+R + require.NoError(t, tui.WaitForText("No runs found", 10*time.Second)) +} + +// TestRunsDashboard_RefreshWithRKey verifies that pressing "r" in the runs +// view reloads runs from the server. +func TestRunsDashboard_RefreshWithRKey(t *testing.T) { + if os.Getenv("SMITHERS_TUI_E2E") != "1" { + t.Skip("set SMITHERS_TUI_E2E=1 to run terminal E2E tests") + } + + srv := startMockSmithersServer(t) + + configDir := t.TempDir() + dataDir := t.TempDir() + writeGlobalConfig(t, configDir, `{ + "smithers": { + "apiUrl": "`+srv.URL+`", + "dbPath": ".smithers/smithers.db", + "workflowDir": ".smithers/workflows" + } +}`) + + t.Setenv("SMITHERS_TUI_GLOBAL_CONFIG", configDir) + t.Setenv("SMITHERS_TUI_GLOBAL_DATA", dataDir) + + tui := launchTUI(t) + defer tui.Terminate() + + require.NoError(t, tui.WaitForText("SMITHERS", 15*time.Second)) + + // Navigate to runs dashboard. + tui.SendKeys("\x12") // Ctrl+R + require.NoError(t, tui.WaitForText("code-review", 10*time.Second)) + + // Press "r" to refresh — should briefly show loading then data again. + tui.SendKeys("r") + // After refresh, runs should still appear. + require.NoError(t, tui.WaitForText("code-review", 10*time.Second)) +} diff --git a/internal/e2e/testdata/mock_smithers_mcp/main.go b/internal/e2e/testdata/mock_smithers_mcp/main.go new file mode 100644 index 00000000..50e905e7 --- /dev/null +++ b/internal/e2e/testdata/mock_smithers_mcp/main.go @@ -0,0 +1,47 @@ +// Package main implements a minimal mock Smithers MCP server for use in E2E +// tests. It registers a small set of fake workflow tools so that the TUI can +// display a non-zero tool count in the header when connected. +// +// Optional environment variables: +// - MOCK_MCP_STARTUP_DELAY_MS — milliseconds to sleep before accepting the +// first connection (defaults to 0). Use this to exercise the +// disconnected→connected transition in tests. +package main + +import ( + "context" + "os" + "strconv" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func main() { + if delayStr := os.Getenv("MOCK_MCP_STARTUP_DELAY_MS"); delayStr != "" { + if ms, err := strconv.Atoi(delayStr); err == nil && ms > 0 { + time.Sleep(time.Duration(ms) * time.Millisecond) + } + } + + server := mcp.NewServer(&mcp.Implementation{Name: "smithers", Title: "Smithers Mock MCP"}, nil) + + // Register fake workflow tools so the TUI shows a non-zero tool count. + for _, tool := range []struct{ name, desc string }{ + {"list_workflows", "List all available Smithers workflows"}, + {"run_workflow", "Trigger a Smithers workflow by name"}, + {"get_run_status", "Retrieve the status of a specific Smithers run"}, + } { + tool := tool + mcp.AddTool(server, &mcp.Tool{Name: tool.name, Description: tool.desc}, + func(_ context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "mock response"}}, + }, nil, nil + }, + ) + } + + // Run until the parent process (the TUI) closes stdin. + _ = server.Run(context.Background(), &mcp.StdioTransport{}) +} diff --git a/internal/e2e/toast_overlay_e2e_test.go b/internal/e2e/toast_overlay_e2e_test.go new file mode 100644 index 00000000..ac7c7e03 --- /dev/null +++ b/internal/e2e/toast_overlay_e2e_test.go @@ -0,0 +1,131 @@ +package e2e_test + +import ( + "os" + "testing" + "time" +) + +// TestToastOverlay_AppearOnStart verifies that the toast overlay renders over +// any active view when NOTIFICATIONS_TOAST_OVERLAYS=1 and +// CRUSH_TEST_TOAST_ON_START=1 are set. +func TestToastOverlay_AppearOnStart(t *testing.T) { + if os.Getenv("SMITHERS_TUI_E2E") != "1" { + t.Skip("set SMITHERS_TUI_E2E=1 to run terminal E2E tests") + } + + configDir := t.TempDir() + dataDir := t.TempDir() + writeGlobalConfig(t, configDir, `{}`) + + t.Setenv("SMITHERS_TUI_GLOBAL_CONFIG", configDir) + t.Setenv("SMITHERS_TUI_GLOBAL_DATA", dataDir) + t.Setenv("NOTIFICATIONS_TOAST_OVERLAYS", "1") + t.Setenv("CRUSH_TEST_TOAST_ON_START", "1") + + tui := launchTUI(t) + defer tui.Terminate() + + // The debug toast should appear in the terminal output. + if err := tui.WaitForText("Toast test", 15*time.Second); err != nil { + t.Fatal(err) + } +} + +// TestToastOverlay_FeatureFlagOff verifies that no toast appears when the +// NOTIFICATIONS_TOAST_OVERLAYS flag is absent, even with CRUSH_TEST_TOAST_ON_START set. +func TestToastOverlay_FeatureFlagOff(t *testing.T) { + if os.Getenv("SMITHERS_TUI_E2E") != "1" { + t.Skip("set SMITHERS_TUI_E2E=1 to run terminal E2E tests") + } + + configDir := t.TempDir() + dataDir := t.TempDir() + writeGlobalConfig(t, configDir, `{}`) + + t.Setenv("SMITHERS_TUI_GLOBAL_CONFIG", configDir) + t.Setenv("SMITHERS_TUI_GLOBAL_DATA", dataDir) + // Do NOT set NOTIFICATIONS_TOAST_OVERLAYS + t.Setenv("CRUSH_TEST_TOAST_ON_START", "1") + + tui := launchTUI(t) + defer tui.Terminate() + + // Wait for the TUI to start fully (look for some stable text). + // The exact startup text may vary, so just wait a moment. + time.Sleep(3 * time.Second) + + // "Toast test" must not appear in the output. + if err := tui.WaitForNoText("Toast test", 2*time.Second); err != nil { + t.Fatalf("toast appeared but feature flag was off: %v", err) + } +} + +// TestToastOverlay_DismissKey verifies that the newest toast is removed when +// the alt+d keybinding is sent. +func TestToastOverlay_DismissKey(t *testing.T) { + if os.Getenv("SMITHERS_TUI_E2E") != "1" { + t.Skip("set SMITHERS_TUI_E2E=1 to run terminal E2E tests") + } + + configDir := t.TempDir() + dataDir := t.TempDir() + writeGlobalConfig(t, configDir, `{}`) + + t.Setenv("SMITHERS_TUI_GLOBAL_CONFIG", configDir) + t.Setenv("SMITHERS_TUI_GLOBAL_DATA", dataDir) + t.Setenv("NOTIFICATIONS_TOAST_OVERLAYS", "1") + t.Setenv("CRUSH_TEST_TOAST_ON_START", "1") + + tui := launchTUI(t) + defer tui.Terminate() + + // Wait for the toast to appear. + if err := tui.WaitForText("Toast test", 15*time.Second); err != nil { + t.Fatal(err) + } + + // Send alt+d to dismiss the toast. + tui.SendKeys("\x1bd") // ESC + 'd' = alt+d + + // The toast should be gone within a short time. + if err := tui.WaitForNoText("Toast test", 5*time.Second); err != nil { + t.Fatalf("toast not dismissed after alt+d: %v", err) + } +} + +// TestToastOverlay_DisableNotificationsConfig verifies that no toast appears +// when disable_notifications is set in the config, even with the feature flag +// enabled and CRUSH_TEST_TOAST_ON_START set. +// +// Note: CRUSH_TEST_TOAST_ON_START fires the toast directly via Init before the +// flag is checked, so this test uses the SSE path. Since no SSE server is +// running, the toast just shouldn't appear due to the config flag. This test +// is therefore limited to confirming the TUI starts without a toast (the config +// check is exercised by the unit tests in notifications_test.go). +func TestToastOverlay_DisableNotificationsConfig(t *testing.T) { + if os.Getenv("SMITHERS_TUI_E2E") != "1" { + t.Skip("set SMITHERS_TUI_E2E=1 to run terminal E2E tests") + } + + configDir := t.TempDir() + dataDir := t.TempDir() + // Config has disable_notifications: true + writeGlobalConfig(t, configDir, `{"options": {"disable_notifications": true}}`) + + t.Setenv("SMITHERS_TUI_GLOBAL_CONFIG", configDir) + t.Setenv("SMITHERS_TUI_GLOBAL_DATA", dataDir) + t.Setenv("NOTIFICATIONS_TOAST_OVERLAYS", "1") + // Do NOT set CRUSH_TEST_TOAST_ON_START so there's no toast triggered at startup. + + tui := launchTUI(t) + defer tui.Terminate() + + // Give the TUI time to start and settle. + time.Sleep(3 * time.Second) + + // No toast should appear. + if err := tui.WaitForNoText("Toast test", 2*time.Second); err != nil { + t.Fatalf("toast appeared but disable_notifications was true: %v", err) + } +} diff --git a/internal/e2e/tui_helpers_test.go b/internal/e2e/tui_helpers_test.go index 5161d541..15023280 100644 --- a/internal/e2e/tui_helpers_test.go +++ b/internal/e2e/tui_helpers_test.go @@ -1,10 +1,7 @@ package e2e_test import ( - "bytes" - "errors" "fmt" - "io" "os" "os/exec" "path/filepath" @@ -13,6 +10,8 @@ import ( "sync" "testing" "time" + + "github.com/stretchr/testify/require" ) const ( @@ -20,74 +19,133 @@ const ( pollInterval = 100 * time.Millisecond ) -var ansiPattern = regexp.MustCompile(`\x1B\[[0-9;]*[a-zA-Z]`) +var ( + ansiPattern = regexp.MustCompile(`\x1B\[[0-9;]*[a-zA-Z]`) -type syncBuffer struct { - mu sync.Mutex - buf bytes.Buffer -} + builtTUIBinaryOnce sync.Once + builtTUIBinaryPath string + builtTUIBinaryErr error +) -func (b *syncBuffer) Write(p []byte) (int, error) { - b.mu.Lock() - defer b.mu.Unlock() - return b.buf.Write(p) +type TUITestInstance struct { + t *testing.T + session string } -func (b *syncBuffer) String() string { - b.mu.Lock() - defer b.mu.Unlock() - return b.buf.String() +type tuiLaunchOptions struct { + args []string + env map[string]string + pathPrefixes []string + workingDir string } -type TUITestInstance struct { - t *testing.T - cmd *exec.Cmd - stdin io.WriteCloser - buffer *syncBuffer +type tmuxKeyToken struct { + hex bool + value string } func launchTUI(t *testing.T, args ...string) *TUITestInstance { t.Helper() + return launchTUIWithOptions(t, tuiLaunchOptions{args: args}) +} - repoRoot, err := filepath.Abs(filepath.Join("..", "..")) - if err != nil { - t.Fatalf("resolve repo root: %v", err) +func launchTUIWithOptions(t *testing.T, opts tuiLaunchOptions) *TUITestInstance { + t.Helper() + + if _, err := exec.LookPath("tmux"); err != nil { + t.Skip("tmux is required for this e2e test") + } + + binary := buildSharedTUIBinary(t) + workingDir := opts.workingDir + if workingDir == "" { + workingDir = e2eRepoRoot(t) } - cmd := exec.Command("go", append([]string{"run", "."}, args...)...) - cmd.Dir = repoRoot - cmd.Env = append(os.Environ(), - "TERM=xterm-256color", - "COLORTERM=truecolor", - "LANG=en_US.UTF-8", - ) + env := mergeEnv(os.Environ(), opts.env) + env = mergeEnv(env, map[string]string{ + "TERM": "xterm-256color", + "COLORTERM": "truecolor", + "LANG": "en_US.UTF-8", + }) + if len(opts.pathPrefixes) > 0 { + env = prependEnvPath(env, opts.pathPrefixes...) + } - stdin, err := cmd.StdinPipe() - if err != nil { - t.Fatalf("stdin pipe: %v", err) + scriptPath := filepath.Join(t.TempDir(), "launch.sh") + script := buildLaunchScript(env, workingDir, append([]string{binary}, opts.args...)) + if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil { + t.Fatalf("write launch script: %v", err) } - stdout, err := cmd.StdoutPipe() - if err != nil { - t.Fatalf("stdout pipe: %v", err) + + session := fmt.Sprintf("crush-e2e-%d", time.Now().UnixNano()) + cmd := exec.Command("tmux", "new-session", "-d", "-s", session, "-x", "120", "-y", "40", scriptPath) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("launch tmux session: %v\n%s", err, output) } - stderr, err := cmd.StderrPipe() - if err != nil { - t.Fatalf("stderr pipe: %v", err) + + return &TUITestInstance{t: t, session: session} +} + +func buildLaunchScript(env []string, workingDir string, argv []string) string { + var builder strings.Builder + builder.WriteString("#!/bin/sh\n") + for _, entry := range env { + key, value, ok := strings.Cut(entry, "=") + if !ok || key == "" { + continue + } + builder.WriteString("export ") + builder.WriteString(key) + builder.WriteString("=") + builder.WriteString(shellQuote(value)) + builder.WriteString("\n") } + builder.WriteString("cd ") + builder.WriteString(shellQuote(workingDir)) + builder.WriteString("\n") + builder.WriteString(shellJoin(argv)) + builder.WriteString("\nstatus=$?\nprintf '\\n[crush exited: %s]\\n' \"$status\"\nsleep 3600\n") + return builder.String() +} + +func buildSharedTUIBinary(t *testing.T) string { + t.Helper() - if err := cmd.Start(); err != nil { - t.Fatalf("start tui: %v", err) + builtTUIBinaryOnce.Do(func() { + buildDir, err := os.MkdirTemp("", "crush-tui-e2e-*") + if err != nil { + builtTUIBinaryErr = err + return + } + + builtTUIBinaryPath = filepath.Join(buildDir, "crush-tui") + cmd := exec.Command("go", "build", "-o", builtTUIBinaryPath, "./main.go") + cmd.Dir = e2eRepoRoot(t) + output, err := cmd.CombinedOutput() + if err != nil { + builtTUIBinaryErr = fmt.Errorf("build tui binary: %w\n%s", err, output) + } + }) + + if builtTUIBinaryErr != nil { + t.Fatalf("%v", builtTUIBinaryErr) } + return builtTUIBinaryPath +} - buf := &syncBuffer{} - go func() { _, _ = io.Copy(buf, stdout) }() - go func() { _, _ = io.Copy(buf, stderr) }() +func e2eRepoRoot(t *testing.T) string { + t.Helper() - return &TUITestInstance{t: t, cmd: cmd, stdin: stdin, buffer: buf} + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("resolve repo root: %v", err) + } + return root } func (t *TUITestInstance) bufferText() string { - out := ansiPattern.ReplaceAllString(t.buffer.String(), "") + out := ansiPattern.ReplaceAllString(t.Snapshot(), "") return strings.ReplaceAll(out, "\r", "") } @@ -140,38 +198,176 @@ func (t *TUITestInstance) WaitForNoText(text string, timeout time.Duration) erro } func (t *TUITestInstance) SendKeys(keys string) { - if t.stdin == nil { - return + t.t.Helper() + + for _, token := range parseKeyTokens(keys) { + var cmd *exec.Cmd + if token.hex { + cmd = exec.Command("tmux", "send-keys", "-t", t.session, "-H", token.value) + } else { + cmd = exec.Command("tmux", "send-keys", "-t", t.session, token.value) + } + if output, err := cmd.CombinedOutput(); err != nil { + t.t.Fatalf("send keys %q: %v\n%s", token.value, err, output) + } } - _, _ = io.WriteString(t.stdin, keys) } func (t *TUITestInstance) Snapshot() string { - return t.bufferText() + t.t.Helper() + + out, err := exec.Command("tmux", "capture-pane", "-t", t.session, "-p").CombinedOutput() + if err != nil { + t.t.Fatalf("capture pane: %v\n%s", err, out) + } + return strings.ReplaceAll(string(out), "\r", "") } func (t *TUITestInstance) Terminate() { t.t.Helper() - if t.cmd == nil || t.cmd.Process == nil { - return - } - - _ = t.cmd.Process.Signal(os.Interrupt) - waitCh := make(chan error, 1) - go func() { - waitCh <- t.cmd.Wait() - }() - - select { - case err := <-waitCh: - if err != nil && !errors.Is(err, exec.ErrNotFound) { - var exitErr *exec.ExitError - if !errors.As(err, &exitErr) { - t.t.Fatalf("wait process: %v", err) + _ = exec.Command("tmux", "kill-session", "-t", t.session).Run() +} + +func openCommandsPalette(t *testing.T, tui *TUITestInstance) { + t.Helper() + + tui.SendKeys("\x10") // ctrl+p + require.NoError(t, tui.WaitForText("Commands", 5*time.Second), + "commands dialog must open; buffer:\n%s", tui.Snapshot()) +} + +func openStartChatFromDashboard(t *testing.T, tui *TUITestInstance) { + t.Helper() + + tui.SendKeys("\r") + require.NoError(t, tui.WaitForText("MCPs", 10*time.Second), + "start chat should open the landing view; buffer:\n%s", tui.Snapshot()) +} + +func parseKeyTokens(keys string) []tmuxKeyToken { + data := []byte(keys) + tokens := make([]tmuxKeyToken, 0, len(data)) + + for i := 0; i < len(data); i++ { + switch data[i] { + case 0x1b: + if i+2 < len(data) && data[i+1] == '[' { + if keyName, ok := tmuxArrowKey(data[i+2]); ok { + tokens = append(tokens, tmuxKeyToken{value: keyName}) + i += 2 + continue + } } + tokens = append(tokens, tmuxKeyToken{hex: true, value: "1b"}) + case '\r', '\n': + tokens = append(tokens, tmuxKeyToken{hex: true, value: "0d"}) + case '\t': + tokens = append(tokens, tmuxKeyToken{hex: true, value: "09"}) + case ' ': + tokens = append(tokens, tmuxKeyToken{value: "Space"}) + default: + if keyHex, ok := tmuxControlKey(data[i]); ok { + tokens = append(tokens, tmuxKeyToken{hex: true, value: keyHex}) + continue + } + tokens = append(tokens, tmuxKeyToken{value: string([]byte{data[i]})}) + } + } + return tokens +} + +func tmuxArrowKey(code byte) (string, bool) { + switch code { + case 'A': + return "Up", true + case 'B': + return "Down", true + case 'C': + return "Right", true + case 'D': + return "Left", true + default: + return "", false + } +} + +func tmuxControlKey(code byte) (string, bool) { + switch { + case code == 0x00: + return "", false + case code == 0x7f || code < 0x20: + return fmt.Sprintf("%02x", code), true + default: + return "", false + } +} + +func mergeEnv(base []string, overrides map[string]string) []string { + order := make([]string, 0, len(base)+len(overrides)) + values := make(map[string]string, len(base)+len(overrides)) + + for _, entry := range base { + key, value, ok := strings.Cut(entry, "=") + if !ok || key == "" { + continue } - case <-time.After(2 * time.Second): - _ = t.cmd.Process.Kill() - _ = <-waitCh + if _, exists := values[key]; !exists { + order = append(order, key) + } + values[key] = value + } + + for key, value := range overrides { + if _, exists := values[key]; !exists { + order = append(order, key) + } + values[key] = value + } + + merged := make([]string, 0, len(order)) + for _, key := range order { + merged = append(merged, key+"="+values[key]) + } + return merged +} + +func prependEnvPath(env []string, prefixes ...string) []string { + currentPath := envValue(env, "PATH") + parts := make([]string, 0, len(prefixes)+1) + for _, prefix := range prefixes { + if prefix != "" { + parts = append(parts, prefix) + } + } + if currentPath != "" { + parts = append(parts, currentPath) + } + return mergeEnv(env, map[string]string{ + "PATH": strings.Join(parts, string(os.PathListSeparator)), + }) +} + +func envValue(env []string, key string) string { + prefix := key + "=" + for _, entry := range env { + if strings.HasPrefix(entry, prefix) { + return strings.TrimPrefix(entry, prefix) + } + } + return "" +} + +func shellJoin(argv []string) string { + quoted := make([]string, 0, len(argv)) + for _, arg := range argv { + quoted = append(quoted, shellQuote(arg)) + } + return strings.Join(quoted, " ") +} + +func shellQuote(value string) string { + if value == "" { + return "''" } + return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'" } diff --git a/internal/jjhub/client.go b/internal/jjhub/client.go new file mode 100644 index 00000000..c3f8ecea --- /dev/null +++ b/internal/jjhub/client.go @@ -0,0 +1,362 @@ +// Package jjhub shells out to the jjhub CLI and parses JSON output. +// This is the POC adapter — will be replaced by direct Go API calls later. +package jjhub + +import ( + "encoding/json" + "fmt" + "os/exec" + "strings" + "time" +) + +// ---- Data types (mirrors jjhub --json output) ---- + +type User struct { + ID int `json:"id"` + Login string `json:"login"` +} + +type Repo struct { + ID int `json:"id"` + Name string `json:"name"` + FullName string `json:"full_name"` + Owner string `json:"owner"` + Description string `json:"description"` + DefaultBookmark string `json:"default_bookmark"` + IsPublic bool `json:"is_public"` + IsArchived bool `json:"is_archived"` + NumIssues int `json:"num_issues"` + NumStars int `json:"num_stars"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Landing struct { + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + State string `json:"state"` // open, closed, merged, draft + TargetBookmark string `json:"target_bookmark"` + ChangeIDs []string `json:"change_ids"` + StackSize int `json:"stack_size"` + ConflictStatus string `json:"conflict_status"` + Author User `json:"author"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// LandingDetail is the rich response from `jjhub land view`. +type LandingDetail struct { + Landing Landing `json:"landing"` + Changes []LandingChange `json:"changes"` + Conflicts LandingConflict `json:"conflicts"` + Reviews []Review `json:"reviews"` +} + +type LandingChange struct { + ID int `json:"id"` + ChangeID string `json:"change_id"` + LandingRequestID int `json:"landing_request_id"` + PositionInStack int `json:"position_in_stack"` + CreatedAt string `json:"created_at"` +} + +type LandingConflict struct { + ConflictStatus string `json:"conflict_status"` + HasConflicts bool `json:"has_conflicts"` + ConflictsByChange map[string]string `json:"conflicts_by_change"` +} + +type Review struct { + ID int `json:"id"` + LandingRequestID int `json:"landing_request_id"` + ReviewerID int `json:"reviewer_id"` + State string `json:"state"` // approve, request_changes, comment + Type string `json:"type"` + Body string `json:"body"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type Issue struct { + ID int `json:"id"` + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + State string `json:"state"` // open, closed + Author User `json:"author"` + Assignees []User `json:"assignees"` + CommentCount int `json:"comment_count"` + MilestoneID *int `json:"milestone_id"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Labels []Label `json:"labels"` +} + +type Label struct { + ID int `json:"id"` + Name string `json:"name"` + Color string `json:"color"` +} + +type Notification struct { + ID int `json:"id"` + Title string `json:"title"` + Type string `json:"type"` + RepoName string `json:"repo_name"` + Unread bool `json:"unread"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type Workspace struct { + ID string `json:"id"` + RepositoryID int `json:"repository_id"` + UserID int `json:"user_id"` + Name string `json:"name"` + Status string `json:"status"` // pending, running, stopped, failed + IsFork bool `json:"is_fork"` + ParentWorkspaceID *string `json:"parent_workspace_id"` + FreestyleVMID string `json:"freestyle_vm_id"` + Persistence string `json:"persistence"` + SSHHost *string `json:"ssh_host"` + SnapshotID *string `json:"snapshot_id"` + IdleTimeoutSeconds int `json:"idle_timeout_seconds"` + SuspendedAt *string `json:"suspended_at"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type Workflow struct { + ID int `json:"id"` + RepositoryID int `json:"repository_id"` + Name string `json:"name"` + Path string `json:"path"` + IsActive bool `json:"is_active"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type Change struct { + ChangeID string `json:"change_id"` + CommitID string `json:"commit_id"` + Description string `json:"description"` + Author Author `json:"author"` + Timestamp string `json:"timestamp"` + IsEmpty bool `json:"is_empty"` + IsWorkingCopy bool `json:"is_working_copy"` + Bookmarks []string `json:"bookmarks"` +} + +type Author struct { + Name string `json:"name"` + Email string `json:"email"` +} + +// ---- Client ---- + +type Client struct { + repo string // owner/repo, empty = auto-detect from cwd +} + +func NewClient(repo string) *Client { + return &Client{repo: repo} +} + +func (c *Client) run(args ...string) ([]byte, error) { + allArgs := append(args, "--json", "--no-color") + cmd := exec.Command("jjhub", allArgs...) + out, err := cmd.CombinedOutput() + if err != nil { + // Extract just the error message, not the full stderr dump. + msg := strings.TrimSpace(string(out)) + if idx := strings.Index(msg, "Error:"); idx >= 0 { + msg = strings.TrimSpace(msg[idx+6:]) + } + return nil, fmt.Errorf("%s", msg) + } + return out, nil +} + +func (c *Client) runRaw(args ...string) (string, error) { + allArgs := append(args, "--no-color") + cmd := exec.Command("jjhub", allArgs...) + out, err := cmd.CombinedOutput() + if err != nil { + msg := strings.TrimSpace(string(out)) + if idx := strings.Index(msg, "Error:"); idx >= 0 { + msg = strings.TrimSpace(msg[idx+6:]) + } + return "", fmt.Errorf("%s", msg) + } + return string(out), nil +} + +func (c *Client) repoArgs() []string { + if c.repo != "" { + return []string{"-R", c.repo} + } + return nil +} + +// ---- List methods ---- + +func (c *Client) ListLandings(state string, limit int) ([]Landing, error) { + args := []string{"land", "list", "-s", state, "-L", fmt.Sprint(limit)} + args = append(args, c.repoArgs()...) + out, err := c.run(args...) + if err != nil { + return nil, err + } + var landings []Landing + if err := json.Unmarshal(out, &landings); err != nil { + return nil, fmt.Errorf("parse landings: %w", err) + } + return landings, nil +} + +func (c *Client) ListIssues(state string, limit int) ([]Issue, error) { + args := []string{"issue", "list", "-s", state, "-L", fmt.Sprint(limit)} + args = append(args, c.repoArgs()...) + out, err := c.run(args...) + if err != nil { + return nil, err + } + var issues []Issue + if err := json.Unmarshal(out, &issues); err != nil { + return nil, fmt.Errorf("parse issues: %w", err) + } + return issues, nil +} + +func (c *Client) ListRepos(limit int) ([]Repo, error) { + args := []string{"repo", "list", "-L", fmt.Sprint(limit)} + out, err := c.run(args...) + if err != nil { + return nil, err + } + var repos []Repo + if err := json.Unmarshal(out, &repos); err != nil { + return nil, fmt.Errorf("parse repos: %w", err) + } + return repos, nil +} + +func (c *Client) ListNotifications(limit int) ([]Notification, error) { + args := []string{"notification", "list", "-L", fmt.Sprint(limit)} + out, err := c.run(args...) + if err != nil { + return nil, err + } + var notifications []Notification + if err := json.Unmarshal(out, ¬ifications); err != nil { + return nil, fmt.Errorf("parse notifications: %w", err) + } + return notifications, nil +} + +func (c *Client) ListWorkspaces(limit int) ([]Workspace, error) { + args := []string{"workspace", "list", "-L", fmt.Sprint(limit)} + args = append(args, c.repoArgs()...) + out, err := c.run(args...) + if err != nil { + return nil, err + } + var ws []Workspace + if err := json.Unmarshal(out, &ws); err != nil { + return nil, fmt.Errorf("parse workspaces: %w", err) + } + return ws, nil +} + +func (c *Client) ListWorkflows(limit int) ([]Workflow, error) { + args := []string{"workflow", "list", "-L", fmt.Sprint(limit)} + args = append(args, c.repoArgs()...) + out, err := c.run(args...) + if err != nil { + return nil, err + } + var wf []Workflow + if err := json.Unmarshal(out, &wf); err != nil { + return nil, fmt.Errorf("parse workflows: %w", err) + } + return wf, nil +} + +func (c *Client) ListChanges(limit int) ([]Change, error) { + args := []string{"change", "list", "--limit", fmt.Sprint(limit)} + out, err := c.run(args...) + if err != nil { + return nil, err + } + var changes []Change + if err := json.Unmarshal(out, &changes); err != nil { + return nil, fmt.Errorf("parse changes: %w", err) + } + return changes, nil +} + +// ---- Detail methods ---- + +func (c *Client) ViewLanding(number int) (*LandingDetail, error) { + args := []string{"land", "view", fmt.Sprint(number)} + args = append(args, c.repoArgs()...) + out, err := c.run(args...) + if err != nil { + return nil, err + } + var d LandingDetail + if err := json.Unmarshal(out, &d); err != nil { + return nil, fmt.Errorf("parse landing detail: %w", err) + } + return &d, nil +} + +func (c *Client) ViewIssue(number int) (*Issue, error) { + args := []string{"issue", "view", fmt.Sprint(number)} + args = append(args, c.repoArgs()...) + out, err := c.run(args...) + if err != nil { + return nil, err + } + var i Issue + if err := json.Unmarshal(out, &i); err != nil { + return nil, fmt.Errorf("parse issue: %w", err) + } + return &i, nil +} + +func (c *Client) GetCurrentRepo() (*Repo, error) { + out, err := c.run("repo", "view") + if err != nil { + return nil, err + } + var r Repo + if err := json.Unmarshal(out, &r); err != nil { + return nil, fmt.Errorf("parse repo: %w", err) + } + return &r, nil +} + +// ---- Diff methods ---- + +func (c *Client) LandingDiff(number int) (string, error) { + args := []string{"land", "diff", fmt.Sprint(number)} + args = append(args, c.repoArgs()...) + return c.runRaw(args...) +} + +func (c *Client) ChangeDiff(changeID string) (string, error) { + args := []string{"change", "diff", changeID} + return c.runRaw(args...) +} + +func (c *Client) WorkingCopyDiff() (string, error) { + cmd := exec.Command("jj", "diff", "--no-color") + out, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("jj diff: %w", err) + } + return string(out), nil +} diff --git a/internal/ui/components/jtable.go b/internal/ui/components/jtable.go new file mode 100644 index 00000000..5a3607b7 --- /dev/null +++ b/internal/ui/components/jtable.go @@ -0,0 +1,331 @@ +package components + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/x/ansi" +) + +// Align controls horizontal alignment within a cell. +type Align int + +const ( + AlignLeft Align = iota + AlignRight +) + +// Column defines one responsive table column. +type Column struct { + Title string + Width int + Grow bool + MinWidth int + Align Align +} + +// Row is one rendered table row. +type Row struct { + Cells []string +} + +var ( + jTableHeaderStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("252")) + jTableAltRowStyle = lipgloss.NewStyle().Background(lipgloss.Color("236")) + jTableRowStyle = lipgloss.NewStyle().Background(lipgloss.Color("234")) + jTableSelected = lipgloss.NewStyle().Background(lipgloss.Color("238")).Bold(true) + jTableInactive = lipgloss.NewStyle().Background(lipgloss.Color("236")).Foreground(lipgloss.Color("250")) + jTableCursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("69")).Bold(true) + jTableMutedStyle = lipgloss.NewStyle().Faint(true) +) + +type visibleColumn struct { + column Column + index int + width int +} + +// RenderTable draws a responsive table with a header, alternating rows, a +// cursor indicator, and a footer scroll indicator. +func RenderTable( + columns []Column, + rows []Row, + cursor int, + offset int, + width int, + height int, + focused bool, +) (string, int) { + if width <= 0 || height <= 0 { + return "", 0 + } + + visibleColumns := filterVisibleColumns(columns, width) + if len(visibleColumns) == 0 { + return "", 0 + } + + const cursorWidth = 2 + availableWidth := width - cursorWidth - max(0, len(visibleColumns)-1) + if availableWidth < len(visibleColumns) { + availableWidth = len(visibleColumns) + } + + assignColumnWidths(visibleColumns, availableWidth) + + headerHeight := 1 + footerHeight := 1 + bodyHeight := max(1, height-headerHeight-footerHeight) + + cursor = clamp(cursor, 0, max(0, len(rows)-1)) + offset = clampOffset(cursor, offset, len(rows), bodyHeight) + + var out strings.Builder + out.WriteString(renderTableHeader(visibleColumns, cursorWidth)) + + if len(rows) == 0 { + out.WriteString("\n") + out.WriteString(jTableMutedStyle.Render(padToWidth("No items found.", width))) + out.WriteString("\n") + out.WriteString(renderTableFooter(width, 0, 0)) + return out.String(), 0 + } + + end := min(len(rows), offset+bodyHeight) + for rowIndex := offset; rowIndex < end; rowIndex++ { + out.WriteString("\n") + out.WriteString(renderTableRow( + rows[rowIndex], + visibleColumns, + rowIndex, + offset, + width, + cursor, + focused, + )) + } + + out.WriteString("\n") + out.WriteString(renderTableFooter(width, cursor+1, len(rows))) + + return out.String(), offset +} + +func filterVisibleColumns(columns []Column, width int) []visibleColumn { + visible := make([]visibleColumn, 0, len(columns)) + for i, column := range columns { + if column.MinWidth > 0 && width < column.MinWidth { + continue + } + visible = append(visible, visibleColumn{ + column: column, + index: i, + }) + } + return visible +} + +func assignColumnWidths(columns []visibleColumn, available int) { + if len(columns) == 0 { + return + } + + fixedTotal := 0 + growCount := 0 + for i := range columns { + if columns[i].column.Grow { + growCount++ + continue + } + columns[i].width = columnBaseWidth(columns[i].column) + fixedTotal += columns[i].width + } + + remaining := max(0, available-fixedTotal) + if growCount > 0 { + share := 0 + extra := 0 + if remaining > 0 { + share = remaining / growCount + extra = remaining % growCount + } + for i := range columns { + if !columns[i].column.Grow { + continue + } + columns[i].width = share + if extra > 0 { + columns[i].width++ + extra-- + } + } + } + + total := 0 + for _, column := range columns { + total += column.width + } + if total > available { + shrinkWidths(columns, total-available) + } + + for i := range columns { + if columns[i].width <= 0 { + columns[i].width = 1 + } + } +} + +func shrinkWidths(columns []visibleColumn, overflow int) { + for overflow > 0 { + shrunk := false + for i := len(columns) - 1; i >= 0 && overflow > 0; i-- { + if columns[i].width <= 1 { + continue + } + columns[i].width-- + overflow-- + shrunk = true + } + if !shrunk { + return + } + } +} + +func columnBaseWidth(column Column) int { + if column.Width > 0 { + return column.Width + } + return max(1, ansi.StringWidth(column.Title)) +} + +func renderTableHeader(columns []visibleColumn, cursorWidth int) string { + cells := make([]string, 0, len(columns)) + for _, column := range columns { + cells = append(cells, jTableHeaderStyle.Render(renderCell(column.column.Title, column.width, AlignLeft))) + } + return strings.Repeat(" ", cursorWidth) + strings.Join(cells, " ") +} + +func renderTableRow( + row Row, + columns []visibleColumn, + rowIndex int, + offset int, + width int, + cursor int, + focused bool, +) string { + indicator := " " + if rowIndex == cursor { + cursorGlyph := styles.BorderThin + if focused { + cursorGlyph = styles.BorderThick + } + indicator = jTableCursorStyle.Render(cursorGlyph + " ") + } + + cells := make([]string, 0, len(columns)) + for _, column := range columns { + cell := "" + if column.index < len(row.Cells) { + cell = row.Cells[column.index] + } + cells = append(cells, renderCell(cell, column.width, column.column.Align)) + } + + line := indicator + strings.Join(cells, " ") + line = padToWidth(line, width) + + switch { + case rowIndex == cursor && focused: + return jTableSelected.Render(line) + case rowIndex == cursor: + return jTableInactive.Render(line) + case (rowIndex-offset)%2 == 1: + return jTableAltRowStyle.Render(line) + default: + return jTableRowStyle.Render(line) + } +} + +func renderTableFooter(width int, current int, total int) string { + label := fmt.Sprintf("%d/%d", current, total) + return jTableMutedStyle.Render(padLeft(label, width)) +} + +func renderCell(value string, width int, align Align) string { + if width <= 0 { + return "" + } + + truncated := value + if ansi.StringWidth(truncated) > width { + if width == 1 { + truncated = ansi.Truncate(value, width, "") + } else { + truncated = ansi.Truncate(value, width, "…") + } + } + + padding := width - ansi.StringWidth(truncated) + if padding <= 0 { + return truncated + } + + switch align { + case AlignRight: + return strings.Repeat(" ", padding) + truncated + default: + return truncated + strings.Repeat(" ", padding) + } +} + +func padToWidth(value string, width int) string { + padding := width - ansi.StringWidth(value) + if padding <= 0 { + return value + } + return value + strings.Repeat(" ", padding) +} + +func padLeft(value string, width int) string { + padding := width - ansi.StringWidth(value) + if padding <= 0 { + return value + } + return strings.Repeat(" ", padding) + value +} + +func clamp(value, lower, upper int) int { + if value < lower { + return lower + } + if value > upper { + return upper + } + return value +} + +func clampOffset(cursor, offset, totalRows, bodyHeight int) int { + if totalRows <= 0 { + return 0 + } + if offset < 0 { + offset = 0 + } + if cursor < offset { + offset = cursor + } + if cursor >= offset+bodyHeight { + offset = cursor - bodyHeight + 1 + } + maxOffset := max(0, totalRows-bodyHeight) + if offset > maxOffset { + offset = maxOffset + } + return offset +} diff --git a/internal/ui/components/jtable_test.go b/internal/ui/components/jtable_test.go new file mode 100644 index 00000000..e59f130b --- /dev/null +++ b/internal/ui/components/jtable_test.go @@ -0,0 +1,87 @@ +package components + +import ( + "strings" + "testing" + + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/x/ansi" + "github.com/stretchr/testify/assert" +) + +func TestRenderTable_HidesColumnsBelowBreakpoint(t *testing.T) { + t.Parallel() + + columns := []Column{ + {Title: "#", Width: 4}, + {Title: "Title", Grow: true}, + {Title: "Author", Width: 10, MinWidth: 80}, + } + rows := []Row{{Cells: []string{"#1", "Landing title", "will"}}} + + rendered, _ := RenderTable(columns, rows, 0, 0, 60, 6, true) + + assert.Contains(t, rendered, "Title") + assert.NotContains(t, rendered, "Author") +} + +func TestRenderTable_ShowsFocusedCursor(t *testing.T) { + t.Parallel() + + rendered, _ := RenderTable( + []Column{{Title: "Title", Grow: true}}, + []Row{{Cells: []string{"Row one"}}}, + 0, + 0, + 40, + 5, + true, + ) + + assert.Contains(t, rendered, styles.BorderThick) +} + +func TestRenderTable_AdjustsOffsetForCursor(t *testing.T) { + t.Parallel() + + rows := []Row{ + {Cells: []string{"one"}}, + {Cells: []string{"two"}}, + {Cells: []string{"three"}}, + {Cells: []string{"four"}}, + {Cells: []string{"five"}}, + } + + rendered, offset := RenderTable( + []Column{{Title: "Title", Grow: true}}, + rows, + 4, + 0, + 40, + 4, + true, + ) + + assert.Equal(t, 3, offset) + assert.Contains(t, rendered, "5/5") +} + +func TestRenderTable_TruncatesANSIContentByVisibleWidth(t *testing.T) { + t.Parallel() + + colored := "\x1b[31mvery-long-colored-title\x1b[0m" + rendered, _ := RenderTable( + []Column{{Title: "Title", Width: 8}}, + []Row{{Cells: []string{colored}}}, + 0, + 0, + 20, + 5, + false, + ) + + lines := strings.Split(rendered, "\n") + assert.Len(t, lines, 3) + assert.Contains(t, lines[1], "…") + assert.LessOrEqual(t, ansi.StringWidth(lines[1]), 20) +} diff --git a/internal/ui/components/logviewer.go b/internal/ui/components/logviewer.go new file mode 100644 index 00000000..ad45f208 --- /dev/null +++ b/internal/ui/components/logviewer.go @@ -0,0 +1,400 @@ +package components + +import ( + "fmt" + "regexp" + "strconv" + "strings" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textinput" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/ui/common" + uistyles "github.com/charmbracelet/crush/internal/ui/styles" +) + +// Compile-time interface check. +var _ Pane = (*LogViewer)(nil) + +// LogLine is one rendered line in the log viewer. +type LogLine struct { + Text string + Error bool +} + +// LogViewer renders searchable, scrollable log output with line numbers. +type LogViewer struct { + viewport viewport.Model + + width int + height int + title string + + lines []LogLine + errorLines map[int]bool + placeholder string + content string + + searchInput textinput.Model + searchActive bool + searchValue string + searchErr error + matchCount int + + sty uistyles.Styles +} + +// NewLogViewer creates a new log viewer. +func NewLogViewer() *LogViewer { + sty := uistyles.DefaultStyles() + + ti := textinput.New() + ti.Prompt = "/ " + ti.Placeholder = "regex search" + ti.SetVirtualCursor(true) + ti.SetStyles(sty.TextInput) + + vp := viewport.New() + vp.SoftWrap = true + vp.FillHeight = true + vp.LeftGutterFunc = func(info viewport.GutterContext) string { + digits := 2 + if info.TotalLines > 0 { + digits = max(2, len(strconv.Itoa(info.TotalLines))) + } + if info.Soft { + return sty.LineNumber.Render(" " + strings.Repeat(" ", digits) + " ") + } + return sty.LineNumber.Render(fmt.Sprintf(" %*d ", digits, info.Index+1)) + } + vp.HighlightStyle = lipgloss.NewStyle(). + Background(sty.BgOverlay). + Foreground(sty.White) + vp.SelectedHighlightStyle = lipgloss.NewStyle(). + Background(sty.Blue). + Foreground(sty.BgBase) + + lv := &LogViewer{ + viewport: vp, + title: "Logs", + errorLines: make(map[int]bool), + placeholder: "Select a task to inspect its logs.", + searchInput: ti, + searchActive: false, + sty: sty, + } + lv.viewport.StyleLineFunc = lv.lineStyle + lv.SetSize(0, 0) + return lv +} + +// Init implements Pane. +func (lv *LogViewer) Init() tea.Cmd { + return nil +} + +// Update implements Pane. +func (lv *LogViewer) Update(msg tea.Msg) (Pane, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + lv.SetSize(msg.Width, msg.Height) + return lv, nil + + case tea.KeyPressMsg: + if lv.searchActive { + switch { + case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))): + lv.searchActive = false + lv.searchInput.Blur() + lv.searchInput.SetValue(lv.searchValue) + lv.applySearch(lv.searchValue) + return lv, nil + + case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))): + lv.searchActive = false + lv.searchInput.Blur() + lv.searchValue = lv.searchInput.Value() + lv.applySearch(lv.searchValue) + return lv, nil + + default: + var cmd tea.Cmd + lv.searchInput, cmd = lv.searchInput.Update(msg) + lv.applySearch(lv.searchInput.Value()) + return lv, cmd + } + } + + switch { + case key.Matches(msg, key.NewBinding(key.WithKeys("/"))): + lv.searchActive = true + lv.searchInput.SetValue(lv.searchValue) + return lv, lv.searchInput.Focus() + + case key.Matches(msg, key.NewBinding(key.WithKeys("n"))): + lv.viewport.HighlightNext() + return lv, nil + + case key.Matches(msg, key.NewBinding(key.WithKeys("N"))): + lv.viewport.HighlightPrevious() + return lv, nil + + case key.Matches(msg, key.NewBinding(key.WithKeys("g", "home"))): + lv.viewport.GotoTop() + return lv, nil + + case key.Matches(msg, key.NewBinding(key.WithKeys("G", "end"))): + lv.viewport.GotoBottom() + return lv, nil + + case key.Matches(msg, key.NewBinding(key.WithKeys("j"))): + lv.viewport.ScrollDown(1) + return lv, nil + + case key.Matches(msg, key.NewBinding(key.WithKeys("k"))): + lv.viewport.ScrollUp(1) + return lv, nil + } + + var cmd tea.Cmd + lv.viewport, cmd = lv.viewport.Update(msg) + return lv, cmd + } + + var cmd tea.Cmd + lv.viewport, cmd = lv.viewport.Update(msg) + return lv, cmd +} + +// View implements Pane. +func (lv *LogViewer) View() string { + if lv.width <= 0 || lv.height <= 0 { + return "" + } + + headerLines := []string{ + lv.renderHeader(), + } + if line, ok := lv.renderSearchLine(); ok { + headerLines = append(headerLines, line) + } + + bodyHeight := max(0, lv.height-len(headerLines)) + body := lv.renderBody(bodyHeight) + + sections := make([]string, 0, len(headerLines)+1) + sections = append(sections, headerLines...) + sections = append(sections, body) + + return lipgloss.NewStyle(). + Width(lv.width). + Height(lv.height). + Render(lipgloss.JoinVertical(lipgloss.Left, sections...)) +} + +// SetSize implements Pane. +func (lv *LogViewer) SetSize(width, height int) { + lv.width = max(0, width) + lv.height = max(0, height) + + viewportWidth := max(0, lv.width-1) + lv.viewport.SetWidth(viewportWidth) + lv.viewport.SetHeight(lv.viewportHeight()) +} + +// SetTitle updates the viewer title. +func (lv *LogViewer) SetTitle(title string) { + if strings.TrimSpace(title) == "" { + lv.title = "Logs" + return + } + lv.title = title +} + +// SetPlaceholder clears content and shows a placeholder message. +func (lv *LogViewer) SetPlaceholder(placeholder string) { + lv.lines = nil + lv.content = "" + lv.placeholder = placeholder + lv.errorLines = make(map[int]bool) + lv.matchCount = 0 + lv.searchErr = nil + lv.viewport.SetContent("") + lv.viewport.ClearHighlights() +} + +// SetLines replaces the viewer contents. +func (lv *LogViewer) SetLines(lines []LogLine) { + lv.lines = append([]LogLine(nil), lines...) + lv.placeholder = "" + lv.errorLines = make(map[int]bool, len(lines)) + + var b strings.Builder + for i, line := range lv.lines { + if i > 0 { + b.WriteByte('\n') + } + b.WriteString(line.Text) + if line.Error { + lv.errorLines[i] = true + } + } + + atBottom := lv.viewport.AtBottom() + lv.content = b.String() + lv.viewport.SetContent(lv.content) + if atBottom { + lv.viewport.GotoBottom() + } + + lv.applySearch(lv.searchValue) +} + +// SearchActive reports whether the search input is focused. +func (lv *LogViewer) SearchActive() bool { + return lv.searchActive +} + +// SearchValue reports the current applied search pattern. +func (lv *LogViewer) SearchValue() string { + return lv.searchValue +} + +// MatchCount reports the number of current regex matches. +func (lv *LogViewer) MatchCount() int { + return lv.matchCount +} + +func (lv *LogViewer) lineStyle(index int) lipgloss.Style { + if lv.errorLines[index] { + return lipgloss.NewStyle(). + Background(lv.sty.RedDark). + Foreground(lv.sty.White) + } + return lipgloss.NewStyle() +} + +func (lv *LogViewer) viewportHeight() int { + height := lv.height - 1 + if _, ok := lv.renderSearchLine(); ok { + height-- + } + return max(0, height) +} + +func (lv *LogViewer) renderHeader() string { + title := lipgloss.NewStyle(). + Bold(true). + Foreground(lv.sty.BlueLight). + Render(lv.title) + + metaParts := make([]string, 0, 2) + if lv.matchCount > 0 { + metaParts = append(metaParts, fmt.Sprintf("%d matches", lv.matchCount)) + } + if len(lv.lines) > 0 { + metaParts = append(metaParts, fmt.Sprintf("%d lines", len(lv.lines))) + } + meta := lipgloss.NewStyle(). + Foreground(lv.sty.FgMuted). + Render(strings.Join(metaParts, " ")) + + if meta == "" { + return lipgloss.NewStyle().Width(lv.width).Render(title) + } + + gap := max(1, lv.width-lipgloss.Width(title)-lipgloss.Width(meta)) + return lipgloss.NewStyle(). + Width(lv.width). + Render(title + strings.Repeat(" ", gap) + meta) +} + +func (lv *LogViewer) renderSearchLine() (string, bool) { + switch { + case lv.searchActive: + return lipgloss.NewStyle(). + Width(lv.width). + Render(lv.searchInput.View()), true + + case lv.searchErr != nil: + return lipgloss.NewStyle(). + Foreground(lv.sty.Red). + Width(lv.width). + Render("Search error: " + lv.searchErr.Error()), true + + case lv.searchValue != "": + msg := fmt.Sprintf("Search /%s", lv.searchValue) + if lv.matchCount == 0 { + msg += " no matches" + } + return lipgloss.NewStyle(). + Foreground(lv.sty.FgMuted). + Width(lv.width). + Render(msg), true + } + + return "", false +} + +func (lv *LogViewer) renderBody(height int) string { + if height <= 0 { + return "" + } + + if len(lv.lines) == 0 { + placeholder := lv.placeholder + if placeholder == "" { + placeholder = "No log output." + } + return lipgloss.NewStyle(). + Foreground(lv.sty.FgMuted). + Width(lv.width). + Height(height). + Render(placeholder) + } + + lv.viewport.SetHeight(height) + view := lv.viewport.View() + scrollbar := common.Scrollbar( + &lv.sty, + height, + lv.viewport.TotalLineCount(), + lv.viewport.VisibleLineCount(), + lv.viewport.YOffset(), + ) + if scrollbar == "" { + return lipgloss.NewStyle(). + Width(lv.width). + Height(height). + Render(view) + } + + return lipgloss.JoinHorizontal(lipgloss.Top, view, scrollbar) +} + +func (lv *LogViewer) applySearch(pattern string) { + lv.searchValue = pattern + lv.searchErr = nil + lv.matchCount = 0 + lv.viewport.ClearHighlights() + + if pattern == "" || lv.content == "" { + return + } + + re, err := regexp.Compile(pattern) + if err != nil { + lv.searchErr = err + return + } + + matches := re.FindAllStringIndex(lv.content, -1) + if len(matches) == 0 { + return + } + + lv.matchCount = len(matches) + lv.viewport.SetHighlights(matches) +} diff --git a/internal/ui/components/logviewer_test.go b/internal/ui/components/logviewer_test.go new file mode 100644 index 00000000..5b7514a8 --- /dev/null +++ b/internal/ui/components/logviewer_test.go @@ -0,0 +1,105 @@ +package components + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/stretchr/testify/assert" +) + +func TestLogViewer_ImplementsPane(t *testing.T) { + t.Parallel() + + var _ Pane = (*LogViewer)(nil) +} + +func TestLogViewer_SetLinesAndView(t *testing.T) { + t.Parallel() + + lv := NewLogViewer() + lv.SetSize(60, 8) + lv.SetTitle("build") + lv.SetLines([]LogLine{ + {Text: "line one"}, + {Text: "line two", Error: true}, + }) + + view := lv.View() + assert.Contains(t, view, "build") + assert.Contains(t, view, "line one") + assert.Contains(t, view, "line two") + assert.Contains(t, view, "1") + assert.True(t, lv.errorLines[1]) +} + +func TestLogViewer_SearchLifecycle(t *testing.T) { + t.Parallel() + + lv := NewLogViewer() + lv.SetSize(60, 8) + lv.SetLines([]LogLine{ + {Text: "error: first"}, + {Text: "info"}, + {Text: "error: second"}, + }) + + _, cmd := lv.Update(tea.KeyPressMsg{Text: "/", Code: '/'}) + assert.NotNil(t, cmd) + + for _, r := range []rune("error") { + updated, _ := lv.Update(tea.KeyPressMsg{Text: string(r), Code: r}) + lv = updated.(*LogViewer) + } + + assert.True(t, lv.searchActive) + assert.Equal(t, 2, lv.MatchCount()) + + updated, _ := lv.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + lv = updated.(*LogViewer) + + assert.False(t, lv.searchActive) + assert.Equal(t, "error", lv.SearchValue()) + assert.Equal(t, 2, lv.MatchCount()) +} + +func TestLogViewer_InvalidRegex(t *testing.T) { + t.Parallel() + + lv := NewLogViewer() + lv.SetSize(60, 8) + lv.SetLines([]LogLine{{Text: "hello"}}) + + _, _ = lv.Update(tea.KeyPressMsg{Text: "/", Code: '/'}) + updated, _ := lv.Update(tea.KeyPressMsg{Text: "[", Code: '['}) + lv = updated.(*LogViewer) + + assert.Error(t, lv.searchErr) + assert.Equal(t, 0, lv.MatchCount()) + assert.True(t, lv.searchActive) +} + +func TestLogViewer_SearchNextMatchScrolls(t *testing.T) { + t.Parallel() + + lv := NewLogViewer() + lv.SetSize(40, 4) + + lines := make([]LogLine, 0, 20) + for i := range 20 { + text := "line " + strings.Repeat("x", i%3) + if i == 12 || i == 17 { + text = "target" + } + lines = append(lines, LogLine{Text: text}) + } + lv.SetLines(lines) + lv.applySearch("target") + initialOffset := lv.viewport.YOffset() + + updated, _ := lv.Update(tea.KeyPressMsg{Text: "n", Code: 'n'}) + lv = updated.(*LogViewer) + + assert.NotEqual(t, initialOffset, lv.viewport.YOffset()) + assert.Equal(t, 2, lv.MatchCount()) +} diff --git a/internal/ui/diffnav/launch.go b/internal/ui/diffnav/launch.go new file mode 100644 index 00000000..5186aeef --- /dev/null +++ b/internal/ui/diffnav/launch.go @@ -0,0 +1,384 @@ +// Package diffnav provides helpers for launching the diffnav TUI diff viewer +// as a subprocess, and prompting the user to install it if not found. +package diffnav + +import ( + "bytes" + "errors" + "fmt" + "os" + "os/exec" + "runtime" + "strings" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/crush/internal/ui/handoff" +) + +// Available returns true if diffnav is on PATH. +func Available() bool { + _, err := exec.LookPath("diffnav") + return err == nil +} + +// LaunchDiffnavWithCommand runs the diff command, writes its output to a +// temporary patch file, and pipes that file into diffnav. If diffnav is not +// installed, returns an InstallPromptMsg instead. +func LaunchDiffnavWithCommand(command string, cwd string, tag any) tea.Cmd { + if !Available() { + return func() tea.Msg { + return InstallPromptMsg{ + PendingCommand: command, + PendingCwd: cwd, + PendingTag: tag, + } + } + } + return func() tea.Msg { + tmpPath, err := writeCommandDiffToTempFile(command, cwd) + if err != nil { + return handoff.HandoffMsg{ + Tag: tag, + Result: handoffResultFromError(err), + } + } + return launchDiffnavFromFile(tmpPath, cwd, tag)() + } +} + +// LaunchDiffnav writes diff content to a temp file and launches diffnav. +func LaunchDiffnav(diffContent string, tag any) tea.Cmd { + if !Available() { + return func() tea.Msg { + return InstallPromptMsg{PendingTag: tag} + } + } + return func() tea.Msg { + tmpPath, err := writeDiffToTempFile(diffContent) + if err != nil { + return handoff.HandoffMsg{ + Tag: tag, + Result: handoffResultFromError(err), + } + } + return launchDiffnavFromFile(tmpPath, "", tag)() + } +} + +func launchDiffnavFromFile(tmpPath string, cwd string, tag any) tea.Cmd { + stderrPath := tmpPath + ".stderr" + binary, args := diffnavInputCommand(tmpPath, stderrPath) + return handoff.HandoffWithCallback(handoff.Options{ + Binary: binary, + Args: args, + Cwd: cwd, + Tag: tag, + }, func(err error) tea.Msg { + return finishDiffnavLaunch(err, stderrPath, tmpPath, cwd, tag) + }) +} + +func writeDiffToTempFile(diffContent string) (string, error) { + tmp, err := os.CreateTemp("", "crush-diff-*.diff") + if err != nil { + return "", err + } + + tmpPath := tmp.Name() + if _, err := tmp.WriteString(diffContent); err != nil { + _ = tmp.Close() + _ = os.Remove(tmpPath) + return "", err + } + if err := tmp.Close(); err != nil { + _ = os.Remove(tmpPath) + return "", err + } + + return tmpPath, nil +} + +func writeCommandDiffToTempFile(command string, cwd string) (string, error) { + tmp, err := os.CreateTemp("", "crush-diff-*.diff") + if err != nil { + return "", err + } + + tmpPath := tmp.Name() + if err := runCommandToWriter(command, cwd, tmp); err != nil { + _ = tmp.Close() + _ = os.Remove(tmpPath) + return "", err + } + if err := tmp.Close(); err != nil { + _ = os.Remove(tmpPath) + return "", err + } + return tmpPath, nil +} + +func runCommandToWriter(command string, cwd string, output *os.File) error { + if strings.TrimSpace(command) == "" { + return errors.New("diff command must not be empty") + } + + binary, args := shellCommand(command) + cmd := exec.Command(binary, args...) //nolint:gosec // command string comes from the caller. + if cwd != "" { + cmd.Dir = cwd + } + cmd.Stdout = output + + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + msg = err.Error() + } + return fmt.Errorf("run diff command: %s", msg) + } + + return nil +} + +func shellCommand(command string) (string, []string) { + if runtime.GOOS == "windows" { + return "cmd", []string{"/C", command} + } + return "sh", []string{"-c", command} +} + +func diffnavInputCommand(inputPath string, stderrPath string) (string, []string) { + return shellCommand("diffnav < " + shellQuote(inputPath) + " 2> " + shellQuote(stderrPath)) +} + +func pagerCommand(path string, pagerEnv string) (string, []string) { + pagerEnv = strings.TrimSpace(pagerEnv) + if pagerEnv != "" { + return shellCommand(pagerEnv + " " + shellQuote(path)) + } + + if runtime.GOOS == "windows" { + return shellCommand("more < " + shellQuote(path)) + } + if _, err := exec.LookPath("less"); err == nil { + return "less", []string{"-R", path} + } + if _, err := exec.LookPath("more"); err == nil { + return "more", []string{path} + } + + return shellCommand("cat " + shellQuote(path)) +} + +func shellQuote(value string) string { + if runtime.GOOS == "windows" { + return `"` + strings.ReplaceAll(value, `"`, `""`) + `"` + } + return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'" +} + +func handoffResultFromError(err error) handoff.HandoffResult { + result := handoff.HandoffResult{Err: err} + if err != nil { + result.ExitCode = 1 + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + result.ExitCode = exitErr.ExitCode() + } + } + return result +} + +func finishDiffnavLaunch(err error, stderrPath string, tmpPath string, cwd string, tag any) tea.Msg { + reason := readCommandOutput(stderrPath) + _ = os.Remove(stderrPath) + + if err == nil { + _ = os.Remove(tmpPath) + return handoff.HandoffMsg{ + Tag: tag, + Result: handoffResultFromError(nil), + } + } + + if strings.TrimSpace(reason) == "" { + reason = err.Error() + } + return PagerFallbackMsg{ + Path: tmpPath, + Cwd: cwd, + Tag: tag, + Reason: reason, + } +} + +func readCommandOutput(path string) string { + content, err := os.ReadFile(path) + if err != nil { + return "" + } + return strings.TrimSpace(string(content)) +} + +// PagerFallbackMsg asks the UI to open a raw pager when diffnav exits with an +// error after the patch file has already been prepared. +type PagerFallbackMsg struct { + Path string + Cwd string + Tag any + Reason string +} + +// PagerErrorMsg reports a pager launch failure back to the UI. +type PagerErrorMsg struct { + Tag any + Err error +} + +// LaunchPager opens the prepared patch file in a plain pager and removes the +// temp file when the pager exits. +func LaunchPager(path string, cwd string, tag any) tea.Cmd { + binary, args := pagerCommand(path, os.Getenv("PAGER")) + return handoff.HandoffWithCallback(handoff.Options{ + Binary: binary, + Args: args, + Cwd: cwd, + Tag: tag, + }, func(err error) tea.Msg { + return finishPagerLaunch(err, path, tag) + }) +} + +func finishPagerLaunch(err error, path string, tag any) tea.Msg { + _ = os.Remove(path) + if err != nil { + return PagerErrorMsg{Tag: tag, Err: err} + } + return nil +} + +// --- Install prompt --- + +// InstallPromptMsg is emitted when diffnav is not found. The UI should +// show a prompt asking the user to install it. +type InstallPromptMsg struct { + PendingCommand string + PendingCwd string + PendingTag any +} + +// InstallResultMsg is emitted after an install attempt completes. +type InstallResultMsg struct { + Success bool + Method string // "brew", "binary", "go" + Err error +} + +// InstallDiffnav attempts to install diffnav using the best available method. +func InstallDiffnav() tea.Cmd { + return func() tea.Msg { + // 1. Try brew (macOS + Linux) + if _, err := exec.LookPath("brew"); err == nil { + cmd := exec.Command("brew", "install", "dlvhdr/formulae/diffnav") + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + if err := cmd.Run(); err == nil { + return InstallResultMsg{Success: true, Method: "brew"} + } + } + + // 2. Try direct binary download from GitHub releases + if err := installFromRelease(); err == nil { + return InstallResultMsg{Success: true, Method: "binary"} + } + + // 3. Try go install (if Go is available) + if _, err := exec.LookPath("go"); err == nil { + cmd := exec.Command("go", "install", "github.com/dlvhdr/diffnav@latest") + cmd.Env = append(os.Environ(), "CGO_ENABLED=0") + if out, err := cmd.CombinedOutput(); err == nil { + return InstallResultMsg{Success: true, Method: "go"} + } else { + return InstallResultMsg{Err: fmt.Errorf("go install: %s", strings.TrimSpace(string(out)))} + } + } + + return InstallResultMsg{ + Err: errors.New("could not install diffnav: no package manager found (tried brew, direct download, go install)"), + } + } +} + +// installFromRelease downloads the latest diffnav binary from GitHub releases. +func installFromRelease() error { + goos := runtime.GOOS + goarch := runtime.GOARCH + + // Map to release naming convention + osName := map[string]string{ + "darwin": "Darwin", "linux": "Linux", "windows": "Windows", + }[goos] + archName := map[string]string{ + "amd64": "x86_64", "arm64": "arm64", "386": "i386", + }[goarch] + if osName == "" || archName == "" { + return fmt.Errorf("unsupported platform: %s/%s", goos, goarch) + } + + ext := "tar.gz" + if goos == "windows" { + ext = "zip" + } + + // Get latest version + version := "0.11.0" // pinned known-good version + url := fmt.Sprintf( + "https://github.com/dlvhdr/diffnav/releases/download/v%s/diffnav_%s_%s.%s", + version, osName, archName, ext, + ) + + // Download and extract to a bin directory + binDir := os.ExpandEnv("$HOME/.local/bin") + os.MkdirAll(binDir, 0o755) + + if ext == "tar.gz" { + cmd := exec.Command("sh", "-c", + fmt.Sprintf("curl -sL '%s' | tar xz -C '%s' diffnav", url, binDir)) + if err := cmd.Run(); err != nil { + return fmt.Errorf("download: %w", err) + } + } else { + return fmt.Errorf("windows zip install not yet supported") + } + + // Verify it's there + path := binDir + "/diffnav" + if _, err := os.Stat(path); err != nil { + return fmt.Errorf("binary not found after download: %w", err) + } + os.Chmod(path, 0o755) + + // Add to PATH for this session if needed + currentPath := os.Getenv("PATH") + if !strings.Contains(currentPath, binDir) { + os.Setenv("PATH", binDir+":"+currentPath) + } + + return nil +} + +// InstallMethods returns a human-readable list of install options for display. +func InstallMethods() string { + var methods []string + if _, err := exec.LookPath("brew"); err == nil { + methods = append(methods, "brew install dlvhdr/formulae/diffnav") + } + methods = append(methods, "Download from github.com/dlvhdr/diffnav/releases") + if _, err := exec.LookPath("go"); err == nil { + methods = append(methods, "go install github.com/dlvhdr/diffnav@latest (requires source build)") + } + return strings.Join(methods, "\n") +} diff --git a/internal/ui/diffnav/launch_test.go b/internal/ui/diffnav/launch_test.go new file mode 100644 index 00000000..abdfe5af --- /dev/null +++ b/internal/ui/diffnav/launch_test.go @@ -0,0 +1,158 @@ +package diffnav + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/charmbracelet/crush/internal/ui/handoff" + "github.com/stretchr/testify/require" +) + +func TestWriteCommandDiffToTempFile_WritesCommandOutput(t *testing.T) { + t.Parallel() + + command := "printf 'hello\\n'" + if runtime.GOOS == "windows" { + command = "echo hello" + } + + tmpPath, err := writeCommandDiffToTempFile(command, "") + require.NoError(t, err) + t.Cleanup(func() { _ = os.Remove(tmpPath) }) + + content, err := os.ReadFile(tmpPath) + require.NoError(t, err) + require.Contains(t, string(content), "hello") +} + +func TestWriteCommandDiffToTempFile_UsesCwd(t *testing.T) { + t.Parallel() + + cwd := t.TempDir() + + command := "pwd" + if runtime.GOOS == "windows" { + command = "cd" + } + + tmpPath, err := writeCommandDiffToTempFile(command, cwd) + require.NoError(t, err) + t.Cleanup(func() { _ = os.Remove(tmpPath) }) + + content, err := os.ReadFile(tmpPath) + require.NoError(t, err) + require.Contains(t, strings.TrimSpace(string(content)), filepath.Clean(cwd)) +} + +func TestWriteCommandDiffToTempFile_ReturnsCommandStderr(t *testing.T) { + t.Parallel() + + command := "echo boom >&2; exit 7" + if runtime.GOOS == "windows" { + command = "echo boom 1>&2 & exit /b 7" + } + + tmpPath, err := writeCommandDiffToTempFile(command, "") + require.Error(t, err) + require.ErrorContains(t, err, "boom") + if tmpPath != "" { + _, statErr := os.Stat(tmpPath) + require.True(t, errors.Is(statErr, os.ErrNotExist)) + } +} + +func TestDiffnavInputCommand_UsesStdinRedirect(t *testing.T) { + t.Parallel() + + inputPath := filepath.Join("tmp", "sample diff.patch") + stderrPath := filepath.Join("tmp", "sample.stderr") + binary, args := diffnavInputCommand(inputPath, stderrPath) + require.NotEmpty(t, binary) + require.NotEmpty(t, args) + + command := args[len(args)-1] + require.Contains(t, command, "diffnav < ") + require.Contains(t, command, shellQuote(inputPath)) + require.Contains(t, command, "2> ") + require.Contains(t, command, shellQuote(stderrPath)) + require.NotContains(t, command, "--command") +} + +func TestPagerCommand_UsesPagerEnv(t *testing.T) { + t.Parallel() + + path := filepath.Join("tmp", "sample diff.patch") + binary, args := pagerCommand(path, "pager --plain") + require.NotEmpty(t, binary) + require.NotEmpty(t, args) + + command := args[len(args)-1] + require.Contains(t, command, "pager --plain") + require.Contains(t, command, shellQuote(path)) +} + +func TestFinishDiffnavLaunch_FallsBackToPagerAndPreservesPatch(t *testing.T) { + t.Parallel() + + tmpPath := filepath.Join(t.TempDir(), "sample.diff") + stderrPath := tmpPath + ".stderr" + require.NoError(t, os.WriteFile(tmpPath, []byte("diff --git"), 0o644)) + require.NoError(t, os.WriteFile(stderrPath, []byte("Caught panic: divide by zero"), 0o644)) + + msg := finishDiffnavLaunch(errors.New("exit status 1"), stderrPath, tmpPath, "/tmp/repo", "tag") + + fallback, ok := msg.(PagerFallbackMsg) + require.True(t, ok, "expected PagerFallbackMsg, got %T", msg) + require.Equal(t, tmpPath, fallback.Path) + require.Equal(t, "/tmp/repo", fallback.Cwd) + require.Equal(t, "tag", fallback.Tag) + require.Contains(t, fallback.Reason, "divide by zero") + + _, err := os.Stat(tmpPath) + require.NoError(t, err) + _, err = os.Stat(stderrPath) + require.True(t, errors.Is(err, os.ErrNotExist)) +} + +func TestFinishDiffnavLaunch_SuccessCleansTempFiles(t *testing.T) { + t.Parallel() + + tmpPath := filepath.Join(t.TempDir(), "sample.diff") + stderrPath := tmpPath + ".stderr" + require.NoError(t, os.WriteFile(tmpPath, []byte("diff --git"), 0o644)) + require.NoError(t, os.WriteFile(stderrPath, []byte(""), 0o644)) + + msg := finishDiffnavLaunch(nil, stderrPath, tmpPath, "/tmp/repo", "tag") + + handoffMsg, ok := msg.(handoff.HandoffMsg) + require.True(t, ok, "expected handoff.HandoffMsg, got %T", msg) + require.Equal(t, "tag", handoffMsg.Tag) + require.Zero(t, handoffMsg.Result.ExitCode) + require.NoError(t, handoffMsg.Result.Err) + + _, err := os.Stat(tmpPath) + require.True(t, errors.Is(err, os.ErrNotExist)) + _, err = os.Stat(stderrPath) + require.True(t, errors.Is(err, os.ErrNotExist)) +} + +func TestFinishPagerLaunch_RemovesPatchAndReturnsError(t *testing.T) { + t.Parallel() + + tmpPath := filepath.Join(t.TempDir(), "sample.diff") + require.NoError(t, os.WriteFile(tmpPath, []byte("diff --git"), 0o644)) + + msg := finishPagerLaunch(errors.New("pager failed"), tmpPath, "tag") + + pagerErr, ok := msg.(PagerErrorMsg) + require.True(t, ok, "expected PagerErrorMsg, got %T", msg) + require.Equal(t, "tag", pagerErr.Tag) + require.ErrorContains(t, pagerErr.Err, "pager failed") + + _, err := os.Stat(tmpPath) + require.True(t, errors.Is(err, os.ErrNotExist)) +} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 6f945af3..040bb2fd 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -33,10 +33,12 @@ import ( "github.com/charmbracelet/crush/internal/fsext" "github.com/charmbracelet/crush/internal/history" "github.com/charmbracelet/crush/internal/home" + "github.com/charmbracelet/crush/internal/jjhub" "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/permission" "github.com/charmbracelet/crush/internal/pubsub" "github.com/charmbracelet/crush/internal/session" + "github.com/charmbracelet/crush/internal/smithers" "github.com/charmbracelet/crush/internal/ui/anim" "github.com/charmbracelet/crush/internal/ui/attachments" "github.com/charmbracelet/crush/internal/ui/chat" @@ -44,10 +46,9 @@ import ( "github.com/charmbracelet/crush/internal/ui/completions" "github.com/charmbracelet/crush/internal/ui/components" "github.com/charmbracelet/crush/internal/ui/dialog" + dn "github.com/charmbracelet/crush/internal/ui/diffnav" fimage "github.com/charmbracelet/crush/internal/ui/image" "github.com/charmbracelet/crush/internal/ui/logo" - "github.com/charmbracelet/crush/internal/jjhub" - "github.com/charmbracelet/crush/internal/smithers" "github.com/charmbracelet/crush/internal/ui/notification" "github.com/charmbracelet/crush/internal/ui/styles" "github.com/charmbracelet/crush/internal/ui/util" @@ -218,10 +219,11 @@ type UI struct { sseEventCh <-chan interface{} // Smithers view router, registry, workspace model, and client. - viewRouter *views.Router - viewRegistry *views.Registry - smithersClient *smithers.Client - dashboard *views.DashboardView + viewRouter *views.Router + viewRegistry *views.Registry + smithersClient *smithers.Client + dashboard *views.DashboardView + pendingDiffInstall *dn.InstallPromptMsg // set when user is prompted to install diffnav // isCanceling tracks whether the user has pressed escape once to cancel. isCanceling bool @@ -1126,7 +1128,6 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.setState(uiLanding, uiFocusEditor) return m, tea.Batch(cmds...) case views.DashboardNavigateMsg: - // Dashboard requested navigation to a view m.setState(uiSmithersView, uiFocusMain) if cmd := m.handleNavigateToView(NavigateToViewMsg{View: msg.View}); cmd != nil { cmds = append(cmds, cmd) @@ -1139,6 +1140,82 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if cmd := m.handleNavigateToView(msg); cmd != nil { cmds = append(cmds, cmd) } + case dn.InstallPromptMsg: + cmds = append(cmds, func() tea.Msg { + return components.ShowToastMsg{ + Title: "diffnav not installed", + Body: "Install diffnav to view diffs? (y to install)", + Level: components.ToastLevelWarning, + } + }) + m.pendingDiffInstall = &msg + case dn.InstallResultMsg: + m.pendingDiffInstall = nil + if msg.Success { + cmds = append(cmds, func() tea.Msg { + return components.ShowToastMsg{ + Title: "diffnav installed", + Body: fmt.Sprintf("Installed via %s. Press d to view diff.", msg.Method), + Level: components.ToastLevelSuccess, + } + }) + } else { + cmds = append(cmds, func() tea.Msg { + return components.ShowToastMsg{ + Title: "Install failed", + Body: msg.Err.Error(), + Level: components.ToastLevelError, + } + }) + } + case dn.PagerFallbackMsg: + cmds = append(cmds, func() tea.Msg { + return components.ShowToastMsg{ + Title: pagerFallbackTitle(msg.Reason), + Body: pagerFallbackBody(msg.Reason), + Level: components.ToastLevelWarning, + } + }) + cmds = append(cmds, dn.LaunchPager(msg.Path, msg.Cwd, msg.Tag)) + case dn.PagerErrorMsg: + if msg.Err != nil { + cmds = append(cmds, func() tea.Msg { + return components.ShowToastMsg{ + Title: "Diff viewer error", + Body: msg.Err.Error(), + Level: components.ToastLevelError, + } + }) + } + case components.ShowToastMsg: + if m.toasts == nil { + cmds = append(cmds, fallbackToastStatusCmd(msg)) + } + case views.PopViewMsg: + if m.state == uiSmithersDashboard { + // Dashboard is not on the router stack — go to chat. + if m.hasSession() { + m.setState(uiChat, uiFocusEditor) + } else { + m.setState(uiLanding, uiFocusEditor) + } + } else if m.state == uiSmithersView { + if m.viewRouter.Depth() <= 1 { + m.viewRouter = views.NewRouter() + m.viewRouter.SetSize(m.width, m.height) + if m.dashboard != nil { + m.setState(uiSmithersDashboard, uiFocusMain) + } else if m.hasSession() { + m.setState(uiChat, uiFocusEditor) + } else { + m.setState(uiLanding, uiFocusEditor) + } + break + } + if cmd := m.viewRouter.Pop(); cmd != nil { + cmds = append(cmds, cmd) + } + } case util.InfoMsg: if msg.Type == util.InfoTypeError { slog.Error("Error reported", "error", msg.Msg) @@ -1185,14 +1262,23 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // Forward non-key messages to the dashboard (for fetch results). + // Navigation messages must NOT be forwarded — they need to reach the + // handler below (e.g. PopViewMsg to return to chat, InstallPromptMsg + // to push a diff view). if m.state == uiSmithersDashboard && m.dashboard != nil { if _, isKey := msg.(tea.KeyPressMsg); !isKey { - updated, cmd := m.dashboard.Update(msg) - if cmd != nil { - cmds = append(cmds, cmd) - } - if d, ok := updated.(*views.DashboardView); ok { - m.dashboard = d + switch msg.(type) { + case views.PopViewMsg, views.OpenChatMsg, views.DashboardNavigateMsg, + dn.InstallPromptMsg, dn.InstallResultMsg, dn.PagerFallbackMsg, dn.PagerErrorMsg: + // Let these fall through to the handler below. + default: + updated, cmd := m.dashboard.Update(msg) + if cmd != nil { + cmds = append(cmds, cmd) + } + if d, ok := updated.(*views.DashboardView); ok { + m.dashboard = d + } } } } @@ -1207,7 +1293,8 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if _, isKey := msg.(tea.KeyPressMsg); !isKey { switch msg.(type) { case views.PopViewMsg, views.OpenChatMsg, views.DashboardNavigateMsg, - views.OpenRunInspectMsg, views.OpenLiveChatMsg, views.OpenTicketDetailMsg: + views.OpenRunInspectMsg, views.OpenLiveChatMsg, views.OpenTicketDetailMsg, + dn.InstallPromptMsg, dn.InstallResultMsg, dn.PagerFallbackMsg, dn.PagerErrorMsg: // These are navigation commands — let them fall through to the handler below. default: if cmd := m.viewRouter.Update(msg); cmd != nil { @@ -1859,21 +1946,6 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { m.setState(uiSmithersView, uiFocusMain) cmds = append(cmds, cmd) - case views.PopViewMsg: - if cmd := m.viewRouter.Pop(); cmd != nil { - cmds = append(cmds, cmd) - } - if !m.viewRouter.HasViews() { - // Return to dashboard in Smithers mode, chat otherwise - if m.dashboard != nil { - m.setState(uiSmithersDashboard, uiFocusMain) - } else if m.hasSession() { - m.setState(uiChat, uiFocusEditor) - } else { - m.setState(uiLanding, uiFocusEditor) - } - } - case dialog.ActionSelectModel: if m.isAgentBusy() { cmds = append(cmds, util.ReportWarn("Agent is busy, please wait...")) @@ -2101,6 +2173,70 @@ func (m *UI) openAuthenticationDialog(provider catwalk.Provider, model config.Se return cmd } +func fallbackToastStatusCmd(msg components.ShowToastMsg) tea.Cmd { + text := strings.TrimSpace(msg.Title) + body := strings.TrimSpace(msg.Body) + if body != "" { + if text != "" { + text += ": " + } + text += body + } + if text == "" { + return nil + } + + info := util.InfoMsg{ + Msg: text, + TTL: msg.TTL, + } + + switch msg.Level { + case components.ToastLevelSuccess: + info.Type = util.InfoTypeSuccess + case components.ToastLevelWarning: + info.Type = util.InfoTypeWarn + case components.ToastLevelError: + info.Type = util.InfoTypeError + default: + info.Type = util.InfoTypeInfo + } + + return util.CmdHandler(info) +} + +func pagerFallbackTitle(reason string) string { + if strings.Contains(strings.ToLower(reason), "panic") { + return "diffnav crashed" + } + return "diffnav failed" +} + +func pagerFallbackBody(reason string) string { + body := "Opening raw diff pager instead." + summary := summarizeExternalError(reason) + if summary == "" { + return body + } + return body + " " + summary +} + +func summarizeExternalError(reason string) string { + for _, line := range strings.Split(reason, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + line = strings.TrimPrefix(line, "FATA ") + line = strings.TrimPrefix(line, "ERROR ") + if len(line) > 96 { + line = line[:93] + "..." + } + return line + } + return "" +} + func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { var cmds []tea.Cmd @@ -2191,11 +2327,31 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { // Only active when the editor is not focused to avoid capturing text input. // TODO: inline approval — wire smithersClient.ApproveGate(approvalID) directly // from the toast key handler for < 3-keystroke approval (notifications-approval-inline). - if key.Matches(msg, m.keyMap.ViewApprovalsShort) && m.focus != uiFocusEditor { + if key.Matches(msg, m.keyMap.ViewApprovalsShort) && + m.focus != uiFocusEditor && + (m.state == uiChat || m.state == uiLanding || m.state == uiSmithersDashboard) { cmds = append(cmds, m.navigateToView("approvals")) return tea.Batch(cmds...) } + // Handle diffnav install prompt. "y" starts the install; any other key + // dismisses the prompt and continues normal key handling. + if m.pendingDiffInstall != nil { + if key.Matches(msg, key.NewBinding(key.WithKeys("y"))) { + m.pendingDiffInstall = nil + cmds = append(cmds, func() tea.Msg { + return components.ShowToastMsg{ + Title: "Installing diffnav...", + Body: "This may take a moment", + Level: components.ToastLevelInfo, + } + }) + cmds = append(cmds, dn.InstallDiffnav()) + return tea.Batch(cmds...) + } + m.pendingDiffInstall = nil + } + // Route all messages to dialog if one is open. if m.dialog.HasDialogs() { return m.handleDialogMsg(msg) @@ -2218,6 +2374,9 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { cmds = append(cmds, m.updateInitializeView(msg)...) return tea.Batch(cmds...) case uiSmithersDashboard: + if handleGlobalKeys(msg) { + return tea.Batch(cmds...) + } // Forward all keys to the dashboard view. if m.dashboard != nil { updated, cmd := m.dashboard.Update(msg) @@ -2818,6 +2977,10 @@ func (m *UI) ShortHelp() []key.Binding { if current := m.viewRouter.Current(); current != nil { binds = append(binds, current.ShortHelp()...) } + case uiSmithersDashboard: + if m.dashboard != nil { + binds = append(binds, m.dashboard.ShortHelp()...) + } default: // TODO: other states // if m.session == nil { diff --git a/internal/ui/model/ui_diffnav_test.go b/internal/ui/model/ui_diffnav_test.go new file mode 100644 index 00000000..456c14f0 --- /dev/null +++ b/internal/ui/model/ui_diffnav_test.go @@ -0,0 +1,118 @@ +package model + +import ( + "testing" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textarea" + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/crush/internal/ui/components" + dn "github.com/charmbracelet/crush/internal/ui/diffnav" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/crush/internal/ui/util" + "github.com/charmbracelet/crush/internal/ui/views" + "github.com/stretchr/testify/require" +) + +type popOnEscView struct{} + +func (v *popOnEscView) Init() tea.Cmd { return nil } + +func (v *popOnEscView) Update(msg tea.Msg) (views.View, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyPressMsg) + if !ok { + return v, nil + } + if keyMsg.Code == tea.KeyEscape { + return v, func() tea.Msg { return views.PopViewMsg{} } + } + return v, nil +} + +func (v *popOnEscView) View() string { return "" } + +func (v *popOnEscView) Name() string { return "pop-on-esc" } + +func (v *popOnEscView) SetSize(width, height int) {} + +func (v *popOnEscView) ShortHelp() []key.Binding { return nil } + +func TestUI_ShowToastMsgFallsBackToStatusWhenToastsDisabled(t *testing.T) { + t.Parallel() + + ui := newShortcutTestUI() + ui.focus = uiFocusNone + ui.toasts = nil + + _, cmd := ui.Update(components.ShowToastMsg{ + Title: "diffnav not installed", + Body: "Install diffnav to view diffs? (y to install)", + Level: components.ToastLevelWarning, + }) + require.NotNil(t, cmd) + + msg := cmd() + infoMsg, ok := msg.(util.InfoMsg) + require.True(t, ok, "expected util.InfoMsg, got %T", msg) + require.Equal(t, util.InfoTypeWarn, infoMsg.Type) + require.Equal(t, "diffnav not installed: Install diffnav to view diffs? (y to install)", infoMsg.Msg) +} + +func TestHandleKeyPressMsg_PendingDiffInstallDismissesAndForwardsEscape(t *testing.T) { + t.Parallel() + + ui := newShortcutTestUI() + ui.state = uiSmithersView + ui.focus = uiFocusMain + ui.pendingDiffInstall = &dn.InstallPromptMsg{PendingCommand: "jjhub change diff abc123"} + ui.viewRouter = views.NewRouter() + ui.viewRouter.Push(&popOnEscView{}, 80, 24) + + cmd := ui.handleKeyPressMsg(tea.KeyPressMsg{Code: tea.KeyEscape}) + require.Nil(t, ui.pendingDiffInstall) + require.NotNil(t, cmd) + + msg := cmd() + _, ok := msg.(views.PopViewMsg) + require.True(t, ok, "expected views.PopViewMsg, got %T", msg) +} + +func TestUI_PopViewMsgFromSingleSmithersViewReturnsToDashboard(t *testing.T) { + t.Parallel() + + ui := newShortcutTestUI() + ui.state = uiSmithersView + ui.focus = uiFocusMain + ui.width = 80 + ui.height = 24 + st := styles.DefaultStyles() + ui.com.Styles = &st + ui.chat = NewChat(ui.com) + ui.status = NewStatus(ui.com, ui) + ui.textarea = textarea.New() + ui.dashboard = &views.DashboardView{} + ui.viewRouter = views.NewRouter() + ui.viewRouter.Push(&popOnEscView{}, ui.width, ui.height) + + model, cmd := ui.Update(views.PopViewMsg{}) + require.Nil(t, cmd) + + updated := model.(*UI) + require.Equal(t, uiSmithersDashboard, updated.state) + require.False(t, updated.viewRouter.HasViews()) +} + +func TestPagerFallbackTitleDetectsCrash(t *testing.T) { + t.Parallel() + + require.Equal(t, "diffnav crashed", pagerFallbackTitle("Caught panic: divide by zero")) + require.Equal(t, "diffnav failed", pagerFallbackTitle("exit status 1")) +} + +func TestPagerFallbackBodyIncludesSummary(t *testing.T) { + t.Parallel() + + body := pagerFallbackBody("FATA program was killed: program experienced a panic\nstack trace") + require.Contains(t, body, "Opening raw diff pager instead.") + require.Contains(t, body, "program was killed: program experienced a panic") +} diff --git a/internal/ui/views/changes.go b/internal/ui/views/changes.go new file mode 100644 index 00000000..00de0641 --- /dev/null +++ b/internal/ui/views/changes.go @@ -0,0 +1,995 @@ +package views + +import ( + "fmt" + "os" + "strings" + "time" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/jjhub" + "github.com/charmbracelet/crush/internal/ui/components" + "github.com/charmbracelet/crush/internal/ui/diffnav" + "github.com/charmbracelet/crush/internal/ui/handoff" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/x/ansi" +) + +// Compile-time interface check. +var _ View = (*ChangesView)(nil) + +const changesDiffTag = "changes-diffnav" + +type changesClient interface { + ListChanges(limit int) ([]jjhub.Change, error) +} + +type diffnavLauncher func(command string, cwd string, tag any) tea.Cmd + +type changesLoadedMsg struct { + changes []jjhub.Change +} + +type changesErrorMsg struct { + err error +} + +type changeListPane struct { + changes []jjhub.Change + cursor int + scrollOffset int + width int + height int +} + +func (p *changeListPane) Init() tea.Cmd { return nil } + +func (p *changeListPane) Update(msg tea.Msg) (components.Pane, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyPressMsg) + if !ok { + return p, nil + } + + switch { + case key.Matches(keyMsg, key.NewBinding(key.WithKeys("up", "k"))): + if p.cursor > 0 { + p.cursor-- + } + case key.Matches(keyMsg, key.NewBinding(key.WithKeys("down", "j"))): + if p.cursor < len(p.changes)-1 { + p.cursor++ + } + case key.Matches(keyMsg, key.NewBinding(key.WithKeys("home", "g"))): + p.cursor = 0 + p.scrollOffset = 0 + case key.Matches(keyMsg, key.NewBinding(key.WithKeys("end", "G"))): + if len(p.changes) > 0 { + p.cursor = len(p.changes) - 1 + } + case key.Matches(keyMsg, key.NewBinding(key.WithKeys("pgup", "ctrl+u"))): + p.cursor -= p.pageSize() + if p.cursor < 0 { + p.cursor = 0 + } + case key.Matches(keyMsg, key.NewBinding(key.WithKeys("pgdown", "ctrl+d"))): + p.cursor += p.pageSize() + if len(p.changes) > 0 && p.cursor >= len(p.changes) { + p.cursor = len(p.changes) - 1 + } + } + + p.clampCursor() + p.ensureCursorVisible() + return p, nil +} + +func (p *changeListPane) SetSize(width, height int) { + p.width = width + p.height = height + p.ensureCursorVisible() +} + +func (p *changeListPane) setChanges(changes []jjhub.Change) { + p.changes = changes + p.clampCursor() + p.ensureCursorVisible() +} + +func (p *changeListPane) setCursor(cursor int) { + p.cursor = cursor + p.clampCursor() + p.ensureCursorVisible() +} + +func (p *changeListPane) clampCursor() { + if len(p.changes) == 0 { + p.cursor = 0 + p.scrollOffset = 0 + return + } + if p.cursor < 0 { + p.cursor = 0 + } + if p.cursor >= len(p.changes) { + p.cursor = len(p.changes) - 1 + } +} + +func (p *changeListPane) visibleRows() int { + if p.height <= 2 { + return 1 + } + return p.height - 2 +} + +func (p *changeListPane) pageSize() int { + pageSize := p.visibleRows() + if pageSize < 1 { + return 1 + } + return pageSize +} + +func (p *changeListPane) ensureCursorVisible() { + if len(p.changes) == 0 { + p.scrollOffset = 0 + return + } + + visibleRows := p.visibleRows() + if p.cursor < p.scrollOffset { + p.scrollOffset = p.cursor + } + if p.cursor >= p.scrollOffset+visibleRows { + p.scrollOffset = p.cursor - visibleRows + 1 + } + maxOffset := max(0, len(p.changes)-visibleRows) + if p.scrollOffset > maxOffset { + p.scrollOffset = maxOffset + } +} + +func (p *changeListPane) View() string { + if len(p.changes) == 0 { + return lipgloss.NewStyle().Faint(true).Render("No changes found.") + } + + visibleColumns := changeTableColumns(p.width) + var b strings.Builder + + headerCells := make([]string, 0, len(visibleColumns)) + for _, col := range visibleColumns { + headerCells = append(headerCells, lipgloss.NewStyle().Bold(true).Faint(true).Render(padChangeCell(col.Title, col.Width))) + } + b.WriteString(" " + strings.Join(headerCells, " ") + "\n") + + visibleRows := p.visibleRows() + p.ensureCursorVisible() + end := min(len(p.changes), p.scrollOffset+visibleRows) + + for i := p.scrollOffset; i < end; i++ { + change := p.changes[i] + cells := make([]string, 0, len(visibleColumns)) + for _, col := range visibleColumns { + cells = append(cells, padChangeCell(changeColumnValue(change, col.Title), col.Width)) + } + + indicator := " " + if i == p.cursor { + indicator = "▸ " + } + + line := indicator + strings.Join(cells, " ") + rowStyle := lipgloss.NewStyle() + if change.IsEmpty { + rowStyle = rowStyle.Faint(true) + } + if i == p.cursor { + rowStyle = rowStyle.Bold(true).Background(lipgloss.Color("238")) + } else if i%2 == 1 { + rowStyle = rowStyle.Background(lipgloss.Color("236")) + } + + b.WriteString(rowStyle.Width(max(0, p.width)).Render(line)) + b.WriteString("\n") + } + + scroll := fmt.Sprintf("%d/%d", p.cursor+1, len(p.changes)) + b.WriteString(lipgloss.NewStyle(). + Width(max(0, p.width)). + Align(lipgloss.Right). + Faint(true). + Render(scroll)) + + return b.String() +} + +type changePreviewPane struct { + changes []jjhub.Change + cursor *int + scrollOffset int + width int + height int +} + +func (p *changePreviewPane) Init() tea.Cmd { return nil } + +func (p *changePreviewPane) Update(msg tea.Msg) (components.Pane, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyPressMsg) + if !ok { + return p, nil + } + + switch { + case key.Matches(keyMsg, key.NewBinding(key.WithKeys("up", "k"))): + if p.scrollOffset > 0 { + p.scrollOffset-- + } + case key.Matches(keyMsg, key.NewBinding(key.WithKeys("down", "j"))): + if p.scrollOffset < p.maxScrollOffset() { + p.scrollOffset++ + } + case key.Matches(keyMsg, key.NewBinding(key.WithKeys("home", "g"))): + p.scrollOffset = 0 + case key.Matches(keyMsg, key.NewBinding(key.WithKeys("end", "G"))): + p.scrollOffset = p.maxScrollOffset() + case key.Matches(keyMsg, key.NewBinding(key.WithKeys("pgup", "ctrl+u"))): + p.scrollOffset -= p.pageSize() + if p.scrollOffset < 0 { + p.scrollOffset = 0 + } + case key.Matches(keyMsg, key.NewBinding(key.WithKeys("pgdown", "ctrl+d"))): + p.scrollOffset += p.pageSize() + if p.scrollOffset > p.maxScrollOffset() { + p.scrollOffset = p.maxScrollOffset() + } + } + + return p, nil +} + +func (p *changePreviewPane) SetSize(width, height int) { + p.width = width + p.height = height + p.clampScroll() +} + +func (p *changePreviewPane) setChanges(changes []jjhub.Change) { + p.changes = changes + p.scrollOffset = 0 +} + +func (p *changePreviewPane) resetScroll() { + p.scrollOffset = 0 +} + +func (p *changePreviewPane) visibleRows() int { + if p.height <= 1 { + return 1 + } + return p.height - 1 +} + +func (p *changePreviewPane) pageSize() int { + pageSize := p.visibleRows() + if pageSize < 1 { + return 1 + } + return pageSize +} + +func (p *changePreviewPane) maxScrollOffset() int { + return max(0, len(p.previewLines())-p.visibleRows()) +} + +func (p *changePreviewPane) clampScroll() { + maxOffset := p.maxScrollOffset() + if p.scrollOffset > maxOffset { + p.scrollOffset = maxOffset + } + if p.scrollOffset < 0 { + p.scrollOffset = 0 + } +} + +func (p *changePreviewPane) previewLines() []string { + if p.cursor == nil || *p.cursor < 0 || *p.cursor >= len(p.changes) { + return []string{lipgloss.NewStyle().Faint(true).Render("Select a change.")} + } + + change := p.changes[*p.cursor] + lines := make([]string, 0, 16) + + titleStyle := lipgloss.NewStyle().Bold(true) + labelStyle := lipgloss.NewStyle().Bold(true).Faint(true) + + lines = append(lines, titleStyle.Render("Change")) + lines = append(lines, renderPreviewField(labelStyle, "Change ID", change.ChangeID, p.width)...) + lines = append(lines, renderPreviewField(labelStyle, "Commit ID", change.CommitID, p.width)...) + lines = append(lines, renderPreviewField(labelStyle, "Author", formatChangeAuthorFull(change.Author), p.width)...) + lines = append(lines, renderPreviewField(labelStyle, "Timestamp", formatChangeTimestampFull(change.Timestamp), p.width)...) + lines = append(lines, labelStyle.Render("Bookmarks")) + if len(change.Bookmarks) == 0 { + lines = append(lines, lipgloss.NewStyle().Faint(true).Render("none")) + } else { + lines = append(lines, renderPreviewTags(change.Bookmarks, p.width)...) + } + lines = append(lines, "") + lines = append(lines, labelStyle.Render("Description")) + + description := strings.TrimSpace(change.Description) + if description == "" { + description = "(empty)" + } + lines = append(lines, wrapPreviewText(description, max(1, p.width))...) + + return lines +} + +func (p *changePreviewPane) View() string { + lines := p.previewLines() + p.clampScroll() + + start := p.scrollOffset + end := min(len(lines), start+p.visibleRows()) + scroll := fmt.Sprintf("%d/%d", min(end, len(lines)), len(lines)) + + var b strings.Builder + for i := start; i < end; i++ { + b.WriteString(lines[i]) + if i < end-1 { + b.WriteString("\n") + } + } + + if end > start { + b.WriteString("\n") + } + b.WriteString(lipgloss.NewStyle(). + Width(max(0, p.width)). + Align(lipgloss.Right). + Faint(true). + Render(scroll)) + + return b.String() +} + +// ChangesView displays JJHub changes in a navigable table with an optional +// sidebar preview and diffnav handoff. +type ChangesView struct { + client changesClient + cwd string + width int + height int + loading bool + err error + statusMsg string + statusErr bool + changes []jjhub.Change + filteredChanges []jjhub.Change + searchActive bool + searchInput textinput.Model + previewVisible bool + diffnavAvailable func() bool + launchDiffnav diffnavLauncher + splitPane *components.SplitPane + listPane *changeListPane + previewPane *changePreviewPane +} + +// NewChangesView creates a new JJHub changes browser. +func NewChangesView() *ChangesView { + cwd, _ := os.Getwd() + return newChangesView(jjhub.NewClient(""), cwd, func() bool { return true }, diffnav.LaunchDiffnavWithCommand) +} + +func newChangesView( + client changesClient, + cwd string, + diffnavAvailable func() bool, + launchDiffnav diffnavLauncher, +) *ChangesView { + listPane := &changeListPane{} + previewPane := &changePreviewPane{cursor: &listPane.cursor} + splitPane := components.NewSplitPane(listPane, previewPane, components.SplitPaneOpts{ + LeftWidth: 72, + CompactBreakpoint: 100, + }) + + searchInput := textinput.New() + searchInput.Placeholder = "search descriptions..." + searchInput.Prompt = "" + searchInput.SetVirtualCursor(true) + + return &ChangesView{ + client: client, + cwd: cwd, + loading: true, + searchInput: searchInput, + diffnavAvailable: diffnavAvailable, + launchDiffnav: launchDiffnav, + splitPane: splitPane, + listPane: listPane, + previewPane: previewPane, + } +} + +// Init fetches the initial change list. +func (v *ChangesView) Init() tea.Cmd { + return v.fetchChangesCmd() +} + +func (v *ChangesView) fetchChangesCmd() tea.Cmd { + client := v.client + return func() tea.Msg { + if client == nil { + return changesErrorMsg{err: fmt.Errorf("jjhub client is not configured")} + } + + changes, err := client.ListChanges(100) + if err != nil { + return changesErrorMsg{err: err} + } + return changesLoadedMsg{changes: changes} + } +} + +// Update handles messages for the changes view. +func (v *ChangesView) Update(msg tea.Msg) (View, tea.Cmd) { + switch msg := msg.(type) { + case changesLoadedMsg: + v.loading = false + v.err = nil + v.changes = msg.changes + v.applyFilter() + v.syncLayout() + return v, nil + + case changesErrorMsg: + v.loading = false + v.err = msg.err + v.syncLayout() + return v, nil + + case handoff.HandoffMsg: + if msg.Tag != changesDiffTag { + return v, nil + } + if msg.Result.Err != nil { + v.statusMsg = fmt.Sprintf("Diffnav error: %v", msg.Result.Err) + v.statusErr = true + v.syncLayout() + } + return v, nil + + case tea.WindowSizeMsg: + v.SetSize(msg.Width, msg.Height) + return v, nil + + case tea.KeyPressMsg: + if v.searchActive { + switch { + case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))): + if v.searchInput.Value() != "" { + v.searchInput.Reset() + v.applyFilter() + return v, nil + } + + v.searchActive = false + v.searchInput.Blur() + v.syncLayout() + return v, nil + + default: + prevQuery := v.searchInput.Value() + var cmd tea.Cmd + v.searchInput, cmd = v.searchInput.Update(msg) + if v.searchInput.Value() != prevQuery { + v.applyFilter() + } + return v, cmd + } + } + + switch { + case key.Matches(msg, key.NewBinding(key.WithKeys("esc", "q", "alt+esc"))): + return v, func() tea.Msg { return PopViewMsg{} } + + case key.Matches(msg, key.NewBinding(key.WithKeys("/"))): + v.searchActive = true + v.syncLayout() + return v, v.searchInput.Focus() + + case key.Matches(msg, key.NewBinding(key.WithKeys("w"))): + v.previewVisible = !v.previewVisible + v.previewPane.resetScroll() + if !v.previewVisible { + v.splitPane.SetFocus(components.FocusLeft) + } + v.syncLayout() + return v, nil + + case key.Matches(msg, key.NewBinding(key.WithKeys("r", "R"))): + v.loading = true + v.err = nil + v.statusMsg = "" + v.statusErr = false + v.syncLayout() + return v, v.fetchChangesCmd() + + case key.Matches(msg, key.NewBinding(key.WithKeys("enter", "d"))): + return v, v.launchSelectedDiff() + } + } + + oldCursor := v.listPane.cursor + if v.previewVisible { + newSplitPane, cmd := v.splitPane.Update(msg) + v.splitPane = newSplitPane + if v.listPane.cursor != oldCursor { + v.previewPane.resetScroll() + } + return v, cmd + } + + newListPane, cmd := v.listPane.Update(msg) + v.listPane = newListPane.(*changeListPane) + if v.listPane.cursor != oldCursor { + v.previewPane.resetScroll() + } + return v, cmd +} + +func (v *ChangesView) launchSelectedDiff() tea.Cmd { + change, ok := v.selectedChange() + if !ok { + return nil + } + + v.statusMsg = "" + v.statusErr = false + v.syncLayout() + return v.launchDiffnav(buildChangeDiffCommand(change), v.cwd, changesDiffTag) +} + +func (v *ChangesView) selectedChange() (jjhub.Change, bool) { + if len(v.filteredChanges) == 0 || v.listPane.cursor >= len(v.filteredChanges) { + return jjhub.Change{}, false + } + return v.filteredChanges[v.listPane.cursor], true +} + +func (v *ChangesView) applyFilter() { + query := strings.TrimSpace(strings.ToLower(v.searchInput.Value())) + selectedID := "" + if selected, ok := v.selectedChange(); ok { + selectedID = selected.ChangeID + } + + filtered := make([]jjhub.Change, 0, len(v.changes)) + for _, change := range v.changes { + if query != "" && !strings.Contains(strings.ToLower(change.Description), query) { + continue + } + filtered = append(filtered, change) + } + + v.filteredChanges = filtered + v.listPane.setChanges(filtered) + v.previewPane.setChanges(filtered) + + if selectedID == "" { + v.listPane.setCursor(0) + return + } + + for i, change := range filtered { + if change.ChangeID == selectedID { + v.listPane.setCursor(i) + return + } + } + + v.listPane.setCursor(min(v.listPane.cursor, max(0, len(filtered)-1))) +} + +func (v *ChangesView) filteredCountLabel() string { + if !v.loading && v.searchInput.Value() != "" { + return fmt.Sprintf("%d/%d", len(v.filteredChanges), len(v.changes)) + } + if v.loading { + return "" + } + return fmt.Sprintf("%d", len(v.filteredChanges)) +} + +func (v *ChangesView) chromeHeight() int { + height := 2 + if v.searchActive { + height += 2 + } + if v.statusMsg != "" { + height++ + } + return height +} + +func (v *ChangesView) syncLayout() { + contentHeight := max(0, v.height-v.chromeHeight()) + v.searchInput.SetWidth(max(10, v.width-4)) + + if v.previewVisible { + v.splitPane.SetSize(v.width, contentHeight) + return + } + + v.listPane.SetSize(v.width, contentHeight) +} + +// View renders the changes browser. +func (v *ChangesView) View() string { + var b strings.Builder + + title := "JJHub › Changes" + if count := v.filteredCountLabel(); count != "" { + title = fmt.Sprintf("%s (%s)", title, count) + } + + header := lipgloss.NewStyle().Bold(true).Render(title) + helpHint := lipgloss.NewStyle().Faint(true).Render("[Esc] Back") + headerLine := header + if v.width > 0 { + gap := v.width - lipgloss.Width(header) - lipgloss.Width(helpHint) + if gap > 1 { + headerLine = header + strings.Repeat(" ", gap) + helpHint + } else { + headerLine = header + " " + helpHint + } + } + + b.WriteString(headerLine) + b.WriteString("\n\n") + + if v.searchActive { + b.WriteString(lipgloss.NewStyle().Faint(true).Render("/") + " " + v.searchInput.View()) + b.WriteString("\n\n") + } + + if v.statusMsg != "" { + style := lipgloss.NewStyle().Foreground(lipgloss.Color("10")) + if v.statusErr { + style = lipgloss.NewStyle().Foreground(lipgloss.Color("9")) + } + b.WriteString(style.Render(" " + v.statusMsg)) + b.WriteString("\n") + } + + if v.loading { + b.WriteString(" Loading changes...\n") + return b.String() + } + + if v.err != nil { + b.WriteString(fmt.Sprintf(" Error: %v\n", v.err)) + return b.String() + } + + if len(v.filteredChanges) == 0 { + if query := v.searchInput.Value(); query != "" { + b.WriteString(fmt.Sprintf(" No changes matching %q.\n", query)) + } else { + b.WriteString(" No changes found.\n") + } + return b.String() + } + + if v.previewVisible { + b.WriteString(v.splitPane.View()) + return b.String() + } + + b.WriteString(v.listPane.View()) + return b.String() +} + +// Name returns the route name. +func (v *ChangesView) Name() string { return "changes" } + +// SetSize stores the current terminal size. +func (v *ChangesView) SetSize(width, height int) { + v.width = width + v.height = height + v.syncLayout() +} + +// ShortHelp returns contextual key bindings for the help bar. +func (v *ChangesView) ShortHelp() []key.Binding { + if v.searchActive { + return []key.Binding{ + key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "clear/back")), + } + } + + bindings := []key.Binding{ + key.NewBinding(key.WithKeys("up", "k", "down", "j"), key.WithHelp("↑↓/jk", "navigate")), + key.NewBinding(key.WithKeys("enter", "d"), key.WithHelp("enter/d", "diff")), + key.NewBinding(key.WithKeys("w"), key.WithHelp("w", "preview")), + key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "search")), + key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")), + } + + if v.previewVisible { + bindings = append(bindings, key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "focus pane"))) + } + + bindings = append(bindings, key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back"))) + return bindings +} + +type changeTableColumn struct { + Title string + Width int + Grow bool + MinWidth int +} + +func changeTableColumns(width int) []changeTableColumn { + columns := []changeTableColumn{ + {Title: "Change ID", Width: 14}, + {Title: "Description", Grow: true}, + {Title: "Author", Width: 16, MinWidth: 68}, + {Title: "Bookmarks", Width: 18, MinWidth: 88}, + {Title: "Timestamp", Width: 10, MinWidth: 56}, + } + + visible := make([]changeTableColumn, 0, len(columns)) + for _, col := range columns { + if col.MinWidth > 0 && width < col.MinWidth { + continue + } + visible = append(visible, col) + } + + fixedWidth := 2 + separatorWidth := max(0, len(visible)-1) + growColumns := 0 + for _, col := range visible { + if col.Grow { + growColumns++ + continue + } + fixedWidth += col.Width + } + + remaining := width - fixedWidth - separatorWidth + if remaining < 12 { + remaining = 12 + } + if growColumns == 0 { + return visible + } + + perColumn := remaining / growColumns + for i := range visible { + if visible[i].Grow { + visible[i].Width = perColumn + } + } + return visible +} + +func changeColumnValue(change jjhub.Change, column string) string { + switch column { + case "Change ID": + marker := " " + if change.IsWorkingCopy { + marker = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Render(styles.ToolPending + " ") + } + return marker + shortChangeID(change.ChangeID) + case "Description": + return formatChangeDescription(change) + case "Author": + return formatChangeAuthor(change.Author) + case "Bookmarks": + return formatChangeBookmarks(change.Bookmarks) + case "Timestamp": + return formatChangeTimestampShort(change.Timestamp) + default: + return "" + } +} + +func shortChangeID(changeID string) string { + runes := []rune(strings.TrimSpace(changeID)) + if len(runes) <= 12 { + return string(runes) + } + return string(runes[:12]) +} + +func formatChangeDescription(change jjhub.Change) string { + description := normalizeChangeText(change.Description) + if change.IsEmpty { + emptyLabel := lipgloss.NewStyle().Faint(true).Render("(empty)") + if description == "" { + return emptyLabel + } + return description + " " + emptyLabel + } + if description == "" { + return lipgloss.NewStyle().Faint(true).Render("(no description)") + } + return description +} + +func normalizeChangeText(text string) string { + text = strings.ReplaceAll(text, "\n", " ") + return strings.Join(strings.Fields(text), " ") +} + +func formatChangeAuthor(author jjhub.Author) string { + switch { + case author.Name != "": + return author.Name + case author.Email != "": + return author.Email + default: + return "-" + } +} + +func formatChangeAuthorFull(author jjhub.Author) string { + switch { + case author.Name != "" && author.Email != "": + return fmt.Sprintf("%s <%s>", author.Name, author.Email) + case author.Name != "": + return author.Name + case author.Email != "": + return author.Email + default: + return "-" + } +} + +func formatChangeBookmarks(bookmarks []string) string { + if len(bookmarks) == 0 { + return lipgloss.NewStyle().Faint(true).Render("-") + } + return strings.Join(bookmarks, ", ") +} + +func formatChangeTimestampShort(ts string) string { + parsed, ok := parseChangeTimestamp(ts) + if !ok { + return truncateStr(ts, 10) + } + + age := time.Since(parsed) + switch { + case age < 0: + return "now" + case age < time.Minute: + return "now" + case age < time.Hour: + return fmt.Sprintf("%dm ago", int(age.Minutes())) + case age < 24*time.Hour: + return fmt.Sprintf("%dh ago", int(age.Hours())) + case age < 7*24*time.Hour: + return fmt.Sprintf("%dd ago", int(age.Hours()/24)) + default: + return parsed.Format("2006-01-02") + } +} + +func formatChangeTimestampFull(ts string) string { + parsed, ok := parseChangeTimestamp(ts) + if !ok { + return ts + } + return parsed.Format(time.RFC3339) +} + +func parseChangeTimestamp(ts string) (time.Time, bool) { + for _, layout := range []string{time.RFC3339Nano, time.RFC3339} { + parsed, err := time.Parse(layout, ts) + if err == nil { + return parsed, true + } + } + return time.Time{}, false +} + +func renderPreviewField(labelStyle lipgloss.Style, label string, value string, width int) []string { + lines := []string{labelStyle.Render(label)} + lines = append(lines, wrapPreviewText(value, max(1, width))...) + return lines +} + +func renderPreviewTags(bookmarks []string, width int) []string { + if len(bookmarks) == 0 { + return []string{lipgloss.NewStyle().Faint(true).Render("none")} + } + + tagStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("86")). + Background(lipgloss.Color("236")). + Padding(0, 1) + + line := make([]string, 0, len(bookmarks)) + for _, bookmark := range bookmarks { + line = append(line, tagStyle.Render(bookmark)) + } + + rendered := strings.Join(line, " ") + if ansi.StringWidth(rendered) <= width { + return []string{rendered} + } + + lines := make([]string, 0, len(bookmarks)) + for _, bookmark := range bookmarks { + lines = append(lines, tagStyle.Render(bookmark)) + } + return lines +} + +func wrapPreviewText(text string, width int) []string { + if width <= 0 { + return []string{text} + } + + paragraphs := strings.Split(text, "\n") + lines := make([]string, 0, len(paragraphs)) + for _, paragraph := range paragraphs { + paragraph = strings.TrimSpace(paragraph) + if paragraph == "" { + lines = append(lines, "") + continue + } + + words := strings.Fields(paragraph) + current := words[0] + for _, word := range words[1:] { + candidate := current + " " + word + if ansi.StringWidth(candidate) <= width { + current = candidate + continue + } + lines = append(lines, current) + current = word + } + lines = append(lines, current) + } + + return lines +} + +func padChangeCell(value string, width int) string { + if width <= 0 { + return "" + } + value = ansi.Truncate(value, width, "…") + padding := width - ansi.StringWidth(value) + if padding <= 0 { + return value + } + return value + strings.Repeat(" ", padding) +} + +func buildChangeDiffCommand(change jjhub.Change) string { + command := "jj diff --git" + if strings.TrimSpace(change.ChangeID) == "" { + return command + } + return command + " -r " + shellQuote(change.ChangeID) +} + +func shellQuote(value string) string { + if value == "" { + return "''" + } + return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'" +} diff --git a/internal/ui/views/changes_test.go b/internal/ui/views/changes_test.go new file mode 100644 index 00000000..9239e100 --- /dev/null +++ b/internal/ui/views/changes_test.go @@ -0,0 +1,406 @@ +package views + +import ( + "errors" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/crush/internal/jjhub" + "github.com/charmbracelet/crush/internal/smithers" + "github.com/charmbracelet/crush/internal/ui/diffnav" + "github.com/charmbracelet/crush/internal/ui/handoff" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeChangesClient struct { + changes []jjhub.Change + err error + calls int +} + +func (c *fakeChangesClient) ListChanges(limit int) ([]jjhub.Change, error) { + c.calls++ + if c.err != nil { + return nil, c.err + } + return c.changes, nil +} + +func sampleChanges() []jjhub.Change { + return []jjhub.Change{ + { + ChangeID: "9c1beef012345678", + CommitID: "b3f5cafe9876543210", + Description: "First change description", + Author: jjhub.Author{Name: "Jane Doe", Email: "jane@example.com"}, + Timestamp: "2025-01-15T12:34:56Z", + Bookmarks: []string{"main", "stack/one"}, + IsWorkingCopy: true, + }, + { + ChangeID: "1f2e3d4c5b6a7980", + CommitID: "abcdef0123456789", + Description: "Second change for search filtering", + Author: jjhub.Author{Name: "Will Example", Email: "will@example.com"}, + Timestamp: "2025-01-14T10:00:00Z", + Bookmarks: []string{"feature/search"}, + }, + { + ChangeID: "emptychange9999", + CommitID: "0000ffffeeee1111", + Description: "", + Author: jjhub.Author{Name: "Empty Author", Email: "empty@example.com"}, + Timestamp: "2025-01-13T09:00:00Z", + IsEmpty: true, + }, + } +} + +func newTestChangesView(t *testing.T) *ChangesView { + t.Helper() + + v := newChangesView( + &fakeChangesClient{}, + "/tmp/repo", + func() bool { return true }, + func(command string, cwd string, tag any) tea.Cmd { return nil }, + ) + v.SetSize(120, 30) + return v +} + +func loadedChangesView(t *testing.T) *ChangesView { + t.Helper() + + v := newTestChangesView(t) + updated, _ := v.Update(changesLoadedMsg{changes: sampleChanges()}) + return updated.(*ChangesView) +} + +func TestChangesView_InitReturnsLoadCmd(t *testing.T) { + client := &fakeChangesClient{changes: sampleChanges()} + v := newChangesView( + client, + "/tmp/repo", + func() bool { return true }, + func(command string, cwd string, tag any) tea.Cmd { return nil }, + ) + + cmd := v.Init() + require.NotNil(t, cmd) + + msg := cmd() + loaded, ok := msg.(changesLoadedMsg) + require.True(t, ok, "expected changesLoadedMsg, got %T", msg) + assert.Len(t, loaded.changes, 3) + assert.Equal(t, 1, client.calls) +} + +func TestChangesView_LoadedMsgRendersTableState(t *testing.T) { + v := loadedChangesView(t) + out := v.View() + + assert.False(t, v.loading) + assert.Contains(t, out, "JJHub › Changes (3)") + assert.Contains(t, out, "▸") + assert.Contains(t, out, "●") + assert.Contains(t, out, "(empty)") + assert.Contains(t, out, "First change description") +} + +func TestChangesView_TogglePreviewShowsSidebarDetails(t *testing.T) { + v := loadedChangesView(t) + + updated, _ := v.Update(tea.KeyPressMsg{Code: 'w'}) + v = updated.(*ChangesView) + out := v.View() + + assert.True(t, v.previewVisible) + assert.Contains(t, out, "Commit ID") + assert.Contains(t, out, "b3f5cafe9876543210") + assert.Contains(t, out, "Jane Doe ") + assert.Contains(t, out, "main") + assert.Contains(t, out, "stack/one") +} + +func TestChangesView_SearchFiltersDescriptions(t *testing.T) { + v := loadedChangesView(t) + + updated, cmd := v.Update(tea.KeyPressMsg{Code: '/'}) + v = updated.(*ChangesView) + assert.True(t, v.searchActive) + _ = cmd + + for _, ch := range "second" { + updated, _ = v.Update(tea.KeyPressMsg{Code: ch, Text: string(ch)}) + v = updated.(*ChangesView) + } + + require.Len(t, v.filteredChanges, 1) + assert.Equal(t, "1f2e3d4c5b6a7980", v.filteredChanges[0].ChangeID) + assert.Contains(t, v.View(), "Second change for search filtering") + assert.NotContains(t, v.View(), "First change description") +} + +func TestChangesView_SearchEscClearsThenExits(t *testing.T) { + v := loadedChangesView(t) + v.searchActive = true + v.searchInput.Focus() //nolint:errcheck + v.searchInput.SetValue("search") + v.applyFilter() + + updated, cmd := v.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + v = updated.(*ChangesView) + assert.True(t, v.searchActive) + assert.Equal(t, "", v.searchInput.Value()) + assert.Nil(t, cmd) + + updated, cmd = v.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + v = updated.(*ChangesView) + assert.False(t, v.searchActive) + assert.Nil(t, cmd) +} + +func TestChangesView_GAndGBounds(t *testing.T) { + v := loadedChangesView(t) + + updated, _ := v.Update(tea.KeyPressMsg{Code: 'G'}) + v = updated.(*ChangesView) + assert.Equal(t, 2, v.listPane.cursor) + + updated, _ = v.Update(tea.KeyPressMsg{Code: 'g'}) + v = updated.(*ChangesView) + assert.Equal(t, 0, v.listPane.cursor) +} + +func TestChangesView_DiffAlwaysAvailable(t *testing.T) { + v := newChangesView( + &fakeChangesClient{}, + "/tmp/repo", + func() bool { return true }, + func(command string, cwd string, tag any) tea.Cmd { + return func() tea.Msg { return nil } + }, + ) + v.SetSize(100, 30) + updated, _ := v.Update(changesLoadedMsg{changes: sampleChanges()}) + v = updated.(*ChangesView) + + updated, cmd := v.Update(tea.KeyPressMsg{Code: 'd'}) + v = updated.(*ChangesView) + + assert.NotNil(t, cmd, "diff should always be available (bundled)") + assert.False(t, v.statusErr) +} + +func TestChangesView_DiffLaunchUsesJJHubCommand(t *testing.T) { + var gotCommand string + var gotCwd string + var gotTag any + + v := newChangesView( + &fakeChangesClient{}, + "/tmp/repo", + func() bool { return true }, + func(command string, cwd string, tag any) tea.Cmd { + gotCommand = command + gotCwd = cwd + gotTag = tag + return func() tea.Msg { return nil } + }, + ) + v.SetSize(100, 30) + updated, _ := v.Update(changesLoadedMsg{changes: sampleChanges()}) + v = updated.(*ChangesView) + + _, cmd := v.Update(tea.KeyPressMsg{Code: 'd'}) + require.NotNil(t, cmd) + assert.Equal(t, "jj diff --git -r '9c1beef012345678'", gotCommand) + assert.Equal(t, "/tmp/repo", gotCwd) + assert.Equal(t, changesDiffTag, gotTag) +} + +func TestChangesView_HandoffErrorSetsStatus(t *testing.T) { + v := loadedChangesView(t) + + updated, _ := v.Update(handoff.HandoffMsg{ + Tag: changesDiffTag, + Result: handoff.HandoffResult{ + Err: errors.New("diffnav failed"), + }, + }) + v = updated.(*ChangesView) + + assert.True(t, v.statusErr) + assert.Contains(t, v.statusMsg, "diffnav failed") +} + +func TestChangesView_RefreshSetsLoading(t *testing.T) { + client := &fakeChangesClient{changes: sampleChanges()} + v := newChangesView( + client, + "/tmp/repo", + func() bool { return true }, + func(command string, cwd string, tag any) tea.Cmd { return nil }, + ) + v.SetSize(100, 30) + updated, _ := v.Update(changesLoadedMsg{changes: sampleChanges()}) + v = updated.(*ChangesView) + + updated, cmd := v.Update(tea.KeyPressMsg{Code: 'R'}) + v = updated.(*ChangesView) + + assert.True(t, v.loading) + require.NotNil(t, cmd) + msg := cmd() + _, ok := msg.(changesLoadedMsg) + assert.True(t, ok) + assert.Equal(t, 1, client.calls) +} + +func TestChangesView_EscapeEmitsPopViewMsg(t *testing.T) { + v := loadedChangesView(t) + + _, cmd := v.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + require.NotNil(t, cmd) + msg := cmd() + _, ok := msg.(PopViewMsg) + assert.True(t, ok) +} + +func TestDefaultRegistry_ContainsChangesView(t *testing.T) { + r := DefaultRegistry() + + view, ok := r.Open("changes", smithers.NewClient()) + require.True(t, ok) + require.NotNil(t, view) + assert.Equal(t, "changes", view.Name()) +} + +func TestBuildChangeDiffCommand_QuotesSafely(t *testing.T) { + command := buildChangeDiffCommand(jjhub.Change{ChangeID: "abc'def"}) + assert.Equal(t, "jj diff --git -r 'abc'\"'\"'def'", command) +} + +// TestChangesView_DKeyWithRealLauncher_ReturnsCmd verifies that pressing 'd' +// on a loaded changes view returns a non-nil command regardless of whether +// diffnav is installed (it should either launch diffnav or prompt to install). +func TestChangesView_DKeyWithRealLauncher_ReturnsCmd(t *testing.T) { + v := newChangesView( + &fakeChangesClient{}, + "/tmp/repo", + func() bool { return true }, // pretend available + diffnav.LaunchDiffnavWithCommand, + ) + v.SetSize(100, 30) + updated, _ := v.Update(changesLoadedMsg{changes: sampleChanges()}) + v = updated.(*ChangesView) + + // Pressing 'd' should return a non-nil cmd + _, cmd := v.Update(tea.KeyPressMsg{Code: 'd'}) + require.NotNil(t, cmd, "d key must return a cmd (either handoff or install prompt)") + + // Execute the cmd and check the msg type + msg := cmd() + require.NotNil(t, msg, "cmd must produce a non-nil message") + + // It's either a handoff (diffnav found) or install prompt (not found) + switch msg.(type) { + case handoff.HandoffMsg: + // diffnav was found and launched (or failed to launch) + case diffnav.InstallPromptMsg: + // diffnav not found, install prompt + default: + // Could be a tea.execMsg from tea.ExecProcess — that's fine too + } +} + +// TestChangesView_DKeyNotInstalled_ReturnsInstallPrompt verifies that when +// diffnav is not installed, pressing 'd' returns an InstallPromptMsg. +func TestChangesView_DKeyNotInstalled_ReturnsInstallPrompt(t *testing.T) { + notInstalled := func(command string, cwd string, tag any) tea.Cmd { + // Simulate what LaunchDiffnavWithCommand does when not installed + return func() tea.Msg { + return diffnav.InstallPromptMsg{ + PendingCommand: command, + PendingCwd: cwd, + PendingTag: tag, + } + } + } + + v := newChangesView( + &fakeChangesClient{}, + "/tmp/repo", + func() bool { return true }, + notInstalled, + ) + v.SetSize(100, 30) + updated, _ := v.Update(changesLoadedMsg{changes: sampleChanges()}) + v = updated.(*ChangesView) + + _, cmd := v.Update(tea.KeyPressMsg{Code: 'd'}) + require.NotNil(t, cmd, "d must return a cmd") + + msg := cmd() + prompt, ok := msg.(diffnav.InstallPromptMsg) + require.True(t, ok, "expected InstallPromptMsg, got %T", msg) + assert.Contains(t, prompt.PendingCommand, "jj diff --git -r") + assert.Equal(t, "/tmp/repo", prompt.PendingCwd) +} + +// TestChangesView_EnterKey_ReturnsDiffCmd verifies enter also triggers diff. +func TestChangesView_EnterKey_ReturnsDiffCmd(t *testing.T) { + var called bool + v := newChangesView( + &fakeChangesClient{}, + "/tmp/repo", + func() bool { return true }, + func(command string, cwd string, tag any) tea.Cmd { + called = true + return func() tea.Msg { return nil } + }, + ) + v.SetSize(100, 30) + updated, _ := v.Update(changesLoadedMsg{changes: sampleChanges()}) + v = updated.(*ChangesView) + + _, cmd := v.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + require.NotNil(t, cmd, "enter must return a cmd") + assert.True(t, called, "enter must trigger the diff launcher") +} + +// TestChangesView_DKeyWhileLoading_IsNoop verifies that pressing 'd' before +// changes have loaded returns nil (no crash, no action). +func TestChangesView_DKeyWhileLoading_IsNoop(t *testing.T) { + v := newTestChangesView(t) + // Don't send changesLoadedMsg — still loading + assert.True(t, v.loading) + + _, cmd := v.Update(tea.KeyPressMsg{Code: 'd'}) + // When loading, 'd' should be a noop since there's no selected change + // The key should NOT be handled at all while loading + assert.Nil(t, cmd, "d while loading should be noop") +} + +// TestChangesView_DKeyWithNoChanges_IsNoop verifies pressing 'd' with empty +// change list is a noop. +func TestChangesView_DKeyWithNoChanges_IsNoop(t *testing.T) { + v := newTestChangesView(t) + updated, _ := v.Update(changesLoadedMsg{changes: nil}) + v = updated.(*ChangesView) + assert.False(t, v.loading) + + _, cmd := v.Update(tea.KeyPressMsg{Code: 'd'}) + assert.Nil(t, cmd, "d with no changes should be noop") +} + +func TestWrapPreviewText_RespectsWidth(t *testing.T) { + lines := wrapPreviewText("alpha beta gamma", 8) + require.NotEmpty(t, lines) + for _, line := range lines { + assert.LessOrEqual(t, len([]rune(strings.TrimSpace(line))), 8) + } +} diff --git a/internal/ui/views/dashboard.go b/internal/ui/views/dashboard.go index 6de27365..b0fc2f9c 100644 --- a/internal/ui/views/dashboard.go +++ b/internal/ui/views/dashboard.go @@ -12,6 +12,9 @@ import ( "charm.land/lipgloss/v2" "github.com/charmbracelet/crush/internal/jjhub" "github.com/charmbracelet/crush/internal/smithers" + "github.com/charmbracelet/crush/internal/ui/components" + "github.com/charmbracelet/crush/internal/ui/diffnav" + "github.com/charmbracelet/crush/internal/ui/handoff" ) // DashboardTab identifies a tab on the Smithers homepage. @@ -23,6 +26,7 @@ const ( DashTabWorkflows DashTabSessions DashTabLandings + DashTabChanges DashTabIssues DashTabWorkspaces ) @@ -39,6 +43,8 @@ func (t DashboardTab) String() string { return "Sessions" case DashTabLandings: return "Landings" + case DashTabChanges: + return "Changes" case DashTabIssues: return "Issues" case DashTabWorkspaces: @@ -71,8 +77,8 @@ type DashboardView struct { tabs []DashboardTab // instance-level tab list (not the global) // Smithers data - runs []smithers.RunSummary - workflows []smithers.Workflow + runs []smithers.RunSummary + workflows []smithers.Workflow runsLoading bool wfLoading bool approvalsLoading bool @@ -82,15 +88,18 @@ type DashboardView struct { approvals []smithers.Approval // JJHub data - landings []jjhub.Landing - issues []jjhub.Issue - workspaces []jjhub.Workspace - landingsLoading bool - issuesLoading bool + landings []jjhub.Landing + changes []jjhub.Change + issues []jjhub.Issue + workspaces []jjhub.Workspace + landingsLoading bool + changesLoading bool + issuesLoading bool workspacesLoading bool - landingsErr error - issuesErr error - workspacesErr error + landingsErr error + changesErr error + issuesErr error + workspacesErr error // repo name shown in header when jjhub is available repoName string @@ -134,6 +143,12 @@ type dashLandingsFetchedMsg struct { err error } +// dashChangesFetchedMsg delivers change data from jjhub. +type dashChangesFetchedMsg struct { + changes []jjhub.Change + err error +} + // dashIssuesFetchedMsg delivers issue data from jjhub. type dashIssuesFetchedMsg struct { issues []jjhub.Issue @@ -170,6 +185,7 @@ func NewDashboardViewWithJJHub(client *smithers.Client, hasSmithers bool, jc *jj wfLoading: hasSmithers, approvalsLoading: hasSmithers, landingsLoading: hasJJHub, + changesLoading: hasJJHub, issuesLoading: hasJJHub, workspacesLoading: hasJJHub, } @@ -177,7 +193,7 @@ func NewDashboardViewWithJJHub(client *smithers.Client, hasSmithers bool, jc *jj // Build the instance-level tab list. baseTabs := []DashboardTab{DashTabOverview, DashTabRuns, DashTabWorkflows, DashTabSessions} if hasJJHub { - baseTabs = append(baseTabs, DashTabLandings, DashTabIssues, DashTabWorkspaces) + baseTabs = append(baseTabs, DashTabLandings, DashTabChanges, DashTabIssues, DashTabWorkspaces) } d.tabs = baseTabs @@ -205,6 +221,7 @@ func NewDashboardViewWithJJHub(client *smithers.Client, hasSmithers bool, jc *jj if hasJJHub { d.menuItems = append(d.menuItems, menuItem{icon: "⬆", label: "Landings", desc: "Browse landing requests", action: func() tea.Msg { return DashboardNavigateMsg{View: "landings"} }}, + menuItem{icon: "±", label: "Changes", desc: "Browse recent changes", action: func() tea.Msg { return DashboardNavigateMsg{View: "changes"} }}, menuItem{icon: "◉", label: "Issues", desc: "Browse issues", action: func() tea.Msg { return DashboardNavigateMsg{View: "issues"} }}, menuItem{icon: "▣", label: "Workspaces", desc: "Manage cloud workspaces", action: func() tea.Msg { return DashboardNavigateMsg{View: "workspaces"} }}, ) @@ -219,7 +236,7 @@ func (d *DashboardView) Init() tea.Cmd { cmds = append(cmds, d.fetchRuns(), d.fetchWorkflows(), d.fetchApprovals()) } if d.jjhubEnabled { - cmds = append(cmds, d.fetchLandings(), d.fetchIssues(), d.fetchWorkspaces(), d.fetchRepoName()) + cmds = append(cmds, d.fetchLandings(), d.fetchChanges(), d.fetchIssues(), d.fetchWorkspaces(), d.fetchRepoName()) } if len(cmds) == 0 { return nil @@ -261,6 +278,14 @@ func (d *DashboardView) Update(msg tea.Msg) (View, tea.Cmd) { } return d, nil + case dashChangesFetchedMsg: + d.changesLoading = false + d.changesErr = msg.err + if msg.err == nil { + d.changes = msg.changes + } + return d, nil + case dashIssuesFetchedMsg: d.issuesLoading = false d.issuesErr = msg.err @@ -332,6 +357,11 @@ func (d *DashboardView) Update(msg tea.Msg) (View, tea.Cmd) { d.activeTab = 6 } return d, nil + case key.Matches(msg, key.NewBinding(key.WithKeys("8"))): + if len(d.tabs) > 7 { + d.activeTab = 7 + } + return d, nil // Navigation within tab case key.Matches(msg, key.NewBinding(key.WithKeys("down", "j"))): @@ -354,6 +384,12 @@ func (d *DashboardView) Update(msg tea.Msg) (View, tea.Cmd) { if len(d.tabs) > 0 && d.tabs[d.activeTab] == DashTabWorkflows { return d, func() tea.Msg { return DashboardNavigateMsg{View: "workflows"} } } + if len(d.tabs) > 0 && d.tabs[d.activeTab] == DashTabLandings { + return d, func() tea.Msg { return DashboardNavigateMsg{View: "landings"} } + } + if len(d.tabs) > 0 && d.tabs[d.activeTab] == DashTabChanges { + return d, func() tea.Msg { return DashboardNavigateMsg{View: "changes"} } + } return d, nil // c for quick chat @@ -365,6 +401,7 @@ func (d *DashboardView) Update(msg tea.Msg) (View, tea.Cmd) { d.runsLoading = d.client != nil d.wfLoading = d.client != nil d.landingsLoading = d.jjhubEnabled + d.changesLoading = d.jjhubEnabled d.issuesLoading = d.jjhubEnabled d.workspacesLoading = d.jjhubEnabled var cmds []tea.Cmd @@ -372,14 +409,54 @@ func (d *DashboardView) Update(msg tea.Msg) (View, tea.Cmd) { cmds = append(cmds, d.fetchRuns(), d.fetchWorkflows()) } if d.jjhubEnabled { - cmds = append(cmds, d.fetchLandings(), d.fetchIssues(), d.fetchWorkspaces()) + cmds = append(cmds, d.fetchLandings(), d.fetchChanges(), d.fetchIssues(), d.fetchWorkspaces()) } return d, tea.Batch(cmds...) + // d for diff (landings and changes tabs) + case key.Matches(msg, key.NewBinding(key.WithKeys("d"))): + if len(d.tabs) > 0 { + switch d.tabs[d.activeTab] { + case DashTabLandings: + if d.landingsLoading { + return d, showDashToast("Still loading landings...") + } + if len(d.landings) > 0 && d.menuCursor < len(d.landings) { + l := d.landings[d.menuCursor] + return d, diffnav.LaunchDiffnavWithCommand(fmt.Sprintf("jjhub land diff %d", l.Number), "", "landing-diff") + } + return d, showDashToast("No landings to diff") + case DashTabChanges: + if d.changesLoading { + return d, showDashToast("Still loading changes...") + } + if len(d.changes) > 0 && d.menuCursor < len(d.changes) { + c := d.changes[d.menuCursor] + return d, diffnav.LaunchDiffnavWithCommand(buildChangeDiffCommand(c), "", "change-diff") + } + return d, showDashToast("No changes to diff") + } + } + return d, nil + + // esc: if on a sub-tab, return to Overview; if already on Overview, pop to chat + case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))): + if len(d.tabs) > 0 && d.tabs[d.activeTab] != DashTabOverview { + d.activeTab = 0 + d.menuCursor = 0 + return d, nil + } + return d, func() tea.Msg { return PopViewMsg{} } + // q to quit (dashboard is the root, so quit the app) case key.Matches(msg, key.NewBinding(key.WithKeys("q", "ctrl+c"))): return d, tea.Quit } + + case handoff.HandoffMsg: + // TUI is resumed automatically by tea.ExecProcess. + // For now we don't need to do anything with the exit code. + return d, nil } return d, nil } @@ -398,20 +475,28 @@ func (d *DashboardView) View() string { parts = append(parts, d.renderTabBar()) // Content - contentHeight := d.height - 5 // header + tab + footer + borders + contentHeight := d.height - 4 // header + tab + footer + borders if contentHeight < 3 { contentHeight = 3 } parts = append(parts, d.renderContent(contentHeight)) // Footer - parts = append(parts, d.renderFooter()) return lipgloss.JoinVertical(lipgloss.Left, parts...) } func (d *DashboardView) Name() string { return "Dashboard" } +func showDashToast(msg string) tea.Cmd { + return func() tea.Msg { + return components.ShowToastMsg{ + Title: msg, + Level: components.ToastLevelInfo, + } + } +} + func (d *DashboardView) SetSize(w, h int) { d.width = w d.height = h @@ -434,7 +519,7 @@ func (d *DashboardView) renderHeader() string { // Show jjhub repo name in header if available. if d.repoName != "" { - logo += lipgloss.NewStyle().Faint(true).Render(" "+d.repoName) + logo += lipgloss.NewStyle().Faint(true).Render(" " + d.repoName) } status := "" @@ -523,6 +608,8 @@ func (d *DashboardView) renderContent(height int) string { return d.renderSessionsSummary(height) case DashTabLandings: return d.renderLandingsSummary(height) + case DashTabChanges: + return d.renderChangesSummary(height) case DashTabIssues: return d.renderIssuesSummary(height) case DashTabWorkspaces: @@ -532,10 +619,18 @@ func (d *DashboardView) renderContent(height int) string { } func (d *DashboardView) renderOverview(height int) string { - var b strings.Builder + panelStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("238")). + Padding(0, 2). + MarginRight(2). + MarginBottom(1) + + titleStyle := lipgloss.NewStyle().Bold(true).MarginBottom(1) // Quick actions menu - b.WriteString("\n") + var menu strings.Builder + menu.WriteString(titleStyle.Render("Quick Actions") + "\n") for i, item := range d.menuItems { cursor := " " style := lipgloss.NewStyle() @@ -543,20 +638,19 @@ func (d *DashboardView) renderOverview(height int) string { cursor = "▸ " style = style.Bold(true).Foreground(lipgloss.Color("63")) } - b.WriteString(cursor + item.icon + " " + style.Render(item.label)) - b.WriteString(" " + lipgloss.NewStyle().Faint(true).Render(item.desc)) - b.WriteString("\n") + menu.WriteString(cursor + item.icon + " " + style.Render(item.label)) + menu.WriteString(" " + lipgloss.NewStyle().Faint(true).Render(item.desc)) + menu.WriteString("\n") } // At-a-glance stats - b.WriteString("\n") - b.WriteString(lipgloss.NewStyle().Bold(true).Render(" At a Glance") + "\n") - b.WriteString(" ─────────────\n") + var glance strings.Builder + glance.WriteString(titleStyle.Render("At a Glance") + "\n") if d.runsLoading { - b.WriteString(" ⟳ Loading runs...\n") + glance.WriteString("⟳ Loading runs...\n") } else if d.runsErr != nil { - b.WriteString(" " + lipgloss.NewStyle().Faint(true).Render("No runs data") + "\n") + glance.WriteString(lipgloss.NewStyle().Faint(true).Render("No runs data") + "\n") } else { running, waiting, completed, failed := 0, 0, 0, 0 for _, r := range d.runs { @@ -571,40 +665,40 @@ func (d *DashboardView) renderOverview(height int) string { failed++ } } - b.WriteString(fmt.Sprintf(" Runs: %d total", len(d.runs))) + glance.WriteString(fmt.Sprintf("Runs: %d total", len(d.runs))) if running > 0 { - b.WriteString(fmt.Sprintf(" %s", lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Render(fmt.Sprintf("● %d running", running)))) + glance.WriteString(fmt.Sprintf(" %s", lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Render(fmt.Sprintf("● %d running", running)))) } if waiting > 0 { - b.WriteString(fmt.Sprintf(" %s", lipgloss.NewStyle().Foreground(lipgloss.Color("3")).Render(fmt.Sprintf("⚠ %d waiting", waiting)))) + glance.WriteString(fmt.Sprintf(" %s", lipgloss.NewStyle().Foreground(lipgloss.Color("3")).Render(fmt.Sprintf("⚠ %d waiting", waiting)))) } if failed > 0 { - b.WriteString(fmt.Sprintf(" %s", lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Render(fmt.Sprintf("✗ %d failed", failed)))) + glance.WriteString(fmt.Sprintf(" %s", lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Render(fmt.Sprintf("✗ %d failed", failed)))) } - b.WriteString("\n") + glance.WriteString("\n") } if d.wfLoading { - b.WriteString(" ⟳ Loading workflows...\n") + glance.WriteString("⟳ Loading workflows...\n") } else if d.wfErr != nil { - b.WriteString(" " + lipgloss.NewStyle().Faint(true).Render("No workflow data") + "\n") + glance.WriteString(lipgloss.NewStyle().Faint(true).Render("No workflow data") + "\n") } else { - b.WriteString(fmt.Sprintf(" Workflows: %d available\n", len(d.workflows))) + glance.WriteString(fmt.Sprintf("Workflows: %d available\n", len(d.workflows))) } if d.approvalsLoading { - b.WriteString(" ⟳ Loading approvals...\n") + glance.WriteString("⟳ Loading approvals...\n") } else if d.approvalsErr != nil { - b.WriteString(" " + lipgloss.NewStyle().Faint(true).Render("No approval data") + "\n") + glance.WriteString(lipgloss.NewStyle().Faint(true).Render("No approval data") + "\n") } else if len(d.approvals) > 0 { style := lipgloss.NewStyle().Foreground(lipgloss.Color("3")).Bold(true) if len(d.approvals) >= 5 { style = style.Foreground(lipgloss.Color("1")) } - b.WriteString(fmt.Sprintf(" %s\n", style.Render(fmt.Sprintf("⚠ Approvals: %d pending", len(d.approvals))))) + glance.WriteString(fmt.Sprintf("%s\n", style.Render(fmt.Sprintf("⚠ Approvals: %d pending", len(d.approvals))))) for i, a := range d.approvals { if i >= 3 { - b.WriteString(fmt.Sprintf(" ... and %d more\n", len(d.approvals)-3)) + glance.WriteString(fmt.Sprintf(" ... and %d more\n", len(d.approvals)-3)) break } gate := a.Gate @@ -615,22 +709,20 @@ func (d *DashboardView) renderOverview(height int) string { if len(id) > 8 { id = id[:8] } - b.WriteString(fmt.Sprintf(" %s %s\n", lipgloss.NewStyle().Faint(true).Render(id), gate)) + glance.WriteString(fmt.Sprintf(" %s %s\n", lipgloss.NewStyle().Faint(true).Render(id), gate)) } } else { - b.WriteString(" Approvals: " + lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Render("none pending ✓") + "\n") + glance.WriteString("Approvals: " + lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Render("none pending ✓") + "\n") } - // JJHub at-a-glance + var codeplane strings.Builder if d.jjhubEnabled { - b.WriteString("\n") - b.WriteString(lipgloss.NewStyle().Bold(true).Render(" Codeplane") + "\n") - b.WriteString(" ─────────────\n") + codeplane.WriteString(titleStyle.Render("Codeplane") + "\n") if d.landingsLoading { - b.WriteString(" ⟳ Loading landings...\n") + codeplane.WriteString("⟳ Loading landings...\n") } else if d.landingsErr != nil { - b.WriteString(" " + lipgloss.NewStyle().Faint(true).Render("No landings data") + "\n") + codeplane.WriteString(lipgloss.NewStyle().Faint(true).Render("No landings data") + "\n") } else { open, merged, draft := 0, 0, 0 for _, l := range d.landings { @@ -643,23 +735,41 @@ func (d *DashboardView) renderOverview(height int) string { draft++ } } - b.WriteString(fmt.Sprintf(" Landings: %d total", len(d.landings))) + codeplane.WriteString(fmt.Sprintf("Landings: %d total", len(d.landings))) if open > 0 { - b.WriteString(" " + jjLandingStateStyle("open").Render(fmt.Sprintf("⬆ %d open", open))) + codeplane.WriteString(" " + jjLandingStateStyle("open").Render(fmt.Sprintf("⬆ %d open", open))) } if draft > 0 { - b.WriteString(" " + jjLandingStateStyle("draft").Render(fmt.Sprintf("◌ %d draft", draft))) + codeplane.WriteString(" " + jjLandingStateStyle("draft").Render(fmt.Sprintf("◌ %d draft", draft))) } if merged > 0 { - b.WriteString(" " + jjLandingStateStyle("merged").Render(fmt.Sprintf("✓ %d merged", merged))) + codeplane.WriteString(" " + jjLandingStateStyle("merged").Render(fmt.Sprintf("✓ %d merged", merged))) } - b.WriteString("\n") + codeplane.WriteString("\n") + } + + if d.changesLoading { + codeplane.WriteString("⟳ Loading changes...\n") + } else if d.changesErr != nil { + codeplane.WriteString(lipgloss.NewStyle().Faint(true).Render("No changes data") + "\n") + } else { + wc := 0 + for _, c := range d.changes { + if c.IsWorkingCopy { + wc++ + } + } + codeplane.WriteString(fmt.Sprintf("Changes: %d total", len(d.changes))) + if wc > 0 { + codeplane.WriteString(" " + lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Render("● working copy")) + } + codeplane.WriteString("\n") } if d.issuesLoading { - b.WriteString(" ⟳ Loading issues...\n") + codeplane.WriteString("⟳ Loading issues...\n") } else if d.issuesErr != nil { - b.WriteString(" " + lipgloss.NewStyle().Faint(true).Render("No issues data") + "\n") + codeplane.WriteString(lipgloss.NewStyle().Faint(true).Render("No issues data") + "\n") } else { openIssues := 0 for _, iss := range d.issues { @@ -667,17 +777,17 @@ func (d *DashboardView) renderOverview(height int) string { openIssues++ } } - b.WriteString(fmt.Sprintf(" Issues: %d total", len(d.issues))) + codeplane.WriteString(fmt.Sprintf("Issues: %d total", len(d.issues))) if openIssues > 0 { - b.WriteString(" " + lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Render(fmt.Sprintf("◉ %d open", openIssues))) + codeplane.WriteString(" " + lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Render(fmt.Sprintf("◉ %d open", openIssues))) } - b.WriteString("\n") + codeplane.WriteString("\n") } if d.workspacesLoading { - b.WriteString(" ⟳ Loading workspaces...\n") + codeplane.WriteString("⟳ Loading workspaces...\n") } else if d.workspacesErr != nil { - b.WriteString(" " + lipgloss.NewStyle().Faint(true).Render("No workspaces data") + "\n") + codeplane.WriteString(lipgloss.NewStyle().Faint(true).Render("No workspaces data") + "\n") } else { running := 0 for _, w := range d.workspaces { @@ -685,15 +795,23 @@ func (d *DashboardView) renderOverview(height int) string { running++ } } - b.WriteString(fmt.Sprintf(" Workspaces: %d total", len(d.workspaces))) + codeplane.WriteString(fmt.Sprintf("Workspaces: %d total", len(d.workspaces))) if running > 0 { - b.WriteString(" " + lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Render(fmt.Sprintf("● %d running", running))) + codeplane.WriteString(" " + lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Render(fmt.Sprintf("● %d running", running))) } - b.WriteString("\n") + codeplane.WriteString("\n") } } - return b.String() + leftCol := panelStyle.Render(strings.TrimRight(menu.String(), "\n")) + + rightBlocks := []string{panelStyle.Render(strings.TrimRight(glance.String(), "\n"))} + if d.jjhubEnabled { + rightBlocks = append(rightBlocks, panelStyle.Render(strings.TrimRight(codeplane.String(), "\n"))) + } + rightCol := lipgloss.JoinVertical(lipgloss.Left, rightBlocks...) + + return lipgloss.NewStyle().Padding(1, 2).Render(lipgloss.JoinHorizontal(lipgloss.Top, leftCol, rightCol)) } func (d *DashboardView) renderRunsSummary(height int) string { @@ -829,8 +947,16 @@ func (d *DashboardView) renderLandingsSummary(height int) string { author := truncateStr(l.Author.Login, 14) changes := fmt.Sprintf("%d", len(l.ChangeIDs)) updated := jjRelativeTime(l.UpdatedAt) - b.WriteString(fmt.Sprintf(" %-3s %-5s %-40s %-14s %-7s %s\n", - icon, num, title, author, changes, lipgloss.NewStyle().Faint(true).Render(updated))) + + prefix := " " + style := lipgloss.NewStyle() + if i == d.menuCursor { + prefix = "▸ " + style = style.Bold(true).Foreground(lipgloss.Color("63")) + } + + b.WriteString(fmt.Sprintf("%s%-3s %-5s %-40s %-14s %-7s %s\n", + prefix, icon, num, style.Render(title), author, changes, lipgloss.NewStyle().Faint(true).Render(updated))) } if len(d.landings) > limit { @@ -839,6 +965,72 @@ func (d *DashboardView) renderLandingsSummary(height int) string { return b.String() } +func (d *DashboardView) renderChangesSummary(height int) string { + var b strings.Builder + b.WriteString("\n " + lipgloss.NewStyle().Bold(true).Render("Recent Changes") + "\n") + b.WriteString(" ─────────────\n") + + if d.changesLoading { + b.WriteString(" ⟳ Loading...\n") + return b.String() + } + if d.changesErr != nil { + b.WriteString(" " + lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Render("✗ "+d.changesErr.Error()) + "\n") + return b.String() + } + if len(d.changes) == 0 { + b.WriteString(" " + lipgloss.NewStyle().Faint(true).Render("No changes found.") + "\n") + return b.String() + } + + limit := height - 5 + if limit > len(d.changes) { + limit = len(d.changes) + } + if limit > 15 { + limit = 15 + } + + // Header row + b.WriteString(fmt.Sprintf(" %-3s %-12s %-40s %-14s %s\n", + lipgloss.NewStyle().Faint(true).Render(""), + lipgloss.NewStyle().Faint(true).Render("Change ID"), + lipgloss.NewStyle().Faint(true).Render("Description"), + lipgloss.NewStyle().Faint(true).Render("Author"), + lipgloss.NewStyle().Faint(true).Render("Timestamp"), + )) + + for i := 0; i < limit; i++ { + c := d.changes[i] + icon := " " + if c.IsWorkingCopy { + icon = lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Render("●") + } + id := c.ChangeID + if len(id) > 12 { + id = id[:12] + } + desc := truncateStr(strings.Split(c.Description, "\n")[0], 40) + author := truncateStr(c.Author.Name, 14) + ts := jjRelativeTime(c.Timestamp) + + prefix := " " + style := lipgloss.NewStyle() + if i == d.menuCursor { + prefix = "▸ " + style = style.Bold(true).Foreground(lipgloss.Color("63")) + } + + b.WriteString(fmt.Sprintf("%s%-3s %-12s %-40s %-14s %s\n", + prefix, icon, id, style.Render(desc), author, lipgloss.NewStyle().Faint(true).Render(ts))) + } + + if len(d.changes) > limit { + b.WriteString(fmt.Sprintf("\n ... and %d more.\n", len(d.changes)-limit)) + } + return b.String() +} + func (d *DashboardView) renderIssuesSummary(height int) string { var b strings.Builder b.WriteString("\n " + lipgloss.NewStyle().Bold(true).Render("Issues") + "\n") @@ -883,8 +1075,16 @@ func (d *DashboardView) renderIssuesSummary(height int) string { author := truncateStr(iss.Author.Login, 14) comments := fmt.Sprintf("%d", iss.CommentCount) updated := jjRelativeTime(iss.UpdatedAt) - b.WriteString(fmt.Sprintf(" %-3s %-5s %-42s %-14s %-9s %s\n", - icon, num, title, author, comments, lipgloss.NewStyle().Faint(true).Render(updated))) + + prefix := " " + style := lipgloss.NewStyle() + if i == d.menuCursor { + prefix = "▸ " + style = style.Bold(true).Foreground(lipgloss.Color("63")) + } + + b.WriteString(fmt.Sprintf("%s%-3s %-5s %-42s %-14s %-9s %s\n", + prefix, icon, num, style.Render(title), author, comments, lipgloss.NewStyle().Faint(true).Render(updated))) } if len(d.issues) > limit { @@ -942,8 +1142,16 @@ func (d *DashboardView) renderWorkspacesSummary(height int) string { ssh = truncateStr(*w.SSHHost, 30) } updated := jjRelativeTime(w.UpdatedAt) - b.WriteString(fmt.Sprintf(" %-3s %-20s %-12s %-14s %-30s %s\n", - icon, name, w.Status, w.Persistence, ssh, lipgloss.NewStyle().Faint(true).Render(updated))) + + prefix := " " + style := lipgloss.NewStyle() + if i == d.menuCursor { + prefix = "▸ " + style = style.Bold(true).Foreground(lipgloss.Color("63")) + } + + b.WriteString(fmt.Sprintf("%s%-3s %-20s %-12s %-14s %-30s %s\n", + prefix, icon, name, w.Status, w.Persistence, ssh, lipgloss.NewStyle().Faint(true).Render(updated))) } if len(d.workspaces) > limit { @@ -952,32 +1160,6 @@ func (d *DashboardView) renderWorkspacesSummary(height int) string { return b.String() } -func (d *DashboardView) renderFooter() string { - sep := lipgloss.NewStyle().Faint(true).Render(" │ ") - numTabs := len(d.tabs) - tabNums := "1-4" - if numTabs > 4 { - tabNums = fmt.Sprintf("1-%d", numTabs) - } - parts := []string{ - helpKV("j/k", "nav"), - helpKV(tabNums, "tabs"), - helpKV("enter", "select"), - helpKV("c", "chat"), - helpKV("r", "refresh"), - helpKV("q", "quit"), - } - line := " " + strings.Join(parts, sep) - return lipgloss.NewStyle(). - Background(lipgloss.Color("236")). - Width(d.width). - Render(line) -} - -func helpKV(k, v string) string { - return lipgloss.NewStyle().Bold(true).Render(k) + " " + lipgloss.NewStyle().Faint(true).Render(v) -} - func statusGlyph(s smithers.RunStatus) string { switch s { case smithers.RunStatusRunning: @@ -1097,14 +1279,27 @@ func jjRelativeTime(ts string) string { // --- Helpers --- func (d *DashboardView) clampCursor() { - max := len(d.menuItems) - 1 - if len(d.tabs) > 0 && d.tabs[d.activeTab] != DashTabOverview { - max = 0 + if len(d.tabs) == 0 { + return } + max := 0 + switch d.tabs[d.activeTab] { + case DashTabOverview: + max = len(d.menuItems) - 1 + case DashTabLandings: + max = len(d.landings) - 1 + case DashTabIssues: + max = len(d.issues) - 1 + case DashTabWorkspaces: + max = len(d.workspaces) - 1 + } + if d.menuCursor < 0 { d.menuCursor = 0 } - if d.menuCursor > max { + if max < 0 { + d.menuCursor = 0 + } else if d.menuCursor > max { d.menuCursor = max } } @@ -1153,6 +1348,17 @@ func (d *DashboardView) fetchLandings() tea.Cmd { } } +func (d *DashboardView) fetchChanges() tea.Cmd { + jc := d.jjhubClient + if jc == nil { + return nil + } + return func() tea.Msg { + changes, err := jc.ListChanges(30) + return dashChangesFetchedMsg{changes: changes, err: err} + } +} + func (d *DashboardView) fetchIssues() tea.Cmd { jc := d.jjhubClient if jc == nil { diff --git a/internal/ui/views/dashboard_test.go b/internal/ui/views/dashboard_test.go new file mode 100644 index 00000000..4a1f4296 --- /dev/null +++ b/internal/ui/views/dashboard_test.go @@ -0,0 +1,93 @@ +package views + +import ( + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/crush/internal/jjhub" + "github.com/charmbracelet/crush/internal/ui/components" + "github.com/charmbracelet/crush/internal/ui/diffnav" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDashboardView_EscFromSubTab_ReturnsToOverview(t *testing.T) { + d := NewDashboardView(nil, false) + d.SetSize(120, 40) + d.tabs = []DashboardTab{DashTabOverview, DashTabRuns, DashTabWorkflows} + d.activeTab = 1 // on Runs tab + + updated, cmd := d.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + d = updated.(*DashboardView) + + assert.Equal(t, 0, d.activeTab, "esc should return to Overview tab") + assert.Nil(t, cmd, "should not emit a command when going back to overview") +} + +func TestDashboardView_EscFromOverview_EmitsPopViewMsg(t *testing.T) { + d := NewDashboardView(nil, false) + d.SetSize(120, 40) + d.tabs = []DashboardTab{DashTabOverview, DashTabRuns} + d.activeTab = 0 // already on Overview + + _, cmd := d.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + require.NotNil(t, cmd, "esc on overview must return a cmd") + msg := cmd() + _, ok := msg.(PopViewMsg) + assert.True(t, ok, "esc on overview must emit PopViewMsg, got %T", msg) +} + +func TestDashboardView_DKeyOnChangesTab_NoChanges_ShowsToast(t *testing.T) { + d := NewDashboardView(nil, false) + d.SetSize(120, 40) + d.tabs = []DashboardTab{DashTabOverview, DashTabChanges} + d.activeTab = 1 + d.changesLoading = false + d.changes = nil // no changes loaded + + _, cmd := d.Update(tea.KeyPressMsg{Code: 'd'}) + require.NotNil(t, cmd, "d with no changes should show a toast, not be silent") + msg := cmd() + toast, ok := msg.(components.ShowToastMsg) + require.True(t, ok, "expected ShowToastMsg, got %T", msg) + assert.Contains(t, toast.Title, "No changes") +} + +func TestDashboardView_DKeyOnChangesTab_StillLoading_ShowsToast(t *testing.T) { + d := NewDashboardView(nil, false) + d.SetSize(120, 40) + d.tabs = []DashboardTab{DashTabOverview, DashTabChanges} + d.activeTab = 1 + d.changesLoading = true + + _, cmd := d.Update(tea.KeyPressMsg{Code: 'd'}) + require.NotNil(t, cmd, "d while loading should show a toast") + msg := cmd() + toast, ok := msg.(components.ShowToastMsg) + require.True(t, ok, "expected ShowToastMsg, got %T", msg) + assert.Contains(t, toast.Title, "loading") +} + +func TestDashboardView_DKeyOnChangesTab_WithChanges_ReturnsDiffCmd(t *testing.T) { + d := NewDashboardView(nil, false) + d.SetSize(120, 40) + d.tabs = []DashboardTab{DashTabOverview, DashTabChanges} + d.activeTab = 1 + d.changesLoading = false + d.changes = []jjhub.Change{ + {ChangeID: "abc123", Description: "test change"}, + } + d.menuCursor = 0 + + _, cmd := d.Update(tea.KeyPressMsg{Code: 'd'}) + require.NotNil(t, cmd, "d with changes must return a cmd") + + msg := cmd() + // Either launches diffnav (if installed) or prompts to install + switch msg.(type) { + case diffnav.InstallPromptMsg: + // Expected when diffnav not installed + default: + // Could be a handoff exec msg — that's fine too + } +} diff --git a/internal/ui/views/integration_test.go b/internal/ui/views/integration_test.go new file mode 100644 index 00000000..40bcf703 --- /dev/null +++ b/internal/ui/views/integration_test.go @@ -0,0 +1,127 @@ +package views + +import ( + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/crush/internal/jjhub" + "github.com/charmbracelet/crush/internal/ui/diffnav" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestIntegration_DashboardToChangesView_FullFlow tests the complete flow: +// Dashboard -> navigate to Changes tab -> press Enter -> ChangesView opens -> +// changes load -> press d -> cmd returned -> press Esc -> PopViewMsg returned. +// +// This is NOT a unit test with mocks. It uses the real constructors. +func TestIntegration_DashboardToChangesView_FullFlow(t *testing.T) { + // Step 1: Create dashboard (no smithers, no jjhub — but tabs still exist if jjhub client provided) + jc := jjhub.NewClient("") + d := NewDashboardViewWithJJHub(nil, false, jc) + d.SetSize(120, 40) + + // Verify Changes tab exists (jjhub client was provided) + hasChanges := false + for _, tab := range d.tabs { + if tab == DashTabChanges { + hasChanges = true + } + } + if !hasChanges { + t.Skip("Changes tab not present (jjhub not detected)") + } + + // Step 2: Navigate to Changes tab + changesIdx := -1 + for i, tab := range d.tabs { + if tab == DashTabChanges { + changesIdx = i + } + } + d.activeTab = changesIdx + + // Step 3: Press Enter on Changes tab — should return DashboardNavigateMsg + updated, cmd := d.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + d = updated.(*DashboardView) + require.NotNil(t, cmd, "Enter on Changes tab must return a cmd") + msg := cmd() + navMsg, ok := msg.(DashboardNavigateMsg) + require.True(t, ok, "expected DashboardNavigateMsg, got %T", msg) + assert.Equal(t, "changes", navMsg.View, "should navigate to 'changes'") + + // Step 4: Simulate what the root model does — open ChangesView via registry + registry := DefaultRegistry() + view, found := registry.Open("changes", nil) + require.True(t, found, "'changes' must be registered") + require.NotNil(t, view) + assert.Equal(t, "changes", view.Name()) + + changesView := view.(*ChangesView) + changesView.SetSize(120, 40) + + // Step 5: Init fetches changes + initCmd := changesView.Init() + require.NotNil(t, initCmd, "Init must return a fetch command") + + // Step 6: Simulate changes loaded + updated2, _ := changesView.Update(changesLoadedMsg{changes: []jjhub.Change{ + {ChangeID: "abc123", Description: "test change", Author: jjhub.Author{Name: "test"}}, + {ChangeID: "def456", Description: "another change", Author: jjhub.Author{Name: "test2"}}, + }}) + changesView = updated2.(*ChangesView) + assert.False(t, changesView.loading, "should not be loading after changesLoadedMsg") + assert.Len(t, changesView.filteredChanges, 2) + + // Step 7: Press 'd' — should return a cmd (either diffnav launch or install prompt) + updated3, dCmd := changesView.Update(tea.KeyPressMsg{Code: 'd'}) + changesView = updated3.(*ChangesView) + require.NotNil(t, dCmd, "d key with loaded changes must return a cmd, NOT nil") + + // Execute the cmd + dMsg := dCmd() + require.NotNil(t, dMsg, "d cmd must produce a message") + t.Logf("d key produced message type: %T", dMsg) + + // It should be either a handoff or install prompt + switch dMsg.(type) { + case diffnav.InstallPromptMsg: + t.Log("diffnav not installed — got InstallPromptMsg (correct)") + default: + t.Logf("got %T — may be a handoff exec msg (correct if diffnav installed)", dMsg) + } + + // Step 8: Press 'enter' — should also trigger diff + updated4, enterCmd := changesView.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + changesView = updated4.(*ChangesView) + require.NotNil(t, enterCmd, "enter key with loaded changes must return a cmd, NOT nil") + + // Step 9: Press Escape — should return PopViewMsg + updated5, escCmd := changesView.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + changesView = updated5.(*ChangesView) + require.NotNil(t, escCmd, "esc must return a cmd") + escMsg := escCmd() + _, isPop := escMsg.(PopViewMsg) + assert.True(t, isPop, "esc must emit PopViewMsg, got %T", escMsg) + + // Step 10: Verify view renders without panic + output := changesView.View() + assert.NotEmpty(t, output) +} + +// TestIntegration_ChangesView_DKeyBeforeLoad_IsNoop verifies that pressing +// d before changes have loaded is a silent noop (no crash, no cmd). +func TestIntegration_ChangesView_DKeyBeforeLoad_IsNoop(t *testing.T) { + registry := DefaultRegistry() + view, _ := registry.Open("changes", nil) + cv := view.(*ChangesView) + cv.SetSize(120, 40) + + // Don't send changesLoadedMsg — still loading + assert.True(t, cv.loading) + + _, cmd := cv.Update(tea.KeyPressMsg{Code: 'd'}) + // When loading, keys go to the split pane or are noops + // The important thing: no panic, and d doesn't crash + t.Logf("d while loading: cmd=%v", cmd) +} diff --git a/internal/ui/views/issues.go b/internal/ui/views/issues.go new file mode 100644 index 00000000..4a441654 --- /dev/null +++ b/internal/ui/views/issues.go @@ -0,0 +1,675 @@ +package views + +import ( + "fmt" + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/crush/internal/jjhub" + "github.com/charmbracelet/crush/internal/smithers" + "github.com/charmbracelet/crush/internal/ui/components" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +var _ View = (*IssuesView)(nil) + +type issuesLoadedMsg struct { + issues []jjhub.Issue +} + +type issuesErrorMsg struct { + err error +} + +type issueRepoLoadedMsg struct { + repo *jjhub.Repo +} + +type issueDetailLoadedMsg struct { + number int + issue *jjhub.Issue +} + +type issueDetailErrorMsg struct { + number int + err error +} + +// IssuesView renders a JJHub issues dashboard. +type IssuesView struct { + smithersClient *smithers.Client + jjhubClient *jjhub.Client + sty styles.Styles + + width int + height int + + loading bool + err error + + repo *jjhub.Repo + + previewOpen bool + search jjSearchState + searchQuery string + filterIndex int + + allIssues []jjhub.Issue + issues []jjhub.Issue + + detailCache map[int]*jjhub.Issue + detailLoading map[int]bool + detailErrors map[int]error + + tablePane *jjTablePane + previewPane *jjPreviewPane + splitPane *components.SplitPane +} + +// IssueDetailView renders a full-screen issue detail drill-down. +type IssueDetailView struct { + parent View + + jjhubClient *jjhub.Client + repo *jjhub.Repo + sty styles.Styles + + width int + height int + + issue jjhub.Issue + detail *jjhub.Issue + loading bool + err error + + previewPane *jjPreviewPane +} + +type issueDetailViewLoadedMsg struct { + issue *jjhub.Issue +} + +type issueDetailViewErrorMsg struct { + err error +} + +var issueFilters = []jjFilterTab{ + {Value: "open", Label: "Open", Icon: jjhubIssueStateIcon("open")}, + {Value: "closed", Label: "Closed", Icon: jjhubIssueStateIcon("closed")}, + {Value: "all", Label: "All", Icon: "•"}, +} + +var issueTableColumns = []components.Column{ + {Title: "", Width: 2}, + {Title: "#", Width: 6, Align: components.AlignRight}, + {Title: "Title", Grow: true}, + {Title: "Author", Width: 14, MinWidth: 90}, + {Title: "Assignees", Width: 16, MinWidth: 108}, + {Title: "Comments", Width: 8, MinWidth: 100, Align: components.AlignRight}, + {Title: "Labels", Width: 18, MinWidth: 118}, + {Title: "Updated", Width: 10, MinWidth: 82}, +} + +// NewIssuesView creates a JJHub issues view. +func NewIssuesView(client *smithers.Client) *IssuesView { + tablePane := newJJTablePane(issueTableColumns) + previewPane := newJJPreviewPane("Select an issue") + splitPane := components.NewSplitPane(tablePane, previewPane, components.SplitPaneOpts{ + LeftWidth: 70, + CompactBreakpoint: 100, + }) + + return &IssuesView{ + smithersClient: client, + jjhubClient: jjhub.NewClient(""), + sty: styles.DefaultStyles(), + loading: true, + previewOpen: true, + search: newJJSearchInput("filter issues by title"), + detailCache: make(map[int]*jjhub.Issue), + detailLoading: make(map[int]bool), + detailErrors: make(map[int]error), + tablePane: tablePane, + previewPane: previewPane, + splitPane: splitPane, + } +} + +func (v *IssuesView) Init() tea.Cmd { + return tea.Batch(v.loadIssuesCmd(), v.loadRepoCmd()) +} + +func (v *IssuesView) Update(msg tea.Msg) (View, tea.Cmd) { + switch msg := msg.(type) { + case issuesLoadedMsg: + v.loading = false + v.err = nil + v.allIssues = msg.issues + selectionChanged := v.rebuildRows() + return v, v.syncPreview(selectionChanged) + + case issuesErrorMsg: + v.loading = false + v.err = msg.err + return v, nil + + case issueRepoLoadedMsg: + v.repo = msg.repo + return v, nil + + case issueDetailLoadedMsg: + delete(v.detailLoading, msg.number) + delete(v.detailErrors, msg.number) + v.detailCache[msg.number] = msg.issue + selectionChanged := v.rebuildRows() + return v, v.syncPreview(selectionChanged) + + case issueDetailErrorMsg: + delete(v.detailLoading, msg.number) + v.detailErrors[msg.number] = msg.err + return v, v.syncPreview(false) + + case tea.WindowSizeMsg: + v.SetSize(msg.Width, msg.Height) + return v, nil + + case tea.KeyPressMsg: + if v.search.active { + return v.updateSearch(msg) + } + + switch { + case key.Matches(msg, key.NewBinding(key.WithKeys("esc", "q"))): + return v, func() tea.Msg { return PopViewMsg{} } + case key.Matches(msg, key.NewBinding(key.WithKeys("/"))): + v.search.active = true + v.search.input.SetValue(v.searchQuery) + return v, v.search.input.Focus() + case key.Matches(msg, key.NewBinding(key.WithKeys("w"))): + v.previewOpen = !v.previewOpen + if v.previewOpen { + return v, v.syncPreview(true) + } + return v, nil + case key.Matches(msg, key.NewBinding(key.WithKeys("s"))): + v.filterIndex = (v.filterIndex + 1) % len(issueFilters) + selectionChanged := v.rebuildRows() + return v, v.syncPreview(selectionChanged) + case key.Matches(msg, key.NewBinding(key.WithKeys("r", "R"))): + v.loading = true + v.err = nil + v.detailCache = make(map[int]*jjhub.Issue) + v.detailLoading = make(map[int]bool) + v.detailErrors = make(map[int]error) + selectionChanged := v.rebuildRows() + return v, tea.Batch(v.Init(), v.syncPreview(selectionChanged)) + case key.Matches(msg, key.NewBinding(key.WithKeys("o"))): + if issue := v.selectedIssue(); issue != nil { + return v, jjOpenURLCmd(jjIssueURL(v.repo, issue.Number)) + } + case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))): + if issue := v.selectedIssue(); issue != nil { + detailView := NewIssueDetailView(v, v.jjhubClient, v.repo, v.sty, *issue, v.detailCache[issue.Number]) + detailView.SetSize(v.width, v.height) + return detailView, detailView.Init() + } + } + } + + previous := v.selectedIssueNumber() + var cmd tea.Cmd + if v.previewOpen { + v.tablePane.SetFocused(v.splitPane.Focus() == components.FocusLeft) + newSplitPane, splitCmd := v.splitPane.Update(msg) + v.splitPane = newSplitPane + cmd = splitCmd + } else { + v.tablePane.SetFocused(true) + _, cmd = v.tablePane.Update(msg) + } + + selectionChanged := previous != v.selectedIssueNumber() + return v, tea.Batch(cmd, v.syncPreview(selectionChanged)) +} + +func (v *IssuesView) View() string { + header := jjRenderHeader( + fmt.Sprintf("JJHUB › Issues (%d)", len(v.issues)), + v.width, + jjMutedStyle.Render("[/] Search [w] Preview [Esc] Back"), + ) + tabs := jjRenderFilterTabs(issueFilters, v.currentFilter(), v.stateCounts()) + + var parts []string + parts = append(parts, header) + if v.search.active { + parts = append(parts, tabs+" "+jjSearchStyle.Render("Search:")+" "+v.search.input.View()) + } else if v.searchQuery != "" { + parts = append(parts, tabs+" "+jjMutedStyle.Render("filter: "+v.searchQuery)) + } else { + parts = append(parts, tabs) + } + + if v.loading && len(v.allIssues) == 0 { + parts = append(parts, jjMutedStyle.Render("Loading issues…")) + return strings.Join(parts, "\n") + } + if v.err != nil && len(v.allIssues) == 0 { + parts = append(parts, jjErrorStyle.Render("Error: "+v.err.Error())) + return strings.Join(parts, "\n") + } + if v.err != nil { + parts = append(parts, jjErrorStyle.Render("Error: "+v.err.Error())) + } + + contentHeight := max(1, v.height-len(parts)-1) + if v.previewOpen { + v.tablePane.SetFocused(v.splitPane.Focus() == components.FocusLeft) + v.splitPane.SetSize(v.width, contentHeight) + parts = append(parts, v.splitPane.View()) + } else { + v.tablePane.SetFocused(true) + v.tablePane.SetSize(v.width, contentHeight) + parts = append(parts, v.tablePane.View()) + } + return strings.Join(parts, "\n") +} + +func (v *IssuesView) Name() string { return "issues" } + +func (v *IssuesView) SetSize(width, height int) { + v.width = width + v.height = height + contentHeight := max(1, height-3) + v.tablePane.SetSize(width, contentHeight) + v.previewPane.SetSize(max(1, width/2), contentHeight) + v.splitPane.SetSize(width, contentHeight) +} + +func (v *IssuesView) ShortHelp() []key.Binding { + if v.search.active { + return []key.Binding{ + key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "apply")), + key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")), + } + } + + help := []key.Binding{ + key.NewBinding(key.WithKeys("j", "k"), key.WithHelp("j/k", "move")), + key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "detail")), + key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "filter")), + key.NewBinding(key.WithKeys("w"), key.WithHelp("w", "preview")), + key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "search")), + key.NewBinding(key.WithKeys("o"), key.WithHelp("o", "browser")), + key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")), + } + if v.previewOpen { + help = append(help, key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "focus"))) + } + return help +} + +func (v *IssuesView) currentFilter() string { + return issueFilters[v.filterIndex].Value +} + +func (v *IssuesView) stateCounts() map[string]int { + counts := map[string]int{ + "open": 0, + "closed": 0, + "all": len(v.allIssues), + } + for _, issue := range v.allIssues { + counts[issue.State]++ + } + return counts +} + +func (v *IssuesView) selectedIssue() *jjhub.Issue { + index := v.tablePane.Cursor() + if index < 0 || index >= len(v.issues) { + return nil + } + issue := v.issues[index] + return &issue +} + +func (v *IssuesView) selectedIssueNumber() int { + if issue := v.selectedIssue(); issue != nil { + return issue.Number + } + return 0 +} + +func (v *IssuesView) rebuildRows() bool { + previous := v.selectedIssueNumber() + filter := v.currentFilter() + + filtered := make([]jjhub.Issue, 0, len(v.allIssues)) + rows := make([]components.Row, 0, len(v.allIssues)) + for _, issue := range v.allIssues { + if filter != "all" && issue.State != filter { + continue + } + if v.searchQuery != "" && !jjMatchesSearch(issue.Title, v.searchQuery) { + continue + } + + labels := "-" + if cached := v.detailCache[issue.Number]; cached != nil && len(cached.Labels) > 0 { + labelNames := make([]string, 0, len(cached.Labels)) + for _, label := range cached.Labels { + labelNames = append(labelNames, label.Name) + } + labels = strings.Join(labelNames, ", ") + } else if len(issue.Labels) > 0 { + labelNames := make([]string, 0, len(issue.Labels)) + for _, label := range issue.Labels { + labelNames = append(labelNames, label.Name) + } + labels = strings.Join(labelNames, ", ") + } + + filtered = append(filtered, issue) + rows = append(rows, components.Row{ + Cells: []string{ + jjhubIssueStateIcon(issue.State), + fmt.Sprintf("#%d", issue.Number), + issue.Title, + issue.Author.Login, + jjJoinAssignees(issue.Assignees), + fmt.Sprintf("%d", issue.CommentCount), + labels, + jjhubRelativeTime(issue.UpdatedAt), + }, + }) + } + + v.issues = filtered + v.tablePane.SetRows(rows) + + targetIndex := 0 + for i, issue := range filtered { + if issue.Number == previous { + targetIndex = i + break + } + } + if len(filtered) > 0 { + v.tablePane.SetCursor(targetIndex) + } + return previous != v.selectedIssueNumber() +} + +func (v *IssuesView) syncPreview(reset bool) tea.Cmd { + issue := v.selectedIssue() + if issue == nil { + v.previewPane.SetContent("", true) + return nil + } + v.previewPane.SetContent(v.renderPreview(*issue), reset) + return v.ensureIssueDetail(*issue) +} + +func (v *IssuesView) renderPreview(issue jjhub.Issue) string { + width := max(24, v.previewPane.width-4) + detail := v.detailCache[issue.Number] + current := issue + if detail != nil { + current = *detail + } + + var body strings.Builder + body.WriteString(jjTitleStyle.Render(current.Title)) + body.WriteString("\n") + body.WriteString(jjBadgeStyleForState(current.State).Render(jjhubIssueStateIcon(current.State) + " " + current.State)) + body.WriteString("\n\n") + body.WriteString(jjMetaRow("Author", "@"+current.Author.Login) + "\n") + body.WriteString(jjMetaRow("Number", fmt.Sprintf("#%d", current.Number)) + "\n") + body.WriteString(jjMetaRow("Assignees", jjJoinAssignees(current.Assignees)) + "\n") + body.WriteString(jjMetaRow("Comments", fmt.Sprintf("%d", current.CommentCount)) + "\n") + body.WriteString(jjMetaRow("Updated", jjFormatTime(current.UpdatedAt)) + "\n") + + body.WriteString("\n") + body.WriteString(jjSectionStyle.Render("Labels")) + body.WriteString("\n") + if len(current.Labels) == 0 { + body.WriteString(jjMutedStyle.Render("No labels.")) + body.WriteString("\n") + } else { + parts := make([]string, 0, len(current.Labels)) + for _, label := range current.Labels { + parts = append(parts, jjRenderLabel(label)) + } + body.WriteString(strings.Join(parts, " ")) + body.WriteString("\n") + } + + if v.detailErrors[issue.Number] != nil { + body.WriteString("\n") + body.WriteString(jjErrorStyle.Render(v.detailErrors[issue.Number].Error())) + body.WriteString("\n") + } + + body.WriteString("\n") + body.WriteString(jjSectionStyle.Render("Description")) + body.WriteString("\n") + body.WriteString(jjMarkdown(current.Body, width, &v.sty)) + return strings.TrimSpace(body.String()) +} + +func (v *IssuesView) ensureIssueDetail(issue jjhub.Issue) tea.Cmd { + if v.detailCache[issue.Number] != nil || v.detailLoading[issue.Number] { + return nil + } + v.detailLoading[issue.Number] = true + return v.loadIssueDetailCmd(issue.Number) +} + +func (v *IssuesView) updateSearch(msg tea.KeyPressMsg) (View, tea.Cmd) { + switch { + case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))): + v.search.active = false + v.search.input.Blur() + return v, nil + case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))): + v.search.active = false + v.searchQuery = strings.TrimSpace(v.search.input.Value()) + v.search.input.Blur() + selectionChanged := v.rebuildRows() + return v, v.syncPreview(selectionChanged) + default: + var cmd tea.Cmd + v.search.input, cmd = v.search.input.Update(msg) + return v, cmd + } +} + +func (v *IssuesView) loadIssuesCmd() tea.Cmd { + client := v.jjhubClient + return func() tea.Msg { + issues, err := client.ListIssues("all", jjDefaultListLimit) + if err != nil { + return issuesErrorMsg{err: err} + } + return issuesLoadedMsg{issues: issues} + } +} + +func (v *IssuesView) loadRepoCmd() tea.Cmd { + client := v.jjhubClient + return func() tea.Msg { + repo, err := client.GetCurrentRepo() + if err != nil { + return nil + } + return issueRepoLoadedMsg{repo: repo} + } +} + +func (v *IssuesView) loadIssueDetailCmd(number int) tea.Cmd { + client := v.jjhubClient + return func() tea.Msg { + issue, err := client.ViewIssue(number) + if err != nil { + return issueDetailErrorMsg{number: number, err: err} + } + return issueDetailLoadedMsg{number: number, issue: issue} + } +} + +// NewIssueDetailView creates a full-screen issue detail drill-down view. +func NewIssueDetailView( + parent View, + client *jjhub.Client, + repo *jjhub.Repo, + sty styles.Styles, + issue jjhub.Issue, + detail *jjhub.Issue, +) *IssueDetailView { + previewPane := newJJPreviewPane("Loading issue detail…") + return &IssueDetailView{ + parent: parent, + jjhubClient: client, + repo: repo, + sty: sty, + issue: issue, + detail: detail, + loading: detail == nil, + previewPane: previewPane, + } +} + +func (v *IssueDetailView) Init() tea.Cmd { + v.syncContent(true) + if v.detail != nil { + return nil + } + client := v.jjhubClient + number := v.issue.Number + return func() tea.Msg { + issue, err := client.ViewIssue(number) + if err != nil { + return issueDetailViewErrorMsg{err: err} + } + return issueDetailViewLoadedMsg{issue: issue} + } +} + +func (v *IssueDetailView) Update(msg tea.Msg) (View, tea.Cmd) { + switch msg := msg.(type) { + case issueDetailViewLoadedMsg: + v.detail = msg.issue + v.loading = false + v.err = nil + if parent, ok := v.parent.(*IssuesView); ok && msg.issue != nil { + parent.detailCache[v.issue.Number] = msg.issue + parent.rebuildRows() + parent.syncPreview(false) + } + v.syncContent(true) + return v, nil + + case issueDetailViewErrorMsg: + v.loading = false + v.err = msg.err + v.syncContent(false) + return v, nil + + case tea.WindowSizeMsg: + v.SetSize(msg.Width, msg.Height) + return v, nil + + case tea.KeyPressMsg: + switch { + case key.Matches(msg, key.NewBinding(key.WithKeys("esc", "q"))): + v.parent.SetSize(v.width, v.height) + return v.parent, nil + case key.Matches(msg, key.NewBinding(key.WithKeys("o"))): + return v, jjOpenURLCmd(jjIssueURL(v.repo, v.issue.Number)) + case key.Matches(msg, key.NewBinding(key.WithKeys("r", "R"))): + v.loading = true + v.err = nil + return v, v.Init() + } + } + + _, cmd := v.previewPane.Update(msg) + return v, cmd +} + +func (v *IssueDetailView) View() string { + header := jjRenderHeader( + fmt.Sprintf("JJHUB › Issues › #%d", v.issue.Number), + v.width, + jjMutedStyle.Render("[o] Browser [Esc] Back"), + ) + parts := []string{header} + if v.err != nil { + parts = append(parts, jjErrorStyle.Render("Error: "+v.err.Error())) + } + if v.loading && v.detail == nil { + parts = append(parts, jjMutedStyle.Render("Loading issue detail…")) + } + parts = append(parts, v.previewPane.View()) + return strings.Join(parts, "\n") +} + +func (v *IssueDetailView) Name() string { return "issue-detail" } + +func (v *IssueDetailView) SetSize(width, height int) { + v.width = width + v.height = height + v.previewPane.SetSize(width, max(1, height-2)) + v.syncContent(false) +} + +func (v *IssueDetailView) ShortHelp() []key.Binding { + return []key.Binding{ + key.NewBinding(key.WithKeys("j", "k"), key.WithHelp("j/k", "scroll")), + key.NewBinding(key.WithKeys("o"), key.WithHelp("o", "browser")), + key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")), + } +} + +func (v *IssueDetailView) syncContent(reset bool) { + width := max(24, v.previewPane.width-4) + current := v.issue + if v.detail != nil { + current = *v.detail + } + + var body strings.Builder + body.WriteString(jjTitleStyle.Render(current.Title)) + body.WriteString("\n") + body.WriteString(jjBadgeStyleForState(current.State).Render(jjhubIssueStateIcon(current.State) + " " + current.State)) + body.WriteString("\n\n") + body.WriteString(jjMetaRow("Author", "@"+current.Author.Login) + "\n") + body.WriteString(jjMetaRow("Number", fmt.Sprintf("#%d", current.Number)) + "\n") + body.WriteString(jjMetaRow("Assignees", jjJoinAssignees(current.Assignees)) + "\n") + body.WriteString(jjMetaRow("Comments", fmt.Sprintf("%d", current.CommentCount)) + "\n") + body.WriteString(jjMetaRow("Created", jjFormatTime(current.CreatedAt)) + "\n") + body.WriteString(jjMetaRow("Updated", jjFormatTime(current.UpdatedAt)) + "\n") + body.WriteString("\n") + body.WriteString(jjSectionStyle.Render("Labels")) + body.WriteString("\n") + if len(current.Labels) == 0 { + body.WriteString(jjMutedStyle.Render("No labels.")) + } else { + labels := make([]string, 0, len(current.Labels)) + for _, label := range current.Labels { + labels = append(labels, jjRenderLabel(label)) + } + body.WriteString(strings.Join(labels, " ")) + } + body.WriteString("\n\n") + body.WriteString(jjSectionStyle.Render("Description")) + body.WriteString("\n") + body.WriteString(jjMarkdown(current.Body, width, &v.sty)) + if v.err != nil { + body.WriteString("\n\n") + body.WriteString(jjErrorStyle.Render(v.err.Error())) + } + v.previewPane.SetContent(strings.TrimSpace(body.String()), reset) +} diff --git a/internal/ui/views/issues_test.go b/internal/ui/views/issues_test.go new file mode 100644 index 00000000..10efc100 --- /dev/null +++ b/internal/ui/views/issues_test.go @@ -0,0 +1,102 @@ +package views + +import ( + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/crush/internal/jjhub" + "github.com/charmbracelet/crush/internal/smithers" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func sampleIssue(number int, state, title string) jjhub.Issue { + return jjhub.Issue{ + Number: number, + Title: title, + Body: "## Details\n\n" + title, + State: state, + Author: jjhub.User{Login: "will"}, + Assignees: []jjhub.User{{Login: "dev1"}}, + CommentCount: 3, + Labels: []jjhub.Label{{Name: "bug", Color: "#f87171"}}, + CreatedAt: time.Now().Add(-4 * time.Hour).Format(time.RFC3339), + UpdatedAt: time.Now().Add(-1 * time.Hour).Format(time.RFC3339), + } +} + +func newTestIssuesView() *IssuesView { + return NewIssuesView(smithers.NewClient()) +} + +func seedIssuesView(v *IssuesView, issues []jjhub.Issue) *IssuesView { + updated, _ := v.Update(issuesLoadedMsg{issues: issues}) + return updated.(*IssuesView) +} + +func TestIssuesView_ImplementsView(t *testing.T) { + t.Parallel() + var _ View = (*IssuesView)(nil) +} + +func TestIssuesView_FilterCycle(t *testing.T) { + t.Parallel() + + v := seedIssuesView(newTestIssuesView(), []jjhub.Issue{ + sampleIssue(1, "open", "Open issue"), + sampleIssue(2, "closed", "Closed issue"), + }) + + updated, _ := v.Update(tea.KeyPressMsg{Code: 's'}) + iv := updated.(*IssuesView) + + assert.Equal(t, "closed", iv.currentFilter()) + assert.Len(t, iv.issues, 1) + assert.Equal(t, 2, iv.issues[0].Number) +} + +func TestIssuesView_SearchApply(t *testing.T) { + t.Parallel() + + v := seedIssuesView(newTestIssuesView(), []jjhub.Issue{ + sampleIssue(1, "open", "Alpha"), + sampleIssue(2, "open", "Beta"), + }) + v.search.active = true + v.search.input.SetValue("alpha") + + updated, _ := v.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + iv := updated.(*IssuesView) + + assert.Equal(t, "alpha", iv.searchQuery) + assert.Len(t, iv.issues, 1) + assert.Equal(t, "Alpha", iv.issues[0].Title) +} + +func TestIssuesView_EnterReturnsDetailView(t *testing.T) { + t.Parallel() + + v := seedIssuesView(newTestIssuesView(), []jjhub.Issue{sampleIssue(1, "open", "Alpha")}) + v.width = 120 + v.height = 40 + + updated, cmd := v.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + + require.IsType(t, &IssueDetailView{}, updated) + require.NotNil(t, cmd) +} + +func TestIssueDetailView_EscReturnsParent(t *testing.T) { + t.Parallel() + + parent := seedIssuesView(newTestIssuesView(), []jjhub.Issue{sampleIssue(1, "open", "Alpha")}) + detail := NewIssueDetailView(parent, jjhub.NewClient(""), nil, styles.DefaultStyles(), sampleIssue(1, "open", "Alpha"), nil) + detail.SetSize(120, 40) + + updated, cmd := detail.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + + require.Nil(t, cmd) + assert.Same(t, parent, updated) +} diff --git a/internal/ui/views/jjhub_common.go b/internal/ui/views/jjhub_common.go new file mode 100644 index 00000000..eb70f7e8 --- /dev/null +++ b/internal/ui/views/jjhub_common.go @@ -0,0 +1,578 @@ +package views + +import ( + "fmt" + "os/exec" + "runtime" + "strings" + "time" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textinput" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/jjhub" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/components" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +const ( + jjDefaultListLimit = 200 + jjhubWebBaseURL = "https://jjhub.tech" +) + +var ( + jjTitleStyle = lipgloss.NewStyle().Bold(true) + jjSectionStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("111")) + jjMetaLabelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245")).Width(12) + jjMetaValueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252")) + jjSearchStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("80")) + jjMutedStyle = lipgloss.NewStyle().Faint(true) + jjErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203")).Bold(true) + jjSuccessStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("77")) + jjOpenStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("77")).Bold(true) + jjMergedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("141")).Bold(true) + jjClosedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203")).Bold(true) + jjDraftStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245")) + jjPendingStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("221")).Bold(true) + jjBadgeBaseStyle = lipgloss.NewStyle().Padding(0, 1) + jjSidebarBoxStyle = lipgloss.NewStyle().Padding(0, 1) +) + +type jjSearchState struct { + active bool + input textinput.Model +} + +type jjTablePane struct { + columns []components.Column + rows []components.Row + cursor int + offset int + width int + height int + focused bool +} + +type jjPreviewPane struct { + sty styles.Styles + viewport viewport.Model + width int + height int + content string + empty string +} + +type jjFilterTab struct { + Value string + Label string + Icon string +} + +func newJJSearchInput(placeholder string) jjSearchState { + input := textinput.New() + input.Placeholder = placeholder + input.SetVirtualCursor(true) + return jjSearchState{input: input} +} + +func newJJTablePane(columns []components.Column) *jjTablePane { + return &jjTablePane{columns: columns} +} + +func (p *jjTablePane) Init() tea.Cmd { return nil } + +func (p *jjTablePane) Update(msg tea.Msg) (components.Pane, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyPressMsg) + if !ok { + return p, nil + } + + switch { + case key.Matches(keyMsg, key.NewBinding(key.WithKeys("up", "k"))): + if p.cursor > 0 { + p.cursor-- + } + case key.Matches(keyMsg, key.NewBinding(key.WithKeys("down", "j"))): + if p.cursor < len(p.rows)-1 { + p.cursor++ + } + case key.Matches(keyMsg, key.NewBinding(key.WithKeys("g", "home"))): + p.cursor = 0 + p.offset = 0 + case key.Matches(keyMsg, key.NewBinding(key.WithKeys("G", "end"))): + if len(p.rows) > 0 { + p.cursor = len(p.rows) - 1 + } + case key.Matches(keyMsg, key.NewBinding(key.WithKeys("pgdown", "ctrl+d"))): + p.cursor = min(len(p.rows)-1, p.cursor+p.pageSize()) + case key.Matches(keyMsg, key.NewBinding(key.WithKeys("pgup", "ctrl+u"))): + p.cursor = max(0, p.cursor-p.pageSize()) + } + + p.clamp() + return p, nil +} + +func (p *jjTablePane) View() string { + rendered, offset := components.RenderTable( + p.columns, + p.rows, + p.cursor, + p.offset, + p.width, + p.height, + p.focused, + ) + p.offset = offset + return rendered +} + +func (p *jjTablePane) SetSize(width, height int) { + p.width = width + p.height = height + p.clamp() +} + +func (p *jjTablePane) SetFocused(focused bool) { + p.focused = focused +} + +func (p *jjTablePane) SetRows(rows []components.Row) { + p.rows = rows + p.clamp() +} + +func (p *jjTablePane) Cursor() int { + return p.cursor +} + +func (p *jjTablePane) Offset() int { + return p.offset +} + +func (p *jjTablePane) SetCursor(cursor int) { + p.cursor = cursor + p.clamp() +} + +func (p *jjTablePane) pageSize() int { + if p.height <= 3 { + return 1 + } + return max(1, p.height-2) +} + +func (p *jjTablePane) clamp() { + if len(p.rows) == 0 { + p.cursor = 0 + p.offset = 0 + return + } + p.cursor = max(0, min(p.cursor, len(p.rows)-1)) + maxOffset := max(0, len(p.rows)-p.pageSize()) + p.offset = max(0, min(p.offset, maxOffset)) +} + +func newJJPreviewPane(empty string) *jjPreviewPane { + sty := styles.DefaultStyles() + vp := viewport.New( + viewport.WithWidth(0), + viewport.WithHeight(0), + ) + vp.SoftWrap = true + vp.FillHeight = true + return &jjPreviewPane{ + sty: sty, + viewport: vp, + empty: empty, + } +} + +func (p *jjPreviewPane) Init() tea.Cmd { return nil } + +func (p *jjPreviewPane) Update(msg tea.Msg) (components.Pane, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyPressMsg) + if !ok { + return p, nil + } + + switch { + case key.Matches(keyMsg, key.NewBinding(key.WithKeys("up", "k"))): + p.viewport.ScrollUp(1) + case key.Matches(keyMsg, key.NewBinding(key.WithKeys("down", "j"))): + p.viewport.ScrollDown(1) + case key.Matches(keyMsg, key.NewBinding(key.WithKeys("pgdown", "ctrl+d"))): + p.viewport.HalfPageDown() + case key.Matches(keyMsg, key.NewBinding(key.WithKeys("pgup", "ctrl+u"))): + p.viewport.HalfPageUp() + case key.Matches(keyMsg, key.NewBinding(key.WithKeys("g", "home"))): + p.viewport.GotoTop() + case key.Matches(keyMsg, key.NewBinding(key.WithKeys("G", "end"))): + p.viewport.GotoBottom() + } + + return p, nil +} + +func (p *jjPreviewPane) View() string { + if p.width <= 0 || p.height <= 0 { + return "" + } + if strings.TrimSpace(p.content) == "" { + return lipgloss.NewStyle(). + Width(p.width). + Height(p.height). + Align(lipgloss.Center, lipgloss.Center). + Render(jjMutedStyle.Render(p.empty)) + } + + bodyHeight := p.bodyHeight() + view := p.viewport.View() + scrollbar := common.Scrollbar( + &p.sty, + bodyHeight, + p.viewport.TotalLineCount(), + bodyHeight, + p.viewport.YOffset(), + ) + if scrollbar != "" { + view = lipgloss.JoinHorizontal(lipgloss.Top, view, scrollbar) + } + + pager := jjMutedStyle.Render( + fmt.Sprintf( + "%d%% %d/%d", + int(p.viewport.ScrollPercent()*100), + min(p.viewport.TotalLineCount(), p.viewport.YOffset()+1), + max(1, p.viewport.TotalLineCount()), + ), + ) + + return lipgloss.JoinVertical(lipgloss.Left, view, pager) +} + +func (p *jjPreviewPane) SetSize(width, height int) { + p.width = width + p.height = height + p.syncViewport(false) +} + +func (p *jjPreviewPane) SetContent(content string, reset bool) { + p.content = content + p.syncViewport(reset) +} + +func (p *jjPreviewPane) bodyHeight() int { + if p.height <= 1 { + return max(1, p.height) + } + return p.height - 1 +} + +func (p *jjPreviewPane) syncViewport(reset bool) { + bodyHeight := p.bodyHeight() + contentWidth := max(1, p.width) + if bodyHeight > 0 && strings.Count(p.content, "\n")+1 > bodyHeight && p.width > 1 { + contentWidth = p.width - 1 + } + p.viewport.SetWidth(max(1, contentWidth)) + p.viewport.SetHeight(max(1, bodyHeight)) + p.viewport.SetContent(p.content) + if reset { + p.viewport.GotoTop() + } +} + +func jjRenderHeader(title string, width int, right string) string { + left := jjTitleStyle.Render(title) + if width <= 0 || right == "" { + return left + } + gap := width - lipgloss.Width(left) - lipgloss.Width(right) + if gap <= 1 { + return left + " " + right + } + return left + strings.Repeat(" ", gap) + right +} + +func jjRenderFilterTabs(tabs []jjFilterTab, selected string, counts map[string]int) string { + parts := make([]string, 0, len(tabs)) + for _, tab := range tabs { + label := fmt.Sprintf("%s %s %d", tab.Icon, tab.Label, counts[tab.Value]) + style := jjBadgeStyleForState(tab.Value) + if tab.Value != selected { + style = style.Faint(true) + } + parts = append(parts, style.Render(label)) + } + return strings.Join(parts, " ") +} + +func jjBadgeStyleForState(state string) lipgloss.Style { + style := jjBadgeBaseStyle + switch state { + case "open", "running": + return style.Foreground(lipgloss.Color("120")).BorderForeground(lipgloss.Color("120")).Border(lipgloss.RoundedBorder()) + case "merged": + return style.Foreground(lipgloss.Color("141")).BorderForeground(lipgloss.Color("141")).Border(lipgloss.RoundedBorder()) + case "closed", "failed": + return style.Foreground(lipgloss.Color("203")).BorderForeground(lipgloss.Color("203")).Border(lipgloss.RoundedBorder()) + case "draft", "stopped": + return style.Foreground(lipgloss.Color("245")).BorderForeground(lipgloss.Color("245")).Border(lipgloss.RoundedBorder()) + case "pending": + return style.Foreground(lipgloss.Color("221")).BorderForeground(lipgloss.Color("221")).Border(lipgloss.RoundedBorder()) + default: + return style.Foreground(lipgloss.Color("250")).BorderForeground(lipgloss.Color("240")).Border(lipgloss.RoundedBorder()) + } +} + +func jjhubLandingStateIcon(state string) string { + switch state { + case "open": + return jjOpenStyle.Render("↑") + case "merged": + return jjMergedStyle.Render(styles.CheckIcon) + case "closed": + return jjClosedStyle.Render(styles.ToolError) + case "draft": + return jjDraftStyle.Render("◌") + default: + return jjMutedStyle.Render("?") + } +} + +func jjhubIssueStateIcon(state string) string { + switch state { + case "open": + return jjOpenStyle.Render(styles.RadioOn) + case "closed": + return jjClosedStyle.Render(styles.RadioOff) + default: + return jjMutedStyle.Render("?") + } +} + +func jjhubWorkspaceStatusIcon(status string) string { + switch status { + case "running": + return jjOpenStyle.Render(styles.ToolPending) + case "pending": + return jjPendingStyle.Render("◌") + case "stopped": + return jjDraftStyle.Render(styles.RadioOff) + case "failed": + return jjClosedStyle.Render(styles.ToolError) + default: + return jjMutedStyle.Render("?") + } +} + +func jjLandingConflictCell(landing jjhub.Landing, detail *jjhub.LandingDetail) string { + conflictStatus := landing.ConflictStatus + if detail != nil && detail.Conflicts.ConflictStatus != "" { + conflictStatus = detail.Conflicts.ConflictStatus + } + switch { + case strings.Contains(strings.ToLower(conflictStatus), "conflict"): + return jjClosedStyle.Render("conflict") + case conflictStatus == "" || conflictStatus == "unknown": + return jjMutedStyle.Render("unknown") + default: + return jjSuccessStyle.Render(conflictStatus) + } +} + +func jjLandingReviewCell(detail *jjhub.LandingDetail) string { + if detail == nil { + return jjMutedStyle.Render("…") + } + return fmt.Sprintf("%d", len(detail.Reviews)) +} + +func jjReviewStateLabel(state string) string { + switch state { + case "approve": + return jjSuccessStyle.Render("approve") + case "request_changes": + return jjClosedStyle.Render("changes") + case "comment": + return jjPendingStyle.Render("comment") + default: + return jjMutedStyle.Render(state) + } +} + +func jjRenderLabel(label jjhub.Label) string { + base := lipgloss.NewStyle().Padding(0, 1) + if label.Color != "" { + return base.Foreground(lipgloss.Color(label.Color)).Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color(label.Color)).Render(label.Name) + } + return base.Foreground(lipgloss.Color("111")).Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("240")).Render(label.Name) +} + +func jjJoinAssignees(users []jjhub.User) string { + if len(users) == 0 { + return "-" + } + parts := make([]string, 0, len(users)) + for _, user := range users { + if user.Login == "" { + continue + } + parts = append(parts, "@"+user.Login) + } + if len(parts) == 0 { + return "-" + } + return strings.Join(parts, ", ") +} + +func jjMarkdown(md string, width int, sty *styles.Styles) string { + if strings.TrimSpace(md) == "" { + return jjMutedStyle.Render("(no description)") + } + if width <= 0 { + width = 40 + } + + if sty == nil { + return jjWrapText(md, width) + } + + renderer := common.MarkdownRenderer(sty, width) + rendered, err := renderer.Render(md) + if err != nil { + return jjWrapText(md, width) + } + return strings.TrimSpace(rendered) +} + +func jjWrapText(text string, width int) string { + if width <= 0 { + return text + } + var out []string + for _, rawLine := range strings.Split(text, "\n") { + line := strings.TrimRight(rawLine, " ") + if line == "" { + out = append(out, "") + continue + } + for lipgloss.Width(line) > width { + runes := []rune(line) + split := min(len(runes), width) + out = append(out, string(runes[:split])) + line = string(runes[split:]) + } + out = append(out, line) + } + return strings.Join(out, "\n") +} + +func jjhubRelativeTime(raw string) string { + if raw == "" { + return "-" + } + parsed, err := time.Parse(time.RFC3339, raw) + if err != nil { + parsed, err = time.Parse(time.RFC3339Nano, raw) + if err != nil { + return raw + } + } + + delta := time.Since(parsed) + switch { + case delta < time.Minute: + return "just now" + case delta < time.Hour: + return fmt.Sprintf("%dm ago", int(delta.Minutes())) + case delta < 24*time.Hour: + return fmt.Sprintf("%dh ago", int(delta.Hours())) + case delta < 7*24*time.Hour: + return fmt.Sprintf("%dd ago", int(delta.Hours()/24)) + case delta < 365*24*time.Hour: + return fmt.Sprintf("%dmo ago", int(delta.Hours()/(24*30))) + default: + return fmt.Sprintf("%dy ago", int(delta.Hours()/(24*365))) + } +} + +func jjFormatTime(raw string) string { + if raw == "" { + return "-" + } + parsed, err := time.Parse(time.RFC3339, raw) + if err != nil { + parsed, err = time.Parse(time.RFC3339Nano, raw) + if err != nil { + return raw + } + } + return parsed.Format("2006-01-02 15:04") +} + +func jjMatchesSearch(text, query string) bool { + return strings.Contains(strings.ToLower(text), strings.ToLower(query)) +} + +func jjMetaRow(label, value string) string { + return jjMetaLabelStyle.Render(label) + jjMetaValueStyle.Render(value) +} + +func jjOpenURLCmd(url string) tea.Cmd { + return func() tea.Msg { + if url == "" { + return components.ShowToastMsg{ + Title: "Browser open failed", + Body: "No URL available for this item.", + Level: components.ToastLevelError, + } + } + + var ( + cmd *exec.Cmd + err error + ) + + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) //nolint:gosec // user-triggered URL open + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) //nolint:gosec // user-triggered URL open + default: + cmd = exec.Command("xdg-open", url) //nolint:gosec // user-triggered URL open + } + + if err = cmd.Start(); err != nil { + return components.ShowToastMsg{ + Title: "Browser open failed", + Body: err.Error(), + Level: components.ToastLevelError, + } + } + + return components.ShowToastMsg{ + Title: "Opened in browser", + Body: url, + Level: components.ToastLevelSuccess, + } + } +} + +func jjLandingURL(repo *jjhub.Repo, number int) string { + if repo == nil || repo.FullName == "" { + return "" + } + return fmt.Sprintf("%s/%s/landings/%d", jjhubWebBaseURL, repo.FullName, number) +} + +func jjIssueURL(repo *jjhub.Repo, number int) string { + if repo == nil || repo.FullName == "" { + return "" + } + return fmt.Sprintf("%s/%s/issues/%d", jjhubWebBaseURL, repo.FullName, number) +} diff --git a/internal/ui/views/landings.go b/internal/ui/views/landings.go new file mode 100644 index 00000000..fd05f077 --- /dev/null +++ b/internal/ui/views/landings.go @@ -0,0 +1,891 @@ +package views + +import ( + "fmt" + "os/exec" + "sort" + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/jjhub" + "github.com/charmbracelet/crush/internal/smithers" + "github.com/charmbracelet/crush/internal/ui/components" + "github.com/charmbracelet/crush/internal/ui/handoff" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +var _ View = (*LandingsView)(nil) + +type landingsLoadedMsg struct { + landings []jjhub.Landing +} + +type landingsErrorMsg struct { + err error +} + +type landingRepoLoadedMsg struct { + repo *jjhub.Repo +} + +type landingChangesLoadedMsg struct { + changes []jjhub.Change +} + +type landingDetailLoadedMsg struct { + number int + detail *jjhub.LandingDetail +} + +type landingDetailErrorMsg struct { + number int + err error +} + +// LandingsView renders a JJHub landing request dashboard. +type LandingsView struct { + smithersClient *smithers.Client + jjhubClient *jjhub.Client + sty styles.Styles + + width int + height int + + loading bool + err error + + repo *jjhub.Repo + + previewOpen bool + search jjSearchState + searchQuery string + filterIndex int + + allLandings []jjhub.Landing + landings []jjhub.Landing + changeMap map[string]jjhub.Change + + detailCache map[int]*jjhub.LandingDetail + detailLoading map[int]bool + detailErrors map[int]error + + tablePane *jjTablePane + previewPane *jjPreviewPane + splitPane *components.SplitPane +} + +// LandingDetailView renders a full-screen tabbed landing detail view. +type LandingDetailView struct { + parent View + + jjhubClient *jjhub.Client + repo *jjhub.Repo + sty styles.Styles + + width int + height int + + landing jjhub.Landing + detail *jjhub.LandingDetail + changeMap map[string]jjhub.Change + + loading bool + err error + tab int + + previewPane *jjPreviewPane +} + +type landingDetailViewLoadedMsg struct { + detail *jjhub.LandingDetail +} + +type landingDetailViewErrorMsg struct { + err error +} + +var landingFilters = []jjFilterTab{ + {Value: "open", Label: "Open", Icon: jjhubLandingStateIcon("open")}, + {Value: "merged", Label: "Merged", Icon: jjhubLandingStateIcon("merged")}, + {Value: "closed", Label: "Closed", Icon: jjhubLandingStateIcon("closed")}, + {Value: "draft", Label: "Draft", Icon: jjhubLandingStateIcon("draft")}, + {Value: "all", Label: "All", Icon: "•"}, +} + +var landingTableColumns = []components.Column{ + {Title: "", Width: 2}, + {Title: "#", Width: 6, Align: components.AlignRight}, + {Title: "Title", Grow: true}, + {Title: "Author", Width: 14, MinWidth: 90}, + {Title: "Stack", Width: 7, MinWidth: 105, Align: components.AlignRight}, + {Title: "Reviews", Width: 8, MinWidth: 118, Align: components.AlignRight}, + {Title: "Conflicts", Width: 10, MinWidth: 100}, + {Title: "Updated", Width: 10, MinWidth: 82}, +} + +var landingDetailTabs = []string{"Overview", "Changes", "Reviews", "Conflicts"} + +// NewLandingsView creates a JJHub landing request view. +func NewLandingsView(client *smithers.Client) *LandingsView { + tablePane := newJJTablePane(landingTableColumns) + previewPane := newJJPreviewPane("Select a landing request") + splitPane := components.NewSplitPane(tablePane, previewPane, components.SplitPaneOpts{ + LeftWidth: 70, + CompactBreakpoint: 100, + }) + + return &LandingsView{ + smithersClient: client, + jjhubClient: jjhub.NewClient(""), + sty: styles.DefaultStyles(), + loading: true, + previewOpen: true, + search: newJJSearchInput("filter landings by title"), + changeMap: make(map[string]jjhub.Change), + detailCache: make(map[int]*jjhub.LandingDetail), + detailLoading: make(map[int]bool), + detailErrors: make(map[int]error), + tablePane: tablePane, + previewPane: previewPane, + splitPane: splitPane, + } +} + +func (v *LandingsView) Init() tea.Cmd { + return tea.Batch( + v.loadLandingsCmd(), + v.loadRepoCmd(), + v.loadChangesCmd(), + ) +} + +func (v *LandingsView) Update(msg tea.Msg) (View, tea.Cmd) { + switch msg := msg.(type) { + case landingsLoadedMsg: + v.loading = false + v.err = nil + v.allLandings = msg.landings + selectionChanged := v.rebuildRows() + return v, v.syncPreview(selectionChanged) + + case landingsErrorMsg: + v.loading = false + v.err = msg.err + return v, nil + + case landingRepoLoadedMsg: + v.repo = msg.repo + return v, nil + + case landingChangesLoadedMsg: + v.changeMap = make(map[string]jjhub.Change, len(msg.changes)) + for _, change := range msg.changes { + v.changeMap[change.ChangeID] = change + } + selectionChanged := v.rebuildRows() + return v, v.syncPreview(selectionChanged) + + case landingDetailLoadedMsg: + delete(v.detailLoading, msg.number) + delete(v.detailErrors, msg.number) + v.detailCache[msg.number] = msg.detail + selectionChanged := v.rebuildRows() + return v, v.syncPreview(selectionChanged) + + case landingDetailErrorMsg: + delete(v.detailLoading, msg.number) + v.detailErrors[msg.number] = msg.err + return v, v.syncPreview(false) + + case tea.WindowSizeMsg: + v.SetSize(msg.Width, msg.Height) + return v, nil + + case tea.KeyPressMsg: + if v.search.active { + return v.updateSearch(msg) + } + + switch { + case key.Matches(msg, key.NewBinding(key.WithKeys("esc", "q"))): + return v, func() tea.Msg { return PopViewMsg{} } + case key.Matches(msg, key.NewBinding(key.WithKeys("/"))): + v.search.active = true + v.search.input.SetValue(v.searchQuery) + return v, v.search.input.Focus() + case key.Matches(msg, key.NewBinding(key.WithKeys("w"))): + v.previewOpen = !v.previewOpen + if v.previewOpen { + return v, v.syncPreview(true) + } + return v, nil + case key.Matches(msg, key.NewBinding(key.WithKeys("s"))): + v.filterIndex = (v.filterIndex + 1) % len(landingFilters) + selectionChanged := v.rebuildRows() + return v, v.syncPreview(selectionChanged) + case key.Matches(msg, key.NewBinding(key.WithKeys("r", "R"))): + v.loading = true + v.err = nil + v.detailCache = make(map[int]*jjhub.LandingDetail) + v.detailLoading = make(map[int]bool) + v.detailErrors = make(map[int]error) + selectionChanged := v.rebuildRows() + return v, tea.Batch(v.Init(), v.syncPreview(selectionChanged)) + case key.Matches(msg, key.NewBinding(key.WithKeys("o"))): + if landing := v.selectedLanding(); landing != nil { + return v, jjOpenURLCmd(jjLandingURL(v.repo, landing.Number)) + } + case key.Matches(msg, key.NewBinding(key.WithKeys("d"))): + if landing := v.selectedLanding(); landing != nil { + return v, v.diffCmd(landing) + } + case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))): + if landing := v.selectedLanding(); landing != nil { + detailView := NewLandingDetailView(v, v.jjhubClient, v.repo, v.sty, *landing, v.detailCache[landing.Number], v.changeMap) + detailView.SetSize(v.width, v.height) + return detailView, detailView.Init() + } + } + } + + previous := v.selectedLandingNumber() + var cmd tea.Cmd + if v.previewOpen { + v.tablePane.SetFocused(v.splitPane.Focus() == components.FocusLeft) + newSplitPane, splitCmd := v.splitPane.Update(msg) + v.splitPane = newSplitPane + cmd = splitCmd + } else { + v.tablePane.SetFocused(true) + _, cmd = v.tablePane.Update(msg) + } + + selectionChanged := previous != v.selectedLandingNumber() + return v, tea.Batch(cmd, v.syncPreview(selectionChanged)) +} + +func (v *LandingsView) View() string { + headerRight := jjMutedStyle.Render("[/] Search [w] Preview [Esc] Back") + header := jjRenderHeader( + fmt.Sprintf("JJHUB › Landings (%d)", len(v.landings)), + v.width, + headerRight, + ) + tabs := jjRenderFilterTabs(landingFilters, v.currentFilter(), v.stateCounts()) + + var parts []string + parts = append(parts, header) + + searchLine := tabs + switch { + case v.search.active: + searchLine = tabs + " " + jjSearchStyle.Render("Search:") + " " + v.search.input.View() + case v.searchQuery != "": + searchLine = tabs + " " + jjMutedStyle.Render("filter: "+v.searchQuery) + } + parts = append(parts, searchLine) + + if v.loading && len(v.allLandings) == 0 { + parts = append(parts, jjMutedStyle.Render("Loading landing requests…")) + return strings.Join(parts, "\n") + } + if v.err != nil && len(v.allLandings) == 0 { + parts = append(parts, jjErrorStyle.Render("Error: "+v.err.Error())) + return strings.Join(parts, "\n") + } + if v.err != nil { + parts = append(parts, jjErrorStyle.Render("Error: "+v.err.Error())) + } + + contentHeight := max(1, v.height-len(parts)-1) + if v.previewOpen { + v.tablePane.SetFocused(v.splitPane.Focus() == components.FocusLeft) + v.splitPane.SetSize(v.width, contentHeight) + parts = append(parts, v.splitPane.View()) + } else { + v.tablePane.SetFocused(true) + v.tablePane.SetSize(v.width, contentHeight) + parts = append(parts, v.tablePane.View()) + } + + return strings.Join(parts, "\n") +} + +func (v *LandingsView) Name() string { return "landings" } + +func (v *LandingsView) SetSize(width, height int) { + v.width = width + v.height = height + contentHeight := max(1, height-3) + v.tablePane.SetSize(width, contentHeight) + v.previewPane.SetSize(max(1, width/2), contentHeight) + v.splitPane.SetSize(width, contentHeight) +} + +func (v *LandingsView) ShortHelp() []key.Binding { + if v.search.active { + return []key.Binding{ + key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "apply")), + key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")), + } + } + + help := []key.Binding{ + key.NewBinding(key.WithKeys("j", "k"), key.WithHelp("j/k", "move")), + key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "detail")), + key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "filter")), + key.NewBinding(key.WithKeys("w"), key.WithHelp("w", "preview")), + key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "search")), + key.NewBinding(key.WithKeys("o"), key.WithHelp("o", "browser")), + key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "diff")), + key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")), + } + if v.previewOpen { + help = append(help, key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "focus"))) + } + return help +} + +func (v *LandingsView) currentFilter() string { + return landingFilters[v.filterIndex].Value +} + +func (v *LandingsView) stateCounts() map[string]int { + counts := map[string]int{ + "open": 0, + "merged": 0, + "closed": 0, + "draft": 0, + "all": len(v.allLandings), + } + for _, landing := range v.allLandings { + counts[landing.State]++ + } + return counts +} + +func (v *LandingsView) selectedLanding() *jjhub.Landing { + index := v.tablePane.Cursor() + if index < 0 || index >= len(v.landings) { + return nil + } + landing := v.landings[index] + return &landing +} + +func (v *LandingsView) selectedLandingNumber() int { + if landing := v.selectedLanding(); landing != nil { + return landing.Number + } + return 0 +} + +func (v *LandingsView) rebuildRows() bool { + previous := v.selectedLandingNumber() + filter := v.currentFilter() + + filtered := make([]jjhub.Landing, 0, len(v.allLandings)) + rows := make([]components.Row, 0, len(v.allLandings)) + for _, landing := range v.allLandings { + if filter != "all" && landing.State != filter { + continue + } + if v.searchQuery != "" && !jjMatchesSearch(landing.Title, v.searchQuery) { + continue + } + filtered = append(filtered, landing) + rows = append(rows, components.Row{ + Cells: []string{ + jjhubLandingStateIcon(landing.State), + fmt.Sprintf("#%d", landing.Number), + landing.Title, + landing.Author.Login, + fmt.Sprintf("%d", max(landing.StackSize, len(landing.ChangeIDs))), + jjLandingReviewCell(v.detailCache[landing.Number]), + jjLandingConflictCell(landing, v.detailCache[landing.Number]), + jjhubRelativeTime(landing.UpdatedAt), + }, + }) + } + + v.landings = filtered + v.tablePane.SetRows(rows) + + targetIndex := 0 + for i, landing := range filtered { + if landing.Number == previous { + targetIndex = i + break + } + } + if len(filtered) > 0 { + v.tablePane.SetCursor(targetIndex) + } + return previous != v.selectedLandingNumber() +} + +func (v *LandingsView) renderPreview(landing jjhub.Landing) string { + width := max(24, v.previewPane.width-4) + detail := v.detailCache[landing.Number] + var body strings.Builder + + body.WriteString(jjTitleStyle.Render(landing.Title)) + body.WriteString("\n") + body.WriteString(jjBadgeStyleForState(landing.State).Render(jjhubLandingStateIcon(landing.State) + " " + landing.State)) + body.WriteString("\n\n") + body.WriteString(jjMetaRow("Author", "@"+landing.Author.Login) + "\n") + body.WriteString(jjMetaRow("Number", fmt.Sprintf("#%d", landing.Number)) + "\n") + body.WriteString(jjMetaRow("Target", landing.TargetBookmark) + "\n") + body.WriteString(jjMetaRow("Created", jjFormatTime(landing.CreatedAt)) + "\n") + body.WriteString(jjMetaRow("Updated", jjFormatTime(landing.UpdatedAt)) + "\n") + body.WriteString(jjMetaRow("Conflicts", lipgloss.NewStyle().UnsetWidth().Render(jjLandingConflictCell(landing, detail))) + "\n") + + body.WriteString("\n") + body.WriteString(jjSectionStyle.Render("Stack")) + body.WriteString("\n") + for _, line := range v.renderLandingStack(detail, landing) { + body.WriteString(line) + body.WriteString("\n") + } + + body.WriteString("\n") + body.WriteString(jjSectionStyle.Render("Reviews")) + body.WriteString("\n") + if detail == nil { + if v.detailErrors[landing.Number] != nil { + body.WriteString(jjErrorStyle.Render(v.detailErrors[landing.Number].Error())) + } else { + body.WriteString(jjMutedStyle.Render("Loading review data…")) + } + body.WriteString("\n") + } else if len(detail.Reviews) == 0 { + body.WriteString(jjMutedStyle.Render("No reviews yet.")) + body.WriteString("\n") + } else { + for _, review := range detail.Reviews { + line := fmt.Sprintf("%s reviewer #%d", jjReviewStateLabel(review.State), review.ReviewerID) + body.WriteString(line) + if review.Body != "" { + body.WriteString("\n") + body.WriteString(jjMutedStyle.Render(jjWrapText(strings.TrimSpace(review.Body), width))) + } + body.WriteString("\n") + } + } + + if detail != nil && detail.Conflicts.HasConflicts { + body.WriteString("\n") + body.WriteString(jjSectionStyle.Render("Conflict details")) + body.WriteString("\n") + keys := make([]string, 0, len(detail.Conflicts.ConflictsByChange)) + for changeID := range detail.Conflicts.ConflictsByChange { + keys = append(keys, changeID) + } + sort.Strings(keys) + for _, changeID := range keys { + body.WriteString(fmt.Sprintf("%s %s\n", lipgloss.NewStyle().Bold(true).Render(changeID), detail.Conflicts.ConflictsByChange[changeID])) + } + } + + body.WriteString("\n") + body.WriteString(jjSectionStyle.Render("Description")) + body.WriteString("\n") + if detail != nil && detail.Landing.Body != "" { + body.WriteString(jjMarkdown(detail.Landing.Body, width, &v.sty)) + } else { + body.WriteString(jjMarkdown(landing.Body, width, &v.sty)) + } + + return jjSidebarBoxStyle.Render(strings.TrimSpace(body.String())) +} + +func (v *LandingsView) renderLandingStack(detail *jjhub.LandingDetail, landing jjhub.Landing) []string { + if detail == nil || len(detail.Changes) == 0 { + lines := make([]string, 0, len(landing.ChangeIDs)) + for i, changeID := range landing.ChangeIDs { + lines = append(lines, fmt.Sprintf("%d. %s", i+1, changeID)) + } + if len(lines) == 0 { + lines = append(lines, jjMutedStyle.Render("No changes in stack.")) + } + return lines + } + + lines := make([]string, 0, len(detail.Changes)) + for _, change := range detail.Changes { + line := fmt.Sprintf("%d. %s", change.PositionInStack, change.ChangeID) + if summary, ok := v.changeMap[change.ChangeID]; ok && summary.Description != "" { + line += " " + jjMutedStyle.Render(truncateStr(summary.Description, 48)) + } + lines = append(lines, line) + } + return lines +} + +func (v *LandingsView) syncPreview(reset bool) tea.Cmd { + landing := v.selectedLanding() + if landing == nil { + v.previewPane.SetContent("", true) + return nil + } + v.previewPane.SetContent(v.renderPreview(*landing), reset) + return v.ensureLandingDetail(*landing) +} + +func (v *LandingsView) ensureLandingDetail(landing jjhub.Landing) tea.Cmd { + if v.detailCache[landing.Number] != nil || v.detailLoading[landing.Number] { + return nil + } + v.detailLoading[landing.Number] = true + return v.loadLandingDetailCmd(landing.Number) +} + +func (v *LandingsView) updateSearch(msg tea.KeyPressMsg) (View, tea.Cmd) { + switch { + case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))): + v.search.active = false + v.search.input.Blur() + return v, nil + case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))): + v.search.active = false + v.searchQuery = strings.TrimSpace(v.search.input.Value()) + v.search.input.Blur() + selectionChanged := v.rebuildRows() + return v, v.syncPreview(selectionChanged) + default: + var cmd tea.Cmd + v.search.input, cmd = v.search.input.Update(msg) + return v, cmd + } +} + +func (v *LandingsView) loadLandingsCmd() tea.Cmd { + client := v.jjhubClient + return func() tea.Msg { + landings, err := client.ListLandings("all", jjDefaultListLimit) + if err != nil { + return landingsErrorMsg{err: err} + } + return landingsLoadedMsg{landings: landings} + } +} + +func (v *LandingsView) loadRepoCmd() tea.Cmd { + client := v.jjhubClient + return func() tea.Msg { + repo, err := client.GetCurrentRepo() + if err != nil { + return nil + } + return landingRepoLoadedMsg{repo: repo} + } +} + +func (v *LandingsView) loadChangesCmd() tea.Cmd { + client := v.jjhubClient + return func() tea.Msg { + changes, err := client.ListChanges(jjDefaultListLimit) + if err != nil { + return nil + } + return landingChangesLoadedMsg{changes: changes} + } +} + +func (v *LandingsView) loadLandingDetailCmd(number int) tea.Cmd { + client := v.jjhubClient + return func() tea.Msg { + detail, err := client.ViewLanding(number) + if err != nil { + return landingDetailErrorMsg{number: number, err: err} + } + return landingDetailLoadedMsg{number: number, detail: detail} + } +} + +func (v *LandingsView) diffCmd(landing *jjhub.Landing) tea.Cmd { + changeID := "" + if len(landing.ChangeIDs) > 0 { + changeID = landing.ChangeIDs[0] + } + if detail := v.detailCache[landing.Number]; detail != nil && len(detail.Changes) > 0 { + changeID = detail.Changes[0].ChangeID + } + if changeID == "" { + return func() tea.Msg { + return components.ShowToastMsg{ + Title: "No diff available", + Body: "The selected landing does not include any changes.", + Level: components.ToastLevelWarning, + } + } + } + + if _, err := exec.LookPath("diffnav"); err == nil { + return handoff.Handoff(handoff.Options{ + Binary: "zsh", + Args: []string{ + "-lc", + buildChangeDiffCommand(jjhub.Change{ChangeID: changeID}) + " | diffnav", + }, + Tag: "landing-diff", + }) + } + + return handoff.Handoff(handoff.Options{ + Binary: "jj", + Args: []string{"diff", "--git", "-r", changeID}, + Tag: "landing-diff", + }) +} + +// NewLandingDetailView creates a full-screen landing detail drill-down view. +func NewLandingDetailView( + parent View, + client *jjhub.Client, + repo *jjhub.Repo, + sty styles.Styles, + landing jjhub.Landing, + detail *jjhub.LandingDetail, + changeMap map[string]jjhub.Change, +) *LandingDetailView { + previewPane := newJJPreviewPane("Loading landing detail…") + return &LandingDetailView{ + parent: parent, + jjhubClient: client, + repo: repo, + sty: sty, + landing: landing, + detail: detail, + changeMap: changeMap, + loading: detail == nil, + previewPane: previewPane, + } +} + +func (v *LandingDetailView) Init() tea.Cmd { + v.syncContent(true) + if v.detail != nil { + return nil + } + client := v.jjhubClient + number := v.landing.Number + return func() tea.Msg { + detail, err := client.ViewLanding(number) + if err != nil { + return landingDetailViewErrorMsg{err: err} + } + return landingDetailViewLoadedMsg{detail: detail} + } +} + +func (v *LandingDetailView) Update(msg tea.Msg) (View, tea.Cmd) { + switch msg := msg.(type) { + case landingDetailViewLoadedMsg: + v.detail = msg.detail + v.loading = false + v.err = nil + if parent, ok := v.parent.(*LandingsView); ok && msg.detail != nil { + parent.detailCache[v.landing.Number] = msg.detail + parent.rebuildRows() + parent.syncPreview(false) + } + v.syncContent(true) + return v, nil + + case landingDetailViewErrorMsg: + v.loading = false + v.err = msg.err + v.syncContent(false) + return v, nil + + case tea.WindowSizeMsg: + v.SetSize(msg.Width, msg.Height) + return v, nil + + case tea.KeyPressMsg: + switch { + case key.Matches(msg, key.NewBinding(key.WithKeys("esc", "q"))): + v.parent.SetSize(v.width, v.height) + return v.parent, nil + case key.Matches(msg, key.NewBinding(key.WithKeys("left"))): + if v.tab > 0 { + v.tab-- + v.syncContent(true) + } + return v, nil + case key.Matches(msg, key.NewBinding(key.WithKeys("right"))): + if v.tab < len(landingDetailTabs)-1 { + v.tab++ + v.syncContent(true) + } + return v, nil + case key.Matches(msg, key.NewBinding(key.WithKeys("1"))): + v.tab = 0 + v.syncContent(true) + return v, nil + case key.Matches(msg, key.NewBinding(key.WithKeys("2"))): + v.tab = 1 + v.syncContent(true) + return v, nil + case key.Matches(msg, key.NewBinding(key.WithKeys("3"))): + v.tab = 2 + v.syncContent(true) + return v, nil + case key.Matches(msg, key.NewBinding(key.WithKeys("4"))): + v.tab = 3 + v.syncContent(true) + return v, nil + case key.Matches(msg, key.NewBinding(key.WithKeys("o"))): + return v, jjOpenURLCmd(jjLandingURL(v.repo, v.landing.Number)) + case key.Matches(msg, key.NewBinding(key.WithKeys("r", "R"))): + v.loading = true + v.err = nil + return v, v.Init() + } + } + + _, cmd := v.previewPane.Update(msg) + return v, cmd +} + +func (v *LandingDetailView) View() string { + header := jjRenderHeader( + fmt.Sprintf("JJHUB › Landings › #%d", v.landing.Number), + v.width, + jjMutedStyle.Render("[1-4] Tabs [o] Browser [Esc] Back"), + ) + tabs := make([]string, 0, len(landingDetailTabs)) + for i, tab := range landingDetailTabs { + style := jjBadgeBaseStyle.Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("240")) + if i == v.tab { + style = style.Foreground(lipgloss.Color("111")).BorderForeground(lipgloss.Color("111")).Bold(true) + } else { + style = style.Faint(true) + } + tabs = append(tabs, style.Render(fmt.Sprintf("%d %s", i+1, tab))) + } + + parts := []string{header, strings.Join(tabs, " ")} + if v.err != nil { + parts = append(parts, jjErrorStyle.Render("Error: "+v.err.Error())) + } + if v.loading && v.detail == nil { + parts = append(parts, jjMutedStyle.Render("Loading landing detail…")) + } + parts = append(parts, v.previewPane.View()) + return strings.Join(parts, "\n") +} + +func (v *LandingDetailView) Name() string { return "landing-detail" } + +func (v *LandingDetailView) SetSize(width, height int) { + v.width = width + v.height = height + v.previewPane.SetSize(width, max(1, height-3)) + v.syncContent(false) +} + +func (v *LandingDetailView) ShortHelp() []key.Binding { + return []key.Binding{ + key.NewBinding(key.WithKeys("1", "2", "3", "4"), key.WithHelp("1-4", "tabs")), + key.NewBinding(key.WithKeys("left", "right"), key.WithHelp("←/→", "tabs")), + key.NewBinding(key.WithKeys("j", "k"), key.WithHelp("j/k", "scroll")), + key.NewBinding(key.WithKeys("o"), key.WithHelp("o", "browser")), + key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")), + } +} + +func (v *LandingDetailView) syncContent(reset bool) { + width := max(24, v.previewPane.width-4) + var body strings.Builder + + body.WriteString(jjTitleStyle.Render(v.landing.Title)) + body.WriteString("\n") + body.WriteString(jjBadgeStyleForState(v.landing.State).Render(jjhubLandingStateIcon(v.landing.State) + " " + v.landing.State)) + body.WriteString("\n\n") + + switch v.tab { + case 0: + body.WriteString(jjMetaRow("Author", "@"+v.landing.Author.Login) + "\n") + body.WriteString(jjMetaRow("Number", fmt.Sprintf("#%d", v.landing.Number)) + "\n") + body.WriteString(jjMetaRow("Target", v.landing.TargetBookmark) + "\n") + body.WriteString(jjMetaRow("Updated", jjFormatTime(v.landing.UpdatedAt)) + "\n") + body.WriteString(jjMetaRow("Conflicts", jjLandingConflictCell(v.landing, v.detail)) + "\n") + body.WriteString("\n") + body.WriteString(jjSectionStyle.Render("Description")) + body.WriteString("\n") + content := v.landing.Body + if v.detail != nil && v.detail.Landing.Body != "" { + content = v.detail.Landing.Body + } + body.WriteString(jjMarkdown(content, width, &v.sty)) + + case 1: + body.WriteString(jjSectionStyle.Render("Stack")) + body.WriteString("\n") + for _, line := range v.renderChangesTab() { + body.WriteString(line) + body.WriteString("\n") + } + + case 2: + body.WriteString(jjSectionStyle.Render("Reviews")) + body.WriteString("\n") + if v.detail == nil || len(v.detail.Reviews) == 0 { + body.WriteString(jjMutedStyle.Render("No reviews yet.")) + } else { + for _, review := range v.detail.Reviews { + body.WriteString(fmt.Sprintf("%s reviewer #%d\n", jjReviewStateLabel(review.State), review.ReviewerID)) + if review.Body != "" { + body.WriteString(jjWrapText(strings.TrimSpace(review.Body), width)) + body.WriteString("\n") + } + body.WriteString("\n") + } + } + + case 3: + body.WriteString(jjSectionStyle.Render("Conflicts")) + body.WriteString("\n") + if v.detail == nil || !v.detail.Conflicts.HasConflicts { + body.WriteString(jjSuccessStyle.Render("Stack is clean.")) + } else { + keys := make([]string, 0, len(v.detail.Conflicts.ConflictsByChange)) + for changeID := range v.detail.Conflicts.ConflictsByChange { + keys = append(keys, changeID) + } + sort.Strings(keys) + for _, changeID := range keys { + body.WriteString(lipgloss.NewStyle().Bold(true).Render(changeID)) + body.WriteString("\n") + body.WriteString(jjWrapText(v.detail.Conflicts.ConflictsByChange[changeID], width)) + body.WriteString("\n\n") + } + } + } + + if v.err != nil { + body.WriteString("\n\n") + body.WriteString(jjErrorStyle.Render(v.err.Error())) + } + + v.previewPane.SetContent(strings.TrimSpace(body.String()), reset) +} + +func (v *LandingDetailView) renderChangesTab() []string { + if v.detail == nil || len(v.detail.Changes) == 0 { + return []string{jjMutedStyle.Render("No stack data available.")} + } + lines := make([]string, 0, len(v.detail.Changes)) + for _, change := range v.detail.Changes { + line := fmt.Sprintf("%d. %s", change.PositionInStack, change.ChangeID) + if summary, ok := v.changeMap[change.ChangeID]; ok && summary.Description != "" { + line += "\n" + jjMutedStyle.Render(summary.Description) + } + lines = append(lines, line) + } + return lines +} diff --git a/internal/ui/views/landings_test.go b/internal/ui/views/landings_test.go new file mode 100644 index 00000000..92d7da1e --- /dev/null +++ b/internal/ui/views/landings_test.go @@ -0,0 +1,115 @@ +package views + +import ( + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/crush/internal/jjhub" + "github.com/charmbracelet/crush/internal/smithers" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func sampleLanding(number int, state, title string) jjhub.Landing { + return jjhub.Landing{ + Number: number, + Title: title, + Body: "## Summary\n\n" + title, + State: state, + TargetBookmark: "main", + ChangeIDs: []string{"abc123", "def456"}, + StackSize: 2, + ConflictStatus: "clean", + Author: jjhub.User{Login: "will"}, + CreatedAt: time.Now().Add(-2 * time.Hour).Format(time.RFC3339), + UpdatedAt: time.Now().Add(-30 * time.Minute).Format(time.RFC3339), + } +} + +func newTestLandingsView() *LandingsView { + return NewLandingsView(smithers.NewClient()) +} + +func seedLandingsView(v *LandingsView, landings []jjhub.Landing) *LandingsView { + updated, _ := v.Update(landingsLoadedMsg{landings: landings}) + return updated.(*LandingsView) +} + +func TestLandingsView_ImplementsView(t *testing.T) { + t.Parallel() + var _ View = (*LandingsView)(nil) +} + +func TestLandingsView_FilterCycle(t *testing.T) { + t.Parallel() + + v := seedLandingsView(newTestLandingsView(), []jjhub.Landing{ + sampleLanding(1, "open", "Open landing"), + sampleLanding(2, "merged", "Merged landing"), + }) + + updated, _ := v.Update(tea.KeyPressMsg{Code: 's'}) + lv := updated.(*LandingsView) + + assert.Equal(t, "merged", lv.currentFilter()) + assert.Len(t, lv.landings, 1) + assert.Equal(t, 2, lv.landings[0].Number) +} + +func TestLandingsView_SearchApply(t *testing.T) { + t.Parallel() + + v := seedLandingsView(newTestLandingsView(), []jjhub.Landing{ + sampleLanding(1, "open", "Alpha"), + sampleLanding(2, "open", "Beta"), + }) + v.search.active = true + v.search.input.SetValue("beta") + + updated, _ := v.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + lv := updated.(*LandingsView) + + assert.Equal(t, "beta", lv.searchQuery) + assert.Len(t, lv.landings, 1) + assert.Equal(t, "Beta", lv.landings[0].Title) +} + +func TestLandingsView_WTogglesPreview(t *testing.T) { + t.Parallel() + + v := seedLandingsView(newTestLandingsView(), []jjhub.Landing{sampleLanding(1, "open", "Alpha")}) + assert.True(t, v.previewOpen) + + updated, _ := v.Update(tea.KeyPressMsg{Code: 'w'}) + lv := updated.(*LandingsView) + + assert.False(t, lv.previewOpen) +} + +func TestLandingsView_EnterReturnsDetailView(t *testing.T) { + t.Parallel() + + v := seedLandingsView(newTestLandingsView(), []jjhub.Landing{sampleLanding(1, "open", "Alpha")}) + v.width = 120 + v.height = 40 + + updated, cmd := v.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + + require.IsType(t, &LandingDetailView{}, updated) + require.NotNil(t, cmd) +} + +func TestLandingDetailView_EscReturnsParent(t *testing.T) { + t.Parallel() + + parent := seedLandingsView(newTestLandingsView(), []jjhub.Landing{sampleLanding(1, "open", "Alpha")}) + detail := NewLandingDetailView(parent, jjhub.NewClient(""), nil, styles.DefaultStyles(), sampleLanding(1, "open", "Alpha"), nil, nil) + detail.SetSize(120, 40) + + updated, cmd := detail.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + + require.Nil(t, cmd) + assert.Same(t, parent, updated) +} diff --git a/internal/ui/views/livechat.go b/internal/ui/views/livechat.go index af1a93db..563c41fe 100644 --- a/internal/ui/views/livechat.go +++ b/internal/ui/views/livechat.go @@ -207,6 +207,10 @@ func (v *LiveChatView) openStreamCmd() tea.Cmd { v.streamDone = false runID := v.runID + if strings.TrimSpace(runID) == "" { + v.streamDone = true + return nil + } client := v.client return func() tea.Msg { @@ -1027,9 +1031,9 @@ type liveChatBodyPane struct { view *LiveChatView } -func (p *liveChatBodyPane) Init() tea.Cmd { return nil } +func (p *liveChatBodyPane) Init() tea.Cmd { return nil } func (p *liveChatBodyPane) Update(msg tea.Msg) (components.Pane, tea.Cmd) { return p, nil } -func (p *liveChatBodyPane) View() string { return p.view.renderBody() } +func (p *liveChatBodyPane) View() string { return p.view.renderBody() } func (p *liveChatBodyPane) SetSize(width, height int) { // The body pane shares the parent view's rendering; dimensions are // governed by the parent LiveChatView. diff --git a/internal/ui/views/registry.go b/internal/ui/views/registry.go index fe52cbfc..c65bb9e1 100644 --- a/internal/ui/views/registry.go +++ b/internal/ui/views/registry.go @@ -50,9 +50,16 @@ func DefaultRegistry() *Registry { r := NewRegistry() r.Register("agents", func(c *smithers.Client) View { return NewAgentsView(c) }) r.Register("approvals", func(c *smithers.Client) View { return NewApprovalsView(c) }) + r.Register("changes", func(c *smithers.Client) View { return NewChangesView() }) + r.Register("issues", func(c *smithers.Client) View { return NewIssuesView(c) }) + r.Register("landings", func(c *smithers.Client) View { return NewLandingsView(c) }) + r.Register("runs", func(c *smithers.Client) View { return NewRunsView(c) }) + r.Register("sessions", func(c *smithers.Client) View { return NewSessionsView(c) }) r.Register("sql", func(c *smithers.Client) View { return NewSQLBrowserView(c) }) r.Register("tickets", func(c *smithers.Client) View { return NewTicketsView(c) }) r.Register("triggers", func(c *smithers.Client) View { return NewTriggersView(c) }) + r.Register("workspaces", func(c *smithers.Client) View { return NewWorkspacesView(c) }) r.Register("workflows", func(c *smithers.Client) View { return NewWorkflowsView(c) }) + r.Register("workflow-runs", func(c *smithers.Client) View { return NewWorkflowRunView(c) }) return r } diff --git a/internal/ui/views/sessions.go b/internal/ui/views/sessions.go new file mode 100644 index 00000000..955d14a3 --- /dev/null +++ b/internal/ui/views/sessions.go @@ -0,0 +1,953 @@ +package views + +import ( + "context" + "fmt" + "path" + "strings" + "time" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/spinner" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/smithers" + "github.com/charmbracelet/crush/internal/ui/components" + "github.com/charmbracelet/crush/internal/ui/handoff" +) + +// Compile-time interface check. +var _ View = (*SessionsView)(nil) + +type sessionsLoadedMsg struct { + runs []smithers.RunSummary +} + +type sessionsErrorMsg struct { + err error +} + +type sessionPreviewLoadedMsg struct { + runID string + cache sessionPreviewCache +} + +type sessionPreviewErrorMsg struct { + runID string + err error +} + +type sessionsHijackSessionMsg struct { + runID string + session *smithers.HijackSession + err error +} + +type sessionPreviewCache struct { + mainNodeID string + blocks []smithers.ChatBlock +} + +type sessionsHandoffTag struct { + runID string +} + +// SessionsView displays recent session-like runs with an optional transcript +// preview sidebar. +type SessionsView struct { + client *smithers.Client + + runs []smithers.RunSummary + cursor int + width int + height int + loading bool + err error + + showSidebar bool + splitPane *components.SplitPane + listPane *sessionsListPane + previewPane *sessionsPreviewPane + + searchActive bool + searchInput textinput.Model + + spinner spinner.Model + + previewCache map[string]sessionPreviewCache + previewErrs map[string]error + previewLoads map[string]bool + + engineCache map[string]string + + hijacking bool + hijackErr error +} + +// NewSessionsView creates a new sessions browser. +func NewSessionsView(client *smithers.Client) *SessionsView { + ti := textinput.New() + ti.Placeholder = "filter sessions..." + ti.SetVirtualCursor(true) + + s := spinner.New() + s.Spinner = spinner.MiniDot + s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("10")) + + v := &SessionsView{ + client: client, + loading: true, + showSidebar: true, + searchInput: ti, + spinner: s, + previewCache: make(map[string]sessionPreviewCache), + previewErrs: make(map[string]error), + previewLoads: make(map[string]bool), + engineCache: make(map[string]string), + } + + v.listPane = &sessionsListPane{view: v} + v.previewPane = &sessionsPreviewPane{view: v} + v.splitPane = components.NewSplitPane(v.listPane, v.previewPane, components.SplitPaneOpts{ + LeftWidth: 58, + CompactBreakpoint: 104, + }) + + return v +} + +// Init loads recent runs and starts the spinner if needed. +func (v *SessionsView) Init() tea.Cmd { + return tea.Batch(v.loadRunsCmd(), v.spinner.Tick) +} + +func (v *SessionsView) loadRunsCmd() tea.Cmd { + client := v.client + return func() tea.Msg { + if client == nil { + return sessionsErrorMsg{err: fmt.Errorf("smithers client not configured")} + } + runs, err := client.ListRuns(context.Background(), smithers.RunFilter{Limit: 50}) + if err != nil { + return sessionsErrorMsg{err: err} + } + return sessionsLoadedMsg{runs: filterSessionRuns(runs)} + } +} + +func (v *SessionsView) fetchPreviewCmd(runID string) tea.Cmd { + client := v.client + return func() tea.Msg { + if client == nil { + return sessionPreviewErrorMsg{runID: runID, err: fmt.Errorf("smithers client not configured")} + } + blocks, err := client.GetChatOutput(context.Background(), runID) + if err != nil { + return sessionPreviewErrorMsg{runID: runID, err: err} + } + return sessionPreviewLoadedMsg{ + runID: runID, + cache: buildSessionPreviewCache(blocks), + } + } +} + +func (v *SessionsView) hijackRunCmd(runID string) tea.Cmd { + client := v.client + return func() tea.Msg { + if client == nil { + return sessionsHijackSessionMsg{runID: runID, err: fmt.Errorf("smithers client not configured")} + } + session, err := client.HijackRun(context.Background(), runID) + return sessionsHijackSessionMsg{runID: runID, session: session, err: err} + } +} + +func (v *SessionsView) visibleRuns() []smithers.RunSummary { + query := strings.TrimSpace(strings.ToLower(v.searchInput.Value())) + if query == "" { + return v.runs + } + + out := make([]smithers.RunSummary, 0, len(v.runs)) + for _, run := range v.runs { + name := strings.ToLower(sessionDisplayName(run)) + if strings.Contains(name, query) { + out = append(out, run) + } + } + return out +} + +func (v *SessionsView) selectedRun() (smithers.RunSummary, bool) { + return components.RunAtCursor(v.visibleRuns(), v.cursor) +} + +func (v *SessionsView) selectedRunID() string { + run, ok := v.selectedRun() + if !ok { + return "" + } + return run.RunID +} + +func (v *SessionsView) clampCursor() { + visible := v.visibleRuns() + switch { + case len(visible) == 0: + v.cursor = 0 + case v.cursor < 0: + v.cursor = 0 + case v.cursor >= len(visible): + v.cursor = len(visible) - 1 + } +} + +func (v *SessionsView) ensurePreviewCmd() tea.Cmd { + run, ok := v.selectedRun() + if !ok { + return nil + } + runID := run.RunID + if _, ok := v.previewCache[runID]; ok { + return nil + } + if _, ok := v.previewErrs[runID]; ok { + return nil + } + if v.previewLoads[runID] { + return nil + } + v.previewLoads[runID] = true + return v.fetchPreviewCmd(runID) +} + +func (v *SessionsView) refreshCmd() tea.Cmd { + v.loading = true + v.err = nil + v.hijackErr = nil + v.previewCache = make(map[string]sessionPreviewCache) + v.previewErrs = make(map[string]error) + v.previewLoads = make(map[string]bool) + return tea.Batch(v.loadRunsCmd(), v.spinner.Tick) +} + +func (v *SessionsView) bodyHeight() int { + h := v.height - 4 + if v.searchActive { + h-- + } + if v.hijacking || v.hijackErr != nil { + h-- + } + if h < 1 { + return 1 + } + return h +} + +func (v *SessionsView) resizeBody() { + bodyHeight := v.bodyHeight() + if v.showSidebar && v.splitPane != nil { + v.splitPane.SetSize(v.width, bodyHeight) + return + } + if v.listPane != nil { + v.listPane.SetSize(v.width, bodyHeight) + } + if v.previewPane != nil { + v.previewPane.SetSize(max(0, v.width/2), bodyHeight) + } +} + +func (v *SessionsView) hasActiveRuns() bool { + for _, run := range v.runs { + if isActiveSessionStatus(run.Status) { + return true + } + } + return false +} + +func (v *SessionsView) shouldSpin() bool { + return v.loading || v.hijacking || v.hasActiveRuns() +} + +func (v *SessionsView) maybeSpinnerCmd() tea.Cmd { + if !v.shouldSpin() { + return nil + } + return v.spinner.Tick +} + +// Update handles messages for the sessions browser. +func (v *SessionsView) Update(msg tea.Msg) (View, tea.Cmd) { + switch msg := msg.(type) { + case sessionsLoadedMsg: + selectedID := v.selectedRunID() + v.runs = msg.runs + v.loading = false + v.err = nil + if selectedID != "" { + for i, run := range v.visibleRuns() { + if run.RunID == selectedID { + v.cursor = i + break + } + } + } + v.clampCursor() + v.resizeBody() + return v, tea.Batch(v.ensurePreviewCmd(), v.maybeSpinnerCmd()) + + case sessionsErrorMsg: + v.loading = false + v.err = msg.err + return v, nil + + case sessionPreviewLoadedMsg: + delete(v.previewLoads, msg.runID) + delete(v.previewErrs, msg.runID) + v.previewCache[msg.runID] = msg.cache + return v, nil + + case sessionPreviewErrorMsg: + delete(v.previewLoads, msg.runID) + v.previewErrs[msg.runID] = msg.err + return v, nil + + case sessionsHijackSessionMsg: + v.hijacking = false + if msg.err != nil { + v.hijackErr = msg.err + return v, nil + } + if msg.session == nil { + v.hijackErr = fmt.Errorf("resume session: empty hijack response") + return v, nil + } + if msg.session.AgentEngine != "" { + v.engineCache[msg.runID] = msg.session.AgentEngine + } + args := msg.session.ResumeArgs() + if !msg.session.SupportsResume || len(args) == 0 { + engine := msg.session.AgentEngine + if engine == "" { + engine = "agent" + } + v.hijackErr = fmt.Errorf("%s does not support session resume", engine) + return v, nil + } + binary := msg.session.AgentBinary + if binary == "" { + binary = msg.session.AgentEngine + } + return v, handoff.Handoff(handoff.Options{ + Binary: binary, + Args: args, + Cwd: msg.session.CWD, + Tag: sessionsHandoffTag{runID: msg.runID}, + }) + + case handoff.HandoffMsg: + tag, ok := msg.Tag.(sessionsHandoffTag) + if !ok { + return v, nil + } + v.hijacking = false + if msg.Result.Err != nil { + v.hijackErr = fmt.Errorf("resume session %s: %w", tag.runID, msg.Result.Err) + } else { + v.hijackErr = nil + } + return v, v.refreshCmd() + + case spinner.TickMsg: + if !v.shouldSpin() { + return v, nil + } + var cmd tea.Cmd + v.spinner, cmd = v.spinner.Update(msg) + return v, cmd + + case tea.WindowSizeMsg: + v.SetSize(msg.Width, msg.Height) + return v, nil + + case tea.KeyPressMsg: + if v.searchActive { + switch { + case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))): + if v.searchInput.Value() != "" { + v.searchInput.Reset() + } else { + v.searchActive = false + v.searchInput.Blur() + } + v.cursor = 0 + v.clampCursor() + v.resizeBody() + return v, v.ensurePreviewCmd() + + case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))): + v.searchActive = false + v.searchInput.Blur() + v.resizeBody() + return v, v.ensurePreviewCmd() + + default: + prev := v.searchInput.Value() + var cmd tea.Cmd + v.searchInput, cmd = v.searchInput.Update(msg) + if v.searchInput.Value() != prev { + v.cursor = 0 + v.clampCursor() + return v, tea.Batch(cmd, v.ensurePreviewCmd()) + } + return v, cmd + } + } + + oldSelected := v.selectedRunID() + + switch { + case key.Matches(msg, key.NewBinding(key.WithKeys("esc", "q", "alt+esc"))): + return v, func() tea.Msg { return PopViewMsg{} } + + case key.Matches(msg, key.NewBinding(key.WithKeys("/"))): + v.searchActive = true + v.searchInput.CursorEnd() + v.resizeBody() + return v, v.searchInput.Focus() + + case key.Matches(msg, key.NewBinding(key.WithKeys("up", "k"))): + if v.cursor > 0 { + v.cursor-- + } + + case key.Matches(msg, key.NewBinding(key.WithKeys("down", "j"))): + if v.cursor < len(v.visibleRuns())-1 { + v.cursor++ + } + + case key.Matches(msg, key.NewBinding(key.WithKeys("g"))): + v.cursor = 0 + + case key.Matches(msg, key.NewBinding(key.WithKeys("G"))): + if n := len(v.visibleRuns()); n > 0 { + v.cursor = n - 1 + } + + case key.Matches(msg, key.NewBinding(key.WithKeys("w"))): + v.showSidebar = !v.showSidebar + v.resizeBody() + return v, nil + + case key.Matches(msg, key.NewBinding(key.WithKeys("r", "R"))): + return v, v.refreshCmd() + + case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))): + run, ok := v.selectedRun() + if !ok { + return v, nil + } + agentName := v.engineLabel(run.RunID) + if agentName == "—" { + agentName = "" + } + return v, func() tea.Msg { + return OpenLiveChatMsg{ + RunID: run.RunID, + AgentName: agentName, + } + } + + case key.Matches(msg, key.NewBinding(key.WithKeys("h"))): + run, ok := v.selectedRun() + if !ok || run.Status.IsTerminal() || v.hijacking { + return v, nil + } + v.hijacking = true + v.hijackErr = nil + return v, tea.Batch(v.hijackRunCmd(run.RunID), v.maybeSpinnerCmd()) + } + + v.clampCursor() + if v.selectedRunID() != oldSelected { + return v, v.ensurePreviewCmd() + } + } + + return v, nil +} + +// View renders the sessions browser. +func (v *SessionsView) View() string { + var b strings.Builder + + header := lipgloss.NewStyle().Bold(true).Render("SMITHERS › Sessions") + helpHint := lipgloss.NewStyle().Faint(true).Render("[Esc] Back") + if v.width > 0 { + gap := v.width - lipgloss.Width(header) - lipgloss.Width(helpHint) - 2 + if gap > 0 { + b.WriteString(header + strings.Repeat(" ", gap) + helpHint) + } else { + b.WriteString(header + " " + helpHint) + } + } else { + b.WriteString(header) + } + b.WriteString("\n") + + if v.searchActive { + b.WriteString(lipgloss.NewStyle().Faint(true).Render("/") + " " + v.searchInput.View()) + } else { + info := fmt.Sprintf("%d sessions", len(v.visibleRuns())) + if q := strings.TrimSpace(v.searchInput.Value()); q != "" { + info += " filter: " + q + } + sidebar := "preview: off" + if v.showSidebar { + sidebar = "preview: on" + } + info = info + " " + sidebar + b.WriteString(lipgloss.NewStyle().Faint(true).Render(info)) + } + b.WriteString("\n") + b.WriteString(strings.Repeat("─", max(1, v.width))) + b.WriteString("\n") + + if v.hijacking { + b.WriteString(lipgloss.NewStyle().Bold(true).Render(v.spinner.View() + " Resuming session...")) + b.WriteString("\n") + } + if v.hijackErr != nil { + b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render( + fmt.Sprintf(" Resume error: %v", v.hijackErr), + )) + b.WriteString("\n") + } + + if v.loading { + b.WriteString(" " + v.spinner.View() + " Loading sessions...\n") + return b.String() + } + if v.err != nil { + b.WriteString(fmt.Sprintf(" Error: %v\n", v.err)) + return b.String() + } + if len(v.visibleRuns()) == 0 { + if q := strings.TrimSpace(v.searchInput.Value()); q != "" { + b.WriteString(fmt.Sprintf(" No sessions matching %q.\n", q)) + } else { + b.WriteString(" No sessions found.\n") + } + return b.String() + } + + v.resizeBody() + if v.showSidebar && v.splitPane != nil { + b.WriteString(v.splitPane.View()) + return b.String() + } + + b.WriteString(v.listPane.View()) + return b.String() +} + +// Name returns the router name. +func (v *SessionsView) Name() string { + return "sessions" +} + +// SetSize stores terminal dimensions. +func (v *SessionsView) SetSize(width, height int) { + v.width = width + v.height = height + v.searchInput.SetWidth(max(12, width-4)) + v.resizeBody() +} + +// ShortHelp returns help bindings for the contextual help bar. +func (v *SessionsView) ShortHelp() []key.Binding { + if v.searchActive { + return []key.Binding{ + key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "apply")), + key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "clear/close")), + } + } + + previewHelp := "preview" + if v.showSidebar { + previewHelp = "hide preview" + } + + return []key.Binding{ + key.NewBinding(key.WithKeys("j", "k"), key.WithHelp("j/k", "navigate")), + key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "open chat")), + key.NewBinding(key.WithKeys("h"), key.WithHelp("h", "resume")), + key.NewBinding(key.WithKeys("w"), key.WithHelp("w", previewHelp)), + key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "search")), + key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")), + key.NewBinding(key.WithKeys("q", "esc"), key.WithHelp("q/esc", "back")), + } +} + +type sessionsListPane struct { + view *SessionsView + width int + height int + scrollOffset int +} + +func (p *sessionsListPane) Init() tea.Cmd { return nil } + +func (p *sessionsListPane) Update(msg tea.Msg) (components.Pane, tea.Cmd) { + return p, nil +} + +func (p *sessionsListPane) SetSize(width, height int) { + p.width = width + p.height = height +} + +func (p *sessionsListPane) View() string { + v := p.view + runs := v.visibleRuns() + if len(runs) == 0 { + return lipgloss.NewStyle().Faint(true).Render("No sessions found") + } + + width := p.width + if width <= 0 { + width = max(40, v.width) + } + + statusW := 2 + engineW := 10 + startedW := 8 + durationW := 7 + messagesW := 4 + if width >= 72 { + engineW = 12 + startedW = 9 + durationW = 8 + messagesW = 5 + } + + gaps := 5 + nameW := width - statusW - engineW - startedW - durationW - messagesW - gaps + if nameW < 12 { + nameW = 12 + } + + headerStyle := lipgloss.NewStyle().Faint(true) + header := strings.Join([]string{ + padRight("", statusW), + padRight("Session", nameW), + padRight("Engine", engineW), + padRight("Started", startedW), + padRight("Duration", durationW), + padRight("Msgs", messagesW), + }, " ") + + rowsHeight := p.height - 1 + if rowsHeight < 1 { + rowsHeight = 1 + } + if p.scrollOffset > v.cursor { + p.scrollOffset = v.cursor + } + if v.cursor >= p.scrollOffset+rowsHeight { + p.scrollOffset = v.cursor - rowsHeight + 1 + } + if p.scrollOffset < 0 { + p.scrollOffset = 0 + } + + end := p.scrollOffset + rowsHeight + if end > len(runs) { + end = len(runs) + } + + var lines []string + lines = append(lines, headerStyle.Render(header)) + + selectedStyle := lipgloss.NewStyle().Bold(true).Background(lipgloss.Color("236")) + for i := p.scrollOffset; i < end; i++ { + run := runs[i] + line := strings.Join([]string{ + padRight(v.statusIcon(run.Status), statusW), + padRight(truncate(sessionDisplayName(run), nameW), nameW), + padRight(truncate(v.engineLabel(run.RunID), engineW), engineW), + padRight(truncate(sessionStartedLabel(run), startedW), startedW), + padRight(truncate(sessionDurationLabel(run), durationW), durationW), + padRight(v.messageCountLabel(run.RunID), messagesW), + }, " ") + if i == v.cursor { + line = selectedStyle.Render(line) + } + lines = append(lines, line) + } + + return strings.Join(lines, "\n") +} + +type sessionsPreviewPane struct { + view *SessionsView + width int + height int +} + +func (p *sessionsPreviewPane) Init() tea.Cmd { return nil } + +func (p *sessionsPreviewPane) Update(msg tea.Msg) (components.Pane, tea.Cmd) { + return p, nil +} + +func (p *sessionsPreviewPane) SetSize(width, height int) { + p.width = width + p.height = height +} + +func (p *sessionsPreviewPane) View() string { + v := p.view + run, ok := v.selectedRun() + if !ok { + return lipgloss.NewStyle().Faint(true).Render("Select a session") + } + + width := p.width + if width <= 0 { + width = max(30, v.width/2) + } + contentWidth := max(16, width-2) + + labelStyle := lipgloss.NewStyle().Faint(true) + titleStyle := lipgloss.NewStyle().Bold(true) + + lines := []string{ + titleStyle.Render("Preview"), + "", + labelStyle.Render("Run ID:") + " " + run.RunID, + labelStyle.Render("Workflow:") + " " + sessionDisplayName(run), + labelStyle.Render("Engine:") + " " + v.engineLabel(run.RunID), + labelStyle.Render("Status:") + " " + string(run.Status), + labelStyle.Render("Started:") + " " + previewTimestamp(run.StartedAtMs), + labelStyle.Render("Duration:") + " " + sessionDurationLabel(run), + labelStyle.Render("Messages:") + " " + v.messageCountLabel(run.RunID), + "", + titleStyle.Render("Recent Messages"), + } + + if v.previewLoads[run.RunID] { + lines = append(lines, v.spinner.View()+" Loading transcript...") + return clipLines(lines, p.height) + } + if err, ok := v.previewErrs[run.RunID]; ok { + lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render(err.Error())) + return clipLines(lines, p.height) + } + + cache, ok := v.previewCache[run.RunID] + if !ok || len(cache.blocks) == 0 { + lines = append(lines, lipgloss.NewStyle().Faint(true).Render("No transcript available.")) + return clipLines(lines, p.height) + } + + previewBlocks := cache.blocks + if len(previewBlocks) > 4 { + previewBlocks = previewBlocks[len(previewBlocks)-4:] + } + for _, block := range previewBlocks { + lines = append(lines, renderPreviewBlock(block, contentWidth)...) + } + + return clipLines(lines, p.height) +} + +func (v *SessionsView) statusIcon(status smithers.RunStatus) string { + switch status { + case smithers.RunStatusRunning, smithers.RunStatusWaitingApproval, smithers.RunStatusWaitingEvent: + return v.spinner.View() + case smithers.RunStatusFinished: + return lipgloss.NewStyle().Faint(true).Render("✓") + case smithers.RunStatusFailed: + return lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render("✗") + case smithers.RunStatusCancelled: + return lipgloss.NewStyle().Faint(true).Render("•") + default: + return lipgloss.NewStyle().Faint(true).Render("·") + } +} + +func (v *SessionsView) engineLabel(runID string) string { + if engine := v.engineCache[runID]; engine != "" { + return engine + } + return "—" +} + +func (v *SessionsView) messageCountLabel(runID string) string { + if cache, ok := v.previewCache[runID]; ok { + return fmt.Sprintf("%d", len(cache.blocks)) + } + if v.previewLoads[runID] { + return "..." + } + return "—" +} + +func filterSessionRuns(runs []smithers.RunSummary) []smithers.RunSummary { + out := make([]smithers.RunSummary, 0, len(runs)) + for _, run := range runs { + if len(run.Summary) > 0 || isInteractiveRun(run) { + out = append(out, run) + } + } + return out +} + +func isInteractiveRun(run smithers.RunSummary) bool { + return strings.TrimSpace(run.WorkflowName) == "" && strings.TrimSpace(run.WorkflowPath) == "" +} + +func isActiveSessionStatus(status smithers.RunStatus) bool { + switch status { + case smithers.RunStatusRunning, smithers.RunStatusWaitingApproval, smithers.RunStatusWaitingEvent: + return true + default: + return false + } +} + +func sessionDisplayName(run smithers.RunSummary) string { + if name := strings.TrimSpace(run.WorkflowName); name != "" { + return name + } + if workflowPath := strings.TrimSpace(run.WorkflowPath); workflowPath != "" { + base := path.Base(workflowPath) + ext := path.Ext(base) + return strings.TrimSuffix(base, ext) + } + return "Interactive" +} + +func sessionStartedLabel(run smithers.RunSummary) string { + if run.StartedAtMs == nil { + return "—" + } + return relativeTime(*run.StartedAtMs) +} + +func sessionDurationLabel(run smithers.RunSummary) string { + if run.StartedAtMs == nil { + return "—" + } + + endMs := time.Now().UnixMilli() + if run.FinishedAtMs != nil { + endMs = *run.FinishedAtMs + } + d := time.Duration(endMs-*run.StartedAtMs) * time.Millisecond + if d < 0 { + d = 0 + } + return formatWait(d) +} + +func previewTimestamp(ms *int64) string { + if ms == nil || *ms <= 0 { + return "—" + } + return time.UnixMilli(*ms).Format("2006-01-02 15:04") +} + +func buildSessionPreviewCache(blocks []smithers.ChatBlock) sessionPreviewCache { + if len(blocks) == 0 { + return sessionPreviewCache{} + } + + type groupStats struct { + count int + firstMs int64 + } + + groups := make(map[string][]smithers.ChatBlock) + stats := make(map[string]groupStats) + + for _, block := range blocks { + nodeID := block.NodeID + groups[nodeID] = append(groups[nodeID], block) + + s := stats[nodeID] + s.count++ + if s.firstMs == 0 || (block.TimestampMs > 0 && block.TimestampMs < s.firstMs) { + s.firstMs = block.TimestampMs + } + stats[nodeID] = s + } + + bestNodeID := "" + bestStats := groupStats{} + for nodeID, stat := range stats { + if stat.count > bestStats.count || + (stat.count == bestStats.count && (bestStats.firstMs == 0 || stat.firstMs < bestStats.firstMs)) { + bestNodeID = nodeID + bestStats = stat + } + } + + return sessionPreviewCache{ + mainNodeID: bestNodeID, + blocks: groups[bestNodeID], + } +} + +func renderPreviewBlock(block smithers.ChatBlock, width int) []string { + roleStyle := lipgloss.NewStyle().Bold(true) + faintStyle := lipgloss.NewStyle().Faint(true) + + role := previewRoleLabel(block.Role) + lines := []string{roleStyle.Render(role)} + + bodyWidth := max(8, width-2) + for _, rawLine := range strings.Split(block.Content, "\n") { + for _, wrapped := range wrapLineToWidth(rawLine, bodyWidth) { + lines = append(lines, " "+faintStyle.Render(truncateStr(wrapped, bodyWidth))) + } + } + lines = append(lines, "") + return lines +} + +func previewRoleLabel(role smithers.ChatRole) string { + switch role { + case smithers.ChatRoleSystem: + return "System" + case smithers.ChatRoleUser: + return "User" + case smithers.ChatRoleAssistant: + return "Assistant" + case smithers.ChatRoleTool: + return "Tool" + default: + return "Message" + } +} + +func clipLines(lines []string, height int) string { + if height <= 0 { + return "" + } + if len(lines) <= height { + return strings.Join(lines, "\n") + } + if height == 1 { + return "…" + } + clipped := append([]string{}, lines[:height-1]...) + clipped = append(clipped, "…") + return strings.Join(clipped, "\n") +} diff --git a/internal/ui/views/sessions_test.go b/internal/ui/views/sessions_test.go new file mode 100644 index 00000000..6dda2108 --- /dev/null +++ b/internal/ui/views/sessions_test.go @@ -0,0 +1,74 @@ +package views + +import ( + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/crush/internal/smithers" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestSessionsView() *SessionsView { + return NewSessionsView(smithers.NewClient()) +} + +func testSessionRun(id, workflow string, status smithers.RunStatus) smithers.RunSummary { + startedAtMs := time.Now().Add(-5 * time.Minute).UnixMilli() + return smithers.RunSummary{ + RunID: id, + WorkflowName: workflow, + Status: status, + StartedAtMs: &startedAtMs, + Summary: map[string]int{ + "finished": 1, + "running": 1, + }, + } +} + +func TestSessionsView_InitReturnsFetchCmd(t *testing.T) { + v := newTestSessionsView() + cmd := v.Init() + assert.NotNil(t, cmd) +} + +func TestSessionsView_JKNavigation(t *testing.T) { + v := newTestSessionsView() + updated, _ := v.Update(sessionsLoadedMsg{ + runs: []smithers.RunSummary{ + testSessionRun("run-1", "Interactive", smithers.RunStatusRunning), + testSessionRun("run-2", "Review", smithers.RunStatusFinished), + testSessionRun("run-3", "Plan", smithers.RunStatusFailed), + }, + }) + v = updated.(*SessionsView) + assert.Equal(t, 0, v.cursor) + + updated, _ = v.Update(tea.KeyPressMsg{Code: 'j'}) + v = updated.(*SessionsView) + assert.Equal(t, 1, v.cursor) + + updated, _ = v.Update(tea.KeyPressMsg{Code: 'k'}) + v = updated.(*SessionsView) + assert.Equal(t, 0, v.cursor) +} + +func TestSessionsView_EscEmitsPopViewMsg(t *testing.T) { + v := newTestSessionsView() + _, cmd := v.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + require.NotNil(t, cmd) + + msg := cmd() + _, ok := msg.(PopViewMsg) + assert.True(t, ok, "Esc should emit PopViewMsg") +} + +func TestSessionsView_ViewRendersLoadingState(t *testing.T) { + v := newTestSessionsView() + v.SetSize(100, 30) + + out := v.View() + assert.Contains(t, out, "Loading sessions") +} diff --git a/internal/ui/views/workflowruns.go b/internal/ui/views/workflowruns.go new file mode 100644 index 00000000..7c52a48a --- /dev/null +++ b/internal/ui/views/workflowruns.go @@ -0,0 +1,1485 @@ +package views + +import ( + "context" + "fmt" + "regexp" + "strings" + "time" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/spinner" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/smithers" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/components" + uistyles "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/x/ansi" +) + +// Compile-time interface checks. +var ( + _ View = (*WorkflowRunView)(nil) + _ Focusable = (*WorkflowRunView)(nil) +) + +type workflowRunsLoadedMsg struct { + runs []smithers.RunSummary +} + +type workflowRunsErrorMsg struct { + err error +} + +type workflowRunInspectionMsg struct { + runID string + inspection *smithers.RunInspection + err error +} + +type workflowRunLogsLoadedMsg struct { + key string + runID string + nodeID string + attempt int + blocks []smithers.ChatBlock + err error +} + +type workflowStreamReadyMsg struct { + ch <-chan interface{} +} + +type workflowStreamUnavailableMsg struct{} + +type workflowTickMsg struct{} + +type workflowRunEnrichedMsg struct { + run smithers.RunSummary +} + +type workflowPane int + +const ( + workflowPaneRuns workflowPane = iota + workflowPaneTasks + workflowPaneLogs +) + +type workflowLayoutMode int + +const ( + workflowLayoutNarrow workflowLayoutMode = iota + workflowLayoutMedium + workflowLayoutWide +) + +type workflowTaskLog struct { + key string + runID string + nodeID string + attempt int + blocks []smithers.ChatBlock + loading bool + loaded bool + err error +} + +var workflowErrorPattern = regexp.MustCompile(`(?i)\b(error|failed|panic|exception|traceback)\b`) + +// WorkflowRunView shows workflow runs, tasks, and task logs side by side. +type WorkflowRunView struct { + client *smithers.Client + sty uistyles.Styles + + width int + height int + + ctx context.Context + cancel context.CancelFunc + + runs []smithers.RunSummary + runCursor int + taskCursor int + + loading bool + err error + + focus workflowPane + zoomedPane *workflowPane + + inspections map[string]*smithers.RunInspection + inspectionErr map[string]error + inspecting map[string]bool + + logs map[string]workflowTaskLog + + logViewer *components.LogViewer + spinner spinner.Model + + allEventsCh <-chan interface{} + streamMode string + pollTicker *time.Ticker +} + +// NewWorkflowRunView creates a workflow run viewer. +func NewWorkflowRunView(client *smithers.Client) *WorkflowRunView { + sty := uistyles.DefaultStyles() + s := spinner.New(spinner.WithSpinner(spinner.MiniDot)) + s.Style = lipgloss.NewStyle().Foreground(sty.Green) + + v := &WorkflowRunView{ + client: client, + sty: sty, + loading: true, + focus: workflowPaneRuns, + inspections: make(map[string]*smithers.RunInspection), + inspectionErr: make(map[string]error), + inspecting: make(map[string]bool), + logs: make(map[string]workflowTaskLog), + logViewer: components.NewLogViewer(), + spinner: s, + } + v.syncLogViewer() + return v +} + +// Init implements View. +func (v *WorkflowRunView) Init() tea.Cmd { + v.ctx, v.cancel = context.WithCancel(context.Background()) + return tea.Batch( + v.loadRunsCmd(), + v.startStreamCmd(), + v.spinner.Tick, + ) +} + +// OnFocus implements Focusable. +func (v *WorkflowRunView) OnFocus() tea.Cmd { + return nil +} + +// OnBlur implements Focusable. +func (v *WorkflowRunView) OnBlur() tea.Cmd { + v.stopBackgroundWork() + return nil +} + +func (v *WorkflowRunView) loadRunsCmd() tea.Cmd { + ctx := v.viewContext() + client := v.client + return func() tea.Msg { + runs, err := client.ListRuns(ctx, smithers.RunFilter{Limit: 50}) + if ctx.Err() != nil { + return nil + } + if err != nil { + return workflowRunsErrorMsg{err: err} + } + return workflowRunsLoadedMsg{runs: runs} + } +} + +func (v *WorkflowRunView) inspectRunCmd(runID string) tea.Cmd { + ctx := v.viewContext() + client := v.client + return func() tea.Msg { + inspection, err := client.InspectRun(ctx, runID) + if ctx.Err() != nil { + return nil + } + return workflowRunInspectionMsg{ + runID: runID, + inspection: inspection, + err: err, + } + } +} + +func (v *WorkflowRunView) loadTaskLogsCmd(runID string, task smithers.RunTask) tea.Cmd { + ctx := v.viewContext() + client := v.client + key := v.logKey(runID, task) + nodeID := task.NodeID + attempt := taskAttempt(task) + + return func() tea.Msg { + blocks, err := client.GetChatOutput(ctx, runID) + if ctx.Err() != nil { + return nil + } + if err != nil { + return workflowRunLogsLoadedMsg{ + key: key, + runID: runID, + nodeID: nodeID, + attempt: attempt, + err: err, + } + } + return workflowRunLogsLoadedMsg{ + key: key, + runID: runID, + nodeID: nodeID, + attempt: attempt, + blocks: filterTaskBlocks(blocks, nodeID, attempt), + } + } +} + +func (v *WorkflowRunView) enrichRunCmd(runID string) tea.Cmd { + ctx := v.viewContext() + client := v.client + return func() tea.Msg { + run, err := client.GetRunSummary(ctx, runID) + if err != nil || run == nil || ctx.Err() != nil { + return nil + } + return workflowRunEnrichedMsg{run: *run} + } +} + +func (v *WorkflowRunView) startStreamCmd() tea.Cmd { + ctx := v.viewContext() + client := v.client + return func() tea.Msg { + ch, err := client.StreamAllEvents(ctx) + if err != nil { + return workflowStreamUnavailableMsg{} + } + return workflowStreamReadyMsg{ch: ch} + } +} + +func (v *WorkflowRunView) pollTickCmd() tea.Cmd { + if v.pollTicker == nil { + return nil + } + ch := v.pollTicker.C + return func() tea.Msg { + <-ch + return workflowTickMsg{} + } +} + +// Update implements View. +func (v *WorkflowRunView) Update(msg tea.Msg) (View, tea.Cmd) { + switch msg := msg.(type) { + case workflowRunsLoadedMsg: + selectedRunID := v.currentRunID() + v.loading = false + v.err = nil + v.runs = msg.runs + v.restoreRunSelection(selectedRunID) + v.clampCursors() + v.syncLogViewer() + return v, v.ensureSelectedInspection(false) + + case workflowRunsErrorMsg: + v.loading = false + v.err = msg.err + v.syncLogViewer() + return v, nil + + case workflowRunInspectionMsg: + v.inspecting[msg.runID] = false + v.inspections[msg.runID] = msg.inspection + if msg.err != nil { + v.inspectionErr[msg.runID] = msg.err + } else { + delete(v.inspectionErr, msg.runID) + } + v.clampCursors() + v.syncLogViewer() + return v, nil + + case workflowRunLogsLoadedMsg: + v.logs[msg.key] = workflowTaskLog{ + key: msg.key, + runID: msg.runID, + nodeID: msg.nodeID, + attempt: msg.attempt, + blocks: append([]smithers.ChatBlock(nil), msg.blocks...), + loaded: msg.err == nil, + loading: false, + err: msg.err, + } + v.syncLogViewer() + return v, nil + + case workflowRunEnrichedMsg: + for i := range v.runs { + if v.runs[i].RunID == msg.run.RunID { + v.runs[i] = msg.run + break + } + } + v.syncLogViewer() + return v, nil + + case workflowStreamReadyMsg: + v.allEventsCh = msg.ch + v.streamMode = "live" + return v, smithers.WaitForAllEvents(v.allEventsCh) + + case workflowStreamUnavailableMsg: + v.streamMode = "polling" + if v.pollTicker != nil { + v.pollTicker.Stop() + } + v.pollTicker = time.NewTicker(5 * time.Second) + return v, v.pollTickCmd() + + case workflowTickMsg: + if v.ctx != nil && v.ctx.Err() != nil { + return v, nil + } + return v, tea.Batch(v.loadRunsCmd(), v.pollTickCmd()) + + case smithers.RunEventMsg: + newRunID := v.applyRunEvent(msg.Event) + v.syncLogViewer() + cmds := []tea.Cmd{smithers.WaitForAllEvents(v.allEventsCh)} + if newRunID != "" { + cmds = append(cmds, v.enrichRunCmd(newRunID)) + } + return v, tea.Batch(cmds...) + + case smithers.RunEventErrorMsg: + return v, smithers.WaitForAllEvents(v.allEventsCh) + + case smithers.RunEventDoneMsg: + if v.ctx != nil && v.ctx.Err() == nil { + return v, v.startStreamCmd() + } + return v, nil + + case spinner.TickMsg: + if !v.shouldAnimate() { + return v, nil + } + var cmd tea.Cmd + v.spinner, cmd = v.spinner.Update(msg) + return v, cmd + + case tea.WindowSizeMsg: + v.SetSize(msg.Width, msg.Height) + return v, nil + + case tea.KeyPressMsg: + if v.focus == workflowPaneLogs && v.logViewer.SearchActive() && + key.Matches(msg, key.NewBinding(key.WithKeys("esc"))) { + updated, cmd := v.logViewer.Update(msg) + v.logViewer = updated.(*components.LogViewer) + return v, cmd + } + + switch { + case key.Matches(msg, key.NewBinding(key.WithKeys("q", "esc"))): + v.stopBackgroundWork() + return v, func() tea.Msg { return PopViewMsg{} } + + case key.Matches(msg, key.NewBinding(key.WithKeys("tab"))): + v.focus = v.nextPane() + v.syncLogViewer() + return v, nil + + case key.Matches(msg, key.NewBinding(key.WithKeys("shift+tab"))): + v.focus = v.prevPane() + v.syncLogViewer() + return v, nil + + case key.Matches(msg, key.NewBinding(key.WithKeys("l"))): + v.focus = v.nextPane() + v.syncLogViewer() + return v, nil + + case key.Matches(msg, key.NewBinding(key.WithKeys("h"))): + v.focus = v.prevPane() + v.syncLogViewer() + return v, nil + + case key.Matches(msg, key.NewBinding(key.WithKeys("z"))): + v.toggleZoom() + return v, nil + + case key.Matches(msg, key.NewBinding(key.WithKeys("r", "R"))): + v.loading = true + cmds := []tea.Cmd{v.loadRunsCmd()} + if cmd := v.ensureSelectedInspection(true); cmd != nil { + cmds = append(cmds, cmd) + } + if cmd := v.ensureSelectedLogs(true); cmd != nil { + cmds = append(cmds, cmd) + } + return v, tea.Batch(cmds...) + + case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))): + switch v.focus { + case workflowPaneRuns: + v.focus = workflowPaneTasks + return v, v.ensureSelectedInspection(true) + case workflowPaneTasks: + v.focus = workflowPaneLogs + return v, v.ensureSelectedLogs(true) + default: + return v, nil + } + } + + if v.focus == workflowPaneLogs { + updated, cmd := v.logViewer.Update(msg) + v.logViewer = updated.(*components.LogViewer) + return v, cmd + } + + switch { + case key.Matches(msg, key.NewBinding(key.WithKeys("up", "k"))): + return v.handleListMove(-1) + + case key.Matches(msg, key.NewBinding(key.WithKeys("down", "j"))): + return v.handleListMove(1) + + case key.Matches(msg, key.NewBinding(key.WithKeys("g", "home"))): + return v.handleListSet(0) + + case key.Matches(msg, key.NewBinding(key.WithKeys("G", "end"))): + switch v.focus { + case workflowPaneRuns: + return v.handleListSet(len(v.runs) - 1) + case workflowPaneTasks: + tasks := v.currentTasks() + return v.handleListSet(len(tasks) - 1) + } + } + } + + return v, nil +} + +func (v *WorkflowRunView) handleListMove(delta int) (View, tea.Cmd) { + switch v.focus { + case workflowPaneRuns: + if len(v.runs) == 0 { + return v, nil + } + next := clampIndex(v.runCursor+delta, len(v.runs)) + if next == v.runCursor { + return v, nil + } + v.runCursor = next + v.taskCursor = 0 + v.syncLogViewer() + return v, v.ensureSelectedInspection(false) + + case workflowPaneTasks: + tasks := v.currentTasks() + if len(tasks) == 0 { + return v, nil + } + next := clampIndex(v.taskCursor+delta, len(tasks)) + if next == v.taskCursor { + return v, nil + } + v.taskCursor = next + v.syncLogViewer() + return v, nil + } + + return v, nil +} + +func (v *WorkflowRunView) handleListSet(index int) (View, tea.Cmd) { + switch v.focus { + case workflowPaneRuns: + if len(v.runs) == 0 { + return v, nil + } + v.runCursor = clampIndex(index, len(v.runs)) + v.taskCursor = 0 + v.syncLogViewer() + return v, v.ensureSelectedInspection(false) + + case workflowPaneTasks: + tasks := v.currentTasks() + if len(tasks) == 0 { + return v, nil + } + v.taskCursor = clampIndex(index, len(tasks)) + v.syncLogViewer() + } + + return v, nil +} + +// View implements View. +func (v *WorkflowRunView) View() string { + if v.width <= 0 { + return "" + } + + header := v.renderHeader() + mainHeight := max(0, v.height-1) + if mainHeight <= 0 { + return header + } + + mode := v.layoutMode() + if v.zoomedPane != nil { + content := v.renderPane(*v.zoomedPane, v.width, mainHeight) + return lipgloss.JoinVertical(lipgloss.Left, header, content) + } + + switch mode { + case workflowLayoutWide: + leftW := max(30, v.width/4) + midW := max(34, v.width/4) + if leftW+midW > v.width-24 { + midW = max(28, (v.width-leftW)/2) + } + rightW := max(24, v.width-leftW-midW) + left := v.renderPane(workflowPaneRuns, leftW, mainHeight) + mid := v.renderPane(workflowPaneTasks, midW, mainHeight) + right := v.renderPane(workflowPaneLogs, rightW, mainHeight) + return lipgloss.JoinVertical( + lipgloss.Left, + header, + lipgloss.JoinHorizontal(lipgloss.Top, left, mid, right), + ) + + case workflowLayoutMedium: + leftW := max(28, v.width/3) + rightW := max(24, v.width-leftW) + detailPane := workflowPaneTasks + if v.focus == workflowPaneLogs { + detailPane = workflowPaneLogs + } + left := v.renderPane(workflowPaneRuns, leftW, mainHeight) + right := v.renderPane(detailPane, rightW, mainHeight) + return lipgloss.JoinVertical( + lipgloss.Left, + header, + lipgloss.JoinHorizontal(lipgloss.Top, left, right), + ) + + default: + return lipgloss.JoinVertical(lipgloss.Left, header, v.renderPane(v.focus, v.width, mainHeight)) + } +} + +// Name implements View. +func (v *WorkflowRunView) Name() string { + return "workflow-runs" +} + +// SetSize implements View. +func (v *WorkflowRunView) SetSize(width, height int) { + v.width = max(0, width) + v.height = max(0, height) + v.syncLogViewer() +} + +// ShortHelp implements View. +func (v *WorkflowRunView) ShortHelp() []key.Binding { + return []key.Binding{ + key.NewBinding(key.WithKeys("j", "k", "↑", "↓"), key.WithHelp("jk/↑↓", "navigate")), + key.NewBinding(key.WithKeys("h", "l", "tab"), key.WithHelp("hl/tab", "switch pane")), + key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "drill in")), + key.NewBinding(key.WithKeys("z"), key.WithHelp("z", "zoom pane")), + key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "search logs")), + key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")), + key.NewBinding(key.WithKeys("q", "esc"), key.WithHelp("q/esc", "back")), + } +} + +func (v *WorkflowRunView) viewContext() context.Context { + if v.ctx != nil { + return v.ctx + } + return context.Background() +} + +func (v *WorkflowRunView) stopBackgroundWork() { + if v.cancel != nil { + v.cancel() + } + if v.pollTicker != nil { + v.pollTicker.Stop() + v.pollTicker = nil + } +} + +func (v *WorkflowRunView) layoutMode() workflowLayoutMode { + switch { + case v.width > 150: + return workflowLayoutWide + case v.width >= 100: + return workflowLayoutMedium + default: + return workflowLayoutNarrow + } +} + +func (v *WorkflowRunView) renderHeader() string { + title := lipgloss.NewStyle().Bold(true).Render("SMITHERS › Workflow Runs") + + mode := "" + switch v.streamMode { + case "live": + mode = lipgloss.NewStyle().Foreground(v.sty.Green).Render("Live") + case "polling": + mode = lipgloss.NewStyle().Foreground(v.sty.FgMuted).Render("Polling") + } + + focus := lipgloss.NewStyle(). + Foreground(v.sty.FgMuted). + Render("Focus: " + v.focus.String()) + + metaParts := make([]string, 0, 3) + if mode != "" { + metaParts = append(metaParts, mode) + } + metaParts = append(metaParts, focus) + if v.zoomedPane != nil { + metaParts = append(metaParts, lipgloss.NewStyle().Foreground(v.sty.BlueLight).Render("Zoom")) + } + meta := strings.Join(metaParts, " ") + if meta == "" { + return lipgloss.NewStyle().Width(v.width).Render(title) + } + + gap := max(1, v.width-lipgloss.Width(title)-lipgloss.Width(meta)) + return lipgloss.NewStyle(). + Width(v.width). + Render(title + strings.Repeat(" ", gap) + meta) +} + +func (v *WorkflowRunView) renderPane(p workflowPane, width, height int) string { + if width <= 0 || height <= 0 { + return "" + } + + focused := v.focus == p + contentWidth := max(0, width-2) + contentHeight := max(0, height-2) + + var content string + switch p { + case workflowPaneRuns: + content = v.renderRunsPane(contentWidth, contentHeight, focused) + case workflowPaneTasks: + content = v.renderTasksPane(contentWidth, contentHeight, focused) + default: + v.logViewer.SetSize(contentWidth, contentHeight) + content = v.logViewer.View() + } + + return v.wrapPane(content, width, height, focused) +} + +func (v *WorkflowRunView) wrapPane(content string, width, height int, focused bool) string { + borderColor := v.sty.Border + if focused { + borderColor = v.sty.BorderColor + } + style := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(borderColor). + Width(max(0, width-2)). + Height(max(0, height-2)) + + return style.Render(content) +} + +func (v *WorkflowRunView) renderRunsPane(width, height int, focused bool) string { + title := v.renderPaneTitle("Runs", fmt.Sprintf("%d", len(v.runs)), width, focused) + bodyHeight := max(0, height-1) + + switch { + case v.loading: + return lipgloss.JoinVertical(lipgloss.Left, title, v.renderMessageBody(width, bodyHeight, v.spinner.View()+" Loading runs...", false)) + case v.err != nil: + return lipgloss.JoinVertical(lipgloss.Left, title, v.renderMessageBody(width, bodyHeight, v.err.Error(), true)) + case len(v.runs) == 0: + return lipgloss.JoinVertical(lipgloss.Left, title, v.renderMessageBody(width, bodyHeight, "No runs found.", false)) + } + + start, end := windowForCursor(v.runCursor, len(v.runs), bodyHeight) + rows := make([]string, 0, end-start) + for i := start; i < end; i++ { + rows = append(rows, v.renderRunRow(v.runs[i], i == v.runCursor, width, focused)) + } + + body := lipgloss.NewStyle(). + Width(width). + Height(bodyHeight). + Render(strings.Join(rows, "\n")) + return lipgloss.JoinVertical(lipgloss.Left, title, body) +} + +func (v *WorkflowRunView) renderTasksPane(width, height int, focused bool) string { + run := v.selectedRun() + meta := "" + if run != nil { + meta = truncateText(run.WorkflowName, 18) + } + title := v.renderPaneTitle("Tasks", meta, width, focused) + bodyHeight := max(0, height-1) + + if run == nil { + return lipgloss.JoinVertical(lipgloss.Left, title, v.renderMessageBody(width, bodyHeight, "Select a run.", false)) + } + if v.inspecting[run.RunID] && v.inspections[run.RunID] == nil { + return lipgloss.JoinVertical(lipgloss.Left, title, v.renderMessageBody(width, bodyHeight, v.spinner.View()+" Loading tasks...", false)) + } + if err := v.inspectionErr[run.RunID]; err != nil && v.inspections[run.RunID] == nil { + return lipgloss.JoinVertical(lipgloss.Left, title, v.renderMessageBody(width, bodyHeight, err.Error(), true)) + } + + tasks := v.currentTasks() + if len(tasks) == 0 { + return lipgloss.JoinVertical(lipgloss.Left, title, v.renderMessageBody(width, bodyHeight, "No tasks for this run.", false)) + } + + start, end := windowForCursor(v.taskCursor, len(tasks), bodyHeight) + rows := make([]string, 0, end-start) + for i := start; i < end; i++ { + rows = append(rows, v.renderTaskRow(tasks[i], i == v.taskCursor, width, focused)) + } + + body := lipgloss.NewStyle(). + Width(width). + Height(bodyHeight). + Render(strings.Join(rows, "\n")) + return lipgloss.JoinVertical(lipgloss.Left, title, body) +} + +func (v *WorkflowRunView) renderPaneTitle(label, meta string, width int, focused bool) string { + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(v.sty.BlueLight) + if focused { + titleStyle = titleStyle.Foreground(v.sty.White) + } + title := titleStyle.Render(label) + if meta == "" { + return lipgloss.NewStyle().Width(width).Render(title) + } + + right := lipgloss.NewStyle().Foreground(v.sty.FgMuted).Render(meta) + gap := max(1, width-lipgloss.Width(title)-lipgloss.Width(right)) + return lipgloss.NewStyle().Width(width).Render(title + strings.Repeat(" ", gap) + right) +} + +func (v *WorkflowRunView) renderMessageBody(width, height int, msg string, isErr bool) string { + style := lipgloss.NewStyle().Foreground(v.sty.FgMuted) + if isErr { + style = lipgloss.NewStyle().Foreground(v.sty.Red) + } + return style.Width(width).Height(height).Render(msg) +} + +func (v *WorkflowRunView) renderRunRow(run smithers.RunSummary, selected bool, width int, focused bool) string { + rightParts := make([]string, 0, 2) + if progress := runProgress(run); progress != "" { + rightParts = append(rightParts, progress) + } + if elapsed := runElapsed(run); elapsed != "" { + rightParts = append(rightParts, elapsed) + } + right := strings.Join(rightParts, " ") + + left := fmt.Sprintf("%s %s", v.runStatusIcon(run.Status), truncateText(run.WorkflowName, max(4, width-lipgloss.Width(right)-6))) + row := left + if right != "" { + gap := max(1, width-lipgloss.Width(left)-lipgloss.Width(right)) + row += strings.Repeat(" ", gap) + lipgloss.NewStyle().Foreground(v.sty.FgMuted).Render(right) + } + return v.renderSelectableRow(row, width, selected, focused) +} + +func (v *WorkflowRunView) renderTaskRow(task smithers.RunTask, selected bool, width int, focused bool) string { + right := "" + if attempt := taskAttemptLabel(task); attempt != "" { + right = lipgloss.NewStyle().Foreground(v.sty.FgMuted).Render(attempt) + } + label := fmt.Sprintf("%s %s", v.taskStatusIcon(task.State), truncateText(taskLabel(task), max(4, width-lipgloss.Width(right)-6))) + row := label + if right != "" { + gap := max(1, width-lipgloss.Width(label)-lipgloss.Width(right)) + row += strings.Repeat(" ", gap) + right + } + return v.renderSelectableRow(row, width, selected, focused) +} + +func (v *WorkflowRunView) renderSelectableRow(row string, width int, selected, focused bool) string { + style := lipgloss.NewStyle().Width(width).Padding(0, 1) + switch { + case selected && focused: + style = style.Background(v.sty.BgOverlay).Foreground(v.sty.White) + case selected: + style = style.Background(v.sty.BgSubtle) + default: + style = style.Foreground(v.sty.FgBase) + } + return style.Render(ansi.Truncate(row, max(0, width-2), "…")) +} + +func (v *WorkflowRunView) runStatusIcon(status smithers.RunStatus) string { + switch status { + case smithers.RunStatusRunning: + return v.spinner.View() + case smithers.RunStatusFinished: + return lipgloss.NewStyle().Foreground(v.sty.Green).Render(uistyles.CheckIcon) + case smithers.RunStatusFailed: + return lipgloss.NewStyle().Foreground(v.sty.Red).Render(uistyles.ToolError) + case smithers.RunStatusCancelled: + return lipgloss.NewStyle().Foreground(v.sty.Yellow).Render("○") + case smithers.RunStatusWaitingApproval: + return lipgloss.NewStyle().Foreground(v.sty.Yellow).Render("⌛") + case smithers.RunStatusWaitingEvent: + return lipgloss.NewStyle().Foreground(v.sty.Yellow).Render("⧖") + default: + return lipgloss.NewStyle().Foreground(v.sty.FgMuted).Render(uistyles.ToolPending) + } +} + +func (v *WorkflowRunView) taskStatusIcon(state smithers.TaskState) string { + switch state { + case smithers.TaskStateRunning: + return v.spinner.View() + case smithers.TaskStateFinished: + return lipgloss.NewStyle().Foreground(v.sty.Green).Render(uistyles.ToolSuccess) + case smithers.TaskStateFailed: + return lipgloss.NewStyle().Foreground(v.sty.Red).Render(uistyles.ToolError) + case smithers.TaskStateCancelled: + return lipgloss.NewStyle().Foreground(v.sty.Yellow).Render("○") + case smithers.TaskStateSkipped: + return lipgloss.NewStyle().Foreground(v.sty.FgMuted).Render("⊘") + case smithers.TaskStateBlocked: + return lipgloss.NewStyle().Foreground(v.sty.FgMuted).Render("⊗") + default: + return lipgloss.NewStyle().Foreground(v.sty.FgMuted).Render("○") + } +} + +func (v *WorkflowRunView) ensureSelectedInspection(force bool) tea.Cmd { + run := v.selectedRun() + if run == nil { + return nil + } + if !force { + if v.inspecting[run.RunID] { + return nil + } + if _, ok := v.inspections[run.RunID]; ok { + return nil + } + } + v.inspecting[run.RunID] = true + delete(v.inspectionErr, run.RunID) + return v.inspectRunCmd(run.RunID) +} + +func (v *WorkflowRunView) ensureSelectedLogs(force bool) tea.Cmd { + run := v.selectedRun() + task := v.selectedTask() + if run == nil || task == nil { + return nil + } + + key := v.logKey(run.RunID, *task) + cache, ok := v.logs[key] + if ok && cache.loading { + return nil + } + if ok && cache.loaded && !force { + return nil + } + + v.logs[key] = workflowTaskLog{ + key: key, + runID: run.RunID, + nodeID: task.NodeID, + attempt: taskAttempt(*task), + loading: true, + } + v.syncLogViewer() + return v.loadTaskLogsCmd(run.RunID, *task) +} + +func (v *WorkflowRunView) syncLogViewer() { + run := v.selectedRun() + if run == nil { + v.logViewer.SetTitle("Logs") + v.logViewer.SetPlaceholder("Select a run to inspect logs.") + return + } + + if v.inspecting[run.RunID] && v.inspections[run.RunID] == nil { + v.logViewer.SetTitle("Logs") + v.logViewer.SetPlaceholder("Loading tasks for " + run.WorkflowName + "...") + return + } + + if err := v.inspectionErr[run.RunID]; err != nil && v.inspections[run.RunID] == nil { + v.logViewer.SetTitle("Logs") + v.logViewer.SetPlaceholder("Failed to load tasks: " + err.Error()) + return + } + + task := v.selectedTask() + if task == nil { + v.logViewer.SetTitle("Logs") + v.logViewer.SetPlaceholder("Select a task and press Enter to load logs.") + return + } + + v.logViewer.SetTitle(taskLabel(*task)) + cacheKey := v.logKey(run.RunID, *task) + cache, ok := v.logs[cacheKey] + if !ok { + v.logViewer.SetPlaceholder("Press Enter to load logs.") + return + } + if cache.loading { + v.logViewer.SetPlaceholder("Loading logs...") + return + } + if cache.err != nil { + v.logViewer.SetPlaceholder("Failed to load logs: " + cache.err.Error()) + return + } + + lines := v.buildLogLines(*task, cache.blocks) + if len(lines) == 0 { + v.logViewer.SetPlaceholder("No logs available for this task.") + return + } + v.logViewer.SetLines(lines) +} + +func (v *WorkflowRunView) buildLogLines(task smithers.RunTask, blocks []smithers.ChatBlock) []components.LogLine { + if len(blocks) == 0 { + return nil + } + + width := max(24, v.width/2) + lines := make([]components.LogLine, 0, len(blocks)*4) + + for i, block := range blocks { + header := strings.ToUpper(string(block.Role)) + if header == "" { + header = "EVENT" + } + headerLine := header + if block.Attempt >= 0 { + headerLine += fmt.Sprintf(" · attempt %d", block.Attempt+1) + } + lines = append(lines, components.LogLine{Text: headerLine}) + + rendered := renderChatBlock(v.sty, block, width) + for _, line := range strings.Split(strings.TrimRight(rendered, "\n"), "\n") { + if line == "" { + lines = append(lines, components.LogLine{Text: ""}) + continue + } + lines = append(lines, components.LogLine{ + Text: " " + line, + Error: task.State == smithers.TaskStateFailed && shouldHighlightError(block, line), + }) + } + + if i < len(blocks)-1 { + lines = append(lines, components.LogLine{Text: ""}) + } + } + + return lines +} + +func (v *WorkflowRunView) shouldAnimate() bool { + if v.loading { + return true + } + for _, r := range v.runs { + if r.Status == smithers.RunStatusRunning { + return true + } + } + for _, loading := range v.inspecting { + if loading { + return true + } + } + for _, logState := range v.logs { + if logState.loading { + return true + } + } + if inspection := v.currentInspection(); inspection != nil { + for _, task := range inspection.Tasks { + if task.State == smithers.TaskStateRunning { + return true + } + } + } + return false +} + +func (v *WorkflowRunView) applyRunEvent(ev smithers.RunEvent) string { + eventType := normalizeEventType(ev.Type) + run := v.findRun(ev.RunID) + if run == nil && ev.RunID != "" { + selectedRunID := v.currentRunID() + stub := smithers.RunSummary{ + RunID: ev.RunID, + Status: runStatusFromEvent(eventType, ev.Status), + StartedAtMs: timestampPtr(ev.TimestampMs), + FinishedAtMs: nil, + } + if stub.Status == "" { + stub.Status = smithers.RunStatusRunning + } + v.runs = append([]smithers.RunSummary{stub}, v.runs...) + if selectedRunID != "" { + v.restoreRunSelection(selectedRunID) + } + return ev.RunID + } + if run == nil { + return "" + } + + switch eventType { + case "runstarted": + run.Status = smithers.RunStatusRunning + if run.StartedAtMs == nil && ev.TimestampMs > 0 { + run.StartedAtMs = timestampPtr(ev.TimestampMs) + } + + case "runstatuschanged": + if status := runStatusFromString(ev.Status); status != "" { + run.Status = status + if status.IsTerminal() && ev.TimestampMs > 0 { + run.FinishedAtMs = timestampPtr(ev.TimestampMs) + } + } + + case "runfinished": + run.Status = smithers.RunStatusFinished + if ev.TimestampMs > 0 { + run.FinishedAtMs = timestampPtr(ev.TimestampMs) + } + + case "runfailed": + run.Status = smithers.RunStatusFailed + if ev.TimestampMs > 0 { + run.FinishedAtMs = timestampPtr(ev.TimestampMs) + } + + case "runcancelled": + run.Status = smithers.RunStatusCancelled + if ev.TimestampMs > 0 { + run.FinishedAtMs = timestampPtr(ev.TimestampMs) + } + + case "nodewaitingapproval": + run.Status = smithers.RunStatusWaitingApproval + v.applyTaskState(ev, smithers.TaskStateBlocked) + + case "nodestatechanged": + v.applyTaskState(ev, taskStateFromString(ev.Status)) + + case "nodestarted": + v.applyTaskState(ev, smithers.TaskStateRunning) + + case "nodefinished": + v.applyTaskState(ev, smithers.TaskStateFinished) + + case "nodefailed": + v.applyTaskState(ev, smithers.TaskStateFailed) + + case "nodecancelled": + v.applyTaskState(ev, smithers.TaskStateCancelled) + + case "nodeskipped": + v.applyTaskState(ev, smithers.TaskStateSkipped) + + case "nodeblocked": + v.applyTaskState(ev, smithers.TaskStateBlocked) + } + + v.clampCursors() + return "" +} + +func (v *WorkflowRunView) applyTaskState(ev smithers.RunEvent, state smithers.TaskState) { + if state == "" { + return + } + insp := v.inspections[ev.RunID] + if insp == nil { + return + } + + for i := range insp.Tasks { + if insp.Tasks[i].NodeID != ev.NodeID { + continue + } + if ev.Iteration != 0 && insp.Tasks[i].Iteration != ev.Iteration { + continue + } + insp.Tasks[i].State = state + if ev.TimestampMs > 0 { + insp.Tasks[i].UpdatedAtMs = timestampPtr(ev.TimestampMs) + } + attempt := ev.Attempt + insp.Tasks[i].LastAttempt = &attempt + return + } + + task := smithers.RunTask{ + NodeID: ev.NodeID, + Iteration: ev.Iteration, + State: state, + } + if ev.Attempt >= 0 { + attempt := ev.Attempt + task.LastAttempt = &attempt + } + if ev.TimestampMs > 0 { + task.UpdatedAtMs = timestampPtr(ev.TimestampMs) + } + insp.Tasks = append(insp.Tasks, task) +} + +func (v *WorkflowRunView) selectedRun() *smithers.RunSummary { + if len(v.runs) == 0 { + return nil + } + v.runCursor = clampIndex(v.runCursor, len(v.runs)) + return &v.runs[v.runCursor] +} + +func (v *WorkflowRunView) currentTasks() []smithers.RunTask { + insp := v.currentInspection() + if insp == nil { + return nil + } + return insp.Tasks +} + +func (v *WorkflowRunView) currentInspection() *smithers.RunInspection { + run := v.selectedRun() + if run == nil { + return nil + } + return v.inspections[run.RunID] +} + +func (v *WorkflowRunView) selectedTask() *smithers.RunTask { + insp := v.currentInspection() + if insp == nil || len(insp.Tasks) == 0 { + return nil + } + v.taskCursor = clampIndex(v.taskCursor, len(insp.Tasks)) + return &insp.Tasks[v.taskCursor] +} + +func (v *WorkflowRunView) currentRunID() string { + run := v.selectedRun() + if run == nil { + return "" + } + return run.RunID +} + +func (v *WorkflowRunView) restoreRunSelection(runID string) { + if runID == "" { + v.runCursor = clampIndex(v.runCursor, len(v.runs)) + return + } + for i := range v.runs { + if v.runs[i].RunID == runID { + v.runCursor = i + return + } + } + v.runCursor = clampIndex(v.runCursor, len(v.runs)) +} + +func (v *WorkflowRunView) clampCursors() { + v.runCursor = clampIndex(v.runCursor, len(v.runs)) + tasks := v.currentTasks() + v.taskCursor = clampIndex(v.taskCursor, len(tasks)) +} + +func (v *WorkflowRunView) findRun(runID string) *smithers.RunSummary { + for i := range v.runs { + if v.runs[i].RunID == runID { + return &v.runs[i] + } + } + return nil +} + +func (v *WorkflowRunView) logKey(runID string, task smithers.RunTask) string { + return fmt.Sprintf("%s:%s:%d", runID, task.NodeID, taskAttempt(task)) +} + +func (v *WorkflowRunView) nextPane() workflowPane { + switch v.focus { + case workflowPaneRuns: + return workflowPaneTasks + case workflowPaneTasks: + return workflowPaneLogs + default: + return workflowPaneRuns + } +} + +func (v *WorkflowRunView) prevPane() workflowPane { + switch v.focus { + case workflowPaneRuns: + return workflowPaneLogs + case workflowPaneTasks: + return workflowPaneRuns + default: + return workflowPaneTasks + } +} + +func (v *WorkflowRunView) toggleZoom() { + if v.zoomedPane != nil && *v.zoomedPane == v.focus { + v.zoomedPane = nil + return + } + pane := v.focus + v.zoomedPane = &pane +} + +func renderChatBlock(sty uistyles.Styles, block smithers.ChatBlock, width int) string { + content := ansi.Strip(block.Content) + if block.Role != smithers.ChatRoleAssistant { + return content + } + + rendered, err := common.MarkdownRenderer(&sty, width).Render(block.Content) + if err != nil { + return content + } + return ansi.Strip(rendered) +} + +func shouldHighlightError(block smithers.ChatBlock, line string) bool { + if block.Role == smithers.ChatRoleUser || block.Role == smithers.ChatRoleSystem { + return false + } + return workflowErrorPattern.MatchString(ansi.Strip(line)) +} + +func filterTaskBlocks(blocks []smithers.ChatBlock, nodeID string, attempt int) []smithers.ChatBlock { + if len(blocks) == 0 { + return nil + } + + filtered := make([]smithers.ChatBlock, 0, len(blocks)) + for _, block := range blocks { + if block.NodeID != nodeID { + continue + } + if attempt >= 0 && block.Attempt != attempt { + continue + } + filtered = append(filtered, block) + } + if len(filtered) > 0 || attempt < 0 { + return filtered + } + + for _, block := range blocks { + if block.NodeID == nodeID { + filtered = append(filtered, block) + } + } + return filtered +} + +func taskLabel(task smithers.RunTask) string { + if task.Label != nil && *task.Label != "" { + return *task.Label + } + return task.NodeID +} + +func taskAttempt(task smithers.RunTask) int { + if task.LastAttempt == nil { + return -1 + } + return *task.LastAttempt +} + +func taskAttemptLabel(task smithers.RunTask) string { + if task.LastAttempt == nil { + return "" + } + return fmt.Sprintf("#%d", *task.LastAttempt+1) +} + +func runElapsed(run smithers.RunSummary) string { + if run.StartedAtMs == nil { + return "" + } + start := time.UnixMilli(*run.StartedAtMs) + end := time.Now() + if run.FinishedAtMs != nil { + end = time.UnixMilli(*run.FinishedAtMs) + } + elapsed := end.Sub(start).Round(time.Second) + hours := int(elapsed.Hours()) + minutes := int(elapsed.Minutes()) % 60 + seconds := int(elapsed.Seconds()) % 60 + + switch { + case hours > 0: + return fmt.Sprintf("%dh %dm", hours, minutes) + case minutes > 0: + return fmt.Sprintf("%dm %ds", minutes, seconds) + default: + return fmt.Sprintf("%ds", seconds) + } +} + +func runProgress(run smithers.RunSummary) string { + total := run.Summary["total"] + if total <= 0 { + return "" + } + done := run.Summary["finished"] + run.Summary["failed"] + run.Summary["cancelled"] + if done > total { + done = total + } + return fmt.Sprintf("%d/%d", done, total) +} + +func truncateText(value string, width int) string { + if width <= 0 { + return "" + } + return ansi.Truncate(value, width, "…") +} + +func clampIndex(index, total int) int { + if total <= 0 { + return 0 + } + if index < 0 { + return 0 + } + if index >= total { + return total - 1 + } + return index +} + +func windowForCursor(cursor, total, height int) (int, int) { + if total <= 0 || height <= 0 { + return 0, 0 + } + if total <= height { + return 0, total + } + start := cursor - height/2 + if start < 0 { + start = 0 + } + if start > total-height { + start = total - height + } + return start, min(total, start+height) +} + +func normalizeEventType(value string) string { + token := strings.ToLower(strings.TrimSpace(value)) + token = strings.ReplaceAll(token, "_", "") + token = strings.ReplaceAll(token, "-", "") + token = strings.ReplaceAll(token, " ", "") + return token +} + +func runStatusFromEvent(eventType, status string) smithers.RunStatus { + if normalized := runStatusFromString(status); normalized != "" { + return normalized + } + switch eventType { + case "runfinished": + return smithers.RunStatusFinished + case "runfailed": + return smithers.RunStatusFailed + case "runcancelled": + return smithers.RunStatusCancelled + case "nodewaitingapproval": + return smithers.RunStatusWaitingApproval + default: + return smithers.RunStatusRunning + } +} + +func runStatusFromString(value string) smithers.RunStatus { + switch strings.ToLower(strings.ReplaceAll(strings.TrimSpace(value), "_", "-")) { + case "running": + return smithers.RunStatusRunning + case "waiting-approval": + return smithers.RunStatusWaitingApproval + case "waiting-event": + return smithers.RunStatusWaitingEvent + case "finished": + return smithers.RunStatusFinished + case "failed": + return smithers.RunStatusFailed + case "cancelled": + return smithers.RunStatusCancelled + default: + return "" + } +} + +func taskStateFromString(value string) smithers.TaskState { + switch strings.ToLower(strings.ReplaceAll(strings.TrimSpace(value), "_", "-")) { + case "pending": + return smithers.TaskStatePending + case "running": + return smithers.TaskStateRunning + case "finished": + return smithers.TaskStateFinished + case "failed": + return smithers.TaskStateFailed + case "cancelled": + return smithers.TaskStateCancelled + case "skipped": + return smithers.TaskStateSkipped + case "blocked": + return smithers.TaskStateBlocked + default: + return "" + } +} + +func timestampPtr(ts int64) *int64 { + if ts <= 0 { + return nil + } + value := ts + return &value +} + +func (p workflowPane) String() string { + switch p { + case workflowPaneRuns: + return "runs" + case workflowPaneTasks: + return "tasks" + default: + return "logs" + } +} diff --git a/internal/ui/views/workflowruns_test.go b/internal/ui/views/workflowruns_test.go new file mode 100644 index 00000000..da27a41b --- /dev/null +++ b/internal/ui/views/workflowruns_test.go @@ -0,0 +1,204 @@ +package views + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/crush/internal/smithers" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWorkflowRunView_ImplementsView(t *testing.T) { + t.Parallel() + + var _ View = (*WorkflowRunView)(nil) +} + +func TestWorkflowRunView_DefaultRegistryContainsWorkflowRuns(t *testing.T) { + t.Parallel() + + r := DefaultRegistry() + v, ok := r.Open("workflow-runs", smithers.NewClient()) + require.True(t, ok) + assert.Equal(t, "workflow-runs", v.Name()) +} + +func TestWorkflowRunView_StartStreamFallbackWithoutServer(t *testing.T) { + t.Parallel() + + v := NewWorkflowRunView(smithers.NewClient()) + v.ctx = context.Background() + + msg := v.startStreamCmd()() + _, ok := msg.(workflowStreamUnavailableMsg) + assert.True(t, ok) +} + +func TestWorkflowRunView_RunEventUpdatesStatusesAndTasks(t *testing.T) { + t.Parallel() + + v := seededWorkflowRunView() + + updated, _ := v.Update(smithers.RunEventMsg{ + RunID: "run-1", + Event: smithers.RunEvent{ + Type: "node_state_changed", + RunID: "run-1", + NodeID: "task-1", + Status: "failed", + TimestampMs: 2000, + }, + }) + v = updated.(*WorkflowRunView) + + task := v.inspections["run-1"].Tasks[0] + assert.Equal(t, smithers.TaskStateFailed, task.State) + + updated, _ = v.Update(smithers.RunEventMsg{ + RunID: "run-1", + Event: smithers.RunEvent{ + Type: "run_status_changed", + RunID: "run-1", + Status: "finished", + TimestampMs: 3000, + }, + }) + v = updated.(*WorkflowRunView) + + assert.Equal(t, smithers.RunStatusFinished, v.runs[0].Status) + require.NotNil(t, v.runs[0].FinishedAtMs) +} + +func TestWorkflowRunView_NavigationAcrossPanes(t *testing.T) { + t.Parallel() + + v := seededWorkflowRunView() + + updated, cmd := v.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + v = updated.(*WorkflowRunView) + assert.Equal(t, workflowPaneTasks, v.focus) + assert.NotNil(t, cmd) + + updated, _ = v.Update(tea.KeyPressMsg{Text: "l", Code: 'l'}) + v = updated.(*WorkflowRunView) + assert.Equal(t, workflowPaneLogs, v.focus) + + updated, _ = v.Update(tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift}) + v = updated.(*WorkflowRunView) + assert.Equal(t, workflowPaneTasks, v.focus) +} + +func TestWorkflowRunView_ResponsiveViewModes(t *testing.T) { + t.Parallel() + + v := seededWorkflowRunView() + v.logs[v.logKey("run-1", v.inspections["run-1"].Tasks[0])] = workflowTaskLog{ + key: v.logKey("run-1", v.inspections["run-1"].Tasks[0]), + runID: "run-1", + nodeID: "task-1", + loaded: true, + blocks: []smithers.ChatBlock{{RunID: "run-1", NodeID: "task-1", Role: smithers.ChatRoleAssistant, Content: "done"}}, + } + v.syncLogViewer() + + v.SetSize(160, 20) + wide := v.View() + assert.Contains(t, wide, "Runs") + assert.Contains(t, wide, "Tasks") + assert.Contains(t, wide, "build") + + v.focus = workflowPaneLogs + v.SetSize(120, 20) + medium := v.View() + assert.Contains(t, medium, "Runs") + assert.Contains(t, medium, "build") + + v.SetSize(80, 20) + narrow := v.View() + assert.NotContains(t, narrow, "Build Workflow") + assert.Contains(t, narrow, "build") +} + +func TestWorkflowRunView_LoadTaskLogsCmdFiltersNodeAndAttempt(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/health": + w.WriteHeader(http.StatusOK) + case "/v1/runs/run-1/chat": + writeEnvelopeResponse(t, w, []smithers.ChatBlock{ + {RunID: "run-1", NodeID: "task-1", Attempt: 1, Role: smithers.ChatRoleAssistant, Content: "selected"}, + {RunID: "run-1", NodeID: "task-1", Attempt: 0, Role: smithers.ChatRoleAssistant, Content: "old"}, + {RunID: "run-1", NodeID: "task-2", Attempt: 1, Role: smithers.ChatRoleAssistant, Content: "other"}, + }) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + + client := smithers.NewClient(smithers.WithAPIURL(srv.URL), smithers.WithHTTPClient(srv.Client())) + v := NewWorkflowRunView(client) + v.ctx = context.Background() + + attempt := 1 + msg := v.loadTaskLogsCmd("run-1", smithers.RunTask{ + NodeID: "task-1", + LastAttempt: &attempt, + })() + require.NotNil(t, msg) + + logsMsg := msg.(workflowRunLogsLoadedMsg) + require.NoError(t, logsMsg.err) + require.Len(t, logsMsg.blocks, 1) + assert.Equal(t, "selected", logsMsg.blocks[0].Content) +} + +func seededWorkflowRunView() *WorkflowRunView { + started := int64(1000) + label := "build" + attempt := 0 + + v := NewWorkflowRunView(smithers.NewClient()) + v.runs = []smithers.RunSummary{{ + RunID: "run-1", + WorkflowName: "Build Workflow", + Status: smithers.RunStatusRunning, + StartedAtMs: &started, + Summary: map[string]int{ + "finished": 0, + "total": 1, + }, + }} + v.loading = false + v.inspections["run-1"] = &smithers.RunInspection{ + RunSummary: v.runs[0], + Tasks: []smithers.RunTask{{ + NodeID: "task-1", + Label: &label, + State: smithers.TaskStateRunning, + LastAttempt: &attempt, + }}, + } + v.syncLogViewer() + return v +} + +func writeEnvelopeResponse(t *testing.T, w http.ResponseWriter, data any) { + t.Helper() + + raw, err := json.Marshal(data) + require.NoError(t, err) + + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "data": json.RawMessage(raw), + })) +} diff --git a/internal/ui/views/workspaces.go b/internal/ui/views/workspaces.go new file mode 100644 index 00000000..e3f63baf --- /dev/null +++ b/internal/ui/views/workspaces.go @@ -0,0 +1,447 @@ +package views + +import ( + "fmt" + "os/exec" + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/crush/internal/jjhub" + "github.com/charmbracelet/crush/internal/smithers" + "github.com/charmbracelet/crush/internal/ui/components" + "github.com/charmbracelet/crush/internal/ui/handoff" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +var _ View = (*WorkspacesView)(nil) + +type workspacesLoadedMsg struct { + workspaces []jjhub.Workspace +} + +type workspacesErrorMsg struct { + err error +} + +type workspaceActionDoneMsg struct { + action string + name string +} + +type workspaceActionErrorMsg struct { + action string + name string + err error +} + +// WorkspacesView renders a JJHub workspaces dashboard. +type WorkspacesView struct { + smithersClient *smithers.Client + jjhubClient *jjhub.Client + sty styles.Styles + + width int + height int + + loading bool + err error + + previewOpen bool + search jjSearchState + searchQuery string + + allWorkspaces []jjhub.Workspace + workspaces []jjhub.Workspace + + tablePane *jjTablePane + previewPane *jjPreviewPane + splitPane *components.SplitPane +} + +var workspaceTableColumns = []components.Column{ + {Title: "Name", Width: 18}, + {Title: "Status", Width: 12}, + {Title: "SSH Host", Grow: true, MinWidth: 94}, + {Title: "Fork?", Width: 6, MinWidth: 84}, + {Title: "Idle", Width: 8, MinWidth: 100}, + {Title: "Created", Width: 10, MinWidth: 88}, +} + +// NewWorkspacesView creates a JJHub workspaces view. +func NewWorkspacesView(client *smithers.Client) *WorkspacesView { + tablePane := newJJTablePane(workspaceTableColumns) + previewPane := newJJPreviewPane("Select a workspace") + splitPane := components.NewSplitPane(tablePane, previewPane, components.SplitPaneOpts{ + LeftWidth: 68, + CompactBreakpoint: 96, + }) + + return &WorkspacesView{ + smithersClient: client, + jjhubClient: jjhub.NewClient(""), + sty: styles.DefaultStyles(), + loading: true, + previewOpen: true, + search: newJJSearchInput("filter workspaces by name"), + tablePane: tablePane, + previewPane: previewPane, + splitPane: splitPane, + } +} + +func (v *WorkspacesView) Init() tea.Cmd { + return v.loadWorkspacesCmd() +} + +func (v *WorkspacesView) Update(msg tea.Msg) (View, tea.Cmd) { + switch msg := msg.(type) { + case workspacesLoadedMsg: + v.loading = false + v.err = nil + v.allWorkspaces = msg.workspaces + selectionChanged := v.rebuildRows() + return v, v.syncPreview(selectionChanged) + + case workspacesErrorMsg: + v.loading = false + v.err = msg.err + return v, nil + + case workspaceActionDoneMsg: + v.loading = true + actionTitle := workspaceActionTitle(msg.action) + toast := func() tea.Msg { + return components.ShowToastMsg{ + Title: actionTitle + " complete", + Body: msg.name, + Level: components.ToastLevelSuccess, + } + } + return v, tea.Batch(v.Init(), toast) + + case workspaceActionErrorMsg: + actionTitle := workspaceActionTitle(msg.action) + return v, func() tea.Msg { + return components.ShowToastMsg{ + Title: actionTitle + " failed", + Body: msg.err.Error(), + Level: components.ToastLevelError, + } + } + + case tea.WindowSizeMsg: + v.SetSize(msg.Width, msg.Height) + return v, nil + + case tea.KeyPressMsg: + if v.search.active { + return v.updateSearch(msg) + } + + switch { + case key.Matches(msg, key.NewBinding(key.WithKeys("esc", "q"))): + return v, func() tea.Msg { return PopViewMsg{} } + case key.Matches(msg, key.NewBinding(key.WithKeys("/"))): + v.search.active = true + v.search.input.SetValue(v.searchQuery) + return v, v.search.input.Focus() + case key.Matches(msg, key.NewBinding(key.WithKeys("w"))): + v.previewOpen = !v.previewOpen + if v.previewOpen { + return v, v.syncPreview(true) + } + return v, nil + case key.Matches(msg, key.NewBinding(key.WithKeys("r", "R"))): + v.loading = true + return v, v.Init() + case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))): + if workspace := v.selectedWorkspace(); workspace != nil { + return v, v.sshCmd(*workspace) + } + case key.Matches(msg, key.NewBinding(key.WithKeys("s"))): + if workspace := v.selectedWorkspace(); workspace != nil { + return v, v.toggleWorkspaceCmd(*workspace) + } + } + } + + previous := v.selectedWorkspaceName() + var cmd tea.Cmd + if v.previewOpen { + v.tablePane.SetFocused(v.splitPane.Focus() == components.FocusLeft) + newSplitPane, splitCmd := v.splitPane.Update(msg) + v.splitPane = newSplitPane + cmd = splitCmd + } else { + v.tablePane.SetFocused(true) + _, cmd = v.tablePane.Update(msg) + } + + selectionChanged := previous != v.selectedWorkspaceName() + return v, tea.Batch(cmd, v.syncPreview(selectionChanged)) +} + +func (v *WorkspacesView) View() string { + header := jjRenderHeader( + fmt.Sprintf("JJHUB › Workspaces (%d)", len(v.workspaces)), + v.width, + jjMutedStyle.Render("[/] Search [w] Preview [Esc] Back"), + ) + + var parts []string + parts = append(parts, header) + if v.search.active { + parts = append(parts, jjSearchStyle.Render("Search:")+" "+v.search.input.View()) + } else if v.searchQuery != "" { + parts = append(parts, jjMutedStyle.Render("filter: "+v.searchQuery)) + } + + if v.loading && len(v.allWorkspaces) == 0 { + parts = append(parts, jjMutedStyle.Render("Loading workspaces…")) + return strings.Join(parts, "\n") + } + if v.err != nil && len(v.allWorkspaces) == 0 { + parts = append(parts, jjErrorStyle.Render("Error: "+v.err.Error())) + return strings.Join(parts, "\n") + } + if v.err != nil { + parts = append(parts, jjErrorStyle.Render("Error: "+v.err.Error())) + } + + contentHeight := max(1, v.height-len(parts)-1) + if v.previewOpen { + v.tablePane.SetFocused(v.splitPane.Focus() == components.FocusLeft) + v.splitPane.SetSize(v.width, contentHeight) + parts = append(parts, v.splitPane.View()) + } else { + v.tablePane.SetFocused(true) + v.tablePane.SetSize(v.width, contentHeight) + parts = append(parts, v.tablePane.View()) + } + return strings.Join(parts, "\n") +} + +func (v *WorkspacesView) Name() string { return "workspaces" } + +func (v *WorkspacesView) SetSize(width, height int) { + v.width = width + v.height = height + contentHeight := max(1, height-2) + v.tablePane.SetSize(width, contentHeight) + v.previewPane.SetSize(max(1, width/2), contentHeight) + v.splitPane.SetSize(width, contentHeight) +} + +func (v *WorkspacesView) ShortHelp() []key.Binding { + if v.search.active { + return []key.Binding{ + key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "apply")), + key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")), + } + } + + help := []key.Binding{ + key.NewBinding(key.WithKeys("j", "k"), key.WithHelp("j/k", "move")), + key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "ssh")), + key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "start/stop")), + key.NewBinding(key.WithKeys("w"), key.WithHelp("w", "preview")), + key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "search")), + key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")), + } + if v.previewOpen { + help = append(help, key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "focus"))) + } + return help +} + +func (v *WorkspacesView) selectedWorkspace() *jjhub.Workspace { + index := v.tablePane.Cursor() + if index < 0 || index >= len(v.workspaces) { + return nil + } + workspace := v.workspaces[index] + return &workspace +} + +func (v *WorkspacesView) selectedWorkspaceName() string { + if workspace := v.selectedWorkspace(); workspace != nil { + return workspace.Name + } + return "" +} + +func (v *WorkspacesView) rebuildRows() bool { + previous := v.selectedWorkspaceName() + + filtered := make([]jjhub.Workspace, 0, len(v.allWorkspaces)) + rows := make([]components.Row, 0, len(v.allWorkspaces)) + for _, workspace := range v.allWorkspaces { + if v.searchQuery != "" && !jjMatchesSearch(workspace.Name, v.searchQuery) { + continue + } + + sshHost := "-" + if workspace.SSHHost != nil && *workspace.SSHHost != "" { + sshHost = *workspace.SSHHost + } + idle := "-" + if workspace.IdleTimeoutSeconds > 0 { + idle = fmt.Sprintf("%dm", workspace.IdleTimeoutSeconds/60) + } + filtered = append(filtered, workspace) + rows = append(rows, components.Row{ + Cells: []string{ + workspace.Name, + jjhubWorkspaceStatusIcon(workspace.Status) + " " + workspace.Status, + sshHost, + map[bool]string{true: "yes", false: "no"}[workspace.IsFork], + idle, + jjhubRelativeTime(workspace.CreatedAt), + }, + }) + } + + v.workspaces = filtered + v.tablePane.SetRows(rows) + + targetIndex := 0 + for i, workspace := range filtered { + if workspace.Name == previous { + targetIndex = i + break + } + } + if len(filtered) > 0 { + v.tablePane.SetCursor(targetIndex) + } + return previous != v.selectedWorkspaceName() +} + +func (v *WorkspacesView) syncPreview(reset bool) tea.Cmd { + workspace := v.selectedWorkspace() + if workspace == nil { + v.previewPane.SetContent("", true) + return nil + } + v.previewPane.SetContent(v.renderPreview(*workspace), reset) + return nil +} + +func (v *WorkspacesView) renderPreview(workspace jjhub.Workspace) string { + sshHost := "-" + if workspace.SSHHost != nil && *workspace.SSHHost != "" { + sshHost = *workspace.SSHHost + } + idle := "-" + if workspace.IdleTimeoutSeconds > 0 { + idle = fmt.Sprintf("%d minutes", workspace.IdleTimeoutSeconds/60) + } + + var body strings.Builder + body.WriteString(jjTitleStyle.Render(workspace.Name)) + body.WriteString("\n") + body.WriteString(jjBadgeStyleForState(workspace.Status).Render(jjhubWorkspaceStatusIcon(workspace.Status) + " " + workspace.Status)) + body.WriteString("\n\n") + body.WriteString(jjMetaRow("SSH", sshHost) + "\n") + body.WriteString(jjMetaRow("Fork", map[bool]string{true: "yes", false: "no"}[workspace.IsFork]) + "\n") + body.WriteString(jjMetaRow("Idle", idle) + "\n") + body.WriteString(jjMetaRow("Created", jjFormatTime(workspace.CreatedAt)) + "\n") + body.WriteString(jjMetaRow("Updated", jjFormatTime(workspace.UpdatedAt)) + "\n") + body.WriteString(jjMetaRow("VM", workspace.FreestyleVMID) + "\n") + if workspace.SnapshotID != nil { + body.WriteString(jjMetaRow("Snapshot", *workspace.SnapshotID) + "\n") + } + if workspace.ParentWorkspaceID != nil { + body.WriteString(jjMetaRow("Parent", *workspace.ParentWorkspaceID) + "\n") + } + if workspace.SuspendedAt != nil { + body.WriteString(jjMetaRow("Suspended", jjFormatTime(*workspace.SuspendedAt)) + "\n") + } + body.WriteString("\n") + body.WriteString(jjSectionStyle.Render("Actions")) + body.WriteString("\n") + body.WriteString("Enter to connect over SSH.\n") + body.WriteString("Press s to ") + if workspace.Status == "running" { + body.WriteString("suspend the workspace.") + } else { + body.WriteString("resume the workspace.") + } + return strings.TrimSpace(body.String()) +} + +func (v *WorkspacesView) updateSearch(msg tea.KeyPressMsg) (View, tea.Cmd) { + switch { + case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))): + v.search.active = false + v.search.input.Blur() + return v, nil + case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))): + v.search.active = false + v.searchQuery = strings.TrimSpace(v.search.input.Value()) + v.search.input.Blur() + selectionChanged := v.rebuildRows() + return v, v.syncPreview(selectionChanged) + default: + var cmd tea.Cmd + v.search.input, cmd = v.search.input.Update(msg) + return v, cmd + } +} + +func (v *WorkspacesView) loadWorkspacesCmd() tea.Cmd { + client := v.jjhubClient + return func() tea.Msg { + workspaces, err := client.ListWorkspaces(jjDefaultListLimit) + if err != nil { + return workspacesErrorMsg{err: err} + } + return workspacesLoadedMsg{workspaces: workspaces} + } +} + +func (v *WorkspacesView) sshCmd(workspace jjhub.Workspace) tea.Cmd { + return handoff.Handoff(handoff.Options{ + Binary: "jjhub", + Args: []string{"workspace", "ssh", workspace.ID}, + Tag: "workspace-ssh", + }) +} + +func (v *WorkspacesView) toggleWorkspaceCmd(workspace jjhub.Workspace) tea.Cmd { + action := "resume" + args := []string{"workspace", "resume", workspace.ID} + if workspace.Status == "running" { + action = "suspend" + args = []string{"workspace", "suspend", workspace.ID} + } + if workspace.Status == "pending" { + return func() tea.Msg { + return components.ShowToastMsg{ + Title: "Workspace pending", + Body: "Wait for the workspace to finish starting before toggling it.", + Level: components.ToastLevelWarning, + } + } + } + + return func() tea.Msg { + cmd := exec.Command("jjhub", args...) //nolint:gosec // user-triggered CLI action + if out, err := cmd.CombinedOutput(); err != nil { + message := strings.TrimSpace(string(out)) + if message == "" { + message = err.Error() + } + return workspaceActionErrorMsg{action: action, name: workspace.Name, err: fmt.Errorf("%s", message)} + } + return workspaceActionDoneMsg{action: action, name: workspace.Name} + } +} + +func workspaceActionTitle(action string) string { + if action == "" { + return "Action" + } + return strings.ToUpper(action[:1]) + action[1:] +} diff --git a/internal/ui/views/workspaces_test.go b/internal/ui/views/workspaces_test.go new file mode 100644 index 00000000..69cbfb10 --- /dev/null +++ b/internal/ui/views/workspaces_test.go @@ -0,0 +1,95 @@ +package views + +import ( + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/crush/internal/jjhub" + "github.com/charmbracelet/crush/internal/smithers" + "github.com/charmbracelet/crush/internal/ui/components" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func sampleWorkspace(name, status string) jjhub.Workspace { + sshHost := name + ".jjhub.tech" + return jjhub.Workspace{ + ID: name + "-id", + Name: name, + Status: status, + SSHHost: &sshHost, + IsFork: true, + IdleTimeoutSeconds: 1800, + FreestyleVMID: "vm-123", + CreatedAt: time.Now().Add(-3 * time.Hour).Format(time.RFC3339), + UpdatedAt: time.Now().Add(-30 * time.Minute).Format(time.RFC3339), + } +} + +func newTestWorkspacesView() *WorkspacesView { + return NewWorkspacesView(smithers.NewClient()) +} + +func seedWorkspacesView(v *WorkspacesView, workspaces []jjhub.Workspace) *WorkspacesView { + updated, _ := v.Update(workspacesLoadedMsg{workspaces: workspaces}) + return updated.(*WorkspacesView) +} + +func TestWorkspacesView_ImplementsView(t *testing.T) { + t.Parallel() + var _ View = (*WorkspacesView)(nil) +} + +func TestWorkspacesView_SearchApply(t *testing.T) { + t.Parallel() + + v := seedWorkspacesView(newTestWorkspacesView(), []jjhub.Workspace{ + sampleWorkspace("alpha", "running"), + sampleWorkspace("beta", "stopped"), + }) + v.search.active = true + v.search.input.SetValue("beta") + + updated, _ := v.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + wv := updated.(*WorkspacesView) + + assert.Equal(t, "beta", wv.searchQuery) + assert.Len(t, wv.workspaces, 1) + assert.Equal(t, "beta", wv.workspaces[0].Name) +} + +func TestWorkspacesView_EnterReturnsSSHCmd(t *testing.T) { + t.Parallel() + + v := seedWorkspacesView(newTestWorkspacesView(), []jjhub.Workspace{sampleWorkspace("alpha", "running")}) + + updated, cmd := v.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + + require.Same(t, v, updated) + require.NotNil(t, cmd) +} + +func TestWorkspacesView_SPendingReturnsWarningToast(t *testing.T) { + t.Parallel() + + v := seedWorkspacesView(newTestWorkspacesView(), []jjhub.Workspace{sampleWorkspace("alpha", "pending")}) + + _, cmd := v.Update(tea.KeyPressMsg{Code: 's'}) + require.NotNil(t, cmd) + + msg := cmd() + toast, ok := msg.(components.ShowToastMsg) + require.True(t, ok) + assert.Equal(t, components.ToastLevelWarning, toast.Level) + assert.Contains(t, toast.Title, "pending") +} + +func TestWorkspacesView_RenderPreviewIncludesSSHHost(t *testing.T) { + t.Parallel() + + v := seedWorkspacesView(newTestWorkspacesView(), []jjhub.Workspace{sampleWorkspace("alpha", "running")}) + + content := v.renderPreview(v.workspaces[0]) + assert.Contains(t, content, "alpha.jjhub.tech") +} diff --git a/poc/jjhub-tui/jjhub/client.go b/poc/jjhub-tui/jjhub/client.go new file mode 100644 index 00000000..1a53b6ac --- /dev/null +++ b/poc/jjhub-tui/jjhub/client.go @@ -0,0 +1,326 @@ +// Package jjhub shells out to the jjhub CLI and parses JSON output. +// This is the POC adapter — will be replaced by direct Go API calls later. +package jjhub + +import ( + "encoding/json" + "fmt" + "os/exec" + "strings" + "time" +) + +// ---- Data types (mirrors jjhub --json output) ---- + +type User struct { + ID int `json:"id"` + Login string `json:"login"` +} + +type Repo struct { + ID int `json:"id"` + Name string `json:"name"` + FullName string `json:"full_name"` + Owner string `json:"owner"` + Description string `json:"description"` + DefaultBookmark string `json:"default_bookmark"` + IsPublic bool `json:"is_public"` + IsArchived bool `json:"is_archived"` + NumIssues int `json:"num_issues"` + NumStars int `json:"num_stars"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Landing struct { + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + State string `json:"state"` // open, closed, merged, draft + TargetBookmark string `json:"target_bookmark"` + ChangeIDs []string `json:"change_ids"` + StackSize int `json:"stack_size"` + ConflictStatus string `json:"conflict_status"` + Author User `json:"author"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// LandingDetail is the rich response from `jjhub land view`. +type LandingDetail struct { + Landing Landing `json:"landing"` + Changes []LandingChange `json:"changes"` + Conflicts LandingConflict `json:"conflicts"` + Reviews []Review `json:"reviews"` +} + +type LandingChange struct { + ID int `json:"id"` + ChangeID string `json:"change_id"` + LandingRequestID int `json:"landing_request_id"` + PositionInStack int `json:"position_in_stack"` + CreatedAt string `json:"created_at"` +} + +type LandingConflict struct { + ConflictStatus string `json:"conflict_status"` + HasConflicts bool `json:"has_conflicts"` + ConflictsByChange map[string]string `json:"conflicts_by_change"` +} + +type Review struct { + ID int `json:"id"` + LandingRequestID int `json:"landing_request_id"` + ReviewerID int `json:"reviewer_id"` + State string `json:"state"` // approve, request_changes, comment + Type string `json:"type"` + Body string `json:"body"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type Issue struct { + ID int `json:"id"` + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + State string `json:"state"` // open, closed + Author User `json:"author"` + Assignees []User `json:"assignees"` + CommentCount int `json:"comment_count"` + MilestoneID *int `json:"milestone_id"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Labels []Label `json:"labels"` +} + +type Label struct { + ID int `json:"id"` + Name string `json:"name"` + Color string `json:"color"` +} + +type Notification struct { + ID int `json:"id"` + Title string `json:"title"` + Type string `json:"type"` + RepoName string `json:"repo_name"` + Unread bool `json:"unread"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type Workspace struct { + ID string `json:"id"` + RepositoryID int `json:"repository_id"` + UserID int `json:"user_id"` + Name string `json:"name"` + Status string `json:"status"` // pending, running, stopped, failed + IsFork bool `json:"is_fork"` + ParentWorkspaceID *string `json:"parent_workspace_id"` + FreestyleVMID string `json:"freestyle_vm_id"` + Persistence string `json:"persistence"` + SSHHost *string `json:"ssh_host"` + SnapshotID *string `json:"snapshot_id"` + IdleTimeoutSeconds int `json:"idle_timeout_seconds"` + SuspendedAt *string `json:"suspended_at"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type Workflow struct { + ID int `json:"id"` + RepositoryID int `json:"repository_id"` + Name string `json:"name"` + Path string `json:"path"` + IsActive bool `json:"is_active"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type Change struct { + ChangeID string `json:"change_id"` + CommitID string `json:"commit_id"` + Description string `json:"description"` + Author Author `json:"author"` + Timestamp string `json:"timestamp"` + IsEmpty bool `json:"is_empty"` + IsWorkingCopy bool `json:"is_working_copy"` + Bookmarks []string `json:"bookmarks"` +} + +type Author struct { + Name string `json:"name"` + Email string `json:"email"` +} + +// ---- Client ---- + +type Client struct { + repo string // owner/repo, empty = auto-detect from cwd +} + +func NewClient(repo string) *Client { + return &Client{repo: repo} +} + +func (c *Client) run(args ...string) ([]byte, error) { + allArgs := append(args, "--json", "--no-color") + cmd := exec.Command("jjhub", allArgs...) + out, err := cmd.CombinedOutput() + if err != nil { + // Extract just the error message, not the full stderr dump. + msg := strings.TrimSpace(string(out)) + if idx := strings.Index(msg, "Error:"); idx >= 0 { + msg = strings.TrimSpace(msg[idx+6:]) + } + return nil, fmt.Errorf("%s", msg) + } + return out, nil +} + +func (c *Client) repoArgs() []string { + if c.repo != "" { + return []string{"-R", c.repo} + } + return nil +} + +// ---- List methods ---- + +func (c *Client) ListLandings(state string, limit int) ([]Landing, error) { + args := []string{"land", "list", "-s", state, "-L", fmt.Sprint(limit)} + args = append(args, c.repoArgs()...) + out, err := c.run(args...) + if err != nil { + return nil, err + } + var landings []Landing + if err := json.Unmarshal(out, &landings); err != nil { + return nil, fmt.Errorf("parse landings: %w", err) + } + return landings, nil +} + +func (c *Client) ListIssues(state string, limit int) ([]Issue, error) { + args := []string{"issue", "list", "-s", state, "-L", fmt.Sprint(limit)} + args = append(args, c.repoArgs()...) + out, err := c.run(args...) + if err != nil { + return nil, err + } + var issues []Issue + if err := json.Unmarshal(out, &issues); err != nil { + return nil, fmt.Errorf("parse issues: %w", err) + } + return issues, nil +} + +func (c *Client) ListRepos(limit int) ([]Repo, error) { + args := []string{"repo", "list", "-L", fmt.Sprint(limit)} + out, err := c.run(args...) + if err != nil { + return nil, err + } + var repos []Repo + if err := json.Unmarshal(out, &repos); err != nil { + return nil, fmt.Errorf("parse repos: %w", err) + } + return repos, nil +} + +func (c *Client) ListNotifications(limit int) ([]Notification, error) { + args := []string{"notification", "list", "-L", fmt.Sprint(limit)} + out, err := c.run(args...) + if err != nil { + return nil, err + } + var notifications []Notification + if err := json.Unmarshal(out, ¬ifications); err != nil { + return nil, fmt.Errorf("parse notifications: %w", err) + } + return notifications, nil +} + +func (c *Client) ListWorkspaces(limit int) ([]Workspace, error) { + args := []string{"workspace", "list", "-L", fmt.Sprint(limit)} + args = append(args, c.repoArgs()...) + out, err := c.run(args...) + if err != nil { + return nil, err + } + var ws []Workspace + if err := json.Unmarshal(out, &ws); err != nil { + return nil, fmt.Errorf("parse workspaces: %w", err) + } + return ws, nil +} + +func (c *Client) ListWorkflows(limit int) ([]Workflow, error) { + args := []string{"workflow", "list", "-L", fmt.Sprint(limit)} + args = append(args, c.repoArgs()...) + out, err := c.run(args...) + if err != nil { + return nil, err + } + var wf []Workflow + if err := json.Unmarshal(out, &wf); err != nil { + return nil, fmt.Errorf("parse workflows: %w", err) + } + return wf, nil +} + +func (c *Client) ListChanges(limit int) ([]Change, error) { + args := []string{"change", "list", "--limit", fmt.Sprint(limit)} + out, err := c.run(args...) + if err != nil { + return nil, err + } + var changes []Change + if err := json.Unmarshal(out, &changes); err != nil { + return nil, fmt.Errorf("parse changes: %w", err) + } + return changes, nil +} + +// ---- Detail methods ---- + +func (c *Client) ViewLanding(number int) (*LandingDetail, error) { + args := []string{"land", "view", fmt.Sprint(number)} + args = append(args, c.repoArgs()...) + out, err := c.run(args...) + if err != nil { + return nil, err + } + var d LandingDetail + if err := json.Unmarshal(out, &d); err != nil { + return nil, fmt.Errorf("parse landing detail: %w", err) + } + return &d, nil +} + +func (c *Client) ViewIssue(number int) (*Issue, error) { + args := []string{"issue", "view", fmt.Sprint(number)} + args = append(args, c.repoArgs()...) + out, err := c.run(args...) + if err != nil { + return nil, err + } + var i Issue + if err := json.Unmarshal(out, &i); err != nil { + return nil, fmt.Errorf("parse issue: %w", err) + } + return &i, nil +} + +func (c *Client) GetCurrentRepo() (*Repo, error) { + out, err := c.run("repo", "view") + if err != nil { + return nil, err + } + var r Repo + if err := json.Unmarshal(out, &r); err != nil { + return nil, fmt.Errorf("parse repo: %w", err) + } + return &r, nil +} diff --git a/poc/jjhub-tui/main.go b/poc/jjhub-tui/main.go new file mode 100644 index 00000000..2e02b228 --- /dev/null +++ b/poc/jjhub-tui/main.go @@ -0,0 +1,54 @@ +// poc/jjhub-tui: gh-dash-inspired TUI for JJHub / Codeplane. +// +// Usage: +// +// go run ./poc/jjhub-tui/ # auto-detect repo from cwd +// go run ./poc/jjhub-tui/ -R roninjin10/jjhub # explicit owner/repo +// +// Shells out to the `jjhub` CLI for data. Pass -R owner/repo if you're not +// in a directory with a jjhub remote. +package main + +import ( + "fmt" + "os" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/crush/poc/jjhub-tui/tui" +) + +func main() { + repo := "" + args := os.Args[1:] + for i := 0; i < len(args); i++ { + switch args[i] { + case "-h", "--help": + fmt.Println("Usage: jjhub-tui [-R owner/repo]") + fmt.Println() + fmt.Println("A gh-dash-inspired terminal dashboard for Codeplane (JJHub).") + fmt.Println("Shows landings, issues, workspaces, workflows, repos, and notifications.") + fmt.Println() + fmt.Println("Options:") + fmt.Println(" -R owner/repo Repository to use (default: auto-detect from cwd)") + os.Exit(0) + case "-R", "--repo": + if i+1 < len(args) { + i++ + repo = args[i] + } else { + fmt.Fprintln(os.Stderr, "error: -R requires an argument") + os.Exit(1) + } + default: + // Also accept bare positional arg for convenience. + repo = args[i] + } + } + + m := tui.NewModel(repo) + p := tea.NewProgram(m) + if _, err := p.Run(); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} diff --git a/poc/jjhub-tui/tui/keys.go b/poc/jjhub-tui/tui/keys.go new file mode 100644 index 00000000..aaad2fca --- /dev/null +++ b/poc/jjhub-tui/tui/keys.go @@ -0,0 +1,62 @@ +package tui + +import "charm.land/bubbles/v2/key" + +type keyMap struct { + Up key.Binding + Down key.Binding + Left key.Binding + Right key.Binding + Enter key.Binding + Quit key.Binding + Help key.Binding + Tab key.Binding + ShiftTab key.Binding + Refresh key.Binding + Preview key.Binding + Escape key.Binding + GotoTop key.Binding + GotoBottom key.Binding + PageDown key.Binding + PageUp key.Binding + Filter key.Binding + Search key.Binding + Open key.Binding + + // Number shortcuts for tabs. + Num1 key.Binding + Num2 key.Binding + Num3 key.Binding + Num4 key.Binding + Num5 key.Binding + Num6 key.Binding +} + +var defaultKeys = keyMap{ + Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")), + Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")), + Left: key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("←/h", "prev tab")), + Right: key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("→/l", "next tab")), + Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "view detail")), + Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), + Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")), + Tab: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next tab")), + ShiftTab: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("S-tab", "prev tab")), + Refresh: key.NewBinding(key.WithKeys("R"), key.WithHelp("R", "refresh all")), + Preview: key.NewBinding(key.WithKeys("w"), key.WithHelp("w", "toggle preview")), + Escape: key.NewBinding(key.WithKeys("escape"), key.WithHelp("esc", "back/clear")), + GotoTop: key.NewBinding(key.WithKeys("g"), key.WithHelp("g", "go to top")), + GotoBottom: key.NewBinding(key.WithKeys("G"), key.WithHelp("G", "go to bottom")), + PageDown: key.NewBinding(key.WithKeys("ctrl+d"), key.WithHelp("C-d", "page down")), + PageUp: key.NewBinding(key.WithKeys("ctrl+u"), key.WithHelp("C-u", "page up")), + Filter: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "cycle state filter")), + Search: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "search")), + Open: key.NewBinding(key.WithKeys("o"), key.WithHelp("o", "open in browser")), + + Num1: key.NewBinding(key.WithKeys("1")), + Num2: key.NewBinding(key.WithKeys("2")), + Num3: key.NewBinding(key.WithKeys("3")), + Num4: key.NewBinding(key.WithKeys("4")), + Num5: key.NewBinding(key.WithKeys("5")), + Num6: key.NewBinding(key.WithKeys("6")), +} diff --git a/poc/jjhub-tui/tui/model.go b/poc/jjhub-tui/tui/model.go new file mode 100644 index 00000000..d6d652b0 --- /dev/null +++ b/poc/jjhub-tui/tui/model.go @@ -0,0 +1,667 @@ +package tui + +import ( + "fmt" + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/poc/jjhub-tui/jjhub" +) + +// ---- Messages ---- + +type landingsFetchedMsg struct { + landings []jjhub.Landing + err error +} + +type issuesFetchedMsg struct { + issues []jjhub.Issue + err error +} + +type reposFetchedMsg struct { + repos []jjhub.Repo + err error +} + +type notificationsFetchedMsg struct { + notifications []jjhub.Notification + err error +} + +type workspacesFetchedMsg struct { + workspaces []jjhub.Workspace + err error +} + +type workflowsFetchedMsg struct { + workflows []jjhub.Workflow + err error +} + +type repoInfoMsg struct { + repo *jjhub.Repo + err error +} + +// ---- Model ---- + +type Model struct { + client *jjhub.Client + tabs []TabKind + activeTab int + sections map[TabKind]*Section + + // Layout + width int + height int + previewOpen bool + showHelp bool + + // Search + searching bool + searchInput string + + // Repo info (shown in header). + repoName string +} + +func NewModel(repo string) *Model { + client := jjhub.NewClient(repo) + sections := make(map[TabKind]*Section) + sections[TabLandings] = NewLandingsSection() + sections[TabIssues] = NewIssuesSection() + sections[TabWorkspaces] = NewWorkspacesSection() + sections[TabWorkflows] = NewWorkflowsSection() + sections[TabRepos] = NewReposSection() + sections[TabNotifications] = NewNotificationsSection() + + return &Model{ + client: client, + tabs: allTabs, + activeTab: 0, + sections: sections, + previewOpen: true, + } +} + +func (m *Model) Init() tea.Cmd { + return tea.Batch( + m.fetchLandings("open"), + m.fetchIssues("open"), + m.fetchWorkspaces(), + m.fetchWorkflows(), + m.fetchRepos(), + m.fetchNotifications(), + m.fetchRepoInfo(), + ) +} + +func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + + // ---- Data fetch results ---- + case repoInfoMsg: + if msg.err == nil && msg.repo != nil { + m.repoName = msg.repo.FullName + if m.repoName == "" { + m.repoName = msg.repo.Name + } + } + return m, nil + + case landingsFetchedMsg: + s := m.sections[TabLandings] + if msg.err != nil { + s.SetError(msg.err) + } else { + s.BuildLandingRows(msg.landings) + } + return m, nil + + case issuesFetchedMsg: + s := m.sections[TabIssues] + if msg.err != nil { + s.SetError(msg.err) + } else { + s.BuildIssueRows(msg.issues) + } + return m, nil + + case reposFetchedMsg: + s := m.sections[TabRepos] + if msg.err != nil { + s.SetError(msg.err) + } else { + s.BuildRepoRows(msg.repos) + } + return m, nil + + case notificationsFetchedMsg: + s := m.sections[TabNotifications] + if msg.err != nil { + s.SetError(msg.err) + } else { + s.BuildNotificationRows(msg.notifications) + } + return m, nil + + case workspacesFetchedMsg: + s := m.sections[TabWorkspaces] + if msg.err != nil { + s.SetError(msg.err) + } else { + s.BuildWorkspaceRows(msg.workspaces) + } + return m, nil + + case workflowsFetchedMsg: + s := m.sections[TabWorkflows] + if msg.err != nil { + s.SetError(msg.err) + } else { + s.BuildWorkflowRows(msg.workflows) + } + return m, nil + + // ---- Keyboard ---- + case tea.KeyMsg: + // Search mode intercepts all keys. + if m.searching { + return m.updateSearch(msg) + } + + if m.showHelp { + m.showHelp = false + return m, nil + } + + sect := m.currentSection() + pageSize := m.contentHeight() / 2 + if pageSize < 1 { + pageSize = 1 + } + + switch { + case key.Matches(msg, defaultKeys.Quit): + return m, tea.Quit + + case key.Matches(msg, defaultKeys.Help): + m.showHelp = true + return m, nil + + // Tab switching by number. + case key.Matches(msg, defaultKeys.Num1): + return m, m.switchTab(0) + case key.Matches(msg, defaultKeys.Num2): + return m, m.switchTab(1) + case key.Matches(msg, defaultKeys.Num3): + return m, m.switchTab(2) + case key.Matches(msg, defaultKeys.Num4): + return m, m.switchTab(3) + case key.Matches(msg, defaultKeys.Num5): + return m, m.switchTab(4) + case key.Matches(msg, defaultKeys.Num6): + return m, m.switchTab(5) + + case key.Matches(msg, defaultKeys.Tab, defaultKeys.Right): + m.nextTab() + return m, nil + case key.Matches(msg, defaultKeys.ShiftTab, defaultKeys.Left): + m.prevTab() + return m, nil + + case key.Matches(msg, defaultKeys.Down): + sect.CursorDown() + return m, nil + case key.Matches(msg, defaultKeys.Up): + sect.CursorUp() + return m, nil + case key.Matches(msg, defaultKeys.GotoTop): + sect.GotoTop() + return m, nil + case key.Matches(msg, defaultKeys.GotoBottom): + sect.GotoBottom() + return m, nil + case key.Matches(msg, defaultKeys.PageDown): + sect.PageDown(pageSize) + return m, nil + case key.Matches(msg, defaultKeys.PageUp): + sect.PageUp(pageSize) + return m, nil + + case key.Matches(msg, defaultKeys.Preview): + m.previewOpen = !m.previewOpen + return m, nil + + case key.Matches(msg, defaultKeys.Refresh): + return m, m.refreshAll() + + case key.Matches(msg, defaultKeys.Filter): + return m, m.cycleFilter() + + case key.Matches(msg, defaultKeys.Search): + m.searching = true + m.searchInput = "" + return m, nil + + case key.Matches(msg, defaultKeys.Escape): + // Clear search if active. + if sect.Search != "" { + sect.Search = "" + return m, m.rebuildCurrentSection() + } + return m, nil + } + } + + return m, nil +} + +// ---- Search mode ---- + +func (m *Model) updateSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, key.NewBinding(key.WithKeys("escape"))): + m.searching = false + m.searchInput = "" + return m, nil + + case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))): + m.searching = false + sect := m.currentSection() + sect.Search = m.searchInput + return m, m.rebuildCurrentSection() + + case key.Matches(msg, key.NewBinding(key.WithKeys("backspace"))): + if len(m.searchInput) > 0 { + m.searchInput = m.searchInput[:len(m.searchInput)-1] + } + return m, nil + + default: + // Append printable characters. + r := msg.String() + if len(r) == 1 && r[0] >= 32 { + m.searchInput += r + } + return m, nil + } +} + +func (m *Model) View() tea.View { + var v tea.View + v.AltScreen = true + + if m.width == 0 || m.height == 0 { + v.Content = spinnerStyle.Render(" ⟳ Loading...") + return v + } + + if m.showHelp { + v.Content = m.viewHelp() + return v + } + + var parts []string + + // Header bar. + parts = append(parts, m.viewHeader()) + + // Tab bar. + parts = append(parts, m.viewTabBar()) + + // Search bar (if searching). + if m.searching { + parts = append(parts, m.viewSearchBar()) + } + + // Main content area. + ch := m.contentHeight() + if m.searching { + ch-- // search bar takes one line + } + parts = append(parts, m.viewContent(ch)) + + // Footer. + parts = append(parts, m.viewFooter()) + + v.Content = lipgloss.JoinVertical(lipgloss.Left, parts...) + return v +} + +// contentHeight returns the available height for the table. +func (m *Model) contentHeight() int { + h := m.height - 5 // header + tab bar + footer + borders + if h < 3 { + h = 3 + } + return h +} + +// ---- Header ---- + +func (m *Model) viewHeader() string { + logo := logoStyle.Render("◆ Codeplane") + right := "" + if m.repoName != "" { + right = repoNameStyle.Render(m.repoName) + } + gap := m.width - lipgloss.Width(logo) - lipgloss.Width(right) - 4 + if gap < 1 { + gap = 1 + } + line := logo + strings.Repeat(" ", gap) + right + return headerBarStyle.Width(m.width).Render(line) +} + +// ---- Tab bar ---- + +func (m *Model) viewTabBar() string { + var tabs []string + for i, t := range m.tabs { + num := fmt.Sprintf("%d", i+1) + label := t.String() + sect := m.sections[t] + count := "" + if !sect.Loading && sect.Error == "" { + count = tabCountStyle.Render(fmt.Sprintf(" %d", len(sect.Rows))) + } + + if i == m.activeTab { + tab := activeTabNumStyle.Render(num) + " " + activeTabStyle.Render(label) + count + tabs = append(tabs, tab) + } else { + tab := inactiveTabNumStyle.Render(num) + " " + inactiveTabStyle.Render(label) + count + tabs = append(tabs, tab) + } + } + bar := strings.Join(tabs, " ") + return tabBarStyle.Width(m.width).Render(" " + bar) +} + +// ---- Search bar ---- + +func (m *Model) viewSearchBar() string { + prompt := searchPromptStyle.Render(" / ") + input := searchInputStyle.Render(m.searchInput) + cursor := "█" + return searchBarStyle.Width(m.width).Render(prompt + input + cursor) +} + +// ---- Main content ---- + +func (m *Model) viewContent(height int) string { + sect := m.currentSection() + + if !m.previewOpen || m.width < 60 { + return sect.ViewTable(m.width, height) + } + + // Split: table on left, preview on right. + previewWidth := m.width * 38 / 100 + if previewWidth > 60 { + previewWidth = 60 + } + if previewWidth < 25 { + previewWidth = 25 + } + tableWidth := m.width - previewWidth - 1 + + table := sect.ViewTable(tableWidth, height) + + previewContent := sect.PreviewContent(previewWidth) + preview := sidebarStyle. + Width(previewWidth - 4). + Height(height). + Render(previewContent) + + return lipgloss.JoinHorizontal(lipgloss.Top, table, preview) +} + +// ---- Footer ---- + +func (m *Model) viewFooter() string { + sep := footerSepStyle.Render("│") + var parts []string + + // Context-aware actions based on current tab. + switch m.tabs[m.activeTab] { + case TabLandings: + sect := m.sections[TabLandings] + parts = append(parts, helpPair("s", "filter:"+sect.FilterLabel)) + case TabIssues: + sect := m.sections[TabIssues] + parts = append(parts, helpPair("s", "filter:"+sect.FilterLabel)) + } + + // Search indicator. + sect := m.currentSection() + if sect.Search != "" { + parts = append(parts, footerFilterStyle.Render("/"+sect.Search)) + } + + parts = append(parts, + sep, + helpPair("j/k", "nav"), + helpPair("1-6", "tabs"), + helpPair("w", "preview"), + helpPair("/", "search"), + helpPair("R", "refresh"), + helpPair("?", "help"), + ) + + line := strings.Join(parts, " ") + return footerStyle.Width(m.width).Render(line) +} + +func helpPair(k, desc string) string { + return footerKeyStyle.Render(k) + " " + footerDescStyle.Render(desc) +} + +// ---- Help overlay ---- + +func (m *Model) viewHelp() string { + title := titleStyle.Render("◆ Codeplane TUI — Keyboard Shortcuts") + + sections := []struct { + name string + keys []struct{ key, desc string } + }{ + {"Navigation", []struct{ key, desc string }{ + {"j / ↓", "Move cursor down"}, + {"k / ↑", "Move cursor up"}, + {"g", "Go to top"}, + {"G", "Go to bottom"}, + {"Ctrl+d", "Page down"}, + {"Ctrl+u", "Page up"}, + }}, + {"Tabs", []struct{ key, desc string }{ + {"1-6", "Jump to tab by number"}, + {"l / → / Tab", "Next tab"}, + {"h / ← / S-Tab", "Previous tab"}, + }}, + {"Views", []struct{ key, desc string }{ + {"w", "Toggle preview sidebar"}, + {"s", "Cycle state filter (landings/issues)"}, + {"/", "Search within current tab"}, + {"Esc", "Clear search / go back"}, + }}, + {"Actions", []struct{ key, desc string }{ + {"R", "Refresh all tabs"}, + {"?", "Toggle this help"}, + {"q / Ctrl+C", "Quit"}, + }}, + } + + var lines []string + for _, s := range sections { + lines = append(lines, helpSectionStyle.Render(s.name)) + for _, h := range s.keys { + lines = append(lines, " "+helpKeyStyle.Render(h.key)+helpDescStyle.Render(h.desc)) + } + } + + body := strings.Join(lines, "\n") + content := title + "\n\n" + body + "\n\n" + dimStyle.Render(" Press any key to close") + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, content) +} + +// ---- Tab navigation ---- + +func (m *Model) nextTab() { + m.activeTab = (m.activeTab + 1) % len(m.tabs) +} + +func (m *Model) prevTab() { + m.activeTab-- + if m.activeTab < 0 { + m.activeTab = len(m.tabs) - 1 + } +} + +func (m *Model) switchTab(index int) tea.Cmd { + if index >= 0 && index < len(m.tabs) { + m.activeTab = index + } + return nil +} + +func (m *Model) currentSection() *Section { + return m.sections[m.tabs[m.activeTab]] +} + +// ---- Filter cycling ---- + +func (m *Model) cycleFilter() tea.Cmd { + tab := m.tabs[m.activeTab] + sect := m.sections[tab] + + switch tab { + case TabLandings: + sect.FilterIndex = (sect.FilterIndex + 1) % len(landingFilters) + f := landingFilters[sect.FilterIndex] + sect.FilterLabel = f.label + sect.Loading = true + return m.fetchLandings(f.value) + + case TabIssues: + sect.FilterIndex = (sect.FilterIndex + 1) % len(issueFilters) + f := issueFilters[sect.FilterIndex] + sect.FilterLabel = f.label + sect.Loading = true + return m.fetchIssues(f.value) + } + return nil +} + +// ---- Rebuild section (after search change) ---- + +func (m *Model) rebuildCurrentSection() tea.Cmd { + sect := m.currentSection() + switch sect.Kind { + case TabLandings: + sect.BuildLandingRows(sect.Landings) + case TabIssues: + sect.BuildIssueRows(sect.Issues) + case TabWorkspaces: + sect.BuildWorkspaceRows(sect.Workspaces) + case TabWorkflows: + sect.BuildWorkflowRows(sect.Workflows) + case TabRepos: + sect.BuildRepoRows(sect.Repos) + case TabNotifications: + sect.BuildNotificationRows(sect.Notifications) + } + return nil +} + +// ---- Data fetching (tea.Cmd) ---- + +func (m *Model) fetchRepoInfo() tea.Cmd { + client := m.client + return func() tea.Msg { + repo, err := client.GetCurrentRepo() + return repoInfoMsg{repo: repo, err: err} + } +} + +func (m *Model) fetchLandings(state string) tea.Cmd { + client := m.client + return func() tea.Msg { + landings, err := client.ListLandings(state, 50) + return landingsFetchedMsg{landings: landings, err: err} + } +} + +func (m *Model) fetchIssues(state string) tea.Cmd { + client := m.client + return func() tea.Msg { + issues, err := client.ListIssues(state, 50) + return issuesFetchedMsg{issues: issues, err: err} + } +} + +func (m *Model) fetchRepos() tea.Cmd { + client := m.client + return func() tea.Msg { + repos, err := client.ListRepos(50) + return reposFetchedMsg{repos: repos, err: err} + } +} + +func (m *Model) fetchNotifications() tea.Cmd { + client := m.client + return func() tea.Msg { + notifs, err := client.ListNotifications(50) + return notificationsFetchedMsg{notifications: notifs, err: err} + } +} + +func (m *Model) fetchWorkspaces() tea.Cmd { + client := m.client + return func() tea.Msg { + ws, err := client.ListWorkspaces(50) + return workspacesFetchedMsg{workspaces: ws, err: err} + } +} + +func (m *Model) fetchWorkflows() tea.Cmd { + client := m.client + return func() tea.Msg { + wf, err := client.ListWorkflows(50) + return workflowsFetchedMsg{workflows: wf, err: err} + } +} + +func (m *Model) refreshAll() tea.Cmd { + for _, s := range m.sections { + s.Loading = true + } + + landingFilter := "open" + if s := m.sections[TabLandings]; s.FilterIndex < len(landingFilters) { + landingFilter = landingFilters[s.FilterIndex].value + } + issueFilter := "open" + if s := m.sections[TabIssues]; s.FilterIndex < len(issueFilters) { + issueFilter = issueFilters[s.FilterIndex].value + } + + return tea.Batch( + m.fetchLandings(landingFilter), + m.fetchIssues(issueFilter), + m.fetchWorkspaces(), + m.fetchWorkflows(), + m.fetchRepos(), + m.fetchNotifications(), + ) +} diff --git a/poc/jjhub-tui/tui/section.go b/poc/jjhub-tui/tui/section.go new file mode 100644 index 00000000..9e1f1da8 --- /dev/null +++ b/poc/jjhub-tui/tui/section.go @@ -0,0 +1,768 @@ +package tui + +import ( + "fmt" + "strings" + "time" + + "github.com/charmbracelet/crush/poc/jjhub-tui/jjhub" +) + +// TabKind identifies a tab type. +type TabKind int + +const ( + TabLandings TabKind = iota + TabIssues + TabWorkspaces + TabWorkflows + TabRepos + TabNotifications +) + +var allTabs = []TabKind{ + TabLandings, TabIssues, TabWorkspaces, + TabWorkflows, TabRepos, TabNotifications, +} + +func (t TabKind) String() string { + switch t { + case TabLandings: + return "Landings" + case TabIssues: + return "Issues" + case TabWorkspaces: + return "Workspaces" + case TabWorkflows: + return "Workflows" + case TabRepos: + return "Repos" + case TabNotifications: + return "Notifications" + default: + return "?" + } +} + +func (t TabKind) Icon() string { + switch t { + case TabLandings: + return "⬆" + case TabIssues: + return "◉" + case TabWorkspaces: + return "▣" + case TabWorkflows: + return "⟳" + case TabRepos: + return "◆" + case TabNotifications: + return "●" + default: + return " " + } +} + +// StateFilter tracks the current filter for list views. +type StateFilter int + +const ( + FilterOpen StateFilter = iota + FilterClosed + FilterAll +) + +var landingFilters = []struct { + label string + value string +}{ + {"Open", "open"}, + {"Merged", "merged"}, + {"Closed", "closed"}, + {"Draft", "draft"}, + {"All", "all"}, +} + +var issueFilters = []struct { + label string + value string +}{ + {"Open", "open"}, + {"Closed", "closed"}, + {"All", "all"}, +} + +// Section holds the state for one tab's content. +type Section struct { + Kind TabKind + Columns []Column + Rows []TableRow + Cursor int + Offset int // scroll offset + Loading bool + Error string + Search string // active search filter + + // Filter state (for tabs that support state filtering). + FilterIndex int + FilterLabel string + + // Raw data for sidebar preview. + Landings []jjhub.Landing + Issues []jjhub.Issue + Repos []jjhub.Repo + Notifications []jjhub.Notification + Workspaces []jjhub.Workspace + Workflows []jjhub.Workflow +} + +// ---- Constructors ---- + +func NewLandingsSection() *Section { + return &Section{ + Kind: TabLandings, + Loading: true, + FilterLabel: "Open", + Columns: []Column{ + {Title: "", Width: 2}, + {Title: "#", Width: 5}, + {Title: "Title", Grow: true}, + {Title: "Author", Width: 14, MinWidth: 80}, + {Title: "Changes", Width: 9, MinWidth: 100}, + {Title: "Target", Width: 10, MinWidth: 90}, + {Title: "Updated", Width: 10}, + }, + } +} + +func NewIssuesSection() *Section { + return &Section{ + Kind: TabIssues, + Loading: true, + FilterLabel: "Open", + Columns: []Column{ + {Title: "", Width: 2}, + {Title: "#", Width: 5}, + {Title: "Title", Grow: true}, + {Title: "Author", Width: 14, MinWidth: 80}, + {Title: "Comments", Width: 10, MinWidth: 90}, + {Title: "Updated", Width: 10}, + }, + } +} + +func NewWorkspacesSection() *Section { + return &Section{ + Kind: TabWorkspaces, + Loading: true, + Columns: []Column{ + {Title: "", Width: 2}, + {Title: "Name", Width: 18}, + {Title: "Status", Width: 10}, + {Title: "Persistence", Width: 14, MinWidth: 90}, + {Title: "SSH", Grow: true, MinWidth: 100}, + {Title: "Idle", Width: 8, MinWidth: 80}, + {Title: "Updated", Width: 10}, + }, + } +} + +func NewWorkflowsSection() *Section { + return &Section{ + Kind: TabWorkflows, + Loading: true, + Columns: []Column{ + {Title: "", Width: 2}, + {Title: "Name", Width: 18}, + {Title: "Path", Grow: true}, + {Title: "Active", Width: 8}, + {Title: "Updated", Width: 10}, + }, + } +} + +func NewReposSection() *Section { + return &Section{ + Kind: TabRepos, + Loading: true, + Columns: []Column{ + {Title: "Name", Width: 20}, + {Title: "Description", Grow: true}, + {Title: "Issues", Width: 8, MinWidth: 80}, + {Title: "Visibility", Width: 10, MinWidth: 90}, + {Title: "Updated", Width: 10}, + }, + } +} + +func NewNotificationsSection() *Section { + return &Section{ + Kind: TabNotifications, + Loading: true, + Columns: []Column{ + {Title: "", Width: 2}, + {Title: "Type", Width: 12}, + {Title: "Title", Grow: true}, + {Title: "Repo", Width: 16, MinWidth: 80}, + {Title: "Updated", Width: 10}, + }, + } +} + +// ---- Build rows from data ---- + +func (s *Section) BuildLandingRows(landings []jjhub.Landing) { + s.Landings = landings + s.Rows = make([]TableRow, 0, len(landings)) + for _, l := range landings { + if s.Search != "" && !matchesSearch(l.Title, s.Search) { + continue + } + s.Rows = append(s.Rows, TableRow{ + Cells: []string{ + landingStateIcon(l.State), + fmt.Sprintf("#%d", l.Number), + l.Title, + l.Author.Login, + fmt.Sprintf("%d", len(l.ChangeIDs)), + l.TargetBookmark, + relativeTime(l.UpdatedAt), + }, + }) + } + s.Loading = false + s.Error = "" + s.clampCursor() +} + +func (s *Section) BuildIssueRows(issues []jjhub.Issue) { + s.Issues = issues + s.Rows = make([]TableRow, 0, len(issues)) + for _, iss := range issues { + if s.Search != "" && !matchesSearch(iss.Title, s.Search) { + continue + } + s.Rows = append(s.Rows, TableRow{ + Cells: []string{ + issueStateIcon(iss.State), + fmt.Sprintf("#%d", iss.Number), + iss.Title, + iss.Author.Login, + fmt.Sprintf("%d", iss.CommentCount), + relativeTime(iss.UpdatedAt), + }, + }) + } + s.Loading = false + s.Error = "" + s.clampCursor() +} + +func (s *Section) BuildWorkspaceRows(workspaces []jjhub.Workspace) { + s.Workspaces = workspaces + s.Rows = make([]TableRow, 0, len(workspaces)) + for _, w := range workspaces { + name := w.Name + if name == "" { + name = dimStyle.Render("(unnamed)") + } + if s.Search != "" && !matchesSearch(name, s.Search) { + continue + } + ssh := "-" + if w.SSHHost != nil && *w.SSHHost != "" { + ssh = *w.SSHHost + } + idle := "-" + if w.IdleTimeoutSeconds > 0 { + idle = fmt.Sprintf("%dm", w.IdleTimeoutSeconds/60) + } + s.Rows = append(s.Rows, TableRow{ + Cells: []string{ + workspaceStatusIcon(w.Status), + name, + w.Status, + w.Persistence, + ssh, + idle, + relativeTime(w.UpdatedAt), + }, + }) + } + s.Loading = false + s.Error = "" + s.clampCursor() +} + +func (s *Section) BuildWorkflowRows(workflows []jjhub.Workflow) { + s.Workflows = workflows + s.Rows = make([]TableRow, 0, len(workflows)) + for _, wf := range workflows { + if s.Search != "" && !matchesSearch(wf.Name, s.Search) { + continue + } + active := closedStyle.Render("✗") + if wf.IsActive { + active = openStyle.Render("✓") + } + s.Rows = append(s.Rows, TableRow{ + Cells: []string{ + workflowIcon(wf.IsActive), + wf.Name, + wf.Path, + active, + relativeTime(wf.UpdatedAt), + }, + }) + } + s.Loading = false + s.Error = "" + s.clampCursor() +} + +func (s *Section) BuildRepoRows(repos []jjhub.Repo) { + s.Repos = repos + s.Rows = make([]TableRow, 0, len(repos)) + for _, r := range repos { + desc := r.Description + if desc == "" { + desc = dimStyle.Render("(no description)") + } + if s.Search != "" && !matchesSearch(r.Name, s.Search) { + continue + } + vis := openStyle.Render("public") + if !r.IsPublic { + vis = closedStyle.Render("private") + } + s.Rows = append(s.Rows, TableRow{ + Cells: []string{ + r.Name, + desc, + fmt.Sprintf("%d", r.NumIssues), + vis, + relativeTime(r.UpdatedAt.Format(time.RFC3339)), + }, + }) + } + s.Loading = false + s.Error = "" + s.clampCursor() +} + +func (s *Section) BuildNotificationRows(notifs []jjhub.Notification) { + s.Notifications = notifs + s.Rows = make([]TableRow, 0, len(notifs)) + for _, n := range notifs { + if s.Search != "" && !matchesSearch(n.Title, s.Search) { + continue + } + indicator := dimStyle.Render("○") + if n.Unread { + indicator = openStyle.Render("●") + } + s.Rows = append(s.Rows, TableRow{ + Cells: []string{ + indicator, + n.Type, + n.Title, + n.RepoName, + relativeTime(n.UpdatedAt), + }, + }) + } + s.Loading = false + s.Error = "" + s.clampCursor() +} + +func (s *Section) SetError(err error) { + s.Loading = false + s.Error = err.Error() +} + +// ---- Navigation ---- + +func (s *Section) CursorUp() { if s.Cursor > 0 { s.Cursor-- } } +func (s *Section) CursorDown() { if s.Cursor < len(s.Rows)-1 { s.Cursor++ } } +func (s *Section) GotoTop() { s.Cursor = 0 } +func (s *Section) GotoBottom() { if len(s.Rows) > 0 { s.Cursor = len(s.Rows) - 1 } } +func (s *Section) PageDown(pageSize int) { + s.Cursor += pageSize + if s.Cursor >= len(s.Rows) { + s.Cursor = len(s.Rows) - 1 + } + if s.Cursor < 0 { + s.Cursor = 0 + } +} +func (s *Section) PageUp(pageSize int) { + s.Cursor -= pageSize + if s.Cursor < 0 { + s.Cursor = 0 + } +} + +func (s *Section) clampCursor() { + if s.Cursor >= len(s.Rows) { + s.Cursor = len(s.Rows) - 1 + } + if s.Cursor < 0 { + s.Cursor = 0 + } +} + +// ---- Rendering ---- + +func (s *Section) ViewTable(width, height int) string { + if s.Loading { + return spinnerStyle.Render(" ⟳ Loading...") + } + if s.Error != "" { + return errorStyle.Render(" ✗ " + s.Error) + } + rendered, newOffset := RenderTable(s.Columns, s.Rows, s.Cursor, s.Offset, width, height) + s.Offset = newOffset + return rendered +} + +// ---- Sidebar preview content ---- + +func (s *Section) PreviewContent(width int) string { + if len(s.Rows) == 0 || s.Cursor < 0 { + return emptyStyle.Render("Nothing selected") + } + + wrapWidth := width - 6 // account for sidebar padding + if wrapWidth < 20 { + wrapWidth = 20 + } + + switch s.Kind { + case TabLandings: + if s.Cursor < len(s.Landings) { + return renderLandingPreview(s.Landings[s.Cursor], wrapWidth) + } + case TabIssues: + if s.Cursor < len(s.Issues) { + return renderIssuePreview(s.Issues[s.Cursor], wrapWidth) + } + case TabWorkspaces: + if s.Cursor < len(s.Workspaces) { + return renderWorkspacePreview(s.Workspaces[s.Cursor]) + } + case TabWorkflows: + if s.Cursor < len(s.Workflows) { + return renderWorkflowPreview(s.Workflows[s.Cursor]) + } + case TabRepos: + if s.Cursor < len(s.Repos) { + return renderRepoPreview(s.Repos[s.Cursor]) + } + case TabNotifications: + if s.Cursor < len(s.Notifications) { + return renderNotificationPreview(s.Notifications[s.Cursor]) + } + } + return emptyStyle.Render("Nothing selected") +} + +// ---- Preview renderers ---- + +func renderLandingPreview(l jjhub.Landing, wrap int) string { + var b strings.Builder + b.WriteString(sidebarTitleStyle.Render(l.Title)) + b.WriteString("\n") + b.WriteString(sidebarSubtitleStyle.Render(fmt.Sprintf("Landing #%d", l.Number))) + b.WriteString("\n\n") + + b.WriteString(fieldRow("State", landingStateIcon(l.State)+" "+l.State)) + b.WriteString(fieldRow("Author", l.Author.Login)) + b.WriteString(fieldRow("Target", sidebarTagStyle.Render(l.TargetBookmark))) + b.WriteString(fieldRow("Stack", fmt.Sprintf("%d change(s)", len(l.ChangeIDs)))) + if l.ConflictStatus != "" && l.ConflictStatus != "unknown" { + b.WriteString(fieldRow("Conflicts", l.ConflictStatus)) + } + b.WriteString(fieldRow("Created", relativeTime(l.CreatedAt))) + b.WriteString(fieldRow("Updated", relativeTime(l.UpdatedAt))) + + if len(l.ChangeIDs) > 0 { + b.WriteString("\n") + b.WriteString(sidebarSectionStyle.Render("Changes")) + b.WriteString("\n") + for _, cid := range l.ChangeIDs { + short := cid + if len(short) > 12 { + short = short[:12] + } + b.WriteString(" " + dimStyle.Render(short) + "\n") + } + } + + if l.Body != "" { + b.WriteString("\n") + b.WriteString(sidebarSectionStyle.Render("Description")) + b.WriteString("\n") + b.WriteString(sidebarBodyStyle.Render(wordWrap(l.Body, wrap))) + b.WriteString("\n") + } + return b.String() +} + +func renderIssuePreview(iss jjhub.Issue, wrap int) string { + var b strings.Builder + b.WriteString(sidebarTitleStyle.Render(iss.Title)) + b.WriteString("\n") + b.WriteString(sidebarSubtitleStyle.Render(fmt.Sprintf("Issue #%d", iss.Number))) + b.WriteString("\n\n") + + b.WriteString(fieldRow("State", issueStateIcon(iss.State)+" "+iss.State)) + b.WriteString(fieldRow("Author", iss.Author.Login)) + b.WriteString(fieldRow("Comments", fmt.Sprintf("%d", iss.CommentCount))) + if len(iss.Assignees) > 0 { + names := make([]string, len(iss.Assignees)) + for i, a := range iss.Assignees { + names[i] = a.Login + } + b.WriteString(fieldRow("Assignees", strings.Join(names, ", "))) + } + if len(iss.Labels) > 0 { + var labels []string + for _, l := range iss.Labels { + labels = append(labels, sidebarTagStyle.Render(l.Name)) + } + b.WriteString(fieldRow("Labels", strings.Join(labels, " "))) + } + b.WriteString(fieldRow("Created", relativeTime(iss.CreatedAt))) + b.WriteString(fieldRow("Updated", relativeTime(iss.UpdatedAt))) + + if iss.Body != "" { + b.WriteString("\n") + b.WriteString(sidebarSectionStyle.Render("Description")) + b.WriteString("\n") + b.WriteString(sidebarBodyStyle.Render(wordWrap(iss.Body, wrap))) + b.WriteString("\n") + } + return b.String() +} + +func renderWorkspacePreview(w jjhub.Workspace) string { + var b strings.Builder + name := w.Name + if name == "" { + name = "(unnamed)" + } + b.WriteString(sidebarTitleStyle.Render(name)) + b.WriteString("\n") + b.WriteString(sidebarSubtitleStyle.Render("Workspace")) + b.WriteString("\n\n") + + b.WriteString(fieldRow("Status", workspaceStatusIcon(w.Status)+" "+w.Status)) + b.WriteString(fieldRow("Persistence", w.Persistence)) + if w.SSHHost != nil && *w.SSHHost != "" { + b.WriteString(fieldRow("SSH", *w.SSHHost)) + } + if w.IdleTimeoutSeconds > 0 { + b.WriteString(fieldRow("Idle timeout", fmt.Sprintf("%d min", w.IdleTimeoutSeconds/60))) + } + if w.IsFork { + b.WriteString(fieldRow("Fork", "yes")) + } + if w.SuspendedAt != nil { + b.WriteString(fieldRow("Suspended", relativeTime(*w.SuspendedAt))) + } + b.WriteString(fieldRow("VM ID", truncateID(w.FreestyleVMID))) + b.WriteString(fieldRow("Created", relativeTime(w.CreatedAt))) + b.WriteString(fieldRow("Updated", relativeTime(w.UpdatedAt))) + return b.String() +} + +func renderWorkflowPreview(wf jjhub.Workflow) string { + var b strings.Builder + b.WriteString(sidebarTitleStyle.Render(wf.Name)) + b.WriteString("\n") + b.WriteString(sidebarSubtitleStyle.Render("Workflow")) + b.WriteString("\n\n") + + active := closedStyle.Render("inactive") + if wf.IsActive { + active = openStyle.Render("active") + } + b.WriteString(fieldRow("Status", active)) + b.WriteString(fieldRow("Path", wf.Path)) + b.WriteString(fieldRow("Created", relativeTime(wf.CreatedAt))) + b.WriteString(fieldRow("Updated", relativeTime(wf.UpdatedAt))) + return b.String() +} + +func renderRepoPreview(r jjhub.Repo) string { + var b strings.Builder + b.WriteString(sidebarTitleStyle.Render(r.Name)) + b.WriteString("\n") + if r.FullName != "" { + b.WriteString(sidebarSubtitleStyle.Render(r.FullName)) + b.WriteString("\n") + } + if r.Description != "" { + b.WriteString("\n") + b.WriteString(sidebarBodyStyle.Render(r.Description)) + b.WriteString("\n") + } + b.WriteString("\n") + visibility := openStyle.Render("public") + if !r.IsPublic { + visibility = closedStyle.Render("private") + } + b.WriteString(fieldRow("Visibility", visibility)) + b.WriteString(fieldRow("Default", sidebarTagStyle.Render(r.DefaultBookmark))) + b.WriteString(fieldRow("Issues", fmt.Sprintf("%d", r.NumIssues))) + b.WriteString(fieldRow("Stars", fmt.Sprintf("%d", r.NumStars))) + b.WriteString(fieldRow("Updated", relativeTime(r.UpdatedAt.Format(time.RFC3339)))) + return b.String() +} + +func renderNotificationPreview(n jjhub.Notification) string { + var b strings.Builder + b.WriteString(sidebarTitleStyle.Render(n.Title)) + b.WriteString("\n\n") + b.WriteString(fieldRow("Type", n.Type)) + b.WriteString(fieldRow("Repo", n.RepoName)) + status := dimStyle.Render("read") + if n.Unread { + status = openStyle.Render("unread") + } + b.WriteString(fieldRow("Status", status)) + b.WriteString(fieldRow("Updated", relativeTime(n.UpdatedAt))) + return b.String() +} + +// ---- Helpers ---- + +func fieldRow(label, value string) string { + return sidebarLabelStyle.Width(14).Render(label) + " " + sidebarValueStyle.Render(value) + "\n" +} + +func landingStateIcon(state string) string { + switch state { + case "open": + return openStyle.Render("⬆") + case "merged": + return mergedStyle.Render("✓") + case "closed": + return closedStyle.Render("✗") + case "draft": + return draftStyle.Render("◌") + default: + return dimStyle.Render("?") + } +} + +func issueStateIcon(state string) string { + switch state { + case "open": + return openStyle.Render("◉") + case "closed": + return closedStyle.Render("◎") + default: + return dimStyle.Render("?") + } +} + +func workspaceStatusIcon(status string) string { + switch status { + case "running": + return runningStyle.Render("●") + case "pending": + return pendingStyle.Render("◌") + case "stopped": + return stoppedStyle.Render("○") + case "failed": + return failedStyle.Render("✗") + default: + return dimStyle.Render("?") + } +} + +func workflowIcon(active bool) string { + if active { + return openStyle.Render("⟳") + } + return dimStyle.Render("○") +} + +func truncateID(id string) string { + if len(id) > 12 { + return id[:12] + "…" + } + return id +} + +func relativeTime(ts string) string { + t, err := time.Parse(time.RFC3339, ts) + if err != nil { + t, err = time.Parse(time.RFC3339Nano, ts) + if err != nil { + return ts + } + } + d := time.Since(t) + switch { + case d < 0: + return "just now" + case d < time.Minute: + return "just now" + case d < time.Hour: + return fmt.Sprintf("%dm ago", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh ago", int(d.Hours())) + case d < 7*24*time.Hour: + return fmt.Sprintf("%dd ago", int(d.Hours()/24)) + case d < 30*24*time.Hour: + return fmt.Sprintf("%dw ago", int(d.Hours()/(24*7))) + case d < 365*24*time.Hour: + return fmt.Sprintf("%dmo ago", int(d.Hours()/(24*30))) + default: + return fmt.Sprintf("%dy ago", int(d.Hours()/(24*365))) + } +} + +func wordWrap(s string, width int) string { + if width <= 0 { + return s + } + // Respect existing newlines. + paragraphs := strings.Split(s, "\n") + var result []string + for _, p := range paragraphs { + if strings.TrimSpace(p) == "" { + result = append(result, "") + continue + } + words := strings.Fields(p) + var lines []string + var current []string + lineLen := 0 + for _, w := range words { + wl := len(w) + if lineLen+wl+len(current) > width && len(current) > 0 { + lines = append(lines, strings.Join(current, " ")) + current = nil + lineLen = 0 + } + current = append(current, w) + lineLen += wl + } + if len(current) > 0 { + lines = append(lines, strings.Join(current, " ")) + } + result = append(result, strings.Join(lines, "\n")) + } + return strings.Join(result, "\n") +} + +func matchesSearch(text, query string) bool { + return strings.Contains( + strings.ToLower(text), + strings.ToLower(query), + ) +} diff --git a/poc/jjhub-tui/tui/styles.go b/poc/jjhub-tui/tui/styles.go new file mode 100644 index 00000000..6e2b0051 --- /dev/null +++ b/poc/jjhub-tui/tui/styles.go @@ -0,0 +1,225 @@ +package tui + +import ( + "charm.land/lipgloss/v2" +) + +// Palette — dark theme inspired by gh-dash / Tailwind slate. +var ( + purple = lipgloss.Color("#7C3AED") + green = lipgloss.Color("#10B981") + red = lipgloss.Color("#EF4444") + yellow = lipgloss.Color("#F59E0B") + blue = lipgloss.Color("#3B82F6") + violet = lipgloss.Color("#8B5CF6") + slate50 = lipgloss.Color("#F8FAFC") + slate300 = lipgloss.Color("#CBD5E1") + slate400 = lipgloss.Color("#94A3B8") + slate500 = lipgloss.Color("#64748B") + slate600 = lipgloss.Color("#475569") + slate700 = lipgloss.Color("#334155") + slate800 = lipgloss.Color("#1E293B") + slate900 = lipgloss.Color("#0F172A") +) + +// ---- Header / Brand ---- + +var ( + logoStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(purple) + + repoNameStyle = lipgloss.NewStyle(). + Foreground(slate300). + Bold(true) + + headerBarStyle = lipgloss.NewStyle(). + BorderBottom(true). + BorderStyle(lipgloss.NormalBorder()). + BorderBottomForeground(slate700). + Padding(0, 1) +) + +// ---- Tab bar ---- + +var ( + activeTabStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(purple). + BorderBottom(true). + BorderStyle(lipgloss.ThickBorder()). + BorderBottomForeground(purple). + Padding(0, 1) + + activeTabNumStyle = lipgloss.NewStyle(). + Foreground(purple). + Bold(true) + + inactiveTabStyle = lipgloss.NewStyle(). + Foreground(slate500). + Padding(0, 1) + + inactiveTabNumStyle = lipgloss.NewStyle(). + Foreground(slate600) + + tabCountStyle = lipgloss.NewStyle(). + Foreground(slate500) + + tabBarStyle = lipgloss.NewStyle(). + BorderBottom(true). + BorderStyle(lipgloss.NormalBorder()). + BorderBottomForeground(slate700) +) + +// ---- Table ---- + +var ( + cursorStyle = lipgloss.NewStyle(). + Foreground(purple). + Bold(true) + + selectedRowStyle = lipgloss.NewStyle(). + Background(slate800) + + normalRowStyle = lipgloss.NewStyle() + + altRowStyle = lipgloss.NewStyle(). + Background(lipgloss.Color("#141B2D")) + + headerColStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(slate500). + Underline(true) + + scrollInfoStyle = lipgloss.NewStyle(). + Foreground(slate600). + Italic(true) +) + +// ---- Status badges ---- + +var ( + openStyle = lipgloss.NewStyle().Foreground(green) + closedStyle = lipgloss.NewStyle().Foreground(red) + mergedStyle = lipgloss.NewStyle().Foreground(violet) + draftStyle = lipgloss.NewStyle().Foreground(slate500) + + runningStyle = lipgloss.NewStyle().Foreground(green) + stoppedStyle = lipgloss.NewStyle().Foreground(slate500) + pendingStyle = lipgloss.NewStyle().Foreground(yellow) + failedStyle = lipgloss.NewStyle().Foreground(red) +) + +// ---- Sidebar / preview ---- + +var ( + sidebarStyle = lipgloss.NewStyle(). + BorderLeft(true). + BorderStyle(lipgloss.NormalBorder()). + BorderLeftForeground(slate700). + PaddingLeft(2). + PaddingRight(1). + PaddingTop(1) + + sidebarTitleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(slate50) + + sidebarSubtitleStyle = lipgloss.NewStyle(). + Foreground(slate400) + + sidebarLabelStyle = lipgloss.NewStyle(). + Foreground(slate500) + + sidebarValueStyle = lipgloss.NewStyle(). + Foreground(slate300) + + sidebarSectionStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(slate400). + MarginTop(1) + + sidebarBodyStyle = lipgloss.NewStyle(). + Foreground(slate400) + + sidebarTagStyle = lipgloss.NewStyle(). + Foreground(violet). + Bold(true) +) + +// ---- Footer ---- + +var ( + footerStyle = lipgloss.NewStyle(). + Foreground(slate500). + BorderTop(true). + BorderStyle(lipgloss.NormalBorder()). + BorderTopForeground(slate700). + Padding(0, 1) + + footerKeyStyle = lipgloss.NewStyle(). + Foreground(slate400). + Bold(true) + + footerDescStyle = lipgloss.NewStyle(). + Foreground(slate600) + + footerFilterStyle = lipgloss.NewStyle(). + Foreground(yellow). + Bold(true) + + footerSepStyle = lipgloss.NewStyle(). + Foreground(slate700) +) + +// ---- Search bar ---- + +var ( + searchBarStyle = lipgloss.NewStyle(). + BorderBottom(true). + BorderStyle(lipgloss.NormalBorder()). + BorderBottomForeground(purple). + Padding(0, 1) + + searchPromptStyle = lipgloss.NewStyle(). + Foreground(purple). + Bold(true) + + searchInputStyle = lipgloss.NewStyle(). + Foreground(slate300) +) + +// ---- General ---- + +var ( + titleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(slate50) + + dimStyle = lipgloss.NewStyle(). + Foreground(slate600) + + errorStyle = lipgloss.NewStyle(). + Foreground(red) + + spinnerStyle = lipgloss.NewStyle(). + Foreground(purple) + + emptyStyle = lipgloss.NewStyle(). + Foreground(slate500). + Italic(true) + + // Help overlay + helpKeyStyle = lipgloss.NewStyle(). + Foreground(slate300). + Bold(true). + Width(18) + + helpDescStyle = lipgloss.NewStyle(). + Foreground(slate500) + + helpSectionStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(purple). + MarginTop(1) +) diff --git a/poc/jjhub-tui/tui/table.go b/poc/jjhub-tui/tui/table.go new file mode 100644 index 00000000..aa857da8 --- /dev/null +++ b/poc/jjhub-tui/tui/table.go @@ -0,0 +1,202 @@ +package tui + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" +) + +// Column defines a table column with optional responsive breakpoint. +type Column struct { + Title string + Width int // fixed width, 0 = auto + Grow bool // take remaining space + MinWidth int // hide column below this terminal width (0 = always show) +} + +// TableRow is one row in the table. +type TableRow struct { + Cells []string +} + +// RenderTable draws a table with header, rows, cursor, and scroll. +// Returns the rendered string. +func RenderTable( + columns []Column, + rows []TableRow, + cursor int, + offset int, + width int, + height int, +) (rendered string, newOffset int) { + if len(rows) == 0 { + return emptyStyle.Render(" No items found."), offset + } + + // Filter visible columns based on terminal width. + type visCol struct { + col Column + index int + } + var visCols []visCol + for i, c := range columns { + if c.MinWidth > 0 && width < c.MinWidth { + continue + } + visCols = append(visCols, visCol{col: c, index: i}) + } + if len(visCols) == 0 { + return "", offset + } + + // Compute column widths. + colWidths := make(map[int]int) + fixedTotal := 0 + growCount := 0 + for _, vc := range visCols { + if vc.col.Grow { + growCount++ + } else { + w := vc.col.Width + if w == 0 { + w = len(vc.col.Title) + 2 + } + colWidths[vc.index] = w + fixedTotal += w + } + } + separators := len(visCols) - 1 + remaining := width - fixedTotal - separators - 2 // -2 for cursor column + if remaining < 0 { + remaining = 0 + } + if growCount > 0 { + per := remaining / growCount + if per < 10 { + per = 10 + } + for _, vc := range visCols { + if vc.col.Grow { + colWidths[vc.index] = per + } + } + } + + var b strings.Builder + + // Header row. + b.WriteString(" ") // cursor column placeholder + var hcells []string + for _, vc := range visCols { + hcells = append(hcells, headerColStyle.Render(padRight(vc.col.Title, colWidths[vc.index]))) + } + b.WriteString(strings.Join(hcells, " ")) + b.WriteString("\n") + + // Viewport rows. + visibleRows := height - 2 // header + scroll info + if visibleRows < 1 { + visibleRows = 1 + } + + // Adjust offset so cursor is visible. + if cursor < offset { + offset = cursor + } + if cursor >= offset+visibleRows { + offset = cursor - visibleRows + 1 + } + newOffset = offset + + for i := offset; i < len(rows) && i < offset+visibleRows; i++ { + row := rows[i] + + // Cursor indicator. + indicator := " " + if i == cursor { + indicator = cursorStyle.Render("> ") + } + + var cells []string + for _, vc := range visCols { + cell := "" + if vc.index < len(row.Cells) { + cell = row.Cells[vc.index] + } + cells = append(cells, padOrTruncate(cell, colWidths[vc.index])) + } + line := indicator + strings.Join(cells, " ") + + if i == cursor { + line = selectedRowStyle.Width(width).Render(line) + } else if (i-offset)%2 == 1 { + line = altRowStyle.Width(width).Render(line) + } + b.WriteString(line) + if i < offset+visibleRows-1 && i < len(rows)-1 { + b.WriteString("\n") + } + } + + // Scroll indicator. + if len(rows) > visibleRows { + pos := fmt.Sprintf(" %d/%d", cursor+1, len(rows)) + scrollLine := "\n" + strings.Repeat(" ", width-lipgloss.Width(pos)-1) + scrollInfoStyle.Render(pos) + b.WriteString(scrollLine) + } + + return b.String(), newOffset +} + +// padRight pads a string with spaces to the given width. +func padRight(s string, width int) string { + if width <= 0 { + return "" + } + visible := lipgloss.Width(s) + if visible >= width { + return s + } + return s + strings.Repeat(" ", width-visible) +} + +// padOrTruncate pads or truncates a string to exactly width visible characters. +func padOrTruncate(s string, width int) string { + if width <= 0 { + return "" + } + visible := lipgloss.Width(s) + if visible > width { + // Truncate: strip ANSI, take runes, add ellipsis. + plain := stripAnsi(s) + runes := []rune(plain) + if len(runes) > width-1 && width > 1 { + return string(runes[:width-1]) + "…" + } + if len(runes) > width { + return string(runes[:width]) + } + return plain + } + return s + strings.Repeat(" ", width-visible) +} + +func stripAnsi(s string) string { + var b strings.Builder + inEsc := false + for _, r := range s { + if r == '\x1b' { + inEsc = true + continue + } + if inEsc { + if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') { + inEsc = false + } + continue + } + b.WriteRune(r) + } + return b.String() +} diff --git a/tests/fixtures/memory-test.db b/tests/fixtures/memory-test.db new file mode 100644 index 00000000..f3ee1afd Binary files /dev/null and b/tests/fixtures/memory-test.db differ diff --git a/tests/runs_realtime_e2e_test.go b/tests/runs_realtime_e2e_test.go new file mode 100644 index 00000000..1bcd493f --- /dev/null +++ b/tests/runs_realtime_e2e_test.go @@ -0,0 +1,475 @@ +// Package tests contains end-to-end integration tests for the Smithers TUI. +// +// runs_realtime_e2e_test.go tests the real-time SSE streaming integration in +// RunsView using a mock httptest.Server. These tests verify the full data-flow +// without starting a real Smithers server. +package tests + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/crush/internal/smithers" + "github.com/charmbracelet/crush/internal/ui/views" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ============================================================= +// Mock server +// ============================================================= + +// mockSSEServer is a controllable httptest.Server that serves: +// - GET /health → 200 +// - GET /v1/runs → JSON array of the current run slice +// - GET /v1/events → long-lived SSE stream driven by eventPush +type mockSSEServer struct { + srv *httptest.Server + eventPush chan string + mu sync.Mutex + runs []smithers.RunSummary + sseConnected chan struct{} + sseDone chan struct{} + listRunsHits atomic.Int64 + disableEvents bool // return 404 for /v1/events when true +} + +func newMockSSEServer(t *testing.T, initial []smithers.RunSummary) *mockSSEServer { + t.Helper() + ms := &mockSSEServer{ + eventPush: make(chan string, 16), + runs: initial, + sseConnected: make(chan struct{}, 4), + sseDone: make(chan struct{}, 4), + } + ms.srv = httptest.NewServer(http.HandlerFunc(ms.handle)) + t.Cleanup(ms.srv.Close) + return ms +} + +func (ms *mockSSEServer) handle(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/health": + w.WriteHeader(http.StatusOK) + + case "/v1/runs": + ms.listRunsHits.Add(1) + ms.mu.Lock() + data, _ := json.Marshal(ms.runs) + ms.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(data) + + case "/v1/events": + if ms.disableEvents { + http.NotFound(w, r) + return + } + ms.handleSSE(w, r) + + default: + http.NotFound(w, r) + } +} + +func (ms *mockSSEServer) handleSSE(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.WriteHeader(http.StatusOK) + f, ok := w.(http.Flusher) + if !ok { + return + } + f.Flush() + + select { + case ms.sseConnected <- struct{}{}: + default: + } + + for { + select { + case frame, open := <-ms.eventPush: + if !open { + return + } + _, _ = fmt.Fprint(w, frame) + f.Flush() + case <-r.Context().Done(): + select { + case ms.sseDone <- struct{}{}: + default: + } + return + } + } +} + +func (ms *mockSSEServer) pushEvent(eventType, runID, status string, seq int) { + payload, _ := json.Marshal(smithers.RunEvent{ + Type: eventType, + RunID: runID, + Status: status, + TimestampMs: time.Now().UnixMilli(), + }) + ms.eventPush <- fmt.Sprintf("event: smithers\ndata: %s\nid: %d\n\n", payload, seq) +} + +func (ms *mockSSEServer) waitConnected(t *testing.T, timeout time.Duration) { + t.Helper() + select { + case <-ms.sseConnected: + case <-time.After(timeout): + t.Fatal("timeout waiting for SSE connection to open") + } +} + +func (ms *mockSSEServer) waitDisconnected(t *testing.T, timeout time.Duration) { + t.Helper() + select { + case <-ms.sseDone: + case <-time.After(timeout): + t.Fatal("timeout waiting for SSE connection to drop") + } +} + +func (ms *mockSSEServer) newClient() *smithers.Client { + c := smithers.NewClient( + smithers.WithAPIURL(ms.srv.URL), + smithers.WithHTTPClient(ms.srv.Client()), + ) + c.SetServerUp(true) + return c +} + +// ============================================================= +// Drive helpers +// ============================================================= + +// execBatch runs cmd (possibly a tea.Batch) and collects resulting messages. +// Child cmds that block longer than perCmdTimeout are skipped so that slow +// SSE-blocking cmds do not stall the test. +func execBatch(cmd tea.Cmd, perCmdTimeout time.Duration) []tea.Msg { + if cmd == nil { + return nil + } + msg := cmd() + batch, ok := msg.(tea.BatchMsg) + if !ok { + return []tea.Msg{msg} + } + var out []tea.Msg + for _, c := range batch { + if c == nil { + continue + } + done := make(chan tea.Msg, 1) + go func(c tea.Cmd) { done <- c() }(c) + select { + case m := <-done: + out = append(out, m) + case <-time.After(perCmdTimeout): + // Blocking cmd (e.g. WaitForAllEvents with no events yet) — skip. + } + } + return out +} + +// driveView applies a slice of messages to v, collecting any cmds returned. +func driveView(v *views.RunsView, msgs []tea.Msg) (*views.RunsView, []tea.Cmd) { + var cmds []tea.Cmd + for _, msg := range msgs { + if msg == nil { + continue + } + updated, cmd := v.Update(msg) + v = updated.(*views.RunsView) + if cmd != nil { + cmds = append(cmds, cmd) + } + } + return v, cmds +} + +// runCmdsWithTimeout executes a slice of cmds concurrently and returns all +// messages that arrive within timeout. +func runCmdsWithTimeout(cmds []tea.Cmd, timeout time.Duration) []tea.Msg { + if len(cmds) == 0 { + return nil + } + ch := make(chan tea.Msg, len(cmds)*2) + deadline := time.After(timeout) + for _, cmd := range cmds { + c := cmd + go func() { + select { + case ch <- c(): + case <-deadline: + } + }() + } + var out []tea.Msg + timer := time.NewTimer(timeout) + defer timer.Stop() + for range cmds { + select { + case msg := <-ch: + out = append(out, msg) + case <-timer.C: + return out + } + } + return out +} + +// cancelViewAtEnd presses Esc on v at test cleanup time so the view context is +// cancelled before the httptest.Server tries to shut down. This prevents the +// server from blocking in Close() waiting for open SSE connections. +func cancelViewAtEnd(t *testing.T, v *views.RunsView) { + t.Helper() + t.Cleanup(func() { + if ctx := v.Ctx(); ctx != nil && ctx.Err() == nil { + v.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) //nolint:errcheck + } + // Give the SSE goroutine a moment to see the cancellation. + time.Sleep(50 * time.Millisecond) + }) +} + +// ============================================================= +// TestRunsRealtimeSSE +// ============================================================= + +// TestRunsRealtimeSSE verifies the full SSE round-trip: +// 1. RunsView subscribes to /v1/events on Init. +// 2. A RunStatusChanged event updates the run status in-place without +// any user keypress. +// 3. The "● Live" indicator is visible in the rendered header. +func TestRunsRealtimeSSE(t *testing.T) { + initial := []smithers.RunSummary{ + {RunID: "abc123", WorkflowName: "wf-alpha", Status: smithers.RunStatusRunning}, + {RunID: "def456", WorkflowName: "wf-beta", Status: smithers.RunStatusRunning}, + } + ms := newMockSSEServer(t, initial) + client := ms.newClient() + + v := views.NewRunsView(client) + cancelViewAtEnd(t, v) // ensures context is cancelled before server.Close() + + // Init: loads runs and starts SSE stream. + initMsgs := execBatch(v.Init(), 3*time.Second) + v, pendingCmds := driveView(v, initMsgs) + + // Wait for SSE connection to be established on the server side. + ms.waitConnected(t, 5*time.Second) + + // Initial load should be complete. + assert.False(t, v.Loading(), "loading should be false after initial load") + assert.Equal(t, "live", v.StreamMode(), "stream mode should be 'live'") + + // Check "● Live" appears in the header. + v.SetSize(120, 40) + assert.Contains(t, v.View(), "● Live") + + // Push a status-change event. + ms.pushEvent("RunStatusChanged", "abc123", "finished", 1) + + // Run the WaitForAllEvents cmd; it should unblock now that there is an event. + eventMsgs := runCmdsWithTimeout(pendingCmds, 3*time.Second) + v, _ = driveView(v, eventMsgs) + + // abc123 should now be "finished". + var found bool + for _, r := range v.Runs() { + if r.RunID == "abc123" { + assert.Equal(t, smithers.RunStatusFinished, r.Status, + "abc123 should reflect the SSE status change") + found = true + } + } + assert.True(t, found, "abc123 must still appear in the run list") +} + +// ============================================================= +// TestRunsView_PollFallback +// ============================================================= + +// TestRunsView_PollFallback verifies the auto-poll fallback path: +// - When /v1/events returns 404, streamMode is set to "polling". +// - The "○ Polling" indicator appears in the rendered header. +// - GET /v1/runs is called at least once (initial load). +func TestRunsView_PollFallback(t *testing.T) { + ms := newMockSSEServer(t, []smithers.RunSummary{ + {RunID: "run-poll", WorkflowName: "wf-poll", Status: smithers.RunStatusRunning}, + }) + ms.disableEvents = true // 404 on /v1/events → triggers poll fallback + + v := views.NewRunsView(ms.newClient()) + cancelViewAtEnd(t, v) + + initMsgs := execBatch(v.Init(), 3*time.Second) + v, _ = driveView(v, initMsgs) + + assert.Equal(t, "polling", v.StreamMode(), + "stream mode should be 'polling' when /v1/events returns 404") + + v.SetSize(120, 40) + assert.Contains(t, v.View(), "○ Polling", + "polling indicator must appear in the rendered header") + + assert.GreaterOrEqual(t, ms.listRunsHits.Load(), int64(1), + "GET /v1/runs must have been called at least once") +} + +// ============================================================= +// TestRunsView_SSEContextCancelDropsConnection +// ============================================================= + +// TestRunsView_SSEContextCancelDropsConnection verifies that pressing Esc +// cancels the view context and causes the SSE connection to be dropped. +func TestRunsView_SSEContextCancelDropsConnection(t *testing.T) { + ms := newMockSSEServer(t, []smithers.RunSummary{ + {RunID: "r1", WorkflowName: "wf-1", Status: smithers.RunStatusRunning}, + }) + + v := views.NewRunsView(ms.newClient()) + // No cancelViewAtEnd here — we press Esc explicitly in the test body. + + initMsgs := execBatch(v.Init(), 3*time.Second) + v, _ = driveView(v, initMsgs) + + ms.waitConnected(t, 5*time.Second) + + // Simulate pressing Esc — cancels the view context, emits PopViewMsg. + v2, escCmd := v.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + require.NotNil(t, escCmd) + + popMsg := escCmd() + _, ok := popMsg.(views.PopViewMsg) + require.True(t, ok, "Esc should emit PopViewMsg") + + // The SSE connection should drop within 2 s after context cancellation. + ms.waitDisconnected(t, 2*time.Second) + + // View context should be cancelled. + rv := v2.(*views.RunsView) + if ctx := rv.Ctx(); ctx != nil { + assert.Error(t, ctx.Err(), "view context should be cancelled after Esc") + } +} + +// ============================================================= +// TestWaitForAllEvents_IntegrationWithStreamAllEvents +// ============================================================= + +// TestWaitForAllEvents_IntegrationWithStreamAllEvents verifies that +// WaitForAllEvents correctly receives events from StreamAllEvents via a +// real HTTP SSE connection to a mock server. +func TestWaitForAllEvents_IntegrationWithStreamAllEvents(t *testing.T) { + ms := newMockSSEServer(t, nil) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + ch, err := ms.newClient().StreamAllEvents(ctx) + require.NoError(t, err, "StreamAllEvents should succeed against mock server") + + ms.waitConnected(t, 2*time.Second) + + // Push two events. + ms.pushEvent("RunStarted", "r1", "running", 1) + ms.pushEvent("RunFinished", "r1", "finished", 2) + + // Collect two events using the WaitForAllEvents pattern. + var received []smithers.RunEventMsg + for i := 0; i < 2; i++ { + cmd := smithers.WaitForAllEvents(ch) + msg := cmd() + switch m := msg.(type) { + case smithers.RunEventMsg: + received = append(received, m) + case smithers.RunEventDoneMsg: + t.Fatal("stream closed before expected events arrived") + case smithers.RunEventErrorMsg: + t.Fatalf("unexpected stream error: %v", m.Err) + default: + t.Fatalf("unexpected message type %T", msg) + } + } + + cancel() // closes the stream + + require.Len(t, received, 2) + assert.Equal(t, "RunStarted", received[0].Event.Type) + assert.Equal(t, "r1", received[0].RunID) + assert.Equal(t, "RunFinished", received[1].Event.Type) + assert.Equal(t, "finished", received[1].Event.Status) +} + +// ============================================================= +// TestRunsView_SSEStatusUpdateApplied (focused integration test) +// ============================================================= + +// TestRunsView_SSEStatusUpdateApplied directly exercises the Update loop with +// a RunEventMsg and verifies the in-place status patch. +func TestRunsView_SSEStatusUpdateApplied(t *testing.T) { + ms := newMockSSEServer(t, []smithers.RunSummary{ + {RunID: "run-a", WorkflowName: "wf-a", Status: smithers.RunStatusRunning}, + }) + + v := views.NewRunsView(ms.newClient()) + cancelViewAtEnd(t, v) + + initMsgs := execBatch(v.Init(), 3*time.Second) + v, _ = driveView(v, initMsgs) + + ms.waitConnected(t, 5*time.Second) + + require.Len(t, v.Runs(), 1, "initial load should have 1 run") + assert.Equal(t, smithers.RunStatusRunning, v.Runs()[0].Status) + + // Push a finish event directly to the view via a RunEventMsg. + ev := smithers.RunEvent{Type: "RunFinished", RunID: "run-a", Status: "finished"} + v2, _ := v.Update(smithers.RunEventMsg{RunID: "run-a", Event: ev}) + rv := v2.(*views.RunsView) + + require.Len(t, rv.Runs(), 1) + assert.Equal(t, smithers.RunStatusFinished, rv.Runs()[0].Status, + "status should be updated to finished by the RunEventMsg") +} + +// ============================================================= +// TestRunsView_SSENewRunInserted (focused integration test) +// ============================================================= + +// TestRunsView_SSENewRunInserted verifies that a RunStarted event for an +// unknown RunID prepends a stub entry to the runs list. +func TestRunsView_SSENewRunInserted(t *testing.T) { + ms := newMockSSEServer(t, []smithers.RunSummary{ + {RunID: "existing", WorkflowName: "wf-x", Status: smithers.RunStatusRunning}, + }) + + v := views.NewRunsView(ms.newClient()) + cancelViewAtEnd(t, v) + + initMsgs := execBatch(v.Init(), 3*time.Second) + v, _ = driveView(v, initMsgs) + + ms.waitConnected(t, 5*time.Second) + + ev := smithers.RunEvent{Type: "RunStarted", RunID: "brand-new", Status: "running"} + v2, _ := v.Update(smithers.RunEventMsg{RunID: "brand-new", Event: ev}) + rv := v2.(*views.RunsView) + + require.Len(t, rv.Runs(), 2, "stub run should be prepended") + assert.Equal(t, "brand-new", rv.Runs()[0].RunID, "new run at front") + assert.Equal(t, "existing", rv.Runs()[1].RunID, "existing run preserved") +} diff --git a/tests/vhs/active-run-summary.tape b/tests/vhs/active-run-summary.tape new file mode 100644 index 00000000..2a7252e1 --- /dev/null +++ b/tests/vhs/active-run-summary.tape @@ -0,0 +1,19 @@ +# active-run-summary.tape — records the Smithers header in zero-state +# (no active runs, blank run-summary segment). +# Set SMITHERS_TUI_E2E=1 and point apiUrl at a live server to record with real data. +Output tests/vhs/output/active-run-summary.gif + +Set Shell zsh +Set FontSize 14 +Set Width 1200 +Set Height 800 + +Type "CRUSH_GLOBAL_CONFIG=tests/vhs/fixtures CRUSH_GLOBAL_DATA=/tmp/crush-vhs go run ." +Enter +Sleep 5s + +# Capture the SMITHERS header with zero-state (no active runs). +Screenshot tests/vhs/output/active-run-summary-startup.png + +Ctrl+c +Sleep 500ms diff --git a/tests/vhs/approvals-queue.tape b/tests/vhs/approvals-queue.tape new file mode 100644 index 00000000..a95213b3 --- /dev/null +++ b/tests/vhs/approvals-queue.tape @@ -0,0 +1,46 @@ +# Approvals queue — happy path with pending approvals. +# Records the full lifecycle: open view, see populated queue, navigate, refresh, return. +Output tests/vhs/output/approvals-queue.gif +Set Shell zsh +Set FontSize 14 +Set Width 1200 +Set Height 800 + +# Launch TUI (configure a real or mock Smithers API via SMITHERS_API_URL if available) +Type "CRUSH_GLOBAL_CONFIG=tests/vhs/fixtures CRUSH_GLOBAL_DATA=/tmp/crush-vhs-approvals go run ." +Enter +Sleep 3s + +# Open the approvals view via Ctrl+A +Ctrl+a +Sleep 2s + +# Approvals view should be visible +Screenshot tests/vhs/output/approvals-queue-loaded.png + +# Navigate down through the list +Down +Sleep 300ms +Down +Sleep 300ms + +Screenshot tests/vhs/output/approvals-queue-cursor.png + +# Navigate back up +Up +Sleep 300ms + +# Manual refresh with r key +Type "r" +Sleep 2s + +Screenshot tests/vhs/output/approvals-queue-refreshed.png + +# Return to chat view with Escape +Escape +Sleep 1s + +Screenshot tests/vhs/output/approvals-queue-back.png + +Ctrl+c +Sleep 1s diff --git a/tests/vhs/branding-status.tape b/tests/vhs/branding-status.tape index d452ead6..23e8e22a 100644 --- a/tests/vhs/branding-status.tape +++ b/tests/vhs/branding-status.tape @@ -1,17 +1,13 @@ -# Smithers branding/status happy-path smoke recording. Output tests/vhs/output/branding-status.gif Set Shell zsh Set FontSize 14 Set Width 1200 Set Height 800 -Type "CRUSH_GLOBAL_CONFIG=tests/vhs/fixtures CRUSH_GLOBAL_DATA=/tmp/crush-vhs-branding-status go run ." +Type "SMITHERS_TUI_GLOBAL_CONFIG=tests/vhs/fixtures SMITHERS_TUI_GLOBAL_DATA=/tmp/crush-vhs-branding-status go run ." Enter Sleep 3s -Ctrl+g -Sleep 1s - Screenshot tests/vhs/output/branding-status.png Ctrl+c diff --git a/tests/vhs/fixtures/.smithers/tickets/eng-tickets-api-client.md b/tests/vhs/fixtures/.smithers/tickets/eng-tickets-api-client.md new file mode 100644 index 00000000..9fe078b3 --- /dev/null +++ b/tests/vhs/fixtures/.smithers/tickets/eng-tickets-api-client.md @@ -0,0 +1,15 @@ +# eng-tickets-api-client + +## Metadata +- ID: eng-tickets-api-client +- Group: Content And Prompts +- Type: engineering + +## Summary + +Implement the smithers client methods for the Tickets domain. + +## Description + +Add ListTickets, GetTicket, CreateTicket, UpdateTicket, DeleteTicket, and +SearchTickets to the smithers.Client with HTTP and exec fallback transports. diff --git a/tests/vhs/fixtures/.smithers/tickets/feat-tickets-create.md b/tests/vhs/fixtures/.smithers/tickets/feat-tickets-create.md new file mode 100644 index 00000000..c70ce076 --- /dev/null +++ b/tests/vhs/fixtures/.smithers/tickets/feat-tickets-create.md @@ -0,0 +1,15 @@ +# feat-tickets-create + +## Metadata +- ID: feat-tickets-create +- Group: Content And Prompts +- Type: feature + +## Summary + +Add a new-ticket creation flow accessible from the tickets list view. + +## Description + +Pressing n in the tickets list opens an inline editor for creating a new +ticket with ID and content fields. diff --git a/tests/vhs/fixtures/.smithers/tickets/feat-tickets-detail-view.md b/tests/vhs/fixtures/.smithers/tickets/feat-tickets-detail-view.md new file mode 100644 index 00000000..694efd20 --- /dev/null +++ b/tests/vhs/fixtures/.smithers/tickets/feat-tickets-detail-view.md @@ -0,0 +1,15 @@ +# feat-tickets-detail-view + +## Metadata +- ID: feat-tickets-detail-view +- Group: Content And Prompts +- Type: feature + +## Summary + +Render a full-screen markdown detail view for a selected ticket. + +## Description + +Wires the Enter key in the tickets list view to push a new TicketDetailView +that renders the ticket content with markdown formatting. diff --git a/tests/vhs/fixtures/.smithers/tickets/feat-tickets-list.md b/tests/vhs/fixtures/.smithers/tickets/feat-tickets-list.md new file mode 100644 index 00000000..c1875286 --- /dev/null +++ b/tests/vhs/fixtures/.smithers/tickets/feat-tickets-list.md @@ -0,0 +1,15 @@ +# feat-tickets-list + +## Metadata +- ID: feat-tickets-list +- Group: Content And Prompts +- Type: feature + +## Summary + +Ship a production-quality navigable ticket list view in the Crush TUI. + +## Description + +Harden the existing TicketsView scaffold with viewport clipping, enhanced snippet +extraction, and a full test suite. diff --git a/tests/vhs/fixtures/.smithers/tickets/feat-tickets-split-pane.md b/tests/vhs/fixtures/.smithers/tickets/feat-tickets-split-pane.md new file mode 100644 index 00000000..3a376cb8 --- /dev/null +++ b/tests/vhs/fixtures/.smithers/tickets/feat-tickets-split-pane.md @@ -0,0 +1,15 @@ +# feat-tickets-split-pane + +## Metadata +- ID: feat-tickets-split-pane +- Group: Content And Prompts +- Type: feature + +## Summary + +Show the tickets list and detail view in a side-by-side split-pane layout. + +## Description + +Splits the terminal horizontally: ticket list on the left (40%), detail view +on the right (60%). Depends on feat-tickets-list and feat-tickets-detail-view. diff --git a/tests/vhs/fixtures/scores-test.db b/tests/vhs/fixtures/scores-test.db new file mode 100644 index 00000000..813cd5dc Binary files /dev/null and b/tests/vhs/fixtures/scores-test.db differ diff --git a/tests/vhs/fixtures/smithers-mcp-connection-status.json b/tests/vhs/fixtures/smithers-mcp-connection-status.json new file mode 100644 index 00000000..7951c901 --- /dev/null +++ b/tests/vhs/fixtures/smithers-mcp-connection-status.json @@ -0,0 +1,13 @@ +{ + "smithers": { + "dbPath": ".smithers/smithers.db", + "workflowDir": ".smithers/workflows" + }, + "mcp": { + "smithers": { + "type": "stdio", + "command": "smithers", + "args": ["--mcp"] + } + } +} diff --git a/tests/vhs/fixtures/smithers-tui.json b/tests/vhs/fixtures/smithers-tui.json new file mode 100644 index 00000000..6f60a86c --- /dev/null +++ b/tests/vhs/fixtures/smithers-tui.json @@ -0,0 +1,6 @@ +{ + "smithers": { + "dbPath": ".smithers/smithers.db", + "workflowDir": ".smithers/workflows" + } +} diff --git a/tests/vhs/memory-browser.tape b/tests/vhs/memory-browser.tape new file mode 100644 index 00000000..058c39aa --- /dev/null +++ b/tests/vhs/memory-browser.tape @@ -0,0 +1,45 @@ +# Memory Browser — happy-path smoke recording. +Output tests/vhs/output/memory-browser.gif +Set Shell zsh +Set FontSize 14 +Set Width 1200 +Set Height 800 + +# Launch TUI with VHS fixtures and test memory DB +Type "CRUSH_GLOBAL_CONFIG=tests/vhs/fixtures CRUSH_GLOBAL_DATA=/tmp/crush-vhs-memory SMITHERS_DB=tests/fixtures/memory-test.db go run ." +Enter +Sleep 3s + +# Open command palette and navigate to memory browser +Ctrl+p +Sleep 500ms +Type "memory" +Sleep 500ms +Enter +Sleep 2s + +# Memory browser should be visible with fact list +Screenshot tests/vhs/output/memory-browser-list.png + +# Navigate down through facts +Down +Sleep 300ms +Down +Sleep 300ms + +Screenshot tests/vhs/output/memory-browser-navigated.png + +# Refresh +Type "r" +Sleep 1s + +Screenshot tests/vhs/output/memory-browser-refreshed.png + +# Return to previous view +Escape +Sleep 1s + +Screenshot tests/vhs/output/memory-browser-back.png + +Ctrl+c +Sleep 500ms diff --git a/tests/vhs/output/branding-status.gif b/tests/vhs/output/branding-status.gif index 206b8c88..b90da01f 100644 Binary files a/tests/vhs/output/branding-status.gif and b/tests/vhs/output/branding-status.gif differ diff --git a/tests/vhs/output/branding-status.png b/tests/vhs/output/branding-status.png index 151f925d..b602f5ba 100644 Binary files a/tests/vhs/output/branding-status.png and b/tests/vhs/output/branding-status.png differ diff --git a/tests/vhs/prompts-list.tape b/tests/vhs/prompts-list.tape new file mode 100644 index 00000000..fb9348cb --- /dev/null +++ b/tests/vhs/prompts-list.tape @@ -0,0 +1,45 @@ +# Prompts list view happy-path smoke recording. +Output tests/vhs/output/prompts-list.gif +Set Shell zsh +Set FontSize 14 +Set Width 1200 +Set Height 800 + +# Ensure fixture prompts exist in the local .smithers/prompts/ directory. +Type "mkdir -p .smithers/prompts" +Enter +Sleep 500ms +Type "printf '# Code Review\\n\\nReview {props.lang} code for {props.focus}.\\n' > .smithers/prompts/code-review.mdx" +Enter +Sleep 500ms +Type "printf '# Deploy\\n\\nDeploy {props.service} to {props.env}.\\n' > .smithers/prompts/deploy.mdx" +Enter +Sleep 500ms + +# Launch the TUI. +Type "CRUSH_GLOBAL_CONFIG=tests/vhs/fixtures CRUSH_GLOBAL_DATA=/tmp/crush-vhs go run ." +Enter +Sleep 3s + +# Open the command palette and navigate to Prompt Templates. +Type "/" +Sleep 500ms +Type "Prompt" +Sleep 300ms +Enter +Sleep 2s + +# Navigate the list. +Type "j" +Sleep 500ms +Type "k" +Sleep 500ms + +Screenshot tests/vhs/output/prompts-list.png + +# Return to chat. +Escape +Sleep 1s + +Ctrl+c +Sleep 1s diff --git a/tests/vhs/runs-realtime.tape b/tests/vhs/runs-realtime.tape new file mode 100644 index 00000000..05fb4eb2 --- /dev/null +++ b/tests/vhs/runs-realtime.tape @@ -0,0 +1,22 @@ +Output tests/vhs/output/runs-realtime.gif +Set FontSize 14 +Set Width 120 +Set Height 40 +Set Shell "bash" + +# Start TUI with a running Smithers server. +Type "SMITHERS_API_URL=http://localhost:7331 go run ." +Enter +Sleep 3s + +# Open the runs dashboard via Ctrl+R. +Ctrl+R +Sleep 2s + +# "● Live" indicator should be visible in the header when the server is running. +# Status changes stream in automatically — no user input needed. +Sleep 8s + +# Return to main view. +Escape +Sleep 1s diff --git a/tests/vhs/runs-status-sectioning.tape b/tests/vhs/runs-status-sectioning.tape new file mode 100644 index 00000000..b5f00570 --- /dev/null +++ b/tests/vhs/runs-status-sectioning.tape @@ -0,0 +1,33 @@ +# runs-status-sectioning.tape — records the runs dashboard with grouped sections +Output tests/vhs/output/runs-status-sectioning.gif +Set FontSize 14 +Set Width 130 +Set Height 40 +Set Shell "bash" +Set Env CRUSH_GLOBAL_CONFIG tests/vhs/fixtures + +Type "go run . --config tests/vhs/fixtures/crush.json" +Enter +Sleep 3s + +# Open runs dashboard +Ctrl+R +Sleep 2s + +# Navigate down (cursor skips headers, only lands on run rows) +Down +Sleep 400ms +Down +Sleep 400ms +Down +Sleep 400ms +Up +Sleep 400ms + +# Refresh +Type "r" +Sleep 2s + +# Back to chat +Escape +Sleep 1s diff --git a/tests/vhs/scores-scaffolding.tape b/tests/vhs/scores-scaffolding.tape new file mode 100644 index 00000000..8dc0fa87 --- /dev/null +++ b/tests/vhs/scores-scaffolding.tape @@ -0,0 +1,36 @@ +# scores-scaffolding.tape — Happy-path smoke recording for the Scores dashboard. +Output tests/vhs/output/scores-scaffolding.gif +Set Shell zsh +Set FontSize 14 +Set Width 1200 +Set Height 800 + +# Launch TUI with fixture DB and clean config/data dirs. +Type "CRUSH_GLOBAL_CONFIG=tests/vhs/fixtures CRUSH_GLOBAL_DATA=/tmp/crush-vhs-scores-scaffolding SMITHERS_DB_PATH=tests/vhs/fixtures/scores-test.db go run ." +Enter +Sleep 3s + +# Open command palette. +Type "/" +Sleep 1s + +# Filter to scores entry. +Type "scores" +Sleep 500ms + +# Select Scores. +Enter +Sleep 2s + +# Screenshot scores dashboard. +Screenshot tests/vhs/output/scores-scaffolding.png + +# Refresh the dashboard. +Type "r" +Sleep 2s + +# Return to chat. +Escape +Sleep 1s + +Ctrl+c diff --git a/tests/vhs/smithers-domain-system-prompt.tape b/tests/vhs/smithers-domain-system-prompt.tape index cb4b42a2..e30af09c 100644 --- a/tests/vhs/smithers-domain-system-prompt.tape +++ b/tests/vhs/smithers-domain-system-prompt.tape @@ -5,7 +5,7 @@ Set FontSize 14 Set Width 1200 Set Height 800 -Type "CRUSH_GLOBAL_CONFIG=tests/vhs/fixtures CRUSH_GLOBAL_DATA=/tmp/crush-vhs go run ." +Type "SMITHERS_TUI_GLOBAL_CONFIG=tests/vhs/fixtures SMITHERS_TUI_GLOBAL_DATA=/tmp/smithers-tui-vhs go run ." Enter Sleep 3s diff --git a/tests/vhs/smithers-mcp-connection-status.tape b/tests/vhs/smithers-mcp-connection-status.tape new file mode 100644 index 00000000..a21e7833 --- /dev/null +++ b/tests/vhs/smithers-mcp-connection-status.tape @@ -0,0 +1,31 @@ +# Smithers MCP connection status happy-path recording. +# This tape verifies that: +# 1. Smithers TUI starts successfully and displays the SMITHERS branding +# 2. The compact header shows Smithers MCP connection status (connected or disconnected) +# 3. When smithers --mcp is available the header shows "smithers connected" +# 4. The TUI remains usable regardless of MCP state +Output tests/vhs/output/smithers-mcp-connection-status.gif +Set Shell zsh +Set FontSize 14 +Set Width 1200 +Set Height 800 + +# Launch with fixture config that wires smithers --mcp as the MCP server. +Type "SMITHERS_TUI_GLOBAL_CONFIG=tests/vhs/fixtures/smithers-mcp-connection-status.json SMITHERS_TUI_GLOBAL_DATA=/tmp/smithers-tui-mcp-status-vhs go run ." +Enter +Sleep 4s + +# At this point the MCP handshake should have completed. +# The compact header should show either "smithers connected" (smithers on PATH) +# or "smithers disconnected" (smithers not on PATH). +Screenshot tests/vhs/output/smithers-mcp-connection-status.png + +# Ask the agent about MCP tools to exercise the connected path. +Type "What Smithers MCP tools are available?" +Enter +Sleep 2s + +Screenshot tests/vhs/output/smithers-mcp-connection-status-chat.png + +Ctrl+c +Sleep 1s diff --git a/tests/vhs/tickets-list.tape b/tests/vhs/tickets-list.tape new file mode 100644 index 00000000..2c788afa --- /dev/null +++ b/tests/vhs/tickets-list.tape @@ -0,0 +1,53 @@ +# Tickets list view happy-path smoke recording. +Output tests/vhs/output/tickets-list.gif +Set Shell zsh +Set FontSize 14 +Set Width 1200 +Set Height 800 + +# Launch TUI with VHS fixtures (includes seeded .smithers/tickets/ files) +Type "CRUSH_GLOBAL_CONFIG=tests/vhs/fixtures CRUSH_GLOBAL_DATA=/tmp/crush-vhs-tickets go run ." +Enter +Sleep 3s + +# Open command palette and navigate to tickets view +Ctrl+p +Sleep 500ms +Type "tickets" +Sleep 500ms +Enter +Sleep 2s + +# Ticket list should be visible with count header +Screenshot tests/vhs/output/tickets-list-loaded.png + +# Navigate down through a few tickets +Down +Sleep 300ms +Down +Sleep 300ms +Down +Sleep 300ms + +Screenshot tests/vhs/output/tickets-list-navigated.png + +# Jump to end (G key) +Type "G" +Sleep 500ms + +Screenshot tests/vhs/output/tickets-list-end.png + +# Jump back to top (g key) +Type "g" +Sleep 500ms + +Screenshot tests/vhs/output/tickets-list-top.png + +# Return to chat view +Escape +Sleep 1s + +Screenshot tests/vhs/output/tickets-list-back.png + +Ctrl+c +Sleep 1s