From dadd176903daf6fb1ebd8f8e79f8ad1c7aafd9cc Mon Sep 17 00:00:00 2001 From: ojowwalker77 Date: Tue, 30 Jun 2026 15:08:23 -0300 Subject: [PATCH 01/10] feat(agent): model pickers for chat and board description Add a per-surface Claude model choice, passed to `claude --model`: - Chat (CanvasAgent) defaults to Opus; picker in the Agent panel header. - Describe board (HeadlessPromptService.describeBoard) defaults to Sonnet. Both bind one UserDefaults key each (ModelPreferences), so the Agent-dock picker and the Settings > Runtime > Models pickers mirror live. The rawValue is the CLI alias (opus/sonnet/haiku) so the CLI resolves the latest in-tier. Refine and Compile stay on the CLI default. --- CHANGELOG.md | 7 +++ .../ComposerApp/Models/ComposerModels.swift | 30 +++++++++++ .../ComposerApp/Services/CanvasAgent.swift | 6 ++- .../Services/HeadlessPromptService.swift | 12 +++-- .../Support/ModelPreferences.swift | 25 ++++++++++ Sources/ComposerApp/Views/AgentDock.swift | 32 ++++++++++++ .../ComposerApp/Views/ComposerCanvas.swift | 2 +- Sources/ComposerApp/Views/SettingsView.swift | 50 +++++++++++++++++++ docs/agent-engines.md | 13 +++++ 9 files changed, 170 insertions(+), 7 deletions(-) create mode 100644 Sources/ComposerApp/Support/ModelPreferences.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 9570477..0be0da2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,13 @@ under the new version heading. ## [Unreleased] +### Added +- **Model pickers for the agent chat and board description.** Choose which Claude model each runs on + (Opus / Sonnet / Haiku). The chat picker lives in the Agent panel header and mirrors a matching + control in **Settings ▸ Runtime ▸ Models**; describing the board has its own picker in the same + place. Chat defaults to **Opus**, describe defaults to **Sonnet**. The choice is passed to + `claude --model`; Refine and Compile stay on the CLI default. + ## [1.2.0] - 2026-06-30 ### Added diff --git a/Sources/ComposerApp/Models/ComposerModels.swift b/Sources/ComposerApp/Models/ComposerModels.swift index e13f62a..451cf69 100644 --- a/Sources/ComposerApp/Models/ComposerModels.swift +++ b/Sources/ComposerApp/Models/ComposerModels.swift @@ -34,3 +34,33 @@ enum HeadlessEngine: String, Codable, CaseIterable, Identifiable, Hashable, Send } } } + +/// A Claude model the headless `claude` CLI can target via `--model`. The rawValue is the CLI +/// *alias* (`opus` / `sonnet` / `haiku`), which the CLI resolves to the latest model in that tier — +/// so this never pins a dated snapshot and never ships its own model. The two surfaces that pick a +/// model default differently: the in-canvas chat agent defaults to Opus, describing the board +/// defaults to Sonnet. See [[ModelPreferences]] and docs/agent-engines.md. +enum ClaudeModel: String, Codable, CaseIterable, Identifiable, Hashable, Sendable { + case opus + case sonnet + case haiku + + var id: String { rawValue } + /// Passed verbatim as the value of `claude --model`. + var cliAlias: String { rawValue } + var title: String { + switch self { + case .opus: "Opus" + case .sonnet: "Sonnet" + case .haiku: "Haiku" + } + } + /// A one-line tier hint shown beneath the name in a picker. + var tagline: String { + switch self { + case .opus: "Most capable" + case .sonnet: "Balanced" + case .haiku: "Fastest" + } + } +} diff --git a/Sources/ComposerApp/Services/CanvasAgent.swift b/Sources/ComposerApp/Services/CanvasAgent.swift index ede734c..b260ed9 100644 --- a/Sources/ComposerApp/Services/CanvasAgent.swift +++ b/Sources/ComposerApp/Services/CanvasAgent.swift @@ -87,7 +87,8 @@ final class CanvasAgent: ObservableObject { runToken &+= 1 let token = runToken let resume = sessionID - Task { await run(prompt: prompt, resume: resume, token: token) } + let model = ModelPreferences.chatModel + Task { await run(prompt: prompt, resume: resume, token: token, model: model) } } func stop() { @@ -107,7 +108,7 @@ final class CanvasAgent: ObservableObject { // MARK: Run one turn - private func run(prompt: String, resume: String?, token: Int) async { + private func run(prompt: String, resume: String?, token: Int, model: ClaudeModel) async { // Write back coarse state only while this turn is still the current one — a stop() or a newer // send() bumps `runToken`, after which this (now superseded) turn must leave shared state alone. func finish(_ work: () -> Void) { @@ -136,6 +137,7 @@ final class CanvasAgent: ObservableObject { // CLI hit a silent wall in `-p` mode - the model used to invent a non-existent "approve in the // app" popup (issue #28). The arbiter tool is intentionally not in `--allowedTools`. var args = ["-p", prompt, + "--model", model.cliAlias, "--output-format", "stream-json", "--verbose", "--mcp-config", mcp, "--allowedTools", tools, diff --git a/Sources/ComposerApp/Services/HeadlessPromptService.swift b/Sources/ComposerApp/Services/HeadlessPromptService.swift index 41721f2..01bb49a 100644 --- a/Sources/ComposerApp/Services/HeadlessPromptService.swift +++ b/Sources/ComposerApp/Services/HeadlessPromptService.swift @@ -47,26 +47,30 @@ struct HeadlessPromptService { /// self-contained, paste-ready brief. `state` is the board graph JSON (the same snapshot the /// canvas MCP `get_canvas` exposes); unlike `compileBoard` (which merges card prose) this reads /// the full graph, so the description covers everything the board holds. - func describeBoard(state: String, engine: HeadlessEngine) async throws -> String { + func describeBoard(state: String, engine: HeadlessEngine, model: ClaudeModel) async throws -> String { let prompt = """ \(BoardDescribe.instruction) ===== BOARD STATE (JSON graph: nodes, edges, reading order) ===== \(state) """ - return try await run(prompt: prompt, engine: engine) + return try await run(prompt: prompt, engine: engine, model: model) } - private func run(prompt: String, engine: HeadlessEngine) async throws -> String { + /// `model` is optional: when nil the CLI picks its own default (used by Refine / Compile); + /// Describe passes the user's chosen `ClaudeModel` so it can run on a different tier. + private func run(prompt: String, engine: HeadlessEngine, model: ClaudeModel? = nil) async throws -> String { guard let executable = CommandLineToolLocator.executableURL(for: engine) else { throw HeadlessPromptError.failed("\(engine.title) CLI is not installed. Check Settings to install or re-detect it.") } - let arguments: [String] + var arguments: [String] switch engine { case .claude: arguments = [executable.path, "-p", prompt] + if let model { arguments += ["--model", model.cliAlias] } case .codex: // Read-only sandbox: one-shot refine/compile must not mutate the user's repo. + // `model` is Claude-only (a `claude --model` alias), so Codex ignores it. arguments = [executable.path, "exec", "--sandbox", "read-only", "--ephemeral", prompt] } let result: Shell.Result diff --git a/Sources/ComposerApp/Support/ModelPreferences.swift b/Sources/ComposerApp/Support/ModelPreferences.swift new file mode 100644 index 0000000..e734499 --- /dev/null +++ b/Sources/ComposerApp/Support/ModelPreferences.swift @@ -0,0 +1,25 @@ +import Foundation + +/// Which Claude model each headless surface targets. Two independent choices, each persisted as a +/// `ClaudeModel` rawValue in UserDefaults so the picker in the Agent dock and the one in +/// Settings ▸ Runtime stay mirrored — both `@AppStorage`-bind the same key. +/// +/// - `chat` backs the in-canvas agent (`CanvasAgent`); default **Opus**. +/// - `describe` backs the "describe board" copy action (`HeadlessPromptService.describeBoard`); +/// default **Sonnet**. +/// +/// Refine and Compile deliberately stay on the CLI's own default model and are not covered here. +enum ModelPreferences { + static let chatModelKey = "model.chat" + static let describeModelKey = "model.describe" + + static let defaultChatModel: ClaudeModel = .opus + static let defaultDescribeModel: ClaudeModel = .sonnet + + static var chatModel: ClaudeModel { stored(chatModelKey) ?? defaultChatModel } + static var describeModel: ClaudeModel { stored(describeModelKey) ?? defaultDescribeModel } + + private static func stored(_ key: String) -> ClaudeModel? { + UserDefaults.standard.string(forKey: key).flatMap(ClaudeModel.init(rawValue:)) + } +} diff --git a/Sources/ComposerApp/Views/AgentDock.swift b/Sources/ComposerApp/Views/AgentDock.swift index d29fa76..3cbad74 100644 --- a/Sources/ComposerApp/Views/AgentDock.swift +++ b/Sources/ComposerApp/Views/AgentDock.swift @@ -9,6 +9,9 @@ struct AgentDock: View { var onClose: () -> Void @State private var draft = "" @FocusState private var inputFocused: Bool + /// The model the agent runs on. Shares its key with the Settings ▸ Runtime picker, so the two + /// always read back the same value (see [[ModelPreferences]]); `CanvasAgent` reads it at send. + @AppStorage(ModelPreferences.chatModelKey) private var chatModel: ClaudeModel = ModelPreferences.defaultChatModel /// Keep the grounding pill compact: at most 8 characters, then an ellipsis. static func trimmed(_ name: String) -> String { @@ -40,6 +43,7 @@ struct AgentDock: View { Text("Agent").font(.body.weight(.semibold)).foregroundStyle(Theme.Palette.body) if agent.isRunning { ProgressView().controlSize(.small).scaleEffect(0.62) } Spacer(minLength: 8) + modelControl groundingControl HStack(spacing: 2) { iconButton("arrow.counterclockwise", help: "New conversation") { agent.reset(); draft = "" } @@ -83,6 +87,34 @@ struct AgentDock: View { } } + /// A quiet capsule menu mirroring the grounding pill: tap to switch which Claude model the agent + /// runs on. The checkmark menu makes the current pick obvious; the label stays compact. + private var modelControl: some View { + Menu { + Picker("Model", selection: $chatModel) { + ForEach(ClaudeModel.allCases) { model in + Text(model.title).tag(model) + } + } + } label: { + HStack(spacing: 5) { + Image(systemName: "cpu").font(.system(size: 10.5)) + Text(chatModel.title).font(.caption.weight(.medium)).lineLimit(1).fixedSize() + Image(systemName: "chevron.up.chevron.down").font(.system(size: 7, weight: .semibold)) + } + .foregroundStyle(Theme.Palette.body) + .padding(.horizontal, 9).frame(height: 24) + .background(Capsule().fill(Color.white.opacity(0.08))) + .overlay(Capsule().strokeBorder(Color.white.opacity(0.10), lineWidth: 1)) + .contentShape(Capsule()) + } + .menuStyle(.button) + .buttonStyle(.plain) + .menuIndicator(.hidden) + .fixedSize() + .help("Model for the agent chat — mirrors Settings ▸ Runtime") + } + // MARK: Input private var inputBar: some View { diff --git a/Sources/ComposerApp/Views/ComposerCanvas.swift b/Sources/ComposerApp/Views/ComposerCanvas.swift index 4d0737b..6573925 100644 --- a/Sources/ComposerApp/Views/ComposerCanvas.swift +++ b/Sources/ComposerApp/Views/ComposerCanvas.swift @@ -947,7 +947,7 @@ struct ComposerCanvas: View { show(Toast(text: "Describing board\u{2026}", symbol: "doc.on.doc", tint: .accentColor)) Task { do { - let description = try await service.describeBoard(state: state, engine: engine) + let description = try await service.describeBoard(state: state, engine: engine, model: ModelPreferences.describeModel) if copyToClipboard(description) { show(Toast(text: "Copied board description", symbol: "doc.on.doc.fill", tint: .accentColor)) } else { diff --git a/Sources/ComposerApp/Views/SettingsView.swift b/Sources/ComposerApp/Views/SettingsView.swift index 0deb614..bde14e2 100644 --- a/Sources/ComposerApp/Views/SettingsView.swift +++ b/Sources/ComposerApp/Views/SettingsView.swift @@ -152,6 +152,10 @@ private struct SettingsContent: View { @ObservedObject private var shortcutStore = ShortcutStore.shared @AppStorage(EnginePreferences.claudeEnabledKey) private var claudeEnabled = true @AppStorage(EnginePreferences.codexEnabledKey) private var codexEnabled = true + // Both keys are shared with their in-canvas pickers (the Agent dock for chat), so the controls + // mirror each other live. See [[ModelPreferences]]. + @AppStorage(ModelPreferences.chatModelKey) private var chatModel: ClaudeModel = ModelPreferences.defaultChatModel + @AppStorage(ModelPreferences.describeModelKey) private var describeModel: ClaudeModel = ModelPreferences.defaultDescribeModel @AppStorage(ComposerPreferences.panelTransparencyKey) private var panelTransparency = ComposerPreferences.defaultPanelTransparency @AppStorage(ComposerPreferences.resolveShellAtCopyKey) private var resolveShellAtCopy = false /// Whether the agent has standing "Always Allow" tool grants - drives the reset control's @@ -244,6 +248,8 @@ private struct SettingsContent: View { } } + modelsCard + Label("CLI prompts stay on your configured agent accounts. Apple Intelligence runs the on-device lint and never sends a draft off your Mac.", systemImage: "lock.fill") .font(.caption) .foregroundStyle(Theme.Palette.count) @@ -282,6 +288,50 @@ private struct SettingsContent: View { .onAppear { agentHasGrants = AgentPermissionBroker.hasRememberedGrants } } + /// Per-surface model choice. Chat mirrors the Agent dock's picker (same key); Describe is the only + /// place to set the model the board-description copy runs on. Refine/Compile aren't listed — they + /// stay on the CLI default deliberately. + private var modelsCard: some View { + VStack(alignment: .leading, spacing: 8) { + Text("MODELS").sectionLabel() + VStack(spacing: 0) { + modelRow( + title: "Agent chat", + subtitle: "The in-canvas agent you talk to. Mirrors the picker in the Agent panel.", + selection: $chatModel) + Divider().overlay(Theme.Palette.separator) + modelRow( + title: "Describe board", + subtitle: "The toolbar copy that summarizes the whole board into a paste-ready brief.", + selection: $describeModel) + } + .padding(.horizontal, 13) + .settingsCard() + } + } + + private func modelRow(title: String, subtitle: String, selection: Binding) -> some View { + HStack(spacing: 11) { + VStack(alignment: .leading, spacing: 2) { + Text(title).font(.callout.weight(.medium)).foregroundStyle(Theme.Palette.body) + Text(subtitle) + .font(.caption).foregroundStyle(Theme.Palette.menuDesc) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 8) + Picker("", selection: selection) { + ForEach(ClaudeModel.allCases) { model in + Text(model.title).tag(model) + } + } + .labelsHidden() + .pickerStyle(.menu) + .fixedSize() + .tint(Theme.Palette.body) + } + .padding(.vertical, 11) + } + /// A live count of what's ready, in the mono "instrument" voice. One status dot, neutral capsule. private func readout(ready: Int, total: Int) -> some View { HStack(spacing: 6) { diff --git a/docs/agent-engines.md b/docs/agent-engines.md index 75990a7..114be07 100644 --- a/docs/agent-engines.md +++ b/docs/agent-engines.md @@ -57,6 +57,12 @@ engine's invocation (and any **headless/non-interactive flags** it needs) is one new `case` here. Failure handling is deliberately blunt: a non-zero exit becomes the trimmed stderr surfaced as a toast; empty stdout is treated as failure too. +One of these — **Describe board** (the toolbar copy that summarizes the whole +board graph) — passes its own `--model`, read from +[`ModelPreferences`](../Sources/ComposerApp/Support/ModelPreferences.swift) +(default Sonnet, picked in Settings ▸ Runtime ▸ Models). Refine and Compile pass +no `--model` and stay on the CLI's own default. + ### Path 2 — streaming canvas agent ([`CanvasAgent`](../Sources/ComposerApp/Services/CanvasAgent.swift)) This is the conversational agent in the dock. Each turn spawns `claude` in @@ -65,6 +71,7 @@ and reshape the board live while it talks. The invocation: ```text claude -p "" + --model # the chat model; default opus --output-format stream-json --verbose --mcp-config '{"mcpServers":{"canvas":{"type":"http","url":"http://127.0.0.1:7337/mcp"}}}' --allowedTools "mcp__canvas__*" # + ,Read,Grep,Glob when grounded @@ -74,6 +81,12 @@ claude -p "" What each piece buys us: +- **`--model`** — which Claude model the chat runs on, read from + [`ModelPreferences`](../Sources/ComposerApp/Support/ModelPreferences.swift) at + send time (default Opus). It's a CLI *alias* (`opus` / `sonnet` / `haiku`), so + the CLI resolves it to the latest model in that tier — BonsAI never pins a + dated snapshot. The picker lives in the Agent dock header and mirrors the one + in Settings ▸ Runtime ▸ Models (both bind the same `UserDefaults` key). - **`--output-format stream-json --verbose`** — the agent emits one JSON object per line (`system` / `assistant` / `result`). `handleLine(_:)` parses those into the chat transcript: assistant `text` becomes a reply, `tool_use` becomes From 145102ad333d669a2335a8117a21120f72e98e6b Mon Sep 17 00:00:00 2001 From: ojowwalker77 Date: Tue, 30 Jun 2026 16:15:59 -0300 Subject: [PATCH 02/10] feat(agent-skills): install canvas-API skill for Claude Code, Codex, and Cursor The bonsai-board skill previously only existed as a local Claude Code skill file living outside the repo. Bundle the canvas API doc for three agents and offer to install it on first launch (only for agents detected on the Mac), with a reinstall/install-more control in Settings > Connectors > Agent Skills. Claude Code and Cursor get a dedicated file; Codex's AGENTS.md is shared with the user's own notes, so it's merged into a marked section instead of overwritten. --- CHANGELOG.md | 5 + Sources/ComposerApp/App/AppDelegate.swift | 32 ++++ .../AgentSkills/claude-code-SKILL.md | 81 +++++++++ .../Resources/AgentSkills/codex-AGENTS.md | 30 ++++ .../AgentSkills/cursor-bonsai-board.mdc | 35 ++++ .../Services/AgentSkillsInstaller.swift | 158 ++++++++++++++++++ Sources/ComposerApp/Views/SettingsView.swift | 63 +++++++ .../AgentSkillsInstallerTests.swift | 67 ++++++++ 8 files changed, 471 insertions(+) create mode 100644 Sources/ComposerApp/Resources/AgentSkills/claude-code-SKILL.md create mode 100644 Sources/ComposerApp/Resources/AgentSkills/codex-AGENTS.md create mode 100644 Sources/ComposerApp/Resources/AgentSkills/cursor-bonsai-board.mdc create mode 100644 Sources/ComposerApp/Services/AgentSkillsInstaller.swift create mode 100644 Tests/ComposerAppTests/AgentSkillsInstallerTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 0be0da2..c11adeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ under the new version heading. ## [Unreleased] ### Added +- **Agent skills for any coding agent.** BonsAI's canvas API (`127.0.0.1:7337`) now ships a portable + skill doc, not just a Claude Code skill. On first launch, if Claude Code, Codex CLI, and/or Cursor + are detected on the Mac, BonsAI offers to install the matching doc into each one's own config + location, so any of them can read and write the board over HTTP. Reinstall or add more anytime + from **Settings ▸ Connectors ▸ Agent Skills**. - **Model pickers for the agent chat and board description.** Choose which Claude model each runs on (Opus / Sonnet / Haiku). The chat picker lives in the Agent panel header and mirrors a matching control in **Settings ▸ Runtime ▸ Models**; describing the board has its own picker in the same diff --git a/Sources/ComposerApp/App/AppDelegate.swift b/Sources/ComposerApp/App/AppDelegate.swift index 068354d..2bfe564 100644 --- a/Sources/ComposerApp/App/AppDelegate.swift +++ b/Sources/ComposerApp/App/AppDelegate.swift @@ -15,6 +15,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { _ = UpdaterController.shared MentionStyleCache.shared.preload() CanvasServer.shared.start() + promptForAgentSkillsIfNeeded() NSApp.servicesProvider = self @@ -59,6 +60,37 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } + /// First launch only: if a coding agent we know how to teach (Claude Code, Codex CLI, Cursor) is + /// detected on this Mac, offer to install the canvas-API doc into its config so it can drive the + /// board over `127.0.0.1:7337` without the user hand-rolling curl commands. Silent no-op if none + /// are detected, or if the user has already been asked once — re-installs live in Settings ▸ + /// Connectors instead of re-prompting every launch. + private func promptForAgentSkillsIfNeeded() { + let promptedKey = "app.agentSkills.hasPrompted" + guard !UserDefaults.standard.bool(forKey: promptedKey) else { return } + let detected = AgentSkillTarget.allCases.filter(\.isDetected) + guard !detected.isEmpty else { return } + + UserDefaults.standard.set(true, forKey: promptedKey) + + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = "Teach your coding agent the BonsAI board?" + let names = detected.map(\.displayName).joined(separator: ", ") + alert.informativeText = "BonsAI found \(names) on this Mac. Install a short skill doc so it knows how to read and write your board over the local canvas API? You can redo this anytime in Settings ▸ Connectors." + alert.addButton(withTitle: "Install") + alert.addButton(withTitle: "Not Now") + guard alert.runModal() == .alertFirstButtonReturn else { return } + + let failures = AgentSkillsInstaller.installAllDetected() + guard !failures.isEmpty else { return } + let failure = NSAlert() + failure.alertStyle = .warning + failure.messageText = "Couldn't install for everyone" + failure.informativeText = failures.map { "\($0.key.displayName): \($0.value.localizedDescription)" }.joined(separator: "\n") + failure.runModal() + } + /// Summon the board (if hidden) and open its companion Settings window. func showSettings() { panelController.show() diff --git a/Sources/ComposerApp/Resources/AgentSkills/claude-code-SKILL.md b/Sources/ComposerApp/Resources/AgentSkills/claude-code-SKILL.md new file mode 100644 index 0000000..13c1137 --- /dev/null +++ b/Sources/ComposerApp/Resources/AgentSkills/claude-code-SKILL.md @@ -0,0 +1,81 @@ +--- +name: bonsai-board +description: Write to (or read) the user's BonsAI board — the spatial idea canvas in the BonsAI macOS app. Use when the user asks to put / drop / add / send something to their BonsAI board, capture an idea or note on the board, sketch a diagram or architecture onto the canvas, evolve an idea already on the board, or read what's currently on it. Works from any repo or directory: BonsAI runs a loopback-only canvas server on 127.0.0.1:7337. Requires the BonsAI app to be running with a board open. +--- + +# Writing to the BonsAI board + +BonsAI is a spatial idea canvas (a macOS app). It exposes a tiny **loopback-only** HTTP +server on `http://127.0.0.1:7337` so any local process — including this agent session — can +read and shape the live board. Your writes appear on screen instantly and are tagged as +agent-authored (`whoWrote: 2`), so the user can tell your cards from theirs. + +The board you write to is **whichever board is currently open** in BonsAI — there is no +board addressing. + +## Before you write: check it's reachable + +```bash +curl -s -m 3 http://127.0.0.1:7337/health +``` + +- `{"ok":true,...}` → good, proceed. +- Connection refused / timeout → **BonsAI isn't running.** Tell the user to open it; do not retry in a loop. +- A write that returns `{"ok":false,"error":"no active canvas"}` → BonsAI is running but no + board is open/registered. Ask the user to open a board. + +## Reading the board + +```bash +curl -s http://127.0.0.1:7337/canvas +``` + +Returns `{ nodes, edges, readingOrder }`. Each node has `id`, `kind`, `text`, `x/y/w/h`, and +`whoWrote` (**1 = the human wrote/edited it, 2 = you drew it, 0 = unknown**). Read the board +before acting on one you've touched before — `whoWrote: 1` nodes are exactly what the human +added or changed since you last looked. + +## Writing: POST one op to `/canvas` + +Every mutation is a single JSON object `{"op": "...", ...}` POSTed to `/canvas`. It returns +`{"ok": true, ...}` (often with the new `id`) or `{"ok": false, "error": "..."}`. + +```bash +curl -s -X POST http://127.0.0.1:7337/canvas \ + --data-binary '{"op":"add_text","text":"Ship the loopback skill"}' +``` + +For text with quotes, newlines, or any length, write the JSON payload to a file first so +escaping is correct, then send it. + +### Op vocabulary + +| op | required | optional | returns | +|----|----------|----------|---------| +| `add_text` | `text` | `x`, `y` | `id` | +| `add_shape` | `kind` (`rectangle`\|`ellipse`\|`diamond`\|`line`\|`arrow`) | `w`, `h`, `x`, `y` | `id` | +| `create_diagram` | `nodes` | `edges`, `direction` | `nodes` (key→id map) | +| `relayout` | — | `direction` | — | +| `update_text` | `id`, `text` | — | — | +| `move` | `id` | `x`, `y` | — | +| `resize` | `id` | `w`, `h` | — | +| `delete` | `id` | — | — | +| `connect` | `from`, `to` (node ids) | `reason` (becomes arrow label) | `id` | +| `set_archived` | `id` | `archived` (bool, default true) | — | +| `supersede` | `id`, `text`, `reason` | — | `id` | + +`direction` is `"down"` (default) or `"right"`. + +Prefer `create_diagram` for any structure (architecture, flow, tree, comparison, decision +graph) — declare nodes + edges in one call and let the board lay it out. Never invent x/y to +place cards yourself. + +When an approach changes, call `supersede` instead of silently overwriting — it fades the +old card, adds the new one below, and links them with a `reason`. + +## Etiquette + +- Keep cards concise; the canvas holds the detail, not long prose. +- Read before mutating a board you've touched; respect `whoWrote: 1` cards. +- One op per request. Batch related cards with `create_diagram` rather than many `add_text`. +- If the server is unreachable, say so once and stop — don't spin retrying. diff --git a/Sources/ComposerApp/Resources/AgentSkills/codex-AGENTS.md b/Sources/ComposerApp/Resources/AgentSkills/codex-AGENTS.md new file mode 100644 index 0000000..38936ab --- /dev/null +++ b/Sources/ComposerApp/Resources/AgentSkills/codex-AGENTS.md @@ -0,0 +1,30 @@ +## BonsAI canvas API + +BonsAI is a spatial idea board (macOS app). When it's running, it exposes a **loopback-only** +HTTP server at `http://127.0.0.1:7337` for reading and writing the board that's currently +open. Use this whenever the user asks to put/drop/add something on their BonsAI board, +sketch a diagram onto the canvas, or read what's on it. + +Check it's up first: `curl -s -m 3 http://127.0.0.1:7337/health` — if that fails to connect, +BonsAI isn't running; tell the user and don't retry in a loop. + +Read the board: `curl -s http://127.0.0.1:7337/canvas` → `{ nodes, edges, readingOrder }`. +Each node has `whoWrote` (1 = human, 2 = agent, 0 = unknown) — treat `whoWrote: 1` nodes as +the human's latest input. + +Write by POSTing one JSON op per request to `/canvas`: + +```bash +curl -s -X POST http://127.0.0.1:7337/canvas \ + --data-binary '{"op":"add_text","text":"Ship the loopback skill"}' +``` + +Ops: `add_text {text,x?,y?}`, `add_shape {kind: rectangle|ellipse|diamond|line|arrow, w?,h?,x?,y?}`, +`create_diagram {nodes,edges?,direction?}` (preferred for any structure — don't hand-place x/y +yourself), `relayout {direction?}`, `update_text {id,text}`, `move {id,x,y}`, `resize {id,w,h}`, +`delete {id}`, `connect {from,to,reason?}`, `set_archived {id,archived?}`, +`supersede {id,text,reason}` (use this instead of overwriting when an idea evolves — it keeps +the old card, fades it, and links the new one with the reason). + +Keep cards short — the canvas holds detail, not paragraphs. Batch related cards with +`create_diagram` rather than many sequential `add_text` calls. diff --git a/Sources/ComposerApp/Resources/AgentSkills/cursor-bonsai-board.mdc b/Sources/ComposerApp/Resources/AgentSkills/cursor-bonsai-board.mdc new file mode 100644 index 0000000..88f1de2 --- /dev/null +++ b/Sources/ComposerApp/Resources/AgentSkills/cursor-bonsai-board.mdc @@ -0,0 +1,35 @@ +--- +description: BonsAI canvas API — read and write the user's spatial idea board over a local loopback HTTP server +globs: +alwaysApply: false +--- + +# BonsAI canvas API + +BonsAI is a spatial idea board (macOS app). When it's running, it exposes a **loopback-only** +HTTP server at `http://127.0.0.1:7337` for reading and writing the board that's currently +open. Use this whenever asked to put/drop/add something on the BonsAI board, sketch a +diagram onto the canvas, or read what's on it. + +Check it's up first: `curl -s -m 3 http://127.0.0.1:7337/health` — if that fails to connect, +BonsAI isn't running; say so and don't retry in a loop. + +Read the board: `curl -s http://127.0.0.1:7337/canvas` → `{ nodes, edges, readingOrder }`. +Each node has `whoWrote` (1 = human, 2 = agent, 0 = unknown) — treat `whoWrote: 1` nodes as +the human's latest input. + +Write by POSTing one JSON op per request to `/canvas`: + +```bash +curl -s -X POST http://127.0.0.1:7337/canvas \ + --data-binary '{"op":"add_text","text":"Ship the loopback skill"}' +``` + +Ops: `add_text {text,x?,y?}`, `add_shape {kind: rectangle|ellipse|diamond|line|arrow, w?,h?,x?,y?}`, +`create_diagram {nodes,edges?,direction?}` (preferred for any structure — don't hand-place x/y +yourself), `relayout {direction?}`, `update_text {id,text}`, `move {id,x,y}`, `resize {id,w,h}`, +`delete {id}`, `connect {from,to,reason?}`, `set_archived {id,archived?}`, +`supersede {id,text,reason}` (use instead of overwriting when an idea evolves). + +Keep cards short — the canvas holds detail, not paragraphs. Batch related cards with +`create_diagram` rather than many sequential `add_text` calls. diff --git a/Sources/ComposerApp/Services/AgentSkillsInstaller.swift b/Sources/ComposerApp/Services/AgentSkillsInstaller.swift new file mode 100644 index 0000000..b8b7b75 --- /dev/null +++ b/Sources/ComposerApp/Services/AgentSkillsInstaller.swift @@ -0,0 +1,158 @@ +import Foundation + +/// A coding agent that can be taught the BonsAI canvas API (`127.0.0.1:7337`). Each case maps to a +/// bundled doc in `Resources/AgentSkills/` and the on-disk location that agent reads instructions from. +enum AgentSkillTarget: String, CaseIterable, Identifiable { + case claudeCode + case codex + case cursor + + var id: String { rawValue } + + var displayName: String { + switch self { + case .claudeCode: "Claude Code" + case .codex: "Codex CLI" + case .cursor: "Cursor" + } + } + + var symbol: String { + switch self { + case .claudeCode: "sparkle" + case .codex: "terminal" + case .cursor: "cursorarrow.rays" + } + } + + /// The directory whose presence implies the tool is installed, so a fresh BonsAI install only + /// offers to wire up agents the user actually has — not every dotfile under the sun. + fileprivate var markerDirectory: URL { + let home = FileManager.default.homeDirectoryForCurrentUser + switch self { + case .claudeCode: return home.appendingPathComponent(".claude", isDirectory: true) + case .codex: return home.appendingPathComponent(".codex", isDirectory: true) + case .cursor: return home.appendingPathComponent(".cursor", isDirectory: true) + } + } + + var isDetected: Bool { + var isDirectory: ObjCBool = false + let exists = FileManager.default.fileExists(atPath: markerDirectory.path, isDirectory: &isDirectory) + return exists && isDirectory.boolValue + } + + fileprivate var resourceName: String { + switch self { + case .claudeCode: return "claude-code-SKILL" + case .codex: return "codex-AGENTS" + case .cursor: return "cursor-bonsai-board" + } + } + + fileprivate var resourceExtension: String { + switch self { + case .claudeCode: return "md" + case .codex: return "md" + case .cursor: return "mdc" + } + } + + /// Whether `destinationURL` is a file BonsAI owns outright (safe to overwrite) or one shared with + /// the user's own content (must be merged — see `AgentSkillsInstaller.mergeMarkedSection`). + fileprivate var ownsDestinationFile: Bool { + switch self { + case .claudeCode, .cursor: return true + case .codex: return false + } + } + + fileprivate var destinationURL: URL { + let home = FileManager.default.homeDirectoryForCurrentUser + switch self { + case .claudeCode: + return home.appendingPathComponent(".claude/skills/bonsai-board/SKILL.md") + case .codex: + // AGENTS.md is Codex's shared instructions file — it may already hold the user's own + // project notes, so the installer merges a marked section instead of replacing it. + return home.appendingPathComponent(".codex/AGENTS.md") + case .cursor: + return home.appendingPathComponent(".cursor/rules/bonsai-board.mdc") + } + } + + /// Whether the skill is already on disk at the expected location for this agent. + var isInstalled: Bool { + FileManager.default.fileExists(atPath: destinationURL.path) + } +} + +enum AgentSkillsInstallerError: LocalizedError { + case missingBundledResource(AgentSkillTarget) + + var errorDescription: String? { + switch self { + case .missingBundledResource(let target): + return "BonsAI's bundled skill file for \(target.displayName) is missing." + } + } +} + +/// Installs the BonsAI canvas-API doc into the config locations coding agents (Claude Code, Codex +/// CLI, Cursor) read on their own. This is the cross-agent counterpart to the Claude Code-only +/// `bonsai-board` skill: any agent that can read a local file and run `curl` can drive the board. +enum AgentSkillsInstaller { + private static let beginMarker = "" + private static let endMarker = "" + + static func install(_ target: AgentSkillTarget) throws { + guard let resourceURL = Bundle.appResources.url( + forResource: target.resourceName, + withExtension: target.resourceExtension, + subdirectory: "AgentSkills" + ) else { + throw AgentSkillsInstallerError.missingBundledResource(target) + } + + let payload = try String(contentsOf: resourceURL, encoding: .utf8) + let destination = target.destinationURL + try FileManager.default.createDirectory( + at: destination.deletingLastPathComponent(), withIntermediateDirectories: true) + + if target.ownsDestinationFile { + try payload.write(to: destination, atomically: true, encoding: .utf8) + } else { + try mergeMarkedSection(payload, into: destination) + } + } + + /// Installs into every detected agent, returning per-target failures (empty on full success). + @discardableResult + static func installAllDetected() -> [AgentSkillTarget: Error] { + var failures: [AgentSkillTarget: Error] = [:] + for target in AgentSkillTarget.allCases where target.isDetected { + do { try install(target) } catch { failures[target] = error } + } + return failures + } + + /// Replaces BonsAI's marked section in a shared instructions file (e.g. Codex's `AGENTS.md`) + /// without touching anything the user wrote outside the markers. `internal` (not `private`) so + /// `AgentSkillsInstallerTests` can exercise the merge against a throwaway tmp file instead of the + /// real `destinationURL`, which always points at the user's actual home directory. + static func mergeMarkedSection(_ payload: String, into destination: URL) throws { + let section = "\(beginMarker)\n\(payload.trimmingCharacters(in: .newlines))\n\(endMarker)" + var existing = (try? String(contentsOf: destination, encoding: .utf8)) ?? "" + + if let beginRange = existing.range(of: beginMarker), + let endRange = existing.range(of: endMarker) { + existing.replaceSubrange(beginRange.lowerBound.. 0 { Divider().overlay(Theme.Palette.separator) } + agentSkillRow(target) + } + } + .padding(.horizontal, 13) + .settingsCard() + if let agentSkillsError { + Text(agentSkillsError) + .font(.caption) + .foregroundStyle(.orange) + } + } + } + + private func agentSkillRow(_ target: AgentSkillTarget) -> some View { + let installed = { _ = agentSkillsRevision; return target.isInstalled }() + return HStack(spacing: 11) { + Image(systemName: target.symbol) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(Theme.Palette.body) + .frame(width: 24, height: 24) + VStack(alignment: .leading, spacing: 2) { + Text(target.displayName).font(.callout.weight(.medium)).foregroundStyle(Theme.Palette.body) + Text(target.isDetected ? (installed ? "Skill installed" : "Detected on this Mac") : "Not detected") + .font(.caption).foregroundStyle(Theme.Palette.menuDesc) + } + Spacer(minLength: 8) + Button(action: { installAgentSkill(target) }) { + Text(installed ? "Reinstall" : "Install") + .font(.caption.weight(.semibold)) + .foregroundStyle(Theme.Palette.body) + .padding(.horizontal, 11) + .frame(height: 26) + } + .buttonStyle(SettingsPillButtonStyle()) + } + .padding(.vertical, 11) + } + + private func installAgentSkill(_ target: AgentSkillTarget) { + do { + try AgentSkillsInstaller.install(target) + agentSkillsError = nil + } catch { + agentSkillsError = "\(target.displayName): \(error.localizedDescription)" + } + agentSkillsRevision += 1 + } + /// Opt-in for copy-time shell. Off by default; even on, every copy confirms what will run. private var shellResolutionCard: some View { VStack(alignment: .leading, spacing: 8) { diff --git a/Tests/ComposerAppTests/AgentSkillsInstallerTests.swift b/Tests/ComposerAppTests/AgentSkillsInstallerTests.swift new file mode 100644 index 0000000..fce4729 --- /dev/null +++ b/Tests/ComposerAppTests/AgentSkillsInstallerTests.swift @@ -0,0 +1,67 @@ +import XCTest +@testable import ComposerApp + +final class AgentSkillsInstallerTests: XCTestCase { + private var tmpFile: URL! + + override func setUp() { + super.setUp() + tmpFile = FileManager.default.temporaryDirectory + .appendingPathComponent("AgentSkillsInstallerTests-\(UUID().uuidString).md") + } + + override func tearDown() { + try? FileManager.default.removeItem(at: tmpFile) + super.tearDown() + } + + func testMergeIntoMissingFileCreatesItWithJustTheSection() throws { + try AgentSkillsInstaller.mergeMarkedSection("the skill body", into: tmpFile) + + let contents = try String(contentsOf: tmpFile, encoding: .utf8) + XCTAssertTrue(contents.contains("the skill body")) + XCTAssertTrue(contents.contains("BEGIN BONSAI BOARD SKILL")) + XCTAssertTrue(contents.contains("END BONSAI BOARD SKILL")) + } + + func testMergePreservesUnrelatedContentOutsideTheMarkers() throws { + try "# My own AGENTS.md notes\nDo not touch this.\n".write( + to: tmpFile, atomically: true, encoding: .utf8) + + try AgentSkillsInstaller.mergeMarkedSection("the skill body", into: tmpFile) + + let contents = try String(contentsOf: tmpFile, encoding: .utf8) + XCTAssertTrue(contents.contains("Do not touch this.")) + XCTAssertTrue(contents.contains("the skill body")) + } + + func testReinstallReplacesThePreviousSectionWithoutDuplicatingIt() throws { + try AgentSkillsInstaller.mergeMarkedSection("version one", into: tmpFile) + try AgentSkillsInstaller.mergeMarkedSection("version two", into: tmpFile) + + let contents = try String(contentsOf: tmpFile, encoding: .utf8) + XCTAssertFalse(contents.contains("version one")) + XCTAssertTrue(contents.contains("version two")) + XCTAssertEqual(contents.components(separatedBy: "BEGIN BONSAI BOARD SKILL").count - 1, 1) + } + + /// `Bundle.appResources` only resolves the staged `.app` layout (by design — see its doc + /// comment), not the xctest runner's working directory, so this checks the source files that + /// `Package.swift` declares as `.process("Resources")` directly rather than through the bundle. + func testAllAgentSkillSourceFilesExistAndAreNonEmpty() throws { + let resourcesDir = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // Tests/ComposerAppTests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // repo root + .appendingPathComponent("Sources/ComposerApp/Resources/AgentSkills") + + let expected = [ + "claude-code-SKILL.md", "codex-AGENTS.md", "cursor-bonsai-board.mdc", + ] + for name in expected { + let url = resourcesDir.appendingPathComponent(name) + let contents = try String(contentsOf: url, encoding: .utf8) + XCTAssertFalse(contents.isEmpty, "\(name) is empty") + } + } +} From 124fd4d457f383e18f4ab193be64446e90441502 Mon Sep 17 00:00:00 2001 From: ojowwalker77 Date: Tue, 30 Jun 2026 17:12:30 -0300 Subject: [PATCH 03/10] fix: flush pending board autosave on app termination DumpStore debounces saves 0.4s after each edit, but AppDelegate never flushed the pending save before exit. A board mutation (including one from an external agent over the canvas API) made within that window was silently lost on quit. Wires up the already-existing-but-unused BoardViewModel.flushSave() via CanvasBridge on applicationWillTerminate, and also translates a bare SIGTERM (e.g. pkill, used by the dev-loop relaunch script) into the normal NSApp.terminate path, since a raw SIGTERM otherwise bypasses AppKit's termination delegate entirely and would never flush. --- Sources/ComposerApp/App/AppDelegate.swift | 22 +++++++++++++++++++ .../ComposerApp/Services/CanvasBridge.swift | 7 ++++++ 2 files changed, 29 insertions(+) diff --git a/Sources/ComposerApp/App/AppDelegate.swift b/Sources/ComposerApp/App/AppDelegate.swift index 068354d..d148993 100644 --- a/Sources/ComposerApp/App/AppDelegate.swift +++ b/Sources/ComposerApp/App/AppDelegate.swift @@ -5,6 +5,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let panelController = PanelController() private let hotKeyManager = HotKeyManager() private let menuBarController = MenuBarController() + /// Held strongly so it keeps firing — a `DispatchSourceSignal` is cancelled on dealloc. + private var sigtermSource: DispatchSourceSignal? func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.regular) @@ -15,6 +17,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { _ = UpdaterController.shared MentionStyleCache.shared.preload() CanvasServer.shared.start() + installSigtermHandler() NSApp.servicesProvider = self @@ -31,6 +34,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate { panelController.show() } + /// The board autosaves on a ~400ms debounce; without this, an edit made just before quit + /// (e.g. a `delete`/`add_text` op from an external agent over the canvas API) is silently + /// lost because the pending save's timer never gets to fire. + func applicationWillTerminate(_ notification: Notification) { + CanvasBridge.shared.flush() + } + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool { if !hasVisibleWindows { panelController.show() } return true @@ -59,6 +69,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } + /// A bare `SIGTERM` (e.g. `pkill`, used by the dev-loop relaunch script) bypasses AppKit's + /// termination delegate entirely by default, so `applicationWillTerminate` would never run and + /// a pending autosave would never flush. Disarm the default disposition and re-route the signal + /// through the normal `NSApp.terminate` path so it does. + private func installSigtermHandler() { + signal(SIGTERM, SIG_IGN) + let source = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main) + source.setEventHandler { NSApp.terminate(nil) } + source.resume() + sigtermSource = source + } + /// Summon the board (if hidden) and open its companion Settings window. func showSettings() { panelController.show() diff --git a/Sources/ComposerApp/Services/CanvasBridge.swift b/Sources/ComposerApp/Services/CanvasBridge.swift index 8181de8..a21d193 100644 --- a/Sources/ComposerApp/Services/CanvasBridge.swift +++ b/Sources/ComposerApp/Services/CanvasBridge.swift @@ -11,6 +11,13 @@ final class CanvasBridge { func register(_ board: BoardViewModel) { self.board = board } + /// Force any debounced board edit to disk right now. Call before the app actually exits — + /// `DumpStore`'s autosave is debounced ~400ms, so an edit (including one from an external + /// agent via the canvas API) made just before quit would otherwise never reach disk. + func flush() { + board?.flushSave() + } + // MARK: Read func snapshot() -> CanvasGraph { From 55909b60bf1134ee2648db2864365f3930008753 Mon Sep 17 00:00:00 2001 From: ojowwalker77 Date: Tue, 30 Jun 2026 22:15:57 -0300 Subject: [PATCH 04/10] fix(canvas): hug image selection ring to the image edge An image card renders its own rounded border. The accent selection ring was drawn `selectionGap` px outside at a larger corner radius, so selecting an image showed two concentric rounded borders with a visible gap. For image cards the ring now hugs the image's own edge (tight gap, matching radius, slightly heavier stroke) as a single clean accent outline. Text, shapes, and lines are unchanged. --- CHANGELOG.md | 5 +++++ Sources/ComposerApp/Views/BoardCardView.swift | 12 +++++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0be0da2..0f067df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ under the new version heading. ## [Unreleased] +### Fixed +- **Image selection ring no longer double-borders.** An image card draws its own rounded border, so + the accent selection ring — which sat a few pixels outside — read as a second, gapped border. The + ring now hugs the image's own edge as a single clean outline. Other elements are unchanged. + ### Added - **Model pickers for the agent chat and board description.** Choose which Claude model each runs on (Opus / Sonnet / Haiku). The chat picker lives in the Agent panel header and mirrors a matching diff --git a/Sources/ComposerApp/Views/BoardCardView.swift b/Sources/ComposerApp/Views/BoardCardView.swift index 8c84f72..fc4ee3d 100644 --- a/Sources/ComposerApp/Views/BoardCardView.swift +++ b/Sources/ComposerApp/Views/BoardCardView.swift @@ -234,14 +234,20 @@ struct BoardCardView: View { // select to move or resize. Shapes keep their ring while editing. if (isSelected || isEditing) && !isEmptyText { let showRing = !isTextElement || (isSelected && !isEditing) + // Image cards draw their own rounded border, so a ring sitting `selectionGap` px outside reads + // as an ugly double border with a gap. Hug the image's own edge instead — a single clean + // accent outline. Other elements (text, shapes, lines) keep the offset ring. + let hugsContent = card.elementKind == .image + let ringGap: CGFloat = hugsContent ? 1 : selectionGap + let ringRadius: CGFloat = hugsContent ? radius : radius + selectionGap // Handles only grab in the select tool — in a drawing tool a corner drag should draw, not resize. let showHandles = isSelected && !isEditing && !card.locked && selectable GeometryReader { geo in ZStack { if showRing { - RoundedRectangle(cornerRadius: radius + selectionGap, style: .continuous) - .strokeBorder(Color.accentColor.opacity(isEditing ? 0.9 : 0.7), lineWidth: 1) - .frame(width: geo.size.width + selectionGap * 2, height: geo.size.height + selectionGap * 2) + RoundedRectangle(cornerRadius: ringRadius, style: .continuous) + .strokeBorder(Color.accentColor.opacity(isEditing ? 0.9 : 0.7), lineWidth: hugsContent ? 1.5 : 1) + .frame(width: geo.size.width + ringGap * 2, height: geo.size.height + ringGap * 2) .position(x: geo.size.width / 2, y: geo.size.height / 2) .allowsHitTesting(false) } From 4914c0c1e29c3d50aba279bacb9736718ee2a4a8 Mon Sep 17 00:00:00 2001 From: ojowwalker77 Date: Tue, 30 Jun 2026 22:59:41 -0300 Subject: [PATCH 05/10] fix(canvas): size image cards to their aspect ratio so the selection ring hugs the image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dropped images were created at a fixed 220x140 landscape frame while the image drew with scaledToFill and no frame clamp, so non-landscape images overflowed the card — the selection ring hugged the frame while the image spilled past it. Size the card to the image's pixel aspect ratio on drop, clamp the image to the frame so it fills-and-crops instead of overflowing, and align the image ring corner radius (10->8) to the image's own border. --- Sources/ComposerApp/Views/BoardCardView.swift | 5 +++- .../ComposerApp/Views/BoardViewModel.swift | 24 ++++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/Sources/ComposerApp/Views/BoardCardView.swift b/Sources/ComposerApp/Views/BoardCardView.swift index fc4ee3d..ecbd448 100644 --- a/Sources/ComposerApp/Views/BoardCardView.swift +++ b/Sources/ComposerApp/Views/BoardCardView.swift @@ -34,7 +34,7 @@ struct BoardCardView: View { private var radius: CGFloat { switch card.elementKind { case .text: 12 - case .image: 10 + case .image: 8 case .rectangle: 8 default: 6 } @@ -682,6 +682,9 @@ private struct ImageObjectPlaceholder: View { Image(nsImage: image) .resizable() .scaledToFill() + // Clamp to the card frame so `scaledToFill` fills-and-crops within the card instead of + // overflowing it — the image's rounded border (and the selection ring) then hug the frame. + .frame(maxWidth: .infinity, maxHeight: .infinity) .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) .overlay(RoundedRectangle(cornerRadius: 8, style: .continuous) .strokeBorder(Color.white.opacity(0.18), lineWidth: 1)) diff --git a/Sources/ComposerApp/Views/BoardViewModel.swift b/Sources/ComposerApp/Views/BoardViewModel.swift index 376980d..8380f1d 100644 --- a/Sources/ComposerApp/Views/BoardViewModel.swift +++ b/Sources/ComposerApp/Views/BoardViewModel.swift @@ -1,6 +1,7 @@ import SwiftUI import SwiftData import AppKit +import ImageIO // MARK: - Per-card runtime state @@ -442,7 +443,7 @@ final class BoardViewModel: ObservableObject { @discardableResult func addImageObject(path: String, at center: CGPoint) -> UUID { registerUndo() - let size = CardState.shapeSize + let size = Self.imageCardSize(forPath: path) let card = CardState( kind: .image, text: "", @@ -464,6 +465,27 @@ final class BoardViewModel: ObservableObject { return card.id } + /// A dropped image keeps its own aspect ratio: size the card to the image so its rounded border and + /// the selection ring coincide instead of the image overflowing (or letterboxing) a fixed landscape + /// default. Reads just the pixel dimensions — no full decode — and fits them into an on-board + /// footprint; falls back to the shape default if the file can't be read. + private static func imageCardSize(forPath path: String) -> CGSize { + guard + let source = CGImageSourceCreateWithURL(URL(fileURLWithPath: path) as CFURL, nil), + let props = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], + let pixelWidth = (props[kCGImagePropertyPixelWidth] as? NSNumber)?.doubleValue, + let pixelHeight = (props[kCGImagePropertyPixelHeight] as? NSNumber)?.doubleValue, + pixelWidth > 0, pixelHeight > 0 + else { return CardState.shapeSize } + + let maxSide: CGFloat = 260 + let aspect = CGFloat(pixelWidth / pixelHeight) + let size = aspect >= 1 + ? CGSize(width: maxSide, height: maxSide / aspect) + : CGSize(width: maxSide * aspect, height: maxSide) + return CGSize(width: size.width.rounded(), height: size.height.rounded()) + } + // MARK: Programmatic mutations (canvas API / external agents) /// Insert a text card carrying `text` at a board point, without entering edit mode — used by From 9f4e42bece9e612a916121fc562e9a4d83c2dab6 Mon Sep 17 00:00:00 2001 From: ojowwalker77 Date: Tue, 30 Jun 2026 22:59:41 -0300 Subject: [PATCH 06/10] fix(agent-skills): resolve bundled skill from the flattened resource bundle; don't clobber an unreadable AGENTS.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .process("Resources") flattens the bundle tree, so the subdirectory: "AgentSkills" lookup always returned nil and every install failed. Try the no-subdirectory form first (as EngineLogo does), with the subdirectory as fallback. Also: mergeMarkedSection mapped any read failure to "" via try?, so an existing-but-unreadable AGENTS.md would be overwritten with only BonsAI's section — now only a genuinely-absent file is treated as empty; a real read error propagates. --- .../ComposerApp/Services/AgentSkillsInstaller.swift | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/Sources/ComposerApp/Services/AgentSkillsInstaller.swift b/Sources/ComposerApp/Services/AgentSkillsInstaller.swift index b8b7b75..787f512 100644 --- a/Sources/ComposerApp/Services/AgentSkillsInstaller.swift +++ b/Sources/ComposerApp/Services/AgentSkillsInstaller.swift @@ -107,9 +107,9 @@ enum AgentSkillsInstaller { static func install(_ target: AgentSkillTarget) throws { guard let resourceURL = Bundle.appResources.url( - forResource: target.resourceName, - withExtension: target.resourceExtension, - subdirectory: "AgentSkills" + forResource: target.resourceName, withExtension: target.resourceExtension + ) ?? Bundle.appResources.url( + forResource: target.resourceName, withExtension: target.resourceExtension, subdirectory: "AgentSkills" ) else { throw AgentSkillsInstallerError.missingBundledResource(target) } @@ -142,7 +142,10 @@ enum AgentSkillsInstaller { /// real `destinationURL`, which always points at the user's actual home directory. static func mergeMarkedSection(_ payload: String, into destination: URL) throws { let section = "\(beginMarker)\n\(payload.trimmingCharacters(in: .newlines))\n\(endMarker)" - var existing = (try? String(contentsOf: destination, encoding: .utf8)) ?? "" + var existing = "" + if FileManager.default.fileExists(atPath: destination.path) { + existing = try String(contentsOf: destination, encoding: .utf8) + } if let beginRange = existing.range(of: beginMarker), let endRange = existing.range(of: endMarker) { From a9933ed375f3f38e3192bed2002fdfb8479383fb Mon Sep 17 00:00:00 2001 From: ojowwalker77 Date: Tue, 30 Jun 2026 23:08:09 -0300 Subject: [PATCH 07/10] feat(canvas): copy image cards as their file path; let Describe read the image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Copy/Compile an image card now serializes to its absolute file path instead of contributing nothing (or only OCR text) — so the reference is pasteable and a board that's just an image no longer copies as empty. Describe passes the image path through the board-state JSON and grants `claude -p` the read-only Read tool (--allowedTools Read) so the model opens and views the actual image. --- CHANGELOG.md | 6 ++++++ .../Services/HeadlessPromptService.swift | 15 +++++++++++---- Sources/ComposerApp/Support/RefineIntent.swift | 2 ++ Sources/ComposerApp/Views/BoardViewModel.swift | 7 ++++--- 4 files changed, 23 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7476b07..5edeef7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,8 +24,14 @@ under the new version heading. control in **Settings ▸ Runtime ▸ Models**; describing the board has its own picker in the same place. Chat defaults to **Opus**, describe defaults to **Sonnet**. The choice is passed to `claude --model`; Refine and Compile stay on the CLI default. +- **Describe board now sees your images.** When describing the board, the agent opens each image + card by its file path and reads the picture, so the description reflects what the image actually + shows instead of noting "an image". ### Fixed +- **Copying a board no longer drops image cards.** An image card now contributes its file path to + the copied and compiled prompt, so a coding agent can open it — and a board that holds only an + image no longer copies as "Nothing to copy yet". - **Board edits made right before quit are no longer lost.** The board autosaves on a ~400ms debounce, so an edit landing just before the app closed — including a `delete`/`add_text` op from an external agent over the canvas API — could be dropped before the pending save fired. BonsAI now diff --git a/Sources/ComposerApp/Services/HeadlessPromptService.swift b/Sources/ComposerApp/Services/HeadlessPromptService.swift index 01bb49a..9946e7b 100644 --- a/Sources/ComposerApp/Services/HeadlessPromptService.swift +++ b/Sources/ComposerApp/Services/HeadlessPromptService.swift @@ -54,12 +54,15 @@ struct HeadlessPromptService { ===== BOARD STATE (JSON graph: nodes, edges, reading order) ===== \(state) """ - return try await run(prompt: prompt, engine: engine, model: model) + // Describe references image cards by absolute path; allow the read-only Read tool so `claude -p` + // can open those images non-interactively (otherwise the permission prompt auto-denies and the + // model never sees them). Read can't mutate anything, so this is safe for a describe pass. + return try await run(prompt: prompt, engine: engine, model: model, allowReadTool: true) } /// `model` is optional: when nil the CLI picks its own default (used by Refine / Compile); /// Describe passes the user's chosen `ClaudeModel` so it can run on a different tier. - private func run(prompt: String, engine: HeadlessEngine, model: ClaudeModel? = nil) async throws -> String { + private func run(prompt: String, engine: HeadlessEngine, model: ClaudeModel? = nil, allowReadTool: Bool = false) async throws -> String { guard let executable = CommandLineToolLocator.executableURL(for: engine) else { throw HeadlessPromptError.failed("\(engine.title) CLI is not installed. Check Settings to install or re-detect it.") } @@ -68,9 +71,13 @@ struct HeadlessPromptService { case .claude: arguments = [executable.path, "-p", prompt] if let model { arguments += ["--model", model.cliAlias] } + // Non-interactive `-p` auto-denies any tool needing permission, so opt Read in explicitly when + // the prompt asks the model to open local files (e.g. Describe reading image cards by path). + if allowReadTool { arguments += ["--allowedTools", "Read"] } case .codex: - // Read-only sandbox: one-shot refine/compile must not mutate the user's repo. - // `model` is Claude-only (a `claude --model` alias), so Codex ignores it. + // Read-only sandbox: one-shot refine/compile must not mutate the user's repo. Codex already + // runs read-only, so it can open referenced files without an extra flag; `model` and the Read + // opt-in are Claude-only, so Codex ignores them. arguments = [executable.path, "exec", "--sandbox", "read-only", "--ephemeral", prompt] } let result: Shell.Result diff --git a/Sources/ComposerApp/Support/RefineIntent.swift b/Sources/ComposerApp/Support/RefineIntent.swift index 3e2aba5..8bbeb2f 100644 --- a/Sources/ComposerApp/Support/RefineIntent.swift +++ b/Sources/ComposerApp/Support/RefineIntent.swift @@ -99,6 +99,8 @@ enum BoardDescribe { cards and shapes (each with an id, kind, text, position, size, and `whoWrote`: 1 = the human \ wrote or edited it, 2 = an agent drew it, 0 = unknown). `edges` are the arrows/lines that bind \ one node to another. `readingOrder` lists node ids top-to-bottom, then left-to-right. \ + An `image` node's `text` is the absolute file path to a picture on this Mac — open and view that \ + file (read it) so the image's actual content is part of the description, not just "an image". \ Read the whole graph and write ONE self-contained description of everything the board holds, so \ someone who cannot see it understands it completely. Walk the cards in reading order; describe \ the shapes and what the arrows connect and imply; surface the structure and relationships, not \ diff --git a/Sources/ComposerApp/Views/BoardViewModel.swift b/Sources/ComposerApp/Views/BoardViewModel.swift index 8380f1d..b7dbf6a 100644 --- a/Sources/ComposerApp/Views/BoardViewModel.swift +++ b/Sources/ComposerApp/Views/BoardViewModel.swift @@ -134,9 +134,10 @@ final class BoardViewModel: ObservableObject { /// Reads the most recent text without creating a runtime/editor bundle for an off-screen card. func plainText(for card: CardState) -> String { - // A screenshot card has no editable text; it contributes its on-device "understanding" (OCR + - // classification) instead, so the image becomes real context for Compile, copy, and the agent. - if card.elementKind == .image { return card.imageUnderstanding ?? "" } + // An image card contributes its file path — so Copy and Compile emit a reference the reader (or a + // coding agent) can open, and Describe can read the actual image off disk. An image with no path + // yet (placeholder) contributes nothing. + if card.elementKind == .image { return card.imagePath ?? "" } return interactions[card.id]?.plainText ?? card.text } From 09779a4a7e3f769abd0572b8679927e02386cb66 Mon Sep 17 00:00:00 2001 From: Jonatas Walker Filho Date: Tue, 30 Jun 2026 23:13:15 -0300 Subject: [PATCH 08/10] edit changelog --- CHANGELOG.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5edeef7..520d9d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,14 +52,7 @@ under the new version heading. allowed tools, and include a Settings control to reset remembered permissions. ### Fixed -- **Shift+Enter in the Agent chat inserts a newline instead of sending.** The input used `.onSubmit`, - which fired on every Return — including Shift+Return — so holding Shift still sent the message. It - now follows the standard chat convention (Slack, Discord, Linear): plain **Enter sends**, and **Shift+Enter** breaks the line at the caret. ([#27](https://github.com/ojowwalker77/BonsAI/issues/27)) -- **Shift+Enter now actually breaks the line (follow-up).** The fix above intercepted Return but - returned `.ignored` for Shift+Return, so the event fell through to the field editor and **selected - all the text** instead of inserting a newline. Shift+Enter now inserts the break directly into the - focused field editor at the caret. ([#27](https://github.com/ojowwalker77/BonsAI/issues/27)) ## [1.2.0] - 2026-06-30 From 2d275e88ba978ebbf922f768be874d77b86ee548 Mon Sep 17 00:00:00 2001 From: ojowwalker77 Date: Tue, 30 Jun 2026 23:17:40 -0300 Subject: [PATCH 09/10] fix: gate Describe model picker to Claude engine; guard marker merge against reversed markers The Settings > Runtime > Models 'Describe board' picker sets a claude --model alias, so it silently no-op'd when preferredEngine() resolved to Codex (Claude disabled/unavailable). It now disables with an explanatory note whenever Claude isn't the engine that will run Describe. Also harden AgentSkillsInstaller.mergeMarkedSection: if a file's end marker precedes its begin marker, append a fresh section instead of trapping on a reversed replaceSubrange range. --- CHANGELOG.md | 3 +- .../Services/AgentSkillsInstaller.swift | 6 +++- Sources/ComposerApp/Views/SettingsView.swift | 32 +++++++++++++++++-- .../AgentSkillsInstallerTests.swift | 12 +++++++ 4 files changed, 48 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 520d9d1..a1e84eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,8 @@ under the new version heading. (Opus / Sonnet / Haiku). The chat picker lives in the Agent panel header and mirrors a matching control in **Settings ▸ Runtime ▸ Models**; describing the board has its own picker in the same place. Chat defaults to **Opus**, describe defaults to **Sonnet**. The choice is passed to - `claude --model`; Refine and Compile stay on the CLI default. + `claude --model`; Refine and Compile stay on the CLI default. The Describe picker disables itself + (with a note) when Codex — not Claude — is the active engine, since Codex ignores the Claude model. - **Describe board now sees your images.** When describing the board, the agent opens each image card by its file path and reads the picture, so the description reflects what the image actually shows instead of noting "an image". diff --git a/Sources/ComposerApp/Services/AgentSkillsInstaller.swift b/Sources/ComposerApp/Services/AgentSkillsInstaller.swift index 787f512..8a5b435 100644 --- a/Sources/ComposerApp/Services/AgentSkillsInstaller.swift +++ b/Sources/ComposerApp/Services/AgentSkillsInstaller.swift @@ -148,7 +148,11 @@ enum AgentSkillsInstaller { } if let beginRange = existing.range(of: beginMarker), - let endRange = existing.range(of: endMarker) { + let endRange = existing.range(of: endMarker), + // Guard against a malformed file where the end marker precedes the begin marker — a reversed + // range would trap `replaceSubrange`. If the markers are out of order, fall through and append + // a fresh section rather than crash on the user's own file contents. + beginRange.lowerBound < endRange.lowerBound { existing.replaceSubrange(beginRange.lowerBound..) -> some View { + private func modelRow( + title: String, subtitle: String, selection: Binding, + active: Bool = true, inactiveNote: String? = nil + ) -> some View { HStack(spacing: 11) { VStack(alignment: .leading, spacing: 2) { Text(title).font(.callout.weight(.medium)).foregroundStyle(Theme.Palette.body) Text(subtitle) .font(.caption).foregroundStyle(Theme.Palette.menuDesc) .fixedSize(horizontal: false, vertical: true) + if !active, let inactiveNote { + Text(inactiveNote) + .font(.caption).foregroundStyle(Color.orange) + .fixedSize(horizontal: false, vertical: true) + } } Spacer(minLength: 8) Picker("", selection: selection) { @@ -332,6 +356,8 @@ private struct SettingsContent: View { .pickerStyle(.menu) .fixedSize() .tint(Theme.Palette.body) + .disabled(!active) + .opacity(active ? 1 : 0.5) } .padding(.vertical, 11) } diff --git a/Tests/ComposerAppTests/AgentSkillsInstallerTests.swift b/Tests/ComposerAppTests/AgentSkillsInstallerTests.swift index fce4729..2bde96f 100644 --- a/Tests/ComposerAppTests/AgentSkillsInstallerTests.swift +++ b/Tests/ComposerAppTests/AgentSkillsInstallerTests.swift @@ -45,6 +45,18 @@ final class AgentSkillsInstallerTests: XCTestCase { XCTAssertEqual(contents.components(separatedBy: "BEGIN BONSAI BOARD SKILL").count - 1, 1) } + func testMergeDoesNotTrapWhenMarkersAreReversedInTheFile() throws { + // A hand-mangled file with the end marker physically before the begin marker would make an + // in-order replace range reversed and trap. The merge must degrade to appending a fresh section. + let begin = "" + let end = "" + try "\(end)\nstray\n\(begin)\n".write(to: tmpFile, atomically: true, encoding: .utf8) + + XCTAssertNoThrow(try AgentSkillsInstaller.mergeMarkedSection("recovered body", into: tmpFile)) + let contents = try String(contentsOf: tmpFile, encoding: .utf8) + XCTAssertTrue(contents.contains("recovered body")) + } + /// `Bundle.appResources` only resolves the staged `.app` layout (by design — see its doc /// comment), not the xctest runner's working directory, so this checks the source files that /// `Package.swift` declares as `.process("Resources")` directly rather than through the bundle. From 22986565a674eb74f7fb22f0bbc158a4e074e337 Mon Sep 17 00:00:00 2001 From: ojowwalker77 Date: Wed, 1 Jul 2026 10:02:47 -0300 Subject: [PATCH 10/10] fix: address PR 41 review comments --- CHANGELOG.md | 8 ++-- .../Services/AgentSkillsInstaller.swift | 40 +++++++++++++++---- .../Services/HeadlessPromptService.swift | 13 ++---- .../ComposerApp/Support/RefineIntent.swift | 5 ++- .../ComposerApp/Views/BoardViewModel.swift | 5 +-- .../AgentSkillsInstallerTests.swift | 22 ++++++++++ 6 files changed, 66 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1e84eb..eb5482c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,9 +25,9 @@ under the new version heading. place. Chat defaults to **Opus**, describe defaults to **Sonnet**. The choice is passed to `claude --model`; Refine and Compile stay on the CLI default. The Describe picker disables itself (with a note) when Codex — not Claude — is the active engine, since Codex ignores the Claude model. -- **Describe board now sees your images.** When describing the board, the agent opens each image - card by its file path and reads the picture, so the description reflects what the image actually - shows instead of noting "an image". +- **Describe board now preserves image references.** Image cards keep their absolute file paths in + the board graph, so the copied description can point at the exact picture without granting the + headless prompt broad local-file read access. ### Fixed - **Copying a board no longer drops image cards.** An image card now contributes its file path to @@ -53,7 +53,7 @@ under the new version heading. allowed tools, and include a Settings control to reset remembered permissions. ### Fixed - **Shift+Enter** breaks the line at the caret. ([#27](https://github.com/ojowwalker77/BonsAI/issues/27)) +- **Shift+Enter in the Agent chat inserts a newline instead of sending.** Shift+Enter breaks the line at the caret. ([#27](https://github.com/ojowwalker77/BonsAI/issues/27)) ## [1.2.0] - 2026-06-30 diff --git a/Sources/ComposerApp/Services/AgentSkillsInstaller.swift b/Sources/ComposerApp/Services/AgentSkillsInstaller.swift index 8a5b435..d31e165 100644 --- a/Sources/ComposerApp/Services/AgentSkillsInstaller.swift +++ b/Sources/ComposerApp/Services/AgentSkillsInstaller.swift @@ -83,7 +83,10 @@ enum AgentSkillTarget: String, CaseIterable, Identifiable { /// Whether the skill is already on disk at the expected location for this agent. var isInstalled: Bool { - FileManager.default.fileExists(atPath: destinationURL.path) + if ownsDestinationFile { + return FileManager.default.fileExists(atPath: destinationURL.path) + } + return AgentSkillsInstaller.hasInstalledManagedSection(at: destinationURL) } } @@ -147,13 +150,8 @@ enum AgentSkillsInstaller { existing = try String(contentsOf: destination, encoding: .utf8) } - if let beginRange = existing.range(of: beginMarker), - let endRange = existing.range(of: endMarker), - // Guard against a malformed file where the end marker precedes the begin marker — a reversed - // range would trap `replaceSubrange`. If the markers are out of order, fall through and append - // a fresh section rather than crash on the user's own file contents. - beginRange.lowerBound < endRange.lowerBound { - existing.replaceSubrange(beginRange.lowerBound.. Bool { + guard let existing = try? String(contentsOf: destination, encoding: .utf8) else { return false } + return completeManagedSectionRange(in: existing) != nil + } + + private static func completeManagedSectionRange(in existing: String) -> Range? { + let beginRanges = markerRanges(of: beginMarker, in: existing) + let endRanges = markerRanges(of: endMarker, in: existing) + guard beginRanges.count == 1, endRanges.count == 1 else { return nil } + let beginRange = beginRanges[0] + let endRange = endRanges[0] + guard beginRange.lowerBound < endRange.lowerBound else { return nil } + return beginRange.lowerBound.. [Range] { + var ranges: [Range] = [] + var searchStart = existing.startIndex + while searchStart < existing.endIndex, + let range = existing[searchStart...].range(of: marker) { + ranges.append(range) + searchStart = range.upperBound + } + return ranges + } } diff --git a/Sources/ComposerApp/Services/HeadlessPromptService.swift b/Sources/ComposerApp/Services/HeadlessPromptService.swift index 9946e7b..ddb62bb 100644 --- a/Sources/ComposerApp/Services/HeadlessPromptService.swift +++ b/Sources/ComposerApp/Services/HeadlessPromptService.swift @@ -54,15 +54,12 @@ struct HeadlessPromptService { ===== BOARD STATE (JSON graph: nodes, edges, reading order) ===== \(state) """ - // Describe references image cards by absolute path; allow the read-only Read tool so `claude -p` - // can open those images non-interactively (otherwise the permission prompt auto-denies and the - // model never sees them). Read can't mutate anything, so this is safe for a describe pass. - return try await run(prompt: prompt, engine: engine, model: model, allowReadTool: true) + return try await run(prompt: prompt, engine: engine, model: model) } /// `model` is optional: when nil the CLI picks its own default (used by Refine / Compile); /// Describe passes the user's chosen `ClaudeModel` so it can run on a different tier. - private func run(prompt: String, engine: HeadlessEngine, model: ClaudeModel? = nil, allowReadTool: Bool = false) async throws -> String { + private func run(prompt: String, engine: HeadlessEngine, model: ClaudeModel? = nil) async throws -> String { guard let executable = CommandLineToolLocator.executableURL(for: engine) else { throw HeadlessPromptError.failed("\(engine.title) CLI is not installed. Check Settings to install or re-detect it.") } @@ -71,13 +68,9 @@ struct HeadlessPromptService { case .claude: arguments = [executable.path, "-p", prompt] if let model { arguments += ["--model", model.cliAlias] } - // Non-interactive `-p` auto-denies any tool needing permission, so opt Read in explicitly when - // the prompt asks the model to open local files (e.g. Describe reading image cards by path). - if allowReadTool { arguments += ["--allowedTools", "Read"] } case .codex: // Read-only sandbox: one-shot refine/compile must not mutate the user's repo. Codex already - // runs read-only, so it can open referenced files without an extra flag; `model` and the Read - // opt-in are Claude-only, so Codex ignores them. + // runs read-only; `model` is Claude-only, so Codex ignores it. arguments = [executable.path, "exec", "--sandbox", "read-only", "--ephemeral", prompt] } let result: Shell.Result diff --git a/Sources/ComposerApp/Support/RefineIntent.swift b/Sources/ComposerApp/Support/RefineIntent.swift index 8bbeb2f..69c0c7c 100644 --- a/Sources/ComposerApp/Support/RefineIntent.swift +++ b/Sources/ComposerApp/Support/RefineIntent.swift @@ -99,8 +99,9 @@ enum BoardDescribe { cards and shapes (each with an id, kind, text, position, size, and `whoWrote`: 1 = the human \ wrote or edited it, 2 = an agent drew it, 0 = unknown). `edges` are the arrows/lines that bind \ one node to another. `readingOrder` lists node ids top-to-bottom, then left-to-right. \ - An `image` node's `text` is the absolute file path to a picture on this Mac — open and view that \ - file (read it) so the image's actual content is part of the description, not just "an image". \ + An `image` node's `text` is the absolute file path to a picture on this Mac. Include that path \ + as the image reference; do not claim to have inspected the picture unless the graph text itself \ + includes that detail. \ Read the whole graph and write ONE self-contained description of everything the board holds, so \ someone who cannot see it understands it completely. Walk the cards in reading order; describe \ the shapes and what the arrows connect and imply; surface the structure and relationships, not \ diff --git a/Sources/ComposerApp/Views/BoardViewModel.swift b/Sources/ComposerApp/Views/BoardViewModel.swift index b7dbf6a..3e75809 100644 --- a/Sources/ComposerApp/Views/BoardViewModel.swift +++ b/Sources/ComposerApp/Views/BoardViewModel.swift @@ -134,9 +134,8 @@ final class BoardViewModel: ObservableObject { /// Reads the most recent text without creating a runtime/editor bundle for an off-screen card. func plainText(for card: CardState) -> String { - // An image card contributes its file path — so Copy and Compile emit a reference the reader (or a - // coding agent) can open, and Describe can read the actual image off disk. An image with no path - // yet (placeholder) contributes nothing. + // An image card contributes its file path so Copy, Compile, and Describe emit a concrete + // reference the reader (or a coding agent) can open. An image with no path yet contributes nothing. if card.elementKind == .image { return card.imagePath ?? "" } return interactions[card.id]?.plainText ?? card.text } diff --git a/Tests/ComposerAppTests/AgentSkillsInstallerTests.swift b/Tests/ComposerAppTests/AgentSkillsInstallerTests.swift index 2bde96f..c122618 100644 --- a/Tests/ComposerAppTests/AgentSkillsInstallerTests.swift +++ b/Tests/ComposerAppTests/AgentSkillsInstallerTests.swift @@ -57,6 +57,28 @@ final class AgentSkillsInstallerTests: XCTestCase { XCTAssertTrue(contents.contains("recovered body")) } + func testDanglingBeginMarkerDoesNotLetReinstallDeleteUserNotes() throws { + let begin = "" + try "\(begin)\nUSER NOTES\n".write(to: tmpFile, atomically: true, encoding: .utf8) + + try AgentSkillsInstaller.mergeMarkedSection("first install", into: tmpFile) + try AgentSkillsInstaller.mergeMarkedSection("second install", into: tmpFile) + + let contents = try String(contentsOf: tmpFile, encoding: .utf8) + XCTAssertTrue(contents.contains("USER NOTES")) + XCTAssertTrue(contents.contains("first install")) + XCTAssertTrue(contents.contains("second install")) + } + + func testUnrelatedSharedAgentsFileDoesNotCountAsInstalled() throws { + try "# My Codex notes\nNo BonsAI section yet.\n".write(to: tmpFile, atomically: true, encoding: .utf8) + + XCTAssertFalse(AgentSkillsInstaller.hasInstalledManagedSection(at: tmpFile)) + + try AgentSkillsInstaller.mergeMarkedSection("the skill body", into: tmpFile) + XCTAssertTrue(AgentSkillsInstaller.hasInstalledManagedSection(at: tmpFile)) + } + /// `Bundle.appResources` only resolves the staged `.app` layout (by design — see its doc /// comment), not the xctest runner's working directory, so this checks the source files that /// `Package.swift` declares as `.process("Resources")` directly rather than through the bundle.