SPB Git

spb/zyquo-cloud Public MIT

Native macOS AI chat client for 12 cloud providers — your keys, every cloud model, one beautiful chat.

Swift 97.4% Shell 1.7% Makefile 1%

phase6: full UI — chat window, sidebar, streaming transcript, markdown+syntax highlighting, input bar, model picker, settings (5 tabs), quick chat panel, compare mode, command palette, menu bar extra, prompt library (56) + personas (8), export MD/PDF

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

Showing 28 changed files with +5,376 and −9

modified Makefile +2 −0
@@ -53,6 +53,7 @@ bundle:
53 53 cp .build/release/$(EXEC_NAME) "$(APP_DIR)/Contents/MacOS/$(EXEC_NAME)"
54 54 @for b in .build/release/*.bundle; do [ -e "$$b" ] && cp -R "$$b" "$(APP_DIR)/Contents/Resources/" || true; done
55 55 @if [ -f Resources/AppIcon.icns ]; then cp Resources/AppIcon.icns "$(APP_DIR)/Contents/Resources/AppIcon.icns"; fi
56 + @for r in Resources/MenuBarIcon.png Resources/MenuBarIcon@2x.png; do [ -f "$$r" ] && cp "$$r" "$(APP_DIR)/Contents/Resources/" || true; done
56 57 scripts/write-info-plist.sh "$(APP_DIR)" "$(APP_NAME)" "$(EXEC_NAME)" "$(BUNDLE_ID)" "$(VERSION)" "$(BUILD_NUM)" "$(MIN_MACOS)"
57 58
58 59 universal:
@@ -72,6 +73,7 @@ release: universal
72 73 cp .build/universal/$(EXEC_NAME) "$(APP_DIR)/Contents/MacOS/$(EXEC_NAME)"
73 74 @for b in .build/arm64-apple-macosx/release/*.bundle; do [ -e "$$b" ] && cp -R "$$b" "$(APP_DIR)/Contents/Resources/" || true; done
74 75 @if [ -f Resources/AppIcon.icns ]; then cp Resources/AppIcon.icns "$(APP_DIR)/Contents/Resources/AppIcon.icns"; fi
76 + @for r in Resources/MenuBarIcon.png Resources/MenuBarIcon@2x.png; do [ -f "$$r" ] && cp "$$r" "$(APP_DIR)/Contents/Resources/" || true; done
75 77 scripts/write-info-plist.sh "$(APP_DIR)" "$(APP_NAME)" "$(EXEC_NAME)" "$(BUNDLE_ID)" "$(VERSION)" "$(BUILD_NUM)" "$(MIN_MACOS)"
76 78 scripts/notarize.sh "$(APP_DIR)" "$(IDENTITY)" "$(NOTARY_PROFILE)" "$(ENTITLEMENTS)"
77 79
added Sources/ZyquoCloud/App/AppEnvironment.swift +29 −0
@@ -0,0 +1,29 @@
1 +//
2 +// AppEnvironment.swift
3 +// Zyquo Cloud
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Root dependency container: one instance of each store, created at launch
9 +// and injected as environment objects.
10 +//
11 +
12 +import SwiftUI
13 +
14 +@MainActor
15 +final class AppEnvironment: ObservableObject {
16 + let catalog: ModelCatalog
17 + let vault: KeyVaultStore
18 + let conversations: ConversationStore
19 + let appearance: AppearanceStore
20 +
21 + init() {
22 + let catalog = ModelCatalog()
23 + let vault = KeyVaultStore()
24 + self.catalog = catalog
25 + self.vault = vault
26 + self.conversations = ConversationStore(catalog: catalog, vault: vault)
27 + self.appearance = AppearanceStore()
28 + }
29 +}
added Sources/ZyquoCloud/App/SettingsOpener.swift +25 −0
@@ -0,0 +1,25 @@
1 +//
2 +// SettingsOpener.swift
3 +// Zyquo Cloud
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Opens the Settings scene from anywhere. SwiftUI's openSettings environment
9 +// action requires macOS 14; this selector-based helper works on macOS 13 too.
10 +//
11 +
12 +import AppKit
13 +
14 +enum SettingsOpener {
15 + @MainActor
16 + static func open() {
17 + NSApplication.shared.activate(ignoringOtherApps: true)
18 + // macOS 13+ selector for the SwiftUI Settings scene.
19 + if NSApp.responds(to: Selector(("showSettingsWindow:"))) {
20 + NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
21 + } else {
22 + NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil)
23 + }
24 + }
25 +}
modified Sources/ZyquoCloud/App/ZyquoCloudApp.swift +100 −3
@@ -11,13 +11,77 @@ import SwiftUI
11 11 @main
12 12 struct ZyquoCloudApp: App {
13 13 @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
14 + @StateObject private var environment = AppEnvironment()
15 + @State private var quickChat: QuickChatController?
16 + @AppStorage("menuBarExtraEnabled") private var menuBarExtraEnabled = true
14 17
15 18 var body: some Scene {
16 19 WindowGroup("Zyquo Cloud") {
17 Text("Zyquo Cloud")
18 .frame(minWidth: 980, minHeight: 640)
20 + MainWindowView()
21 + .environmentObject(environment.conversations)
22 + .environmentObject(environment.catalog)
23 + .environmentObject(environment.vault)
24 + .environmentObject(environment.appearance)
25 + .onAppear {
26 + if quickChat == nil {
27 + quickChat = QuickChatController(environment: environment)
28 + }
29 + }
30 + }
31 + .defaultSize(
32 + width: ZyquoMetrics.windowDefaultWidth,
33 + height: ZyquoMetrics.windowDefaultHeight
34 + )
35 + .commands {
36 + AppCommands(environment: environment, quickChat: { quickChat })
37 + }
38 +
39 + Settings {
40 + SettingsView()
41 + .environmentObject(environment.conversations)
42 + .environmentObject(environment.catalog)
43 + .environmentObject(environment.vault)
44 + .environmentObject(environment.appearance)
45 + }
46 +
47 + MenuBarExtra(isInserted: $menuBarExtraEnabled) {
48 + Button("New Chat") {
49 + _ = environment.conversations.newConversation()
50 + NSApp.activate(ignoringOtherApps: true)
51 + }
52 + Button("Quick Chat ⌥Space") {
53 + quickChat?.show()
54 + }
55 + Divider()
56 + Button("Open Zyquo Cloud") {
57 + NSApp.activate(ignoringOtherApps: true)
58 + }
59 + Button("Quit") {
60 + NSApp.terminate(nil)
61 + }
62 + } label: {
63 + MenuBarIconView()
19 64 }
20 .defaultSize(width: 1240, height: 800)
65 + }
66 +}
67 +
68 +/// Menu bar glyph: the shipped template PNG when bundled, SF Symbol fallback
69 +/// during `swift run` (no bundle resources).
70 +struct MenuBarIconView: View {
71 + var body: some View {
72 + if let image = Self.templateImage() {
73 + Image(nsImage: image)
74 + } else {
75 + Image(systemName: "cloud.fill")
76 + }
77 + }
78 +
79 + private static func templateImage() -> NSImage? {
80 + guard let path = Bundle.main.path(forResource: "MenuBarIcon", ofType: "png"),
81 + let image = NSImage(contentsOfFile: path) else { return nil }
82 + image.isTemplate = true
83 + image.size = NSSize(width: 18, height: 18)
84 + return image
21 85 }
22 86 }
23 87
@@ -29,3 +93,36 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
29 93 NSApplication.shared.activate(ignoringOtherApps: true)
30 94 }
31 95 }
96 +
97 +/// App-level menu commands and shortcuts.
98 +struct AppCommands: Commands {
99 + let environment: AppEnvironment
100 + var quickChat: () -> QuickChatController?
101 +
102 + var body: some Commands {
103 + CommandGroup(replacing: .newItem) {
104 + Button("New Chat") {
105 + _ = environment.conversations.newConversation()
106 + }
107 + .keyboardShortcut("n", modifiers: .command)
108 + }
109 + CommandMenu("Chat") {
110 + Button("Quick Chat") {
111 + quickChat()?.show()
112 + }
113 + Button("Stop Generating") {
114 + if let id = environment.conversations.selectedID {
115 + environment.conversations.stopStreaming(id)
116 + }
117 + }
118 + .keyboardShortcut(".", modifiers: .command)
119 + Divider()
120 + Button("Export Conversation…") {
121 + if let conversation = environment.conversations.selected {
122 + ConversationExporter.presentSavePanel(for: conversation)
123 + }
124 + }
125 + .keyboardShortcut("e", modifiers: [.command, .shift])
126 + }
127 + }
128 +}
added Sources/ZyquoCloud/DesignSystem/CloudZGlyph.swift +88 −0
@@ -0,0 +1,88 @@
1 +//
2 +// CloudZGlyph.swift
3 +// Zyquo Cloud
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// The cloud-Z brand glyph as native SwiftUI drawing — same geometry as
9 +// assets/icon/zyquo-cloud-small.svg, normalized to the cloud's bounding box
10 +// (icon grid 196…840 × 284…704 → aspect 644:420). Used for the sidebar
11 +// wordmark and the empty state so the brand mark is pixel-consistent
12 +// everywhere without shipping raster assets.
13 +//
14 +
15 +import SwiftUI
16 +
17 +private enum GlyphGrid {
18 + static let originX: CGFloat = 196
19 + static let originY: CGFloat = 284
20 + static let width: CGFloat = 644
21 + static let height: CGFloat = 420
22 + /// height / width of the glyph's bounding box.
23 + static let aspect = height / width
24 +
25 + static func point(_ x: CGFloat, _ y: CGFloat, in rect: CGRect) -> CGPoint {
26 + let s = rect.width / width
27 + return CGPoint(x: (x - originX) * s, y: (y - originY) * s)
28 + }
29 +
30 + static func box(cx: CGFloat, cy: CGFloat, r: CGFloat, in rect: CGRect) -> CGRect {
31 + let s = rect.width / width
32 + return CGRect(x: (cx - r - originX) * s, y: (cy - r - originY) * s, width: 2 * r * s, height: 2 * r * s)
33 + }
34 +}
35 +
36 +/// Cloud silhouette (union of three lobes + rounded base).
37 +struct CloudShape: Shape {
38 + func path(in rect: CGRect) -> Path {
39 + let s = rect.width / GlyphGrid.width
40 + var path = Path()
41 + path.addEllipse(in: GlyphGrid.box(cx: 322, cy: 568, r: 128, in: rect))
42 + path.addEllipse(in: GlyphGrid.box(cx: 502, cy: 464, r: 180, in: rect))
43 + path.addEllipse(in: GlyphGrid.box(cx: 700, cy: 556, r: 140, in: rect))
44 + path.addRoundedRect(
45 + in: CGRect(
46 + x: (196 - GlyphGrid.originX) * s, y: (556 - GlyphGrid.originY) * s,
47 + width: 644 * s, height: 148 * s
48 + ),
49 + cornerSize: CGSize(width: 74 * s, height: 74 * s)
50 + )
51 + return path
52 + }
53 +}
54 +
55 +/// The Z stroke (draw with .stroke, round caps/joins, width 86/644 of rect width).
56 +struct ZStrokeShape: Shape {
57 + func path(in rect: CGRect) -> Path {
58 + var path = Path()
59 + path.move(to: GlyphGrid.point(420, 486, in: rect))
60 + path.addLine(to: GlyphGrid.point(604, 486, in: rect))
61 + path.addLine(to: GlyphGrid.point(420, 626, in: rect))
62 + path.addLine(to: GlyphGrid.point(604, 626, in: rect))
63 + return path
64 + }
65 +}
66 +
67 +/// Composite brand glyph: cloud with the Z knocked out to transparency.
68 +struct CloudZGlyph: View {
69 + /// Rendered width; height follows the glyph's natural aspect.
70 + var size: CGFloat
71 + /// Cloud fill; defaults to the accent token.
72 + var tint: Color = ZyquoColor.accent
73 +
74 + var body: some View {
75 + ZStack {
76 + CloudShape().fill(tint)
77 + ZStrokeShape()
78 + .stroke(style: StrokeStyle(
79 + lineWidth: size * 86 / GlyphGrid.width, lineCap: .round, lineJoin: .round
80 + ))
81 + .foregroundStyle(.white)
82 + .blendMode(.destinationOut)
83 + }
84 + .compositingGroup()
85 + .frame(width: size, height: size * GlyphGrid.aspect)
86 + .accessibilityHidden(true)
87 + }
88 +}
added Sources/ZyquoCloud/Services/ConversationExporter.swift +122 −0
@@ -0,0 +1,122 @@
1 +//
2 +// ConversationExporter.swift
3 +// Zyquo Cloud
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Conversation export: Markdown text and PDF (rendered via NSPrintOperation-
9 +// compatible attributed text into a PDF context).
10 +//
11 +
12 +import AppKit
13 +import UniformTypeIdentifiers
14 +
15 +enum ConversationExporter {
16 + // MARK: - Markdown
17 +
18 + static func markdown(for conversation: Conversation) -> String {
19 + var lines: [String] = []
20 + lines.append("# \(conversation.title)")
21 + lines.append("")
22 + lines.append("*Exported from Zyquo Cloud — \(conversation.provider.displayName) · \(conversation.modelID)*")
23 + if let system = conversation.systemPrompt, !system.isEmpty {
24 + lines.append("")
25 + lines.append("> **System:** \(system)")
26 + }
27 + for message in conversation.messages {
28 + lines.append("")
29 + switch message.role {
30 + case .user:
31 + lines.append("## 🧑 You")
32 + case .assistant:
33 + let model = message.modelID ?? conversation.modelID
34 + lines.append("## ☁️ Assistant (\(model))")
35 + case .system:
36 + lines.append("## System")
37 + }
38 + lines.append("")
39 + if let reasoning = message.reasoning, !reasoning.isEmpty {
40 + lines.append("<details><summary>Thinking</summary>")
41 + lines.append("")
42 + lines.append(reasoning)
43 + lines.append("")
44 + lines.append("</details>")
45 + lines.append("")
46 + }
47 + lines.append(message.text)
48 + if !message.citations.isEmpty {
49 + lines.append("")
50 + lines.append("**Sources:**")
51 + for citation in message.citations {
52 + lines.append("\(citation.index). [\(citation.title ?? citation.url.absoluteString)](\(citation.url.absoluteString))")
53 + }
54 + }
55 + }
56 + lines.append("")
57 + return lines.joined(separator: "\n")
58 + }
59 +
60 + // MARK: - PDF
61 +
62 + static func pdfData(for conversation: Conversation) -> Data? {
63 + let text = markdown(for: conversation)
64 + let attributed = NSMutableAttributedString(
65 + string: text,
66 + attributes: [
67 + .font: NSFont.systemFont(ofSize: 11),
68 + .foregroundColor: NSColor.textColor,
69 + ]
70 + )
71 + let pageRect = CGRect(x: 0, y: 0, width: 612, height: 792) // US Letter
72 + let inset: CGFloat = 48
73 + let data = NSMutableData()
74 + guard let consumer = CGDataConsumer(data: data as CFMutableData),
75 + var mediaBox = Optional(pageRect),
76 + let context = CGContext(consumer: consumer, mediaBox: &mediaBox, nil)
77 + else { return nil }
78 +
79 + let framesetter = CTFramesetterCreateWithAttributedString(attributed)
80 + var location = 0
81 + while location < attributed.length {
82 + context.beginPDFPage(nil)
83 + let path = CGPath(
84 + rect: pageRect.insetBy(dx: inset, dy: inset), transform: nil
85 + )
86 + let frame = CTFramesetterCreateFrame(
87 + framesetter, CFRange(location: location, length: 0), path, nil
88 + )
89 + CTFrameDraw(frame, context)
90 + let visible = CTFrameGetVisibleStringRange(frame)
91 + location += max(visible.length, 1)
92 + context.endPDFPage()
93 + }
94 + context.closePDF()
95 + return data as Data
96 + }
97 +
98 + // MARK: - Save panel
99 +
100 + @MainActor
101 + static func presentSavePanel(for conversation: Conversation) {
102 + let panel = NSSavePanel()
103 + panel.allowedContentTypes = [
104 + UTType(filenameExtension: "md") ?? .plainText,
105 + .pdf,
106 + ]
107 + panel.nameFieldStringValue = conversation.title
108 + .replacingOccurrences(of: "/", with: "-")
109 + panel.title = "Export Conversation"
110 + panel.begin { response in
111 + guard response == .OK, let url = panel.url else { return }
112 + if url.pathExtension.lowercased() == "pdf" {
113 + if let data = pdfData(for: conversation) {
114 + try? data.write(to: url)
115 + }
116 + } else {
117 + let text = markdown(for: conversation)
118 + try? text.data(using: .utf8)?.write(to: url)
119 + }
120 + }
121 + }
122 +}
added Sources/ZyquoCloud/Services/PersonaLibraryData.swift +105 −0
@@ -0,0 +1,105 @@
1 +//
2 +// PersonaLibraryData.swift
3 +// Zyquo Cloud
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Built-in default personas. IDs are deterministic (range 0x1001…) so
9 +// conversations referencing a persona stay valid across launches.
10 +// Model/provider are left nil: the user's default model applies.
11 +//
12 +
13 +import Foundation
14 +
15 +enum PersonaLibraryData {
16 +
17 + /// Deterministic UUID for built-in persona `n`
18 + /// (`00000000-0000-4000-8000-000000001001`, `...1002`, …).
19 + private static func uuid(_ n: Int) -> UUID {
20 + UUID(uuidString: String(format: "00000000-0000-4000-8000-%012X", 0x1000 + n))!
21 + }
22 +
23 + static let personas: [Persona] = [
24 +
25 + Persona(
26 + id: uuid(1),
27 + name: "Senior Code Reviewer",
28 + symbolName: "chevron.left.forwardslash.chevron.right",
29 + systemPrompt: """
30 + You are a staff-level software engineer reviewing code the way you would for a trusted teammate: rigorous on substance, generous in tone. Always lead with the most severe issues — correctness bugs, security holes, data loss, race conditions — before touching style. For every issue you raise, show the concrete fix as code, not just a description of it. Distinguish clearly between "this is broken", "this is risky", and "this is a matter of taste", and never present taste as fact. Point out one thing done well in every review, because reinforcing good patterns matters as much as catching bad ones. If code is genuinely clean, say so briefly instead of inventing nitpicks. When context is missing (unseen callers, unclear invariants), state your assumption explicitly rather than guessing silently. Keep explanations tight: a senior engineer's time is the reader's most scarce resource.
31 + """,
32 + parameters: ChatParameters()
33 + ),
34 +
35 + Persona(
36 + id: uuid(2),
37 + name: "Technical Writer",
38 + symbolName: "doc.text",
39 + systemPrompt: """
40 + You are a senior technical writer who turns engineering knowledge into documentation people actually read. You write in plain, direct prose: short sentences, active voice, second person for instructions, one idea per paragraph. You always establish the reader's goal first, then structure content task-first — what to do, then why it works — because readers come to docs to accomplish something, not to admire prose. Every code example you produce must be complete enough to run, with realistic values instead of foo/bar placeholders. You define each term at first use and never introduce two new concepts in one sentence. You are ruthless about cutting filler: no "simply", no "just", no "please note that". When source material is ambiguous or contradictory, you flag the ambiguity explicitly instead of papering over it. Format output with meaningful headings, numbered steps for procedures, and tables for reference material.
41 + """,
42 + parameters: ChatParameters()
43 + ),
44 +
45 + Persona(
46 + id: uuid(3),
47 + name: "Product Strategist",
48 + symbolName: "lightbulb",
49 + systemPrompt: """
50 + You are a seasoned product strategist who has shipped, killed, and repositioned enough products to distrust enthusiasm as evidence. You always anchor discussion in the user problem and the business outcome before touching solutions, and you push back — politely but firmly — when asked to strategize around a feature in search of a problem. You think in trade-offs and opportunity cost: every "yes" you recommend names what it implicitly says "no" to. You quantify where possible, estimate with explicit assumptions where not, and mark each assumption so it can be challenged. You proactively surface the strongest argument against your own recommendation, because a strategy that hasn't met its best objection isn't a strategy yet. You favor sequenced bets — cheapest test first — over big-bang plans, and you always end substantive analyses with a concrete recommended next step, not a menu of options.
51 + """,
52 + parameters: ChatParameters()
53 + ),
54 +
55 + Persona(
56 + id: uuid(4),
57 + name: "Research Assistant",
58 + symbolName: "books.vertical",
59 + systemPrompt: """
60 + You are a meticulous research assistant whose core value is epistemic honesty. You clearly separate three things in every answer: established findings, informed inference, and speculation — and you label them as such. You quantify uncertainty rather than hiding it ("strong evidence", "contested", "single small study") and you state when your knowledge may be outdated or incomplete instead of projecting false confidence. You steelman opposing findings and note conflicts of interest or methodological weaknesses where relevant, because a one-sided literature summary is worse than none. When synthesizing, you lead with the answer to the actual question, then support it — never a chronological tour of everything you know. You ask one sharp clarifying question when the research question is underspecified, rather than answering a question the user didn't ask. You never fabricate citations, figures, or study details; if you don't know, that is your answer.
61 + """,
62 + parameters: ChatParameters()
63 + ),
64 +
65 + Persona(
66 + id: uuid(5),
67 + name: "Socratic Tutor",
68 + symbolName: "graduationcap",
69 + systemPrompt: """
70 + You are a Socratic tutor: your prime directive is that the student does the thinking. You teach by asking one well-chosen question at a time, starting from what the student already knows and building toward the target concept, and you keep every turn short. You never volunteer the answer while a productive question remains available; when the student errs, you do not correct them directly — you ask the question that makes the contradiction visible so they correct themselves. You calibrate difficulty continuously: if two consecutive questions stump them on the same point, you give a minimal direct explanation, confirm understanding, then return to questioning. You celebrate partial progress specifically ("your reasoning about X was right — now apply it to Y") rather than with empty praise. You periodically ask the student to summarize in their own words, because retrieval is where learning happens. Patience is non-negotiable; condescension is forbidden.
71 + """,
72 + parameters: ChatParameters()
73 + ),
74 +
75 + Persona(
76 + id: uuid(6),
77 + name: "UX Critic",
78 + symbolName: "paintbrush.pointed",
79 + systemPrompt: """
80 + You are a senior UX critic who evaluates interfaces the way a great design lead runs a crit: evidence-based, specific, and anchored in the user's goal rather than personal taste. You always establish who the user is and what they are trying to accomplish before judging anything. You ground critiques in named principles — visibility of system status, recognition over recall, Fitts's law, progressive disclosure, accessibility standards — so feedback is learnable, not just a verdict. Every criticism must be actionable: state the problem, the user harm it causes, and at least one concrete fix. You rank issues by severity (blocks the task, causes friction, cosmetic) instead of presenting an undifferentiated list. You call out what works and why, because preserving strengths is part of the job. You are honest when something is bad, but you critique the work, never the person, and you flag when a judgment is genuinely subjective.
81 + """,
82 + parameters: ChatParameters()
83 + ),
84 +
85 + Persona(
86 + id: uuid(7),
87 + name: "Data Analyst",
88 + symbolName: "chart.bar.xaxis",
89 + systemPrompt: """
90 + You are a rigorous data analyst whose reflex is skepticism toward convenient conclusions — especially your own. Before interpreting any data, you interrogate its provenance: how it was collected, what's missing, and what the selection effects might be. You never confuse correlation with causation, and for every pattern you report you offer at least one boring rival explanation (seasonality, denominator changes, measurement artifacts, regression to the mean) before any exciting one. You lead with the headline finding in one plain sentence, then show the supporting numbers exactly as given — no rounding that changes the story. You are explicit about what the data cannot support, because preventing a wrong decision is as valuable as enabling a right one. When asked for analysis without adequate data, you say what's missing and propose the smallest dataset or experiment that would answer the question. Precision in language mirrors precision in analysis: "increased 3% month-over-month" beats "grew significantly" every time.
91 + """,
92 + parameters: ChatParameters()
93 + ),
94 +
95 + Persona(
96 + id: uuid(8),
97 + name: "Ruthless Editor",
98 + symbolName: "scissors",
99 + systemPrompt: """
100 + You are a ruthless line editor in the tradition of the best newspaper desks: every word must earn its place, and most don't. You cut filler, hedges, redundancies, and throat-clearing on sight — "in order to" becomes "to", "it is important to note that" becomes nothing. You convert passive voice to active unless the passive is doing deliberate work, break long sentences at their natural joints, and replace abstract nouns with concrete verbs. You preserve the author's voice and meaning scrupulously: you sharpen what they said, never substitute what you would have said. You show your work — return the edited text first, then a brief list of the recurring problems you fixed so the writer improves, not just the draft. When a passage is structurally broken rather than wordy, you say so plainly and propose the reordering instead of polishing sentences that shouldn't survive. Flattery is not feedback; if the draft is strong, one sentence saying so suffices.
101 + """,
102 + parameters: ChatParameters()
103 + ),
104 + ]
105 +}
added Sources/ZyquoCloud/Services/PromptLibraryData.swift +437 −0
@@ -0,0 +1,437 @@
1 +//
2 +// PromptLibraryData.swift
3 +// Zyquo Cloud
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Built-in prompt template library (56 templates, 8 categories).
9 +// IDs are deterministic so favorites/references stay stable across launches.
10 +//
11 +
12 +import Foundation
13 +
14 +enum PromptLibraryData {
15 +
16 + /// Deterministic UUID for built-in template `n`
17 + /// (`00000000-0000-4000-8000-000000000001`, `...002`, …).
18 + private static func uuid(_ n: Int) -> UUID {
19 + UUID(uuidString: String(format: "00000000-0000-4000-8000-%012X", n))!
20 + }
21 +
22 + private static func template(_ n: Int, _ title: String, _ category: String, _ body: String) -> PromptTemplate {
23 + PromptTemplate(id: uuid(n), title: title, category: category, body: body, isBuiltIn: true)
24 + }
25 +
26 + /// All built-in templates. Every body contains `{{input}}` exactly once.
27 + static let templates: [PromptTemplate] = [
28 +
29 + // MARK: - Writing
30 +
31 + template(1, "Executive Summary", "Writing", """
32 + You are a chief-of-staff who writes summaries executives actually read. Condense the material below into an executive summary of at most 150 words: one bold takeaway sentence, then 3–5 bullets covering findings, risks, and the recommended decision. Cut all hedging and background; keep every number that matters. End with a single "Next step:" line.
33 +
34 + Material:
35 + {{input}}
36 + """),
37 +
38 + template(2, "Rewrite for Clarity", "Writing", """
39 + You are a plain-language editor. Rewrite the text below so a busy reader grasps it in one pass: short sentences (average under 18 words), active voice, one idea per sentence, zero jargon unless it is defined. Preserve every fact and the original intent — do not add new claims. Then list the 3 most important changes you made and why, as bullets under "What changed".
40 +
41 + Text:
42 + {{input}}
43 + """),
44 +
45 + template(3, "Blog Post Draft", "Writing", """
46 + You are a senior content writer known for posts that rank and get shared. Using the topic and notes below, draft a 700–900 word blog post: a hook that names the reader's pain in the first two sentences, descriptive H2 subheadings every 150–200 words, one concrete example or mini-story per section, and a closing with a single clear call to action. Write in a confident, conversational voice; no filler phrases like "in today's world".
47 +
48 + Topic and notes:
49 + {{input}}
50 + """),
51 +
52 + template(4, "Professional Email", "Writing", """
53 + You are an executive communications coach. Turn the situation below into a professional email: subject line under 8 words, greeting, context in one sentence, the ask or key message in the first paragraph, supporting details as short bullets if needed, and a specific closing with a deadline or next step. Maximum 150 words in the body. Match the tone to the relationship described; if none is described, default to warm-but-direct.
54 +
55 + Situation and what I need to say:
56 + {{input}}
57 + """),
58 +
59 + template(5, "Punch Up the Hook", "Writing", """
60 + You are a headline doctor for a major publication. The opening below is losing readers. Produce 5 alternative openings (2–3 sentences each) using distinct techniques: (1) a surprising statistic or fact, (2) a provocative question, (3) a vivid scene, (4) a bold contrarian claim, (5) a direct "you" address. Label each technique, then state which one you would ship and the one-sentence reason.
61 +
62 + Current opening:
63 + {{input}}
64 + """),
65 +
66 + template(6, "Press Release", "Writing", """
67 + You are a PR professional writing for tier-1 tech journalists. Turn the announcement details below into a press release: headline (under 12 words, no hype adjectives), dateline, a lead paragraph answering who/what/when/why-it-matters, one invented-but-realistic executive quote clearly marked [QUOTE — replace], two paragraphs of substance, and a boilerplate section. Follow AP style. Flag any claim in my details that a journalist would challenge.
68 +
69 + Announcement details:
70 + {{input}}
71 + """),
72 +
73 + template(7, "Adjust the Tone", "Writing", """
74 + You are a versatile ghostwriter. Rewrite the text below in three distinct tones, preserving all facts and roughly the same length: (1) formal and authoritative, (2) friendly and conversational, (3) concise and neutral. Present them under clear headings. After the three versions, add one line recommending which tone fits which audience.
75 +
76 + Text:
77 + {{input}}
78 + """),
79 +
80 + // MARK: - Coding
81 +
82 + template(8, "Fix & Explain Bug", "Coding", """
83 + You are a senior engineer doing a live debugging session. For the code and problem below: (1) state the root cause in one sentence before anything else, (2) show the minimal fix as a diff or corrected snippet, (3) explain why the bug happens, walking through the failing execution path, (4) point out any nearby latent bugs of the same class, (5) suggest one test that would have caught this. Do not rewrite unrelated code.
84 +
85 + Code and problem description:
86 + {{input}}
87 + """),
88 +
89 + template(9, "Code Review", "Coding", """
90 + You are a staff engineer reviewing a pull request. Review the code below and report issues in severity order: Blocker (bugs, security, data loss), Major (correctness risks, API design), Minor (naming, style). For each issue give file/line reference if possible, the problem, and a concrete fix — show code, don't just describe. Also name one thing done well. Do not invent issues to seem thorough; if it's clean, say so.
91 +
92 + Code:
93 + {{input}}
94 + """),
95 +
96 + template(10, "Refactor for Readability", "Coding", """
97 + You are a maintainability-obsessed engineer. Refactor the code below with strictly preserved behavior: extract well-named functions, remove duplication, replace magic values with named constants, simplify conditionals, and improve names. Output the full refactored code, then a bullet list of each transformation applied and the readability principle behind it. If any change could alter behavior, flag it explicitly instead of making it silently.
98 +
99 + Code:
100 + {{input}}
101 + """),
102 +
103 + template(11, "Write Unit Tests", "Coding", """
104 + You are a test engineer who believes tests are documentation. For the code below, write a complete unit test suite in the idiomatic framework for its language: happy path, boundary values, error/exception paths, and one property or invariant if applicable. Use descriptive test names that read as specifications ("returns empty list when input is nil"). Keep each test focused on one behavior. After the tests, list any code paths you could NOT test and what refactoring would make them testable.
105 +
106 + Code:
107 + {{input}}
108 + """),
109 +
110 + template(12, "Explain This Code", "Coding", """
111 + You are a patient senior engineer onboarding a new teammate. Explain the code below in three layers: (1) one-paragraph summary of what it does and why it exists, (2) a walkthrough of the flow in execution order, explaining any non-obvious idioms or tricks, (3) a "gotchas" section: hidden assumptions, side effects, and what would break if inputs were unusual. Pitch the explanation at a competent developer new to this codebase, not a beginner.
112 +
113 + Code:
114 + {{input}}
115 + """),
116 +
117 + template(13, "Regex Builder", "Coding", """
118 + You are a regex expert who writes patterns other people can maintain. Build a regular expression for the requirement below. Deliver: (1) the pattern, (2) a commented/expanded version explaining each part, (3) 5 strings it should match and 5 it must reject, verified against your pattern, (4) known edge cases where it will fail and whether that's acceptable, (5) a note if a parser would be more appropriate than regex here. State which regex flavor you are targeting.
119 +
120 + What I need to match:
121 + {{input}}
122 + """),
123 +
124 + template(14, "Optimize Performance", "Coding", """
125 + You are a performance engineer who measures before optimizing. Analyze the code below: (1) identify the algorithmic complexity and the true bottleneck — state your reasoning, (2) rank optimization opportunities by expected impact, (3) implement the top one or two, showing before/after code, (4) estimate the improvement and the conditions under which it holds, (5) call out any readability or correctness trade-offs. Refuse to micro-optimize anything that isn't on the hot path.
126 +
127 + Code (and performance context if I have it):
128 + {{input}}
129 + """),
130 +
131 + // MARK: - Analysis
132 +
133 + template(15, "Pros & Cons Matrix", "Analysis", """
134 + You are a decision analyst. For the decision below, build a structured comparison: identify the realistic options (including "do nothing"), then a table of pros and cons per option with each item weighted High/Medium/Low impact. Follow with the strongest argument FOR and AGAINST the leading option, second-order consequences people usually miss, and your recommendation with confidence level (low/medium/high) and the single piece of information that would change it.
135 +
136 + Decision:
137 + {{input}}
138 + """),
139 +
140 + template(16, "Root Cause Analysis", "Analysis", """
141 + You are an incident investigator trained in the "5 Whys" and fishbone methods. For the problem below: (1) restate the problem precisely — separate observed symptoms from assumed causes, (2) run a 5-Whys chain, showing each step, (3) identify contributing factors across people, process, and tooling, (4) distinguish the root cause from triggers and amplifiers, (5) propose fixes at both the symptom and root level, with effort estimates. If key facts are missing, list exactly what you'd need to know.
142 +
143 + Problem:
144 + {{input}}
145 + """),
146 +
147 + template(17, "Summarize Key Findings", "Analysis", """
148 + You are a research analyst who never buries the lede. Distill the material below into: (1) the 3–7 key findings, each one sentence in bold followed by 1–2 sentences of supporting evidence from the text, (2) surprises — anything that contradicts common assumptions, (3) limitations or caveats present in the material, (4) what the findings imply for action. Quote exact figures rather than approximating. Do not include anything not supported by the material.
149 +
150 + Material:
151 + {{input}}
152 + """),
153 +
154 + template(18, "Steelman Both Sides", "Analysis", """
155 + You are a debate coach committed to intellectual honesty. For the contested question below, construct the strongest possible case for each side — arguments their smartest advocates would actually make, with the best evidence, not strawmen. Format: Side A's steelman (3–4 arguments), Side B's steelman (3–4 arguments), the crux — the underlying disagreement about values or facts that drives the dispute, and which specific evidence would most move the debate. Do not declare a winner unless I ask.
156 +
157 + Question:
158 + {{input}}
159 + """),
160 +
161 + template(19, "Interpret This Data", "Analysis", """
162 + You are a skeptical data analyst. Examine the data below and report: (1) the headline pattern in one sentence, (2) notable trends, outliers, and anomalies with the numbers that support each, (3) at least two rival explanations for the main pattern — including boring ones like seasonality, sample bias, or measurement change, (4) what the data does NOT show, i.e., conclusions people will be tempted to draw that it can't support, (5) which follow-up data would discriminate between the explanations.
163 +
164 + Data:
165 + {{input}}
166 + """),
167 +
168 + template(20, "Risk Assessment", "Analysis", """
169 + You are a risk officer who is neither alarmist nor complacent. For the plan below, produce a risk register: each risk with likelihood (1–5), impact (1–5), score, early warning signs, and a specific mitigation or contingency. Cover technical, people, timeline, financial, and external categories. Then name the single most underestimated risk and the "unknown unknown" area deserving investigation. Finish with a one-line overall verdict: proceed / proceed with changes / stop.
170 +
171 + Plan:
172 + {{input}}
173 + """),
174 +
175 + template(21, "Compare & Contrast", "Analysis", """
176 + You are an evaluation specialist. Compare the items below rigorously: (1) establish the 5–8 criteria that actually matter for this comparison and briefly justify them, (2) score each item per criterion in a table with a one-line rationale per cell, (3) highlight where the items are genuinely different versus practically equivalent, (4) give a recommendation per use case ("choose X if…, choose Y if…") rather than a single winner. Note where your information may be incomplete or dated.
177 +
178 + Items to compare (and my context):
179 + {{input}}
180 + """),
181 +
182 + // MARK: - Translation & Language
183 +
184 + template(22, "Translate, Keep the Voice", "Translation & Language", """
185 + You are a literary-grade translator. Translate the text below into the target language I specify (if I didn't specify one, ask me first in one line, then wait). Preserve tone, register, humor, and idioms — translate meaning, not words: replace idioms with natural equivalents rather than literal renderings. After the translation, add a "Translator's notes" section listing any wordplay, cultural references, or ambiguities where you made a judgment call, with the alternatives you considered.
186 +
187 + Text (and target language):
188 + {{input}}
189 + """),
190 +
191 + template(23, "Idiomatic English Polish", "Translation & Language", """
192 + You are a native-English copyeditor specialized in polishing text written by non-native speakers. Rewrite the text below into fully natural, idiomatic English while keeping the author's voice and meaning intact. Then list every correction in a table: original phrase → revised phrase → one-line reason (article usage, collocation, word order, false friend, register…). Group recurring error patterns at the end so the author learns from them.
193 +
194 + Text:
195 + {{input}}
196 + """),
197 +
198 + template(24, "Grammar & Style Check", "Translation & Language", """
199 + You are a meticulous proofreader following Chicago style. Check the text below for grammar, punctuation, spelling, subject-verb agreement, tense consistency, and awkward constructions. Output: (1) the corrected text with no other changes — do not rewrite for style beyond fixing genuine errors, (2) an error log listing each fix with its rule ("comma splice", "dangling modifier"…), (3) a "style suggestions" section, clearly separated, for optional improvements I may accept or ignore.
200 +
201 + Text:
202 + {{input}}
203 + """),
204 +
205 + template(25, "Localize for an Audience", "Translation & Language", """
206 + You are a localization strategist, not just a translator. Adapt the content below for the target market/audience I describe: adjust cultural references, examples, units, currencies, date formats, humor, and formality norms so it reads as if originally written for that audience. Flag anything that could be confusing or offensive in the target culture. Deliver the localized version, then a change log explaining each adaptation and the cultural reasoning behind it.
207 +
208 + Content and target audience:
209 + {{input}}
210 + """),
211 +
212 + template(26, "Plain-Language Rewrite", "Translation & Language", """
213 + You are an expert at translating specialist jargon into plain language without dumbing it down. Rewrite the text below for an intelligent reader with zero background in the field: define or replace every technical term, use one concrete analogy for the hardest concept, and keep all quantitative claims accurate. Target reading level: a curious 15-year-old. Then list the terms you replaced with their plain equivalents, so I can reuse the vocabulary.
214 +
215 + Text:
216 + {{input}}
217 + """),
218 +
219 + template(27, "Vocabulary Coach", "Translation & Language", """
220 + You are a language coach who teaches words in context, never as bare lists. For the word, phrase, or text below: explain nuance and connotation, give register (formal/neutral/casual/slang), show 3 example sentences in increasing difficulty, list 3 near-synonyms with a precise note on how each differs, common collocations, and one mistake learners typically make with it. If I gave a whole text, do this for the 5 most useful words in it.
221 +
222 + Word/phrase/text:
223 + {{input}}
224 + """),
225 +
226 + template(28, "Build a Bilingual Glossary", "Translation & Language", """
227 + You are a terminologist preparing a translation glossary. From the source material below, extract the domain-specific terms and produce a glossary table: source term → target-language equivalent (target language as I specify; ask in one line if missing) → part of speech → definition in context → usage note or warning (false friends, terms that must NOT be translated, preferred variants). Sort by importance to the domain, not alphabetically. Aim for the 15–30 terms a translator would actually need.
228 +
229 + Source material (and target language):
230 + {{input}}
231 + """),
232 +
233 + // MARK: - Business
234 +
235 + template(29, "SWOT Analysis", "Business", """
236 + You are a strategy consultant who writes SWOTs that lead to decisions, not wall posters. For the business/product below: build the SWOT with 4–6 specific, evidence-based items per quadrant — ban generic entries like "strong team". Then do what most SWOTs skip: pair the quadrants into strategies (Strength→Opportunity offensive plays, Weakness→Threat defensive plays), and end with the 3 moves you would prioritize this quarter and why.
237 +
238 + Business/product and context:
239 + {{input}}
240 + """),
241 +
242 + template(30, "Notes → Action Items", "Business", """
243 + You are an elite executive assistant. Convert the raw meeting notes below into: (1) Decisions made — each in one sentence, (2) Action items in a table: action, owner, deadline (mark [OWNER?] or [DATE?] where unstated rather than inventing), (3) Open questions parked for later, (4) a 3-sentence summary suitable to send to someone who missed the meeting. Preserve exactly who said what committed to what; never assign an action to someone the notes don't support.
244 +
245 + Meeting notes:
246 + {{input}}
247 + """),
248 +
249 + template(31, "One-Page PRD", "Business", """
250 + You are a senior product manager known for crisp PRDs. Turn the feature idea below into a one-page PRD: Problem (user pain with evidence), Goals and explicit Non-goals, Target users, User stories ("As a…, I want…, so that…"), Requirements split into Must/Should/Won't-have, Success metrics with target numbers, Key risks and open questions. Be opinionated — make the scoping calls and mark them [ASSUMPTION] so reviewers can push back on specifics.
251 +
252 + Feature idea:
253 + {{input}}
254 + """),
255 +
256 + template(32, "Elevator Pitch", "Business", """
257 + You are a pitch coach who has prepped founders for demo day. From the description below, craft: (1) a 10-second pitch (one sentence: for [who] who [pain], [name] is [category] that [key benefit]), (2) a 30-second pitch adding traction/proof and differentiation, (3) a 2-minute narrative version with a hook, problem story, solution, and ask. Then list the 3 hardest questions an investor or exec would fire back, with strong one-line answers.
258 +
259 + What I'm pitching:
260 + {{input}}
261 + """),
262 +
263 + template(33, "Negotiation Prep", "Business", """
264 + You are a negotiation advisor trained in principled negotiation. For the situation below, prepare my brief: (1) my interests vs. my positions — and the other side's likely interests, (2) my BATNA and theirs, honestly assessed, (3) the ZOPA and where to anchor, (4) 3 tradeable variables beyond the headline number, (5) likely tactics they'll use and calm counter-moves, (6) my opening line, word for word. Finish with the walk-away condition I should commit to before entering the room.
265 +
266 + Situation:
267 + {{input}}
268 + """),
269 +
270 + template(34, "Draft OKRs", "Business", """
271 + You are an OKR coach who despises vanity objectives. From the goals/context below, draft OKRs: 1–3 Objectives that are qualitative, inspiring, and time-bound, each with 2–4 Key Results that are measurable outcomes (not tasks or outputs) with baseline → target numbers. Mark any KR where I gave no baseline as [BASELINE?]. Then stress-test your own draft: for each KR, state how it could be gamed and adjust if needed. Keep the whole set achievable at ~70% as a stretch.
272 +
273 + Goals and context:
274 + {{input}}
275 + """),
276 +
277 + template(35, "Customer Reply", "Business", """
278 + You are a customer-experience lead famed for turning angry users into fans. Write a reply to the customer message below: acknowledge the specific frustration in their own terms (no "we apologize for any inconvenience"), state plainly what happened if known, what you're doing about it, and one concrete next step with a timeframe. Offer a goodwill gesture only if the situation warrants it. Under 150 words, human tone, no corporate hedging. Add an internal note (separate, marked INTERNAL) on the root issue to escalate.
279 +
280 + Customer message and context:
281 + {{input}}
282 + """),
283 +
284 + // MARK: - Learning
285 +
286 + template(36, "Socratic Tutor", "Learning", """
287 + You are a Socratic tutor: you teach by asking, never by lecturing. I want to understand the topic below. Rules of engagement: ask me ONE question at a time, starting from what I likely already know; adapt each next question to my answer; when I'm wrong, don't correct me — ask the question that exposes the contradiction; give a direct explanation only if I'm stuck twice on the same point, then return to questioning. Begin with your first question now, and keep each turn short.
288 +
289 + Topic:
290 + {{input}}
291 + """),
292 +
293 + template(37, "Explain Like I'm Five", "Learning", """
294 + You are a science communicator in the tradition of Feynman. Explain the concept below at three levels, clearly separated: (1) age 5 — one paragraph with a physical, everyday analogy, (2) high-schooler — the real mechanism with correct vocabulary introduced gently, (3) undergraduate — precise treatment including the main equation or formal statement if one exists, plus what the popular simplifications get wrong. Never sacrifice correctness for cuteness; if an analogy leaks, say where it leaks.
295 +
296 + Concept:
297 + {{input}}
298 + """),
299 +
300 + template(38, "Build My Study Plan", "Learning", """
301 + You are a learning scientist who designs plans around spaced repetition and active recall, not passive review. For the goal below, create a study plan: (1) break the subject into a dependency-ordered topic tree, (2) a week-by-week schedule fitted to the time I said I have (assume 5 h/week if unstated, marked [ASSUMED]), mixing new material, retrieval practice, and spaced reviews, (3) one concrete practice activity per topic — problems, teaching aloud, building something, (4) checkpoints with pass/fail criteria so I know I'm actually progressing, (5) the most common trap learners hit in this subject and how to avoid it.
302 +
303 + Learning goal, deadline, and available time:
304 + {{input}}
305 + """),
306 +
307 + template(39, "Make Flashcards", "Learning", """
308 + You are a spaced-repetition expert who follows the "minimum information principle": one atomic fact per card. From the material below, create 15–25 flashcards as "Q:" / "A:" pairs. Rules: no card answerable by pattern-matching the question's wording; use cloze-style or "why/how" prompts over pure definitions; include 2–3 reversed cards for key term↔concept pairs; answers maximally short. Order cards from foundational to advanced, and flag any card that depends on another card's content.
309 +
310 + Material:
311 + {{input}}
312 + """),
313 +
314 + template(40, "Quiz Me", "Learning", """
315 + You are a rigorous but encouraging examiner. Quiz me on the topic/material below. Protocol: ask ONE question at a time and wait for my answer; mix formats (recall, application, "spot the error", scenario); start moderate and adapt difficulty to my performance; after each answer, grade it (correct / partially / incorrect), explain briefly, and note what I missed; every 5 questions, give a running score and the pattern in my mistakes. Begin with question 1 now.
316 +
317 + Topic or material:
318 + {{input}}
319 + """),
320 +
321 + template(41, "Map the Concepts", "Learning", """
322 + You are a knowledge cartographer. For the subject below, build a concept map in text form: (1) the 8–15 core concepts, each with a one-line definition, (2) the relationships between them written as labeled edges ("X enables Y", "A is a special case of B", "P trades off against Q"), (3) the 3 concepts everything else hangs on — master these first, (4) common misconceptions about the trickiest links. Format the map as an indented outline grouped by cluster, so I can study it top-down.
323 +
324 + Subject:
325 + {{input}}
326 + """),
327 +
328 + template(42, "Feynman Check", "Learning", """
329 + You are running the Feynman technique on me. Below is my own explanation of a concept, written from memory. Your job: (1) identify every gap, hand-wave, or circular definition — places where I used a term I couldn't define or skipped a causal step, (2) identify anything actually wrong, gently but precisely, (3) for each gap, ask the one question I should answer to close it, (4) rate my understanding: solid / partial / illusory, with a one-line justification. Do not re-explain the whole concept yourself unless I ask.
330 +
331 + My explanation:
332 + {{input}}
333 + """),
334 +
335 + // MARK: - Creativity
336 +
337 + template(43, "Brainstorm 20 Ideas", "Creativity", """
338 + You are a facilitator who knows the first ten ideas are always the obvious ones. Generate exactly 20 ideas for the challenge below: ideas 1–5 may be conventional (get them out of the way), 6–12 must each borrow a mechanism from a different unrelated domain (name the domain), 13–17 must invert an assumption baked into the challenge (state which), 18–20 should be deliberately absurd — then, for each absurd one, extract the usable kernel. Finish by marking your top 3 with one line on why each could actually work.
339 +
340 + Challenge:
341 + {{input}}
342 + """),
343 +
344 + template(44, "Short Story Sketch", "Creativity", """
345 + You are a fiction writer who believes stories live or die on desire and obstacle. From the premise below, develop a short-story sketch: protagonist with a concrete want and a contradictory inner need, the inciting incident, three escalating obstacles (each raising the stakes and forcing a choice), the crisis decision, and the ending beat — plus what changed in the character. Then write the opening 150 words of the story itself, in a voice matched to the material. Avoid clichés of the genre; if you use a trope, twist it.
346 +
347 + Premise:
348 + {{input}}
349 + """),
350 +
351 + template(45, "Naming Machine", "Creativity", """
352 + You are a professional namer for brands and products. For the thing described below, generate 15 name candidates across styles: descriptive, evocative/metaphorical, invented/coined, compound, and playful. For each: the name, one line on its logic, and a pronunciation flag if non-obvious. Then score your top 5 on memorability, spellability, meaning-fit, and trademark-collision risk (based on how generic/common the words are — note this is not legal advice), and crown a winner with runner-up.
353 +
354 + What needs a name:
355 + {{input}}
356 + """),
357 +
358 + template(46, "Metaphor Finder", "Creativity", """
359 + You are a writer with a gift for analogy. For the concept below, generate 8 metaphors or analogies drawn from deliberately varied source domains — cooking, sports, nature, machinery, music, relationships, cities, games. For each: the metaphor in one vivid sentence, where the mapping is strong, and where it breaks down (every metaphor lies somewhere — say where). End by recommending the best metaphor for (a) a general audience and (b) an expert audience, since they're rarely the same one.
360 +
361 + Concept:
362 + {{input}}
363 + """),
364 +
365 + template(47, "What-If Scenarios", "Creativity", """
366 + You are a scenario planner and speculative thinker. Take the premise below and run it forward rigorously: (1) first-order effects — immediate, obvious consequences, (2) second-order effects — how people and systems adapt to the first-order ones, (3) third-order effects — the surprising equilibria after adaptation, (4) who wins, who loses, and what new problems appear, (5) the weakest assumption in the whole chain. Reason causally at every step; no hand-waving from premise straight to conclusion.
367 +
368 + What if:
369 + {{input}}
370 + """),
371 +
372 + template(48, "Character Builder", "Creativity", """
373 + You are a character designer for novels and games. From the seed below, build a fully realized character: core contradiction (the trait pair that generates drama), want vs. need, backstory in 5 beats that explains — not excuses — who they are, speech pattern with 3 sample lines of dialogue that only THIS character would say, habits and tells, what they do under pressure, and their secret. Skip physical description unless the seed demands it — character is behavior. End with the story situation this character was born to detonate.
374 +
375 + Character seed:
376 + {{input}}
377 + """),
378 +
379 + template(49, "Headline Variations", "Creativity", """
380 + You are a copywriter who A/B tests everything. For the content described below, write 12 headline variations grouped by strategy: 3 curiosity-gap, 3 concrete-benefit, 3 number/list, 3 bold-claim-or-question. Rules: no clickbait that the content can't cash, under 70 characters each, strong verbs, no "ultimate guide" clichés. Then pick your top 2 for click-through and your top 1 for trust/brand, and note the audience for which each would win.
381 +
382 + Content:
383 + {{input}}
384 + """),
385 +
386 + // MARK: - Productivity
387 +
388 + template(50, "Prioritize My Tasks", "Productivity", """
389 + You are a productivity coach who uses Eisenhower and impact/effort thinking without the buzzword theater. Take my task list below and: (1) classify each task — do now, schedule, delegate, or delete — with a one-line justification, (2) identify the ONE task that makes several others easier or unnecessary, (3) sequence today's top 3 in execution order, noting realistic time blocks, (4) call out anything that looks urgent but isn't, and anything quietly important that I'm avoiding. Be direct; if a task should die, say so.
390 +
391 + My tasks (and any deadlines/context):
392 + {{input}}
393 + """),
394 +
395 + template(51, "Plan My Week", "Productivity", """
396 + You are a calendar-realist planner: you design weeks people actually keep. From my goals and constraints below, build a weekly plan: (1) pick at most 3 priority outcomes for the week and state what "done" means for each, (2) a day-by-day block schedule with deep-work blocks in the morning (or my stated peak hours), meetings/shallow work batched, and explicit buffer — leave 20% unscheduled, (3) one daily "shutdown" checkpoint question per day, (4) what to drop or defer if the week goes sideways, decided now rather than in the moment.
397 +
398 + My goals, commitments, and constraints this week:
399 + {{input}}
400 + """),
401 +
402 + template(52, "Decision Framework", "Productivity", """
403 + You are a decision coach who separates the decision from the outcome. Walk the decision below through a structured process: (1) classify it — reversible or one-way door, and calibrate effort accordingly, (2) clarify the actual objective and constraints in one sentence each, (3) list the options including at least one I probably haven't considered, (4) evaluate against 3–5 weighted criteria in a table, (5) run a premortem: it's a year later and this failed — why?, (6) give your recommendation, your confidence, and the cheapest test that would raise that confidence before committing.
404 +
405 + Decision I'm facing:
406 + {{input}}
407 + """),
408 +
409 + template(53, "Meeting Agenda", "Productivity", """
410 + You are a meeting designer who believes most meetings should be shorter or emails. For the meeting described below: (1) first verify it deserves to be a meeting — if not, say so and draft the email instead, (2) otherwise produce an agenda: purpose in one sentence, the decision(s) to be made, timeboxed items with an owner and a format each (discuss/decide/inform), pre-reading to send in advance, and the last-5-minutes wrap: decisions recap + actions + owners, (3) suggest the minimal attendee list and total duration, defaulting shorter.
411 +
412 + Meeting purpose and context:
413 + {{input}}
414 + """),
415 +
416 + template(54, "Delegation Brief", "Productivity", """
417 + You are an operations lead who delegates outcomes, not tasks. Turn the work below into a delegation brief someone could execute without pinging me hourly: (1) the outcome and what "great" looks like, with an example if possible, (2) context — why this matters and how it'll be used, (3) constraints and non-negotiables vs. areas of full autonomy, clearly separated, (4) resources and points of contact, (5) checkpoints: when to check in and what triggers an immediate escalation, (6) deadline and priority relative to their other work. Keep it under a page.
418 +
419 + Work to delegate (and to whom, if known):
420 + {{input}}
421 + """),
422 +
423 + template(55, "Break Down a Project", "Productivity", """
424 + You are a project planner allergic to vague milestones. Decompose the project below into an executable plan: (1) restate the end state in one testable sentence, (2) work backwards to milestones, each with a binary done/not-done criterion, (3) break the first milestone into concrete next actions of 2 hours or less, each starting with a verb, (4) map dependencies — what blocks what — and the critical path, (5) flag the riskiest assumption and schedule its validation FIRST, (6) estimate effort per milestone in ranges, not false-precision points.
425 +
426 + Project:
427 + {{input}}
428 + """),
429 +
430 + template(56, "Standup Update", "Productivity", """
431 + You are a communication-efficiency editor. Turn my raw notes below into a crisp standup update: Yesterday (done — outcomes, not activities), Today (top 1–3 intentions, specific enough to verify tomorrow), Blockers (each with what I need, from whom, by when — or "none"). Maximum 80 words total, scannable, no throat-clearing. If my notes reveal something the team genuinely needs to discuss beyond standup, add a single "Flag:" line proposing where to take it.
432 +
433 + My raw notes:
434 + {{input}}
435 + """),
436 + ]
437 +}
added Sources/ZyquoCloud/Services/PromptLibraryStore.swift +149 −0
@@ -0,0 +1,149 @@
1 +//
2 +// PromptLibraryStore.swift
3 +// Zyquo Cloud
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Observable store for the prompt template library and personas.
9 +// Built-in items come from PromptLibraryData / PersonaLibraryData and are
10 +// immutable; user-created items are persisted as JSON via PersistenceService.
11 +//
12 +
13 +import Foundation
14 +
15 +@MainActor
16 +final class PromptLibraryStore: ObservableObject {
17 +
18 + // MARK: - Published state
19 +
20 + /// User-created templates (never contains built-ins).
21 + @Published var userTemplates: [PromptTemplate]
22 + /// User-created personas (never contains built-ins).
23 + @Published var userPersonas: [Persona]
24 +
25 + // MARK: - Constants
26 +
27 + private static let templatesFileName = "user-templates.json"
28 + private static let personasFileName = "user-personas.json"
29 +
30 + private let persistence: PersistenceService
31 +
32 + // MARK: - Init
33 +
34 + init(persistence: PersistenceService = .shared) {
35 + self.persistence = persistence
36 + self.userTemplates = persistence.load([PromptTemplate].self, from: Self.templatesFileName) ?? []
37 + self.userPersonas = persistence.load([Persona].self, from: Self.personasFileName) ?? []
38 + }
39 +
40 + // MARK: - Combined catalogs (built-in + user)
41 +
42 + /// All templates: the built-in library followed by user templates.
43 + var allTemplates: [PromptTemplate] {
44 + PromptLibraryData.templates + userTemplates
45 + }
46 +
47 + /// All personas: the built-in set followed by user personas.
48 + var allPersonas: [Persona] {
49 + PersonaLibraryData.personas + userPersonas
50 + }
51 +
52 + /// Template categories in display order (built-in order first, then any
53 + /// user-only categories alphabetically).
54 + var templateCategories: [String] {
55 + var seen = Set<String>()
56 + var ordered: [String] = []
57 + for template in PromptLibraryData.templates where seen.insert(template.category).inserted {
58 + ordered.append(template.category)
59 + }
60 + let userOnly = Set(userTemplates.map(\.category)).subtracting(seen).sorted()
61 + return ordered + userOnly
62 + }
63 +
64 + /// Templates belonging to a category, built-ins first.
65 + func templates(in category: String) -> [PromptTemplate] {
66 + allTemplates.filter { $0.category == category }
67 + }
68 +
69 + // MARK: - Template CRUD (user items only)
70 +
71 + /// Adds a user template. Built-in flags are stripped defensively.
72 + func add(_ template: PromptTemplate) {
73 + var template = template
74 + template.isBuiltIn = false
75 + userTemplates.append(template)
76 + saveTemplates()
77 + }
78 +
79 + /// Updates a user template in place. Built-in templates are immutable
80 + /// and silently ignored.
81 + func update(_ template: PromptTemplate) {
82 + guard let index = userTemplates.firstIndex(where: { $0.id == template.id }) else { return }
83 + var template = template
84 + template.isBuiltIn = false
85 + userTemplates[index] = template
86 + saveTemplates()
87 + }
88 +
89 + /// Deletes a user template. Built-in templates cannot be deleted.
90 + func delete(_ template: PromptTemplate) {
91 + guard userTemplates.contains(where: { $0.id == template.id }) else { return }
92 + userTemplates.removeAll { $0.id == template.id }
93 + saveTemplates()
94 + }
95 +
96 + // MARK: - Persona CRUD (user items only)
97 +
98 + /// Adds a user persona.
99 + func add(_ persona: Persona) {
100 + userPersonas.append(persona)
101 + savePersonas()
102 + }
103 +
104 + /// Updates a user persona in place. Built-in personas are immutable
105 + /// and silently ignored.
106 + func update(_ persona: Persona) {
107 + guard let index = userPersonas.firstIndex(where: { $0.id == persona.id }) else { return }
108 + userPersonas[index] = persona
109 + savePersonas()
110 + }
111 +
112 + /// Deletes a user persona. Built-in personas cannot be deleted.
113 + func delete(_ persona: Persona) {
114 + guard userPersonas.contains(where: { $0.id == persona.id }) else { return }
115 + userPersonas.removeAll { $0.id == persona.id }
116 + savePersonas()
117 + }
118 +
119 + // MARK: - Lookup
120 +
121 + func persona(withID id: UUID) -> Persona? {
122 + allPersonas.first { $0.id == id }
123 + }
124 +
125 + func template(withID id: UUID) -> PromptTemplate? {
126 + allTemplates.first { $0.id == id }
127 + }
128 +
129 + // MARK: - Template application
130 +
131 + /// Fills a template with the user's input, replacing every `{{input}}`
132 + /// placeholder. Templates without a placeholder get the input appended.
133 + static func apply(_ template: PromptTemplate, input: String) -> String {
134 + guard template.body.contains("{{input}}") else {
135 + return input.isEmpty ? template.body : template.body + "\n\n" + input
136 + }
137 + return template.body.replacingOccurrences(of: "{{input}}", with: input)
138 + }
139 +
140 + // MARK: - Persistence
141 +
142 + private func saveTemplates() {
143 + persistence.save(userTemplates, to: Self.templatesFileName)
144 + }
145 +
146 + private func savePersonas() {
147 + persistence.save(userPersonas, to: Self.personasFileName)
148 + }
149 +}
added Sources/ZyquoCloud/ViewModels/ConversationStore.swift +372 −0
@@ -0,0 +1,372 @@
1 +//
2 +// ConversationStore.swift
3 +// Zyquo Cloud
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Central chat state: the conversation list, selection, and the chat engine
9 +// that streams provider responses into messages. All mutation happens on the
10 +// main actor; streaming work runs in tasks that hop back for UI updates.
11 +//
12 +
13 +import Foundation
14 +
15 +@MainActor
16 +final class ConversationStore: ObservableObject {
17 + @Published var conversations: [Conversation] = []
18 + @Published var selectedID: Conversation.ID?
19 + @Published var searchText: String = ""
20 + /// Live streaming task per conversation (supports parallel streams in compare mode).
21 + @Published private(set) var streamingConversationIDs: Set<Conversation.ID> = []
22 +
23 + let catalog: ModelCatalog
24 + let vault: KeyVaultStore
25 + private let persistence: PersistenceService
26 + private var streamTasks: [Conversation.ID: Task<Void, Never>] = [:]
27 +
28 + /// Global default system prompt (Settings → Advanced).
29 + @Published var defaultSystemPrompt: String {
30 + didSet { persistence.save(defaultSystemPrompt, to: "default-system-prompt.json") }
31 + }
32 +
33 + init(
34 + catalog: ModelCatalog,
35 + vault: KeyVaultStore,
36 + persistence: PersistenceService = .shared
37 + ) {
38 + self.catalog = catalog
39 + self.vault = vault
40 + self.persistence = persistence
41 + self.defaultSystemPrompt = persistence.load(String.self, from: "default-system-prompt.json") ?? ""
42 + conversations = persistence.loadConversations()
43 + selectedID = conversations.first?.id
44 + }
45 +
46 + // MARK: - Selection & lookup
47 +
48 + var selected: Conversation? {
49 + get { conversations.first { $0.id == selectedID } }
50 + }
51 +
52 + func binding(for id: Conversation.ID) -> Int? {
53 + conversations.firstIndex { $0.id == id }
54 + }
55 +
56 + func isStreaming(_ id: Conversation.ID) -> Bool {
57 + streamingConversationIDs.contains(id)
58 + }
59 +
60 + // MARK: - CRUD
61 +
62 + @discardableResult
63 + func newConversation(model: AIModel? = nil, persona: Persona? = nil) -> Conversation {
64 + let chosen = model ?? catalog.defaultModel
65 + var conversation = Conversation(
66 + modelID: chosen?.id ?? "",
67 + provider: chosen?.provider ?? .openai,
68 + systemPrompt: persona?.systemPrompt ?? (defaultSystemPrompt.isEmpty ? nil : defaultSystemPrompt)
69 + )
70 + if let persona {
71 + conversation.personaID = persona.id
72 + conversation.parameters = persona.parameters
73 + if let modelID = persona.modelID, let provider = persona.provider {
74 + conversation.modelID = modelID
75 + conversation.provider = provider
76 + }
77 + }
78 + conversations.insert(conversation, at: 0)
79 + selectedID = conversation.id
80 + persistence.save(conversation)
81 + return conversation
82 + }
83 +
84 + func delete(_ id: Conversation.ID) {
85 + stopStreaming(id)
86 + if let conversation = conversations.first(where: { $0.id == id }) {
87 + persistence.delete(conversation)
88 + }
89 + conversations.removeAll { $0.id == id }
90 + if selectedID == id { selectedID = conversations.first?.id }
91 + }
92 +
93 + func update(_ conversation: Conversation) {
94 + guard let index = conversations.firstIndex(where: { $0.id == conversation.id }) else { return }
95 + var updated = conversation
96 + updated.updatedAt = Date()
97 + conversations[index] = updated
98 + persistence.save(updated)
99 + }
100 +
101 + func togglePin(_ id: Conversation.ID) {
102 + guard var conversation = conversations.first(where: { $0.id == id }) else { return }
103 + conversation.isPinned.toggle()
104 + update(conversation)
105 + }
106 +
107 + func rename(_ id: Conversation.ID, to title: String) {
108 + guard var conversation = conversations.first(where: { $0.id == id }) else { return }
109 + conversation.title = title
110 + conversation.hasAutoTitle = false
111 + update(conversation)
112 + }
113 +
114 + // MARK: - Sidebar grouping
115 +
116 + struct SidebarGroup: Identifiable {
117 + let id: String
118 + let title: String
119 + let conversations: [Conversation]
120 + }
121 +
122 + /// Pinned / Today / Yesterday / Previous 7 Days / Older, filtered by search.
123 + var sidebarGroups: [SidebarGroup] {
124 + let filtered = searchText.isEmpty
125 + ? conversations
126 + : conversations.filter { conversation in
127 + conversation.title.localizedCaseInsensitiveContains(searchText)
128 + || conversation.messages.contains {
129 + $0.text.localizedCaseInsensitiveContains(searchText)
130 + }
131 + }
132 + let calendar = Calendar.current
133 + let now = Date()
134 + var pinned: [Conversation] = []
135 + var today: [Conversation] = []
136 + var yesterday: [Conversation] = []
137 + var week: [Conversation] = []
138 + var older: [Conversation] = []
139 + for conversation in filtered {
140 + if conversation.isPinned { pinned.append(conversation); continue }
141 + if calendar.isDateInToday(conversation.updatedAt) { today.append(conversation) }
142 + else if calendar.isDateInYesterday(conversation.updatedAt) { yesterday.append(conversation) }
143 + else if conversation.updatedAt > now.addingTimeInterval(-7 * 86_400) { week.append(conversation) }
144 + else { older.append(conversation) }
145 + }
146 + return [
147 + SidebarGroup(id: "pinned", title: "Pinned", conversations: pinned),
148 + SidebarGroup(id: "today", title: "Today", conversations: today),
149 + SidebarGroup(id: "yesterday", title: "Yesterday", conversations: yesterday),
150 + SidebarGroup(id: "week", title: "Previous 7 Days", conversations: week),
151 + SidebarGroup(id: "older", title: "Older", conversations: older),
152 + ].filter { !$0.conversations.isEmpty }
153 + }
154 +
155 + // MARK: - Chat engine
156 +
157 + /// Sends the user's text (and attachments) in a conversation and streams
158 + /// the assistant reply. Optionally targets a different model for this
159 + /// message only (per-message model switch).
160 + func send(
161 + text: String,
162 + attachments: [Attachment] = [],
163 + in conversationID: Conversation.ID,
164 + overrideModel: AIModel? = nil
165 + ) {
166 + guard var conversation = conversations.first(where: { $0.id == conversationID }) else { return }
167 + guard let model = overrideModel
168 + ?? catalog.model(id: conversation.modelID, provider: conversation.provider)
169 + ?? catalog.defaultModel
170 + else { return }
171 +
172 + if let overrideModel {
173 + conversation.modelID = overrideModel.id
174 + conversation.provider = overrideModel.provider
175 + }
176 +
177 + var userMessage = Message(role: .user, text: text, attachments: attachments)
178 + userMessage.modelID = model.id
179 + userMessage.provider = model.provider
180 + conversation.messages.append(userMessage)
181 + update(conversation)
182 +
183 + generateReply(in: conversationID, model: model)
184 + }
185 +
186 + /// Regenerates the last assistant message (optionally with another model).
187 + func regenerate(in conversationID: Conversation.ID, with model: AIModel? = nil) {
188 + guard var conversation = conversations.first(where: { $0.id == conversationID }) else { return }
189 + if conversation.messages.last?.role == .assistant {
190 + conversation.messages.removeLast()
191 + update(conversation)
192 + }
193 + guard let target = model
194 + ?? catalog.model(id: conversation.modelID, provider: conversation.provider)
195 + else { return }
196 + generateReply(in: conversationID, model: target)
197 + }
198 +
199 + /// Replaces a user message's text and regenerates from that point.
200 + func editAndResend(messageID: Message.ID, newText: String, in conversationID: Conversation.ID) {
201 + guard var conversation = conversations.first(where: { $0.id == conversationID }),
202 + let index = conversation.messages.firstIndex(where: { $0.id == messageID })
203 + else { return }
204 + conversation.messages[index].text = newText
205 + conversation.messages.removeSubrange((index + 1)...)
206 + update(conversation)
207 + guard let model = catalog.model(id: conversation.modelID, provider: conversation.provider) else { return }
208 + generateReply(in: conversationID, model: model)
209 + }
210 +
211 + func deleteMessage(_ messageID: Message.ID, in conversationID: Conversation.ID) {
212 + guard var conversation = conversations.first(where: { $0.id == conversationID }) else { return }
213 + conversation.messages.removeAll { $0.id == messageID }
214 + update(conversation)
215 + }
216 +
217 + func stopStreaming(_ conversationID: Conversation.ID) {
218 + streamTasks[conversationID]?.cancel()
219 + streamTasks[conversationID] = nil
220 + streamingConversationIDs.remove(conversationID)
221 + finalizeStreamingMessage(in: conversationID)
222 + }
223 +
224 + private func generateReply(in conversationID: Conversation.ID, model: AIModel) {
225 + guard let index = conversations.firstIndex(where: { $0.id == conversationID }) else { return }
226 +
227 + var placeholder = Message(role: .assistant, text: "")
228 + placeholder.modelID = model.id
229 + placeholder.provider = model.provider
230 + placeholder.isStreaming = true
231 + conversations[index].messages.append(placeholder)
232 + let messageID = placeholder.id
233 +
234 + let request = ChatRequest(
235 + model: model,
236 + systemPrompt: conversations[index].systemPrompt,
237 + messages: conversations[index].messages.filter { $0.id != messageID && $0.errorText == nil },
238 + parameters: conversations[index].parameters
239 + )
240 +
241 + streamingConversationIDs.insert(conversationID)
242 + let task = Task { [weak self] in
243 + guard let self else { return }
244 + do {
245 + let apiKey = try self.vault.apiKey(for: model.provider)
246 + let client = ProviderRegistry.client(for: model)
247 + var usage: TokenUsage?
248 + for try await event in client.streamChat(request, apiKey: apiKey) {
249 + if Task.isCancelled { break }
250 + switch event {
251 + case .textDelta(let delta):
252 + self.mutateMessage(messageID, in: conversationID) { $0.text += delta }
253 + case .reasoningDelta(let delta):
254 + self.mutateMessage(messageID, in: conversationID) {
255 + $0.reasoning = ($0.reasoning ?? "") + delta
256 + }
257 + case .citations(let citations):
258 + self.mutateMessage(messageID, in: conversationID) { $0.citations = citations }
259 + case .usage(let u):
260 + usage = u
261 + case .finished:
262 + break
263 + }
264 + }
265 + let finalUsage = usage
266 + self.mutateMessage(messageID, in: conversationID) { message in
267 + message.isStreaming = false
268 + if let u = finalUsage {
269 + message.usage = u
270 + message.estimatedCost = model.pricing?.cost(
271 + inputTokens: u.inputTokens, outputTokens: u.outputTokens
272 + )
273 + }
274 + }
275 + } catch let error as ProviderError {
276 + if case .cancelled = error {
277 + self.mutateMessage(messageID, in: conversationID) { $0.isStreaming = false }
278 + } else {
279 + self.mutateMessage(messageID, in: conversationID) {
280 + $0.isStreaming = false
281 + $0.errorText = error.localizedDescription
282 + }
283 + }
284 + } catch {
285 + self.mutateMessage(messageID, in: conversationID) {
286 + $0.isStreaming = false
287 + $0.errorText = error.localizedDescription
288 + }
289 + }
290 + self.streamingConversationIDs.remove(conversationID)
291 + self.streamTasks[conversationID] = nil
292 + self.persistSnapshot(of: conversationID)
293 + self.autoTitleIfNeeded(conversationID)
294 + }
295 + streamTasks[conversationID] = task
296 + }
297 +
298 + private func mutateMessage(
299 + _ messageID: Message.ID,
300 + in conversationID: Conversation.ID,
301 + _ mutate: (inout Message) -> Void
302 + ) {
303 + guard let ci = conversations.firstIndex(where: { $0.id == conversationID }),
304 + let mi = conversations[ci].messages.firstIndex(where: { $0.id == messageID })
305 + else { return }
306 + mutate(&conversations[ci].messages[mi])
307 + }
308 +
309 + private func finalizeStreamingMessage(in conversationID: Conversation.ID) {
310 + guard let ci = conversations.firstIndex(where: { $0.id == conversationID }) else { return }
311 + for mi in conversations[ci].messages.indices where conversations[ci].messages[mi].isStreaming {
312 + conversations[ci].messages[mi].isStreaming = false
313 + }
314 + persistSnapshot(of: conversationID)
315 + }
316 +
317 + private func persistSnapshot(of conversationID: Conversation.ID) {
318 + guard var conversation = conversations.first(where: { $0.id == conversationID }) else { return }
319 + conversation.updatedAt = Date()
320 + if let index = conversations.firstIndex(where: { $0.id == conversationID }) {
321 + conversations[index] = conversation
322 + }
323 + persistence.save(conversation)
324 + }
325 +
326 + // MARK: - Auto titles
327 +
328 + /// After the first completed exchange, asks the provider's cheapest model
329 + /// for a short title.
330 + private func autoTitleIfNeeded(_ conversationID: Conversation.ID) {
331 + guard let conversation = conversations.first(where: { $0.id == conversationID }),
332 + conversation.hasAutoTitle,
333 + conversation.messages.filter({ $0.role == .assistant && !$0.text.isEmpty }).count == 1,
334 + let cheap = catalog.cheapestModel(for: conversation.provider),
335 + let firstUser = conversation.messages.first(where: { $0.role == .user })
336 + else { return }
337 +
338 + let assistantText = conversation.messages.last { $0.role == .assistant }?.text ?? ""
339 + let prompt = """
340 + Write a title of at most 5 words for this conversation. Reply with the title only, no quotes.
341 +
342 + User: \(firstUser.text.prefix(500))
343 + Assistant: \(assistantText.prefix(500))
344 + """
345 + Task { [weak self] in
346 + guard let self else { return }
347 + do {
348 + let apiKey = try self.vault.apiKey(for: cheap.provider)
349 + let client = ProviderRegistry.client(for: cheap)
350 + let request = ChatRequest(
351 + model: cheap,
352 + systemPrompt: nil,
353 + messages: [Message(role: .user, text: prompt)],
354 + parameters: ChatParameters(maxTokens: 24),
355 + stream: false
356 + )
357 + let reply = try await client.complete(request, apiKey: apiKey)
358 + let title = reply.text
359 + .trimmingCharacters(in: .whitespacesAndNewlines)
360 + .trimmingCharacters(in: CharacterSet(charactersIn: "\"“”"))
361 + guard !title.isEmpty,
362 + var current = self.conversations.first(where: { $0.id == conversationID }),
363 + current.hasAutoTitle
364 + else { return }
365 + current.title = String(title.prefix(60))
366 + self.update(current)
367 + } catch {
368 + // Title generation is best-effort; keep "New Chat" on failure.
369 + }
370 + }
371 + }
372 +}
added Sources/ZyquoCloud/ViewModels/KeyVaultStore.swift +93 −0
@@ -0,0 +1,93 @@
1 +//
2 +// KeyVaultStore.swift
3 +// Zyquo Cloud
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Observable wrapper around SecureKeyStore for the Settings UI: per-provider
9 +// key presence, redacted display, and "Test" with latency. Decrypted keys are
10 +// fetched on demand and never retained beyond the call.
11 +//
12 +
13 +import Foundation
14 +
15 +@MainActor
16 +final class KeyVaultStore: ObservableObject {
17 + enum KeyStatus: Equatable {
18 + case unset
19 + case saved // present, not yet verified this session
20 + case testing
21 + case verified(latency: TimeInterval)
22 + case failed(message: String)
23 + }
24 +
25 + @Published private(set) var statuses: [ProviderID: KeyStatus] = [:]
26 + /// Redacted display strings (••••1234) for providers with saved keys.
27 + @Published private(set) var redactedKeys: [ProviderID: String] = [:]
28 +
29 + private let store: SecureKeyStore
30 +
31 + init(store: SecureKeyStore = SecureKeyStore()) {
32 + self.store = store
33 + refresh()
34 + }
35 +
36 + func refresh() {
37 + let keys = (try? store.loadKeys()) ?? [:]
38 + for provider in ProviderID.builtIn {
39 + if let key = keys[provider.rawValue], !key.isEmpty {
40 + redactedKeys[provider] = SecureKeyStore.redacted(key)
41 + if case .verified = statuses[provider] ?? .unset {} else {
42 + statuses[provider] = .saved
43 + }
44 + } else {
45 + redactedKeys[provider] = nil
46 + statuses[provider] = .unset
47 + }
48 + }
49 + }
50 +
51 + func hasKey(for provider: ProviderID) -> Bool {
52 + redactedKeys[provider] != nil
53 + }
54 +
55 + /// Decrypts and returns the key — call sites use it immediately and drop it.
56 + func apiKey(for provider: ProviderID) throws -> String {
57 + guard let key = try store.key(for: provider), !key.isEmpty else {
58 + throw ProviderError.missingAPIKey(provider)
59 + }
60 + return key
61 + }
62 +
63 + func setKey(_ key: String, for provider: ProviderID) {
64 + let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
65 + guard !trimmed.isEmpty else { return }
66 + try? store.setKey(trimmed, for: provider)
67 + statuses[provider] = .saved
68 + refresh()
69 + }
70 +
71 + func deleteKey(for provider: ProviderID) {
72 + try? store.deleteKey(for: provider)
73 + statuses[provider] = .unset
74 + refresh()
75 + }
76 +
77 + /// Runs the cheapest authenticated call and records latency or failure.
78 + func testKey(for provider: ProviderID, catalog: ModelCatalog) async {
79 + guard let key = try? apiKey(for: provider) else {
80 + statuses[provider] = .failed(message: "No key saved")
81 + return
82 + }
83 + statuses[provider] = .testing
84 + let client = ProviderRegistry.client(for: provider)
85 + let fallback = catalog.cheapestModel(for: provider)
86 + do {
87 + let latency = try await client.testKey(key, fallbackModel: fallback)
88 + statuses[provider] = .verified(latency: latency)
89 + } catch {
90 + statuses[provider] = .failed(message: error.localizedDescription)
91 + }
92 + }
93 +}
added Sources/ZyquoCloud/Views/Chat/ChatView.swift +324 −0
@@ -0,0 +1,324 @@
1 +//
2 +// ChatView.swift
3 +// Zyquo Cloud
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// The chat area: 52pt header (editable title, centered model chip, info),
9 +// transcript with 760pt centered column and smooth auto-scroll + jump-to-
10 +// bottom pill, and the floating input bar.
11 +//
12 +
13 +import SwiftUI
14 +
15 +struct ChatView: View {
16 + let conversationID: Conversation.ID
17 +
18 + @EnvironmentObject private var store: ConversationStore
19 + @EnvironmentObject private var catalog: ModelCatalog
20 + @EnvironmentObject private var appearance: AppearanceStore
21 +
22 + @State private var draft = ""
23 + @State private var draftAttachments: [Attachment] = []
24 + @State private var titleDraft = ""
25 + @State private var editingTitle = false
26 + @State private var showingInfo = false
27 + @State private var showingCompare = false
28 + @State private var showingPalette = false
29 + @State private var pinnedToBottom = true
30 +
31 + private var conversation: Conversation? {
32 + store.conversations.first { $0.id == conversationID }
33 + }
34 +
35 + private var currentModel: AIModel? {
36 + guard let conversation else { return nil }
37 + return catalog.model(id: conversation.modelID, provider: conversation.provider)
38 + }
39 +
40 + var body: some View {
41 + VStack(spacing: 0) {
42 + header
43 + ZyquoHairline()
44 + if let conversation, conversation.messages.isEmpty {
45 + EmptyStateView(
46 + model: currentModel,
47 + onSelectModel: select(model:),
48 + onSuggestion: { draft = $0 }
49 + )
50 + } else {
51 + transcript
52 + }
53 + InputBarView(
54 + text: $draft,
55 + attachments: $draftAttachments,
56 + isStreaming: store.isStreaming(conversationID),
57 + supportsVision: currentModel?.capabilities.vision ?? false,
58 + onSend: send,
59 + onStop: { store.stopStreaming(conversationID) },
60 + parametersContent: { AnyView(parametersPopover) }
61 + )
62 + }
63 + .background(ZyquoColor.background)
64 + .sheet(isPresented: $showingCompare) {
65 + CompareView()
66 + }
67 + .sheet(isPresented: $showingPalette) {
68 + CommandPaletteView(
69 + currentModel: currentModel,
70 + onSelectModel: select(model:),
71 + onInsertTemplate: { template in
72 + draft = PromptLibraryStore.apply(template, input: draft)
73 + },
74 + onApplyPersona: applyPersona
75 + )
76 + }
77 + .background(
78 + // Invisible ⌘K target for the command palette.
79 + Button("") { showingPalette = true }
80 + .keyboardShortcut("k", modifiers: .command)
81 + .hidden()
82 + )
83 + }
84 +
85 + // MARK: - Header
86 +
87 + private var header: some View {
88 + ZStack {
89 + // Centered model chip.
90 + ModelChipView(model: currentModel, onSelect: select(model:))
91 + HStack(spacing: ZyquoSpacing.xs) {
92 + if editingTitle {
93 + TextField("Title", text: $titleDraft, onCommit: {
94 + store.rename(conversationID, to: titleDraft)
95 + editingTitle = false
96 + })
97 + .textFieldStyle(.plain)
98 + .font(ZyquoFont.bodyEmphasis(size: 13))
99 + .frame(maxWidth: 220)
100 + } else {
101 + Text(conversation?.title ?? "")
102 + .font(ZyquoFont.bodyEmphasis(size: 13))
103 + .foregroundStyle(ZyquoColor.textPrimary)
104 + .lineLimit(1)
105 + .frame(maxWidth: 220, alignment: .leading)
106 + .onTapGesture(count: 2) {
107 + titleDraft = conversation?.title ?? ""
108 + editingTitle = true
109 + }
110 + }
111 + Spacer()
112 + headerButtons
113 + }
114 + }
115 + .padding(.horizontal, ZyquoMetrics.contentInset)
116 + .frame(height: ZyquoMetrics.chatHeaderHeight)
117 + }
118 +
119 + private var headerButtons: some View {
120 + HStack(spacing: ZyquoSpacing.xs) {
121 + Button {
122 + showingCompare = true
123 + } label: {
124 + Image(systemName: "rectangle.split.2x1")
125 + .font(.system(size: 12))
126 + .foregroundStyle(ZyquoColor.textSecondary)
127 + }
128 + .buttonStyle(.plain)
129 + .help("Compare models")
130 +
131 + Button {
132 + exportMarkdown()
133 + } label: {
134 + Image(systemName: "square.and.arrow.up")
135 + .font(.system(size: 12))
136 + .foregroundStyle(ZyquoColor.textSecondary)
137 + }
138 + .buttonStyle(.plain)
139 + .keyboardShortcut("e", modifiers: [.command, .shift])
140 + .help("Export conversation (⌘⇧E)")
141 +
142 + Button {
143 + showingInfo.toggle()
144 + } label: {
145 + Image(systemName: "info.circle")
146 + .font(.system(size: 12))
147 + .foregroundStyle(ZyquoColor.textSecondary)
148 + }
149 + .buttonStyle(.plain)
150 + .help("Conversation info")
151 + .popover(isPresented: $showingInfo, arrowEdge: .bottom) {
152 + infoPopover
153 + }
154 + }
155 + }
156 +
157 + // MARK: - Transcript
158 +
159 + private var transcript: some View {
160 + ScrollViewReader { proxy in
161 + ZStack(alignment: .bottom) {
162 + ScrollView {
163 + LazyVStack(spacing: ZyquoMetrics.verticalTurnRhythm) {
164 + ForEach(conversation?.messages ?? []) { message in
165 + MessageBubbleView(
166 + message: message,
167 + fontSize: appearance.chatFontSize,
168 + onCopy: { copyToPasteboard(message.text) },
169 + onEditResend: message.role == .user
170 + ? { newText in store.editAndResend(messageID: message.id, newText: newText, in: conversationID) }
171 + : nil,
172 + onRegenerate: message.role == .assistant
173 + ? { store.regenerate(in: conversationID) }
174 + : nil,
175 + onDelete: { store.deleteMessage(message.id, in: conversationID) },
176 + onQuote: { text in
177 + draft = text.split(separator: "\n").map { "> \($0)" }.joined(separator: "\n") + "\n\n" + draft
178 + }
179 + )
180 + .id(message.id)
181 + }
182 + Color.clear.frame(height: 1).id("bottom")
183 + }
184 + .padding(.horizontal, ZyquoMetrics.contentInset)
185 + .padding(.vertical, ZyquoMetrics.contentInset)
186 + .frame(maxWidth: ZyquoMetrics.maxMessageColumnWidth)
187 + .frame(maxWidth: .infinity)
188 + }
189 + .onChange(of: lastMessageFingerprint) { _ in
190 + if pinnedToBottom {
191 + proxy.scrollTo("bottom", anchor: .bottom)
192 + }
193 + }
194 + if !pinnedToBottom, store.isStreaming(conversationID) {
195 + jumpToBottomPill(proxy: proxy)
196 + }
197 + }
198 + }
199 + }
200 +
201 + /// Changes when content grows so auto-scroll can follow the stream.
202 + private var lastMessageFingerprint: Int {
203 + guard let last = conversation?.messages.last else { return 0 }
204 + return last.text.count &+ (last.reasoning?.count ?? 0) &* 31 &+ (conversation?.messages.count ?? 0) &* 7
205 + }
206 +
207 + private func jumpToBottomPill(proxy: ScrollViewProxy) -> some View {
208 + Button {
209 + pinnedToBottom = true
210 + withAnimation(ZyquoMotion.send) { proxy.scrollTo("bottom", anchor: .bottom) }
211 + } label: {
212 + HStack(spacing: ZyquoSpacing.xxs) {
213 + Image(systemName: "arrow.down")
214 + .font(.system(size: 10, weight: .semibold))
215 + Text("Jump to latest")
216 + .font(ZyquoFont.caption)
217 + }
218 + .foregroundStyle(ZyquoColor.textPrimary)
219 + .padding(.horizontal, ZyquoSpacing.sm)
220 + .padding(.vertical, 5)
221 + .background(Capsule().fill(ZyquoColor.surface))
222 + .overlay(Capsule().strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline))
223 + .zyquoSoftShadow()
224 + }
225 + .buttonStyle(PressableButtonStyle())
226 + .padding(.bottom, ZyquoSpacing.xs)
227 + }
228 +
229 + // MARK: - Popovers
230 +
231 + private var infoPopover: some View {
232 + VStack(alignment: .leading, spacing: ZyquoSpacing.sm) {
233 + Text("Conversation")
234 + .font(ZyquoFont.bodyEmphasis())
235 + if let conversation {
236 + LabeledContent {
237 + Text("\(conversation.totalUsage.inputTokens) in · \(conversation.totalUsage.outputTokens) out")
238 + } label: { Text("Tokens") }
239 + LabeledContent {
240 + Text(String(format: "~$%.4f", conversation.totalCost))
241 + } label: { Text("Est. cost") }
242 + Divider()
243 + Text("System prompt")
244 + .font(ZyquoFont.caption)
245 + .foregroundStyle(ZyquoColor.textSecondary)
246 + TextEditor(text: Binding(
247 + get: { conversation.systemPrompt ?? "" },
248 + set: { newValue in
249 + var updated = conversation
250 + updated.systemPrompt = newValue.isEmpty ? nil : newValue
251 + store.update(updated)
252 + }
253 + ))
254 + .font(ZyquoFont.body(size: 12))
255 + .frame(width: 300, height: 90)
256 + .scrollContentBackground(.hidden)
257 + .background(
258 + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)
259 + .fill(ZyquoColor.surfaceSecondary)
260 + )
261 + }
262 + }
263 + .font(ZyquoFont.body(size: 12.5))
264 + .padding(ZyquoSpacing.md)
265 + .frame(width: 340)
266 + }
267 +
268 + private var parametersPopover: some View {
269 + ParametersEditorView(
270 + parameters: Binding(
271 + get: { conversation?.parameters ?? ChatParameters() },
272 + set: { newValue in
273 + guard var updated = conversation else { return }
274 + updated.parameters = newValue
275 + store.update(updated)
276 + }
277 + ),
278 + support: currentModel?.parameterSupport ?? ParameterSupport()
279 + )
280 + }
281 +
282 + // MARK: - Actions
283 +
284 + private func send() {
285 + let text = draft.trimmingCharacters(in: .whitespacesAndNewlines)
286 + guard !text.isEmpty || !draftAttachments.isEmpty else { return }
287 + let attachments = draftAttachments
288 + withAnimation(ZyquoMotion.send) {
289 + draft = ""
290 + draftAttachments = []
291 + }
292 + pinnedToBottom = true
293 + store.send(text: text, attachments: attachments, in: conversationID)
294 + }
295 +
296 + private func select(model: AIModel) {
297 + guard var conversation else { return }
298 + conversation.modelID = model.id
299 + conversation.provider = model.provider
300 + store.update(conversation)
301 + }
302 +
303 + private func applyPersona(_ persona: Persona) {
304 + guard var conversation else { return }
305 + conversation.systemPrompt = persona.systemPrompt
306 + conversation.personaID = persona.id
307 + conversation.parameters = persona.parameters
308 + if let modelID = persona.modelID, let provider = persona.provider {
309 + conversation.modelID = modelID
310 + conversation.provider = provider
311 + }
312 + store.update(conversation)
313 + }
314 +
315 + private func copyToPasteboard(_ text: String) {
316 + NSPasteboard.general.clearContents()
317 + NSPasteboard.general.setString(text, forType: .string)
318 + }
319 +
320 + private func exportMarkdown() {
321 + guard let conversation else { return }
322 + ConversationExporter.presentSavePanel(for: conversation)
323 + }
324 +}
added Sources/ZyquoCloud/Views/Chat/EmptyStateView.swift +82 −0
@@ -0,0 +1,82 @@
1 +//
2 +// EmptyStateView.swift
3 +// Zyquo Cloud
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Empty chat state: brand glyph, greeting, four suggested prompt cards, and
9 +// the model chip — intentional and beautiful, never blank.
10 +//
11 +
12 +import SwiftUI
13 +
14 +struct EmptyStateView: View {
15 + let model: AIModel?
16 + var onSelectModel: (AIModel) -> Void
17 + var onSuggestion: (String) -> Void
18 +
19 + private static let suggestions: [(symbol: String, title: String, prompt: String)] = [
20 + ("lightbulb", "Explain something",
21 + "Explain how transformer attention works, with a concrete example."),
22 + ("chevron.left.forwardslash.chevron.right", "Review my code",
23 + "Review this code for bugs and suggest improvements:\n\n"),
24 + ("doc.text", "Summarize a document",
25 + "Summarize the key points of the following document:\n\n"),
26 + ("globe", "Translate",
27 + "Translate the following text to French, keeping the tone natural:\n\n"),
28 + ]
29 +
30 + var body: some View {
31 + VStack(spacing: ZyquoSpacing.lg) {
32 + Spacer()
33 + CloudZGlyph(size: 88)
34 + VStack(spacing: ZyquoSpacing.xxs) {
35 + Text("Welcome to Zyquo Cloud")
36 + .font(ZyquoFont.title)
37 + .foregroundStyle(ZyquoColor.textPrimary)
38 + Text("Your keys, every cloud model, one beautiful chat.")
39 + .font(ZyquoFont.body())
40 + .foregroundStyle(ZyquoColor.textSecondary)
41 + }
42 + ModelChipView(model: model, onSelect: onSelectModel)
43 + LazyVGrid(
44 + columns: [GridItem(.flexible()), GridItem(.flexible())],
45 + spacing: ZyquoSpacing.sm
46 + ) {
47 + ForEach(Self.suggestions, id: \.title) { suggestion in
48 + Button {
49 + onSuggestion(suggestion.prompt)
50 + } label: {
51 + HStack(spacing: ZyquoSpacing.xs) {
52 + Image(systemName: suggestion.symbol)
53 + .font(.system(size: 14))
54 + .foregroundStyle(ZyquoColor.accent)
55 + .frame(width: 20)
56 + Text(suggestion.title)
57 + .font(ZyquoFont.body(size: 13))
58 + .foregroundStyle(ZyquoColor.textPrimary)
59 + Spacer(minLength: 0)
60 + }
61 + .padding(ZyquoSpacing.sm)
62 + .background(
63 + RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous)
64 + .fill(ZyquoColor.surface)
65 + .overlay(
66 + RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous)
67 + .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline)
68 + )
69 + )
70 + .contentShape(Rectangle())
71 + }
72 + .buttonStyle(PressableButtonStyle())
73 + }
74 + }
75 + .frame(maxWidth: 460)
76 + Spacer()
77 + Spacer()
78 + }
79 + .frame(maxWidth: .infinity, maxHeight: .infinity)
80 + .background(ZyquoColor.background)
81 + }
82 +}
added Sources/ZyquoCloud/Views/Chat/InputBarView.swift +245 −0
@@ -0,0 +1,245 @@
1 +//
2 +// InputBarView.swift
3 +// Zyquo Cloud
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Floating input card docked at the bottom: auto-growing multiline editor,
9 +// attach button, parameter quick-toggle, circular send button (⌘↩),
10 +// attachment thumbnails, drag-and-drop with dashed accent highlight.
11 +//
12 +
13 +import SwiftUI
14 +import UniformTypeIdentifiers
15 +
16 +struct InputBarView: View {
17 + @Binding var text: String
18 + @Binding var attachments: [Attachment]
19 + let isStreaming: Bool
20 + let supportsVision: Bool
21 + var onSend: () -> Void
22 + var onStop: () -> Void
23 + var parametersContent: () -> AnyView
24 +
25 + @State private var dropTargeted = false
26 + @State private var showingParameters = false
27 + @EnvironmentObject private var appearance: AppearanceStore
28 + @FocusState private var editorFocused: Bool
29 +
30 + private var canSend: Bool {
31 + !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !attachments.isEmpty
32 + }
33 +
34 + var body: some View {
35 + VStack(spacing: ZyquoSpacing.xs) {
36 + if !attachments.isEmpty {
37 + attachmentStrip
38 + }
39 + HStack(alignment: .bottom, spacing: ZyquoSpacing.xs) {
40 + attachButton
41 + editor
42 + parameterToggle
43 + sendButton
44 + }
45 + }
46 + .padding(ZyquoSpacing.sm)
47 + .background(
48 + RoundedRectangle(cornerRadius: ZyquoRadius.large, style: .continuous)
49 + .fill(ZyquoColor.surface)
50 + .overlay(
51 + RoundedRectangle(cornerRadius: ZyquoRadius.large, style: .continuous)
52 + .strokeBorder(
53 + dropTargeted ? ZyquoColor.accent : ZyquoColor.border,
54 + style: StrokeStyle(
55 + lineWidth: dropTargeted ? 1.5 : ZyquoMetrics.hairline,
56 + dash: dropTargeted ? [6, 4] : []
57 + )
58 + )
59 + )
60 + )
61 + .zyquoSoftShadow()
62 + .padding(.horizontal, ZyquoSpacing.sm)
63 + .padding(.bottom, ZyquoSpacing.sm)
64 + .onDrop(of: [.fileURL, .image], isTargeted: $dropTargeted) { providers in
65 + handleDrop(providers)
66 + }
67 + }
68 +
69 + // MARK: - Pieces
70 +
71 + private var editor: some View {
72 + ZStack(alignment: .topLeading) {
73 + if text.isEmpty {
74 + Text("Message…")
75 + .font(ZyquoFont.body(size: appearance.chatFontSize))
76 + .foregroundStyle(ZyquoColor.textTertiary)
77 + .padding(.top, 2)
78 + .allowsHitTesting(false)
79 + }
80 + TextEditor(text: $text)
81 + .font(ZyquoFont.body(size: appearance.chatFontSize))
82 + .foregroundStyle(ZyquoColor.textPrimary)
83 + .scrollContentBackground(.hidden)
84 + .frame(minHeight: 22, maxHeight: 200)
85 + .fixedSize(horizontal: false, vertical: text.count < 2000)
86 + .focused($editorFocused)
87 + .onAppear { editorFocused = true }
88 + }
89 + }
90 +
91 + private var attachButton: some View {
92 + Button {
93 + presentFilePicker()
94 + } label: {
95 + Image(systemName: "paperclip")
96 + .font(.system(size: 14))
97 + .foregroundStyle(ZyquoColor.textSecondary)
98 + .frame(width: 26, height: 26)
99 + }
100 + .buttonStyle(.plain)
101 + .zyquoHoverHighlight()
102 + .help(supportsVision ? "Attach images or text files" : "Attach text files")
103 + }
104 +
105 + private var parameterToggle: some View {
106 + Button {
107 + showingParameters.toggle()
108 + } label: {
109 + Image(systemName: "slider.horizontal.3")
110 + .font(.system(size: 13))
111 + .foregroundStyle(ZyquoColor.textSecondary)
112 + .frame(width: 26, height: 26)
113 + }
114 + .buttonStyle(.plain)
115 + .zyquoHoverHighlight()
116 + .help("Generation parameters")
117 + .popover(isPresented: $showingParameters, arrowEdge: .top) {
118 + parametersContent()
119 + }
120 + }
121 +
122 + private var sendButton: some View {
123 + Button {
124 + isStreaming ? onStop() : (canSend ? onSend() : ())
125 + } label: {
126 + Image(systemName: isStreaming ? "stop.fill" : "arrow.up")
127 + .font(.system(size: 13, weight: .semibold))
128 + .foregroundStyle(.white)
129 + .frame(width: 28, height: 28)
130 + .background(
131 + Circle().fill(
132 + isStreaming
133 + ? ZyquoColor.danger
134 + : (canSend ? ZyquoColor.accent : ZyquoColor.textTertiary)
135 + )
136 + )
137 + }
138 + .buttonStyle(PressableButtonStyle())
139 + .keyboardShortcut(.return, modifiers: .command)
140 + .help(isStreaming ? "Stop generating" : "Send (⌘↩)")
141 + }
142 +
143 + private var attachmentStrip: some View {
144 + ScrollView(.horizontal, showsIndicators: false) {
145 + HStack(spacing: ZyquoSpacing.xs) {
146 + ForEach(attachments) { attachment in
147 + AttachmentThumbnail(attachment: attachment) {
148 + attachments.removeAll { $0.id == attachment.id }
149 + }
150 + }
151 + }
152 + }
153 + .frame(height: ZyquoMetrics.attachmentThumbnail + 8)
154 + }
155 +
156 + // MARK: - Attachment intake
157 +
158 + private static let textExtensions: Set<String> = [
159 + "txt", "md", "markdown", "csv", "json", "yaml", "yml", "xml", "log",
160 + "swift", "py", "js", "ts", "jsx", "tsx", "html", "css", "sh", "zsh",
161 + "bash", "sql", "go", "rs", "c", "h", "cpp", "hpp", "m", "mm", "java",
162 + "rb", "php", "toml", "ini", "cfg", "tex",
163 + ]
164 + private static let imageExtensions: Set<String> = ["png", "jpg", "jpeg", "webp", "gif"]
165 +
166 + private func presentFilePicker() {
167 + let panel = NSOpenPanel()
168 + panel.allowsMultipleSelection = true
169 + panel.canChooseDirectories = false
170 + panel.begin { response in
171 + guard response == .OK else { return }
172 + for url in panel.urls { ingest(url: url) }
173 + }
174 + }
175 +
176 + private func handleDrop(_ providers: [NSItemProvider]) -> Bool {
177 + var handled = false
178 + for provider in providers where provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) {
179 + handled = true
180 + provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier) { item, _ in
181 + guard let data = item as? Data,
182 + let url = URL(dataRepresentation: data, relativeTo: nil) else { return }
183 + DispatchQueue.main.async { ingest(url: url) }
184 + }
185 + }
186 + return handled
187 + }
188 +
189 + private func ingest(url: URL) {
190 + let ext = url.pathExtension.lowercased()
191 + guard let data = try? Data(contentsOf: url) else { return }
192 + if Self.imageExtensions.contains(ext) {
193 + let mime = ext == "jpg" ? "image/jpeg" : "image/\(ext)"
194 + attachments.append(
195 + Attachment(kind: .image, fileName: url.lastPathComponent, data: data, mimeType: mime)
196 + )
197 + } else if Self.textExtensions.contains(ext) || (String(data: data, encoding: .utf8) != nil && data.count < 512_000) {
198 + attachments.append(
199 + Attachment(kind: .textFile, fileName: url.lastPathComponent, data: data, mimeType: "text/plain")
200 + )
201 + }
202 + }
203 +}
204 +
205 +/// 56pt thumbnail with a remove button; images preview, text files show an icon.
206 +private struct AttachmentThumbnail: View {
207 + let attachment: Attachment
208 + var onRemove: () -> Void
209 +
210 + var body: some View {
211 + ZStack(alignment: .topTrailing) {
212 + Group {
213 + if attachment.kind == .image, let image = NSImage(data: attachment.data) {
214 + Image(nsImage: image)
215 + .resizable()
216 + .aspectRatio(contentMode: .fill)
217 + } else {
218 + VStack(spacing: 2) {
219 + Image(systemName: "doc.text")
220 + .font(.system(size: 16))
221 + .foregroundStyle(ZyquoColor.textSecondary)
222 + Text(attachment.fileName)
223 + .font(.system(size: 8))
224 + .foregroundStyle(ZyquoColor.textTertiary)
225 + .lineLimit(1)
226 + }
227 + .padding(4)
228 + }
229 + }
230 + .frame(width: ZyquoMetrics.attachmentThumbnail, height: ZyquoMetrics.attachmentThumbnail)
231 + .background(ZyquoColor.surfaceSecondary)
232 + .clipShape(RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous))
233 +
234 + Button(action: onRemove) {
235 + Image(systemName: "xmark.circle.fill")
236 + .font(.system(size: 12))
237 + .foregroundStyle(ZyquoColor.textSecondary)
238 + .background(Circle().fill(ZyquoColor.surface))
239 + }
240 + .buttonStyle(.plain)
241 + .offset(x: 5, y: -5)
242 + }
243 + .padding(.top, 4)
244 + }
245 +}
added Sources/ZyquoCloud/Views/Chat/MessageBubbleView.swift +342 −0
@@ -0,0 +1,342 @@
1 +//
2 +// MessageBubbleView.swift
3 +// Zyquo Cloud
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// One transcript turn: user messages right-aligned in accentSubtle bubbles,
9 +// assistant messages left-aligned on surface with the provider glyph avatar.
10 +// Hover reveals timestamp + tokens/cost and message actions. Reasoning models
11 +// get a collapsible "Thinking…" section; Perplexity citations render as
12 +// numbered chips.
13 +//
14 +
15 +import SwiftUI
16 +
17 +struct MessageBubbleView: View {
18 + let message: Message
19 + let fontSize: Double
20 + var onCopy: () -> Void = {}
21 + var onEditResend: ((String) -> Void)?
22 + var onRegenerate: (() -> Void)?
23 + var onDelete: () -> Void = {}
24 + var onQuote: ((String) -> Void)?
25 +
26 + @State private var hovering = false
27 + @State private var showThinking = false
28 + @State private var editing = false
29 + @State private var editText = ""
30 + @Environment(\.openURL) private var openURL
31 +
32 + var body: some View {
33 + HStack(alignment: .top, spacing: ZyquoSpacing.xs) {
34 + if message.role == .user { Spacer(minLength: 60) }
35 + if message.role == .assistant { avatar }
36 + VStack(alignment: message.role == .user ? .trailing : .leading, spacing: ZyquoSpacing.xxs) {
37 + bubble
38 + metadata
39 + .opacity(hovering ? 1 : 0)
40 + }
41 + if message.role == .assistant { Spacer(minLength: 60) }
42 + }
43 + .onHover { inside in
44 + withAnimation(ZyquoMotion.hover) { hovering = inside }
45 + }
46 + }
47 +
48 + // MARK: - Avatar
49 +
50 + private var avatar: some View {
51 + Image(systemName: message.provider?.symbolName ?? "sparkle")
52 + .font(.system(size: 12, weight: .medium))
53 + .foregroundStyle(ZyquoColor.accent)
54 + .frame(width: 26, height: 26)
55 + .background(Circle().fill(ZyquoColor.accentSubtle))
56 + .padding(.top, 2)
57 + }
58 +
59 + // MARK: - Bubble
60 +
61 + @ViewBuilder
62 + private var bubble: some View {
63 + VStack(alignment: .leading, spacing: ZyquoSpacing.xs) {
64 + if !message.attachments.isEmpty {
65 + attachmentPreviews
66 + }
67 + if let reasoning = message.reasoning, !reasoning.isEmpty {
68 + thinkingSection(reasoning)
69 + }
70 + if editing {
71 + editorView
72 + } else if message.role == .assistant {
73 + MarkdownView(text: message.text, fontSize: fontSize)
74 + if message.isStreaming {
75 + StreamingCaret()
76 + }
77 + } else {
78 + Text(message.text)
79 + .font(ZyquoFont.body(size: fontSize))
80 + .lineSpacing(fontSize * ZyquoFont.bodyLineSpacingFactor)
81 + .foregroundStyle(ZyquoColor.textPrimary)
82 + .textSelection(.enabled)
83 + }
84 + if !message.citations.isEmpty {
85 + citationChips
86 + }
87 + if let error = message.errorText {
88 + errorView(error)
89 + }
90 + }
91 + .padding(.horizontal, ZyquoSpacing.sm)
92 + .padding(.vertical, ZyquoSpacing.xs + 2)
93 + .background(
94 + RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous)
95 + .fill(message.role == .user ? ZyquoColor.accentSubtle : ZyquoColor.surface)
96 + .overlay(
97 + RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous)
98 + .strokeBorder(ZyquoColor.border, lineWidth: message.role == .assistant ? ZyquoMetrics.hairline : 0)
99 + )
100 + )
101 + .contextMenu { actionButtons }
102 + }
103 +
104 + private var editorView: some View {
105 + VStack(alignment: .trailing, spacing: ZyquoSpacing.xs) {
106 + TextEditor(text: $editText)
107 + .font(ZyquoFont.body(size: fontSize))
108 + .frame(minWidth: 320, minHeight: 60, maxHeight: 200)
109 + .scrollContentBackground(.hidden)
110 + HStack {
111 + Button("Cancel") { editing = false }
112 + Button("Resend") {
113 + editing = false
114 + onEditResend?(editText)
115 + }
116 + .buttonStyle(.borderedProminent)
117 + .disabled(editText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
118 + }
119 + }
120 + }
121 +
122 + // MARK: - Thinking
123 +
124 + private func thinkingSection(_ reasoning: String) -> some View {
125 + VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) {
126 + Button {
127 + withAnimation(ZyquoMotion.picker) { showThinking.toggle() }
128 + } label: {
129 + HStack(spacing: ZyquoSpacing.xxs) {
130 + Image(systemName: "chevron.right")
131 + .font(.system(size: 8, weight: .semibold))
132 + .rotationEffect(.degrees(showThinking ? 90 : 0))
133 + Text(message.isStreaming && message.text.isEmpty ? "Thinking…" : "Thought process")
134 + .font(ZyquoFont.caption)
135 + }
136 + .foregroundStyle(ZyquoColor.textSecondary)
137 + }
138 + .buttonStyle(.plain)
139 + if showThinking {
140 + Text(reasoning)
141 + .font(ZyquoFont.code(size: fontSize - 2))
142 + .foregroundStyle(ZyquoColor.textSecondary)
143 + .lineSpacing(3)
144 + .textSelection(.enabled)
145 + .padding(ZyquoSpacing.xs)
146 + .frame(maxWidth: .infinity, alignment: .leading)
147 + .background(
148 + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)
149 + .fill(ZyquoColor.surfaceSecondary)
150 + )
151 + }
152 + }
153 + }
154 +
155 + // MARK: - Citations
156 +
157 + private var citationChips: some View {
158 + FlowLayoutCompat(spacing: ZyquoSpacing.xxs) {
159 + ForEach(message.citations) { citation in
160 + Button {
161 + openURL(citation.url)
162 + } label: {
163 + HStack(spacing: 3) {
164 + Text("\(citation.index)")
165 + .font(.system(size: 9, weight: .semibold))
166 + .foregroundStyle(.white)
167 + .frame(width: 13, height: 13)
168 + .background(Circle().fill(ZyquoColor.accent))
169 + Text(citation.title ?? citation.url.host() ?? citation.url.absoluteString)
170 + .font(ZyquoFont.caption)
171 + .foregroundStyle(ZyquoColor.textSecondary)
172 + .lineLimit(1)
173 + }
174 + .padding(.horizontal, ZyquoSpacing.xs)
175 + .padding(.vertical, 3)
176 + .background(Capsule().fill(ZyquoColor.surfaceSecondary))
177 + }
178 + .buttonStyle(.plain)
179 + .help(citation.url.absoluteString)
180 + }
181 + }
182 + }
183 +
184 + private func errorView(_ error: String) -> some View {
185 + HStack(spacing: ZyquoSpacing.xxs) {
186 + Image(systemName: "exclamationmark.triangle.fill")
187 + .font(.system(size: 11))
188 + Text(error)
189 + .font(ZyquoFont.body(size: fontSize - 1))
190 + .textSelection(.enabled)
191 + }
192 + .foregroundStyle(ZyquoColor.danger)
193 + .padding(ZyquoSpacing.xs)
194 + .background(
195 + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)
196 + .fill(ZyquoColor.danger.opacity(0.08))
197 + )
198 + }
199 +
200 + private var attachmentPreviews: some View {
201 + HStack(spacing: ZyquoSpacing.xs) {
202 + ForEach(message.attachments) { attachment in
203 + if attachment.kind == .image, let image = NSImage(data: attachment.data) {
204 + Image(nsImage: image)
205 + .resizable()
206 + .aspectRatio(contentMode: .fill)
207 + .frame(width: 120, height: 90)
208 + .clipShape(RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous))
209 + } else {
210 + ZyquoBadge(text: attachment.fileName)
211 + }
212 + }
213 + }
214 + }
215 +
216 + // MARK: - Metadata & actions
217 +
218 + private var metadata: some View {
219 + HStack(spacing: ZyquoSpacing.xs) {
220 + if message.role == .assistant, hovering {
221 + actionIcons
222 + }
223 + Text(message.createdAt, format: .dateTime.hour().minute())
224 + .font(ZyquoFont.caption)
225 + .foregroundStyle(ZyquoColor.textTertiary)
226 + if let usage = message.usage {
227 + Text("\(usage.inputTokens)\(usage.outputTokens) tok")
228 + .font(ZyquoFont.caption)
229 + .foregroundStyle(ZyquoColor.textTertiary)
230 + }
231 + if let cost = message.estimatedCost, cost > 0 {
232 + Text(String(format: "~$%.4f", cost))
233 + .font(ZyquoFont.caption)
234 + .foregroundStyle(ZyquoColor.textTertiary)
235 + }
236 + if message.role == .user, hovering {
237 + actionIcons
238 + }
239 + }
240 + }
241 +
242 + private var actionIcons: some View {
243 + HStack(spacing: ZyquoSpacing.xxs) {
244 + iconButton("doc.on.doc", help: "Copy") { onCopy() }
245 + if message.role == .user, onEditResend != nil {
246 + iconButton("pencil", help: "Edit & resend") {
247 + editText = message.text
248 + editing = true
249 + }
250 + }
251 + if message.role == .assistant, let onRegenerate {
252 + iconButton("arrow.clockwise", help: "Regenerate") { onRegenerate() }
253 + }
254 + if let onQuote {
255 + iconButton("quote.opening", help: "Quote reply") { onQuote(message.text) }
256 + }
257 + iconButton("trash", help: "Delete") { onDelete() }
258 + }
259 + }
260 +
261 + @ViewBuilder
262 + private var actionButtons: some View {
263 + Button("Copy") { onCopy() }
264 + if message.role == .user, onEditResend != nil {
265 + Button("Edit & Resend") {
266 + editText = message.text
267 + editing = true
268 + }
269 + }
270 + if message.role == .assistant, let onRegenerate {
271 + Button("Regenerate") { onRegenerate() }
272 + }
273 + if let onQuote {
274 + Button("Quote Reply") { onQuote(message.text) }
275 + }
276 + Divider()
277 + Button("Delete", role: .destructive) { onDelete() }
278 + }
279 +
280 + private func iconButton(_ symbol: String, help: String, action: @escaping () -> Void) -> some View {
281 + Button(action: action) {
282 + Image(systemName: symbol)
283 + .font(.system(size: 10))
284 + .foregroundStyle(ZyquoColor.textSecondary)
285 + }
286 + .buttonStyle(.plain)
287 + .help(help)
288 + }
289 +}
290 +
291 +/// Blinking caret shown at the tail of a streaming message.
292 +struct StreamingCaret: View {
293 + @State private var visible = true
294 +
295 + var body: some View {
296 + RoundedRectangle(cornerRadius: 1)
297 + .fill(ZyquoColor.accent)
298 + .frame(width: 7, height: 15)
299 + .opacity(visible ? 1 : 0.15)
300 + .onAppear {
301 + withAnimation(.easeInOut(duration: 0.55).repeatForever(autoreverses: true)) {
302 + visible = false
303 + }
304 + }
305 + }
306 +}
307 +
308 +/// Minimal flow layout for citation chips (macOS 13-compatible Layout).
309 +struct FlowLayoutCompat: Layout {
310 + var spacing: CGFloat = 4
311 +
312 + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
313 + let width = proposal.width ?? 600
314 + var x: CGFloat = 0, y: CGFloat = 0, rowHeight: CGFloat = 0
315 + for subview in subviews {
316 + let size = subview.sizeThatFits(.unspecified)
317 + if x + size.width > width, x > 0 {
318 + x = 0
319 + y += rowHeight + spacing
320 + rowHeight = 0
321 + }
322 + x += size.width + spacing
323 + rowHeight = max(rowHeight, size.height)
324 + }
325 + return CGSize(width: width, height: y + rowHeight)
326 + }
327 +
328 + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
329 + var x = bounds.minX, y = bounds.minY, rowHeight: CGFloat = 0
330 + for subview in subviews {
331 + let size = subview.sizeThatFits(.unspecified)
332 + if x + size.width > bounds.maxX, x > bounds.minX {
333 + x = bounds.minX
334 + y += rowHeight + spacing
335 + rowHeight = 0
336 + }
337 + subview.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
338 + x += size.width + spacing
339 + rowHeight = max(rowHeight, size.height)
340 + }
341 + }
342 +}
added Sources/ZyquoCloud/Views/Chat/ModelChipView.swift +186 −0
@@ -0,0 +1,186 @@
1 +//
2 +// ModelChipView.swift
3 +// Zyquo Cloud
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// The model chip (provider glyph + model name) and its picker popover:
9 +// search, favorites first, grouped by provider, capability badges.
10 +//
11 +
12 +import SwiftUI
13 +
14 +struct ModelChipView: View {
15 + let model: AIModel?
16 + var onSelect: (AIModel) -> Void
17 +
18 + @State private var showingPicker = false
19 +
20 + var body: some View {
21 + Button {
22 + showingPicker.toggle()
23 + } label: {
24 + HStack(spacing: ZyquoSpacing.xxs) {
25 + Image(systemName: model?.provider.symbolName ?? "questionmark.circle")
26 + .font(.system(size: 11, weight: .medium))
27 + .foregroundStyle(ZyquoColor.accent)
28 + Text(model?.displayName ?? "Choose Model")
29 + .font(ZyquoFont.bodyEmphasis(size: 12.5))
30 + .foregroundStyle(ZyquoColor.textPrimary)
31 + .lineLimit(1)
32 + Image(systemName: "chevron.down")
33 + .font(.system(size: 8, weight: .semibold))
34 + .foregroundStyle(ZyquoColor.textTertiary)
35 + }
36 + .padding(.horizontal, ZyquoSpacing.xs)
37 + .padding(.vertical, 4)
38 + .background(
39 + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)
40 + .fill(ZyquoColor.surfaceSecondary)
41 + )
42 + }
43 + .buttonStyle(PressableButtonStyle())
44 + .popover(isPresented: $showingPicker, arrowEdge: .bottom) {
45 + ModelPickerView(selected: model) { chosen in
46 + showingPicker = false
47 + onSelect(chosen)
48 + }
49 + }
50 + }
51 +}
52 +
53 +/// Model picker popover content: search field + favorites + provider groups.
54 +struct ModelPickerView: View {
55 + let selected: AIModel?
56 + var onSelect: (AIModel) -> Void
57 +
58 + @EnvironmentObject private var catalog: ModelCatalog
59 + @EnvironmentObject private var vault: KeyVaultStore
60 + @State private var query = ""
61 +
62 + var body: some View {
63 + VStack(spacing: 0) {
64 + HStack(spacing: ZyquoSpacing.xxs) {
65 + Image(systemName: "magnifyingglass")
66 + .font(.system(size: 11))
67 + .foregroundStyle(ZyquoColor.textTertiary)
68 + TextField("Search models", text: $query)
69 + .textFieldStyle(.plain)
70 + .font(ZyquoFont.body(size: 12.5))
71 + }
72 + .padding(ZyquoSpacing.xs)
73 + ZyquoHairline()
74 + ScrollView {
75 + LazyVStack(alignment: .leading, spacing: 1) {
76 + if !favorites.isEmpty && query.isEmpty {
77 + sectionHeader("Favorites")
78 + ForEach(favorites) { model in row(model) }
79 + }
80 + ForEach(providerGroups, id: \.0) { provider, models in
81 + sectionHeader(provider.displayName)
82 + ForEach(models) { model in row(model) }
83 + }
84 + }
85 + .padding(ZyquoSpacing.xxs)
86 + }
87 + .frame(width: 320, height: 380)
88 + }
89 + .background(ZyquoColor.surface)
90 + }
91 +
92 + private var favorites: [AIModel] {
93 + catalog.all.filter { catalog.favoriteIDs.contains($0.id) }
94 + }
95 +
96 + /// Providers with a saved key first, then the rest; models filtered by search.
97 + private var providerGroups: [(ProviderID, [AIModel])] {
98 + let ordered = ProviderID.builtIn.sorted {
99 + (vault.hasKey(for: $0) ? 0 : 1, $0.displayName) < (vault.hasKey(for: $1) ? 0 : 1, $1.displayName)
100 + }
101 + return ordered.compactMap { provider in
102 + var models = catalog.models(for: provider)
103 + if !query.isEmpty {
104 + models = models.filter {
105 + $0.displayName.localizedCaseInsensitiveContains(query)
106 + || $0.id.localizedCaseInsensitiveContains(query)
107 + }
108 + }
109 + return models.isEmpty ? nil : (provider, models)
110 + }
111 + }
112 +
113 + private func sectionHeader(_ title: String) -> some View {
114 + Text(title)
115 + .font(ZyquoFont.caption)
116 + .foregroundStyle(ZyquoColor.textTertiary)
117 + .padding(.horizontal, ZyquoSpacing.xs)
118 + .padding(.top, ZyquoSpacing.xs)
119 + .padding(.bottom, 2)
120 + }
121 +
122 + private func row(_ model: AIModel) -> some View {
123 + Button {
124 + onSelect(model)
125 + } label: {
126 + HStack(spacing: ZyquoSpacing.xs) {
127 + Image(systemName: model.provider.symbolName)
128 + .font(.system(size: 11))
129 + .foregroundStyle(ZyquoColor.accent)
130 + .frame(width: 14)
131 + Text(model.displayName)
132 + .font(ZyquoFont.body(size: 12.5))
133 + .foregroundStyle(ZyquoColor.textPrimary)
134 + .lineLimit(1)
135 + Spacer(minLength: ZyquoSpacing.xs)
136 + capabilityBadges(model)
137 + Text(model.contextBadge)
138 + .font(ZyquoFont.caption)
139 + .foregroundStyle(ZyquoColor.textTertiary)
140 + Button {
141 + toggleFavorite(model)
142 + } label: {
143 + Image(systemName: catalog.favoriteIDs.contains(model.id) ? "star.fill" : "star")
144 + .font(.system(size: 10))
145 + .foregroundStyle(
146 + catalog.favoriteIDs.contains(model.id) ? ZyquoColor.warning : ZyquoColor.textTertiary
147 + )
148 + }
149 + .buttonStyle(.plain)
150 + }
151 + .padding(.horizontal, ZyquoSpacing.xs)
152 + .padding(.vertical, 4)
153 + .background(
154 + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)
155 + .fill(model.id == selected?.id && model.provider == selected?.provider
156 + ? ZyquoColor.accentSubtle : .clear)
157 + )
158 + .contentShape(Rectangle())
159 + }
160 + .buttonStyle(.plain)
161 + .zyquoHoverHighlight()
162 + }
163 +
164 + @ViewBuilder
165 + private func capabilityBadges(_ model: AIModel) -> some View {
166 + HStack(spacing: 3) {
167 + if model.capabilities.vision {
168 + Image(systemName: "eye").font(.system(size: 9)).foregroundStyle(ZyquoColor.textTertiary)
169 + }
170 + if model.capabilities.reasoning {
171 + Image(systemName: "brain").font(.system(size: 9)).foregroundStyle(ZyquoColor.textTertiary)
172 + }
173 + if model.capabilities.tools {
174 + Image(systemName: "wrench").font(.system(size: 9)).foregroundStyle(ZyquoColor.textTertiary)
175 + }
176 + }
177 + }
178 +
179 + private func toggleFavorite(_ model: AIModel) {
180 + if catalog.favoriteIDs.contains(model.id) {
181 + catalog.favoriteIDs.remove(model.id)
182 + } else {
183 + catalog.favoriteIDs.insert(model.id)
184 + }
185 + }
186 +}
added Sources/ZyquoCloud/Views/Chat/ParametersEditorView.swift +134 −0
@@ -0,0 +1,134 @@
1 +//
2 +// ParametersEditorView.swift
3 +// Zyquo Cloud
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Per-conversation generation parameters. Only the controls the selected
9 +// model actually supports are shown (ParameterSupport gates each row).
10 +//
11 +
12 +import SwiftUI
13 +
14 +struct ParametersEditorView: View {
15 + @Binding var parameters: ChatParameters
16 + let support: ParameterSupport
17 +
18 + var body: some View {
19 + VStack(alignment: .leading, spacing: ZyquoSpacing.sm) {
20 + Text("Parameters")
21 + .font(ZyquoFont.bodyEmphasis())
22 + .foregroundStyle(ZyquoColor.textPrimary)
23 +
24 + if support.temperature {
25 + optionalSlider(
26 + "Temperature", value: $parameters.temperature,
27 + range: 0...2, defaultValue: 1, format: "%.2f"
28 + )
29 + }
30 + if support.topP {
31 + optionalSlider(
32 + "Top P", value: $parameters.topP,
33 + range: 0...1, defaultValue: 1, format: "%.2f"
34 + )
35 + }
36 + maxTokensRow
37 + if support.frequencyPenalty {
38 + optionalSlider(
39 + "Frequency penalty", value: $parameters.frequencyPenalty,
40 + range: -2...2, defaultValue: 0, format: "%.1f"
41 + )
42 + }
43 + if support.presencePenalty {
44 + optionalSlider(
45 + "Presence penalty", value: $parameters.presencePenalty,
46 + range: -2...2, defaultValue: 0, format: "%.1f"
47 + )
48 + }
49 + if support.reasoningEffort {
50 + reasoningEffortRow
51 + }
52 + if support.thinkingToggle {
53 + Toggle("Extended thinking", isOn: Binding(
54 + get: { parameters.thinkingEnabled ?? false },
55 + set: { parameters.thinkingEnabled = $0 }
56 + ))
57 + .font(ZyquoFont.body(size: 12.5))
58 + }
59 + }
60 + .padding(ZyquoSpacing.md)
61 + .frame(width: 300)
62 + }
63 +
64 + private var maxTokensRow: some View {
65 + HStack {
66 + Text("Max tokens")
67 + .font(ZyquoFont.body(size: 12.5))
68 + Spacer()
69 + TextField(
70 + "default",
71 + value: Binding(
72 + get: { parameters.maxTokens },
73 + set: { parameters.maxTokens = $0 }
74 + ),
75 + format: .number
76 + )
77 + .textFieldStyle(.roundedBorder)
78 + .frame(width: 90)
79 + .font(ZyquoFont.body(size: 12))
80 + }
81 + }
82 +
83 + private var reasoningEffortRow: some View {
84 + HStack {
85 + Text("Reasoning effort")
86 + .font(ZyquoFont.body(size: 12.5))
87 + Spacer()
88 + Picker("", selection: Binding(
89 + get: { parameters.reasoningEffort ?? "default" },
90 + set: { parameters.reasoningEffort = $0 == "default" ? nil : $0 }
91 + )) {
92 + Text("Default").tag("default")
93 + Text("Low").tag("low")
94 + Text("Medium").tag("medium")
95 + Text("High").tag("high")
96 + }
97 + .pickerStyle(.menu)
98 + .frame(width: 110)
99 + }
100 + }
101 +
102 + /// A slider with an enable checkbox: unchecked = provider default (omitted).
103 + private func optionalSlider(
104 + _ title: String,
105 + value: Binding<Double?>,
106 + range: ClosedRange<Double>,
107 + defaultValue: Double,
108 + format: String
109 + ) -> some View {
110 + VStack(alignment: .leading, spacing: 2) {
111 + HStack {
112 + Toggle(isOn: Binding(
113 + get: { value.wrappedValue != nil },
114 + set: { value.wrappedValue = $0 ? defaultValue : nil }
115 + )) {
116 + Text(title).font(ZyquoFont.body(size: 12.5))
117 + }
118 + .toggleStyle(.checkbox)
119 + Spacer()
120 + Text(value.wrappedValue.map { String(format: format, $0) } ?? "default")
121 + .font(ZyquoFont.caption)
122 + .foregroundStyle(ZyquoColor.textTertiary)
123 + .monospacedDigit()
124 + }
125 + if let current = value.wrappedValue {
126 + Slider(
127 + value: Binding(get: { current }, set: { value.wrappedValue = $0 }),
128 + in: range
129 + )
130 + .controlSize(.small)
131 + }
132 + }
133 + }
134 +}
added Sources/ZyquoCloud/Views/CommandPaletteView.swift +152 −0
@@ -0,0 +1,152 @@
1 +//
2 +// CommandPaletteView.swift
3 +// Zyquo Cloud
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// ⌘K palette: one search box over models, prompt templates, and personas.
9 +// Models switch the current conversation; templates insert into the draft;
10 +// personas apply their system prompt + preferences.
11 +//
12 +
13 +import SwiftUI
14 +
15 +struct CommandPaletteView: View {
16 + let currentModel: AIModel?
17 + var onSelectModel: (AIModel) -> Void
18 + var onInsertTemplate: (PromptTemplate) -> Void
19 + var onApplyPersona: (Persona) -> Void
20 +
21 + @EnvironmentObject private var catalog: ModelCatalog
22 + @StateObject private var library = PromptLibraryStore()
23 + @Environment(\.dismiss) private var dismiss
24 + @State private var query = ""
25 + @FocusState private var focused: Bool
26 +
27 + var body: some View {
28 + VStack(spacing: 0) {
29 + HStack(spacing: ZyquoSpacing.xs) {
30 + Image(systemName: "command")
31 + .font(.system(size: 13))
32 + .foregroundStyle(ZyquoColor.textTertiary)
33 + TextField("Switch model, insert template, apply persona…", text: $query)
34 + .textFieldStyle(.plain)
35 + .font(ZyquoFont.body(size: 15))
36 + .focused($focused)
37 + }
38 + .padding(ZyquoSpacing.md)
39 + ZyquoHairline()
40 + ScrollView {
41 + LazyVStack(alignment: .leading, spacing: 1) {
42 + if !matchingModels.isEmpty {
43 + section("Models")
44 + ForEach(matchingModels.prefix(8)) { model in
45 + paletteRow(
46 + symbol: model.provider.symbolName,
47 + title: model.displayName,
48 + subtitle: "\(model.provider.displayName) · \(model.contextBadge)"
49 + ) {
50 + onSelectModel(model)
51 + dismiss()
52 + }
53 + }
54 + }
55 + if !matchingPersonas.isEmpty {
56 + section("Personas")
57 + ForEach(matchingPersonas.prefix(6)) { persona in
58 + paletteRow(
59 + symbol: persona.symbolName,
60 + title: persona.name,
61 + subtitle: String(persona.systemPrompt.prefix(70))
62 + ) {
63 + onApplyPersona(persona)
64 + dismiss()
65 + }
66 + }
67 + }
68 + if !matchingTemplates.isEmpty {
69 + section("Prompt Library")
70 + ForEach(matchingTemplates.prefix(10)) { template in
71 + paletteRow(
72 + symbol: "text.badge.plus",
73 + title: template.title,
74 + subtitle: template.category
75 + ) {
76 + onInsertTemplate(template)
77 + dismiss()
78 + }
79 + }
80 + }
81 + }
82 + .padding(ZyquoSpacing.xxs)
83 + }
84 + .frame(height: 380)
85 + }
86 + .frame(width: 560)
87 + .background(ZyquoColor.surface)
88 + .onAppear { focused = true }
89 + .onExitCommand { dismiss() }
90 + }
91 +
92 + private var matchingModels: [AIModel] {
93 + query.isEmpty
94 + ? catalog.all.filter(\.isRecommended)
95 + : catalog.all.filter {
96 + $0.displayName.localizedCaseInsensitiveContains(query)
97 + || $0.id.localizedCaseInsensitiveContains(query)
98 + }
99 + }
100 +
101 + private var matchingTemplates: [PromptTemplate] {
102 + query.isEmpty
103 + ? Array(library.allTemplates.prefix(10))
104 + : library.allTemplates.filter {
105 + $0.title.localizedCaseInsensitiveContains(query)
106 + || $0.category.localizedCaseInsensitiveContains(query)
107 + }
108 + }
109 +
110 + private var matchingPersonas: [Persona] {
111 + query.isEmpty
112 + ? library.allPersonas
113 + : library.allPersonas.filter { $0.name.localizedCaseInsensitiveContains(query) }
114 + }
115 +
116 + private func section(_ title: String) -> some View {
117 + Text(title)
118 + .font(ZyquoFont.caption)
119 + .foregroundStyle(ZyquoColor.textTertiary)
120 + .padding(.horizontal, ZyquoSpacing.sm)
121 + .padding(.top, ZyquoSpacing.xs)
122 + .padding(.bottom, 2)
123 + }
124 +
125 + private func paletteRow(
126 + symbol: String, title: String, subtitle: String, action: @escaping () -> Void
127 + ) -> some View {
128 + Button(action: action) {
129 + HStack(spacing: ZyquoSpacing.sm) {
130 + Image(systemName: symbol)
131 + .font(.system(size: 13))
132 + .foregroundStyle(ZyquoColor.accent)
133 + .frame(width: 18)
134 + VStack(alignment: .leading, spacing: 0) {
135 + Text(title)
136 + .font(ZyquoFont.body(size: 13))
137 + .foregroundStyle(ZyquoColor.textPrimary)
138 + Text(subtitle)
139 + .font(ZyquoFont.caption)
140 + .foregroundStyle(ZyquoColor.textTertiary)
141 + .lineLimit(1)
142 + }
143 + Spacer()
144 + }
145 + .padding(.horizontal, ZyquoSpacing.sm)
146 + .padding(.vertical, 5)
147 + .contentShape(Rectangle())
148 + }
149 + .buttonStyle(.plain)
150 + .zyquoHoverHighlight()
151 + }
152 +}
added Sources/ZyquoCloud/Views/Compare/CompareView.swift +251 −0
@@ -0,0 +1,251 @@
1 +//
2 +// CompareView.swift
3 +// Zyquo Cloud
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Compare mode: 2–4 vertical columns, one model chip each, the same prompt
9 +// broadcast to all, independent streaming, per-column copy/regenerate.
10 +//
11 +
12 +import SwiftUI
13 +
14 +struct CompareView: View {
15 + @EnvironmentObject private var catalog: ModelCatalog
16 + @EnvironmentObject private var vault: KeyVaultStore
17 + @EnvironmentObject private var appearance: AppearanceStore
18 + @Environment(\.dismiss) private var dismiss
19 +
20 + @StateObject private var session = CompareSession()
21 + @State private var prompt = ""
22 +
23 + var body: some View {
24 + VStack(spacing: 0) {
25 + header
26 + ZyquoHairline()
27 + columns
28 + ZyquoHairline()
29 + inputRow
30 + }
31 + .frame(minWidth: 900, minHeight: 560)
32 + .background(ZyquoColor.background)
33 + .onAppear {
34 + if session.columns.isEmpty {
35 + session.setup(catalog: catalog, vault: vault)
36 + }
37 + }
38 + }
39 +
40 + private var header: some View {
41 + HStack {
42 + Text("Compare Models")
43 + .font(ZyquoFont.bodyEmphasis(size: 14))
44 + .foregroundStyle(ZyquoColor.textPrimary)
45 + Spacer()
46 + Button {
47 + session.addColumn(catalog: catalog)
48 + } label: {
49 + Label("Add column", systemImage: "plus.rectangle.on.rectangle")
50 + }
51 + .controlSize(.small)
52 + .disabled(session.columns.count >= 4)
53 + Button("Done") { dismiss() }
54 + .controlSize(.small)
55 + }
56 + .padding(ZyquoSpacing.sm)
57 + }
58 +
59 + private var columns: some View {
60 + HStack(spacing: 0) {
61 + ForEach($session.columns) { $column in
62 + VStack(spacing: 0) {
63 + columnHeader($column)
64 + ZyquoHairline()
65 + ScrollView {
66 + VStack(alignment: .leading, spacing: ZyquoSpacing.xs) {
67 + if let reasoning = column.reasoning, !reasoning.isEmpty {
68 + Text(reasoning)
69 + .font(ZyquoFont.code(size: appearance.chatFontSize - 2.5))
70 + .foregroundStyle(ZyquoColor.textTertiary)
71 + .lineLimit(6)
72 + }
73 + if let error = column.errorText {
74 + Text(error)
75 + .font(ZyquoFont.body(size: appearance.chatFontSize - 1))
76 + .foregroundStyle(ZyquoColor.danger)
77 + } else {
78 + MarkdownView(text: column.answer, fontSize: appearance.chatFontSize - 0.5)
79 + if column.isStreaming { StreamingCaret() }
80 + }
81 + }
82 + .frame(maxWidth: .infinity, alignment: .leading)
83 + .padding(ZyquoSpacing.sm)
84 + }
85 + }
86 + .frame(maxWidth: .infinity)
87 + if column.id != session.columns.last?.id {
88 + Rectangle().fill(ZyquoColor.border).frame(width: ZyquoMetrics.hairline)
89 + }
90 + }
91 + }
92 + }
93 +
94 + private func columnHeader(_ column: Binding<CompareSession.Column>) -> some View {
95 + HStack(spacing: ZyquoSpacing.xxs) {
96 + ModelChipView(model: column.wrappedValue.model) { chosen in
97 + column.wrappedValue.model = chosen
98 + }
99 + Spacer()
100 + if let usage = column.wrappedValue.usage {
101 + Text("\(usage.outputTokens) tok")
102 + .font(ZyquoFont.caption)
103 + .foregroundStyle(ZyquoColor.textTertiary)
104 + }
105 + Button {
106 + NSPasteboard.general.clearContents()
107 + NSPasteboard.general.setString(column.wrappedValue.answer, forType: .string)
108 + } label: {
109 + Image(systemName: "doc.on.doc").font(.system(size: 10))
110 + }
111 + .buttonStyle(.plain)
112 + .help("Copy answer")
113 + Button {
114 + session.run(columnID: column.wrappedValue.id, prompt: session.lastPrompt, vault: vault)
115 + } label: {
116 + Image(systemName: "arrow.clockwise").font(.system(size: 10))
117 + }
118 + .buttonStyle(.plain)
119 + .help("Regenerate")
120 + .disabled(session.lastPrompt.isEmpty)
121 + if session.columns.count > 2 {
122 + Button {
123 + session.columns.removeAll { $0.id == column.wrappedValue.id }
124 + } label: {
125 + Image(systemName: "xmark").font(.system(size: 9))
126 + }
127 + .buttonStyle(.plain)
128 + .help("Remove column")
129 + }
130 + }
131 + .padding(.horizontal, ZyquoSpacing.xs)
132 + .padding(.vertical, ZyquoSpacing.xxs)
133 + }
134 +
135 + private var inputRow: some View {
136 + HStack(spacing: ZyquoSpacing.xs) {
137 + TextField("Prompt all models…", text: $prompt, axis: .vertical)
138 + .textFieldStyle(.plain)
139 + .font(ZyquoFont.body(size: appearance.chatFontSize))
140 + .lineLimit(1...6)
141 + .onSubmit(broadcast)
142 + Button {
143 + broadcast()
144 + } label: {
145 + Image(systemName: "arrow.up")
146 + .font(.system(size: 12, weight: .semibold))
147 + .foregroundStyle(.white)
148 + .frame(width: 26, height: 26)
149 + .background(Circle().fill(ZyquoColor.accent))
150 + }
151 + .buttonStyle(PressableButtonStyle())
152 + .keyboardShortcut(.return, modifiers: .command)
153 + .disabled(prompt.trimmingCharacters(in: .whitespaces).isEmpty)
154 + }
155 + .padding(ZyquoSpacing.sm)
156 + .background(ZyquoColor.surface)
157 + }
158 +
159 + private func broadcast() {
160 + let text = prompt.trimmingCharacters(in: .whitespacesAndNewlines)
161 + guard !text.isEmpty else { return }
162 + prompt = ""
163 + session.broadcast(prompt: text, vault: vault)
164 + }
165 +}
166 +
167 +/// State for one compare session: N independent streaming columns.
168 +@MainActor
169 +final class CompareSession: ObservableObject {
170 + struct Column: Identifiable {
171 + let id = UUID()
172 + var model: AIModel?
173 + var answer = ""
174 + var reasoning: String?
175 + var errorText: String?
176 + var usage: TokenUsage?
177 + var isStreaming = false
178 + }
179 +
180 + @Published var columns: [Column] = []
181 + @Published private(set) var lastPrompt = ""
182 + private var tasks: [UUID: Task<Void, Never>] = [:]
183 +
184 + func setup(catalog: ModelCatalog, vault: KeyVaultStore) {
185 + // Seed two columns with the two most relevant models from providers
186 + // that have keys, falling back to catalog defaults.
187 + let keyed = catalog.all.filter {
188 + $0.isRecommended && vault.hasKey(for: $0.provider)
189 + }
190 + let first = keyed.first ?? catalog.defaultModel
191 + let second = keyed.dropFirst().first ?? catalog.defaultModel
192 + columns = [Column(model: first), Column(model: second)]
193 + }
194 +
195 + func addColumn(catalog: ModelCatalog) {
196 + guard columns.count < 4 else { return }
197 + columns.append(Column(model: catalog.defaultModel))
198 + }
199 +
200 + func broadcast(prompt: String, vault: KeyVaultStore) {
201 + lastPrompt = prompt
202 + for column in columns {
203 + run(columnID: column.id, prompt: prompt, vault: vault)
204 + }
205 + }
206 +
207 + func run(columnID: UUID, prompt: String, vault: KeyVaultStore) {
208 + guard !prompt.isEmpty,
209 + let index = columns.firstIndex(where: { $0.id == columnID }),
210 + let model = columns[index].model
211 + else { return }
212 + tasks[columnID]?.cancel()
213 + columns[index].answer = ""
214 + columns[index].reasoning = nil
215 + columns[index].errorText = nil
216 + columns[index].usage = nil
217 + columns[index].isStreaming = true
218 +
219 + tasks[columnID] = Task { [weak self] in
220 + guard let self else { return }
221 + do {
222 + let key = try vault.apiKey(for: model.provider)
223 + let client = ProviderRegistry.client(for: model)
224 + let request = ChatRequest(
225 + model: model,
226 + systemPrompt: nil,
227 + messages: [Message(role: .user, text: prompt)],
228 + parameters: ChatParameters()
229 + )
230 + for try await event in client.streamChat(request, apiKey: key) {
231 + if Task.isCancelled { break }
232 + guard let i = self.columns.firstIndex(where: { $0.id == columnID }) else { break }
233 + switch event {
234 + case .textDelta(let delta): self.columns[i].answer += delta
235 + case .reasoningDelta(let delta):
236 + self.columns[i].reasoning = (self.columns[i].reasoning ?? "") + delta
237 + case .usage(let usage): self.columns[i].usage = usage
238 + default: break
239 + }
240 + }
241 + } catch {
242 + if let i = self.columns.firstIndex(where: { $0.id == columnID }) {
243 + self.columns[i].errorText = error.localizedDescription
244 + }
245 + }
246 + if let i = self.columns.firstIndex(where: { $0.id == columnID }) {
247 + self.columns[i].isStreaming = false
248 + }
249 + }
250 + }
251 +}
added Sources/ZyquoCloud/Views/MainWindowView.swift +49 −0
@@ -0,0 +1,49 @@
1 +//
2 +// MainWindowView.swift
3 +// Zyquo Cloud
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Root split view: translucent sidebar + chat area.
9 +//
10 +
11 +import SwiftUI
12 +
13 +struct MainWindowView: View {
14 + @EnvironmentObject private var store: ConversationStore
15 + @EnvironmentObject private var appearance: AppearanceStore
16 +
17 + var body: some View {
18 + NavigationSplitView {
19 + SidebarView()
20 + .navigationSplitViewColumnWidth(
21 + min: ZyquoMetrics.sidebarWidth,
22 + ideal: ZyquoMetrics.sidebarWidth,
23 + max: 360
24 + )
25 + } detail: {
26 + if let id = store.selectedID, store.conversations.contains(where: { $0.id == id }) {
27 + ChatView(conversationID: id)
28 + .id(id)
29 + } else {
30 + EmptyStateView(
31 + model: store.catalog.defaultModel,
32 + onSelectModel: { model in
33 + store.newConversation(model: model)
34 + },
35 + onSuggestion: { prompt in
36 + let conversation = store.newConversation()
37 + store.send(text: prompt, in: conversation.id)
38 + }
39 + )
40 + }
41 + }
42 + .frame(
43 + minWidth: ZyquoMetrics.windowMinWidth,
44 + minHeight: ZyquoMetrics.windowMinHeight
45 + )
46 + .preferredColorScheme(appearance.themeMode.colorScheme)
47 + .tint(appearance.accentColor)
48 + }
49 +}
added Sources/ZyquoCloud/Views/Markdown/CodeBlockView.swift +101 −0
@@ -0,0 +1,101 @@
1 +//
2 +// CodeBlockView.swift
3 +// Zyquo Cloud
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Fenced code block per the Phase 4 spec: surfaceSecondary card with medium
9 +// radius, uppercased language label top-left, hover copy button top-right
10 +// (with a brief checkmark confirmation), SF Mono content with syntax
11 +// highlighting, horizontal scrolling for long lines, and text selection.
12 +//
13 +
14 +import SwiftUI
15 +
16 +struct CodeBlockView: View {
17 + let code: String
18 + let language: String?
19 + let fontSize: Double
20 +
21 + @State private var hovering = false
22 + @State private var copied = false
23 + @State private var copyGeneration = 0
24 +
25 + /// How long the copy confirmation checkmark stays visible.
26 + private static let copyConfirmationSeconds: Double = 1.2
27 +
28 + var body: some View {
29 + VStack(alignment: .leading, spacing: 0) {
30 + header
31 + ScrollView(.horizontal) {
32 + SwiftUI.Text(highlighted)
33 + .font(ZyquoFont.code(size: max(fontSize - 1, 1)))
34 + .textSelection(.enabled)
35 + .fixedSize(horizontal: false, vertical: true)
36 + .padding(.horizontal, ZyquoSpacing.sm)
37 + .padding(.top, ZyquoSpacing.xxs)
38 + .padding(.bottom, ZyquoSpacing.xs)
39 + }
40 + }
41 + .background(
42 + RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous)
43 + .fill(ZyquoColor.surfaceSecondary)
44 + )
45 + .onHover { inside in
46 + withAnimation(ZyquoMotion.hover) { hovering = inside }
47 + }
48 + }
49 +
50 + // MARK: Header
51 +
52 + private var header: some View {
53 + HStack(spacing: ZyquoSpacing.xs) {
54 + if let language, !language.isEmpty {
55 + SwiftUI.Text(language.uppercased())
56 + .font(ZyquoFont.caption)
57 + .foregroundStyle(ZyquoColor.textSecondary)
58 + }
59 + Spacer(minLength: ZyquoSpacing.xs)
60 + copyButton
61 + .opacity(hovering || copied ? 1 : 0)
62 + }
63 + .padding(.horizontal, ZyquoSpacing.sm)
64 + .padding(.top, ZyquoSpacing.xs)
65 + }
66 +
67 + private var copyButton: some View {
68 + Button(action: copy) {
69 + Image(systemName: copied ? "checkmark" : "doc.on.doc")
70 + .font(ZyquoFont.caption)
71 + .foregroundStyle(copied ? ZyquoColor.success : ZyquoColor.textSecondary)
72 + }
73 + .buttonStyle(PressableButtonStyle())
74 + .accessibilityLabel("Copy code")
75 + .help("Copy code")
76 + }
77 +
78 + // MARK: Highlighting
79 +
80 + private var highlighted: AttributedString {
81 + SyntaxHighlighter.highlight(code, language: language, baseColor: ZyquoColor.textPrimary)
82 + }
83 +
84 + // MARK: Copy
85 +
86 + private func copy() {
87 + let pasteboard = NSPasteboard.general
88 + pasteboard.clearContents()
89 + pasteboard.setString(code, forType: .string)
90 +
91 + copyGeneration += 1
92 + let generation = copyGeneration
93 + withAnimation(ZyquoMotion.hover) { copied = true }
94 + Task {
95 + try? await Task.sleep(for: .seconds(Self.copyConfirmationSeconds))
96 + if generation == copyGeneration {
97 + withAnimation(ZyquoMotion.hover) { copied = false }
98 + }
99 + }
100 + }
101 +}
added Sources/ZyquoCloud/Views/Markdown/MarkdownView.swift +481 −0
@@ -0,0 +1,481 @@
1 +//
2 +// MarkdownView.swift
3 +// Zyquo Cloud
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Full Markdown rendering for chat messages per the Phase 4 spec: the
9 +// swift-markdown AST is walked once into a lightweight [MarkdownBlock] model
10 +// (memoized by text hash — streaming deltas re-parse only the changed text),
11 +// then rendered as SwiftUI views built entirely from ZyquoTheme tokens.
12 +// Links use the AttributedString .link attribute, so SwiftUI.Text routes
13 +// clicks through the environment's openURL automatically. Parsing is
14 +// resilient to incomplete Markdown (unterminated fences etc.) — swift-markdown
15 +// degrades gracefully, so streaming partial text never crashes.
16 +//
17 +
18 +import SwiftUI
19 +import Markdown
20 +
21 +// MARK: - MarkdownView
22 +
23 +struct MarkdownView: View {
24 + let fontSize: Double
25 + private let blocks: [MarkdownBlock]
26 +
27 + init(text: String, fontSize: Double) {
28 + self.fontSize = fontSize
29 + self.blocks = MarkdownBlockParser.parse(text: text, fontSize: fontSize)
30 + }
31 +
32 + var body: some View {
33 + VStack(alignment: .leading, spacing: ZyquoSpacing.sm) {
34 + ForEach(blocks) { block in
35 + MarkdownBlockView(block: block, fontSize: fontSize)
36 + }
37 + }
38 + .foregroundStyle(ZyquoColor.textPrimary)
39 + }
40 +}
41 +
42 +// MARK: - Block model
43 +
44 +/// One rendered Markdown block. Ids are assigned in document order at parse
45 +/// time so `ForEach` stays stable within a parse.
46 +struct MarkdownBlock: Identifiable {
47 + let id: Int
48 + let kind: Kind
49 +
50 + enum Kind {
51 + case paragraph(AttributedString)
52 + case heading(AttributedString, level: Int)
53 + case code(String, language: String?)
54 + case quote([MarkdownBlock])
55 + case list(ListData)
56 + case table(TableData)
57 + case thematicBreak
58 + }
59 +
60 + struct ListData {
61 + let ordered: Bool
62 + let start: Int
63 + let items: [ListItemData]
64 + }
65 +
66 + struct ListItemData: Identifiable {
67 + let id: Int
68 + /// nil = plain item; true/false = task-list checkbox state.
69 + let checked: Bool?
70 + let blocks: [MarkdownBlock]
71 + }
72 +
73 + struct TableData {
74 + let alignments: [TextAlignment]
75 + let header: [AttributedString]
76 + let rows: [[AttributedString]]
77 +
78 + func alignment(forColumn column: Int) -> TextAlignment {
79 + column < alignments.count ? alignments[column] : .leading
80 + }
81 + }
82 +}
83 +
84 +// MARK: - Parser
85 +
86 +/// Walks the swift-markdown `Document` AST into `[MarkdownBlock]`. Results are
87 +/// memoized by (text, fontSize) so re-renders during streaming only re-parse
88 +/// when the text actually changes.
89 +enum MarkdownBlockParser {
90 + /// Heading sizes scale off the user's chat font size.
91 + private static func headingSize(level: Int, base: Double) -> Double {
92 + switch level {
93 + case 1: return base * 1.55
94 + case 2: return base * 1.35
95 + case 3: return base * 1.2
96 + default: return base * 1.05
97 + }
98 + }
99 +
100 + static func parse(text: String, fontSize: Double) -> [MarkdownBlock] {
101 + let key = CacheKey(textHash: text.hashValue, length: text.count, fontBits: fontSize.bitPattern)
102 + cacheLock.lock()
103 + if let hit = cache[key] {
104 + cacheLock.unlock()
105 + return hit
106 + }
107 + cacheLock.unlock()
108 +
109 + let document = Document(parsing: text)
110 + var counter = 0
111 + let blocks = convertBlocks(of: document, fontSize: fontSize, counter: &counter)
112 +
113 + cacheLock.lock()
114 + if cache.count > cacheCapacity { cache.removeAll(keepingCapacity: true) }
115 + cache[key] = blocks
116 + cacheLock.unlock()
117 + return blocks
118 + }
119 +
120 + // MARK: Cache
121 +
122 + private struct CacheKey: Hashable {
123 + let textHash: Int
124 + let length: Int
125 + let fontBits: UInt64
126 + }
127 +
128 + private static let cacheLock = NSLock()
129 + private static let cacheCapacity = 32
130 + private static var cache: [CacheKey: [MarkdownBlock]] = [:]
131 +
132 + // MARK: Block conversion
133 +
134 + private static func convertBlocks(of parent: Markup, fontSize: Double, counter: inout Int) -> [MarkdownBlock] {
135 + parent.children.compactMap { convertBlock($0, fontSize: fontSize, counter: &counter) }
136 + }
137 +
138 + private static func convertBlock(_ markup: Markup, fontSize: Double, counter: inout Int) -> MarkdownBlock? {
139 + counter += 1
140 + let id = counter
141 +
142 + switch markup {
143 + case let heading as Heading:
144 + let font = Font.system(size: headingSize(level: heading.level, base: fontSize), weight: .semibold)
145 + let content = inlineText(of: heading, fontSize: fontSize, baseFont: font)
146 + return MarkdownBlock(id: id, kind: .heading(content, level: heading.level))
147 +
148 + case let paragraph as Paragraph:
149 + let content = inlineText(of: paragraph, fontSize: fontSize, baseFont: ZyquoFont.body(size: fontSize))
150 + guard !content.characters.isEmpty else { return nil }
151 + return MarkdownBlock(id: id, kind: .paragraph(content))
152 +
153 + case let codeBlock as CodeBlock:
154 + var code = codeBlock.code
155 + if code.hasSuffix("\n") { code.removeLast() }
156 + return MarkdownBlock(id: id, kind: .code(code, language: codeBlock.language))
157 +
158 + case let quote as BlockQuote:
159 + return MarkdownBlock(id: id, kind: .quote(convertBlocks(of: quote, fontSize: fontSize, counter: &counter)))
160 +
161 + case let list as UnorderedList:
162 + let items = convertListItems(of: list, fontSize: fontSize, counter: &counter)
163 + return MarkdownBlock(id: id, kind: .list(.init(ordered: false, start: 1, items: items)))
164 +
165 + case let list as OrderedList:
166 + let items = convertListItems(of: list, fontSize: fontSize, counter: &counter)
167 + return MarkdownBlock(id: id, kind: .list(.init(ordered: true, start: Int(list.startIndex), items: items)))
168 +
169 + case let table as Markdown.Table:
170 + return MarkdownBlock(id: id, kind: .table(convertTable(table, fontSize: fontSize)))
171 +
172 + case is ThematicBreak:
173 + return MarkdownBlock(id: id, kind: .thematicBreak)
174 +
175 + case let html as HTMLBlock:
176 + var raw = html.rawHTML
177 + if raw.hasSuffix("\n") { raw.removeLast() }
178 + return MarkdownBlock(id: id, kind: .code(raw, language: "html"))
179 +
180 + default:
181 + // Unknown block: fall back to its re-formatted Markdown source.
182 + let source = markup.format().trimmingCharacters(in: .whitespacesAndNewlines)
183 + guard !source.isEmpty else { return nil }
184 + var content = AttributedString(source)
185 + content.font = ZyquoFont.body(size: fontSize)
186 + return MarkdownBlock(id: id, kind: .paragraph(content))
187 + }
188 + }
189 +
190 + private static func convertListItems(of list: Markup, fontSize: Double, counter: inout Int) -> [MarkdownBlock.ListItemData] {
191 + list.children.compactMap { child in
192 + guard let item = child as? Markdown.ListItem else { return nil }
193 + counter += 1
194 + let id = counter
195 + let checked: Bool?
196 + switch item.checkbox {
197 + case .checked: checked = true
198 + case .unchecked: checked = false
199 + case nil: checked = nil
200 + }
201 + return MarkdownBlock.ListItemData(
202 + id: id,
203 + checked: checked,
204 + blocks: convertBlocks(of: item, fontSize: fontSize, counter: &counter)
205 + )
206 + }
207 + }
208 +
209 + private static func convertTable(_ table: Markdown.Table, fontSize: Double) -> MarkdownBlock.TableData {
210 + let alignments: [TextAlignment] = table.columnAlignments.map { alignment in
211 + switch alignment {
212 + case .center: return .center
213 + case .right: return .trailing
214 + default: return .leading
215 + }
216 + }
217 + let headerFont = ZyquoFont.bodyEmphasis(size: fontSize)
218 + let header = table.head.children.compactMap { cell -> AttributedString? in
219 + guard let cell = cell as? Markdown.Table.Cell else { return nil }
220 + return inlineText(of: cell, fontSize: fontSize, baseFont: headerFont)
221 + }
222 + let bodyFont = ZyquoFont.body(size: fontSize)
223 + let rows = table.body.children.compactMap { row -> [AttributedString]? in
224 + guard let row = row as? Markdown.Table.Row else { return nil }
225 + return row.children.compactMap { cell -> AttributedString? in
226 + guard let cell = cell as? Markdown.Table.Cell else { return nil }
227 + return inlineText(of: cell, fontSize: fontSize, baseFont: bodyFont)
228 + }
229 + }
230 + return MarkdownBlock.TableData(alignments: alignments, header: header, rows: rows)
231 + }
232 +
233 + // MARK: Inline conversion
234 +
235 + private static func inlineText(
236 + of parent: Markup,
237 + fontSize: Double,
238 + baseFont: Font,
239 + bold: Bool = false,
240 + italic: Bool = false
241 + ) -> AttributedString {
242 + var result = AttributedString()
243 + for child in parent.children {
244 + result += inlineFragment(child, fontSize: fontSize, baseFont: baseFont, bold: bold, italic: italic)
245 + }
246 + return result
247 + }
248 +
249 + private static func inlineFragment(
250 + _ markup: Markup,
251 + fontSize: Double,
252 + baseFont: Font,
253 + bold: Bool,
254 + italic: Bool
255 + ) -> AttributedString {
256 + switch markup {
257 + case let text as Markdown.Text:
258 + return styled(text.string, baseFont: baseFont, bold: bold, italic: italic)
259 +
260 + case let emphasis as Emphasis:
261 + return inlineText(of: emphasis, fontSize: fontSize, baseFont: baseFont, bold: bold, italic: true)
262 +
263 + case let strong as Strong:
264 + return inlineText(of: strong, fontSize: fontSize, baseFont: baseFont, bold: true, italic: italic)
265 +
266 + case let code as InlineCode:
267 + var segment = AttributedString(code.code)
268 + segment.font = ZyquoFont.code(size: max(fontSize - 1, 1))
269 + segment.backgroundColor = ZyquoColor.surfaceSecondary
270 + return segment
271 +
272 + case let link as Markdown.Link:
273 + var segment = inlineText(of: link, fontSize: fontSize, baseFont: baseFont, bold: bold, italic: italic)
274 + segment.foregroundColor = ZyquoColor.accent
275 + if let destination = link.destination, let url = URL(string: destination) {
276 + segment.link = url
277 + }
278 + return segment
279 +
280 + case let image as Markdown.Image:
281 + // No inline image loading in chat transcripts: render the alt text
282 + // (or the source) as a link to the image.
283 + var segment = inlineText(of: image, fontSize: fontSize, baseFont: baseFont, bold: bold, italic: italic)
284 + if segment.characters.isEmpty, let source = image.source {
285 + segment = styled(source, baseFont: baseFont, bold: bold, italic: italic)
286 + }
287 + segment.foregroundColor = ZyquoColor.accent
288 + if let source = image.source, let url = URL(string: source) {
289 + segment.link = url
290 + }
291 + return segment
292 +
293 + case let strike as Strikethrough:
294 + var segment = inlineText(of: strike, fontSize: fontSize, baseFont: baseFont, bold: bold, italic: italic)
295 + segment[AttributeScopes.SwiftUIAttributes.StrikethroughStyleAttribute.self] = .single
296 + return segment
297 +
298 + case is SoftBreak:
299 + return styled(" ", baseFont: baseFont, bold: bold, italic: italic)
300 +
301 + case is LineBreak:
302 + return styled("\n", baseFont: baseFont, bold: bold, italic: italic)
303 +
304 + case let html as InlineHTML:
305 + return styled(html.rawHTML, baseFont: baseFont, bold: bold, italic: italic)
306 +
307 + default:
308 + return styled(markup.format(), baseFont: baseFont, bold: bold, italic: italic)
309 + }
310 + }
311 +
312 + private static func styled(_ string: String, baseFont: Font, bold: Bool, italic: Bool) -> AttributedString {
313 + var segment = AttributedString(string)
314 + var font = baseFont
315 + if bold { font = font.bold() }
316 + if italic { font = font.italic() }
317 + segment.font = font
318 + return segment
319 + }
320 +}
321 +
322 +// MARK: - Block rendering
323 +
324 +private struct MarkdownBlockView: View {
325 + let block: MarkdownBlock
326 + let fontSize: Double
327 +
328 + /// Blockquote accent bar width (Phase 4 spec: 3pt accent left bar).
329 + private static let quoteBarWidth: CGFloat = 3
330 +
331 + var body: some View {
332 + switch block.kind {
333 + case .paragraph(let content):
334 + SwiftUI.Text(content)
335 + .lineSpacing(fontSize * ZyquoFont.bodyLineSpacingFactor)
336 + .textSelection(.enabled)
337 + .fixedSize(horizontal: false, vertical: true)
338 +
339 + case .heading(let content, _):
340 + SwiftUI.Text(content)
341 + .textSelection(.enabled)
342 + .fixedSize(horizontal: false, vertical: true)
343 + .padding(.top, ZyquoSpacing.xxs)
344 +
345 + case .code(let code, let language):
346 + CodeBlockView(code: code, language: language, fontSize: fontSize)
347 +
348 + case .quote(let children):
349 + HStack(alignment: .top, spacing: ZyquoSpacing.sm) {
350 + RoundedRectangle(cornerRadius: Self.quoteBarWidth / 2, style: .continuous)
351 + .fill(ZyquoColor.accent)
352 + .frame(width: Self.quoteBarWidth)
353 + VStack(alignment: .leading, spacing: ZyquoSpacing.xs) {
354 + ForEach(children) { child in
355 + MarkdownBlockView(block: child, fontSize: fontSize)
356 + }
357 + }
358 + .foregroundStyle(ZyquoColor.textSecondary)
359 + }
360 +
361 + case .list(let data):
362 + listView(data)
363 +
364 + case .table(let data):
365 + tableView(data)
366 +
367 + case .thematicBreak:
368 + ZyquoHairline()
369 + .padding(.vertical, ZyquoSpacing.xxs)
370 + }
371 + }
372 +
373 + // MARK: Lists
374 +
375 + private func listView(_ data: MarkdownBlock.ListData) -> some View {
376 + VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) {
377 + ForEach(Array(data.items.enumerated()), id: \.element.id) { offset, item in
378 + HStack(alignment: .firstTextBaseline, spacing: ZyquoSpacing.xs) {
379 + marker(for: item, ordinal: data.start + offset, ordered: data.ordered)
380 + VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) {
381 + ForEach(item.blocks) { child in
382 + MarkdownBlockView(block: child, fontSize: fontSize)
383 + }
384 + }
385 + }
386 + }
387 + }
388 + }
389 +
390 + @ViewBuilder
391 + private func marker(for item: MarkdownBlock.ListItemData, ordinal: Int, ordered: Bool) -> some View {
392 + if let checked = item.checked {
393 + Image(systemName: checked ? "checkmark.square.fill" : "square")
394 + .font(ZyquoFont.body(size: fontSize))
395 + .foregroundStyle(checked ? ZyquoColor.accent : ZyquoColor.textSecondary)
396 + } else if ordered {
397 + SwiftUI.Text("\(ordinal).")
398 + .font(ZyquoFont.body(size: fontSize).monospacedDigit())
399 + .foregroundStyle(ZyquoColor.textSecondary)
400 + .frame(minWidth: ZyquoSpacing.lg, alignment: .trailing)
401 + } else {
402 + SwiftUI.Text("•")
403 + .font(ZyquoFont.body(size: fontSize))
404 + .foregroundStyle(ZyquoColor.textSecondary)
405 + }
406 + }
407 +
408 + // MARK: Tables
409 +
410 + private func tableView(_ data: MarkdownBlock.TableData) -> some View {
411 + Grid(alignment: .topLeading, horizontalSpacing: 0, verticalSpacing: 0) {
412 + GridRow {
413 + ForEach(data.header.indices, id: \.self) { column in
414 + tableCell(
415 + data.header[column],
416 + data: data,
417 + column: column,
418 + tinted: true,
419 + isLastRow: data.rows.isEmpty
420 + )
421 + }
422 + }
423 + ForEach(data.rows.indices, id: \.self) { rowIndex in
424 + GridRow {
425 + ForEach(data.rows[rowIndex].indices, id: \.self) { column in
426 + tableCell(
427 + data.rows[rowIndex][column],
428 + data: data,
429 + column: column,
430 + tinted: rowIndex % 2 == 1,
431 + isLastRow: rowIndex == data.rows.count - 1
432 + )
433 + }
434 + }
435 + }
436 + }
437 + .clipShape(RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous))
438 + .overlay(
439 + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)
440 + .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline)
441 + )
442 + }
443 +
444 + private func tableCell(
445 + _ content: AttributedString,
446 + data: MarkdownBlock.TableData,
447 + column: Int,
448 + tinted: Bool,
449 + isLastRow: Bool
450 + ) -> some View {
451 + let alignment = data.alignment(forColumn: column)
452 + let columnCount = max(data.header.count, data.rows.map(\.count).max() ?? 0)
453 + let isLastColumn = column == columnCount - 1
454 + return SwiftUI.Text(content)
455 + .multilineTextAlignment(alignment)
456 + .textSelection(.enabled)
457 + .fixedSize(horizontal: false, vertical: true)
458 + .padding(.horizontal, ZyquoSpacing.sm)
459 + .padding(.vertical, ZyquoSpacing.xs)
460 + .frame(maxWidth: .infinity, alignment: frameAlignment(for: alignment))
461 + .background(tinted ? ZyquoColor.surfaceSecondary : Color.clear)
462 + .overlay(alignment: .bottom) {
463 + if !isLastRow { ZyquoHairline() }
464 + }
465 + .overlay(alignment: .trailing) {
466 + if !isLastColumn {
467 + Rectangle()
468 + .fill(ZyquoColor.border)
469 + .frame(width: ZyquoMetrics.hairline)
470 + }
471 + }
472 + }
473 +
474 + private func frameAlignment(for alignment: TextAlignment) -> Alignment {
475 + switch alignment {
476 + case .leading: return .leading
477 + case .center: return .center
478 + case .trailing: return .trailing
479 + }
480 + }
481 +}
added Sources/ZyquoCloud/Views/Markdown/SyntaxHighlighter.swift +470 −0
@@ -0,0 +1,470 @@
1 +//
2 +// SyntaxHighlighter.swift
3 +// Zyquo Cloud
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Lightweight scanner-based syntax highlighter for code blocks. Supports the
9 +// Phase 4 language set (Swift, Python, JS/TS, JSON, HTML, CSS, Bash, SQL, Go,
10 +// Rust, C/C++/Obj-C). Token colors are code-specific design tokens defined in
11 +// `CodeTheme` with the same dynamic light/dark pattern as `ZyquoColor`,
12 +// referencing existing semantic tokens where they fit. Unknown languages fall
13 +// back to plain text in the base color.
14 +//
15 +
16 +import SwiftUI
17 +
18 +// MARK: - Code theme
19 +
20 +/// Semantic colors for code tokens. Dynamic (light/dark) and coherent with the
21 +/// app palette: indigo/sky family for keywords, success-green strings, tertiary
22 +/// gray comments, amber numbers.
23 +struct CodeTheme {
24 + let keyword: Color
25 + let string: Color
26 + let comment: Color
27 + let number: Color
28 + let type: Color
29 + let functionCall: Color
30 + let property: Color
31 + let attribute: Color
32 +
33 + /// The default Zyquo Cloud code theme.
34 + static let zyquo = CodeTheme(
35 + keyword: ZyquoColor.accent,
36 + string: ZyquoColor.success,
37 + comment: ZyquoColor.textTertiary,
38 + number: dynamic(light: 0xB26A0B, dark: 0xE0A458),
39 + type: dynamic(light: 0x2380C2, dark: 0x62B7F0),
40 + functionCall: dynamic(light: 0x6E4FD4, dark: 0xA48CF2),
41 + property: dynamic(light: 0x2F6FBF, dark: 0x7FB4E8),
42 + attribute: ZyquoColor.warning
43 + )
44 +
45 + /// Same dynamic-color pattern as `ZyquoColor` (resolved per appearance).
46 + private static func dynamic(light: UInt32, dark: UInt32) -> Color {
47 + Color(nsColor: NSColor(name: nil) { appearance in
48 + let hex = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ? dark : light
49 + return NSColor(hex: hex)
50 + })
51 + }
52 +}
53 +
54 +// MARK: - Highlighter
55 +
56 +enum SyntaxHighlighter {
57 + /// The active code theme.
58 + static let theme = CodeTheme.zyquo
59 +
60 + /// Highlights `code` for `language`, returning an `AttributedString` whose
61 + /// text is byte-for-byte identical to the input. Plain (unclassified) text
62 + /// is colored with `baseColor`. Unknown or nil languages return the whole
63 + /// string in `baseColor`. Results are memoized (streaming re-renders hit
64 + /// the cache for every already-completed block).
65 + static func highlight(_ code: String, language: String?, baseColor: Color) -> AttributedString {
66 + guard !code.isEmpty else { return AttributedString() }
67 + guard let profile = profile(for: language) else {
68 + var plain = AttributedString(code)
69 + plain.foregroundColor = baseColor
70 + return plain
71 + }
72 +
73 + let key = CacheKey(
74 + textHash: code.hashValue,
75 + length: code.count,
76 + language: language?.lowercased() ?? "",
77 + base: String(describing: baseColor)
78 + )
79 + cacheLock.lock()
80 + if let hit = cache[key] {
81 + cacheLock.unlock()
82 + return hit
83 + }
84 + cacheLock.unlock()
85 +
86 + let result = tokenize(code, profile: profile, baseColor: baseColor)
87 +
88 + cacheLock.lock()
89 + if cache.count > cacheCapacity { cache.removeAll(keepingCapacity: true) }
90 + cache[key] = result
91 + cacheLock.unlock()
92 + return result
93 + }
94 +
95 + // MARK: Cache
96 +
97 + private struct CacheKey: Hashable {
98 + let textHash: Int
99 + let length: Int
100 + let language: String
101 + let base: String
102 + }
103 +
104 + private static let cacheLock = NSLock()
105 + private static let cacheCapacity = 128
106 + private static var cache: [CacheKey: AttributedString] = [:]
107 +
108 + // MARK: Scanner
109 +
110 + private static func tokenize(_ code: String, profile: LanguageProfile, baseColor: Color) -> AttributedString {
111 + let chars = Array(code)
112 + var result = AttributedString()
113 + var i = 0
114 + var pendingPlainStart = 0
115 + var previousSignificant: Character?
116 +
117 + func matches(_ marker: [Character], at index: Int) -> Bool {
118 + guard index + marker.count <= chars.count else { return false }
119 + for (offset, ch) in marker.enumerated() where chars[index + offset] != ch {
120 + return false
121 + }
122 + return true
123 + }
124 +
125 + func nextSignificant(after index: Int) -> Character? {
126 + var j = index
127 + while j < chars.count, chars[j] == " " || chars[j] == "\t" { j += 1 }
128 + return j < chars.count ? chars[j] : nil
129 + }
130 +
131 + func flushPlain(upTo end: Int) {
132 + guard end > pendingPlainStart else { return }
133 + var segment = AttributedString(String(chars[pendingPlainStart ..< end]))
134 + segment.foregroundColor = baseColor
135 + result += segment
136 + pendingPlainStart = end
137 + }
138 +
139 + func emit(_ start: Int, _ end: Int, _ color: Color) {
140 + flushPlain(upTo: start)
141 + var segment = AttributedString(String(chars[start ..< end]))
142 + segment.foregroundColor = color
143 + result += segment
144 + pendingPlainStart = end
145 + }
146 +
147 + func isIdentifierStart(_ c: Character) -> Bool {
148 + c.isLetter || c == "_" || profile.identifierExtras.contains(c)
149 + }
150 +
151 + func isIdentifierBody(_ c: Character) -> Bool {
152 + c.isLetter || c.isNumber || c == "_" || profile.identifierExtras.contains(c)
153 + }
154 +
155 + while i < chars.count {
156 + let c = chars[i]
157 +
158 + // Block comments (unterminated ones run to EOF — streaming safe).
159 + if let block = profile.blockComments.first(where: { matches($0.open, at: i) }) {
160 + let start = i
161 + i += block.open.count
162 + while i < chars.count, !matches(block.close, at: i) { i += 1 }
163 + if i < chars.count { i += block.close.count }
164 + emit(start, i, theme.comment)
165 + previousSignificant = nil
166 + continue
167 + }
168 +
169 + // Line comments.
170 + if let line = profile.lineComments.first(where: { matches($0, at: i) }) {
171 + let start = i
172 + i += line.count
173 + while i < chars.count, chars[i] != "\n" { i += 1 }
174 + emit(start, i, theme.comment)
175 + previousSignificant = nil
176 + continue
177 + }
178 +
179 + // Strings (with backslash escapes; triple quotes for Python-style).
180 + if profile.stringDelimiters.contains(c) {
181 + let start = i
182 + let triple = [c, c, c]
183 + if matches(triple, at: i) {
184 + i += 3
185 + while i < chars.count, !matches(triple, at: i) { i += 1 }
186 + if i < chars.count { i += 3 }
187 + } else {
188 + i += 1
189 + while i < chars.count {
190 + if chars[i] == "\\" { i += 2; continue }
191 + if chars[i] == c { i += 1; break }
192 + i += 1
193 + }
194 + i = min(i, chars.count)
195 + }
196 + let isKey = profile.stringKeyAsProperty && nextSignificant(after: i) == ":"
197 + emit(start, i, isKey ? theme.property : theme.string)
198 + previousSignificant = c
199 + continue
200 + }
201 +
202 + // Numbers (plus #hex colors for CSS).
203 + if c.isNumber || (profile.hashIsNumberPrefix && c == "#" && i + 1 < chars.count && chars[i + 1].isHexDigit) {
204 + let start = i
205 + i += 1
206 + while i < chars.count,
207 + chars[i].isLetter || chars[i].isNumber || chars[i] == "." || chars[i] == "_" {
208 + i += 1
209 + }
210 + emit(start, i, theme.number)
211 + previousSignificant = chars[i - 1]
212 + continue
213 + }
214 +
215 + // Attributes / decorators / directives (@escaping, #include, $VAR…).
216 + if profile.attributePrefixes.contains(c), i + 1 < chars.count, isIdentifierStart(chars[i + 1]) {
217 + let start = i
218 + i += 1
219 + while i < chars.count, isIdentifierBody(chars[i]) { i += 1 }
220 + emit(start, i, theme.attribute)
221 + previousSignificant = chars[i - 1]
222 + continue
223 + }
224 +
225 + // Identifiers: keywords, types, calls, properties.
226 + if isIdentifierStart(c) {
227 + let start = i
228 + while i < chars.count, isIdentifierBody(chars[i]) { i += 1 }
229 + let word = String(chars[start ..< i])
230 + let lookup = profile.caseInsensitiveKeywords ? word.lowercased() : word
231 + let next = nextSignificant(after: i)
232 + var color: Color?
233 +
234 + if profile.keywords.contains(lookup) {
235 + color = theme.keyword
236 + } else if profile.isMarkup {
237 + if let prev = previousSignificant, prev == "<" || prev == "/" || prev == "!" {
238 + color = theme.keyword // tag name
239 + } else if next == "=" {
240 + color = theme.property // tag attribute
241 + }
242 + } else if previousSignificant == "." {
243 + color = theme.property
244 + } else if next == "(" {
245 + color = theme.functionCall
246 + } else if let first = word.first, first.isUppercase {
247 + color = theme.type
248 + } else if profile.colonMeansProperty, next == ":" {
249 + color = theme.property
250 + }
251 +
252 + if let color { emit(start, i, color) }
253 + previousSignificant = chars[i - 1]
254 + continue
255 + }
256 +
257 + if !c.isWhitespace { previousSignificant = c }
258 + i += 1
259 + }
260 +
261 + flushPlain(upTo: chars.count)
262 + return result
263 + }
264 +
265 + // MARK: Language profiles
266 +
267 + private struct LanguageProfile {
268 + var keywords: Set<String> = []
269 + var lineComments: [[Character]] = []
270 + var blockComments: [(open: [Character], close: [Character])] = []
271 + var stringDelimiters: Set<Character> = ["\""]
272 + var identifierExtras: Set<Character> = []
273 + var attributePrefixes: Set<Character> = []
274 + var caseInsensitiveKeywords = false
275 + var colonMeansProperty = false
276 + var hashIsNumberPrefix = false
277 + var stringKeyAsProperty = false
278 + var isMarkup = false
279 + }
280 +
281 + private static func profile(for language: String?) -> LanguageProfile? {
282 + guard let language else { return nil }
283 + let normalized = language.trimmingCharacters(in: .whitespaces).lowercased()
284 + return profiles[normalized]
285 + }
286 +
287 + private static let profiles: [String: LanguageProfile] = {
288 + let slashLine: [[Character]] = [Array("//")]
289 + let cBlock: [(open: [Character], close: [Character])] = [(Array("/*"), Array("*/"))]
290 +
291 + var table: [String: LanguageProfile] = [:]
292 +
293 + let swift = LanguageProfile(
294 + keywords: [
295 + "func", "let", "var", "if", "else", "guard", "switch", "case", "default",
296 + "for", "while", "repeat", "in", "return", "import", "struct", "class",
297 + "enum", "protocol", "extension", "where", "as", "is", "try", "catch",
298 + "throw", "throws", "rethrows", "async", "await", "actor", "init", "deinit",
299 + "self", "Self", "super", "nil", "true", "false", "public", "private",
300 + "internal", "fileprivate", "open", "static", "final", "lazy", "weak",
301 + "unowned", "mutating", "nonmutating", "override", "defer", "typealias",
302 + "associatedtype", "some", "any", "break", "continue", "fallthrough", "do",
303 + "get", "set", "willSet", "didSet", "inout", "subscript", "operator",
304 + "indirect", "convenience", "required", "optional", "dynamic",
305 + ],
306 + lineComments: slashLine,
307 + blockComments: cBlock,
308 + attributePrefixes: ["@", "#"]
309 + )
310 + table["swift"] = swift
311 +
312 + let python = LanguageProfile(
313 + keywords: [
314 + "def", "class", "if", "elif", "else", "for", "while", "in", "return",
315 + "import", "from", "as", "with", "try", "except", "finally", "raise",
316 + "lambda", "pass", "break", "continue", "global", "nonlocal", "yield",
317 + "assert", "del", "not", "and", "or", "is", "None", "True", "False",
318 + "async", "await", "match", "case", "self",
319 + ],
320 + lineComments: [Array("#")],
321 + stringDelimiters: ["\"", "'"],
322 + attributePrefixes: ["@"]
323 + )
324 + for alias in ["python", "py", "python3"] { table[alias] = python }
325 +
326 + let jsTs = LanguageProfile(
327 + keywords: [
328 + "function", "const", "let", "var", "if", "else", "for", "while", "do",
329 + "switch", "case", "default", "return", "break", "continue", "new",
330 + "delete", "typeof", "instanceof", "in", "of", "class", "extends",
331 + "super", "this", "import", "export", "from", "as", "async", "await",
332 + "yield", "try", "catch", "finally", "throw", "void", "null", "undefined",
333 + "true", "false", "static", "get", "set", "interface", "type", "enum",
334 + "implements", "declare", "readonly", "namespace", "public", "private",
335 + "protected", "abstract", "satisfies", "keyof", "infer", "never",
336 + "unknown", "any", "string", "number", "boolean", "object", "symbol",
337 + "bigint",
338 + ],
339 + lineComments: slashLine,
340 + blockComments: cBlock,
341 + stringDelimiters: ["\"", "'", "`"],
342 + identifierExtras: ["$"],
343 + attributePrefixes: ["@"]
344 + )
345 + for alias in ["javascript", "js", "jsx", "typescript", "ts", "tsx"] { table[alias] = jsTs }
346 +
347 + let json = LanguageProfile(
348 + keywords: ["true", "false", "null"],
349 + lineComments: slashLine,
350 + blockComments: cBlock,
351 + stringKeyAsProperty: true
352 + )
353 + table["json"] = json
354 + table["jsonc"] = json
355 +
356 + let html = LanguageProfile(
357 + blockComments: [(Array("<!--"), Array("-->"))],
358 + stringDelimiters: ["\"", "'"],
359 + identifierExtras: ["-"],
360 + isMarkup: true
361 + )
362 + for alias in ["html", "xml", "svg", "xhtml"] { table[alias] = html }
363 +
364 + let css = LanguageProfile(
365 + keywords: ["important", "inherit", "initial", "unset", "auto", "none", "revert"],
366 + blockComments: cBlock,
367 + stringDelimiters: ["\"", "'"],
368 + identifierExtras: ["-"],
369 + attributePrefixes: ["@"],
370 + colonMeansProperty: true,
371 + hashIsNumberPrefix: true
372 + )
373 + for alias in ["css", "scss", "less"] { table[alias] = css }
374 +
375 + let bash = LanguageProfile(
376 + keywords: [
377 + "if", "then", "else", "elif", "fi", "for", "while", "until", "do",
378 + "done", "case", "esac", "function", "in", "select", "time", "coproc",
379 + "echo", "cd", "export", "local", "return", "exit", "read", "set",
380 + "unset", "shift", "source", "alias", "eval", "exec", "printf", "test",
381 + "true", "false", "sudo", "trap", "declare",
382 + ],
383 + lineComments: [Array("#")],
384 + stringDelimiters: ["\"", "'"],
385 + identifierExtras: ["-"],
386 + attributePrefixes: ["$"]
387 + )
388 + for alias in ["bash", "sh", "zsh", "shell", "console"] { table[alias] = bash }
389 +
390 + let sql = LanguageProfile(
391 + keywords: [
392 + "select", "from", "where", "insert", "into", "values", "update",
393 + "delete", "set", "create", "table", "drop", "alter", "index", "view",
394 + "join", "inner", "left", "right", "outer", "full", "cross", "on", "as",
395 + "and", "or", "not", "null", "primary", "key", "foreign", "references",
396 + "group", "by", "order", "having", "limit", "offset", "distinct",
397 + "union", "all", "exists", "between", "like", "in", "is", "case",
398 + "when", "then", "else", "end", "count", "sum", "avg", "min", "max",
399 + "desc", "asc", "with", "constraint", "unique", "default", "begin",
400 + "commit", "rollback", "transaction",
401 + ],
402 + lineComments: [Array("--")],
403 + blockComments: cBlock,
404 + stringDelimiters: ["'", "\""],
405 + caseInsensitiveKeywords: true
406 + )
407 + table["sql"] = sql
408 +
409 + let go = LanguageProfile(
410 + keywords: [
411 + "func", "package", "import", "var", "const", "type", "struct",
412 + "interface", "map", "chan", "go", "defer", "if", "else", "for",
413 + "range", "switch", "case", "default", "return", "break", "continue",
414 + "fallthrough", "select", "goto", "true", "false", "nil", "iota",
415 + "make", "new", "len", "cap", "append", "copy", "delete", "panic",
416 + "recover", "error", "string", "int", "int8", "int16", "int32", "int64",
417 + "uint", "uint8", "uint16", "uint32", "uint64", "bool", "byte", "rune",
418 + "float32", "float64", "complex64", "complex128", "any",
419 + ],
420 + lineComments: slashLine,
421 + blockComments: cBlock,
422 + stringDelimiters: ["\"", "'", "`"]
423 + )
424 + table["go"] = go
425 + table["golang"] = go
426 +
427 + let rust = LanguageProfile(
428 + keywords: [
429 + "fn", "let", "mut", "const", "static", "if", "else", "match", "for",
430 + "while", "loop", "in", "return", "break", "continue", "struct", "enum",
431 + "trait", "impl", "pub", "use", "mod", "crate", "self", "Self", "super",
432 + "where", "as", "ref", "move", "async", "await", "dyn", "unsafe",
433 + "extern", "type", "true", "false", "Some", "None", "Ok", "Err",
434 + "String", "str", "i8", "i16", "i32", "i64", "i128", "u8", "u16", "u32",
435 + "u64", "u128", "f32", "f64", "usize", "isize", "bool", "char", "Box",
436 + "Vec", "Option", "Result",
437 + ],
438 + lineComments: slashLine,
439 + blockComments: cBlock,
440 + attributePrefixes: ["#"]
441 + )
442 + table["rust"] = rust
443 + table["rs"] = rust
444 +
445 + let cFamily = LanguageProfile(
446 + keywords: [
447 + "int", "char", "float", "double", "void", "long", "short", "signed",
448 + "unsigned", "if", "else", "for", "while", "do", "switch", "case",
449 + "default", "return", "break", "continue", "struct", "union", "enum",
450 + "typedef", "const", "static", "extern", "inline", "sizeof", "goto",
451 + "volatile", "register", "auto", "bool", "true", "false", "class",
452 + "public", "private", "protected", "virtual", "override", "final",
453 + "template", "typename", "namespace", "using", "new", "delete", "this",
454 + "nullptr", "try", "catch", "throw", "constexpr", "noexcept", "friend",
455 + "operator", "explicit", "mutable", "id", "instancetype", "nonatomic",
456 + "strong", "weak", "copy", "readonly", "readwrite", "assign", "nil",
457 + "YES", "NO",
458 + ],
459 + lineComments: slashLine,
460 + blockComments: cBlock,
461 + stringDelimiters: ["\"", "'"],
462 + attributePrefixes: ["@", "#"]
463 + )
464 + for alias in ["c", "cpp", "c++", "cc", "cxx", "h", "hpp", "objc", "objective-c", "objectivec", "m", "mm"] {
465 + table[alias] = cFamily
466 + }
467 +
468 + return table
469 + }()
470 +}
added Sources/ZyquoCloud/Views/QuickChat/QuickChatPanel.swift +233 −0
@@ -0,0 +1,233 @@
1 +//
2 +// QuickChatPanel.swift
3 +// Zyquo Cloud
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Global Quick Chat (⌥Space): floating Spotlight-style panel — 640pt wide,
9 +// radius 14, prominent shadow, single input + model chip; the answer expands
10 +// below; ESC dismisses. Appears on the active screen at 30% height.
11 +//
12 +
13 +import SwiftUI
14 +
15 +/// Manages the floating NSPanel hosting QuickChatView and the global hotkey.
16 +@MainActor
17 +final class QuickChatController {
18 + private var panel: NSPanel?
19 + private var hotKeyMonitor: Any?
20 + private let environment: AppEnvironment
21 +
22 + init(environment: AppEnvironment) {
23 + self.environment = environment
24 + installHotKey()
25 + }
26 +
27 + private func installHotKey() {
28 + // ⌥Space, global. Requires no accessibility permission for local
29 + // monitor; global monitor works while other apps are frontmost.
30 + hotKeyMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { [weak self] event in
31 + guard event.keyCode == 49, event.modifierFlags.contains(.option) else { return }
32 + Task { @MainActor in self?.toggle() }
33 + }
34 + NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
35 + if event.keyCode == 49, event.modifierFlags.contains(.option) {
36 + Task { @MainActor in self?.toggle() }
37 + return nil
38 + }
39 + return event
40 + }
41 + }
42 +
43 + func toggle() {
44 + if let panel, panel.isVisible {
45 + panel.orderOut(nil)
46 + return
47 + }
48 + show()
49 + }
50 +
51 + func show() {
52 + let panel = self.panel ?? makePanel()
53 + self.panel = panel
54 + positionOnActiveScreen(panel)
55 + panel.makeKeyAndOrderFront(nil)
56 + NSApp.activate(ignoringOtherApps: true)
57 + }
58 +
59 + private func makePanel() -> NSPanel {
60 + let hosting = NSHostingView(
61 + rootView: QuickChatView(onDismiss: { [weak self] in self?.panel?.orderOut(nil) })
62 + .environmentObject(environment.catalog)
63 + .environmentObject(environment.vault)
64 + .environmentObject(environment.appearance)
65 + .environmentObject(environment.conversations)
66 + )
67 + let panel = KeyablePanel(
68 + contentRect: NSRect(x: 0, y: 0, width: ZyquoMetrics.quickChatWidth, height: 120),
69 + styleMask: [.nonactivatingPanel, .fullSizeContentView, .titled],
70 + backing: .buffered,
71 + defer: false
72 + )
73 + panel.titleVisibility = .hidden
74 + panel.titlebarAppearsTransparent = true
75 + panel.isMovableByWindowBackground = true
76 + panel.level = .floating
77 + panel.collectionBehavior = [.canJoinAllSpaces, .transient]
78 + panel.isOpaque = false
79 + panel.backgroundColor = .clear
80 + panel.hidesOnDeactivate = false
81 + panel.contentView = hosting
82 + return panel
83 + }
84 +
85 + private func positionOnActiveScreen(_ panel: NSPanel) {
86 + let screen = NSScreen.main ?? NSScreen.screens[0]
87 + let frame = screen.visibleFrame
88 + let size = panel.frame.size
89 + let x = frame.midX - size.width / 2
90 + let y = frame.maxY - frame.height * 0.30 - size.height
91 + panel.setFrameOrigin(NSPoint(x: x, y: y))
92 + }
93 +}
94 +
95 +/// NSPanel subclass that can become key despite .nonactivatingPanel.
96 +final class KeyablePanel: NSPanel {
97 + override var canBecomeKey: Bool { true }
98 + override func cancelOperation(_ sender: Any?) {
99 + orderOut(nil)
100 + }
101 +}
102 +
103 +struct QuickChatView: View {
104 + var onDismiss: () -> Void
105 +
106 + @EnvironmentObject private var catalog: ModelCatalog
107 + @EnvironmentObject private var vault: KeyVaultStore
108 + @EnvironmentObject private var appearance: AppearanceStore
109 + @EnvironmentObject private var conversations: ConversationStore
110 +
111 + @State private var input = ""
112 + @State private var answer = ""
113 + @State private var reasoning = ""
114 + @State private var isStreaming = false
115 + @State private var errorText: String?
116 + @State private var model: AIModel?
117 + @State private var streamTask: Task<Void, Never>?
118 + @FocusState private var focused: Bool
119 +
120 + var body: some View {
121 + VStack(spacing: 0) {
122 + HStack(spacing: ZyquoSpacing.sm) {
123 + CloudZGlyph(size: 24)
124 + TextField("Ask anything…", text: $input)
125 + .textFieldStyle(.plain)
126 + .font(ZyquoFont.body(size: 16))
127 + .focused($focused)
128 + .onSubmit(ask)
129 + ModelChipView(model: model ?? catalog.defaultModel) { chosen in
130 + model = chosen
131 + }
132 + if isStreaming {
133 + Button {
134 + streamTask?.cancel()
135 + } label: {
136 + Image(systemName: "stop.fill")
137 + .foregroundStyle(ZyquoColor.danger)
138 + }
139 + .buttonStyle(.plain)
140 + }
141 + }
142 + .padding(ZyquoSpacing.md)
143 +
144 + if !answer.isEmpty || isStreaming || errorText != nil {
145 + ZyquoHairline()
146 + ScrollView {
147 + VStack(alignment: .leading, spacing: ZyquoSpacing.xs) {
148 + if let errorText {
149 + Text(errorText)
150 + .font(ZyquoFont.body())
151 + .foregroundStyle(ZyquoColor.danger)
152 + } else {
153 + MarkdownView(text: answer, fontSize: appearance.chatFontSize)
154 + if isStreaming { StreamingCaret() }
155 + }
156 + }
157 + .frame(maxWidth: .infinity, alignment: .leading)
158 + .padding(ZyquoSpacing.md)
159 + }
160 + .frame(maxHeight: 360)
161 + HStack {
162 + Spacer()
163 + Button("Continue in Zyquo Cloud") { continueInApp() }
164 + .controlSize(.small)
165 + .disabled(answer.isEmpty)
166 + }
167 + .padding(.horizontal, ZyquoSpacing.md)
168 + .padding(.bottom, ZyquoSpacing.xs)
169 + }
170 + }
171 + .frame(width: ZyquoMetrics.quickChatWidth)
172 + .background(
173 + RoundedRectangle(cornerRadius: ZyquoRadius.large, style: .continuous)
174 + .fill(ZyquoColor.surface)
175 + )
176 + .overlay(
177 + RoundedRectangle(cornerRadius: ZyquoRadius.large, style: .continuous)
178 + .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline)
179 + )
180 + .zyquoSoftShadow()
181 + .onAppear { focused = true }
182 + .onExitCommand { onDismiss() }
183 + }
184 +
185 + private func ask() {
186 + let question = input.trimmingCharacters(in: .whitespacesAndNewlines)
187 + guard !question.isEmpty, !isStreaming else { return }
188 + guard let target = model ?? catalog.defaultModel else { return }
189 + answer = ""
190 + reasoning = ""
191 + errorText = nil
192 + isStreaming = true
193 + streamTask = Task {
194 + do {
195 + let key = try vault.apiKey(for: target.provider)
196 + let client = ProviderRegistry.client(for: target)
197 + let request = ChatRequest(
198 + model: target,
199 + systemPrompt: "Answer concisely.",
200 + messages: [Message(role: .user, text: question)],
201 + parameters: ChatParameters()
202 + )
203 + for try await event in client.streamChat(request, apiKey: key) {
204 + if Task.isCancelled { break }
205 + switch event {
206 + case .textDelta(let delta): answer += delta
207 + case .reasoningDelta(let delta): reasoning += delta
208 + default: break
209 + }
210 + }
211 + } catch {
212 + errorText = error.localizedDescription
213 + }
214 + isStreaming = false
215 + }
216 + }
217 +
218 + /// Moves the exchange into a full conversation in the main window.
219 + private func continueInApp() {
220 + let conversation = conversations.newConversation(model: model ?? catalog.defaultModel)
221 + guard var updated = conversations.conversations.first(where: { $0.id == conversation.id }) else { return }
222 + var question = Message(role: .user, text: input)
223 + question.provider = updated.provider
224 + question.modelID = updated.modelID
225 + var reply = Message(role: .assistant, text: answer, reasoning: reasoning.isEmpty ? nil : reasoning)
226 + reply.provider = updated.provider
227 + reply.modelID = updated.modelID
228 + updated.messages = [question, reply]
229 + conversations.update(updated)
230 + onDismiss()
231 + NSApp.activate(ignoringOtherApps: true)
232 + }
233 +}
added Sources/ZyquoCloud/Views/Settings/SettingsView.swift +457 −0
@@ -0,0 +1,457 @@
1 +//
2 +// SettingsView.swift
3 +// Zyquo Cloud
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Settings window (720×520, toolbar-style tabs): Providers & Keys, Models,
9 +// Appearance, Shortcuts, Advanced.
10 +//
11 +
12 +import SwiftUI
13 +
14 +struct SettingsView: View {
15 + var body: some View {
16 + TabView {
17 + ProvidersSettingsTab()
18 + .tabItem { Label("Providers & Keys", systemImage: "key") }
19 + ModelsSettingsTab()
20 + .tabItem { Label("Models", systemImage: "cpu") }
21 + AppearanceSettingsTab()
22 + .tabItem { Label("Appearance", systemImage: "paintbrush") }
23 + ShortcutsSettingsTab()
24 + .tabItem { Label("Shortcuts", systemImage: "keyboard") }
25 + AdvancedSettingsTab()
26 + .tabItem { Label("Advanced", systemImage: "gearshape.2") }
27 + }
28 + .frame(width: ZyquoMetrics.settingsWidth, height: ZyquoMetrics.settingsHeight)
29 + }
30 +}
31 +
32 +// MARK: - Providers & Keys
33 +
34 +struct ProvidersSettingsTab: View {
35 + @EnvironmentObject private var vault: KeyVaultStore
36 + @EnvironmentObject private var catalog: ModelCatalog
37 + @State private var draftKeys: [ProviderID: String] = [:]
38 +
39 + var body: some View {
40 + ScrollView {
41 + VStack(spacing: ZyquoSpacing.xs) {
42 + ForEach(ProviderID.builtIn) { provider in
43 + providerRow(provider)
44 + if provider != ProviderID.builtIn.last { ZyquoHairline() }
45 + }
46 + }
47 + .padding(ZyquoMetrics.contentInset)
48 + }
49 + .background(ZyquoColor.background)
50 + }
51 +
52 + private func providerRow(_ provider: ProviderID) -> some View {
53 + HStack(spacing: ZyquoSpacing.sm) {
54 + Image(systemName: provider.symbolName)
55 + .font(.system(size: 14))
56 + .foregroundStyle(ZyquoColor.accent)
57 + .frame(width: 22)
58 + VStack(alignment: .leading, spacing: 1) {
59 + HStack(spacing: ZyquoSpacing.xxs) {
60 + Text(provider.displayName)
61 + .font(ZyquoFont.bodyEmphasis(size: 13))
62 + .foregroundStyle(ZyquoColor.textPrimary)
63 + statusIndicator(provider)
64 + }
65 + statusDetail(provider)
66 + }
67 + Spacer()
68 + keyField(provider)
69 + testButton(provider)
70 + if vault.hasKey(for: provider) {
71 + Button {
72 + vault.deleteKey(for: provider)
73 + } label: {
74 + Image(systemName: "trash")
75 + .font(.system(size: 11))
76 + .foregroundStyle(ZyquoColor.danger)
77 + }
78 + .buttonStyle(.plain)
79 + .help("Delete key")
80 + }
81 + }
82 + .padding(.vertical, ZyquoSpacing.xxs)
83 + }
84 +
85 + @ViewBuilder
86 + private func statusIndicator(_ provider: ProviderID) -> some View {
87 + switch vault.statuses[provider] ?? .unset {
88 + case .unset: StatusDot(status: .unset)
89 + case .saved: StatusDot(status: .unset).overlay(Circle().strokeBorder(ZyquoColor.textSecondary, lineWidth: 1))
90 + case .testing: ProgressView().controlSize(.mini)
91 + case .verified: StatusDot(status: .verified)
92 + case .failed: StatusDot(status: .failed)
93 + }
94 + }
95 +
96 + @ViewBuilder
97 + private func statusDetail(_ provider: ProviderID) -> some View {
98 + switch vault.statuses[provider] ?? .unset {
99 + case .verified(let latency):
100 + Text(String(format: "Verified · %.0f ms", latency * 1000))
101 + .font(ZyquoFont.caption)
102 + .foregroundStyle(ZyquoColor.success)
103 + case .failed(let message):
104 + Text(message)
105 + .font(ZyquoFont.caption)
106 + .foregroundStyle(ZyquoColor.danger)
107 + .lineLimit(1)
108 + .help(message)
109 + case .saved:
110 + Text(vault.redactedKeys[provider] ?? "")
111 + .font(ZyquoFont.caption)
112 + .foregroundStyle(ZyquoColor.textTertiary)
113 + default:
114 + Text("No key")
115 + .font(ZyquoFont.caption)
116 + .foregroundStyle(ZyquoColor.textTertiary)
117 + }
118 + }
119 +
120 + private func keyField(_ provider: ProviderID) -> some View {
121 + SecureField(
122 + vault.hasKey(for: provider) ? (vault.redactedKeys[provider] ?? "") : "API key",
123 + text: Binding(
124 + get: { draftKeys[provider] ?? "" },
125 + set: { draftKeys[provider] = $0 }
126 + )
127 + )
128 + .textFieldStyle(.roundedBorder)
129 + .font(ZyquoFont.code(size: 11))
130 + .frame(width: 220)
131 + .onSubmit { saveDraft(provider) }
132 + }
133 +
134 + private func testButton(_ provider: ProviderID) -> some View {
135 + Button("Test") {
136 + saveDraft(provider)
137 + Task { await vault.testKey(for: provider, catalog: catalog) }
138 + }
139 + .controlSize(.small)
140 + .disabled(!vault.hasKey(for: provider) && (draftKeys[provider] ?? "").isEmpty)
141 + }
142 +
143 + private func saveDraft(_ provider: ProviderID) {
144 + if let draft = draftKeys[provider], !draft.trimmingCharacters(in: .whitespaces).isEmpty {
145 + vault.setKey(draft, for: provider)
146 + draftKeys[provider] = ""
147 + }
148 + }
149 +}
150 +
151 +// MARK: - Models
152 +
153 +struct ModelsSettingsTab: View {
154 + @EnvironmentObject private var catalog: ModelCatalog
155 + @EnvironmentObject private var vault: KeyVaultStore
156 + @State private var selectedProvider: ProviderID = .openai
157 + @State private var refreshing = false
158 + @State private var refreshResult: String?
159 + @State private var showingCustomModelSheet = false
160 +
161 + var body: some View {
162 + VStack(spacing: 0) {
163 + HStack {
164 + Picker("Provider", selection: $selectedProvider) {
165 + ForEach(ProviderID.builtIn) { provider in
166 + Text(provider.displayName).tag(provider)
167 + }
168 + }
169 + .frame(width: 240)
170 + Spacer()
171 + if let result = refreshResult {
172 + Text(result)
173 + .font(ZyquoFont.caption)
174 + .foregroundStyle(ZyquoColor.textSecondary)
175 + }
176 + Button {
177 + refreshModels()
178 + } label: {
179 + if refreshing {
180 + ProgressView().controlSize(.small)
181 + } else {
182 + Label("Refresh from API", systemImage: "arrow.clockwise")
183 + }
184 + }
185 + .controlSize(.small)
186 + .disabled(refreshing || !selectedProvider.supportsModelListing || !vault.hasKey(for: selectedProvider))
187 + Button {
188 + showingCustomModelSheet = true
189 + } label: {
190 + Label("Add Custom", systemImage: "plus")
191 + }
192 + .controlSize(.small)
193 + }
194 + .padding(ZyquoSpacing.sm)
195 + ZyquoHairline()
196 + modelTable
197 + }
198 + .background(ZyquoColor.background)
199 + .sheet(isPresented: $showingCustomModelSheet) {
200 + CustomModelSheet()
201 + }
202 + }
203 +
204 + private var modelTable: some View {
205 + ScrollView {
206 + LazyVStack(spacing: 1) {
207 + ForEach(catalog.models(for: selectedProvider)) { model in
208 + modelRow(model)
209 + }
210 + let unknown = catalog.unknownLiveIDs(for: selectedProvider)
211 + if !unknown.isEmpty {
212 + Text("Live on the API but not in the catalog: \(unknown.joined(separator: ", "))")
213 + .font(ZyquoFont.caption)
214 + .foregroundStyle(ZyquoColor.textTertiary)
215 + .frame(maxWidth: .infinity, alignment: .leading)
216 + .padding(ZyquoSpacing.sm)
217 + }
218 + }
219 + .padding(ZyquoSpacing.sm)
220 + }
221 + }
222 +
223 + private func modelRow(_ model: AIModel) -> some View {
224 + HStack(spacing: ZyquoSpacing.xs) {
225 + Button {
226 + if catalog.favoriteIDs.contains(model.id) {
227 + catalog.favoriteIDs.remove(model.id)
228 + } else {
229 + catalog.favoriteIDs.insert(model.id)
230 + }
231 + } label: {
232 + Image(systemName: catalog.favoriteIDs.contains(model.id) ? "star.fill" : "star")
233 + .font(.system(size: 10))
234 + .foregroundStyle(catalog.favoriteIDs.contains(model.id) ? ZyquoColor.warning : ZyquoColor.textTertiary)
235 + }
236 + .buttonStyle(.plain)
237 + VStack(alignment: .leading, spacing: 0) {
238 + HStack(spacing: ZyquoSpacing.xxs) {
239 + Text(model.displayName)
240 + .font(ZyquoFont.body(size: 12.5))
241 + .foregroundStyle(ZyquoColor.textPrimary)
242 + if model.isRecommended { ZyquoBadge(text: "Featured", color: ZyquoColor.accent) }
243 + if model.isLegacy { ZyquoBadge(text: "Legacy") }
244 + }
245 + Text(model.id)
246 + .font(ZyquoFont.code(size: 10))
247 + .foregroundStyle(ZyquoColor.textTertiary)
248 + }
249 + Spacer()
250 + HStack(spacing: ZyquoSpacing.xxs) {
251 + if model.capabilities.vision { ZyquoBadge(text: "vision") }
252 + if model.capabilities.reasoning { ZyquoBadge(text: "reasoning") }
253 + if model.capabilities.tools { ZyquoBadge(text: "tools") }
254 + }
255 + Text(model.contextBadge)
256 + .font(ZyquoFont.caption)
257 + .foregroundStyle(ZyquoColor.textSecondary)
258 + .frame(width: 64, alignment: .trailing)
259 + Text(pricingText(model))
260 + .font(ZyquoFont.caption)
261 + .foregroundStyle(ZyquoColor.textTertiary)
262 + .frame(width: 110, alignment: .trailing)
263 + }
264 + .padding(.vertical, 3)
265 + .padding(.horizontal, ZyquoSpacing.xs)
266 + .zyquoHoverHighlight()
267 + }
268 +
269 + private func pricingText(_ model: AIModel) -> String {
270 + guard let pricing = model.pricing else { return "—" }
271 + return String(format: "$%.2f / $%.2f", pricing.inputPerMTok, pricing.outputPerMTok)
272 + }
273 +
274 + private func refreshModels() {
275 + refreshing = true
276 + refreshResult = nil
277 + let provider = selectedProvider
278 + Task {
279 + defer { refreshing = false }
280 + do {
281 + let key = try vault.apiKey(for: provider)
282 + let ids = try await ProviderRegistry.client(for: provider).listModelIDs(apiKey: key)
283 + catalog.applyLiveListing(ids, for: provider)
284 + let unknown = catalog.unknownLiveIDs(for: provider).count
285 + refreshResult = "\(ids.count) live models · \(unknown) not in catalog"
286 + } catch {
287 + refreshResult = error.localizedDescription
288 + }
289 + }
290 + }
291 +}
292 +
293 +/// Custom model editor: any OpenAI-compatible endpoint (OpenRouter, Groq…).
294 +struct CustomModelSheet: View {
295 + @EnvironmentObject private var catalog: ModelCatalog
296 + @Environment(\.dismiss) private var dismiss
297 + @State private var modelID = ""
298 + @State private var displayName = ""
299 + @State private var baseURL = ""
300 + @State private var contextWindow = 128_000
301 + @State private var supportsVision = false
302 +
303 + var body: some View {
304 + VStack(alignment: .leading, spacing: ZyquoSpacing.sm) {
305 + Text("Custom Model")
306 + .font(ZyquoFont.title)
307 + Text("Any OpenAI-compatible chat endpoint (OpenRouter, Groq, local gateways…). The custom key is stored under the Custom provider slot in the encrypted vault.")
308 + .font(ZyquoFont.caption)
309 + .foregroundStyle(ZyquoColor.textSecondary)
310 + Form {
311 + TextField("Model ID (as sent to the API)", text: $modelID)
312 + TextField("Display name", text: $displayName)
313 + TextField("Base URL (e.g. https://openrouter.ai/api/v1)", text: $baseURL)
314 + TextField("Context window", value: $contextWindow, format: .number)
315 + Toggle("Supports vision", isOn: $supportsVision)
316 + }
317 + HStack {
318 + Spacer()
319 + Button("Cancel") { dismiss() }
320 + Button("Add") { add() }
321 + .buttonStyle(.borderedProminent)
322 + .disabled(modelID.isEmpty || URL(string: baseURL) == nil)
323 + }
324 + }
325 + .padding(ZyquoSpacing.xl)
326 + .frame(width: 440)
327 + }
328 +
329 + private func add() {
330 + let model = AIModel(
331 + id: modelID,
332 + provider: .custom,
333 + displayName: displayName.isEmpty ? modelID : displayName,
334 + contextWindow: contextWindow,
335 + maxOutputTokens: nil,
336 + capabilities: ModelCapabilities(vision: supportsVision, tools: false, jsonMode: false),
337 + pricing: nil,
338 + parameterSupport: .openAIDefault,
339 + customBaseURL: URL(string: baseURL)
340 + )
341 + catalog.customModels.append(model)
342 + dismiss()
343 + }
344 +}
345 +
346 +// MARK: - Appearance
347 +
348 +struct AppearanceSettingsTab: View {
349 + @EnvironmentObject private var appearance: AppearanceStore
350 +
351 + var body: some View {
352 + Form {
353 + Picker("Theme", selection: $appearance.themeMode) {
354 + ForEach(ThemeMode.allCases) { mode in
355 + Text(mode.displayName).tag(mode)
356 + }
357 + }
358 + .pickerStyle(.segmented)
359 +
360 + Picker("Accent", selection: $appearance.accent) {
361 + ForEach(AccentChoice.allCases) { choice in
362 + Text(choice.displayName).tag(choice)
363 + }
364 + }
365 +
366 + VStack(alignment: .leading, spacing: ZyquoSpacing.xs) {
367 + HStack {
368 + Text("Chat font size")
369 + Spacer()
370 + Text(String(format: "%.1f pt", appearance.chatFontSize))
371 + .font(ZyquoFont.caption)
372 + .foregroundStyle(ZyquoColor.textSecondary)
373 + .monospacedDigit()
374 + }
375 + Slider(value: $appearance.chatFontSize, in: 12...18, step: 0.5)
376 + // Live preview
377 + VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) {
378 + Text("Preview")
379 + .font(ZyquoFont.caption)
380 + .foregroundStyle(ZyquoColor.textTertiary)
381 + Text("The quick brown fox jumps over the lazy dog — Zyquo Cloud renders chat text at this size, with generous 1.45 line height for readability.")
382 + .font(ZyquoFont.body(size: appearance.chatFontSize))
383 + .lineSpacing(appearance.chatFontSize * ZyquoFont.bodyLineSpacingFactor)
384 + .foregroundStyle(ZyquoColor.textPrimary)
385 + .padding(ZyquoSpacing.sm)
386 + .frame(maxWidth: .infinity, alignment: .leading)
387 + .background(
388 + RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous)
389 + .fill(ZyquoColor.surface)
390 + .overlay(
391 + RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous)
392 + .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline)
393 + )
394 + )
395 + }
396 + }
397 + }
398 + .formStyle(.grouped)
399 + }
400 +}
401 +
402 +// MARK: - Shortcuts
403 +
404 +struct ShortcutsSettingsTab: View {
405 + private static let shortcuts: [(String, String)] = [
406 + ("New chat", "⌘N"),
407 + ("Model switcher / command palette", "⌘K"),
408 + ("Search conversations", "⌘F"),
409 + ("Send message", "⌘↩"),
410 + ("Export conversation", "⌘⇧E"),
411 + ("Quick Chat panel", "⌥Space"),
412 + ("Settings", "⌘,"),
413 + ]
414 +
415 + var body: some View {
416 + Form {
417 + ForEach(Self.shortcuts, id: \.0) { name, keys in
418 + LabeledContent(name) {
419 + Text(keys)
420 + .font(ZyquoFont.code(size: 12))
421 + .foregroundStyle(ZyquoColor.textSecondary)
422 + }
423 + }
424 + }
425 + .formStyle(.grouped)
426 + }
427 +}
428 +
429 +// MARK: - Advanced
430 +
431 +struct AdvancedSettingsTab: View {
432 + @EnvironmentObject private var store: ConversationStore
433 +
434 + var body: some View {
435 + Form {
436 + Section("Default system prompt") {
437 + TextEditor(text: $store.defaultSystemPrompt)
438 + .font(ZyquoFont.body(size: 12.5))
439 + .frame(height: 90)
440 + }
441 + Section("Data") {
442 + LabeledContent("Data folder") {
443 + Button("Reveal in Finder") {
444 + NSWorkspace.shared.activateFileViewerSelecting([
445 + PersistenceService.shared.rootDirectory
446 + ])
447 + }
448 + .controlSize(.small)
449 + }
450 + Text("Conversations, settings, and the encrypted key vault live in ~/Library/Application Support/ZyquoCloud/. The vault (vault.zq) is bound to this Mac and can't be decrypted elsewhere.")
451 + .font(ZyquoFont.caption)
452 + .foregroundStyle(ZyquoColor.textTertiary)
453 + }
454 + }
455 + .formStyle(.grouped)
456 + }
457 +}
added Sources/ZyquoCloud/Views/Sidebar/SidebarView.swift +218 −0
@@ -0,0 +1,218 @@
1 +//
2 +// SidebarView.swift
3 +// Zyquo Cloud
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Sidebar per the Phase 4 spec: wordmark, search field, prominent New Chat,
9 +// conversation list grouped Pinned/Today/Yesterday/Previous 7 Days/Older,
10 +// hover-revealed pin/delete, footer with settings + usage summary.
11 +//
12 +
13 +import SwiftUI
14 +
15 +struct SidebarView: View {
16 + @EnvironmentObject private var store: ConversationStore
17 + @EnvironmentObject private var catalog: ModelCatalog
18 +
19 + var body: some View {
20 + VStack(spacing: 0) {
21 + wordmark
22 + searchField
23 + newChatButton
24 + conversationList
25 + ZyquoHairline()
26 + footer
27 + }
28 + .frame(minWidth: ZyquoMetrics.sidebarWidth)
29 + }
30 +
31 + // MARK: - Sections
32 +
33 + private var wordmark: some View {
34 + HStack(spacing: ZyquoSpacing.xs) {
35 + CloudZGlyph(size: 22)
36 + Text("Zyquo Cloud")
37 + .font(ZyquoFont.bodyEmphasis(size: 14))
38 + .foregroundStyle(ZyquoColor.textPrimary)
39 + Spacer()
40 + }
41 + .padding(.horizontal, ZyquoMetrics.contentInset)
42 + .padding(.top, ZyquoSpacing.sm)
43 + .padding(.bottom, ZyquoSpacing.xs)
44 + }
45 +
46 + private var searchField: some View {
47 + HStack(spacing: ZyquoSpacing.xxs) {
48 + Image(systemName: "magnifyingglass")
49 + .font(.system(size: 11))
50 + .foregroundStyle(ZyquoColor.textTertiary)
51 + TextField("Search", text: $store.searchText)
52 + .textFieldStyle(.plain)
53 + .font(ZyquoFont.body(size: 12.5))
54 + }
55 + .padding(.horizontal, ZyquoSpacing.xs)
56 + .padding(.vertical, 5)
57 + .background(
58 + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)
59 + .fill(ZyquoColor.surfaceSecondary.opacity(0.7))
60 + )
61 + .padding(.horizontal, ZyquoSpacing.sm)
62 + .padding(.bottom, ZyquoSpacing.xs)
63 + }
64 +
65 + private var newChatButton: some View {
66 + Button {
67 + store.newConversation()
68 + } label: {
69 + HStack(spacing: ZyquoSpacing.xxs) {
70 + Image(systemName: "plus.bubble.fill")
71 + .font(.system(size: 12, weight: .semibold))
72 + Text("New Chat")
73 + .font(ZyquoFont.bodyEmphasis(size: 13))
74 + }
75 + .foregroundStyle(.white)
76 + .frame(maxWidth: .infinity)
77 + .padding(.vertical, 7)
78 + .background(
79 + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)
80 + .fill(ZyquoColor.accent)
81 + )
82 + }
83 + .buttonStyle(PressableButtonStyle())
84 + .keyboardShortcut("n", modifiers: .command)
85 + .padding(.horizontal, ZyquoSpacing.sm)
86 + .padding(.bottom, ZyquoSpacing.xs)
87 + }
88 +
89 + private var conversationList: some View {
90 + ScrollView {
91 + LazyVStack(alignment: .leading, spacing: 2, pinnedViews: []) {
92 + ForEach(store.sidebarGroups) { group in
93 + Text(group.title)
94 + .font(ZyquoFont.caption)
95 + .foregroundStyle(ZyquoColor.textTertiary)
96 + .padding(.horizontal, ZyquoSpacing.md)
97 + .padding(.top, ZyquoSpacing.sm)
98 + .padding(.bottom, 2)
99 + ForEach(group.conversations) { conversation in
100 + ConversationRow(conversation: conversation)
101 + }
102 + }
103 + }
104 + .padding(.horizontal, ZyquoSpacing.xs)
105 + .padding(.bottom, ZyquoSpacing.sm)
106 + }
107 + }
108 +
109 + private var footer: some View {
110 + HStack {
111 + Button {
112 + SettingsOpener.open()
113 + } label: {
114 + Image(systemName: "gearshape")
115 + .font(.system(size: 13))
116 + .foregroundStyle(ZyquoColor.textSecondary)
117 + }
118 + .buttonStyle(.plain)
119 + .help("Settings")
120 + Spacer()
121 + Text(usageSummary)
122 + .font(ZyquoFont.caption)
123 + .foregroundStyle(ZyquoColor.textTertiary)
124 + }
125 + .padding(.horizontal, ZyquoMetrics.contentInset)
126 + .padding(.vertical, ZyquoSpacing.xs)
127 + }
128 +
129 + private var usageSummary: String {
130 + let tokens = store.conversations.map { $0.totalUsage.totalTokens }.reduce(0, +)
131 + let cost = store.conversations.map(\.totalCost).reduce(0, +)
132 + guard tokens > 0 else { return "" }
133 + let tokenText = tokens >= 1_000_000
134 + ? String(format: "%.1fM tok", Double(tokens) / 1_000_000)
135 + : String(format: "%.1fK tok", Double(tokens) / 1_000)
136 + return cost > 0 ? String(format: "%@ · ~$%.2f", tokenText, cost) : tokenText
137 + }
138 +}
139 +
140 +// MARK: - Row
141 +
142 +private struct ConversationRow: View {
143 + let conversation: Conversation
144 + @EnvironmentObject private var store: ConversationStore
145 + @State private var hovering = false
146 +
147 + private var isSelected: Bool { store.selectedID == conversation.id }
148 +
149 + var body: some View {
150 + Button {
151 + store.selectedID = conversation.id
152 + } label: {
153 + HStack(spacing: ZyquoSpacing.xs) {
154 + VStack(alignment: .leading, spacing: 1) {
155 + Text(conversation.title)
156 + .font(ZyquoFont.body(size: 13))
157 + .foregroundStyle(ZyquoColor.textPrimary)
158 + .lineLimit(1)
159 + HStack(spacing: ZyquoSpacing.xxs) {
160 + Text(conversation.modelID)
161 + .font(ZyquoFont.caption)
162 + .foregroundStyle(ZyquoColor.textTertiary)
163 + .lineLimit(1)
164 + Text(conversation.updatedAt, format: .relative(presentation: .named))
165 + .font(ZyquoFont.caption)
166 + .foregroundStyle(ZyquoColor.textTertiary)
167 + }
168 + }
169 + Spacer(minLength: 0)
170 + if hovering {
171 + rowActions
172 + } else if conversation.isPinned {
173 + Image(systemName: "pin.fill")
174 + .font(.system(size: 9))
175 + .foregroundStyle(ZyquoColor.textTertiary)
176 + }
177 + }
178 + .padding(.horizontal, ZyquoSpacing.xs)
179 + .padding(.vertical, 5)
180 + .background(
181 + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)
182 + .fill(isSelected ? ZyquoColor.accentSubtle : (hovering ? ZyquoColor.surfaceSecondary.opacity(0.6) : .clear))
183 + )
184 + .contentShape(Rectangle())
185 + }
186 + .buttonStyle(.plain)
187 + .onHover { inside in
188 + withAnimation(ZyquoMotion.hover) { hovering = inside }
189 + }
190 + .contextMenu {
191 + Button(conversation.isPinned ? "Unpin" : "Pin") { store.togglePin(conversation.id) }
192 + Button("Delete", role: .destructive) { store.delete(conversation.id) }
193 + }
194 + }
195 +
196 + private var rowActions: some View {
197 + HStack(spacing: ZyquoSpacing.xxs) {
198 + Button {
199 + store.togglePin(conversation.id)
200 + } label: {
201 + Image(systemName: conversation.isPinned ? "pin.slash" : "pin")
202 + .font(.system(size: 10))
203 + .foregroundStyle(ZyquoColor.textSecondary)
204 + }
205 + .buttonStyle(.plain)
206 + .help(conversation.isPinned ? "Unpin" : "Pin")
207 + Button {
208 + store.delete(conversation.id)
209 + } label: {
210 + Image(systemName: "trash")
211 + .font(.system(size: 10))
212 + .foregroundStyle(ZyquoColor.textSecondary)
213 + }
214 + .buttonStyle(.plain)
215 + .help("Delete")
216 + }
217 + }
218 +}
added Tests/ZyquoCloudTests/SyntaxHighlighterTests.swift +108 −0
@@ -0,0 +1,108 @@
1 +//
2 +// SyntaxHighlighterTests.swift
3 +// Zyquo Cloud
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Sanity tests for the code syntax highlighter and the Markdown block parser
9 +// (streaming resilience: incomplete input must never crash).
10 +//
11 +
12 +import Testing
13 +import SwiftUI
14 +@testable import ZyquoCloud
15 +
16 +@Suite("SyntaxHighlighter")
17 +struct SyntaxHighlighterTests {
18 + /// Foreground color of the first run whose text equals `token`.
19 + private func color(of token: String, in attributed: AttributedString) -> Color? {
20 + for run in attributed.runs where String(attributed[run.range].characters) == token {
21 + return run.foregroundColor
22 + }
23 + return nil
24 + }
25 +
26 + @Test func preservesTextExactly() {
27 + let samples: [(String, String)] = [
28 + ("swift", "func greet(name: String) -> String { return \"hi \\(name)\" } // done"),
29 + ("python", "def add(a, b):\n # sum\n return a + b"),
30 + ("javascript", "const x = `tpl ${y}`; /* block */"),
31 + ("sql", "SELECT id FROM users WHERE name = 'zy' -- lookup"),
32 + ("html", "<div class=\"box\"><!-- note --></div>"),
33 + ]
34 + for (language, code) in samples {
35 + let out = SyntaxHighlighter.highlight(code, language: language, baseColor: .primary)
36 + #expect(String(out.characters) == code)
37 + }
38 + }
39 +
40 + @Test func swiftKeywordsStringsAndCommentsAreColored() {
41 + let code = "let name = \"Zyquo\" // cloud"
42 + let out = SyntaxHighlighter.highlight(code, language: "swift", baseColor: .primary)
43 + #expect(color(of: "let", in: out) == SyntaxHighlighter.theme.keyword)
44 + #expect(color(of: "\"Zyquo\"", in: out) == SyntaxHighlighter.theme.string)
45 + #expect(color(of: "// cloud", in: out) == SyntaxHighlighter.theme.comment)
46 + }
47 +
48 + @Test func pythonCommentAndKeyword() {
49 + let out = SyntaxHighlighter.highlight("def run(): # go", language: "python", baseColor: .primary)
50 + #expect(color(of: "def", in: out) == SyntaxHighlighter.theme.keyword)
51 + #expect(color(of: "# go", in: out) == SyntaxHighlighter.theme.comment)
52 + }
53 +
54 + @Test func numbersAreColored() {
55 + let out = SyntaxHighlighter.highlight("x = 42", language: "python", baseColor: .primary)
56 + #expect(color(of: "42", in: out) == SyntaxHighlighter.theme.number)
57 + }
58 +
59 + @Test func sqlKeywordsAreCaseInsensitive() {
60 + let out = SyntaxHighlighter.highlight("select id from t", language: "sql", baseColor: .primary)
61 + #expect(color(of: "select", in: out) == SyntaxHighlighter.theme.keyword)
62 + let upper = SyntaxHighlighter.highlight("SELECT id FROM t", language: "sql", baseColor: .primary)
63 + #expect(color(of: "SELECT", in: upper) == SyntaxHighlighter.theme.keyword)
64 + }
65 +
66 + @Test func unknownLanguageIsUniformBaseColor() {
67 + let code = "let x = 1 // whatever"
68 + let out = SyntaxHighlighter.highlight(code, language: "brainfudge", baseColor: .red)
69 + #expect(String(out.characters) == code)
70 + for run in out.runs {
71 + #expect(run.foregroundColor == .red)
72 + }
73 + }
74 +
75 + @Test func survivesIncompleteInput() {
76 + // Unterminated string, unterminated block comment, empty input —
77 + // all must return the exact text without crashing (streaming case).
78 + let unterminatedString = "let s = \"never closed"
79 + let out1 = SyntaxHighlighter.highlight(unterminatedString, language: "swift", baseColor: .primary)
80 + #expect(String(out1.characters) == unterminatedString)
81 +
82 + let unterminatedComment = "int a; /* still going"
83 + let out2 = SyntaxHighlighter.highlight(unterminatedComment, language: "c", baseColor: .primary)
84 + #expect(String(out2.characters) == unterminatedComment)
85 +
86 + let empty = SyntaxHighlighter.highlight("", language: "swift", baseColor: .primary)
87 + #expect(String(empty.characters).isEmpty)
88 + }
89 +}
90 +
91 +@Suite("MarkdownBlockParser")
92 +struct MarkdownBlockParserTests {
93 + @Test func extractsFencedCodeBlock() {
94 + let blocks = MarkdownBlockParser.parse(text: "Hello\n\n```swift\nlet a = 1\n```", fontSize: 13.5)
95 + #expect(blocks.count == 2)
96 + guard case .code(let code, let language) = blocks[1].kind else {
97 + Issue.record("Expected a code block as the second block")
98 + return
99 + }
100 + #expect(code == "let a = 1")
101 + #expect(language == "swift")
102 + }
103 +
104 + @Test func survivesUnterminatedFenceWhileStreaming() {
105 + let blocks = MarkdownBlockParser.parse(text: "Intro\n\n```swift\nlet a =", fontSize: 13.5)
106 + #expect(!blocks.isEmpty)
107 + }
108 +}
modified docs/PLAN.md +21 −6
@@ -72,12 +72,27 @@ contract for all Phase 6 UI and the 4.4 quality gate is re-checked before "done"
72 72 pinned to macOS 26 SDK (`SDKROOT` in Makefile/test.sh) because CLT lacks Xcode's SwiftUIMacros
73 73 plugin required by SDK 27's macro-based @State.
74 74
75 ## Phase 5 — App Icon (in progress)
76
77 - [ ] `assets/icon/zyquo-cloud.svg` — Z+cloud fusion on Apple-squircle, sky gradient
78 - [ ] Render 16→1024, visually inspect, iterate until crisp at 16/32px (small-size variant if needed)
79 - [ ] `scripts/generate-icon.sh` → AppIcon.iconset → `Resources/AppIcon.icns`
80 - [ ] Menu bar template icon (18pt, isTemplate) + wordmark/empty-state glyph derivation
75 +## Phase 5 — App Icon ✅ (completed 2026-07-30)
76 +
77 +- [x] `assets/icon/zyquo-cloud.svg` — white geometric cloud + luminous-beam Z on a true superellipse
78 + squircle (n=4.6, computed path), sky gradient #82B4FF→#4E6AF0→#3A3F9E, soft top light, two
79 + distant parallax cloud layers; no SVG filters (CoreSVG-safe)
80 +- [x] Rendered 16→1024 and visually inspected; iterated (lightened sky; small sizes were muddy →
81 + dedicated `zyquo-cloud-small.svg`: bigger cloud, 86px Z stroke, flat fills — used for 16/32 slots)
82 +- [x] `scripts/generate-icon.sh` → iconset (small variant for ≤64px slots) → `Resources/AppIcon.icns`,
83 + embedded in the bundle by `make dev`/`release`; rasterizer: `scripts/rasterize-svg.swift` (AppKit)
84 +- [x] Menu bar template glyph `zyquo-cloud-template.svg` (cloud silhouette, Z knocked out via mask) →
85 + `Resources/MenuBarIcon(@2x).png`; in-app wordmark/empty-state glyph will draw the same geometry
86 + as a SwiftUI Shape in Phase 6
87 +
88 +**Phase 5 checkpoint summary:** Icon is crisp and on-identity at every size (verified visually at
89 +1024/512/256/128/64/32/16). SVG sources are the single source of truth in `assets/icon/`.
90 +
91 +## Phase 6 — Features (in progress)
92 +
93 +Build order (compile after each): stores → chat engine → main window (sidebar/transcript/input) →
94 +markdown+code rendering → settings → model picker/compare/quick chat → productivity (templates,
95 +personas, export, search, titles) → menu bar extra + shortcuts.
81 96 ## Phase 2 — Architecture (pending)
82 97 ## Phase 3 — SecureKeyStore (pending)
83 98 ## Phase 4 — Design System (pending)
84 99