Skip to main content

smux/
fzf.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use anyhow::{Context, Result, bail};
7
8use crate::config::{PickerBindings, PickerPreviewSettings};
9use crate::process::{CommandRunner, default_runner};
10use crate::ui::DisplayStyle;
11
12#[derive(Clone, Debug, Eq, PartialEq)]
13pub enum EntryKind {
14    Session,
15    Directory,
16    Project,
17    InvalidProject,
18}
19
20#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21pub enum SelectAction {
22    Open,
23    Delete,
24    SaveProject,
25}
26
27#[derive(Clone, Debug, Eq, PartialEq)]
28pub struct Entry {
29    pub kind: EntryKind,
30    pub label: String,
31    pub value: String,
32    pub preview: Option<String>,
33}
34
35impl Entry {
36    pub fn session(style: DisplayStyle, value: String) -> Self {
37        Self {
38            kind: EntryKind::Session,
39            label: style.session_label(&value),
40            value,
41            preview: None,
42        }
43    }
44
45    pub fn directory(style: DisplayStyle, value: String) -> Self {
46        Self {
47            kind: EntryKind::Directory,
48            label: style.directory_label(&value),
49            value,
50            preview: None,
51        }
52    }
53
54    pub fn project(
55        style: DisplayStyle,
56        value: String,
57        label_value: String,
58        preview: Option<String>,
59    ) -> Self {
60        Self {
61            kind: EntryKind::Project,
62            label: style.project_label(&label_value),
63            value,
64            preview,
65        }
66    }
67
68    pub fn invalid_project(
69        style: DisplayStyle,
70        value: String,
71        error: &str,
72        preview: Option<String>,
73    ) -> Self {
74        Self {
75            kind: EntryKind::InvalidProject,
76            label: style.invalid_project_label(&value, error),
77            value,
78            preview,
79        }
80    }
81
82    fn encode(&self) -> String {
83        let kind = match self.kind {
84            EntryKind::Session => "session",
85            EntryKind::Directory => "folder",
86            EntryKind::Project => "project",
87            EntryKind::InvalidProject => "project-broken",
88        };
89
90        let preview = self
91            .preview
92            .as_deref()
93            .unwrap_or_default()
94            .replace(['\t', '\n', '\r'], " ");
95        format!("{kind}\t{}\t{}\t{preview}", self.value, self.label)
96    }
97
98    fn decode(line: &str) -> Result<Self> {
99        let mut parts = line.splitn(4, '\t');
100        let kind = parts.next().context("missing entry kind")?;
101        let value = parts.next().context("missing entry value")?.to_owned();
102        let label = parts.next().context("missing entry label")?.to_owned();
103        let preview = parts
104            .next()
105            .filter(|value| !value.is_empty())
106            .map(ToOwned::to_owned);
107
108        let kind = match kind {
109            "session" => EntryKind::Session,
110            "folder" => EntryKind::Directory,
111            "project" => EntryKind::Project,
112            "project-broken" => EntryKind::InvalidProject,
113            other => bail!("unknown picker entry kind: {other}"),
114        };
115
116        Ok(Self {
117            kind,
118            label,
119            value,
120            preview,
121        })
122    }
123}
124
125#[derive(Clone, Debug, Eq, PartialEq)]
126pub struct Selection {
127    pub action: SelectAction,
128    pub entry: Entry,
129}
130
131#[derive(Clone, Debug, Eq, PartialEq)]
132pub struct Choice {
133    pub kind: String,
134    pub label: String,
135    pub value: String,
136}
137
138impl Choice {
139    pub fn new(kind: impl Into<String>, label: String, value: String) -> Self {
140        Self {
141            kind: kind.into(),
142            label,
143            value,
144        }
145    }
146
147    fn encode(&self) -> String {
148        format!("{}\t{}\t{}", self.kind, self.value, self.label)
149    }
150
151    fn decode(line: &str) -> Result<Self> {
152        let mut parts = line.splitn(3, '\t');
153        let kind = parts.next().context("missing choice kind")?.to_owned();
154        let value = parts.next().context("missing choice value")?.to_owned();
155        let label = parts.next().context("missing choice label")?.to_owned();
156        Ok(Self { kind, label, value })
157    }
158}
159
160pub fn select(
161    entries: Vec<Entry>,
162    bindings: &PickerBindings,
163    preview: &PickerPreviewSettings,
164) -> Result<Option<Selection>> {
165    select_with_runner(default_runner(), entries, "smux> ", bindings, preview)
166}
167
168pub fn select_value(prompt: &str, choices: Vec<Choice>) -> Result<Option<String>> {
169    select_value_with_runner(default_runner(), prompt, choices)
170}
171
172struct TempInputFile {
173    path: PathBuf,
174}
175
176impl TempInputFile {
177    fn new(contents: &str) -> Result<Self> {
178        let mut path = std::env::temp_dir();
179        let nanos = SystemTime::now()
180            .duration_since(UNIX_EPOCH)
181            .context("system clock should be after unix epoch")?
182            .as_nanos();
183        path.push(format!("smux-fzf-{}-{nanos}.tsv", std::process::id()));
184        fs::write(&path, contents)
185            .with_context(|| format!("failed to write {}", path.display()))?;
186        Ok(Self { path })
187    }
188
189    fn shell_quoted_path(&self) -> String {
190        shell_quote(&self.path)
191    }
192}
193
194impl Drop for TempInputFile {
195    fn drop(&mut self) {
196        let _ = fs::remove_file(&self.path);
197    }
198}
199
200fn shell_quote(path: &Path) -> String {
201    shell_quote_str(&path.to_string_lossy())
202}
203
204fn shell_quote_str(value: &str) -> String {
205    format!("'{}'", value.replace('\'', "'\\''"))
206}
207
208fn cat_command(file: &TempInputFile) -> String {
209    format!("cat {}", file.shell_quoted_path())
210}
211
212fn filter_command(file: &TempInputFile, kind: &str) -> String {
213    if kind == "project" {
214        format!(
215            "awk -F '\\t' '$1 == \"project\" || $1 == \"project-broken\"' {}",
216            file.shell_quoted_path()
217        )
218    } else {
219        format!(
220            "awk -F '\\t' '$1 == \"{kind}\"' {}",
221            file.shell_quoted_path()
222        )
223    }
224}
225
226fn add_common_picker_args(args: &mut Vec<String>, prompt: &str, header: &str, bindings: &str) {
227    args.extend([
228        "--ansi".to_owned(),
229        "--delimiter".to_owned(),
230        "\t".to_owned(),
231        "--layout".to_owned(),
232        "reverse".to_owned(),
233        "--header".to_owned(),
234        header.to_owned(),
235        "--bind".to_owned(),
236        "tab:down,btab:up".to_owned(),
237        "--bind".to_owned(),
238        bindings.to_owned(),
239        "--with-nth".to_owned(),
240        "3".to_owned(),
241        "--nth".to_owned(),
242        "1,2,4".to_owned(),
243        "--prompt".to_owned(),
244        prompt.to_owned(),
245        "--no-sort".to_owned(),
246    ]);
247}
248
249fn add_preview_args(args: &mut Vec<String>, preview: &PickerPreviewSettings) {
250    let session_preview = preview.sessions.as_deref().unwrap_or(
251        "tmux list-panes -s -t \"$SMUX_PREVIEW_SESSION\" -F \"#{window_index}\t#{window_name}\t#{?window_active,1,0}\t#{pane_index}\t#{?pane_active,1,0}\t#{pane_current_command}\t#{pane_current_path}\" 2>/dev/null | awk -F '\t' 'BEGIN { current = \"\"; first_pane = 1; esc = sprintf(\"%c\", 27); reset = esc \"[0m\"; window_box = esc \"[38;2;26;33;36;48;2;149;192;202m\"; window_active_box = esc \"[38;2;26;33;36;48;2;81;156;174m\"; pane_box = esc \"[38;2;26;33;36;48;2;231;198;100m\"; pane_active_box = esc \"[38;2;26;33;36;48;2;243;150;96m\"; window_name = esc \"[38;2;149;192;202m\"; window_name_active = esc \"[1;38;2;118;204;224m\"; pane_text = esc \"[38;2;231;198;100m\"; pane_text_active = esc \"[1;38;2;243;150;96m\"; path_color = esc \"[2;38;2;149;192;202m\" } { if ($1 != current) { if (NR > 1) print \"\"; box = ($3 == \"1\" ? window_active_box : window_box); name = ($3 == \"1\" ? window_name_active : window_name); printf \"%s %s %s %s%s%s\\n\", box, $1, reset, name, $2, reset; current = $1; first_pane = 1 } pbox = ($5 == \"1\" ? pane_active_box : pane_box); ptext = ($5 == \"1\" ? pane_text_active : pane_text); printf \"\\n  %s %s %s %s%s%s\\n\", pbox, $4, reset, ptext, $6, reset; printf \"    %s%s%s\\n\", path_color, $7, reset }'",
252    );
253    let folder_preview = preview.folders.as_deref().unwrap_or(
254        "if command -v eza >/dev/null 2>&1; then eza --tree --level=2 --color=always --icons=always \"$SMUX_PREVIEW_PATH\"; else ls -la \"$SMUX_PREVIEW_PATH\"; fi",
255    );
256    let project_preview = preview.projects.as_deref().unwrap_or(
257        "if command -v bat >/dev/null 2>&1; then bat --style=plain --color=always --language=toml \"$SMUX_PREVIEW_FILE\"; else sed -n '1,200p' \"$SMUX_PREVIEW_FILE\"; fi",
258    );
259    let session_preview = shell_quote_str(session_preview);
260    let folder_preview = shell_quote_str(folder_preview);
261    let project_preview = shell_quote_str(project_preview);
262    let preview_command = format!(
263        "SMUX_SESSION_PREVIEW={session_preview} SMUX_FOLDER_PREVIEW={folder_preview} SMUX_PROJECT_PREVIEW={project_preview} sh -c 'kind=\"$1\"; value=\"$2\"; extra=\"$3\"; case \"$kind\" in session) SMUX_PREVIEW_KIND=\"$kind\" SMUX_PREVIEW_SESSION=\"$value\" sh -lc \"$SMUX_SESSION_PREVIEW\" ;; folder) SMUX_PREVIEW_PATH=\"$value\" SMUX_PREVIEW_KIND=\"$kind\" sh -lc \"$SMUX_FOLDER_PREVIEW\" ;; project|project-broken) if [ -n \"$extra\" ] && [ -f \"$extra\" ]; then SMUX_PREVIEW_KIND=\"$kind\" SMUX_PREVIEW_FILE=\"$extra\" sh -lc \"$SMUX_PROJECT_PREVIEW\"; else printf \"No preview available\\n\"; fi ;; *) printf \"No preview available\\n\" ;; esac' _ {{1}} {{2}} {{4}}"
264    );
265    args.extend([
266        "--preview".to_owned(),
267        preview_command,
268        "--preview-window".to_owned(),
269        "right:55%".to_owned(),
270    ]);
271}
272
273fn select_value_with_runner(
274    runner: Arc<dyn CommandRunner>,
275    prompt: &str,
276    choices: Vec<Choice>,
277) -> Result<Option<String>> {
278    let mut args = Vec::new();
279    let input = choices
280        .into_iter()
281        .map(|choice| choice.encode())
282        .collect::<Vec<_>>()
283        .join("\n")
284        + "\n";
285    let input_file = TempInputFile::new(&input)?;
286    let all_command = cat_command(&input_file);
287    let template_command = filter_command(&input_file, "template");
288    add_common_picker_args(
289        &mut args,
290        prompt,
291        "ctrl-x all  ctrl-t templates",
292        &format!(
293            "ctrl-x:change-prompt(template> )+clear-query+reload({all_command}),ctrl-t:change-prompt(template> )+clear-query+reload({template_command})"
294        ),
295    );
296    let output = runner
297        .run_capture_with_input("fzf", &args, &input)
298        .context("failed to launch fzf")?;
299
300    if output.status.code == Some(130) {
301        return Ok(None);
302    }
303
304    if !output.status.success {
305        bail!("fzf exited with status {:?}", output.status.code);
306    }
307
308    let selection = String::from_utf8(output.stdout).context("fzf output was not valid utf-8")?;
309    let selection = selection.trim_end();
310
311    if selection.is_empty() {
312        return Ok(None);
313    }
314
315    Ok(Some(Choice::decode(selection)?.value))
316}
317
318fn select_with_runner(
319    runner: Arc<dyn CommandRunner>,
320    entries: Vec<Entry>,
321    prompt: &str,
322    bindings: &PickerBindings,
323    preview: &PickerPreviewSettings,
324) -> Result<Option<Selection>> {
325    let mut args = Vec::new();
326    let input = entries
327        .into_iter()
328        .map(|entry| entry.encode())
329        .collect::<Vec<_>>()
330        .join("\n")
331        + "\n";
332    let input_file = TempInputFile::new(&input)?;
333    let all_command = cat_command(&input_file);
334    let session_command = filter_command(&input_file, "session");
335    let folder_command = filter_command(&input_file, "folder");
336    let project_command = filter_command(&input_file, "project");
337    add_common_picker_args(
338        &mut args,
339        prompt,
340        &format!(
341            "enter open  {delete} delete  {save} save project  {reset} all  {sessions} sessions  {folders} folders  {projects} projects",
342            delete = bindings.delete_session,
343            save = bindings.save_project,
344            reset = bindings.reset,
345            sessions = bindings.sessions,
346            folders = bindings.folders,
347            projects = bindings.projects,
348        ),
349        &format!(
350            "{reset}:change-prompt(smux> )+clear-query+reload({all_command}),{sessions}:change-prompt(session> )+clear-query+reload({session_command}),{folders}:change-prompt(folder> )+clear-query+reload({folder_command}),{projects}:change-prompt(project> )+clear-query+reload({project_command})",
351            reset = bindings.reset,
352            sessions = bindings.sessions,
353            folders = bindings.folders,
354            projects = bindings.projects,
355        ),
356    );
357    add_preview_args(&mut args, preview);
358    args.extend([
359        "--expect".to_owned(),
360        format!("{},{}", bindings.delete_session, bindings.save_project),
361    ]);
362    let output = runner
363        .run_capture_with_input("fzf", &args, &input)
364        .context("failed to launch fzf")?;
365
366    if output.status.code == Some(130) {
367        return Ok(None);
368    }
369
370    if !output.status.success {
371        bail!("fzf exited with status {:?}", output.status.code);
372    }
373
374    let selection = String::from_utf8(output.stdout).context("fzf output was not valid utf-8")?;
375    let selection = selection.trim_end();
376
377    if selection.is_empty() {
378        return Ok(None);
379    }
380
381    let mut lines = selection.lines();
382    let first = lines
383        .next()
384        .context("fzf selection output was unexpectedly empty")?;
385    let (action, encoded_entry) = match lines.next() {
386        Some(encoded_entry) if !first.is_empty() => {
387            let action = match first {
388                key if key == bindings.delete_session => SelectAction::Delete,
389                key if key == bindings.save_project => SelectAction::SaveProject,
390                other => bail!("unknown picker action: {other}"),
391            };
392            (action, encoded_entry)
393        }
394        Some(encoded_entry) => (SelectAction::Open, encoded_entry),
395        None => (SelectAction::Open, first),
396    };
397
398    Ok(Some(Selection {
399        action,
400        entry: Entry::decode(encoded_entry)?,
401    }))
402}
403
404#[cfg(test)]
405mod tests {
406    use std::sync::Arc;
407
408    use crate::process::{CommandOutput, CommandStatus, FakeCommandRunner};
409
410    use super::{
411        Choice, Entry, EntryKind, SelectAction, select_value_with_runner, select_with_runner,
412    };
413    use crate::config::{IconMode, PickerBindings, PickerPreviewSettings};
414    use crate::ui::DisplayStyle;
415
416    #[test]
417    fn entry_round_trip() {
418        let entry = Entry {
419            kind: EntryKind::Directory,
420            label: "dir      /tmp/example".to_owned(),
421            value: "/tmp/example".to_owned(),
422            preview: None,
423        };
424
425        let decoded = Entry::decode(&entry.encode()).expect("entry should decode");
426        assert_eq!(decoded, entry);
427    }
428
429    #[test]
430    fn selector_passes_entries_to_fzf() {
431        let runner = Arc::new(FakeCommandRunner::new());
432        runner.push_capture(Ok(CommandOutput {
433            status: CommandStatus {
434                success: true,
435                code: Some(0),
436            },
437            stdout: b"folder\t/tmp/example\tdir      /tmp/example\n".to_vec(),
438            stderr: Vec::new(),
439        }));
440
441        let result = select_with_runner(
442            runner.clone(),
443            vec![Entry::directory(
444                DisplayStyle::from_icon_mode(IconMode::Never),
445                "/tmp/example".to_owned(),
446            )],
447            "smux> ",
448            &PickerBindings::default(),
449            &PickerPreviewSettings::default(),
450        )
451        .expect("selection should succeed");
452
453        assert!(result.is_some());
454        let recorded = runner.recorded();
455        assert_eq!(recorded[0].program, "fzf");
456        assert!(recorded[0].args.contains(&"--ansi".to_owned()));
457        assert!(recorded[0].args.contains(&"reverse".to_owned()));
458        assert!(recorded[0].args.contains(&"3".to_owned()));
459        assert!(recorded[0].args.contains(&"1,2,4".to_owned()));
460        assert!(recorded[0].args.contains(&"--expect".to_owned()));
461        assert!(recorded[0].args.contains(&"--preview".to_owned()));
462        assert!(recorded[0].args.contains(&"ctrl-x,ctrl-y".to_owned()));
463        assert!(
464            recorded[0]
465                .args
466                .iter()
467                .any(|arg| arg.contains("enter open  ctrl-x delete  ctrl-y save project"))
468        );
469        assert!(
470            recorded[0]
471                .args
472                .iter()
473                .any(|arg| arg.contains("ctrl-c:change-prompt(smux> )+clear-query+reload("))
474        );
475        assert!(
476            recorded[0]
477                .args
478                .iter()
479                .any(|arg| arg.contains("ctrl-p projects"))
480        );
481        assert!(
482            recorded[0]
483                .args
484                .iter()
485                .any(|arg| arg.contains("ctrl-p:change-prompt(project> )+clear-query+reload("))
486        );
487        assert_eq!(
488            recorded[0].stdin.as_deref(),
489            Some("folder\t/tmp/example\tdir      /tmp/example\t\n")
490        );
491        let selection = result.expect("selection should be present");
492        assert_eq!(selection.action, SelectAction::Open);
493        assert_eq!(selection.entry.kind, EntryKind::Directory);
494    }
495
496    #[test]
497    fn selector_supports_delete_action_for_sessions() {
498        let runner = Arc::new(FakeCommandRunner::new());
499        runner.push_capture(Ok(CommandOutput {
500            status: CommandStatus {
501                success: true,
502                code: Some(0),
503            },
504            stdout: b"ctrl-x\nsession\tdemo\tsession  demo\n".to_vec(),
505            stderr: Vec::new(),
506        }));
507
508        let result = select_with_runner(
509            runner,
510            vec![Entry::session(
511                DisplayStyle::from_icon_mode(IconMode::Never),
512                "demo".to_owned(),
513            )],
514            "smux> ",
515            &PickerBindings::default(),
516            &PickerPreviewSettings::default(),
517        )
518        .expect("selection should succeed")
519        .expect("selection should be present");
520
521        assert_eq!(result.action, SelectAction::Delete);
522        assert_eq!(result.entry.kind, EntryKind::Session);
523        assert_eq!(result.entry.value, "demo");
524    }
525
526    #[test]
527    fn selector_supports_save_project_action_for_sessions() {
528        let runner = Arc::new(FakeCommandRunner::new());
529        runner.push_capture(Ok(CommandOutput {
530            status: CommandStatus {
531                success: true,
532                code: Some(0),
533            },
534            stdout: b"ctrl-y\nsession\tdemo\tsession  demo\n".to_vec(),
535            stderr: Vec::new(),
536        }));
537
538        let result = select_with_runner(
539            runner,
540            vec![Entry::session(
541                DisplayStyle::from_icon_mode(IconMode::Never),
542                "demo".to_owned(),
543            )],
544            "smux> ",
545            &PickerBindings::default(),
546            &PickerPreviewSettings::default(),
547        )
548        .expect("selection should succeed")
549        .expect("selection should be present");
550
551        assert_eq!(result.action, SelectAction::SaveProject);
552        assert_eq!(result.entry.kind, EntryKind::Session);
553        assert_eq!(result.entry.value, "demo");
554    }
555
556    #[test]
557    fn selector_treats_empty_expect_key_as_open() {
558        let runner = Arc::new(FakeCommandRunner::new());
559        runner.push_capture(Ok(CommandOutput {
560            status: CommandStatus {
561                success: true,
562                code: Some(0),
563            },
564            stdout: b"\nfolder\t/tmp/example\tdir      /tmp/example\n".to_vec(),
565            stderr: Vec::new(),
566        }));
567
568        let result = select_with_runner(
569            runner,
570            vec![Entry::directory(
571                DisplayStyle::from_icon_mode(IconMode::Never),
572                "/tmp/example".to_owned(),
573            )],
574            "smux> ",
575            &PickerBindings::default(),
576            &PickerPreviewSettings::default(),
577        )
578        .expect("selection should succeed")
579        .expect("selection should be present");
580
581        assert_eq!(result.action, SelectAction::Open);
582        assert_eq!(result.entry.kind, EntryKind::Directory);
583        assert_eq!(result.entry.value, "/tmp/example");
584    }
585
586    #[test]
587    fn selector_uses_configured_picker_bindings() {
588        let runner = Arc::new(FakeCommandRunner::new());
589        runner.push_capture(Ok(CommandOutput {
590            status: CommandStatus {
591                success: true,
592                code: Some(0),
593            },
594            stdout: b"\nfolder\t/tmp/example\tdir      /tmp/example\n".to_vec(),
595            stderr: Vec::new(),
596        }));
597
598        let bindings = PickerBindings {
599            reset: "alt-a".to_owned(),
600            sessions: "alt-s".to_owned(),
601            folders: "alt-f".to_owned(),
602            projects: "alt-p".to_owned(),
603            delete_session: "alt-x".to_owned(),
604            save_project: "alt-y".to_owned(),
605        };
606
607        let _ = select_with_runner(
608            runner.clone(),
609            vec![Entry::directory(
610                DisplayStyle::from_icon_mode(IconMode::Never),
611                "/tmp/example".to_owned(),
612            )],
613            "smux> ",
614            &bindings,
615            &PickerPreviewSettings::default(),
616        )
617        .expect("selection should succeed");
618
619        let recorded = runner.recorded();
620        assert!(recorded[0].args.contains(&"alt-x,alt-y".to_owned()));
621        assert!(
622            recorded[0]
623                .args
624                .iter()
625                .any(|arg| arg.contains("alt-a:change-prompt(smux> )+clear-query+reload("))
626        );
627        assert!(
628            recorded[0]
629                .args
630                .iter()
631                .any(|arg| arg.contains("alt-s:change-prompt(session> )+clear-query+reload("))
632        );
633        assert!(
634            recorded[0]
635                .args
636                .iter()
637                .any(|arg| arg.contains("alt-f:change-prompt(folder> )+clear-query+reload("))
638        );
639        assert!(
640            recorded[0]
641                .args
642                .iter()
643                .any(|arg| arg.contains("alt-p:change-prompt(project> )+clear-query+reload("))
644        );
645    }
646
647    #[test]
648    fn selector_uses_configured_folder_preview_command() {
649        let runner = Arc::new(FakeCommandRunner::new());
650        runner.push_capture(Ok(CommandOutput {
651            status: CommandStatus {
652                success: true,
653                code: Some(0),
654            },
655            stdout: b"\nfolder\t/tmp/example\tdir      /tmp/example\t\n".to_vec(),
656            stderr: Vec::new(),
657        }));
658
659        let _ = select_with_runner(
660            runner.clone(),
661            vec![Entry::directory(
662                DisplayStyle::from_icon_mode(IconMode::Never),
663                "/tmp/example".to_owned(),
664            )],
665            "smux> ",
666            &PickerBindings::default(),
667            &PickerPreviewSettings {
668                folders: Some("eza --tree --level=2 \"$SMUX_PREVIEW_PATH\"".to_owned()),
669                ..Default::default()
670            },
671        )
672        .expect("selection should succeed");
673
674        let recorded = runner.recorded();
675        assert!(
676            recorded[0]
677                .args
678                .iter()
679                .any(|arg| arg.contains("eza --tree --level=2"))
680        );
681    }
682
683    #[test]
684    fn template_selector_returns_selected_value() {
685        let runner = Arc::new(FakeCommandRunner::new());
686        runner.push_capture(Ok(CommandOutput {
687            status: CommandStatus {
688                success: true,
689                code: Some(0),
690            },
691            stdout: b"template\trust\ttemplate rust\n".to_vec(),
692            stderr: Vec::new(),
693        }));
694
695        let result = select_value_with_runner(
696            runner.clone(),
697            "template> ",
698            vec![
699                Choice::new(
700                    "template",
701                    "template default".to_owned(),
702                    "default".to_owned(),
703                ),
704                Choice::new("template", "template rust".to_owned(), "rust".to_owned()),
705            ],
706        )
707        .expect("selection should succeed");
708
709        assert_eq!(result.as_deref(), Some("rust"));
710        let recorded = runner.recorded();
711        assert!(recorded[0].args.contains(&"--ansi".to_owned()));
712        assert!(recorded[0].args.contains(&"reverse".to_owned()));
713        assert!(recorded[0].args.contains(&"3".to_owned()));
714        assert!(recorded[0].args.contains(&"1,2,4".to_owned()));
715        assert!(
716            recorded[0]
717                .args
718                .iter()
719                .any(|arg| arg.contains("ctrl-t templates"))
720        );
721        assert!(
722            recorded[0]
723                .args
724                .iter()
725                .any(|arg| arg.contains("ctrl-t:change-prompt(template> )+clear-query+reload("))
726        );
727        assert_eq!(
728            recorded[0].stdin.as_deref(),
729            Some("template\tdefault\ttemplate default\ntemplate\trust\ttemplate rust\n")
730        );
731    }
732}