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(
275        "Ctrl+Shift+Y",
276        "Copy entire conversation transcript (clean plain text)",
277    ));
278    lines.push(key_row("Ctrl+R", "Start/stop voice recording"));
279    lines.push(key_row("Ctrl+V", "Paste from clipboard (or image)"));
280    lines.push(key_row("Enter", "Send message or run slash command"));
281    lines.push(key_row("Tab", "Accept slash autocomplete"));
282    lines.push(blank());
283
284    // ── Images ──
285    lines.push(heading("IMAGES"));
286    lines.push(separator());
287    if crate::tui::clipboard::is_ssh_or_headless() {
288        lines.push(Line::from(Span::styled(
289            "  SSH/headless session — clipboard unavailable",
290            Style::default().fg(Color::Yellow),
291        )));
292        lines.push(blank());
293        lines.push(Line::from(Span::raw(
294            "  To paste an image without clipboard access:",
295        )));
296        lines.push(Line::from(Span::raw(
297            "  1. Run `codetether clipboard image` where clipboard works",
298        )));
299        lines.push(Line::from(Span::raw(
300            "  2. It copies a data:image URL to your clipboard",
301        )));
302        lines.push(Line::from(Span::raw(
303            "  3. Paste it here with Ctrl+Shift+V or right-click paste",
304        )));
305        lines.push(blank());
306    }
307    lines.push(cmd_row("/image", "", "Attach image file from disk"));
308    lines.push(cmd_row("/file", "", "Attach file contents to composer"));
309    lines.push(blank());
310
311    lines.push(heading("TEXT EDITING"));
312    lines.push(key_row("Left / Right", "Move cursor"));
313    lines.push(key_row("Ctrl+Left/Right", "Move by word"));
314    lines.push(key_row("Home / End", "Jump to start / end of input"));
315    lines.push(key_row("Backspace", "Delete backward"));
316    lines.push(key_row("Delete", "Delete forward"));
317    lines.push(key_row("Ctrl+Up / Down", "Command history (in chat)"));
318    lines.push(blank());
319
320    lines.push(heading("SCROLLING"));
321    lines.push(key_row("Up / Down", "Scroll messages"));
322    lines.push(key_row("Shift+Up / Down", "Scroll compact tool panels"));
323    lines.push(key_row("PgUp / PgDn", "Scroll by page"));
324    lines.push(key_row("Mouse wheel", "Scroll the current list or view"));
325    lines.push(key_row("Shift+Wheel", "Scroll compact tool panels in chat"));
326    lines.push(key_row("Alt+J / Alt+K", "Chat scroll down / up"));
327    lines.push(key_row("Alt+D / Alt+U", "Chat page down / up"));
328    lines.push(key_row(
329        "Ctrl+G / Ctrl+Shift+G",
330        "Jump chat to top / bottom",
331    ));
332    lines.push(blank());
333
334    // ── Slash commands ──
335    lines.push(heading("SLASH COMMANDS"));
336    lines.push(separator());
337    lines.push(Line::from(Span::styled(
338        "  Aliases: type a prefix and Tab to autocomplete",
339        Style::default().fg(Color::DarkGray),
340    )));
341    lines.push(blank());
342
343    lines.push(heading("Navigation"));
344    lines.push(cmd_row("/chat", "", "Return to chat view"));
345    lines.push(cmd_row("/help", "/h /?", "Open this help"));
346    lines.push(cmd_row("/sessions", "/s", "Session picker"));
347    lines.push(cmd_row("/model", "/m", "Model picker"));
348    lines.push(cmd_row("/file", "", "Attach file contents to composer"));
349    lines.push(cmd_row(
350        "/image",
351        "",
352        "Attach image file (png, jpg, gif, webp, bmp, svg)",
353    ));
354    lines.push(cmd_row(
355        "/autoapply",
356        "/aa",
357        "Toggle edit preview auto-apply",
358    ));
359    lines.push(cmd_row(
360        "/network",
361        "",
362        "Toggle sandbox bash network access",
363    ));
364    lines.push(cmd_row(
365        "/autocomplete",
366        "",
367        "Toggle slash-command Tab autocomplete",
368    ));
369    lines.push(cmd_row(
370        "/ask",
371        "",
372        "Ephemeral side question (full context, no tools, not saved)",
373    ));
374    lines.push(cmd_row("/settings", "/set", "Settings panel"));
375    lines.push(cmd_row("/new", "", "Start fresh chat buffer"));
376    lines.push(cmd_row("/undo", "", "Undo last turn (accepts /undo <N>)"));
377    lines.push(cmd_row(
378        "/fork",
379        "",
380        "Fork session at current point (/fork <N> drops last N turns)",
381    ));
382    lines.push(blank());
383
384    lines.push(heading("Protocol & Observability"));
385    lines.push(cmd_row("/bus", "/b", "Protocol bus log"));
386    lines.push(cmd_row("/protocol", "/p", "Protocol bus (alias)"));
387    lines.push(cmd_row("/swarm", "/w", "Swarm agent view"));
388    lines.push(cmd_row("/ralph", "/r", "Ralph PRD loop view"));
389    lines.push(cmd_row("/latency", "", "Provider + tool latency inspector"));
390    lines.push(cmd_row(
391        "/inspector",
392        "",
393        "Token metrics & tool call inspector",
394    ));
395    lines.push(blank());
396
397    lines.push(heading("Development Tools"));
398    lines.push(cmd_row("/lsp", "", "LSP diagnostics view"));
399    lines.push(cmd_row("/rlm", "", "RLM processing view"));
400    lines.push(cmd_row("/symbols", "/sym", "Workspace symbol search"));
401    lines.push(cmd_row("/keys", "", "Print all commands to status bar"));
402    lines.push(blank());
403
404    // ── View-specific controls ──
405    lines.push(heading("SESSION PICKER"));
406    lines.push(separator());
407    lines.push(key_row("Up / Down", "Navigate sessions"));
408    lines.push(key_row("Enter", "Load selected session"));
409    lines.push(key_row("Type", "Filter sessions by name/ID"));
410    lines.push(key_row("Backspace", "Clear filter character"));
411    lines.push(key_row("Esc", "Close picker"));
412    lines.push(blank());
413
414    lines.push(heading("SETTINGS"));
415    lines.push(separator());
416    lines.push(key_row("Up / Down", "Select a setting"));
417    lines.push(key_row("Enter", "Toggle selected setting"));
418    lines.push(key_row("a", "Toggle edit auto-apply"));
419    lines.push(key_row("n", "Toggle network access"));
420    lines.push(key_row("Tab", "Toggle slash autocomplete"));
421    lines.push(key_row("Esc", "Return to chat"));
422    lines.push(blank());
423
424    lines.push(heading("PROTOCOL BUS LOG"));
425    lines.push(separator());
426    lines.push(key_row("Up / Down", "Navigate entries"));
427    lines.push(key_row("Enter", "Open detail view"));
428    lines.push(key_row("/", "Enter filter mode"));
429    lines.push(key_row("c", "Clear filter"));
430    lines.push(key_row("g", "Jump to latest entry"));
431    lines.push(key_row("Esc", "Close detail or filter"));
432    lines.push(blank());
433
434    lines.push(heading("SWARM / RALPH"));
435    lines.push(separator());
436    lines.push(key_row("Up / Down", "Select sub-agent / story"));
437    lines.push(key_row("Enter", "Open detail view"));
438    lines.push(key_row("PgUp / PgDn", "Scroll detail content"));
439    lines.push(key_row("Esc", "Exit detail"));
440    lines.push(blank());
441
442    lines.push(separator());
443    lines.push(Line::from(Span::styled(
444        "  Press Esc to return to chat",
445        Style::default().fg(Color::DarkGray),
446    )));
447    lines.push(blank());
448
449    lines
450}
451
452pub fn render_help_overlay_if_needed(f: &mut Frame, app_state: &mut AppState) {
453    if !app_state.show_help {
454        return;
455    }
456
457    let area = centered_rect(60, 60, f.area());
458    let lines = build_help_lines(app_state);
459    let total_lines = lines.len();
460    let content_height = area.height.saturating_sub(2) as usize;
461    let max_scroll = total_lines.saturating_sub(content_height);
462    if app_state.help_scroll.offset > max_scroll {
463        app_state.help_scroll.offset = max_scroll;
464    }
465    let scroll_offset = app_state.help_scroll.offset;
466
467    f.render_widget(Clear, area);
468    let widget = Paragraph::new(lines)
469        .block(
470            Block::default()
471                .borders(Borders::ALL)
472                .title(" Help ")
473                .border_style(Style::default().fg(Color::Yellow)),
474        )
475        .wrap(Wrap { trim: false })
476        .scroll((scroll_offset as u16, 0));
477    f.render_widget(widget, area);
478
479    if total_lines > content_height {
480        let mut sb_state = ScrollbarState::new(total_lines).position(scroll_offset);
481        f.render_stateful_widget(
482            Scrollbar::new(ScrollbarOrientation::VerticalRight),
483            area,
484            &mut sb_state,
485        );
486    }
487}