diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 9232d28..1a04309 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -429,6 +429,10 @@ struct SessionDetailView: View { approval: approval, questions: questions, availableHeight: availableHeight) } else { ApprovalCardView(approval: approval, availableHeight: availableHeight) + // So a host_exec approval's working-directory line abbreviates the "cd" + // target the same way the settled transcript card does. + .environment(\.transcriptWorktreeRoot, + TranscriptProjection.worktreeRoot(in: store.openEvents[...])) } } else if store.openApprovalLoading { // Opened from a Live Activity / notification for a session blocked on approval, but @@ -665,6 +669,13 @@ struct TranscriptList: View { projectionCache.items(for: events, showRaw: showRaw, showLockEvents: showLockEvents) } + /// The session's worktree root — its `sessionStarted` cwd — read straight from the events (an + /// early-returning scan) and injected into the transcript so a host card's working-directory + /// line can abbreviate an absolute "cd" path to a short "/…" anchor. + private var worktreeRoot: String? { + TranscriptProjection.worktreeRoot(in: events[...]) + } + /// Kick off (or restart) the off-main Markdown pre-warm for the visible messages. `MarkdownText` /// parses every prose line with `AttributedString(markdown:)` during the eager first layout — a /// cost that lands on the main thread right as the session is pushed and the tab bar slides @@ -757,6 +768,7 @@ struct TranscriptList: View { // changes as its streaming deltas coalesce — nudging the viewport mid-stream. Color.clear.frame(height: 1).id(Self.bottomAnchorID) } + .environment(\.transcriptWorktreeRoot, worktreeRoot) .padding() // Cap the transcript to a readable measure on a wide iPad so lines don't run // edge-to-edge on a 13-inch screen; a no-op at phone width. diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/HostCommandSummary.swift b/NucleicRemote/NucleicRemote/Views/Transcript/HostCommandSummary.swift index a4f51a1..49596b9 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/HostCommandSummary.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/HostCommandSummary.swift @@ -454,6 +454,72 @@ enum HostCommandSummary { if raw.contains("git rev-parse --show-toplevel") { return "the repository root" } return raw } + + // MARK: - Path abbreviation + + /// Abbreviates an absolute filesystem *directory* path for display in a host card, anchoring it + /// at the nearest meaningful directory so a "cd /Users/me/…" reads with context but sheds the + /// noisy base above it. The mobile mirror of the desktop's `HeuristicSummary.abbreviatePath` + /// (NucleicCore) — the remote app links only `NucleicProtocol`, so it's duplicated here: + /// - a path inside a **worktree** (a sibling under the same base as `worktree`, the session's + /// own worktree root) trims to "/", dropping the ".nucleic/worktrees/" + /// plumbing above it; + /// - otherwise a path inside the **project** (`project`, the repo root) trims to + /// "/", so "/Users/me/projects/proj1/subdir" → "proj1/subdir"; + /// - otherwise a path under the user's home shortens to "~/"; + /// - anything else (or an already-relative path) is returned unchanged. + /// + /// Unlike the desktop, no symlink-canonicalization is done: the remote only ever sees *host* + /// paths over the wire, with no local filesystem to resolve them against. + static func abbreviatePath(_ path: String, worktree: String?, project: String?) -> String { + guard path.hasPrefix("/") else { return path } + + // A worktree lives at "/"; anchoring at that parent base catches the session's + // own worktree *and* any sibling one, keeping "/". + if let worktree, !worktree.isEmpty, + let rel = pathComponent(path, under: (worktree as NSString).deletingLastPathComponent) { + return rel + } + // A path inside the repo keeps the project's own folder name as its anchor. + if let project, !project.isEmpty, + let rel = pathComponent(path, under: (project as NSString).deletingLastPathComponent) { + return rel + } + // Anything else under the user's home shortens to "~". + if let home = homePrefix(worktree ?? project), let rel = pathComponent(path, under: home) { + return "~/" + rel + } + return path + } + + /// The repo root inferred from a worktree path — the segment before "/.nucleic/worktrees/…", so + /// ".../proj1/.nucleic/worktrees/amber" → ".../proj1" — used to feed `abbreviatePath` a project + /// anchor the remote doesn't otherwise carry. Nil for a non-default-layout path (a custom + /// `worktreeBase` won't match; the worktree anchor itself still does). + static func repoRoot(forWorktree worktree: String?) -> String? { + guard let worktree, let range = worktree.range(of: "/.nucleic/worktrees/") else { return nil } + return String(worktree[worktree.startIndex.. String? { + guard base.hasPrefix("/"), base != "/", !base.isEmpty else { return nil } + let prefix = base.hasSuffix("/") ? base : base + "/" + guard path.hasPrefix(prefix) else { return nil } + let rel = String(path.dropFirst(prefix.count)) + return rel.isEmpty ? nil : rel + } + + /// The user's home as "/Users/" (or "/home/"), read from `anchor`'s first two + /// components. Nil when `anchor` (or nil) doesn't look like a per-user home path. + private static func homePrefix(_ anchor: String?) -> String? { + guard let anchor else { return nil } + let comps = anchor.split(separator: "/", omittingEmptySubsequences: true) + guard comps.count >= 2, comps[0] == "Users" || comps[0] == "home" else { return nil } + return "/\(comps[0])/\(comps[1])" + } } // MARK: - Shell lexing diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/HostExecCard.swift b/NucleicRemote/NucleicRemote/Views/Transcript/HostExecCard.swift index b59726f..9cf96ce 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/HostExecCard.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/HostExecCard.swift @@ -1,6 +1,20 @@ import SwiftUI import NucleicProtocol +private struct TranscriptWorktreeRootKey: EnvironmentKey { + static let defaultValue: String? = nil +} + +extension EnvironmentValues { + /// The session's worktree root (its `sessionStarted` cwd), injected by `SessionDetailView` so a + /// host card's working-directory line can abbreviate an absolute "cd" path to a short + /// "/…" anchor. Nil (the default) leaves the directory verbatim. + var transcriptWorktreeRoot: String? { + get { self[TranscriptWorktreeRootKey.self] } + set { self[TranscriptWorktreeRootKey.self] = newValue } + } +} + /// A `mcp__nucleic__host_exec` tool call in the transcript, rendered as a structured card — the /// mobile echo of the Mac's `HostExecToolCard`. It leads with the deterministic, command-derived /// purpose (the easy-to-read headline), shows the literal `$ command` beneath it, and reveals the @@ -108,6 +122,11 @@ struct HostCommandBreakdown: View { /// The chat card heads its row with the purpose already, so it passes `false`. var showPurpose: Bool = true + /// The session's worktree root, so the working-directory line ("cd" target) abbreviates to a + /// short "/…" anchor instead of a full absolute path. Injected by + /// `SessionDetailView`; nil leaves the directory verbatim. + @Environment(\.transcriptWorktreeRoot) private var worktreeRoot + private let accent = Palette.accent var body: some View { @@ -117,7 +136,7 @@ struct HostCommandBreakdown: View { if summary.isElevated || summary.isDestructive { riskBanner } } if let dir = summary.workingDirectory { - detailRow(icon: "folder", lines: ["in \(dir)"]) + detailRow(icon: "folder", lines: ["in \(HostCommandSummary.abbreviatePath(dir, worktree: worktreeRoot, project: HostCommandSummary.repoRoot(forWorktree: worktreeRoot)))"]) } steps }