An AI agent built to do Ralph loops - plan mode for planning and ralph mode for implementing.

docs: plan for TUI implementation

+2002
+318
docs/plans/2026-01-24-tui-design.md
··· 1 + # TUI Design Document 2 + 3 + **Date:** 2026-01-24 4 + **Status:** Draft 5 + 6 + ## Overview 7 + 8 + Interactive terminal user interface for rustagent with three main views: Dashboard, Planning, and Execution. Built with Ratatui. 9 + 10 + ## Layout Structure 11 + 12 + ``` 13 + ┌─────────────────────────────────────────────────────────┐ 14 + │ [1] Dashboard │ [2] Planning │ [3] Execution │ ← Tab bar 15 + ├─────────────────────────────────────────────────────────┤ 16 + │ │ 17 + │ Active View │ 18 + │ │ 19 + ├─────────────────────────────────────────────────────────┤ 20 + │ Status bar: context • progress • shortcuts │ 21 + └─────────────────────────────────────────────────────────┘ 22 + ``` 23 + 24 + ## Navigation 25 + 26 + - `1`, `2`, `3` keys switch tabs directly 27 + - `Tab` / `Shift+Tab` cycle through tabs 28 + - `?` opens help overlay with all keybindings 29 + - `[` / `]` toggles slide-in side panel (context-aware) 30 + - `Esc` closes any open panel 31 + 32 + ## Side Panel System 33 + 34 + Slide-in panel from right, persists until dismissed. Content is context-aware: 35 + 36 + - **In Execution** → shows task list or spec details 37 + - **In Planning** → shows generated spec preview 38 + - **In Dashboard** → shows spec details for selected item 39 + 40 + --- 41 + 42 + ## Dashboard View 43 + 44 + Toggle between Kanban (`K`) and Activity Feed (`A`) views. 45 + 46 + ``` 47 + ┌─────────────────────────────────────────────────────────┐ 48 + │ View: [K]anban │ [A]ctivity Filter: [a]ll ▼ │ 49 + ├─────────────────────────────────────────────────────────┤ 50 + │ │ 51 + │ (Kanban or Activity view content) │ 52 + │ │ 53 + ├─────────────────────────────────────────────────────────┤ 54 + │ ↑↓ navigate • Enter run • e edit • d delete • n new │ 55 + └─────────────────────────────────────────────────────────┘ 56 + ``` 57 + 58 + ### Kanban View 59 + 60 + ``` 61 + │ Draft │ Ready │ Running │ Completed │ 62 + ├──────────────┼──────────────┼──────────────┼─────────────┤ 63 + │ ┌──────────┐ │ ┌──────────┐ │ ┌──────────┐ │ │ 64 + │ │ auth-sys │ │ │ logging │ │ │ api-refac│ │ │ 65 + │ │ 0/5 tasks│ │ │ 4 tasks │ │ │ 2/6 ████░│ │ │ 66 + │ └──────────┘ │ └──────────┘ │ └──────────┘ │ │ 67 + ``` 68 + 69 + - Arrow keys move between cards 70 + - `h`/`l` or `←`/`→` move between columns 71 + - `j`/`k` or `↑`/`↓` move within column 72 + 73 + ### Activity Feed View 74 + 75 + ``` 76 + │ Time │ Event │ Spec │ Details │ 77 + ├──────────┼───────────────────┼──────────────┼───────────────┤ 78 + │ 2m ago │ ✓ Task completed │ api-refactor │ Add endpoints │ 79 + │ 5m ago │ ▶ Run started │ api-refactor │ │ 80 + │ 1h ago │ ✗ Blocked │ auth-system │ Missing keys │ 81 + │ 2h ago │ ✓ Spec created │ logging │ │ 82 + ``` 83 + 84 + - Column headers always visible (frozen at top) 85 + - Columns sortable with `s` then select column 86 + - `Enter` on an item jumps to that spec/task 87 + - `f` opens filter menu (by status, date range, spec) 88 + 89 + --- 90 + 91 + ## Planning View 92 + 93 + Chat-based interface for spec creation with vim-style input. 94 + 95 + ``` 96 + ┌─────────────────────────────────────────────────────────┐ 97 + │ ┌─────────────────────────────────────────────────────┐ │ 98 + │ │ Assistant │ │ 99 + │ │ What would you like to build? │ │ 100 + │ │ │ │ 101 + │ │ You │ │ 102 + │ │ I need a user authentication system with JWT... │ │ 103 + │ │ │ │ 104 + │ │ Assistant ◐ │ │ 105 + │ │ Breaking that down into tasks: │ │ 106 + │ │ ☐ 1. Create User model │ │ 107 + │ │ ☐ 2. Implement JWT generation │ │ 108 + │ │ ☐ 3. Add login/logout endpoints │ │ 109 + │ └─────────────────────────────────────────────────────┘ │ 110 + ├─────────────────────────────────────────────────────────┤ 111 + │ > │ 112 + ├─────────────────────────────────────────────────────────┤ 113 + │ i insert • ↑↓ scroll • Ctrl+S save • ] spec panel • ? │ 114 + └─────────────────────────────────────────────────────────┘ 115 + ``` 116 + 117 + ### Features 118 + 119 + - **Vim-style input**: `i` to enter insert mode, `Esc` to exit and scroll 120 + - **Clear message attribution**: Labels ("Assistant", "You") above messages 121 + - **Thinking indicator**: Spinner (◐) while waiting for LLM response 122 + - **Inline task checkboxes**: Tasks rendered as actionable items in chat 123 + - **Cancel generation**: `Ctrl+X` cancels in-progress generation 124 + 125 + ### Keybindings 126 + 127 + | Key | Action | 128 + |-----|--------| 129 + | `i` | Enter insert mode | 130 + | `Esc` | Exit insert mode, scroll messages | 131 + | `↑`/`↓` | Scroll chat history | 132 + | `Ctrl+S` | Save spec to disk | 133 + | `Ctrl+P` | Open spec preview panel | 134 + | `Ctrl+R` | Save and run (switch to Execution) | 135 + | `Ctrl+X` | Cancel in-progress generation | 136 + | `]` | Toggle spec JSON panel | 137 + 138 + ### Side Panel (Spec Preview) 139 + 140 + ``` 141 + │ Chat conversation ││ spec.json │ 142 + │ ││ ────────────────────── │ 143 + │ ││ { │ 144 + │ ││ "name": "user-auth", │ 145 + │ ││ "tasks": [...] │ 146 + │ ││ } │ 147 + ``` 148 + 149 + - Live-updates as conversation progresses 150 + - `e` in panel opens inline editor to tweak tasks manually 151 + 152 + --- 153 + 154 + ## Execution View 155 + 156 + Split layout with task focus on left, streaming output on right. 157 + 158 + ``` 159 + ┌─────────────────────────────────────────────────────────┐ 160 + ├────────────────────────┬────────────────────────────────┤ 161 + │ Current Task 2/6 │ Output │ 162 + ├────────────────────────┤────────────────────────────────┤ 163 + │ ▶ Add login endpoints │ Assistant │ 164 + │ │ I'll create the login route... │ 165 + │ Acceptance Criteria: │ │ 166 + │ ☐ POST /login exists │ ┌─ run_command ──────────────┐ │ 167 + │ ☐ Returns JWT token │ │ $ cargo check │ │ 168 + │ ☐ Validates password │ │ Compiling auth v0.1.0 │ │ 169 + │ │ │ Finished dev [unopt] │ │ 170 + ├────────────────────────┤ └────────────────────────────┘ │ 171 + │ Tasks │ │ 172 + │ ✓ Create User model │ ┌─ write_file ───────────────┐ │ 173 + │ ✓ JWT generation │ │ src/routes/login.rs │ │ 174 + │ ▶ Login endpoints │ │ +42 lines │ │ 175 + │ ○ Password reset │ └────────────────────────────┘ │ 176 + │ ○ Logout endpoint │ │ 177 + │ ○ Tests │ Assistant ◐ │ 178 + ├────────────────────────┴────────────────────────────────┤ 179 + │ Running • 2/6 tasks • Ctrl+X stop • ] tasks • [ spec │ 180 + └─────────────────────────────────────────────────────────┘ 181 + ``` 182 + 183 + ### Left Pane (Task Focus) 184 + 185 + - Current task prominently displayed with acceptance criteria 186 + - Task list with status indicators: 187 + - `✓` complete 188 + - `▶` in progress 189 + - `○` pending 190 + - `✗` blocked 191 + - Progress fraction in header (2/6) 192 + 193 + ### Right Pane (Streaming Output) 194 + 195 + - LLM messages stream in real-time 196 + - Tool calls displayed in bordered boxes with tool name header 197 + - Collapsible tool output (`Enter` on a tool box to expand/collapse) 198 + - Spinner on active response 199 + 200 + ### Keybindings 201 + 202 + | Key | Action | 203 + |-----|--------| 204 + | `Ctrl+X` | Stop/pause execution | 205 + | `↑`/`↓` or `j`/`k` | Scroll output | 206 + | `[` | Slide-in spec panel | 207 + | `]` | Slide-in full task list | 208 + | `r` | Resume if paused | 209 + | `Enter` | Expand/collapse tool output | 210 + | `?` | Help | 211 + 212 + --- 213 + 214 + ## Technical Architecture 215 + 216 + ### Module Structure 217 + 218 + ``` 219 + src/tui/ 220 + ├── mod.rs # App state, event loop, main TUI entry 221 + ├── tabs.rs # Tab bar widget 222 + ├── panel.rs # Slide-in panel system 223 + ├── status_bar.rs # Status bar widget 224 + ├── dashboard/ 225 + │ ├── mod.rs # Dashboard view container 226 + │ ├── kanban.rs # Kanban board widget 227 + │ └── activity.rs # Activity feed widget 228 + ├── planning/ 229 + │ ├── mod.rs # Planning view container 230 + │ ├── chat.rs # Chat log widget 231 + │ └── input.rs # Vim-style input box 232 + ├── execution/ 233 + │ ├── mod.rs # Execution view container 234 + │ ├── task_pane.rs # Left pane with task info 235 + │ └── output_pane.rs # Right pane with streaming output 236 + └── widgets/ 237 + ├── mod.rs # Shared widget exports 238 + ├── spinner.rs # Animated spinner 239 + └── tool_box.rs # Tool call display box 240 + ``` 241 + 242 + ### Component Hierarchy 243 + 244 + ``` 245 + App 246 + ├── TabBar # Navigation 247 + ├── StatusBar # Context hints, shortcuts 248 + ├── SidePanel # Slide-in overlay 249 + └── Views 250 + ├── DashboardView 251 + │ ├── KanbanBoard # Card grid by status 252 + │ └── ActivityFeed # Table with headers 253 + ├── PlanningView 254 + │ ├── ChatLog # Scrollable message list 255 + │ └── InputBox # Vim-style text input 256 + └── ExecutionView 257 + ├── TaskPane # Left split 258 + │ ├── CurrentTask 259 + │ └── TaskList 260 + └── OutputPane # Right split 261 + ├── MessageStream 262 + └── ToolCallBox 263 + ``` 264 + 265 + ### Dependencies 266 + 267 + | Crate | Purpose | 268 + |-------|---------| 269 + | `ratatui` | TUI framework | 270 + | `crossterm` | Terminal backend | 271 + | `tokio` | Async runtime (already in use) | 272 + | `tui-textarea` | Vim-style text input widget | 273 + 274 + ### Integration Points 275 + 276 + - **PlanningAgent** streams messages to `PlanningView` via `tokio::sync::mpsc` channel 277 + - **RalphLoop** streams messages/tool calls to `ExecutionView` via channel 278 + - **Spec files** read/written through existing `spec.rs` module 279 + - **Config** loaded through existing `config.rs` 280 + 281 + ### Event Flow 282 + 283 + ``` 284 + Terminal Events (crossterm) 285 + 286 + 287 + App::handle_event() 288 + 289 + ├── Tab navigation → switch active view 290 + ├── Global keys → help, panels 291 + └── View-specific → delegate to active view 292 + 293 + Async Streams (tokio channels) 294 + 295 + 296 + App::handle_message() 297 + 298 + ├── LLM response → update chat/output 299 + ├── Tool call → add tool box 300 + └── Task update → refresh task list 301 + ``` 302 + 303 + --- 304 + 305 + ## Out of Scope 306 + 307 + - Session management (save/restore conversations) 308 + - Multi-session switching 309 + 310 + --- 311 + 312 + ## Next Steps 313 + 314 + 1. Add `ratatui`, `crossterm`, `tui-textarea` to `Cargo.toml` 315 + 2. Create `src/tui/mod.rs` with basic app skeleton 316 + 3. Implement tab bar and view switching 317 + 4. Build out views incrementally: Dashboard → Planning → Execution 318 + 5. Add channel-based streaming from existing agents
+1684
docs/plans/2026-01-24-tui-implementation.md
··· 1 + # TUI Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Build an interactive TUI for rustagent with Dashboard, Planning, and Execution views. 6 + 7 + **Architecture:** Ratatui-based TUI with tab navigation, slide-in panels, and async message streaming from existing PlanningAgent and RalphLoop via tokio channels. 8 + 9 + **Tech Stack:** ratatui, crossterm, tui-textarea, tokio (existing) 10 + 11 + --- 12 + 13 + ## Design Decisions 14 + 15 + ### Terminal Lifecycle Safety 16 + - Always restore terminal on exit, error, or panic using `scopeguard` or manual drop guard 17 + - Handle `Event::Resize` to redraw correctly 18 + - Use `KeyEventKind::Press` to filter out key repeats/releases 19 + 20 + ### Input Model 21 + - Use full `KeyEvent` (not just `KeyCode`) to preserve modifiers for tui-textarea 22 + - Planning view needs Ctrl+S, Ctrl+X, etc. 23 + 24 + ### Output Management 25 + - Cap execution output to last 1000 items to prevent unbounded growth 26 + - Clamp scroll offsets to `u16::MAX` and content bounds 27 + 28 + ### Async Integration (future) 29 + - Dedicated OS thread for terminal events, forward via channel 30 + - Or use crossterm async event stream with tokio 31 + - Bounded channels with backpressure for agent messages 32 + 33 + --- 34 + 35 + ## Task 1: Add TUI Dependencies 36 + 37 + **Files:** 38 + - Modify: `Cargo.toml` 39 + 40 + **Step 1: Add ratatui and related dependencies** 41 + 42 + Add to `[dependencies]` section in `Cargo.toml`: 43 + 44 + ```toml 45 + ratatui = "0.29" 46 + crossterm = "0.28" 47 + tui-textarea = { version = "0.7", default-features = false, features = ["crossterm"] } 48 + ``` 49 + 50 + Note: `tui-textarea` requires explicit crossterm feature for ratatui 0.29 compatibility. 51 + 52 + **Step 2: Verify dependencies compile** 53 + 54 + Run: `cargo check` 55 + Expected: Compiles without errors 56 + 57 + **Step 3: Commit** 58 + 59 + ```bash 60 + git add Cargo.toml Cargo.lock 61 + git commit -m "deps: add ratatui, crossterm, tui-textarea for TUI" 62 + ``` 63 + 64 + --- 65 + 66 + ## Task 2: Create TUI Module Skeleton 67 + 68 + **Files:** 69 + - Create: `src/tui/mod.rs` 70 + - Create: `src/tui/app.rs` 71 + - Modify: `src/lib.rs` 72 + 73 + **Step 1: Create basic app state** 74 + 75 + Create `src/tui/app.rs`: 76 + 77 + ```rust 78 + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; 79 + 80 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 81 + pub enum ActiveTab { 82 + Dashboard, 83 + Planning, 84 + Execution, 85 + } 86 + 87 + pub struct App { 88 + pub running: bool, 89 + pub active_tab: ActiveTab, 90 + } 91 + 92 + impl App { 93 + pub fn new() -> Self { 94 + Self { 95 + running: true, 96 + active_tab: ActiveTab::Dashboard, 97 + } 98 + } 99 + 100 + /// Handle key events. Uses full KeyEvent to preserve modifiers. 101 + pub fn handle_key(&mut self, key: KeyEvent) { 102 + match (key.code, key.modifiers) { 103 + (KeyCode::Char('c'), KeyModifiers::CONTROL) => self.running = false, 104 + (KeyCode::Char('q'), KeyModifiers::NONE) => self.running = false, 105 + (KeyCode::Char('1'), KeyModifiers::NONE) => self.active_tab = ActiveTab::Dashboard, 106 + (KeyCode::Char('2'), KeyModifiers::NONE) => self.active_tab = ActiveTab::Planning, 107 + (KeyCode::Char('3'), KeyModifiers::NONE) => self.active_tab = ActiveTab::Execution, 108 + (KeyCode::Tab, _) => { 109 + self.active_tab = match self.active_tab { 110 + ActiveTab::Dashboard => ActiveTab::Planning, 111 + ActiveTab::Planning => ActiveTab::Execution, 112 + ActiveTab::Execution => ActiveTab::Dashboard, 113 + }; 114 + } 115 + _ => {} 116 + } 117 + } 118 + } 119 + 120 + impl Default for App { 121 + fn default() -> Self { 122 + Self::new() 123 + } 124 + } 125 + ``` 126 + 127 + **Step 2: Create TUI module entry** 128 + 129 + Create `src/tui/mod.rs`: 130 + 131 + ```rust 132 + mod app; 133 + 134 + pub use app::{App, ActiveTab}; 135 + 136 + use std::io; 137 + use std::panic; 138 + use crossterm::{ 139 + execute, 140 + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, 141 + event::{self, Event, KeyEventKind}, 142 + }; 143 + use ratatui::{ 144 + backend::CrosstermBackend, 145 + Terminal, 146 + }; 147 + 148 + pub type Tui = Terminal<CrosstermBackend<io::Stdout>>; 149 + 150 + pub fn setup_terminal() -> io::Result<Tui> { 151 + enable_raw_mode()?; 152 + let mut stdout = io::stdout(); 153 + execute!(stdout, EnterAlternateScreen)?; 154 + let backend = CrosstermBackend::new(stdout); 155 + Terminal::new(backend) 156 + } 157 + 158 + pub fn restore_terminal(terminal: &mut Tui) -> io::Result<()> { 159 + disable_raw_mode()?; 160 + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; 161 + terminal.show_cursor()?; 162 + Ok(()) 163 + } 164 + 165 + /// Run the TUI event loop with panic safety. 166 + /// Terminal is always restored even on panic or error. 167 + pub fn run(terminal: &mut Tui, app: &mut App) -> anyhow::Result<()> { 168 + // Set panic hook to restore terminal 169 + let original_hook = panic::take_hook(); 170 + panic::set_hook(Box::new(move |info| { 171 + let _ = disable_raw_mode(); 172 + let _ = execute!(io::stdout(), LeaveAlternateScreen); 173 + original_hook(info); 174 + })); 175 + 176 + let result = run_loop(terminal, app); 177 + 178 + // Restore original panic hook 179 + let _ = panic::take_hook(); 180 + 181 + result 182 + } 183 + 184 + fn run_loop(terminal: &mut Tui, app: &mut App) -> anyhow::Result<()> { 185 + while app.running { 186 + terminal.draw(|frame| crate::tui::ui::draw(frame, app))?; 187 + 188 + if event::poll(std::time::Duration::from_millis(100))? { 189 + match event::read()? { 190 + Event::Key(key) if key.kind == KeyEventKind::Press => { 191 + app.handle_key(key); 192 + } 193 + Event::Resize(_, _) => { 194 + // Terminal will redraw on next iteration 195 + } 196 + _ => {} 197 + } 198 + } 199 + } 200 + Ok(()) 201 + } 202 + ``` 203 + 204 + **Step 3: Export TUI module from lib.rs** 205 + 206 + Add to `src/lib.rs`: 207 + 208 + ```rust 209 + pub mod tui; 210 + ``` 211 + 212 + **Step 4: Verify it compiles** 213 + 214 + Run: `cargo check` 215 + Expected: Compiles without errors 216 + 217 + **Step 5: Commit** 218 + 219 + ```bash 220 + git add src/tui/ src/lib.rs 221 + git commit -m "feat(tui): add basic app state and terminal setup" 222 + ``` 223 + 224 + --- 225 + 226 + ## Task 3: Implement Tab Bar Widget 227 + 228 + **Files:** 229 + - Create: `src/tui/widgets/mod.rs` 230 + - Create: `src/tui/widgets/tabs.rs` 231 + - Modify: `src/tui/mod.rs` 232 + 233 + **Step 1: Create tabs widget** 234 + 235 + Create `src/tui/widgets/tabs.rs`: 236 + 237 + ```rust 238 + use ratatui::{ 239 + buffer::Buffer, 240 + layout::Rect, 241 + style::{Color, Modifier, Style}, 242 + text::{Line, Span}, 243 + widgets::{Tabs as RataTabs, Widget}, 244 + }; 245 + 246 + use crate::tui::ActiveTab; 247 + 248 + pub struct TabBar { 249 + active: ActiveTab, 250 + } 251 + 252 + impl TabBar { 253 + pub fn new(active: ActiveTab) -> Self { 254 + Self { active } 255 + } 256 + } 257 + 258 + impl Widget for TabBar { 259 + fn render(self, area: Rect, buf: &mut Buffer) { 260 + // Use Line::from for ratatui 0.29 compatibility 261 + let titles = vec![ 262 + Line::from("[1] Dashboard"), 263 + Line::from("[2] Planning"), 264 + Line::from("[3] Execution"), 265 + ]; 266 + let selected = match self.active { 267 + ActiveTab::Dashboard => 0, 268 + ActiveTab::Planning => 1, 269 + ActiveTab::Execution => 2, 270 + }; 271 + 272 + let tabs = RataTabs::new(titles) 273 + .select(selected) 274 + .style(Style::default().fg(Color::White)) 275 + .highlight_style( 276 + Style::default() 277 + .fg(Color::Yellow) 278 + .add_modifier(Modifier::BOLD), 279 + ) 280 + .divider(Span::raw(" │ ")); 281 + 282 + tabs.render(area, buf); 283 + } 284 + } 285 + ``` 286 + 287 + **Step 2: Create widgets module** 288 + 289 + Create `src/tui/widgets/mod.rs`: 290 + 291 + ```rust 292 + mod tabs; 293 + 294 + pub use tabs::TabBar; 295 + ``` 296 + 297 + **Step 3: Update TUI mod.rs to include widgets** 298 + 299 + Add to `src/tui/mod.rs`: 300 + 301 + ```rust 302 + pub mod widgets; 303 + ``` 304 + 305 + **Step 4: Verify it compiles** 306 + 307 + Run: `cargo check` 308 + Expected: Compiles without errors 309 + 310 + **Step 5: Commit** 311 + 312 + ```bash 313 + git add src/tui/widgets/ 314 + git commit -m "feat(tui): add tab bar widget" 315 + ``` 316 + 317 + --- 318 + 319 + ## Task 4: Implement Basic UI Rendering 320 + 321 + **Files:** 322 + - Create: `src/tui/ui.rs` 323 + - Modify: `src/tui/mod.rs` 324 + 325 + **Step 1: Create UI rendering function** 326 + 327 + Create `src/tui/ui.rs`: 328 + 329 + ```rust 330 + use ratatui::{ 331 + layout::{Constraint, Direction, Layout, Rect}, 332 + style::{Color, Style}, 333 + text::{Line, Span}, 334 + widgets::{Block, Borders, Paragraph}, 335 + Frame, 336 + }; 337 + 338 + use crate::tui::{App, ActiveTab}; 339 + use crate::tui::widgets::TabBar; 340 + 341 + pub fn draw(frame: &mut Frame, app: &App) { 342 + let chunks = Layout::default() 343 + .direction(Direction::Vertical) 344 + .constraints([ 345 + Constraint::Length(1), // Tab bar 346 + Constraint::Min(0), // Main content 347 + Constraint::Length(1), // Status bar 348 + ]) 349 + .split(frame.area()); 350 + 351 + // Tab bar 352 + frame.render_widget(TabBar::new(app.active_tab), chunks[0]); 353 + 354 + // Main content area 355 + let content_block = Block::default() 356 + .borders(Borders::ALL) 357 + .title(match app.active_tab { 358 + ActiveTab::Dashboard => " Dashboard ", 359 + ActiveTab::Planning => " Planning ", 360 + ActiveTab::Execution => " Execution ", 361 + }); 362 + 363 + let placeholder = Paragraph::new(match app.active_tab { 364 + ActiveTab::Dashboard => "Dashboard view - coming soon", 365 + ActiveTab::Planning => "Planning view - coming soon", 366 + ActiveTab::Execution => "Execution view - coming soon", 367 + }) 368 + .block(content_block); 369 + 370 + frame.render_widget(placeholder, chunks[1]); 371 + 372 + // Status bar 373 + let status = Line::from(vec![ 374 + Span::raw(" q quit │ 1/2/3 switch tabs │ Tab cycle │ ? help "), 375 + ]); 376 + let status_bar = Paragraph::new(status) 377 + .style(Style::default().bg(Color::DarkGray)); 378 + frame.render_widget(status_bar, chunks[2]); 379 + } 380 + ``` 381 + 382 + **Step 2: Export ui module** 383 + 384 + Add to `src/tui/mod.rs`: 385 + 386 + ```rust 387 + mod ui; 388 + 389 + pub use ui::draw; 390 + ``` 391 + 392 + **Step 3: Verify it compiles** 393 + 394 + Run: `cargo check` 395 + Expected: Compiles without errors 396 + 397 + **Step 4: Commit** 398 + 399 + ```bash 400 + git add src/tui/ui.rs src/tui/mod.rs 401 + git commit -m "feat(tui): add basic UI layout with tab bar and status bar" 402 + ``` 403 + 404 + --- 405 + 406 + ## Task 5: Add TUI Command to CLI 407 + 408 + **Files:** 409 + - Modify: `src/main.rs` 410 + 411 + **Step 1: Make TUI the default command** 412 + 413 + Update the `Cli` struct in `src/main.rs` to make the subcommand optional: 414 + 415 + ```rust 416 + #[derive(Parser)] 417 + #[command(name = "rustagent")] 418 + #[command(about = "A Rust-based AI agent for task execution", long_about = None)] 419 + struct Cli { 420 + #[command(subcommand)] 421 + command: Option<Commands>, 422 + } 423 + ``` 424 + 425 + Add to the `Commands` enum: 426 + 427 + ```rust 428 + /// Launch interactive TUI 429 + Tui, 430 + ``` 431 + 432 + **Step 2: Update match statement to handle default** 433 + 434 + Replace the match statement in `main()`: 435 + 436 + ```rust 437 + // Default to TUI if no command specified 438 + let command = cli.command.unwrap_or(Commands::Tui); 439 + 440 + match command { 441 + Commands::Init { spec_dir } => { 442 + // ... existing init code unchanged 443 + } 444 + Commands::Plan { spec_dir } => { 445 + // ... existing plan code unchanged 446 + } 447 + Commands::Run { 448 + spec_file, 449 + max_iterations, 450 + } => { 451 + // ... existing run code unchanged 452 + } 453 + Commands::Tui => { 454 + use rustagent::tui; 455 + 456 + let mut terminal = tui::setup_terminal()?; 457 + let mut app = tui::App::new(); 458 + 459 + // Run with panic-safe terminal restoration 460 + let result = tui::run(&mut terminal, &mut app); 461 + 462 + // Always restore terminal 463 + tui::restore_terminal(&mut terminal)?; 464 + 465 + // Propagate any error from the run loop 466 + result?; 467 + } 468 + } 469 + ``` 470 + 471 + **Step 3: Verify it compiles and runs** 472 + 473 + Run: `cargo check` 474 + Expected: Compiles without errors 475 + 476 + Run: `cargo run` 477 + Expected: TUI launches (default command), tabs switch with 1/2/3, q quits 478 + 479 + Run: `cargo run -- tui` 480 + Expected: Same behavior with explicit tui subcommand 481 + 482 + **Step 4: Commit** 483 + 484 + ```bash 485 + git add src/main.rs 486 + git commit -m "feat(cli): add tui subcommand to launch interactive TUI" 487 + ``` 488 + 489 + --- 490 + 491 + ## Task 6: Implement Dashboard Kanban View 492 + 493 + **Files:** 494 + - Create: `src/tui/views/mod.rs` 495 + - Create: `src/tui/views/dashboard.rs` 496 + - Modify: `src/tui/ui.rs` 497 + - Modify: `src/tui/mod.rs` 498 + 499 + **Step 1: Create dashboard state and view** 500 + 501 + Create `src/tui/views/dashboard.rs`: 502 + 503 + ```rust 504 + use ratatui::{ 505 + buffer::Buffer, 506 + layout::{Constraint, Direction, Layout, Rect}, 507 + style::{Color, Modifier, Style}, 508 + text::{Line, Span}, 509 + widgets::{Block, Borders, List, ListItem, Paragraph, Widget}, 510 + }; 511 + 512 + use crate::spec::{Spec, TaskStatus}; 513 + 514 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 515 + pub enum DashboardMode { 516 + Kanban, 517 + Activity, 518 + } 519 + 520 + pub struct DashboardState { 521 + pub mode: DashboardMode, 522 + pub specs: Vec<SpecSummary>, 523 + pub selected_column: usize, 524 + pub selected_row: usize, 525 + } 526 + 527 + #[derive(Debug, Clone)] 528 + pub struct SpecSummary { 529 + pub name: String, 530 + pub status: SpecStatus, 531 + pub task_progress: (usize, usize), // (completed, total) 532 + } 533 + 534 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 535 + pub enum SpecStatus { 536 + Draft, 537 + Ready, 538 + Running, 539 + Completed, 540 + } 541 + 542 + impl DashboardState { 543 + pub fn new() -> Self { 544 + Self { 545 + mode: DashboardMode::Kanban, 546 + specs: Vec::new(), 547 + selected_column: 0, 548 + selected_row: 0, 549 + } 550 + } 551 + 552 + pub fn toggle_mode(&mut self) { 553 + self.mode = match self.mode { 554 + DashboardMode::Kanban => DashboardMode::Activity, 555 + DashboardMode::Activity => DashboardMode::Kanban, 556 + }; 557 + } 558 + } 559 + 560 + impl Default for DashboardState { 561 + fn default() -> Self { 562 + Self::new() 563 + } 564 + } 565 + 566 + pub fn draw_dashboard(frame: &mut ratatui::Frame, area: Rect, state: &DashboardState) { 567 + let chunks = Layout::default() 568 + .direction(Direction::Vertical) 569 + .constraints([ 570 + Constraint::Length(1), // Mode toggle 571 + Constraint::Min(0), // Content 572 + ]) 573 + .split(area); 574 + 575 + // Mode toggle bar 576 + let mode_text = match state.mode { 577 + DashboardMode::Kanban => " View: [K]anban │ Activity ", 578 + DashboardMode::Activity => " View: Kanban │ [A]ctivity ", 579 + }; 580 + let mode_bar = Paragraph::new(mode_text) 581 + .style(Style::default().fg(Color::Cyan)); 582 + frame.render_widget(mode_bar, chunks[0]); 583 + 584 + // Content based on mode 585 + match state.mode { 586 + DashboardMode::Kanban => draw_kanban(frame, chunks[1], state), 587 + DashboardMode::Activity => draw_activity(frame, chunks[1], state), 588 + } 589 + } 590 + 591 + fn draw_kanban(frame: &mut ratatui::Frame, area: Rect, state: &DashboardState) { 592 + let columns = Layout::default() 593 + .direction(Direction::Horizontal) 594 + .constraints([ 595 + Constraint::Percentage(25), 596 + Constraint::Percentage(25), 597 + Constraint::Percentage(25), 598 + Constraint::Percentage(25), 599 + ]) 600 + .split(area); 601 + 602 + let column_titles = ["Draft", "Ready", "Running", "Completed"]; 603 + let statuses = [SpecStatus::Draft, SpecStatus::Ready, SpecStatus::Running, SpecStatus::Completed]; 604 + 605 + for (i, (col_area, (title, status))) in columns.iter() 606 + .zip(column_titles.iter().zip(statuses.iter())) 607 + .enumerate() 608 + { 609 + let is_selected = i == state.selected_column; 610 + let style = if is_selected { 611 + Style::default().fg(Color::Yellow) 612 + } else { 613 + Style::default() 614 + }; 615 + 616 + let block = Block::default() 617 + .borders(Borders::ALL) 618 + .title(*title) 619 + .border_style(style); 620 + 621 + let specs_in_column: Vec<&SpecSummary> = state.specs 622 + .iter() 623 + .filter(|s| s.status == *status) 624 + .collect(); 625 + 626 + let items: Vec<ListItem> = specs_in_column 627 + .iter() 628 + .enumerate() 629 + .map(|(j, spec)| { 630 + let content = format!("{} ({}/{})", spec.name, spec.task_progress.0, spec.task_progress.1); 631 + let item_style = if is_selected && j == state.selected_row { 632 + Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD) 633 + } else { 634 + Style::default() 635 + }; 636 + ListItem::new(content).style(item_style) 637 + }) 638 + .collect(); 639 + 640 + let list = List::new(items).block(block); 641 + frame.render_widget(list, *col_area); 642 + } 643 + } 644 + 645 + fn draw_activity(frame: &mut ratatui::Frame, area: Rect, _state: &DashboardState) { 646 + let block = Block::default() 647 + .borders(Borders::ALL) 648 + .title(" Activity Feed "); 649 + 650 + // Header row 651 + let header = Line::from(vec![ 652 + Span::styled("Time ", Style::default().add_modifier(Modifier::BOLD)), 653 + Span::styled("│ ", Style::default().fg(Color::DarkGray)), 654 + Span::styled("Event ", Style::default().add_modifier(Modifier::BOLD)), 655 + Span::styled("│ ", Style::default().fg(Color::DarkGray)), 656 + Span::styled("Spec ", Style::default().add_modifier(Modifier::BOLD)), 657 + Span::styled("│ ", Style::default().fg(Color::DarkGray)), 658 + Span::styled("Details", Style::default().add_modifier(Modifier::BOLD)), 659 + ]); 660 + 661 + let content = Paragraph::new(vec![ 662 + header, 663 + Line::from("─".repeat(area.width.saturating_sub(2) as usize)), 664 + Line::from(" No activity yet"), 665 + ]) 666 + .block(block); 667 + 668 + frame.render_widget(content, area); 669 + } 670 + ``` 671 + 672 + **Step 2: Create views module** 673 + 674 + Create `src/tui/views/mod.rs`: 675 + 676 + ```rust 677 + mod dashboard; 678 + 679 + pub use dashboard::{DashboardState, DashboardMode, SpecSummary, SpecStatus, draw_dashboard}; 680 + ``` 681 + 682 + **Step 3: Update TUI mod.rs** 683 + 684 + Add to `src/tui/mod.rs`: 685 + 686 + ```rust 687 + pub mod views; 688 + ``` 689 + 690 + **Step 4: Update App state to include dashboard state** 691 + 692 + Modify `src/tui/app.rs` to add: 693 + 694 + ```rust 695 + use crate::tui::views::DashboardState; 696 + ``` 697 + 698 + And update the `App` struct: 699 + 700 + ```rust 701 + pub struct App { 702 + pub running: bool, 703 + pub active_tab: ActiveTab, 704 + pub dashboard: DashboardState, 705 + } 706 + 707 + impl App { 708 + pub fn new() -> Self { 709 + Self { 710 + running: true, 711 + active_tab: ActiveTab::Dashboard, 712 + dashboard: DashboardState::new(), 713 + } 714 + } 715 + 716 + pub fn handle_key(&mut self, key: KeyCode) { 717 + match key { 718 + KeyCode::Char('q') => self.running = false, 719 + KeyCode::Char('1') => self.active_tab = ActiveTab::Dashboard, 720 + KeyCode::Char('2') => self.active_tab = ActiveTab::Planning, 721 + KeyCode::Char('3') => self.active_tab = ActiveTab::Execution, 722 + KeyCode::Tab => { 723 + self.active_tab = match self.active_tab { 724 + ActiveTab::Dashboard => ActiveTab::Planning, 725 + ActiveTab::Planning => ActiveTab::Execution, 726 + ActiveTab::Execution => ActiveTab::Dashboard, 727 + }; 728 + } 729 + // Dashboard-specific keys 730 + KeyCode::Char('k') | KeyCode::Char('K') if self.active_tab == ActiveTab::Dashboard => { 731 + self.dashboard.mode = crate::tui::views::DashboardMode::Kanban; 732 + } 733 + KeyCode::Char('a') | KeyCode::Char('A') if self.active_tab == ActiveTab::Dashboard => { 734 + self.dashboard.mode = crate::tui::views::DashboardMode::Activity; 735 + } 736 + _ => {} 737 + } 738 + } 739 + } 740 + ``` 741 + 742 + **Step 5: Update UI to use dashboard view** 743 + 744 + Modify `src/tui/ui.rs` to use the dashboard view: 745 + 746 + ```rust 747 + use crate::tui::views::draw_dashboard; 748 + ``` 749 + 750 + And update the `draw` function to replace the Dashboard placeholder: 751 + 752 + ```rust 753 + // Main content area 754 + match app.active_tab { 755 + ActiveTab::Dashboard => { 756 + draw_dashboard(frame, chunks[1], &app.dashboard); 757 + } 758 + ActiveTab::Planning => { 759 + let block = Block::default() 760 + .borders(Borders::ALL) 761 + .title(" Planning "); 762 + let placeholder = Paragraph::new("Planning view - coming soon").block(block); 763 + frame.render_widget(placeholder, chunks[1]); 764 + } 765 + ActiveTab::Execution => { 766 + let block = Block::default() 767 + .borders(Borders::ALL) 768 + .title(" Execution "); 769 + let placeholder = Paragraph::new("Execution view - coming soon").block(block); 770 + frame.render_widget(placeholder, chunks[1]); 771 + } 772 + } 773 + ``` 774 + 775 + **Step 6: Verify it compiles and runs** 776 + 777 + Run: `cargo check` 778 + Expected: Compiles without errors 779 + 780 + Run: `cargo run -- tui` 781 + Expected: Dashboard shows Kanban with 4 columns, K/A switches modes 782 + 783 + **Step 7: Commit** 784 + 785 + ```bash 786 + git add src/tui/ 787 + git commit -m "feat(tui): implement dashboard with kanban and activity views" 788 + ``` 789 + 790 + --- 791 + 792 + ## Task 7: Implement Planning View with Chat Interface 793 + 794 + **Files:** 795 + - Create: `src/tui/views/planning.rs` 796 + - Modify: `src/tui/views/mod.rs` 797 + - Modify: `src/tui/app.rs` 798 + - Modify: `src/tui/ui.rs` 799 + 800 + **Step 1: Create planning view state and rendering** 801 + 802 + Create `src/tui/views/planning.rs`: 803 + 804 + ```rust 805 + use ratatui::{ 806 + layout::{Constraint, Direction, Layout, Rect}, 807 + style::{Color, Modifier, Style}, 808 + text::{Line, Span}, 809 + widgets::{Block, Borders, Paragraph, Wrap}, 810 + Frame, 811 + }; 812 + use tui_textarea::TextArea; 813 + 814 + #[derive(Debug, Clone)] 815 + pub struct ChatMessage { 816 + pub role: MessageRole, 817 + pub content: String, 818 + } 819 + 820 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 821 + pub enum MessageRole { 822 + User, 823 + Assistant, 824 + } 825 + 826 + pub struct PlanningState { 827 + pub messages: Vec<ChatMessage>, 828 + pub input: TextArea<'static>, 829 + pub insert_mode: bool, 830 + pub scroll_offset: usize, 831 + pub thinking: bool, 832 + } 833 + 834 + impl PlanningState { 835 + pub fn new() -> Self { 836 + let mut input = TextArea::default(); 837 + input.set_cursor_line_style(Style::default()); 838 + input.set_placeholder_text("Type your message..."); 839 + 840 + Self { 841 + messages: vec![ChatMessage { 842 + role: MessageRole::Assistant, 843 + content: "What would you like to build?".to_string(), 844 + }], 845 + input, 846 + insert_mode: false, 847 + scroll_offset: 0, 848 + thinking: false, 849 + } 850 + } 851 + 852 + pub fn add_message(&mut self, role: MessageRole, content: String) { 853 + self.messages.push(ChatMessage { role, content }); 854 + } 855 + 856 + pub fn submit_input(&mut self) -> Option<String> { 857 + let text: String = self.input.lines().join("\n"); 858 + if text.trim().is_empty() { 859 + return None; 860 + } 861 + self.input.select_all(); 862 + self.input.cut(); 863 + Some(text) 864 + } 865 + } 866 + 867 + impl Default for PlanningState { 868 + fn default() -> Self { 869 + Self::new() 870 + } 871 + } 872 + 873 + pub fn draw_planning(frame: &mut Frame, area: Rect, state: &mut PlanningState) { 874 + let chunks = Layout::default() 875 + .direction(Direction::Vertical) 876 + .constraints([ 877 + Constraint::Min(0), // Chat history 878 + Constraint::Length(3), // Input area 879 + Constraint::Length(1), // Hints 880 + ]) 881 + .split(area); 882 + 883 + // Chat history 884 + draw_chat_history(frame, chunks[0], state); 885 + 886 + // Input area 887 + let input_block = Block::default() 888 + .borders(Borders::ALL) 889 + .border_style(if state.insert_mode { 890 + Style::default().fg(Color::Green) 891 + } else { 892 + Style::default() 893 + }) 894 + .title(if state.insert_mode { " Input (INSERT) " } else { " Input " }); 895 + 896 + state.input.set_block(input_block); 897 + frame.render_widget(&state.input, chunks[1]); 898 + 899 + // Hints 900 + let hints = if state.insert_mode { 901 + " Esc exit insert │ Enter send " 902 + } else { 903 + " i insert │ ↑↓ scroll │ Ctrl+S save │ ] spec panel " 904 + }; 905 + let hints_bar = Paragraph::new(hints) 906 + .style(Style::default().fg(Color::DarkGray)); 907 + frame.render_widget(hints_bar, chunks[2]); 908 + } 909 + 910 + fn draw_chat_history(frame: &mut Frame, area: Rect, state: &PlanningState) { 911 + let block = Block::default() 912 + .borders(Borders::ALL) 913 + .title(" Chat "); 914 + 915 + let inner = block.inner(area); 916 + frame.render_widget(block, area); 917 + 918 + let mut lines: Vec<Line> = Vec::new(); 919 + 920 + for msg in &state.messages { 921 + let (label, style) = match msg.role { 922 + MessageRole::User => ("You", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), 923 + MessageRole::Assistant => ("Assistant", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), 924 + }; 925 + 926 + lines.push(Line::from(Span::styled(label, style))); 927 + 928 + for line in msg.content.lines() { 929 + lines.push(Line::from(format!(" {}", line))); 930 + } 931 + lines.push(Line::from("")); 932 + } 933 + 934 + if state.thinking { 935 + lines.push(Line::from(vec![ 936 + Span::styled("Assistant", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), 937 + Span::raw(" "), 938 + Span::styled("◐", Style::default().fg(Color::Yellow)), 939 + ])); 940 + } 941 + 942 + // Clamp scroll offset to u16::MAX to prevent overflow 943 + let scroll_y = state.scroll_offset.min(u16::MAX as usize) as u16; 944 + 945 + let paragraph = Paragraph::new(lines) 946 + .wrap(Wrap { trim: false }) 947 + .scroll((scroll_y, 0)); 948 + 949 + frame.render_widget(paragraph, inner); 950 + } 951 + ``` 952 + 953 + **Step 2: Export planning view** 954 + 955 + Add to `src/tui/views/mod.rs`: 956 + 957 + ```rust 958 + mod planning; 959 + 960 + pub use planning::{PlanningState, ChatMessage, MessageRole, draw_planning}; 961 + ``` 962 + 963 + **Step 3: Update App with planning state** 964 + 965 + Modify `src/tui/app.rs` to include planning state and handle keys: 966 + 967 + Add import: 968 + ```rust 969 + use crate::tui::views::{DashboardState, DashboardMode, PlanningState}; 970 + use crossterm::event::KeyCode; 971 + ``` 972 + 973 + Update App struct: 974 + ```rust 975 + pub struct App { 976 + pub running: bool, 977 + pub active_tab: ActiveTab, 978 + pub dashboard: DashboardState, 979 + pub planning: PlanningState, 980 + } 981 + ``` 982 + 983 + Update new(): 984 + ```rust 985 + pub fn new() -> Self { 986 + Self { 987 + running: true, 988 + active_tab: ActiveTab::Dashboard, 989 + dashboard: DashboardState::new(), 990 + planning: PlanningState::new(), 991 + } 992 + } 993 + ``` 994 + 995 + Update handle_key() to handle planning input: 996 + ```rust 997 + pub fn handle_key(&mut self, key: KeyCode) { 998 + // Planning insert mode handling 999 + if self.active_tab == ActiveTab::Planning && self.planning.insert_mode { 1000 + match key { 1001 + KeyCode::Esc => { 1002 + self.planning.insert_mode = false; 1003 + } 1004 + KeyCode::Enter => { 1005 + if let Some(text) = self.planning.submit_input() { 1006 + self.planning.add_message( 1007 + crate::tui::views::MessageRole::User, 1008 + text, 1009 + ); 1010 + // TODO: Send to planning agent 1011 + } 1012 + } 1013 + _ => { 1014 + self.planning.input.input(crossterm::event::KeyEvent::new(key, crossterm::event::KeyModifiers::NONE)); 1015 + } 1016 + } 1017 + return; 1018 + } 1019 + 1020 + match key { 1021 + KeyCode::Char('q') => self.running = false, 1022 + KeyCode::Char('1') => self.active_tab = ActiveTab::Dashboard, 1023 + KeyCode::Char('2') => self.active_tab = ActiveTab::Planning, 1024 + KeyCode::Char('3') => self.active_tab = ActiveTab::Execution, 1025 + KeyCode::Tab => { 1026 + self.active_tab = match self.active_tab { 1027 + ActiveTab::Dashboard => ActiveTab::Planning, 1028 + ActiveTab::Planning => ActiveTab::Execution, 1029 + ActiveTab::Execution => ActiveTab::Dashboard, 1030 + }; 1031 + } 1032 + // Dashboard keys 1033 + KeyCode::Char('k') | KeyCode::Char('K') if self.active_tab == ActiveTab::Dashboard => { 1034 + self.dashboard.mode = DashboardMode::Kanban; 1035 + } 1036 + KeyCode::Char('a') | KeyCode::Char('A') if self.active_tab == ActiveTab::Dashboard => { 1037 + self.dashboard.mode = DashboardMode::Activity; 1038 + } 1039 + // Planning keys 1040 + KeyCode::Char('i') if self.active_tab == ActiveTab::Planning => { 1041 + self.planning.insert_mode = true; 1042 + } 1043 + _ => {} 1044 + } 1045 + } 1046 + ``` 1047 + 1048 + **Step 4: Update UI to render planning view** 1049 + 1050 + Modify `src/tui/ui.rs`: 1051 + 1052 + Add import: 1053 + ```rust 1054 + use crate::tui::views::{draw_dashboard, draw_planning}; 1055 + ``` 1056 + 1057 + Update the Planning arm in draw(): 1058 + ```rust 1059 + ActiveTab::Planning => { 1060 + draw_planning(frame, chunks[1], &mut app.planning); 1061 + } 1062 + ``` 1063 + 1064 + Note: This requires changing the `app` parameter to `&mut App`. 1065 + 1066 + **Step 5: Update main.rs to pass mutable app** 1067 + 1068 + Update the draw call in main.rs: 1069 + ```rust 1070 + terminal.draw(|frame| tui::draw(frame, &mut app))?; 1071 + ``` 1072 + 1073 + **Step 6: Verify it compiles and runs** 1074 + 1075 + Run: `cargo check` 1076 + Expected: Compiles without errors 1077 + 1078 + Run: `cargo run -- tui` 1079 + Expected: Planning tab shows chat, i enters insert mode, Esc exits, Enter submits 1080 + 1081 + **Step 7: Commit** 1082 + 1083 + ```bash 1084 + git add src/tui/ 1085 + git commit -m "feat(tui): implement planning view with chat interface" 1086 + ``` 1087 + 1088 + --- 1089 + 1090 + ## Task 8: Implement Execution View with Split Layout 1091 + 1092 + **Files:** 1093 + - Create: `src/tui/views/execution.rs` 1094 + - Modify: `src/tui/views/mod.rs` 1095 + - Modify: `src/tui/app.rs` 1096 + - Modify: `src/tui/ui.rs` 1097 + 1098 + **Step 1: Create execution view** 1099 + 1100 + Create `src/tui/views/execution.rs`: 1101 + 1102 + ```rust 1103 + use ratatui::{ 1104 + layout::{Constraint, Direction, Layout, Rect}, 1105 + style::{Color, Modifier, Style}, 1106 + text::{Line, Span}, 1107 + widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}, 1108 + Frame, 1109 + }; 1110 + 1111 + use crate::spec::{Task, TaskStatus}; 1112 + 1113 + #[derive(Debug, Clone)] 1114 + pub struct ToolCall { 1115 + pub name: String, 1116 + pub output: String, 1117 + pub collapsed: bool, 1118 + } 1119 + 1120 + #[derive(Debug, Clone)] 1121 + pub enum OutputItem { 1122 + Message { role: String, content: String }, 1123 + ToolCall(ToolCall), 1124 + } 1125 + 1126 + const MAX_OUTPUT_ITEMS: usize = 1000; 1127 + 1128 + pub struct ExecutionState { 1129 + pub running: bool, 1130 + pub current_task: Option<Task>, 1131 + pub tasks: Vec<Task>, 1132 + pub output: Vec<OutputItem>, 1133 + pub scroll_offset: usize, 1134 + } 1135 + 1136 + impl ExecutionState { 1137 + pub fn new() -> Self { 1138 + Self { 1139 + running: false, 1140 + current_task: None, 1141 + tasks: Vec::new(), 1142 + output: Vec::new(), 1143 + scroll_offset: 0, 1144 + } 1145 + } 1146 + 1147 + pub fn task_progress(&self) -> (usize, usize) { 1148 + let completed = self.tasks.iter() 1149 + .filter(|t| t.status == TaskStatus::Complete) 1150 + .count(); 1151 + (completed, self.tasks.len()) 1152 + } 1153 + 1154 + /// Add output item, capping total to MAX_OUTPUT_ITEMS 1155 + pub fn add_output(&mut self, item: OutputItem) { 1156 + self.output.push(item); 1157 + if self.output.len() > MAX_OUTPUT_ITEMS { 1158 + self.output.remove(0); 1159 + // Adjust scroll offset if we removed content above viewport 1160 + self.scroll_offset = self.scroll_offset.saturating_sub(1); 1161 + } 1162 + } 1163 + 1164 + /// Clamp scroll offset to valid range 1165 + pub fn clamp_scroll(&mut self, content_height: usize, viewport_height: usize) { 1166 + let max_scroll = content_height.saturating_sub(viewport_height); 1167 + self.scroll_offset = self.scroll_offset.min(max_scroll); 1168 + } 1169 + } 1170 + 1171 + impl Default for ExecutionState { 1172 + fn default() -> Self { 1173 + Self::new() 1174 + } 1175 + } 1176 + 1177 + pub fn draw_execution(frame: &mut Frame, area: Rect, state: &ExecutionState) { 1178 + let chunks = Layout::default() 1179 + .direction(Direction::Horizontal) 1180 + .constraints([ 1181 + Constraint::Percentage(35), // Task pane 1182 + Constraint::Percentage(65), // Output pane 1183 + ]) 1184 + .split(area); 1185 + 1186 + draw_task_pane(frame, chunks[0], state); 1187 + draw_output_pane(frame, chunks[1], state); 1188 + } 1189 + 1190 + fn draw_task_pane(frame: &mut Frame, area: Rect, state: &ExecutionState) { 1191 + let chunks = Layout::default() 1192 + .direction(Direction::Vertical) 1193 + .constraints([ 1194 + Constraint::Length(8), // Current task 1195 + Constraint::Min(0), // Task list 1196 + ]) 1197 + .split(area); 1198 + 1199 + // Current task 1200 + let (completed, total) = state.task_progress(); 1201 + let title = format!(" Current Task {}/{} ", completed, total); 1202 + let current_block = Block::default() 1203 + .borders(Borders::ALL) 1204 + .title(title); 1205 + 1206 + let current_content = if let Some(task) = &state.current_task { 1207 + let mut lines = vec![ 1208 + Line::from(Span::styled( 1209 + format!("▶ {}", task.title), 1210 + Style::default().add_modifier(Modifier::BOLD), 1211 + )), 1212 + Line::from(""), 1213 + Line::from(Span::styled("Acceptance Criteria:", Style::default().fg(Color::Cyan))), 1214 + ]; 1215 + for criterion in &task.acceptance_criteria { 1216 + lines.push(Line::from(format!(" ☐ {}", criterion))); 1217 + } 1218 + lines 1219 + } else { 1220 + vec![Line::from("No task running")] 1221 + }; 1222 + 1223 + let current = Paragraph::new(current_content) 1224 + .block(current_block) 1225 + .wrap(Wrap { trim: false }); 1226 + frame.render_widget(current, chunks[0]); 1227 + 1228 + // Task list 1229 + let tasks_block = Block::default() 1230 + .borders(Borders::ALL) 1231 + .title(" Tasks "); 1232 + 1233 + let items: Vec<ListItem> = state.tasks.iter().map(|task| { 1234 + let (icon, style) = match task.status { 1235 + TaskStatus::Complete => ("✓", Style::default().fg(Color::Green)), 1236 + TaskStatus::InProgress => ("▶", Style::default().fg(Color::Yellow)), 1237 + TaskStatus::Pending => ("○", Style::default().fg(Color::DarkGray)), 1238 + TaskStatus::Blocked => ("✗", Style::default().fg(Color::Red)), 1239 + }; 1240 + ListItem::new(format!(" {} {}", icon, task.title)).style(style) 1241 + }).collect(); 1242 + 1243 + let list = List::new(items).block(tasks_block); 1244 + frame.render_widget(list, chunks[1]); 1245 + } 1246 + 1247 + fn draw_output_pane(frame: &mut Frame, area: Rect, state: &ExecutionState) { 1248 + let block = Block::default() 1249 + .borders(Borders::ALL) 1250 + .title(" Output "); 1251 + 1252 + let inner = block.inner(area); 1253 + frame.render_widget(block, area); 1254 + 1255 + let mut lines: Vec<Line> = Vec::new(); 1256 + 1257 + for item in &state.output { 1258 + match item { 1259 + OutputItem::Message { role, content } => { 1260 + let style = if role == "Assistant" { 1261 + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) 1262 + } else { 1263 + Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) 1264 + }; 1265 + lines.push(Line::from(Span::styled(role.clone(), style))); 1266 + for line in content.lines() { 1267 + lines.push(Line::from(format!(" {}", line))); 1268 + } 1269 + lines.push(Line::from("")); 1270 + } 1271 + OutputItem::ToolCall(tc) => { 1272 + lines.push(Line::from(vec![ 1273 + Span::raw("┌─ "), 1274 + Span::styled(&tc.name, Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)), 1275 + Span::raw(" ─"), 1276 + ])); 1277 + if !tc.collapsed { 1278 + for line in tc.output.lines().take(5) { 1279 + lines.push(Line::from(format!("│ {}", line))); 1280 + } 1281 + } 1282 + lines.push(Line::from("└────────────────────")); 1283 + lines.push(Line::from("")); 1284 + } 1285 + } 1286 + } 1287 + 1288 + if lines.is_empty() { 1289 + lines.push(Line::from(" Waiting for execution...")); 1290 + } 1291 + 1292 + // Clamp scroll offset to u16::MAX to prevent overflow 1293 + let scroll_y = state.scroll_offset.min(u16::MAX as usize) as u16; 1294 + 1295 + let paragraph = Paragraph::new(lines) 1296 + .wrap(Wrap { trim: false }) 1297 + .scroll((scroll_y, 0)); 1298 + 1299 + frame.render_widget(paragraph, inner); 1300 + } 1301 + ``` 1302 + 1303 + **Step 2: Export execution view** 1304 + 1305 + Add to `src/tui/views/mod.rs`: 1306 + 1307 + ```rust 1308 + mod execution; 1309 + 1310 + pub use execution::{ExecutionState, OutputItem, ToolCall, draw_execution}; 1311 + ``` 1312 + 1313 + **Step 3: Update App with execution state** 1314 + 1315 + Add to imports in `src/tui/app.rs`: 1316 + ```rust 1317 + use crate::tui::views::{DashboardState, DashboardMode, PlanningState, ExecutionState}; 1318 + ``` 1319 + 1320 + Update App struct: 1321 + ```rust 1322 + pub struct App { 1323 + pub running: bool, 1324 + pub active_tab: ActiveTab, 1325 + pub dashboard: DashboardState, 1326 + pub planning: PlanningState, 1327 + pub execution: ExecutionState, 1328 + } 1329 + ``` 1330 + 1331 + Update new(): 1332 + ```rust 1333 + pub fn new() -> Self { 1334 + Self { 1335 + running: true, 1336 + active_tab: ActiveTab::Dashboard, 1337 + dashboard: DashboardState::new(), 1338 + planning: PlanningState::new(), 1339 + execution: ExecutionState::new(), 1340 + } 1341 + } 1342 + ``` 1343 + 1344 + **Step 4: Update UI to render execution view** 1345 + 1346 + Add import in `src/tui/ui.rs`: 1347 + ```rust 1348 + use crate::tui::views::{draw_dashboard, draw_planning, draw_execution}; 1349 + ``` 1350 + 1351 + Update the Execution arm: 1352 + ```rust 1353 + ActiveTab::Execution => { 1354 + draw_execution(frame, chunks[1], &app.execution); 1355 + } 1356 + ``` 1357 + 1358 + **Step 5: Verify it compiles and runs** 1359 + 1360 + Run: `cargo check` 1361 + Expected: Compiles without errors 1362 + 1363 + Run: `cargo run -- tui` 1364 + Expected: Execution tab shows split layout with task pane and output pane 1365 + 1366 + **Step 6: Commit** 1367 + 1368 + ```bash 1369 + git add src/tui/ 1370 + git commit -m "feat(tui): implement execution view with split layout" 1371 + ``` 1372 + 1373 + --- 1374 + 1375 + ## Task 9: Add Slide-in Side Panel 1376 + 1377 + **Files:** 1378 + - Create: `src/tui/widgets/panel.rs` 1379 + - Modify: `src/tui/widgets/mod.rs` 1380 + - Modify: `src/tui/app.rs` 1381 + - Modify: `src/tui/ui.rs` 1382 + 1383 + **Step 1: Create panel widget** 1384 + 1385 + Create `src/tui/widgets/panel.rs`: 1386 + 1387 + ```rust 1388 + use ratatui::{ 1389 + layout::Rect, 1390 + style::{Color, Style}, 1391 + widgets::{Block, Borders, Clear, Paragraph, Wrap}, 1392 + Frame, 1393 + }; 1394 + 1395 + pub struct SidePanel { 1396 + pub visible: bool, 1397 + pub title: String, 1398 + pub content: String, 1399 + pub width_percent: u16, 1400 + } 1401 + 1402 + impl SidePanel { 1403 + pub fn new() -> Self { 1404 + Self { 1405 + visible: false, 1406 + title: String::new(), 1407 + content: String::new(), 1408 + width_percent: 40, 1409 + } 1410 + } 1411 + 1412 + pub fn toggle(&mut self) { 1413 + self.visible = !self.visible; 1414 + } 1415 + 1416 + pub fn set_content(&mut self, title: &str, content: String) { 1417 + self.title = title.to_string(); 1418 + self.content = content; 1419 + } 1420 + } 1421 + 1422 + impl Default for SidePanel { 1423 + fn default() -> Self { 1424 + Self::new() 1425 + } 1426 + } 1427 + 1428 + pub fn draw_side_panel(frame: &mut Frame, area: Rect, panel: &SidePanel) { 1429 + if !panel.visible { 1430 + return; 1431 + } 1432 + 1433 + let panel_width = (area.width as u32 * panel.width_percent as u32 / 100) as u16; 1434 + let panel_area = Rect { 1435 + x: area.x + area.width - panel_width, 1436 + y: area.y, 1437 + width: panel_width, 1438 + height: area.height, 1439 + }; 1440 + 1441 + // Clear the area first 1442 + frame.render_widget(Clear, panel_area); 1443 + 1444 + let block = Block::default() 1445 + .borders(Borders::ALL) 1446 + .border_style(Style::default().fg(Color::Cyan)) 1447 + .title(format!(" {} ", panel.title)); 1448 + 1449 + let content = Paragraph::new(panel.content.clone()) 1450 + .block(block) 1451 + .wrap(Wrap { trim: false }); 1452 + 1453 + frame.render_widget(content, panel_area); 1454 + } 1455 + ``` 1456 + 1457 + **Step 2: Export panel widget** 1458 + 1459 + Add to `src/tui/widgets/mod.rs`: 1460 + 1461 + ```rust 1462 + mod panel; 1463 + 1464 + pub use panel::{SidePanel, draw_side_panel}; 1465 + ``` 1466 + 1467 + **Step 3: Add panel to App state** 1468 + 1469 + Add to `src/tui/app.rs`: 1470 + 1471 + ```rust 1472 + use crate::tui::widgets::SidePanel; 1473 + ``` 1474 + 1475 + Update App struct: 1476 + ```rust 1477 + pub struct App { 1478 + pub running: bool, 1479 + pub active_tab: ActiveTab, 1480 + pub dashboard: DashboardState, 1481 + pub planning: PlanningState, 1482 + pub execution: ExecutionState, 1483 + pub side_panel: SidePanel, 1484 + } 1485 + ``` 1486 + 1487 + Update new(): 1488 + ```rust 1489 + pub fn new() -> Self { 1490 + Self { 1491 + running: true, 1492 + active_tab: ActiveTab::Dashboard, 1493 + dashboard: DashboardState::new(), 1494 + planning: PlanningState::new(), 1495 + execution: ExecutionState::new(), 1496 + side_panel: SidePanel::new(), 1497 + } 1498 + } 1499 + ``` 1500 + 1501 + Add panel toggle to handle_key(): 1502 + ```rust 1503 + KeyCode::Char('[') | KeyCode::Char(']') => { 1504 + self.side_panel.toggle(); 1505 + } 1506 + KeyCode::Esc => { 1507 + if self.side_panel.visible { 1508 + self.side_panel.visible = false; 1509 + } 1510 + } 1511 + ``` 1512 + 1513 + **Step 4: Render panel in UI** 1514 + 1515 + Add import in `src/tui/ui.rs`: 1516 + ```rust 1517 + use crate::tui::widgets::{TabBar, draw_side_panel}; 1518 + ``` 1519 + 1520 + Add panel rendering at end of draw(): 1521 + ```rust 1522 + // Side panel overlay (rendered last) 1523 + draw_side_panel(frame, chunks[1], &app.side_panel); 1524 + ``` 1525 + 1526 + **Step 5: Verify it compiles and runs** 1527 + 1528 + Run: `cargo check` 1529 + Expected: Compiles without errors 1530 + 1531 + Run: `cargo run -- tui` 1532 + Expected: [ or ] toggles side panel, Esc closes it 1533 + 1534 + **Step 6: Commit** 1535 + 1536 + ```bash 1537 + git add src/tui/ 1538 + git commit -m "feat(tui): add slide-in side panel widget" 1539 + ``` 1540 + 1541 + --- 1542 + 1543 + ## Task 10: Load Specs from Disk in Dashboard 1544 + 1545 + **Files:** 1546 + - Modify: `src/tui/views/dashboard.rs` 1547 + - Modify: `src/tui/app.rs` 1548 + 1549 + **Step 1: Add spec loading to dashboard** 1550 + 1551 + Add to `src/tui/views/dashboard.rs`: 1552 + 1553 + ```rust 1554 + use std::path::Path; 1555 + use std::fs; 1556 + use crate::spec::Spec; 1557 + 1558 + impl DashboardState { 1559 + pub fn load_specs(&mut self, spec_dir: &str) { 1560 + self.specs.clear(); 1561 + 1562 + let spec_path = Path::new(spec_dir); 1563 + if !spec_path.exists() { 1564 + return; 1565 + } 1566 + 1567 + if let Ok(entries) = fs::read_dir(spec_path) { 1568 + for entry in entries.flatten() { 1569 + let path = entry.path(); 1570 + 1571 + // Look for spec.json files 1572 + let spec_file = if path.is_dir() { 1573 + path.join("spec.json") 1574 + } else if path.extension().map_or(false, |e| e == "json") { 1575 + path 1576 + } else { 1577 + continue; 1578 + }; 1579 + 1580 + if let Ok(spec) = Spec::load(&spec_file) { 1581 + let completed = spec.tasks.iter() 1582 + .filter(|t| t.status == crate::spec::TaskStatus::Complete) 1583 + .count(); 1584 + let total = spec.tasks.len(); 1585 + 1586 + let status = if completed == total && total > 0 { 1587 + SpecStatus::Completed 1588 + } else if spec.tasks.iter().any(|t| t.status == crate::spec::TaskStatus::InProgress) { 1589 + SpecStatus::Running 1590 + } else if total > 0 { 1591 + SpecStatus::Ready 1592 + } else { 1593 + SpecStatus::Draft 1594 + }; 1595 + 1596 + self.specs.push(SpecSummary { 1597 + name: spec.name, 1598 + status, 1599 + task_progress: (completed, total), 1600 + }); 1601 + } 1602 + } 1603 + } 1604 + } 1605 + } 1606 + ``` 1607 + 1608 + **Step 2: Load specs on app init** 1609 + 1610 + Update `src/tui/app.rs` App::new() to accept spec_dir: 1611 + 1612 + ```rust 1613 + pub fn new(spec_dir: &str) -> Self { 1614 + let mut dashboard = DashboardState::new(); 1615 + dashboard.load_specs(spec_dir); 1616 + 1617 + Self { 1618 + running: true, 1619 + active_tab: ActiveTab::Dashboard, 1620 + dashboard, 1621 + planning: PlanningState::new(), 1622 + execution: ExecutionState::new(), 1623 + side_panel: SidePanel::new(), 1624 + } 1625 + } 1626 + ``` 1627 + 1628 + **Step 3: Update main.rs to pass spec_dir** 1629 + 1630 + Update the Tui command in main.rs: 1631 + 1632 + ```rust 1633 + Commands::Tui => { 1634 + let config_path = find_config_path()?; 1635 + let config = config::Config::load(&config_path)?; 1636 + let spec_dir = config.rustagent.spec_dir.clone(); 1637 + 1638 + use rustagent::tui::{self, App}; 1639 + use crossterm::event::{self, Event, KeyEventKind}; 1640 + 1641 + let mut terminal = tui::setup_terminal()?; 1642 + let mut app = App::new(&spec_dir); 1643 + // ... rest unchanged 1644 + } 1645 + ``` 1646 + 1647 + **Step 4: Verify it compiles and runs** 1648 + 1649 + Run: `cargo check` 1650 + Expected: Compiles without errors 1651 + 1652 + Run: `cargo run -- tui` 1653 + Expected: Dashboard shows specs from spec_dir in Kanban columns 1654 + 1655 + **Step 5: Commit** 1656 + 1657 + ```bash 1658 + git add src/tui/ src/main.rs 1659 + git commit -m "feat(tui): load specs from disk in dashboard" 1660 + ``` 1661 + 1662 + --- 1663 + 1664 + ## Summary 1665 + 1666 + This plan covers the core TUI implementation: 1667 + 1668 + 1. ✅ Dependencies 1669 + 2. ✅ Module skeleton 1670 + 3. ✅ Tab bar widget 1671 + 4. ✅ Basic UI rendering 1672 + 5. ✅ CLI command 1673 + 6. ✅ Dashboard with Kanban/Activity 1674 + 7. ✅ Planning view with chat 1675 + 8. ✅ Execution view with split layout 1676 + 9. ✅ Side panel 1677 + 10. ✅ Spec loading 1678 + 1679 + **Future tasks (not in this plan):** 1680 + - Async channel integration with PlanningAgent 1681 + - Async channel integration with RalphLoop 1682 + - Spinner animation 1683 + - Help overlay 1684 + - Keyboard navigation in Kanban