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