tmai 1.7.0

Tactful Multi Agent Interface - Monitor and control multiple AI coding agents
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
use ratatui::{
    layout::Rect,
    style::{Color, Modifier, Style},
    text::{Line, Span},
    widgets::{
        Block, BorderType, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState,
    },
    Frame,
};

use tmai_core::state::AppState;
use tmai_core::teams::TaskStatus;

/// Full-screen overlay showing all teams, their members, and task progress
pub struct TeamOverview;

impl TeamOverview {
    /// Render the team overview screen (full screen, like HelpScreen)
    ///
    /// Shows all teams with their members, task progress bars, and dependency info.
    /// Scrollable with j/k, close with T or Esc.
    pub fn render(frame: &mut Frame, area: Rect, state: &AppState) {
        let content_lines = Self::build_content(state);
        let total_lines = content_lines.len();

        // Calculate visible area (subtract 2 for border)
        let visible_height = area.height.saturating_sub(2) as usize;

        // Clamp scroll to valid range
        let max_scroll = total_lines.saturating_sub(visible_height);
        let scroll = (state.view.team_overview_scroll as usize).min(max_scroll);

        let block = Block::default()
            .title(" Team Overview (j/k to scroll, T or Esc to close) ")
            .borders(Borders::ALL)
            .border_type(BorderType::Rounded)
            .border_style(Style::default().fg(Color::Cyan));

        let paragraph = Paragraph::new(content_lines)
            .block(block)
            .scroll((scroll as u16, 0));

        frame.render_widget(paragraph, area);

        // Render scrollbar
        if total_lines > visible_height {
            let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
                .begin_symbol(Some("\u{2191}"))
                .end_symbol(Some("\u{2193}"));

            let mut scrollbar_state = ScrollbarState::new(max_scroll).position(scroll);

            frame.render_stateful_widget(
                scrollbar,
                area.inner(ratatui::layout::Margin {
                    vertical: 1,
                    horizontal: 0,
                }),
                &mut scrollbar_state,
            );
        }
    }

    /// Build the content lines for the team overview
    fn build_content(state: &AppState) -> Vec<Line<'static>> {
        let mut lines = Vec::new();

        lines.push(Self::title_line("Team Overview"));
        lines.push(Line::from(""));

        if state.teams.is_empty() {
            lines.push(Line::from(Span::styled(
                "  No teams found.",
                Style::default().fg(Color::DarkGray),
            )));
            lines.push(Line::from(""));
            lines.push(Line::from(Span::styled(
                "  Teams are detected from ~/.claude/teams/*/config.json",
                Style::default().fg(Color::DarkGray),
            )));
            return lines;
        }

        // Sort team names for consistent display
        let mut team_names: Vec<&String> = state.teams.keys().collect();
        team_names.sort();

        for team_name in team_names {
            let snapshot = &state.teams[team_name];
            let config = &snapshot.config;
            let tasks = &snapshot.tasks;

            // Team header
            let member_count = config.members.len();
            lines.push(Self::section_header(&format!(
                "{} ({} members)",
                team_name, member_count
            )));

            // Team description
            if let Some(ref desc) = config.description {
                lines.push(Line::from(vec![
                    Span::styled("  ", Style::default()),
                    Span::styled(desc.clone(), Style::default().fg(Color::DarkGray)),
                ]));
            }

            // Worktree summary (if any)
            if !snapshot.worktree_names.is_empty() {
                lines.push(Line::from(vec![
                    Span::styled("  Worktrees: ", Style::default().fg(Color::DarkGray)),
                    Span::styled(
                        snapshot.worktree_names.join(", "),
                        Style::default().fg(Color::Magenta),
                    ),
                ]));
            }

            // Task progress bar
            let total_tasks = tasks.len();
            if total_tasks > 0 {
                let done = tasks
                    .iter()
                    .filter(|t| t.status == TaskStatus::Completed)
                    .count();
                let in_progress = tasks
                    .iter()
                    .filter(|t| t.status == TaskStatus::InProgress)
                    .count();
                let percent = if total_tasks > 0 {
                    (done * 100) / total_tasks
                } else {
                    0
                };

                let bar = Self::build_progress_bar(done, in_progress, total_tasks, 20);
                lines.push(Line::from(vec![
                    Span::styled("  Tasks: ", Style::default().fg(Color::DarkGray)),
                    bar,
                    Span::styled(
                        format!(" {}/{} ({}%)", done, total_tasks, percent),
                        Style::default().fg(Color::White),
                    ),
                ]));
            } else {
                lines.push(Line::from(Span::styled(
                    "  Tasks: none",
                    Style::default().fg(Color::DarkGray),
                )));
            }

            lines.push(Line::from(""));

            // Member list with status and current task
            lines.push(Line::from(Span::styled(
                "  Members:",
                Style::default()
                    .fg(Color::White)
                    .add_modifier(Modifier::BOLD),
            )));

            for member in &config.members {
                let mut spans = vec![Span::styled("    ", Style::default())];

                // Check if member has a mapped pane and its status
                let has_pane = snapshot.member_panes.contains_key(&member.name);
                let is_idle = has_pane
                    && snapshot
                        .member_panes
                        .get(&member.name)
                        .and_then(|target| state.agents.get(target))
                        .is_some_and(|a| matches!(a.status, tmai_core::agents::AgentStatus::Idle));
                let status_icon = if has_pane { "\u{25CF}" } else { "\u{25CB}" };
                let status_color = if is_idle {
                    Color::Yellow
                } else if has_pane {
                    Color::Green
                } else {
                    Color::DarkGray
                };

                spans.push(Span::styled(
                    format!("{} ", status_icon),
                    Style::default().fg(status_color),
                ));

                spans.push(Span::styled(
                    member.name.clone(),
                    Style::default()
                        .fg(Color::Cyan)
                        .add_modifier(Modifier::BOLD),
                ));

                // Show agent type if available
                if let Some(ref agent_type) = member.agent_type {
                    spans.push(Span::styled(
                        format!(" ({})", agent_type),
                        Style::default().fg(Color::DarkGray),
                    ));
                }

                // Show idle indicator for team members
                if is_idle {
                    spans.push(Span::styled(
                        " idle",
                        Style::default()
                            .fg(Color::Yellow)
                            .add_modifier(Modifier::ITALIC),
                    ));
                }

                // Show worktree info if the member has a mapped pane
                if let Some(pane_target) = snapshot.member_panes.get(&member.name) {
                    if let Some(agent) = state.agents.get(pane_target) {
                        if let Some(ref wt_name) = agent.worktree_name {
                            let branch = agent.git_branch.as_deref().unwrap_or("?");
                            spans.push(Span::styled(
                                format!(" [WT: {} @ {}]", wt_name, branch),
                                Style::default().fg(Color::Magenta),
                            ));
                        } else if agent.is_worktree == Some(true) {
                            if let Some(ref branch) = agent.git_branch {
                                spans.push(Span::styled(
                                    format!(" [WT: {}]", branch),
                                    Style::default().fg(Color::Magenta),
                                ));
                            }
                        }
                    }
                }

                // Show current task if the member owns one that's in progress
                let current_task = tasks.iter().find(|t| {
                    t.owner.as_deref() == Some(&member.name) && t.status == TaskStatus::InProgress
                });
                if let Some(task) = current_task {
                    spans.push(Span::styled(
                        format!(" -> #{} {}", task.id, task.subject),
                        Style::default().fg(Color::Yellow),
                    ));
                }

                lines.push(Line::from(spans));
            }

            lines.push(Line::from(""));

            // Task list with dependencies
            if !tasks.is_empty() {
                lines.push(Line::from(Span::styled(
                    "  Tasks:",
                    Style::default()
                        .fg(Color::White)
                        .add_modifier(Modifier::BOLD),
                )));

                for task in tasks {
                    let status_icon = match task.status {
                        TaskStatus::Completed => Span::styled(
                            "\u{2713} ",
                            Style::default()
                                .fg(Color::Green)
                                .add_modifier(Modifier::BOLD),
                        ),
                        TaskStatus::InProgress => Span::styled(
                            format!("{} ", state.spinner_char()),
                            Style::default()
                                .fg(Color::Yellow)
                                .add_modifier(Modifier::BOLD),
                        ),
                        TaskStatus::Pending => {
                            Span::styled("\u{25CB} ", Style::default().fg(Color::DarkGray))
                        }
                    };

                    let subject_color = match task.status {
                        TaskStatus::Completed => Color::Green,
                        TaskStatus::InProgress => Color::Yellow,
                        TaskStatus::Pending => Color::DarkGray,
                    };

                    let mut spans = vec![
                        Span::styled("    ", Style::default()),
                        status_icon,
                        Span::styled(format!("#{} ", task.id), Style::default().fg(Color::Cyan)),
                        Span::styled(task.subject.clone(), Style::default().fg(subject_color)),
                    ];

                    // Show owner
                    if let Some(ref owner) = task.owner {
                        spans.push(Span::styled(
                            format!(" [{}]", owner),
                            Style::default().fg(Color::Magenta),
                        ));
                    }

                    // Show blocked_by
                    if !task.blocked_by.is_empty() {
                        spans.push(Span::styled(
                            format!(" blocked by #{}", task.blocked_by.join(", #")),
                            Style::default().fg(Color::Red),
                        ));
                    }

                    // Show blocks
                    if !task.blocks.is_empty() {
                        spans.push(Span::styled(
                            format!(" blocks #{}", task.blocks.join(", #")),
                            Style::default().fg(Color::Blue),
                        ));
                    }

                    lines.push(Line::from(spans));
                }
            }

            lines.push(Line::from(""));
        }

        lines.push(Line::from(Span::styled(
            "Press T or Esc to close",
            Style::default().fg(Color::DarkGray),
        )));

        lines
    }

    /// Build a progress bar span
    fn build_progress_bar(
        done: usize,
        in_progress: usize,
        total: usize,
        bar_width: usize,
    ) -> Span<'static> {
        if total == 0 {
            return Span::styled(
                "\u{2591}".repeat(bar_width),
                Style::default().fg(Color::DarkGray),
            );
        }

        let done_width = (done * bar_width) / total;
        let in_progress_width = (in_progress * bar_width) / total;
        let remaining_width = bar_width.saturating_sub(done_width + in_progress_width);

        let mut bar = String::new();
        bar.push_str(&"\u{2588}".repeat(done_width));
        bar.push_str(&"\u{2593}".repeat(in_progress_width));
        bar.push_str(&"\u{2591}".repeat(remaining_width));

        // We need to use a single span with the full bar since we can't mix colors in one Span.
        // Use the dominant color based on progress.
        let color = if done == total {
            Color::Green
        } else if done + in_progress > 0 {
            Color::Yellow
        } else {
            Color::DarkGray
        };

        Span::styled(bar, Style::default().fg(color))
    }

    /// Create a title line
    fn title_line(text: &str) -> Line<'static> {
        Line::from(vec![Span::styled(
            text.to_string(),
            Style::default()
                .fg(Color::Cyan)
                .add_modifier(Modifier::BOLD),
        )])
    }

    /// Create a section header line
    fn section_header(text: &str) -> Line<'static> {
        Line::from(vec![Span::styled(
            format!("\u{2500}\u{2500}\u{2500} {} \u{2500}\u{2500}\u{2500}", text),
            Style::default()
                .fg(Color::Yellow)
                .add_modifier(Modifier::BOLD),
        )])
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_progress_bar_empty() {
        let bar = TeamOverview::build_progress_bar(0, 0, 0, 10);
        // Should produce a bar of light shade characters
        assert_eq!(bar.content.len(), 30); // 10 chars * 3 bytes each (UTF-8)
    }

    #[test]
    fn test_progress_bar_full() {
        let bar = TeamOverview::build_progress_bar(5, 0, 5, 10);
        // All done: should be green full blocks
        assert!(bar.content.contains('\u{2588}'));
    }

    #[test]
    fn test_progress_bar_partial() {
        let bar = TeamOverview::build_progress_bar(2, 1, 5, 10);
        // Should contain both full blocks and medium shade
        let content = bar.content.to_string();
        assert!(content.contains('\u{2588}') || content.contains('\u{2593}'));
    }
}