Skip to main content

wisp_ui/
lib.rs

1use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2use ratatui::{
3    buffer::Buffer,
4    layout::{Constraint, Direction, Layout, Rect},
5    style::{Color, Modifier, Style},
6    symbols::border,
7    text::{Line, Span, Text},
8    widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Widget},
9};
10use wisp_core::{GitBranchStatus, GitBranchSync, PickerMode, SessionListItem, SessionListItemKind};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum SurfaceKind {
14    Picker,
15    SidebarCompact,
16    SidebarExpanded,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct SurfaceModel {
21    pub title: String,
22    pub query: String,
23    pub items: Vec<SessionListItem>,
24    pub selected: usize,
25    pub show_help: bool,
26    pub preview: Option<Vec<String>>,
27    pub kind: SurfaceKind,
28    pub bindings: KeyBindings,
29    pub mode: PickerMode,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum UiIntent {
34    SelectNext,
35    SelectPrev,
36    ActivateSelected,
37    CreateSessionFromQuery,
38    RenameSession,
39    ToggleSort,
40    CloseSession,
41    FilterChanged(String),
42    Backspace,
43    ToggleCompactSidebar,
44    TogglePreview,
45    ToggleDetails,
46    ToggleWorktreeMode,
47    Close,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct KeyBindings {
52    pub down: UiIntent,
53    pub up: UiIntent,
54    pub ctrl_j: UiIntent,
55    pub ctrl_k: UiIntent,
56    pub enter: UiIntent,
57    pub shift_enter: UiIntent,
58    pub backspace: UiIntent,
59    pub ctrl_r: UiIntent,
60    pub ctrl_s: UiIntent,
61    pub ctrl_x: UiIntent,
62    pub ctrl_p: UiIntent,
63    pub ctrl_d: UiIntent,
64    pub ctrl_m: UiIntent,
65    pub esc: UiIntent,
66    pub ctrl_c: UiIntent,
67    pub ctrl_w: UiIntent,
68}
69
70impl Default for KeyBindings {
71    fn default() -> Self {
72        Self {
73            down: UiIntent::SelectNext,
74            up: UiIntent::SelectPrev,
75            ctrl_j: UiIntent::SelectNext,
76            ctrl_k: UiIntent::SelectPrev,
77            enter: UiIntent::ActivateSelected,
78            shift_enter: UiIntent::CreateSessionFromQuery,
79            backspace: UiIntent::Backspace,
80            ctrl_r: UiIntent::RenameSession,
81            ctrl_s: UiIntent::ToggleSort,
82            ctrl_x: UiIntent::CloseSession,
83            ctrl_p: UiIntent::TogglePreview,
84            ctrl_d: UiIntent::ToggleDetails,
85            ctrl_m: UiIntent::ToggleCompactSidebar,
86            esc: UiIntent::Close,
87            ctrl_c: UiIntent::Close,
88            ctrl_w: UiIntent::ToggleWorktreeMode,
89        }
90    }
91}
92
93pub fn render_surface(area: Rect, buffer: &mut Buffer, model: &SurfaceModel) {
94    match model.kind {
95        SurfaceKind::Picker => render_picker(area, buffer, model),
96        SurfaceKind::SidebarCompact | SurfaceKind::SidebarExpanded => {
97            render_sidebar(area, buffer, model)
98        }
99    }
100}
101
102#[must_use]
103pub fn translate_key(key: KeyEvent, bindings: &KeyBindings) -> Option<UiIntent> {
104    match key.code {
105        KeyCode::Down => Some(bindings.down.clone()),
106        KeyCode::Up => Some(bindings.up.clone()),
107        KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::CONTROL) => {
108            Some(bindings.ctrl_j.clone())
109        }
110        KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) => {
111            Some(bindings.ctrl_k.clone())
112        }
113        KeyCode::Enter if key.modifiers.contains(KeyModifiers::SHIFT) => {
114            Some(bindings.shift_enter.clone())
115        }
116        KeyCode::Enter => Some(bindings.enter.clone()),
117        KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
118            Some(bindings.ctrl_r.clone())
119        }
120        KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
121            Some(bindings.ctrl_s.clone())
122        }
123        KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) => {
124            Some(bindings.ctrl_x.clone())
125        }
126        KeyCode::Esc => Some(bindings.esc.clone()),
127        KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
128            Some(bindings.ctrl_c.clone())
129        }
130        KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
131            Some(bindings.ctrl_p.clone())
132        }
133        KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
134            Some(bindings.ctrl_d.clone())
135        }
136        KeyCode::Char('m') if key.modifiers.contains(KeyModifiers::CONTROL) => {
137            Some(bindings.ctrl_m.clone())
138        }
139        KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => {
140            Some(bindings.ctrl_w.clone())
141        }
142        KeyCode::Backspace => Some(bindings.backspace.clone()),
143        KeyCode::Char(character)
144            if !key
145                .modifiers
146                .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
147        {
148            Some(UiIntent::FilterChanged(character.to_string()))
149        }
150        _ => None,
151    }
152}
153
154fn render_picker(area: Rect, buffer: &mut Buffer, model: &SurfaceModel) {
155    let chunks = Layout::default()
156        .direction(Direction::Vertical)
157        .constraints([
158            Constraint::Length(3),
159            Constraint::Min(5),
160            Constraint::Length(if model.show_help { 3 } else { 1 }),
161        ])
162        .split(area);
163
164    render_boxed_paragraph(
165        chunks[0],
166        buffer,
167        model.title.as_str(),
168        Text::from(model.query.as_str()),
169        false,
170    );
171
172    let body_chunks = if model.preview.is_some() {
173        Layout::default()
174            .direction(Direction::Horizontal)
175            .constraints([Constraint::Percentage(45), Constraint::Percentage(55)])
176            .split(chunks[1])
177    } else {
178        Layout::default()
179            .direction(Direction::Horizontal)
180            .constraints([Constraint::Percentage(100)])
181            .split(chunks[1])
182    };
183
184    render_list(body_chunks[0], buffer, model, false);
185
186    if let Some(preview) = &model.preview {
187        render_boxed_paragraph(
188            body_chunks[1],
189            buffer,
190            "Preview",
191            ansi_preview_text(preview),
192            true,
193        );
194    }
195
196    render_footer(chunks[2], buffer, model);
197}
198
199fn render_sidebar(area: Rect, buffer: &mut Buffer, model: &SurfaceModel) {
200    let chunks = Layout::default()
201        .direction(Direction::Vertical)
202        .constraints([
203            Constraint::Length(3),
204            Constraint::Min(4),
205            Constraint::Length(if model.show_help { 3 } else { 1 }),
206        ])
207        .split(area);
208
209    render_boxed_paragraph(
210        chunks[0],
211        buffer,
212        model.title.as_str(),
213        Text::from(model.query.as_str()),
214        false,
215    );
216
217    render_list(
218        chunks[1],
219        buffer,
220        model,
221        matches!(model.kind, SurfaceKind::SidebarCompact),
222    );
223    render_footer(chunks[2], buffer, model);
224}
225
226fn render_list(area: Rect, buffer: &mut Buffer, model: &SurfaceModel, compact: bool) {
227    let branch_width = if compact {
228        0
229    } else {
230        model
231            .items
232            .iter()
233            .filter_map(|item| item.git_branch.as_ref())
234            .map(|branch| branch.name.chars().count())
235            .max()
236            .unwrap_or(0)
237            .min(18)
238    };
239    let dirty_width = if compact || branch_width == 0 { 0 } else { 1 };
240    let marker_width = 3usize;
241    let available_width = usize::from(area.width.saturating_sub(2));
242    let gap_width = if compact { 0 } else { 2 };
243    let session_width = if compact {
244        available_width.saturating_sub(marker_width)
245    } else {
246        let max_session_width = model
247            .items
248            .iter()
249            .map(|item| item.label.chars().count())
250            .max()
251            .unwrap_or(0)
252            .min(28);
253        let branch_space = if branch_width == 0 {
254            0
255        } else {
256            branch_width + dirty_width + gap_width
257        };
258        let title_budget = available_width
259            .saturating_sub(marker_width + gap_width + branch_space)
260            .max(12);
261        max_session_width.min(title_budget.saturating_sub(8)).max(8)
262    };
263
264    let items = model
265        .items
266        .iter()
267        .enumerate()
268        .map(|(index, item)| {
269            let marker = if matches!(item.kind, SessionListItemKind::Worktree) {
270                "W"
271            } else if item.is_current {
272                "•"
273            } else if item.is_previous {
274                "‹›"
275            } else {
276                " "
277            };
278            let badge = match item.attention {
279                wisp_core::AttentionBadge::None => "",
280                wisp_core::AttentionBadge::Silence => "~",
281                wisp_core::AttentionBadge::Unseen => "+",
282                wisp_core::AttentionBadge::Activity => "#",
283                wisp_core::AttentionBadge::Bell => "!",
284            };
285            let icon = format!("{marker}{badge}");
286            let style = if index == model.selected {
287                Style::default().add_modifier(Modifier::REVERSED)
288            } else {
289                Style::default()
290            };
291            let line = if compact {
292                Line::from(Span::styled(
293                    format!("{icon} {}", truncate_text(&item.label, session_width)),
294                    style,
295                ))
296            } else {
297                let branch_space = if branch_width == 0 {
298                    0
299                } else {
300                    branch_width + dirty_width + gap_width
301                };
302                let title_width = available_width
303                    .saturating_sub(marker_width + session_width + gap_width + branch_space);
304                let session = pad_text(&truncate_text(&item.label, session_width), session_width);
305                let title_source = item
306                    .active_window_label
307                    .as_deref()
308                    .or(item.path_hint.as_deref())
309                    .unwrap_or_default();
310                let title = pad_text(&truncate_text(title_source, title_width), title_width);
311                let prefix = if branch_width == 0 {
312                    format!("{icon} {session}  {title}")
313                } else {
314                    format!("{icon} {session}  {title}  ")
315                };
316
317                let mut spans = vec![Span::styled(prefix, style)];
318                if branch_width > 0 {
319                    if let Some(branch) = item.git_branch.as_ref() {
320                        spans.push(Span::styled(
321                            pad_left(&truncate_left(&branch.name, branch_width), branch_width),
322                            style.patch(branch_style(branch)),
323                        ));
324                        spans.push(Span::styled(
325                            if branch.dirty { "*" } else { " " },
326                            style.patch(Style::default().fg(Color::Yellow)),
327                        ));
328                    } else {
329                        spans.push(Span::styled(" ".repeat(branch_width + dirty_width), style));
330                    }
331                }
332                Line::from(spans)
333            };
334
335            ListItem::new(line)
336        })
337        .collect::<Vec<_>>();
338
339    let block = rounded_block("Sessions");
340    let inner = block.inner(area);
341    block.render(area, buffer);
342    Clear.render(inner, buffer);
343    List::new(items).render(inner, buffer);
344}
345
346fn render_footer(area: Rect, buffer: &mut Buffer, model: &SurfaceModel) {
347    let text = if model.show_help {
348        bindings_help_text(&model.bindings)
349    } else {
350        compact_bindings_help_text(&model.bindings)
351    };
352
353    let block = rounded_block("");
354    let inner = block.inner(area);
355    block.render(area, buffer);
356    Clear.render(inner, buffer);
357    Paragraph::new(text).render(inner, buffer);
358}
359
360fn render_boxed_paragraph(
361    area: Rect,
362    buffer: &mut Buffer,
363    title: &str,
364    text: Text<'_>,
365    center_single_line: bool,
366) {
367    let block = rounded_block(title);
368    let inner = block.inner(area);
369    block.render(area, buffer);
370    Clear.render(inner, buffer);
371
372    // Center single-line content both vertically and horizontally.
373    let lines = text.lines.len();
374    if center_single_line && lines == 1 {
375        let line = &text.lines[0];
376        let line_width: usize = line.spans.iter().map(|s| s.content.chars().count()).sum();
377        if line_width < usize::from(inner.width) {
378            let horizontal_pad = (usize::from(inner.width) - line_width) / 2;
379            let vertical_pad = if inner.height > 1 {
380                usize::from(inner.height) / 2
381            } else {
382                0
383            };
384
385            let mut centered_spans = vec![Span::raw(" ".repeat(horizontal_pad))];
386            centered_spans.extend(line.spans.iter().cloned());
387
388            let mut centered_text = Vec::with_capacity(vertical_pad + 1);
389            for _ in 0..vertical_pad {
390                centered_text.push(Line::from(""));
391            }
392            centered_text.push(Line::from(centered_spans));
393
394            Paragraph::new(Text::from(centered_text)).render(inner, buffer);
395        } else {
396            Paragraph::new(text).render(inner, buffer);
397        }
398    } else {
399        Paragraph::new(text).render(inner, buffer);
400    }
401}
402
403fn rounded_block(title: &str) -> Block<'_> {
404    Block::default()
405        .title(title)
406        .borders(Borders::ALL)
407        .border_set(border::ROUNDED)
408}
409
410fn bindings_help_text(bindings: &KeyBindings) -> String {
411    format!(
412        "down {}  up {}  ^j {}  ^k {}  enter {}  S-enter {}  backspace {}  ^r {}  ^s {}  ^x {}  ^p {}  ^d {}  ^m {}  ^w {}  esc {}  ^c {}",
413        intent_label(&bindings.down),
414        intent_label(&bindings.up),
415        intent_label(&bindings.ctrl_j),
416        intent_label(&bindings.ctrl_k),
417        intent_label(&bindings.enter),
418        intent_label(&bindings.shift_enter),
419        intent_label(&bindings.backspace),
420        intent_label(&bindings.ctrl_r),
421        intent_label(&bindings.ctrl_s),
422        intent_label(&bindings.ctrl_x),
423        intent_label(&bindings.ctrl_p),
424        intent_label(&bindings.ctrl_d),
425        intent_label(&bindings.ctrl_m),
426        intent_label(&bindings.ctrl_w),
427        intent_label(&bindings.esc),
428        intent_label(&bindings.ctrl_c),
429    )
430}
431
432fn compact_bindings_help_text(bindings: &KeyBindings) -> String {
433    format!(
434        "esc {}  ^c {}",
435        intent_label(&bindings.esc),
436        intent_label(&bindings.ctrl_c),
437    )
438}
439
440fn intent_label(intent: &UiIntent) -> &'static str {
441    match intent {
442        UiIntent::ActivateSelected => "open",
443        UiIntent::CreateSessionFromQuery => "create",
444        UiIntent::RenameSession => "rename",
445        UiIntent::ToggleSort => "sort",
446        UiIntent::CloseSession => "close session",
447        UiIntent::TogglePreview => "preview",
448        UiIntent::ToggleDetails => "details",
449        UiIntent::ToggleCompactSidebar => "compact",
450        UiIntent::Close => "close",
451        UiIntent::SelectNext => "move down",
452        UiIntent::SelectPrev => "move up",
453        UiIntent::FilterChanged(_) => "filter",
454        UiIntent::Backspace => "backspace",
455        UiIntent::ToggleWorktreeMode => "worktree",
456    }
457}
458
459fn pad_text(value: &str, width: usize) -> String {
460    let len = value.chars().count();
461    if len >= width {
462        value.to_string()
463    } else {
464        format!("{value}{}", " ".repeat(width - len))
465    }
466}
467
468fn pad_left(value: &str, width: usize) -> String {
469    let len = value.chars().count();
470    if len >= width {
471        value.to_string()
472    } else {
473        format!("{}{value}", " ".repeat(width - len))
474    }
475}
476
477fn truncate_text(value: &str, width: usize) -> String {
478    if width == 0 {
479        return String::new();
480    }
481    let chars = value.chars().collect::<Vec<_>>();
482    if chars.len() <= width {
483        return value.to_string();
484    }
485    if width == 1 {
486        return "…".to_string();
487    }
488    chars[..width - 1].iter().collect::<String>() + "…"
489}
490
491fn truncate_left(value: &str, width: usize) -> String {
492    if width == 0 {
493        return String::new();
494    }
495    let chars = value.chars().collect::<Vec<_>>();
496    if chars.len() <= width {
497        return value.to_string();
498    }
499    if width == 1 {
500        return "…".to_string();
501    }
502    format!(
503        "…{}",
504        chars[chars.len() - (width - 1)..]
505            .iter()
506            .collect::<String>()
507    )
508}
509
510fn branch_style(branch: &GitBranchStatus) -> Style {
511    let color = match branch.sync {
512        GitBranchSync::Unknown => Color::Gray,
513        GitBranchSync::Pushed => Color::Green,
514        GitBranchSync::NotPushed => Color::Red,
515    };
516    Style::default().fg(color)
517}
518
519fn ansi_preview_text(preview: &[String]) -> Text<'static> {
520    let mut lines = Vec::with_capacity(preview.len().max(1));
521    for line in preview {
522        lines.push(parse_ansi_line(&sanitize_ansi_input(line)));
523    }
524    if lines.is_empty() {
525        lines.push(Line::default());
526    }
527    Text::from(lines)
528}
529
530fn sanitize_ansi_input(input: &str) -> String {
531    let mut sanitized = String::with_capacity(input.len());
532    let mut chars = input.chars().peekable();
533
534    while let Some(ch) = chars.next() {
535        match ch {
536            '\u{1b}' => match chars.peek().copied() {
537                Some('[') => {
538                    chars.next();
539                    let mut sequence = String::from("\u{1b}[");
540                    let mut final_byte = None;
541                    for next in chars.by_ref() {
542                        sequence.push(next);
543                        if ('@'..='~').contains(&next) {
544                            final_byte = Some(next);
545                            break;
546                        }
547                    }
548                    if final_byte == Some('m') {
549                        sanitized.push_str(&sequence);
550                    }
551                }
552                Some(']') => {
553                    chars.next();
554                    while let Some(next) = chars.next() {
555                        if next == '\u{7}' {
556                            break;
557                        }
558                        if next == '\u{1b}' && matches!(chars.peek(), Some('\\')) {
559                            chars.next();
560                            break;
561                        }
562                    }
563                }
564                _ => {}
565            },
566            '\r' => {}
567            ch if ch.is_control() => {}
568            _ => sanitized.push(ch),
569        }
570    }
571
572    sanitized
573}
574
575fn parse_ansi_line(input: &str) -> Line<'static> {
576    let mut spans = Vec::new();
577    let mut style = Style::default();
578    let mut chars = input.chars().peekable();
579    let mut plain = String::new();
580
581    while let Some(ch) = chars.next() {
582        if ch == '\u{1b}' && matches!(chars.peek(), Some('[')) {
583            chars.next();
584            flush_span(&mut spans, &mut plain, style);
585
586            let mut sequence = String::new();
587            for next in chars.by_ref() {
588                if next == 'm' {
589                    style = apply_sgr(style, &sequence);
590                    break;
591                }
592                sequence.push(next);
593            }
594        } else {
595            plain.push(ch);
596        }
597    }
598
599    flush_span(&mut spans, &mut plain, style);
600    Line::from(spans)
601}
602
603fn flush_span(spans: &mut Vec<Span<'static>>, plain: &mut String, style: Style) {
604    if plain.is_empty() {
605        return;
606    }
607
608    spans.push(Span::styled(std::mem::take(plain), style));
609}
610
611fn apply_sgr(mut style: Style, sequence: &str) -> Style {
612    let codes = if sequence.is_empty() {
613        vec![0]
614    } else {
615        sequence
616            .split(';')
617            .map(|part| part.parse::<u16>().unwrap_or(0))
618            .collect::<Vec<_>>()
619    };
620
621    let mut index = 0;
622    while index < codes.len() {
623        match codes[index] {
624            0 => style = Style::default(),
625            1 => style = style.add_modifier(Modifier::BOLD),
626            2 => style = style.add_modifier(Modifier::DIM),
627            3 => style = style.add_modifier(Modifier::ITALIC),
628            4 => style = style.add_modifier(Modifier::UNDERLINED),
629            5 => style = style.add_modifier(Modifier::SLOW_BLINK),
630            7 => style = style.add_modifier(Modifier::REVERSED),
631            9 => style = style.add_modifier(Modifier::CROSSED_OUT),
632            22 => style = style.remove_modifier(Modifier::BOLD | Modifier::DIM),
633            23 => style = style.remove_modifier(Modifier::ITALIC),
634            24 => style = style.remove_modifier(Modifier::UNDERLINED),
635            25 => style = style.remove_modifier(Modifier::SLOW_BLINK),
636            27 => style = style.remove_modifier(Modifier::REVERSED),
637            29 => style = style.remove_modifier(Modifier::CROSSED_OUT),
638            30..=37 | 90..=97 => {
639                style.fg = Some(ansi_named_color(codes[index]));
640            }
641            39 => style.fg = Some(Color::Reset),
642            40..=47 | 100..=107 => {
643                style.bg = Some(ansi_named_color(codes[index]));
644            }
645            49 => style.bg = Some(Color::Reset),
646            38 | 48 => {
647                let is_foreground = codes[index] == 38;
648                let slice = &codes[index + 1..];
649                if let Some((color, consumed)) = ansi_extended_color(slice) {
650                    if is_foreground {
651                        style.fg = Some(color);
652                    } else {
653                        style.bg = Some(color);
654                    }
655                    index += consumed;
656                }
657            }
658            _ => {}
659        }
660        index += 1;
661    }
662
663    style
664}
665
666fn ansi_extended_color(codes: &[u16]) -> Option<(Color, usize)> {
667    match codes {
668        [5, value, ..] => Some((Color::Indexed((*value).min(u8::MAX as u16) as u8), 2)),
669        [2, red, green, blue, ..] => Some((
670            Color::Rgb(
671                (*red).min(u8::MAX as u16) as u8,
672                (*green).min(u8::MAX as u16) as u8,
673                (*blue).min(u8::MAX as u16) as u8,
674            ),
675            4,
676        )),
677        _ => None,
678    }
679}
680
681fn ansi_named_color(code: u16) -> Color {
682    match code {
683        30 | 40 => Color::Black,
684        31 | 41 => Color::Red,
685        32 | 42 => Color::Green,
686        33 | 43 => Color::Yellow,
687        34 | 44 => Color::Blue,
688        35 | 45 => Color::Magenta,
689        36 | 46 => Color::Cyan,
690        37 | 47 => Color::Gray,
691        90 | 100 => Color::DarkGray,
692        91 | 101 => Color::LightRed,
693        92 | 102 => Color::LightGreen,
694        93 | 103 => Color::LightYellow,
695        94 | 104 => Color::LightBlue,
696        95 | 105 => Color::LightMagenta,
697        96 | 106 => Color::LightCyan,
698        97 | 107 => Color::White,
699        _ => Color::Reset,
700    }
701}
702
703#[cfg(test)]
704mod tests {
705    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
706    use ratatui::buffer::Buffer;
707    use ratatui::layout::Rect;
708    use ratatui::style::Color;
709    use wisp_core::{AttentionBadge, PickerMode, SessionListItem, SessionListItemKind};
710
711    use crate::{
712        KeyBindings, SurfaceKind, SurfaceModel, UiIntent, ansi_preview_text, render_surface,
713        sanitize_ansi_input, translate_key,
714    };
715
716    fn item(label: &str) -> SessionListItem {
717        SessionListItem {
718            session_id: label.to_string(),
719            label: label.to_string(),
720            kind: SessionListItemKind::Session,
721            is_current: false,
722            is_previous: false,
723            last_activity: None,
724            attached: false,
725            attention: AttentionBadge::None,
726            attention_count: 0,
727            active_window_label: Some("shell".to_string()),
728            path_hint: None,
729            command_hint: None,
730            git_branch: None,
731            worktree_path: None,
732            worktree_branch: None,
733        }
734    }
735
736    #[test]
737    fn renders_picker_with_preview() {
738        let mut buffer = Buffer::empty(Rect::new(0, 0, 60, 12));
739        let model = SurfaceModel {
740            title: "Wisp Picker".to_string(),
741            query: "alp".to_string(),
742            items: vec![item("alpha"), item("beta")],
743            selected: 0,
744            show_help: true,
745            preview: Some(vec!["preview line".to_string()]),
746            kind: SurfaceKind::Picker,
747            bindings: KeyBindings::default(),
748            mode: PickerMode::AllSessions,
749        };
750
751        render_surface(buffer.area, &mut buffer, &model);
752
753        let rendered = buffer
754            .content
755            .iter()
756            .map(|cell| cell.symbol())
757            .collect::<String>();
758        assert!(rendered.contains("Wisp Picker"));
759        assert!(rendered.contains("Preview"));
760        assert!(rendered.contains("alpha"));
761    }
762
763    #[test]
764    fn renders_ansi_colored_preview_content() {
765        let text = ansi_preview_text(&["\u{1b}[31mred\u{1b}[0m".to_string()]);
766        let first_span = &text.lines[0].spans[0];
767
768        assert_eq!(first_span.content, "red");
769        assert_eq!(first_span.style.fg, Some(Color::Red));
770    }
771
772    #[test]
773    fn strips_non_sgr_escape_sequences_from_preview_content() {
774        let sanitized = sanitize_ansi_input("hello\u{1b}[2K\u{1b}[1G\u{1b}[31mred\u{1b}[0m\r");
775
776        assert_eq!(sanitized, "hello\u{1b}[31mred\u{1b}[0m");
777    }
778
779    #[test]
780    fn renders_compact_sidebar() {
781        let mut buffer = Buffer::empty(Rect::new(0, 0, 30, 10));
782        let mut current = item("alpha");
783        current.is_current = true;
784        current.attention = AttentionBadge::Bell;
785        let model = SurfaceModel {
786            title: "Sidebar".to_string(),
787            query: String::new(),
788            items: vec![current],
789            selected: 0,
790            show_help: false,
791            preview: None,
792            kind: SurfaceKind::SidebarCompact,
793            bindings: KeyBindings::default(),
794            mode: PickerMode::AllSessions,
795        };
796
797        render_surface(buffer.area, &mut buffer, &model);
798
799        let rendered = buffer
800            .content
801            .iter()
802            .map(|cell| cell.symbol())
803            .collect::<String>();
804        assert!(rendered.contains("Sidebar"));
805        assert!(rendered.contains("•! alpha"));
806    }
807
808    #[test]
809    fn renders_worktree_rows_with_path_hint_in_sidebar() {
810        let mut buffer = Buffer::empty(Rect::new(0, 0, 50, 10));
811        let model = SurfaceModel {
812            title: "Sidebar".to_string(),
813            query: String::new(),
814            items: vec![SessionListItem {
815                session_id: "worktree:/tmp/demo/app".to_string(),
816                label: "app".to_string(),
817                kind: SessionListItemKind::Worktree,
818                is_current: false,
819                is_previous: false,
820                last_activity: None,
821                attached: false,
822                attention: AttentionBadge::None,
823                attention_count: 0,
824                active_window_label: None,
825                path_hint: Some("~/src/demo/app".to_string()),
826                command_hint: None,
827                git_branch: None,
828                worktree_path: Some(std::path::PathBuf::from("/tmp/demo/app")),
829                worktree_branch: Some("feature/demo".to_string()),
830            }],
831            selected: 0,
832            show_help: false,
833            preview: None,
834            kind: SurfaceKind::SidebarExpanded,
835            bindings: KeyBindings::default(),
836            mode: PickerMode::Worktree,
837        };
838
839        render_surface(buffer.area, &mut buffer, &model);
840
841        let rendered = buffer
842            .content
843            .iter()
844            .map(|cell| cell.symbol())
845            .collect::<String>();
846        assert!(rendered.contains("app"));
847        assert!(rendered.contains("~/src/demo/app"));
848    }
849
850    #[test]
851    fn centers_single_line_boxed_paragraph_horizontally_when_inner_height_is_one() {
852        let mut buffer = Buffer::empty(Rect::new(0, 0, 12, 3));
853
854        super::render_boxed_paragraph(
855            buffer.area,
856            &mut buffer,
857            "",
858            ratatui::text::Text::from("hi"),
859            true,
860        );
861
862        let row = (0..usize::from(buffer.area.width))
863            .map(|x| buffer[(x as u16, 1)].symbol())
864            .collect::<String>();
865        assert!(row.contains("    hi"));
866    }
867
868    #[test]
869    fn translates_supported_keys() {
870        assert_eq!(
871            translate_key(
872                KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),
873                &KeyBindings::default()
874            ),
875            Some(UiIntent::SelectNext)
876        );
877        assert_eq!(
878            translate_key(
879                KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL),
880                &KeyBindings::default(),
881            ),
882            Some(UiIntent::SelectNext)
883        );
884        assert_eq!(
885            translate_key(
886                KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT),
887                &KeyBindings::default(),
888            ),
889            Some(UiIntent::CreateSessionFromQuery)
890        );
891        assert_eq!(
892            translate_key(
893                KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
894                &KeyBindings::default(),
895            ),
896            Some(UiIntent::Backspace)
897        );
898        assert_eq!(
899            translate_key(
900                KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
901                &KeyBindings::default(),
902            ),
903            Some(UiIntent::RenameSession)
904        );
905        assert_eq!(
906            translate_key(
907                KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL),
908                &KeyBindings::default(),
909            ),
910            Some(UiIntent::ToggleSort)
911        );
912        assert_eq!(
913            translate_key(
914                KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL),
915                &KeyBindings::default(),
916            ),
917            Some(UiIntent::ToggleDetails)
918        );
919        assert_eq!(
920            translate_key(
921                KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
922                &KeyBindings::default(),
923            ),
924            Some(UiIntent::CloseSession)
925        );
926        assert_eq!(
927            translate_key(
928                KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL),
929                &KeyBindings::default(),
930            ),
931            Some(UiIntent::TogglePreview)
932        );
933        assert_eq!(
934            translate_key(
935                KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL),
936                &KeyBindings::default(),
937            ),
938            Some(UiIntent::ToggleWorktreeMode)
939        );
940        assert_eq!(
941            translate_key(
942                KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE),
943                &KeyBindings::default(),
944            ),
945            Some(UiIntent::FilterChanged("q".to_string()))
946        );
947        assert_eq!(
948            translate_key(
949                KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
950                &KeyBindings::default(),
951            ),
952            Some(UiIntent::FilterChanged("x".to_string()))
953        );
954        assert_eq!(
955            translate_key(
956                KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
957                &KeyBindings::default()
958            ),
959            Some(UiIntent::Close)
960        );
961    }
962
963    #[test]
964    fn uses_configured_binding_actions_for_non_text_keys() {
965        let bindings = KeyBindings {
966            down: UiIntent::Close,
967            up: UiIntent::TogglePreview,
968            ctrl_j: UiIntent::ToggleSort,
969            ctrl_k: UiIntent::RenameSession,
970            shift_enter: UiIntent::ActivateSelected,
971            backspace: UiIntent::CloseSession,
972            ..KeyBindings::default()
973        };
974
975        assert_eq!(
976            translate_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), &bindings),
977            Some(UiIntent::Close)
978        );
979        assert_eq!(
980            translate_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE), &bindings),
981            Some(UiIntent::TogglePreview)
982        );
983        assert_eq!(
984            translate_key(
985                KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL),
986                &bindings
987            ),
988            Some(UiIntent::ToggleSort)
989        );
990        assert_eq!(
991            translate_key(
992                KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL),
993                &bindings
994            ),
995            Some(UiIntent::RenameSession)
996        );
997        assert_eq!(
998            translate_key(
999                KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT),
1000                &bindings
1001            ),
1002            Some(UiIntent::ActivateSelected)
1003        );
1004        assert_eq!(
1005            translate_key(
1006                KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
1007                &bindings
1008            ),
1009            Some(UiIntent::CloseSession)
1010        );
1011    }
1012}