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