diff --git a/packages/coding-agent/src/modes/components/status-line/component.ts b/packages/coding-agent/src/modes/components/status-line/component.ts index bfed035ed..90e3b6bb4 100644 --- a/packages/coding-agent/src/modes/components/status-line/component.ts +++ b/packages/coding-agent/src/modes/components/status-line/component.ts @@ -160,6 +160,29 @@ interface ActiveRepoCache { projectDir: string; activeRepo: ActiveRepoContext | null; effectiveGitCwd: string; + /** Project + worktree dir name when `projectDir` is a linked worktree, else null. */ + worktree: WorktreeContext | null; +} + +interface WorktreeContext { + /** Primary-checkout (project) name shown by the path segment. */ + projectName: string; + /** Worktree directory name — suppressed from the path when it equals the branch. */ + worktreeName: string; +} + +/** + * Project + worktree-dir names when `cwd` is a linked git worktree, else null. + * The project name comes from the shared primary checkout; bare-repo worktrees + * resolve to the shared `foo.git` dir, so a trailing `.git` is stripped. + */ +function resolveWorktreeContext(cwd: string): WorktreeContext | null { + const worktree = git.repo.linkedWorktreeSync(cwd); + if (!worktree) return null; + const base = path.basename(worktree.primaryRoot); + const projectName = base.endsWith(".git") ? base.slice(0, -4) : base; + if (!projectName) return null; + return { projectName, worktreeName: path.basename(worktree.root) }; } /** @@ -312,7 +335,10 @@ export class StatusLineComponent implements Component { const activeRepo = resolveActiveRepoContextSync(projectDir); const effectiveGitCwd = activeRepo?.repoRoot ?? projectDir; - this.#activeRepoCache = { projectDir, activeRepo, effectiveGitCwd }; + // Only collapse the bare-cwd case: a single-direct-child-repo context + // (activeRepo set) renders ` ↳ `, which we leave intact. + const worktree = activeRepo ? null : resolveWorktreeContext(effectiveGitCwd); + this.#activeRepoCache = { projectDir, activeRepo, effectiveGitCwd, worktree }; return this.#activeRepoCache; } @@ -988,7 +1014,7 @@ export class StatusLineComponent implements Component { const projectDir = getProjectDir(); const activeRepoCache = shouldResolveActiveRepo ? this.#resolveActiveRepoCache() - : { projectDir, activeRepo: null, effectiveGitCwd: projectDir }; + : { projectDir, activeRepo: null, effectiveGitCwd: projectDir, worktree: null }; const gitBranch = includeGit || includePr ? this.#getCurrentBranch(activeRepoCache.effectiveGitCwd) : null; const gitStatus = includeGit ? this.#getGitStatus(activeRepoCache.effectiveGitCwd) : null; const gitPr = includePr ? this.#lookupPr(activeRepoCache.effectiveGitCwd) : null; @@ -1015,6 +1041,7 @@ export class StatusLineComponent implements Component { status: gitStatus, pr: gitPr, }, + worktree: activeRepoCache.worktree, usage: this.#cachedUsage, }; } diff --git a/packages/coding-agent/src/modes/components/status-line/segments.ts b/packages/coding-agent/src/modes/components/status-line/segments.ts index c914389f5..3bf9e38c1 100644 --- a/packages/coding-agent/src/modes/components/status-line/segments.ts +++ b/packages/coding-agent/src/modes/components/status-line/segments.ts @@ -20,6 +20,13 @@ function withIcon(icon: string, text: string): string { return icon ? `${icon} ${text}` : text; } +/** Left-truncate a path/label to `maxLen`, prefixing an ellipsis when clipped. */ +function clampPathLength(pwd: string, maxLen: number): string { + if (pwd.length <= maxLen) return pwd; + const ellipsis = "…"; + return `${ellipsis}${pwd.slice(-Math.max(0, maxLen - ellipsis.length))}`; +} + /** * Leading glyph of a thinking-level display string (e.g. "◉ xhigh" → "◉"). * Compact mode promotes this glyph to the model-segment icon so the level @@ -220,12 +227,24 @@ const pathSegment: StatusLineSegment = { id: "path", render(ctx) { const opts = ctx.options.path ?? {}; + const stripPrefix = opts.stripWorkPrefix !== false; + + // Linked git worktree: the on-disk path nests the worktree base, the + // project, and a worktree dir that usually duplicates the branch (already + // shown by the git segment). Collapse to the project name, appending the + // worktree dir only when it diverges from the branch. + if (stripPrefix && ctx.worktree) { + const { projectName, worktreeName } = ctx.worktree; + const label = ctx.git.branch === worktreeName ? projectName : `${projectName}/${worktreeName}`; + const content = withIcon(theme.icon.worktree, clampPathLength(label, opts.maxLength ?? 40)); + return { content: theme.fg("statusLinePath", content), visible: true }; + } const projectDir = ctx.activeRepo?.cwd ?? getProjectDir(); const { scratch, relative } = classifyProjectDir(projectDir); let pwd = projectDir; - if (opts.stripWorkPrefix !== false) { + if (stripPrefix) { if (scratch) { if (relative) pwd = relative; } else { @@ -237,17 +256,12 @@ const pathSegment: StatusLineSegment = { pwd = shortenPath(pwd); } - const maxLen = opts.maxLength ?? 40; - if (pwd.length > maxLen) { - const ellipsis = "…"; - const sliceLen = Math.max(0, maxLen - ellipsis.length); - pwd = `${ellipsis}${pwd.slice(-sliceLen)}`; - } + pwd = clampPathLength(pwd, opts.maxLength ?? 40); if (repoSuffix) { pwd = `${pwd}${repoSuffix}`; } - const showScratchIcon = scratch && opts.stripWorkPrefix !== false; + const showScratchIcon = scratch && stripPrefix; const icon = showScratchIcon ? theme.icon.scratchFolder : theme.icon.folder; const content = withIcon(icon, pwd); return { content: theme.fg("statusLinePath", content), visible: true }; diff --git a/packages/coding-agent/src/modes/components/status-line/types.ts b/packages/coding-agent/src/modes/components/status-line/types.ts index f041445d4..ab8a71b95 100644 --- a/packages/coding-agent/src/modes/components/status-line/types.ts +++ b/packages/coding-agent/src/modes/components/status-line/types.ts @@ -97,6 +97,13 @@ export interface SegmentContext { status: { staged: number; unstaged: number; untracked: number } | null; pr: { number: number; url: string } | null; }; + /** + * Set when the path cwd is a *linked* git worktree, naming the shared + * primary checkout (the project). Lets the path segment collapse the + * base-prefixed `//` path to the project name — + * the worktree/branch is already shown by the git segment. + */ + worktree: { projectName: string; worktreeName: string } | null; usage: { tier?: string; fiveHour?: { percent: number; resetMinutes?: number }; diff --git a/packages/coding-agent/src/modes/theme/theme.ts b/packages/coding-agent/src/modes/theme/theme.ts index 80d0dd9f8..88ebf487c 100644 --- a/packages/coding-agent/src/modes/theme/theme.ts +++ b/packages/coding-agent/src/modes/theme/theme.ts @@ -96,6 +96,7 @@ export type SymbolKey = | "icon.pause" | "icon.loop" | "icon.folder" + | "icon.worktree" | "icon.search" | "icon.scratchFolder" | "icon.file" @@ -299,6 +300,7 @@ const UNICODE_SYMBOLS: SymbolMap = { "icon.pause": "⏸", "icon.loop": "↻", "icon.folder": "📁", + "icon.worktree": "🌳", "icon.search": "🔍", "icon.scratchFolder": "🗑", "icon.file": "📄", @@ -561,6 +563,8 @@ const NERD_SYMBOLS: SymbolMap = { "icon.search": "\uf002", // pick: | alt: "icon.scratchFolder": "\uf014", + // pick: nf-fa-sitemap | alt: nf-cod-list_tree + "icon.worktree": "\uf0e8", // pick:  | alt:   "icon.file": "\uf15b", // pick:  | alt:  ⎇ @@ -808,6 +812,7 @@ const ASCII_SYMBOLS: SymbolMap = { "icon.pause": "||", "icon.loop": "loop", "icon.folder": "[D]", + "icon.worktree": "[wt]", "icon.search": "[/]", "icon.scratchFolder": "[T]", "icon.file": "[F]", @@ -1797,6 +1802,7 @@ export class Theme { pause: this.#symbols["icon.pause"], loop: this.#symbols["icon.loop"], folder: this.#symbols["icon.folder"], + worktree: this.#symbols["icon.worktree"], scratchFolder: this.#symbols["icon.scratchFolder"], file: this.#symbols["icon.file"], git: this.#symbols["icon.git"], diff --git a/packages/coding-agent/src/utils/git.ts b/packages/coding-agent/src/utils/git.ts index 33f875a80..86afc8bc0 100644 --- a/packages/coding-agent/src/utils/git.ts +++ b/packages/coding-agent/src/utils/git.ts @@ -1712,6 +1712,19 @@ export const repo = { return primaryRootFromRepositorySync(repository); }, + /** + * Linked-worktree metadata for `cwd`, or `null` when `cwd` is the primary + * checkout (or outside a repository). `root` is the worktree's own checkout + * root; `primaryRoot` is the shared main checkout that names the project. + * Resolves purely via on-disk `.git`/`commondir` walking — no subprocess — + * so the status line may call it on every render. + */ + linkedWorktreeSync(cwd: string): { root: string; primaryRoot: string } | null { + const repository = resolveRepositorySync(cwd); + if (!repository || !isLinkedWorktree(repository)) return null; + return { root: repository.repoRoot, primaryRoot: primaryRootFromRepositorySync(repository) }; + }, + /** Full GitRepository metadata (sync). */ resolveSync(cwd: string): GitRepository | null { return resolveRepositorySync(cwd);