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%

Phase 6 (wave 2): Settings window, Quick Task panel, menu bar extra, templates, personas, ⌘K palette, exports & shortcuts

- Settings scene (760×560, 7 tabs): Providers & Keys (vault UI + env-var
  indicator + per-provider Test), Models (shared catalog, Agent badges,
  persisted default agent model), Safety (default mode, allow/deny rule
  editor over policy-rules.json, read-only built-in destructive-pattern
  list, AppleScript/workspace-escape settings), Agent (budgets, timeout,
  parallel tool calls, compaction threshold + Personas CRUD), Appearance
  (theme/accent swatches/font size), Shortcuts, Advanced (reveal folders,
  audit-log export, task import/export, menu bar toggle)
- Quick Task panel (⌥Space): floating NSPanel, default model + Guarded,
  live step cards with inline approvals, promote to full window
- Menu bar extra with running-task status (toggleable, template icon)
- 29 built-in task templates in 6 categories + user templates CRUD,
  variable fill-in sheet, template browser + ⌘K command palette
- Personas: CRUD, per-task picker, system-prompt addendum via
  AgentConfiguration.personaAddendum → AgentSystemPrompt (## Persona)
- Dynamic accent: ZyquoColor.accent/accentSubtle resolve through
  ZyquoAccentResolver fed by AppearanceStore (token API unchanged)
- RunController: settings-driven AgentConfiguration/ExecutionConfiguration,
  persona pass-through, auto-generated titles after completed runs
- Transcript export: Markdown + PDF (TaskTranscriptExporter)
- Shortcuts: ⌘K palette, ⌘⇧A audit log, ⌘⇧E export, ⌥Space quick task

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
simon-pierre boucher committed 11 days ago (Jul 30, 2026) parent 0f048f2

Showing 34 changed files with +3,268 and −174

modified Sources/ZyquoAgent/Agent/AgentLoop.swift +5 −1
@@ -29,6 +29,9 @@ struct AgentConfiguration: Sendable {
29 29 /// Default per-command timeout for the ExecutionService the host wires up
30 30 /// (the loop itself never spawns processes).
31 31 var perCommandTimeout: TimeInterval?
32 + /// Active persona's system-prompt addition, appended to the agent system
33 + /// prompt as a trailing "## Persona" section (Phase 6 personas).
34 + var personaAddendum: String?
32 35
33 36 static let `default` = AgentConfiguration()
34 37 }
@@ -159,7 +162,8 @@ actor AgentLoop {
159 162 let systemPrompt = AgentSystemPrompt.build(
160 163 workspacePath: workspace.root.path,
161 164 toolNames: tools.toolNames,
162 safetyMode: await policy.mode
165 + safetyMode: await policy.mode,
166 + personaAddendum: configuration.personaAddendum
163 167 )
164 168 let toolSpecs = tools.toolSpecs + [Planner.toolSpec]
165 169 let fixedTokens = MemoryManager.estimateFixedTokens(systemPrompt: systemPrompt, toolSpecs: toolSpecs)
modified Sources/ZyquoAgent/Agent/AgentSystemPrompt.swift +18 −3
@@ -16,17 +16,21 @@
16 16 import Foundation
17 17
18 18 enum AgentSystemPrompt {
19 /// Builds the system prompt for one run.
19 + /// Builds the system prompt for one run. `personaAddendum` is the active
20 + /// persona's system-prompt addition (Phase 6), appended as its own
21 + /// section after the core prompt — the safety/completion contract above
22 + /// it always stands.
20 23 static func build(
21 24 workspacePath: String,
22 25 toolNames: [String],
23 safetyMode: SafetyMode
26 + safetyMode: SafetyMode,
27 + personaAddendum: String? = nil
24 28 ) -> String {
25 29 let os = ProcessInfo.processInfo.operatingSystemVersionString
26 30 let date = ISO8601DateFormatter().string(from: Date()).prefix(10)
27 31 let tools = toolNames.joined(separator: ", ")
28 32
29 return """
33 + var prompt = """
30 34 You are Zyquo Agent, an autonomous agent that operates the user's Mac by calling tools. \
31 35 You work in a plan act observe reflect loop: issue tool calls, examine each result, \
32 36 and decide the next action from what you actually observed — never from assumptions.
@@ -81,5 +85,16 @@ enum AgentSystemPrompt {
81 85 - If you cannot complete the task, respond without tool calls explaining what you tried, what \
82 86 blocks you, and what the user could do.
83 87 """
88 +
89 + if let personaAddendum,
90 + !personaAddendum.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
91 + prompt += """
92 +
93 +
94 + ## Persona
95 + \(personaAddendum.trimmingCharacters(in: .whitespacesAndNewlines))
96 + """
97 + }
98 + return prompt
84 99 }
85 100 }
modified Sources/ZyquoAgent/App/ZyquoAgentApp.swift +170 −13
@@ -6,9 +6,12 @@
6 6 // Mail: contact@spboucher.ai
7 7 //
8 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).
9 +// hub, model catalog, key vault, appearance, agent settings, templates,
10 +// personas, UI signals), presents the command-center window (1320×860
11 +// default, 1040×680 min), the Settings scene (760×560, 7 tabs), the
12 +// toggleable menu bar extra with running-task status, the ⌥Space Quick Task
13 +// panel, and the full Phase 6 command set (⌘N, ⌘K, ⌘⇧A, ⌘⇧E, ⌥Space;
14 +// ⌘⏎/⌘./⌘F live in the detail/sidebar views).
12 15 //
13 16
14 17 import SwiftUI
@@ -21,46 +24,200 @@ final class AppEnvironment: ObservableObject {
21 24 let catalog: ModelCatalog
22 25 let vault: KeyVaultStore
23 26 let appearance: AppearanceStore
27 + let settings: AgentSettingsStore
28 + let templates: TemplateStore
29 + let personas: PersonaStore
30 + let uiState: AppUIState
24 31
25 32 init() {
26 33 let tasks = TaskStore()
34 + let settings = AgentSettingsStore()
27 35 self.tasks = tasks
28 self.hub = RunHub(store: tasks)
36 + self.settings = settings
37 + self.hub = RunHub(store: tasks, settings: settings)
29 38 self.catalog = ModelCatalog()
30 39 self.vault = KeyVaultStore()
31 40 self.appearance = AppearanceStore()
41 + self.templates = TemplateStore()
42 + self.personas = PersonaStore()
43 + self.uiState = AppUIState()
44 + }
45 +
46 + /// The model new tasks start with (persisted default, catalog fallback).
47 + var newTaskModel: AIModel? {
48 + settings.defaultAgentModel(in: catalog)
32 49 }
33 50 }
34 51
35 52 struct ZyquoAgentApp: App {
36 53 @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
37 54 @StateObject private var environment = AppEnvironment()
55 + @State private var quickTask: QuickTaskController?
56 + @AppStorage("menuBarExtraEnabled") private var menuBarExtraEnabled = true
38 57
39 58 var body: some Scene {
40 59 WindowGroup("Zyquo Agent") {
41 60 MainWindowView()
42 .environmentObject(environment.tasks)
43 .environmentObject(environment.hub)
44 .environmentObject(environment.catalog)
45 .environmentObject(environment.vault)
46 .environmentObject(environment.appearance)
61 + .modifier(EnvironmentInjector(environment: environment))
47 62 .frame(
48 63 minWidth: ZyquoMetrics.windowMinWidth,
49 64 minHeight: ZyquoMetrics.windowMinHeight
50 65 )
66 + .onAppear {
67 + if quickTask == nil {
68 + quickTask = QuickTaskController(environment: environment)
69 + }
70 + }
51 71 }
52 72 .defaultSize(
53 73 width: ZyquoMetrics.windowDefaultWidth,
54 74 height: ZyquoMetrics.windowDefaultHeight
55 75 )
56 76 .commands {
57 CommandGroup(replacing: .newItem) {
58 Button("New Task") {
59 environment.tasks.newTask(model: environment.catalog.defaultAgentModel)
77 + AppCommands(environment: environment, quickTask: { quickTask })
78 + }
79 +
80 + Settings {
81 + SettingsView()
82 + .modifier(EnvironmentInjector(environment: environment))
83 + }
84 +
85 + MenuBarExtra(isInserted: $menuBarExtraEnabled) {
86 + MenuBarContent(store: environment.tasks, environment: environment, quickTask: { quickTask })
87 + } label: {
88 + MenuBarIconView()
89 + }
90 + }
91 +}
92 +
93 +/// Injects the full shared object graph (all scenes get the same set).
94 +private struct EnvironmentInjector: ViewModifier {
95 + let environment: AppEnvironment
96 +
97 + func body(content: Content) -> some View {
98 + content
99 + .environmentObject(environment.tasks)
100 + .environmentObject(environment.hub)
101 + .environmentObject(environment.catalog)
102 + .environmentObject(environment.vault)
103 + .environmentObject(environment.appearance)
104 + .environmentObject(environment.settings)
105 + .environmentObject(environment.templates)
106 + .environmentObject(environment.personas)
107 + .environmentObject(environment.uiState)
108 + }
109 +}
110 +
111 +// MARK: - Commands
112 +
113 +/// App-level menu commands and shortcuts (⌘N, ⌘K, ⌘⇧A, ⌘⇧E, Quick Task).
114 +struct AppCommands: Commands {
115 + let environment: AppEnvironment
116 + var quickTask: () -> QuickTaskController?
117 +
118 + var body: some Commands {
119 + CommandGroup(replacing: .newItem) {
120 + Button("New Task") {
121 + environment.tasks.newTask(
122 + model: environment.newTaskModel,
123 + safetyMode: environment.settings.settings.defaultSafetyMode
124 + )
125 + }
126 + .keyboardShortcut("n", modifiers: .command)
127 + }
128 + CommandMenu("Task") {
129 + Button("Command Palette") {
130 + environment.uiState.showCommandPalette.toggle()
131 + }
132 + .keyboardShortcut("k", modifiers: .command)
133 + Button("Quick Task") {
134 + quickTask()?.show()
135 + }
136 + Button("Browse Templates…") {
137 + environment.uiState.showTemplateBrowser = true
138 + }
139 + Divider()
140 + Button("Open Audit Log") {
141 + environment.uiState.requestAuditLog()
142 + }
143 + .keyboardShortcut("a", modifiers: [.command, .shift])
144 + Divider()
145 + Button("Export Transcript as Markdown…") {
146 + if let id = environment.tasks.selectedID, let task = environment.tasks.task(id: id) {
147 + TaskTranscriptExporter.presentSavePanel(for: task, format: .markdown)
148 + }
149 + }
150 + .keyboardShortcut("e", modifiers: [.command, .shift])
151 + .disabled(environment.tasks.selectedID == nil)
152 + Button("Export Transcript as PDF…") {
153 + if let id = environment.tasks.selectedID, let task = environment.tasks.task(id: id) {
154 + TaskTranscriptExporter.presentSavePanel(for: task, format: .pdf)
155 + }
156 + }
157 + .disabled(environment.tasks.selectedID == nil)
158 + }
159 + }
160 +}
161 +
162 +// MARK: - Menu bar extra
163 +
164 +/// Menu bar glyph: the shipped template PNG when bundled, SF Symbol fallback
165 +/// during `swift run` (no bundle resources).
166 +struct MenuBarIconView: View {
167 + var body: some View {
168 + if let image = Self.templateImage() {
169 + Image(nsImage: image)
170 + } else {
171 + Image(systemName: "bolt.circle")
172 + }
173 + }
174 +
175 + private static func templateImage() -> NSImage? {
176 + guard let path = Bundle.main.path(forResource: "MenuBarIcon", ofType: "png"),
177 + let image = NSImage(contentsOfFile: path) else { return nil }
178 + image.isTemplate = true
179 + image.size = NSSize(width: 18, height: 18)
180 + return image
181 + }
182 +}
183 +
184 +/// Menu content: running-task status, New Task, Quick Task, open, quit.
185 +struct MenuBarContent: View {
186 + @ObservedObject var store: TaskStore
187 + let environment: AppEnvironment
188 + var quickTask: () -> QuickTaskController?
189 +
190 + var body: some View {
191 + let running = store.tasks.filter { $0.status.isActive }
192 + if running.isEmpty {
193 + Text("No running tasks")
194 + } else {
195 + Text("\(running.count) running task\(running.count == 1 ? "" : "s")")
196 + ForEach(running) { task in
197 + Button("\(task.title)\(task.status.displayName)") {
198 + store.selectedID = task.id
199 + NSApp.activate(ignoringOtherApps: true)
60 200 }
61 .keyboardShortcut("n", modifiers: .command)
62 201 }
63 202 }
203 + Divider()
204 + Button("New Task") {
205 + store.newTask(
206 + model: environment.newTaskModel,
207 + safetyMode: environment.settings.settings.defaultSafetyMode
208 + )
209 + NSApp.activate(ignoringOtherApps: true)
210 + }
211 + Button("Quick Task ⌥Space") {
212 + quickTask()?.show()
213 + }
214 + Divider()
215 + Button("Open Zyquo Agent") {
216 + NSApp.activate(ignoringOtherApps: true)
217 + }
218 + Button("Quit") {
219 + NSApp.terminate(nil)
220 + }
64 221 }
65 222 }
66 223
modified Sources/ZyquoAgent/DesignSystem/AppearanceStore.swift +9 −1
@@ -89,7 +89,14 @@ final class AppearanceStore: ObservableObject {
89 89 static let fileName = "appearance.json"
90 90
91 91 @Published var themeMode: ThemeMode { didSet { save() } }
92 @Published var accent: AccentChoice { didSet { save() } }
92 + @Published var accent: AccentChoice {
93 + didSet {
94 + // Feed the design-system resolver so the static ZyquoColor.accent
95 + // token picks up the new choice on the next draw.
96 + ZyquoAccentResolver.current = accent
97 + save()
98 + }
99 + }
93 100 /// Chat body font size, clamped to the spec's 12–18pt range.
94 101 @Published var chatFontSize: Double {
95 102 didSet {
@@ -107,6 +114,7 @@ final class AppearanceStore: ObservableObject {
107 114 themeMode = stored.themeMode
108 115 accent = stored.accent
109 116 chatFontSize = stored.chatFontSize
117 + ZyquoAccentResolver.current = stored.accent
110 118 }
111 119
112 120 /// Current accent color resolved for the active appearance.
modified Sources/ZyquoAgent/DesignSystem/ZyquoTheme.swift +23 −4
@@ -25,10 +25,21 @@ enum ZyquoColor {
25 25 static let surface = dynamic(light: 0xFFFFFF, dark: 0x1E1B2A)
26 26 /// Hover states, code/terminal block background.
27 27 static let surfaceSecondary = dynamic(light: 0xF4F2F8, dark: 0x262233)
28 /// Primary actions, selection, links, run button (confident violet).
29 static let accent = dynamic(light: 0x7A5AF0, dark: 0x9B82F6)
30 /// Selected task rows, user bubble tint.
31 static let accentSubtle = dynamic(light: 0xEFEBFD, dark: 0x2E2749)
28 + /// Primary actions, selection, links, run button (confident violet by
29 + /// default). The dynamic provider reads the CURRENT accent choice from
30 + /// ZyquoAccentResolver at draw time, so the whole token API stays static
31 + /// while Settings › Appearance swaps the accent live.
32 + static let accent = Color(nsColor: NSColor(name: nil) { appearance in
33 + let (light, dark) = ZyquoAccentResolver.current.accentHex
34 + let hex = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ? dark : light
35 + return NSColor(hex: hex)
36 + })
37 + /// Selected task rows, user bubble tint (follows the accent choice).
38 + static let accentSubtle = Color(nsColor: NSColor(name: nil) { appearance in
39 + let (light, dark) = ZyquoAccentResolver.current.subtleHex
40 + let hex = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ? dark : light
41 + return NSColor(hex: hex)
42 + })
32 43 static let textPrimary = dynamic(light: 0x1B1A20, dark: 0xEAE8F0)
33 44 static let textSecondary = dynamic(light: 0x6E6B78, dark: 0x9C98AA)
34 45 static let textTertiary = dynamic(light: 0xA09DAC, dark: 0x6B6878)
@@ -48,6 +59,14 @@ enum ZyquoColor {
48 59 }
49 60 }
50 61
62 +/// Holds the accent choice ZyquoColor's dynamic providers read at draw time.
63 +/// Written only by AppearanceStore on the main thread (init + user change);
64 +/// scene roots re-identify on accent change so every view redraws with the
65 +/// new value immediately.
66 +enum ZyquoAccentResolver {
67 + static var current: AccentChoice = .violet
68 +}
69 +
51 70 extension NSColor {
52 71 /// 0xRRGGBB → NSColor (sRGB).
53 72 convenience init(hex: UInt32) {
modified Sources/ZyquoAgent/Execution/PolicyEngine.swift +38 −0
@@ -464,6 +464,44 @@ enum ShellCommandAnalyzer {
464 464 "chmod", "chown", "chflags", "truncate", "dd", "rsync", "unzip", "tar"
465 465 ]
466 466
467 + // MARK: Built-in pattern descriptions (Settings › Safety, read-only)
468 +
469 + /// Human-readable descriptions of the built-in hard-deny patterns —
470 + /// actions that never run, in any mode. Display-only mirror of the
471 + /// classification logic above; the logic itself is the source of truth.
472 + static let hardDenyDescriptions: [String] = [
473 + "Fork bombs (`:(){ :|:& };:` and renamed variants)",
474 + "Filesystem creation — `mkfs*`, `newfs_apfs`, `newfs_hfs` (destroys the target volume)",
475 + "`diskutil erase* / partitionDisk / zeroDisk / reformat / apfs delete…`",
476 + "`dd` writing directly to a device node (`of=/dev/…`)",
477 + "`rm` targeting `/`, `/*`, or the entire home directory",
478 + "Any write into `/System` (protected by System Integrity Protection)",
479 + "Commands matching one of your deny rules",
480 + ]
481 +
482 + /// Human-readable descriptions of the always-ask circuit breakers —
483 + /// actions that require approval in EVERY mode, including Autonomous;
484 + /// remembered allow rules can never override them.
485 + static let alwaysAskDescriptions: [String] = [
486 + "`sudo` / `doas` — elevated privileges are never run silently",
487 + "`shutdown`, `reboot`, `halt`",
488 + "`kill`, `pkill`, `killall` — terminating processes",
489 + "`launchctl` — modifying launchd services",
490 + "`systemsetup`, `csrutil`, `nvram`, `spctl` — system-level configuration",
491 + "`security` — keychain access",
492 + "`defaults write` / `defaults delete` — preference changes",
493 + "`installer`, `softwareupdate --install` — system-wide installs",
494 + "`git push --force` — rewriting remote history",
495 + "AppleScript requesting administrator privileges",
496 + "Recursive `chmod`/`chown`/`chflags` outside the workspace",
497 + "`rm -rf` (or any deletion) on paths outside the task workspace",
498 + "`mv`/`cp` whose destination escapes the workspace or targets a system path",
499 + "Output redirection (`>`/`>>`) to files outside the workspace or with unresolvable variables",
500 + "Network downloads piped into an interpreter (`curl … | sh`)",
501 + "`find -exec rm/mv/chmod/chown/shred` — mass file modification",
502 + "Any file read or write outside the task workspace (FileTools)",
503 + ]
504 +
467 505 // MARK: Entry point
468 506
469 507 static func analyze(
modified Sources/ZyquoAgent/Models/AgentTask.swift +3 −0
@@ -85,6 +85,9 @@ struct AgentTask: Codable, Identifiable, Sendable {
85 85 var modelID: String
86 86 var providerID: ProviderID
87 87 var safetyMode: SafetyMode = .guarded
88 + /// Active persona (Phase 6): its system-prompt addition is appended to
89 + /// the agent system prompt on every run of this task; nil = none.
90 + var personaID: UUID?
88 91 /// Path of the task's workspace directory; nil until the first run
89 92 /// creates it.
90 93 var workspacePath: String?
modified Sources/ZyquoAgent/Models/ChatParameters.swift +7 −1
@@ -27,6 +27,8 @@ struct ChatParameters: Codable, Hashable {
27 27 }
28 28
29 29 /// A reusable configuration: system prompt + preferred model + parameters.
30 +/// Zyquo Agent adds `safetyMode` — the safety default a persona applies to
31 +/// new tasks (nil = keep the app default).
30 32 struct Persona: Codable, Identifiable, Hashable {
31 33 let id: UUID
32 34 var name: String
@@ -36,6 +38,8 @@ struct Persona: Codable, Identifiable, Hashable {
36 38 var modelID: String?
37 39 var provider: ProviderID?
38 40 var parameters: ChatParameters
41 + /// Default safety mode applied when a new task adopts this persona.
42 + var safetyMode: SafetyMode?
39 43
40 44 init(
41 45 id: UUID = UUID(),
@@ -44,7 +48,8 @@ struct Persona: Codable, Identifiable, Hashable {
44 48 systemPrompt: String,
45 49 modelID: String? = nil,
46 50 provider: ProviderID? = nil,
47 parameters: ChatParameters = ChatParameters()
51 + parameters: ChatParameters = ChatParameters(),
52 + safetyMode: SafetyMode? = nil
48 53 ) {
49 54 self.id = id
50 55 self.name = name
@@ -53,5 +58,6 @@ struct Persona: Codable, Identifiable, Hashable {
53 58 self.modelID = modelID
54 59 self.provider = provider
55 60 self.parameters = parameters
61 + self.safetyMode = safetyMode
56 62 }
57 63 }
added Sources/ZyquoAgent/Models/TaskTemplate.swift +310 −0
@@ -0,0 +1,310 @@
1 +//
2 +// TaskTemplate.swift
3 +// Zyquo Agent
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Ready-made agent task templates (Phase 6): a title, category, prompt with
9 +// {{variable}} placeholders, and a suggested safety mode. The built-in
10 +// library ships ≥25 genuinely useful tasks; user templates layer on top
11 +// (TemplateStore). Using a template with variables opens the fill-in sheet.
12 +//
13 +
14 +import Foundation
15 +
16 +/// Template grouping shown in the browser and the command palette.
17 +enum TemplateCategory: String, Codable, CaseIterable, Identifiable {
18 + case filesAndFolders
19 + case development
20 + case automation
21 + case data
22 + case systemInfo
23 + case writing
24 +
25 + var id: String { rawValue }
26 +
27 + var displayName: String {
28 + switch self {
29 + case .filesAndFolders: return "Files & Folders"
30 + case .development: return "Development"
31 + case .automation: return "Automation (AppleScript)"
32 + case .data: return "Data"
33 + case .systemInfo: return "System Info"
34 + case .writing: return "Writing"
35 + }
36 + }
37 +
38 + var symbolName: String {
39 + switch self {
40 + case .filesAndFolders: return "folder"
41 + case .development: return "chevron.left.forwardslash.chevron.right"
42 + case .automation: return "applescript"
43 + case .data: return "tablecells"
44 + case .systemInfo: return "cpu"
45 + case .writing: return "text.quote"
46 + }
47 + }
48 +}
49 +
50 +/// One reusable agent task. `prompt` may contain `{{variable}}` placeholders
51 +/// filled in by the user before the task is created.
52 +struct TaskTemplate: Codable, Identifiable, Hashable {
53 + var id: UUID = UUID()
54 + var title: String
55 + var category: TemplateCategory
56 + /// SF Symbol shown next to the title (defaults to the category glyph).
57 + var symbolName: String?
58 + var prompt: String
59 + var suggestedSafetyMode: SafetyMode = .guarded
60 + /// Built-ins are read-only; user templates are editable/deletable.
61 + var isBuiltIn: Bool = false
62 +
63 + var displaySymbol: String { symbolName ?? category.symbolName }
64 +
65 + /// Distinct `{{variable}}` names in declaration order.
66 + var variables: [String] {
67 + Self.variables(in: prompt)
68 + }
69 +
70 + /// Extracts distinct `{{name}}` placeholders from a prompt.
71 + static func variables(in prompt: String) -> [String] {
72 + var names: [String] = []
73 + var rest = Substring(prompt)
74 + while let open = rest.range(of: "{{"), let close = rest[open.upperBound...].range(of: "}}") {
75 + let name = rest[open.upperBound..<close.lowerBound]
76 + .trimmingCharacters(in: .whitespaces)
77 + if !name.isEmpty, !names.contains(name) {
78 + names.append(name)
79 + }
80 + rest = rest[close.upperBound...]
81 + }
82 + return names
83 + }
84 +
85 + /// The prompt with every `{{variable}}` replaced by its filled value.
86 + func renderedPrompt(values: [String: String]) -> String {
87 + var rendered = prompt
88 + for (name, value) in values {
89 + rendered = rendered.replacingOccurrences(of: "{{\(name)}}", with: value)
90 + rendered = rendered.replacingOccurrences(of: "{{ \(name) }}", with: value)
91 + }
92 + return rendered
93 + }
94 +}
95 +
96 +// MARK: - Built-in library (≥25 templates)
97 +
98 +enum TaskTemplateLibrary {
99 + static let builtIn: [TaskTemplate] = [
100 + // ---- Files & Folders (6) ---------------------------------------
101 + TaskTemplate(
102 + title: "Organize my Downloads folder",
103 + category: .filesAndFolders,
104 + symbolName: "folder.badge.gearshape",
105 + prompt: "Look at my Downloads folder (~/Downloads), group the files by type into subfolders (Images, Documents, Archives, Installers, Audio, Video, Other), move them accordingly, and show me a summary table of what you moved. Do not delete anything.",
106 + isBuiltIn: true
107 + ),
108 + TaskTemplate(
109 + title: "Batch-rename files by pattern",
110 + category: .filesAndFolders,
111 + symbolName: "textformat.abc.dottedunderline",
112 + prompt: "Rename all files in {{folder}} to a consistent kebab-case pattern with a zero-padded numeric suffix ({{prefix}}-01, {{prefix}}-02, …), keeping extensions, and list the before → after mapping.",
113 + isBuiltIn: true
114 + ),
115 + TaskTemplate(
116 + title: "Find my largest files",
117 + category: .filesAndFolders,
118 + prompt: "Find the 20 largest files under {{folder}} (skip hidden files and app bundles), and present them as a table with size, path, and last-modified date. Do not modify anything.",
119 + isBuiltIn: true
120 + ),
121 + TaskTemplate(
122 + title: "Deduplicate a folder",
123 + category: .filesAndFolders,
124 + symbolName: "doc.on.doc",
125 + prompt: "Scan {{folder}} for duplicate files (same content, compare by checksum). Report every duplicate group with paths and sizes, then move the redundant copies (keep the oldest of each group) into a 'Duplicates' subfolder — never delete outright.",
126 + isBuiltIn: true
127 + ),
128 + TaskTemplate(
129 + title: "Archive old files",
130 + category: .filesAndFolders,
131 + symbolName: "archivebox",
132 + prompt: "In {{folder}}, find files not modified in the last {{months}} months, move them into an 'Archive-{{months}}mo' subfolder preserving their relative structure, then zip that subfolder and report the space it occupies.",
133 + isBuiltIn: true
134 + ),
135 + TaskTemplate(
136 + title: "Build a folder structure from a spec",
137 + category: .filesAndFolders,
138 + symbolName: "folder.badge.plus",
139 + prompt: "Create this folder structure in the workspace and put a short README.md in each leaf folder describing its purpose:\n\n{{structure}}",
140 + isBuiltIn: true
141 + ),
142 +
143 + // ---- Development (6) --------------------------------------------
144 + TaskTemplate(
145 + title: "Set up a Python project and run the tests",
146 + category: .development,
147 + prompt: "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.",
148 + isBuiltIn: true
149 + ),
150 + TaskTemplate(
151 + title: "Scaffold a Node.js CLI tool",
152 + category: .development,
153 + symbolName: "terminal",
154 + prompt: "Scaffold a Node.js command-line tool named {{name}} in the workspace: package.json with a bin entry, a src/index.js implementing {{description}}, and a README. Run it once with --help to verify it works.",
155 + isBuiltIn: true
156 + ),
157 + TaskTemplate(
158 + title: "Initialize a git repository with hygiene files",
159 + category: .development,
160 + symbolName: "arrow.triangle.branch",
161 + prompt: "Initialize a git repository in the workspace with a sensible .gitignore for {{language}}, a README.md skeleton, an MIT LICENSE with the current year, and make the initial commit. Show the resulting git log.",
162 + isBuiltIn: true
163 + ),
164 + TaskTemplate(
165 + title: "Explain and fix a failing script",
166 + category: .development,
167 + symbolName: "ladybug",
168 + prompt: "Here is a script that fails. Save it in the workspace, run it, diagnose the failure from the actual output, fix it, re-run to verify, and explain the root cause:\n\n{{script}}",
169 + isBuiltIn: true
170 + ),
171 + TaskTemplate(
172 + title: "Write a script to automate a chore",
173 + category: .development,
174 + symbolName: "wand.and.stars",
175 + prompt: "Write a well-commented shell script in the workspace that {{chore}}. Test it against sample data you create in the workspace first, show the output, and explain how to use it.",
176 + isBuiltIn: true
177 + ),
178 + TaskTemplate(
179 + title: "Profile a directory's code statistics",
180 + category: .development,
181 + symbolName: "chart.bar",
182 + prompt: "Analyze the source code under {{folder}}: count files and lines per language/extension, find the 10 longest files, and summarize the project layout in a short report saved as report.md in the workspace.",
183 + isBuiltIn: true
184 + ),
185 +
186 + // ---- Automation (AppleScript) (5) --------------------------------
187 + TaskTemplate(
188 + title: "Export my Notes to Markdown",
189 + category: .automation,
190 + symbolName: "note.text",
191 + prompt: "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.",
192 + isBuiltIn: true
193 + ),
194 + TaskTemplate(
195 + title: "Create a reminder",
196 + category: .automation,
197 + symbolName: "checklist",
198 + prompt: "Use AppleScript to create a reminder in the Reminders app titled \"{{title}}\" due {{due}}. Confirm it was created by reading it back.",
199 + isBuiltIn: true
200 + ),
201 + TaskTemplate(
202 + title: "List today's calendar events",
203 + category: .automation,
204 + symbolName: "calendar",
205 + prompt: "Use AppleScript to read today's events from the Calendar app and present them as a Markdown agenda (time, title, calendar name). Save it as agenda.md in the workspace.",
206 + isBuiltIn: true
207 + ),
208 + TaskTemplate(
209 + title: "Tidy my Desktop into a dated folder",
210 + category: .automation,
211 + symbolName: "menubar.dock.rectangle",
212 + prompt: "Use Finder automation (AppleScript or shell) to move everything currently on my Desktop into a new folder named 'Desktop {{date}}' inside ~/Documents, then list what was moved.",
213 + isBuiltIn: true
214 + ),
215 + TaskTemplate(
216 + title: "Draft an email in Mail",
217 + category: .automation,
218 + symbolName: "envelope",
219 + prompt: "Use AppleScript to create a DRAFT (do not send) in Apple Mail addressed to {{recipient}} with subject \"{{subject}}\". Write the body from these points: {{points}}. Leave it open for my review.",
220 + isBuiltIn: true
221 + ),
222 +
223 + // ---- Data (4) -----------------------------------------------------
224 + TaskTemplate(
225 + title: "Parse a CSV and summarize it",
226 + category: .data,
227 + prompt: "Read the CSV file at {{path}}, describe its columns and row count, compute basic statistics for the numeric columns (min/max/mean), surface anything anomalous, and save the summary as summary.md in the workspace.",
228 + isBuiltIn: true
229 + ),
230 + TaskTemplate(
231 + title: "Convert JSON to CSV",
232 + category: .data,
233 + symbolName: "arrow.left.arrow.right",
234 + prompt: "Read the JSON file at {{path}}, flatten its records sensibly, write them as a CSV in the workspace, and show me the header plus the first 5 rows.",
235 + isBuiltIn: true
236 + ),
237 + TaskTemplate(
238 + title: "Aggregate a log file",
239 + category: .data,
240 + symbolName: "doc.plaintext",
241 + prompt: "Analyze the log file at {{path}}: count entries per severity level, extract the 10 most frequent error messages with counts, note the time range covered, and write findings.md in the workspace.",
242 + isBuiltIn: true
243 + ),
244 + TaskTemplate(
245 + title: "Diff two files and explain the changes",
246 + category: .data,
247 + symbolName: "plus.forwardslash.minus",
248 + prompt: "Compare {{fileA}} and {{fileB}} with diff, then explain the meaningful differences in plain language, grouped by theme, and save the annotated diff in the workspace.",
249 + isBuiltIn: true
250 + ),
251 +
252 + // ---- System Info (4) -----------------------------------------------
253 + TaskTemplate(
254 + title: "Storage health report",
255 + category: .systemInfo,
256 + symbolName: "internaldrive",
257 + prompt: "Produce a storage report for this Mac: total/used/free disk space, the 10 largest folders in my home directory (one level deep), and cache folders that look safely cleanable. Report only — do not delete anything.",
258 + isBuiltIn: true
259 + ),
260 + TaskTemplate(
261 + title: "Snapshot my Mac's configuration",
262 + category: .systemInfo,
263 + prompt: "Collect a configuration snapshot: macOS version, hardware model, CPU/RAM, uptime, network interfaces with IPs, and installed developer toolchains (git, node, python3, swift — with versions). Save it as system-snapshot.md in the workspace.",
264 + isBuiltIn: true
265 + ),
266 + TaskTemplate(
267 + title: "What is using my resources right now?",
268 + category: .systemInfo,
269 + symbolName: "gauge.with.needle",
270 + prompt: "Show the 10 processes using the most CPU and the 10 using the most memory right now, with a one-line interpretation of anything unusual. Read-only — do not kill anything.",
271 + isBuiltIn: true
272 + ),
273 + TaskTemplate(
274 + title: "Audit my login items and launch agents",
275 + category: .systemInfo,
276 + symbolName: "power",
277 + prompt: "List my user LaunchAgents (~/Library/LaunchAgents) and the system ones, with each plist's program and schedule, and flag anything that looks like abandoned software. Read-only — change nothing.",
278 + isBuiltIn: true
279 + ),
280 +
281 + // ---- Writing (4) ----------------------------------------------------
282 + TaskTemplate(
283 + title: "Summarize a document",
284 + category: .writing,
285 + prompt: "Read the document at {{path}} and write a structured summary (TL;DR, key points, open questions) as summary.md in the workspace, keeping it under 400 words.",
286 + isBuiltIn: true
287 + ),
288 + TaskTemplate(
289 + title: "Draft a README for a project",
290 + category: .writing,
291 + symbolName: "book",
292 + prompt: "Inspect the project at {{folder}} (layout, manifest files, entry points) and draft a complete README.md in the workspace: what it is, how to install, how to run, and project structure.",
293 + isBuiltIn: true
294 + ),
295 + TaskTemplate(
296 + title: "Turn rough notes into a document",
297 + category: .writing,
298 + symbolName: "square.and.pencil",
299 + prompt: "Turn these rough notes into a well-structured Markdown document with headings, saved as {{filename}}.md in the workspace:\n\n{{notes}}",
300 + isBuiltIn: true
301 + ),
302 + TaskTemplate(
303 + title: "Weekly review from my file activity",
304 + category: .writing,
305 + symbolName: "calendar.badge.clock",
306 + prompt: "Find files under {{folder}} modified in the last 7 days, group them by project/folder, and draft a short weekly review (what moved, what looks stalled) as weekly-review.md in the workspace.",
307 + isBuiltIn: true
308 + ),
309 + ]
310 +}
added Sources/ZyquoAgent/Services/TaskTranscriptExporter.swift +145 −0
@@ -0,0 +1,145 @@
1 +//
2 +// TaskTranscriptExporter.swift
3 +// Zyquo Agent
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Exports a task's transcript (prompts, run steps with tool calls/results,
9 +// final answers) as Markdown or PDF via NSSavePanel. The PDF path stays
10 +// deliberately simple: the Markdown text set on an off-screen NSTextView,
11 +// rendered with dataWithPDF (standard AppKit, no print dialog).
12 +//
13 +
14 +import AppKit
15 +import Foundation
16 +import UniformTypeIdentifiers
17 +
18 +@MainActor
19 +enum TaskTranscriptExporter {
20 + enum Format {
21 + case markdown
22 + case pdf
23 + }
24 +
25 + /// Presents the save panel and writes the transcript in the given format.
26 + static func presentSavePanel(for task: AgentTask, format: Format) {
27 + let panel = NSSavePanel()
28 + switch format {
29 + case .markdown:
30 + panel.allowedContentTypes = [.plainText]
31 + panel.nameFieldStringValue = "\(WorkspaceManager.slug(from: task.title)).md"
32 + case .pdf:
33 + panel.allowedContentTypes = [.pdf]
34 + panel.nameFieldStringValue = "\(WorkspaceManager.slug(from: task.title)).pdf"
35 + }
36 + let markdown = markdown(for: task)
37 + panel.begin { response in
38 + guard response == .OK, let url = panel.url else { return }
39 + Task { @MainActor in
40 + switch format {
41 + case .markdown:
42 + try? markdown.write(to: url, atomically: true, encoding: .utf8)
43 + case .pdf:
44 + if let data = pdfData(from: markdown, title: task.title) {
45 + try? data.write(to: url, options: .atomic)
46 + }
47 + }
48 + }
49 + }
50 + }
51 +
52 + // MARK: - Markdown
53 +
54 + /// The task's history (prompts, steps, answers) as Markdown.
55 + static func markdown(for task: AgentTask) -> String {
56 + var lines: [String] = ["# \(task.title)", ""]
57 + lines.append("Model: `\(task.modelID)` (\(task.providerID.displayName)) · Safety: \(task.safetyMode.displayName)")
58 + if let workspace = task.workspacePath {
59 + lines.append("Workspace: `\(workspace)`")
60 + }
61 + lines.append("")
62 + for message in task.messages {
63 + switch message.kind {
64 + case .user:
65 + lines.append("## 🧑 Prompt")
66 + lines.append(message.text)
67 + case .agentRun:
68 + lines.append("## 🤖 Run")
69 + for step in message.steps ?? [] {
70 + lines.append("### Step \(step.index)")
71 + if !step.text.isEmpty { lines.append(step.text) }
72 + for invocation in step.toolInvocations {
73 + lines.append("```")
74 + lines.append("\(invocation.call.name): \(invocation.call.argumentsJSON)")
75 + if let result = invocation.result {
76 + lines.append("→ \(result.content)")
77 + }
78 + lines.append("```")
79 + }
80 + }
81 + if !message.text.isEmpty {
82 + lines.append("### Result")
83 + lines.append(message.text)
84 + }
85 + }
86 + lines.append("")
87 + }
88 + return lines.joined(separator: "\n")
89 + }
90 +
91 + // MARK: - PDF
92 +
93 + /// Renders the Markdown text into PDF data via an off-screen NSTextView.
94 + /// Headings get a heavier system font; fenced blocks stay monospaced.
95 + static func pdfData(from markdown: String, title: String) -> Data? {
96 + let attributed = attributedTranscript(from: markdown)
97 + let pageWidth: CGFloat = 612 // US Letter, points
98 + let inset: CGFloat = 48
99 + let textView = NSTextView(frame: NSRect(x: 0, y: 0, width: pageWidth, height: 10))
100 + textView.textContainerInset = NSSize(width: inset, height: inset)
101 + textView.isEditable = false
102 + textView.textStorage?.setAttributedString(attributed)
103 + guard let container = textView.textContainer, let manager = textView.layoutManager else {
104 + return nil
105 + }
106 + manager.ensureLayout(for: container)
107 + let used = manager.usedRect(for: container)
108 + textView.frame = NSRect(x: 0, y: 0, width: pageWidth, height: used.height + inset * 2)
109 + return textView.dataWithPDF(inside: textView.bounds)
110 + }
111 +
112 + /// Simple line-based styling: #/##/### headings, ``` code fences, body.
113 + private static func attributedTranscript(from markdown: String) -> NSAttributedString {
114 + let result = NSMutableAttributedString()
115 + let bodyFont = NSFont.systemFont(ofSize: 11)
116 + let codeFont = NSFont.monospacedSystemFont(ofSize: 9.5, weight: .regular)
117 + var inCodeFence = false
118 +
119 + for line in markdown.components(separatedBy: "\n") {
120 + var font = bodyFont
121 + var text = line
122 + if line.hasPrefix("```") {
123 + inCodeFence.toggle()
124 + continue
125 + }
126 + if inCodeFence {
127 + font = codeFont
128 + } else if line.hasPrefix("### ") {
129 + font = NSFont.systemFont(ofSize: 12, weight: .semibold)
130 + text = String(line.dropFirst(4))
131 + } else if line.hasPrefix("## ") {
132 + font = NSFont.systemFont(ofSize: 14, weight: .semibold)
133 + text = String(line.dropFirst(3))
134 + } else if line.hasPrefix("# ") {
135 + font = NSFont.systemFont(ofSize: 18, weight: .bold)
136 + text = String(line.dropFirst(2))
137 + }
138 + result.append(NSAttributedString(
139 + string: text + "\n",
140 + attributes: [.font: font, .foregroundColor: NSColor.textColor]
141 + ))
142 + }
143 + return result
144 + }
145 +}
added Sources/ZyquoAgent/ViewModels/AgentSettingsStore.swift +108 −0
@@ -0,0 +1,108 @@
1 +//
2 +// AgentSettingsStore.swift
3 +// Zyquo Agent
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// App-wide agent preferences (Settings › Agent + Safety), persisted to
9 +// AgentSettings.json via PersistenceService and folded into the
10 +// AgentConfiguration each run starts with (RunController.start).
11 +//
12 +// Two Safety settings are persisted and surfaced but not yet enforced by
13 +// the PolicyEngine (it has no hook for them; documented gap):
14 +// - requireApprovalForAppleScript: the engine already asks in Manual and
15 +// Guarded; this toggle is meant to force asking in Autonomous too.
16 +// - workspaceEscapePolicy: the engine currently ALWAYS asks on any file
17 +// access outside the workspace (the strict default); the "deny" choice
18 +// is stored for a future engine hook.
19 +//
20 +
21 +import Combine
22 +import Foundation
23 +
24 +/// How file access outside the task workspace should be handled.
25 +enum WorkspaceEscapePolicy: String, Codable, CaseIterable, Identifiable {
26 + /// Every outside-workspace read/write asks for approval (built-in default).
27 + case alwaysAsk
28 + /// Outside-workspace access is denied without asking.
29 + case deny
30 +
31 + var id: String { rawValue }
32 +
33 + var displayName: String {
34 + switch self {
35 + case .alwaysAsk: return "Always ask"
36 + case .deny: return "Deny without asking"
37 + }
38 + }
39 +}
40 +
41 +/// The persisted settings document (AgentSettings.json).
42 +struct AgentSettings: Codable {
43 + // Agent tab
44 + var maxSteps: Int = LoopGuardConfiguration.default.maxSteps
45 + var tokenBudget: Int = LoopGuardConfiguration.default.tokenBudget
46 + /// Wall-clock budget in minutes.
47 + var timeBudgetMinutes: Int = Int(LoopGuardConfiguration.default.wallClockBudget / 60)
48 + /// Per-command timeout in seconds.
49 + var perCommandTimeoutSeconds: Int = Int(ExecutionConfiguration.default.defaultTimeout)
50 + var parallelToolCalls: Bool = false
51 + /// Context-window fraction that triggers memory compaction.
52 + var compactionThreshold: Double = MemoryConfiguration.default.compactionThreshold
53 +
54 + // Safety tab
55 + var defaultSafetyMode: SafetyMode = .guarded
56 + var requireApprovalForAppleScript: Bool = true
57 + var workspaceEscapePolicy: WorkspaceEscapePolicy = .alwaysAsk
58 +
59 + // Models tab: persisted default agent model ("provider|modelID").
60 + var defaultAgentModelKey: String?
61 +}
62 +
63 +/// Observable wrapper the Settings tabs bind to; every change persists.
64 +@MainActor
65 +final class AgentSettingsStore: ObservableObject {
66 + static let fileName = "AgentSettings.json"
67 +
68 + @Published var settings: AgentSettings {
69 + didSet { persistence.save(settings, to: Self.fileName) }
70 + }
71 +
72 + private let persistence: PersistenceService
73 +
74 + init(persistence: PersistenceService = .shared) {
75 + self.persistence = persistence
76 + self.settings = persistence.load(AgentSettings.self, from: Self.fileName) ?? AgentSettings()
77 + }
78 +
79 + /// The engine configuration a new run starts with, built from the
80 + /// persisted settings (clamped to sane bounds).
81 + func agentConfiguration(personaAddendum: String? = nil) -> AgentConfiguration {
82 + var configuration = AgentConfiguration.default
83 + configuration.loopGuard.maxSteps = max(1, settings.maxSteps)
84 + configuration.loopGuard.tokenBudget = max(10_000, settings.tokenBudget)
85 + configuration.loopGuard.wallClockBudget = TimeInterval(max(1, settings.timeBudgetMinutes)) * 60
86 + configuration.memory.compactionThreshold = min(0.95, max(0.5, settings.compactionThreshold))
87 + configuration.parallelToolCalls = settings.parallelToolCalls
88 + configuration.perCommandTimeout = TimeInterval(max(5, settings.perCommandTimeoutSeconds))
89 + configuration.personaAddendum = personaAddendum
90 + return configuration
91 + }
92 +
93 + /// The persisted default agent model resolved against the catalog.
94 + func defaultAgentModel(in catalog: ModelCatalog) -> AIModel? {
95 + if let key = settings.defaultAgentModelKey {
96 + let parts = key.split(separator: "|", maxSplits: 1)
97 + if parts.count == 2, let provider = ProviderID(rawValue: String(parts[0])),
98 + let model = catalog.model(id: String(parts[1]), provider: provider) {
99 + return model
100 + }
101 + }
102 + return catalog.defaultAgentModel
103 + }
104 +
105 + func setDefaultAgentModel(_ model: AIModel) {
106 + settings.defaultAgentModelKey = "\(model.provider.rawValue)|\(model.id)"
107 + }
108 +}
added Sources/ZyquoAgent/ViewModels/AppUIState.swift +33 −0
@@ -0,0 +1,33 @@
1 +//
2 +// AppUIState.swift
3 +// Zyquo Agent
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Cross-window UI signals: the app-level command set (⌘K palette, ⌘⇧A
9 +// audit log, template browser) publishes here and the views that own the
10 +// corresponding local state react. Counters are used for one-shot requests
11 +// so repeated invocations always fire onChange.
12 +//
13 +
14 +import Combine
15 +import Foundation
16 +
17 +@MainActor
18 +final class AppUIState: ObservableObject {
19 + /// ⌘K command palette visibility (overlay on the main window).
20 + @Published var showCommandPalette = false
21 + /// Template browser sheet visibility (empty state / palette / menu).
22 + @Published var showTemplateBrowser = false
23 + /// One-shot request: open the activity drawer on the Audit Log tab (⌘⇧A).
24 + @Published var auditLogRequest = 0
25 + /// One-shot request: toggle the activity drawer (palette action).
26 + @Published var drawerToggleRequest = 0
27 + /// One-shot request: toggle the plan panel (palette action).
28 + @Published var planToggleRequest = 0
29 +
30 + func requestAuditLog() { auditLogRequest &+= 1 }
31 + func requestDrawerToggle() { drawerToggleRequest &+= 1 }
32 + func requestPlanToggle() { planToggleRequest &+= 1 }
33 +}
added Sources/ZyquoAgent/ViewModels/PersonaStore.swift +51 −0
@@ -0,0 +1,51 @@
1 +//
2 +// PersonaStore.swift
3 +// Zyquo Agent
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Personas (Phase 6): system-prompt addition + preferred agent model +
9 +// default safety mode, persisted to personas.json. A task adopting a
10 +// persona gets its addendum appended to the agent system prompt on every
11 +// run (AgentConfiguration.personaAddendum).
12 +//
13 +
14 +import Combine
15 +import Foundation
16 +
17 +@MainActor
18 +final class PersonaStore: ObservableObject {
19 + static let fileName = "personas.json"
20 +
21 + @Published private(set) var personas: [Persona] {
22 + didSet { persistence.save(personas, to: Self.fileName) }
23 + }
24 +
25 + private let persistence: PersistenceService
26 +
27 + init(persistence: PersistenceService = .shared) {
28 + self.persistence = persistence
29 + self.personas = persistence.load([Persona].self, from: Self.fileName) ?? []
30 + }
31 +
32 + func persona(id: UUID?) -> Persona? {
33 + guard let id else { return nil }
34 + return personas.first { $0.id == id }
35 + }
36 +
37 + // MARK: - CRUD
38 +
39 + func add(_ persona: Persona) {
40 + personas.append(persona)
41 + }
42 +
43 + func update(_ persona: Persona) {
44 + guard let index = personas.firstIndex(where: { $0.id == persona.id }) else { return }
45 + personas[index] = persona
46 + }
47 +
48 + func delete(_ id: Persona.ID) {
49 + personas.removeAll { $0.id == id }
50 + }
51 +}
modified Sources/ZyquoAgent/ViewModels/RunController.swift +74 −7
@@ -149,7 +149,7 @@ final class RunController: ObservableObject {
149 149 @Published private(set) var tokensUsed = 0
150 150 @Published private(set) var stepsUsed = 0
151 151 @Published private(set) var runStartedAt: Date?
152 let loopGuardConfiguration = LoopGuardConfiguration.default
152 + @Published private(set) var loopGuardConfiguration = LoopGuardConfiguration.default
153 153
154 154 // Drawer feeds
155 155 @Published private(set) var terminalLines: [TerminalLine] = []
@@ -162,20 +162,32 @@ final class RunController: ObservableObject {
162 162 let taskID: AgentTask.ID
163 163 private let store: TaskStore
164 164 private let policyPersistence: PersistenceService
165 + /// App-wide agent settings (budgets, timeouts, persona-independent
166 + /// tunables). Nil in headless smoke tests → AgentConfiguration.default.
167 + private weak var settings: AgentSettingsStore?
165 168
166 169 private var loop: AgentLoop?
167 170 private var policy: PolicyEngine?
168 171 private var audit: AuditLog?
169 172 private var workspace: WorkspaceManager?
170 173 private var consumeTask: Task<Void, Never>?
174 + /// Retained for the post-run auto-title call (app runs only).
175 + private var titleContext: (model: AIModel, client: any ProviderClient, apiKey: String)?
176 + private var titleTask: Task<Void, Never>?
171 177
172 178 /// Terminal feed cap — append-only ring so hours-long runs stay light.
173 179 private static let terminalLineCap = 4000
174 180
175 init(taskID: AgentTask.ID, store: TaskStore, policyPersistence: PersistenceService = .shared) {
181 + init(
182 + taskID: AgentTask.ID,
183 + store: TaskStore,
184 + policyPersistence: PersistenceService = .shared,
185 + settings: AgentSettingsStore? = nil
186 + ) {
176 187 self.taskID = taskID
177 188 self.store = store
178 189 self.policyPersistence = policyPersistence
190 + self.settings = settings
179 191 if let workspaceURL = store.task(id: taskID)?.workspaceURL,
180 192 let attached = try? WorkspaceManager(existingAt: workspaceURL) {
181 193 self.workspace = attached
@@ -195,6 +207,7 @@ final class RunController: ObservableObject {
195 207 func start(
196 208 prompt: String,
197 209 model: AIModel,
210 + persona: Persona? = nil,
198 211 client injectedClient: (any ProviderClient)? = nil,
199 212 apiKey injectedKey: String? = nil
200 213 ) {
@@ -253,7 +266,18 @@ final class RunController: ObservableObject {
253 266 }
254 267 let policy = PolicyEngine(mode: task.safetyMode, approvals: presenter, persistence: policyPersistence)
255 268 let audit = AuditLog(fileURL: workspace.internalDirectory.appendingPathComponent("audit.jsonl"))
256 let executor = ExecutionService()
269 +
270 + // Settings › Agent budgets/tunables + the persona's prompt addendum.
271 + let personaAddendum = persona?.systemPrompt
272 + let configuration = settings?.agentConfiguration(personaAddendum: personaAddendum)
273 + ?? { var c = AgentConfiguration.default; c.personaAddendum = personaAddendum; return c }()
274 + var executionConfiguration = ExecutionConfiguration.default
275 + if let timeout = configuration.perCommandTimeout {
276 + executionConfiguration.defaultTimeout = timeout
277 + }
278 + self.loopGuardConfiguration = configuration.loopGuard
279 +
280 + let executor = ExecutionService(configuration: executionConfiguration)
257 281 let tools = ToolRegistry.standard(executor: executor)
258 282 let loop = AgentLoop(
259 283 model: model,
@@ -262,15 +286,20 @@ final class RunController: ObservableObject {
262 286 tools: tools,
263 287 policy: policy,
264 288 audit: audit,
265 workspace: workspace
289 + workspace: workspace,
290 + configuration: configuration
266 291 )
267 292 self.policy = policy
268 293 self.audit = audit
269 294 self.loop = loop
295 + // Auto-title uses the injected client only for real runs — smoke
296 + // tests inject a scripted client and must stay deterministic.
297 + self.titleContext = injectedClient == nil ? (model, client, apiKey) : nil
270 298 self.systemPromptPreview = AgentSystemPrompt.build(
271 299 workspacePath: workspace.root.path,
272 300 toolNames: tools.toolNames,
273 safetyMode: task.safetyMode
301 + safetyMode: task.safetyMode,
302 + personaAddendum: personaAddendum
274 303 )
275 304
276 305 // Reset live state.
@@ -529,6 +558,42 @@ final class RunController: ObservableObject {
529 558 guardTrip = nil
530 559 refreshWorkspaceState()
531 560 refreshAudit()
561 +
562 + if case .completed = outcome {
563 + generateTitleIfStillDefault()
564 + }
565 + }
566 +
567 + // MARK: - Auto-title
568 +
569 + /// After a successful run, if the title is still the prompt-derived
570 + /// default, fire a cheap background call (the task's own provider/model,
571 + /// ≤20 tokens) to produce a 4–6 word title. Silent failure by design.
572 + private func generateTitleIfStillDefault() {
573 + guard titleTask == nil, let context = titleContext, let task else { return }
574 + guard let firstPrompt = task.messages.first(where: { $0.kind == .user })?.text,
575 + task.title == AgentTask.title(fromPrompt: firstPrompt) else { return }
576 + let request = ChatRequest(
577 + model: context.model,
578 + systemPrompt: "You write concise task titles.",
579 + messages: [Message(
580 + role: .user,
581 + text: "Summarize this task as a 4-6 word title. Reply with the title only — no quotes, no trailing punctuation.\n\nTask: \(firstPrompt.prefix(600))"
582 + )],
583 + parameters: ChatParameters(maxTokens: 20),
584 + stream: false
585 + )
586 + let taskID = taskID
587 + let store = store
588 + titleTask = Task { [weak self] in
589 + defer { self?.titleTask = nil }
590 + guard let reply = try? await context.client.complete(request, apiKey: context.apiKey) else { return }
591 + let title = reply.text
592 + .trimmingCharacters(in: .whitespacesAndNewlines)
593 + .trimmingCharacters(in: CharacterSet(charactersIn: "\"'“”.\n"))
594 + guard !title.isEmpty, title.count <= 80, !title.contains("\n") else { return }
595 + store.rename(taskID, to: title)
596 + }
532 597 }
533 598
534 599 // MARK: - Drawer refresh
@@ -620,14 +685,16 @@ final class RunController: ObservableObject {
620 685 final class RunHub: ObservableObject {
621 686 private var controllers: [AgentTask.ID: RunController] = [:]
622 687 private let store: TaskStore
688 + private let settings: AgentSettingsStore?
623 689
624 init(store: TaskStore) {
690 + init(store: TaskStore, settings: AgentSettingsStore? = nil) {
625 691 self.store = store
692 + self.settings = settings
626 693 }
627 694
628 695 func controller(for taskID: AgentTask.ID) -> RunController {
629 696 if let existing = controllers[taskID] { return existing }
630 let controller = RunController(taskID: taskID, store: store)
697 + let controller = RunController(taskID: taskID, store: store, settings: settings)
631 698 controllers[taskID] = controller
632 699 return controller
633 700 }
modified Sources/ZyquoAgent/ViewModels/TaskStore.swift +20 −2
@@ -43,19 +43,37 @@ final class TaskStore: ObservableObject {
43 43
44 44 /// Creates a new task and selects it.
45 45 @discardableResult
46 func newTask(model: AIModel?, safetyMode: SafetyMode = .guarded) -> AgentTask {
47 let task = AgentTask(
46 + func newTask(model: AIModel?, safetyMode: SafetyMode = .guarded, personaID: UUID? = nil) -> AgentTask {
47 + var task = AgentTask(
48 48 title: "New Task",
49 49 modelID: model?.id ?? "",
50 50 providerID: model?.provider ?? .anthropic,
51 51 safetyMode: safetyMode
52 52 )
53 + task.personaID = personaID
53 54 tasks.insert(task, at: 0)
54 55 save(task)
55 56 selectedID = task.id
56 57 return task
57 58 }
58 59
60 + /// Imports task records (Settings › Advanced). Existing IDs are skipped
61 + /// so re-importing an export never duplicates; imported tasks arrive with
62 + /// any active status normalized to idle. Returns how many were added.
63 + @discardableResult
64 + func importTasks(_ imported: [AgentTask]) -> Int {
65 + var added = 0
66 + let existing = Set(tasks.map(\.id))
67 + for var task in imported where !existing.contains(task.id) {
68 + if task.status.isActive { task.status = .idle }
69 + tasks.append(task)
70 + save(task)
71 + added += 1
72 + }
73 + tasks.sort { $0.updatedAt > $1.updatedAt }
74 + return added
75 + }
76 +
59 77 func task(id: AgentTask.ID) -> AgentTask? {
60 78 tasks.first { $0.id == id }
61 79 }
added Sources/ZyquoAgent/ViewModels/TemplateStore.swift +56 −0
@@ -0,0 +1,56 @@
1 +//
2 +// TemplateStore.swift
3 +// Zyquo Agent
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Task templates: the built-in library (TaskTemplateLibrary) plus the user's
9 +// own templates, persisted to templates.json. Built-ins are read-only.
10 +//
11 +
12 +import Combine
13 +import Foundation
14 +
15 +@MainActor
16 +final class TemplateStore: ObservableObject {
17 + static let fileName = "templates.json"
18 +
19 + /// User-created templates (CRUD, persisted).
20 + @Published private(set) var userTemplates: [TaskTemplate] {
21 + didSet { persistence.save(userTemplates, to: Self.fileName) }
22 + }
23 +
24 + private let persistence: PersistenceService
25 +
26 + init(persistence: PersistenceService = .shared) {
27 + self.persistence = persistence
28 + self.userTemplates = persistence.load([TaskTemplate].self, from: Self.fileName) ?? []
29 + }
30 +
31 + /// Built-ins first (library order), then user templates.
32 + var all: [TaskTemplate] {
33 + TaskTemplateLibrary.builtIn + userTemplates
34 + }
35 +
36 + func templates(in category: TemplateCategory) -> [TaskTemplate] {
37 + all.filter { $0.category == category }
38 + }
39 +
40 + // MARK: - CRUD (user templates only)
41 +
42 + func add(_ template: TaskTemplate) {
43 + var template = template
44 + template.isBuiltIn = false
45 + userTemplates.append(template)
46 + }
47 +
48 + func update(_ template: TaskTemplate) {
49 + guard let index = userTemplates.firstIndex(where: { $0.id == template.id }) else { return }
50 + userTemplates[index] = template
51 + }
52 +
53 + func delete(_ id: TaskTemplate.ID) {
54 + userTemplates.removeAll { $0.id == id }
55 + }
56 +}
added Sources/ZyquoAgent/Views/CommandPaletteView.swift +187 −0
@@ -0,0 +1,187 @@
1 +//
2 +// CommandPaletteView.swift
3 +// Zyquo Agent
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// The ⌘K command palette: one fuzzy-filtered list over app actions
9 +// (New Task, Open Settings, toggle panels, audit log, template browser),
10 +// the task library, and every task template. Enter runs the top hit,
11 +// Esc dismisses. Rendered as an overlay card on the main window.
12 +//
13 +
14 +import SwiftUI
15 +
16 +/// One palette entry.
17 +struct PaletteItem: Identifiable {
18 + enum Kind {
19 + case action
20 + case template
21 + case task
22 + }
23 +
24 + let id: String
25 + var kind: Kind
26 + var title: String
27 + var subtitle: String?
28 + var symbolName: String
29 + var perform: () -> Void
30 +}
31 +
32 +struct CommandPaletteView: View {
33 + var items: [PaletteItem]
34 + var onDismiss: () -> Void
35 +
36 + @State private var query = ""
37 + @FocusState private var focused: Bool
38 +
39 + private var filtered: [PaletteItem] {
40 + let trimmed = query.trimmingCharacters(in: .whitespaces)
41 + guard !trimmed.isEmpty else { return items }
42 + return items
43 + .compactMap { item -> (PaletteItem, Int)? in
44 + guard let score = Self.fuzzyScore(needle: trimmed, haystack: item.title) else {
45 + if let subtitle = item.subtitle,
46 + let subScore = Self.fuzzyScore(needle: trimmed, haystack: subtitle) {
47 + return (item, subScore + 100)
48 + }
49 + return nil
50 + }
51 + return (item, score)
52 + }
53 + .sorted { $0.1 < $1.1 }
54 + .map(\.0)
55 + }
56 +
57 + var body: some View {
58 + VStack(spacing: 0) {
59 + HStack(spacing: ZyquoSpacing.xs) {
60 + Image(systemName: "command")
61 + .font(.system(size: 13))
62 + .foregroundStyle(ZyquoColor.accent)
63 + TextField("Type a command, template, or task…", text: $query)
64 + .textFieldStyle(.plain)
65 + .font(ZyquoFont.body(size: 15))
66 + .focused($focused)
67 + .onSubmit(runFirst)
68 + Text("esc")
69 + .font(ZyquoFont.code(size: 10))
70 + .foregroundStyle(ZyquoColor.textTertiary)
71 + .padding(.horizontal, ZyquoSpacing.xxs)
72 + .padding(.vertical, 1)
73 + .background(
74 + RoundedRectangle(cornerRadius: 3, style: .continuous)
75 + .fill(ZyquoColor.surfaceSecondary)
76 + )
77 + }
78 + .padding(ZyquoSpacing.sm)
79 + ZyquoHairline()
80 + ScrollView {
81 + LazyVStack(spacing: 1) {
82 + if filtered.isEmpty {
83 + Text("No matches")
84 + .font(ZyquoFont.body(size: 12.5))
85 + .foregroundStyle(ZyquoColor.textTertiary)
86 + .padding(ZyquoSpacing.sm)
87 + }
88 + ForEach(Array(filtered.prefix(40).enumerated()), id: \.element.id) { index, item in
89 + row(item, isFirst: index == 0)
90 + }
91 + }
92 + .padding(ZyquoSpacing.xxs)
93 + }
94 + .frame(maxHeight: 320)
95 + }
96 + .frame(width: 560)
97 + .background(
98 + RoundedRectangle(cornerRadius: ZyquoRadius.large, style: .continuous)
99 + .fill(ZyquoColor.surface)
100 + )
101 + .overlay(
102 + RoundedRectangle(cornerRadius: ZyquoRadius.large, style: .continuous)
103 + .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline)
104 + )
105 + .zyquoSoftShadow()
106 + .onAppear { focused = true }
107 + .onExitCommand { onDismiss() }
108 + }
109 +
110 + private func row(_ item: PaletteItem, isFirst: Bool) -> some View {
111 + Button {
112 + onDismiss()
113 + item.perform()
114 + } label: {
115 + HStack(spacing: ZyquoSpacing.xs) {
116 + Image(systemName: item.symbolName)
117 + .font(.system(size: 12))
118 + .foregroundStyle(ZyquoColor.accent)
119 + .frame(width: 18)
120 + Text(item.title)
121 + .font(ZyquoFont.body(size: 13))
122 + .foregroundStyle(ZyquoColor.textPrimary)
123 + .lineLimit(1)
124 + if let subtitle = item.subtitle {
125 + Text(subtitle)
126 + .font(ZyquoFont.caption)
127 + .foregroundStyle(ZyquoColor.textTertiary)
128 + .lineLimit(1)
129 + }
130 + Spacer(minLength: 0)
131 + ZyquoBadge(text: kindLabel(item.kind))
132 + if isFirst && !query.isEmpty {
133 + Text("↩")
134 + .font(ZyquoFont.code(size: 10))
135 + .foregroundStyle(ZyquoColor.textTertiary)
136 + }
137 + }
138 + .padding(.horizontal, ZyquoSpacing.xs)
139 + .padding(.vertical, 5)
140 + .background(
141 + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)
142 + .fill(isFirst && !query.isEmpty ? ZyquoColor.accentSubtle : .clear)
143 + )
144 + .contentShape(Rectangle())
145 + }
146 + .buttonStyle(.plain)
147 + .zyquoHoverHighlight()
148 + }
149 +
150 + private func kindLabel(_ kind: PaletteItem.Kind) -> String {
151 + switch kind {
152 + case .action: return "action"
153 + case .template: return "template"
154 + case .task: return "task"
155 + }
156 + }
157 +
158 + private func runFirst() {
159 + guard let first = filtered.first else { return }
160 + onDismiss()
161 + first.perform()
162 + }
163 +
164 + /// Case-insensitive subsequence match; lower score = tighter match
165 + /// (prefix matches beat scattered ones). Nil = no match.
166 + static func fuzzyScore(needle: String, haystack: String) -> Int? {
167 + let needleChars = Array(needle.lowercased())
168 + let haystackChars = Array(haystack.lowercased())
169 + guard !needleChars.isEmpty else { return 0 }
170 + var score = 0
171 + var haystackIndex = 0
172 + for character in needleChars {
173 + var found = false
174 + while haystackIndex < haystackChars.count {
175 + if haystackChars[haystackIndex] == character {
176 + found = true
177 + haystackIndex += 1
178 + break
179 + }
180 + score += 1
181 + haystackIndex += 1
182 + }
183 + if !found { return nil }
184 + }
185 + return score
186 + }
187 +}
modified Sources/ZyquoAgent/Views/EmptyStateView.swift +59 −0
@@ -15,10 +15,17 @@ import SwiftUI
15 15 struct AgentEmptyStateView: View {
16 16 let model: AIModel?
17 17 let safetyMode: SafetyMode
18 + /// Name of the task's active persona (nil = none / no task yet).
19 + var personaName: String?
18 20 var onSelectModel: (AIModel) -> Void
19 21 var onSelectSafetyMode: (SafetyMode) -> Void
22 + /// Called with the chosen persona (nil = none).
23 + var onSelectPersona: ((Persona?) -> Void)?
24 + var onBrowseTemplates: (() -> Void)?
20 25 var onSuggestion: (String) -> Void
21 26
27 + @EnvironmentObject private var personas: PersonaStore
28 +
22 29 private static let suggestions: [(symbol: String, title: String, prompt: String)] = [
23 30 ("folder.badge.gearshape", "Organize my Downloads folder",
24 31 "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."),
@@ -46,6 +53,24 @@ struct AgentEmptyStateView: View {
46 53 HStack(spacing: ZyquoSpacing.sm) {
47 54 ModelChipView(model: model, onSelect: onSelectModel)
48 55 SafetyModePicker(mode: safetyMode, onSelect: onSelectSafetyMode)
56 + if let onSelectPersona, !personas.personas.isEmpty {
57 + personaMenu(onSelectPersona)
58 + }
59 + }
60 + if let onBrowseTemplates {
61 + Button {
62 + onBrowseTemplates()
63 + } label: {
64 + HStack(spacing: ZyquoSpacing.xxs) {
65 + Image(systemName: "square.grid.2x2")
66 + .font(.system(size: 11))
67 + Text("Browse templates")
68 + .font(ZyquoFont.body(size: 12.5))
69 + }
70 + .foregroundStyle(ZyquoColor.accent)
71 + }
72 + .buttonStyle(.plain)
73 + .help("25+ ready-made agent tasks (⌘K)")
49 74 }
50 75 LazyVGrid(
51 76 columns: [GridItem(.flexible()), GridItem(.flexible())],
@@ -87,6 +112,40 @@ struct AgentEmptyStateView: View {
87 112 .frame(maxWidth: .infinity, maxHeight: .infinity)
88 113 .background(ZyquoColor.background)
89 114 }
115 +
116 + /// Persona chip + menu (None + every stored persona).
117 + private func personaMenu(_ onSelect: @escaping (Persona?) -> Void) -> some View {
118 + Menu {
119 + Button("None") { onSelect(nil) }
120 + Divider()
121 + ForEach(personas.personas) { persona in
122 + Button {
123 + onSelect(persona)
124 + } label: {
125 + Label(persona.name, systemImage: persona.symbolName)
126 + }
127 + }
128 + } label: {
129 + HStack(spacing: ZyquoSpacing.xxs) {
130 + Image(systemName: "person.crop.circle")
131 + .font(.system(size: 11, weight: .medium))
132 + .foregroundStyle(ZyquoColor.accent)
133 + Text(personaName ?? "Persona")
134 + .font(ZyquoFont.bodyEmphasis(size: 12.5))
135 + .foregroundStyle(personaName == nil ? ZyquoColor.textSecondary : ZyquoColor.textPrimary)
136 + .lineLimit(1)
137 + }
138 + .padding(.horizontal, ZyquoSpacing.xs)
139 + .padding(.vertical, 4)
140 + .background(
141 + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)
142 + .fill(ZyquoColor.surfaceSecondary)
143 + )
144 + }
145 + .menuStyle(.borderlessButton)
146 + .fixedSize()
147 + .help("Persona — adds a system-prompt section and can pin a model and safety default")
148 + }
90 149 }
91 150
92 151 /// The Manual / Guarded / Autonomous segmented control (header + empty state).
modified Sources/ZyquoAgent/Views/MainWindowView.swift +138 −26
@@ -7,7 +7,9 @@
7 7 //
8 8 // The command-center root: translucent 260pt sidebar (NavigationSplitView's
9 9 // native sidebar material) + the task detail. With no task selected, the
10 // "What should I do on your Mac?" hero creates one.
10 +// "What should I do on your Mac?" hero creates one. Hosts the ⌘K command
11 +// palette overlay and the template browser sheet; the root re-identifies on
12 +// accent change so every ZyquoColor.accent token redraws immediately.
11 13 //
12 14
13 15 import SwiftUI
@@ -16,40 +18,150 @@ struct MainWindowView: View {
16 18 @EnvironmentObject private var store: TaskStore
17 19 @EnvironmentObject private var catalog: ModelCatalog
18 20 @EnvironmentObject private var appearance: AppearanceStore
21 + @EnvironmentObject private var settings: AgentSettingsStore
22 + @EnvironmentObject private var templates: TemplateStore
23 + @EnvironmentObject private var personas: PersonaStore
24 + @EnvironmentObject private var uiState: AppUIState
19 25
20 26 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 )
27 + ZStack(alignment: .top) {
28 + NavigationSplitView {
29 + SidebarView()
30 + .navigationSplitViewColumnWidth(
31 + min: ZyquoMetrics.sidebarWidth,
32 + ideal: ZyquoMetrics.sidebarWidth,
33 + max: 360
34 + )
35 + } detail: {
36 + if let id = store.selectedID, store.task(id: id) != nil {
37 + TaskDetailView(taskID: id)
38 + } else {
39 + AgentEmptyStateView(
40 + model: defaultModel,
41 + safetyMode: settings.settings.defaultSafetyMode,
42 + personaName: nil,
43 + onSelectModel: { model in
44 + store.newTask(model: model, safetyMode: settings.settings.defaultSafetyMode)
45 + },
46 + onSelectSafetyMode: { mode in
47 + store.newTask(model: defaultModel, safetyMode: mode)
48 + },
49 + onSelectPersona: { persona in
50 + newTask(with: persona)
51 + },
52 + onBrowseTemplates: {
53 + uiState.showTemplateBrowser = true
54 + },
55 + onSuggestion: { prompt in
56 + store.pendingDraft = prompt
57 + store.newTask(model: defaultModel, safetyMode: settings.settings.defaultSafetyMode)
58 + }
59 + )
60 + }
61 + }
62 + if uiState.showCommandPalette {
63 + paletteOverlay
46 64 }
47 65 }
48 66 .frame(
49 67 minWidth: ZyquoMetrics.windowMinWidth,
50 68 minHeight: ZyquoMetrics.windowMinHeight
51 69 )
70 + .sheet(isPresented: $uiState.showTemplateBrowser) {
71 + TemplateBrowserView { prompt, safetyMode in
72 + store.pendingDraft = prompt
73 + store.newTask(model: defaultModel, safetyMode: safetyMode)
74 + }
75 + }
52 76 .preferredColorScheme(appearance.themeMode.colorScheme)
53 77 .tint(appearance.accentColor)
78 + .id(appearance.accent)
79 + }
80 +
81 + /// New tasks start with the persisted default agent model.
82 + private var defaultModel: AIModel? {
83 + settings.defaultAgentModel(in: catalog)
84 + }
85 +
86 + private func newTask(with persona: Persona?) {
87 + let model: AIModel?
88 + if let persona, let id = persona.modelID, let provider = persona.provider,
89 + let preferred = catalog.model(id: id, provider: provider) {
90 + model = preferred
91 + } else {
92 + model = defaultModel
93 + }
94 + store.newTask(
95 + model: model,
96 + safetyMode: persona?.safetyMode ?? settings.settings.defaultSafetyMode,
97 + personaID: persona?.id
98 + )
99 + }
100 +
101 + // MARK: - ⌘K palette
102 +
103 + private var paletteOverlay: some View {
104 + ZStack(alignment: .top) {
105 + // Click-away scrim (transparent, keeps the window visible).
106 + Color.black.opacity(0.001)
107 + .onTapGesture { uiState.showCommandPalette = false }
108 + CommandPaletteView(items: paletteItems) {
109 + uiState.showCommandPalette = false
110 + }
111 + .padding(.top, ZyquoSpacing.xxl * 2)
112 + }
113 + .ignoresSafeArea()
114 + .transition(.opacity)
115 + }
116 +
117 + private var paletteItems: [PaletteItem] {
118 + var items: [PaletteItem] = [
119 + PaletteItem(id: "action-new-task", kind: .action, title: "New Task", subtitle: "⌘N", symbolName: "plus.circle") {
120 + store.newTask(model: defaultModel, safetyMode: settings.settings.defaultSafetyMode)
121 + },
122 + PaletteItem(id: "action-settings", kind: .action, title: "Open Settings", subtitle: "⌘,", symbolName: "gearshape") {
123 + SettingsOpener.open()
124 + },
125 + PaletteItem(id: "action-templates", kind: .action, title: "Browse Templates", subtitle: nil, symbolName: "square.grid.2x2") {
126 + uiState.showTemplateBrowser = true
127 + },
128 + PaletteItem(id: "action-audit", kind: .action, title: "Open Audit Log", subtitle: "⌘⇧A", symbolName: "list.bullet.rectangle") {
129 + uiState.requestAuditLog()
130 + },
131 + PaletteItem(id: "action-drawer", kind: .action, title: "Toggle Activity Drawer", subtitle: nil, symbolName: "terminal") {
132 + uiState.requestDrawerToggle()
133 + },
134 + PaletteItem(id: "action-plan", kind: .action, title: "Toggle Plan Panel", subtitle: nil, symbolName: "sidebar.right") {
135 + uiState.requestPlanToggle()
136 + },
137 + ]
138 + for template in templates.all {
139 + items.append(PaletteItem(
140 + id: "template-\(template.id.uuidString)",
141 + kind: .template,
142 + title: template.title,
143 + subtitle: template.category.displayName,
144 + symbolName: template.displaySymbol
145 + ) {
146 + if template.variables.isEmpty {
147 + store.pendingDraft = template.prompt
148 + store.newTask(model: defaultModel, safetyMode: template.suggestedSafetyMode)
149 + } else {
150 + uiState.showTemplateBrowser = true
151 + }
152 + })
153 + }
154 + for task in store.tasks.prefix(60) {
155 + items.append(PaletteItem(
156 + id: "task-\(task.id.uuidString)",
157 + kind: .task,
158 + title: task.title,
159 + subtitle: task.status.displayName,
160 + symbolName: "checklist"
161 + ) {
162 + store.selectedID = task.id
163 + })
164 + }
165 + return items
54 166 }
55 167 }
modified Sources/ZyquoAgent/Views/ModelChipView.swift +4 −2
@@ -67,6 +67,7 @@ struct ModelPickerView: View {
67 67
68 68 @EnvironmentObject private var catalog: ModelCatalog
69 69 @EnvironmentObject private var vault: KeyVaultStore
70 + @EnvironmentObject private var settings: AgentSettingsStore
70 71 @State private var query = ""
71 72
72 73 var body: some View {
@@ -130,8 +131,9 @@ struct ModelPickerView: View {
130 131 }
131 132
132 133 private func row(_ model: AIModel) -> some View {
133 let isDefault = model.id == catalog.defaultAgentModel?.id
134 && model.provider == catalog.defaultAgentModel?.provider
134 + let defaultModel = settings.defaultAgentModel(in: catalog)
135 + let isDefault = model.id == defaultModel?.id
136 + && model.provider == defaultModel?.provider
135 137 let isSelected = model.id == selected?.id && model.provider == selected?.provider
136 138 return Button {
137 139 onSelect(model)
added Sources/ZyquoAgent/Views/QuickTask/QuickTaskPanel.swift +301 −0
@@ -0,0 +1,301 @@
1 +//
2 +// QuickTaskPanel.swift
3 +// Zyquo Agent
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Global Quick Task (⌥Space): floating Spotlight-style panel — 640pt wide,
9 +// radius 14, soft shadow — that fires a one-off agent task with the default
10 +// model in Guarded mode, expands to show the live steps compactly (reusing
11 +// StepCardView, including inline approval cards), and can promote the task
12 +// to the full window. ESC dismisses. Ported from Zyquo Cloud's QuickChat
13 +// controller pattern (NSPanel + ⌥Space global/local key monitors).
14 +//
15 +
16 +import AppKit
17 +import SwiftUI
18 +
19 +/// Manages the floating NSPanel hosting QuickTaskView and the global hotkey.
20 +@MainActor
21 +final class QuickTaskController {
22 + private var panel: NSPanel?
23 + private var hotKeyMonitor: Any?
24 + private let environment: AppEnvironment
25 +
26 + init(environment: AppEnvironment) {
27 + self.environment = environment
28 + installHotKey()
29 + }
30 +
31 + private func installHotKey() {
32 + // ⌥Space, global. The global monitor fires while other apps are
33 + // frontmost; the local monitor covers Zyquo Agent itself.
34 + hotKeyMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { [weak self] event in
35 + guard event.keyCode == 49, event.modifierFlags.contains(.option) else { return }
36 + Task { @MainActor in self?.toggle() }
37 + }
38 + NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
39 + if event.keyCode == 49, event.modifierFlags.contains(.option) {
40 + Task { @MainActor in self?.toggle() }
41 + return nil
42 + }
43 + return event
44 + }
45 + }
46 +
47 + func toggle() {
48 + if let panel, panel.isVisible {
49 + panel.orderOut(nil)
50 + return
51 + }
52 + show()
53 + }
54 +
55 + func show() {
56 + let panel = self.panel ?? makePanel()
57 + self.panel = panel
58 + positionOnActiveScreen(panel)
59 + panel.makeKeyAndOrderFront(nil)
60 + NSApp.activate(ignoringOtherApps: true)
61 + }
62 +
63 + private func makePanel() -> NSPanel {
64 + let hosting = NSHostingView(
65 + rootView: QuickTaskView(onDismiss: { [weak self] in self?.panel?.orderOut(nil) })
66 + .environmentObject(environment.tasks)
67 + .environmentObject(environment.hub)
68 + .environmentObject(environment.catalog)
69 + .environmentObject(environment.vault)
70 + .environmentObject(environment.appearance)
71 + .environmentObject(environment.settings)
72 + )
73 + let panel = KeyableTaskPanel(
74 + contentRect: NSRect(x: 0, y: 0, width: ZyquoMetrics.quickTaskWidth, height: 120),
75 + styleMask: [.nonactivatingPanel, .fullSizeContentView, .titled],
76 + backing: .buffered,
77 + defer: false
78 + )
79 + panel.titleVisibility = .hidden
80 + panel.titlebarAppearsTransparent = true
81 + panel.isMovableByWindowBackground = true
82 + panel.level = .floating
83 + panel.collectionBehavior = [.canJoinAllSpaces, .transient]
84 + panel.isOpaque = false
85 + panel.backgroundColor = .clear
86 + panel.hidesOnDeactivate = false
87 + panel.contentView = hosting
88 + return panel
89 + }
90 +
91 + private func positionOnActiveScreen(_ panel: NSPanel) {
92 + let screen = NSScreen.main ?? NSScreen.screens[0]
93 + let frame = screen.visibleFrame
94 + let size = panel.frame.size
95 + let x = frame.midX - size.width / 2
96 + let y = frame.maxY - frame.height * 0.30 - size.height
97 + panel.setFrameOrigin(NSPoint(x: x, y: y))
98 + }
99 +}
100 +
101 +/// NSPanel subclass that can become key despite .nonactivatingPanel.
102 +final class KeyableTaskPanel: NSPanel {
103 + override var canBecomeKey: Bool { true }
104 + override func cancelOperation(_ sender: Any?) {
105 + orderOut(nil)
106 + }
107 +}
108 +
109 +// MARK: - View
110 +
111 +struct QuickTaskView: View {
112 + var onDismiss: () -> Void
113 +
114 + @EnvironmentObject private var tasks: TaskStore
115 + @EnvironmentObject private var hub: RunHub
116 + @EnvironmentObject private var catalog: ModelCatalog
117 + @EnvironmentObject private var vault: KeyVaultStore
118 + @EnvironmentObject private var appearance: AppearanceStore
119 + @EnvironmentObject private var settings: AgentSettingsStore
120 +
121 + @State private var input = ""
122 + @State private var model: AIModel?
123 + @State private var controller: RunController?
124 + @State private var errorText: String?
125 + @FocusState private var focused: Bool
126 +
127 + var body: some View {
128 + VStack(spacing: 0) {
129 + inputRow
130 + if let controller {
131 + ZyquoHairline()
132 + QuickTaskRunView(controller: controller, fontSize: 12.5)
133 + footer(controller)
134 + } else if let errorText {
135 + ZyquoHairline()
136 + Text(errorText)
137 + .font(ZyquoFont.body(size: 12.5))
138 + .foregroundStyle(ZyquoColor.danger)
139 + .frame(maxWidth: .infinity, alignment: .leading)
140 + .padding(ZyquoSpacing.md)
141 + }
142 + }
143 + .frame(width: ZyquoMetrics.quickTaskWidth)
144 + .background(
145 + RoundedRectangle(cornerRadius: ZyquoRadius.large, style: .continuous)
146 + .fill(ZyquoColor.surface)
147 + )
148 + .overlay(
149 + RoundedRectangle(cornerRadius: ZyquoRadius.large, style: .continuous)
150 + .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline)
151 + )
152 + .zyquoSoftShadow()
153 + .tint(appearance.accentColor)
154 + .onAppear { focused = true }
155 + .onExitCommand { onDismiss() }
156 + }
157 +
158 + private var inputRow: some View {
159 + HStack(spacing: ZyquoSpacing.sm) {
160 + AgentZGlyph(size: 24)
161 + TextField("What should I do on your Mac?", text: $input)
162 + .textFieldStyle(.plain)
163 + .font(ZyquoFont.body(size: 16))
164 + .focused($focused)
165 + .onSubmit(run)
166 + ZyquoBadge(text: SafetyMode.guarded.displayName, color: ZyquoColor.textSecondary)
167 + .help("Quick tasks run in Guarded mode: safe actions auto-run, anything mutating asks")
168 + ModelChipView(model: model ?? settings.defaultAgentModel(in: catalog)) { chosen in
169 + model = chosen
170 + }
171 + if controller?.isRunning == true {
172 + Button {
173 + controller?.cancel()
174 + } label: {
175 + Image(systemName: "stop.fill")
176 + .foregroundStyle(ZyquoColor.danger)
177 + }
178 + .buttonStyle(.plain)
179 + .help("Stop the run")
180 + }
181 + }
182 + .padding(ZyquoSpacing.md)
183 + }
184 +
185 + private func footer(_ controller: RunController) -> some View {
186 + HStack {
187 + if controller.isRunning {
188 + HStack(spacing: ZyquoSpacing.xxs) {
189 + ProgressView().controlSize(.mini)
190 + Text("Running…")
191 + .font(ZyquoFont.caption)
192 + .foregroundStyle(ZyquoColor.textSecondary)
193 + }
194 + }
195 + Spacer()
196 + Button("Open in Zyquo Agent") { promote(controller) }
197 + .controlSize(.small)
198 + }
199 + .padding(.horizontal, ZyquoSpacing.md)
200 + .padding(.bottom, ZyquoSpacing.xs)
201 + }
202 +
203 + // MARK: - Actions
204 +
205 + private func run() {
206 + let prompt = input.trimmingCharacters(in: .whitespacesAndNewlines)
207 + guard !prompt.isEmpty, controller?.isRunning != true else { return }
208 + guard let target = model ?? settings.defaultAgentModel(in: catalog) else {
209 + errorText = "No agent-capable model available — configure one in Settings › Models."
210 + return
211 + }
212 + guard AgentCLI.resolveAPIKey(for: target.provider) != nil else {
213 + errorText = ProviderError.missingAPIKey(target.provider).localizedDescription
214 + return
215 + }
216 + errorText = nil
217 + let task = tasks.newTask(model: target, safetyMode: .guarded)
218 + let runController = hub.controller(for: task.id)
219 + controller = runController
220 + runController.start(prompt: prompt, model: target)
221 + }
222 +
223 + /// Brings the task into the full command-center window.
224 + private func promote(_ controller: RunController) {
225 + tasks.selectedID = controller.taskID
226 + onDismiss()
227 + NSApp.activate(ignoringOtherApps: true)
228 + }
229 +}
230 +
231 +/// The compact live run feed: step cards + inline approval + outcome.
232 +private struct QuickTaskRunView: View {
233 + @ObservedObject var controller: RunController
234 + let fontSize: Double
235 +
236 + private static let bottomID = "quicktask-bottom"
237 +
238 + var body: some View {
239 + ScrollViewReader { proxy in
240 + ScrollView {
241 + LazyVStack(alignment: .leading, spacing: ZyquoSpacing.xs) {
242 + ForEach(controller.entries) { entry in
243 + switch entry {
244 + case .step(let step):
245 + StepCardView(step: step, fontSize: fontSize, isLive: controller.isRunning)
246 + case .compaction(let record):
247 + CompactionNoticeView(record: record)
248 + }
249 + }
250 + if let approval = controller.pendingApproval {
251 + ApprovalCardView(approval: approval) { resolution in
252 + controller.resolveApproval(resolution)
253 + }
254 + }
255 + if let trip = controller.guardTrip {
256 + GuardTripCardView(
257 + trip: trip,
258 + onContinue: { controller.resumeAfterTrip(raisingBudget: true) },
259 + onStop: { controller.stopAfterTrip() }
260 + )
261 + }
262 + outcomeView
263 + if let error = controller.lastError {
264 + RunNoticeView(symbol: "exclamationmark.triangle.fill", text: error, color: ZyquoColor.danger)
265 + }
266 + Color.clear.frame(height: 1).id(Self.bottomID)
267 + }
268 + .padding(ZyquoSpacing.md)
269 + }
270 + .frame(maxHeight: 380)
271 + .onChange(of: fingerprint) { _ in
272 + proxy.scrollTo(Self.bottomID, anchor: .bottom)
273 + }
274 + }
275 + }
276 +
277 + @ViewBuilder
278 + private var outcomeView: some View {
279 + switch controller.outcome {
280 + case .completed(let answer):
281 + MarkdownView(text: answer, fontSize: fontSize)
282 + case .failed(let reason):
283 + RunNoticeView(symbol: "xmark.circle.fill", text: reason, color: ZyquoColor.danger)
284 + case .cancelled:
285 + RunNoticeView(symbol: "slash.circle", text: "Run cancelled.", color: ZyquoColor.textTertiary)
286 + case .stoppedByUser(let reason):
287 + RunNoticeView(symbol: "stop.circle", text: "Run stopped — \(reason)", color: ZyquoColor.textTertiary)
288 + case nil:
289 + EmptyView()
290 + }
291 + }
292 +
293 + private var fingerprint: Int {
294 + var value = controller.entries.count &* 13
295 + if case .step(let step)? = controller.entries.last {
296 + value &+= step.text.count &+ step.invocations.count
297 + }
298 + if controller.outcome != nil { value &+= 1 }
299 + return value
300 + }
301 +}
added Sources/ZyquoAgent/Views/Settings/AdvancedSettingsTab.swift +150 −0
@@ -0,0 +1,150 @@
1 +//
2 +// AdvancedSettingsTab.swift
3 +// Zyquo Agent
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Settings › Advanced — reveal the data/workspaces folders, export every
9 +// task's append-only audit log into one file, import/export tasks as JSON,
10 +// and the menu bar extra toggle.
11 +//
12 +
13 +import SwiftUI
14 +import UniformTypeIdentifiers
15 +
16 +struct AdvancedSettingsTab: View {
17 + @EnvironmentObject private var store: TaskStore
18 + @AppStorage("menuBarExtraEnabled") private var menuBarExtraEnabled = true
19 + @State private var statusMessage: String?
20 +
21 + var body: some View {
22 + Form {
23 + Section("Menu bar") {
24 + Toggle("Show Zyquo Agent in the menu bar", isOn: $menuBarExtraEnabled)
25 + Text("The menu bar extra shows running-task status and offers New Task, Quick Task (⌥Space), and quick access to running tasks.")
26 + .font(ZyquoFont.caption)
27 + .foregroundStyle(ZyquoColor.textTertiary)
28 + }
29 +
30 + Section("Data") {
31 + LabeledContent("Data folder") {
32 + Button("Reveal in Finder") {
33 + NSWorkspace.shared.activateFileViewerSelecting([
34 + PersistenceService.shared.rootDirectory
35 + ])
36 + }
37 + .controlSize(.small)
38 + }
39 + LabeledContent("Workspaces folder") {
40 + Button("Reveal in Finder") {
41 + NSWorkspace.shared.activateFileViewerSelecting([
42 + PersistenceService.shared.workspacesDirectory
43 + ])
44 + }
45 + .controlSize(.small)
46 + }
47 + Text("Tasks, settings, personas, templates, and the encrypted key vault live in ~/Library/Application Support/ZyquoAgent/. The vault (vault.zq) is bound to this Mac and can't be decrypted elsewhere.")
48 + .font(ZyquoFont.caption)
49 + .foregroundStyle(ZyquoColor.textTertiary)
50 + }
51 +
52 + Section("Audit logs") {
53 + LabeledContent("All executed actions, across every task") {
54 + Button("Export…") { exportAuditLogs() }
55 + .controlSize(.small)
56 + }
57 + Text("Concatenates each task workspace's append-only audit.jsonl (one JSON entry per executed action) into a single export.")
58 + .font(ZyquoFont.caption)
59 + .foregroundStyle(ZyquoColor.textTertiary)
60 + }
61 +
62 + Section("Tasks") {
63 + HStack {
64 + Button("Export All Tasks…") { exportTasks() }
65 + .controlSize(.small)
66 + Button("Import Tasks…") { importTasks() }
67 + .controlSize(.small)
68 + }
69 + Text("Exports task records (titles, prompts, run histories, settings) as JSON. Workspaces are folders on disk and are not embedded.")
70 + .font(ZyquoFont.caption)
71 + .foregroundStyle(ZyquoColor.textTertiary)
72 + }
73 +
74 + if let statusMessage {
75 + Text(statusMessage)
76 + .font(ZyquoFont.caption)
77 + .foregroundStyle(ZyquoColor.success)
78 + }
79 + }
80 + .formStyle(.grouped)
81 + }
82 +
83 + // MARK: - Audit export
84 +
85 + private func exportAuditLogs() {
86 + let panel = NSSavePanel()
87 + panel.allowedContentTypes = [UTType(filenameExtension: "jsonl") ?? .plainText]
88 + panel.nameFieldStringValue = "zyquo-agent-audit.jsonl"
89 + let tasks = store.tasks
90 + panel.begin { response in
91 + guard response == .OK, let url = panel.url else { return }
92 + var lines: [String] = []
93 + var covered = 0
94 + for task in tasks {
95 + guard let workspace = task.workspaceURL else { continue }
96 + let auditURL = workspace
97 + .appendingPathComponent(".zyquo")
98 + .appendingPathComponent("audit.jsonl")
99 + guard let content = try? String(contentsOf: auditURL, encoding: .utf8),
100 + !content.isEmpty else { continue }
101 + covered += 1
102 + lines.append(contentsOf: content.split(separator: "\n").map(String.init))
103 + }
104 + try? (lines.joined(separator: "\n") + "\n").write(to: url, atomically: true, encoding: .utf8)
105 + Task { @MainActor in
106 + statusMessage = "Exported \(lines.count) audit entr\(lines.count == 1 ? "y" : "ies") from \(covered) task\(covered == 1 ? "" : "s")."
107 + }
108 + }
109 + }
110 +
111 + // MARK: - Task import/export
112 +
113 + private func exportTasks() {
114 + let panel = NSSavePanel()
115 + panel.allowedContentTypes = [.json]
116 + panel.nameFieldStringValue = "zyquo-agent-tasks.json"
117 + let tasks = store.tasks
118 + panel.begin { response in
119 + guard response == .OK, let url = panel.url else { return }
120 + let encoder = JSONEncoder()
121 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
122 + encoder.dateEncodingStrategy = .iso8601
123 + guard let data = try? encoder.encode(tasks) else { return }
124 + try? data.write(to: url, options: .atomic)
125 + Task { @MainActor in
126 + statusMessage = "Exported \(tasks.count) task\(tasks.count == 1 ? "" : "s")."
127 + }
128 + }
129 + }
130 +
131 + private func importTasks() {
132 + let panel = NSOpenPanel()
133 + panel.allowedContentTypes = [.json]
134 + panel.allowsMultipleSelection = false
135 + panel.begin { response in
136 + guard response == .OK, let url = panel.url,
137 + let data = try? Data(contentsOf: url) else { return }
138 + let decoder = JSONDecoder()
139 + decoder.dateDecodingStrategy = .iso8601
140 + guard let imported = try? decoder.decode([AgentTask].self, from: data) else {
141 + Task { @MainActor in statusMessage = "Import failed — not a Zyquo Agent task export." }
142 + return
143 + }
144 + Task { @MainActor in
145 + let added = store.importTasks(imported)
146 + statusMessage = "Imported \(added) task\(added == 1 ? "" : "s") (\(imported.count - added) already present)."
147 + }
148 + }
149 + }
150 +}
added Sources/ZyquoAgent/Views/Settings/AgentSettingsTab.swift +227 −0
@@ -0,0 +1,227 @@
1 +//
2 +// AgentSettingsTab.swift
3 +// Zyquo Agent
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Settings › Agent — the engine tunables persisted to AgentSettings.json
9 +// and applied to every new run (max steps, token/time budgets, per-command
10 +// timeout, parallel tool calls, compaction threshold), plus the Personas
11 +// CRUD (system-prompt addition + preferred model + safety default).
12 +//
13 +
14 +import SwiftUI
15 +
16 +struct AgentSettingsTab: View {
17 + @EnvironmentObject private var settings: AgentSettingsStore
18 + @EnvironmentObject private var personas: PersonaStore
19 + @EnvironmentObject private var catalog: ModelCatalog
20 + @State private var editingPersona: Persona?
21 + @State private var creatingPersona = false
22 +
23 + var body: some View {
24 + Form {
25 + Section("Run budgets (LoopGuard)") {
26 + Stepper(value: $settings.settings.maxSteps, in: 5...200, step: 5) {
27 + LabeledContent("Max steps per run", value: "\(settings.settings.maxSteps)")
28 + }
29 + Stepper(value: $settings.settings.tokenBudget, in: 50_000...5_000_000, step: 50_000) {
30 + LabeledContent("Token budget per run", value: "\(settings.settings.tokenBudget / 1_000)K")
31 + }
32 + Stepper(value: $settings.settings.timeBudgetMinutes, in: 5...240, step: 5) {
33 + LabeledContent("Time budget per run", value: "\(settings.settings.timeBudgetMinutes) min")
34 + }
35 + Text("When a budget trips, the run pauses and asks — it never aborts silently.")
36 + .font(ZyquoFont.caption)
37 + .foregroundStyle(ZyquoColor.textTertiary)
38 + }
39 +
40 + Section("Execution") {
41 + Stepper(value: $settings.settings.perCommandTimeoutSeconds, in: 10...3600, step: 10) {
42 + LabeledContent("Per-command timeout", value: "\(settings.settings.perCommandTimeoutSeconds) s")
43 + }
44 + Toggle("Parallel tool calls", isOn: $settings.settings.parallelToolCalls)
45 + Text("When on, a turn's tool calls run concurrently — but only when every call is read-only under the active policy. Mutating calls always run sequentially.")
46 + .font(ZyquoFont.caption)
47 + .foregroundStyle(ZyquoColor.textTertiary)
48 + }
49 +
50 + Section("Memory") {
51 + VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) {
52 + LabeledContent("Compaction threshold") {
53 + Text("\(Int(settings.settings.compactionThreshold * 100))% of context window")
54 + .font(ZyquoFont.caption)
55 + .foregroundStyle(ZyquoColor.textSecondary)
56 + .monospacedDigit()
57 + }
58 + Slider(value: $settings.settings.compactionThreshold, in: 0.5...0.95, step: 0.05)
59 + }
60 + Text("Older completed steps are summarized when estimated context usage crosses this fraction; the plan, MEMORY.md, and recent steps always survive verbatim.")
61 + .font(ZyquoFont.caption)
62 + .foregroundStyle(ZyquoColor.textTertiary)
63 + }
64 +
65 + Section("Personas") {
66 + if personas.personas.isEmpty {
67 + Text("Personas add a system-prompt section to every run of a task, and can pin a preferred model and safety default.")
68 + .font(ZyquoFont.body(size: 12))
69 + .foregroundStyle(ZyquoColor.textTertiary)
70 + }
71 + ForEach(personas.personas) { persona in
72 + personaRow(persona)
73 + }
74 + Button {
75 + creatingPersona = true
76 + } label: {
77 + Label("New Persona", systemImage: "plus")
78 + }
79 + .controlSize(.small)
80 + }
81 + }
82 + .formStyle(.grouped)
83 + .sheet(item: $editingPersona) { persona in
84 + PersonaEditorSheet(persona: persona) { updated in
85 + personas.update(updated)
86 + }
87 + }
88 + .sheet(isPresented: $creatingPersona) {
89 + PersonaEditorSheet(persona: Persona(name: "", systemPrompt: "")) { created in
90 + personas.add(created)
91 + }
92 + }
93 + }
94 +
95 + private func personaRow(_ persona: Persona) -> some View {
96 + HStack(spacing: ZyquoSpacing.xs) {
97 + Image(systemName: persona.symbolName)
98 + .font(.system(size: 12))
99 + .foregroundStyle(ZyquoColor.accent)
100 + .frame(width: 18)
101 + VStack(alignment: .leading, spacing: 1) {
102 + Text(persona.name)
103 + .font(ZyquoFont.bodyEmphasis(size: 12.5))
104 + .foregroundStyle(ZyquoColor.textPrimary)
105 + Text(persona.systemPrompt)
106 + .font(ZyquoFont.caption)
107 + .foregroundStyle(ZyquoColor.textTertiary)
108 + .lineLimit(1)
109 + }
110 + Spacer()
111 + if let modelID = persona.modelID {
112 + ZyquoBadge(text: modelID.split(separator: "/").last.map(String.init) ?? modelID)
113 + }
114 + if let mode = persona.safetyMode {
115 + ZyquoBadge(text: mode.displayName, color: ZyquoColor.textSecondary)
116 + }
117 + Button {
118 + editingPersona = persona
119 + } label: {
120 + Image(systemName: "pencil")
121 + .font(.system(size: 11))
122 + .foregroundStyle(ZyquoColor.textSecondary)
123 + }
124 + .buttonStyle(.plain)
125 + .help("Edit persona")
126 + Button {
127 + personas.delete(persona.id)
128 + } label: {
129 + Image(systemName: "trash")
130 + .font(.system(size: 11))
131 + .foregroundStyle(ZyquoColor.danger)
132 + }
133 + .buttonStyle(.plain)
134 + .help("Delete persona")
135 + }
136 + }
137 +}
138 +
139 +// MARK: - Persona editor
140 +
141 +struct PersonaEditorSheet: View {
142 + @State var persona: Persona
143 + var onSave: (Persona) -> Void
144 +
145 + @EnvironmentObject private var catalog: ModelCatalog
146 + @Environment(\.dismiss) private var dismiss
147 +
148 + /// Sentinel tag for "no preferred model" in the picker.
149 + private static let noModelTag = ""
150 +
151 + var body: some View {
152 + VStack(alignment: .leading, spacing: ZyquoSpacing.sm) {
153 + Text(persona.name.isEmpty ? "New Persona" : "Edit Persona")
154 + .font(ZyquoFont.title)
155 + .foregroundStyle(ZyquoColor.textPrimary)
156 + Form {
157 + TextField("Name", text: $persona.name)
158 + VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) {
159 + Text("System-prompt addition")
160 + .font(ZyquoFont.caption)
161 + .foregroundStyle(ZyquoColor.textSecondary)
162 + TextEditor(text: $persona.systemPrompt)
163 + .font(ZyquoFont.body(size: 12.5))
164 + .frame(height: 110)
165 + .padding(ZyquoSpacing.xxs)
166 + .background(
167 + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)
168 + .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline)
169 + )
170 + }
171 + Picker("Preferred agent model", selection: modelSelection) {
172 + Text("None (use default)").tag(Self.noModelTag)
173 + ForEach(catalog.agentCapableModels) { model in
174 + Text("\(model.displayName)\(model.provider.displayName)")
175 + .tag("\(model.provider.rawValue)|\(model.id)")
176 + }
177 + }
178 + Picker("Default safety mode", selection: safetySelection) {
179 + Text("App default").tag(Self.noModelTag)
180 + ForEach(SafetyMode.allCases) { mode in
181 + Text(mode.displayName).tag(mode.rawValue)
182 + }
183 + }
184 + }
185 + HStack {
186 + Spacer()
187 + Button("Cancel") { dismiss() }
188 + Button("Save") {
189 + onSave(persona)
190 + dismiss()
191 + }
192 + .buttonStyle(.borderedProminent)
193 + .keyboardShortcut(.defaultAction)
194 + .disabled(persona.name.trimmingCharacters(in: .whitespaces).isEmpty)
195 + }
196 + }
197 + .padding(ZyquoSpacing.xl)
198 + .frame(width: 460)
199 + .background(ZyquoColor.surface)
200 + }
201 +
202 + private var modelSelection: Binding<String> {
203 + Binding(
204 + get: {
205 + guard let provider = persona.provider, let id = persona.modelID else { return Self.noModelTag }
206 + return "\(provider.rawValue)|\(id)"
207 + },
208 + set: { key in
209 + let parts = key.split(separator: "|", maxSplits: 1)
210 + if parts.count == 2, let provider = ProviderID(rawValue: String(parts[0])) {
211 + persona.provider = provider
212 + persona.modelID = String(parts[1])
213 + } else {
214 + persona.provider = nil
215 + persona.modelID = nil
216 + }
217 + }
218 + )
219 + }
220 +
221 + private var safetySelection: Binding<String> {
222 + Binding(
223 + get: { persona.safetyMode?.rawValue ?? Self.noModelTag },
224 + set: { persona.safetyMode = SafetyMode(rawValue: $0) }
225 + )
226 + }
227 +}
added Sources/ZyquoAgent/Views/Settings/AppearanceSettingsTab.swift +110 −0
@@ -0,0 +1,110 @@
1 +//
2 +// AppearanceSettingsTab.swift
3 +// Zyquo Agent
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Settings › Appearance — theme mode (Light/Dark/System), the five accent
9 +// choices with swatches (violet flagship + graphite, sky, emerald, amber),
10 +// and the chat font size slider (12–18pt) with live preview.
11 +//
12 +
13 +import SwiftUI
14 +
15 +struct AppearanceSettingsTab: View {
16 + @EnvironmentObject private var appearance: AppearanceStore
17 +
18 + var body: some View {
19 + Form {
20 + Picker("Theme", selection: $appearance.themeMode) {
21 + ForEach(ThemeMode.allCases) { mode in
22 + Text(mode.displayName).tag(mode)
23 + }
24 + }
25 + .pickerStyle(.segmented)
26 +
27 + VStack(alignment: .leading, spacing: ZyquoSpacing.xs) {
28 + Text("Accent")
29 + HStack(spacing: ZyquoSpacing.sm) {
30 + ForEach(AccentChoice.allCases) { choice in
31 + accentSwatch(choice)
32 + }
33 + Spacer(minLength: 0)
34 + }
35 + }
36 +
37 + VStack(alignment: .leading, spacing: ZyquoSpacing.xs) {
38 + HStack {
39 + Text("Chat font size")
40 + Spacer()
41 + Text(String(format: "%.1f pt", appearance.chatFontSize))
42 + .font(ZyquoFont.caption)
43 + .foregroundStyle(ZyquoColor.textSecondary)
44 + .monospacedDigit()
45 + }
46 + Slider(value: $appearance.chatFontSize, in: 12...18, step: 0.5)
47 + VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) {
48 + Text("Preview")
49 + .font(ZyquoFont.caption)
50 + .foregroundStyle(ZyquoColor.textTertiary)
51 + Text("The quick brown fox jumps over the lazy dog — Zyquo Agent renders task text at this size, with generous 1.45 line height for readability.")
52 + .font(ZyquoFont.body(size: appearance.chatFontSize))
53 + .lineSpacing(appearance.chatFontSize * ZyquoFont.bodyLineSpacingFactor)
54 + .foregroundStyle(ZyquoColor.textPrimary)
55 + .padding(ZyquoSpacing.sm)
56 + .frame(maxWidth: .infinity, alignment: .leading)
57 + .background(
58 + RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous)
59 + .fill(ZyquoColor.surface)
60 + .overlay(
61 + RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous)
62 + .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline)
63 + )
64 + )
65 + }
66 + }
67 + }
68 + .formStyle(.grouped)
69 + }
70 +
71 + private func accentSwatch(_ choice: AccentChoice) -> some View {
72 + let isSelected = appearance.accent == choice
73 + return Button {
74 + withAnimation(ZyquoMotion.appear) { appearance.accent = choice }
75 + } label: {
76 + VStack(spacing: ZyquoSpacing.xxs) {
77 + Circle()
78 + .fill(swatchColor(choice))
79 + .frame(width: 26, height: 26)
80 + .overlay(
81 + Circle().strokeBorder(
82 + isSelected ? ZyquoColor.textPrimary : ZyquoColor.border,
83 + lineWidth: isSelected ? 2 : ZyquoMetrics.hairline
84 + )
85 + )
86 + .overlay {
87 + if isSelected {
88 + Image(systemName: "checkmark")
89 + .font(.system(size: 10, weight: .bold))
90 + .foregroundStyle(.white)
91 + }
92 + }
93 + Text(choice.displayName)
94 + .font(ZyquoFont.caption)
95 + .foregroundStyle(isSelected ? ZyquoColor.textPrimary : ZyquoColor.textSecondary)
96 + }
97 + }
98 + .buttonStyle(PressableButtonStyle())
99 + .help("\(choice.displayName) accent")
100 + }
101 +
102 + /// Appearance-resolved swatch color for one accent choice.
103 + private func swatchColor(_ choice: AccentChoice) -> Color {
104 + let (light, dark) = choice.accentHex
105 + return Color(nsColor: NSColor(name: nil) { appearance in
106 + let hex = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ? dark : light
107 + return NSColor(hex: hex)
108 + })
109 + }
110 +}
added Sources/ZyquoAgent/Views/Settings/ModelsSettingsTab.swift +177 −0
@@ -0,0 +1,177 @@
1 +//
2 +// ModelsSettingsTab.swift
3 +// Zyquo Agent
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Settings › Models — the full shared Zyquo Cloud catalog grouped by
9 +// provider, with the Agent twist: agent-capable models carry an "Agent"
10 +// badge, and the star sets the persisted default agent model used by new
11 +// tasks. Per-model info: context window, capability flags, pricing.
12 +//
13 +
14 +import SwiftUI
15 +
16 +struct ModelsSettingsTab: View {
17 + @EnvironmentObject private var catalog: ModelCatalog
18 + @EnvironmentObject private var vault: KeyVaultStore
19 + @EnvironmentObject private var settings: AgentSettingsStore
20 + @State private var selectedProvider: ProviderID = AgentModelSupport.defaultModelProvider
21 + @State private var refreshing = false
22 + @State private var refreshResult: String?
23 +
24 + var body: some View {
25 + VStack(spacing: 0) {
26 + HStack {
27 + Picker("Provider", selection: $selectedProvider) {
28 + ForEach(ProviderID.builtIn) { provider in
29 + Text(provider.displayName).tag(provider)
30 + }
31 + }
32 + .frame(width: 240)
33 + Spacer()
34 + if let result = refreshResult {
35 + Text(result)
36 + .font(ZyquoFont.caption)
37 + .foregroundStyle(ZyquoColor.textSecondary)
38 + .lineLimit(1)
39 + }
40 + Button {
41 + refreshModels()
42 + } label: {
43 + if refreshing {
44 + ProgressView().controlSize(.small)
45 + } else {
46 + Label("Refresh from API", systemImage: "arrow.clockwise")
47 + }
48 + }
49 + .controlSize(.small)
50 + .disabled(refreshing || !selectedProvider.supportsModelListing || !vault.hasKey(for: selectedProvider))
51 + }
52 + .padding(ZyquoSpacing.sm)
53 + ZyquoHairline()
54 + defaultBanner
55 + ZyquoHairline()
56 + modelTable
57 + }
58 + .background(ZyquoColor.background)
59 + }
60 +
61 + private var defaultBanner: some View {
62 + HStack(spacing: ZyquoSpacing.xs) {
63 + Image(systemName: "star.fill")
64 + .font(.system(size: 10))
65 + .foregroundStyle(ZyquoColor.warning)
66 + if let model = settings.defaultAgentModel(in: catalog) {
67 + Text("Default agent model: \(model.displayName) (\(model.provider.displayName)) — new tasks start with it. Star another agent-capable model to change.")
68 + .font(ZyquoFont.caption)
69 + .foregroundStyle(ZyquoColor.textSecondary)
70 + } else {
71 + Text("No default agent model — star an agent-capable model below.")
72 + .font(ZyquoFont.caption)
73 + .foregroundStyle(ZyquoColor.textSecondary)
74 + }
75 + Spacer(minLength: 0)
76 + }
77 + .padding(.horizontal, ZyquoSpacing.sm)
78 + .padding(.vertical, ZyquoSpacing.xxs)
79 + }
80 +
81 + private var modelTable: some View {
82 + ScrollView {
83 + LazyVStack(spacing: 1) {
84 + ForEach(catalog.models(for: selectedProvider)) { model in
85 + modelRow(model)
86 + }
87 + let unknown = catalog.unknownLiveIDs(for: selectedProvider)
88 + if !unknown.isEmpty {
89 + Text("Live on the API but not in the catalog: \(unknown.joined(separator: ", "))")
90 + .font(ZyquoFont.caption)
91 + .foregroundStyle(ZyquoColor.textTertiary)
92 + .frame(maxWidth: .infinity, alignment: .leading)
93 + .padding(ZyquoSpacing.sm)
94 + }
95 + }
96 + .padding(ZyquoSpacing.sm)
97 + }
98 + }
99 +
100 + private func modelRow(_ model: AIModel) -> some View {
101 + let isDefault = isDefaultAgentModel(model)
102 + return HStack(spacing: ZyquoSpacing.xs) {
103 + Button {
104 + settings.setDefaultAgentModel(model)
105 + } label: {
106 + Image(systemName: isDefault ? "star.fill" : "star")
107 + .font(.system(size: 10))
108 + .foregroundStyle(isDefault ? ZyquoColor.warning : ZyquoColor.textTertiary)
109 + }
110 + .buttonStyle(.plain)
111 + .disabled(!model.agentCapable)
112 + .help(model.agentCapable ? "Set as the default agent model" : "Not agent-capable — cannot be the default")
113 + VStack(alignment: .leading, spacing: 0) {
114 + HStack(spacing: ZyquoSpacing.xxs) {
115 + Text(model.displayName)
116 + .font(ZyquoFont.body(size: 12.5))
117 + .foregroundStyle(model.agentCapable ? ZyquoColor.textPrimary : ZyquoColor.textSecondary)
118 + if model.agentCapable {
119 + ZyquoBadge(text: "Agent", color: ZyquoColor.accent)
120 + .help("Verified for deep agentic, multi-step tool use")
121 + }
122 + if model.isRecommended { ZyquoBadge(text: "Featured", color: ZyquoColor.success) }
123 + if model.isLegacy { ZyquoBadge(text: "Legacy") }
124 + }
125 + Text(model.id)
126 + .font(ZyquoFont.code(size: 10))
127 + .foregroundStyle(ZyquoColor.textTertiary)
128 + }
129 + Spacer()
130 + HStack(spacing: ZyquoSpacing.xxs) {
131 + if model.capabilities.tools { ZyquoBadge(text: "tools") }
132 + if model.capabilities.vision { ZyquoBadge(text: "vision") }
133 + if model.capabilities.reasoning { ZyquoBadge(text: "reasoning") }
134 + }
135 + Text(model.contextBadge)
136 + .font(ZyquoFont.caption)
137 + .foregroundStyle(ZyquoColor.textSecondary)
138 + .frame(width: 64, alignment: .trailing)
139 + Text(pricingText(model))
140 + .font(ZyquoFont.caption)
141 + .foregroundStyle(ZyquoColor.textTertiary)
142 + .frame(width: 100, alignment: .trailing)
143 + .help("USD per 1M tokens, input / output (estimates)")
144 + }
145 + .padding(.vertical, 3)
146 + .padding(.horizontal, ZyquoSpacing.xs)
147 + .zyquoHoverHighlight()
148 + }
149 +
150 + private func isDefaultAgentModel(_ model: AIModel) -> Bool {
151 + let current = settings.defaultAgentModel(in: catalog)
152 + return current?.id == model.id && current?.provider == model.provider
153 + }
154 +
155 + private func pricingText(_ model: AIModel) -> String {
156 + guard let pricing = model.pricing else { return "—" }
157 + return String(format: "$%.2f / $%.2f", pricing.inputPerMTok, pricing.outputPerMTok)
158 + }
159 +
160 + private func refreshModels() {
161 + refreshing = true
162 + refreshResult = nil
163 + let provider = selectedProvider
164 + Task {
165 + defer { refreshing = false }
166 + do {
167 + let key = try vault.apiKey(for: provider)
168 + let ids = try await ProviderRegistry.client(for: provider).listModelIDs(apiKey: key)
169 + catalog.applyLiveListing(ids, for: provider)
170 + let unknown = catalog.unknownLiveIDs(for: provider).count
171 + refreshResult = "\(ids.count) live models · \(unknown) not in catalog"
172 + } catch {
173 + refreshResult = error.localizedDescription
174 + }
175 + }
176 + }
177 +}
added Sources/ZyquoAgent/Views/Settings/ProvidersSettingsTab.swift +147 −0
@@ -0,0 +1,147 @@
1 +//
2 +// ProvidersSettingsTab.swift
3 +// Zyquo Agent
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Settings › Providers & Keys — ported from Zyquo Cloud's vault UI: one row
9 +// per provider (glyph, name, status dot, masked key field, Test, delete),
10 +// extended with the environment-variable fallback indicator (keys resolved
11 +// from the environment take precedence over the vault at run time).
12 +//
13 +
14 +import SwiftUI
15 +
16 +struct ProvidersSettingsTab: View {
17 + @EnvironmentObject private var vault: KeyVaultStore
18 + @EnvironmentObject private var catalog: ModelCatalog
19 + @State private var draftKeys: [ProviderID: String] = [:]
20 +
21 + var body: some View {
22 + ScrollView {
23 + VStack(spacing: ZyquoSpacing.xs) {
24 + Text("Keys are stored in the encrypted vault shared with Zyquo Cloud (AES-256-GCM, bound to this Mac — never the Keychain). Environment variables, when set, take precedence at run time.")
25 + .font(ZyquoFont.caption)
26 + .foregroundStyle(ZyquoColor.textTertiary)
27 + .frame(maxWidth: .infinity, alignment: .leading)
28 + ForEach(ProviderID.builtIn) { provider in
29 + providerRow(provider)
30 + if provider != ProviderID.builtIn.last { ZyquoHairline() }
31 + }
32 + }
33 + .padding(ZyquoMetrics.contentInset)
34 + }
35 + .background(ZyquoColor.background)
36 + }
37 +
38 + private func providerRow(_ provider: ProviderID) -> some View {
39 + HStack(spacing: ZyquoSpacing.sm) {
40 + Image(systemName: provider.symbolName)
41 + .font(.system(size: 14))
42 + .foregroundStyle(ZyquoColor.accent)
43 + .frame(width: 22)
44 + VStack(alignment: .leading, spacing: 1) {
45 + HStack(spacing: ZyquoSpacing.xxs) {
46 + Text(provider.displayName)
47 + .font(ZyquoFont.bodyEmphasis(size: 13))
48 + .foregroundStyle(ZyquoColor.textPrimary)
49 + statusIndicator(provider)
50 + if let envName = activeEnvironmentKey(provider) {
51 + ZyquoBadge(text: "env: \(envName)", color: ZyquoColor.success)
52 + .help("An environment variable provides this key; it takes precedence over the vault.")
53 + }
54 + }
55 + statusDetail(provider)
56 + }
57 + Spacer()
58 + keyField(provider)
59 + testButton(provider)
60 + if vault.hasKey(for: provider) {
61 + Button {
62 + vault.deleteKey(for: provider)
63 + } label: {
64 + Image(systemName: "trash")
65 + .font(.system(size: 11))
66 + .foregroundStyle(ZyquoColor.danger)
67 + }
68 + .buttonStyle(.plain)
69 + .help("Delete key from the vault")
70 + }
71 + }
72 + .padding(.vertical, ZyquoSpacing.xxs)
73 + }
74 +
75 + /// The first set environment variable providing a key for this provider.
76 + private func activeEnvironmentKey(_ provider: ProviderID) -> String? {
77 + let environment = ProcessInfo.processInfo.environment
78 + return AgentCLI.environmentKeyNames(for: provider).first {
79 + !(environment[$0] ?? "").isEmpty
80 + }
81 + }
82 +
83 + @ViewBuilder
84 + private func statusIndicator(_ provider: ProviderID) -> some View {
85 + switch vault.statuses[provider] ?? .unset {
86 + case .unset: StatusDot(status: .unset)
87 + case .saved: StatusDot(status: .unset).overlay(Circle().strokeBorder(ZyquoColor.textSecondary, lineWidth: 1))
88 + case .testing: ProgressView().controlSize(.mini)
89 + case .verified: StatusDot(status: .verified)
90 + case .failed: StatusDot(status: .failed)
91 + }
92 + }
93 +
94 + @ViewBuilder
95 + private func statusDetail(_ provider: ProviderID) -> some View {
96 + switch vault.statuses[provider] ?? .unset {
97 + case .verified(let latency):
98 + Text(String(format: "Verified · %.0f ms", latency * 1000))
99 + .font(ZyquoFont.caption)
100 + .foregroundStyle(ZyquoColor.success)
101 + case .failed(let message):
102 + Text(message)
103 + .font(ZyquoFont.caption)
104 + .foregroundStyle(ZyquoColor.danger)
105 + .lineLimit(1)
106 + .help(message)
107 + case .saved:
108 + Text(vault.redactedKeys[provider] ?? "")
109 + .font(ZyquoFont.caption)
110 + .foregroundStyle(ZyquoColor.textTertiary)
111 + default:
112 + Text("No key")
113 + .font(ZyquoFont.caption)
114 + .foregroundStyle(ZyquoColor.textTertiary)
115 + }
116 + }
117 +
118 + private func keyField(_ provider: ProviderID) -> some View {
119 + SecureField(
120 + vault.hasKey(for: provider) ? (vault.redactedKeys[provider] ?? "") : "API key",
121 + text: Binding(
122 + get: { draftKeys[provider] ?? "" },
123 + set: { draftKeys[provider] = $0 }
124 + )
125 + )
126 + .textFieldStyle(.roundedBorder)
127 + .font(ZyquoFont.code(size: 11))
128 + .frame(width: 200)
129 + .onSubmit { saveDraft(provider) }
130 + }
131 +
132 + private func testButton(_ provider: ProviderID) -> some View {
133 + Button("Test") {
134 + saveDraft(provider)
135 + Task { await vault.testKey(for: provider, catalog: catalog) }
136 + }
137 + .controlSize(.small)
138 + .disabled(!vault.hasKey(for: provider) && (draftKeys[provider] ?? "").isEmpty)
139 + }
140 +
141 + private func saveDraft(_ provider: ProviderID) {
142 + if let draft = draftKeys[provider], !draft.trimmingCharacters(in: .whitespaces).isEmpty {
143 + vault.setKey(draft, for: provider)
144 + draftKeys[provider] = ""
145 + }
146 + }
147 +}
added Sources/ZyquoAgent/Views/Settings/SafetySettingsTab.swift +214 −0
@@ -0,0 +1,214 @@
1 +//
2 +// SafetySettingsTab.swift
3 +// Zyquo Agent
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Settings › Safety — default safety mode, the editable allow/deny rule
9 +// lists, the read-only built-in destructive-pattern list, the AppleScript
10 +// approval toggle and the workspace-escape policy.
11 +//
12 +// The rule editor reads/writes the same policy-rules.json the PolicyEngine
13 +// uses (through PersistenceService). Each run constructs a fresh
14 +// PolicyEngine at task start, which loads the rules then — edits here apply
15 +// to the NEXT run, not to a run already in flight.
16 +//
17 +
18 +import SwiftUI
19 +
20 +struct SafetySettingsTab: View {
21 + @EnvironmentObject private var settings: AgentSettingsStore
22 +
23 + var body: some View {
24 + Form {
25 + Section("Defaults") {
26 + Picker("Default safety mode for new tasks", selection: $settings.settings.defaultSafetyMode) {
27 + ForEach(SafetyMode.allCases) { mode in
28 + Text(mode.displayName).tag(mode)
29 + }
30 + }
31 + .pickerStyle(.segmented)
32 + Text(modeDescription)
33 + .font(ZyquoFont.caption)
34 + .foregroundStyle(ZyquoColor.textTertiary)
35 + }
36 +
37 + Section("AppleScript & workspace boundaries") {
38 + Toggle("Require approval for AppleScript", isOn: $settings.settings.requireApprovalForAppleScript)
39 + Text("Manual and Guarded modes always ask before AppleScript runs. This setting is stored for the Autonomous-mode engine hook (not yet enforced there — Autonomous currently auto-runs only scripts with no risky patterns).")
40 + .font(ZyquoFont.caption)
41 + .foregroundStyle(ZyquoColor.textTertiary)
42 + Picker("File access outside the workspace", selection: $settings.settings.workspaceEscapePolicy) {
43 + ForEach(WorkspaceEscapePolicy.allCases) { policy in
44 + Text(policy.displayName).tag(policy)
45 + }
46 + }
47 + Text("The engine currently always asks on any read or write outside the task workspace, in every mode. “Deny without asking” is stored for a future engine hook.")
48 + .font(ZyquoFont.caption)
49 + .foregroundStyle(ZyquoColor.textTertiary)
50 + }
51 +
52 + PolicyRulesSection()
53 +
54 + Section("Built-in destructive patterns (always ask — every mode)") {
55 + DisclosureGroup("Never run (hard deny)") {
56 + ForEach(ShellCommandAnalyzer.hardDenyDescriptions, id: \.self) { item in
57 + patternRow(item, color: ZyquoColor.danger)
58 + }
59 + }
60 + DisclosureGroup("Always require approval") {
61 + ForEach(ShellCommandAnalyzer.alwaysAskDescriptions, id: \.self) { item in
62 + patternRow(item, color: ZyquoColor.warning)
63 + }
64 + }
65 + Text("These circuit breakers are built in and cannot be disabled — not even by Autonomous mode or a remembered allow rule.")
66 + .font(ZyquoFont.caption)
67 + .foregroundStyle(ZyquoColor.textTertiary)
68 + }
69 + }
70 + .formStyle(.grouped)
71 + }
72 +
73 + private var modeDescription: String {
74 + switch settings.settings.defaultSafetyMode {
75 + case .manual: return "Manual — approve every action."
76 + case .guarded: return "Guarded — safe/read-only actions run automatically, anything mutating asks."
77 + case .autonomous: return "Autonomous — runs freely within budget; destructive and elevated actions still ask."
78 + }
79 + }
80 +
81 + private func patternRow(_ text: String, color: Color) -> some View {
82 + HStack(alignment: .firstTextBaseline, spacing: ZyquoSpacing.xxs) {
83 + Circle()
84 + .fill(color)
85 + .frame(width: 5, height: 5)
86 + .padding(.top, 4)
87 + Text(text)
88 + .font(ZyquoFont.body(size: 12))
89 + .foregroundStyle(ZyquoColor.textSecondary)
90 + .fixedSize(horizontal: false, vertical: true)
91 + }
92 + }
93 +}
94 +
95 +// MARK: - Allow/deny rule editor
96 +
97 +/// Edits the PolicyEngine's stored rules through the same policy-rules.json
98 +/// document (PersistenceService). Live engines load rules at task start.
99 +private struct PolicyRulesSection: View {
100 + @State private var rules = StoredPolicyRules()
101 + @State private var newPattern = ""
102 + @State private var newKind = "bash"
103 + @State private var newList: RuleList = .allow
104 +
105 + private enum RuleList: String, CaseIterable, Identifiable {
106 + case allow = "Allow"
107 + case deny = "Deny"
108 + var id: String { rawValue }
109 + }
110 +
111 + private static let rulesFileName = "policy-rules.json"
112 +
113 + var body: some View {
114 + Section("Allow / deny rules") {
115 + Text("Token-prefix rules evaluated per subcommand: “brew list” matches `brew list --versions` but not `brew install`. Deny rules always win; allow rules can never cover a built-in destructive pattern. Changes apply to the next run.")
116 + .font(ZyquoFont.caption)
117 + .foregroundStyle(ZyquoColor.textTertiary)
118 +
119 + if rules.allow.isEmpty && rules.deny.isEmpty {
120 + Text("No rules yet — “Approve & remember” on an approval card adds allow rules here.")
121 + .font(ZyquoFont.body(size: 12))
122 + .foregroundStyle(ZyquoColor.textTertiary)
123 + }
124 + ForEach(rules.deny, id: \.self) { rule in
125 + ruleRow(rule, isDeny: true)
126 + }
127 + ForEach(rules.allow, id: \.self) { rule in
128 + ruleRow(rule, isDeny: false)
129 + }
130 +
131 + HStack(spacing: ZyquoSpacing.xs) {
132 + Picker("", selection: $newList) {
133 + ForEach(RuleList.allCases) { list in Text(list.rawValue).tag(list) }
134 + }
135 + .labelsHidden()
136 + .frame(width: 90)
137 + Picker("", selection: $newKind) {
138 + Text("bash").tag("bash")
139 + Text("osascript").tag("osascript")
140 + }
141 + .labelsHidden()
142 + .frame(width: 110)
143 + TextField("Pattern (e.g. brew list)", text: $newPattern)
144 + .textFieldStyle(.roundedBorder)
145 + .font(ZyquoFont.code(size: 11))
146 + .onSubmit(addRule)
147 + Button("Add") { addRule() }
148 + .controlSize(.small)
149 + .disabled(newPattern.trimmingCharacters(in: .whitespaces).isEmpty)
150 + }
151 + }
152 + .onAppear(perform: load)
153 + }
154 +
155 + private func ruleRow(_ rule: StoredPolicyRule, isDeny: Bool) -> some View {
156 + HStack(spacing: ZyquoSpacing.xs) {
157 + ZyquoBadge(
158 + text: isDeny ? "deny" : "allow",
159 + color: isDeny ? ZyquoColor.danger : ZyquoColor.success
160 + )
161 + Text(rule.kind)
162 + .font(ZyquoFont.code(size: 11))
163 + .foregroundStyle(ZyquoColor.textTertiary)
164 + .frame(width: 70, alignment: .leading)
165 + Text(rule.pattern)
166 + .font(ZyquoFont.code(size: 11.5))
167 + .foregroundStyle(ZyquoColor.textPrimary)
168 + Spacer()
169 + Button {
170 + remove(rule, fromDeny: isDeny)
171 + } label: {
172 + Image(systemName: "trash")
173 + .font(.system(size: 10))
174 + .foregroundStyle(ZyquoColor.danger)
175 + }
176 + .buttonStyle(.plain)
177 + .help("Remove rule")
178 + }
179 + }
180 +
181 + // MARK: Persistence (same document the PolicyEngine loads per task start)
182 +
183 + private func load() {
184 + rules = PersistenceService.shared.load(StoredPolicyRules.self, from: Self.rulesFileName)
185 + ?? StoredPolicyRules()
186 + }
187 +
188 + private func save() {
189 + PersistenceService.shared.save(rules, to: Self.rulesFileName)
190 + }
191 +
192 + private func addRule() {
193 + let pattern = newPattern.trimmingCharacters(in: .whitespaces)
194 + guard !pattern.isEmpty else { return }
195 + let rule = StoredPolicyRule(kind: newKind, pattern: pattern)
196 + switch newList {
197 + case .allow:
198 + if !rules.allow.contains(rule) { rules.allow.append(rule) }
199 + case .deny:
200 + if !rules.deny.contains(rule) { rules.deny.append(rule) }
201 + }
202 + newPattern = ""
203 + save()
204 + }
205 +
206 + private func remove(_ rule: StoredPolicyRule, fromDeny: Bool) {
207 + if fromDeny {
208 + rules.deny.removeAll { $0 == rule }
209 + } else {
210 + rules.allow.removeAll { $0 == rule }
211 + }
212 + save()
213 + }
214 +}
added Sources/ZyquoAgent/Views/Settings/SettingsView.swift +59 −0
@@ -0,0 +1,59 @@
1 +//
2 +// SettingsView.swift
3 +// Zyquo Agent
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Settings window (760×560, native toolbar-style tabs): Providers & Keys,
9 +// Models, Safety, Agent, Appearance, Shortcuts, Advanced — same structure
10 +// and DNA as Zyquo Cloud's Settings, extended with the Agent tabs.
11 +//
12 +
13 +import AppKit
14 +import SwiftUI
15 +
16 +struct SettingsView: View {
17 + @EnvironmentObject private var appearance: AppearanceStore
18 +
19 + var body: some View {
20 + TabView {
21 + ProvidersSettingsTab()
22 + .tabItem { Label("Providers & Keys", systemImage: "key") }
23 + ModelsSettingsTab()
24 + .tabItem { Label("Models", systemImage: "cpu") }
25 + SafetySettingsTab()
26 + .tabItem { Label("Safety", systemImage: "shield.lefthalf.filled") }
27 + AgentSettingsTab()
28 + .tabItem { Label("Agent", systemImage: "gearshape.arrow.triangle.2.circlepath") }
29 + AppearanceSettingsTab()
30 + .tabItem { Label("Appearance", systemImage: "paintbrush") }
31 + ShortcutsSettingsTab()
32 + .tabItem { Label("Shortcuts", systemImage: "keyboard") }
33 + AdvancedSettingsTab()
34 + .tabItem { Label("Advanced", systemImage: "gearshape.2") }
35 + }
36 + .frame(width: ZyquoMetrics.settingsWidth, height: ZyquoMetrics.settingsHeight)
37 + .preferredColorScheme(appearance.themeMode.colorScheme)
38 + .tint(appearance.accentColor)
39 + .id(appearance.accent)
40 + }
41 +}
42 +
43 +/// Opens the SwiftUI Settings scene from anywhere (gear buttons, palette).
44 +/// macOS 14+ exposes `showSettingsWindow:`; macOS 13 kept the old
45 +/// `showPreferencesWindow:` selector — try both.
46 +@MainActor
47 +enum SettingsOpener {
48 + static func open() {
49 + NSApp.activate(ignoringOtherApps: true)
50 + let selectors = ["showSettingsWindow:", "showPreferencesWindow:"]
51 + for name in selectors {
52 + let selector = Selector(name)
53 + if NSApp.responds(to: selector) || NSApp.sendAction(selector, to: nil, from: nil) {
54 + NSApp.sendAction(selector, to: nil, from: nil)
55 + return
56 + }
57 + }
58 + }
59 +}
added Sources/ZyquoAgent/Views/Settings/ShortcutsSettingsTab.swift +39 −0
@@ -0,0 +1,39 @@
1 +//
2 +// ShortcutsSettingsTab.swift
3 +// Zyquo Agent
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Settings › Shortcuts — the read-only shortcut reference for the full
9 +// Phase 6 command set.
10 +//
11 +
12 +import SwiftUI
13 +
14 +struct ShortcutsSettingsTab: View {
15 + private static let shortcuts: [(String, String)] = [
16 + ("New task", "⌘N"),
17 + ("Command palette (templates · tasks · actions)", "⌘K"),
18 + ("Run task", "⌘↩"),
19 + ("Stop run", "⌘."),
20 + ("Search tasks", "⌘F"),
21 + ("Open audit log", "⌘⇧A"),
22 + ("Quick Task panel", "⌥Space"),
23 + ("Export transcript (Markdown)", "⌘⇧E"),
24 + ("Settings", "⌘,"),
25 + ]
26 +
27 + var body: some View {
28 + Form {
29 + ForEach(Self.shortcuts, id: \.0) { name, keys in
30 + LabeledContent(name) {
31 + Text(keys)
32 + .font(ZyquoFont.code(size: 12))
33 + .foregroundStyle(ZyquoColor.textSecondary)
34 + }
35 + }
36 + }
37 + .formStyle(.grouped)
38 + }
39 +}
deleted Sources/ZyquoAgent/Views/SettingsPlaceholderView.swift +0 −47
@@ -1,47 +0,0 @@
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 }
modified Sources/ZyquoAgent/Views/Sidebar/SidebarView.swift +10 −9
@@ -18,8 +18,8 @@ struct SidebarView: View {
18 18 @EnvironmentObject private var store: TaskStore
19 19 @EnvironmentObject private var hub: RunHub
20 20 @EnvironmentObject private var catalog: ModelCatalog
21 + @EnvironmentObject private var settings: AgentSettingsStore
21 22 @FocusState private var searchFocused: Bool
22 @State private var showingSettingsPlaceholder = false
23 23
24 24 var body: some View {
25 25 VStack(spacing: 0) {
@@ -37,9 +37,6 @@ struct SidebarView: View {
37 37 .keyboardShortcut("f", modifiers: .command)
38 38 .hidden()
39 39 )
40 .sheet(isPresented: $showingSettingsPlaceholder) {
41 SettingsPlaceholderView()
42 }
43 40 }
44 41
45 42 // MARK: - Sections
@@ -79,7 +76,10 @@ struct SidebarView: View {
79 76
80 77 private var newTaskButton: some View {
81 78 Button {
82 store.newTask(model: catalog.defaultAgentModel)
79 + store.newTask(
80 + model: settings.defaultAgentModel(in: catalog),
81 + safetyMode: settings.settings.defaultSafetyMode
82 + )
83 83 } label: {
84 84 HStack(spacing: ZyquoSpacing.xxs) {
85 85 Image(systemName: "plus.circle.fill")
@@ -124,14 +124,14 @@ struct SidebarView: View {
124 124 private var footer: some View {
125 125 HStack(spacing: ZyquoSpacing.xs) {
126 126 Button {
127 showingSettingsPlaceholder = true
127 + SettingsOpener.open()
128 128 } label: {
129 129 Image(systemName: "gearshape")
130 130 .font(.system(size: 13))
131 131 .foregroundStyle(ZyquoColor.textSecondary)
132 132 }
133 133 .buttonStyle(.plain)
134 .help("Settings")
134 + .help("Settings (⌘,)")
135 135 ZyquoBadge(text: footerSafetyMode.displayName, color: ZyquoColor.textSecondary)
136 136 Spacer(minLength: 0)
137 137 if let model = footerModel {
@@ -145,7 +145,8 @@ struct SidebarView: View {
145 145
146 146 /// Safety mode of the selected task (or the app default).
147 147 private var footerSafetyMode: SafetyMode {
148 store.selectedID.flatMap { store.task(id: $0)?.safetyMode } ?? .guarded
148 + store.selectedID.flatMap { store.task(id: $0)?.safetyMode }
149 + ?? settings.settings.defaultSafetyMode
149 150 }
150 151
151 152 /// Model of the selected task (or the default agent model).
@@ -154,7 +155,7 @@ struct SidebarView: View {
154 155 let model = catalog.model(id: task.modelID, provider: task.providerID) {
155 156 return model
156 157 }
157 return catalog.defaultAgentModel
158 + return settings.defaultAgentModel(in: catalog)
158 159 }
159 160 }
160 161
modified Sources/ZyquoAgent/Views/TaskDetailView.swift +48 −58
@@ -32,12 +32,13 @@ struct TaskDetailContent: View {
32 32 @EnvironmentObject private var catalog: ModelCatalog
33 33 @EnvironmentObject private var vault: KeyVaultStore
34 34 @EnvironmentObject private var appearance: AppearanceStore
35 + @EnvironmentObject private var personas: PersonaStore
36 + @EnvironmentObject private var uiState: AppUIState
35 37
36 38 @State private var draft = ""
37 39 @State private var editingTitle = false
38 40 @State private var titleDraft = ""
39 41 @State private var showingInfo = false
40 @State private var showingSettingsPlaceholder = false
41 42 @State private var planVisible = true
42 43 @State private var drawerVisible = false
43 44
@@ -92,9 +93,6 @@ struct TaskDetailContent: View {
92 93 .keyboardShortcut(".", modifiers: .command)
93 94 .hidden()
94 95 )
95 .sheet(isPresented: $showingSettingsPlaceholder) {
96 SettingsPlaceholderView()
97 }
98 96 .onAppear {
99 97 if let pending = store.pendingDraft {
100 98 draft = pending
@@ -104,6 +102,16 @@ struct TaskDetailContent: View {
104 102 .onChange(of: controller.isRunning) { running in
105 103 if running { drawerVisible = true }
106 104 }
105 + // App-level command signals (⌘⇧A, palette actions).
106 + .onChange(of: uiState.auditLogRequest) { _ in
107 + withAnimation(ZyquoMotion.appear) { drawerVisible = true }
108 + }
109 + .onChange(of: uiState.drawerToggleRequest) { _ in
110 + withAnimation(ZyquoMotion.appear) { drawerVisible.toggle() }
111 + }
112 + .onChange(of: uiState.planToggleRequest) { _ in
113 + withAnimation(ZyquoMotion.appear) { planVisible.toggle() }
114 + }
107 115 }
108 116
109 117 // MARK: - Header (52pt)
@@ -164,15 +172,22 @@ struct TaskDetailContent: View {
164 172 active: drawerVisible,
165 173 help: "Toggle activity drawer"
166 174 ) { withAnimation(ZyquoMotion.appear) { drawerVisible.toggle() } }
167 Button {
168 exportTranscript()
175 + Menu {
176 + Button("Export as Markdown…") {
177 + if let task { TaskTranscriptExporter.presentSavePanel(for: task, format: .markdown) }
178 + }
179 + Button("Export as PDF…") {
180 + if let task { TaskTranscriptExporter.presentSavePanel(for: task, format: .pdf) }
181 + }
169 182 } label: {
170 183 Image(systemName: "square.and.arrow.up")
171 184 .font(.system(size: 12))
172 185 .foregroundStyle(ZyquoColor.textSecondary)
173 186 }
174 .buttonStyle(.plain)
175 .help("Export task transcript as Markdown")
187 + .menuStyle(.borderlessButton)
188 + .menuIndicator(.hidden)
189 + .fixedSize()
190 + .help("Export task transcript (Markdown / PDF)")
176 191 Button {
177 192 showingInfo.toggle()
178 193 } label: {
@@ -268,12 +283,31 @@ struct TaskDetailContent: View {
268 283 AgentEmptyStateView(
269 284 model: currentModel,
270 285 safetyMode: task.safetyMode,
286 + personaName: personas.persona(id: task.personaID)?.name,
271 287 onSelectModel: select(model:),
272 288 onSelectSafetyMode: { controller.setSafetyMode($0) },
289 + onSelectPersona: { persona in select(persona: persona) },
290 + onBrowseTemplates: { uiState.showTemplateBrowser = true },
273 291 onSuggestion: { draft = $0 }
274 292 )
275 293 }
276 294
295 + /// Adopts a persona for this task: stores the id and applies its
296 + /// preferred model / safety default when set.
297 + private func select(persona: Persona?) {
298 + guard var task else { return }
299 + task.personaID = persona?.id
300 + if let persona, let id = persona.modelID, let provider = persona.provider,
301 + let preferred = catalog.model(id: id, provider: provider) {
302 + task.modelID = preferred.id
303 + task.providerID = preferred.provider
304 + }
305 + store.update(task, touch: false)
306 + if let mode = persona?.safetyMode {
307 + controller.setSafetyMode(mode)
308 + }
309 + }
310 +
277 311 private var noKeyForModel: Bool {
278 312 guard let model = currentModel else { return false }
279 313 return AgentCLI.resolveAPIKey(for: model.provider) == nil
@@ -286,7 +320,7 @@ struct TaskDetailContent: View {
286 320 Text("No API key for \(currentModel?.provider.displayName ?? "this provider") yet.")
287 321 .font(ZyquoFont.body(size: 12.5))
288 322 Button("Providers & Keys…") {
289 showingSettingsPlaceholder = true
323 + SettingsOpener.open()
290 324 }
291 325 .font(ZyquoFont.body(size: 12.5))
292 326 Spacer(minLength: 0)
@@ -318,7 +352,11 @@ struct TaskDetailContent: View {
318 352 guard let model = currentModel else { return }
319 353 let prompt = draft
320 354 withAnimation(ZyquoMotion.appear) { draft = "" }
321 controller.start(prompt: prompt, model: model)
355 + controller.start(
356 + prompt: prompt,
357 + model: model,
358 + persona: personas.persona(id: task?.personaID)
359 + )
322 360 }
323 361
324 362 private func select(model: AIModel) {
@@ -328,52 +366,4 @@ struct TaskDetailContent: View {
328 366 store.update(task, touch: false)
329 367 }
330 368
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 369 }
added Sources/ZyquoAgent/Views/Templates/TemplateBrowserView.swift +323 −0
@@ -0,0 +1,323 @@
1 +//
2 +// TemplateBrowserView.swift
3 +// Zyquo Agent
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// The template browser sheet (empty state "Browse templates", ⌘K, menu):
9 +// built-in library + user templates grouped by category, searchable, with
10 +// user-template CRUD. Using a template with {{variables}} opens the fill-in
11 +// sheet; the rendered prompt lands in a new task's input bar (pendingDraft).
12 +//
13 +
14 +import SwiftUI
15 +
16 +struct TemplateBrowserView: View {
17 + /// Called with the rendered prompt and the template's suggested mode.
18 + var onUse: (String, SafetyMode) -> Void
19 +
20 + @EnvironmentObject private var templates: TemplateStore
21 + @Environment(\.dismiss) private var dismiss
22 + @State private var query = ""
23 + @State private var fillingTemplate: TaskTemplate?
24 + @State private var editingTemplate: TaskTemplate?
25 + @State private var creatingTemplate = false
26 +
27 + var body: some View {
28 + VStack(spacing: 0) {
29 + header
30 + ZyquoHairline()
31 + ScrollView {
32 + LazyVStack(alignment: .leading, spacing: 1) {
33 + ForEach(TemplateCategory.allCases) { category in
34 + let matching = filtered(in: category)
35 + if !matching.isEmpty {
36 + sectionHeader(category)
37 + ForEach(matching) { template in
38 + templateRow(template)
39 + }
40 + }
41 + }
42 + }
43 + .padding(ZyquoSpacing.xs)
44 + }
45 + ZyquoHairline()
46 + footer
47 + }
48 + .frame(width: 560, height: 480)
49 + .background(ZyquoColor.surface)
50 + .sheet(item: $fillingTemplate) { template in
51 + TemplateFillSheet(template: template) { prompt in
52 + use(prompt: prompt, template: template)
53 + }
54 + }
55 + .sheet(item: $editingTemplate) { template in
56 + TemplateEditorSheet(template: template, isNew: false)
57 + }
58 + .sheet(isPresented: $creatingTemplate) {
59 + TemplateEditorSheet(
60 + template: TaskTemplate(title: "", category: .filesAndFolders, prompt: ""),
61 + isNew: true
62 + )
63 + }
64 + }
65 +
66 + private var header: some View {
67 + HStack(spacing: ZyquoSpacing.xs) {
68 + Image(systemName: "square.grid.2x2")
69 + .font(.system(size: 14))
70 + .foregroundStyle(ZyquoColor.accent)
71 + Text("Task Templates")
72 + .font(ZyquoFont.bodyEmphasis(size: 14))
73 + .foregroundStyle(ZyquoColor.textPrimary)
74 + Spacer()
75 + HStack(spacing: ZyquoSpacing.xxs) {
76 + Image(systemName: "magnifyingglass")
77 + .font(.system(size: 11))
78 + .foregroundStyle(ZyquoColor.textTertiary)
79 + TextField("Search templates", text: $query)
80 + .textFieldStyle(.plain)
81 + .font(ZyquoFont.body(size: 12.5))
82 + .frame(width: 160)
83 + }
84 + .padding(.horizontal, ZyquoSpacing.xs)
85 + .padding(.vertical, 4)
86 + .background(
87 + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)
88 + .fill(ZyquoColor.surfaceSecondary)
89 + )
90 + }
91 + .padding(ZyquoSpacing.sm)
92 + }
93 +
94 + private var footer: some View {
95 + HStack {
96 + Button {
97 + creatingTemplate = true
98 + } label: {
99 + Label("New Template", systemImage: "plus")
100 + }
101 + .controlSize(.small)
102 + Spacer()
103 + Button("Close") { dismiss() }
104 + .controlSize(.small)
105 + .keyboardShortcut(.cancelAction)
106 + }
107 + .padding(ZyquoSpacing.sm)
108 + }
109 +
110 + private func sectionHeader(_ category: TemplateCategory) -> some View {
111 + HStack(spacing: ZyquoSpacing.xxs) {
112 + Image(systemName: category.symbolName)
113 + .font(.system(size: 10))
114 + Text(category.displayName)
115 + .font(ZyquoFont.caption)
116 + }
117 + .foregroundStyle(ZyquoColor.textTertiary)
118 + .padding(.horizontal, ZyquoSpacing.xs)
119 + .padding(.top, ZyquoSpacing.sm)
120 + .padding(.bottom, 2)
121 + }
122 +
123 + private func templateRow(_ template: TaskTemplate) -> some View {
124 + Button {
125 + if template.variables.isEmpty {
126 + use(prompt: template.prompt, template: template)
127 + } else {
128 + fillingTemplate = template
129 + }
130 + } label: {
131 + HStack(spacing: ZyquoSpacing.xs) {
132 + Image(systemName: template.displaySymbol)
133 + .font(.system(size: 12))
134 + .foregroundStyle(ZyquoColor.accent)
135 + .frame(width: 18)
136 + VStack(alignment: .leading, spacing: 1) {
137 + HStack(spacing: ZyquoSpacing.xxs) {
138 + Text(template.title)
139 + .font(ZyquoFont.body(size: 13))
140 + .foregroundStyle(ZyquoColor.textPrimary)
141 + if !template.isBuiltIn {
142 + ZyquoBadge(text: "yours", color: ZyquoColor.success)
143 + }
144 + if !template.variables.isEmpty {
145 + ZyquoBadge(text: "\(template.variables.count) variable\(template.variables.count == 1 ? "" : "s")")
146 + }
147 + }
148 + Text(template.prompt)
149 + .font(ZyquoFont.caption)
150 + .foregroundStyle(ZyquoColor.textTertiary)
151 + .lineLimit(1)
152 + }
153 + Spacer(minLength: 0)
154 + ZyquoBadge(text: template.suggestedSafetyMode.displayName, color: ZyquoColor.textSecondary)
155 + if !template.isBuiltIn {
156 + Button {
157 + editingTemplate = template
158 + } label: {
159 + Image(systemName: "pencil")
160 + .font(.system(size: 10))
161 + .foregroundStyle(ZyquoColor.textSecondary)
162 + }
163 + .buttonStyle(.plain)
164 + .help("Edit template")
165 + }
166 + }
167 + .padding(.horizontal, ZyquoSpacing.xs)
168 + .padding(.vertical, 5)
169 + .contentShape(Rectangle())
170 + }
171 + .buttonStyle(.plain)
172 + .zyquoHoverHighlight()
173 + }
174 +
175 + private func filtered(in category: TemplateCategory) -> [TaskTemplate] {
176 + let all = templates.templates(in: category)
177 + let trimmed = query.trimmingCharacters(in: .whitespaces)
178 + guard !trimmed.isEmpty else { return all }
179 + return all.filter {
180 + $0.title.localizedCaseInsensitiveContains(trimmed)
181 + || $0.prompt.localizedCaseInsensitiveContains(trimmed)
182 + }
183 + }
184 +
185 + private func use(prompt: String, template: TaskTemplate) {
186 + dismiss()
187 + onUse(prompt, template.suggestedSafetyMode)
188 + }
189 +}
190 +
191 +// MARK: - Variable fill-in
192 +
193 +struct TemplateFillSheet: View {
194 + let template: TaskTemplate
195 + var onSubmit: (String) -> Void
196 +
197 + @Environment(\.dismiss) private var dismiss
198 + @State private var values: [String: String] = [:]
199 +
200 + var body: some View {
201 + VStack(alignment: .leading, spacing: ZyquoSpacing.sm) {
202 + HStack(spacing: ZyquoSpacing.xs) {
203 + Image(systemName: template.displaySymbol)
204 + .font(.system(size: 14))
205 + .foregroundStyle(ZyquoColor.accent)
206 + Text(template.title)
207 + .font(ZyquoFont.title)
208 + .foregroundStyle(ZyquoColor.textPrimary)
209 + }
210 + Form {
211 + ForEach(template.variables, id: \.self) { name in
212 + TextField(name.capitalized, text: Binding(
213 + get: { values[name] ?? "" },
214 + set: { values[name] = $0 }
215 + ))
216 + }
217 + }
218 + Text(preview)
219 + .font(ZyquoFont.body(size: 12))
220 + .foregroundStyle(ZyquoColor.textSecondary)
221 + .lineLimit(5)
222 + .padding(ZyquoSpacing.xs)
223 + .frame(maxWidth: .infinity, alignment: .leading)
224 + .background(
225 + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)
226 + .fill(ZyquoColor.surfaceSecondary)
227 + )
228 + HStack {
229 + Spacer()
230 + Button("Cancel") { dismiss() }
231 + Button("Use Template") {
232 + dismiss()
233 + onSubmit(template.renderedPrompt(values: values))
234 + }
235 + .buttonStyle(.borderedProminent)
236 + .keyboardShortcut(.defaultAction)
237 + .disabled(!allFilled)
238 + }
239 + }
240 + .padding(ZyquoSpacing.xl)
241 + .frame(width: 440)
242 + .background(ZyquoColor.surface)
243 + }
244 +
245 + private var allFilled: Bool {
246 + template.variables.allSatisfy {
247 + !(values[$0] ?? "").trimmingCharacters(in: .whitespaces).isEmpty
248 + }
249 + }
250 +
251 + private var preview: String {
252 + template.renderedPrompt(values: values)
253 + }
254 +}
255 +
256 +// MARK: - User-template editor
257 +
258 +struct TemplateEditorSheet: View {
259 + @State var template: TaskTemplate
260 + let isNew: Bool
261 +
262 + @EnvironmentObject private var templates: TemplateStore
263 + @Environment(\.dismiss) private var dismiss
264 +
265 + var body: some View {
266 + VStack(alignment: .leading, spacing: ZyquoSpacing.sm) {
267 + Text(isNew ? "New Template" : "Edit Template")
268 + .font(ZyquoFont.title)
269 + .foregroundStyle(ZyquoColor.textPrimary)
270 + Form {
271 + TextField("Title", text: $template.title)
272 + Picker("Category", selection: $template.category) {
273 + ForEach(TemplateCategory.allCases) { category in
274 + Text(category.displayName).tag(category)
275 + }
276 + }
277 + Picker("Suggested safety mode", selection: $template.suggestedSafetyMode) {
278 + ForEach(SafetyMode.allCases) { mode in
279 + Text(mode.displayName).tag(mode)
280 + }
281 + }
282 + VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) {
283 + Text("Prompt — use {{variable}} for fill-in placeholders")
284 + .font(ZyquoFont.caption)
285 + .foregroundStyle(ZyquoColor.textSecondary)
286 + TextEditor(text: $template.prompt)
287 + .font(ZyquoFont.body(size: 12.5))
288 + .frame(height: 120)
289 + .padding(ZyquoSpacing.xxs)
290 + .background(
291 + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)
292 + .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline)
293 + )
294 + }
295 + }
296 + HStack {
297 + if !isNew {
298 + Button("Delete", role: .destructive) {
299 + templates.delete(template.id)
300 + dismiss()
301 + }
302 + }
303 + Spacer()
304 + Button("Cancel") { dismiss() }
305 + Button("Save") {
306 + if isNew {
307 + templates.add(template)
308 + } else {
309 + templates.update(template)
310 + }
311 + dismiss()
312 + }
313 + .buttonStyle(.borderedProminent)
314 + .keyboardShortcut(.defaultAction)
315 + .disabled(template.title.trimmingCharacters(in: .whitespaces).isEmpty
316 + || template.prompt.trimmingCharacters(in: .whitespaces).isEmpty)
317 + }
318 + }
319 + .padding(ZyquoSpacing.xl)
320 + .frame(width: 480)
321 + .background(ZyquoColor.surface)
322 + }
323 +}
modified Sources/ZyquoAgent/Views/TerminalDrawerView.swift +5 −0
@@ -18,6 +18,7 @@ import SwiftUI
18 18
19 19 struct TerminalDrawerView: View {
20 20 @ObservedObject var controller: RunController
21 + @EnvironmentObject private var uiState: AppUIState
21 22
22 23 enum Tab: String, CaseIterable, Identifiable {
23 24 case live = "Live"
@@ -48,6 +49,10 @@ struct TerminalDrawerView: View {
48 49 case .live: break
49 50 }
50 51 }
52 + // ⌘⇧A / palette: land on the Audit Log tab.
53 + .onChange(of: uiState.auditLogRequest) { _ in
54 + withAnimation(ZyquoMotion.picker) { tab = .audit }
55 + }
51 56 .sheet(item: $previewedFile) { entry in
52 57 FilePreviewSheet(entry: entry, workspaceRoot: controller.workspaceRoot)
53 58 }
54 59