703 lines
36 KiB
Swift
703 lines
36 KiB
Swift
import Foundation
|
|
import SwiftUI
|
|
import NucleicCore
|
|
|
|
extension HostCommandSummary {
|
|
/// The environment used to resolve `$PWD`-style substitutions when a host card *displays* a
|
|
/// `host_exec` command — shared by the approval card (``HostExecCard``) and its settled in-chat
|
|
/// form (``HostExecToolCard``) so both show the same resolved command. This app runs on the
|
|
/// macOS host, so its own process environment *is* the host's; `PWD` is pinned to the command's
|
|
/// working directory (where a host_exec command actually runs) when one is known, so `$PWD`
|
|
/// resolves there rather than to wherever the app happened to be launched.
|
|
static func hostDisplayEnvironment(workingDirectory: String?) -> [String: String] {
|
|
var env = ProcessInfo.processInfo.environment
|
|
if let workingDirectory, !workingDirectory.isEmpty { env["PWD"] = workingDirectory }
|
|
return env
|
|
}
|
|
}
|
|
|
|
/// Renders a `mcp__nucleic__host_exec` approval — the agent asking to run a shell command on
|
|
/// the macOS **host**, OUTSIDE the sandbox container — as a structured card instead of the raw
|
|
/// `{"command": …}` JSON blob the generic detail box would show.
|
|
///
|
|
/// Two things matter when granting this gate, so the card surfaces both: the header names the
|
|
/// tool being called (a monospaced badge of the fully-qualified tool name) and flags that it
|
|
/// escapes the sandbox, and the command itself is shown in a pretty, bounded monospaced block —
|
|
/// wrapping to fit and scrolling only once it's genuinely tall — so the user can read exactly
|
|
/// what will run on their machine before allowing it.
|
|
struct HostExecCard: View {
|
|
@Environment(\.appPalette) private var palette
|
|
/// The shell command the agent wants to run on the host.
|
|
let command: String
|
|
/// The fully-qualified tool being invoked (e.g. `mcp__nucleic__host_exec`), shown verbatim
|
|
/// so it's unambiguous which tool the approval gates.
|
|
var toolName: String
|
|
/// The agent's stated justification for escaping the sandbox, when present. Shown as a
|
|
/// *claim* — the command breakdown and the trust badge are what the user checks it against.
|
|
var reason: String? = nil
|
|
/// The on-device model's cross-check of `reason` against `command`, when a reason is shown.
|
|
/// Nil means the check is still in flight (badge reads "Checking…"). Produced by a separate
|
|
/// local model from the raw text, so a deceptive agent can't fake a trustworthy badge.
|
|
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 }
|
|
|
|
/// Past this height the command block scrolls instead of growing, so a long command can't
|
|
/// push the Allow/Deny buttons off-screen. Mirrors `ApprovalDetailBox`.
|
|
private let maxHeight: CGFloat = 220
|
|
@State private var contentHeight: CGFloat = 0
|
|
|
|
/// The deterministic, command-derived breakdown (program, actions, flags, inferred purpose),
|
|
/// parsed from the command string alone — never from anything the agent claimed it would do.
|
|
private var parsed: HostCommandSummary.Summary? { HostCommandSummary.summary(for: command) }
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
header
|
|
// What this looks like, laid out so the user can actually check it: the inferred
|
|
// 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,
|
|
projectRoot: projectRoot, worktreeRoot: worktreeRoot)
|
|
Text("Exact command")
|
|
.font(.caption2.weight(.semibold)).foregroundStyle(.secondary)
|
|
}
|
|
commandBlock
|
|
if let reason, !reason.isEmpty {
|
|
reasonBlock(reason)
|
|
}
|
|
}
|
|
.padding(.horizontal, 12).padding(.vertical, 10)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(accent.opacity(0.06))
|
|
.overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(accent.opacity(0.16), lineWidth: 1))
|
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
}
|
|
|
|
/// Names what's being gated: a host-machine glyph, a plain-language line, and the
|
|
/// fully-qualified tool name as a monospaced badge so the exact tool is never in doubt.
|
|
private var header: some View {
|
|
HStack(alignment: .firstTextBaseline, spacing: 9) {
|
|
Image(systemName: "desktopcomputer")
|
|
.font(.callout).foregroundStyle(accent).frame(width: 16)
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("Run on host machine")
|
|
.font(.callout.weight(.semibold))
|
|
.foregroundStyle(AppTheme.primaryText)
|
|
HStack(spacing: 6) {
|
|
Text(toolName)
|
|
.font(.system(.caption2, design: .monospaced))
|
|
.foregroundStyle(accent)
|
|
.padding(.horizontal, 6).padding(.vertical, 2)
|
|
.background(accent.opacity(0.12), in: Capsule())
|
|
.textSelection(.enabled)
|
|
Text("outside the sandbox")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
}
|
|
|
|
/// The command itself, pretty-printed in a bounded monospaced block. The text wraps and the
|
|
/// box sizes to its content — a short command sits in a snug block — and only begins
|
|
/// scrolling once the content exceeds `maxHeight`.
|
|
private var commandBlock: some View {
|
|
ScrollView(.vertical) {
|
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
|
Text("$")
|
|
.font(.system(.callout, design: .monospaced))
|
|
.foregroundStyle(accent.opacity(0.7))
|
|
Text(command)
|
|
.font(.system(.callout, design: .monospaced))
|
|
.foregroundStyle(AppTheme.primaryText)
|
|
.textSelection(.enabled)
|
|
.multilineTextAlignment(.leading)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.padding(10)
|
|
.background(GeometryReader { geo in
|
|
Color.clear.preference(key: HostExecHeightKey.self, value: geo.size.height)
|
|
})
|
|
}
|
|
// Until the first measurement lands, show the content at full height so it never
|
|
// flashes as a zero-height sliver.
|
|
.frame(height: contentHeight == 0 ? nil : min(contentHeight, maxHeight))
|
|
.onPreferenceChange(HostExecHeightKey.self) { contentHeight = $0 }
|
|
.background(accent.opacity(0.05), in: .rect(cornerRadius: 6))
|
|
.overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(accent.opacity(0.12), lineWidth: 1))
|
|
}
|
|
|
|
/// The agent's stated reason for needing the host, with an on-device trust badge that
|
|
/// cross-checks it against the command. The badge is the user's guard against a deceptive
|
|
/// reason: it's computed by a separate local model from the raw command + reason, so the
|
|
/// agent can't mint its own "trusted" mark. While the check is in flight the badge reads
|
|
/// "Checking…"; a model-written rationale sits beneath when the verdict warrants it.
|
|
private func reasonBlock(_ reason: String) -> some View {
|
|
VStack(alignment: .leading, spacing: 5) {
|
|
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
|
Text("Agent's reason")
|
|
.font(.caption2.weight(.semibold)).foregroundStyle(.secondary)
|
|
Spacer(minLength: 8)
|
|
trustBadge
|
|
}
|
|
Text(reason)
|
|
.font(.callout)
|
|
.foregroundStyle(AppTheme.primaryText)
|
|
.textSelection(.enabled)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(8)
|
|
.background(reasonTint.opacity(0.06), in: .rect(cornerRadius: 6))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 6).strokeBorder(reasonTint.opacity(0.20), lineWidth: 1))
|
|
// The model's one-line "why" for anything short of a clean pass, so a caution/flag
|
|
// isn't a bare color — the user sees the reasoning. Omitted for a clean CONSISTENT.
|
|
if let audit = reasonAudit, !audit.rationale.isEmpty,
|
|
audit.trust != .consistent, audit.trust != .notChecked {
|
|
Text(audit.rationale)
|
|
.font(.caption2).italic()
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The trust badge shown against the reason: a spinner while the on-device check runs, then a
|
|
/// colored icon + label for the verdict.
|
|
@ViewBuilder private var trustBadge: some View {
|
|
if let audit = reasonAudit {
|
|
let style = Self.trustStyle(audit.trust)
|
|
Label(style.label, systemImage: style.icon)
|
|
.font(.caption2.weight(.semibold))
|
|
.foregroundStyle(style.color)
|
|
.padding(.horizontal, 6).padding(.vertical, 2)
|
|
.background(style.color.opacity(0.12), in: Capsule())
|
|
.help(audit.rationale)
|
|
} else {
|
|
HStack(spacing: 4) {
|
|
ProgressView().controlSize(.mini)
|
|
Text("Checking…").font(.caption2).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The reason box's tint tracks the verdict — red for a contradiction, amber for a partial or
|
|
/// unverified check, green for a clean pass — so a mismatch reads at a glance, not just in the
|
|
/// badge. Neutral (the card accent) while the check is still running.
|
|
private var reasonTint: Color {
|
|
guard let trust = reasonAudit?.trust else { return accent }
|
|
return Self.trustStyle(trust).color
|
|
}
|
|
|
|
/// Icon, label, and color for each trust verdict. Contradictory is the loud one (red) — it's
|
|
/// the "the agent may be lying to you" case; a clean pass is calm green; everything uncertain
|
|
/// is amber/secondary so it never reads as endorsed.
|
|
static func trustStyle(_ trust: HostExecReasonAudit.Trust) -> (label: String, icon: String, color: Color) {
|
|
switch trust {
|
|
case .consistent: ("Reason matches command", "checkmark.seal.fill", .green)
|
|
case .partial: ("Partly supported", "exclamationmark.triangle.fill", .orange)
|
|
case .contradictory: ("Doesn't match command", "xmark.octagon.fill", .red)
|
|
case .unclear: ("Couldn't verify", "questionmark.circle.fill", .orange)
|
|
case .notChecked: ("Not verified", "questionmark.circle", .secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct HostExecHeightKey: PreferenceKey {
|
|
static var defaultValue: CGFloat { 0 }
|
|
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
|
value = max(value, nextValue())
|
|
}
|
|
}
|
|
|
|
// MARK: - In-chat host_exec card
|
|
|
|
/// Renders a `mcp__nucleic__host_exec` tool call *in the transcript* as a structured card: a
|
|
/// host-machine glyph, the shell command pretty-printed with a `$` prompt, and the command's
|
|
/// output revealed on tap. Replaces the generic `mcp__nucleic__host_exec {"command": …}` JSON
|
|
/// row the catch-all tool card would otherwise show, so a host call reads as cleanly as the
|
|
/// agent's other activity — just flagged, by the desktop glyph and "Host" tag, as running on
|
|
/// the macOS host *outside the sandbox*. `HostExecCard` is the approval-time prompt; this is its
|
|
/// settled, in-chat form, and it mirrors `ToolCallRow`'s collapse behavior so the two read alike.
|
|
struct HostExecToolCard: View {
|
|
@Environment(\.appPalette) private var palette
|
|
/// The shell command the agent ran on the host.
|
|
let command: String
|
|
/// The command's output, revealed on tap once it has run; nil while still running or empty.
|
|
var output: String? = nil
|
|
/// Whether the command has finished — a spinner shows in the header until it has.
|
|
let finished: Bool
|
|
/// Whether the result came back an error, which tints the output and its glyph `danger`.
|
|
var isError: Bool = false
|
|
/// 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.
|
|
private var accent: Color { palette.accent }
|
|
@State private var expanded = false
|
|
|
|
/// The deterministic, command-derived breakdown (program, actions, flags, inferred purpose) —
|
|
/// parsed from the command string alone, so the collapsed line can lead with what the call
|
|
/// *does* rather than the raw command. Recomputed cheaply from the command on each render.
|
|
private var parsed: HostCommandSummary.Summary? { HostCommandSummary.summary(for: command) }
|
|
|
|
/// Whether there's output to reveal.
|
|
private var hasOutput: Bool { !(output ?? "").isEmpty }
|
|
|
|
/// Whether the parse carries structure worth revealing (a working dir, more than one step, or
|
|
/// any flags/args/env) — so a richer command is expandable even before it has output.
|
|
private var hasBreakdown: Bool {
|
|
guard let parsed else { return false }
|
|
if parsed.workingDirectory != nil || parsed.invocations.count > 1 { return true }
|
|
return parsed.invocations.contains {
|
|
!$0.flags.isEmpty || !$0.arguments.isEmpty || !$0.env.isEmpty
|
|
}
|
|
}
|
|
|
|
private var canExpand: Bool { hasOutput || hasBreakdown }
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
header
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
guard canExpand else { return }
|
|
// Un-animated: a height-changing disclosure inside the eager transcript
|
|
// (see ToolCallRow's tap handler in TranscriptRow.swift for the rationale).
|
|
expanded.toggle()
|
|
}
|
|
if expanded {
|
|
if hasBreakdown, let parsed {
|
|
Divider().overlay(accent.opacity(0.14))
|
|
HostCommandBreakdown(summary: parsed, accent: accent, showPurpose: false,
|
|
projectRoot: projectRoot, worktreeRoot: worktreeRoot)
|
|
.padding(.horizontal, 10).padding(.vertical, 8)
|
|
}
|
|
if let output, !output.isEmpty {
|
|
Divider().overlay(accent.opacity(0.14))
|
|
outputBody(output)
|
|
}
|
|
}
|
|
}
|
|
.background(accent.opacity(0.06))
|
|
.overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(accent.opacity(0.14), lineWidth: 1))
|
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
}
|
|
|
|
/// The host glyph, a "Host" tag, the inferred purpose (the easy-to-read headline), and the
|
|
/// `$`-prefixed command beneath it as the literal — plus a running spinner and, when there's
|
|
/// detail to reveal, a disclosure chevron.
|
|
private var header: some View {
|
|
HStack(alignment: .top, spacing: 8) {
|
|
Image(systemName: "desktopcomputer")
|
|
.font(.caption).foregroundStyle(accent).frame(width: 15)
|
|
.padding(.top, 1)
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
|
Text("HOST")
|
|
.font(.caption2.weight(.bold)).foregroundStyle(accent)
|
|
if let parsed {
|
|
// The command-derived purpose leads as the bold headline — what the call
|
|
// looks like it does — rendered with its `code` spans; "HOST" is just a
|
|
// quiet tag beside it (the desktop glyph already marks it as a host call).
|
|
Text(TranscriptRow.inlineMarkdown(parsed.purpose))
|
|
.font(.callout.weight(.semibold)).foregroundStyle(AppTheme.primaryText)
|
|
.lineLimit(1).truncationMode(.tail)
|
|
} else {
|
|
Text("Host")
|
|
.font(.callout.weight(.semibold)).foregroundStyle(AppTheme.primaryText)
|
|
}
|
|
}
|
|
commandLine
|
|
}
|
|
Spacer(minLength: 0)
|
|
if !finished, let onSkip {
|
|
SkipToolCallButton(action: onSkip)
|
|
}
|
|
if !finished {
|
|
ProgressView().controlSize(.small).scaleEffect(0.7)
|
|
}
|
|
if canExpand {
|
|
Image(systemName: "chevron.right")
|
|
.font(.caption2.weight(.semibold))
|
|
.foregroundStyle(.tertiary)
|
|
.rotationEffect(.degrees(expanded ? 90 : 0))
|
|
.padding(.top, 2)
|
|
}
|
|
}
|
|
.padding(.horizontal, 10).padding(.vertical, 6)
|
|
.animation(.easeInOut(duration: 0.15), value: expanded)
|
|
}
|
|
|
|
/// The literal command in a `$`-prefixed monospaced run — one line, truncated, when
|
|
/// collapsed; wrapping in full once expanded — so the exact command stays visible beneath the
|
|
/// human-readable purpose.
|
|
private var commandLine: some View {
|
|
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
|
Text("$")
|
|
.font(.system(.caption, design: .monospaced))
|
|
.foregroundStyle(accent.opacity(0.7))
|
|
Text(command)
|
|
.font(.system(.caption, design: .monospaced))
|
|
.foregroundStyle(.secondary)
|
|
// Expanded, the literal wraps but stays bounded — a long chain's command string
|
|
// is itself long, and the compact step list above carries the structure.
|
|
.lineLimit(expanded ? 6 : 1)
|
|
.truncationMode(.middle)
|
|
.textSelection(.enabled)
|
|
.fixedSize(horizontal: false, vertical: expanded)
|
|
}
|
|
}
|
|
|
|
/// The command's output, revealed on expand: a return glyph and the text in a monospaced
|
|
/// block, tinted `danger` when the call errored. Mirrors `ToolCallRow`'s result body,
|
|
/// including its render cap — a huge host command output laid out uncapped in the eager
|
|
/// transcript stack would stall the main thread.
|
|
private func outputBody(_ output: String) -> some View {
|
|
let (shown, elided) = TranscriptRow.cappedOutput(output)
|
|
return HStack(alignment: .top, spacing: 8) {
|
|
Image(systemName: isError ? "exclamationmark.triangle.fill" : "arrow.turn.down.right")
|
|
.font(.caption2)
|
|
.foregroundStyle(isError ? AnyShapeStyle(palette.danger) : AnyShapeStyle(.tertiary))
|
|
.frame(width: 15)
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(shown)
|
|
.font(.system(.caption, design: .monospaced))
|
|
.foregroundStyle(isError ? AnyShapeStyle(palette.danger) : AnyShapeStyle(.secondary))
|
|
.textSelection(.enabled)
|
|
if elided > 0 { TruncatedOutputNote(elided: elided) }
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(.horizontal, 10).padding(.vertical, 6)
|
|
}
|
|
}
|
|
|
|
// MARK: - Command breakdown
|
|
|
|
/// The shared, deterministic breakdown of a `host_exec` command — driven entirely by
|
|
/// ``HostCommandSummary`` (parsed from the command string, never from the agent). It lays out
|
|
/// the inferred purpose (optionally), the working directory, and each program invocation as its
|
|
/// program + actions + flags + operands, so the user can verify *exactly* what's about to run.
|
|
/// Used by ``HostExecCard`` (the approval, with the purpose) and ``HostExecToolCard`` (the
|
|
/// in-chat call, where the purpose already heads the row so it's shown without it).
|
|
struct HostCommandBreakdown: View {
|
|
@Environment(\.appPalette) private var palette
|
|
let summary: HostCommandSummary.Summary
|
|
/// The card's accent (the program/action chips and glyphs).
|
|
let accent: Color
|
|
/// 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) {
|
|
if showPurpose {
|
|
purposeRow
|
|
if summary.isElevated || summary.isDestructive { riskBanner }
|
|
}
|
|
if let dir = summary.workingDirectory {
|
|
detailRow(icon: "folder",
|
|
lines: ["in \(HeuristicSummary.abbreviatePath(dir, worktree: worktreeRoot, project: projectRoot))"])
|
|
}
|
|
steps
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
/// At most this many steps render in the compact chain list before the rest fold into a
|
|
/// "+N more" tail — so a long `cmd1 && cmd2 && …` pipeline can't grow the card unbounded
|
|
/// (the exact command shown alongside is always the complete source of truth).
|
|
private static let maxVisibleSteps = 7
|
|
|
|
/// The invocations, rendered to fit the chain length: a lone command gets the full block
|
|
/// (program + actions + every env/flag/operand spelled out for verification); a *chain*
|
|
/// collapses to one compact line per command so a long pipeline stays short, not a tall
|
|
/// stack of blocks.
|
|
@ViewBuilder private var steps: some View {
|
|
if summary.invocations.count <= 1 {
|
|
if let only = summary.invocations.first { invocationView(only) }
|
|
} else {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
ForEach(Array(summary.invocations.prefix(Self.maxVisibleSteps))) { inv in
|
|
stepRow(inv)
|
|
}
|
|
let hidden = summary.invocations.count - Self.maxVisibleSteps
|
|
if hidden > 0 {
|
|
Text("+\(hidden) more step\(hidden == 1 ? "" : "s")")
|
|
.font(.caption2).foregroundStyle(.tertiary)
|
|
.padding(.leading, 22)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One command in a chain, compacted to a single line: a step glyph, the program + actions
|
|
/// emphasized, then a truncated tail of its env/flags/operands — so each command reads at a
|
|
/// glance without the multi-line block. A destructive/elevated step is tinted and glyphed in
|
|
/// the danger color so it still stands out in the list.
|
|
private func stepRow(_ inv: HostCommandSummary.Invocation) -> some View {
|
|
let risky = inv.destructive || inv.elevated
|
|
let tail = stepTail(inv)
|
|
let headline = Text(inv.headline)
|
|
.font(.system(.caption, design: .monospaced).weight(.semibold))
|
|
.foregroundColor(AppTheme.primaryText)
|
|
let tailText = Text(tail.isEmpty ? "" : " " + tail)
|
|
.font(.system(.caption, design: .monospaced))
|
|
.foregroundColor(.secondary)
|
|
return HStack(alignment: .firstTextBaseline, spacing: 8) {
|
|
Image(systemName: stepGlyph(inv))
|
|
.font(.caption2).foregroundStyle(risky ? palette.danger : accent).frame(width: 14)
|
|
Text("\(headline)\(tailText)")
|
|
.lineLimit(1).truncationMode(.middle)
|
|
.textSelection(.enabled)
|
|
Spacer(minLength: 0)
|
|
}
|
|
}
|
|
|
|
/// The trailing detail of a compacted step — its env, flags, and operands joined on one line,
|
|
/// truncated by the row. The literal command block carries the untruncated form.
|
|
private func stepTail(_ inv: HostCommandSummary.Invocation) -> String {
|
|
(inv.env.map(\.display) + inv.flags.map(\.display) + inv.arguments).joined(separator: " ")
|
|
}
|
|
|
|
/// The leading glyph for a compacted step: a delete/elevation marker takes priority so the
|
|
/// risky step is unmistakable; otherwise a plain terminal glyph.
|
|
private func stepGlyph(_ inv: HostCommandSummary.Invocation) -> String {
|
|
if inv.destructive { return "trash" }
|
|
if inv.elevated { return "lock.shield" }
|
|
return "terminal"
|
|
}
|
|
|
|
/// The deterministic, command-derived purpose — the card's focal point, set in a prominent
|
|
/// accent banner so the user reads *what this does* first, with a quiet caption clarifying
|
|
/// it's *Nucleic's* reading of the command, not the agent's claim about it.
|
|
private var purposeRow: some View {
|
|
HStack(alignment: .top, spacing: 10) {
|
|
Image(systemName: "sparkles")
|
|
.font(.title3).foregroundStyle(accent).frame(width: 22)
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(TranscriptRow.inlineMarkdown(summary.purpose))
|
|
.font(.title3.weight(.semibold))
|
|
.foregroundStyle(AppTheme.primaryText)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.textSelection(.enabled)
|
|
Text("Likely purpose · inferred from the command")
|
|
.font(.caption2.weight(.medium)).foregroundStyle(.secondary)
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(.horizontal, 11).padding(.vertical, 9)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(accent.opacity(0.12), in: .rect(cornerRadius: 8))
|
|
.overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(accent.opacity(0.22), lineWidth: 1))
|
|
.help("Inferred by Nucleic from the command itself — not from anything the agent said.")
|
|
}
|
|
|
|
/// A red banner for the two things a host command most needs flagged before "Allow": running
|
|
/// as root (`sudo`) and deleting files (`rm`).
|
|
private var riskBanner: some View {
|
|
let icon = summary.isElevated ? "lock.shield" : "trash"
|
|
let text = summary.isElevated
|
|
? "Runs with elevated privileges (sudo)."
|
|
: "Deletes files on the host."
|
|
return HStack(alignment: .firstTextBaseline, spacing: 7) {
|
|
Image(systemName: icon).font(.caption).foregroundStyle(palette.danger).frame(width: 16)
|
|
Text(text).font(.caption.weight(.medium)).foregroundStyle(palette.danger)
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(.horizontal, 8).padding(.vertical, 5)
|
|
.background(palette.danger.opacity(0.08), in: .rect(cornerRadius: 6))
|
|
}
|
|
|
|
/// One program invocation: the program name as a filled badge, its actions as outlined chips,
|
|
/// and — beneath — its env, flags, and operands each as a labeled, monospaced list so every
|
|
/// part of the command is individually legible.
|
|
private func invocationView(_ inv: HostCommandSummary.Invocation) -> some View {
|
|
VStack(alignment: .leading, spacing: 5) {
|
|
HStack(spacing: 6) {
|
|
if inv.elevated { chip("sudo", color: palette.danger, filled: true) }
|
|
chip(inv.program, color: accent, filled: true)
|
|
ForEach(inv.actions, id: \.self) { chip($0, color: accent, filled: false) }
|
|
if inv.destructive { chip("deletes", color: palette.danger, filled: false) }
|
|
Spacer(minLength: 0)
|
|
}
|
|
if !inv.env.isEmpty {
|
|
detailRow(icon: "leaf", lines: inv.env.map(\.display))
|
|
}
|
|
if !inv.flags.isEmpty {
|
|
detailRow(icon: "slider.horizontal.3", lines: inv.flags.map(\.display))
|
|
}
|
|
if !inv.arguments.isEmpty {
|
|
detailRow(icon: "chevron.right", lines: [inv.arguments.joined(separator: " ")])
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A small chip — a filled program badge or an outlined action/marker pill.
|
|
private func chip(_ text: String, color: Color, filled: Bool) -> some View {
|
|
Text(text)
|
|
.font(.system(.caption2, design: .monospaced).weight(.medium))
|
|
.foregroundStyle(color)
|
|
.padding(.horizontal, 6).padding(.vertical, 2)
|
|
.background(color.opacity(filled ? 0.16 : 0), in: Capsule())
|
|
.overlay(Capsule().strokeBorder(color.opacity(filled ? 0 : 0.4), lineWidth: 0.75))
|
|
}
|
|
|
|
/// A labeled detail block: a small tertiary glyph and a monospaced list (env vars, one flag
|
|
/// per line, or the operands) — the granular pieces the user scans to verify the command.
|
|
private func detailRow(icon: String, lines: [String]) -> some View {
|
|
HStack(alignment: .top, spacing: 7) {
|
|
Image(systemName: icon).font(.caption2).foregroundStyle(.tertiary)
|
|
.frame(width: 14).padding(.top, 1)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
|
|
Text(line)
|
|
.font(.system(.caption, design: .monospaced))
|
|
.foregroundStyle(.secondary)
|
|
.textSelection(.enabled)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Host-command conflict (HOST_EXEC §concurrency)
|
|
|
|
/// View-model for the host-command conflict override prompt: the command this session is about to
|
|
/// run on the shared host, plus the other session(s) already running something the fuzzy detector
|
|
/// judged *similar* (the simultaneous-builds case). Built from the approval's `host_command_conflict`
|
|
/// payload in `ApprovalBar`.
|
|
struct HostCommandConflictInfo: Equatable {
|
|
struct Other: Equatable, Identifiable {
|
|
/// The conflicting session's title — so the user knows exactly *which* session clashes.
|
|
let session: String
|
|
/// The command that session is currently running.
|
|
let command: String
|
|
/// Why the two are considered similar (e.g. "both are swift build/compile commands").
|
|
let reason: String
|
|
var id: String { session + "\u{1}" + command }
|
|
}
|
|
/// The command this session wants to run on the host.
|
|
let command: String
|
|
/// The already-running command(s) it would collide with — never empty when this is shown.
|
|
let others: [Other]
|
|
}
|
|
|
|
/// Renders the host-command conflict override prompt as a structured card: it names *which* session
|
|
/// is already running a similar command, shows both commands, and explains why running them at once
|
|
/// could collide — so the user can either let the other finish ("Cancel") or, if it's a false
|
|
/// positive, proceed ("Run anyway"). The buttons live in `ApprovalBar`; this card is the rationale.
|
|
struct HostCommandConflictCard: View {
|
|
@Environment(\.appPalette) private var palette
|
|
let info: HostCommandConflictInfo
|
|
/// The card's accent — `attention` in a pending approval.
|
|
var tint: Color? = nil
|
|
|
|
private var accent: Color { tint ?? palette.attention }
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
header
|
|
// What this session wants to run.
|
|
labelledCommand("This session wants to run", info.command)
|
|
// Each session it would collide with: who, why, and what they're running.
|
|
ForEach(info.others) { other in
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
|
Image(systemName: "person.fill.questionmark")
|
|
.font(.caption).foregroundStyle(accent).frame(width: 14)
|
|
Text(other.session)
|
|
.font(.callout.weight(.semibold))
|
|
.foregroundStyle(AppTheme.primaryText)
|
|
.textSelection(.enabled)
|
|
Text("is already running")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
if !other.command.isEmpty { commandBlock(other.command) }
|
|
if !other.reason.isEmpty {
|
|
Text(other.reason)
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
.padding(.top, 2)
|
|
}
|
|
Text("Running both on the host at once can collide — e.g. two builds contending for the "
|
|
+ "toolchain, or sharing one trunk's build directory. Choose Wait to queue behind it "
|
|
+ "and run automatically once it finishes, or Run anyway if this isn't a real conflict.")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
.padding(.horizontal, 12).padding(.vertical, 10)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(accent.opacity(0.06))
|
|
.overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(accent.opacity(0.16), lineWidth: 1))
|
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
}
|
|
|
|
private var header: some View {
|
|
HStack(alignment: .firstTextBaseline, spacing: 9) {
|
|
Image(systemName: "exclamationmark.arrow.triangle.2.circlepath")
|
|
.font(.callout).foregroundStyle(accent).frame(width: 16)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Possible host-command conflict")
|
|
.font(.callout.weight(.semibold))
|
|
.foregroundStyle(AppTheme.primaryText)
|
|
Text("another session is running a similar command")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
}
|
|
|
|
/// A captioned command line — `label` above a monospaced command block.
|
|
private func labelledCommand(_ label: String, _ command: String) -> some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(label).font(.caption).foregroundStyle(.secondary)
|
|
commandBlock(command)
|
|
}
|
|
}
|
|
|
|
/// A single command in a snug monospaced block with a `$` prompt — mirrors `HostExecCard`.
|
|
private func commandBlock(_ command: String) -> some View {
|
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
|
Text("$")
|
|
.font(.system(.caption, design: .monospaced))
|
|
.foregroundStyle(accent.opacity(0.7))
|
|
Text(command)
|
|
.font(.system(.caption, design: .monospaced))
|
|
.foregroundStyle(AppTheme.primaryText)
|
|
.textSelection(.enabled)
|
|
.multilineTextAlignment(.leading)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.padding(8)
|
|
.background(accent.opacity(0.05), in: .rect(cornerRadius: 6))
|
|
.overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(accent.opacity(0.12), lineWidth: 1))
|
|
}
|
|
}
|