Skip to main content

codetether_agent/tui/
help.rs

1use ratatui::{
2    Frame,
3    layout::{Constraint, Direction, Layout, Rect},
4    style::{Color, Modifier, Style},
5    text::{Line, Span},
6    widgets::{
7        Block, Borders, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap,
8    },
9};
10
11use crate::tui::app::state::AppState;
12use crate::tui::theme::Theme;
13use crate::tui::theme_utils::{detect_color_support, validate_theme};
14use crate::tui::token_display::TokenDisplay;
15
16/// Mutable scroll state for the help view, stored externally.
17pub struct HelpScrollState {
18    pub offset: usize,
19}
20
21impl Default for HelpScrollState {
22    fn default() -> Self {
23        Self { offset: 0 }
24    }
25}
26
27impl HelpScrollState {
28    pub fn scroll_up(&mut self, n: usize) {
29        self.offset = self.offset.saturating_sub(n);
30    }
31
32    pub fn scroll_down(&mut self, n: usize, max: usize) {
33        self.offset = (self.offset + n).min(max);
34    }
35}
36
37fn heading(text: &str) -> Line<'static> {
38    Line::from(Span::styled(
39        format!("  {text}"),
40        Style::default()
41            .fg(Color::Cyan)
42            .add_modifier(Modifier::BOLD),
43    ))
44}
45
46fn separator() -> Line<'static> {
47    Line::from(Span::styled(
48        "  ─────────────────────────────────────────────",
49        Style::default().fg(Color::DarkGray),
50    ))
51}
52
53fn key_row(key: &str, desc: &str) -> Line<'static> {
54    Line::from(vec![
55        Span::styled(
56            format!("  {key:<18}"),
57            Style::default()
58                .fg(Color::Yellow)
59                .add_modifier(Modifier::BOLD),
60        ),
61        Span::raw(desc.to_string()),
62    ])
63}
64
65fn cmd_row(cmd: &str, alias: &str, desc: &str) -> Line<'static> {
66    let mut spans = vec![Span::styled(
67        format!("  {cmd:<14}"),
68        Style::default()
69            .fg(Color::Green)
70            .add_modifier(Modifier::BOLD),
71    )];
72    if !alias.is_empty() {
73        spans.push(Span::styled(
74            format!("{alias:<6} "),
75            Style::default().fg(Color::DarkGray),
76        ));
77    } else {
78        spans.push(Span::raw("       ".to_string()));
79    }
80    spans.push(Span::raw(desc.to_string()));
81    Line::from(spans)
82}
83
84fn blank() -> Line<'static> {
85    Line::from("")
86}
87
88pub fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
89    let popup_layout = Layout::default()
90        .direction(Direction::Vertical)
91        .constraints([
92            Constraint::Percentage((100 - percent_y) / 2),
93            Constraint::Percentage(percent_y),
94            Constraint::Percentage((100 - percent_y) / 2),
95        ])
96        .split(r);
97
98    Layout::default()
99        .direction(Direction::Horizontal)
100        .constraints([
101            Constraint::Percentage((100 - percent_x) / 2),
102            Constraint::Percentage(percent_x),
103            Constraint::Percentage((100 - percent_x) / 2),
104        ])
105        .split(popup_layout[1])[1]
106}
107
108pub fn build_help_lines(app_state: &AppState) -> Vec<Line<'static>> {
109    let token_display = TokenDisplay::new();
110    let color_support = detect_color_support();
111    let validated_theme = validate_theme(&Theme::default());
112    let session_label = app_state
113        .session_id
114        .as_deref()
115        .map(|id| {
116            if id.len() > 20 {
117                format!("{}…", &id[..20])
118            } else {
119                id.to_string()
120            }
121        })
122        .unwrap_or_else(|| "(none)".to_string());
123
124    let mut lines: Vec<Line<'static>> = Vec::with_capacity(80);
125
126    // ── Title ──
127    lines.push(Line::from(vec![
128        Span::styled(
129            "  CodeTether TUI",
130            Style::default()
131                .fg(Color::Cyan)
132                .add_modifier(Modifier::BOLD),
133        ),
134        Span::styled(
135            "  ·  Help & Reference",
136            Style::default().fg(Color::DarkGray),
137        ),
138    ]));
139    lines.push(separator());
140    lines.push(blank());
141    for line in token_display.create_detailed_display().into_iter().take(8) {
142        lines.push(Line::from(line));
143    }
144    lines.push(blank());
145
146    // ── Session info ──
147    lines.push(heading("SESSION"));
148    lines.push(Line::from(vec![
149        Span::styled("  Session:   ", Style::default().fg(Color::DarkGray)),
150        Span::styled(session_label, Style::default().fg(Color::White)),
151    ]));
152    lines.push(Line::from(vec![
153        Span::styled("  Directory: ", Style::default().fg(Color::DarkGray)),
154        Span::styled(
155            app_state.cwd_display.clone(),
156            Style::default().fg(Color::White),
157        ),
158    ]));
159    lines.push(Line::from(vec![
160        Span::styled("  Colors:    ", Style::default().fg(Color::DarkGray)),
161        Span::styled(
162            match color_support {
163                crate::tui::theme_utils::ColorSupport::Monochrome => "Monochrome",
164                crate::tui::theme_utils::ColorSupport::Ansi8 => "ANSI-8",
165                crate::tui::theme_utils::ColorSupport::Ansi256 => "ANSI-256",
166                crate::tui::theme_utils::ColorSupport::TrueColor => "TrueColor",
167            },
168            Style::default().fg(Color::White),
169        ),
170        Span::styled("  theme validated", Style::default().fg(Color::DarkGray)),
171        Span::styled(
172            if validated_theme.background.is_some() {
173                " (bg set)"
174            } else {
175                " (bg default)"
176            },
177            Style::default().fg(Color::DarkGray),
178        ),
179    ]));
180    lines.push(Line::from(vec![
181        Span::styled("  Auto-apply:", Style::default().fg(Color::DarkGray)),
182        Span::styled(
183            if app_state.auto_apply_edits {
184                " ON"
185            } else {
186                " OFF"
187            },
188            if app_state.auto_apply_edits {
189                Style::default().fg(Color::Green)
190            } else {
191                Style::default().fg(Color::Yellow)
192            },
193        ),
194        Span::styled(
195            "  (/autoapply or /aa)",
196            Style::default().fg(Color::DarkGray),
197        ),
198    ]));
199    lines.push(Line::from(vec![
200        Span::styled("  Network:   ", Style::default().fg(Color::DarkGray)),
201        Span::styled(
202            if app_state.allow_network {
203                " ON"
204            } else {
205                " OFF"
206            },
207            if app_state.allow_network {
208                Style::default().fg(Color::Green)
209            } else {
210                Style::default().fg(Color::Yellow)
211            },
212        ),
213        Span::styled("  (/network)", Style::default().fg(Color::DarkGray)),
214    ]));
215    lines.push(Line::from(vec![
216        Span::styled("  Autocomplete:", Style::default().fg(Color::DarkGray)),
217        Span::styled(
218            if app_state.slash_autocomplete {
219                " ON"
220            } else {
221                " OFF"
222            },
223            if app_state.slash_autocomplete {
224                Style::default().fg(Color::Green)
225            } else {
226                Style::default().fg(Color::Yellow)
227            },
228        ),
229        Span::styled("  (/autocomplete)", Style::default().fg(Color::DarkGray)),
230    ]));
231
232    // Worker info if connected
233    if let Some(ref wid) = app_state.worker_id {
234        lines.push(Line::from(vec![
235            Span::styled("  Worker:    ", Style::default().fg(Color::DarkGray)),
236            Span::styled(
237                app_state.worker_name.clone().unwrap_or_else(|| wid.clone()),
238                Style::default().fg(Color::Green),
239            ),
240            if app_state.a2a_connected {
241                Span::styled("  (A2A connected)", Style::default().fg(Color::Green))
242            } else {
243                Span::styled("  (disconnected)", Style::default().fg(Color::Red))
244            },
245        ]));
246    }
247    lines.push(blank());
248
249    let global = crate::telemetry::TOKEN_USAGE.global_snapshot();
250    if global.total.total() > 0 {
251        let _cost = token_display.calculate_cost_for_tokens("gpt-4o", global.input, global.output);
252        lines.push(Line::from(vec![
253            Span::styled("  Tokens: ", Style::default().fg(Color::DarkGray)),
254            Span::styled(
255                format!("{} total", global.total.total()),
256                Style::default().fg(Color::Yellow),
257            ),
258        ]));
259        lines.push(blank());
260    }
261
262    // ── Keyboard shortcuts ──
263    lines.push(heading("KEYBOARD SHORTCUTS"));
264    lines.push(separator());
265    lines.push(key_row(
266        "Ctrl+C",
267        "Interrupt turn (while streaming) or quit (when idle)",
268    ));
269    lines.push(key_row("Ctrl+Q", "Quit"));
270    lines.push(key_row("Esc", "Back / close overlay / exit detail"));
271    lines.push(key_row("Ctrl+T", "Symbol search (workspace)"));
272    lines.push(key_row("Ctrl+W", "Start a /ask side question in chat"));
273    lines.push(key_row("Ctrl+Y", "Copy latest assistant reply"));
274    lines.push(key_row("Ctrl+V", "Paste image from clipboard"));
275    lines.push(key_row("Enter", "Send message or run slash command"));
276    lines.push(key_row("Tab", "Accept slash autocomplete"));
277    lines.push(blank());
278
279    lines.push(heading("TEXT EDITING"));
280    lines.push(key_row("Left / Right", "Move cursor"));
281    lines.push(key_row("Ctrl+Left/Right", "Move by word"));
282    lines.push(key_row("Home / End", "Jump to start / end of input"));
283    lines.push(key_row("Backspace", "Delete backward"));
284    lines.push(key_row("Delete", "Delete forward"));
285    lines.push(key_row("Ctrl+Up / Down", "Command history (in chat)"));
286    lines.push(blank());
287
288    lines.push(heading("SCROLLING"));
289    lines.push(key_row("Up / Down", "Scroll messages"));
290    lines.push(key_row("Shift+Up / Down", "Scroll compact tool panels"));
291    lines.push(key_row("PgUp / PgDn", "Scroll by page"));
292    lines.push(key_row("Mouse wheel", "Scroll the current list or view"));
293    lines.push(key_row("Shift+Wheel", "Scroll compact tool panels in chat"));
294    lines.push(key_row("Alt+J / Alt+K", "Chat scroll down / up"));
295    lines.push(key_row("Alt+D / Alt+U", "Chat page down / up"));
296    lines.push(key_row(
297        "Ctrl+G / Ctrl+Shift+G",
298        "Jump chat to top / bottom",
299    ));
300    lines.push(blank());
301
302    // ── Slash commands ──
303    lines.push(heading("SLASH COMMANDS"));
304    lines.push(separator());
305    lines.push(Line::from(Span::styled(
306        "  Aliases: type a prefix and Tab to autocomplete",
307        Style::default().fg(Color::DarkGray),
308    )));
309    lines.push(blank());
310
311    lines.push(heading("Navigation"));
312    lines.push(cmd_row("/chat", "", "Return to chat view"));
313    lines.push(cmd_row("/help", "/h /?", "Open this help"));
314    lines.push(cmd_row("/sessions", "/s", "Session picker"));
315    lines.push(cmd_row("/model", "/m", "Model picker"));
316    lines.push(cmd_row("/file", "", "Attach file contents to composer"));
317    lines.push(cmd_row(
318        "/autoapply",
319        "/aa",
320        "Toggle edit preview auto-apply",
321    ));
322    lines.push(cmd_row(
323        "/network",
324        "",
325        "Toggle sandbox bash network access",
326    ));
327    lines.push(cmd_row(
328        "/autocomplete",
329        "",
330        "Toggle slash-command Tab autocomplete",
331    ));
332    lines.push(cmd_row(
333        "/ask",
334        "",
335        "Ephemeral side question (full context, no tools, not saved)",
336    ));
337    lines.push(cmd_row("/settings", "/set", "Settings panel"));
338    lines.push(cmd_row("/new", "", "Start fresh chat buffer"));
339    lines.push(cmd_row("/undo", "", "Undo last turn (accepts /undo <N>)"));
340    lines.push(cmd_row(
341        "/fork",
342        "",
343        "Fork session at current point (/fork <N> drops last N turns)",
344    ));
345    lines.push(blank());
346
347    lines.push(heading("Protocol & Observability"));
348    lines.push(cmd_row("/bus", "/b", "Protocol bus log"));
349    lines.push(cmd_row("/protocol", "/p", "Protocol bus (alias)"));
350    lines.push(cmd_row("/swarm", "/w", "Swarm agent view"));
351    lines.push(cmd_row("/ralph", "/r", "Ralph PRD loop view"));
352    lines.push(cmd_row("/latency", "", "Provider + tool latency inspector"));
353    lines.push(cmd_row(
354        "/inspector",
355        "",
356        "Token metrics & tool call inspector",
357    ));
358    lines.push(blank());
359
360    lines.push(heading("Development Tools"));
361    lines.push(cmd_row("/lsp", "", "LSP diagnostics view"));
362    lines.push(cmd_row("/rlm", "", "RLM processing view"));
363    lines.push(cmd_row("/symbols", "/sym", "Workspace symbol search"));
364    lines.push(cmd_row("/keys", "", "Print all commands to status bar"));
365    lines.push(blank());
366
367    // ── View-specific controls ──
368    lines.push(heading("SESSION PICKER"));
369    lines.push(separator());
370    lines.push(key_row("Up / Down", "Navigate sessions"));
371    lines.push(key_row("Enter", "Load selected session"));
372    lines.push(key_row("Type", "Filter sessions by name/ID"));
373    lines.push(key_row("Backspace", "Clear filter character"));
374    lines.push(key_row("Esc", "Close picker"));
375    lines.push(blank());
376
377    lines.push(heading("SETTINGS"));
378    lines.push(separator());
379    lines.push(key_row("Up / Down", "Select a setting"));
380    lines.push(key_row("Enter", "Toggle selected setting"));
381    lines.push(key_row("a", "Toggle edit auto-apply"));
382    lines.push(key_row("n", "Toggle network access"));
383    lines.push(key_row("Tab", "Toggle slash autocomplete"));
384    lines.push(key_row("Esc", "Return to chat"));
385    lines.push(blank());
386
387    lines.push(heading("PROTOCOL BUS LOG"));
388    lines.push(separator());
389    lines.push(key_row("Up / Down", "Navigate entries"));
390    lines.push(key_row("Enter", "Open detail view"));
391    lines.push(key_row("/", "Enter filter mode"));
392    lines.push(key_row("c", "Clear filter"));
393    lines.push(key_row("g", "Jump to latest entry"));
394    lines.push(key_row("Esc", "Close detail or filter"));
395    lines.push(blank());
396
397    lines.push(heading("SWARM / RALPH"));
398    lines.push(separator());
399    lines.push(key_row("Up / Down", "Select sub-agent / story"));
400    lines.push(key_row("Enter", "Open detail view"));
401    lines.push(key_row("PgUp / PgDn", "Scroll detail content"));
402    lines.push(key_row("Esc", "Exit detail"));
403    lines.push(blank());
404
405    lines.push(separator());
406    lines.push(Line::from(Span::styled(
407        "  Press Esc to return to chat",
408        Style::default().fg(Color::DarkGray),
409    )));
410    lines.push(blank());
411
412    lines
413}
414
415pub fn render_help_overlay_if_needed(f: &mut Frame, app_state: &mut AppState) {
416    if !app_state.show_help {
417        return;
418    }
419
420    let area = centered_rect(60, 60, f.area());
421    let lines = build_help_lines(app_state);
422    let total_lines = lines.len();
423    let content_height = area.height.saturating_sub(2) as usize;
424    let max_scroll = total_lines.saturating_sub(content_height);
425    if app_state.help_scroll.offset > max_scroll {
426        app_state.help_scroll.offset = max_scroll;
427    }
428    let scroll_offset = app_state.help_scroll.offset;
429
430    f.render_widget(Clear, area);
431    let widget = Paragraph::new(lines)
432        .block(
433            Block::default()
434                .borders(Borders::ALL)
435                .title(" Help ")
436                .border_style(Style::default().fg(Color::Yellow)),
437        )
438        .wrap(Wrap { trim: false })
439        .scroll((scroll_offset as u16, 0));
440    f.render_widget(widget, area);
441
442    if total_lines > content_height {
443        let mut sb_state = ScrollbarState::new(total_lines).position(scroll_offset);
444        f.render_stateful_widget(
445            Scrollbar::new(ScrollbarOrientation::VerticalRight),
446            area,
447            &mut sb_state,
448        );
449    }
450}