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("Ctrl+C / Ctrl+Q", "Quit"));
266    lines.push(key_row("Esc", "Back / close overlay / exit detail"));
267    lines.push(key_row("Ctrl+T", "Symbol search (workspace)"));
268    lines.push(key_row("Ctrl+W", "Start a /steer command in chat"));
269    lines.push(key_row("Ctrl+Y", "Copy latest assistant reply"));
270    lines.push(key_row("Ctrl+V", "Paste image from clipboard"));
271    lines.push(key_row("Enter", "Send message or run slash command"));
272    lines.push(key_row("Tab", "Accept slash autocomplete"));
273    lines.push(blank());
274
275    lines.push(heading("TEXT EDITING"));
276    lines.push(key_row("Left / Right", "Move cursor"));
277    lines.push(key_row("Ctrl+Left/Right", "Move by word"));
278    lines.push(key_row("Home / End", "Jump to start / end of input"));
279    lines.push(key_row("Backspace", "Delete backward"));
280    lines.push(key_row("Delete", "Delete forward"));
281    lines.push(key_row("Ctrl+Up / Down", "Command history (in chat)"));
282    lines.push(blank());
283
284    lines.push(heading("SCROLLING"));
285    lines.push(key_row("Up / Down", "Scroll messages"));
286    lines.push(key_row("Shift+Up / Down", "Scroll compact tool panels"));
287    lines.push(key_row("PgUp / PgDn", "Scroll by page"));
288    lines.push(key_row("Mouse wheel", "Scroll the current list or view"));
289    lines.push(key_row("Shift+Wheel", "Scroll compact tool panels in chat"));
290    lines.push(key_row("Alt+J / Alt+K", "Chat scroll down / up"));
291    lines.push(key_row("Alt+D / Alt+U", "Chat page down / up"));
292    lines.push(key_row(
293        "Ctrl+G / Ctrl+Shift+G",
294        "Jump chat to top / bottom",
295    ));
296    lines.push(blank());
297
298    // ── Slash commands ──
299    lines.push(heading("SLASH COMMANDS"));
300    lines.push(separator());
301    lines.push(Line::from(Span::styled(
302        "  Aliases: type a prefix and Tab to autocomplete",
303        Style::default().fg(Color::DarkGray),
304    )));
305    lines.push(blank());
306
307    lines.push(heading("Navigation"));
308    lines.push(cmd_row("/chat", "", "Return to chat view"));
309    lines.push(cmd_row("/help", "/h /?", "Open this help"));
310    lines.push(cmd_row("/sessions", "/s", "Session picker"));
311    lines.push(cmd_row("/model", "/m", "Model picker"));
312    lines.push(cmd_row("/file", "", "Attach file contents to composer"));
313    lines.push(cmd_row(
314        "/autoapply",
315        "/aa",
316        "Toggle edit preview auto-apply",
317    ));
318    lines.push(cmd_row(
319        "/network",
320        "",
321        "Toggle sandbox bash network access",
322    ));
323    lines.push(cmd_row(
324        "/autocomplete",
325        "",
326        "Toggle slash-command Tab autocomplete",
327    ));
328    lines.push(cmd_row(
329        "/steer",
330        "",
331        "Queue guidance for the next turn (/steer clear to reset)",
332    ));
333    lines.push(cmd_row("/settings", "/set", "Settings panel"));
334    lines.push(cmd_row("/new", "", "Start fresh chat buffer"));
335    lines.push(cmd_row("/undo", "", "Undo last user message and response"));
336    lines.push(blank());
337
338    lines.push(heading("Protocol & Observability"));
339    lines.push(cmd_row("/bus", "/b", "Protocol bus log"));
340    lines.push(cmd_row("/protocol", "/p", "Protocol bus (alias)"));
341    lines.push(cmd_row("/swarm", "/w", "Swarm agent view"));
342    lines.push(cmd_row("/ralph", "/r", "Ralph PRD loop view"));
343    lines.push(cmd_row("/latency", "", "Provider + tool latency inspector"));
344    lines.push(cmd_row(
345        "/inspector",
346        "",
347        "Token metrics & tool call inspector",
348    ));
349    lines.push(blank());
350
351    lines.push(heading("Development Tools"));
352    lines.push(cmd_row("/lsp", "", "LSP diagnostics view"));
353    lines.push(cmd_row("/rlm", "", "RLM processing view"));
354    lines.push(cmd_row("/symbols", "/sym", "Workspace symbol search"));
355    lines.push(cmd_row("/keys", "", "Print all commands to status bar"));
356    lines.push(blank());
357
358    // ── View-specific controls ──
359    lines.push(heading("SESSION PICKER"));
360    lines.push(separator());
361    lines.push(key_row("Up / Down", "Navigate sessions"));
362    lines.push(key_row("Enter", "Load selected session"));
363    lines.push(key_row("Type", "Filter sessions by name/ID"));
364    lines.push(key_row("Backspace", "Clear filter character"));
365    lines.push(key_row("Esc", "Close picker"));
366    lines.push(blank());
367
368    lines.push(heading("SETTINGS"));
369    lines.push(separator());
370    lines.push(key_row("Up / Down", "Select a setting"));
371    lines.push(key_row("Enter", "Toggle selected setting"));
372    lines.push(key_row("a", "Toggle edit auto-apply"));
373    lines.push(key_row("n", "Toggle network access"));
374    lines.push(key_row("Tab", "Toggle slash autocomplete"));
375    lines.push(key_row("Esc", "Return to chat"));
376    lines.push(blank());
377
378    lines.push(heading("PROTOCOL BUS LOG"));
379    lines.push(separator());
380    lines.push(key_row("Up / Down", "Navigate entries"));
381    lines.push(key_row("Enter", "Open detail view"));
382    lines.push(key_row("/", "Enter filter mode"));
383    lines.push(key_row("c", "Clear filter"));
384    lines.push(key_row("g", "Jump to latest entry"));
385    lines.push(key_row("Esc", "Close detail or filter"));
386    lines.push(blank());
387
388    lines.push(heading("SWARM / RALPH"));
389    lines.push(separator());
390    lines.push(key_row("Up / Down", "Select sub-agent / story"));
391    lines.push(key_row("Enter", "Open detail view"));
392    lines.push(key_row("PgUp / PgDn", "Scroll detail content"));
393    lines.push(key_row("Esc", "Exit detail"));
394    lines.push(blank());
395
396    lines.push(separator());
397    lines.push(Line::from(Span::styled(
398        "  Press Esc to return to chat",
399        Style::default().fg(Color::DarkGray),
400    )));
401    lines.push(blank());
402
403    lines
404}
405
406pub fn render_help_overlay_if_needed(f: &mut Frame, app_state: &mut AppState) {
407    if !app_state.show_help {
408        return;
409    }
410
411    let area = centered_rect(60, 60, f.area());
412    let lines = build_help_lines(app_state);
413    let total_lines = lines.len();
414    let content_height = area.height.saturating_sub(2) as usize;
415    let max_scroll = total_lines.saturating_sub(content_height);
416    if app_state.help_scroll.offset > max_scroll {
417        app_state.help_scroll.offset = max_scroll;
418    }
419    let scroll_offset = app_state.help_scroll.offset;
420
421    f.render_widget(Clear, area);
422    let widget = Paragraph::new(lines)
423        .block(
424            Block::default()
425                .borders(Borders::ALL)
426                .title(" Help ")
427                .border_style(Style::default().fg(Color::Yellow)),
428        )
429        .wrap(Wrap { trim: false })
430        .scroll((scroll_offset as u16, 0));
431    f.render_widget(widget, area);
432
433    if total_lines > content_height {
434        let mut sb_state = ScrollbarState::new(total_lines).position(scroll_offset);
435        f.render_stateful_widget(
436            Scrollbar::new(ScrollbarOrientation::VerticalRight),
437            area,
438            &mut sb_state,
439        );
440    }
441}