phase6(wave1): command-center UI — TaskStore/RunController/approval bridge, sidebar, step-card transcript, plan panel, terminal drawer, empty state, Markdown port
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 26 changed files with +5,207 and −19
modified
Sources/ZyquoAgent/App/AgentCLI.swift
+4 −3
@@ -307,8 +307,9 @@ enum AgentCLI { | ||
| 307 | 307 | } |
| 308 | 308 | |
| 309 | 309 | /// Environment variable names checked (in order) for each provider, |
| 310 | − /// before falling back to the encrypted vault. | |
| 311 | − private static func environmentKeyNames(for provider: ProviderID) -> [String] { | |
| 310 | + /// before falling back to the encrypted vault. Internal: the UI's | |
| 311 | + /// RunController resolves keys the same way. | |
| 312 | + static func environmentKeyNames(for provider: ProviderID) -> [String] { | |
| 312 | 313 | switch provider { |
| 313 | 314 | case .openai: return ["OPENAI_API_KEY"] |
| 314 | 315 | case .anthropic: return ["ANTHROPIC_API_KEY"] |
@@ -326,7 +327,7 @@ enum AgentCLI { | ||
| 326 | 327 | } |
| 327 | 328 | } |
| 328 | 329 | |
| 329 | − private static func resolveAPIKey(for provider: ProviderID) -> String? { | |
| 330 | + static func resolveAPIKey(for provider: ProviderID) -> String? { | |
| 330 | 331 | let environment = ProcessInfo.processInfo.environment |
| 331 | 332 | for name in environmentKeyNames(for: provider) { |
| 332 | 333 | if let value = environment[name], !value.isEmpty { |
modified
Sources/ZyquoAgent/App/Main.swift
+9 −0
@@ -33,6 +33,15 @@ enum Main { | ||
| 33 | 33 | // can run (a blocking semaphore here deadlocks the harness). |
| 34 | 34 | dispatchMain() |
| 35 | 35 | } |
| 36 | + if arguments.contains("--run-ui-smoke") { | |
| 37 | + // Hidden CI check: drives the UI's RunController with the mock | |
| 38 | + // provider and asserts the published state transitions. | |
| 39 | + Task { @MainActor in | |
| 40 | + let status = await UISmoke.run() | |
| 41 | + exit(status) | |
| 42 | + } | |
| 43 | + dispatchMain() | |
| 44 | + } | |
| 36 | 45 | if arguments.contains("--load-vault") { |
| 37 | 46 | AgentCLI.loadVault() |
| 38 | 47 | exit(0) |
added
Sources/ZyquoAgent/App/UISmoke.swift
+120 −0
@@ -0,0 +1,120 @@ | ||
| 1 | +// | |
| 2 | +// UISmoke.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Hidden `--run-ui-smoke` mode: drives the RunController (the UI's view | |
| 9 | +// model) with the scripted MockProviderClient — no network, no keys, no | |
| 10 | +// window — and asserts the published state transitions the command-center | |
| 11 | +// UI renders from: step entries appear and stream, the plan lands, tokens | |
| 12 | +// accumulate, the run completes, and the task record gains the persisted | |
| 13 | +// agent-run message with its embedded steps. Everything runs against | |
| 14 | +// temp-rooted persistence so the user's real tasks and policy rules are | |
| 15 | +// never touched. | |
| 16 | +// | |
| 17 | + | |
| 18 | +import Foundation | |
| 19 | + | |
| 20 | +enum UISmoke { | |
| 21 | + /// Returns the process exit status (0 = all checks passed). | |
| 22 | + @MainActor | |
| 23 | + static func run() async -> Int32 { | |
| 24 | + var failures: [String] = [] | |
| 25 | + func check(_ condition: Bool, _ label: String) { | |
| 26 | + if condition { | |
| 27 | + print(" ✔ \(label)") | |
| 28 | + } else { | |
| 29 | + failures.append(label) | |
| 30 | + print(" ✘ \(label)") | |
| 31 | + } | |
| 32 | + } | |
| 33 | + | |
| 34 | + print("Zyquo Agent — UI smoke (RunController + MockProviderClient)") | |
| 35 | + | |
| 36 | + // Temp-rooted persistence: no real tasks/rules touched. | |
| 37 | + let temporaryRoot = FileManager.default.temporaryDirectory | |
| 38 | + .appendingPathComponent("ZyquoAgent-uismoke-\(UUID().uuidString.prefix(8))") | |
| 39 | + let persistence = PersistenceService(rootDirectory: temporaryRoot) | |
| 40 | + defer { try? FileManager.default.removeItem(at: temporaryRoot) } | |
| 41 | + | |
| 42 | + let store = TaskStore(persistence: persistence) | |
| 43 | + let task = store.newTask(model: MockProviderClient.model) | |
| 44 | + let controller = RunController(taskID: task.id, store: store, policyPersistence: persistence) | |
| 45 | + | |
| 46 | + var sawEntries = false | |
| 47 | + var sawPlan = false | |
| 48 | + var sawRunningStatus = false | |
| 49 | + var sawApproval = false | |
| 50 | + | |
| 51 | + controller.start( | |
| 52 | + prompt: "Create a demo folder in the workspace with a shell command and verify it exists.", | |
| 53 | + model: MockProviderClient.model, | |
| 54 | + client: MockProviderClient(), | |
| 55 | + apiKey: "mock" | |
| 56 | + ) | |
| 57 | + check(controller.isRunning, "run starts (isRunning)") | |
| 58 | + | |
| 59 | + // Poll the published state until the run finishes (mock finishes in | |
| 60 | + // well under a second; 15s is a generous CI ceiling). | |
| 61 | + let deadline = Date().addingTimeInterval(15) | |
| 62 | + while controller.outcome == nil && Date() < deadline { | |
| 63 | + sawEntries = sawEntries || !controller.entries.isEmpty | |
| 64 | + sawPlan = sawPlan || controller.plan != nil | |
| 65 | + sawRunningStatus = sawRunningStatus || (store.task(id: task.id)?.status.isActive ?? false) | |
| 66 | + // Guarded mode holds the mock's `mkdir` at the gate — resolve the | |
| 67 | + // approval card the way the UI's Approve button does. | |
| 68 | + if controller.pendingApproval != nil { | |
| 69 | + sawApproval = true | |
| 70 | + controller.resolveApproval(.approve) | |
| 71 | + } | |
| 72 | + try? await Task.sleep(nanoseconds: 20_000_000) | |
| 73 | + } | |
| 74 | + | |
| 75 | + check(sawEntries, "live step entries were published during the run") | |
| 76 | + check(sawPlan, "plan was published (update_plan intercepted)") | |
| 77 | + check(sawRunningStatus, "task status went active while running") | |
| 78 | + check(sawApproval, "approval card surfaced and resolved (guarded mode)") | |
| 79 | + | |
| 80 | + guard let outcome = controller.outcome else { | |
| 81 | + print(" ✘ run did not finish within 15s") | |
| 82 | + return 1 | |
| 83 | + } | |
| 84 | + if case .completed(let answer) = outcome { | |
| 85 | + check(!answer.isEmpty, "run completed with a final answer") | |
| 86 | + } else { | |
| 87 | + check(false, "run completed (got \(outcome))") | |
| 88 | + } | |
| 89 | + | |
| 90 | + check(!controller.isRunning, "isRunning cleared after the run") | |
| 91 | + check(controller.entries.isEmpty, "live timeline folded into history after the run") | |
| 92 | + check(controller.tokensUsed > 0, "token usage accumulated") | |
| 93 | + check(controller.pendingApproval == nil, "no dangling approval") | |
| 94 | + | |
| 95 | + let finished = store.task(id: task.id) | |
| 96 | + check(finished?.status == .done, "task status is Done") | |
| 97 | + check(finished?.messages.count == 2, "history has prompt + run (got \(finished?.messages.count ?? 0))") | |
| 98 | + let runMessage = finished?.messages.last | |
| 99 | + check(runMessage?.kind == .agentRun, "last history entry is the agent run") | |
| 100 | + check((runMessage?.steps?.count ?? 0) >= 3, "run message embeds the steps (got \(runMessage?.steps?.count ?? 0))") | |
| 101 | + check(runMessage?.plan?.doneCount == 2, "run message carries the finished plan") | |
| 102 | + check(finished?.workspacePath != nil, "task bound to a workspace") | |
| 103 | + check(!controller.terminalLines.isEmpty, "terminal feed captured output") | |
| 104 | + | |
| 105 | + // Reopen path: a fresh controller re-attaches the workspace. | |
| 106 | + if let workspacePath = finished?.workspacePath { | |
| 107 | + let reopened = RunController(taskID: task.id, store: store, policyPersistence: persistence) | |
| 108 | + check(reopened.workspaceRoot?.path == workspacePath, "reopened controller reattaches the workspace") | |
| 109 | + check(reopened.plan != nil, "reopened controller restores the last plan") | |
| 110 | + try? FileManager.default.removeItem(at: URL(fileURLWithPath: workspacePath)) | |
| 111 | + } | |
| 112 | + | |
| 113 | + if failures.isEmpty { | |
| 114 | + print("UI smoke: all checks passed.") | |
| 115 | + return 0 | |
| 116 | + } | |
| 117 | + print("UI smoke: \(failures.count) check(s) FAILED.") | |
| 118 | + return 1 | |
| 119 | + } | |
| 120 | +} | |
modified
Sources/ZyquoAgent/App/ZyquoAgentApp.swift
+46 −16
@@ -5,32 +5,62 @@ | ||
| 5 | 5 | // Author: Simon-Pierre Boucher |
| 6 | 6 | // Mail: contact@spboucher.ai |
| 7 | 7 | // |
| 8 | +// The SwiftUI app shell: builds the shared environment (task store, run | |
| 9 | +// hub, model catalog, key vault, appearance), presents the command-center | |
| 10 | +// window (1320×860 default, 1040×680 min), and wires the wave-1 command set | |
| 11 | +// (⌘N new task; ⌘⏎ run and ⌘. stop are handled inside the detail view). | |
| 12 | +// | |
| 8 | 13 | |
| 9 | 14 | import SwiftUI |
| 10 | 15 | |
| 16 | +/// Shared object graph for the app session. | |
| 17 | +@MainActor | |
| 18 | +final class AppEnvironment: ObservableObject { | |
| 19 | + let tasks: TaskStore | |
| 20 | + let hub: RunHub | |
| 21 | + let catalog: ModelCatalog | |
| 22 | + let vault: KeyVaultStore | |
| 23 | + let appearance: AppearanceStore | |
| 24 | + | |
| 25 | + init() { | |
| 26 | + let tasks = TaskStore() | |
| 27 | + self.tasks = tasks | |
| 28 | + self.hub = RunHub(store: tasks) | |
| 29 | + self.catalog = ModelCatalog() | |
| 30 | + self.vault = KeyVaultStore() | |
| 31 | + self.appearance = AppearanceStore() | |
| 32 | + } | |
| 33 | +} | |
| 34 | + | |
| 11 | 35 | struct ZyquoAgentApp: App { |
| 12 | 36 | @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate |
| 37 | + @StateObject private var environment = AppEnvironment() | |
| 13 | 38 | |
| 14 | 39 | var body: some Scene { |
| 15 | 40 | WindowGroup("Zyquo Agent") { |
| 16 | − PlaceholderRootView() | |
| 17 | − .frame(minWidth: 1040, minHeight: 680) | |
| 41 | + MainWindowView() | |
| 42 | + .environmentObject(environment.tasks) | |
| 43 | + .environmentObject(environment.hub) | |
| 44 | + .environmentObject(environment.catalog) | |
| 45 | + .environmentObject(environment.vault) | |
| 46 | + .environmentObject(environment.appearance) | |
| 47 | + .frame( | |
| 48 | + minWidth: ZyquoMetrics.windowMinWidth, | |
| 49 | + minHeight: ZyquoMetrics.windowMinHeight | |
| 50 | + ) | |
| 18 | 51 | } |
| 19 | − .defaultSize(width: 1320, height: 860) | |
| 20 | − } | |
| 21 | −} | |
| 22 | − | |
| 23 | −/// Phase 1 stand-in for the command-center window; replaced by the real | |
| 24 | −/// MainWindowView in Phase 6. | |
| 25 | −struct PlaceholderRootView: View { | |
| 26 | − var body: some View { | |
| 27 | − VStack(spacing: 12) { | |
| 28 | − Text("Zyquo Agent") | |
| 29 | − .font(.largeTitle.weight(.semibold)) | |
| 30 | − Text("The agent engine is under construction (Phase 3).") | |
| 31 | − .foregroundStyle(.secondary) | |
| 52 | + .defaultSize( | |
| 53 | + width: ZyquoMetrics.windowDefaultWidth, | |
| 54 | + height: ZyquoMetrics.windowDefaultHeight | |
| 55 | + ) | |
| 56 | + .commands { | |
| 57 | + CommandGroup(replacing: .newItem) { | |
| 58 | + Button("New Task") { | |
| 59 | + environment.tasks.newTask(model: environment.catalog.defaultAgentModel) | |
| 60 | + } | |
| 61 | + .keyboardShortcut("n", modifiers: .command) | |
| 62 | + } | |
| 32 | 63 | } |
| 33 | − .frame(maxWidth: .infinity, maxHeight: .infinity) | |
| 34 | 64 | } |
| 35 | 65 | } |
| 36 | 66 | |
added
Sources/ZyquoAgent/DesignSystem/AgentZGlyph.swift
+78 −0
@@ -0,0 +1,78 @@ | ||
| 1 | +// | |
| 2 | +// AgentZGlyph.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// The in-app Agent brand glyph (wordmark + empty state): the family | |
| 9 | +// Z-monogram whose lower stroke resolves into a command-prompt caret — | |
| 10 | +// "the Z that acts". Drawn as vector paths on a normalized grid so it scales | |
| 11 | +// from the 22pt wordmark to the 88pt hero without redesign. The full app | |
| 12 | +// icon (Phase 5) shares this creative direction. | |
| 13 | +// | |
| 14 | + | |
| 15 | +import SwiftUI | |
| 16 | + | |
| 17 | +/// Normalized 100×100 design grid for the glyph paths. | |
| 18 | +private enum AgentGlyphGrid { | |
| 19 | + static let unit: CGFloat = 100 | |
| 20 | +} | |
| 21 | + | |
| 22 | +/// The Z stroke: top bar → diagonal → shortened bottom bar (making room for | |
| 23 | +/// the caret block that follows it). | |
| 24 | +private struct AgentZStroke: Shape { | |
| 25 | + func path(in rect: CGRect) -> Path { | |
| 26 | + let scale = min(rect.width, rect.height) / AgentGlyphGrid.unit | |
| 27 | + func point(_ x: CGFloat, _ y: CGFloat) -> CGPoint { | |
| 28 | + CGPoint(x: rect.minX + x * scale, y: rect.minY + y * scale) | |
| 29 | + } | |
| 30 | + var path = Path() | |
| 31 | + path.move(to: point(18, 22)) | |
| 32 | + path.addLine(to: point(82, 22)) | |
| 33 | + path.addLine(to: point(22, 78)) | |
| 34 | + path.addLine(to: point(58, 78)) | |
| 35 | + return path | |
| 36 | + } | |
| 37 | +} | |
| 38 | + | |
| 39 | +/// The command caret `›` finishing the Z's lower stroke. | |
| 40 | +private struct AgentCaretStroke: Shape { | |
| 41 | + func path(in rect: CGRect) -> Path { | |
| 42 | + let scale = min(rect.width, rect.height) / AgentGlyphGrid.unit | |
| 43 | + func point(_ x: CGFloat, _ y: CGFloat) -> CGPoint { | |
| 44 | + CGPoint(x: rect.minX + x * scale, y: rect.minY + y * scale) | |
| 45 | + } | |
| 46 | + var path = Path() | |
| 47 | + path.move(to: point(70, 64)) | |
| 48 | + path.addLine(to: point(84, 78)) | |
| 49 | + path.addLine(to: point(70, 92)) | |
| 50 | + return path | |
| 51 | + } | |
| 52 | +} | |
| 53 | + | |
| 54 | +/// The Zyquo Agent glyph: violet Z monogram with a command caret. | |
| 55 | +struct AgentZGlyph: View { | |
| 56 | + /// Rendered width and height (the glyph is square). | |
| 57 | + var size: CGFloat | |
| 58 | + var tint: Color = ZyquoColor.accent | |
| 59 | + | |
| 60 | + private var lineWidth: CGFloat { size * 14 / AgentGlyphGrid.unit } | |
| 61 | + | |
| 62 | + var body: some View { | |
| 63 | + ZStack { | |
| 64 | + AgentZStroke() | |
| 65 | + .stroke( | |
| 66 | + tint, | |
| 67 | + style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round) | |
| 68 | + ) | |
| 69 | + AgentCaretStroke() | |
| 70 | + .stroke( | |
| 71 | + tint.opacity(0.55), | |
| 72 | + style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round) | |
| 73 | + ) | |
| 74 | + } | |
| 75 | + .frame(width: size, height: size) | |
| 76 | + .accessibilityHidden(true) | |
| 77 | + } | |
| 78 | +} | |
modified
Sources/ZyquoAgent/DesignSystem/ZyquoTheme.swift
+8 −0
@@ -132,6 +132,10 @@ enum ZyquoMetrics { | ||
| 132 | 132 | static let settingsWidth: CGFloat = 760 |
| 133 | 133 | static let settingsHeight: CGFloat = 560 |
| 134 | 134 | static let verticalTurnRhythm: CGFloat = 16 |
| 135 | + /// Default height of the Activity/Terminal drawer when expanded. | |
| 136 | + static let terminalDrawerHeight: CGFloat = 240 | |
| 137 | + /// Width of the empty-state suggestion grid. | |
| 138 | + static let emptyStateGridWidth: CGFloat = 520 | |
| 135 | 139 | } |
| 136 | 140 | |
| 137 | 141 | /// Motion tokens: hover 80ms ease, appear 150ms ease-out fade+rise, pressed 0.97. |
@@ -140,6 +144,10 @@ enum ZyquoMotion { | ||
| 140 | 144 | static let appear = Animation.easeOut(duration: 0.15) |
| 141 | 145 | static let pressedScale: CGFloat = 0.97 |
| 142 | 146 | static let picker = Animation.snappy |
| 147 | + /// Gentle repeating pulse for approval cards awaiting a decision. | |
| 148 | + static let pulse = Animation.easeInOut(duration: 1.2).repeatForever(autoreverses: true) | |
| 149 | + /// Rise distance for appearing step cards (150ms fade+rise). | |
| 150 | + static let appearRise: CGFloat = 8 | |
| 143 | 151 | } |
| 144 | 152 | |
| 145 | 153 | // MARK: - View helpers |
added
Sources/ZyquoAgent/Models/AgentTask.swift
+120 −0
@@ -0,0 +1,120 @@ | ||
| 1 | +// | |
| 2 | +// AgentTask.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// The persisted task record: one human↔agent conversation bound to one | |
| 9 | +// workspace. The user's prompts and each run's steps + final answer are | |
| 10 | +// embedded (AgentStep is Codable and its tool outputs are already bounded by | |
| 11 | +// the MemoryManager's offloading), so reopening a past task re-renders its | |
| 12 | +// full step history even after multiple runs — the workspace transcript | |
| 13 | +// (`.zyquo/transcript.json`, via Transcript.load) additionally holds the | |
| 14 | +// latest run for trajectory inspection. | |
| 15 | +// | |
| 16 | + | |
| 17 | +import Foundation | |
| 18 | + | |
| 19 | +/// Sidebar/status-pill state of a task. | |
| 20 | +enum AgentTaskStatus: String, Codable, Sendable { | |
| 21 | + /// No run yet, or the last run's outcome was cleared. | |
| 22 | + case idle | |
| 23 | + /// A run is preparing (workspace, system prompt) or drafting its plan. | |
| 24 | + case planning | |
| 25 | + /// A run is actively streaming or executing tools. | |
| 26 | + case running | |
| 27 | + /// A tool call is held at the policy gate, waiting for the user. | |
| 28 | + case awaitingApproval | |
| 29 | + /// A LoopGuard trip paused the run, waiting for continue/stop. | |
| 30 | + case awaitingInput | |
| 31 | + case done | |
| 32 | + case failed | |
| 33 | + | |
| 34 | + var displayName: String { | |
| 35 | + switch self { | |
| 36 | + case .idle: return "Idle" | |
| 37 | + case .planning: return "Planning" | |
| 38 | + case .running: return "Running" | |
| 39 | + case .awaitingApproval: return "Awaiting approval" | |
| 40 | + case .awaitingInput: return "Awaiting input" | |
| 41 | + case .done: return "Done" | |
| 42 | + case .failed: return "Failed" | |
| 43 | + } | |
| 44 | + } | |
| 45 | + | |
| 46 | + /// True while a live run owns this task. | |
| 47 | + var isActive: Bool { | |
| 48 | + switch self { | |
| 49 | + case .planning, .running, .awaitingApproval, .awaitingInput: return true | |
| 50 | + case .idle, .done, .failed: return false | |
| 51 | + } | |
| 52 | + } | |
| 53 | +} | |
| 54 | + | |
| 55 | +/// One entry of the task's human↔agent history: either a user prompt or one | |
| 56 | +/// completed agent run (its steps, plan, and outcome). | |
| 57 | +struct TaskMessage: Codable, Identifiable, Sendable { | |
| 58 | + enum Kind: String, Codable, Sendable { | |
| 59 | + case user | |
| 60 | + case agentRun | |
| 61 | + } | |
| 62 | + | |
| 63 | + var id: UUID = UUID() | |
| 64 | + var kind: Kind | |
| 65 | + /// User prompt text, or the run's final answer (empty when it failed). | |
| 66 | + var text: String | |
| 67 | + /// The run's steps, embedded so past runs re-render verbatim. | |
| 68 | + var steps: [AgentStep]? | |
| 69 | + /// The plan as it stood when the run ended. | |
| 70 | + var plan: TaskPlan? | |
| 71 | + /// How the run ended (agentRun entries only). | |
| 72 | + var outcome: AgentRunOutcome? | |
| 73 | + var createdAt: Date = Date() | |
| 74 | +} | |
| 75 | + | |
| 76 | +/// One agent task (a sidebar row): conversation + model + safety mode + | |
| 77 | +/// workspace binding. | |
| 78 | +struct AgentTask: Codable, Identifiable, Sendable { | |
| 79 | + var id: UUID = UUID() | |
| 80 | + var title: String | |
| 81 | + var createdAt: Date = Date() | |
| 82 | + var updatedAt: Date = Date() | |
| 83 | + var pinned: Bool = false | |
| 84 | + var status: AgentTaskStatus = .idle | |
| 85 | + var modelID: String | |
| 86 | + var providerID: ProviderID | |
| 87 | + var safetyMode: SafetyMode = .guarded | |
| 88 | + /// Path of the task's workspace directory; nil until the first run | |
| 89 | + /// creates it. | |
| 90 | + var workspacePath: String? | |
| 91 | + /// The user↔agent history (prompts + completed runs). | |
| 92 | + var messages: [TaskMessage] = [] | |
| 93 | + | |
| 94 | + /// Default title derived from a prompt's leading words. | |
| 95 | + static func title(fromPrompt prompt: String) -> String { | |
| 96 | + let firstLine = prompt | |
| 97 | + .split(separator: "\n", omittingEmptySubsequences: true) | |
| 98 | + .first.map(String.init) ?? prompt | |
| 99 | + let trimmed = firstLine.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 100 | + guard !trimmed.isEmpty else { return "New Task" } | |
| 101 | + if trimmed.count <= 48 { return trimmed } | |
| 102 | + let cut = trimmed.prefix(48) | |
| 103 | + // Break on the last word boundary inside the prefix. | |
| 104 | + if let lastSpace = cut.lastIndex(of: " ") { | |
| 105 | + return String(cut[..<lastSpace]) + "…" | |
| 106 | + } | |
| 107 | + return String(cut) + "…" | |
| 108 | + } | |
| 109 | + | |
| 110 | + /// Workspace directory URL, when one has been created. | |
| 111 | + var workspaceURL: URL? { | |
| 112 | + workspacePath.map { URL(fileURLWithPath: $0) } | |
| 113 | + } | |
| 114 | + | |
| 115 | + /// Latest persisted run transcript from the workspace, when present. | |
| 116 | + func loadTranscript() -> TranscriptDocument? { | |
| 117 | + guard let url = workspaceURL else { return nil } | |
| 118 | + return Transcript.load(from: url) | |
| 119 | + } | |
| 120 | +} | |
added
Sources/ZyquoAgent/ViewModels/KeyVaultStore.swift
+93 −0
@@ -0,0 +1,93 @@ | ||
| 1 | +// | |
| 2 | +// KeyVaultStore.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Observable wrapper around SecureKeyStore for the Settings UI: per-provider | |
| 9 | +// key presence, redacted display, and "Test" with latency. Decrypted keys are | |
| 10 | +// fetched on demand and never retained beyond the call. | |
| 11 | +// | |
| 12 | + | |
| 13 | +import Foundation | |
| 14 | + | |
| 15 | +@MainActor | |
| 16 | +final class KeyVaultStore: ObservableObject { | |
| 17 | + enum KeyStatus: Equatable { | |
| 18 | + case unset | |
| 19 | + case saved // present, not yet verified this session | |
| 20 | + case testing | |
| 21 | + case verified(latency: TimeInterval) | |
| 22 | + case failed(message: String) | |
| 23 | + } | |
| 24 | + | |
| 25 | + @Published private(set) var statuses: [ProviderID: KeyStatus] = [:] | |
| 26 | + /// Redacted display strings (••••1234) for providers with saved keys. | |
| 27 | + @Published private(set) var redactedKeys: [ProviderID: String] = [:] | |
| 28 | + | |
| 29 | + private let store: SecureKeyStore | |
| 30 | + | |
| 31 | + init(store: SecureKeyStore = SecureKeyStore()) { | |
| 32 | + self.store = store | |
| 33 | + refresh() | |
| 34 | + } | |
| 35 | + | |
| 36 | + func refresh() { | |
| 37 | + let keys = (try? store.loadKeys()) ?? [:] | |
| 38 | + for provider in ProviderID.builtIn { | |
| 39 | + if let key = keys[provider.rawValue], !key.isEmpty { | |
| 40 | + redactedKeys[provider] = SecureKeyStore.redacted(key) | |
| 41 | + if case .verified = statuses[provider] ?? .unset {} else { | |
| 42 | + statuses[provider] = .saved | |
| 43 | + } | |
| 44 | + } else { | |
| 45 | + redactedKeys[provider] = nil | |
| 46 | + statuses[provider] = .unset | |
| 47 | + } | |
| 48 | + } | |
| 49 | + } | |
| 50 | + | |
| 51 | + func hasKey(for provider: ProviderID) -> Bool { | |
| 52 | + redactedKeys[provider] != nil | |
| 53 | + } | |
| 54 | + | |
| 55 | + /// Decrypts and returns the key — call sites use it immediately and drop it. | |
| 56 | + func apiKey(for provider: ProviderID) throws -> String { | |
| 57 | + guard let key = try store.key(for: provider), !key.isEmpty else { | |
| 58 | + throw ProviderError.missingAPIKey(provider) | |
| 59 | + } | |
| 60 | + return key | |
| 61 | + } | |
| 62 | + | |
| 63 | + func setKey(_ key: String, for provider: ProviderID) { | |
| 64 | + let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 65 | + guard !trimmed.isEmpty else { return } | |
| 66 | + try? store.setKey(trimmed, for: provider) | |
| 67 | + statuses[provider] = .saved | |
| 68 | + refresh() | |
| 69 | + } | |
| 70 | + | |
| 71 | + func deleteKey(for provider: ProviderID) { | |
| 72 | + try? store.deleteKey(for: provider) | |
| 73 | + statuses[provider] = .unset | |
| 74 | + refresh() | |
| 75 | + } | |
| 76 | + | |
| 77 | + /// Runs the cheapest authenticated call and records latency or failure. | |
| 78 | + func testKey(for provider: ProviderID, catalog: ModelCatalog) async { | |
| 79 | + guard let key = try? apiKey(for: provider) else { | |
| 80 | + statuses[provider] = .failed(message: "No key saved") | |
| 81 | + return | |
| 82 | + } | |
| 83 | + statuses[provider] = .testing | |
| 84 | + let client = ProviderRegistry.client(for: provider) | |
| 85 | + let fallback = catalog.cheapestModel(for: provider) | |
| 86 | + do { | |
| 87 | + let latency = try await client.testKey(key, fallbackModel: fallback) | |
| 88 | + statuses[provider] = .verified(latency: latency) | |
| 89 | + } catch { | |
| 90 | + statuses[provider] = .failed(message: error.localizedDescription) | |
| 91 | + } | |
| 92 | + } | |
| 93 | +} | |
added
Sources/ZyquoAgent/ViewModels/RunController.swift
+645 −0
@@ -0,0 +1,645 @@ | ||
| 1 | +// | |
| 2 | +// RunController.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Owns the live run for one task: assembles the engine (provider client + | |
| 9 | +// key, workspace, tools, policy gate with the UI approval presenter, audit | |
| 10 | +// log, AgentLoop) and consumes the AsyncThrowingStream<AgentEvent>, | |
| 11 | +// publishing everything the command-center UI renders — live step cards | |
| 12 | +// (streaming text/thinking/tool arguments/tool output), the plan, budgets, | |
| 13 | +// the pending approval, guard trips, compactions, the terminal feed, audit | |
| 14 | +// entries, and workspace files. AgentEvents arrive off the main thread; the | |
| 15 | +// consuming Task is MainActor-bound so every mutation happens on main. | |
| 16 | +// | |
| 17 | + | |
| 18 | +import Combine | |
| 19 | +import Foundation | |
| 20 | + | |
| 21 | +// MARK: - Live timeline model | |
| 22 | + | |
| 23 | +/// One tool invocation as it renders live inside a step card. | |
| 24 | +struct LiveInvocation: Identifiable { | |
| 25 | + enum Phase { | |
| 26 | + /// Arguments still streaming from the model. | |
| 27 | + case streaming | |
| 28 | + /// Cleared (or clearing) the policy gate and executing. | |
| 29 | + case executing | |
| 30 | + case finished | |
| 31 | + } | |
| 32 | + | |
| 33 | + var id: String | |
| 34 | + var streamIndex: Int? | |
| 35 | + var name: String | |
| 36 | + /// Accumulating raw argument JSON (may be partial while streaming). | |
| 37 | + var argumentsJSON: String | |
| 38 | + var phase: Phase = .streaming | |
| 39 | + var outputLines: [TerminalLine] = [] | |
| 40 | + var result: ToolResult? | |
| 41 | + var exitCode: Int32? | |
| 42 | + var policyDecision: PolicyDecisionRecord? | |
| 43 | + | |
| 44 | + /// The human-facing payload: the shell command / script / path when the | |
| 45 | + /// arguments parse, else the raw JSON. | |
| 46 | + var displayPayload: String { | |
| 47 | + guard let data = argumentsJSON.data(using: .utf8), | |
| 48 | + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { | |
| 49 | + return argumentsJSON | |
| 50 | + } | |
| 51 | + for key in ["command", "script", "path"] { | |
| 52 | + if let value = object[key] as? String { return value } | |
| 53 | + } | |
| 54 | + return argumentsJSON | |
| 55 | + } | |
| 56 | +} | |
| 57 | + | |
| 58 | +/// One agent step as it renders live (mirrors AgentStep, mutable per event). | |
| 59 | +struct LiveStep: Identifiable { | |
| 60 | + var id: UUID | |
| 61 | + var index: Int | |
| 62 | + var status: AgentStepStatus | |
| 63 | + var thinking: String | |
| 64 | + var text: String | |
| 65 | + var invocations: [LiveInvocation] | |
| 66 | + var startedAt: Date | |
| 67 | + var finishedAt: Date? | |
| 68 | + var inputTokens: Int? | |
| 69 | + var outputTokens: Int? | |
| 70 | + | |
| 71 | + /// A step with no tool calls whose run completed = the final answer. | |
| 72 | + var isFinalAnswer: Bool { invocations.isEmpty && status == .completed && !text.isEmpty } | |
| 73 | + | |
| 74 | + init(step: AgentStep) { | |
| 75 | + self.id = step.id | |
| 76 | + self.index = step.index | |
| 77 | + self.status = step.status | |
| 78 | + self.thinking = step.thinking ?? "" | |
| 79 | + self.text = step.text | |
| 80 | + self.invocations = step.toolInvocations.map { LiveInvocation(invocation: $0) } | |
| 81 | + self.startedAt = step.startedAt | |
| 82 | + self.finishedAt = step.finishedAt | |
| 83 | + self.inputTokens = step.inputTokens | |
| 84 | + self.outputTokens = step.outputTokens | |
| 85 | + } | |
| 86 | +} | |
| 87 | + | |
| 88 | +extension LiveInvocation { | |
| 89 | + /// Builds a finished invocation view from a persisted AgentToolInvocation | |
| 90 | + /// (past-run re-rendering and stepCompleted reconciliation). | |
| 91 | + init(invocation: AgentToolInvocation) { | |
| 92 | + self.id = invocation.call.id | |
| 93 | + self.streamIndex = nil | |
| 94 | + self.name = invocation.call.name | |
| 95 | + self.argumentsJSON = invocation.call.argumentsJSON | |
| 96 | + self.phase = .finished | |
| 97 | + self.result = invocation.result | |
| 98 | + self.exitCode = invocation.exitCode | |
| 99 | + self.policyDecision = invocation.policyDecision | |
| 100 | + } | |
| 101 | +} | |
| 102 | + | |
| 103 | +/// One item of the live run timeline (step cards interleaved with | |
| 104 | +/// compaction notices, in event order). | |
| 105 | +enum RunEntry: Identifiable { | |
| 106 | + case step(LiveStep) | |
| 107 | + case compaction(CompactionRecord) | |
| 108 | + | |
| 109 | + var id: UUID { | |
| 110 | + switch self { | |
| 111 | + case .step(let step): return step.id | |
| 112 | + case .compaction(let record): return record.id | |
| 113 | + } | |
| 114 | + } | |
| 115 | +} | |
| 116 | + | |
| 117 | +/// One line of the Activity/Terminal drawer's live feed. | |
| 118 | +struct TerminalLine: Identifiable { | |
| 119 | + enum Kind { | |
| 120 | + case command | |
| 121 | + case stdout | |
| 122 | + case stderr | |
| 123 | + case note | |
| 124 | + /// cwd banner / run lifecycle marker. | |
| 125 | + case meta | |
| 126 | + } | |
| 127 | + | |
| 128 | + let id = UUID() | |
| 129 | + var kind: Kind | |
| 130 | + var text: String | |
| 131 | + var timestamp: Date = Date() | |
| 132 | +} | |
| 133 | + | |
| 134 | +// MARK: - RunController | |
| 135 | + | |
| 136 | +@MainActor | |
| 137 | +final class RunController: ObservableObject { | |
| 138 | + // Live run state | |
| 139 | + @Published private(set) var entries: [RunEntry] = [] | |
| 140 | + @Published private(set) var plan: TaskPlan? | |
| 141 | + @Published private(set) var runStatus: AgentRunStatus? | |
| 142 | + @Published private(set) var outcome: AgentRunOutcome? | |
| 143 | + @Published private(set) var pendingApproval: PendingApproval? | |
| 144 | + @Published private(set) var guardTrip: LoopGuardTrip? | |
| 145 | + @Published private(set) var isRunning = false | |
| 146 | + @Published private(set) var lastError: String? | |
| 147 | + | |
| 148 | + // Budgets & usage (Plan panel meters) | |
| 149 | + @Published private(set) var tokensUsed = 0 | |
| 150 | + @Published private(set) var stepsUsed = 0 | |
| 151 | + @Published private(set) var runStartedAt: Date? | |
| 152 | + let loopGuardConfiguration = LoopGuardConfiguration.default | |
| 153 | + | |
| 154 | + // Drawer feeds | |
| 155 | + @Published private(set) var terminalLines: [TerminalLine] = [] | |
| 156 | + @Published private(set) var auditEntries: [AuditEntry] = [] | |
| 157 | + @Published private(set) var workspaceFiles: [WorkspaceFileEntry] = [] | |
| 158 | + | |
| 159 | + // Info popover | |
| 160 | + @Published private(set) var systemPromptPreview: String? | |
| 161 | + | |
| 162 | + let taskID: AgentTask.ID | |
| 163 | + private let store: TaskStore | |
| 164 | + private let policyPersistence: PersistenceService | |
| 165 | + | |
| 166 | + private var loop: AgentLoop? | |
| 167 | + private var policy: PolicyEngine? | |
| 168 | + private var audit: AuditLog? | |
| 169 | + private var workspace: WorkspaceManager? | |
| 170 | + private var consumeTask: Task<Void, Never>? | |
| 171 | + | |
| 172 | + /// Terminal feed cap — append-only ring so hours-long runs stay light. | |
| 173 | + private static let terminalLineCap = 4000 | |
| 174 | + | |
| 175 | + init(taskID: AgentTask.ID, store: TaskStore, policyPersistence: PersistenceService = .shared) { | |
| 176 | + self.taskID = taskID | |
| 177 | + self.store = store | |
| 178 | + self.policyPersistence = policyPersistence | |
| 179 | + if let workspaceURL = store.task(id: taskID)?.workspaceURL, | |
| 180 | + let attached = try? WorkspaceManager(existingAt: workspaceURL) { | |
| 181 | + self.workspace = attached | |
| 182 | + self.audit = AuditLog(fileURL: attached.internalDirectory.appendingPathComponent("audit.jsonl")) | |
| 183 | + self.plan = store.task(id: taskID)?.messages.last(where: { $0.plan != nil })?.plan | |
| 184 | + refreshWorkspaceState() | |
| 185 | + } | |
| 186 | + } | |
| 187 | + | |
| 188 | + private var task: AgentTask? { store.task(id: taskID) } | |
| 189 | + | |
| 190 | + // MARK: - Starting a run | |
| 191 | + | |
| 192 | + /// Starts a run for the given prompt. `client`/`apiKey` are injectable | |
| 193 | + /// for the offline UI smoke test; app runs resolve them from the | |
| 194 | + /// ProviderRegistry and the encrypted vault / environment. | |
| 195 | + func start( | |
| 196 | + prompt: String, | |
| 197 | + model: AIModel, | |
| 198 | + client injectedClient: (any ProviderClient)? = nil, | |
| 199 | + apiKey injectedKey: String? = nil | |
| 200 | + ) { | |
| 201 | + guard !isRunning, var task else { return } | |
| 202 | + let trimmedPrompt = prompt.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 203 | + guard !trimmedPrompt.isEmpty else { return } | |
| 204 | + | |
| 205 | + guard model.capabilities.tools else { | |
| 206 | + lastError = "\(model.displayName) does not support tool calling and cannot run agent tasks. Pick an agent-capable model." | |
| 207 | + return | |
| 208 | + } | |
| 209 | + | |
| 210 | + let client = injectedClient ?? ProviderRegistry.client(for: model) | |
| 211 | + let apiKey: String | |
| 212 | + if let injectedKey { | |
| 213 | + apiKey = injectedKey | |
| 214 | + } else if let resolved = AgentCLI.resolveAPIKey(for: model.provider) { | |
| 215 | + apiKey = resolved | |
| 216 | + } else { | |
| 217 | + lastError = ProviderError.missingAPIKey(model.provider).localizedDescription | |
| 218 | + return | |
| 219 | + } | |
| 220 | + | |
| 221 | + // Workspace: reattach the task's existing one, or create it now. | |
| 222 | + let workspace: WorkspaceManager | |
| 223 | + do { | |
| 224 | + if let url = task.workspaceURL { | |
| 225 | + workspace = try WorkspaceManager(existingAt: url) | |
| 226 | + } else { | |
| 227 | + let title = task.title == "New Task" ? AgentTask.title(fromPrompt: trimmedPrompt) : task.title | |
| 228 | + workspace = try WorkspaceManager(taskTitle: title) | |
| 229 | + task.workspacePath = workspace.root.path | |
| 230 | + } | |
| 231 | + } catch { | |
| 232 | + lastError = "Could not prepare the workspace: \(error.localizedDescription)" | |
| 233 | + return | |
| 234 | + } | |
| 235 | + self.workspace = workspace | |
| 236 | + | |
| 237 | + // First prompt names the task. | |
| 238 | + if task.title == "New Task" { | |
| 239 | + task.title = AgentTask.title(fromPrompt: trimmedPrompt) | |
| 240 | + } | |
| 241 | + task.modelID = model.id | |
| 242 | + task.providerID = model.provider | |
| 243 | + task.messages.append(TaskMessage(kind: .user, text: trimmedPrompt)) | |
| 244 | + task.status = .planning | |
| 245 | + store.update(task) | |
| 246 | + | |
| 247 | + // Engine assembly — every action passes the policy gate + audit log. | |
| 248 | + let presenter = UIApprovalPresenter() | |
| 249 | + presenter.onRequest = { [weak self] pending in | |
| 250 | + Task { @MainActor in | |
| 251 | + self?.presentApproval(pending) | |
| 252 | + } | |
| 253 | + } | |
| 254 | + let policy = PolicyEngine(mode: task.safetyMode, approvals: presenter, persistence: policyPersistence) | |
| 255 | + let audit = AuditLog(fileURL: workspace.internalDirectory.appendingPathComponent("audit.jsonl")) | |
| 256 | + let executor = ExecutionService() | |
| 257 | + let tools = ToolRegistry.standard(executor: executor) | |
| 258 | + let loop = AgentLoop( | |
| 259 | + model: model, | |
| 260 | + client: client, | |
| 261 | + apiKey: apiKey, | |
| 262 | + tools: tools, | |
| 263 | + policy: policy, | |
| 264 | + audit: audit, | |
| 265 | + workspace: workspace | |
| 266 | + ) | |
| 267 | + self.policy = policy | |
| 268 | + self.audit = audit | |
| 269 | + self.loop = loop | |
| 270 | + self.systemPromptPreview = AgentSystemPrompt.build( | |
| 271 | + workspacePath: workspace.root.path, | |
| 272 | + toolNames: tools.toolNames, | |
| 273 | + safetyMode: task.safetyMode | |
| 274 | + ) | |
| 275 | + | |
| 276 | + // Reset live state. | |
| 277 | + entries = [] | |
| 278 | + outcome = nil | |
| 279 | + guardTrip = nil | |
| 280 | + pendingApproval = nil | |
| 281 | + lastError = nil | |
| 282 | + tokensUsed = 0 | |
| 283 | + stepsUsed = 0 | |
| 284 | + isRunning = true | |
| 285 | + runStartedAt = Date() | |
| 286 | + appendTerminal(.meta, "▶ run started — \(model.displayName) · \(task.safetyMode.displayName) mode") | |
| 287 | + appendTerminal(.meta, "cwd \(workspace.root.path)") | |
| 288 | + | |
| 289 | + // Consume the event stream on the MainActor (the stream itself is | |
| 290 | + // produced inside the AgentLoop actor; only the handling hops here). | |
| 291 | + consumeTask = Task { [weak self] in | |
| 292 | + do { | |
| 293 | + for try await event in await loop.run(task: trimmedPrompt) { | |
| 294 | + self?.handle(event) | |
| 295 | + } | |
| 296 | + } catch { | |
| 297 | + self?.finish(with: .failed(reason: error.localizedDescription)) | |
| 298 | + } | |
| 299 | + } | |
| 300 | + } | |
| 301 | + | |
| 302 | + // MARK: - Run control | |
| 303 | + | |
| 304 | + /// Stops the run (⌘. / Stop button): kills the in-flight model stream | |
| 305 | + /// and any executing process. | |
| 306 | + func cancel() { | |
| 307 | + pendingApproval?.resolve(.deny) | |
| 308 | + pendingApproval = nil | |
| 309 | + let loop = loop | |
| 310 | + Task { await loop?.cancel() } | |
| 311 | + } | |
| 312 | + | |
| 313 | + /// Answers a guard trip with "continue" (optionally raising budgets). | |
| 314 | + func resumeAfterTrip(raisingBudget: Bool = true) { | |
| 315 | + guardTrip = nil | |
| 316 | + updateTaskStatus(.running) | |
| 317 | + let loop = loop | |
| 318 | + Task { await loop?.resume(raisingBudget: raisingBudget) } | |
| 319 | + } | |
| 320 | + | |
| 321 | + /// Answers a guard trip with "stop". | |
| 322 | + func stopAfterTrip() { | |
| 323 | + guardTrip = nil | |
| 324 | + let loop = loop | |
| 325 | + Task { await loop?.stop() } | |
| 326 | + } | |
| 327 | + | |
| 328 | + /// Resolves the pending approval card. | |
| 329 | + func resolveApproval(_ resolution: ApprovalResolution) { | |
| 330 | + guard let pending = pendingApproval else { return } | |
| 331 | + pendingApproval = nil | |
| 332 | + mutateLastStep { step in | |
| 333 | + if step.status == .awaitingApproval { step.status = .executing } | |
| 334 | + } | |
| 335 | + if isRunning { | |
| 336 | + updateTaskStatus(.running) | |
| 337 | + } | |
| 338 | + pending.resolve(resolution) | |
| 339 | + } | |
| 340 | + | |
| 341 | + /// Switches the safety mode for this task (live runs switch immediately). | |
| 342 | + func setSafetyMode(_ mode: SafetyMode) { | |
| 343 | + guard var task, task.safetyMode != mode else { return } | |
| 344 | + task.safetyMode = mode | |
| 345 | + store.update(task, touch: false) | |
| 346 | + let policy = policy | |
| 347 | + Task { await policy?.setMode(mode) } | |
| 348 | + } | |
| 349 | + | |
| 350 | + // MARK: - Event handling (MainActor) | |
| 351 | + | |
| 352 | + private func handle(_ event: AgentEvent) { | |
| 353 | + switch event { | |
| 354 | + case .statusChanged(let status): | |
| 355 | + runStatus = status | |
| 356 | + switch status { | |
| 357 | + case .preparing: | |
| 358 | + updateTaskStatus(.planning) | |
| 359 | + case .running, .executingTools, .compacting: | |
| 360 | + if pendingApproval == nil { updateTaskStatus(.running) } | |
| 361 | + case .awaitingUser: | |
| 362 | + updateTaskStatus(.awaitingInput) | |
| 363 | + case .finished: | |
| 364 | + break // runFinished carries the outcome | |
| 365 | + } | |
| 366 | + | |
| 367 | + case .stepStarted(let step): | |
| 368 | + entries.append(.step(LiveStep(step: step))) | |
| 369 | + stepsUsed = max(stepsUsed, step.index) | |
| 370 | + | |
| 371 | + case .thinkingDelta(let stepID, let delta): | |
| 372 | + mutateStep(id: stepID) { $0.thinking += delta } | |
| 373 | + | |
| 374 | + case .textDelta(let stepID, let delta): | |
| 375 | + mutateStep(id: stepID) { $0.text += delta } | |
| 376 | + | |
| 377 | + case .toolCallStreaming(let stepID, let index, let id, let name): | |
| 378 | + mutateStep(id: stepID) { step in | |
| 379 | + var invocation = LiveInvocation(id: id, streamIndex: index, name: name, argumentsJSON: "") | |
| 380 | + invocation.phase = .streaming | |
| 381 | + step.invocations.append(invocation) | |
| 382 | + } | |
| 383 | + | |
| 384 | + case .toolCallArgumentsDelta(let stepID, let index, let delta): | |
| 385 | + mutateStep(id: stepID) { step in | |
| 386 | + if let i = step.invocations.lastIndex(where: { $0.streamIndex == index }) { | |
| 387 | + step.invocations[i].argumentsJSON += delta | |
| 388 | + } | |
| 389 | + } | |
| 390 | + | |
| 391 | + case .toolCallStarted(let stepID, let invocation): | |
| 392 | + mutateStep(id: stepID) { step in | |
| 393 | + if let i = step.invocations.firstIndex(where: { $0.id == invocation.call.id }) { | |
| 394 | + step.invocations[i].argumentsJSON = invocation.call.argumentsJSON | |
| 395 | + step.invocations[i].phase = .executing | |
| 396 | + } else { | |
| 397 | + var live = LiveInvocation( | |
| 398 | + id: invocation.call.id, | |
| 399 | + streamIndex: nil, | |
| 400 | + name: invocation.call.name, | |
| 401 | + argumentsJSON: invocation.call.argumentsJSON | |
| 402 | + ) | |
| 403 | + live.phase = .executing | |
| 404 | + step.invocations.append(live) | |
| 405 | + } | |
| 406 | + step.status = .executing | |
| 407 | + } | |
| 408 | + if let payload = payloadPreview(of: invocation.call) { | |
| 409 | + appendTerminal(.command, "$ \(payload)") | |
| 410 | + } | |
| 411 | + | |
| 412 | + case .toolOutput(let stepID, let callID, let chunk): | |
| 413 | + let line: TerminalLine | |
| 414 | + switch chunk { | |
| 415 | + case .stdout(let text): line = TerminalLine(kind: .stdout, text: text) | |
| 416 | + case .stderr(let text): line = TerminalLine(kind: .stderr, text: text) | |
| 417 | + case .note(let text): line = TerminalLine(kind: .note, text: text) | |
| 418 | + } | |
| 419 | + mutateStep(id: stepID) { step in | |
| 420 | + if let i = step.invocations.firstIndex(where: { $0.id == callID }) { | |
| 421 | + step.invocations[i].outputLines.append(line) | |
| 422 | + } | |
| 423 | + } | |
| 424 | + appendTerminal(line.kind, line.text) | |
| 425 | + | |
| 426 | + case .toolCallFinished(let stepID, let invocation): | |
| 427 | + mutateStep(id: stepID) { step in | |
| 428 | + if let i = step.invocations.firstIndex(where: { $0.id == invocation.call.id }) { | |
| 429 | + step.invocations[i].phase = .finished | |
| 430 | + step.invocations[i].result = invocation.result | |
| 431 | + step.invocations[i].exitCode = invocation.exitCode | |
| 432 | + step.invocations[i].policyDecision = invocation.policyDecision | |
| 433 | + } else { | |
| 434 | + step.invocations.append(LiveInvocation(invocation: invocation)) | |
| 435 | + } | |
| 436 | + } | |
| 437 | + | |
| 438 | + case .stepCompleted(let step): | |
| 439 | + mutateStep(id: step.id) { live in | |
| 440 | + live.status = step.status | |
| 441 | + live.finishedAt = step.finishedAt | |
| 442 | + live.inputTokens = step.inputTokens | |
| 443 | + live.outputTokens = step.outputTokens | |
| 444 | + if !step.text.isEmpty { live.text = step.text } | |
| 445 | + if let thinking = step.thinking { live.thinking = thinking } | |
| 446 | + for invocation in step.toolInvocations { | |
| 447 | + if let i = live.invocations.firstIndex(where: { $0.id == invocation.call.id }) { | |
| 448 | + live.invocations[i].result = invocation.result | |
| 449 | + live.invocations[i].exitCode = invocation.exitCode | |
| 450 | + live.invocations[i].policyDecision = invocation.policyDecision | |
| 451 | + live.invocations[i].phase = .finished | |
| 452 | + } | |
| 453 | + } | |
| 454 | + } | |
| 455 | + tokensUsed += (step.inputTokens ?? 0) + (step.outputTokens ?? 0) | |
| 456 | + refreshWorkspaceState() | |
| 457 | + | |
| 458 | + case .planUpdated(let updated): | |
| 459 | + plan = updated | |
| 460 | + | |
| 461 | + case .guardTripped(let trip): | |
| 462 | + guardTrip = trip | |
| 463 | + appendTerminal(.meta, "⏸ loop guard [\(trip.reason.rawValue)] — \(trip.message)") | |
| 464 | + | |
| 465 | + case .compactionPerformed(let record): | |
| 466 | + entries.append(.compaction(record)) | |
| 467 | + appendTerminal(.meta, "⟳ compacted \(record.summarizedSteps) step(s): ~\(record.beforeTokens) → ~\(record.afterTokens) tokens") | |
| 468 | + | |
| 469 | + case .runFinished(let outcome): | |
| 470 | + finish(with: outcome) | |
| 471 | + } | |
| 472 | + } | |
| 473 | + | |
| 474 | + private func presentApproval(_ pending: PendingApproval) { | |
| 475 | + pendingApproval = pending | |
| 476 | + mutateLastStep { $0.status = .awaitingApproval } | |
| 477 | + updateTaskStatus(.awaitingApproval) | |
| 478 | + appendTerminal(.meta, "⚠ approval required: \(pending.action.payload)") | |
| 479 | + } | |
| 480 | + | |
| 481 | + private func finish(with outcome: AgentRunOutcome) { | |
| 482 | + self.outcome = outcome | |
| 483 | + isRunning = false | |
| 484 | + consumeTask = nil | |
| 485 | + loop = nil | |
| 486 | + | |
| 487 | + // Fold the finished run into the task's persisted history so past | |
| 488 | + // runs re-render exactly, then clear the live timeline. | |
| 489 | + var steps: [AgentStep] = [] | |
| 490 | + if let workspaceURL = task?.workspaceURL, | |
| 491 | + let transcript = Transcript.load(from: workspaceURL) { | |
| 492 | + steps = transcript.steps | |
| 493 | + } | |
| 494 | + | |
| 495 | + let finalText: String | |
| 496 | + let status: AgentTaskStatus | |
| 497 | + switch outcome { | |
| 498 | + case .completed(let answer): | |
| 499 | + finalText = answer | |
| 500 | + status = .done | |
| 501 | + appendTerminal(.meta, "✔ run completed") | |
| 502 | + case .failed(let reason): | |
| 503 | + finalText = reason | |
| 504 | + status = .failed | |
| 505 | + appendTerminal(.meta, "✘ run failed — \(reason)") | |
| 506 | + case .cancelled: | |
| 507 | + finalText = "Run cancelled." | |
| 508 | + status = .idle | |
| 509 | + appendTerminal(.meta, "■ run cancelled") | |
| 510 | + case .stoppedByUser(let reason): | |
| 511 | + finalText = "Run stopped — \(reason)" | |
| 512 | + status = .idle | |
| 513 | + appendTerminal(.meta, "■ run stopped — \(reason)") | |
| 514 | + } | |
| 515 | + | |
| 516 | + if var task { | |
| 517 | + task.messages.append(TaskMessage( | |
| 518 | + kind: .agentRun, | |
| 519 | + text: finalText, | |
| 520 | + steps: steps, | |
| 521 | + plan: plan, | |
| 522 | + outcome: outcome | |
| 523 | + )) | |
| 524 | + task.status = status | |
| 525 | + store.update(task) | |
| 526 | + } | |
| 527 | + entries = [] | |
| 528 | + pendingApproval = nil | |
| 529 | + guardTrip = nil | |
| 530 | + refreshWorkspaceState() | |
| 531 | + refreshAudit() | |
| 532 | + } | |
| 533 | + | |
| 534 | + // MARK: - Drawer refresh | |
| 535 | + | |
| 536 | + /// Reloads the Files tab (agent-touched files with badges). | |
| 537 | + func refreshWorkspaceState() { | |
| 538 | + guard let workspace else { return } | |
| 539 | + Task { [weak self] in | |
| 540 | + await workspace.refreshScan() | |
| 541 | + let files = await workspace.files() | |
| 542 | + self?.workspaceFiles = files | |
| 543 | + } | |
| 544 | + } | |
| 545 | + | |
| 546 | + /// Reloads the Audit tab from the workspace's append-only JSONL log. | |
| 547 | + func refreshAudit() { | |
| 548 | + guard let audit else { return } | |
| 549 | + Task { [weak self] in | |
| 550 | + let entries = await audit.entries() | |
| 551 | + self?.auditEntries = entries | |
| 552 | + } | |
| 553 | + } | |
| 554 | + | |
| 555 | + /// The workspace root, when one exists (Files tab, workspace chip). | |
| 556 | + var workspaceRoot: URL? { workspace?.root ?? task?.workspaceURL } | |
| 557 | + | |
| 558 | + /// Renames a plan item from the Plan panel. Display-side only: the agent | |
| 559 | + /// re-reads the plan on its next `update_plan` call, and the edit is | |
| 560 | + /// carried in the plan attached to the run's history entry. | |
| 561 | + func renamePlanItem(id: UUID, to title: String) { | |
| 562 | + guard var plan else { return } | |
| 563 | + let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 564 | + guard !trimmed.isEmpty, | |
| 565 | + let index = plan.items.firstIndex(where: { $0.id == id }) else { return } | |
| 566 | + plan.items[index].title = trimmed | |
| 567 | + plan.updatedAt = Date() | |
| 568 | + self.plan = plan | |
| 569 | + } | |
| 570 | + | |
| 571 | + // MARK: - Helpers | |
| 572 | + | |
| 573 | + private func mutateStep(id: UUID, _ mutate: (inout LiveStep) -> Void) { | |
| 574 | + for index in entries.indices.reversed() { | |
| 575 | + if case .step(var step) = entries[index], step.id == id { | |
| 576 | + mutate(&step) | |
| 577 | + entries[index] = .step(step) | |
| 578 | + return | |
| 579 | + } | |
| 580 | + } | |
| 581 | + } | |
| 582 | + | |
| 583 | + private func mutateLastStep(_ mutate: (inout LiveStep) -> Void) { | |
| 584 | + for index in entries.indices.reversed() { | |
| 585 | + if case .step(var step) = entries[index] { | |
| 586 | + mutate(&step) | |
| 587 | + entries[index] = .step(step) | |
| 588 | + return | |
| 589 | + } | |
| 590 | + } | |
| 591 | + } | |
| 592 | + | |
| 593 | + private func updateTaskStatus(_ status: AgentTaskStatus) { | |
| 594 | + store.setStatus(status, for: taskID) | |
| 595 | + } | |
| 596 | + | |
| 597 | + private func appendTerminal(_ kind: TerminalLine.Kind, _ text: String) { | |
| 598 | + terminalLines.append(TerminalLine(kind: kind, text: text)) | |
| 599 | + if terminalLines.count > Self.terminalLineCap { | |
| 600 | + terminalLines.removeFirst(terminalLines.count - Self.terminalLineCap) | |
| 601 | + } | |
| 602 | + } | |
| 603 | + | |
| 604 | + private func payloadPreview(of call: ToolCall) -> String? { | |
| 605 | + guard let arguments = call.argumentsDictionary else { return call.name } | |
| 606 | + for key in ["command", "script", "path"] { | |
| 607 | + if let value = arguments[key] as? String { | |
| 608 | + return call.name == "bash" ? value : "\(call.name): \(value)" | |
| 609 | + } | |
| 610 | + } | |
| 611 | + return call.name | |
| 612 | + } | |
| 613 | +} | |
| 614 | + | |
| 615 | +// MARK: - RunHub | |
| 616 | + | |
| 617 | +/// Keeps one RunController per task alive for the app session, so a run keeps | |
| 618 | +/// streaming (and stays cancellable) while the user browses other tasks. | |
| 619 | +@MainActor | |
| 620 | +final class RunHub: ObservableObject { | |
| 621 | + private var controllers: [AgentTask.ID: RunController] = [:] | |
| 622 | + private let store: TaskStore | |
| 623 | + | |
| 624 | + init(store: TaskStore) { | |
| 625 | + self.store = store | |
| 626 | + } | |
| 627 | + | |
| 628 | + func controller(for taskID: AgentTask.ID) -> RunController { | |
| 629 | + if let existing = controllers[taskID] { return existing } | |
| 630 | + let controller = RunController(taskID: taskID, store: store) | |
| 631 | + controllers[taskID] = controller | |
| 632 | + return controller | |
| 633 | + } | |
| 634 | + | |
| 635 | + /// Drops the controller for a deleted task (cancelling any live run). | |
| 636 | + func remove(taskID: AgentTask.ID) { | |
| 637 | + controllers[taskID]?.cancel() | |
| 638 | + controllers[taskID] = nil | |
| 639 | + } | |
| 640 | + | |
| 641 | + /// The controller of any task currently running (menu-bar/⌘. targets). | |
| 642 | + var runningControllers: [RunController] { | |
| 643 | + controllers.values.filter(\.isRunning) | |
| 644 | + } | |
| 645 | +} | |
added
Sources/ZyquoAgent/ViewModels/TaskStore.swift
+196 −0
@@ -0,0 +1,196 @@ | ||
| 1 | +// | |
| 2 | +// TaskStore.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// CRUD + persistence for AgentTasks. Each task is saved as its own JSON | |
| 9 | +// document under `Tasks/<uuid>.json` in the app data folder (small atomic | |
| 10 | +// writes even when a task embeds a long step history). Provides the sidebar | |
| 11 | +// grouping (Pinned/Today/Yesterday/Previous 7 Days/Older) and full-text | |
| 12 | +// search across titles and message history. | |
| 13 | +// | |
| 14 | + | |
| 15 | +import Combine | |
| 16 | +import Foundation | |
| 17 | + | |
| 18 | +@MainActor | |
| 19 | +final class TaskStore: ObservableObject { | |
| 20 | + @Published private(set) var tasks: [AgentTask] = [] | |
| 21 | + @Published var selectedID: AgentTask.ID? | |
| 22 | + @Published var searchText = "" | |
| 23 | + /// Prompt handed from the empty state to the next opened task's input | |
| 24 | + /// bar (consumed once by the detail view). | |
| 25 | + @Published var pendingDraft: String? | |
| 26 | + | |
| 27 | + private let persistence: PersistenceService | |
| 28 | + private static let tasksSubdirectory = "Tasks" | |
| 29 | + | |
| 30 | + init(persistence: PersistenceService = .shared) { | |
| 31 | + self.persistence = persistence | |
| 32 | + try? FileManager.default.createDirectory( | |
| 33 | + at: tasksDirectory, withIntermediateDirectories: true | |
| 34 | + ) | |
| 35 | + loadAll() | |
| 36 | + } | |
| 37 | + | |
| 38 | + private var tasksDirectory: URL { | |
| 39 | + persistence.rootDirectory.appendingPathComponent(Self.tasksSubdirectory) | |
| 40 | + } | |
| 41 | + | |
| 42 | + // MARK: - CRUD | |
| 43 | + | |
| 44 | + /// Creates a new task and selects it. | |
| 45 | + @discardableResult | |
| 46 | + func newTask(model: AIModel?, safetyMode: SafetyMode = .guarded) -> AgentTask { | |
| 47 | + let task = AgentTask( | |
| 48 | + title: "New Task", | |
| 49 | + modelID: model?.id ?? "", | |
| 50 | + providerID: model?.provider ?? .anthropic, | |
| 51 | + safetyMode: safetyMode | |
| 52 | + ) | |
| 53 | + tasks.insert(task, at: 0) | |
| 54 | + save(task) | |
| 55 | + selectedID = task.id | |
| 56 | + return task | |
| 57 | + } | |
| 58 | + | |
| 59 | + func task(id: AgentTask.ID) -> AgentTask? { | |
| 60 | + tasks.first { $0.id == id } | |
| 61 | + } | |
| 62 | + | |
| 63 | + /// Replaces the stored task and persists it; bumps `updatedAt`. | |
| 64 | + func update(_ task: AgentTask, touch: Bool = true) { | |
| 65 | + var task = task | |
| 66 | + if touch { task.updatedAt = Date() } | |
| 67 | + guard let index = tasks.firstIndex(where: { $0.id == task.id }) else { return } | |
| 68 | + tasks[index] = task | |
| 69 | + save(task) | |
| 70 | + } | |
| 71 | + | |
| 72 | + func rename(_ id: AgentTask.ID, to title: String) { | |
| 73 | + guard var task = task(id: id) else { return } | |
| 74 | + let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 75 | + guard !trimmed.isEmpty else { return } | |
| 76 | + task.title = trimmed | |
| 77 | + update(task) | |
| 78 | + } | |
| 79 | + | |
| 80 | + func togglePin(_ id: AgentTask.ID) { | |
| 81 | + guard var task = task(id: id) else { return } | |
| 82 | + task.pinned.toggle() | |
| 83 | + update(task, touch: false) | |
| 84 | + } | |
| 85 | + | |
| 86 | + func setStatus(_ status: AgentTaskStatus, for id: AgentTask.ID) { | |
| 87 | + guard var task = task(id: id), task.status != status else { return } | |
| 88 | + task.status = status | |
| 89 | + update(task, touch: false) | |
| 90 | + } | |
| 91 | + | |
| 92 | + func appendMessage(_ message: TaskMessage, to id: AgentTask.ID) { | |
| 93 | + guard var task = task(id: id) else { return } | |
| 94 | + task.messages.append(message) | |
| 95 | + update(task) | |
| 96 | + } | |
| 97 | + | |
| 98 | + /// Deletes the task record and, when requested, its workspace folder. | |
| 99 | + func delete(_ id: AgentTask.ID, deleteWorkspace: Bool) { | |
| 100 | + guard let task = task(id: id) else { return } | |
| 101 | + if deleteWorkspace, let workspace = task.workspaceURL { | |
| 102 | + // Only ever remove directories that live under the managed | |
| 103 | + // Workspaces/ root — never an arbitrary user path. | |
| 104 | + let root = persistence.workspacesDirectory.standardizedFileURL.path | |
| 105 | + let target = workspace.standardizedFileURL.path | |
| 106 | + if target.hasPrefix(root + "/") { | |
| 107 | + try? FileManager.default.removeItem(at: workspace) | |
| 108 | + } | |
| 109 | + } | |
| 110 | + tasks.removeAll { $0.id == id } | |
| 111 | + try? FileManager.default.removeItem(at: fileURL(for: id)) | |
| 112 | + if selectedID == id { selectedID = nil } | |
| 113 | + } | |
| 114 | + | |
| 115 | + // MARK: - Sidebar grouping & search | |
| 116 | + | |
| 117 | + struct SidebarGroup: Identifiable { | |
| 118 | + var id: String | |
| 119 | + var title: String | |
| 120 | + var tasks: [AgentTask] | |
| 121 | + } | |
| 122 | + | |
| 123 | + /// Tasks matching the search text (title + message history), newest first. | |
| 124 | + var filteredTasks: [AgentTask] { | |
| 125 | + let query = searchText.trimmingCharacters(in: .whitespaces) | |
| 126 | + let sorted = tasks.sorted { $0.updatedAt > $1.updatedAt } | |
| 127 | + guard !query.isEmpty else { return sorted } | |
| 128 | + return sorted.filter { task in | |
| 129 | + if task.title.localizedCaseInsensitiveContains(query) { return true } | |
| 130 | + return task.messages.contains { $0.text.localizedCaseInsensitiveContains(query) } | |
| 131 | + } | |
| 132 | + } | |
| 133 | + | |
| 134 | + /// Pinned / Today / Yesterday / Previous 7 Days / Older. | |
| 135 | + var sidebarGroups: [SidebarGroup] { | |
| 136 | + let calendar = Calendar.current | |
| 137 | + let now = Date() | |
| 138 | + var pinned: [AgentTask] = [] | |
| 139 | + var today: [AgentTask] = [] | |
| 140 | + var yesterday: [AgentTask] = [] | |
| 141 | + var previousWeek: [AgentTask] = [] | |
| 142 | + var older: [AgentTask] = [] | |
| 143 | + | |
| 144 | + for task in filteredTasks { | |
| 145 | + if task.pinned { | |
| 146 | + pinned.append(task) | |
| 147 | + } else if calendar.isDateInToday(task.updatedAt) { | |
| 148 | + today.append(task) | |
| 149 | + } else if calendar.isDateInYesterday(task.updatedAt) { | |
| 150 | + yesterday.append(task) | |
| 151 | + } else if let days = calendar.dateComponents([.day], from: task.updatedAt, to: now).day, days < 7 { | |
| 152 | + previousWeek.append(task) | |
| 153 | + } else { | |
| 154 | + older.append(task) | |
| 155 | + } | |
| 156 | + } | |
| 157 | + | |
| 158 | + return [ | |
| 159 | + SidebarGroup(id: "pinned", title: "Pinned", tasks: pinned), | |
| 160 | + SidebarGroup(id: "today", title: "Today", tasks: today), | |
| 161 | + SidebarGroup(id: "yesterday", title: "Yesterday", tasks: yesterday), | |
| 162 | + SidebarGroup(id: "week", title: "Previous 7 Days", tasks: previousWeek), | |
| 163 | + SidebarGroup(id: "older", title: "Older", tasks: older), | |
| 164 | + ].filter { !$0.tasks.isEmpty } | |
| 165 | + } | |
| 166 | + | |
| 167 | + // MARK: - Persistence | |
| 168 | + | |
| 169 | + private func fileURL(for id: AgentTask.ID) -> URL { | |
| 170 | + tasksDirectory.appendingPathComponent("\(id.uuidString).json") | |
| 171 | + } | |
| 172 | + | |
| 173 | + private func save(_ task: AgentTask) { | |
| 174 | + persistence.save(task, to: "\(Self.tasksSubdirectory)/\(task.id.uuidString).json") | |
| 175 | + } | |
| 176 | + | |
| 177 | + private func loadAll() { | |
| 178 | + let files = (try? FileManager.default.contentsOfDirectory( | |
| 179 | + at: tasksDirectory, includingPropertiesForKeys: nil | |
| 180 | + )) ?? [] | |
| 181 | + var loaded: [AgentTask] = [] | |
| 182 | + for file in files where file.pathExtension == "json" { | |
| 183 | + if let task = persistence.load(AgentTask.self, from: "\(Self.tasksSubdirectory)/\(file.lastPathComponent)") { | |
| 184 | + loaded.append(task) | |
| 185 | + } | |
| 186 | + } | |
| 187 | + // A crash mid-run must not leave phantom "Running" pills behind. | |
| 188 | + tasks = loaded | |
| 189 | + .map { task in | |
| 190 | + var task = task | |
| 191 | + if task.status.isActive { task.status = .idle } | |
| 192 | + return task | |
| 193 | + } | |
| 194 | + .sorted { $0.updatedAt > $1.updatedAt } | |
| 195 | + } | |
| 196 | +} | |
added
Sources/ZyquoAgent/ViewModels/UIApprovalPresenter.swift
+75 −0
@@ -0,0 +1,75 @@ | ||
| 1 | +// | |
| 2 | +// UIApprovalPresenter.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Bridges the PolicyEngine's ApprovalPresenting protocol to the UI: when the | |
| 9 | +// gate holds an action, the presenter parks the engine on a | |
| 10 | +// CheckedContinuation and publishes a PendingApproval to the RunController; | |
| 11 | +// the inline approval card resolves it (Approve / Approve & remember / Edit / | |
| 12 | +// Deny). Resolution is single-shot — a second resolve is ignored. | |
| 13 | +// | |
| 14 | + | |
| 15 | +import Foundation | |
| 16 | + | |
| 17 | +/// One approval request surfaced to the UI, carrying its resolution hook. | |
| 18 | +struct PendingApproval: Identifiable, Sendable { | |
| 19 | + let id = UUID() | |
| 20 | + let action: ActionRequest | |
| 21 | + let risk: RiskAssessment | |
| 22 | + private let resolver: ApprovalResolver | |
| 23 | + | |
| 24 | + init(action: ActionRequest, risk: RiskAssessment, continuation: CheckedContinuation<ApprovalResolution, Never>) { | |
| 25 | + self.action = action | |
| 26 | + self.risk = risk | |
| 27 | + self.resolver = ApprovalResolver(continuation: continuation) | |
| 28 | + } | |
| 29 | + | |
| 30 | + /// Resolves the request exactly once; later calls are no-ops. | |
| 31 | + func resolve(_ resolution: ApprovalResolution) { | |
| 32 | + resolver.resolve(resolution) | |
| 33 | + } | |
| 34 | +} | |
| 35 | + | |
| 36 | +/// Single-shot continuation wrapper (thread-safe). | |
| 37 | +private final class ApprovalResolver: @unchecked Sendable { | |
| 38 | + private let lock = NSLock() | |
| 39 | + private var continuation: CheckedContinuation<ApprovalResolution, Never>? | |
| 40 | + | |
| 41 | + init(continuation: CheckedContinuation<ApprovalResolution, Never>) { | |
| 42 | + self.continuation = continuation | |
| 43 | + } | |
| 44 | + | |
| 45 | + func resolve(_ resolution: ApprovalResolution) { | |
| 46 | + lock.lock() | |
| 47 | + let continuation = self.continuation | |
| 48 | + self.continuation = nil | |
| 49 | + lock.unlock() | |
| 50 | + continuation?.resume(returning: resolution) | |
| 51 | + } | |
| 52 | +} | |
| 53 | + | |
| 54 | +/// The ApprovalPresenting implementation the UI wires into the PolicyEngine. | |
| 55 | +/// `onRequest` is installed by the RunController and hops to the MainActor. | |
| 56 | +final class UIApprovalPresenter: ApprovalPresenting, @unchecked Sendable { | |
| 57 | + private let lock = NSLock() | |
| 58 | + private var _onRequest: (@Sendable (PendingApproval) -> Void)? | |
| 59 | + | |
| 60 | + /// Installed by the RunController before the run starts. | |
| 61 | + var onRequest: (@Sendable (PendingApproval) -> Void)? { | |
| 62 | + get { lock.lock(); defer { lock.unlock() }; return _onRequest } | |
| 63 | + set { lock.lock(); defer { lock.unlock() }; _onRequest = newValue } | |
| 64 | + } | |
| 65 | + | |
| 66 | + func requestApproval(for action: ActionRequest, risk: RiskAssessment) async -> ApprovalResolution { | |
| 67 | + guard let onRequest else { | |
| 68 | + // No UI attached (should not happen in app runs): fail closed. | |
| 69 | + return .deny | |
| 70 | + } | |
| 71 | + return await withCheckedContinuation { continuation in | |
| 72 | + onRequest(PendingApproval(action: action, risk: risk, continuation: continuation)) | |
| 73 | + } | |
| 74 | + } | |
| 75 | +} | |
added
Sources/ZyquoAgent/Views/Conversation/ApprovalCardView.swift
+262 −0
@@ -0,0 +1,262 @@ | ||
| 1 | +// | |
| 2 | +// ApprovalCardView.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// The inline approval card: kind, the EXACT payload in monospace, risk level | |
| 9 | +// + reason, cwd, model explanation, and the resolution buttons — Approve / | |
| 10 | +// Approve & remember (safe shell classes only) / Edit (inline editor) / | |
| 11 | +// Deny. The border pulses gently to draw attention; the run is parked on | |
| 12 | +// the PolicyEngine's continuation until a button resolves it. | |
| 13 | +// | |
| 14 | + | |
| 15 | +import SwiftUI | |
| 16 | + | |
| 17 | +struct ApprovalCardView: View { | |
| 18 | + let approval: PendingApproval | |
| 19 | + var onResolve: (ApprovalResolution) -> Void | |
| 20 | + | |
| 21 | + @State private var editing = false | |
| 22 | + @State private var editedPayload = "" | |
| 23 | + @State private var pulsing = false | |
| 24 | + | |
| 25 | + var body: some View { | |
| 26 | + VStack(alignment: .leading, spacing: ZyquoSpacing.sm) { | |
| 27 | + header | |
| 28 | + payloadBlock | |
| 29 | + details | |
| 30 | + if editing { | |
| 31 | + editor | |
| 32 | + } | |
| 33 | + buttons | |
| 34 | + } | |
| 35 | + .padding(ZyquoSpacing.md) | |
| 36 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 37 | + .background( | |
| 38 | + RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous) | |
| 39 | + .fill(ZyquoColor.warning.opacity(0.06)) | |
| 40 | + .overlay( | |
| 41 | + RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous) | |
| 42 | + .strokeBorder(ZyquoColor.warning.opacity(pulsing ? 0.9 : 0.4), lineWidth: 1) | |
| 43 | + ) | |
| 44 | + ) | |
| 45 | + .onAppear { | |
| 46 | + withAnimation(ZyquoMotion.pulse) { pulsing = true } | |
| 47 | + } | |
| 48 | + .transition(.opacity.combined(with: .offset(y: ZyquoMotion.appearRise))) | |
| 49 | + } | |
| 50 | + | |
| 51 | + // MARK: Pieces | |
| 52 | + | |
| 53 | + private var header: some View { | |
| 54 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 55 | + Image(systemName: "hand.raised.fill") | |
| 56 | + .font(.system(size: 12)) | |
| 57 | + .foregroundStyle(ZyquoColor.warning) | |
| 58 | + Text("Approval required — \(kindLabel)") | |
| 59 | + .font(ZyquoFont.bodyEmphasis()) | |
| 60 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 61 | + Spacer(minLength: 0) | |
| 62 | + ZyquoBadge(text: approval.risk.level.rawValue, color: riskColor) | |
| 63 | + } | |
| 64 | + } | |
| 65 | + | |
| 66 | + private var payloadBlock: some View { | |
| 67 | + ScrollView(.horizontal, showsIndicators: false) { | |
| 68 | + Text(approval.action.payload) | |
| 69 | + .font(ZyquoFont.code()) | |
| 70 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 71 | + .textSelection(.enabled) | |
| 72 | + .padding(ZyquoSpacing.sm) | |
| 73 | + } | |
| 74 | + .background( | |
| 75 | + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) | |
| 76 | + .fill(ZyquoColor.surfaceSecondary) | |
| 77 | + ) | |
| 78 | + } | |
| 79 | + | |
| 80 | + private var details: some View { | |
| 81 | + VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) { | |
| 82 | + detailRow(label: "Risk", value: approval.risk.reason, color: riskColor) | |
| 83 | + detailRow(label: "Directory", value: approval.action.cwd.path, color: ZyquoColor.textSecondary) | |
| 84 | + if let explanation = approval.action.explanation, !explanation.isEmpty { | |
| 85 | + detailRow(label: "Why", value: explanation, color: ZyquoColor.textSecondary) | |
| 86 | + } | |
| 87 | + } | |
| 88 | + } | |
| 89 | + | |
| 90 | + private func detailRow(label: String, value: String, color: Color) -> some View { | |
| 91 | + HStack(alignment: .firstTextBaseline, spacing: ZyquoSpacing.xs) { | |
| 92 | + Text(label) | |
| 93 | + .font(ZyquoFont.caption) | |
| 94 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 95 | + .frame(width: 60, alignment: .trailing) | |
| 96 | + Text(value) | |
| 97 | + .font(ZyquoFont.body(size: 12.5)) | |
| 98 | + .foregroundStyle(color) | |
| 99 | + .textSelection(.enabled) | |
| 100 | + .fixedSize(horizontal: false, vertical: true) | |
| 101 | + } | |
| 102 | + } | |
| 103 | + | |
| 104 | + private var editor: some View { | |
| 105 | + VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) { | |
| 106 | + Text("Edit before approving — the edited command is re-checked by the safety policy.") | |
| 107 | + .font(ZyquoFont.caption) | |
| 108 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 109 | + TextEditor(text: $editedPayload) | |
| 110 | + .font(ZyquoFont.code()) | |
| 111 | + .scrollContentBackground(.hidden) | |
| 112 | + .frame(minHeight: 48, maxHeight: 140) | |
| 113 | + .padding(ZyquoSpacing.xxs) | |
| 114 | + .background( | |
| 115 | + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) | |
| 116 | + .fill(ZyquoColor.surfaceSecondary) | |
| 117 | + ) | |
| 118 | + } | |
| 119 | + } | |
| 120 | + | |
| 121 | + private var buttons: some View { | |
| 122 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 123 | + if editing { | |
| 124 | + Button("Approve Edited") { | |
| 125 | + let trimmed = editedPayload.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 126 | + guard !trimmed.isEmpty else { return } | |
| 127 | + onResolve(.approveEdited(trimmed)) | |
| 128 | + } | |
| 129 | + .buttonStyle(.borderedProminent) | |
| 130 | + .disabled(editedPayload.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) | |
| 131 | + Button("Cancel Edit") { | |
| 132 | + withAnimation(ZyquoMotion.appear) { editing = false } | |
| 133 | + } | |
| 134 | + } else { | |
| 135 | + Button("Approve") { onResolve(.approve) } | |
| 136 | + .buttonStyle(.borderedProminent) | |
| 137 | + .keyboardShortcut(.defaultAction) | |
| 138 | + if canRemember { | |
| 139 | + Button("Approve & Remember") { onResolve(.approveAndRemember) } | |
| 140 | + .help("Also remember a narrow allow rule for this command class") | |
| 141 | + } | |
| 142 | + Button("Edit") { | |
| 143 | + editedPayload = approval.action.payload | |
| 144 | + withAnimation(ZyquoMotion.appear) { editing = true } | |
| 145 | + } | |
| 146 | + } | |
| 147 | + Spacer(minLength: 0) | |
| 148 | + Button(role: .destructive) { | |
| 149 | + onResolve(.deny) | |
| 150 | + } label: { | |
| 151 | + Text("Deny") | |
| 152 | + .foregroundStyle(ZyquoColor.danger) | |
| 153 | + } | |
| 154 | + .keyboardShortcut(.cancelAction) | |
| 155 | + } | |
| 156 | + .controlSize(.regular) | |
| 157 | + } | |
| 158 | + | |
| 159 | + // MARK: Helpers | |
| 160 | + | |
| 161 | + private var kindLabel: String { | |
| 162 | + switch approval.action.kind { | |
| 163 | + case .shellCommand: return "shell command" | |
| 164 | + case .appleScript: return "AppleScript" | |
| 165 | + case .fileWrite: return "file write (workspace)" | |
| 166 | + case .fileWriteOutsideWorkspace: return "file write OUTSIDE the workspace" | |
| 167 | + case .fileReadOutsideWorkspace: return "file read OUTSIDE the workspace" | |
| 168 | + } | |
| 169 | + } | |
| 170 | + | |
| 171 | + /// "Approve & remember" persists per-subcommand allow rules — only shell | |
| 172 | + /// commands have a narrow, stable pattern to remember, and destructive/ | |
| 173 | + /// elevated classes can never be remembered away. | |
| 174 | + private var canRemember: Bool { | |
| 175 | + approval.action.kind == .shellCommand | |
| 176 | + && approval.risk.level != .destructive | |
| 177 | + && approval.risk.level != .elevated | |
| 178 | + } | |
| 179 | + | |
| 180 | + private var riskColor: Color { | |
| 181 | + switch approval.risk.level { | |
| 182 | + case .safe: return ZyquoColor.success | |
| 183 | + case .mutating: return ZyquoColor.warning | |
| 184 | + case .destructive, .elevated: return ZyquoColor.danger | |
| 185 | + } | |
| 186 | + } | |
| 187 | +} | |
| 188 | + | |
| 189 | +// MARK: - LoopGuard trip card | |
| 190 | + | |
| 191 | +/// Inline pause card for a LoopGuard trip: reason + Continue (raise budget) | |
| 192 | +/// or Stop. | |
| 193 | +struct GuardTripCardView: View { | |
| 194 | + let trip: LoopGuardTrip | |
| 195 | + var onContinue: () -> Void | |
| 196 | + var onStop: () -> Void | |
| 197 | + | |
| 198 | + var body: some View { | |
| 199 | + VStack(alignment: .leading, spacing: ZyquoSpacing.sm) { | |
| 200 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 201 | + Image(systemName: "pause.circle.fill") | |
| 202 | + .font(.system(size: 12)) | |
| 203 | + .foregroundStyle(ZyquoColor.warning) | |
| 204 | + Text("Run paused — \(tripTitle)") | |
| 205 | + .font(ZyquoFont.bodyEmphasis()) | |
| 206 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 207 | + Spacer(minLength: 0) | |
| 208 | + Text("step \(trip.stepIndex)") | |
| 209 | + .font(ZyquoFont.caption) | |
| 210 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 211 | + } | |
| 212 | + Text(trip.message) | |
| 213 | + .font(ZyquoFont.body(size: 12.5)) | |
| 214 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 215 | + .fixedSize(horizontal: false, vertical: true) | |
| 216 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 217 | + Button("Continue with raised budget") { onContinue() } | |
| 218 | + .buttonStyle(.borderedProminent) | |
| 219 | + Button("Stop") { onStop() } | |
| 220 | + Spacer(minLength: 0) | |
| 221 | + } | |
| 222 | + } | |
| 223 | + .padding(ZyquoSpacing.md) | |
| 224 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 225 | + .background( | |
| 226 | + RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous) | |
| 227 | + .fill(ZyquoColor.warning.opacity(0.06)) | |
| 228 | + .overlay( | |
| 229 | + RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous) | |
| 230 | + .strokeBorder(ZyquoColor.warning.opacity(0.5), lineWidth: 1) | |
| 231 | + ) | |
| 232 | + ) | |
| 233 | + .transition(.opacity.combined(with: .offset(y: ZyquoMotion.appearRise))) | |
| 234 | + } | |
| 235 | + | |
| 236 | + private var tripTitle: String { | |
| 237 | + switch trip.reason { | |
| 238 | + case .maxSteps: return "step limit reached" | |
| 239 | + case .tokenBudget: return "token budget reached" | |
| 240 | + case .wallClockBudget: return "time budget reached" | |
| 241 | + case .repetition: return "repeating a failing action" | |
| 242 | + case .stall: return "no progress detected" | |
| 243 | + } | |
| 244 | + } | |
| 245 | +} | |
| 246 | + | |
| 247 | +/// One-line compaction notice in the transcript. | |
| 248 | +struct CompactionNoticeView: View { | |
| 249 | + let record: CompactionRecord | |
| 250 | + | |
| 251 | + var body: some View { | |
| 252 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 253 | + Image(systemName: "arrow.triangle.2.circlepath") | |
| 254 | + .font(.system(size: 10)) | |
| 255 | + Text("Compacted \(record.summarizedSteps) step\(record.summarizedSteps == 1 ? "" : "s") — ~\(record.beforeTokens) → ~\(record.afterTokens) tokens in context") | |
| 256 | + .font(ZyquoFont.caption) | |
| 257 | + } | |
| 258 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 259 | + .frame(maxWidth: .infinity, alignment: .center) | |
| 260 | + .padding(.vertical, ZyquoSpacing.xxs) | |
| 261 | + } | |
| 262 | +} | |
added
Sources/ZyquoAgent/Views/Conversation/ConversationView.swift
+281 −0
@@ -0,0 +1,281 @@ | ||
| 1 | +// | |
| 2 | +// ConversationView.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// The center conversation column (max 760pt): the task's persisted history | |
| 9 | +// (user bubbles + past runs' step cards + Markdown final answers) followed | |
| 10 | +// by the live run's streaming timeline — step cards, compaction notices, the | |
| 11 | +// blocking approval card, and the LoopGuard pause card. Auto-scrolls while | |
| 12 | +// pinned to the bottom; scrolling up unpins and shows a jump-to-latest pill. | |
| 13 | +// | |
| 14 | + | |
| 15 | +import SwiftUI | |
| 16 | + | |
| 17 | +struct ConversationView: View { | |
| 18 | + let task: AgentTask | |
| 19 | + @ObservedObject var controller: RunController | |
| 20 | + | |
| 21 | + @EnvironmentObject private var appearance: AppearanceStore | |
| 22 | + @State private var pinnedToBottom = true | |
| 23 | + | |
| 24 | + private static let bottomID = "conversation-bottom" | |
| 25 | + private static let scrollSpace = "conversation-scroll" | |
| 26 | + /// Distance from the bottom (pt) still counted as "pinned". | |
| 27 | + private static let pinThreshold: CGFloat = 60 | |
| 28 | + | |
| 29 | + var body: some View { | |
| 30 | + GeometryReader { viewport in | |
| 31 | + ScrollViewReader { proxy in | |
| 32 | + ZStack(alignment: .bottom) { | |
| 33 | + ScrollView { | |
| 34 | + LazyVStack(alignment: .leading, spacing: ZyquoMetrics.verticalTurnRhythm) { | |
| 35 | + history | |
| 36 | + liveRun | |
| 37 | + bottomMarker | |
| 38 | + } | |
| 39 | + .padding(.horizontal, ZyquoMetrics.contentInset) | |
| 40 | + .padding(.vertical, ZyquoMetrics.contentInset) | |
| 41 | + .frame(maxWidth: ZyquoMetrics.maxMessageColumnWidth) | |
| 42 | + .frame(maxWidth: .infinity) | |
| 43 | + } | |
| 44 | + .coordinateSpace(name: Self.scrollSpace) | |
| 45 | + .onPreferenceChange(BottomMarkerPreferenceKey.self) { markerY in | |
| 46 | + // Marker position in the viewport's coordinate space: | |
| 47 | + // beyond the visible height ⇒ the user scrolled up. | |
| 48 | + pinnedToBottom = markerY < viewport.size.height + Self.pinThreshold | |
| 49 | + } | |
| 50 | + .onChange(of: contentFingerprint) { _ in | |
| 51 | + if pinnedToBottom { | |
| 52 | + proxy.scrollTo(Self.bottomID, anchor: .bottom) | |
| 53 | + } | |
| 54 | + } | |
| 55 | + if !pinnedToBottom, controller.isRunning { | |
| 56 | + jumpToBottomPill(proxy: proxy) | |
| 57 | + } | |
| 58 | + } | |
| 59 | + } | |
| 60 | + } | |
| 61 | + } | |
| 62 | + | |
| 63 | + // MARK: - History (persisted runs) | |
| 64 | + | |
| 65 | + @ViewBuilder | |
| 66 | + private var history: some View { | |
| 67 | + ForEach(task.messages) { message in | |
| 68 | + switch message.kind { | |
| 69 | + case .user: | |
| 70 | + UserBubbleView(text: message.text, fontSize: appearance.chatFontSize) | |
| 71 | + case .agentRun: | |
| 72 | + agentRunHistory(message) | |
| 73 | + } | |
| 74 | + } | |
| 75 | + } | |
| 76 | + | |
| 77 | + @ViewBuilder | |
| 78 | + private func agentRunHistory(_ message: TaskMessage) -> some View { | |
| 79 | + let steps = historySteps(of: message) | |
| 80 | + ForEach(steps, id: \.id) { step in | |
| 81 | + StepCardView(step: LiveStep(step: step), fontSize: appearance.chatFontSize) | |
| 82 | + } | |
| 83 | + outcomeView(for: message) | |
| 84 | + } | |
| 85 | + | |
| 86 | + /// Past-run steps to render as cards — the trailing final-answer step is | |
| 87 | + /// folded into the answer bubble instead of duplicating. | |
| 88 | + private func historySteps(of message: TaskMessage) -> [AgentStep] { | |
| 89 | + var steps = message.steps ?? [] | |
| 90 | + if let last = steps.last, last.isFinal { | |
| 91 | + steps.removeLast() | |
| 92 | + } | |
| 93 | + return steps | |
| 94 | + } | |
| 95 | + | |
| 96 | + @ViewBuilder | |
| 97 | + private func outcomeView(for message: TaskMessage) -> some View { | |
| 98 | + switch message.outcome { | |
| 99 | + case .completed: | |
| 100 | + AgentAnswerBubbleView( | |
| 101 | + text: message.text, | |
| 102 | + provider: task.providerID, | |
| 103 | + fontSize: appearance.chatFontSize | |
| 104 | + ) | |
| 105 | + case .failed(let reason): | |
| 106 | + RunNoticeView(symbol: "xmark.circle.fill", text: reason, color: ZyquoColor.danger) | |
| 107 | + case .cancelled: | |
| 108 | + RunNoticeView(symbol: "slash.circle", text: "Run cancelled.", color: ZyquoColor.textTertiary) | |
| 109 | + case .stoppedByUser(let reason): | |
| 110 | + RunNoticeView(symbol: "stop.circle", text: "Run stopped — \(reason)", color: ZyquoColor.textTertiary) | |
| 111 | + case nil: | |
| 112 | + EmptyView() | |
| 113 | + } | |
| 114 | + } | |
| 115 | + | |
| 116 | + // MARK: - Live run | |
| 117 | + | |
| 118 | + @ViewBuilder | |
| 119 | + private var liveRun: some View { | |
| 120 | + ForEach(controller.entries) { entry in | |
| 121 | + switch entry { | |
| 122 | + case .step(let step): | |
| 123 | + StepCardView(step: step, fontSize: appearance.chatFontSize, isLive: controller.isRunning) | |
| 124 | + case .compaction(let record): | |
| 125 | + CompactionNoticeView(record: record) | |
| 126 | + } | |
| 127 | + } | |
| 128 | + if let approval = controller.pendingApproval { | |
| 129 | + ApprovalCardView(approval: approval) { resolution in | |
| 130 | + withAnimation(ZyquoMotion.appear) { | |
| 131 | + controller.resolveApproval(resolution) | |
| 132 | + } | |
| 133 | + } | |
| 134 | + } | |
| 135 | + if let trip = controller.guardTrip { | |
| 136 | + GuardTripCardView( | |
| 137 | + trip: trip, | |
| 138 | + onContinue: { controller.resumeAfterTrip(raisingBudget: true) }, | |
| 139 | + onStop: { controller.stopAfterTrip() } | |
| 140 | + ) | |
| 141 | + } | |
| 142 | + if let error = controller.lastError { | |
| 143 | + RunNoticeView(symbol: "exclamationmark.triangle.fill", text: error, color: ZyquoColor.danger) | |
| 144 | + } | |
| 145 | + } | |
| 146 | + | |
| 147 | + // MARK: - Scrolling | |
| 148 | + | |
| 149 | + private var bottomMarker: some View { | |
| 150 | + GeometryReader { geometry in | |
| 151 | + Color.clear.preference( | |
| 152 | + key: BottomMarkerPreferenceKey.self, | |
| 153 | + value: geometry.frame(in: .named(Self.scrollSpace)).minY | |
| 154 | + ) | |
| 155 | + } | |
| 156 | + .frame(height: 1) | |
| 157 | + .id(Self.bottomID) | |
| 158 | + } | |
| 159 | + | |
| 160 | + /// Changes when streamed content grows so auto-scroll can follow. | |
| 161 | + private var contentFingerprint: Int { | |
| 162 | + var fingerprint = task.messages.count &* 31 &+ controller.entries.count &* 7 | |
| 163 | + if case .step(let step)? = controller.entries.last { | |
| 164 | + fingerprint &+= step.text.count &+ step.thinking.count | |
| 165 | + fingerprint &+= step.invocations.reduce(0) { $0 &+ $1.argumentsJSON.count &+ $1.outputLines.count } | |
| 166 | + } | |
| 167 | + if controller.pendingApproval != nil { fingerprint &+= 1 } | |
| 168 | + if controller.guardTrip != nil { fingerprint &+= 3 } | |
| 169 | + return fingerprint | |
| 170 | + } | |
| 171 | + | |
| 172 | + private func jumpToBottomPill(proxy: ScrollViewProxy) -> some View { | |
| 173 | + Button { | |
| 174 | + pinnedToBottom = true | |
| 175 | + withAnimation(ZyquoMotion.appear) { proxy.scrollTo(Self.bottomID, anchor: .bottom) } | |
| 176 | + } label: { | |
| 177 | + HStack(spacing: ZyquoSpacing.xxs) { | |
| 178 | + Image(systemName: "arrow.down") | |
| 179 | + .font(.system(size: 10, weight: .semibold)) | |
| 180 | + Text("Jump to latest") | |
| 181 | + .font(ZyquoFont.caption) | |
| 182 | + } | |
| 183 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 184 | + .padding(.horizontal, ZyquoSpacing.sm) | |
| 185 | + .padding(.vertical, 5) | |
| 186 | + .background(Capsule().fill(ZyquoColor.surface)) | |
| 187 | + .overlay(Capsule().strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline)) | |
| 188 | + .zyquoSoftShadow() | |
| 189 | + } | |
| 190 | + .buttonStyle(PressableButtonStyle()) | |
| 191 | + .padding(.bottom, ZyquoSpacing.xs) | |
| 192 | + } | |
| 193 | +} | |
| 194 | + | |
| 195 | +/// Y position of the transcript's bottom marker in the scroll viewport space. | |
| 196 | +private struct BottomMarkerPreferenceKey: PreferenceKey { | |
| 197 | + static var defaultValue: CGFloat = 0 | |
| 198 | + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { | |
| 199 | + value = nextValue() | |
| 200 | + } | |
| 201 | +} | |
| 202 | + | |
| 203 | +// MARK: - Bubbles & notices | |
| 204 | + | |
| 205 | +/// User prompt: right-aligned accent-subtle bubble. | |
| 206 | +struct UserBubbleView: View { | |
| 207 | + let text: String | |
| 208 | + let fontSize: Double | |
| 209 | + | |
| 210 | + var body: some View { | |
| 211 | + HStack(alignment: .top, spacing: ZyquoSpacing.xs) { | |
| 212 | + Spacer(minLength: 60) | |
| 213 | + Text(text) | |
| 214 | + .font(ZyquoFont.body(size: fontSize)) | |
| 215 | + .lineSpacing(fontSize * ZyquoFont.bodyLineSpacingFactor) | |
| 216 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 217 | + .textSelection(.enabled) | |
| 218 | + .padding(.horizontal, ZyquoSpacing.sm) | |
| 219 | + .padding(.vertical, ZyquoSpacing.xs + 2) | |
| 220 | + .background( | |
| 221 | + RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous) | |
| 222 | + .fill(ZyquoColor.accentSubtle) | |
| 223 | + ) | |
| 224 | + } | |
| 225 | + } | |
| 226 | +} | |
| 227 | + | |
| 228 | +/// Final answer: left-aligned surface bubble with provider avatar + Markdown. | |
| 229 | +struct AgentAnswerBubbleView: View { | |
| 230 | + let text: String | |
| 231 | + let provider: ProviderID | |
| 232 | + let fontSize: Double | |
| 233 | + | |
| 234 | + var body: some View { | |
| 235 | + HStack(alignment: .top, spacing: ZyquoSpacing.xs) { | |
| 236 | + Image(systemName: provider.symbolName) | |
| 237 | + .font(.system(size: 12, weight: .medium)) | |
| 238 | + .foregroundStyle(ZyquoColor.accent) | |
| 239 | + .frame(width: 26, height: 26) | |
| 240 | + .background(Circle().fill(ZyquoColor.accentSubtle)) | |
| 241 | + .padding(.top, 2) | |
| 242 | + MarkdownView(text: text, fontSize: fontSize) | |
| 243 | + .padding(.horizontal, ZyquoSpacing.sm) | |
| 244 | + .padding(.vertical, ZyquoSpacing.xs + 2) | |
| 245 | + .background( | |
| 246 | + RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous) | |
| 247 | + .fill(ZyquoColor.surface) | |
| 248 | + .overlay( | |
| 249 | + RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous) | |
| 250 | + .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline) | |
| 251 | + ) | |
| 252 | + ) | |
| 253 | + Spacer(minLength: 60) | |
| 254 | + } | |
| 255 | + } | |
| 256 | +} | |
| 257 | + | |
| 258 | +/// Inline run-lifecycle notice (failure / cancelled / stopped / error). | |
| 259 | +struct RunNoticeView: View { | |
| 260 | + let symbol: String | |
| 261 | + let text: String | |
| 262 | + let color: Color | |
| 263 | + | |
| 264 | + var body: some View { | |
| 265 | + HStack(spacing: ZyquoSpacing.xxs) { | |
| 266 | + Image(systemName: symbol) | |
| 267 | + .font(.system(size: 11)) | |
| 268 | + Text(text) | |
| 269 | + .font(ZyquoFont.body(size: 12.5)) | |
| 270 | + .textSelection(.enabled) | |
| 271 | + .fixedSize(horizontal: false, vertical: true) | |
| 272 | + } | |
| 273 | + .foregroundStyle(color) | |
| 274 | + .padding(ZyquoSpacing.xs) | |
| 275 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 276 | + .background( | |
| 277 | + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) | |
| 278 | + .fill(color.opacity(0.08)) | |
| 279 | + ) | |
| 280 | + } | |
| 281 | +} | |
added
Sources/ZyquoAgent/Views/Conversation/InputBarView.swift
+175 −0
@@ -0,0 +1,175 @@ | ||
| 1 | +// | |
| 2 | +// InputBarView.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Floating input card docked at the bottom of the conversation column: | |
| 9 | +// auto-growing multiline editor, attach-text-file button (contents are | |
| 10 | +// appended to the prompt), and the violet circular Run button (⌘↩) that | |
| 11 | +// becomes Stop (⌘.) while a run is live. Disabled states explain themselves | |
| 12 | +// (no model / no key). | |
| 13 | +// | |
| 14 | + | |
| 15 | +import SwiftUI | |
| 16 | +import UniformTypeIdentifiers | |
| 17 | + | |
| 18 | +struct InputBarView: View { | |
| 19 | + @Binding var text: String | |
| 20 | + let isRunning: Bool | |
| 21 | + /// Non-nil disables Run and shows why (no key / no model). | |
| 22 | + let disabledReason: String? | |
| 23 | + var onRun: () -> Void | |
| 24 | + var onStop: () -> Void | |
| 25 | + | |
| 26 | + @State private var dropTargeted = false | |
| 27 | + @EnvironmentObject private var appearance: AppearanceStore | |
| 28 | + @FocusState private var editorFocused: Bool | |
| 29 | + | |
| 30 | + private var canRun: Bool { | |
| 31 | + disabledReason == nil && !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty | |
| 32 | + } | |
| 33 | + | |
| 34 | + var body: some View { | |
| 35 | + VStack(spacing: ZyquoSpacing.xxs) { | |
| 36 | + if let reason = disabledReason { | |
| 37 | + HStack(spacing: ZyquoSpacing.xxs) { | |
| 38 | + Image(systemName: "exclamationmark.circle") | |
| 39 | + .font(.system(size: 10)) | |
| 40 | + Text(reason) | |
| 41 | + .font(ZyquoFont.caption) | |
| 42 | + } | |
| 43 | + .foregroundStyle(ZyquoColor.warning) | |
| 44 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 45 | + } | |
| 46 | + HStack(alignment: .bottom, spacing: ZyquoSpacing.xs) { | |
| 47 | + attachButton | |
| 48 | + editor | |
| 49 | + runButton | |
| 50 | + } | |
| 51 | + } | |
| 52 | + .padding(ZyquoSpacing.sm) | |
| 53 | + .background( | |
| 54 | + RoundedRectangle(cornerRadius: ZyquoRadius.large, style: .continuous) | |
| 55 | + .fill(ZyquoColor.surface) | |
| 56 | + .overlay( | |
| 57 | + RoundedRectangle(cornerRadius: ZyquoRadius.large, style: .continuous) | |
| 58 | + .strokeBorder( | |
| 59 | + dropTargeted ? ZyquoColor.accent : ZyquoColor.border, | |
| 60 | + style: StrokeStyle( | |
| 61 | + lineWidth: dropTargeted ? 1.5 : ZyquoMetrics.hairline, | |
| 62 | + dash: dropTargeted ? [6, 4] : [] | |
| 63 | + ) | |
| 64 | + ) | |
| 65 | + ) | |
| 66 | + ) | |
| 67 | + .zyquoSoftShadow() | |
| 68 | + .padding(.horizontal, ZyquoSpacing.sm) | |
| 69 | + .padding(.bottom, ZyquoSpacing.sm) | |
| 70 | + .onDrop(of: [.fileURL], isTargeted: $dropTargeted) { providers in | |
| 71 | + handleDrop(providers) | |
| 72 | + } | |
| 73 | + } | |
| 74 | + | |
| 75 | + // MARK: - Pieces | |
| 76 | + | |
| 77 | + private var editor: some View { | |
| 78 | + ZStack(alignment: .topLeading) { | |
| 79 | + if text.isEmpty { | |
| 80 | + Text("What should I do on your Mac?") | |
| 81 | + .font(ZyquoFont.body(size: appearance.chatFontSize)) | |
| 82 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 83 | + .padding(.top, 2) | |
| 84 | + .allowsHitTesting(false) | |
| 85 | + } | |
| 86 | + TextEditor(text: $text) | |
| 87 | + .font(ZyquoFont.body(size: appearance.chatFontSize)) | |
| 88 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 89 | + .scrollContentBackground(.hidden) | |
| 90 | + .frame(minHeight: 22, maxHeight: 200) | |
| 91 | + .fixedSize(horizontal: false, vertical: text.count < 2000) | |
| 92 | + .focused($editorFocused) | |
| 93 | + .onAppear { editorFocused = true } | |
| 94 | + } | |
| 95 | + } | |
| 96 | + | |
| 97 | + private var attachButton: some View { | |
| 98 | + Button { | |
| 99 | + presentFilePicker() | |
| 100 | + } label: { | |
| 101 | + Image(systemName: "paperclip") | |
| 102 | + .font(.system(size: 14)) | |
| 103 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 104 | + .frame(width: 26, height: 26) | |
| 105 | + } | |
| 106 | + .buttonStyle(.plain) | |
| 107 | + .zyquoHoverHighlight() | |
| 108 | + .help("Attach text files (contents are added to the prompt)") | |
| 109 | + } | |
| 110 | + | |
| 111 | + private var runButton: some View { | |
| 112 | + Button { | |
| 113 | + isRunning ? onStop() : (canRun ? onRun() : ()) | |
| 114 | + } label: { | |
| 115 | + Image(systemName: isRunning ? "stop.fill" : "arrow.up") | |
| 116 | + .font(.system(size: 13, weight: .semibold)) | |
| 117 | + .foregroundStyle(.white) | |
| 118 | + .frame(width: 28, height: 28) | |
| 119 | + .background( | |
| 120 | + Circle().fill( | |
| 121 | + isRunning | |
| 122 | + ? ZyquoColor.danger | |
| 123 | + : (canRun ? ZyquoColor.accent : ZyquoColor.textTertiary) | |
| 124 | + ) | |
| 125 | + ) | |
| 126 | + } | |
| 127 | + .buttonStyle(PressableButtonStyle()) | |
| 128 | + .keyboardShortcut(.return, modifiers: .command) | |
| 129 | + .help(isRunning ? "Stop the run (⌘.)" : "Run (⌘↩)") | |
| 130 | + } | |
| 131 | + | |
| 132 | + // MARK: - Text-file intake | |
| 133 | + | |
| 134 | + private static let textExtensions: Set<String> = [ | |
| 135 | + "txt", "md", "markdown", "csv", "json", "yaml", "yml", "xml", "log", | |
| 136 | + "swift", "py", "js", "ts", "jsx", "tsx", "html", "css", "sh", "zsh", | |
| 137 | + "bash", "sql", "go", "rs", "c", "h", "cpp", "hpp", "m", "mm", "java", | |
| 138 | + "rb", "php", "toml", "ini", "cfg", "tex", | |
| 139 | + ] | |
| 140 | + | |
| 141 | + private func presentFilePicker() { | |
| 142 | + let panel = NSOpenPanel() | |
| 143 | + panel.allowsMultipleSelection = true | |
| 144 | + panel.canChooseDirectories = false | |
| 145 | + panel.begin { response in | |
| 146 | + guard response == .OK else { return } | |
| 147 | + for url in panel.urls { ingest(url: url) } | |
| 148 | + } | |
| 149 | + } | |
| 150 | + | |
| 151 | + private func handleDrop(_ providers: [NSItemProvider]) -> Bool { | |
| 152 | + var handled = false | |
| 153 | + for provider in providers where provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) { | |
| 154 | + handled = true | |
| 155 | + provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier) { item, _ in | |
| 156 | + guard let data = item as? Data, | |
| 157 | + let url = URL(dataRepresentation: data, relativeTo: nil) else { return } | |
| 158 | + DispatchQueue.main.async { ingest(url: url) } | |
| 159 | + } | |
| 160 | + } | |
| 161 | + return handled | |
| 162 | + } | |
| 163 | + | |
| 164 | + /// Appends a readable text file's contents to the prompt, fenced and | |
| 165 | + /// labeled with its name. | |
| 166 | + private func ingest(url: URL) { | |
| 167 | + let ext = url.pathExtension.lowercased() | |
| 168 | + guard let data = try? Data(contentsOf: url), | |
| 169 | + Self.textExtensions.contains(ext) | |
| 170 | + || (String(data: data, encoding: .utf8) != nil && data.count < 512_000), | |
| 171 | + let content = String(data: data, encoding: .utf8) else { return } | |
| 172 | + let block = "\n\n--- \(url.lastPathComponent) ---\n\(content)\n--- end \(url.lastPathComponent) ---\n" | |
| 173 | + text += block | |
| 174 | + } | |
| 175 | +} | |
added
Sources/ZyquoAgent/Views/Conversation/StepCardView.swift
+378 −0
@@ -0,0 +1,378 @@ | ||
| 1 | +// | |
| 2 | +// StepCardView.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// One AgentStep rendered as a live card: the collapsible thinking section | |
| 9 | +// (dimmed), the thought line (streaming text), then each tool invocation as | |
| 10 | +// a labeled chip + exact command in a monospace block + the streamed, | |
| 11 | +// color-coded tool result (stdout/stderr, truncated with expand). Step cards | |
| 12 | +// animate in with the family 150ms fade+rise. | |
| 13 | +// | |
| 14 | + | |
| 15 | +import SwiftUI | |
| 16 | + | |
| 17 | +/// SF Symbol per tool wire name (chips, drawer, audit rows). | |
| 18 | +func toolSymbolName(_ toolName: String) -> String { | |
| 19 | + switch toolName { | |
| 20 | + case "bash": return "terminal" | |
| 21 | + case "osascript": return "applescript" | |
| 22 | + case "read_file": return "doc.text" | |
| 23 | + case "write_file": return "square.and.pencil" | |
| 24 | + case "edit_file": return "pencil.line" | |
| 25 | + case "list_dir": return "folder" | |
| 26 | + case "search_files": return "text.magnifyingglass" | |
| 27 | + case "update_plan": return "checklist" | |
| 28 | + default: return "wrench.and.screwdriver" | |
| 29 | + } | |
| 30 | +} | |
| 31 | + | |
| 32 | +struct StepCardView: View { | |
| 33 | + let step: LiveStep | |
| 34 | + let fontSize: Double | |
| 35 | + /// True while this step belongs to the in-flight run (streaming caret). | |
| 36 | + var isLive: Bool = false | |
| 37 | + | |
| 38 | + @State private var showThinking = false | |
| 39 | + | |
| 40 | + var body: some View { | |
| 41 | + VStack(alignment: .leading, spacing: ZyquoSpacing.xs) { | |
| 42 | + header | |
| 43 | + if !step.thinking.isEmpty { | |
| 44 | + thinkingSection | |
| 45 | + } | |
| 46 | + if !step.text.isEmpty { | |
| 47 | + if step.isFinalAnswer { | |
| 48 | + MarkdownView(text: step.text, fontSize: fontSize) | |
| 49 | + } else { | |
| 50 | + Text(step.text) | |
| 51 | + .font(ZyquoFont.body(size: fontSize)) | |
| 52 | + .lineSpacing(fontSize * ZyquoFont.bodyLineSpacingFactor) | |
| 53 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 54 | + .textSelection(.enabled) | |
| 55 | + .fixedSize(horizontal: false, vertical: true) | |
| 56 | + } | |
| 57 | + } | |
| 58 | + if isLive, step.status == .streaming, step.text.isEmpty, step.invocations.isEmpty { | |
| 59 | + StreamingCaret() | |
| 60 | + } | |
| 61 | + ForEach(step.invocations) { invocation in | |
| 62 | + ToolInvocationView(invocation: invocation, fontSize: fontSize) | |
| 63 | + } | |
| 64 | + } | |
| 65 | + .padding(ZyquoSpacing.sm) | |
| 66 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 67 | + .background( | |
| 68 | + RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous) | |
| 69 | + .fill(ZyquoColor.surface) | |
| 70 | + .overlay( | |
| 71 | + RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous) | |
| 72 | + .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline) | |
| 73 | + ) | |
| 74 | + ) | |
| 75 | + .transition(.opacity.combined(with: .offset(y: ZyquoMotion.appearRise))) | |
| 76 | + } | |
| 77 | + | |
| 78 | + // MARK: Header | |
| 79 | + | |
| 80 | + private var header: some View { | |
| 81 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 82 | + Text("Step \(step.index)") | |
| 83 | + .font(ZyquoFont.caption) | |
| 84 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 85 | + statusLabel | |
| 86 | + Spacer(minLength: 0) | |
| 87 | + if let input = step.inputTokens, let output = step.outputTokens { | |
| 88 | + Text("\(input)→\(output) tok") | |
| 89 | + .font(ZyquoFont.caption) | |
| 90 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 91 | + } | |
| 92 | + } | |
| 93 | + } | |
| 94 | + | |
| 95 | + @ViewBuilder | |
| 96 | + private var statusLabel: some View { | |
| 97 | + switch step.status { | |
| 98 | + case .streaming: | |
| 99 | + if isLive { | |
| 100 | + HStack(spacing: ZyquoSpacing.xxs) { | |
| 101 | + ProgressView().controlSize(.mini) | |
| 102 | + Text("Thinking") | |
| 103 | + .font(ZyquoFont.caption) | |
| 104 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 105 | + } | |
| 106 | + } | |
| 107 | + case .awaitingApproval: | |
| 108 | + Label("Awaiting approval", systemImage: "hand.raised") | |
| 109 | + .font(ZyquoFont.caption) | |
| 110 | + .foregroundStyle(ZyquoColor.warning) | |
| 111 | + case .executing: | |
| 112 | + HStack(spacing: ZyquoSpacing.xxs) { | |
| 113 | + if isLive { ProgressView().controlSize(.mini) } | |
| 114 | + Text("Running tools") | |
| 115 | + .font(ZyquoFont.caption) | |
| 116 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 117 | + } | |
| 118 | + case .completed: | |
| 119 | + EmptyView() | |
| 120 | + case .failed: | |
| 121 | + Label("Failed", systemImage: "xmark.circle") | |
| 122 | + .font(ZyquoFont.caption) | |
| 123 | + .foregroundStyle(ZyquoColor.danger) | |
| 124 | + case .cancelled: | |
| 125 | + Label("Cancelled", systemImage: "slash.circle") | |
| 126 | + .font(ZyquoFont.caption) | |
| 127 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 128 | + } | |
| 129 | + } | |
| 130 | + | |
| 131 | + // MARK: Thinking | |
| 132 | + | |
| 133 | + private var thinkingSection: some View { | |
| 134 | + VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) { | |
| 135 | + Button { | |
| 136 | + withAnimation(ZyquoMotion.picker) { showThinking.toggle() } | |
| 137 | + } label: { | |
| 138 | + HStack(spacing: ZyquoSpacing.xxs) { | |
| 139 | + Image(systemName: "chevron.right") | |
| 140 | + .font(.system(size: 8, weight: .semibold)) | |
| 141 | + .rotationEffect(.degrees(showThinking ? 90 : 0)) | |
| 142 | + Text(isLive && step.status == .streaming && step.text.isEmpty ? "Thinking…" : "Thought process") | |
| 143 | + .font(ZyquoFont.caption) | |
| 144 | + } | |
| 145 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 146 | + } | |
| 147 | + .buttonStyle(.plain) | |
| 148 | + if showThinking { | |
| 149 | + Text(step.thinking) | |
| 150 | + .font(ZyquoFont.code(size: max(fontSize - 2, 1))) | |
| 151 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 152 | + .lineSpacing(3) | |
| 153 | + .textSelection(.enabled) | |
| 154 | + .padding(ZyquoSpacing.xs) | |
| 155 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 156 | + .background( | |
| 157 | + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) | |
| 158 | + .fill(ZyquoColor.surfaceSecondary) | |
| 159 | + ) | |
| 160 | + } | |
| 161 | + } | |
| 162 | + } | |
| 163 | +} | |
| 164 | + | |
| 165 | +// MARK: - Tool invocation | |
| 166 | + | |
| 167 | +/// One tool call inside a step: chip + command block + streamed result. | |
| 168 | +struct ToolInvocationView: View { | |
| 169 | + let invocation: LiveInvocation | |
| 170 | + let fontSize: Double | |
| 171 | + | |
| 172 | + @State private var expanded = false | |
| 173 | + | |
| 174 | + /// Output lines shown before the expand toggle appears. | |
| 175 | + private static let collapsedLineLimit = 12 | |
| 176 | + | |
| 177 | + var body: some View { | |
| 178 | + VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) { | |
| 179 | + chipRow | |
| 180 | + // The plan tool's JSON is internal bookkeeping — the Plan panel | |
| 181 | + // renders it; every other payload shows verbatim. | |
| 182 | + if !invocation.displayPayload.isEmpty, invocation.name != "update_plan" { | |
| 183 | + commandBlock | |
| 184 | + } | |
| 185 | + if !invocation.outputLines.isEmpty { | |
| 186 | + outputBlock | |
| 187 | + } else if let result = invocation.result, !result.content.isEmpty, | |
| 188 | + invocation.name != "bash", invocation.name != "osascript" { | |
| 189 | + resultSummary(result) | |
| 190 | + } | |
| 191 | + } | |
| 192 | + } | |
| 193 | + | |
| 194 | + private var chipRow: some View { | |
| 195 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 196 | + HStack(spacing: ZyquoSpacing.xxs) { | |
| 197 | + Image(systemName: toolSymbolName(invocation.name)) | |
| 198 | + .font(.system(size: 10, weight: .medium)) | |
| 199 | + Text(invocation.name) | |
| 200 | + .font(ZyquoFont.code(size: 11)) | |
| 201 | + } | |
| 202 | + .foregroundStyle(chipColor) | |
| 203 | + .padding(.horizontal, ZyquoSpacing.xs) | |
| 204 | + .padding(.vertical, 2) | |
| 205 | + .background(Capsule().fill(chipColor.opacity(0.1))) | |
| 206 | + | |
| 207 | + switch invocation.phase { | |
| 208 | + case .streaming: | |
| 209 | + Text("composing…") | |
| 210 | + .font(ZyquoFont.caption) | |
| 211 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 212 | + case .executing: | |
| 213 | + HStack(spacing: ZyquoSpacing.xxs) { | |
| 214 | + ProgressView().controlSize(.mini) | |
| 215 | + Text("running") | |
| 216 | + .font(ZyquoFont.caption) | |
| 217 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 218 | + } | |
| 219 | + case .finished: | |
| 220 | + if let result = invocation.result { | |
| 221 | + Image(systemName: result.isError ? "xmark.circle.fill" : "checkmark.circle.fill") | |
| 222 | + .font(.system(size: 10)) | |
| 223 | + .foregroundStyle(result.isError ? ZyquoColor.danger : ZyquoColor.success) | |
| 224 | + } | |
| 225 | + } | |
| 226 | + if let decision = invocation.policyDecision, decision.ruling != .autoAllowed { | |
| 227 | + ZyquoBadge(text: policyLabel(decision), color: ZyquoColor.textSecondary) | |
| 228 | + } | |
| 229 | + Spacer(minLength: 0) | |
| 230 | + if let code = exitCodeText { | |
| 231 | + Text(code) | |
| 232 | + .font(ZyquoFont.caption) | |
| 233 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 234 | + } | |
| 235 | + } | |
| 236 | + } | |
| 237 | + | |
| 238 | + private var commandBlock: some View { | |
| 239 | + ScrollView(.horizontal, showsIndicators: false) { | |
| 240 | + Text(invocation.displayPayload) | |
| 241 | + .font(ZyquoFont.code(size: max(fontSize - 1, 1))) | |
| 242 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 243 | + .textSelection(.enabled) | |
| 244 | + .padding(.horizontal, ZyquoSpacing.sm) | |
| 245 | + .padding(.vertical, ZyquoSpacing.xs) | |
| 246 | + } | |
| 247 | + .background( | |
| 248 | + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) | |
| 249 | + .fill(ZyquoColor.surfaceSecondary) | |
| 250 | + ) | |
| 251 | + } | |
| 252 | + | |
| 253 | + private var outputBlock: some View { | |
| 254 | + let lines = invocation.outputLines | |
| 255 | + let truncated = !expanded && lines.count > Self.collapsedLineLimit | |
| 256 | + let visible = truncated ? Array(lines.suffix(Self.collapsedLineLimit)) : lines | |
| 257 | + return VStack(alignment: .leading, spacing: 1) { | |
| 258 | + if truncated { | |
| 259 | + expandToggle(hiddenCount: lines.count - Self.collapsedLineLimit) | |
| 260 | + } | |
| 261 | + ForEach(visible) { line in | |
| 262 | + Text(line.text) | |
| 263 | + .font(ZyquoFont.code(size: max(fontSize - 1.5, 1))) | |
| 264 | + .foregroundStyle(outputColor(for: line.kind)) | |
| 265 | + .textSelection(.enabled) | |
| 266 | + .fixedSize(horizontal: false, vertical: true) | |
| 267 | + } | |
| 268 | + if expanded, lines.count > Self.collapsedLineLimit { | |
| 269 | + collapseToggle | |
| 270 | + } | |
| 271 | + } | |
| 272 | + .padding(.horizontal, ZyquoSpacing.sm) | |
| 273 | + .padding(.vertical, ZyquoSpacing.xs) | |
| 274 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 275 | + .background( | |
| 276 | + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) | |
| 277 | + .fill(ZyquoColor.surfaceSecondary.opacity(0.6)) | |
| 278 | + ) | |
| 279 | + } | |
| 280 | + | |
| 281 | + private func resultSummary(_ result: ToolResult) -> some View { | |
| 282 | + let content = result.content | |
| 283 | + let truncated = !expanded && content.count > 600 | |
| 284 | + let visible = truncated ? String(content.prefix(600)) + "…" : content | |
| 285 | + return VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) { | |
| 286 | + Text(visible) | |
| 287 | + .font(ZyquoFont.code(size: max(fontSize - 1.5, 1))) | |
| 288 | + .foregroundStyle(result.isError ? ZyquoColor.danger : ZyquoColor.textSecondary) | |
| 289 | + .textSelection(.enabled) | |
| 290 | + .fixedSize(horizontal: false, vertical: true) | |
| 291 | + if truncated { | |
| 292 | + Button("Show all") { withAnimation(ZyquoMotion.appear) { expanded = true } } | |
| 293 | + .buttonStyle(.plain) | |
| 294 | + .font(ZyquoFont.caption) | |
| 295 | + .foregroundStyle(ZyquoColor.accent) | |
| 296 | + } | |
| 297 | + } | |
| 298 | + .padding(.horizontal, ZyquoSpacing.sm) | |
| 299 | + .padding(.vertical, ZyquoSpacing.xs) | |
| 300 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 301 | + .background( | |
| 302 | + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) | |
| 303 | + .fill(ZyquoColor.surfaceSecondary.opacity(0.6)) | |
| 304 | + ) | |
| 305 | + } | |
| 306 | + | |
| 307 | + private func expandToggle(hiddenCount: Int) -> some View { | |
| 308 | + Button { | |
| 309 | + withAnimation(ZyquoMotion.appear) { expanded = true } | |
| 310 | + } label: { | |
| 311 | + Text("… \(hiddenCount) earlier line\(hiddenCount == 1 ? "" : "s") — show all") | |
| 312 | + .font(ZyquoFont.caption) | |
| 313 | + .foregroundStyle(ZyquoColor.accent) | |
| 314 | + } | |
| 315 | + .buttonStyle(.plain) | |
| 316 | + } | |
| 317 | + | |
| 318 | + private var collapseToggle: some View { | |
| 319 | + Button { | |
| 320 | + withAnimation(ZyquoMotion.appear) { expanded = false } | |
| 321 | + } label: { | |
| 322 | + Text("Collapse output") | |
| 323 | + .font(ZyquoFont.caption) | |
| 324 | + .foregroundStyle(ZyquoColor.accent) | |
| 325 | + } | |
| 326 | + .buttonStyle(.plain) | |
| 327 | + } | |
| 328 | + | |
| 329 | + // MARK: Helpers | |
| 330 | + | |
| 331 | + private var chipColor: Color { | |
| 332 | + if let result = invocation.result, result.isError { return ZyquoColor.danger } | |
| 333 | + return ZyquoColor.accent | |
| 334 | + } | |
| 335 | + | |
| 336 | + private var exitCodeText: String? { | |
| 337 | + guard let result = invocation.result, | |
| 338 | + invocation.name == "bash" || invocation.name == "osascript" else { return nil } | |
| 339 | + if let code = invocation.exitCode { return "exit \(code)" } | |
| 340 | + // ShellTool reports the exit code inside the result text; surface | |
| 341 | + // errors without duplicating it. | |
| 342 | + return result.isError ? "failed" : nil | |
| 343 | + } | |
| 344 | + | |
| 345 | + private func policyLabel(_ decision: PolicyDecisionRecord) -> String { | |
| 346 | + switch decision.ruling { | |
| 347 | + case .autoAllowed: return "auto-allowed" | |
| 348 | + case .approvedByUser: return "approved" | |
| 349 | + case .editedAndApproved: return "edited & approved" | |
| 350 | + case .denied: return "denied" | |
| 351 | + } | |
| 352 | + } | |
| 353 | + | |
| 354 | + private func outputColor(for kind: TerminalLine.Kind) -> Color { | |
| 355 | + switch kind { | |
| 356 | + case .stderr: return ZyquoColor.danger | |
| 357 | + case .note, .meta: return ZyquoColor.textTertiary | |
| 358 | + case .stdout, .command: return ZyquoColor.textSecondary | |
| 359 | + } | |
| 360 | + } | |
| 361 | +} | |
| 362 | + | |
| 363 | +/// Blinking caret shown at the tail of streaming content (family standard). | |
| 364 | +struct StreamingCaret: View { | |
| 365 | + @State private var visible = true | |
| 366 | + | |
| 367 | + var body: some View { | |
| 368 | + RoundedRectangle(cornerRadius: 1) | |
| 369 | + .fill(ZyquoColor.accent) | |
| 370 | + .frame(width: 7, height: 15) | |
| 371 | + .opacity(visible ? 1 : 0.15) | |
| 372 | + .onAppear { | |
| 373 | + withAnimation(.easeInOut(duration: 0.55).repeatForever(autoreverses: true)) { | |
| 374 | + visible = false | |
| 375 | + } | |
| 376 | + } | |
| 377 | + } | |
| 378 | +} | |
added
Sources/ZyquoAgent/Views/EmptyStateView.swift
+120 −0
@@ -0,0 +1,120 @@ | ||
| 1 | +// | |
| 2 | +// EmptyStateView.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// The "What should I do on your Mac?" hero: brand glyph, four example task | |
| 9 | +// cards (clicking fills the input), the safety-mode selector, and the model | |
| 10 | +// chip — powerful and trustworthy, never blank. | |
| 11 | +// | |
| 12 | + | |
| 13 | +import SwiftUI | |
| 14 | + | |
| 15 | +struct AgentEmptyStateView: View { | |
| 16 | + let model: AIModel? | |
| 17 | + let safetyMode: SafetyMode | |
| 18 | + var onSelectModel: (AIModel) -> Void | |
| 19 | + var onSelectSafetyMode: (SafetyMode) -> Void | |
| 20 | + var onSuggestion: (String) -> Void | |
| 21 | + | |
| 22 | + private static let suggestions: [(symbol: String, title: String, prompt: String)] = [ | |
| 23 | + ("folder.badge.gearshape", "Organize my Downloads folder", | |
| 24 | + "Look at my Downloads folder, group the files by type into subfolders (Images, Documents, Archives, Installers…), and show me a summary of what you moved."), | |
| 25 | + ("textformat.abc.dottedunderline", "Batch-rename these files", | |
| 26 | + "Rename all the files in the workspace to a consistent kebab-case pattern with a numeric suffix, and list the before → after mapping."), | |
| 27 | + ("chevron.left.forwardslash.chevron.right", "Set up a Python project and run the tests", | |
| 28 | + "Create a small Python project in the workspace with a src/ layout, one example module with two functions, pytest tests for them, then run the tests and report the results."), | |
| 29 | + ("note.text", "Export my Notes to Markdown", | |
| 30 | + "Use AppleScript to read my Apple Notes and export each note in the default folder as a Markdown file in the workspace, named after its title."), | |
| 31 | + ] | |
| 32 | + | |
| 33 | + var body: some View { | |
| 34 | + VStack(spacing: ZyquoSpacing.lg) { | |
| 35 | + Spacer() | |
| 36 | + AgentZGlyph(size: 88) | |
| 37 | + VStack(spacing: ZyquoSpacing.xxs) { | |
| 38 | + Text("What should I do on your Mac?") | |
| 39 | + .font(ZyquoFont.title) | |
| 40 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 41 | + Text("Zyquo Agent plans, runs commands, and works until it's done — with your approval on anything risky.") | |
| 42 | + .font(ZyquoFont.body()) | |
| 43 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 44 | + .multilineTextAlignment(.center) | |
| 45 | + } | |
| 46 | + HStack(spacing: ZyquoSpacing.sm) { | |
| 47 | + ModelChipView(model: model, onSelect: onSelectModel) | |
| 48 | + SafetyModePicker(mode: safetyMode, onSelect: onSelectSafetyMode) | |
| 49 | + } | |
| 50 | + LazyVGrid( | |
| 51 | + columns: [GridItem(.flexible()), GridItem(.flexible())], | |
| 52 | + spacing: ZyquoSpacing.sm | |
| 53 | + ) { | |
| 54 | + ForEach(Self.suggestions, id: \.title) { suggestion in | |
| 55 | + Button { | |
| 56 | + onSuggestion(suggestion.prompt) | |
| 57 | + } label: { | |
| 58 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 59 | + Image(systemName: suggestion.symbol) | |
| 60 | + .font(.system(size: 14)) | |
| 61 | + .foregroundStyle(ZyquoColor.accent) | |
| 62 | + .frame(width: 20) | |
| 63 | + Text(suggestion.title) | |
| 64 | + .font(ZyquoFont.body(size: 13)) | |
| 65 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 66 | + .multilineTextAlignment(.leading) | |
| 67 | + Spacer(minLength: 0) | |
| 68 | + } | |
| 69 | + .padding(ZyquoSpacing.sm) | |
| 70 | + .background( | |
| 71 | + RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous) | |
| 72 | + .fill(ZyquoColor.surface) | |
| 73 | + .overlay( | |
| 74 | + RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous) | |
| 75 | + .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline) | |
| 76 | + ) | |
| 77 | + ) | |
| 78 | + .contentShape(Rectangle()) | |
| 79 | + } | |
| 80 | + .buttonStyle(PressableButtonStyle()) | |
| 81 | + } | |
| 82 | + } | |
| 83 | + .frame(maxWidth: ZyquoMetrics.emptyStateGridWidth) | |
| 84 | + Spacer() | |
| 85 | + Spacer() | |
| 86 | + } | |
| 87 | + .frame(maxWidth: .infinity, maxHeight: .infinity) | |
| 88 | + .background(ZyquoColor.background) | |
| 89 | + } | |
| 90 | +} | |
| 91 | + | |
| 92 | +/// The Manual / Guarded / Autonomous segmented control (header + empty state). | |
| 93 | +struct SafetyModePicker: View { | |
| 94 | + let mode: SafetyMode | |
| 95 | + var onSelect: (SafetyMode) -> Void | |
| 96 | + | |
| 97 | + var body: some View { | |
| 98 | + Picker("Safety mode", selection: Binding( | |
| 99 | + get: { mode }, | |
| 100 | + set: { onSelect($0) } | |
| 101 | + )) { | |
| 102 | + ForEach(SafetyMode.allCases) { candidate in | |
| 103 | + Text(candidate.displayName).tag(candidate) | |
| 104 | + } | |
| 105 | + } | |
| 106 | + .pickerStyle(.segmented) | |
| 107 | + .labelsHidden() | |
| 108 | + .controlSize(.small) | |
| 109 | + .fixedSize() | |
| 110 | + .help(helpText) | |
| 111 | + } | |
| 112 | + | |
| 113 | + private var helpText: String { | |
| 114 | + switch mode { | |
| 115 | + case .manual: return "Manual — approve every action" | |
| 116 | + case .guarded: return "Guarded — safe actions run automatically, anything mutating asks" | |
| 117 | + case .autonomous: return "Autonomous — runs freely within budget; destructive actions still ask" | |
| 118 | + } | |
| 119 | + } | |
| 120 | +} | |
added
Sources/ZyquoAgent/Views/MainWindowView.swift
+55 −0
@@ -0,0 +1,55 @@ | ||
| 1 | +// | |
| 2 | +// MainWindowView.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// The command-center root: translucent 260pt sidebar (NavigationSplitView's | |
| 9 | +// native sidebar material) + the task detail. With no task selected, the | |
| 10 | +// "What should I do on your Mac?" hero creates one. | |
| 11 | +// | |
| 12 | + | |
| 13 | +import SwiftUI | |
| 14 | + | |
| 15 | +struct MainWindowView: View { | |
| 16 | + @EnvironmentObject private var store: TaskStore | |
| 17 | + @EnvironmentObject private var catalog: ModelCatalog | |
| 18 | + @EnvironmentObject private var appearance: AppearanceStore | |
| 19 | + | |
| 20 | + var body: some View { | |
| 21 | + NavigationSplitView { | |
| 22 | + SidebarView() | |
| 23 | + .navigationSplitViewColumnWidth( | |
| 24 | + min: ZyquoMetrics.sidebarWidth, | |
| 25 | + ideal: ZyquoMetrics.sidebarWidth, | |
| 26 | + max: 360 | |
| 27 | + ) | |
| 28 | + } detail: { | |
| 29 | + if let id = store.selectedID, store.task(id: id) != nil { | |
| 30 | + TaskDetailView(taskID: id) | |
| 31 | + } else { | |
| 32 | + AgentEmptyStateView( | |
| 33 | + model: catalog.defaultAgentModel, | |
| 34 | + safetyMode: .guarded, | |
| 35 | + onSelectModel: { model in | |
| 36 | + store.newTask(model: model) | |
| 37 | + }, | |
| 38 | + onSelectSafetyMode: { mode in | |
| 39 | + store.newTask(model: catalog.defaultAgentModel, safetyMode: mode) | |
| 40 | + }, | |
| 41 | + onSuggestion: { prompt in | |
| 42 | + store.pendingDraft = prompt | |
| 43 | + store.newTask(model: catalog.defaultAgentModel) | |
| 44 | + } | |
| 45 | + ) | |
| 46 | + } | |
| 47 | + } | |
| 48 | + .frame( | |
| 49 | + minWidth: ZyquoMetrics.windowMinWidth, | |
| 50 | + minHeight: ZyquoMetrics.windowMinHeight | |
| 51 | + ) | |
| 52 | + .preferredColorScheme(appearance.themeMode.colorScheme) | |
| 53 | + .tint(appearance.accentColor) | |
| 54 | + } | |
| 55 | +} | |
added
Sources/ZyquoAgent/Views/Markdown/CodeBlockView.swift
+101 −0
@@ -0,0 +1,101 @@ | ||
| 1 | +// | |
| 2 | +// CodeBlockView.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Fenced code block per the Phase 4 spec: surfaceSecondary card with medium | |
| 9 | +// radius, uppercased language label top-left, hover copy button top-right | |
| 10 | +// (with a brief checkmark confirmation), SF Mono content with syntax | |
| 11 | +// highlighting, horizontal scrolling for long lines, and text selection. | |
| 12 | +// | |
| 13 | + | |
| 14 | +import SwiftUI | |
| 15 | + | |
| 16 | +struct CodeBlockView: View { | |
| 17 | + let code: String | |
| 18 | + let language: String? | |
| 19 | + let fontSize: Double | |
| 20 | + | |
| 21 | + @State private var hovering = false | |
| 22 | + @State private var copied = false | |
| 23 | + @State private var copyGeneration = 0 | |
| 24 | + | |
| 25 | + /// How long the copy confirmation checkmark stays visible. | |
| 26 | + private static let copyConfirmationSeconds: Double = 1.2 | |
| 27 | + | |
| 28 | + var body: some View { | |
| 29 | + VStack(alignment: .leading, spacing: 0) { | |
| 30 | + header | |
| 31 | + ScrollView(.horizontal) { | |
| 32 | + SwiftUI.Text(highlighted) | |
| 33 | + .font(ZyquoFont.code(size: max(fontSize - 1, 1))) | |
| 34 | + .textSelection(.enabled) | |
| 35 | + .fixedSize(horizontal: false, vertical: true) | |
| 36 | + .padding(.horizontal, ZyquoSpacing.sm) | |
| 37 | + .padding(.top, ZyquoSpacing.xxs) | |
| 38 | + .padding(.bottom, ZyquoSpacing.xs) | |
| 39 | + } | |
| 40 | + } | |
| 41 | + .background( | |
| 42 | + RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous) | |
| 43 | + .fill(ZyquoColor.surfaceSecondary) | |
| 44 | + ) | |
| 45 | + .onHover { inside in | |
| 46 | + withAnimation(ZyquoMotion.hover) { hovering = inside } | |
| 47 | + } | |
| 48 | + } | |
| 49 | + | |
| 50 | + // MARK: Header | |
| 51 | + | |
| 52 | + private var header: some View { | |
| 53 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 54 | + if let language, !language.isEmpty { | |
| 55 | + SwiftUI.Text(language.uppercased()) | |
| 56 | + .font(ZyquoFont.caption) | |
| 57 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 58 | + } | |
| 59 | + Spacer(minLength: ZyquoSpacing.xs) | |
| 60 | + copyButton | |
| 61 | + .opacity(hovering || copied ? 1 : 0) | |
| 62 | + } | |
| 63 | + .padding(.horizontal, ZyquoSpacing.sm) | |
| 64 | + .padding(.top, ZyquoSpacing.xs) | |
| 65 | + } | |
| 66 | + | |
| 67 | + private var copyButton: some View { | |
| 68 | + Button(action: copy) { | |
| 69 | + Image(systemName: copied ? "checkmark" : "doc.on.doc") | |
| 70 | + .font(ZyquoFont.caption) | |
| 71 | + .foregroundStyle(copied ? ZyquoColor.success : ZyquoColor.textSecondary) | |
| 72 | + } | |
| 73 | + .buttonStyle(PressableButtonStyle()) | |
| 74 | + .accessibilityLabel("Copy code") | |
| 75 | + .help("Copy code") | |
| 76 | + } | |
| 77 | + | |
| 78 | + // MARK: Highlighting | |
| 79 | + | |
| 80 | + private var highlighted: AttributedString { | |
| 81 | + SyntaxHighlighter.highlight(code, language: language, baseColor: ZyquoColor.textPrimary) | |
| 82 | + } | |
| 83 | + | |
| 84 | + // MARK: Copy | |
| 85 | + | |
| 86 | + private func copy() { | |
| 87 | + let pasteboard = NSPasteboard.general | |
| 88 | + pasteboard.clearContents() | |
| 89 | + pasteboard.setString(code, forType: .string) | |
| 90 | + | |
| 91 | + copyGeneration += 1 | |
| 92 | + let generation = copyGeneration | |
| 93 | + withAnimation(ZyquoMotion.hover) { copied = true } | |
| 94 | + Task { | |
| 95 | + try? await Task.sleep(for: .seconds(Self.copyConfirmationSeconds)) | |
| 96 | + if generation == copyGeneration { | |
| 97 | + withAnimation(ZyquoMotion.hover) { copied = false } | |
| 98 | + } | |
| 99 | + } | |
| 100 | + } | |
| 101 | +} | |
added
Sources/ZyquoAgent/Views/Markdown/MarkdownView.swift
+481 −0
@@ -0,0 +1,481 @@ | ||
| 1 | +// | |
| 2 | +// MarkdownView.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Full Markdown rendering for chat messages per the Phase 4 spec: the | |
| 9 | +// swift-markdown AST is walked once into a lightweight [MarkdownBlock] model | |
| 10 | +// (memoized by text hash — streaming deltas re-parse only the changed text), | |
| 11 | +// then rendered as SwiftUI views built entirely from ZyquoTheme tokens. | |
| 12 | +// Links use the AttributedString .link attribute, so SwiftUI.Text routes | |
| 13 | +// clicks through the environment's openURL automatically. Parsing is | |
| 14 | +// resilient to incomplete Markdown (unterminated fences etc.) — swift-markdown | |
| 15 | +// degrades gracefully, so streaming partial text never crashes. | |
| 16 | +// | |
| 17 | + | |
| 18 | +import SwiftUI | |
| 19 | +import Markdown | |
| 20 | + | |
| 21 | +// MARK: - MarkdownView | |
| 22 | + | |
| 23 | +struct MarkdownView: View { | |
| 24 | + let fontSize: Double | |
| 25 | + private let blocks: [MarkdownBlock] | |
| 26 | + | |
| 27 | + init(text: String, fontSize: Double) { | |
| 28 | + self.fontSize = fontSize | |
| 29 | + self.blocks = MarkdownBlockParser.parse(text: text, fontSize: fontSize) | |
| 30 | + } | |
| 31 | + | |
| 32 | + var body: some View { | |
| 33 | + VStack(alignment: .leading, spacing: ZyquoSpacing.sm) { | |
| 34 | + ForEach(blocks) { block in | |
| 35 | + MarkdownBlockView(block: block, fontSize: fontSize) | |
| 36 | + } | |
| 37 | + } | |
| 38 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 39 | + } | |
| 40 | +} | |
| 41 | + | |
| 42 | +// MARK: - Block model | |
| 43 | + | |
| 44 | +/// One rendered Markdown block. Ids are assigned in document order at parse | |
| 45 | +/// time so `ForEach` stays stable within a parse. | |
| 46 | +struct MarkdownBlock: Identifiable { | |
| 47 | + let id: Int | |
| 48 | + let kind: Kind | |
| 49 | + | |
| 50 | + enum Kind { | |
| 51 | + case paragraph(AttributedString) | |
| 52 | + case heading(AttributedString, level: Int) | |
| 53 | + case code(String, language: String?) | |
| 54 | + case quote([MarkdownBlock]) | |
| 55 | + case list(ListData) | |
| 56 | + case table(TableData) | |
| 57 | + case thematicBreak | |
| 58 | + } | |
| 59 | + | |
| 60 | + struct ListData { | |
| 61 | + let ordered: Bool | |
| 62 | + let start: Int | |
| 63 | + let items: [ListItemData] | |
| 64 | + } | |
| 65 | + | |
| 66 | + struct ListItemData: Identifiable { | |
| 67 | + let id: Int | |
| 68 | + /// nil = plain item; true/false = task-list checkbox state. | |
| 69 | + let checked: Bool? | |
| 70 | + let blocks: [MarkdownBlock] | |
| 71 | + } | |
| 72 | + | |
| 73 | + struct TableData { | |
| 74 | + let alignments: [TextAlignment] | |
| 75 | + let header: [AttributedString] | |
| 76 | + let rows: [[AttributedString]] | |
| 77 | + | |
| 78 | + func alignment(forColumn column: Int) -> TextAlignment { | |
| 79 | + column < alignments.count ? alignments[column] : .leading | |
| 80 | + } | |
| 81 | + } | |
| 82 | +} | |
| 83 | + | |
| 84 | +// MARK: - Parser | |
| 85 | + | |
| 86 | +/// Walks the swift-markdown `Document` AST into `[MarkdownBlock]`. Results are | |
| 87 | +/// memoized by (text, fontSize) so re-renders during streaming only re-parse | |
| 88 | +/// when the text actually changes. | |
| 89 | +enum MarkdownBlockParser { | |
| 90 | + /// Heading sizes scale off the user's chat font size. | |
| 91 | + private static func headingSize(level: Int, base: Double) -> Double { | |
| 92 | + switch level { | |
| 93 | + case 1: return base * 1.55 | |
| 94 | + case 2: return base * 1.35 | |
| 95 | + case 3: return base * 1.2 | |
| 96 | + default: return base * 1.05 | |
| 97 | + } | |
| 98 | + } | |
| 99 | + | |
| 100 | + static func parse(text: String, fontSize: Double) -> [MarkdownBlock] { | |
| 101 | + let key = CacheKey(textHash: text.hashValue, length: text.count, fontBits: fontSize.bitPattern) | |
| 102 | + cacheLock.lock() | |
| 103 | + if let hit = cache[key] { | |
| 104 | + cacheLock.unlock() | |
| 105 | + return hit | |
| 106 | + } | |
| 107 | + cacheLock.unlock() | |
| 108 | + | |
| 109 | + let document = Document(parsing: text) | |
| 110 | + var counter = 0 | |
| 111 | + let blocks = convertBlocks(of: document, fontSize: fontSize, counter: &counter) | |
| 112 | + | |
| 113 | + cacheLock.lock() | |
| 114 | + if cache.count > cacheCapacity { cache.removeAll(keepingCapacity: true) } | |
| 115 | + cache[key] = blocks | |
| 116 | + cacheLock.unlock() | |
| 117 | + return blocks | |
| 118 | + } | |
| 119 | + | |
| 120 | + // MARK: Cache | |
| 121 | + | |
| 122 | + private struct CacheKey: Hashable { | |
| 123 | + let textHash: Int | |
| 124 | + let length: Int | |
| 125 | + let fontBits: UInt64 | |
| 126 | + } | |
| 127 | + | |
| 128 | + private static let cacheLock = NSLock() | |
| 129 | + private static let cacheCapacity = 32 | |
| 130 | + private static var cache: [CacheKey: [MarkdownBlock]] = [:] | |
| 131 | + | |
| 132 | + // MARK: Block conversion | |
| 133 | + | |
| 134 | + private static func convertBlocks(of parent: Markup, fontSize: Double, counter: inout Int) -> [MarkdownBlock] { | |
| 135 | + parent.children.compactMap { convertBlock($0, fontSize: fontSize, counter: &counter) } | |
| 136 | + } | |
| 137 | + | |
| 138 | + private static func convertBlock(_ markup: Markup, fontSize: Double, counter: inout Int) -> MarkdownBlock? { | |
| 139 | + counter += 1 | |
| 140 | + let id = counter | |
| 141 | + | |
| 142 | + switch markup { | |
| 143 | + case let heading as Heading: | |
| 144 | + let font = Font.system(size: headingSize(level: heading.level, base: fontSize), weight: .semibold) | |
| 145 | + let content = inlineText(of: heading, fontSize: fontSize, baseFont: font) | |
| 146 | + return MarkdownBlock(id: id, kind: .heading(content, level: heading.level)) | |
| 147 | + | |
| 148 | + case let paragraph as Paragraph: | |
| 149 | + let content = inlineText(of: paragraph, fontSize: fontSize, baseFont: ZyquoFont.body(size: fontSize)) | |
| 150 | + guard !content.characters.isEmpty else { return nil } | |
| 151 | + return MarkdownBlock(id: id, kind: .paragraph(content)) | |
| 152 | + | |
| 153 | + case let codeBlock as CodeBlock: | |
| 154 | + var code = codeBlock.code | |
| 155 | + if code.hasSuffix("\n") { code.removeLast() } | |
| 156 | + return MarkdownBlock(id: id, kind: .code(code, language: codeBlock.language)) | |
| 157 | + | |
| 158 | + case let quote as BlockQuote: | |
| 159 | + return MarkdownBlock(id: id, kind: .quote(convertBlocks(of: quote, fontSize: fontSize, counter: &counter))) | |
| 160 | + | |
| 161 | + case let list as UnorderedList: | |
| 162 | + let items = convertListItems(of: list, fontSize: fontSize, counter: &counter) | |
| 163 | + return MarkdownBlock(id: id, kind: .list(.init(ordered: false, start: 1, items: items))) | |
| 164 | + | |
| 165 | + case let list as OrderedList: | |
| 166 | + let items = convertListItems(of: list, fontSize: fontSize, counter: &counter) | |
| 167 | + return MarkdownBlock(id: id, kind: .list(.init(ordered: true, start: Int(list.startIndex), items: items))) | |
| 168 | + | |
| 169 | + case let table as Markdown.Table: | |
| 170 | + return MarkdownBlock(id: id, kind: .table(convertTable(table, fontSize: fontSize))) | |
| 171 | + | |
| 172 | + case is ThematicBreak: | |
| 173 | + return MarkdownBlock(id: id, kind: .thematicBreak) | |
| 174 | + | |
| 175 | + case let html as HTMLBlock: | |
| 176 | + var raw = html.rawHTML | |
| 177 | + if raw.hasSuffix("\n") { raw.removeLast() } | |
| 178 | + return MarkdownBlock(id: id, kind: .code(raw, language: "html")) | |
| 179 | + | |
| 180 | + default: | |
| 181 | + // Unknown block: fall back to its re-formatted Markdown source. | |
| 182 | + let source = markup.format().trimmingCharacters(in: .whitespacesAndNewlines) | |
| 183 | + guard !source.isEmpty else { return nil } | |
| 184 | + var content = AttributedString(source) | |
| 185 | + content.font = ZyquoFont.body(size: fontSize) | |
| 186 | + return MarkdownBlock(id: id, kind: .paragraph(content)) | |
| 187 | + } | |
| 188 | + } | |
| 189 | + | |
| 190 | + private static func convertListItems(of list: Markup, fontSize: Double, counter: inout Int) -> [MarkdownBlock.ListItemData] { | |
| 191 | + list.children.compactMap { child in | |
| 192 | + guard let item = child as? Markdown.ListItem else { return nil } | |
| 193 | + counter += 1 | |
| 194 | + let id = counter | |
| 195 | + let checked: Bool? | |
| 196 | + switch item.checkbox { | |
| 197 | + case .checked: checked = true | |
| 198 | + case .unchecked: checked = false | |
| 199 | + case nil: checked = nil | |
| 200 | + } | |
| 201 | + return MarkdownBlock.ListItemData( | |
| 202 | + id: id, | |
| 203 | + checked: checked, | |
| 204 | + blocks: convertBlocks(of: item, fontSize: fontSize, counter: &counter) | |
| 205 | + ) | |
| 206 | + } | |
| 207 | + } | |
| 208 | + | |
| 209 | + private static func convertTable(_ table: Markdown.Table, fontSize: Double) -> MarkdownBlock.TableData { | |
| 210 | + let alignments: [TextAlignment] = table.columnAlignments.map { alignment in | |
| 211 | + switch alignment { | |
| 212 | + case .center: return .center | |
| 213 | + case .right: return .trailing | |
| 214 | + default: return .leading | |
| 215 | + } | |
| 216 | + } | |
| 217 | + let headerFont = ZyquoFont.bodyEmphasis(size: fontSize) | |
| 218 | + let header = table.head.children.compactMap { cell -> AttributedString? in | |
| 219 | + guard let cell = cell as? Markdown.Table.Cell else { return nil } | |
| 220 | + return inlineText(of: cell, fontSize: fontSize, baseFont: headerFont) | |
| 221 | + } | |
| 222 | + let bodyFont = ZyquoFont.body(size: fontSize) | |
| 223 | + let rows = table.body.children.compactMap { row -> [AttributedString]? in | |
| 224 | + guard let row = row as? Markdown.Table.Row else { return nil } | |
| 225 | + return row.children.compactMap { cell -> AttributedString? in | |
| 226 | + guard let cell = cell as? Markdown.Table.Cell else { return nil } | |
| 227 | + return inlineText(of: cell, fontSize: fontSize, baseFont: bodyFont) | |
| 228 | + } | |
| 229 | + } | |
| 230 | + return MarkdownBlock.TableData(alignments: alignments, header: header, rows: rows) | |
| 231 | + } | |
| 232 | + | |
| 233 | + // MARK: Inline conversion | |
| 234 | + | |
| 235 | + private static func inlineText( | |
| 236 | + of parent: Markup, | |
| 237 | + fontSize: Double, | |
| 238 | + baseFont: Font, | |
| 239 | + bold: Bool = false, | |
| 240 | + italic: Bool = false | |
| 241 | + ) -> AttributedString { | |
| 242 | + var result = AttributedString() | |
| 243 | + for child in parent.children { | |
| 244 | + result += inlineFragment(child, fontSize: fontSize, baseFont: baseFont, bold: bold, italic: italic) | |
| 245 | + } | |
| 246 | + return result | |
| 247 | + } | |
| 248 | + | |
| 249 | + private static func inlineFragment( | |
| 250 | + _ markup: Markup, | |
| 251 | + fontSize: Double, | |
| 252 | + baseFont: Font, | |
| 253 | + bold: Bool, | |
| 254 | + italic: Bool | |
| 255 | + ) -> AttributedString { | |
| 256 | + switch markup { | |
| 257 | + case let text as Markdown.Text: | |
| 258 | + return styled(text.string, baseFont: baseFont, bold: bold, italic: italic) | |
| 259 | + | |
| 260 | + case let emphasis as Emphasis: | |
| 261 | + return inlineText(of: emphasis, fontSize: fontSize, baseFont: baseFont, bold: bold, italic: true) | |
| 262 | + | |
| 263 | + case let strong as Strong: | |
| 264 | + return inlineText(of: strong, fontSize: fontSize, baseFont: baseFont, bold: true, italic: italic) | |
| 265 | + | |
| 266 | + case let code as InlineCode: | |
| 267 | + var segment = AttributedString(code.code) | |
| 268 | + segment.font = ZyquoFont.code(size: max(fontSize - 1, 1)) | |
| 269 | + segment.backgroundColor = ZyquoColor.surfaceSecondary | |
| 270 | + return segment | |
| 271 | + | |
| 272 | + case let link as Markdown.Link: | |
| 273 | + var segment = inlineText(of: link, fontSize: fontSize, baseFont: baseFont, bold: bold, italic: italic) | |
| 274 | + segment.foregroundColor = ZyquoColor.accent | |
| 275 | + if let destination = link.destination, let url = URL(string: destination) { | |
| 276 | + segment.link = url | |
| 277 | + } | |
| 278 | + return segment | |
| 279 | + | |
| 280 | + case let image as Markdown.Image: | |
| 281 | + // No inline image loading in chat transcripts: render the alt text | |
| 282 | + // (or the source) as a link to the image. | |
| 283 | + var segment = inlineText(of: image, fontSize: fontSize, baseFont: baseFont, bold: bold, italic: italic) | |
| 284 | + if segment.characters.isEmpty, let source = image.source { | |
| 285 | + segment = styled(source, baseFont: baseFont, bold: bold, italic: italic) | |
| 286 | + } | |
| 287 | + segment.foregroundColor = ZyquoColor.accent | |
| 288 | + if let source = image.source, let url = URL(string: source) { | |
| 289 | + segment.link = url | |
| 290 | + } | |
| 291 | + return segment | |
| 292 | + | |
| 293 | + case let strike as Strikethrough: | |
| 294 | + var segment = inlineText(of: strike, fontSize: fontSize, baseFont: baseFont, bold: bold, italic: italic) | |
| 295 | + segment[AttributeScopes.SwiftUIAttributes.StrikethroughStyleAttribute.self] = .single | |
| 296 | + return segment | |
| 297 | + | |
| 298 | + case is SoftBreak: | |
| 299 | + return styled(" ", baseFont: baseFont, bold: bold, italic: italic) | |
| 300 | + | |
| 301 | + case is LineBreak: | |
| 302 | + return styled("\n", baseFont: baseFont, bold: bold, italic: italic) | |
| 303 | + | |
| 304 | + case let html as InlineHTML: | |
| 305 | + return styled(html.rawHTML, baseFont: baseFont, bold: bold, italic: italic) | |
| 306 | + | |
| 307 | + default: | |
| 308 | + return styled(markup.format(), baseFont: baseFont, bold: bold, italic: italic) | |
| 309 | + } | |
| 310 | + } | |
| 311 | + | |
| 312 | + private static func styled(_ string: String, baseFont: Font, bold: Bool, italic: Bool) -> AttributedString { | |
| 313 | + var segment = AttributedString(string) | |
| 314 | + var font = baseFont | |
| 315 | + if bold { font = font.bold() } | |
| 316 | + if italic { font = font.italic() } | |
| 317 | + segment.font = font | |
| 318 | + return segment | |
| 319 | + } | |
| 320 | +} | |
| 321 | + | |
| 322 | +// MARK: - Block rendering | |
| 323 | + | |
| 324 | +private struct MarkdownBlockView: View { | |
| 325 | + let block: MarkdownBlock | |
| 326 | + let fontSize: Double | |
| 327 | + | |
| 328 | + /// Blockquote accent bar width (Phase 4 spec: 3pt accent left bar). | |
| 329 | + private static let quoteBarWidth: CGFloat = 3 | |
| 330 | + | |
| 331 | + var body: some View { | |
| 332 | + switch block.kind { | |
| 333 | + case .paragraph(let content): | |
| 334 | + SwiftUI.Text(content) | |
| 335 | + .lineSpacing(fontSize * ZyquoFont.bodyLineSpacingFactor) | |
| 336 | + .textSelection(.enabled) | |
| 337 | + .fixedSize(horizontal: false, vertical: true) | |
| 338 | + | |
| 339 | + case .heading(let content, _): | |
| 340 | + SwiftUI.Text(content) | |
| 341 | + .textSelection(.enabled) | |
| 342 | + .fixedSize(horizontal: false, vertical: true) | |
| 343 | + .padding(.top, ZyquoSpacing.xxs) | |
| 344 | + | |
| 345 | + case .code(let code, let language): | |
| 346 | + CodeBlockView(code: code, language: language, fontSize: fontSize) | |
| 347 | + | |
| 348 | + case .quote(let children): | |
| 349 | + HStack(alignment: .top, spacing: ZyquoSpacing.sm) { | |
| 350 | + RoundedRectangle(cornerRadius: Self.quoteBarWidth / 2, style: .continuous) | |
| 351 | + .fill(ZyquoColor.accent) | |
| 352 | + .frame(width: Self.quoteBarWidth) | |
| 353 | + VStack(alignment: .leading, spacing: ZyquoSpacing.xs) { | |
| 354 | + ForEach(children) { child in | |
| 355 | + MarkdownBlockView(block: child, fontSize: fontSize) | |
| 356 | + } | |
| 357 | + } | |
| 358 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 359 | + } | |
| 360 | + | |
| 361 | + case .list(let data): | |
| 362 | + listView(data) | |
| 363 | + | |
| 364 | + case .table(let data): | |
| 365 | + tableView(data) | |
| 366 | + | |
| 367 | + case .thematicBreak: | |
| 368 | + ZyquoHairline() | |
| 369 | + .padding(.vertical, ZyquoSpacing.xxs) | |
| 370 | + } | |
| 371 | + } | |
| 372 | + | |
| 373 | + // MARK: Lists | |
| 374 | + | |
| 375 | + private func listView(_ data: MarkdownBlock.ListData) -> some View { | |
| 376 | + VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) { | |
| 377 | + ForEach(Array(data.items.enumerated()), id: \.element.id) { offset, item in | |
| 378 | + HStack(alignment: .firstTextBaseline, spacing: ZyquoSpacing.xs) { | |
| 379 | + marker(for: item, ordinal: data.start + offset, ordered: data.ordered) | |
| 380 | + VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) { | |
| 381 | + ForEach(item.blocks) { child in | |
| 382 | + MarkdownBlockView(block: child, fontSize: fontSize) | |
| 383 | + } | |
| 384 | + } | |
| 385 | + } | |
| 386 | + } | |
| 387 | + } | |
| 388 | + } | |
| 389 | + | |
| 390 | + @ViewBuilder | |
| 391 | + private func marker(for item: MarkdownBlock.ListItemData, ordinal: Int, ordered: Bool) -> some View { | |
| 392 | + if let checked = item.checked { | |
| 393 | + Image(systemName: checked ? "checkmark.square.fill" : "square") | |
| 394 | + .font(ZyquoFont.body(size: fontSize)) | |
| 395 | + .foregroundStyle(checked ? ZyquoColor.accent : ZyquoColor.textSecondary) | |
| 396 | + } else if ordered { | |
| 397 | + SwiftUI.Text("\(ordinal).") | |
| 398 | + .font(ZyquoFont.body(size: fontSize).monospacedDigit()) | |
| 399 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 400 | + .frame(minWidth: ZyquoSpacing.lg, alignment: .trailing) | |
| 401 | + } else { | |
| 402 | + SwiftUI.Text("•") | |
| 403 | + .font(ZyquoFont.body(size: fontSize)) | |
| 404 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 405 | + } | |
| 406 | + } | |
| 407 | + | |
| 408 | + // MARK: Tables | |
| 409 | + | |
| 410 | + private func tableView(_ data: MarkdownBlock.TableData) -> some View { | |
| 411 | + Grid(alignment: .topLeading, horizontalSpacing: 0, verticalSpacing: 0) { | |
| 412 | + GridRow { | |
| 413 | + ForEach(data.header.indices, id: \.self) { column in | |
| 414 | + tableCell( | |
| 415 | + data.header[column], | |
| 416 | + data: data, | |
| 417 | + column: column, | |
| 418 | + tinted: true, | |
| 419 | + isLastRow: data.rows.isEmpty | |
| 420 | + ) | |
| 421 | + } | |
| 422 | + } | |
| 423 | + ForEach(data.rows.indices, id: \.self) { rowIndex in | |
| 424 | + GridRow { | |
| 425 | + ForEach(data.rows[rowIndex].indices, id: \.self) { column in | |
| 426 | + tableCell( | |
| 427 | + data.rows[rowIndex][column], | |
| 428 | + data: data, | |
| 429 | + column: column, | |
| 430 | + tinted: rowIndex % 2 == 1, | |
| 431 | + isLastRow: rowIndex == data.rows.count - 1 | |
| 432 | + ) | |
| 433 | + } | |
| 434 | + } | |
| 435 | + } | |
| 436 | + } | |
| 437 | + .clipShape(RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)) | |
| 438 | + .overlay( | |
| 439 | + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) | |
| 440 | + .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline) | |
| 441 | + ) | |
| 442 | + } | |
| 443 | + | |
| 444 | + private func tableCell( | |
| 445 | + _ content: AttributedString, | |
| 446 | + data: MarkdownBlock.TableData, | |
| 447 | + column: Int, | |
| 448 | + tinted: Bool, | |
| 449 | + isLastRow: Bool | |
| 450 | + ) -> some View { | |
| 451 | + let alignment = data.alignment(forColumn: column) | |
| 452 | + let columnCount = max(data.header.count, data.rows.map(\.count).max() ?? 0) | |
| 453 | + let isLastColumn = column == columnCount - 1 | |
| 454 | + return SwiftUI.Text(content) | |
| 455 | + .multilineTextAlignment(alignment) | |
| 456 | + .textSelection(.enabled) | |
| 457 | + .fixedSize(horizontal: false, vertical: true) | |
| 458 | + .padding(.horizontal, ZyquoSpacing.sm) | |
| 459 | + .padding(.vertical, ZyquoSpacing.xs) | |
| 460 | + .frame(maxWidth: .infinity, alignment: frameAlignment(for: alignment)) | |
| 461 | + .background(tinted ? ZyquoColor.surfaceSecondary : Color.clear) | |
| 462 | + .overlay(alignment: .bottom) { | |
| 463 | + if !isLastRow { ZyquoHairline() } | |
| 464 | + } | |
| 465 | + .overlay(alignment: .trailing) { | |
| 466 | + if !isLastColumn { | |
| 467 | + Rectangle() | |
| 468 | + .fill(ZyquoColor.border) | |
| 469 | + .frame(width: ZyquoMetrics.hairline) | |
| 470 | + } | |
| 471 | + } | |
| 472 | + } | |
| 473 | + | |
| 474 | + private func frameAlignment(for alignment: TextAlignment) -> Alignment { | |
| 475 | + switch alignment { | |
| 476 | + case .leading: return .leading | |
| 477 | + case .center: return .center | |
| 478 | + case .trailing: return .trailing | |
| 479 | + } | |
| 480 | + } | |
| 481 | +} | |
added
Sources/ZyquoAgent/Views/Markdown/SyntaxHighlighter.swift
+470 −0
@@ -0,0 +1,470 @@ | ||
| 1 | +// | |
| 2 | +// SyntaxHighlighter.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Lightweight scanner-based syntax highlighter for code blocks. Supports the | |
| 9 | +// Phase 4 language set (Swift, Python, JS/TS, JSON, HTML, CSS, Bash, SQL, Go, | |
| 10 | +// Rust, C/C++/Obj-C). Token colors are code-specific design tokens defined in | |
| 11 | +// `CodeTheme` with the same dynamic light/dark pattern as `ZyquoColor`, | |
| 12 | +// referencing existing semantic tokens where they fit. Unknown languages fall | |
| 13 | +// back to plain text in the base color. | |
| 14 | +// | |
| 15 | + | |
| 16 | +import SwiftUI | |
| 17 | + | |
| 18 | +// MARK: - Code theme | |
| 19 | + | |
| 20 | +/// Semantic colors for code tokens. Dynamic (light/dark) and coherent with the | |
| 21 | +/// app palette: indigo/sky family for keywords, success-green strings, tertiary | |
| 22 | +/// gray comments, amber numbers. | |
| 23 | +struct CodeTheme { | |
| 24 | + let keyword: Color | |
| 25 | + let string: Color | |
| 26 | + let comment: Color | |
| 27 | + let number: Color | |
| 28 | + let type: Color | |
| 29 | + let functionCall: Color | |
| 30 | + let property: Color | |
| 31 | + let attribute: Color | |
| 32 | + | |
| 33 | + /// The default Zyquo Agent code theme. | |
| 34 | + static let zyquo = CodeTheme( | |
| 35 | + keyword: ZyquoColor.accent, | |
| 36 | + string: ZyquoColor.success, | |
| 37 | + comment: ZyquoColor.textTertiary, | |
| 38 | + number: dynamic(light: 0xB26A0B, dark: 0xE0A458), | |
| 39 | + type: dynamic(light: 0x2380C2, dark: 0x62B7F0), | |
| 40 | + functionCall: dynamic(light: 0x6E4FD4, dark: 0xA48CF2), | |
| 41 | + property: dynamic(light: 0x2F6FBF, dark: 0x7FB4E8), | |
| 42 | + attribute: ZyquoColor.warning | |
| 43 | + ) | |
| 44 | + | |
| 45 | + /// Same dynamic-color pattern as `ZyquoColor` (resolved per appearance). | |
| 46 | + private static func dynamic(light: UInt32, dark: UInt32) -> Color { | |
| 47 | + Color(nsColor: NSColor(name: nil) { appearance in | |
| 48 | + let hex = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ? dark : light | |
| 49 | + return NSColor(hex: hex) | |
| 50 | + }) | |
| 51 | + } | |
| 52 | +} | |
| 53 | + | |
| 54 | +// MARK: - Highlighter | |
| 55 | + | |
| 56 | +enum SyntaxHighlighter { | |
| 57 | + /// The active code theme. | |
| 58 | + static let theme = CodeTheme.zyquo | |
| 59 | + | |
| 60 | + /// Highlights `code` for `language`, returning an `AttributedString` whose | |
| 61 | + /// text is byte-for-byte identical to the input. Plain (unclassified) text | |
| 62 | + /// is colored with `baseColor`. Unknown or nil languages return the whole | |
| 63 | + /// string in `baseColor`. Results are memoized (streaming re-renders hit | |
| 64 | + /// the cache for every already-completed block). | |
| 65 | + static func highlight(_ code: String, language: String?, baseColor: Color) -> AttributedString { | |
| 66 | + guard !code.isEmpty else { return AttributedString() } | |
| 67 | + guard let profile = profile(for: language) else { | |
| 68 | + var plain = AttributedString(code) | |
| 69 | + plain.foregroundColor = baseColor | |
| 70 | + return plain | |
| 71 | + } | |
| 72 | + | |
| 73 | + let key = CacheKey( | |
| 74 | + textHash: code.hashValue, | |
| 75 | + length: code.count, | |
| 76 | + language: language?.lowercased() ?? "", | |
| 77 | + base: String(describing: baseColor) | |
| 78 | + ) | |
| 79 | + cacheLock.lock() | |
| 80 | + if let hit = cache[key] { | |
| 81 | + cacheLock.unlock() | |
| 82 | + return hit | |
| 83 | + } | |
| 84 | + cacheLock.unlock() | |
| 85 | + | |
| 86 | + let result = tokenize(code, profile: profile, baseColor: baseColor) | |
| 87 | + | |
| 88 | + cacheLock.lock() | |
| 89 | + if cache.count > cacheCapacity { cache.removeAll(keepingCapacity: true) } | |
| 90 | + cache[key] = result | |
| 91 | + cacheLock.unlock() | |
| 92 | + return result | |
| 93 | + } | |
| 94 | + | |
| 95 | + // MARK: Cache | |
| 96 | + | |
| 97 | + private struct CacheKey: Hashable { | |
| 98 | + let textHash: Int | |
| 99 | + let length: Int | |
| 100 | + let language: String | |
| 101 | + let base: String | |
| 102 | + } | |
| 103 | + | |
| 104 | + private static let cacheLock = NSLock() | |
| 105 | + private static let cacheCapacity = 128 | |
| 106 | + private static var cache: [CacheKey: AttributedString] = [:] | |
| 107 | + | |
| 108 | + // MARK: Scanner | |
| 109 | + | |
| 110 | + private static func tokenize(_ code: String, profile: LanguageProfile, baseColor: Color) -> AttributedString { | |
| 111 | + let chars = Array(code) | |
| 112 | + var result = AttributedString() | |
| 113 | + var i = 0 | |
| 114 | + var pendingPlainStart = 0 | |
| 115 | + var previousSignificant: Character? | |
| 116 | + | |
| 117 | + func matches(_ marker: [Character], at index: Int) -> Bool { | |
| 118 | + guard index + marker.count <= chars.count else { return false } | |
| 119 | + for (offset, ch) in marker.enumerated() where chars[index + offset] != ch { | |
| 120 | + return false | |
| 121 | + } | |
| 122 | + return true | |
| 123 | + } | |
| 124 | + | |
| 125 | + func nextSignificant(after index: Int) -> Character? { | |
| 126 | + var j = index | |
| 127 | + while j < chars.count, chars[j] == " " || chars[j] == "\t" { j += 1 } | |
| 128 | + return j < chars.count ? chars[j] : nil | |
| 129 | + } | |
| 130 | + | |
| 131 | + func flushPlain(upTo end: Int) { | |
| 132 | + guard end > pendingPlainStart else { return } | |
| 133 | + var segment = AttributedString(String(chars[pendingPlainStart ..< end])) | |
| 134 | + segment.foregroundColor = baseColor | |
| 135 | + result += segment | |
| 136 | + pendingPlainStart = end | |
| 137 | + } | |
| 138 | + | |
| 139 | + func emit(_ start: Int, _ end: Int, _ color: Color) { | |
| 140 | + flushPlain(upTo: start) | |
| 141 | + var segment = AttributedString(String(chars[start ..< end])) | |
| 142 | + segment.foregroundColor = color | |
| 143 | + result += segment | |
| 144 | + pendingPlainStart = end | |
| 145 | + } | |
| 146 | + | |
| 147 | + func isIdentifierStart(_ c: Character) -> Bool { | |
| 148 | + c.isLetter || c == "_" || profile.identifierExtras.contains(c) | |
| 149 | + } | |
| 150 | + | |
| 151 | + func isIdentifierBody(_ c: Character) -> Bool { | |
| 152 | + c.isLetter || c.isNumber || c == "_" || profile.identifierExtras.contains(c) | |
| 153 | + } | |
| 154 | + | |
| 155 | + while i < chars.count { | |
| 156 | + let c = chars[i] | |
| 157 | + | |
| 158 | + // Block comments (unterminated ones run to EOF — streaming safe). | |
| 159 | + if let block = profile.blockComments.first(where: { matches($0.open, at: i) }) { | |
| 160 | + let start = i | |
| 161 | + i += block.open.count | |
| 162 | + while i < chars.count, !matches(block.close, at: i) { i += 1 } | |
| 163 | + if i < chars.count { i += block.close.count } | |
| 164 | + emit(start, i, theme.comment) | |
| 165 | + previousSignificant = nil | |
| 166 | + continue | |
| 167 | + } | |
| 168 | + | |
| 169 | + // Line comments. | |
| 170 | + if let line = profile.lineComments.first(where: { matches($0, at: i) }) { | |
| 171 | + let start = i | |
| 172 | + i += line.count | |
| 173 | + while i < chars.count, chars[i] != "\n" { i += 1 } | |
| 174 | + emit(start, i, theme.comment) | |
| 175 | + previousSignificant = nil | |
| 176 | + continue | |
| 177 | + } | |
| 178 | + | |
| 179 | + // Strings (with backslash escapes; triple quotes for Python-style). | |
| 180 | + if profile.stringDelimiters.contains(c) { | |
| 181 | + let start = i | |
| 182 | + let triple = [c, c, c] | |
| 183 | + if matches(triple, at: i) { | |
| 184 | + i += 3 | |
| 185 | + while i < chars.count, !matches(triple, at: i) { i += 1 } | |
| 186 | + if i < chars.count { i += 3 } | |
| 187 | + } else { | |
| 188 | + i += 1 | |
| 189 | + while i < chars.count { | |
| 190 | + if chars[i] == "\\" { i += 2; continue } | |
| 191 | + if chars[i] == c { i += 1; break } | |
| 192 | + i += 1 | |
| 193 | + } | |
| 194 | + i = min(i, chars.count) | |
| 195 | + } | |
| 196 | + let isKey = profile.stringKeyAsProperty && nextSignificant(after: i) == ":" | |
| 197 | + emit(start, i, isKey ? theme.property : theme.string) | |
| 198 | + previousSignificant = c | |
| 199 | + continue | |
| 200 | + } | |
| 201 | + | |
| 202 | + // Numbers (plus #hex colors for CSS). | |
| 203 | + if c.isNumber || (profile.hashIsNumberPrefix && c == "#" && i + 1 < chars.count && chars[i + 1].isHexDigit) { | |
| 204 | + let start = i | |
| 205 | + i += 1 | |
| 206 | + while i < chars.count, | |
| 207 | + chars[i].isLetter || chars[i].isNumber || chars[i] == "." || chars[i] == "_" { | |
| 208 | + i += 1 | |
| 209 | + } | |
| 210 | + emit(start, i, theme.number) | |
| 211 | + previousSignificant = chars[i - 1] | |
| 212 | + continue | |
| 213 | + } | |
| 214 | + | |
| 215 | + // Attributes / decorators / directives (@escaping, #include, $VAR…). | |
| 216 | + if profile.attributePrefixes.contains(c), i + 1 < chars.count, isIdentifierStart(chars[i + 1]) { | |
| 217 | + let start = i | |
| 218 | + i += 1 | |
| 219 | + while i < chars.count, isIdentifierBody(chars[i]) { i += 1 } | |
| 220 | + emit(start, i, theme.attribute) | |
| 221 | + previousSignificant = chars[i - 1] | |
| 222 | + continue | |
| 223 | + } | |
| 224 | + | |
| 225 | + // Identifiers: keywords, types, calls, properties. | |
| 226 | + if isIdentifierStart(c) { | |
| 227 | + let start = i | |
| 228 | + while i < chars.count, isIdentifierBody(chars[i]) { i += 1 } | |
| 229 | + let word = String(chars[start ..< i]) | |
| 230 | + let lookup = profile.caseInsensitiveKeywords ? word.lowercased() : word | |
| 231 | + let next = nextSignificant(after: i) | |
| 232 | + var color: Color? | |
| 233 | + | |
| 234 | + if profile.keywords.contains(lookup) { | |
| 235 | + color = theme.keyword | |
| 236 | + } else if profile.isMarkup { | |
| 237 | + if let prev = previousSignificant, prev == "<" || prev == "/" || prev == "!" { | |
| 238 | + color = theme.keyword // tag name | |
| 239 | + } else if next == "=" { | |
| 240 | + color = theme.property // tag attribute | |
| 241 | + } | |
| 242 | + } else if previousSignificant == "." { | |
| 243 | + color = theme.property | |
| 244 | + } else if next == "(" { | |
| 245 | + color = theme.functionCall | |
| 246 | + } else if let first = word.first, first.isUppercase { | |
| 247 | + color = theme.type | |
| 248 | + } else if profile.colonMeansProperty, next == ":" { | |
| 249 | + color = theme.property | |
| 250 | + } | |
| 251 | + | |
| 252 | + if let color { emit(start, i, color) } | |
| 253 | + previousSignificant = chars[i - 1] | |
| 254 | + continue | |
| 255 | + } | |
| 256 | + | |
| 257 | + if !c.isWhitespace { previousSignificant = c } | |
| 258 | + i += 1 | |
| 259 | + } | |
| 260 | + | |
| 261 | + flushPlain(upTo: chars.count) | |
| 262 | + return result | |
| 263 | + } | |
| 264 | + | |
| 265 | + // MARK: Language profiles | |
| 266 | + | |
| 267 | + private struct LanguageProfile { | |
| 268 | + var keywords: Set<String> = [] | |
| 269 | + var lineComments: [[Character]] = [] | |
| 270 | + var blockComments: [(open: [Character], close: [Character])] = [] | |
| 271 | + var stringDelimiters: Set<Character> = ["\""] | |
| 272 | + var identifierExtras: Set<Character> = [] | |
| 273 | + var attributePrefixes: Set<Character> = [] | |
| 274 | + var caseInsensitiveKeywords = false | |
| 275 | + var colonMeansProperty = false | |
| 276 | + var hashIsNumberPrefix = false | |
| 277 | + var stringKeyAsProperty = false | |
| 278 | + var isMarkup = false | |
| 279 | + } | |
| 280 | + | |
| 281 | + private static func profile(for language: String?) -> LanguageProfile? { | |
| 282 | + guard let language else { return nil } | |
| 283 | + let normalized = language.trimmingCharacters(in: .whitespaces).lowercased() | |
| 284 | + return profiles[normalized] | |
| 285 | + } | |
| 286 | + | |
| 287 | + private static let profiles: [String: LanguageProfile] = { | |
| 288 | + let slashLine: [[Character]] = [Array("//")] | |
| 289 | + let cBlock: [(open: [Character], close: [Character])] = [(Array("/*"), Array("*/"))] | |
| 290 | + | |
| 291 | + var table: [String: LanguageProfile] = [:] | |
| 292 | + | |
| 293 | + let swift = LanguageProfile( | |
| 294 | + keywords: [ | |
| 295 | + "func", "let", "var", "if", "else", "guard", "switch", "case", "default", | |
| 296 | + "for", "while", "repeat", "in", "return", "import", "struct", "class", | |
| 297 | + "enum", "protocol", "extension", "where", "as", "is", "try", "catch", | |
| 298 | + "throw", "throws", "rethrows", "async", "await", "actor", "init", "deinit", | |
| 299 | + "self", "Self", "super", "nil", "true", "false", "public", "private", | |
| 300 | + "internal", "fileprivate", "open", "static", "final", "lazy", "weak", | |
| 301 | + "unowned", "mutating", "nonmutating", "override", "defer", "typealias", | |
| 302 | + "associatedtype", "some", "any", "break", "continue", "fallthrough", "do", | |
| 303 | + "get", "set", "willSet", "didSet", "inout", "subscript", "operator", | |
| 304 | + "indirect", "convenience", "required", "optional", "dynamic", | |
| 305 | + ], | |
| 306 | + lineComments: slashLine, | |
| 307 | + blockComments: cBlock, | |
| 308 | + attributePrefixes: ["@", "#"] | |
| 309 | + ) | |
| 310 | + table["swift"] = swift | |
| 311 | + | |
| 312 | + let python = LanguageProfile( | |
| 313 | + keywords: [ | |
| 314 | + "def", "class", "if", "elif", "else", "for", "while", "in", "return", | |
| 315 | + "import", "from", "as", "with", "try", "except", "finally", "raise", | |
| 316 | + "lambda", "pass", "break", "continue", "global", "nonlocal", "yield", | |
| 317 | + "assert", "del", "not", "and", "or", "is", "None", "True", "False", | |
| 318 | + "async", "await", "match", "case", "self", | |
| 319 | + ], | |
| 320 | + lineComments: [Array("#")], | |
| 321 | + stringDelimiters: ["\"", "'"], | |
| 322 | + attributePrefixes: ["@"] | |
| 323 | + ) | |
| 324 | + for alias in ["python", "py", "python3"] { table[alias] = python } | |
| 325 | + | |
| 326 | + let jsTs = LanguageProfile( | |
| 327 | + keywords: [ | |
| 328 | + "function", "const", "let", "var", "if", "else", "for", "while", "do", | |
| 329 | + "switch", "case", "default", "return", "break", "continue", "new", | |
| 330 | + "delete", "typeof", "instanceof", "in", "of", "class", "extends", | |
| 331 | + "super", "this", "import", "export", "from", "as", "async", "await", | |
| 332 | + "yield", "try", "catch", "finally", "throw", "void", "null", "undefined", | |
| 333 | + "true", "false", "static", "get", "set", "interface", "type", "enum", | |
| 334 | + "implements", "declare", "readonly", "namespace", "public", "private", | |
| 335 | + "protected", "abstract", "satisfies", "keyof", "infer", "never", | |
| 336 | + "unknown", "any", "string", "number", "boolean", "object", "symbol", | |
| 337 | + "bigint", | |
| 338 | + ], | |
| 339 | + lineComments: slashLine, | |
| 340 | + blockComments: cBlock, | |
| 341 | + stringDelimiters: ["\"", "'", "`"], | |
| 342 | + identifierExtras: ["$"], | |
| 343 | + attributePrefixes: ["@"] | |
| 344 | + ) | |
| 345 | + for alias in ["javascript", "js", "jsx", "typescript", "ts", "tsx"] { table[alias] = jsTs } | |
| 346 | + | |
| 347 | + let json = LanguageProfile( | |
| 348 | + keywords: ["true", "false", "null"], | |
| 349 | + lineComments: slashLine, | |
| 350 | + blockComments: cBlock, | |
| 351 | + stringKeyAsProperty: true | |
| 352 | + ) | |
| 353 | + table["json"] = json | |
| 354 | + table["jsonc"] = json | |
| 355 | + | |
| 356 | + let html = LanguageProfile( | |
| 357 | + blockComments: [(Array("<!--"), Array("-->"))], | |
| 358 | + stringDelimiters: ["\"", "'"], | |
| 359 | + identifierExtras: ["-"], | |
| 360 | + isMarkup: true | |
| 361 | + ) | |
| 362 | + for alias in ["html", "xml", "svg", "xhtml"] { table[alias] = html } | |
| 363 | + | |
| 364 | + let css = LanguageProfile( | |
| 365 | + keywords: ["important", "inherit", "initial", "unset", "auto", "none", "revert"], | |
| 366 | + blockComments: cBlock, | |
| 367 | + stringDelimiters: ["\"", "'"], | |
| 368 | + identifierExtras: ["-"], | |
| 369 | + attributePrefixes: ["@"], | |
| 370 | + colonMeansProperty: true, | |
| 371 | + hashIsNumberPrefix: true | |
| 372 | + ) | |
| 373 | + for alias in ["css", "scss", "less"] { table[alias] = css } | |
| 374 | + | |
| 375 | + let bash = LanguageProfile( | |
| 376 | + keywords: [ | |
| 377 | + "if", "then", "else", "elif", "fi", "for", "while", "until", "do", | |
| 378 | + "done", "case", "esac", "function", "in", "select", "time", "coproc", | |
| 379 | + "echo", "cd", "export", "local", "return", "exit", "read", "set", | |
| 380 | + "unset", "shift", "source", "alias", "eval", "exec", "printf", "test", | |
| 381 | + "true", "false", "sudo", "trap", "declare", | |
| 382 | + ], | |
| 383 | + lineComments: [Array("#")], | |
| 384 | + stringDelimiters: ["\"", "'"], | |
| 385 | + identifierExtras: ["-"], | |
| 386 | + attributePrefixes: ["$"] | |
| 387 | + ) | |
| 388 | + for alias in ["bash", "sh", "zsh", "shell", "console"] { table[alias] = bash } | |
| 389 | + | |
| 390 | + let sql = LanguageProfile( | |
| 391 | + keywords: [ | |
| 392 | + "select", "from", "where", "insert", "into", "values", "update", | |
| 393 | + "delete", "set", "create", "table", "drop", "alter", "index", "view", | |
| 394 | + "join", "inner", "left", "right", "outer", "full", "cross", "on", "as", | |
| 395 | + "and", "or", "not", "null", "primary", "key", "foreign", "references", | |
| 396 | + "group", "by", "order", "having", "limit", "offset", "distinct", | |
| 397 | + "union", "all", "exists", "between", "like", "in", "is", "case", | |
| 398 | + "when", "then", "else", "end", "count", "sum", "avg", "min", "max", | |
| 399 | + "desc", "asc", "with", "constraint", "unique", "default", "begin", | |
| 400 | + "commit", "rollback", "transaction", | |
| 401 | + ], | |
| 402 | + lineComments: [Array("--")], | |
| 403 | + blockComments: cBlock, | |
| 404 | + stringDelimiters: ["'", "\""], | |
| 405 | + caseInsensitiveKeywords: true | |
| 406 | + ) | |
| 407 | + table["sql"] = sql | |
| 408 | + | |
| 409 | + let go = LanguageProfile( | |
| 410 | + keywords: [ | |
| 411 | + "func", "package", "import", "var", "const", "type", "struct", | |
| 412 | + "interface", "map", "chan", "go", "defer", "if", "else", "for", | |
| 413 | + "range", "switch", "case", "default", "return", "break", "continue", | |
| 414 | + "fallthrough", "select", "goto", "true", "false", "nil", "iota", | |
| 415 | + "make", "new", "len", "cap", "append", "copy", "delete", "panic", | |
| 416 | + "recover", "error", "string", "int", "int8", "int16", "int32", "int64", | |
| 417 | + "uint", "uint8", "uint16", "uint32", "uint64", "bool", "byte", "rune", | |
| 418 | + "float32", "float64", "complex64", "complex128", "any", | |
| 419 | + ], | |
| 420 | + lineComments: slashLine, | |
| 421 | + blockComments: cBlock, | |
| 422 | + stringDelimiters: ["\"", "'", "`"] | |
| 423 | + ) | |
| 424 | + table["go"] = go | |
| 425 | + table["golang"] = go | |
| 426 | + | |
| 427 | + let rust = LanguageProfile( | |
| 428 | + keywords: [ | |
| 429 | + "fn", "let", "mut", "const", "static", "if", "else", "match", "for", | |
| 430 | + "while", "loop", "in", "return", "break", "continue", "struct", "enum", | |
| 431 | + "trait", "impl", "pub", "use", "mod", "crate", "self", "Self", "super", | |
| 432 | + "where", "as", "ref", "move", "async", "await", "dyn", "unsafe", | |
| 433 | + "extern", "type", "true", "false", "Some", "None", "Ok", "Err", | |
| 434 | + "String", "str", "i8", "i16", "i32", "i64", "i128", "u8", "u16", "u32", | |
| 435 | + "u64", "u128", "f32", "f64", "usize", "isize", "bool", "char", "Box", | |
| 436 | + "Vec", "Option", "Result", | |
| 437 | + ], | |
| 438 | + lineComments: slashLine, | |
| 439 | + blockComments: cBlock, | |
| 440 | + attributePrefixes: ["#"] | |
| 441 | + ) | |
| 442 | + table["rust"] = rust | |
| 443 | + table["rs"] = rust | |
| 444 | + | |
| 445 | + let cFamily = LanguageProfile( | |
| 446 | + keywords: [ | |
| 447 | + "int", "char", "float", "double", "void", "long", "short", "signed", | |
| 448 | + "unsigned", "if", "else", "for", "while", "do", "switch", "case", | |
| 449 | + "default", "return", "break", "continue", "struct", "union", "enum", | |
| 450 | + "typedef", "const", "static", "extern", "inline", "sizeof", "goto", | |
| 451 | + "volatile", "register", "auto", "bool", "true", "false", "class", | |
| 452 | + "public", "private", "protected", "virtual", "override", "final", | |
| 453 | + "template", "typename", "namespace", "using", "new", "delete", "this", | |
| 454 | + "nullptr", "try", "catch", "throw", "constexpr", "noexcept", "friend", | |
| 455 | + "operator", "explicit", "mutable", "id", "instancetype", "nonatomic", | |
| 456 | + "strong", "weak", "copy", "readonly", "readwrite", "assign", "nil", | |
| 457 | + "YES", "NO", | |
| 458 | + ], | |
| 459 | + lineComments: slashLine, | |
| 460 | + blockComments: cBlock, | |
| 461 | + stringDelimiters: ["\"", "'"], | |
| 462 | + attributePrefixes: ["@", "#"] | |
| 463 | + ) | |
| 464 | + for alias in ["c", "cpp", "c++", "cc", "cxx", "h", "hpp", "objc", "objective-c", "objectivec", "m", "mm"] { | |
| 465 | + table[alias] = cFamily | |
| 466 | + } | |
| 467 | + | |
| 468 | + return table | |
| 469 | + }() | |
| 470 | +} | |
added
Sources/ZyquoAgent/Views/ModelChipView.swift
+180 −0
@@ -0,0 +1,180 @@ | ||
| 1 | +// | |
| 2 | +// ModelChipView.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// The header model chip and its picker popover — the same catalog as Zyquo | |
| 9 | +// Cloud, with the Agent twist: agent-capable models are emphasized (and the | |
| 10 | +// recommended tier surfaced first, default agent model starred), while | |
| 11 | +// models without reliable multi-step tool use are dimmed and labeled | |
| 12 | +// "limited tool use". | |
| 13 | +// | |
| 14 | + | |
| 15 | +import SwiftUI | |
| 16 | + | |
| 17 | +struct ModelChipView: View { | |
| 18 | + let model: AIModel? | |
| 19 | + var onSelect: (AIModel) -> Void | |
| 20 | + | |
| 21 | + @State private var showingPicker = false | |
| 22 | + | |
| 23 | + var body: some View { | |
| 24 | + Button { | |
| 25 | + showingPicker.toggle() | |
| 26 | + } label: { | |
| 27 | + HStack(spacing: ZyquoSpacing.xxs) { | |
| 28 | + Image(systemName: model?.provider.symbolName ?? "questionmark.circle") | |
| 29 | + .font(.system(size: 11, weight: .medium)) | |
| 30 | + .foregroundStyle(ZyquoColor.accent) | |
| 31 | + Text(model?.displayName ?? "Choose Model") | |
| 32 | + .font(ZyquoFont.bodyEmphasis(size: 12.5)) | |
| 33 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 34 | + .lineLimit(1) | |
| 35 | + if let model, !model.agentCapable { | |
| 36 | + Image(systemName: "exclamationmark.triangle") | |
| 37 | + .font(.system(size: 9)) | |
| 38 | + .foregroundStyle(ZyquoColor.warning) | |
| 39 | + .help("Limited tool use — not recommended for agent tasks") | |
| 40 | + } | |
| 41 | + Image(systemName: "chevron.down") | |
| 42 | + .font(.system(size: 8, weight: .semibold)) | |
| 43 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 44 | + } | |
| 45 | + .padding(.horizontal, ZyquoSpacing.xs) | |
| 46 | + .padding(.vertical, 4) | |
| 47 | + .background( | |
| 48 | + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) | |
| 49 | + .fill(ZyquoColor.surfaceSecondary) | |
| 50 | + ) | |
| 51 | + } | |
| 52 | + .buttonStyle(PressableButtonStyle()) | |
| 53 | + .popover(isPresented: $showingPicker, arrowEdge: .bottom) { | |
| 54 | + ModelPickerView(selected: model) { chosen in | |
| 55 | + showingPicker = false | |
| 56 | + onSelect(chosen) | |
| 57 | + } | |
| 58 | + } | |
| 59 | + } | |
| 60 | +} | |
| 61 | + | |
| 62 | +/// Picker popover: search + recommended agent tier + provider groups (full | |
| 63 | +/// shared catalog; non-agent-capable entries dimmed). | |
| 64 | +struct ModelPickerView: View { | |
| 65 | + let selected: AIModel? | |
| 66 | + var onSelect: (AIModel) -> Void | |
| 67 | + | |
| 68 | + @EnvironmentObject private var catalog: ModelCatalog | |
| 69 | + @EnvironmentObject private var vault: KeyVaultStore | |
| 70 | + @State private var query = "" | |
| 71 | + | |
| 72 | + var body: some View { | |
| 73 | + VStack(spacing: 0) { | |
| 74 | + HStack(spacing: ZyquoSpacing.xxs) { | |
| 75 | + Image(systemName: "magnifyingglass") | |
| 76 | + .font(.system(size: 11)) | |
| 77 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 78 | + TextField("Search models", text: $query) | |
| 79 | + .textFieldStyle(.plain) | |
| 80 | + .font(ZyquoFont.body(size: 12.5)) | |
| 81 | + } | |
| 82 | + .padding(ZyquoSpacing.xs) | |
| 83 | + ZyquoHairline() | |
| 84 | + ScrollView { | |
| 85 | + LazyVStack(alignment: .leading, spacing: 1) { | |
| 86 | + if query.isEmpty, !catalog.recommendedAgentModels.isEmpty { | |
| 87 | + sectionHeader("Recommended for agent tasks") | |
| 88 | + ForEach(catalog.recommendedAgentModels) { model in | |
| 89 | + row(model) | |
| 90 | + } | |
| 91 | + } | |
| 92 | + ForEach(providerGroups, id: \.0) { provider, models in | |
| 93 | + sectionHeader(provider.displayName) | |
| 94 | + ForEach(models) { model in row(model) } | |
| 95 | + } | |
| 96 | + } | |
| 97 | + .padding(ZyquoSpacing.xxs) | |
| 98 | + } | |
| 99 | + .frame(width: 340, height: 400) | |
| 100 | + } | |
| 101 | + .background(ZyquoColor.surface) | |
| 102 | + } | |
| 103 | + | |
| 104 | + /// Providers with a saved key first; models filtered by the search text. | |
| 105 | + private var providerGroups: [(ProviderID, [AIModel])] { | |
| 106 | + let ordered = ProviderID.builtIn.sorted { | |
| 107 | + (vault.hasKey(for: $0) ? 0 : 1, $0.displayName) < (vault.hasKey(for: $1) ? 0 : 1, $1.displayName) | |
| 108 | + } | |
| 109 | + return ordered.compactMap { provider in | |
| 110 | + var models = catalog.models(for: provider) | |
| 111 | + if !query.isEmpty { | |
| 112 | + models = models.filter { | |
| 113 | + $0.displayName.localizedCaseInsensitiveContains(query) | |
| 114 | + || $0.id.localizedCaseInsensitiveContains(query) | |
| 115 | + } | |
| 116 | + } | |
| 117 | + // Agent-capable first within each provider. | |
| 118 | + models = models.sorted { ($0.agentCapable ? 0 : 1) < ($1.agentCapable ? 0 : 1) } | |
| 119 | + return models.isEmpty ? nil : (provider, models) | |
| 120 | + } | |
| 121 | + } | |
| 122 | + | |
| 123 | + private func sectionHeader(_ title: String) -> some View { | |
| 124 | + Text(title) | |
| 125 | + .font(ZyquoFont.caption) | |
| 126 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 127 | + .padding(.horizontal, ZyquoSpacing.xs) | |
| 128 | + .padding(.top, ZyquoSpacing.xs) | |
| 129 | + .padding(.bottom, 2) | |
| 130 | + } | |
| 131 | + | |
| 132 | + private func row(_ model: AIModel) -> some View { | |
| 133 | + let isDefault = model.id == catalog.defaultAgentModel?.id | |
| 134 | + && model.provider == catalog.defaultAgentModel?.provider | |
| 135 | + let isSelected = model.id == selected?.id && model.provider == selected?.provider | |
| 136 | + return Button { | |
| 137 | + onSelect(model) | |
| 138 | + } label: { | |
| 139 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 140 | + Image(systemName: model.provider.symbolName) | |
| 141 | + .font(.system(size: 11)) | |
| 142 | + .foregroundStyle(model.agentCapable ? ZyquoColor.accent : ZyquoColor.textTertiary) | |
| 143 | + .frame(width: 14) | |
| 144 | + Text(model.displayName) | |
| 145 | + .font(model.agentCapable ? ZyquoFont.bodyEmphasis(size: 12.5) : ZyquoFont.body(size: 12.5)) | |
| 146 | + .foregroundStyle(model.agentCapable ? ZyquoColor.textPrimary : ZyquoColor.textTertiary) | |
| 147 | + .lineLimit(1) | |
| 148 | + if isDefault { | |
| 149 | + Image(systemName: "star.fill") | |
| 150 | + .font(.system(size: 9)) | |
| 151 | + .foregroundStyle(ZyquoColor.warning) | |
| 152 | + .help("Default agent model") | |
| 153 | + } | |
| 154 | + Spacer(minLength: ZyquoSpacing.xs) | |
| 155 | + if !model.agentCapable { | |
| 156 | + Text("limited tool use") | |
| 157 | + .font(ZyquoFont.caption) | |
| 158 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 159 | + } | |
| 160 | + if model.capabilities.reasoning { | |
| 161 | + Image(systemName: "brain") | |
| 162 | + .font(.system(size: 9)) | |
| 163 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 164 | + } | |
| 165 | + Text(model.contextBadge) | |
| 166 | + .font(ZyquoFont.caption) | |
| 167 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 168 | + } | |
| 169 | + .padding(.horizontal, ZyquoSpacing.xs) | |
| 170 | + .padding(.vertical, 4) | |
| 171 | + .background( | |
| 172 | + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) | |
| 173 | + .fill(isSelected ? ZyquoColor.accentSubtle : .clear) | |
| 174 | + ) | |
| 175 | + .contentShape(Rectangle()) | |
| 176 | + } | |
| 177 | + .buttonStyle(.plain) | |
| 178 | + .zyquoHoverHighlight() | |
| 179 | + } | |
| 180 | +} | |
added
Sources/ZyquoAgent/Views/PlanPanelView.swift
+230 −0
@@ -0,0 +1,230 @@ | ||
| 1 | +// | |
| 2 | +// PlanPanelView.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// The right-hand Plan/Todo panel (~300pt, collapsible): the live checklist | |
| 9 | +// the agent maintains via `update_plan` — items animate through pending/ | |
| 10 | +// active/done/failed, titles are user-editable — plus a progress bar and the | |
| 11 | +// LoopGuard meters (steps, tokens, elapsed time vs. budgets). | |
| 12 | +// | |
| 13 | + | |
| 14 | +import Combine | |
| 15 | +import SwiftUI | |
| 16 | + | |
| 17 | +struct PlanPanelView: View { | |
| 18 | + @ObservedObject var controller: RunController | |
| 19 | + | |
| 20 | + /// Ticks the elapsed-time meter while a run is live. | |
| 21 | + @State private var now = Date() | |
| 22 | + private let clock = Timer.publish(every: 1, on: .main, in: .common).autoconnect() | |
| 23 | + | |
| 24 | + var body: some View { | |
| 25 | + VStack(alignment: .leading, spacing: 0) { | |
| 26 | + header | |
| 27 | + ZyquoHairline() | |
| 28 | + ScrollView { | |
| 29 | + VStack(alignment: .leading, spacing: ZyquoSpacing.sm) { | |
| 30 | + if let plan = controller.plan, !plan.items.isEmpty { | |
| 31 | + progressBar(plan) | |
| 32 | + checklist(plan) | |
| 33 | + } else { | |
| 34 | + emptyPlan | |
| 35 | + } | |
| 36 | + } | |
| 37 | + .padding(ZyquoSpacing.sm) | |
| 38 | + } | |
| 39 | + ZyquoHairline() | |
| 40 | + meters | |
| 41 | + } | |
| 42 | + .frame(width: ZyquoMetrics.planPanelWidth) | |
| 43 | + .background(ZyquoColor.background) | |
| 44 | + .onReceive(clock) { tick in | |
| 45 | + if controller.isRunning { now = tick } | |
| 46 | + } | |
| 47 | + } | |
| 48 | + | |
| 49 | + // MARK: - Header | |
| 50 | + | |
| 51 | + private var header: some View { | |
| 52 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 53 | + Image(systemName: "checklist") | |
| 54 | + .font(.system(size: 11)) | |
| 55 | + .foregroundStyle(ZyquoColor.accent) | |
| 56 | + Text("Plan") | |
| 57 | + .font(ZyquoFont.bodyEmphasis(size: 12.5)) | |
| 58 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 59 | + Spacer(minLength: 0) | |
| 60 | + if let plan = controller.plan, !plan.items.isEmpty { | |
| 61 | + Text("\(plan.doneCount)/\(plan.items.count)") | |
| 62 | + .font(ZyquoFont.caption) | |
| 63 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 64 | + } | |
| 65 | + } | |
| 66 | + .padding(.horizontal, ZyquoSpacing.sm) | |
| 67 | + .frame(height: ZyquoSpacing.xxl) | |
| 68 | + } | |
| 69 | + | |
| 70 | + // MARK: - Checklist | |
| 71 | + | |
| 72 | + private func progressBar(_ plan: TaskPlan) -> some View { | |
| 73 | + ProgressView(value: Double(plan.doneCount), total: Double(max(plan.items.count, 1))) | |
| 74 | + .progressViewStyle(.linear) | |
| 75 | + .tint(ZyquoColor.accent) | |
| 76 | + .animation(ZyquoMotion.appear, value: plan.doneCount) | |
| 77 | + } | |
| 78 | + | |
| 79 | + private func checklist(_ plan: TaskPlan) -> some View { | |
| 80 | + VStack(alignment: .leading, spacing: ZyquoSpacing.xs) { | |
| 81 | + ForEach(plan.items) { item in | |
| 82 | + PlanItemRow(item: item) { newTitle in | |
| 83 | + controller.renamePlanItem(id: item.id, to: newTitle) | |
| 84 | + } | |
| 85 | + } | |
| 86 | + } | |
| 87 | + } | |
| 88 | + | |
| 89 | + private var emptyPlan: some View { | |
| 90 | + VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) { | |
| 91 | + Text("No plan yet") | |
| 92 | + .font(ZyquoFont.body(size: 12.5)) | |
| 93 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 94 | + Text("The agent drafts a checklist here when a run starts, and checks items off as it works.") | |
| 95 | + .font(ZyquoFont.caption) | |
| 96 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 97 | + .fixedSize(horizontal: false, vertical: true) | |
| 98 | + } | |
| 99 | + .padding(.top, ZyquoSpacing.xs) | |
| 100 | + } | |
| 101 | + | |
| 102 | + // MARK: - Budget meters | |
| 103 | + | |
| 104 | + private var meters: some View { | |
| 105 | + let config = controller.loopGuardConfiguration | |
| 106 | + let elapsed = controller.runStartedAt.map { min(now.timeIntervalSince($0), config.wallClockBudget) } ?? 0 | |
| 107 | + return VStack(alignment: .leading, spacing: ZyquoSpacing.xs) { | |
| 108 | + meter( | |
| 109 | + label: "Steps", | |
| 110 | + value: Double(controller.stepsUsed), | |
| 111 | + total: Double(config.maxSteps), | |
| 112 | + text: "\(controller.stepsUsed)/\(config.maxSteps)" | |
| 113 | + ) | |
| 114 | + meter( | |
| 115 | + label: "Tokens", | |
| 116 | + value: Double(controller.tokensUsed), | |
| 117 | + total: Double(config.tokenBudget), | |
| 118 | + text: tokenText | |
| 119 | + ) | |
| 120 | + meter( | |
| 121 | + label: "Time", | |
| 122 | + value: elapsed, | |
| 123 | + total: config.wallClockBudget, | |
| 124 | + text: timeText(elapsed: elapsed, budget: config.wallClockBudget) | |
| 125 | + ) | |
| 126 | + } | |
| 127 | + .padding(ZyquoSpacing.sm) | |
| 128 | + } | |
| 129 | + | |
| 130 | + private func meter(label: String, value: Double, total: Double, text: String) -> some View { | |
| 131 | + VStack(alignment: .leading, spacing: 2) { | |
| 132 | + HStack { | |
| 133 | + Text(label) | |
| 134 | + .font(ZyquoFont.caption) | |
| 135 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 136 | + Spacer(minLength: 0) | |
| 137 | + Text(text) | |
| 138 | + .font(ZyquoFont.caption) | |
| 139 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 140 | + } | |
| 141 | + ProgressView(value: min(value, total), total: max(total, 1)) | |
| 142 | + .progressViewStyle(.linear) | |
| 143 | + .tint(value / max(total, 1) > 0.85 ? ZyquoColor.warning : ZyquoColor.accent) | |
| 144 | + .controlSize(.small) | |
| 145 | + } | |
| 146 | + } | |
| 147 | + | |
| 148 | + private var tokenText: String { | |
| 149 | + let used = controller.tokensUsed | |
| 150 | + let budget = controller.loopGuardConfiguration.tokenBudget | |
| 151 | + func format(_ count: Int) -> String { | |
| 152 | + count >= 1_000 ? String(format: "%.0fK", Double(count) / 1_000) : "\(count)" | |
| 153 | + } | |
| 154 | + return "\(format(used))/\(format(budget))" | |
| 155 | + } | |
| 156 | + | |
| 157 | + private func timeText(elapsed: TimeInterval, budget: TimeInterval) -> String { | |
| 158 | + "\(Int(elapsed / 60))m/\(Int(budget / 60))m" | |
| 159 | + } | |
| 160 | +} | |
| 161 | + | |
| 162 | +// MARK: - Row | |
| 163 | + | |
| 164 | +private struct PlanItemRow: View { | |
| 165 | + let item: PlanItem | |
| 166 | + var onRename: (String) -> Void | |
| 167 | + | |
| 168 | + @State private var editing = false | |
| 169 | + @State private var draft = "" | |
| 170 | + | |
| 171 | + var body: some View { | |
| 172 | + HStack(alignment: .firstTextBaseline, spacing: ZyquoSpacing.xs) { | |
| 173 | + statusIcon | |
| 174 | + .animation(ZyquoMotion.appear, value: item.status) | |
| 175 | + VStack(alignment: .leading, spacing: 1) { | |
| 176 | + if editing { | |
| 177 | + TextField("Step", text: $draft, onCommit: { | |
| 178 | + editing = false | |
| 179 | + onRename(draft) | |
| 180 | + }) | |
| 181 | + .textFieldStyle(.plain) | |
| 182 | + .font(ZyquoFont.body(size: 12.5)) | |
| 183 | + } else { | |
| 184 | + Text(item.title) | |
| 185 | + .font(ZyquoFont.body(size: 12.5)) | |
| 186 | + .foregroundStyle(item.status == .done ? ZyquoColor.textTertiary : ZyquoColor.textPrimary) | |
| 187 | + .strikethrough(item.status == .done, color: ZyquoColor.textTertiary) | |
| 188 | + .fixedSize(horizontal: false, vertical: true) | |
| 189 | + .onTapGesture(count: 2) { | |
| 190 | + draft = item.title | |
| 191 | + editing = true | |
| 192 | + } | |
| 193 | + } | |
| 194 | + if let note = item.note, !note.isEmpty { | |
| 195 | + Text(note) | |
| 196 | + .font(ZyquoFont.caption) | |
| 197 | + .foregroundStyle(item.status == .failed ? ZyquoColor.danger : ZyquoColor.textTertiary) | |
| 198 | + .fixedSize(horizontal: false, vertical: true) | |
| 199 | + } | |
| 200 | + } | |
| 201 | + Spacer(minLength: 0) | |
| 202 | + } | |
| 203 | + .contentShape(Rectangle()) | |
| 204 | + .help("Double-click to edit") | |
| 205 | + } | |
| 206 | + | |
| 207 | + @ViewBuilder | |
| 208 | + private var statusIcon: some View { | |
| 209 | + switch item.status { | |
| 210 | + case .pending: | |
| 211 | + Image(systemName: "circle") | |
| 212 | + .font(.system(size: 11)) | |
| 213 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 214 | + case .active: | |
| 215 | + Image(systemName: "circle.dotted.circle") | |
| 216 | + .font(.system(size: 11)) | |
| 217 | + .foregroundStyle(ZyquoColor.accent) | |
| 218 | + case .done: | |
| 219 | + Image(systemName: "checkmark.circle.fill") | |
| 220 | + .font(.system(size: 11)) | |
| 221 | + .foregroundStyle(ZyquoColor.success) | |
| 222 | + .transition(.scale.combined(with: .opacity)) | |
| 223 | + case .failed: | |
| 224 | + Image(systemName: "xmark.circle.fill") | |
| 225 | + .font(.system(size: 11)) | |
| 226 | + .foregroundStyle(ZyquoColor.danger) | |
| 227 | + .transition(.scale.combined(with: .opacity)) | |
| 228 | + } | |
| 229 | + } | |
| 230 | +} | |
added
Sources/ZyquoAgent/Views/SettingsPlaceholderView.swift
+47 −0
@@ -0,0 +1,47 @@ | ||
| 1 | +// | |
| 2 | +// SettingsPlaceholderView.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Wave-1 stand-in for the full Settings window (Providers & Keys, Models, | |
| 9 | +// Safety, Agent, Appearance, Shortcuts, Advanced arrive in wave 2). Until | |
| 10 | +// then, keys can be added via the encrypted vault CLI or environment | |
| 11 | +// variables — this sheet says so plainly instead of dead-ending the user. | |
| 12 | +// | |
| 13 | + | |
| 14 | +import SwiftUI | |
| 15 | + | |
| 16 | +struct SettingsPlaceholderView: View { | |
| 17 | + @Environment(\.dismiss) private var dismiss | |
| 18 | + | |
| 19 | + var body: some View { | |
| 20 | + VStack(alignment: .leading, spacing: ZyquoSpacing.md) { | |
| 21 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 22 | + Image(systemName: "gearshape") | |
| 23 | + .font(.system(size: 16)) | |
| 24 | + .foregroundStyle(ZyquoColor.accent) | |
| 25 | + Text("Settings") | |
| 26 | + .font(ZyquoFont.title) | |
| 27 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 28 | + } | |
| 29 | + Text("The full Settings window — Providers & Keys, Models, Safety rules, Agent budgets, Appearance, and Shortcuts — arrives in the next update.") | |
| 30 | + .font(ZyquoFont.body()) | |
| 31 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 32 | + .fixedSize(horizontal: false, vertical: true) | |
| 33 | + Text("Until then, Zyquo Agent reads API keys from your environment (for example ANTHROPIC_API_KEY or OPENAI_API_KEY) or from the encrypted vault shared with Zyquo Cloud.") | |
| 34 | + .font(ZyquoFont.body()) | |
| 35 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 36 | + .fixedSize(horizontal: false, vertical: true) | |
| 37 | + HStack { | |
| 38 | + Spacer() | |
| 39 | + Button("OK") { dismiss() } | |
| 40 | + .keyboardShortcut(.defaultAction) | |
| 41 | + } | |
| 42 | + } | |
| 43 | + .padding(ZyquoSpacing.xl) | |
| 44 | + .frame(width: ZyquoMetrics.quickTaskWidth - ZyquoSpacing.xxl * 4) | |
| 45 | + .background(ZyquoColor.surface) | |
| 46 | + } | |
| 47 | +} | |
added
Sources/ZyquoAgent/Views/Sidebar/SidebarView.swift
+340 −0
@@ -0,0 +1,340 @@ | ||
| 1 | +// | |
| 2 | +// SidebarView.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Sidebar per the Phase 4 spec: "Zyquo Agent" wordmark, search, prominent | |
| 9 | +// New Task button, task rows grouped Pinned/Today/Yesterday/Previous 7 Days/ | |
| 10 | +// Older — each with title, animated status pill, model badge, relative time, | |
| 11 | +// and a subtle activity indicator while running. Footer: settings gear + | |
| 12 | +// safety-mode chip + active model chip. | |
| 13 | +// | |
| 14 | + | |
| 15 | +import SwiftUI | |
| 16 | + | |
| 17 | +struct SidebarView: View { | |
| 18 | + @EnvironmentObject private var store: TaskStore | |
| 19 | + @EnvironmentObject private var hub: RunHub | |
| 20 | + @EnvironmentObject private var catalog: ModelCatalog | |
| 21 | + @FocusState private var searchFocused: Bool | |
| 22 | + @State private var showingSettingsPlaceholder = false | |
| 23 | + | |
| 24 | + var body: some View { | |
| 25 | + VStack(spacing: 0) { | |
| 26 | + wordmark | |
| 27 | + searchField | |
| 28 | + newTaskButton | |
| 29 | + taskList | |
| 30 | + ZyquoHairline() | |
| 31 | + footer | |
| 32 | + } | |
| 33 | + .frame(minWidth: ZyquoMetrics.sidebarWidth) | |
| 34 | + .background( | |
| 35 | + // ⌘F focuses task search. | |
| 36 | + Button("") { searchFocused = true } | |
| 37 | + .keyboardShortcut("f", modifiers: .command) | |
| 38 | + .hidden() | |
| 39 | + ) | |
| 40 | + .sheet(isPresented: $showingSettingsPlaceholder) { | |
| 41 | + SettingsPlaceholderView() | |
| 42 | + } | |
| 43 | + } | |
| 44 | + | |
| 45 | + // MARK: - Sections | |
| 46 | + | |
| 47 | + private var wordmark: some View { | |
| 48 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 49 | + AgentZGlyph(size: 22) | |
| 50 | + Text("Zyquo Agent") | |
| 51 | + .font(ZyquoFont.bodyEmphasis(size: 14)) | |
| 52 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 53 | + Spacer() | |
| 54 | + } | |
| 55 | + .padding(.horizontal, ZyquoMetrics.contentInset) | |
| 56 | + .padding(.top, ZyquoSpacing.sm) | |
| 57 | + .padding(.bottom, ZyquoSpacing.xs) | |
| 58 | + } | |
| 59 | + | |
| 60 | + private var searchField: some View { | |
| 61 | + HStack(spacing: ZyquoSpacing.xxs) { | |
| 62 | + Image(systemName: "magnifyingglass") | |
| 63 | + .font(.system(size: 11)) | |
| 64 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 65 | + TextField("Search tasks", text: $store.searchText) | |
| 66 | + .textFieldStyle(.plain) | |
| 67 | + .font(ZyquoFont.body(size: 12.5)) | |
| 68 | + .focused($searchFocused) | |
| 69 | + } | |
| 70 | + .padding(.horizontal, ZyquoSpacing.xs) | |
| 71 | + .padding(.vertical, 5) | |
| 72 | + .background( | |
| 73 | + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) | |
| 74 | + .fill(ZyquoColor.surfaceSecondary.opacity(0.7)) | |
| 75 | + ) | |
| 76 | + .padding(.horizontal, ZyquoSpacing.sm) | |
| 77 | + .padding(.bottom, ZyquoSpacing.xs) | |
| 78 | + } | |
| 79 | + | |
| 80 | + private var newTaskButton: some View { | |
| 81 | + Button { | |
| 82 | + store.newTask(model: catalog.defaultAgentModel) | |
| 83 | + } label: { | |
| 84 | + HStack(spacing: ZyquoSpacing.xxs) { | |
| 85 | + Image(systemName: "plus.circle.fill") | |
| 86 | + .font(.system(size: 12, weight: .semibold)) | |
| 87 | + Text("New Task") | |
| 88 | + .font(ZyquoFont.bodyEmphasis(size: 13)) | |
| 89 | + } | |
| 90 | + .foregroundStyle(.white) | |
| 91 | + .frame(maxWidth: .infinity) | |
| 92 | + .padding(.vertical, 7) | |
| 93 | + .background( | |
| 94 | + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) | |
| 95 | + .fill(ZyquoColor.accent) | |
| 96 | + ) | |
| 97 | + } | |
| 98 | + .buttonStyle(PressableButtonStyle()) | |
| 99 | + .help("New Task (⌘N)") | |
| 100 | + .padding(.horizontal, ZyquoSpacing.sm) | |
| 101 | + .padding(.bottom, ZyquoSpacing.xs) | |
| 102 | + } | |
| 103 | + | |
| 104 | + private var taskList: some View { | |
| 105 | + ScrollView { | |
| 106 | + LazyVStack(alignment: .leading, spacing: 2) { | |
| 107 | + ForEach(store.sidebarGroups) { group in | |
| 108 | + Text(group.title) | |
| 109 | + .font(ZyquoFont.caption) | |
| 110 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 111 | + .padding(.horizontal, ZyquoMetrics.contentInset) | |
| 112 | + .padding(.top, ZyquoSpacing.sm) | |
| 113 | + .padding(.bottom, 2) | |
| 114 | + ForEach(group.tasks) { task in | |
| 115 | + TaskRow(task: task) | |
| 116 | + } | |
| 117 | + } | |
| 118 | + } | |
| 119 | + .padding(.horizontal, ZyquoSpacing.xs) | |
| 120 | + .padding(.bottom, ZyquoSpacing.sm) | |
| 121 | + } | |
| 122 | + } | |
| 123 | + | |
| 124 | + private var footer: some View { | |
| 125 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 126 | + Button { | |
| 127 | + showingSettingsPlaceholder = true | |
| 128 | + } label: { | |
| 129 | + Image(systemName: "gearshape") | |
| 130 | + .font(.system(size: 13)) | |
| 131 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 132 | + } | |
| 133 | + .buttonStyle(.plain) | |
| 134 | + .help("Settings") | |
| 135 | + ZyquoBadge(text: footerSafetyMode.displayName, color: ZyquoColor.textSecondary) | |
| 136 | + Spacer(minLength: 0) | |
| 137 | + if let model = footerModel { | |
| 138 | + ZyquoBadge(text: model.displayName, color: ZyquoColor.textSecondary) | |
| 139 | + .help("Active model") | |
| 140 | + } | |
| 141 | + } | |
| 142 | + .padding(.horizontal, ZyquoMetrics.contentInset) | |
| 143 | + .padding(.vertical, ZyquoSpacing.xs) | |
| 144 | + } | |
| 145 | + | |
| 146 | + /// Safety mode of the selected task (or the app default). | |
| 147 | + private var footerSafetyMode: SafetyMode { | |
| 148 | + store.selectedID.flatMap { store.task(id: $0)?.safetyMode } ?? .guarded | |
| 149 | + } | |
| 150 | + | |
| 151 | + /// Model of the selected task (or the default agent model). | |
| 152 | + private var footerModel: AIModel? { | |
| 153 | + if let id = store.selectedID, let task = store.task(id: id), | |
| 154 | + let model = catalog.model(id: task.modelID, provider: task.providerID) { | |
| 155 | + return model | |
| 156 | + } | |
| 157 | + return catalog.defaultAgentModel | |
| 158 | + } | |
| 159 | +} | |
| 160 | + | |
| 161 | +// MARK: - Status pill | |
| 162 | + | |
| 163 | +/// Small colored capsule showing a task's lifecycle state; transitions animate. | |
| 164 | +struct StatusPill: View { | |
| 165 | + let status: AgentTaskStatus | |
| 166 | + | |
| 167 | + var body: some View { | |
| 168 | + Text(status.displayName) | |
| 169 | + .font(ZyquoFont.caption) | |
| 170 | + .foregroundStyle(color) | |
| 171 | + .padding(.horizontal, ZyquoSpacing.xxs + 2) | |
| 172 | + .padding(.vertical, 1) | |
| 173 | + .background(Capsule().fill(color.opacity(0.12))) | |
| 174 | + .animation(ZyquoMotion.appear, value: status) | |
| 175 | + .contentTransition(.opacity) | |
| 176 | + } | |
| 177 | + | |
| 178 | + private var color: Color { | |
| 179 | + switch status { | |
| 180 | + case .idle: return ZyquoColor.textTertiary | |
| 181 | + case .planning, .running: return ZyquoColor.accent | |
| 182 | + case .awaitingApproval, .awaitingInput: return ZyquoColor.warning | |
| 183 | + case .done: return ZyquoColor.success | |
| 184 | + case .failed: return ZyquoColor.danger | |
| 185 | + } | |
| 186 | + } | |
| 187 | +} | |
| 188 | + | |
| 189 | +/// Subtle pulsing dot shown while a task's run is live. | |
| 190 | +struct ActivityIndicatorDot: View { | |
| 191 | + @State private var dimmed = false | |
| 192 | + | |
| 193 | + var body: some View { | |
| 194 | + Circle() | |
| 195 | + .fill(ZyquoColor.accent) | |
| 196 | + .frame(width: 6, height: 6) | |
| 197 | + .opacity(dimmed ? 0.25 : 1) | |
| 198 | + .onAppear { | |
| 199 | + withAnimation(ZyquoMotion.pulse) { dimmed = true } | |
| 200 | + } | |
| 201 | + } | |
| 202 | +} | |
| 203 | + | |
| 204 | +// MARK: - Row | |
| 205 | + | |
| 206 | +private struct TaskRow: View { | |
| 207 | + let task: AgentTask | |
| 208 | + @EnvironmentObject private var store: TaskStore | |
| 209 | + @EnvironmentObject private var hub: RunHub | |
| 210 | + @State private var hovering = false | |
| 211 | + | |
| 212 | + private var isSelected: Bool { store.selectedID == task.id } | |
| 213 | + | |
| 214 | + var body: some View { | |
| 215 | + Button { | |
| 216 | + store.selectedID = task.id | |
| 217 | + } label: { | |
| 218 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 219 | + VStack(alignment: .leading, spacing: 2) { | |
| 220 | + HStack(spacing: ZyquoSpacing.xxs) { | |
| 221 | + if task.status.isActive { | |
| 222 | + ActivityIndicatorDot() | |
| 223 | + } | |
| 224 | + Text(task.title) | |
| 225 | + .font(ZyquoFont.body(size: 13)) | |
| 226 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 227 | + .lineLimit(1) | |
| 228 | + } | |
| 229 | + HStack(spacing: ZyquoSpacing.xxs) { | |
| 230 | + StatusPill(status: task.status) | |
| 231 | + Text(task.modelID.isEmpty ? "no model" : shortModelName) | |
| 232 | + .font(ZyquoFont.caption) | |
| 233 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 234 | + .lineLimit(1) | |
| 235 | + Text(task.updatedAt, format: .relative(presentation: .named)) | |
| 236 | + .font(ZyquoFont.caption) | |
| 237 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 238 | + .lineLimit(1) | |
| 239 | + } | |
| 240 | + } | |
| 241 | + Spacer(minLength: 0) | |
| 242 | + if hovering { | |
| 243 | + rowActions | |
| 244 | + } else if task.pinned { | |
| 245 | + Image(systemName: "pin.fill") | |
| 246 | + .font(.system(size: 9)) | |
| 247 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 248 | + } | |
| 249 | + } | |
| 250 | + .padding(.horizontal, ZyquoSpacing.xs) | |
| 251 | + .padding(.vertical, 5) | |
| 252 | + .background( | |
| 253 | + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) | |
| 254 | + .fill(isSelected ? ZyquoColor.accentSubtle : (hovering ? ZyquoColor.surfaceSecondary.opacity(0.6) : .clear)) | |
| 255 | + ) | |
| 256 | + .contentShape(Rectangle()) | |
| 257 | + } | |
| 258 | + .buttonStyle(.plain) | |
| 259 | + .onHover { inside in | |
| 260 | + withAnimation(ZyquoMotion.hover) { hovering = inside } | |
| 261 | + } | |
| 262 | + .contextMenu { | |
| 263 | + Button(task.pinned ? "Unpin" : "Pin") { store.togglePin(task.id) } | |
| 264 | + Button("Rename…") { promptForRename() } | |
| 265 | + Button("Delete…", role: .destructive) { confirmDelete() } | |
| 266 | + } | |
| 267 | + } | |
| 268 | + | |
| 269 | + /// Model badge text without the id's path prefix ("deepseek-ai/X" → "X"). | |
| 270 | + private var shortModelName: String { | |
| 271 | + task.modelID.split(separator: "/").last.map(String.init) ?? task.modelID | |
| 272 | + } | |
| 273 | + | |
| 274 | + private var rowActions: some View { | |
| 275 | + HStack(spacing: ZyquoSpacing.xxs) { | |
| 276 | + Button { | |
| 277 | + store.togglePin(task.id) | |
| 278 | + } label: { | |
| 279 | + Image(systemName: task.pinned ? "pin.slash" : "pin") | |
| 280 | + .font(.system(size: 10)) | |
| 281 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 282 | + } | |
| 283 | + .buttonStyle(.plain) | |
| 284 | + .help(task.pinned ? "Unpin" : "Pin") | |
| 285 | + Button { | |
| 286 | + confirmDelete() | |
| 287 | + } label: { | |
| 288 | + Image(systemName: "trash") | |
| 289 | + .font(.system(size: 10)) | |
| 290 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 291 | + } | |
| 292 | + .buttonStyle(.plain) | |
| 293 | + .help("Delete") | |
| 294 | + } | |
| 295 | + } | |
| 296 | + | |
| 297 | + private func promptForRename() { | |
| 298 | + let alert = NSAlert() | |
| 299 | + alert.messageText = "Rename Task" | |
| 300 | + let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 240, height: 22)) | |
| 301 | + field.stringValue = task.title | |
| 302 | + alert.accessoryView = field | |
| 303 | + alert.addButton(withTitle: "Rename") | |
| 304 | + alert.addButton(withTitle: "Cancel") | |
| 305 | + guard alert.runModal() == .alertFirstButtonReturn else { return } | |
| 306 | + store.rename(task.id, to: field.stringValue) | |
| 307 | + } | |
| 308 | + | |
| 309 | + /// Deleting is destructive: confirm, and ask separately about the | |
| 310 | + /// workspace folder (which may contain the agent's produced files). | |
| 311 | + private func confirmDelete() { | |
| 312 | + let alert = NSAlert() | |
| 313 | + alert.alertStyle = .warning | |
| 314 | + alert.messageText = "Delete “\(task.title)”?" | |
| 315 | + if task.workspacePath != nil { | |
| 316 | + alert.informativeText = "This task has a workspace folder with the files the agent created. You can keep the folder or delete it too." | |
| 317 | + alert.addButton(withTitle: "Delete Task & Workspace") | |
| 318 | + alert.addButton(withTitle: "Delete Task Only") | |
| 319 | + alert.addButton(withTitle: "Cancel") | |
| 320 | + switch alert.runModal() { | |
| 321 | + case .alertFirstButtonReturn: | |
| 322 | + hub.remove(taskID: task.id) | |
| 323 | + store.delete(task.id, deleteWorkspace: true) | |
| 324 | + case .alertSecondButtonReturn: | |
| 325 | + hub.remove(taskID: task.id) | |
| 326 | + store.delete(task.id, deleteWorkspace: false) | |
| 327 | + default: | |
| 328 | + break | |
| 329 | + } | |
| 330 | + } else { | |
| 331 | + alert.informativeText = "This cannot be undone." | |
| 332 | + alert.addButton(withTitle: "Delete") | |
| 333 | + alert.addButton(withTitle: "Cancel") | |
| 334 | + if alert.runModal() == .alertFirstButtonReturn { | |
| 335 | + hub.remove(taskID: task.id) | |
| 336 | + store.delete(task.id, deleteWorkspace: false) | |
| 337 | + } | |
| 338 | + } | |
| 339 | + } | |
| 340 | +} | |
added
Sources/ZyquoAgent/Views/TaskDetailView.swift
+379 −0
@@ -0,0 +1,379 @@ | ||
| 1 | +// | |
| 2 | +// TaskDetailView.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// The command-center detail for one task: the 52pt header (editable title, | |
| 9 | +// centered model chip, safety-mode segmented control, workspace chip, | |
| 10 | +// export, info popover), the conversation column + collapsible plan panel, | |
| 11 | +// the collapsible Activity/Terminal drawer, and the floating input bar. | |
| 12 | +// | |
| 13 | + | |
| 14 | +import SwiftUI | |
| 15 | +import UniformTypeIdentifiers | |
| 16 | + | |
| 17 | +/// Thin wrapper resolving the task's RunController from the RunHub. | |
| 18 | +struct TaskDetailView: View { | |
| 19 | + let taskID: AgentTask.ID | |
| 20 | + @EnvironmentObject private var hub: RunHub | |
| 21 | + | |
| 22 | + var body: some View { | |
| 23 | + TaskDetailContent(controller: hub.controller(for: taskID)) | |
| 24 | + .id(taskID) | |
| 25 | + } | |
| 26 | +} | |
| 27 | + | |
| 28 | +struct TaskDetailContent: View { | |
| 29 | + @ObservedObject var controller: RunController | |
| 30 | + | |
| 31 | + @EnvironmentObject private var store: TaskStore | |
| 32 | + @EnvironmentObject private var catalog: ModelCatalog | |
| 33 | + @EnvironmentObject private var vault: KeyVaultStore | |
| 34 | + @EnvironmentObject private var appearance: AppearanceStore | |
| 35 | + | |
| 36 | + @State private var draft = "" | |
| 37 | + @State private var editingTitle = false | |
| 38 | + @State private var titleDraft = "" | |
| 39 | + @State private var showingInfo = false | |
| 40 | + @State private var showingSettingsPlaceholder = false | |
| 41 | + @State private var planVisible = true | |
| 42 | + @State private var drawerVisible = false | |
| 43 | + | |
| 44 | + private var task: AgentTask? { store.task(id: controller.taskID) } | |
| 45 | + | |
| 46 | + private var currentModel: AIModel? { | |
| 47 | + guard let task else { return nil } | |
| 48 | + return catalog.model(id: task.modelID, provider: task.providerID) ?? catalog.defaultAgentModel | |
| 49 | + } | |
| 50 | + | |
| 51 | + var body: some View { | |
| 52 | + VStack(spacing: 0) { | |
| 53 | + header | |
| 54 | + ZyquoHairline() | |
| 55 | + HStack(spacing: 0) { | |
| 56 | + VStack(spacing: 0) { | |
| 57 | + if let task, task.messages.isEmpty, controller.entries.isEmpty { | |
| 58 | + emptyState(task) | |
| 59 | + } else if let task { | |
| 60 | + ConversationView(task: task, controller: controller) | |
| 61 | + } | |
| 62 | + if noKeyForModel { | |
| 63 | + noKeyBanner | |
| 64 | + } | |
| 65 | + InputBarView( | |
| 66 | + text: $draft, | |
| 67 | + isRunning: controller.isRunning, | |
| 68 | + disabledReason: inputDisabledReason, | |
| 69 | + onRun: run, | |
| 70 | + onStop: { controller.cancel() } | |
| 71 | + ) | |
| 72 | + } | |
| 73 | + .frame(maxWidth: .infinity) | |
| 74 | + if planVisible { | |
| 75 | + Rectangle() | |
| 76 | + .fill(ZyquoColor.border) | |
| 77 | + .frame(width: ZyquoMetrics.hairline) | |
| 78 | + PlanPanelView(controller: controller) | |
| 79 | + .transition(.move(edge: .trailing).combined(with: .opacity)) | |
| 80 | + } | |
| 81 | + } | |
| 82 | + if drawerVisible { | |
| 83 | + ZyquoHairline() | |
| 84 | + TerminalDrawerView(controller: controller) | |
| 85 | + .transition(.move(edge: .bottom).combined(with: .opacity)) | |
| 86 | + } | |
| 87 | + } | |
| 88 | + .background(ZyquoColor.background) | |
| 89 | + .background( | |
| 90 | + // ⌘. stops the current run. | |
| 91 | + Button("") { controller.cancel() } | |
| 92 | + .keyboardShortcut(".", modifiers: .command) | |
| 93 | + .hidden() | |
| 94 | + ) | |
| 95 | + .sheet(isPresented: $showingSettingsPlaceholder) { | |
| 96 | + SettingsPlaceholderView() | |
| 97 | + } | |
| 98 | + .onAppear { | |
| 99 | + if let pending = store.pendingDraft { | |
| 100 | + draft = pending | |
| 101 | + store.pendingDraft = nil | |
| 102 | + } | |
| 103 | + } | |
| 104 | + .onChange(of: controller.isRunning) { running in | |
| 105 | + if running { drawerVisible = true } | |
| 106 | + } | |
| 107 | + } | |
| 108 | + | |
| 109 | + // MARK: - Header (52pt) | |
| 110 | + | |
| 111 | + private var header: some View { | |
| 112 | + ZStack { | |
| 113 | + ModelChipView(model: currentModel, onSelect: select(model:)) | |
| 114 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 115 | + titleView | |
| 116 | + Spacer() | |
| 117 | + headerControls | |
| 118 | + } | |
| 119 | + } | |
| 120 | + .padding(.horizontal, ZyquoMetrics.contentInset) | |
| 121 | + .frame(height: ZyquoMetrics.headerHeight) | |
| 122 | + } | |
| 123 | + | |
| 124 | + @ViewBuilder | |
| 125 | + private var titleView: some View { | |
| 126 | + if editingTitle { | |
| 127 | + TextField("Title", text: $titleDraft, onCommit: { | |
| 128 | + store.rename(controller.taskID, to: titleDraft) | |
| 129 | + editingTitle = false | |
| 130 | + }) | |
| 131 | + .textFieldStyle(.plain) | |
| 132 | + .font(ZyquoFont.bodyEmphasis(size: 13)) | |
| 133 | + .frame(maxWidth: 220) | |
| 134 | + } else { | |
| 135 | + Text(task?.title ?? "") | |
| 136 | + .font(ZyquoFont.bodyEmphasis(size: 13)) | |
| 137 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 138 | + .lineLimit(1) | |
| 139 | + .frame(maxWidth: 220, alignment: .leading) | |
| 140 | + .onTapGesture(count: 2) { | |
| 141 | + titleDraft = task?.title ?? "" | |
| 142 | + editingTitle = true | |
| 143 | + } | |
| 144 | + .help("Double-click to rename") | |
| 145 | + } | |
| 146 | + } | |
| 147 | + | |
| 148 | + private var headerControls: some View { | |
| 149 | + HStack(spacing: ZyquoSpacing.sm) { | |
| 150 | + SafetyModePicker(mode: task?.safetyMode ?? .guarded) { mode in | |
| 151 | + controller.setSafetyMode(mode) | |
| 152 | + } | |
| 153 | + if let root = controller.workspaceRoot { | |
| 154 | + workspaceChip(root) | |
| 155 | + } | |
| 156 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 157 | + toggleButton( | |
| 158 | + symbol: "sidebar.right", | |
| 159 | + active: planVisible, | |
| 160 | + help: "Toggle plan panel" | |
| 161 | + ) { withAnimation(ZyquoMotion.appear) { planVisible.toggle() } } | |
| 162 | + toggleButton( | |
| 163 | + symbol: "terminal", | |
| 164 | + active: drawerVisible, | |
| 165 | + help: "Toggle activity drawer" | |
| 166 | + ) { withAnimation(ZyquoMotion.appear) { drawerVisible.toggle() } } | |
| 167 | + Button { | |
| 168 | + exportTranscript() | |
| 169 | + } label: { | |
| 170 | + Image(systemName: "square.and.arrow.up") | |
| 171 | + .font(.system(size: 12)) | |
| 172 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 173 | + } | |
| 174 | + .buttonStyle(.plain) | |
| 175 | + .help("Export task transcript as Markdown") | |
| 176 | + Button { | |
| 177 | + showingInfo.toggle() | |
| 178 | + } label: { | |
| 179 | + Image(systemName: "info.circle") | |
| 180 | + .font(.system(size: 12)) | |
| 181 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 182 | + } | |
| 183 | + .buttonStyle(.plain) | |
| 184 | + .help("Task info") | |
| 185 | + .popover(isPresented: $showingInfo, arrowEdge: .bottom) { | |
| 186 | + infoPopover | |
| 187 | + } | |
| 188 | + } | |
| 189 | + } | |
| 190 | + } | |
| 191 | + | |
| 192 | + private func toggleButton(symbol: String, active: Bool, help: String, action: @escaping () -> Void) -> some View { | |
| 193 | + Button(action: action) { | |
| 194 | + Image(systemName: symbol) | |
| 195 | + .font(.system(size: 12)) | |
| 196 | + .foregroundStyle(active ? ZyquoColor.accent : ZyquoColor.textSecondary) | |
| 197 | + } | |
| 198 | + .buttonStyle(.plain) | |
| 199 | + .help(help) | |
| 200 | + } | |
| 201 | + | |
| 202 | + private func workspaceChip(_ root: URL) -> some View { | |
| 203 | + Button { | |
| 204 | + NSWorkspace.shared.activateFileViewerSelecting([root]) | |
| 205 | + } label: { | |
| 206 | + HStack(spacing: ZyquoSpacing.xxs) { | |
| 207 | + Image(systemName: "folder") | |
| 208 | + .font(.system(size: 10)) | |
| 209 | + Text(root.lastPathComponent) | |
| 210 | + .font(ZyquoFont.caption) | |
| 211 | + .lineLimit(1) | |
| 212 | + } | |
| 213 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 214 | + .padding(.horizontal, ZyquoSpacing.xs) | |
| 215 | + .padding(.vertical, 3) | |
| 216 | + .background(Capsule().fill(ZyquoColor.surfaceSecondary)) | |
| 217 | + } | |
| 218 | + .buttonStyle(.plain) | |
| 219 | + .help("Reveal workspace in Finder\n\(root.path)") | |
| 220 | + } | |
| 221 | + | |
| 222 | + // MARK: - Info popover | |
| 223 | + | |
| 224 | + private var infoPopover: some View { | |
| 225 | + VStack(alignment: .leading, spacing: ZyquoSpacing.sm) { | |
| 226 | + Text("Task") | |
| 227 | + .font(ZyquoFont.bodyEmphasis()) | |
| 228 | + let config = controller.loopGuardConfiguration | |
| 229 | + LabeledContent("Budgets") { | |
| 230 | + Text("\(config.maxSteps) steps · \(config.tokenBudget / 1_000)K tokens · \(Int(config.wallClockBudget / 60)) min") | |
| 231 | + } | |
| 232 | + LabeledContent("Used this run") { | |
| 233 | + Text("\(controller.stepsUsed) steps · \(controller.tokensUsed) tokens") | |
| 234 | + } | |
| 235 | + if let workspace = controller.workspaceRoot { | |
| 236 | + LabeledContent("Workspace") { | |
| 237 | + Text(workspace.path) | |
| 238 | + .lineLimit(2) | |
| 239 | + .truncationMode(.middle) | |
| 240 | + } | |
| 241 | + } | |
| 242 | + Divider() | |
| 243 | + Text("System prompt") | |
| 244 | + .font(ZyquoFont.caption) | |
| 245 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 246 | + ScrollView { | |
| 247 | + Text(controller.systemPromptPreview ?? "The full system prompt is assembled when a run starts (role, tools, workspace, safety expectations, done-signal).") | |
| 248 | + .font(ZyquoFont.code(size: 10.5)) | |
| 249 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 250 | + .textSelection(.enabled) | |
| 251 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 252 | + .padding(ZyquoSpacing.xs) | |
| 253 | + } | |
| 254 | + .frame(width: 340, height: 160) | |
| 255 | + .background( | |
| 256 | + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) | |
| 257 | + .fill(ZyquoColor.surfaceSecondary) | |
| 258 | + ) | |
| 259 | + } | |
| 260 | + .font(ZyquoFont.body(size: 12.5)) | |
| 261 | + .padding(ZyquoSpacing.md) | |
| 262 | + .frame(width: 380) | |
| 263 | + } | |
| 264 | + | |
| 265 | + // MARK: - Empty & no-key states | |
| 266 | + | |
| 267 | + private func emptyState(_ task: AgentTask) -> some View { | |
| 268 | + AgentEmptyStateView( | |
| 269 | + model: currentModel, | |
| 270 | + safetyMode: task.safetyMode, | |
| 271 | + onSelectModel: select(model:), | |
| 272 | + onSelectSafetyMode: { controller.setSafetyMode($0) }, | |
| 273 | + onSuggestion: { draft = $0 } | |
| 274 | + ) | |
| 275 | + } | |
| 276 | + | |
| 277 | + private var noKeyForModel: Bool { | |
| 278 | + guard let model = currentModel else { return false } | |
| 279 | + return AgentCLI.resolveAPIKey(for: model.provider) == nil | |
| 280 | + } | |
| 281 | + | |
| 282 | + private var noKeyBanner: some View { | |
| 283 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 284 | + Image(systemName: "key") | |
| 285 | + .font(.system(size: 11)) | |
| 286 | + Text("No API key for \(currentModel?.provider.displayName ?? "this provider") yet.") | |
| 287 | + .font(ZyquoFont.body(size: 12.5)) | |
| 288 | + Button("Providers & Keys…") { | |
| 289 | + showingSettingsPlaceholder = true | |
| 290 | + } | |
| 291 | + .font(ZyquoFont.body(size: 12.5)) | |
| 292 | + Spacer(minLength: 0) | |
| 293 | + } | |
| 294 | + .foregroundStyle(ZyquoColor.warning) | |
| 295 | + .padding(.horizontal, ZyquoSpacing.sm) | |
| 296 | + .padding(.vertical, ZyquoSpacing.xs) | |
| 297 | + .background( | |
| 298 | + RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous) | |
| 299 | + .fill(ZyquoColor.warning.opacity(0.08)) | |
| 300 | + ) | |
| 301 | + .padding(.horizontal, ZyquoSpacing.sm) | |
| 302 | + } | |
| 303 | + | |
| 304 | + private var inputDisabledReason: String? { | |
| 305 | + guard currentModel != nil else { return "Choose a model to run agent tasks." } | |
| 306 | + if noKeyForModel { | |
| 307 | + return "Add an API key for \(currentModel?.provider.displayName ?? "the provider") to run." | |
| 308 | + } | |
| 309 | + if let model = currentModel, !model.agentCapable { | |
| 310 | + return "\(model.displayName) has limited tool use — agent runs may be unreliable." | |
| 311 | + } | |
| 312 | + return nil | |
| 313 | + } | |
| 314 | + | |
| 315 | + // MARK: - Actions | |
| 316 | + | |
| 317 | + private func run() { | |
| 318 | + guard let model = currentModel else { return } | |
| 319 | + let prompt = draft | |
| 320 | + withAnimation(ZyquoMotion.appear) { draft = "" } | |
| 321 | + controller.start(prompt: prompt, model: model) | |
| 322 | + } | |
| 323 | + | |
| 324 | + private func select(model: AIModel) { | |
| 325 | + guard var task else { return } | |
| 326 | + task.modelID = model.id | |
| 327 | + task.providerID = model.provider | |
| 328 | + store.update(task, touch: false) | |
| 329 | + } | |
| 330 | + | |
| 331 | + /// Exports the task's history (prompts, steps, answers) as Markdown. | |
| 332 | + private func exportTranscript() { | |
| 333 | + guard let task else { return } | |
| 334 | + let panel = NSSavePanel() | |
| 335 | + panel.allowedContentTypes = [.plainText] | |
| 336 | + panel.nameFieldStringValue = "\(WorkspaceManager.slug(from: task.title)).md" | |
| 337 | + panel.begin { response in | |
| 338 | + guard response == .OK, let url = panel.url else { return } | |
| 339 | + let markdown = Self.markdown(for: task) | |
| 340 | + try? markdown.write(to: url, atomically: true, encoding: .utf8) | |
| 341 | + } | |
| 342 | + } | |
| 343 | + | |
| 344 | + private static func markdown(for task: AgentTask) -> String { | |
| 345 | + var lines: [String] = ["# \(task.title)", ""] | |
| 346 | + lines.append("Model: `\(task.modelID)` (\(task.providerID.displayName)) · Safety: \(task.safetyMode.displayName)") | |
| 347 | + if let workspace = task.workspacePath { | |
| 348 | + lines.append("Workspace: `\(workspace)`") | |
| 349 | + } | |
| 350 | + lines.append("") | |
| 351 | + for message in task.messages { | |
| 352 | + switch message.kind { | |
| 353 | + case .user: | |
| 354 | + lines.append("## 🧑 Prompt") | |
| 355 | + lines.append(message.text) | |
| 356 | + case .agentRun: | |
| 357 | + lines.append("## 🤖 Run") | |
| 358 | + for step in message.steps ?? [] { | |
| 359 | + lines.append("### Step \(step.index)") | |
| 360 | + if !step.text.isEmpty { lines.append(step.text) } | |
| 361 | + for invocation in step.toolInvocations { | |
| 362 | + lines.append("```") | |
| 363 | + lines.append("\(invocation.call.name): \(invocation.call.argumentsJSON)") | |
| 364 | + if let result = invocation.result { | |
| 365 | + lines.append("→ \(result.content)") | |
| 366 | + } | |
| 367 | + lines.append("```") | |
| 368 | + } | |
| 369 | + } | |
| 370 | + if !message.text.isEmpty { | |
| 371 | + lines.append("### Result") | |
| 372 | + lines.append(message.text) | |
| 373 | + } | |
| 374 | + } | |
| 375 | + lines.append("") | |
| 376 | + } | |
| 377 | + return lines.joined(separator: "\n") | |
| 378 | + } | |
| 379 | +} | |
added
Sources/ZyquoAgent/Views/TerminalDrawerView.swift
+314 −0
@@ -0,0 +1,314 @@ | ||
| 1 | +// | |
| 2 | +// TerminalDrawerView.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// The bottom Activity/Terminal drawer with three tabs: | |
| 9 | +// Live — color-coded streaming feed of raw command execution (SF Mono, | |
| 10 | +// append-only, auto-scrolling). | |
| 11 | +// Audit — the workspace's append-only audit log (time, kind, payload, | |
| 12 | +// ruling, exit code). | |
| 13 | +// Files — the workspace tree with created/modified badges, click to | |
| 14 | +// preview, reveal in Finder. | |
| 15 | +// | |
| 16 | + | |
| 17 | +import SwiftUI | |
| 18 | + | |
| 19 | +struct TerminalDrawerView: View { | |
| 20 | + @ObservedObject var controller: RunController | |
| 21 | + | |
| 22 | + enum Tab: String, CaseIterable, Identifiable { | |
| 23 | + case live = "Live" | |
| 24 | + case audit = "Audit Log" | |
| 25 | + case files = "Files" | |
| 26 | + var id: String { rawValue } | |
| 27 | + } | |
| 28 | + | |
| 29 | + @State private var tab: Tab = .live | |
| 30 | + @State private var previewedFile: WorkspaceFileEntry? | |
| 31 | + | |
| 32 | + var body: some View { | |
| 33 | + VStack(spacing: 0) { | |
| 34 | + tabBar | |
| 35 | + ZyquoHairline() | |
| 36 | + switch tab { | |
| 37 | + case .live: liveFeed | |
| 38 | + case .audit: auditTable | |
| 39 | + case .files: filesList | |
| 40 | + } | |
| 41 | + } | |
| 42 | + .frame(height: ZyquoMetrics.terminalDrawerHeight) | |
| 43 | + .background(ZyquoColor.surfaceSecondary.opacity(0.4)) | |
| 44 | + .onChange(of: tab) { newTab in | |
| 45 | + switch newTab { | |
| 46 | + case .audit: controller.refreshAudit() | |
| 47 | + case .files: controller.refreshWorkspaceState() | |
| 48 | + case .live: break | |
| 49 | + } | |
| 50 | + } | |
| 51 | + .sheet(item: $previewedFile) { entry in | |
| 52 | + FilePreviewSheet(entry: entry, workspaceRoot: controller.workspaceRoot) | |
| 53 | + } | |
| 54 | + } | |
| 55 | + | |
| 56 | + // MARK: - Tab bar | |
| 57 | + | |
| 58 | + private var tabBar: some View { | |
| 59 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 60 | + ForEach(Tab.allCases) { candidate in | |
| 61 | + Button { | |
| 62 | + withAnimation(ZyquoMotion.picker) { tab = candidate } | |
| 63 | + } label: { | |
| 64 | + Text(candidate.rawValue) | |
| 65 | + .font(tab == candidate ? ZyquoFont.bodyEmphasis(size: 11.5) : ZyquoFont.body(size: 11.5)) | |
| 66 | + .foregroundStyle(tab == candidate ? ZyquoColor.textPrimary : ZyquoColor.textSecondary) | |
| 67 | + .padding(.horizontal, ZyquoSpacing.xs) | |
| 68 | + .padding(.vertical, 3) | |
| 69 | + .background( | |
| 70 | + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) | |
| 71 | + .fill(tab == candidate ? ZyquoColor.surface : .clear) | |
| 72 | + ) | |
| 73 | + } | |
| 74 | + .buttonStyle(.plain) | |
| 75 | + } | |
| 76 | + Spacer(minLength: 0) | |
| 77 | + if let root = controller.workspaceRoot { | |
| 78 | + Text(root.lastPathComponent) | |
| 79 | + .font(ZyquoFont.code(size: 10.5)) | |
| 80 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 81 | + .lineLimit(1) | |
| 82 | + .help(root.path) | |
| 83 | + } | |
| 84 | + } | |
| 85 | + .padding(.horizontal, ZyquoSpacing.sm) | |
| 86 | + .padding(.vertical, ZyquoSpacing.xxs) | |
| 87 | + } | |
| 88 | + | |
| 89 | + // MARK: - Live feed | |
| 90 | + | |
| 91 | + private static let liveBottomID = "terminal-live-bottom" | |
| 92 | + | |
| 93 | + private var liveFeed: some View { | |
| 94 | + ScrollViewReader { proxy in | |
| 95 | + ScrollView { | |
| 96 | + LazyVStack(alignment: .leading, spacing: 1) { | |
| 97 | + if controller.terminalLines.isEmpty { | |
| 98 | + Text("Command output streams here while the agent works.") | |
| 99 | + .font(ZyquoFont.code(size: 11.5)) | |
| 100 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 101 | + .padding(.top, ZyquoSpacing.xs) | |
| 102 | + } | |
| 103 | + ForEach(controller.terminalLines) { line in | |
| 104 | + Text(line.text) | |
| 105 | + .font(ZyquoFont.code(size: 11.5)) | |
| 106 | + .foregroundStyle(color(for: line.kind)) | |
| 107 | + .textSelection(.enabled) | |
| 108 | + .fixedSize(horizontal: false, vertical: true) | |
| 109 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 110 | + } | |
| 111 | + Color.clear.frame(height: 1).id(Self.liveBottomID) | |
| 112 | + } | |
| 113 | + .padding(.horizontal, ZyquoSpacing.sm) | |
| 114 | + .padding(.vertical, ZyquoSpacing.xs) | |
| 115 | + } | |
| 116 | + .onChange(of: controller.terminalLines.count) { _ in | |
| 117 | + proxy.scrollTo(Self.liveBottomID, anchor: .bottom) | |
| 118 | + } | |
| 119 | + } | |
| 120 | + } | |
| 121 | + | |
| 122 | + private func color(for kind: TerminalLine.Kind) -> Color { | |
| 123 | + switch kind { | |
| 124 | + case .command: return ZyquoColor.accent | |
| 125 | + case .stdout: return ZyquoColor.textPrimary | |
| 126 | + case .stderr: return ZyquoColor.danger | |
| 127 | + case .note: return ZyquoColor.textSecondary | |
| 128 | + case .meta: return ZyquoColor.textTertiary | |
| 129 | + } | |
| 130 | + } | |
| 131 | + | |
| 132 | + // MARK: - Audit tab | |
| 133 | + | |
| 134 | + private var auditTable: some View { | |
| 135 | + ScrollView { | |
| 136 | + LazyVStack(alignment: .leading, spacing: 0) { | |
| 137 | + if controller.auditEntries.isEmpty { | |
| 138 | + Text("Every executed action is recorded here — nothing the agent does is invisible.") | |
| 139 | + .font(ZyquoFont.body(size: 12)) | |
| 140 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 141 | + .padding(ZyquoSpacing.sm) | |
| 142 | + } | |
| 143 | + ForEach(controller.auditEntries) { entry in | |
| 144 | + auditRow(entry) | |
| 145 | + ZyquoHairline() | |
| 146 | + } | |
| 147 | + } | |
| 148 | + } | |
| 149 | + .onAppear { controller.refreshAudit() } | |
| 150 | + } | |
| 151 | + | |
| 152 | + private func auditRow(_ entry: AuditEntry) -> some View { | |
| 153 | + HStack(alignment: .firstTextBaseline, spacing: ZyquoSpacing.sm) { | |
| 154 | + Text(entry.timestamp, format: .dateTime.hour().minute().second()) | |
| 155 | + .font(ZyquoFont.code(size: 10.5)) | |
| 156 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 157 | + .frame(width: 64, alignment: .leading) | |
| 158 | + HStack(spacing: ZyquoSpacing.xxs) { | |
| 159 | + Image(systemName: toolSymbolName(entry.actionKind)) | |
| 160 | + .font(.system(size: 9)) | |
| 161 | + Text(entry.actionKind) | |
| 162 | + .font(ZyquoFont.code(size: 10.5)) | |
| 163 | + } | |
| 164 | + .foregroundStyle(ZyquoColor.accent) | |
| 165 | + .frame(width: 84, alignment: .leading) | |
| 166 | + Text(entry.payload) | |
| 167 | + .font(ZyquoFont.code(size: 10.5)) | |
| 168 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 169 | + .lineLimit(1) | |
| 170 | + .truncationMode(.tail) | |
| 171 | + .help(entry.payload) | |
| 172 | + Spacer(minLength: 0) | |
| 173 | + Text(entry.ruling) | |
| 174 | + .font(ZyquoFont.caption) | |
| 175 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 176 | + if let code = entry.exitCode { | |
| 177 | + Text("exit \(code)") | |
| 178 | + .font(ZyquoFont.caption) | |
| 179 | + .foregroundStyle(code == 0 ? ZyquoColor.success : ZyquoColor.danger) | |
| 180 | + } | |
| 181 | + } | |
| 182 | + .padding(.horizontal, ZyquoSpacing.sm) | |
| 183 | + .padding(.vertical, ZyquoSpacing.xxs) | |
| 184 | + } | |
| 185 | + | |
| 186 | + // MARK: - Files tab | |
| 187 | + | |
| 188 | + private var filesList: some View { | |
| 189 | + ScrollView { | |
| 190 | + LazyVStack(alignment: .leading, spacing: 0) { | |
| 191 | + if controller.workspaceFiles.isEmpty { | |
| 192 | + Text("Files the agent creates or modifies in the workspace appear here.") | |
| 193 | + .font(ZyquoFont.body(size: 12)) | |
| 194 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 195 | + .padding(ZyquoSpacing.sm) | |
| 196 | + } | |
| 197 | + ForEach(controller.workspaceFiles) { entry in | |
| 198 | + fileRow(entry) | |
| 199 | + } | |
| 200 | + } | |
| 201 | + .padding(.vertical, ZyquoSpacing.xxs) | |
| 202 | + } | |
| 203 | + .onAppear { controller.refreshWorkspaceState() } | |
| 204 | + } | |
| 205 | + | |
| 206 | + private func fileRow(_ entry: WorkspaceFileEntry) -> some View { | |
| 207 | + Button { | |
| 208 | + previewedFile = entry | |
| 209 | + } label: { | |
| 210 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 211 | + Image(systemName: "doc.text") | |
| 212 | + .font(.system(size: 11)) | |
| 213 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 214 | + Text(entry.path) | |
| 215 | + .font(ZyquoFont.code(size: 11.5)) | |
| 216 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 217 | + .lineLimit(1) | |
| 218 | + ZyquoBadge( | |
| 219 | + text: entry.status == .created ? "created" : "modified", | |
| 220 | + color: entry.status == .created ? ZyquoColor.success : ZyquoColor.warning | |
| 221 | + ) | |
| 222 | + Spacer(minLength: 0) | |
| 223 | + Text(entry.lastTouched, format: .relative(presentation: .named)) | |
| 224 | + .font(ZyquoFont.caption) | |
| 225 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 226 | + Button { | |
| 227 | + reveal(entry) | |
| 228 | + } label: { | |
| 229 | + Image(systemName: "arrow.right.circle") | |
| 230 | + .font(.system(size: 11)) | |
| 231 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 232 | + } | |
| 233 | + .buttonStyle(.plain) | |
| 234 | + .help("Reveal in Finder") | |
| 235 | + } | |
| 236 | + .padding(.horizontal, ZyquoSpacing.sm) | |
| 237 | + .padding(.vertical, ZyquoSpacing.xxs) | |
| 238 | + .contentShape(Rectangle()) | |
| 239 | + } | |
| 240 | + .buttonStyle(.plain) | |
| 241 | + .zyquoHoverHighlight() | |
| 242 | + } | |
| 243 | + | |
| 244 | + private func reveal(_ entry: WorkspaceFileEntry) { | |
| 245 | + guard let root = controller.workspaceRoot else { return } | |
| 246 | + let url = root.appendingPathComponent(entry.path) | |
| 247 | + NSWorkspace.shared.activateFileViewerSelecting([url]) | |
| 248 | + } | |
| 249 | +} | |
| 250 | + | |
| 251 | +// MARK: - File preview sheet | |
| 252 | + | |
| 253 | +/// Quick preview of a workspace file (text contents, capped) with a reveal | |
| 254 | +/// button. | |
| 255 | +struct FilePreviewSheet: View { | |
| 256 | + let entry: WorkspaceFileEntry | |
| 257 | + let workspaceRoot: URL? | |
| 258 | + | |
| 259 | + @Environment(\.dismiss) private var dismiss | |
| 260 | + | |
| 261 | + /// Preview cap: files larger than this show a truncated head. | |
| 262 | + private static let previewByteLimit = 200_000 | |
| 263 | + | |
| 264 | + var body: some View { | |
| 265 | + VStack(alignment: .leading, spacing: ZyquoSpacing.sm) { | |
| 266 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 267 | + Image(systemName: "doc.text") | |
| 268 | + .font(.system(size: 13)) | |
| 269 | + .foregroundStyle(ZyquoColor.accent) | |
| 270 | + Text(entry.path) | |
| 271 | + .font(ZyquoFont.bodyEmphasis()) | |
| 272 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 273 | + .lineLimit(1) | |
| 274 | + Spacer(minLength: 0) | |
| 275 | + Button("Reveal in Finder") { | |
| 276 | + if let root = workspaceRoot { | |
| 277 | + NSWorkspace.shared.activateFileViewerSelecting([root.appendingPathComponent(entry.path)]) | |
| 278 | + } | |
| 279 | + } | |
| 280 | + Button("Done") { dismiss() } | |
| 281 | + .keyboardShortcut(.defaultAction) | |
| 282 | + } | |
| 283 | + ScrollView([.vertical, .horizontal]) { | |
| 284 | + Text(contents) | |
| 285 | + .font(ZyquoFont.code()) | |
| 286 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 287 | + .textSelection(.enabled) | |
| 288 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 289 | + .padding(ZyquoSpacing.sm) | |
| 290 | + } | |
| 291 | + .background( | |
| 292 | + RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous) | |
| 293 | + .fill(ZyquoColor.surfaceSecondary) | |
| 294 | + ) | |
| 295 | + } | |
| 296 | + .padding(ZyquoSpacing.md) | |
| 297 | + .frame( | |
| 298 | + width: ZyquoMetrics.maxMessageColumnWidth, | |
| 299 | + height: ZyquoMetrics.settingsHeight | |
| 300 | + ) | |
| 301 | + .background(ZyquoColor.surface) | |
| 302 | + } | |
| 303 | + | |
| 304 | + private var contents: String { | |
| 305 | + guard let root = workspaceRoot else { return "(workspace unavailable)" } | |
| 306 | + let url = root.appendingPathComponent(entry.path) | |
| 307 | + guard let data = try? Data(contentsOf: url) else { return "(could not read file)" } | |
| 308 | + if data.count > Self.previewByteLimit { | |
| 309 | + let head = String(decoding: data.prefix(Self.previewByteLimit), as: UTF8.self) | |
| 310 | + return head + "\n… [truncated — open in Finder for the full file]" | |
| 311 | + } | |
| 312 | + return String(decoding: data, as: UTF8.self) | |
| 313 | + } | |
| 314 | +} | |
| 315 | ||