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("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 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 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}