Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
7885a1d
fix(agent): Shift+Enter inserts a newline at the caret, not select-all
ojowwalker77 Jun 30, 2026
371491e
docs: changelog 1.2.1 (1.2.0 entry + Shift+Enter fix)
ojowwalker77 Jun 30, 2026
145102a
feat(agent-skills): install canvas-API skill for Claude Code, Codex, …
ojowwalker77 Jun 30, 2026
55909b6
fix(canvas): hug image selection ring to the image edge
ojowwalker77 Jul 1, 2026
dfb80ea
fix(canvas): hug image selection ring to the image edge
ojowwalker77 Jul 1, 2026
f932b56
Merge branch 'release-1.2.2' into feat/agent-skills-installer
ojowwalker77 Jul 1, 2026
3dd51b6
Merge pull request #37 from ojowwalker77/feat/agent-skills-installer
ojowwalker77 Jul 1, 2026
fbff8f0
Merge branch 'main' into release-1.2.2; roll up 1.2.2 changelog
ojowwalker77 Jul 1, 2026
4914c0c
fix(canvas): size image cards to their aspect ratio so the selection …
ojowwalker77 Jul 1, 2026
9f4e42b
fix(agent-skills): resolve bundled skill from the flattened resource …
ojowwalker77 Jul 1, 2026
a9933ed
feat(canvas): copy image cards as their file path; let Describe read …
ojowwalker77 Jul 1, 2026
09779a4
edit changelog
ojowwalker77 Jul 1, 2026
2d275e8
fix: gate Describe model picker to Claude engine; guard marker merge …
ojowwalker77 Jul 1, 2026
2298656
fix: address PR 41 review comments
ojowwalker77 Jul 1, 2026
2bae9eb
Release 1.2.2
ojowwalker77 Jul 1, 2026
6876629
refactor(ui): standard window becomes the only interface
ojowwalker77 Jul 1, 2026
cb62062
feat(theme): named theme flavors with a preview gallery
ojowwalker77 Jul 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,49 @@ under the new version heading.

## [Unreleased]

## [1.2.2] - 2026-06-30

### 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
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 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
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
flushes the pending save on termination, and a bare `SIGTERM` (e.g. `pkill` from the dev-loop
relaunch script) is rerouted through the normal quit path so the flush still runs.
- **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.

## [1.2.1] - 2026-06-30

### Added
- **Smart paste.** Pasting a GitHub issue/PR URL, an existing file path (`/…`, `~/…`, or `file://…`), or a library name like `next.js` / `vercel/next.js` now becomes the matching connector chip (`@github`, `@finder`, `@context7`) instead of raw text.
- **Quick capture.** A menu-bar leaf opens a one-line capture field (↩ sends to the current board). macOS **Services → Send to BonsAI** and `bonsai://capture?text=…` use the same path. The loopback API adds `POST /capture`.
- **Codex engine.** Refine and Compile can run through `codex exec` (read-only sandbox) when Codex CLI is installed — toggle in Settings ▸ Runtime.
- **Canvas API docs + integrations.** [docs/canvas-api.md](docs/canvas-api.md) formalizes the `127.0.0.1:7337` API; [integrations/raycast](integrations/raycast/README.md) and [integrations/alfred](integrations/alfred/README.md) ship starter scripts.
- **Agent tool permission prompts.** Agent-run MCP tool calls now ask before running, remember
allowed tools, and include a Settings control to reset remembered permissions.

### Fixed
- **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

Expand Down
64 changes: 43 additions & 21 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,26 +1,48 @@
# Composer implementation guardrails
# BonsAI implementation guardrails

## CRITICAL: preserve the board + dock composition
## Architecture: one standard window, floating chrome

**BE SUPER AWARE: DO NOT CHANGE THESE LINES OR FUCK UP THIS COMPOSITION BY “CLEANING UP” THE LAYOUT.**
BonsAI is a single standard macOS window (`FloatingPanel`, titled/resizable/full-size content)
whose canvas fills it edge to edge. ALL chrome floats over the canvas as Liquid Glass pills:

The Agent and Settings are deliberately separate AppKit panels, coordinated with the board window.
They must not be folded into `ComposerCanvas`, rendered as an overlay, or made into an `HStack`
sidebar.
- Top-left: `+` pill and the hover board picker (after the repositioned traffic lights).
- Top-right: AI actions (Describe Board · Copy Board · agent toggle).
- Bottom-center: ONE command bar — zoom · the eight tools · grounding folder · Settings.
- Agent and Settings are SwiftUI glass overlays inside the canvas (`dockOverlay`), NOT separate
windows. There are no auxiliary panels.

- `PanelController.positionWorkspace()` owns the two-window geometry.
- The dock's `y: y` and `height: workspaceHeight - cardTopInset` are intentional: they align the
dock with the visible board card, which starts below the toolbar, while keeping their bottoms
perfectly level.
- `Theme.Size.railGutter(in:) == 6%` is a deliberately tight gap (tightened from 9% on request) so
the rail reads as attached to the board, not marooned at the screen edge. It's the floor before
the fixed-width rail starts crowding the card — keep it screen-relative; don't drop it further or
the rail will overlap the canvas on narrow/laptop windows.
- The top toolbar centers on `WorkspaceLayout.toolbarCenterX` from `PanelController`, which is the
full board-plus-Agent/Settings composition. Do not center it only within the reduced board card.
- Keep dock width, gap, rail gutter, and toolbar gutter screen-relative. No fixed width/minimum
should be introduced in this outer layout.
The old floating-panel mode (chromeless glass panel + sibling dock windows) was removed
deliberately in July 2026. Do not reintroduce it.

If this area is edited, run `./script/build_and_run.sh --verify` and visually open both Settings
and Agent. Confirm: separate windows, a shrunken board, aligned top/bottom edges, and a tight
rail-to-canvas gap (rail close to the card, not crowding or overlapping it).
## CRITICAL: the design system

- **`WindowChrome` (Theme.swift) is law** for floating controls: controlHeight 34, padH 6, padV 5,
radius 14, edgeInset 16, trafficLightInset 82, iconFont (17 medium), labelFont (13 medium),
labelPadH 10, itemSpacing 4. No inline sizes, paddings, fonts, or corner radii in chrome views.
- **Every pill/bar is built with `.chromePill()`** (the one wrapper: padding + glass) around
`SidebarButton` / `SidebarAgentButton` / `CanvasToolbar` controls. Never hand-assemble a pill
with raw `.padding(...)` + `.composerPopupSurface()` — that is how sizes drifted apart before.
- **Traffic lights are repositioned** onto the control row's centerline
(`FloatingPanel.layoutWindowChromeButtons`), re-applied by `PanelController` on
resize/move/key-state changes. Don't remove those delegate hooks — AppKit resets the buttons.
- **Colors are ThemeFlavors** (`Support/ThemeFlavor.swift`): four named themes — Bonsai Dark,
Bonsai Light, Catppuccin Mocha, Catppuccin Latte (palette data in `Support/Catppuccin.swift`).
Every `Theme.Palette` token maps a semantic role onto the active flavor's slots (text/subtext/
overlay/surface/base); views consume ONLY tokens. Never hard-code a hex or
`Color.white`/`Color.black` in a view — every literal has broken one theme. The accent is
`Theme.Palette.accent`, never `Color.accentColor`. Theme switching REBUILDS the canvas
(PanelController.applyTheme) because tokens are plain flavor lookups; the agent is a singleton
(`CanvasAgent.shared`) so its conversation survives. Settings shows flavor-painted preview
cards (`ThemePreviewCard`) — new themes are a `ThemeFlavor` + enum case, nothing else.
- **The canvas is solid by default** (`windowCanvas` = the flavor's `base`) painted over a
behind-window blur; the Settings ▸ Appearance ▸ Canvas slider (`canvasTransparencyKey`,
default 0) recedes it toward desktop glass. The window itself is non-opaque with a clear
backing so the blur can sample — don't flip it back to opaque.
- **Glass is `floatingGlass` / `composerPopupSurface` / `dockPanelSurface`** — one recipe. No
custom frosts, no white-fill "frosted" variants (tried, rejected as generic gray).
- Theming is `ComposerTheme` (System/Light/Dark) applied as the window's `NSAppearance`;
`composerThemeChanged` re-applies it live.

If this area is edited, run `./script/build_and_run.sh --verify` and visually confirm: solid
canvas, one bottom command bar, aligned top pills on the traffic-light centerline, and both
themes clean (no black ink in light mode).
32 changes: 32 additions & 0 deletions Sources/ComposerApp/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
_ = UpdaterController.shared
MentionStyleCache.shared.preload()
CanvasServer.shared.start()
promptForAgentSkillsIfNeeded()
installSigtermHandler()

NSApp.servicesProvider = self
Expand Down Expand Up @@ -69,6 +70,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()
}

/// 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
Expand Down
27 changes: 0 additions & 27 deletions Sources/ComposerApp/Panel/ComposerDockPanelContent.swift

This file was deleted.

80 changes: 44 additions & 36 deletions Sources/ComposerApp/Panel/FloatingPanel.swift
Original file line number Diff line number Diff line change
@@ -1,57 +1,71 @@
import AppKit

/// Chromeless, normal-level window for BonsAI's board (and its auxiliary dock / settings siblings).
/// Borderless windows return false from `canBecomeKey` by default — hence the override below.
/// BonsAI's board window: a standard titled, resizable macOS window with a full-size content
/// view. Every control floats over the solid canvas inside; the title bar is transparent and the
/// traffic lights are re-laid onto the control row's centerline.
final class FloatingPanel: NSPanel {
/// The board is the workspace's primary panel. Agent and Settings are separate sibling panels
/// whose Escape action should close only themselves.
var isAuxiliaryPanel = false
/// MANDATORY: a borderless / non-activating panel returns false by default,
/// MANDATORY: panels return false by default in some configurations,
/// so without this the text canvas never gets an insertion point.
override var canBecomeKey: Bool { true }
/// The board is a normal main window; the auxiliary dock / settings panels are not.
override var canBecomeMain: Bool { !isAuxiliaryPanel }
override var canBecomeMain: Bool { true }

init(contentRect: NSRect) {
super.init(
contentRect: contentRect,
styleMask: [.borderless], // a normal (activating) window, not a floating overlay panel
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered,
defer: false
)
isFloatingPanel = false
becomesKeyOnlyIfNeeded = false
hidesOnDeactivate = false
// The canvas owns dragging (pan the board, move cards). Background window-drag would
// fight every gesture — drag a card and the whole window would move with it.
isMovableByWindowBackground = false
isMovable = false
isReleasedWhenClosed = false
level = .normal // a normal Dock-app window, not always-on-top
// Summon onto whatever Space the user is on (via the hotkey) rather than yanking them to
// another Space; still allowed to appear over a full-screen app.
collectionBehavior = [.moveToActiveSpace, .fullScreenAuxiliary]
animationBehavior = .none
// Themed at the window level so the adaptive palette resolves app-wide. The default theme is
// dark — BonsAI's signature look — with System/Light as the user's opt-in (⚙︎ Appearance).
appearance = ComposerPreferences.theme.nsAppearance

// A real window: the title-bar strip drags it, but the canvas (content view) must not —
// it owns background drag for panning. Traffic lights float over a full-size content view.
isMovable = true
isMovableByWindowBackground = false
titleVisibility = .hidden
titlebarAppearsTransparent = true
title = "BonsAI"
collectionBehavior = [.fullScreenPrimary]
// Non-opaque with a clear backing: the canvas paints its own surface — solid at the default
// 0 transparency, receding over a behind-window blur as the Settings slider comes up.
isOpaque = false
backgroundColor = .clear
// Let AppKit cast the soft drop shadow that grounds a floating glass panel — it
// follows the rounded vibrant content's alpha, which a SwiftUI shadow can't (the
// content fills the window, so a SwiftUI shadow has no margin to bleed into).
hasShadow = true
}

// A command panel is dark glass regardless of system appearance — consistent with
// the brand-icon color extraction, which already normalizes for a forced-dark panel.
appearance = NSAppearance(named: .darkAqua)
/// Put the traffic lights on the SAME centerline as the floating control row (the Books-style
/// strip), instead of AppKit's default top-left corner — that mismatch is what made the top-left
/// read as two unrelated rows. Lights start at the shared edge inset, so lights and pills all
/// sit on one spacing grid. AppKit resets these frames on resize and key-state changes, so the
/// controller re-calls this after each.
func layoutWindowChromeButtons() {
guard !styleMask.contains(.fullScreen) else { return }
let buttons: [NSWindow.ButtonType] = [.closeButton, .miniaturizeButton, .zoomButton]
guard let container = standardWindowButton(.closeButton)?.superview else { return }
// Center of the floating control row: edge inset + half the pill height.
let rowCenterY = WindowChrome.edgeInset + (WindowChrome.controlHeight + WindowChrome.padV * 2) / 2
var x = WindowChrome.edgeInset
for type in buttons {
guard let button = standardWindowButton(type) else { continue }
let y = container.isFlipped
? rowCenterY - button.frame.height / 2
: container.frame.height - rowCenterY - button.frame.height / 2
button.setFrameOrigin(NSPoint(x: x, y: y))
x += button.frame.width + 6
}
}

/// Escape dismisses when the panel itself is first responder.
/// Escape hides the window when it is itself first responder.
override func cancelOperation(_ sender: Any?) {
if isAuxiliaryPanel {
NotificationCenter.default.post(name: .composerDismissDock, object: nil)
} else {
(delegate as? PanelController)?.hide()
}
(delegate as? PanelController)?.hide()
}

/// BonsAI has no menu bar, so app-menu shortcuts don't fire. Catch the board's
Expand All @@ -61,11 +75,6 @@ final class FloatingPanel: NSPanel {
let raw = event.charactersIgnoringModifiers?.lowercased()
let textIsEditing = firstResponder is NSTextView

if flags == [.command, .shift], raw == "c" {
NotificationCenter.default.post(name: .composerCopy, object: nil)
return true
}

if !textIsEditing {
if flags == [.command], raw == "z" {
NotificationCenter.default.post(name: .composerUndoBoard, object: nil)
Expand Down Expand Up @@ -135,9 +144,8 @@ final class FloatingPanel: NSPanel {
NotificationCenter.default.post(name: .composerToggleAgent, object: nil)
return true
}
// ⌘K summons the command palette. Only the board panel owns it — the auxiliary agent/settings
// panels would open it in a window that isn't key, leaving the search field unfocusable.
if flags == [.command], raw == "k", !isAuxiliaryPanel {
// ⌘K summons the command palette.
if flags == [.command], raw == "k" {
NotificationCenter.default.post(name: .composerTogglePalette, object: nil)
return true
}
Expand Down
Loading