Skip to main content

recursive/tui/ui/
modal.rs

1//! Modal stack (Goal 146).
2//!
3//! A modal is a transient overlay drawn on top of the chat screen.
4//! [`crate::tui::app::App`] owns a `Vec<Modal>`; the topmost element (the
5//! "active" modal) is rendered centred over a half-screen window and
6//! consumes all key events until popped.
7//!
8//! Modals never mutate runtime state directly — they are pure
9//! read-only views over [`App`]. Side-effects (clear / exit /
10//! compact) are routed through the command system in
11//! [`crate::commands`] and the input dispatcher in
12//! [`crate::tui::app::App`].
13
14use 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/// A simple read-only journal entry: filename + its first 30 lines.
24#[derive(Clone, Debug, PartialEq, Eq)]
25pub struct JournalEntry {
26    pub name: String,
27    pub preview: String,
28}
29
30// ── Goal-171: resume-picker entry ────────────────────────────────────────────
31
32/// One entry in the [`Modal::ResumePicker`] list.
33#[derive(Clone, Debug, PartialEq)]
34pub struct ResumeEntry {
35    /// Absolute path to the session directory.
36    pub session_dir: std::path::PathBuf,
37    /// Short description (≤40 chars): first user prompt or goal text.
38    pub slug: String,
39    /// Human-readable last-updated date ("2026-06-01 14:22").
40    pub updated_at: String,
41    /// Number of recorded messages.
42    pub turn_count: usize,
43    /// Cumulative cost in USD (0.0 if unknown).
44    pub cost_usd: f64,
45}
46
47/// One entry in the [`Modal::McpServers`] list.
48#[derive(Clone, Debug, PartialEq, Eq)]
49pub struct McpEntry {
50    pub name: String,
51    pub transport: String,
52    pub enabled: bool,
53}
54
55/// A confirmation request awaiting `y/n`.
56#[derive(Clone, Debug, PartialEq, Eq)]
57pub enum ConfirmAction {
58    Exit,
59    Clear,
60}
61
62/// All modal flavours the TUI knows how to render.
63///
64/// Goal 146 ships Help / CostDetail / ModelInfo / ToolList / Journal
65/// / Confirm. Goal 147 folds the Plan-mode confirmation into the
66/// modal stack as `Modal::PlanReview`, replacing the dedicated
67/// `AppScreen::PlanReview` (now removed).
68#[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    /// Goal-147: structured plan-mode confirmation. `tool_calls`
85    /// carries the pending tool calls as JSON values (see
86    /// [`AgentEvent::PlanProposed`]). `edited_text` is reserved for
87    /// future inline editing — Goal 147 only uses the `e` key to
88    /// copy the plan into the prompt buffer and dismiss, so this
89    /// field is currently always `None` but kept for forward
90    /// compatibility per the goal's field schema.
91    PlanReview {
92        plan_text: String,
93        tool_calls: Vec<serde_json::Value>,
94        edited_text: Option<String>,
95    },
96    /// Goal-171: session resume picker. Shows a list of recent sessions
97    /// ordered by last-updated; ↑/↓ selects, Enter resumes, Esc cancels.
98    ResumePicker {
99        entries: Vec<ResumeEntry>,
100        selected: usize,
101    },
102    /// Goal-173: MCP server list. Shows configured MCP servers with
103    /// their transport type and enabled status.
104    McpServers {
105        entries: Vec<McpEntry>,
106        selected: usize,
107    },
108}
109
110impl Modal {
111    /// Title shown in the modal's top border.
112    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
127// ──────────────────────────────────────────────────────────────────────
128// Rendering
129// ──────────────────────────────────────────────────────────────────────
130
131/// Render the topmost modal centred on the frame area.
132///
133/// The dim backdrop is drawn first via [`Clear`]; the modal frame
134/// occupies roughly two-thirds of the screen. The caller (chat
135/// renderer) skips its own input cursor when a modal is active.
136pub fn render(frame: &mut Frame, app: &App) {
137    let Some(modal) = app.modals.last() else {
138        return;
139    };
140    // Use 88% width and 90% height so the expanded viewport (40 rows)
141    // gives roughly 33 usable content rows inside the border.
142    let area = centred_rect(frame.area(), 88, 90);
143
144    // Dim backdrop.
145    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
175/// Carve a centred rectangle out of `outer`, taking the requested
176/// percentage of width and height.
177fn 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    // Built-in commands from the registry.
205    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    // Goal-169: skill-backed commands loaded from .recursive/skills/.
215    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    // Pull from Config so the modal shows the same endpoint the runtime
326    // will use, including the `provider.preset` chain. Previously this
327    // read RECURSIVE_API_BASE directly and used a `model.starts_with(...)`
328    // heuristic — both bypassed preset resolution and displayed wrong
329    // values when the user set `provider.preset = "deepseek"`.
330    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        // Limit preview to 12 lines; use modal_scroll (↑/↓) to read more.
425        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
545/// Goal-147: render the body of a [`Modal::PlanReview`].
546///
547/// Layout, mirroring the goal §1 ASCII sketch:
548///
549/// ```text
550/// Plan Proposal
551///
552/// <plan_text multi-line, white>
553///
554/// Pending tools (N):
555///   • name(arguments_preview)
556///   • …
557///
558/// [y/Enter] Approve  [n/Esc] Reject  [e] Edit
559/// ```
560///
561/// `tool_calls` is the JSON-shaped payload from
562/// `AgentEvent::PlanProposed.tool_calls`. Each entry should have
563/// `name` (string), optional `id` (string), and `arguments` (JSON).
564/// We tolerate missing fields and render a best-effort preview.
565pub 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
628/// Format a single tool's `arguments` JSON value as a short preview
629/// inside the PlanReview tool list. Strings get quoted-and-clamped,
630/// objects get a `key=value` reduction (up to two keys), other JSON
631/// values render via their `to_string()`.
632fn 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
671// ──────────────────────────────────────────────────────────────────────
672// Journal helpers
673// ──────────────────────────────────────────────────────────────────────
674
675/// Read up to `max_entries` `.md` files from `.dev/journal/`, sorted
676/// by modification time descending. Each entry's preview is the
677/// first 30 lines.
678///
679/// The path is hard-coded to `.dev/journal/` to keep `/journal` from
680/// becoming an arbitrary file-read primitive (see goal §9 Notes).
681pub 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
685/// Pure helper: read journal entries from `dir`. Exposed so tests can
686/// point at a tempdir.
687pub 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
731// ──────────────────────────────────────────────────────────────────────
732// Session helpers (Goal-171)
733// ──────────────────────────────────────────────────────────────────────
734
735/// Load up to `limit` recent sessions from `workspace`, sorted by
736/// `updated_at` descending. Silently skips dirs with missing/corrupt metadata.
737pub 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            // SessionCost tracks token counts only; no USD field.
764            let cost_usd = 0.0_f64;
765            let _ = meta.cost; // suppress unused warning
766            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// ──────────────────────────────────────────────────────────────────────
778// Tests
779// ──────────────────────────────────────────────────────────────────────
780
781#[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        // Simulating an Esc when a modal is active calls into App's
793        // modal-priority path; here we directly assert pop.
794        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        // Sleep enough so the second file's mtime is strictly newer
886        // — filesystems may collapse same-second mtimes.
887        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        // 30-line cap enforced (we asked for 3): old.md preview has 3 lines.
897        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(&registry);
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        // Keys section
912        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(&registry);
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}