Files
nucleic/Sources/NucleicApp/HostExecCard.swift
T

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))
}
}