Merge nucleic/amber-spruce-koala-cxai into dev
This commit is contained in:
@@ -41,6 +41,10 @@ struct HostExecCard: View {
|
||||
var reasonAudit: HostExecReasonAudit? = nil
|
||||
/// The card's accent — `attention` in a pending approval.
|
||||
var tint: Color? = nil
|
||||
/// The session's project and worktree roots, so the working-directory line abbreviates to a
|
||||
/// short "<worktree name>/…" anchor instead of a full absolute path.
|
||||
var projectRoot: String? = nil
|
||||
var worktreeRoot: String? = nil
|
||||
|
||||
/// Host execution always carries the attention accent: it's never the calm default path.
|
||||
private var accent: Color { tint ?? palette.attention }
|
||||
@@ -61,7 +65,8 @@ struct HostExecCard: View {
|
||||
// purpose and the program/actions/flags, then the exact command beneath as the
|
||||
// ground truth the breakdown is derived from.
|
||||
if let parsed {
|
||||
HostCommandBreakdown(summary: parsed, accent: accent, showPurpose: true)
|
||||
HostCommandBreakdown(summary: parsed, accent: accent, showPurpose: true,
|
||||
projectRoot: projectRoot, worktreeRoot: worktreeRoot)
|
||||
Text("Exact command")
|
||||
.font(.caption2.weight(.semibold)).foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -237,6 +242,10 @@ struct HostExecToolCard: View {
|
||||
/// Invoked when the user taps **Skip** on this still-running host call — abandons the (apparently
|
||||
/// stuck) command so the agent gives up on it. `nil` hides the button; set only while running.
|
||||
var onSkip: (() -> Void)? = nil
|
||||
/// The session's project and worktree roots, so the working-directory line ("cd" target) shown
|
||||
/// on expand abbreviates to a short "<worktree name>/…" anchor instead of a full absolute path.
|
||||
var projectRoot: String? = nil
|
||||
var worktreeRoot: String? = nil
|
||||
|
||||
/// In-chat host calls carry the calm transcript accent (the desktop glyph and "Host" tag do
|
||||
/// the distinguishing); the loud `attention` accent is reserved for the pending approval.
|
||||
@@ -276,7 +285,8 @@ struct HostExecToolCard: View {
|
||||
if expanded {
|
||||
if hasBreakdown, let parsed {
|
||||
Divider().overlay(accent.opacity(0.14))
|
||||
HostCommandBreakdown(summary: parsed, accent: accent, showPurpose: false)
|
||||
HostCommandBreakdown(summary: parsed, accent: accent, showPurpose: false,
|
||||
projectRoot: projectRoot, worktreeRoot: worktreeRoot)
|
||||
.padding(.horizontal, 10).padding(.vertical, 8)
|
||||
}
|
||||
if let output, !output.isEmpty {
|
||||
@@ -395,6 +405,11 @@ struct HostCommandBreakdown: View {
|
||||
/// Whether to render the prominent "Likely purpose" row and any `sudo`/destructive banner.
|
||||
/// The chat card heads its row with the purpose already, so it passes `false`.
|
||||
var showPurpose: Bool = true
|
||||
/// The session's project root and worktree root, used to abbreviate the working-directory line
|
||||
/// ("cd" target) so it reads as "<worktree name>/…" or "<project>/…" instead of a full
|
||||
/// absolute path. Nil (the default) leaves the directory verbatim.
|
||||
var projectRoot: String? = nil
|
||||
var worktreeRoot: String? = nil
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 9) {
|
||||
@@ -403,7 +418,8 @@ 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 \(HeuristicSummary.abbreviatePath(dir, worktree: worktreeRoot, project: projectRoot))"])
|
||||
}
|
||||
steps
|
||||
}
|
||||
|
||||
@@ -1986,7 +1986,9 @@ struct ApprovalBar: View {
|
||||
// to run on the host, pretty-formatted, instead of the raw `{"command": …}` JSON.
|
||||
HostExecCard(
|
||||
command: hostExecCommand, toolName: request.toolName,
|
||||
reason: hostExecReason, reasonAudit: reasonAudit, tint: palette.attention)
|
||||
reason: hostExecReason, reasonAudit: reasonAudit, tint: palette.attention,
|
||||
projectRoot: store.openSession.flatMap { store.project($0.projectID)?.rootPath },
|
||||
worktreeRoot: store.openSession?.worktreePath)
|
||||
.task(id: request.id) {
|
||||
// Cross-check the agent's reason against the command on-device, so a reason
|
||||
// that doesn't match what the command does is flagged before approval. Runs
|
||||
|
||||
@@ -284,7 +284,9 @@ struct TranscriptRow: View, Equatable {
|
||||
environment: HostCommandSummary.hostDisplayEnvironment(workingDirectory: cwd))
|
||||
return HostExecToolCard(
|
||||
command: resolved, output: output, finished: finished, isError: result?.isError == true,
|
||||
onSkip: onSkip)
|
||||
onSkip: onSkip,
|
||||
projectRoot: store.openSession.flatMap { store.project($0.projectID)?.rootPath },
|
||||
worktreeRoot: store.openSession?.worktreePath)
|
||||
}
|
||||
|
||||
/// The pretty card for a *completed* AskUserQuestion whose result records the user's
|
||||
|
||||
@@ -981,6 +981,83 @@ public enum HeuristicSummary {
|
||||
return out
|
||||
}
|
||||
|
||||
/// Abbreviates an absolute filesystem *directory* path for display in a tool card, anchoring
|
||||
/// it at the nearest meaningful directory so it reads with context yet sheds the noisy base
|
||||
/// above it — the rule behind every "cd /Users/me/…" a host card would otherwise print in full:
|
||||
/// - a path inside a **worktree** (a sibling under the same base as `worktree`, the session's
|
||||
/// own worktree root) trims to "<worktree name>/<rest>", so
|
||||
/// "/Users/me/proj/.nucleic/worktrees/amber-koala/Sources" → "amber-koala/Sources": the
|
||||
/// per-session worktree name is the anchor and the ".nucleic/worktrees/" plumbing is dropped;
|
||||
/// - otherwise a path inside the **project** (`project`, the repo root) trims to
|
||||
/// "<project dir>/<rest>", so "/Users/me/projects/proj1/subdir" → "proj1/subdir";
|
||||
/// - otherwise a path under the user's home shortens to "~/<rest>";
|
||||
/// - anything else (a path outside all of these, or one that's already relative) is unchanged.
|
||||
///
|
||||
/// `worktree`/`project` are the same roots the tool rows already carry (the session's
|
||||
/// `worktreePath` and the project's `rootPath`); either may be nil, and a rule is skipped when
|
||||
/// its anchor is missing. The worktree rule is checked first so a worktree path anchors at its
|
||||
/// short name rather than the ".nucleic/worktrees/…" form the project rule would produce.
|
||||
public static func abbreviatePath(_ path: String, worktree: String?, project: String?) -> String {
|
||||
guard path.hasPrefix("/") else { return path }
|
||||
|
||||
// A worktree lives at "<base>/<name>"; anchoring at that parent base catches the session's
|
||||
// own worktree *and* any sibling one, keeping "<name>/<rest>" while shedding the shared
|
||||
// ".nucleic/worktrees/" prefix above it.
|
||||
if let worktree, !worktree.isEmpty {
|
||||
for root in canonicalVariants(worktree) {
|
||||
if let rel = pathComponent(path, under: (root 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 {
|
||||
for root in canonicalVariants(project) {
|
||||
if let rel = pathComponent(path, under: (root as NSString).deletingLastPathComponent) {
|
||||
return rel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Anything else under the user's home shortens to "~". The home prefix is derived from an
|
||||
// anchor (so it matches the *host* home the paths use, even when the app is itself sandboxed
|
||||
// under a different home) rather than the process's own `NSHomeDirectory()`.
|
||||
if let home = homePrefix(worktree ?? project), let rel = pathComponent(path, under: home) {
|
||||
return "~/" + rel
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
/// The portion of `path` strictly inside directory `base` ("/base/a/b" under "/base" → "a/b"),
|
||||
/// or nil when `path` isn't within `base`. `base` must be an absolute directory; the root "/"
|
||||
/// and an empty base are rejected so they can't swallow every path into a bare component.
|
||||
private static func pathComponent(_ path: String, under base: String) -> 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
|
||||
}
|
||||
|
||||
/// A path paired with its symlink-resolved form, deduped — so a prefix match holds whether the
|
||||
/// stored path is the symlinked or the real one (a worktree is reachable both ways).
|
||||
private static func canonicalVariants(_ path: String) -> [String] {
|
||||
let canonical = GitWorktreeManager.canonical(path)
|
||||
return canonical == path ? [path] : [path, canonical]
|
||||
}
|
||||
|
||||
/// The user's home as "/Users/<name>" (or "/home/<name>"), read from `anchor`'s first two
|
||||
/// components rather than the process's `NSHomeDirectory()` — the displayed paths are *host*
|
||||
/// paths, and this app may itself run sandboxed under a different home. 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])"
|
||||
}
|
||||
|
||||
/// The index of the last user message (start of the most recent turn).
|
||||
private static func lastTurnStart(_ events: [AgentEvent]) -> Int {
|
||||
var start = events.startIndex
|
||||
|
||||
@@ -319,6 +319,40 @@ struct IntelligenceTests {
|
||||
#expect(HeuristicSummary.displayPath("/repo/file.swift", relativeTo: nil) == "file.swift")
|
||||
}
|
||||
|
||||
@Test func abbreviatePathAnchorsAtWorktreeProjectOrHome() {
|
||||
let project = "/Users/uname/projects/proj1"
|
||||
let worktree = "/Users/uname/projects/proj1/.nucleic/worktrees/amber-koala"
|
||||
|
||||
// A path inside the session's worktree anchors at the worktree name, dropping the
|
||||
// ".nucleic/worktrees/" plumbing above it.
|
||||
#expect(HeuristicSummary.abbreviatePath(
|
||||
worktree + "/Sources/x.swift", worktree: worktree, project: project)
|
||||
== "amber-koala/Sources/x.swift")
|
||||
// The worktree root itself → just its name.
|
||||
#expect(HeuristicSummary.abbreviatePath(worktree, worktree: worktree, project: project)
|
||||
== "amber-koala")
|
||||
// A *sibling* worktree (same base, different name) anchors at its own name too.
|
||||
#expect(HeuristicSummary.abbreviatePath(
|
||||
"/Users/uname/projects/proj1/.nucleic/worktrees/blue-otter/a",
|
||||
worktree: worktree, project: project) == "blue-otter/a")
|
||||
|
||||
// A path in the project but outside any worktree keeps the project folder as the anchor.
|
||||
#expect(HeuristicSummary.abbreviatePath(
|
||||
project + "/subdir", worktree: worktree, project: project) == "proj1/subdir")
|
||||
#expect(HeuristicSummary.abbreviatePath(
|
||||
"/Users/uname/projects/proj1/subdir", worktree: nil, project: project) == "proj1/subdir")
|
||||
|
||||
// Elsewhere under home → "~/…"; fully outside → verbatim; relative → unchanged.
|
||||
#expect(HeuristicSummary.abbreviatePath(
|
||||
"/Users/uname/Downloads/x", worktree: worktree, project: project) == "~/Downloads/x")
|
||||
#expect(HeuristicSummary.abbreviatePath(
|
||||
"/opt/tools/bin", worktree: worktree, project: project) == "/opt/tools/bin")
|
||||
#expect(HeuristicSummary.abbreviatePath(
|
||||
"the repository root", worktree: worktree, project: project) == "the repository root")
|
||||
// No anchors: an absolute path is left as-is.
|
||||
#expect(HeuristicSummary.abbreviatePath("/x/y", worktree: nil, project: nil) == "/x/y")
|
||||
}
|
||||
|
||||
@Test func aiEligibilityNeverIncludesReadAndRespectsScope() {
|
||||
let reads = [
|
||||
ToolCall(toolCallID: "1", name: "Read", input: ["file_path": "/r/A.swift"]),
|
||||
|
||||
Reference in New Issue
Block a user