#![cfg(not(target_os = "emscripten"))]
use anyhow::{Context, Result};
use clap::ValueEnum;
use std::io::{IsTerminal, Write};
use std::process::{Command, Stdio};
use std::sync::OnceLock;
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, ValueEnum)]
#[value(rename_all = "lower")]
pub enum Picker {
#[default]
Auto,
Fzf,
Skim,
}
static PICKER_OVERRIDE: OnceLock<Picker> = OnceLock::new();
pub fn set_picker_override(picker: Picker) {
let _ = PICKER_OVERRIDE.set(picker);
}
fn current_picker() -> Picker {
PICKER_OVERRIDE.get().copied().unwrap_or_default()
}
pub fn available() -> bool {
if !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() {
return false;
}
embedded_picker_available() || external_fzf_available()
}
pub fn external_fzf_available() -> bool {
which("fzf").is_some()
}
#[cfg(feature = "embedded-picker")]
pub const fn embedded_picker_available() -> bool {
true
}
#[cfg(not(feature = "embedded-picker"))]
pub const fn embedded_picker_available() -> bool {
false
}
fn which(cmd: &str) -> Option<std::path::PathBuf> {
let path = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path) {
let candidate = dir.join(cmd);
if candidate.is_file() {
return Some(candidate);
}
}
None
}
pub(crate) fn tab_safe(s: &str) -> String {
s.replace(['\t', '\n', '\r'], " ")
}
pub(crate) fn pad_or_truncate(s: &str, width: usize) -> String {
let count = s.chars().count();
if count == width {
s.to_string()
} else if count < width {
format!("{s}{}", " ".repeat(width - count))
} else {
let head: String = s.chars().take(width.saturating_sub(1)).collect();
format!("{head}…")
}
}
pub(crate) fn clip_chars(s: &str, width: usize) -> String {
if s.chars().count() <= width {
return s.to_string();
}
let head: String = s.chars().take(width.saturating_sub(1)).collect();
format!("{head}…")
}
pub(crate) fn project_short(p: &str) -> String {
let trimmed = p.trim_end_matches('/');
let parts: Vec<&str> = trimmed.rsplit('/').take(2).collect();
if parts.is_empty() {
return p.to_string();
}
let mut out: Vec<&str> = parts.into_iter().collect();
out.reverse();
out.join("/")
}
pub(crate) fn count(n: usize, unit: &str) -> String {
format!("{n:>4} {unit}")
}
pub(crate) fn render_row(
leading: Option<&str>,
when: Option<chrono::DateTime<chrono::Utc>>,
count: &str,
project: Option<&str>,
title: &str,
) -> String {
const PROJECT_WIDTH: usize = 28;
const TITLE_MAX: usize = 96;
let leading_segment = match leading {
Some(s) => format!("{s} "),
None => String::new(),
};
let when_str = when
.map(|t| t.format("%Y-%m-%d %H:%M").to_string())
.unwrap_or_else(|| " — ".to_string());
let project_segment = match project {
Some(p) => format!("{} ", pad_or_truncate(&tab_safe(p), PROJECT_WIDTH)),
None => String::new(),
};
let title_str = clip_chars(&clean_for_picker_display(title), TITLE_MAX);
format!("{leading_segment}{when_str} {count} {project_segment}{title_str}")
}
pub(crate) fn clean_for_picker_display(s: &str) -> String {
if let Some(out) = strip_slash_command_envelope(s) {
return out;
}
if let Some(out) = strip_local_command_caveat(s) {
return out;
}
strip_xml_tags(s)
}
fn strip_slash_command_envelope(s: &str) -> Option<String> {
let name = extract_tag_content(s, "command-name")?;
let args = extract_tag_content(s, "command-args").unwrap_or_default();
let name = name.trim();
let args = args.trim();
if args.is_empty() {
Some(name.to_string())
} else {
Some(format!("{name} {args}"))
}
}
fn strip_local_command_caveat(s: &str) -> Option<String> {
if !s.contains("<local-command-caveat>") {
return None;
}
if let Some(cmd) = extract_tag_content(s, "bash-input") {
return Some(format!("! {}", cmd.trim()));
}
let stripped = remove_block(s, "<local-command-caveat>", "</local-command-caveat>");
Some(strip_xml_tags(&stripped))
}
fn extract_tag_content(s: &str, tag: &str) -> Option<String> {
let open = format!("<{tag}>");
let close = format!("</{tag}>");
let start = s.find(&open)? + open.len();
let end = s[start..].find(&close)?;
Some(s[start..start + end].to_string())
}
fn remove_block(s: &str, start: &str, end: &str) -> String {
let Some(open_idx) = s.find(start) else {
return s.to_string();
};
let after_open = open_idx + start.len();
let Some(close_off) = s[after_open..].find(end) else {
return s.to_string();
};
let close_idx = after_open + close_off + end.len();
format!("{}{}", &s[..open_idx], &s[close_idx..])
}
fn strip_xml_tags(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut in_tag = false;
for ch in s.chars() {
match ch {
'<' => in_tag = true,
'>' if in_tag => in_tag = false,
_ if !in_tag => out.push(ch),
_ => {}
}
}
let mut collapsed = String::with_capacity(out.len());
let mut prev_space = false;
for ch in out.chars() {
if ch.is_whitespace() {
if !prev_space {
collapsed.push(' ');
}
prev_space = true;
} else {
collapsed.push(ch);
prev_space = false;
}
}
collapsed.trim().to_string()
}
pub(crate) fn substitute_exe_placeholder(preview: &str) -> String {
let exe = match std::env::current_exe() {
Ok(p) => shell_quote(&p.to_string_lossy()),
Err(_) => "path".to_string(),
};
preview.replace("{exe}", &exe)
}
fn shell_quote(s: &str) -> String {
let escaped = s.replace('\'', "'\\''");
format!("'{escaped}'")
}
pub enum PickResult {
Selected(Vec<String>),
NoMatch,
Cancelled,
}
pub fn pick(lines: &[String], opts: &PickOptions<'_>) -> Result<PickResult> {
match current_picker() {
Picker::Fzf => {
if !external_fzf_available() {
anyhow::bail!(
"`--picker fzf` requested but `fzf` isn't on PATH; install it or pass `--picker auto`/`skim`"
);
}
pick_external(lines, opts)
}
Picker::Skim => pick_embedded(lines, opts),
Picker::Auto => {
if external_fzf_available() {
pick_external(lines, opts)
} else {
pick_embedded(lines, opts)
}
}
}
}
#[cfg(feature = "embedded-picker")]
fn pick_embedded(lines: &[String], opts: &PickOptions<'_>) -> Result<PickResult> {
crate::skim_picker::pick(lines, opts)
}
#[cfg(not(feature = "embedded-picker"))]
fn pick_embedded(_lines: &[String], _opts: &PickOptions<'_>) -> Result<PickResult> {
anyhow::bail!(
"embedded skim picker isn't compiled in (built with `--no-default-features`); install `fzf` and pass `--picker fzf` or rebuild with the default `embedded-picker` feature"
)
}
pub fn pick_external(lines: &[String], opts: &PickOptions<'_>) -> Result<PickResult> {
let mut args: Vec<String> = vec![
"--delimiter=\t".into(),
format!("--with-nth={}", opts.with_nth),
format!("--prompt={}", opts.prompt),
format!("--tiebreak={}", opts.tiebreak),
];
if opts.multi {
args.push("--multi".into());
}
if let Some(preview) = opts.preview {
args.push(format!("--preview={}", substitute_exe_placeholder(preview)));
args.push(format!("--preview-window={}", opts.preview_window));
args.push("--preview-wrap-sign=".into());
}
if let Some(header) = opts.header {
args.push(format!("--header={}", header));
}
let mut child = Command::new("fzf")
.args(&args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
.context("Failed to spawn fzf")?;
{
let stdin = child
.stdin
.as_mut()
.ok_or_else(|| anyhow::anyhow!("Failed to open fzf stdin"))?;
for line in lines {
stdin.write_all(line.as_bytes())?;
stdin.write_all(b"\n")?;
}
}
let output = child.wait_with_output().context("Failed to wait on fzf")?;
match output.status.code() {
Some(0) => {
let text = String::from_utf8_lossy(&output.stdout);
Ok(PickResult::Selected(
text.lines().map(|s| s.to_string()).collect(),
))
}
Some(1) => Ok(PickResult::NoMatch),
Some(130) => Ok(PickResult::Cancelled),
_ => anyhow::bail!("fzf exited with status {:?}", output.status),
}
}
pub struct PickOptions<'a> {
pub with_nth: &'a str,
pub prompt: &'a str,
pub preview: Option<&'a str>,
pub preview_window: &'a str,
pub header: Option<&'a str>,
pub tiebreak: &'a str,
pub multi: bool,
}
impl Default for PickOptions<'_> {
fn default() -> Self {
Self {
with_nth: "2..",
prompt: "> ",
preview: None,
preview_window: "right:60%:wrap-word",
header: None,
tiebreak: "index",
multi: false,
}
}
}
pub fn print_recipe(provider: &str, project_keyed: bool) {
let leader = if !external_fzf_available() && !embedded_picker_available() {
"Interactive selection needs `fzf` on PATH (or a build with the \
`embedded-picker` feature) and a TTY."
} else {
"Interactive selection needs a TTY."
};
if project_keyed {
eprintln!(
"{leader}\n\
\n\
Manual recipe:\n \
path p list {provider} --format tsv \\\n \
| fzf --delimiter=$'\\t' --with-nth=3.. \\\n \
--preview 'path show {provider} --project {{1}} --session {{2}}' \\\n \
| awk -F'\\t' '{{print $1 \"\\n\" $2}}' \\\n \
| xargs -L2 sh -c 'path p import {provider} --project \"$1\" --session \"$2\"' --\n"
);
} else {
eprintln!(
"{leader}\n\
\n\
Manual recipe:\n \
path p list {provider} --format tsv \\\n \
| fzf --delimiter=$'\\t' --with-nth=2.. \\\n \
--preview 'path show {provider} --session {{1}}' \\\n \
| cut -f1 \\\n \
| xargs -I{{}} path p import {provider} --session {{}}\n"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shell_quote_wraps_in_single_quotes() {
assert_eq!(shell_quote("/usr/local/bin/path"), "'/usr/local/bin/path'");
}
#[test]
fn shell_quote_escapes_embedded_single_quote() {
assert_eq!(shell_quote("/o'reilly/bin"), "'/o'\\''reilly/bin'");
}
#[test]
fn substitute_exe_replaces_placeholder() {
let out = substitute_exe_placeholder("{exe} show --ansi claude --session {1}");
assert!(!out.contains("{exe}"), "placeholder not substituted: {out}");
assert!(out.contains(" show --ansi claude --session {1}"));
}
#[test]
fn substitute_exe_leaves_other_placeholders_alone() {
let out = substitute_exe_placeholder("{exe} show --session {1} --project {2}");
assert!(out.contains("{1}"));
assert!(out.contains("{2}"));
}
use chrono::TimeZone;
fn jan_first() -> chrono::DateTime<chrono::Utc> {
chrono::Utc.with_ymd_and_hms(2026, 1, 29, 10, 5, 0).unwrap()
}
#[test]
fn render_row_bare_matches_expected_format() {
let out = render_row(None, Some(jan_first()), " 17 msgs", None, "hello world");
assert_eq!(out, "2026-01-29 10:05 17 msgs hello world");
}
#[test]
fn render_row_with_leading_prefix_separates_with_one_space() {
let out = render_row(
Some("· claude "),
Some(jan_first()),
" 17 msgs",
None,
"hi",
);
assert!(out.starts_with("· claude 2026-01-29 10:05"), "{out}");
}
#[test]
fn render_row_pads_project_to_fixed_width() {
let short = render_row(None, Some(jan_first()), " 17 msgs", Some("a/b"), "t");
let long = render_row(
None,
Some(jan_first()),
" 17 msgs",
Some("a-much-longer-name"),
"t",
);
let title_offset = |s: &str| s.find("t").and_then(|_| s.rfind(" t")).unwrap();
assert_eq!(
title_offset(&short),
title_offset(&long),
"title misaligned across rows:\n short={short:?}\n long={long:?}"
);
}
#[test]
fn render_row_truncates_oversized_project_with_ellipsis() {
let long = "x".repeat(40);
let out = render_row(None, Some(jan_first()), " 17 msgs", Some(&long), "t");
let project_segment = out
.strip_prefix("2026-01-29 10:05 17 msgs ")
.unwrap()
.strip_suffix(" t")
.unwrap();
assert_eq!(project_segment.chars().count(), 28);
assert!(project_segment.ends_with('…'), "got: {project_segment:?}");
}
#[test]
fn render_row_missing_when_renders_placeholder_at_fixed_width() {
let with = render_row(None, Some(jan_first()), " 17 msgs", None, "t");
let without = render_row(None, None, " 17 msgs", None, "t");
let char_offset = |s: &str| {
s.chars()
.collect::<Vec<_>>()
.windows(7)
.position(|w| w.iter().collect::<String>() == "17 msgs")
};
assert_eq!(char_offset(&with), char_offset(&without));
}
#[test]
fn render_row_clips_long_title_with_ellipsis() {
let title = "x".repeat(200);
let out = render_row(None, Some(jan_first()), " 17 msgs", None, &title);
let title_part = out.strip_prefix("2026-01-29 10:05 17 msgs ").unwrap();
assert_eq!(title_part.chars().count(), 96);
assert!(title_part.ends_with('…'), "got: {title_part:?}");
}
#[test]
fn render_row_strips_slash_command_envelope_from_title() {
let raw = "<command-message>biko</command-message> <command-name>/biko</command-name> <command-args>do the thing</command-args>";
let out = render_row(None, Some(jan_first()), " 17 msgs", None, raw);
assert!(out.ends_with("/biko do the thing"), "got: {out}");
assert!(!out.contains("<command-"));
}
#[test]
fn count_right_aligns_number_to_four_chars() {
assert_eq!(count(7, "msgs"), " 7 msgs");
assert_eq!(count(1572, "msgs"), "1572 msgs");
assert_eq!(count(12, "entries"), " 12 entries");
}
}