Merge nucleic/amber-spruce-koala-cxai into dev
This commit is contained in:
@@ -41,6 +41,10 @@ struct HostExecCard: View {
|
|||||||
var reasonAudit: HostExecReasonAudit? = nil
|
var reasonAudit: HostExecReasonAudit? = nil
|
||||||
/// The card's accent — `attention` in a pending approval.
|
/// The card's accent — `attention` in a pending approval.
|
||||||
var tint: Color? = nil
|
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.
|
/// Host execution always carries the attention accent: it's never the calm default path.
|
||||||
private var accent: Color { tint ?? palette.attention }
|
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
|
// purpose and the program/actions/flags, then the exact command beneath as the
|
||||||
// ground truth the breakdown is derived from.
|
// ground truth the breakdown is derived from.
|
||||||
if let parsed {
|
if let parsed {
|
||||||
HostCommandBreakdown(summary: parsed, accent: accent, showPurpose: true)
|
HostCommandBreakdown(summary: parsed, accent: accent, showPurpose: true,
|
||||||
|
projectRoot: projectRoot, worktreeRoot: worktreeRoot)
|
||||||
Text("Exact command")
|
Text("Exact command")
|
||||||
.font(.caption2.weight(.semibold)).foregroundStyle(.secondary)
|
.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
|
/// 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.
|
/// stuck) command so the agent gives up on it. `nil` hides the button; set only while running.
|
||||||
var onSkip: (() -> Void)? = nil
|
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
|
/// 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.
|
/// the distinguishing); the loud `attention` accent is reserved for the pending approval.
|
||||||
@@ -276,7 +285,8 @@ struct HostExecToolCard: View {
|
|||||||
if expanded {
|
if expanded {
|
||||||
if hasBreakdown, let parsed {
|
if hasBreakdown, let parsed {
|
||||||
Divider().overlay(accent.opacity(0.14))
|
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)
|
.padding(.horizontal, 10).padding(.vertical, 8)
|
||||||
}
|
}
|
||||||
if let output, !output.isEmpty {
|
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.
|
/// 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`.
|
/// The chat card heads its row with the purpose already, so it passes `false`.
|
||||||
var showPurpose: Bool = true
|
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 {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 9) {
|
VStack(alignment: .leading, spacing: 9) {
|
||||||
@@ -403,7 +418,8 @@ struct HostCommandBreakdown: View {
|
|||||||
if summary.isElevated || summary.isDestructive { riskBanner }
|
if summary.isElevated || summary.isDestructive { riskBanner }
|
||||||
}
|
}
|
||||||
if let dir = summary.workingDirectory {
|
if let dir = summary.workingDirectory {
|
||||||
detailRow(icon: "folder", lines: ["in \(dir)"])
|
detailRow(icon: "folder",
|
||||||
|
lines: ["in \(HeuristicSummary.abbreviatePath(dir, worktree: worktreeRoot, project: projectRoot))"])
|
||||||
}
|
}
|
||||||
steps
|
steps
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1986,7 +1986,9 @@ struct ApprovalBar: View {
|
|||||||
// to run on the host, pretty-formatted, instead of the raw `{"command": …}` JSON.
|
// to run on the host, pretty-formatted, instead of the raw `{"command": …}` JSON.
|
||||||
HostExecCard(
|
HostExecCard(
|
||||||
command: hostExecCommand, toolName: request.toolName,
|
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) {
|
.task(id: request.id) {
|
||||||
// Cross-check the agent's reason against the command on-device, so a reason
|
// 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
|
// 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))
|
environment: HostCommandSummary.hostDisplayEnvironment(workingDirectory: cwd))
|
||||||
return HostExecToolCard(
|
return HostExecToolCard(
|
||||||
command: resolved, output: output, finished: finished, isError: result?.isError == true,
|
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
|
/// The pretty card for a *completed* AskUserQuestion whose result records the user's
|
||||||
|
|||||||
@@ -981,6 +981,83 @@ public enum HeuristicSummary {
|
|||||||
return out
|
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).
|
/// The index of the last user message (start of the most recent turn).
|
||||||
private static func lastTurnStart(_ events: [AgentEvent]) -> Int {
|
private static func lastTurnStart(_ events: [AgentEvent]) -> Int {
|
||||||
var start = events.startIndex
|
var start = events.startIndex
|
||||||
|
|||||||
@@ -319,6 +319,40 @@ struct IntelligenceTests {
|
|||||||
#expect(HeuristicSummary.displayPath("/repo/file.swift", relativeTo: nil) == "file.swift")
|
#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() {
|
@Test func aiEligibilityNeverIncludesReadAndRespectsScope() {
|
||||||
let reads = [
|
let reads = [
|
||||||
ToolCall(toolCallID: "1", name: "Read", input: ["file_path": "/r/A.swift"]),
|
ToolCall(toolCallID: "1", name: "Read", input: ["file_path": "/r/A.swift"]),
|
||||||
|
|||||||
Reference in New Issue
Block a user