Skip to main content

path_cli/
cmd_share.rs

1//! `path share` — interactive Pathbase upload across installed agent
2//! harnesses. See `docs/superpowers/specs/2026-05-07-path-share-command-design.md`.
3
4#![cfg(not(target_os = "emscripten"))]
5
6use anyhow::Result;
7use chrono::{DateTime, Utc};
8use clap::{Args, ValueEnum};
9use std::path::PathBuf;
10
11use crate::cmd_export::RepoSpec;
12
13#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
14#[value(rename_all = "lower")]
15pub enum HarnessArg {
16    Claude,
17    Gemini,
18    Codex,
19    Opencode,
20    Cursor,
21    Pi,
22}
23
24#[derive(Args, Debug)]
25pub struct ShareArgs {
26    /// Pathbase server URL (defaults to the stored session's server)
27    #[arg(long)]
28    pub url: Option<String>,
29
30    /// Force the anonymous endpoint, ignoring any stored credentials
31    #[arg(long, conflicts_with_all = ["repo", "public"])]
32    pub anon: bool,
33
34    /// Target a specific repo as `owner/name` instead of `<you>/pathstash`
35    #[arg(long, value_parser = crate::cmd_export::parse_repo_spec)]
36    pub repo: Option<RepoSpec>,
37
38    /// Human-readable display label for the uploaded graph
39    /// (defaults to the toolpath document id). Free-form; not used
40    /// in the URL — graphs are addressed by UUID server-side.
41    #[arg(long, alias = "slug")]
42    pub name: Option<String>,
43
44    /// Mark the uploaded graph public (default: unlisted, addressable only by UUID)
45    #[arg(long)]
46    pub public: bool,
47
48    /// Narrow the picker to one harness, or skip the picker entirely
49    /// when used with --session.
50    #[arg(long, value_enum)]
51    pub harness: Option<HarnessArg>,
52
53    /// Skip the picker. Requires --harness; requires --project for
54    /// claude/gemini/pi.
55    #[arg(long, requires = "harness")]
56    pub session: Option<String>,
57
58    /// Override cwd-as-project. Filters the picker to sessions tied to
59    /// this project across all harnesses.
60    #[arg(long)]
61    pub project: Option<PathBuf>,
62
63    /// Skip writing the cache; derive in-memory only
64    #[arg(long)]
65    pub no_cache: bool,
66}
67
68/// Which agent harness a session was produced by.
69#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
70pub(crate) enum Harness {
71    Claude,
72    Gemini,
73    Codex,
74    Opencode,
75    Cursor,
76    Pi,
77}
78
79impl Harness {
80    pub(crate) fn name(&self) -> &'static str {
81        match self {
82            Harness::Claude => "claude",
83            Harness::Gemini => "gemini",
84            Harness::Codex => "codex",
85            Harness::Opencode => "opencode",
86            Harness::Cursor => "cursor",
87            Harness::Pi => "pi",
88        }
89    }
90
91    /// Padded so all symbols line up in the fzf column. Longest is
92    /// "opencode" (8); pad shorter names to match.
93    pub(crate) fn symbol(&self) -> &'static str {
94        match self {
95            Harness::Claude => "claude  ",
96            Harness::Gemini => "gemini  ",
97            Harness::Codex => "codex   ",
98            Harness::Opencode => "opencode",
99            Harness::Cursor => "cursor  ",
100            Harness::Pi => "pi      ",
101        }
102    }
103
104    /// True when the underlying provider keys sessions by project path.
105    /// claude/gemini/pi: true. codex/opencode/cursor: false (sessions
106    /// store cwd per-row, not as a directory key — cursor stores it as
107    /// `workspaceIdentifier.uri.fsPath` on each composer).
108    pub(crate) fn project_keyed(&self) -> bool {
109        matches!(self, Harness::Claude | Harness::Gemini | Harness::Pi)
110    }
111
112    pub(crate) fn from_arg(arg: HarnessArg) -> Self {
113        match arg {
114            HarnessArg::Claude => Harness::Claude,
115            HarnessArg::Gemini => Harness::Gemini,
116            HarnessArg::Codex => Harness::Codex,
117            HarnessArg::Opencode => Harness::Opencode,
118            HarnessArg::Cursor => Harness::Cursor,
119            HarnessArg::Pi => Harness::Pi,
120        }
121    }
122
123    pub(crate) fn parse(s: &str) -> Option<Self> {
124        match s {
125            "claude" => Some(Harness::Claude),
126            "gemini" => Some(Harness::Gemini),
127            "codex" => Some(Harness::Codex),
128            "opencode" => Some(Harness::Opencode),
129            "cursor" => Some(Harness::Cursor),
130            "pi" => Some(Harness::Pi),
131            _ => None,
132        }
133    }
134}
135
136/// One row in the unified session picker.
137#[derive(Debug, Clone)]
138pub(crate) struct SessionRow {
139    pub(crate) harness: Harness,
140    /// Project path for keyed providers; `None` for codex/opencode.
141    pub(crate) project: Option<String>,
142    /// Recorded cwd from the session (codex/opencode only).
143    pub(crate) cwd: Option<String>,
144    pub(crate) session_id: String,
145    pub(crate) title: String,
146    pub(crate) last_activity: Option<DateTime<Utc>>,
147    pub(crate) message_count: usize,
148    pub(crate) matches_cwd: bool,
149}
150
151/// Bundle of provider managers used during aggregation. Production code
152/// builds this from real `$HOME` via `from_environment`; tests construct
153/// it directly with provider-specific resolvers.
154#[derive(Default)]
155pub(crate) struct HarnessBundle {
156    pub(crate) claude: Option<toolpath_claude::ClaudeConvo>,
157    pub(crate) gemini: Option<toolpath_gemini::GeminiConvo>,
158    pub(crate) codex: Option<toolpath_codex::CodexConvo>,
159    pub(crate) opencode: Option<toolpath_opencode::OpencodeConvo>,
160    pub(crate) cursor: Option<toolpath_cursor::CursorConvo>,
161    pub(crate) pi: Option<toolpath_pi::PiConvo>,
162}
163
164impl HarnessBundle {
165    /// Build the production bundle. Each provider is included
166    /// unconditionally (its `new()` doesn't fail on a missing home dir);
167    /// `gather_sessions` skips the ones whose listing returns empty/NotFound.
168    pub(crate) fn from_environment() -> Self {
169        Self {
170            claude: Some(toolpath_claude::ClaudeConvo::new()),
171            gemini: Some(toolpath_gemini::GeminiConvo::new()),
172            codex: Some(toolpath_codex::CodexConvo::new()),
173            opencode: Some(toolpath_opencode::OpencodeConvo::new()),
174            cursor: Some(toolpath_cursor::CursorConvo::new()),
175            pi: Some(toolpath_pi::PiConvo::new()),
176        }
177    }
178}
179
180/// Aggregate sessions across the harnesses in `bundle`, ranked so that
181/// rows whose project (or recorded cwd) canonicalizes to `cwd` come
182/// first, sorted by descending `last_activity`.
183///
184/// Filters: `harness_filter` keeps only rows from one harness; `project_filter`
185/// keeps only rows whose project (for keyed) or cwd (for session-keyed)
186/// canonicalizes to that path.
187pub(crate) fn gather_sessions(
188    bundle: &HarnessBundle,
189    cwd: &std::path::Path,
190    harness_filter: Option<Harness>,
191    project_filter: Option<&std::path::Path>,
192) -> Vec<SessionRow> {
193    let mut rows = Vec::new();
194    let canonical_cwd = canonicalize_or_self(cwd);
195    let canonical_project = project_filter.map(canonicalize_or_self);
196
197    let want = |h: Harness| harness_filter.is_none_or(|f| f == h);
198
199    if want(Harness::Claude)
200        && let Some(mgr) = &bundle.claude
201    {
202        collect_claude(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows);
203    }
204    if want(Harness::Gemini)
205        && let Some(mgr) = &bundle.gemini
206    {
207        collect_gemini(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows);
208    }
209    if want(Harness::Pi)
210        && let Some(mgr) = &bundle.pi
211    {
212        collect_pi(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows);
213    }
214    if want(Harness::Codex)
215        && let Some(mgr) = &bundle.codex
216    {
217        collect_codex(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows);
218    }
219    if want(Harness::Opencode)
220        && let Some(mgr) = &bundle.opencode
221    {
222        collect_opencode(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows);
223    }
224    if want(Harness::Cursor)
225        && let Some(mgr) = &bundle.cursor
226    {
227        collect_cursor(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows);
228    }
229
230    rows.sort_by(|a, b| {
231        b.matches_cwd
232            .cmp(&a.matches_cwd)
233            .then_with(|| b.last_activity.cmp(&a.last_activity))
234    });
235    rows
236}
237
238fn canonicalize_or_self(p: &std::path::Path) -> std::path::PathBuf {
239    std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
240}
241
242fn paths_match(a: &std::path::Path, b: &std::path::Path) -> bool {
243    canonicalize_or_self(a) == canonicalize_or_self(b)
244}
245
246fn collect_claude(
247    mgr: &toolpath_claude::ClaudeConvo,
248    canonical_cwd: &std::path::Path,
249    project_filter: Option<&std::path::Path>,
250    out: &mut Vec<SessionRow>,
251) {
252    let projects = match mgr.list_projects() {
253        Ok(ps) if !ps.is_empty() => ps,
254        Ok(_) => return,
255        Err(e) if is_not_found_claude(&e) => return,
256        Err(e) => {
257            eprintln!("warning: claude aggregation failed: {e}");
258            return;
259        }
260    };
261    for project in projects {
262        let project_path = std::path::Path::new(&project);
263        if let Some(filter) = project_filter
264            && !paths_match(project_path, filter)
265        {
266            continue;
267        }
268        let metas = match mgr.list_conversation_metadata(&project) {
269            Ok(m) => m,
270            Err(e) => {
271                eprintln!("warning: claude project {project} failed: {e}");
272                continue;
273            }
274        };
275        let matches_cwd = paths_match(project_path, canonical_cwd);
276        for m in metas {
277            out.push(SessionRow {
278                harness: Harness::Claude,
279                project: Some(m.project_path),
280                cwd: None,
281                session_id: m.session_id,
282                title: m
283                    .first_user_message
284                    .unwrap_or_else(|| "(no prompt)".to_string()),
285                last_activity: m.last_activity,
286                message_count: m.message_count,
287                matches_cwd,
288            });
289        }
290    }
291}
292
293fn collect_gemini(
294    mgr: &toolpath_gemini::GeminiConvo,
295    canonical_cwd: &std::path::Path,
296    project_filter: Option<&std::path::Path>,
297    out: &mut Vec<SessionRow>,
298) {
299    let projects = match mgr.list_projects() {
300        Ok(ps) if !ps.is_empty() => ps,
301        Ok(_) => return,
302        Err(e) if is_not_found_gemini(&e) => return,
303        Err(e) => {
304            eprintln!("warning: gemini aggregation failed: {e}");
305            return;
306        }
307    };
308    for project in projects {
309        let project_path = std::path::Path::new(&project);
310        if let Some(filter) = project_filter
311            && !paths_match(project_path, filter)
312        {
313            continue;
314        }
315        let metas = match mgr.list_conversation_metadata(&project) {
316            Ok(m) => m,
317            Err(e) => {
318                eprintln!("warning: gemini project {project} failed: {e}");
319                continue;
320            }
321        };
322        let matches_cwd = paths_match(project_path, canonical_cwd);
323        for m in metas {
324            out.push(SessionRow {
325                harness: Harness::Gemini,
326                project: Some(m.project_path),
327                cwd: None,
328                session_id: m.session_uuid,
329                title: m
330                    .first_user_message
331                    .unwrap_or_else(|| "(no prompt)".to_string()),
332                last_activity: m.last_activity,
333                message_count: m.message_count,
334                matches_cwd,
335            });
336        }
337    }
338}
339
340fn collect_pi(
341    mgr: &toolpath_pi::PiConvo,
342    canonical_cwd: &std::path::Path,
343    project_filter: Option<&std::path::Path>,
344    out: &mut Vec<SessionRow>,
345) {
346    let projects = match mgr.list_projects() {
347        Ok(ps) if !ps.is_empty() => ps,
348        Ok(_) => return,
349        Err(e) if is_not_found_pi(&e) => return,
350        Err(e) => {
351            eprintln!("warning: pi aggregation failed: {e}");
352            return;
353        }
354    };
355    for project in projects {
356        let project_path = std::path::Path::new(&project);
357        if let Some(filter) = project_filter
358            && !paths_match(project_path, filter)
359        {
360            continue;
361        }
362        let metas = match mgr.list_sessions(&project) {
363            Ok(m) => m,
364            Err(e) => {
365                eprintln!("warning: pi project {project} failed: {e}");
366                continue;
367            }
368        };
369        let matches_cwd = paths_match(project_path, canonical_cwd);
370        for m in metas {
371            // SessionMeta.timestamp is a String; parse to DateTime when possible.
372            let last_activity = chrono::DateTime::parse_from_rfc3339(&m.timestamp)
373                .ok()
374                .map(|d| d.with_timezone(&Utc));
375            out.push(SessionRow {
376                harness: Harness::Pi,
377                project: Some(project.clone()),
378                cwd: None,
379                session_id: m.id,
380                title: m
381                    .first_user_message
382                    .unwrap_or_else(|| "(no prompt)".to_string()),
383                last_activity,
384                message_count: m.entry_count,
385                matches_cwd,
386            });
387        }
388    }
389}
390
391fn collect_codex(
392    mgr: &toolpath_codex::CodexConvo,
393    canonical_cwd: &std::path::Path,
394    project_filter: Option<&std::path::Path>,
395    out: &mut Vec<SessionRow>,
396) {
397    let metas = match mgr.list_sessions() {
398        Ok(m) if !m.is_empty() => m,
399        Ok(_) => return,
400        Err(e) if is_not_found_codex(&e) => return,
401        Err(e) => {
402            eprintln!("warning: codex aggregation failed: {e}");
403            return;
404        }
405    };
406    for m in metas {
407        let cwd_str = m.cwd.as_ref().map(|p| p.to_string_lossy().into_owned());
408        if let Some(filter) = project_filter {
409            let stored = match cwd_str.as_deref() {
410                Some(s) => std::path::PathBuf::from(s),
411                None => continue,
412            };
413            if !paths_match(&stored, filter) {
414                continue;
415            }
416        }
417        let matches_cwd = m
418            .cwd
419            .as_deref()
420            .map(|p| paths_match(p, canonical_cwd))
421            .unwrap_or(false);
422        out.push(SessionRow {
423            harness: Harness::Codex,
424            project: None,
425            cwd: cwd_str,
426            session_id: m.id,
427            title: m
428                .first_user_message
429                .unwrap_or_else(|| "(no prompt)".to_string()),
430            last_activity: m.last_activity,
431            message_count: m.line_count,
432            matches_cwd,
433        });
434    }
435}
436
437fn collect_opencode(
438    mgr: &toolpath_opencode::OpencodeConvo,
439    canonical_cwd: &std::path::Path,
440    project_filter: Option<&std::path::Path>,
441    out: &mut Vec<SessionRow>,
442) {
443    let metas = match mgr.io().list_session_metadata(None) {
444        Ok(m) if !m.is_empty() => m,
445        Ok(_) => return,
446        Err(e) if is_not_found_opencode(&e) => return,
447        Err(e) => {
448            eprintln!("warning: opencode aggregation failed: {e}");
449            return;
450        }
451    };
452    for m in metas {
453        if let Some(filter) = project_filter
454            && !paths_match(&m.directory, filter)
455        {
456            continue;
457        }
458        let matches_cwd = paths_match(&m.directory, canonical_cwd);
459        let cwd_str = m.directory.to_string_lossy().into_owned();
460        let title = match (&m.first_user_message, m.title.is_empty()) {
461            (Some(s), _) if !s.is_empty() => s.clone(),
462            (_, false) => m.title.clone(),
463            _ => "(no prompt)".to_string(),
464        };
465        out.push(SessionRow {
466            harness: Harness::Opencode,
467            project: None,
468            cwd: Some(cwd_str),
469            session_id: m.id,
470            title,
471            last_activity: m.last_activity,
472            message_count: m.message_count,
473            matches_cwd,
474        });
475    }
476}
477
478fn collect_cursor(
479    mgr: &toolpath_cursor::CursorConvo,
480    canonical_cwd: &std::path::Path,
481    project_filter: Option<&std::path::Path>,
482    out: &mut Vec<SessionRow>,
483) {
484    let metas = match mgr.io().list_session_metadata() {
485        Ok(m) if !m.is_empty() => m,
486        Ok(_) => return,
487        Err(e) if is_not_found_cursor(&e) => return,
488        Err(e) => {
489            eprintln!("warning: cursor aggregation failed: {e}");
490            return;
491        }
492    };
493    for m in metas {
494        // Cursor stores each composer's workspace as the absolute
495        // path of the folder Cursor.app was open on. Sessions
496        // without a workspace (numeric/remote workspace ids) are
497        // dropped from the picker — we can't tell what they're
498        // tied to.
499        let Some(workspace) = m.workspace_path.as_ref() else {
500            continue;
501        };
502        if let Some(filter) = project_filter
503            && !paths_match(workspace, filter)
504        {
505            continue;
506        }
507        let matches_cwd = paths_match(workspace, canonical_cwd);
508        let cwd_str = workspace.to_string_lossy().into_owned();
509        let title = match (&m.first_user_message, &m.name) {
510            (Some(s), _) if !s.is_empty() => s.clone(),
511            (_, Some(n)) if !n.is_empty() => n.clone(),
512            _ => "(no prompt)".to_string(),
513        };
514        out.push(SessionRow {
515            harness: Harness::Cursor,
516            project: None,
517            cwd: Some(cwd_str),
518            session_id: m.id,
519            title,
520            last_activity: m.last_activity,
521            message_count: m.message_count,
522            matches_cwd,
523        });
524    }
525}
526
527fn is_not_found_claude(err: &toolpath_claude::ConvoError) -> bool {
528    use toolpath_claude::ConvoError;
529    matches!(err, ConvoError::Io(e) if e.kind() == std::io::ErrorKind::NotFound)
530        || matches!(err, ConvoError::NoHomeDirectory)
531        || matches!(err, ConvoError::ClaudeDirectoryNotFound(_))
532}
533
534fn is_not_found_gemini(err: &toolpath_gemini::ConvoError) -> bool {
535    use toolpath_gemini::ConvoError;
536    matches!(err, ConvoError::Io(e) if e.kind() == std::io::ErrorKind::NotFound)
537        || matches!(err, ConvoError::NoHomeDirectory)
538        || matches!(err, ConvoError::GeminiDirectoryNotFound(_))
539}
540
541fn is_not_found_pi(err: &toolpath_pi::PiError) -> bool {
542    use toolpath_pi::PiError;
543    matches!(err, PiError::Io(e) if e.kind() == std::io::ErrorKind::NotFound)
544        || matches!(err, PiError::ProjectNotFound(_))
545}
546
547fn is_not_found_codex(err: &toolpath_codex::ConvoError) -> bool {
548    use toolpath_codex::ConvoError;
549    matches!(err, ConvoError::Io(e) if e.kind() == std::io::ErrorKind::NotFound)
550        || matches!(err, ConvoError::NoHomeDirectory)
551        || matches!(err, ConvoError::CodexDirectoryNotFound(_))
552}
553
554fn is_not_found_opencode(err: &toolpath_opencode::ConvoError) -> bool {
555    use toolpath_opencode::ConvoError;
556    matches!(err, ConvoError::Io(e) if e.kind() == std::io::ErrorKind::NotFound)
557        || matches!(err, ConvoError::NoHomeDirectory)
558        || matches!(err, ConvoError::OpencodeDirectoryNotFound(_))
559        || matches!(err, ConvoError::DatabaseNotFound(_))
560}
561
562fn is_not_found_cursor(err: &toolpath_cursor::CursorError) -> bool {
563    use toolpath_cursor::CursorError;
564    matches!(err, CursorError::Io(e) if e.kind() == std::io::ErrorKind::NotFound)
565        || matches!(err, CursorError::NoHomeDirectory)
566        || matches!(err, CursorError::CursorDataDirectoryNotFound(_))
567        || matches!(err, CursorError::DatabaseNotFound(_))
568}
569
570pub fn run(args: ShareArgs) -> Result<()> {
571    let harness = args.harness.map(Harness::from_arg);
572
573    if args.session.is_some() && harness.is_none() {
574        anyhow::bail!("--session requires --harness");
575    }
576
577    // Build upload args + base URL once and reuse for both the explicit
578    // path and the picker path. `needs_auth` decides whether preflight
579    // can fall back to anon on credential failure.
580    let upload_args = crate::cmd_export::PathbaseUploadArgs {
581        url: args.url.clone(),
582        anon: args.anon,
583        repo: args.repo.clone(),
584        name: args.name.clone(),
585        public: args.public,
586    };
587    let base_url = crate::cmd_export::resolve_upload_base_url(&upload_args);
588    let needs_auth = upload_args.repo.is_some() || upload_args.public || upload_args.name.is_some();
589
590    if let (Some(h), Some(session)) = (harness, &args.session) {
591        // Explicit-args: validate creds before derive so a credential
592        // failure doesn't waste the derive/cache work.
593        let auth = crate::cmd_pathbase::preflight_auth(&base_url, upload_args.anon, needs_auth)?;
594        return share_explicit(h, session.as_str(), &args, auth, base_url);
595    }
596
597    let cwd = std::env::current_dir()?;
598    let bundle = HarnessBundle::from_environment();
599    let project_filter = args.project.as_deref();
600    let rows = gather_sessions(&bundle, &cwd, harness, project_filter);
601
602    if rows.is_empty() {
603        return bail_no_sessions(&bundle, project_filter);
604    }
605
606    if !crate::fuzzy::available() {
607        eprintln!(
608            "Interactive `path share` needs `fzf` on PATH and a TTY.\n\
609             \n\
610             Manual recipe:\n  \
611             path import <harness>      # writes a cache entry, prints its id\n  \
612             path export pathbase --input <id>"
613        );
614        anyhow::bail!("fzf unavailable; run `path import <harness>` then `path export pathbase`");
615    }
616
617    // We have rows AND fzf available — now validate credentials before
618    // making the user pick a session. If preflight returns Anon (either
619    // explicit --anon, no creds + no auth flags, or auth probe failed
620    // and fell back), the picker still fires with that knowledge baked in.
621    let auth = crate::cmd_pathbase::preflight_auth(&base_url, upload_args.anon, needs_auth)?;
622
623    let lines: Vec<String> = rows.iter().map(format_picker_row).collect();
624    let header = format!("share an agent session (Enter = upload to {base_url})");
625    let opts = crate::fuzzy::PickOptions {
626        with_nth: "4",
627        prompt: "share> ",
628        preview: Some("{exe} show --ansi {1} --project {2} --session {3}"),
629        // Stacked layout: preview above the list, list below. Fits narrow
630        // terminals better than the default side-by-side and gives the
631        // session preview the full terminal width to render `path show`.
632        preview_window: "up:60%:wrap-word",
633        header: Some(&header),
634        tiebreak: "index",
635        multi: false,
636    };
637    let line = match crate::fuzzy::pick(&lines, &opts)? {
638        crate::fuzzy::PickResult::Selected(v) => match v.into_iter().next() {
639            Some(l) => l,
640            // Selected with an empty payload should not happen (fzf exits 0
641            // only when at least one row was confirmed), but treat it like
642            // no-match for safety.
643            None => return Ok(()),
644        },
645        // No row matched the query — exit 0, same as today, no extra noise.
646        crate::fuzzy::PickResult::NoMatch => return Ok(()),
647        // Esc / Ctrl-C: deliberate user cancel. Signal to the shell with
648        // exit 130 so it's distinguishable from a successful share.
649        crate::fuzzy::PickResult::Cancelled => std::process::exit(130),
650    };
651    let (h, key, session, title) = parse_picker_row(&line)
652        .ok_or_else(|| anyhow::anyhow!("internal: failed to parse picker row"))?;
653
654    let explicit = ShareArgs {
655        url: args.url.clone(),
656        anon: args.anon,
657        repo: args.repo.clone(),
658        name: args.name.clone(),
659        public: args.public,
660        harness: Some(harness_to_arg(h)),
661        session: None, // unused by share_explicit
662        project: if h.project_keyed() {
663            Some(PathBuf::from(&key))
664        } else {
665            None
666        },
667        no_cache: args.no_cache,
668    };
669    // Show the conversation title in the confirmation line; the session id
670    // is opaque and doesn't help the user verify they picked the right
671    // thing. `{:?}` adds the surrounding quotes per the spec.
672    eprintln!("Picked {} session {:?}", h.name(), title);
673    share_explicit(h, &session, &explicit, auth, base_url)
674}
675
676fn harness_to_arg(h: Harness) -> HarnessArg {
677    match h {
678        Harness::Claude => HarnessArg::Claude,
679        Harness::Gemini => HarnessArg::Gemini,
680        Harness::Codex => HarnessArg::Codex,
681        Harness::Opencode => HarnessArg::Opencode,
682        Harness::Cursor => HarnessArg::Cursor,
683        Harness::Pi => HarnessArg::Pi,
684    }
685}
686
687fn bail_no_sessions(
688    bundle: &HarnessBundle,
689    project_filter: Option<&std::path::Path>,
690) -> Result<()> {
691    if let Some(p) = project_filter {
692        anyhow::bail!(
693            "No agent sessions found in project {}. Run without --project to see sessions across all projects.",
694            p.display()
695        );
696    }
697
698    let mut summary = String::from("No agent sessions found.\n");
699    // Pad harness names so the path column lines up: "opencode:" is the
700    // longest at 9 chars (8 + colon).
701    let home = home_dir();
702    summary.push_str(&format_status_line(
703        "claude",
704        &harness_status_claude(bundle, home.as_deref()),
705    ));
706    summary.push_str(&format_status_line(
707        "gemini",
708        &harness_status_gemini(bundle, home.as_deref()),
709    ));
710    summary.push_str(&format_status_line(
711        "codex",
712        &harness_status_codex(bundle, home.as_deref()),
713    ));
714    summary.push_str(&format_status_line(
715        "opencode",
716        &harness_status_opencode(bundle, home.as_deref()),
717    ));
718    summary.push_str(&format_status_line(
719        "cursor",
720        &harness_status_cursor(bundle, home.as_deref()),
721    ));
722    summary.push_str(&format_status_line(
723        "pi",
724        &harness_status_pi(bundle, home.as_deref()),
725    ));
726    eprint!("{summary}");
727    anyhow::bail!("no shareable sessions");
728}
729
730/// Cross-platform `$HOME` lookup matching the providers' internal helpers.
731/// Returns `None` only when neither `$HOME` nor `$USERPROFILE` is set.
732fn home_dir() -> Option<std::path::PathBuf> {
733    std::env::var_os("HOME")
734        .or_else(|| std::env::var_os("USERPROFILE"))
735        .map(std::path::PathBuf::from)
736}
737
738/// Human-readable status of a harness's on-disk store: either the (possibly
739/// home-relative) path with a "(0 sessions)" hint, or the path with a
740/// "not found" hint when the directory/database is absent.
741#[derive(Debug, PartialEq, Eq)]
742struct HarnessStatus {
743    /// Display path (tilde-prefixed when under `$HOME`).
744    path: String,
745    /// True when the path exists on disk.
746    exists: bool,
747}
748
749impl HarnessStatus {
750    fn render(&self) -> String {
751        if self.exists {
752            format!("{} (0 sessions)", self.path)
753        } else {
754            format!("{} not found", self.path)
755        }
756    }
757
758    /// Status when the resolver itself failed (e.g. no $HOME).
759    fn unresolved() -> Self {
760        Self {
761            path: "<no home directory>".to_string(),
762            exists: false,
763        }
764    }
765}
766
767/// Format a single status line, padding the harness name so that the path
768/// column lines up across all five rows. The longest name is "opencode" (8).
769fn format_status_line(name: &str, status: &HarnessStatus) -> String {
770    format!("  {:<9} {}\n", format!("{name}:"), status.render())
771}
772
773fn harness_status_claude(bundle: &HarnessBundle, home: Option<&std::path::Path>) -> HarnessStatus {
774    let Some(mgr) = &bundle.claude else {
775        return HarnessStatus::unresolved();
776    };
777    match mgr.resolver().projects_dir() {
778        Ok(p) => HarnessStatus {
779            path: home_relative(&p, home),
780            exists: p.exists(),
781        },
782        Err(_) => HarnessStatus::unresolved(),
783    }
784}
785
786fn harness_status_gemini(bundle: &HarnessBundle, home: Option<&std::path::Path>) -> HarnessStatus {
787    let Some(mgr) = &bundle.gemini else {
788        return HarnessStatus::unresolved();
789    };
790    match mgr.resolver().tmp_dir() {
791        Ok(p) => HarnessStatus {
792            path: home_relative(&p, home),
793            exists: p.exists(),
794        },
795        Err(_) => HarnessStatus::unresolved(),
796    }
797}
798
799fn harness_status_codex(bundle: &HarnessBundle, home: Option<&std::path::Path>) -> HarnessStatus {
800    let Some(mgr) = &bundle.codex else {
801        return HarnessStatus::unresolved();
802    };
803    match mgr.resolver().sessions_root() {
804        Ok(p) => HarnessStatus {
805            path: home_relative(&p, home),
806            exists: p.exists(),
807        },
808        Err(_) => HarnessStatus::unresolved(),
809    }
810}
811
812fn harness_status_opencode(
813    bundle: &HarnessBundle,
814    home: Option<&std::path::Path>,
815) -> HarnessStatus {
816    let Some(mgr) = &bundle.opencode else {
817        return HarnessStatus::unresolved();
818    };
819    match mgr.resolver().db_path() {
820        Ok(p) => HarnessStatus {
821            path: home_relative(&p, home),
822            exists: p.exists(),
823        },
824        Err(_) => HarnessStatus::unresolved(),
825    }
826}
827
828fn harness_status_pi(bundle: &HarnessBundle, home: Option<&std::path::Path>) -> HarnessStatus {
829    let Some(mgr) = &bundle.pi else {
830        return HarnessStatus::unresolved();
831    };
832    let p = mgr.resolver().sessions_dir().to_path_buf();
833    HarnessStatus {
834        path: home_relative(&p, home),
835        exists: p.exists(),
836    }
837}
838
839fn harness_status_cursor(
840    bundle: &HarnessBundle,
841    home: Option<&std::path::Path>,
842) -> HarnessStatus {
843    let Some(mgr) = &bundle.cursor else {
844        return HarnessStatus::unresolved();
845    };
846    match mgr.resolver().db_path() {
847        Ok(p) => HarnessStatus {
848            path: home_relative(&p, home),
849            exists: p.exists(),
850        },
851        Err(_) => HarnessStatus::unresolved(),
852    }
853}
854
855/// Display `path` as `~/relative/part` when it's under `home`, otherwise
856/// return its absolute lossy form. Pure helper — does no filesystem I/O.
857fn home_relative(path: &std::path::Path, home: Option<&std::path::Path>) -> String {
858    if let Some(home) = home
859        && let Ok(rest) = path.strip_prefix(home)
860    {
861        // strip_prefix returns the empty path when path == home; treat that
862        // as plain "~".
863        if rest.as_os_str().is_empty() {
864            return "~".to_string();
865        }
866        return format!("~/{}", rest.display());
867    }
868    path.display().to_string()
869}
870
871fn share_explicit(
872    harness: Harness,
873    session: &str,
874    args: &ShareArgs,
875    auth: crate::cmd_pathbase::AuthMode,
876    base_url: String,
877) -> Result<()> {
878    let project = match (harness.project_keyed(), args.project.as_ref()) {
879        (true, Some(p)) => Some(p.to_string_lossy().into_owned()),
880        (true, None) => anyhow::bail!(
881            "--project required when --harness is {} and --session is set",
882            harness.name()
883        ),
884        (false, _) => None,
885    };
886
887    let derived = derive_session(harness, project.as_deref(), session)?;
888    let summary = format!("{} session {}", harness.name(), derived.cache_id);
889
890    if !args.no_cache {
891        // The cache entry should always reflect what was just uploaded.
892        // `path share` is "ship the current state of this session"; if
893        // the conversation has grown since a prior share, the in-memory
894        // body has the new turns but a stale cache file would not — and
895        // the upload uses the fresh body, not the cache. Always
896        // overwrite so cache and upload agree (use `--no-cache` to skip
897        // the cache write entirely).
898        let path = crate::cmd_cache::write_cached(&derived.cache_id, &derived.doc, true)?;
899        eprintln!(
900            "Cached {} session → {} ({})",
901            harness.name(),
902            derived.cache_id,
903            path.display()
904        );
905    }
906
907    let body = derived.doc.to_json()?;
908    let upload = crate::cmd_export::PathbaseUploadArgs {
909        url: args.url.clone(),
910        anon: args.anon,
911        repo: args.repo.clone(),
912        name: args.name.clone(),
913        public: args.public,
914    };
915    crate::cmd_export::run_pathbase_inner(auth, base_url, upload, &body, &summary)
916}
917
918/// Build the TSV line fed to the picker. Three hidden parser-only
919/// columns lead the row (harness key, project/cwd, session id); a
920/// fourth column carries the pre-formatted display string from
921/// `fuzzy::render_row`; a fifth carries the raw title so
922/// `parse_picker_row` can recover it without reparsing the display.
923///
924/// The display column is space-padded rather than tab-separated so the
925/// columns line up consistently across pickers — terminal tab stops
926/// produce ugly variable gaps in both fzf and skim.
927fn format_picker_row(row: &SessionRow) -> String {
928    let key = row
929        .project
930        .clone()
931        .or_else(|| row.cwd.clone())
932        .unwrap_or_default();
933    let scope = if row.matches_cwd { "·" } else { " " };
934    let leading = format!("{scope} {}", row.harness.symbol());
935    let display = render_row(
936        Some(&leading),
937        row.last_activity,
938        &count(row.message_count, "msgs"),
939        Some(&project_short(&key)),
940        &row.title,
941    );
942    let title = clean_for_picker_display(&row.title);
943    format!(
944        "{}\t{}\t{}\t{}\t{}",
945        row.harness.name(),
946        tab_safe(&key),
947        tab_safe(&row.session_id),
948        display,
949        tab_safe(&title),
950    )
951}
952
953/// Inverse of [`format_picker_row`] — pulls (harness, key, session,
954/// title) back out of the line the picker returned. Returns `None` if
955/// the line is malformed.
956fn parse_picker_row(line: &str) -> Option<(Harness, String, String, String)> {
957    let mut parts = line.split('\t');
958    let h = Harness::parse(parts.next()?)?;
959    let key = parts.next()?.to_string();
960    let session = parts.next()?.to_string();
961    if session.is_empty() {
962        return None;
963    }
964    // Skip the pre-formatted display column (col 4) to reach the raw
965    // title at col 5.
966    let title = parts.nth(1).unwrap_or("").to_string();
967    Some((h, key, session, title))
968}
969
970use crate::fuzzy::{clean_for_picker_display, count, project_short, render_row, tab_safe};
971
972fn derive_session(
973    harness: Harness,
974    project: Option<&str>,
975    session: &str,
976) -> Result<crate::cmd_import::DerivedDoc> {
977    match harness {
978        Harness::Claude => {
979            crate::cmd_import::derive_claude_session(project.expect("project_keyed"), session)
980        }
981        Harness::Gemini => crate::cmd_import::derive_gemini_session(
982            project.expect("project_keyed"),
983            session,
984            false,
985        ),
986        Harness::Pi => {
987            crate::cmd_import::derive_pi_session(project.expect("project_keyed"), session, None)
988        }
989        Harness::Codex => crate::cmd_import::derive_codex_session(session),
990        Harness::Opencode => crate::cmd_import::derive_opencode_session(session, false),
991        Harness::Cursor => crate::cmd_import::derive_cursor_session(session),
992    }
993}
994
995#[cfg(test)]
996mod tests {
997    use super::*;
998
999    #[test]
1000    fn harness_name_and_symbol_are_distinct() {
1001        let all = [
1002            Harness::Claude,
1003            Harness::Gemini,
1004            Harness::Codex,
1005            Harness::Opencode,
1006            Harness::Cursor,
1007            Harness::Pi,
1008        ];
1009        let names: Vec<&str> = all.iter().map(|h| h.name()).collect();
1010        let symbols: Vec<&str> = all.iter().map(|h| h.symbol()).collect();
1011        assert_eq!(names.len(), 6);
1012        assert_eq!(
1013            names.iter().collect::<std::collections::HashSet<_>>().len(),
1014            6,
1015            "names must be unique"
1016        );
1017        assert_eq!(
1018            symbols
1019                .iter()
1020                .collect::<std::collections::HashSet<_>>()
1021                .len(),
1022            6,
1023            "symbols must be unique"
1024        );
1025    }
1026
1027    #[test]
1028    fn harness_project_keyed_matches_design() {
1029        assert!(Harness::Claude.project_keyed());
1030        assert!(Harness::Gemini.project_keyed());
1031        assert!(Harness::Pi.project_keyed());
1032        assert!(!Harness::Codex.project_keyed());
1033        assert!(!Harness::Opencode.project_keyed());
1034        assert!(!Harness::Cursor.project_keyed());
1035    }
1036
1037    #[test]
1038    fn harness_from_arg_roundtrips() {
1039        for (arg, harness) in [
1040            (HarnessArg::Claude, Harness::Claude),
1041            (HarnessArg::Gemini, Harness::Gemini),
1042            (HarnessArg::Codex, Harness::Codex),
1043            (HarnessArg::Opencode, Harness::Opencode),
1044            (HarnessArg::Cursor, Harness::Cursor),
1045            (HarnessArg::Pi, Harness::Pi),
1046        ] {
1047            assert_eq!(Harness::from_arg(arg), harness);
1048        }
1049    }
1050
1051    use std::path::Path;
1052    use tempfile::TempDir;
1053
1054    fn write_claude_session(claude_dir: &Path, project_slug: &str, session: &str, prompt: &str) {
1055        let project_dir = claude_dir.join("projects").join(project_slug);
1056        std::fs::create_dir_all(&project_dir).unwrap();
1057        let user = format!(
1058            r#"{{"type":"user","uuid":"u-{session}","timestamp":"2024-01-02T00:00:00Z","cwd":"/test/project","message":{{"role":"user","content":"{prompt}"}}}}"#
1059        );
1060        let asst = format!(
1061            r#"{{"type":"assistant","uuid":"a-{session}","timestamp":"2024-01-02T00:00:01Z","message":{{"role":"assistant","content":"hi"}}}}"#
1062        );
1063        std::fs::write(
1064            project_dir.join(format!("{session}.jsonl")),
1065            format!("{user}\n{asst}\n"),
1066        )
1067        .unwrap();
1068    }
1069
1070    fn claude_only_bundle(home: &Path) -> HarnessBundle {
1071        let claude_dir = home.join(".claude");
1072        std::fs::create_dir_all(&claude_dir).unwrap();
1073        let resolver = toolpath_claude::PathResolver::new().with_claude_dir(&claude_dir);
1074        HarnessBundle {
1075            claude: Some(toolpath_claude::ClaudeConvo::with_resolver(resolver)),
1076            ..Default::default()
1077        }
1078    }
1079
1080    #[test]
1081    fn gather_sessions_includes_claude_rows_for_a_project() {
1082        let temp = TempDir::new().unwrap();
1083        write_claude_session(
1084            &temp.path().join(".claude"),
1085            "-test-project",
1086            "abc-session-one",
1087            "Add a feature",
1088        );
1089        let bundle = claude_only_bundle(temp.path());
1090        let cwd = Path::new("/test/project");
1091        let rows = gather_sessions(&bundle, cwd, None, None);
1092
1093        assert_eq!(rows.len(), 1);
1094        assert_eq!(rows[0].harness, Harness::Claude);
1095        assert_eq!(rows[0].session_id, "abc-session-one");
1096        assert_eq!(rows[0].project.as_deref(), Some("/test/project"));
1097        assert!(rows[0].matches_cwd, "cwd should match the project path");
1098    }
1099
1100    #[test]
1101    fn gather_sessions_marks_non_matching_project_rows() {
1102        let temp = TempDir::new().unwrap();
1103        write_claude_session(
1104            &temp.path().join(".claude"),
1105            "-test-project",
1106            "abc-session-one",
1107            "Add a feature",
1108        );
1109        let bundle = claude_only_bundle(temp.path());
1110        let cwd = Path::new("/some/other/place");
1111        let rows = gather_sessions(&bundle, cwd, None, None);
1112
1113        assert_eq!(rows.len(), 1);
1114        assert!(!rows[0].matches_cwd);
1115    }
1116
1117    #[test]
1118    fn gather_sessions_skips_harness_with_no_home_dir() {
1119        // Empty bundle => no rows, no panic.
1120        let bundle = HarnessBundle::default();
1121        let rows = gather_sessions(&bundle, Path::new("/anywhere"), None, None);
1122        assert!(rows.is_empty());
1123    }
1124
1125    #[test]
1126    fn gather_sessions_filters_by_harness() {
1127        let temp = TempDir::new().unwrap();
1128        write_claude_session(
1129            &temp.path().join(".claude"),
1130            "-test-project",
1131            "abc-session-one",
1132            "hi",
1133        );
1134        let bundle = claude_only_bundle(temp.path());
1135        let cwd = Path::new("/test/project");
1136        let rows = gather_sessions(&bundle, cwd, Some(Harness::Codex), None);
1137        assert!(rows.is_empty(), "filter to codex must drop claude rows");
1138    }
1139
1140    fn codex_only_bundle(home: &Path) -> HarnessBundle {
1141        let codex_dir = home.join(".codex");
1142        std::fs::create_dir_all(&codex_dir).unwrap();
1143        let resolver = toolpath_codex::PathResolver::new().with_codex_dir(&codex_dir);
1144        HarnessBundle {
1145            codex: Some(toolpath_codex::CodexConvo::with_resolver(resolver)),
1146            ..Default::default()
1147        }
1148    }
1149
1150    fn write_codex_session(codex_dir: &Path, id: &str, cwd: &str) {
1151        // Date-bucketed layout: ~/.codex/sessions/YYYY/MM/DD/rollout-*-<id>.jsonl
1152        let dir = codex_dir.join("sessions/2026/05/07");
1153        std::fs::create_dir_all(&dir).unwrap();
1154        let file = dir.join(format!("rollout-2026-05-07T00-00-00-{id}.jsonl"));
1155        let meta = format!(
1156            r#"{{"timestamp":"2026-05-07T00:00:00Z","type":"session_meta","payload":{{"id":"{id}","timestamp":"2026-05-07T00:00:00Z","cwd":"{cwd}","originator":"codex-tui","cli_version":"test","source":"cli","model_provider":"openai"}}}}"#
1157        );
1158        let user = r#"{"timestamp":"2026-05-07T00:00:01Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}}"#;
1159        std::fs::write(file, format!("{meta}\n{user}\n")).unwrap();
1160    }
1161
1162    #[test]
1163    fn gather_sessions_includes_codex_rows_with_cwd_match() {
1164        let temp = TempDir::new().unwrap();
1165        write_codex_session(
1166            &temp.path().join(".codex"),
1167            "00000000-0000-0000-0000-0000000000aa",
1168            "/work/proj",
1169        );
1170        let bundle = codex_only_bundle(temp.path());
1171        let rows = gather_sessions(&bundle, Path::new("/work/proj"), None, None);
1172        assert_eq!(rows.len(), 1);
1173        assert_eq!(rows[0].harness, Harness::Codex);
1174        assert_eq!(rows[0].cwd.as_deref(), Some("/work/proj"));
1175        assert!(rows[0].matches_cwd);
1176    }
1177
1178    #[test]
1179    fn gather_sessions_ranks_cwd_matches_first() {
1180        // Two claude sessions: one in cwd (older), one elsewhere (newer).
1181        // Despite the elsewhere row being newer, the cwd-match must come first.
1182        let temp = TempDir::new().unwrap();
1183        let claude_dir = temp.path().join(".claude");
1184        write_claude_session(&claude_dir, "-cwd-project", "in-cwd-session", "hi");
1185        // Bump activity on the not-in-cwd session by writing a later timestamp.
1186        let not_dir = claude_dir.join("projects").join("-other-project");
1187        std::fs::create_dir_all(&not_dir).unwrap();
1188        std::fs::write(
1189            not_dir.join("not-in-cwd-session.jsonl"),
1190            r#"{"type":"user","uuid":"u-x","timestamp":"2030-01-01T00:00:00Z","cwd":"/other/project","message":{"role":"user","content":"later"}}"#.to_string()
1191                + "\n",
1192        )
1193        .unwrap();
1194        let bundle = claude_only_bundle(temp.path());
1195        let rows = gather_sessions(&bundle, Path::new("/cwd/project"), None, None);
1196
1197        assert_eq!(rows.len(), 2);
1198        assert_eq!(rows[0].session_id, "in-cwd-session");
1199        assert!(rows[0].matches_cwd);
1200        assert!(!rows[1].matches_cwd);
1201    }
1202
1203    #[test]
1204    #[cfg(unix)]
1205    fn paths_match_canonicalizes_through_symlink() {
1206        // `paths_match` is the function that produces `SessionRow.matches_cwd`
1207        // (collect_* all delegate to it). Without canonicalization, a user who
1208        // navigated to a project via a symlink would see their cwd-row sink
1209        // in the picker because the symlink path string ≠ the project path
1210        // string. Verify both arguments are canonicalized.
1211        //
1212        // Note: we test `paths_match` directly rather than going through
1213        // `gather_sessions` because Claude's project-dir slug encoding is
1214        // lossy (sanitize_project_path: '/', '_', '.' → '-'; unsanitize: only
1215        // '-' → '/'). On macOS, tempdir paths contain '.' and end up under
1216        // /private/var/..., so the unsanitized slug never round-trips back to
1217        // the real on-disk path. This direct test covers the canonicalization
1218        // bug regardless of platform-specific tempdir layouts.
1219        let temp = TempDir::new().unwrap();
1220        let real_project = temp.path().join("real-project");
1221        std::fs::create_dir_all(&real_project).unwrap();
1222        let symlink_path = temp.path().join("symlink-to-project");
1223        std::os::unix::fs::symlink(&real_project, &symlink_path).unwrap();
1224
1225        // Sanity-check the setup: the symlink and its target are different
1226        // string-paths but resolve to the same canonical path.
1227        assert_ne!(real_project, symlink_path);
1228        assert_eq!(
1229            std::fs::canonicalize(&real_project).unwrap(),
1230            std::fs::canonicalize(&symlink_path).unwrap(),
1231        );
1232
1233        // The actual property under test.
1234        assert!(
1235            paths_match(&real_project, &symlink_path),
1236            "paths_match must canonicalize both sides so symlink == target"
1237        );
1238        // And symmetric.
1239        assert!(
1240            paths_match(&symlink_path, &real_project),
1241            "paths_match must be symmetric across the symlink"
1242        );
1243    }
1244
1245    #[test]
1246    fn parse_picker_row_roundtrips_keyed() {
1247        let row = SessionRow {
1248            harness: Harness::Claude,
1249            project: Some("/tmp/proj".to_string()),
1250            cwd: None,
1251            session_id: "sess-abc".to_string(),
1252            title: "Hello\tworld".to_string(),
1253            last_activity: None,
1254            message_count: 3,
1255            matches_cwd: true,
1256        };
1257        let line = format_picker_row(&row);
1258        let (harness, key, session, title) = parse_picker_row(&line).unwrap();
1259        assert_eq!(harness, Harness::Claude);
1260        assert_eq!(key, "/tmp/proj");
1261        assert_eq!(session, "sess-abc");
1262        // tab_safe replaces the tab with a space, but the title content
1263        // otherwise round-trips.
1264        assert_eq!(title, "Hello world");
1265    }
1266
1267    #[test]
1268    fn parse_picker_row_roundtrips_session_keyed() {
1269        let row = SessionRow {
1270            harness: Harness::Codex,
1271            project: None,
1272            cwd: Some("/work/proj".to_string()),
1273            session_id: "0190abcd".to_string(),
1274            title: "(no prompt)".to_string(),
1275            last_activity: None,
1276            message_count: 0,
1277            matches_cwd: false,
1278        };
1279        let line = format_picker_row(&row);
1280        let (harness, key, session, title) = parse_picker_row(&line).unwrap();
1281        assert_eq!(harness, Harness::Codex);
1282        assert_eq!(key, "/work/proj"); // codex has no project; cwd carried as the keyed slot
1283        assert_eq!(session, "0190abcd");
1284        assert_eq!(title, "(no prompt)");
1285    }
1286
1287    #[test]
1288    fn parse_picker_row_carries_title_with_unicode() {
1289        let row = SessionRow {
1290            harness: Harness::Gemini,
1291            project: Some("/work/proj".to_string()),
1292            cwd: None,
1293            session_id: "11111111-2222-3333-4444-555555555555".to_string(),
1294            title: "Add the share command — finally".to_string(),
1295            last_activity: None,
1296            message_count: 42,
1297            matches_cwd: true,
1298        };
1299        let line = format_picker_row(&row);
1300        let (_, _, _, title) = parse_picker_row(&line).unwrap();
1301        assert_eq!(title, "Add the share command — finally");
1302    }
1303
1304    #[test]
1305    fn home_relative_strips_home_prefix() {
1306        let home = Path::new("/Users/alex");
1307        assert_eq!(
1308            home_relative(Path::new("/Users/alex/.claude/projects"), Some(home)),
1309            "~/.claude/projects"
1310        );
1311    }
1312
1313    #[test]
1314    fn home_relative_returns_tilde_for_home_itself() {
1315        let home = Path::new("/Users/alex");
1316        assert_eq!(home_relative(home, Some(home)), "~");
1317    }
1318
1319    #[test]
1320    fn home_relative_passes_through_paths_outside_home() {
1321        let home = Path::new("/Users/alex");
1322        assert_eq!(
1323            home_relative(Path::new("/tmp/elsewhere"), Some(home)),
1324            "/tmp/elsewhere"
1325        );
1326    }
1327
1328    #[test]
1329    fn home_relative_passes_through_when_no_home() {
1330        assert_eq!(home_relative(Path::new("/foo/bar"), None), "/foo/bar");
1331    }
1332
1333    #[test]
1334    fn harness_status_renders_existing_path_with_zero_sessions() {
1335        let s = HarnessStatus {
1336            path: "~/.claude/projects".to_string(),
1337            exists: true,
1338        };
1339        assert_eq!(s.render(), "~/.claude/projects (0 sessions)");
1340    }
1341
1342    #[test]
1343    fn harness_status_renders_missing_path_as_not_found() {
1344        let s = HarnessStatus {
1345            path: "~/.gemini/tmp".to_string(),
1346            exists: false,
1347        };
1348        assert_eq!(s.render(), "~/.gemini/tmp not found");
1349    }
1350
1351    #[test]
1352    fn format_status_line_pads_for_alignment() {
1353        let s = HarnessStatus {
1354            path: "~/.codex/sessions".to_string(),
1355            exists: true,
1356        };
1357        // "claude:" (7) needs 2 trailing spaces; "opencode:" (9) needs 0;
1358        // "pi:" (3) needs 6. The visible-path column should always start at
1359        // the same offset.
1360        let claude_line = format_status_line("claude", &s);
1361        let opencode_line = format_status_line("opencode", &s);
1362        let pi_line = format_status_line("pi", &s);
1363        let offset = |line: &str| line.find('~').unwrap();
1364        assert_eq!(offset(&claude_line), offset(&opencode_line));
1365        assert_eq!(offset(&claude_line), offset(&pi_line));
1366    }
1367
1368    #[test]
1369    fn harness_status_for_missing_claude_dir_reports_not_found() {
1370        // Bundle whose claude resolver points at a directory that doesn't
1371        // exist on disk; the status should still resolve a path and report
1372        // it as missing rather than going through the `unresolved` branch.
1373        let temp = TempDir::new().unwrap();
1374        let claude_dir = temp.path().join(".claude"); // never created
1375        let resolver = toolpath_claude::PathResolver::new().with_claude_dir(&claude_dir);
1376        let bundle = HarnessBundle {
1377            claude: Some(toolpath_claude::ClaudeConvo::with_resolver(resolver)),
1378            ..Default::default()
1379        };
1380        let status = harness_status_claude(&bundle, None);
1381        assert!(!status.exists, "missing dir must report exists=false");
1382        assert!(
1383            status.path.contains("projects"),
1384            "path must include the projects subdir (got {:?})",
1385            status.path
1386        );
1387    }
1388
1389    #[test]
1390    fn harness_status_for_present_claude_dir_reports_existence() {
1391        let temp = TempDir::new().unwrap();
1392        let claude_dir = temp.path().join(".claude");
1393        std::fs::create_dir_all(claude_dir.join("projects")).unwrap();
1394        let resolver = toolpath_claude::PathResolver::new().with_claude_dir(&claude_dir);
1395        let bundle = HarnessBundle {
1396            claude: Some(toolpath_claude::ClaudeConvo::with_resolver(resolver)),
1397            ..Default::default()
1398        };
1399        let status = harness_status_claude(&bundle, None);
1400        assert!(status.exists);
1401    }
1402
1403    #[test]
1404    fn harness_status_for_empty_bundle_is_unresolved() {
1405        let bundle = HarnessBundle::default();
1406        // Every harness slot is None, so each status hits the unresolved branch.
1407        for status in [
1408            harness_status_claude(&bundle, None),
1409            harness_status_gemini(&bundle, None),
1410            harness_status_codex(&bundle, None),
1411            harness_status_opencode(&bundle, None),
1412            harness_status_pi(&bundle, None),
1413        ] {
1414            assert_eq!(status, HarnessStatus::unresolved());
1415            assert!(!status.exists);
1416        }
1417    }
1418}