1use ratatui::layout::Rect;
15use ratatui::prelude::*;
16use ratatui::style::{Color, Modifier, Style};
17use ratatui::text::{Line, Span};
18use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
19
20use crate::tui::app::{App, UsageStats};
21use crate::tui::commands::CommandRegistry;
22
23#[derive(Clone, Debug, PartialEq, Eq)]
25pub struct JournalEntry {
26 pub name: String,
27 pub preview: String,
28}
29
30#[derive(Clone, Debug, PartialEq)]
34pub struct ResumeEntry {
35 pub session_dir: std::path::PathBuf,
37 pub slug: String,
39 pub updated_at: String,
41 pub turn_count: usize,
43 pub cost_usd: f64,
45}
46
47#[derive(Clone, Debug, PartialEq, Eq)]
49pub struct McpEntry {
50 pub name: String,
51 pub transport: String,
52 pub enabled: bool,
53}
54
55#[derive(Clone, Debug, PartialEq, Eq)]
57pub enum ConfirmAction {
58 Exit,
59 Clear,
60}
61
62#[derive(Clone, Debug, PartialEq)]
69pub enum Modal {
70 Help,
71 CostDetail,
72 ModelInfo,
73 ToolList {
74 entries: Vec<(String, String)>,
75 },
76 Journal {
77 entries: Vec<JournalEntry>,
78 selected: usize,
79 },
80 Confirm {
81 prompt: String,
82 on_yes: ConfirmAction,
83 },
84 PlanReview {
92 plan_text: String,
93 tool_calls: Vec<serde_json::Value>,
94 edited_text: Option<String>,
95 },
96 ResumePicker {
99 entries: Vec<ResumeEntry>,
100 selected: usize,
101 },
102 McpServers {
105 entries: Vec<McpEntry>,
106 selected: usize,
107 },
108}
109
110impl Modal {
111 pub fn title(&self) -> &'static str {
113 match self {
114 Modal::Help => " Help ",
115 Modal::CostDetail => " Token usage ",
116 Modal::ModelInfo => " Model ",
117 Modal::ToolList { .. } => " Tools ",
118 Modal::Journal { .. } => " Journal ",
119 Modal::Confirm { .. } => " Confirm ",
120 Modal::PlanReview { .. } => " Plan Proposal ",
121 Modal::ResumePicker { .. } => " Resume Session ",
122 Modal::McpServers { .. } => " MCP Servers ",
123 }
124 }
125}
126
127pub fn render(frame: &mut Frame, app: &App) {
137 let Some(modal) = app.modals.last() else {
138 return;
139 };
140 let area = centred_rect(frame.area(), 88, 90);
143
144 frame.render_widget(Clear, area);
146
147 let body = match modal {
148 Modal::Help => render_help_body(&app.commands),
149 Modal::CostDetail => render_cost_body(&app.usage, &app.model_name),
150 Modal::ModelInfo => render_model_body(&app.model_name),
151 Modal::ToolList { entries } => render_tool_body(entries),
152 Modal::Journal { entries, selected } => render_journal_body(entries, *selected),
153 Modal::Confirm { prompt, .. } => render_confirm_body(prompt),
154 Modal::ResumePicker { entries, selected } => render_resume_picker_body(entries, *selected),
155 Modal::McpServers { entries, selected } => render_mcp_servers_body(entries, *selected),
156 Modal::PlanReview {
157 plan_text,
158 tool_calls,
159 ..
160 } => render_plan_review(plan_text, tool_calls),
161 };
162
163 let block = Block::default()
164 .borders(Borders::ALL)
165 .border_style(Style::default().fg(Color::Cyan))
166 .title(modal.title())
167 .style(Style::default().bg(Color::Black));
168 let para = Paragraph::new(body)
169 .block(block)
170 .wrap(Wrap { trim: false })
171 .scroll((app.modal_scroll, 0));
172 frame.render_widget(para, area);
173}
174
175fn centred_rect(outer: Rect, pct_w: u16, pct_h: u16) -> Rect {
178 let w = outer.width.saturating_mul(pct_w) / 100;
179 let h = outer.height.saturating_mul(pct_h) / 100;
180 Rect {
181 x: outer.x + (outer.width.saturating_sub(w)) / 2,
182 y: outer.y + (outer.height.saturating_sub(h)) / 2,
183 width: w,
184 height: h,
185 }
186}
187
188fn render_help_body(registry: &CommandRegistry) -> Vec<Line<'static>> {
189 let header = Style::default()
190 .fg(Color::Yellow)
191 .add_modifier(Modifier::BOLD);
192 let key = Style::default().fg(Color::Cyan);
193 let dim = Style::default().fg(Color::DarkGray);
194 let skill_style = Style::default().fg(Color::Green);
195
196 let mut out = Vec::new();
197 out.push(Line::from(Span::styled(
198 "Recursive TUI — Help".to_string(),
199 header,
200 )));
201 out.push(Line::raw(""));
202 out.push(Line::from(Span::styled("Commands:".to_string(), header)));
203
204 for spec in registry.commands() {
206 out.push(Line::from(vec![
207 Span::raw(" "),
208 Span::styled(format!("/{:<10}", spec.name), key),
209 Span::raw(" "),
210 Span::raw(spec.summary.to_string()),
211 ]));
212 }
213
214 let skills = registry.skill_commands();
216 if !skills.is_empty() {
217 out.push(Line::raw(""));
218 out.push(Line::from(Span::styled(
219 "Skill Commands:".to_string(),
220 header,
221 )));
222 for skill in skills {
223 out.push(Line::from(vec![
224 Span::raw(" "),
225 Span::styled(format!("/{:<10}", skill.name), skill_style),
226 Span::raw(" "),
227 Span::raw(skill.description.clone()),
228 ]));
229 }
230 }
231
232 out.push(Line::raw(""));
233 out.push(Line::from(Span::styled("Keys:".to_string(), header)));
234 let keys: &[(&str, &str)] = &[
235 ("Enter", "Submit"),
236 ("Shift+Enter", "Newline"),
237 ("Shift+Tab", "Cycle input mode (prompt → bash → note)"),
238 ("↑/↓ (empty)", "Browse history"),
239 ("PgUp / PgDn", "Scroll transcript"),
240 ("Ctrl+E", "Toggle expand on tool result / EOL in input"),
241 ("Ctrl+A", "Move to line start"),
242 ("Ctrl+C", "Interrupt (Step 5)"),
243 ("Esc", "Close modal / cancel"),
244 ("q (in modal)", "Close modal"),
245 ];
246 for (k, desc) in keys {
247 out.push(Line::from(vec![
248 Span::raw(" "),
249 Span::styled(format!("{k:<14}"), key),
250 Span::raw(" "),
251 Span::raw(desc.to_string()),
252 ]));
253 }
254 out.push(Line::raw(""));
255 out.push(Line::from(Span::styled(
256 "Esc / q to close".to_string(),
257 dim,
258 )));
259 out
260}
261
262fn render_cost_body(usage: &UsageStats, model: &str) -> Vec<Line<'static>> {
263 let pricing = crate::llm::pricing_for(model);
264 let cost_in = pricing.map(|p| (usage.total_input as f64) * p.input_per_million / 1_000_000.0);
265 let cost_out =
266 pricing.map(|p| (usage.total_output as f64) * p.output_per_million / 1_000_000.0);
267 let cost_total = cost_in.zip(cost_out).map(|(a, b)| a + b);
268
269 let fmt_cost = |c: Option<f64>| match c {
270 Some(v) => format!("(${v:.4})"),
271 None => String::from("(no pricing)"),
272 };
273
274 let header = Style::default()
275 .fg(Color::Yellow)
276 .add_modifier(Modifier::BOLD);
277 let body = Style::default().fg(Color::White);
278
279 let mut out = vec![Line::from(Span::styled(
280 "Token usage (this session)".to_string(),
281 header,
282 ))];
283 out.push(Line::raw(""));
284 out.push(Line::from(vec![
285 Span::raw(" "),
286 Span::styled(
287 format!("Input : {:<7} {}", usage.total_input, fmt_cost(cost_in)),
288 body,
289 ),
290 ]));
291 out.push(Line::from(vec![
292 Span::raw(" "),
293 Span::styled(
294 format!("Output : {:<7} {}", usage.total_output, fmt_cost(cost_out)),
295 body,
296 ),
297 ]));
298 out.push(Line::from(vec![
299 Span::raw(" "),
300 Span::styled(
301 format!(
302 "Total : {:<7} {}",
303 usage.total_input.saturating_add(usage.total_output),
304 fmt_cost(cost_total)
305 ),
306 body,
307 ),
308 ]));
309 out.push(Line::raw(""));
310 out.push(Line::from(vec![
311 Span::raw(" "),
312 Span::raw(format!(
313 "Last turn latency: {:.2} s",
314 usage.last_latency_ms as f64 / 1000.0
315 )),
316 ]));
317 out.push(Line::from(vec![
318 Span::raw(" "),
319 Span::raw(format!("Provider : {model}")),
320 ]));
321 out
322}
323
324fn render_model_body(model: &str) -> Vec<Line<'static>> {
325 let cfg = crate::config::Config::from_env().ok();
331 let api_base = cfg
332 .as_ref()
333 .map(|c| c.api_base.clone())
334 .unwrap_or_else(|| "https://api.anthropic.com".to_string());
335 let provider = cfg
336 .as_ref()
337 .and_then(|c| c.preset.clone())
338 .or_else(|| crate::providers::find_preset_by_api_base(&api_base).map(|p| p.id.to_string()))
339 .unwrap_or_else(|| "custom".to_string());
340
341 let header = Style::default()
342 .fg(Color::Yellow)
343 .add_modifier(Modifier::BOLD);
344 let mut out = vec![Line::from(Span::styled(
345 "Current model".to_string(),
346 header,
347 ))];
348 out.push(Line::raw(""));
349 out.push(Line::from(format!(" Model : {model}")));
350 out.push(Line::from(format!(" Provider : {provider}")));
351 out.push(Line::from(format!(" Endpoint : {api_base}")));
352 out.push(Line::raw(""));
353 out.push(Line::from(Span::styled(
354 "(read-only — switching models requires restart)".to_string(),
355 Style::default().fg(Color::DarkGray),
356 )));
357 out
358}
359
360fn render_tool_body(entries: &[(String, String)]) -> Vec<Line<'static>> {
361 let header = Style::default()
362 .fg(Color::Yellow)
363 .add_modifier(Modifier::BOLD);
364 let key = Style::default().fg(Color::Cyan);
365 let mut out = vec![Line::from(Span::styled(
366 format!("Available tools ({})", entries.len()),
367 header,
368 ))];
369 out.push(Line::raw(""));
370 if entries.is_empty() {
371 out.push(Line::from(Span::styled(
372 " (no tools registered)".to_string(),
373 Style::default().fg(Color::DarkGray),
374 )));
375 } else {
376 for (name, desc) in entries {
377 out.push(Line::from(vec![
378 Span::raw(" "),
379 Span::styled(format!("{name:<16}"), key),
380 Span::raw(" "),
381 Span::raw(short_desc(desc, 60)),
382 ]));
383 }
384 }
385 out
386}
387
388fn render_journal_body(entries: &[JournalEntry], selected: usize) -> Vec<Line<'static>> {
389 let header = Style::default()
390 .fg(Color::Yellow)
391 .add_modifier(Modifier::BOLD);
392 let mut out = vec![Line::from(Span::styled(
393 "Recent journal entries".to_string(),
394 header,
395 ))];
396 out.push(Line::raw(""));
397 if entries.is_empty() {
398 out.push(Line::from(Span::styled(
399 " (no entries in .dev/journal/)".to_string(),
400 Style::default().fg(Color::DarkGray),
401 )));
402 return out;
403 }
404 for (i, entry) in entries.iter().enumerate() {
405 let marker = if i == selected { "▶" } else { " " };
406 let style = if i == selected {
407 Style::default()
408 .fg(Color::Yellow)
409 .add_modifier(Modifier::BOLD)
410 } else {
411 Style::default().fg(Color::White)
412 };
413 out.push(Line::from(vec![
414 Span::raw(format!(" {marker} ")),
415 Span::styled(entry.name.clone(), style),
416 ]));
417 }
418 out.push(Line::raw(""));
419 if let Some(active) = entries.get(selected) {
420 out.push(Line::from(Span::styled(
421 format!("── {} ──", active.name),
422 Style::default().fg(Color::DarkGray),
423 )));
424 for line in active.preview.lines().take(12) {
426 out.push(Line::from(format!(" {line}")));
427 }
428 let total = active.preview.lines().count();
429 if total > 12 {
430 out.push(Line::from(Span::styled(
431 format!(" … ({} more lines, ↑/↓ to scroll)", total - 12),
432 Style::default().fg(Color::DarkGray),
433 )));
434 }
435 }
436 out.push(Line::raw(""));
437 out.push(Line::from(Span::styled(
438 "↑/↓ navigate | Esc / q close".to_string(),
439 Style::default().fg(Color::DarkGray),
440 )));
441 out
442}
443
444fn render_resume_picker_body(entries: &[ResumeEntry], selected: usize) -> Vec<Line<'static>> {
445 let header = Style::default()
446 .fg(Color::Yellow)
447 .add_modifier(Modifier::BOLD);
448 let dim = Style::default().fg(Color::DarkGray);
449
450 let mut out = vec![Line::from(Span::styled(
451 "Recent sessions (↑/↓ select · Enter resume · Esc cancel)".to_string(),
452 header,
453 ))];
454 out.push(Line::raw(""));
455
456 if entries.is_empty() {
457 out.push(Line::from(Span::styled(
458 " (no saved sessions found)".to_string(),
459 dim,
460 )));
461 return out;
462 }
463
464 for (i, entry) in entries.iter().enumerate() {
465 let sel_marker = if i == selected { "▶" } else { " " };
466 let row_style = if i == selected {
467 Style::default()
468 .fg(Color::Yellow)
469 .add_modifier(Modifier::BOLD)
470 } else {
471 Style::default().fg(Color::White)
472 };
473 let line_text = format!(
474 " {} {:<42} turns:{:>3} {}",
475 sel_marker, entry.slug, entry.turn_count, entry.updated_at
476 );
477 out.push(Line::from(Span::styled(line_text, row_style)));
478 }
479 out.push(Line::raw(""));
480 out.push(Line::from(Span::styled(
481 "↑/↓ navigate | Enter resume | Esc cancel".to_string(),
482 dim,
483 )));
484 out
485}
486
487fn render_mcp_servers_body(entries: &[McpEntry], selected: usize) -> Vec<Line<'static>> {
488 let header = Style::default()
489 .fg(Color::Yellow)
490 .add_modifier(Modifier::BOLD);
491 let dim = Style::default().fg(Color::DarkGray);
492 let green = Style::default().fg(Color::Green);
493 let disabled = Style::default().fg(Color::DarkGray);
494
495 let mut out = vec![Line::from(Span::styled(
496 "Configured MCP servers (↑/↓ navigate · Esc close)".to_string(),
497 header,
498 ))];
499 out.push(Line::raw(""));
500
501 if entries.is_empty() {
502 out.push(Line::from(Span::styled(
503 " No MCP servers configured".to_string(),
504 dim,
505 )));
506 return out;
507 }
508
509 for (i, entry) in entries.iter().enumerate() {
510 let sel_marker = if i == selected { "▶" } else { " " };
511 let bullet = if entry.enabled { "●" } else { "○" };
512 let style = if i == selected {
513 Style::default()
514 .fg(Color::Yellow)
515 .add_modifier(Modifier::BOLD)
516 } else if entry.enabled {
517 green
518 } else {
519 disabled
520 };
521 let line_text = format!(
522 " {} {} {} ({})",
523 sel_marker, bullet, entry.name, entry.transport
524 );
525 out.push(Line::from(Span::styled(line_text, style)));
526 }
527 out.push(Line::raw(""));
528 out.push(Line::from(Span::styled(
529 "↑/↓ navigate | Esc / q close".to_string(),
530 dim,
531 )));
532 out
533}
534
535fn render_confirm_body(prompt: &str) -> Vec<Line<'static>> {
536 let mut out = vec![Line::from(prompt.to_string())];
537 out.push(Line::raw(""));
538 out.push(Line::from(Span::styled(
539 " [y] yes [n] no [Esc] cancel".to_string(),
540 Style::default().fg(Color::DarkGray),
541 )));
542 out
543}
544
545pub fn render_plan_review(plan_text: &str, tool_calls: &[serde_json::Value]) -> Vec<Line<'static>> {
566 let header = Style::default()
567 .fg(Color::Yellow)
568 .add_modifier(Modifier::BOLD);
569 let body = Style::default().fg(Color::White);
570 let key = Style::default().fg(Color::Cyan);
571 let dim = Style::default().fg(Color::DarkGray);
572
573 let mut out = Vec::new();
574 out.push(Line::from(Span::styled(
575 "Plan Proposal".to_string(),
576 header,
577 )));
578 out.push(Line::raw(""));
579 for raw in plan_text.lines() {
580 out.push(Line::from(Span::styled(raw.to_string(), body)));
581 }
582 out.push(Line::raw(""));
583 out.push(Line::from(Span::styled(
584 format!("Pending tools ({}):", tool_calls.len()),
585 header,
586 )));
587 if tool_calls.is_empty() {
588 out.push(Line::from(Span::styled(" (none)".to_string(), dim)));
589 } else {
590 for tc in tool_calls {
591 let name = tc
592 .get("name")
593 .and_then(|v| v.as_str())
594 .unwrap_or("<unknown>");
595 let args = tc
596 .get("arguments")
597 .map(plan_review_args_preview)
598 .unwrap_or_default();
599 out.push(Line::from(vec![
600 Span::raw(" • "),
601 Span::styled(name.to_string(), key),
602 Span::styled(format!("({args})"), body),
603 ]));
604 }
605 }
606 out.push(Line::raw(""));
607 out.push(Line::from(vec![
608 Span::styled("[y/Enter] ", key),
609 Span::styled(
610 "Approve",
611 Style::default()
612 .fg(Color::Green)
613 .add_modifier(Modifier::BOLD),
614 ),
615 Span::raw(" "),
616 Span::styled("[n/Esc] ", key),
617 Span::styled(
618 "Reject",
619 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
620 ),
621 Span::raw(" "),
622 Span::styled("[e] ", key),
623 Span::styled("Edit", Style::default().fg(Color::Yellow)),
624 ]));
625 out
626}
627
628fn plan_review_args_preview(value: &serde_json::Value) -> String {
633 use serde_json::Value;
634 match value {
635 Value::String(s) => format!("\"{}\"", short(s, 40)),
636 Value::Object(map) => {
637 let mut parts = Vec::new();
638 for (k, v) in map.iter().take(2) {
639 let v_str = match v {
640 Value::String(s) => format!("\"{}\"", short(s, 24)),
641 other => short(&other.to_string(), 24),
642 };
643 parts.push(format!("{k}={v_str}"));
644 }
645 short(&parts.join(", "), 60)
646 }
647 Value::Null => String::new(),
648 other => short(&other.to_string(), 60),
649 }
650}
651
652fn short(s: &str, max: usize) -> String {
653 if s.chars().count() <= max {
654 s.to_string()
655 } else {
656 let head: String = s.chars().take(max.saturating_sub(1)).collect();
657 format!("{head}…")
658 }
659}
660
661fn short_desc(desc: &str, max: usize) -> String {
662 let one_line = desc.lines().next().unwrap_or("").trim();
663 if one_line.chars().count() <= max {
664 one_line.to_string()
665 } else {
666 let head: String = one_line.chars().take(max.saturating_sub(1)).collect();
667 format!("{head}…")
668 }
669}
670
671pub fn load_recent_journal_entries(max_entries: usize) -> Vec<JournalEntry> {
682 load_journal_from(std::path::Path::new(".dev/journal"), max_entries, 30)
683}
684
685pub fn load_journal_from(
688 dir: &std::path::Path,
689 max_entries: usize,
690 preview_lines: usize,
691) -> Vec<JournalEntry> {
692 let read_dir = match std::fs::read_dir(dir) {
693 Ok(rd) => rd,
694 Err(_) => return Vec::new(),
695 };
696
697 let mut paths: Vec<(std::path::PathBuf, std::time::SystemTime)> = Vec::new();
698 for entry in read_dir.flatten() {
699 let path = entry.path();
700 if path.extension().and_then(|s| s.to_str()) != Some("md") {
701 continue;
702 }
703 let mtime = entry
704 .metadata()
705 .and_then(|m| m.modified())
706 .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
707 paths.push((path, mtime));
708 }
709 paths.sort_by_key(|b| std::cmp::Reverse(b.1));
710 paths.truncate(max_entries);
711
712 paths
713 .into_iter()
714 .map(|(path, _)| {
715 let name = path
716 .file_name()
717 .and_then(|s| s.to_str())
718 .unwrap_or("")
719 .to_string();
720 let preview = std::fs::read_to_string(&path)
721 .unwrap_or_default()
722 .lines()
723 .take(preview_lines)
724 .collect::<Vec<_>>()
725 .join("\n");
726 JournalEntry { name, preview }
727 })
728 .collect()
729}
730
731pub fn load_recent_sessions(workspace: &std::path::Path, limit: usize) -> Vec<ResumeEntry> {
738 let pairs = match crate::session::SessionReader::list_sessions_sorted_by_updated_at(workspace) {
739 Ok(v) => v,
740 Err(_) => return Vec::new(),
741 };
742
743 pairs
744 .into_iter()
745 .take(limit)
746 .map(|(dir, meta)| {
747 let raw_slug = if !meta.goal.is_empty() {
748 meta.goal.clone()
749 } else {
750 meta.first_prompt.clone().unwrap_or_default()
751 };
752 let slug = if raw_slug.chars().count() > 40 {
753 let s: String = raw_slug.chars().take(39).collect();
754 format!("{s}…")
755 } else {
756 raw_slug
757 };
758 let updated_at = meta
759 .updated_at
760 .get(..16)
761 .unwrap_or(&meta.updated_at)
762 .replace('T', " ");
763 let cost_usd = 0.0_f64;
765 let _ = meta.cost; ResumeEntry {
767 session_dir: dir,
768 slug,
769 updated_at,
770 turn_count: meta.message_count as usize,
771 cost_usd,
772 }
773 })
774 .collect()
775}
776
777#[cfg(test)]
782mod tests {
783 use super::*;
784 use crate::tui::app::App;
785
786 #[test]
787 fn esc_pops_top_modal() {
788 let mut app = App::new();
789 app.modals.push(Modal::Help);
790 app.modals.push(Modal::CostDetail);
791 assert_eq!(app.modals.len(), 2);
792 app.handle_modal_key(crossterm::event::KeyEvent::new(
795 crossterm::event::KeyCode::Esc,
796 crossterm::event::KeyModifiers::NONE,
797 ));
798 assert_eq!(app.modals.len(), 1);
799 assert_eq!(app.modals.last(), Some(&Modal::Help));
800 app.handle_modal_key(crossterm::event::KeyEvent::new(
801 crossterm::event::KeyCode::Char('q'),
802 crossterm::event::KeyModifiers::NONE,
803 ));
804 assert!(app.modals.is_empty());
805 }
806
807 #[test]
808 fn confirm_yes_executes_action_and_pops() {
809 let mut app = App::new();
810 app.modals.push(Modal::Confirm {
811 prompt: "Quit?".into(),
812 on_yes: ConfirmAction::Exit,
813 });
814 app.handle_modal_key(crossterm::event::KeyEvent::new(
815 crossterm::event::KeyCode::Char('y'),
816 crossterm::event::KeyModifiers::NONE,
817 ));
818 assert!(app.modals.is_empty());
819 assert!(app.should_quit);
820 }
821
822 #[test]
823 fn confirm_n_pops_without_action() {
824 let mut app = App::new();
825 app.modals.push(Modal::Confirm {
826 prompt: "Clear?".into(),
827 on_yes: ConfirmAction::Clear,
828 });
829 let blocks_before = app.blocks.len();
830 app.handle_modal_key(crossterm::event::KeyEvent::new(
831 crossterm::event::KeyCode::Char('n'),
832 crossterm::event::KeyModifiers::NONE,
833 ));
834 assert!(app.modals.is_empty());
835 assert_eq!(app.blocks.len(), blocks_before);
836 }
837
838 #[test]
839 fn journal_up_down_moves_selection() {
840 let mut app = App::new();
841 app.modals.push(Modal::Journal {
842 entries: vec![
843 JournalEntry {
844 name: "a.md".into(),
845 preview: "p1".into(),
846 },
847 JournalEntry {
848 name: "b.md".into(),
849 preview: "p2".into(),
850 },
851 JournalEntry {
852 name: "c.md".into(),
853 preview: "p3".into(),
854 },
855 ],
856 selected: 0,
857 });
858 app.handle_modal_key(crossterm::event::KeyEvent::new(
859 crossterm::event::KeyCode::Down,
860 crossterm::event::KeyModifiers::NONE,
861 ));
862 match app.modals.last() {
863 Some(Modal::Journal { selected, .. }) => assert_eq!(*selected, 1),
864 other => panic!("expected Journal, got {other:?}"),
865 }
866 app.handle_modal_key(crossterm::event::KeyEvent::new(
867 crossterm::event::KeyCode::Up,
868 crossterm::event::KeyModifiers::NONE,
869 ));
870 match app.modals.last() {
871 Some(Modal::Journal { selected, .. }) => assert_eq!(*selected, 0),
872 other => panic!("expected Journal, got {other:?}"),
873 }
874 }
875
876 #[test]
877 fn journal_loader_picks_recent_md_files() {
878 let tmp = tempfile::tempdir().unwrap();
879 let dir = tmp.path();
880 std::fs::write(
881 dir.join("old.md"),
882 "line1\nline2\nline3\nline4\nline5\nline6",
883 )
884 .unwrap();
885 std::thread::sleep(std::time::Duration::from_millis(10));
888 std::fs::write(dir.join("new.md"), "fresh content\nmore").unwrap();
889 std::fs::write(dir.join("ignored.txt"), "should be skipped").unwrap();
890
891 let entries = load_journal_from(dir, 5, 3);
892 assert_eq!(entries.len(), 2);
893 assert_eq!(entries[0].name, "new.md");
894 assert!(entries[0].preview.starts_with("fresh content"));
895 assert_eq!(entries[1].name, "old.md");
896 assert_eq!(entries[1].preview.lines().count(), 3);
898 }
899
900 #[test]
901 fn render_help_lists_registered_commands() {
902 let registry = CommandRegistry::default_set();
903 let lines = render_help_body(®istry);
904 let text: String = lines
905 .iter()
906 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
907 .collect();
908 assert!(text.contains("/help"));
909 assert!(text.contains("/clear"));
910 assert!(text.contains("/exit"));
911 assert!(text.contains("Shift+Tab"));
913 assert!(text.contains("Ctrl+E"));
914 }
915
916 #[test]
917 fn mcp_entry_renders_enabled() {
918 let entry = McpEntry {
919 name: "my-server".to_string(),
920 transport: "stdio".to_string(),
921 enabled: true,
922 };
923 let lines = render_mcp_servers_body(&[entry], 0);
924 let text: String = lines
925 .iter()
926 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
927 .collect();
928 assert!(text.contains("my-server"));
929 assert!(text.contains("stdio"));
930 assert!(text.contains("●"));
931 }
932
933 #[test]
934 fn render_help_lists_skill_commands_when_present() {
935 use crate::tui::skill_commands::SkillCommand;
936 use std::path::PathBuf;
937 let skill = SkillCommand {
938 name: "my-skill".to_string(),
939 description: "A test skill".to_string(),
940 aliases: Vec::new(),
941 argument_hint: String::new(),
942 allowed_tools: None,
943 prompt_template: "Do $ARGUMENTS".to_string(),
944 source_path: PathBuf::from("my-skill.md"),
945 };
946 let registry = CommandRegistry::default_set().with_skill_commands(vec![skill]);
947 let lines = render_help_body(®istry);
948 let text: String = lines
949 .iter()
950 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
951 .collect();
952 assert!(text.contains("Skill Commands:"));
953 assert!(text.contains("/my-skill"));
954 assert!(text.contains("A test skill"));
955 }
956}