SPB Git

spb/zyquo-agent Public MIT

The autonomous agent that actually operates your Mac — plans, runs real commands, verifies its own work.

Swift 94.7% Shell 4.1% Python 0.7% Makefile 0.5%

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>
simon-pierre boucher committed 11 days ago (Jul 30, 2026) parent 44eb89f

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