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
16pub 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 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 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 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 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 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 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 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}