diff --git a/CHANGELOG.md b/CHANGELOG.md index 0be0da2..eb5482c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 064a6f5..1cc244f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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). diff --git a/Sources/ComposerApp/App/AppDelegate.swift b/Sources/ComposerApp/App/AppDelegate.swift index d148993..303e48b 100644 --- a/Sources/ComposerApp/App/AppDelegate.swift +++ b/Sources/ComposerApp/App/AppDelegate.swift @@ -17,6 +17,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { _ = UpdaterController.shared MentionStyleCache.shared.preload() CanvasServer.shared.start() + promptForAgentSkillsIfNeeded() installSigtermHandler() NSApp.servicesProvider = self @@ -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 diff --git a/Sources/ComposerApp/Panel/ComposerDockPanelContent.swift b/Sources/ComposerApp/Panel/ComposerDockPanelContent.swift deleted file mode 100644 index 970f92d..0000000 --- a/Sources/ComposerApp/Panel/ComposerDockPanelContent.swift +++ /dev/null @@ -1,27 +0,0 @@ -import SwiftUI - -/// SwiftUI content for the workspace's real auxiliary window. The main board never hosts this -/// hierarchy, so Settings and the agent remain visually and behaviorally separate from the canvas. -struct ComposerDockPanelContent: View { - let kind: ComposerDockKind - let agent: CanvasAgent? - let width: CGFloat - - var body: some View { - Group { - switch kind { - case .agent: - if let agent { - AgentDock(agent: agent, width: width, onClose: dismiss) - } - case .settings: - SettingsOverlay(width: width, onClose: dismiss) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - private func dismiss() { - NotificationCenter.default.post(name: .composerDismissDock, object: nil) - } -} diff --git a/Sources/ComposerApp/Panel/FloatingPanel.swift b/Sources/ComposerApp/Panel/FloatingPanel.swift index 333201d..d281deb 100644 --- a/Sources/ComposerApp/Panel/FloatingPanel.swift +++ b/Sources/ComposerApp/Panel/FloatingPanel.swift @@ -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 @@ -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) @@ -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 } diff --git a/Sources/ComposerApp/Panel/PanelController.swift b/Sources/ComposerApp/Panel/PanelController.swift index 7085151..b76f996 100644 --- a/Sources/ComposerApp/Panel/PanelController.swift +++ b/Sources/ComposerApp/Panel/PanelController.swift @@ -1,14 +1,12 @@ import AppKit import SwiftUI -/// Owns the single reusable floating panel: summon/dismiss, animation, -/// center-on-mouse, focus, and click-away dismissal. +/// Owns BonsAI's single board window: show/hide (the global hotkey toggles it), frame restore, +/// traffic-light layout, theming, and first-responder focus. Agent and Settings are SwiftUI +/// overlays inside the canvas — there are no auxiliary windows. @MainActor final class PanelController: NSObject, NSWindowDelegate { private var panel: FloatingPanel? - private var dock: FloatingPanel? - private var dockKind: ComposerDockKind? - private var dockAgent: CanvasAgent? var isVisible: Bool { panel?.isVisible ?? false } override init() { @@ -16,17 +14,8 @@ final class PanelController: NSObject, NSWindowDelegate { NotificationCenter.default.addObserver( self, selector: #selector(handleDismiss), name: .composerDismiss, object: nil) NotificationCenter.default.addObserver( - forName: .composerPresentDock, object: nil, queue: .main - ) { [weak self] note in - MainActor.assumeIsolated { - guard let rawKind = note.userInfo?["kind"] as? String, - let kind = ComposerDockKind(rawValue: rawKind) else { return } - self?.presentDock(kind, agent: note.object as? CanvasAgent) - } - } - NotificationCenter.default.addObserver( - forName: .composerDismissDock, object: nil, queue: .main - ) { [weak self] _ in MainActor.assumeIsolated { self?.dismissDock() } } + forName: .composerThemeChanged, object: nil, queue: .main + ) { [weak self] _ in MainActor.assumeIsolated { self?.applyTheme() } } } @objc private func handleDismiss() { hide() } @@ -36,84 +25,65 @@ final class PanelController: NSObject, NSWindowDelegate { func show() { let panel = self.panel ?? makePanel() self.panel = panel - positionWorkspace() - - panel.alphaValue = 0 - panel.contentView?.wantsLayer = true - panel.contentView?.layer?.transform = CATransform3DMakeScale(0.97, 0.97, 1) - - // Normal app: bring BonsAI forward and focus the board. + // The window keeps whatever frame the user left it at — no reframing on summon. NSApp.activate(ignoringOtherApps: true) panel.makeKeyAndOrderFront(nil) panel.orderFrontRegardless() - if let dockKind, let dock { - installDockContent(kind: dockKind, in: dock) - dock.orderFrontRegardless() - dock.makeKeyAndOrderFront(nil) - } else { - focusEditor(in: panel) - } + panel.layoutWindowChromeButtons() + focusEditor(in: panel) // The active card's editor only exists once SwiftUI mounts it, so ask the canvas to enter - // editing — the caret is ready to type the instant the panel appears (no double-click). + // editing — the caret is ready to type the instant the window appears (no double-click). DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { NotificationCenter.default.post(name: .composerEnterEditing, object: nil) } - - if reduceMotion { - panel.alphaValue = 1 - panel.contentView?.layer?.transform = CATransform3DIdentity - } else { - NSAnimationContext.runAnimationGroup { ctx in - ctx.duration = 0.26 - ctx.timingFunction = CAMediaTimingFunction(name: .easeOut) - panel.animator().alphaValue = 1 - panel.contentView?.layer?.transform = CATransform3DIdentity - } - } } func hide() { guard let panel, panel.isVisible else { return } - dock?.orderOut(nil) - guard !reduceMotion else { - panel.orderOut(nil) - panel.contentView?.layer?.transform = CATransform3DIdentity - NSApp.deactivate() - return - } - NSAnimationContext.runAnimationGroup({ ctx in - ctx.duration = Theme.Motion.dismissDuration - ctx.timingFunction = CAMediaTimingFunction(name: .easeIn) - panel.animator().alphaValue = 0 - panel.contentView?.layer?.transform = CATransform3DMakeScale(0.97, 0.97, 1) - }, completionHandler: { - MainActor.assumeIsolated { - panel.orderOut(nil) - panel.contentView?.layer?.transform = CATransform3DIdentity - NSApp.deactivate() - } - }) + panel.orderOut(nil) + NSApp.deactivate() + } + + /// Apply the selected theme: set the window's appearance class AND rebuild the canvas — + /// palette tokens are plain flavor lookups captured at render, so the tree must re-render from + /// scratch. Board content is store-backed and the agent is a singleton, so nothing is lost. + private func applyTheme() { + guard let panel else { return } + panel.appearance = ComposerPreferences.theme.nsAppearance + installContent(ComposerCanvas(), in: panel) + panel.layoutWindowChromeButtons() } // MARK: Build private func makePanel() -> FloatingPanel { - let initialFrame = initialPanelFrame() - let panel = FloatingPanel(contentRect: initialFrame) + let panel = FloatingPanel(contentRect: defaultWindowFrame()) panel.delegate = self + panel.minSize = NSSize(width: 640, height: 460) + // Restore (and keep persisting) the size/position the user last left the window at. + panel.setFrameAutosaveName("BonsAIBoardWindow") installContent(ComposerCanvas(), in: panel) return panel } - private func makeDockPanel() -> FloatingPanel { - let panel = FloatingPanel(contentRect: NSRect(x: 0, y: 0, width: 1, height: 1)) - panel.isAuxiliaryPanel = true - panel.delegate = self - return panel + /// A comfortable default the first time the window is shown; superseded thereafter by the + /// autosaved frame. + private func defaultWindowFrame() -> NSRect { + guard let visible = NSScreen.main?.visibleFrame else { + return NSRect(x: 0, y: 0, width: 1100, height: 720) + } + let width = min(1180, visible.width * 0.72).rounded() + let height = min(820, visible.height * 0.84).rounded() + return NSRect( + x: (visible.midX - width / 2).rounded(), + y: (visible.midY - height / 2).rounded(), + width: width, + height: height + ) } - /// A hosted SwiftUI view must not be allowed to infer an AppKit window size. Both workspace - /// panels are explicitly framed from the display's usable area below. + /// A hosted SwiftUI view must not be allowed to infer an AppKit window size — the window is + /// explicitly framed (and then user-resized). private func installContent(_ root: Content, in panel: FloatingPanel) { let host = NSHostingView(rootView: root) host.translatesAutoresizingMaskIntoConstraints = false @@ -122,9 +92,6 @@ final class PanelController: NSObject, NSWindowDelegate { let container = NonMovableView() container.wantsLayer = true container.layer?.backgroundColor = NSColor.clear.cgColor - container.layer?.cornerRadius = Theme.Radius.panel - container.layer?.cornerCurve = .continuous - container.layer?.masksToBounds = false container.addSubview(host) NSLayoutConstraint.activate([ host.topAnchor.constraint(equalTo: container.topAnchor), @@ -135,95 +102,19 @@ final class PanelController: NSObject, NSWindowDelegate { panel.contentView = container } - private func presentDock(_ kind: ComposerDockKind, agent: CanvasAgent?) { - if let agent { dockAgent = agent } - guard kind != .agent || dockAgent != nil else { return } - dockKind = kind - let dock = self.dock ?? makeDockPanel() - self.dock = dock - positionWorkspace() - installDockContent(kind: kind, in: dock) - - guard panel?.isVisible == true else { return } - dock.orderFrontRegardless() - dock.makeKeyAndOrderFront(nil) - } + // MARK: NSWindowDelegate — AppKit resets the titlebar buttons; put them back on the control row - private func installDockContent(kind: ComposerDockKind, in panel: FloatingPanel) { - let width = panel.frame.width - installContent( - ComposerDockPanelContent(kind: kind, agent: dockAgent, width: width), - in: panel - ) - } - - private func dismissDock() { - guard let kind = dockKind else { return } - dock?.orderOut(nil) - dockKind = nil - positionWorkspace() - NotificationCenter.default.post( - name: .composerDockDismissed, - object: nil, - userInfo: ["kind": kind.rawValue] - ) - } - - /// `show()` immediately repositions this panel on the display beneath the pointer. Starting it - /// at the same screen-relative size prevents a one-frame fixed-size layout before that happens. - private func initialPanelFrame() -> NSRect { - guard let visible = NSScreen.main?.visibleFrame else { - return NSRect(x: 0, y: 0, width: 1, height: 1) - } - return NSRect( - x: visible.midX - visible.width * Theme.Size.screenFraction / 2, - y: visible.midY - visible.height * Theme.Size.screenFraction / 2, - width: visible.width * Theme.Size.screenFraction, - height: visible.height * Theme.Size.screenFraction - ) - } - - // MARK: Placement + func windowDidResize(_ notification: Notification) { relayoutChromeButtons(notification) } + func windowDidMove(_ notification: Notification) { relayoutChromeButtons(notification) } + func windowDidBecomeKey(_ notification: Notification) { relayoutChromeButtons(notification) } + func windowDidResignKey(_ notification: Notification) { relayoutChromeButtons(notification) } - private func positionWorkspace() { - guard let panel else { return } - let mouse = NSEvent.mouseLocation - let screen = NSScreen.screens.first { NSMouseInRect(mouse, $0.frame, false) } - ?? NSScreen.main ?? NSScreen.screens.first - guard let visible = screen?.visibleFrame else { panel.center(); return } - let workspaceWidth = min((visible.width * Theme.Size.screenFraction).rounded(), visible.width) - let workspaceHeight = min((visible.height * Theme.Size.screenFraction).rounded(), visible.height) - let x = max(visible.minX, min((visible.midX - workspaceWidth / 2).rounded(), visible.maxX - workspaceWidth)) - let y = max(visible.minY, min((visible.midY - workspaceHeight / 2).rounded(), visible.maxY - workspaceHeight)) - // The top toolbar is visually centered on the entire composed workspace. Its SwiftUI host is - // the left board window, so this remains a coordinate relative to that left edge. - WorkspaceLayout.shared.toolbarCenterX = workspaceWidth / 2 - - guard dockKind != nil else { - panel.setFrame(NSRect(x: x, y: y, width: workspaceWidth, height: workspaceHeight), display: true) - return - } - - let dockWidth = Theme.Size.dockWidth(in: workspaceWidth) - let gap = Theme.Size.dockMargin(in: workspaceWidth) - let boardWidth = max(workspaceWidth - dockWidth - gap, 1) - // The board's SwiftUI card begins below its floating toolbar and remains flush with the - // workspace bottom. Its sibling dock keeps that same bottom edge and loses the same top slice. - let cardTopInset = Theme.Size.toolbarGutter(in: workspaceHeight) - let dockHeight = max(workspaceHeight - cardTopInset, 1) - panel.setFrame(NSRect(x: x, y: y, width: boardWidth, height: workspaceHeight), display: true) - dock?.setFrame( - NSRect( - x: x + boardWidth + gap, - y: y, - width: dockWidth, - height: dockHeight - ), - display: true - ) + private func relayoutChromeButtons(_ notification: Notification) { + guard let panel, (notification.object as? NSWindow) === panel else { return } + panel.layoutWindowChromeButtons() } - // MARK: Focus the text view so typing works the instant the panel appears. + // MARK: Focus the text view so typing works the instant the window appears. private func focusEditor(in panel: NSPanel) { guard let content = panel.contentView, let textView = firstTextView(in: content) else { return } @@ -237,10 +128,6 @@ final class PanelController: NSObject, NSWindowDelegate { } return nil } - - private var reduceMotion: Bool { - NSWorkspace.shared.accessibilityDisplayShouldReduceMotion - } } /// Host container that never lets a click-drag move the window — the canvas owns all dragging. 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..d31e165 --- /dev/null +++ b/Sources/ComposerApp/Services/AgentSkillsInstaller.swift @@ -0,0 +1,189 @@ +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 { + if ownsDestinationFile { + return FileManager.default.fileExists(atPath: destinationURL.path) + } + return AgentSkillsInstaller.hasInstalledManagedSection(at: destinationURL) + } +} + +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 + ) ?? 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 = "" + if FileManager.default.fileExists(atPath: destination.path) { + existing = try String(contentsOf: destination, encoding: .utf8) + } + + if let managedRange = completeManagedSectionRange(in: existing) { + existing.replaceSubrange(managedRange, with: section) + } else { + if !existing.isEmpty { + existing += existing.hasSuffix("\n\n") ? "" : (existing.hasSuffix("\n") ? "\n" : "\n\n") + } + existing += section + "\n" + } + try existing.write(to: destination, atomically: true, encoding: .utf8) + } + + static func hasInstalledManagedSection(at destination: URL) -> 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/CanvasAgent.swift b/Sources/ComposerApp/Services/CanvasAgent.swift index b260ed9..7d805ee 100644 --- a/Sources/ComposerApp/Services/CanvasAgent.swift +++ b/Sources/ComposerApp/Services/CanvasAgent.swift @@ -24,6 +24,10 @@ final class AgentTranscript: ObservableObject { @MainActor final class CanvasAgent: ObservableObject { + /// One agent for the app's one window — a singleton so the conversation survives canvas + /// rebuilds (e.g. a theme switch). + static let shared = CanvasAgent() + /// Streaming messages live in their own observable so the board, toolbar, and ⌘K palette can /// observe the agent for *coarse* state (below) without re-rendering on every streamed token. let transcript = AgentTranscript() diff --git a/Sources/ComposerApp/Services/HeadlessPromptService.swift b/Sources/ComposerApp/Services/HeadlessPromptService.swift index 01bb49a..054c514 100644 --- a/Sources/ComposerApp/Services/HeadlessPromptService.swift +++ b/Sources/ComposerApp/Services/HeadlessPromptService.swift @@ -43,20 +43,6 @@ struct HeadlessPromptService { return try await run(prompt: prompt, engine: engine) } - /// Describe the WHOLE board — text cards, shapes, diagrams, and how they connect — as one - /// 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, 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, 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) async throws -> String { @@ -69,8 +55,8 @@ struct HeadlessPromptService { 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. + // Read-only sandbox: one-shot refine/compile must not mutate the user's repo. Codex already + // 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/Services/ScreenCaptureService.swift b/Sources/ComposerApp/Services/ScreenCaptureService.swift index 34c163d..10eb895 100644 --- a/Sources/ComposerApp/Services/ScreenCaptureService.swift +++ b/Sources/ComposerApp/Services/ScreenCaptureService.swift @@ -735,7 +735,7 @@ private final class AnnotationToolbar: NSView { /// tool after a draw, so it can reflect that without a feedback loop. func highlight(toolIndex index: Int) { for (i, button) in toolButtons.enumerated() { - button.contentTintColor = i == index ? .controlAccentColor : NSColor.white.withAlphaComponent(0.7) + button.contentTintColor = i == index ? Theme.Palette.nsAccent : NSColor.white.withAlphaComponent(0.7) } } diff --git a/Sources/ComposerApp/Services/SelfContainedRenderer.swift b/Sources/ComposerApp/Services/SelfContainedRenderer.swift deleted file mode 100644 index 99fa936..0000000 --- a/Sources/ComposerApp/Services/SelfContainedRenderer.swift +++ /dev/null @@ -1,92 +0,0 @@ -import AppKit - -/// Expands the note's `@mentions` into a self-contained block of text ready to paste into -/// a coding harness. The note body stays first; resolved context is appended as labelled -/// sections. Resolved app chips are fetched live and inlined; unresolved ones fall back -/// to connector-specific instructions. -enum SelfContainedRenderer { - struct Result { - let text: String - /// Connector-specific failures, already phrased for display to the person who clicked Copy. - let failures: [String] - } - - /// `runShell` gates the copy-time shell expansion: `$(command)` substitution and `name=(value)` - /// variables only run when the user has opted in (and confirmed the run); otherwise their literal - /// source is kept and a single note explains how to enable it. - /// - /// App chips/skills/clipboard are scanned from the *expanded* body, so a `$file` that resolves to - /// an `@finder` chip still gets its section — and a consumed, never-referenced definition doesn't. - /// `commandDirectory` is the working directory for `$(…)` — the board's grounding folder when set, - /// otherwise the user's home. `perCommandTimeout` caps each command so a hung one can't freeze the - /// copy. - static let perCommandTimeout: TimeInterval = 20 - - static func render(_ plain: String, runShell: Bool = false, commandDirectory: String = NSHomeDirectory()) async -> Result { - let clipboard = await MainActor.run { NSPasteboard.general.string(forType: .string)?.trimmed } - - var body = plain.trimmed - var shellFailures: [String] = [] - let shellCommands = ShellTemplate.commands(in: plain) - let hasVariables = !ShellTemplate.definedNames(in: plain).isEmpty - // Variable substitution is pure text and always runs; only `$(…)` command execution is gated. - if !shellCommands.isEmpty || hasVariables { - let expansion = await ShellTemplate.expand(plain, runCommands: runShell) { command in - try? await Shell.run(["bash", "-c", command], directory: commandDirectory, timeout: perCommandTimeout) - } - body = expansion.text.trimmed - shellFailures = expansion.failures - if !shellCommands.isEmpty, !runShell { - let count = shellCommands.count - shellFailures.append("Shell resolution is off — turn on “Resolve shell at copy time” in Settings ▸ Connectors to run \(count) command\(count == 1 ? "" : "s") at copy time.") - } - } - - var sections: [String] = [] - if !body.isEmpty { sections.append(body) } - - let skills = MentionCatalog.all - .filter { $0.kind == .skill && body.contains($0.id) } - .map(\.id).sorted() - if !skills.isEmpty { - sections.append("## Skills To Use\n" + skills.map { "- \($0.dropFirst())" }.joined(separator: "\n")) - } - - let appSections = await appSections(for: AppToken.scan(body)) - sections.append(contentsOf: appSections.sections) - - if body.contains("@clipboard"), let clip = clipboard, !clip.isEmpty { - sections.append("## Clipboard\n\(clip)") - } - - return Result(text: sections.joined(separator: "\n\n") + "\n", failures: shellFailures + appSections.failures) - } - - // MARK: App sections (fetched concurrently, emitted in note order) - - private static func appSections(for tokens: [(token: String, appID: String, selection: AppSelection?)]) async -> (sections: [String], failures: [String]) { - guard !tokens.isEmpty else { return ([], []) } - return await withTaskGroup(of: (Int, String?, String?).self) { group in - for (index, entry) in tokens.enumerated() { - group.addTask { - guard let connector = AppConnectorRegistry.connector(for: entry.appID) else { - return (index, nil, "\(entry.appID): Composer does not have a connector for this token.") - } - do { - return (index, try await connector.render(selection: entry.selection), nil) - } catch { - let action = "Resolving \(entry.appID)" - return (index, nil, "\(entry.appID): \(UserFacingError.message(for: error, while: action))") - } - } - } - var collected: [(Int, String?, String?)] = [] - for await result in group { collected.append(result) } - let ordered = collected.sorted { $0.0 < $1.0 } - return ( - ordered.compactMap(\.1).filter { !$0.isEmpty }, - ordered.compactMap(\.2) - ) - } - } -} diff --git a/Sources/ComposerApp/Support/Catppuccin.swift b/Sources/ComposerApp/Support/Catppuccin.swift new file mode 100644 index 0000000..d83c788 --- /dev/null +++ b/Sources/ComposerApp/Support/Catppuccin.swift @@ -0,0 +1,47 @@ +import AppKit + +/// The Catppuccin palette (catppuccin.com) — data for the Catppuccin `ThemeFlavor`s. +/// Adding Frappé or Macchiato is pasting a palette + one `ThemeFlavor` entry. +struct CatppuccinFlavor { + // Accents + let rosewater: NSColor; let flamingo: NSColor; let pink: NSColor; let mauve: NSColor + let red: NSColor; let maroon: NSColor; let peach: NSColor; let yellow: NSColor + let green: NSColor; let teal: NSColor; let sky: NSColor; let sapphire: NSColor + let blue: NSColor; let lavender: NSColor + // Typography: text = primary ink, subtext = secondary/tertiary. + let text: NSColor; let subtext1: NSColor; let subtext0: NSColor + // Overlays: dim ink — badges, placeholders, disabled, hairlines. + let overlay2: NSColor; let overlay1: NSColor; let overlay0: NSColor + // Surfaces: UI fills — rows, chips, washes. + let surface2: NSColor; let surface1: NSColor; let surface0: NSColor + // Backgrounds: base = the canvas; mantle/crust recede below it. + let base: NSColor; let mantle: NSColor; let crust: NSColor +} + +enum Catppuccin { + static let latte = CatppuccinFlavor( + rosewater: hex(0xDC8A78), flamingo: hex(0xDD7878), pink: hex(0xEA76CB), mauve: hex(0x8839EF), + red: hex(0xD20F39), maroon: hex(0xE64553), peach: hex(0xFE640B), yellow: hex(0xDF8E1D), + green: hex(0x40A02B), teal: hex(0x179299), sky: hex(0x04A5E5), sapphire: hex(0x209FB5), + blue: hex(0x1E66F5), lavender: hex(0x7287FD), + text: hex(0x4C4F69), subtext1: hex(0x5C5F77), subtext0: hex(0x6C6F85), + overlay2: hex(0x7C7F93), overlay1: hex(0x8C8FA1), overlay0: hex(0x9CA0B0), + surface2: hex(0xACB0BE), surface1: hex(0xBCC0CC), surface0: hex(0xCCD0DA), + base: hex(0xEFF1F5), mantle: hex(0xE6E9EF), crust: hex(0xDCE0E8)) + + static let mocha = CatppuccinFlavor( + rosewater: hex(0xF5E0DC), flamingo: hex(0xF2CDCD), pink: hex(0xF5C2E7), mauve: hex(0xCBA6F7), + red: hex(0xF38BA8), maroon: hex(0xEBA0AC), peach: hex(0xFAB387), yellow: hex(0xF9E2AF), + green: hex(0xA6E3A1), teal: hex(0x94E2D5), sky: hex(0x89DCEB), sapphire: hex(0x74C7EC), + blue: hex(0x89B4FA), lavender: hex(0xB4BEFE), + text: hex(0xCDD6F4), subtext1: hex(0xBAC2DE), subtext0: hex(0xA6ADC8), + overlay2: hex(0x9399B2), overlay1: hex(0x7F849C), overlay0: hex(0x6C7086), + surface2: hex(0x585B70), surface1: hex(0x45475A), surface0: hex(0x313244), + base: hex(0x1E1E2E), mantle: hex(0x181825), crust: hex(0x11111B)) + + private static func hex(_ value: UInt32) -> NSColor { + NSColor(srgbRed: CGFloat((value >> 16) & 0xFF) / 255.0, + green: CGFloat((value >> 8) & 0xFF) / 255.0, + blue: CGFloat(value & 0xFF) / 255.0, alpha: 1.0) + } +} diff --git a/Sources/ComposerApp/Support/ComposerPreferences.swift b/Sources/ComposerApp/Support/ComposerPreferences.swift index 872f945..27b56f3 100644 --- a/Sources/ComposerApp/Support/ComposerPreferences.swift +++ b/Sources/ComposerApp/Support/ComposerPreferences.swift @@ -1,19 +1,53 @@ import AppKit import Foundation +/// The app-wide theme: a named flavor (palette + appearance class). Switching rebuilds the +/// canvas so every plain-color token re-resolves against the new flavor. +enum ComposerTheme: String, CaseIterable, Identifiable { + case bonsaiDark + case bonsaiLight + case catppuccinMocha + case catppuccinLatte + + var id: String { rawValue } + + var title: String { + switch self { + case .bonsaiDark: "Bonsai Dark" + case .bonsaiLight: "Bonsai Light" + case .catppuccinMocha: "Catppuccin Mocha" + case .catppuccinLatte: "Catppuccin Latte" + } + } + + var flavor: ThemeFlavor { + switch self { + case .bonsaiDark: .bonsaiDark + case .bonsaiLight: .bonsaiLight + case .catppuccinMocha: .catppuccinMocha + case .catppuccinLatte: .catppuccinLatte + } + } + + var nsAppearance: NSAppearance? { + NSAppearance(named: flavor.isDark ? .darkAqua : .aqua) + } +} + /// User-tunable appearance controls shared by SwiftUI surfaces and AppKit text views. enum ComposerPreferences { static let editorFontSizeKey = "composer.editor.fontPointSize" - static let panelTransparencyKey = "composer.panel.backgroundTransparency" - static let resolveShellAtCopyKey = "composer.copy.resolveShellCommands" + /// App-wide theme. Defaults to Bonsai Dark — the signature look. + static let themeKey = "composer.appearance.theme" + /// Canvas background transparency (0 = solid, default). Sliding it up lets the desktop blur + /// through the board surface. + static let canvasTransparencyKey = "composer.canvas.backgroundTransparency" + static let maxCanvasTransparency = 0.72 static let minEditorFontSize: CGFloat = 11 static let maxEditorFontSize: CGFloat = 28 static let fontSizeStep: CGFloat = 1 - static let defaultPanelTransparency = 0.18 - static let maxPanelTransparency = 0.72 - private static var defaultEditorFontSize: CGFloat { NSFont.preferredFont(forTextStyle: .body).pointSize + 2 } @@ -27,10 +61,9 @@ enum ComposerPreferences { NSFont.systemFont(ofSize: editorFontSize) } - /// Whether `{{x = cmd}}` variables and `[sh: cmd]` nodes run at copy time. Off by default: running - /// shell pulled from board text is opt-in, and even when on, each copy confirms what will execute. - static var resolveShellAtCopy: Bool { - UserDefaults.standard.bool(forKey: resolveShellAtCopyKey) + /// The app-wide theme (see `ComposerTheme`). Defaults to Bonsai Dark. + static var theme: ComposerTheme { + ComposerTheme(rawValue: UserDefaults.standard.string(forKey: themeKey) ?? "") ?? .bonsaiDark } @discardableResult @@ -66,8 +99,8 @@ enum ComposerPreferences { return false } - static func clampedPanelTransparency(_ value: Double) -> Double { - min(max(value, 0), maxPanelTransparency) + static func clampedCanvasTransparency(_ value: Double) -> Double { + min(max(value, 0), maxCanvasTransparency) } private static func clamp(_ value: CGFloat, _ lower: CGFloat, _ upper: CGFloat) -> CGFloat { diff --git a/Sources/ComposerApp/Support/Haptics.swift b/Sources/ComposerApp/Support/Haptics.swift new file mode 100644 index 0000000..80bcf4f --- /dev/null +++ b/Sources/ComposerApp/Support/Haptics.swift @@ -0,0 +1,20 @@ +import AppKit + +/// Trackpad haptics for direct-manipulation moments. macOS only renders these while fingers are +/// on the trackpad, so every call is a safe no-op from a mouse. +enum Haptics { + /// A light tick — tool picks, rail buttons, small toggles. + static func tap() { + NSHapticFeedbackManager.defaultPerformer.perform(.alignment, performanceTime: .default) + } + + /// A firmer knock — switching context (boards, panels). + static func level() { + NSHapticFeedbackManager.defaultPerformer.perform(.levelChange, performanceTime: .default) + } + + /// The default thud — creating something new. + static func generic() { + NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .default) + } +} diff --git a/Sources/ComposerApp/Support/MentionStyle.swift b/Sources/ComposerApp/Support/MentionStyle.swift index 6df973a..778a577 100644 --- a/Sources/ComposerApp/Support/MentionStyle.swift +++ b/Sources/ComposerApp/Support/MentionStyle.swift @@ -58,23 +58,41 @@ func dominantColor(of image: NSImage) -> NSColor { let raw = NSColor(srgbRed: CGFloat(sumR / weightTotal), green: CGFloat(sumG / weightTotal), blue: CGFloat(sumB / weightTotal), alpha: 1.0) - return normalizeForDarkPanel(raw) + return normalizeForCanvas(raw) } -private func legibleNeutral() -> NSColor { NSColor(white: 0.86, alpha: 1.0) } +/// Grayscale brand marks (the Octocat, Notion's N) get the flavor's ink instead of a washed +/// average. A dynamic color so it re-resolves at draw time after a theme switch. +private func legibleNeutral() -> NSColor { + NSColor(name: nil) { _ in Theme.flavor.text } +} -/// Raise a too-dark color while preserving hue; clamp legibility on the dark panel. -private func normalizeForDarkPanel(_ color: NSColor) -> NSColor { +/// Clamp a brand color for legibility on the CURRENT canvas, preserving hue: raise too-dark +/// colors on the dark board, deepen too-bright ones on paper. A dynamic color, so chips stay +/// legible when the theme flips without re-extracting anything. +private func normalizeForCanvas(_ color: NSColor) -> NSColor { guard let rgb = color.usingColorSpace(.sRGB) else { return legibleNeutral() } var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 rgb.getHue(&h, saturation: &s, brightness: &b, alpha: &a) if s < 0.12 { return legibleNeutral() } let lum = 0.299 * rgb.redComponent + 0.587 * rgb.greenComponent + 0.114 * rgb.blueComponent - if lum < 0.55 { - b = min(1.0, max(b, 0.80)) - s = min(s, 0.85) + return NSColor(name: nil) { _ in + if Theme.flavor.isDark { + var db = b, ds = s + if lum < 0.55 { + db = min(1.0, max(b, 0.80)) + ds = min(s, 0.85) + } + return NSColor(hue: h, saturation: ds, brightness: db, alpha: 1.0) + } else { + var db = b, ds = s + if lum > 0.60 { + db = min(b, 0.58) + ds = max(s, 0.55) + } + return NSColor(hue: h, saturation: ds, brightness: db, alpha: 1.0) + } } - return NSColor(hue: h, saturation: s, brightness: b, alpha: 1.0) } // MARK: - Style cache (favicon fetch + cache + preload) @@ -146,7 +164,7 @@ final class MentionStyleCache { loadFavicon(id: item.id, host: host) } else { images[item.id] = symbolImage(item.symbol) - colors[item.id] = NSColor.controlAccentColor.usingColorSpace(.sRGB) ?? legibleNeutral() + colors[item.id] = NSColor(name: nil) { _ in Theme.flavor.info } } } broadcast() @@ -198,7 +216,7 @@ final class MentionStyleCache { .withSymbolConfiguration(config) ?? NSImage(size: NSSize(width: 14, height: 14)) let base = NSImage(systemSymbolName: symbol, accessibilityDescription: nil)? .withSymbolConfiguration(config) ?? fallback - let tint = NSColor.controlAccentColor + let tint = Theme.Palette.nsAccent return NSImage(size: base.size, flipped: false) { rect in base.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 1.0) tint.set() diff --git a/Sources/ComposerApp/Support/MentionToken.swift b/Sources/ComposerApp/Support/MentionToken.swift index 4e91314..24979a6 100644 --- a/Sources/ComposerApp/Support/MentionToken.swift +++ b/Sources/ComposerApp/Support/MentionToken.swift @@ -97,10 +97,10 @@ enum MentionToken { static func attributed(token: String, label: String, font: NSFont, showDisclosure: Bool) -> NSAttributedString { let chip = NSMutableAttributedString(string: label, attributes: [ .font: font, - .foregroundColor: NSColor.controlAccentColor, - .backgroundColor: NSColor.controlAccentColor.withAlphaComponent(0.14), + .foregroundColor: Theme.Palette.nsAccent, + .backgroundColor: Theme.Palette.nsAccent.withAlphaComponent(0.14), ]) - if showDisclosure { chip.append(MentionChip.disclosure(font: font, color: NSColor.controlAccentColor.withAlphaComponent(0.42))) } + if showDisclosure { chip.append(MentionChip.disclosure(font: font, color: Theme.Palette.nsAccent.withAlphaComponent(0.42))) } chip.addAttribute(.mentionToken, value: token, range: NSRange(location: 0, length: chip.length)) return chip } diff --git a/Sources/ComposerApp/Support/ModelPreferences.swift b/Sources/ComposerApp/Support/ModelPreferences.swift index e734499..8d4f483 100644 --- a/Sources/ComposerApp/Support/ModelPreferences.swift +++ b/Sources/ComposerApp/Support/ModelPreferences.swift @@ -1,23 +1,16 @@ 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**. +/// Which Claude model the in-canvas agent targets, persisted as a `ClaudeModel` rawValue in +/// UserDefaults so the picker in the Agent panel and the one in Settings ▸ Runtime stay +/// mirrored — both `@AppStorage`-bind the same key. /// /// 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/Support/Notifications.swift b/Sources/ComposerApp/Support/Notifications.swift index 3c28258..94f7010 100644 --- a/Sources/ComposerApp/Support/Notifications.swift +++ b/Sources/ComposerApp/Support/Notifications.swift @@ -1,17 +1,9 @@ import Foundation -/// The two auxiliary panels are real AppKit windows, coordinated with the board window rather -/// than rendered inside its SwiftUI hierarchy. -enum ComposerDockKind: String { - case agent - case settings -} - extension Notification.Name { static let composerToggleWindow = Notification.Name("composerToggleWindow") static let composerShowWindow = Notification.Name("composerShowWindow") static let composerDismiss = Notification.Name("composerDismiss") - static let composerCopy = Notification.Name("composerCopy") /// Fires on ⌘R / ⌘↩ — compile the whole board into one paste-ready draft. static let composerCompileBoard = Notification.Name("composerCompileBoard") /// Fires when a refine starts/ends; userInfo["busy"] gates click-away dismissal. @@ -54,19 +46,15 @@ extension Notification.Name { static let composerTogglePalette = Notification.Name("composerTogglePalette") /// Opens the separate Settings panel (sidebar gear, ⌘, or the menu-bar item). static let composerShowSettings = Notification.Name("composerShowSettings") - /// Requests an auxiliary panel. `object` is the active `CanvasAgent` for `.agent` and - /// `userInfo["kind"]` is a `ComposerDockKind.rawValue`. - static let composerPresentDock = Notification.Name("composerPresentDock") - /// Requests the currently-visible auxiliary panel to close. - static let composerDismissDock = Notification.Name("composerDismissDock") - /// Sent after the panel has closed, so the board can update its toolbar/overlay state. - static let composerDockDismissed = Notification.Name("composerDockDismissed") /// Fires after ⌘+/⌘− or Settings changes the editor point size. static let composerFontSizeChanged = Notification.Name("composerFontSizeChanged") /// Fires when MentionStyleCache gains a favicon/brand color (e.g. for the Settings Apps list). static let composerStyleCacheUpdated = Notification.Name("composerStyleCacheUpdated") /// Re-bind the global summon hotkey after the user records a new shortcut in Settings. static let composerShortcutChanged = Notification.Name("composerShortcutChanged") + /// Fires when the app-wide theme (System / Light / Dark) changes — windows re-apply their + /// `NSAppearance` in place, no rebuild needed. + static let composerThemeChanged = Notification.Name("composerThemeChanged") /// Fires on the capture hotkey ("Snap to board") — grab a screen region, understand it on-device, /// and drop it on the board as an agent-ready card. static let composerCaptureToBoard = Notification.Name("composerCaptureToBoard") diff --git a/Sources/ComposerApp/Support/RefineIntent.swift b/Sources/ComposerApp/Support/RefineIntent.swift index 3e2aba5..73f12f2 100644 --- a/Sources/ComposerApp/Support/RefineIntent.swift +++ b/Sources/ComposerApp/Support/RefineIntent.swift @@ -87,28 +87,6 @@ enum BoardCompile { """ } -// MARK: - Board describe - -/// The board-level "Copy as description" action: hands the engine the whole board graph (the same -/// snapshot the canvas MCP `get_canvas` exposes) and asks for one self-contained, paste-ready -/// description of EVERYTHING on the board — text cards, shapes, diagrams, and how they connect — -/// not just the card prose `BoardCompile` merges. -enum BoardDescribe { - static let instruction = """ - You are given the full state of a visual thinking board as a JSON graph. `nodes` are the text \ - 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. \ - 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 \ - just a flat list. Spell out enough context that the description stands on its own. \ - Keep every @mention token (for example @context7, @github, @finder, @browser, and any with \ - trailing ids) EXACTLY as written — never rephrase them, fold them into prose, or drop them. \ - Do not add commentary, preamble, quotes, or markdown fences. Return ONLY the description. - """ -} - // MARK: - Refine UI state /// Drives the whole-draft refine affordances: the intent menu and the post-refine diff --git a/Sources/ComposerApp/Support/Theme.swift b/Sources/ComposerApp/Support/Theme.swift index b976244..c673b65 100644 --- a/Sources/ComposerApp/Support/Theme.swift +++ b/Sources/ComposerApp/Support/Theme.swift @@ -4,15 +4,19 @@ import AppKit // MARK: - Design tokens /// One source of truth for spatial, material, color, and motion tokens. -/// Colors are adaptive so the panel and popovers follow the system appearance. +/// Colors resolve through the selected `ThemeFlavor` — no view ever hard-codes a hex; tokens map +/// semantic roles onto flavor slots. A theme switch rebuilds the canvas (PanelController), so +/// plain colors are safe here. enum Theme { - static var nsBodyText: NSColor { - Adaptive.ns(light: Adaptive.white(0.04, 0.84), dark: Adaptive.white(1.00, 0.88)) - } + /// The active flavor (Settings ▸ Appearance ▸ Theme). + static var flavor: ThemeFlavor { ComposerPreferences.theme.flavor } - static var nsPlaceholderText: NSColor { - Adaptive.ns(light: Adaptive.white(0.02, 0.38), dark: Adaptive.white(1.00, 0.48)) - } + static var nsBodyText: NSColor { flavor.text } + + static var nsPlaceholderText: NSColor { flavor.overlay1 } + + /// The solid canvas — the flavor's `base`. + static var nsWindowCanvas: NSColor { flavor.base } enum Radius { static let panel: CGFloat = 22 @@ -28,34 +32,6 @@ enum Theme { } enum Size { - /// The whole panel (card + the rail/toolbar gutters) fills this fraction of the screen's - /// visible frame, centered — Composer is a near-fullscreen canvas. The card auto-derives - /// from the window size minus the gutters in the canvas layout. - static let screenFraction: CGFloat = 0.95 - /// Main-surface measurements are proportions of the current viewport. They deliberately live - /// here instead of as point constants: opening the dock must redistribute the *actual* window - /// width, whether Composer is on a compact laptop display or a wide external screen. - static func railGutter(in windowWidth: CGFloat) -> CGFloat { - // This owns the rail itself plus a small gap before the board card begins. Kept tight - // (6%) so the rail reads as attached to the board rather than marooned at the screen edge; - // it's the floor before the fixed-width rail starts crowding the card. - (max(windowWidth, 0) * 0.060).rounded() - } - static func railInset(in windowWidth: CGFloat) -> CGFloat { - (max(windowWidth, 0) * 0.014).rounded() - } - static func toolbarGutter(in windowHeight: CGFloat) -> CGFloat { - (max(windowHeight, 0) * 0.060).rounded() - } - static func toolbarInset(in windowHeight: CGFloat) -> CGFloat { - (max(windowHeight, 0) * 0.012).rounded() - } - static func dockMargin(in windowWidth: CGFloat) -> CGFloat { - (max(windowWidth, 0) * 0.009).rounded() - } - static func dockWidth(in windowWidth: CGFloat) -> CGFloat { - (max(windowWidth, 0) * 0.24).rounded() - } static let actionBarHeight: CGFloat = 34 static let actionBarItemHeight: CGFloat = 28 static let menuWidth: CGFloat = 320 @@ -80,48 +56,71 @@ enum Theme { static let actionIcon = SwiftUI.Font.body.weight(.medium) } - /// All foreground and surface colors are adaptive. Avoid hard-coded white/black in views. + /// Semantic roles mapped onto the active flavor's slots. Views consume ONLY these tokens. enum Palette { - static var body: Color { Color(nsColor: Theme.nsBodyText) } - static var title: Color { Adaptive.color(light: Adaptive.white(0.02, 0.42), dark: Adaptive.white(1.00, 0.36)) } - static var count: Color { Adaptive.color(light: Adaptive.white(0.02, 0.30), dark: Adaptive.white(1.00, 0.22)) } - static var placeholder: Color { Color(nsColor: Theme.nsPlaceholderText) } - static var menuDesc: Color { Adaptive.color(light: Adaptive.white(0.02, 0.58), dark: Adaptive.white(1.00, 0.58)) } - - static var accentFill: Color { Color.accentColor.opacity(0.20) } - static var rowFill: Color { Adaptive.color(light: Adaptive.white(0.00, 0.045), dark: Adaptive.white(1.00, 0.055)) } - static var selectedRowFill: Color { Color.accentColor.opacity(0.24) } - - static var panelBase: Color { - Adaptive.color( - light: Adaptive.srgb(0.965, 0.960, 0.945), - dark: Adaptive.srgb(0.070, 0.078, 0.086) - ) + private static func c(_ ns: NSColor, _ alpha: CGFloat = 1) -> Color { + Color(nsColor: alpha == 1 ? ns : ns.withAlphaComponent(alpha)) + } + + /// The one accent (mauve on Catppuccin, the system accent on Bonsai themes). + static var accent: Color { c(Theme.flavor.accent) } + static var nsAccent: NSColor { Theme.flavor.accent } + + static var body: Color { c(Theme.flavor.text) } + static var title: Color { c(Theme.flavor.overlay1) } + static var count: Color { c(Theme.flavor.overlay0) } + static var placeholder: Color { c(Theme.flavor.overlay1) } + static var menuDesc: Color { c(Theme.flavor.subtext0) } + + static var accentFill: Color { c(Theme.flavor.accent, 0.20) } + static var rowFill: Color { c(Theme.flavor.surface0, 0.45) } + static var selectedRowFill: Color { c(Theme.flavor.accent, 0.24) } + + static var panelHairline: Color { c(Theme.flavor.overlay0, 0.35) } + static var panelInnerLine: Color { c(Theme.flavor.surface2, 0.30) } + + static var popupScrim: Color { c(Theme.flavor.base, 0.60) } + + /// Uniform legibility tint under the Liquid Glass surface — the flavor's own base, so pills + /// read as raised canvas material in every theme. + static var raisedTint: Color { c(Theme.flavor.base, 0.45) } + static var raisedRim: Color { c(Theme.flavor.overlay0, 0.25) } + + static var windowCanvas: Color { c(Theme.flavor.base) } + + /// Chrome tokens for the floating pills, bars, and their controls. + static var chromeGlyph: Color { c(Theme.flavor.subtext1) } + static var chromeGlyphHover: Color { c(Theme.flavor.text) } + static var chromeGlyphDim: Color { c(Theme.flavor.overlay0) } + static var chromeBadge: Color { c(Theme.flavor.overlay1) } + static var chromeText: Color { c(Theme.flavor.subtext1) } + static var hoverWash: Color { c(Theme.flavor.surface1, 0.55) } + static var chromeDivider: Color { c(Theme.flavor.surface2, 0.80) } + /// Ink for freehand strokes drawn straight on the board. + static var inkStroke: Color { c(Theme.flavor.text, 0.92) } + /// Drawn board elements (shapes, lines, arrows). + static var elementStroke: Color { c(Theme.flavor.text, 0.85) } + /// Shape interiors: unfilled on light themes (outline-only, like a whiteboard); a soft surface + /// fill on dark ones, where it grounds the shape against the canvas. The light value is + /// near-zero alpha rather than `.clear` so the interior still hit-tests for select. + static var elementFill: Color { + Theme.flavor.isDark ? c(Theme.flavor.surface0, 0.55) : c(Theme.flavor.text, 0.001) + } + /// Elements cast a grounding shadow only on dark themes — ink on paper casts none. + static var elementShadow: Color { + Theme.flavor.isDark ? c(Theme.flavor.crust, 0.55) : Color.clear } - static var panelScrim: Color { Adaptive.color(light: Adaptive.white(1.00, 0.50), dark: Adaptive.white(0.00, 0.66)) } - static var panelBottomShade: Color { Adaptive.color(light: Adaptive.white(0.00, 0.035), dark: Adaptive.white(0.00, 0.10)) } - static var panelTopSheen: Color { Adaptive.color(light: Adaptive.white(1.00, 0.46), dark: Adaptive.white(1.00, 0.04)) } - static var panelHairline: Color { Adaptive.color(light: Adaptive.white(0.00, 0.10), dark: Adaptive.white(1.00, 0.08)) } - static var panelInnerLine: Color { Adaptive.color(light: Adaptive.white(1.00, 0.40), dark: Adaptive.white(1.00, 0.06)) } - - static var popupScrim: Color { Adaptive.color(light: Adaptive.white(1.00, 0.56), dark: Adaptive.white(0.00, 0.24)) } - static var popupSheen: Color { Adaptive.color(light: Adaptive.white(1.00, 0.38), dark: Adaptive.white(1.00, 0.045)) } - static var popupHairline: Color { Adaptive.color(light: Adaptive.white(0.00, 0.10), dark: Adaptive.white(1.00, 0.08)) } - - /// Uniform legibility tint + edge for the unified Liquid Glass surface (forced-dark panel). - static var raisedTint: Color { Color.black.opacity(0.16) } - static var raisedRim: Color { Color.white.opacity(0.07) } - - static var barScrim: Color { Adaptive.color(light: Adaptive.white(1.00, 0.60), dark: Adaptive.white(0.00, 0.42)) } - static var barHairline: Color { Adaptive.color(light: Adaptive.white(0.00, 0.10), dark: Adaptive.white(1.00, 0.08)) } - static var barSheen: Color { Adaptive.color(light: Adaptive.white(1.00, 0.36), dark: Adaptive.white(1.00, 0.055)) } - - static var separator: Color { Adaptive.color(light: Adaptive.white(0.00, 0.085), dark: Adaptive.white(1.00, 0.07)) } - static var keycapFill: Color { Adaptive.color(light: Adaptive.white(0.00, 0.060), dark: Adaptive.white(1.00, 0.08)) } - static var segmentedFill: Color { Adaptive.color(light: Adaptive.white(0.00, 0.045), dark: Adaptive.white(1.00, 0.05)) } - static var tagFill: Color { Adaptive.color(light: Adaptive.white(0.00, 0.060), dark: Adaptive.white(1.00, 0.075)) } - static var buttonHover: Color { Adaptive.color(light: Adaptive.white(0.00, 0.070), dark: Adaptive.white(1.00, 0.12)) } - static var toastScrim: Color { Adaptive.color(light: Adaptive.white(1.00, 0.42), dark: Adaptive.white(0.00, 0.35)) } + /// The shape-label chip: solid fills (a translucent fill lets the chip's own shadow bleed + /// through and muddy it — the "gray smear" bug). + static var labelChipFill: Color { + Theme.flavor.isDark ? c(Theme.flavor.surface0) : c(Theme.flavor.mantle) + } + + static var separator: Color { c(Theme.flavor.surface2, 0.60) } + static var keycapFill: Color { c(Theme.flavor.surface0, 0.70) } + static var segmentedFill: Color { c(Theme.flavor.surface0, 0.55) } + static var tagFill: Color { c(Theme.flavor.surface0, 0.60) } + static var buttonHover: Color { c(Theme.flavor.surface1, 0.80) } } enum Shadow { @@ -143,6 +142,46 @@ enum Theme { } } +// MARK: - Standard-window chrome metrics + +/// One design system for the standard-window floating controls — the board pill, the tool bar, the +/// action pill, and the rail all share this control height, inner padding, and corner radius so they +/// read as siblings instead of four bespoke shapes. +enum WindowChrome { + static let controlHeight: CGFloat = 34 + static let padH: CGFloat = 6 + static let padV: CGFloat = 5 + static let radius: CGFloat = Theme.Radius.menu + /// Uniform distance every floating control keeps from the window edges (the board pill clears the + /// traffic lights instead). One number so nothing sits a different distance from its edge. + static let edgeInset: CGFloat = 16 + /// Left offset for the top-left board pill: the traffic lights (repositioned onto the control + /// row's centerline, starting at `edgeInset`) end at 16 + 3×14 + 2×6 = 70; +12 breathing room. + static let trafficLightInset: CGFloat = 82 + /// EVERY chrome glyph: one size, one weight. No inline `.font(.system(size: …))` in chrome views. + static let iconSize: CGFloat = 17 + static var iconFont: Font { .system(size: iconSize, weight: .medium) } + /// EVERY chrome text label (board name, zoom %, chip text). + static var labelFont: Font { .system(size: 13, weight: .medium) } + /// Inner horizontal padding for a text-bearing control inside a pill (icons are square and + /// need none). + static let labelPadH: CGFloat = 10 + /// Spacing between sibling controls inside one pill/bar. + static let itemSpacing: CGFloat = 4 +} + +extension View { + /// THE one wrapper for every floating chrome pill and bar: identical padding, radius, and glass. + /// Views never add their own surface padding — wrap the control row in this and it is, by + /// construction, the same size as every other pill. + func chromePill() -> some View { + self + .padding(.horizontal, WindowChrome.padH) + .padding(.vertical, WindowChrome.padV) + .composerPopupSurface(radius: WindowChrome.radius) + } +} + // MARK: - Adaptive colors private enum Adaptive { @@ -227,42 +266,28 @@ extension View { func composerPopupSurface(radius: CGFloat = Theme.Radius.menu) -> some View { floatingGlass(RoundedRectangle(cornerRadius: radius, style: .continuous)) } + + /// Background for the Agent / Settings panels floating over the canvas — plain Liquid Glass. + func dockPanelSurface(radius: CGFloat = Theme.Radius.panel) -> some View { + floatingGlass(RoundedRectangle(cornerRadius: radius, style: .continuous)) + } } // MARK: - Panel backdrop -/// The frosted, rounded, scrimmed card the whole canvas sits on. +/// The canvas backdrop: the solid board surface (black in dark, paper white in light) over a +/// behind-window desktop blur. At the default 0 transparency the surface is fully opaque — +/// indistinguishable from solid; sliding up recedes it so the frosted desktop shows through. struct ComposerPanelBackground: View { - var radius: CGFloat = Theme.Radius.panel - @AppStorage(ComposerPreferences.panelTransparencyKey) private var panelTransparency = ComposerPreferences.defaultPanelTransparency + @AppStorage(ComposerPreferences.canvasTransparencyKey) private var canvasTransparency = 0.0 var body: some View { - let shape = RoundedRectangle(cornerRadius: radius, style: .continuous) - // 0 = Opaque, maxPanelTransparency = Glass. Normalize to 0…1 so the tint sweeps a - // wide, obviously-live range as the slider moves. - let glass = ComposerPreferences.clampedPanelTransparency(panelTransparency) / ComposerPreferences.maxPanelTransparency - let tint = 0.80 - 0.58 * glass - + let glass = ComposerPreferences.clampedCanvasTransparency(canvasTransparency) + / ComposerPreferences.maxCanvasTransparency ZStack { - // Genuine frosted glass: `.behindWindow` samples and blurs the desktop behind the - // panel (Spotlight / Control Center vibrancy), not just content within the window. VisualEffectBackground(material: .hudWindow, blending: .behindWindow, state: .active) - - // Legibility tint over the blur — recedes toward Glass, deepens toward Opaque. - Color.black.opacity(tint) - - // Top sheen → clear → faint floor gives the slab depth. - LinearGradient( - stops: [ - .init(color: Theme.Palette.panelTopSheen, location: 0), - .init(color: Color.clear, location: 0.34), - .init(color: Theme.Palette.panelBottomShade, location: 1) - ], - startPoint: .top, - endPoint: .bottom - ) + Theme.Palette.windowCanvas.opacity(1.0 - 0.65 * glass) } - .clipShape(shape) .ignoresSafeArea() } } diff --git a/Sources/ComposerApp/Support/ThemeFlavor.swift b/Sources/ComposerApp/Support/ThemeFlavor.swift new file mode 100644 index 0000000..c117e63 --- /dev/null +++ b/Sources/ComposerApp/Support/ThemeFlavor.swift @@ -0,0 +1,70 @@ +import AppKit + +/// One complete theme: a semantic palette plus its appearance class. `Theme.Palette` tokens map +/// roles onto these slots, so adding a theme is a data change — never a view change. +/// +/// Slot semantics follow Catppuccin's model: `text` > `subtext` (secondary ink) > `overlay` +/// (dim ink, hairlines) > `surface` (fills) > `base`/`mantle`/`crust` (backgrounds). +struct ThemeFlavor { + let isDark: Bool + let text: NSColor + let subtext1: NSColor + let subtext0: NSColor + let overlay2: NSColor + let overlay1: NSColor + let overlay0: NSColor + let surface2: NSColor + let surface1: NSColor + let surface0: NSColor + let base: NSColor + let mantle: NSColor + let crust: NSColor + /// The one accent (selection, active tool, send). + let accent: NSColor + /// Informational tint (link-ish chips without a brand color). + let info: NSColor +} + +extension ThemeFlavor { + private static func hex(_ value: UInt32) -> NSColor { + NSColor(srgbRed: CGFloat((value >> 16) & 0xFF) / 255.0, + green: CGFloat((value >> 8) & 0xFF) / 255.0, + blue: CGFloat(value & 0xFF) / 255.0, alpha: 1.0) + } + + /// BonsAI's original dark look: stone ink (#F5F4EF family) on pure black. + static let bonsaiDark = ThemeFlavor( + isDark: true, + text: hex(0xE3E2DD), subtext1: hex(0xA5A4A0), subtext0: hex(0x9B9A96), + overlay2: hex(0x807F7C), overlay1: hex(0x585856), overlay0: hex(0x403F3E), + surface2: hex(0x2A2A28), surface1: hex(0x1F1F1E), surface0: hex(0x161615), + base: hex(0x000000), mantle: hex(0x2B2B2B), crust: hex(0x000000), + accent: NSColor.controlAccentColor, info: NSColor.controlAccentColor) + + /// BonsAI's original light look: #575757 ink on soft stone paper (#F5F4EF). + static let bonsaiLight = ThemeFlavor( + isDark: false, + text: hex(0x575757), subtext1: hex(0x6B6B69), subtext0: hex(0x757572), + overlay2: hex(0x8F8F8B), overlay1: hex(0x9B9B96), overlay0: hex(0xACACA6), + surface2: hex(0xC4C3BC), surface1: hex(0xD3D2CA), surface0: hex(0xDEDDD5), + base: hex(0xF5F4EF), mantle: hex(0xFAF9F5), crust: hex(0xEBEAE4), + accent: NSColor.controlAccentColor, info: NSColor.controlAccentColor) + + /// Catppuccin Mocha (catppuccin.com) — accent mauve. + static let catppuccinMocha = ThemeFlavor( + isDark: true, + text: Catppuccin.mocha.text, subtext1: Catppuccin.mocha.subtext1, subtext0: Catppuccin.mocha.subtext0, + overlay2: Catppuccin.mocha.overlay2, overlay1: Catppuccin.mocha.overlay1, overlay0: Catppuccin.mocha.overlay0, + surface2: Catppuccin.mocha.surface2, surface1: Catppuccin.mocha.surface1, surface0: Catppuccin.mocha.surface0, + base: Catppuccin.mocha.base, mantle: Catppuccin.mocha.mantle, crust: Catppuccin.mocha.crust, + accent: Catppuccin.mocha.mauve, info: Catppuccin.mocha.blue) + + /// Catppuccin Latte — accent mauve. + static let catppuccinLatte = ThemeFlavor( + isDark: false, + text: Catppuccin.latte.text, subtext1: Catppuccin.latte.subtext1, subtext0: Catppuccin.latte.subtext0, + overlay2: Catppuccin.latte.overlay2, overlay1: Catppuccin.latte.overlay1, overlay0: Catppuccin.latte.overlay0, + surface2: Catppuccin.latte.surface2, surface1: Catppuccin.latte.surface1, surface0: Catppuccin.latte.surface0, + base: Catppuccin.latte.base, mantle: Catppuccin.latte.mantle, crust: Catppuccin.latte.crust, + accent: Catppuccin.latte.mauve, info: Catppuccin.latte.blue) +} diff --git a/Sources/ComposerApp/Support/WorkspaceLayout.swift b/Sources/ComposerApp/Support/WorkspaceLayout.swift deleted file mode 100644 index 8af5981..0000000 --- a/Sources/ComposerApp/Support/WorkspaceLayout.swift +++ /dev/null @@ -1,13 +0,0 @@ -import SwiftUI - -/// Transient geometry shared between the AppKit workspace controller and the SwiftUI board. -/// The board's window becomes narrower when a companion panel opens, but the top toolbar belongs -/// to the composed workspace, so its visual center must come from the controller that owns both. -@MainActor -final class WorkspaceLayout: ObservableObject { - static let shared = WorkspaceLayout() - - @Published var toolbarCenterX: CGFloat = 0 - - private init() {} -} diff --git a/Sources/ComposerApp/Views/AgentDock.swift b/Sources/ComposerApp/Views/AgentDock.swift index 3cbad74..18a401d 100644 --- a/Sources/ComposerApp/Views/AgentDock.swift +++ b/Sources/ComposerApp/Views/AgentDock.swift @@ -1,7 +1,10 @@ import SwiftUI +import AppKit -/// The companion chat window for the canvas. You talk; it edits the board via the canvas MCP while -/// remaining a distinct, right-docked glass panel. +/// The agent chat panel floating over the canvas. Modern chat layout: a slim identity header, +/// the transcript, and one input container that carries the composer plus its context controls +/// (model, grounding) on a bottom row — like every current chat app, instead of a pill-crowded +/// header. struct AgentDock: View { @ObservedObject var agent: CanvasAgent /// Sized by the canvas relative to the window so the dock adapts to the display. @@ -13,9 +16,9 @@ struct AgentDock: View { /// 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. + /// Keep the grounding chip compact: at most 12 characters, then an ellipsis. static func trimmed(_ name: String) -> String { - name.count > 8 ? String(name.prefix(8)) + "\u{2026}" : name + name.count > 12 ? String(name.prefix(12)) + "\u{2026}" : name } var body: some View { @@ -24,72 +27,77 @@ struct AgentDock: View { Divider().overlay(Theme.Palette.separator) // The message list observes the transcript directly, so a streamed token re-renders only it — // not this dock's header/input, and never the canvas (which observes the agent's coarse state). - AgentTranscriptView(transcript: agent.transcript, isRunning: agent.isRunning) - inputBar + AgentTranscriptView( + transcript: agent.transcript, + isRunning: agent.isRunning, + onSuggest: { agent.send($0) } + ) + inputArea } .frame(width: width) .frame(maxHeight: .infinity) - // Identical glass to the main window — same frosted treatment, tint, and corner radius — so the - // dock reads as a second panel floating beside the card. The panel's own drop shadow grounds it. - .background(ComposerPanelBackground(radius: Theme.Radius.panel)) - .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.panel, style: .continuous)) + // Liquid Glass floating over the canvas; its own drop shadow grounds it. + .dockPanelSurface() } - // MARK: Header + // MARK: Header — identity only; context controls live with the composer below. private var header: some View { - HStack(spacing: 10) { - AgentEngineIcon(size: 17) - Text("Agent").font(.body.weight(.semibold)).foregroundStyle(Theme.Palette.body) - if agent.isRunning { ProgressView().controlSize(.small).scaleEffect(0.62) } + HStack(spacing: 9) { + AgentEngineIcon(size: 16) + Text("Agent").font(.callout.weight(.semibold)).foregroundStyle(Theme.Palette.body) + if agent.isRunning { ProgressView().controlSize(.small).scaleEffect(0.55) } Spacer(minLength: 8) - modelControl - groundingControl - HStack(spacing: 2) { - iconButton("arrow.counterclockwise", help: "New conversation") { agent.reset(); draft = "" } - iconButton("xmark", help: "Close ⌘J", action: onClose) - } + iconButton("arrow.counterclockwise", help: "New conversation") { agent.reset(); draft = "" } + iconButton("xmark", help: "Close ⌘J", action: onClose) } - .padding(.leading, 16).padding(.trailing, 12).frame(height: 52) + .padding(.leading, 14).padding(.trailing, 10).frame(height: 46) } - @ViewBuilder - private var groundingControl: some View { - if let dir = agent.groundingDirectory { - // One capsule, two targets: the name changes the folder; the trailing ✕ un-grounds the - // board back to canvas-only. (Before, there was no way to remove a grounding once set.) - HStack(spacing: 6) { - Button { agent.chooseDirectory() } label: { - HStack(spacing: 5) { - Image(systemName: "folder.fill").font(.system(size: 10.5)) - Text(Self.trimmed(dir.lastPathComponent)).font(.caption.weight(.medium)).lineLimit(1).fixedSize() + // MARK: Input — one container: composer on top, context chips + send below. + + private var inputArea: some View { + VStack(alignment: .leading, spacing: 9) { + TextField("Message the agent…", text: $draft, axis: .vertical) + .textFieldStyle(.plain) + .lineLimit(1...6) + .font(.callout) + .foregroundStyle(Theme.Palette.body) + .focused($inputFocused) + // Enter sends; Shift+Enter inserts a newline at the caret — the standard chat convention + // (Slack, Discord, Linear). We must handle BOTH keys ourselves. Returning `.ignored` for + // Shift+Return (the previous fix) let the event fall through to the field editor, which on a + // Return selected all the text instead of breaking the line. So for Shift+Return we insert the + // line break directly into the focused field editor — while editing, the key window's first + // responder is the NSTextView backing this TextField (the panel relies on the same fact, see + // FloatingPanel.performKeyEquivalent). The insert routes through the normal text-change path, + // so `draft` updates and the field auto-grows. See https://github.com/ojowwalker77/BonsAI/issues/27. + .onKeyPress(.return, phases: .down) { keyPress in + guard keyPress.modifiers.contains(.shift) else { submit(); return .handled } + if let editor = NSApp.keyWindow?.firstResponder as? NSTextView { + editor.insertNewlineIgnoringFieldEditor(nil) + } else { + draft.append("\n") // fallback: no field editor in reach — append rather than drop the break } - .foregroundStyle(Theme.Palette.body) - .contentShape(Rectangle()) + return .handled } - .buttonStyle(.plain) - .help("Grounded in \(dir.path) — click to change") - Button { agent.setGroundingDirectory(nil) } label: { - Image(systemName: "xmark") - .font(.system(size: 9, weight: .bold)) - .foregroundStyle(Theme.Palette.title) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .help("Remove grounding — back to canvas-only") + HStack(spacing: 6) { + modelChip + groundingChip + Spacer(minLength: 8) + sendButton } - .padding(.horizontal, 9).frame(height: 24) - .background(Capsule().fill(Color.white.opacity(0.08))) - .overlay(Capsule().strokeBorder(Color.white.opacity(0.10), lineWidth: 1)) - } else { - iconButton("folder.badge.plus", help: "Ground the agent in a folder it can read") { agent.chooseDirectory() } } + .padding(.horizontal, 12).padding(.top, 10).padding(.bottom, 8) + .background(RoundedRectangle(cornerRadius: WindowChrome.radius, style: .continuous).fill(Theme.Palette.rowFill)) + .overlay(RoundedRectangle(cornerRadius: WindowChrome.radius, style: .continuous).strokeBorder(Theme.Palette.panelHairline, lineWidth: 1)) + .padding(12) + .onAppear { inputFocused = true } } - /// 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 { + /// Quiet model selector chip: the current model + a chevron, checkmarked menu on click. + private var modelChip: some View { Menu { Picker("Model", selection: $chatModel) { ForEach(ClaudeModel.allCases) { model in @@ -97,15 +105,13 @@ struct AgentDock: View { } } } label: { - HStack(spacing: 5) { - Image(systemName: "cpu").font(.system(size: 10.5)) + HStack(spacing: 4) { Text(chatModel.title).font(.caption.weight(.medium)).lineLimit(1).fixedSize() - Image(systemName: "chevron.up.chevron.down").font(.system(size: 7, weight: .semibold)) + Image(systemName: "chevron.down").font(.system(size: 7, weight: .bold)) } - .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)) + .foregroundStyle(Theme.Palette.menuDesc) + .padding(.horizontal, 9).frame(height: 22) + .background(Capsule().fill(Theme.Palette.keycapFill)) .contentShape(Capsule()) } .menuStyle(.button) @@ -115,43 +121,79 @@ struct AgentDock: View { .help("Model for the agent chat — mirrors Settings ▸ Runtime") } - // MARK: Input + /// Grounding chip: folder name (click to change) + ✕ to un-ground; a quiet add-chip when unset. + @ViewBuilder + private var groundingChip: some View { + if let dir = agent.groundingDirectory { + HStack(spacing: 6) { + Button { agent.chooseDirectory() } label: { + HStack(spacing: 4) { + Image(systemName: "folder.fill").font(.system(size: 9.5)) + Text(Self.trimmed(dir.lastPathComponent)).font(.caption.weight(.medium)).lineLimit(1).fixedSize() + } + .foregroundStyle(Theme.Palette.menuDesc) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help("Grounded in \(dir.path) — click to change") - private var inputBar: some View { - HStack(alignment: .bottom, spacing: 8) { - TextField("Message the agent…", text: $draft, axis: .vertical) - .textFieldStyle(.plain) - .lineLimit(1...6) - .font(.callout) - .foregroundStyle(Theme.Palette.body) - .focused($inputFocused) - // Enter sends; Shift+Enter inserts a newline — the standard chat convention (Slack, Discord, - // Linear). `.onSubmit` fired on every Return, including Shift+Return, so a shifted Return sent - // instead of breaking the line. We intercept the key instead: plain Return we consume and - // submit; for Shift+Return we return `.ignored` so the field editor inserts the break at the - // caret. See https://github.com/ojowwalker77/BonsAI/issues/27. - .onKeyPress(.return, phases: .down) { keyPress in - if keyPress.modifiers.contains(.shift) { return .ignored } - submit() - return .handled + Button { agent.setGroundingDirectory(nil) } label: { + Image(systemName: "xmark") + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(Theme.Palette.title) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help("Remove grounding — back to canvas-only") + } + .padding(.horizontal, 9).frame(height: 22) + .background(Capsule().fill(Theme.Palette.keycapFill)) + } else { + Button { agent.chooseDirectory() } label: { + HStack(spacing: 4) { + Image(systemName: "folder.badge.plus").font(.system(size: 9.5)) + Text("Ground").font(.caption.weight(.medium)) } + .foregroundStyle(Theme.Palette.menuDesc) + .padding(.horizontal, 9).frame(height: 22) + .background(Capsule().fill(Theme.Palette.keycapFill)) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .help("Ground the agent in a folder it can read") + } + } + + /// Modern send affordance: an accent-filled circle that reads as THE action; morphs into a + /// stop control while the agent runs. + private var sendButton: some View { + Group { if agent.isRunning { Button(action: agent.stop) { - Image(systemName: "stop.circle.fill").font(.title3).foregroundStyle(Theme.Palette.title) - }.buttonStyle(.plain).help("Stop") + Image(systemName: "stop.fill") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(Theme.Palette.body) + .frame(width: 26, height: 26) + .background(Circle().fill(Theme.Palette.keycapFill)) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .help("Stop") } else { Button(action: submit) { - Image(systemName: "arrow.up.circle.fill") - .font(.title3) - .foregroundStyle(canSend ? Color.accentColor : Theme.Palette.title.opacity(0.6)) - }.buttonStyle(.plain).disabled(!canSend) + Image(systemName: "arrow.up") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(canSend ? Color.white : Theme.Palette.chromeGlyphDim) + .frame(width: 26, height: 26) + .background(Circle().fill(canSend ? Theme.Palette.accent : Theme.Palette.keycapFill)) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .disabled(!canSend) + .help("Send · ⇧↩ for a new line") } } - .padding(.leading, 14).padding(.trailing, 10).padding(.vertical, 9) - .background(RoundedRectangle(cornerRadius: 13, style: .continuous).fill(Color.white.opacity(0.06))) - .overlay(RoundedRectangle(cornerRadius: 13, style: .continuous).strokeBorder(Color.white.opacity(0.09), lineWidth: 1)) - .padding(12) - .onAppear { inputFocused = true } + .animation(.easeOut(duration: 0.12), value: canSend) } private var canSend: Bool { !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } @@ -180,6 +222,8 @@ struct AgentDock: View { private struct AgentTranscriptView: View { @ObservedObject var transcript: AgentTranscript let isRunning: Bool + /// Empty-state suggestion chips send their prompt straight to the agent. + var onSuggest: (String) -> Void var body: some View { ScrollViewReader { proxy in @@ -206,12 +250,19 @@ private struct AgentTranscriptView: View { } private var emptyState: some View { - VStack(alignment: .leading, spacing: 6) { - Text("Think out loud.").font(.body.weight(.medium)).foregroundStyle(Theme.Palette.body) - Text("The agent reads your board and edits it as you talk — adding, sharpening, and connecting cards. Try “read my board and tell me what's missing.”") - .font(.caption).foregroundStyle(Theme.Palette.menuDesc) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) + VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 5) { + Text("Think out loud").font(.body.weight(.semibold)).foregroundStyle(Theme.Palette.body) + Text("The agent reads your board and edits it as you talk — adding, sharpening, and connecting cards.") + .font(.caption).foregroundStyle(Theme.Palette.menuDesc) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + VStack(alignment: .leading, spacing: 6) { + SuggestionChip(text: "Read my board and tell me what's missing", onSuggest: onSuggest) + SuggestionChip(text: "Tidy the board and group related cards", onSuggest: onSuggest) + SuggestionChip(text: "Turn my notes into a build plan", onSuggest: onSuggest) + } } .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 8) @@ -224,13 +275,13 @@ private struct AgentTranscriptView: View { Text(message.text) .font(.callout).foregroundStyle(Theme.Palette.body) .padding(.horizontal, 11).padding(.vertical, 8) - .background(RoundedRectangle(cornerRadius: 11, style: .continuous).fill(Color.accentColor.opacity(0.20))) + .background(RoundedRectangle(cornerRadius: 11, style: .continuous).fill(Theme.Palette.accent.opacity(0.20))) .frame(maxWidth: .infinity, alignment: .trailing) case .assistant: Text(Self.markdown(message.text)) .font(.callout).foregroundStyle(Theme.Palette.body).textSelection(.enabled) .lineSpacing(2.5) - .tint(Color.accentColor) + .tint(Theme.Palette.accent) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) case .tool: @@ -243,7 +294,7 @@ private struct AgentTranscriptView: View { } .foregroundStyle(Theme.Palette.title) .padding(.horizontal, 9).padding(.vertical, 5) - .background(RoundedRectangle(cornerRadius: 8, style: .continuous).fill(Color.white.opacity(0.045))) + .background(RoundedRectangle(cornerRadius: 8, style: .continuous).fill(Theme.Palette.rowFill)) .frame(maxWidth: .infinity, alignment: .leading) .help(message.text) case .error: @@ -268,3 +319,28 @@ private struct AgentTranscriptView: View { options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(text) } } + +/// One tappable starter prompt on the empty state. +private struct SuggestionChip: View { + let text: String + var onSuggest: (String) -> Void + @State private var hovering = false + + var body: some View { + Button { Haptics.tap(); onSuggest(text) } label: { + HStack(spacing: 7) { + Image(systemName: "arrow.up.right") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(Theme.Palette.accent) + Text(text).font(.caption).foregroundStyle(Theme.Palette.body).lineLimit(1) + } + .padding(.horizontal, 10).frame(height: 28) + .background(Capsule().fill(hovering ? Theme.Palette.buttonHover : Theme.Palette.rowFill)) + .overlay(Capsule().strokeBorder(Theme.Palette.panelHairline, lineWidth: 1)) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .onHover { hovering = $0 } + .animation(.easeOut(duration: 0.1), value: hovering) + } +} diff --git a/Sources/ComposerApp/Views/AppSearchPanel.swift b/Sources/ComposerApp/Views/AppSearchPanel.swift index b487c59..245d088 100644 --- a/Sources/ComposerApp/Views/AppSearchPanel.swift +++ b/Sources/ComposerApp/Views/AppSearchPanel.swift @@ -131,7 +131,7 @@ struct AppSearchPanel: View { let on = state.githubKind == kind Text(kind.shortLabel) .font(.caption.weight(.medium)) - .foregroundStyle(on ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.secondary)) + .foregroundStyle(on ? AnyShapeStyle(Theme.Palette.accent) : AnyShapeStyle(.secondary)) .padding(.horizontal, 8).padding(.vertical, 3) .background(RoundedRectangle(cornerRadius: 5).fill(on ? Theme.Palette.accentFill : .clear)) .contentShape(Rectangle()) diff --git a/Sources/ComposerApp/Views/BoardCardView.swift b/Sources/ComposerApp/Views/BoardCardView.swift index 8c84f72..413c77a 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 } @@ -165,10 +165,12 @@ struct BoardCardView: View { .padding(.vertical, 7) .frame(width: min(max(liveFrame.width - 20, 120), 220)) .background( + // Same solid adaptive chip as the rendered label, so entering/leaving edit doesn't flash + // between a dark editor and a themed chip. RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(Color.black.opacity(0.36)) + .fill(Theme.Palette.labelChipFill) .overlay(RoundedRectangle(cornerRadius: 8, style: .continuous) - .strokeBorder(Color.white.opacity(0.14), lineWidth: 1)) + .strokeBorder(Theme.Palette.panelHairline, lineWidth: 1)) ) .focused($labelFocused) .onSubmit { board.endEditing(card.id) } @@ -234,14 +236,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(Theme.Palette.accent.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) } @@ -272,7 +280,7 @@ struct BoardCardView: View { RoundedRectangle(cornerRadius: 2.5, style: .continuous) .fill(Color.white) .frame(width: 8, height: 8) - .overlay(RoundedRectangle(cornerRadius: 2.5, style: .continuous).strokeBorder(Color.accentColor.opacity(0.9), lineWidth: 1)) + .overlay(RoundedRectangle(cornerRadius: 2.5, style: .continuous).strokeBorder(Theme.Palette.accent.opacity(0.9), lineWidth: 1)) .shadow(color: .black.opacity(0.35), radius: 2, y: 1) .padding(9) .contentShape(Rectangle()) @@ -433,8 +441,9 @@ private struct NodeLabel: View { .multilineTextAlignment(.center) .lineLimit(5) .minimumScaleFactor(0.82) - .foregroundStyle(Color.white.opacity(0.95)) - .shadow(color: .black.opacity(0.45), radius: 3, y: 1) + // Board ink, not white — and ink on paper casts no shadow (elementShadow is clear in light). + .foregroundStyle(Theme.Palette.body) + .shadow(color: Theme.Palette.elementShadow, radius: 3, y: 1) .padding(.horizontal, 12 * zoom) .padding(.vertical, 8 * zoom) .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -541,7 +550,7 @@ private struct ComposerChipText: View { let isApp = item?.kind == .app let label = isApp ? AppToken.label(appID: appID, selection: parsed?.selection) : (item?.label ?? raw) let cache = MentionStyleCache.shared - let color = Color(nsColor: cache.color(for: appID) ?? .controlAccentColor) + let color = Color(nsColor: cache.color(for: appID) ?? Theme.Palette.nsAccent) var chip = Text(verbatim: "") // Build the inline icon at the zoomed size so the brand mark stays crisp alongside the text. @@ -563,16 +572,17 @@ private struct CanvasLabel: View { .font(.system(size: 14 * zoom, weight: .semibold)) .lineLimit(2) .multilineTextAlignment(.center) - .foregroundStyle(Color.white.opacity(0.90)) + .foregroundStyle(Theme.Palette.body) .padding(.horizontal, 9 * zoom) .padding(.vertical, 5 * zoom) .background( + // Solid fill — a translucent chip lets its own drop shadow bleed through and muddies it. RoundedRectangle(cornerRadius: 7, style: .continuous) - .fill(Color.black.opacity(0.34)) + .fill(Theme.Palette.labelChipFill) .overlay(RoundedRectangle(cornerRadius: 7, style: .continuous) - .strokeBorder(Color.white.opacity(0.12), lineWidth: 1)) + .strokeBorder(Theme.Palette.panelHairline, lineWidth: 1)) ) - .shadow(color: .black.opacity(0.18), radius: 5, y: 2) + .shadow(color: .black.opacity(0.12), radius: 4, y: 1) .padding(8) .allowsHitTesting(false) } @@ -583,9 +593,9 @@ private struct ShapeBox: View { var body: some View { BoxShape(kind: kind) - .fill(Color.black.opacity(0.22)) - .overlay(BoxShape(kind: kind).stroke(Color.white.opacity(0.72), lineWidth: 2)) - .shadow(color: .black.opacity(0.22), radius: 10, y: 4) + .fill(Theme.Palette.elementFill) + .overlay(BoxShape(kind: kind).stroke(Theme.Palette.elementStroke, lineWidth: 2)) + .shadow(color: Theme.Palette.elementShadow, radius: 10, y: 4) .padding(2) } } @@ -643,8 +653,8 @@ private struct LineShape: View { } } } - .stroke(Color.white.opacity(0.78), style: StrokeStyle(lineWidth: 3, lineCap: .round, lineJoin: .round)) - .shadow(color: .black.opacity(0.20), radius: 6, y: 3) + .stroke(Theme.Palette.elementStroke, style: StrokeStyle(lineWidth: 3, lineCap: .round, lineJoin: .round)) + .shadow(color: Theme.Palette.elementShadow, radius: 6, y: 3) } } } @@ -660,8 +670,8 @@ private struct FreehandShape: View { path.move(to: first) for point in mapped.dropFirst() { path.addLine(to: point) } } - .stroke(Color.white.opacity(0.78), style: StrokeStyle(lineWidth: 3, lineCap: .round, lineJoin: .round)) - .shadow(color: .black.opacity(0.20), radius: 6, y: 3) + .stroke(Theme.Palette.elementStroke, style: StrokeStyle(lineWidth: 3, lineCap: .round, lineJoin: .round)) + .shadow(color: Theme.Palette.elementShadow, radius: 6, y: 3) } } } @@ -676,13 +686,16 @@ 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)) + .strokeBorder(Theme.Palette.panelHairline, lineWidth: 1)) .shadow(color: .black.opacity(0.18), radius: 10, y: 4) } else { RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(Color.black.opacity(0.16)) + .fill(Theme.Palette.elementFill) .overlay { VStack(spacing: 8) { Image(systemName: "photo") @@ -691,11 +704,11 @@ private struct ImageObjectPlaceholder: View { .font(.caption.weight(.medium)) .lineLimit(1) } - .foregroundStyle(Color.white.opacity(0.72)) + .foregroundStyle(Theme.Palette.chromeText) .padding(10) } .overlay(RoundedRectangle(cornerRadius: 8, style: .continuous) - .strokeBorder(Color.white.opacity(0.26), style: StrokeStyle(lineWidth: 1.5, dash: [6, 5]))) + .strokeBorder(Theme.Palette.chromeDivider, style: StrokeStyle(lineWidth: 1.5, dash: [6, 5]))) .shadow(color: .black.opacity(0.18), radius: 10, y: 4) } } diff --git a/Sources/ComposerApp/Views/BoardViewModel.swift b/Sources/ComposerApp/Views/BoardViewModel.swift index 376980d..3e75809 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 @@ -133,9 +134,9 @@ 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, 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 } @@ -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 diff --git a/Sources/ComposerApp/Views/CanvasToolbar.swift b/Sources/ComposerApp/Views/CanvasToolbar.swift index 3628118..0e422af 100644 --- a/Sources/ComposerApp/Views/CanvasToolbar.swift +++ b/Sources/ComposerApp/Views/CanvasToolbar.swift @@ -37,25 +37,10 @@ enum CanvasTool: Equatable { } } -/// The floating top tool cluster — the canvas's analog of the left `Sidebar`, using the same -/// `railSurface()` recipe so it reads as a sibling rail floating above the card. Holds the -/// canvas tools + zoom + the board Copy. (The agent and its grounding folder live on the left -/// `Sidebar`, grouped with the other board-session actions.) +/// The canvas tool cluster — the eight placement/selection tools, rendered bare so the bottom +/// command bar can lay it alongside zoom and session utilities under one shared glass surface. struct CanvasToolbar: View { @Binding var tool: CanvasTool - let zoomPercent: Int - /// Describe Board — Claude reads the whole board and writes a self-contained description. - var onCopy: () -> Void - /// Copy Board — deterministic self-contained render (expands @connectors and copy-time shell). - var onCopyBoard: () -> Void - /// True while Describe Board's `claude -p` call is in flight — its button shows a spinner. - var isCopying: Bool - /// True while Copy Board's shell expansion runs — its button shows a spinner and is inert. - var isCopyingBoard: Bool = false - var onZoomOut: () -> Void - var onZoomIn: () -> Void - var onZoomReset: () -> Void - var onFit: () -> Void var body: some View { HStack(spacing: 5) { @@ -75,46 +60,14 @@ struct CanvasToolbar: View { active: tool == .arrow, shortcut: 7) { tool = .arrow } ToolButton(symbol: "scribble.variable", help: "Freehand stroke · drag to draw ⌘8", active: tool == .freehand, shortcut: 8) { tool = .freehand } - - divider - - ToolButton(symbol: "minus.magnifyingglass", help: "Zoom out", action: onZoomOut) - Button(action: onZoomReset) { - Text("\(zoomPercent)%") - .font(.caption.monospacedDigit().weight(.medium)) - .foregroundStyle(Color.white.opacity(0.82)) - .frame(width: 44, height: 30) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .help("Reset to 100%") - ToolButton(symbol: "plus.magnifyingglass", help: "Zoom in", action: onZoomIn) - ToolButton(symbol: "arrow.up.left.and.down.right.magnifyingglass", help: "Fit board", action: onFit) - - divider - - TextToolButton(title: "Describe Board", - help: "Claude reads the whole board and writes a self-contained description", - busy: isCopying, action: onCopy) - TextToolButton(title: "Copy Board", - help: "Copy self-contained text · expands @connectors and copy-time $(shell)", - busy: isCopyingBoard, action: onCopyBoard) } - .padding(.horizontal, 8) - .padding(.vertical, 5) - .railSurface() - } - - private var divider: some View { - Rectangle().fill(Color.white.opacity(0.12)).frame(width: 1, height: 20).padding(.horizontal, 2) } } -/// Square dimensions for a toolbar control — the glyph is sized to match the left `Sidebar`'s -/// 17pt icons so the two rails read as siblings. +/// Tool buttons share the chrome grid — same square, same glyph size as every other control. private enum ToolMetrics { - static let side: CGFloat = 34 - static let icon: CGFloat = 17 + static let side: CGFloat = WindowChrome.controlHeight + static let icon: CGFloat = WindowChrome.iconSize } private struct ToolButton: View { @@ -131,15 +84,15 @@ private struct ToolButton: View { @State private var hovering = false var body: some View { - Button(action: action) { + Button(action: { Haptics.tap(); action() }) { Group { if busy { ProgressView() .controlSize(.small) - .tint(Color.white.opacity(0.9)) + .tint(Theme.Palette.chromeGlyphHover) } else { Image(systemName: symbol) - .font(.system(size: ToolMetrics.icon, weight: .medium)) + .font(WindowChrome.iconFont) .foregroundStyle(foreground) } } @@ -148,13 +101,13 @@ private struct ToolButton: View { // background is a neutral hover wash. .background( RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(hovering && !disabled && !busy ? Color.white.opacity(0.12) : Color.clear) + .fill(hovering && !disabled && !busy ? Theme.Palette.hoverWash : Color.clear) ) .overlay(alignment: .bottomTrailing) { if let shortcut, !busy { Text("\(shortcut)") .font(.system(size: 8, weight: .bold)) - .foregroundStyle(active ? Color.accentColor : Color.white.opacity(hovering ? 0.6 : 0.34)) + .foregroundStyle(active ? Theme.Palette.accent : (hovering ? Theme.Palette.chromeGlyph : Theme.Palette.chromeBadge)) .padding(.trailing, 3).padding(.bottom, 2) } } @@ -168,48 +121,8 @@ private struct ToolButton: View { } private var foreground: AnyShapeStyle { - if disabled { return AnyShapeStyle(Color.white.opacity(0.26)) } - if active { return AnyShapeStyle(Color.accentColor) } - return AnyShapeStyle(Color.white.opacity(hovering ? 0.95 : 0.62)) - } -} - -/// A labelled board action (Describe Board, Copy Board) — same hover wash and busy-spinner recipe -/// as `ToolButton`, but reads in plain English instead of a glyph. Sized to the rail's height so it -/// sits flush with the tool icons. -private struct TextToolButton: View { - let title: String - let help: String - var busy = false - var action: () -> Void - @State private var hovering = false - - var body: some View { - Button(action: action) { - Group { - if busy { - ProgressView() - .controlSize(.small) - .tint(Color.white.opacity(0.9)) - } else { - Text(title) - .font(.caption.weight(.bold)) - .foregroundStyle(Color.white.opacity(hovering ? 0.98 : 0.78)) - .fixedSize() - } - } - .frame(height: ToolMetrics.side) - .padding(.horizontal, 11) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(hovering && !busy ? Color.white.opacity(0.12) : Color.clear) - ) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(busy) - .onHover { hovering = $0 } - .help(help) - .animation(.easeOut(duration: 0.12), value: hovering) + if disabled { return AnyShapeStyle(Theme.Palette.chromeGlyphDim) } + if active { return AnyShapeStyle(Theme.Palette.accent) } + return AnyShapeStyle(hovering ? Theme.Palette.chromeGlyphHover : Theme.Palette.chromeGlyph) } } diff --git a/Sources/ComposerApp/Views/CommandPalette.swift b/Sources/ComposerApp/Views/CommandPalette.swift index 71e23b0..144607b 100644 --- a/Sources/ComposerApp/Views/CommandPalette.swift +++ b/Sources/ComposerApp/Views/CommandPalette.swift @@ -198,7 +198,7 @@ struct CommandPalette: View { private func boardRow(_ dump: Dump, selected: Bool) -> some View { HStack(spacing: 10) { Circle() - .fill(dump.persistentModelID == store.currentID ? Color.accentColor : Color.clear) + .fill(dump.persistentModelID == store.currentID ? Theme.Palette.accent : Color.clear) .frame(width: 6, height: 6) Text(dump.title.isEmpty ? "Empty draft" : dump.title) .font(Theme.Typography.menuName) @@ -219,7 +219,7 @@ struct CommandPalette: View { HStack(spacing: 10) { Image(systemName: command.symbol) .font(.body) - .foregroundStyle(selected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(Theme.Palette.menuDesc)) + .foregroundStyle(selected ? AnyShapeStyle(Theme.Palette.accent) : AnyShapeStyle(Theme.Palette.menuDesc)) .frame(width: 18) Text(command.title) .font(Theme.Typography.menuName) diff --git a/Sources/ComposerApp/Views/CompiledDraftOverlay.swift b/Sources/ComposerApp/Views/CompiledDraftOverlay.swift index 569f70a..77a94b4 100644 --- a/Sources/ComposerApp/Views/CompiledDraftOverlay.swift +++ b/Sources/ComposerApp/Views/CompiledDraftOverlay.swift @@ -35,7 +35,7 @@ struct CompiledDraftOverlay: View { private var header: some View { HStack(spacing: 10) { - Image(systemName: "wand.and.rays").foregroundStyle(Color.accentColor) + Image(systemName: "wand.and.rays").foregroundStyle(Theme.Palette.accent) Text("Compiled draft") .font(.title2.weight(.semibold)) .foregroundStyle(Theme.Palette.body) @@ -46,7 +46,7 @@ struct CompiledDraftOverlay: View { Text("Copy") } .font(.body.weight(.medium)) - .foregroundStyle(Color.accentColor) + .foregroundStyle(Theme.Palette.accent) .padding(.horizontal, 12) .frame(height: 30) .background(RoundedRectangle(cornerRadius: 8, style: .continuous).fill(Theme.Palette.accentFill)) diff --git a/Sources/ComposerApp/Views/ComposerCanvas.swift b/Sources/ComposerApp/Views/ComposerCanvas.swift index 6573925..b40d307 100644 --- a/Sources/ComposerApp/Views/ComposerCanvas.swift +++ b/Sources/ComposerApp/Views/ComposerCanvas.swift @@ -10,16 +10,10 @@ struct ComposerCanvas: View { @StateObject private var store = DumpStore.shared @StateObject private var board = BoardViewModel() @ObservedObject private var engineCapabilities = EngineCapabilityStore.shared - @ObservedObject private var workspaceLayout = WorkspaceLayout.shared @ObservedObject private var userFacingErrors = UserFacingErrorStore.shared @State private var tool: CanvasTool = .select @State private var isWorking = false - /// Scoped to the toolbar Copy button so only it shows a spinner while its `claude -p` describe - /// runs (`isWorking` also covers compile/refine, which shouldn't spin the Copy glyph). - @State private var isDescribing = false - /// True while Copy Board's shell expansion is in flight — disables the button and shows a spinner. - @State private var isCopyingBoard = false @State private var toast: Toast? @State private var lastViewportSize: CGSize = .zero @State private var selectionRect: CGRect? @@ -30,10 +24,16 @@ struct ComposerCanvas: View { /// Observed for the agent's *coarse* state (isRunning / grounding) so the toolbar and ⌘K palette /// stay in sync. The streaming transcript lives on `agent.transcript`, which the canvas does NOT /// observe, so per-token updates re-render only the dock — never the board. - @StateObject private var agent = CanvasAgent() + @ObservedObject private var agent = CanvasAgent.shared @State private var showAgent = false /// The ⌘K command palette (board switcher + buried board-level actions) is showing. @State private var showPalette = false + /// The board picker opens on hover; a short grace timer stops it flickering while the pointer + /// crosses the gap between the pill and the list. While a row is renaming or confirming a + /// delete, the panel is pinned open regardless of hover. + @State private var boardPickerOpen = false + @State private var boardPickerPinned = false + @State private var boardPickerCloseWork: DispatchWorkItem? /// The card that held the caret when the palette was summoned, captured before the palette's /// search field steals first responder — so a cancel can hand editing back to it. @State private var paletteReturnCardID: UUID? @@ -68,11 +68,7 @@ struct ComposerCanvas: View { @ViewBuilder private func canvasRoot(proxy: GeometryProxy) -> some View { - let layout = CanvasSurfaceLayout(windowSize: proxy.size) - let inner = layout.cardSize - let toolbarCenterX = workspaceLayout.toolbarCenterX > 0 - ? workspaceLayout.toolbarCenterX - : proxy.size.width / 2 + let inner = proxy.size ZStack(alignment: .topLeading) { ZStack(alignment: .topLeading) { ComposerPanelBackground() @@ -80,9 +76,7 @@ struct ComposerCanvas: View { compiledOverlay toastView } - .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.panel, style: .continuous)) - .frame(width: layout.cardSize.width, height: layout.cardSize.height, alignment: .topLeading) - .offset(x: layout.cardOrigin.x, y: layout.cardOrigin.y) + .frame(width: inner.width, height: inner.height, alignment: .topLeading) // Active-card overlays resolve through screen → window space, so they live in // full-window coordinates and keep working while the board itself is transformed. @@ -92,22 +86,18 @@ struct ComposerCanvas: View { size: proxy.size, isWorking: isWorking, onRefine: { refineSelection($0, card: editing) }, - onCopy: { copyBoard() }, onApplyFix: { editing.controller.applyLintFix(range: $0.range, expecting: $0.phrase, with: $1) }, onAskClaude: { askClaude(about: $0, card: editing) } ) .id(editing.id) } - // Rails float in the gutters; the history list opens over the card. - historyListOverlay(in: proxy.size) - sidebar(in: proxy.size) - toolbar( - fit: inner, - windowSize: proxy.size, - cardSize: layout.cardSize, - workspaceCenterX: toolbarCenterX - ) + // Floating chrome: board identity top-left (the pill IS the board manager), agent top-right, + // everything hands-on (tools, zoom, folder, settings) in one bottom command bar. + boardSwitcherPill(in: proxy.size) + boardActionsPill(in: proxy.size) + bottomCommandBar(fit: inner) + dockOverlay(in: proxy.size) commandPaletteOverlay(in: proxy.size) commandBridge } @@ -142,7 +132,6 @@ struct ComposerCanvas: View { private var navigationCommandBridge: some View { commandAnchor - .onReceive(NotificationCenter.default.publisher(for: .composerCopy)) { _ in copyBoard() } .onReceive(NotificationCenter.default.publisher(for: .composerCompileBoard)) { _ in runCompile() } .onReceive(NotificationCenter.default.publisher(for: .composerShowSettings)) { _ in openSettings() } .onReceive(NotificationCenter.default.publisher(for: .composerCaptureCompleted)) { note in @@ -152,14 +141,6 @@ struct ComposerCanvas: View { .onReceive(NotificationCenter.default.publisher(for: .composerPrevDump)) { _ in handlePrevDump() } .onReceive(NotificationCenter.default.publisher(for: .composerNextDump)) { _ in handleNextDump() } .onReceive(NotificationCenter.default.publisher(for: .composerNewDump)) { _ in handleNewDump() } - .onReceive(NotificationCenter.default.publisher(for: .composerDockDismissed)) { note in - guard let rawKind = note.userInfo?["kind"] as? String, - let kind = ComposerDockKind(rawValue: rawKind) else { return } - switch kind { - case .agent: showAgent = false - case .settings: store.isSettingsOpen = false - } - } } private var boardEditCommandBridge: some View { @@ -224,19 +205,16 @@ struct ComposerCanvas: View { show(Toast(text: "Captured on board", symbol: "leaf.fill", tint: .accentColor)) } - /// The agent and Settings share the single auxiliary-panel slot. + /// The agent and Settings share the single overlay slot, driven by `showAgent` / + /// `store.isSettingsOpen` — they float over the canvas as glass panels. private func toggleAgent() { - if showAgent { - showAgent = false - NotificationCenter.default.post(name: .composerDismissDock, object: nil) - } else { - showAgent = true - store.isSettingsOpen = false - NotificationCenter.default.post( - name: .composerPresentDock, - object: agent, - userInfo: ["kind": ComposerDockKind.agent.rawValue] - ) + withAnimation(Theme.Motion.accessory) { + if showAgent { + showAgent = false + } else { + showAgent = true + store.isSettingsOpen = false + } } } @@ -320,9 +298,9 @@ struct ComposerCanvas: View { private var selectionRectView: some View { if let rect = selectionRect, rect.width > 1, rect.height > 1 { RoundedRectangle(cornerRadius: 2, style: .continuous) - .fill(Color.accentColor.opacity(0.10)) + .fill(Theme.Palette.accent.opacity(0.10)) .overlay(RoundedRectangle(cornerRadius: 2, style: .continuous) - .strokeBorder(Color.accentColor.opacity(0.72), lineWidth: 1)) + .strokeBorder(Theme.Palette.accent.opacity(0.72), lineWidth: 1)) .frame(width: rect.width, height: rect.height) .position(x: rect.midX, y: rect.midY) .allowsHitTesting(false) @@ -337,7 +315,7 @@ struct ComposerCanvas: View { path.move(to: first) for point in points.dropFirst() { path.addLine(to: point) } } - .stroke(Color.white.opacity(0.82), style: StrokeStyle(lineWidth: 3, lineCap: .round, lineJoin: .round)) + .stroke(Theme.Palette.inkStroke, style: StrokeStyle(lineWidth: 3, lineCap: .round, lineJoin: .round)) .shadow(color: .black.opacity(0.22), radius: 5, y: 2) .allowsHitTesting(false) } @@ -427,70 +405,204 @@ struct ComposerCanvas: View { ) } - // MARK: Rails + overlays + // MARK: Floating chrome - private func sidebar(in windowSize: CGSize) -> some View { - Sidebar( - store: store, - groundedFolder: groundingPath.isEmpty ? nil : URL(fileURLWithPath: groundingPath).lastPathComponent, - agentOpen: showAgent, - onNew: { newBoard() }, - onHistory: { - store.isHistoryOpen.toggle() - if store.isHistoryOpen { closeAuxiliaryPanel() } - }, - onAgent: { toggleAgent() }, - onFolder: { agent.chooseDirectory() }, - onClearFolder: { agent.setGroundingDirectory(nil) }, - onSettings: { toggleSettings() } - ) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) - .padding(.leading, Theme.Size.railInset(in: windowSize.width)) - } - - private func toolbar( - fit innerSize: CGSize, - windowSize: CGSize, - cardSize: CGSize, - workspaceCenterX: CGFloat - ) -> some View { - CanvasToolbar( - tool: $tool, - zoomPercent: Int((effectiveScale * 100).rounded()), - onCopy: { describeBoard() }, - onCopyBoard: { copyBoard() }, - isCopying: isDescribing, - isCopyingBoard: isCopyingBoard, - onZoomOut: { zoom(0.8, anchoredAt: zoomAnchor) }, - onZoomIn: { zoom(1.25, anchoredAt: zoomAnchor) }, - onZoomReset: { withAnimation(Theme.Motion.accessory) { scale = 1 } }, - onFit: { withAnimation(Theme.Motion.accessory) { fitBoard(in: innerSize) } } - ) - // The board's host window narrows for Agent/Settings, but the toolbar belongs to the complete - // composed workspace. `workspaceCenterX` is supplied by the AppKit controller in board-window - // coordinates, so the controls remain centered across both panels. - .frame(width: cardSize.width, alignment: .top) - .frame(maxHeight: .infinity, alignment: .top) - .offset(x: workspaceCenterX - cardSize.width / 2) - .padding(.top, Theme.Size.toolbarInset(in: windowSize.height)) + /// The current board's display name for the standard-window pill (never empty, capped so the + /// pill hugs its content instead of stretching). + private var currentBoardName: String { + let name = store.current?.title.trimmed ?? "" + guard !name.isEmpty else { return "Untitled" } + return name.count > 32 ? String(name.prefix(32)) + "\u{2026}" : name } - @ViewBuilder - private func historyListOverlay(in size: CGSize) -> some View { - if store.isHistoryOpen { - ZStack { - Color.clear.contentShape(Rectangle()).onTapGesture { store.isHistoryOpen = false } - HistoryList( - store: store, - onPick: { pickBoard($0) }, - onDelete: { deleteBoard($0) }, - onRename: { renameBoard($0, to: $1) }, - onNew: { newBoard() } - ) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) - .padding(.leading, Theme.Size.railGutter(in: size.width) + size.width * 0.004) + /// The board pill floats top-left after the traffic lights. At rest it is just the current + /// board's name; hovering grows it into the board manager — every board with rename/delete, + /// plus a New board row. + private func boardSwitcherPill(in size: CGSize) -> some View { + boardPickerMenu + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding(.top, WindowChrome.edgeInset) + .padding(.leading, WindowChrome.trafficLightInset) + .zIndex(60) + } + + /// The board picker is ONE glass container. At rest it is just the current board's name; on + /// hover the same surface grows downward into the board manager — every board with + /// rename/delete, plus a New board row. No second popover, no gap: the pill itself expands. + private var boardPickerMenu: some View { + VStack(alignment: .leading, spacing: WindowChrome.itemSpacing) { + Text(currentBoardName) + .font(WindowChrome.labelFont) + .foregroundStyle(Theme.Palette.body) + .lineLimit(1) + .padding(.horizontal, WindowChrome.labelPadH) + .frame(height: WindowChrome.controlHeight) + + if boardPickerOpen { + // The expanded manager keeps a fixed width — it must never inherit the window's. + VStack(alignment: .leading, spacing: WindowChrome.itemSpacing) { + Divider().overlay(Theme.Palette.separator).padding(.horizontal, 2) + + ScrollView { + LazyVStack(alignment: .leading, spacing: WindowChrome.itemSpacing) { + ForEach(store.dumps, id: \.persistentModelID) { dump in + BoardPickerRow( + title: dump.title.isEmpty ? "Untitled" : String(dump.title.prefix(40)), + isCurrent: dump.persistentModelID == store.currentID, + onPick: { + Haptics.level() + boardPickerOpen = false + pickBoard(dump.persistentModelID) + }, + onRename: { renameBoard(dump.persistentModelID, to: $0) }, + // The last board can't be deleted — it can still be renamed. + onDelete: store.dumps.count > 1 ? { deleteBoard(dump.persistentModelID) } : nil, + onManaging: { boardPickerPinned = $0 } + ) + } + } + } + .frame(maxHeight: 320) + .fixedSize(horizontal: false, vertical: true) + + Divider().overlay(Theme.Palette.separator).padding(.horizontal, 2) + newBoardRow + } + .frame(width: 248) } - .transition(.opacity) + } + .padding(.horizontal, WindowChrome.padH) + .padding(.vertical, WindowChrome.padV) + .composerPopupSurface() + .onHover { setBoardPickerHover($0) } + .animation(.easeOut(duration: 0.16), value: boardPickerOpen) + .help(boardPickerOpen ? "" : "Switch board") + } + + /// Full-width "New board" action pinned under the list. + private var newBoardRow: some View { + Button { + Haptics.generic() + boardPickerOpen = false + newBoard() + } label: { + HStack(spacing: 6) { + Image(systemName: "plus").font(.system(size: 11, weight: .semibold)) + Text("New board").font(WindowChrome.labelFont) + } + .foregroundStyle(Theme.Palette.body) + .frame(maxWidth: .infinity) + .frame(height: 30) + .background(RoundedRectangle(cornerRadius: 7, style: .continuous).fill(Theme.Palette.rowFill)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help("New board ⌘N") + } + + /// Opening is immediate; closing waits a beat so crossing the pill→list gap doesn't flicker. + /// A pinned panel (inline rename / delete confirm in progress) never closes on hover-out. + private func setBoardPickerHover(_ hovering: Bool) { + boardPickerCloseWork?.cancel() + boardPickerCloseWork = nil + if hovering { + boardPickerOpen = true + } else { + let work = DispatchWorkItem { if !boardPickerPinned { boardPickerOpen = false } } + boardPickerCloseWork = work + DispatchQueue.main.asyncAfter(deadline: .now() + 0.18, execute: work) + } + } + + /// The agent toggle floats as its own pill in the top-right. Board reading/exporting belongs to + /// the agent and the local Canvas API now — the old Describe/Copy buttons are gone. + private func boardActionsPill(in size: CGSize) -> some View { + SidebarAgentButton(active: showAgent) { toggleAgent() } + .chromePill() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) + .padding(.top, WindowChrome.edgeInset) + .padding(.trailing, WindowChrome.edgeInset) + } + + /// Standard-window mode: ONE bottom-center command bar carrying everything hands-on — + /// zoom · tools · folder/settings — tldraw-style, so the top stays calm (identity left, + /// AI actions right) and the bottom is a single strong grouping instead of scattered pills. + private func bottomCommandBar(fit innerSize: CGSize) -> some View { + let grounded = !groundingPath.isEmpty + let folderName = grounded ? URL(fileURLWithPath: groundingPath).lastPathComponent : nil + return HStack(spacing: WindowChrome.itemSpacing) { + SidebarButton(symbol: "minus.magnifyingglass", help: "Zoom out") { zoom(0.8, anchoredAt: zoomAnchor) } + Button(action: { Haptics.tap(); withAnimation(Theme.Motion.accessory) { scale = 1 } }) { + Text("\(Int((effectiveScale * 100).rounded()))%") + .font(WindowChrome.labelFont.monospacedDigit()) + .foregroundStyle(Theme.Palette.chromeText) + .frame(width: 44, height: WindowChrome.controlHeight) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help("Reset to 100%") + SidebarButton(symbol: "plus.magnifyingglass", help: "Zoom in") { zoom(1.25, anchoredAt: zoomAnchor) } + SidebarButton(symbol: "arrow.up.left.and.down.right.magnifyingglass", help: "Fit board") { + withAnimation(Theme.Motion.accessory) { fitBoard(in: innerSize) } + } + + barDivider + + CanvasToolbar(tool: $tool) + + barDivider + + SidebarButton(symbol: grounded ? "folder.fill" : "folder.badge.plus", + help: folderName.map { "Agent grounded in \($0) · click to change" } + ?? "Ground the agent in a folder it can read", + active: grounded) { agent.chooseDirectory() } + .contextMenu { + if grounded { + Button("Change Folder\u{2026}") { agent.chooseDirectory() } + Button("Remove Grounding", role: .destructive) { agent.setGroundingDirectory(nil) } + } else { + Button("Ground in Folder\u{2026}") { agent.chooseDirectory() } + } + } + SidebarButton(symbol: "gearshape", help: "Settings ⌘,", + active: store.isSettingsOpen) { toggleSettings() } + } + .chromePill() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + .padding(.bottom, WindowChrome.edgeInset) + } + + private var barDivider: some View { + Rectangle().fill(Theme.Palette.chromeDivider) + .frame(width: 1, height: 20) + .padding(.horizontal, 4) + } + + /// Agent and Settings float over the canvas as glass panels (top-right, full-height). + /// One slot — they never co-exist. + @ViewBuilder + private func dockOverlay(in size: CGSize) -> some View { + let width = min(360, max(300, size.width * 0.32)) + if showAgent { + AgentDock(agent: agent, width: width, onClose: { toggleAgent() }) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) + .padding(.top, size.height * 0.10) + .padding(.trailing, WindowChrome.edgeInset) + // Stop above the bottom command bar rather than covering its right end. + .padding(.bottom, WindowChrome.edgeInset + WindowChrome.controlHeight + WindowChrome.padV * 2 + 8) + .shadow(color: Theme.Shadow.panel.color, radius: Theme.Shadow.panel.radius, y: Theme.Shadow.panel.y) + .transition(.move(edge: .trailing).combined(with: .opacity)) + .zIndex(40) + } else if store.isSettingsOpen { + SettingsOverlay(width: width, onClose: { toggleSettings() }) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) + .padding(.top, size.height * 0.10) + .padding(.trailing, WindowChrome.edgeInset) + // Stop above the bottom command bar rather than covering its right end. + .padding(.bottom, WindowChrome.edgeInset + WindowChrome.controlHeight + WindowChrome.padV * 2 + 8) + .shadow(color: Theme.Shadow.panel.color, radius: Theme.Shadow.panel.radius, y: Theme.Shadow.panel.y) + .transition(.move(edge: .trailing).combined(with: .opacity)) + .zIndex(40) } } @@ -703,8 +815,7 @@ struct ComposerCanvas: View { /// gear while Settings is up closes it again. (⌘, and the menu-bar item still always open.) private func toggleSettings() { if store.isSettingsOpen { - store.isSettingsOpen = false - NotificationCenter.default.post(name: .composerDismissDock, object: nil) + withAnimation(Theme.Motion.accessory) { store.isSettingsOpen = false } } else { openSettings() } @@ -713,20 +824,18 @@ struct ComposerCanvas: View { private func openSettings() { store.isHistoryOpen = false store.compiledDraft = nil - showAgent = false - store.isSettingsOpen = true - NotificationCenter.default.post( - name: .composerPresentDock, - object: nil, - userInfo: ["kind": ComposerDockKind.settings.rawValue] - ) + withAnimation(Theme.Motion.accessory) { + showAgent = false + store.isSettingsOpen = true + } } private func closeAuxiliaryPanel() { guard showAgent || store.isSettingsOpen else { return } - showAgent = false - store.isSettingsOpen = false - NotificationCenter.default.post(name: .composerDismissDock, object: nil) + withAnimation(Theme.Motion.accessory) { + showAgent = false + store.isSettingsOpen = false + } } // MARK: Command palette (⌘K) @@ -796,8 +905,6 @@ struct ComposerCanvas: View { var commands: [PaletteCommand] = [ PaletteCommand(id: "new-board", title: "New board", symbol: "square.and.pencil", shortcut: "⌘N") { newBoard() }, PaletteCommand(id: "compile", title: "Compile board into one draft", symbol: "wand.and.stars", shortcut: "⌘R") { runCompile() }, - PaletteCommand(id: "copy", title: "Copy whole board", subtitle: "Self-contained · connectors resolved", symbol: "doc.on.doc", shortcut: "⌘⇧C") { copyBoard() }, - PaletteCommand(id: "describe", title: "Copy board description with Claude", symbol: "doc.text.magnifyingglass") { describeBoard() }, PaletteCommand(id: "capture", title: "Capture screen to board", subtitle: "Read on-device into an agent-ready card", symbol: "text.viewfinder", shortcut: ShortcutStore.shared.captureShortcut.displayString) { NotificationCenter.default.post(name: .composerCaptureToBoard, object: nil) }, @@ -818,7 +925,7 @@ struct ComposerCanvas: View { return commands } - // MARK: Compile + copy + refine + // MARK: Compile + refine /// Collapse the whole board into one ordered, paste-ready draft. private func runCompile() { @@ -844,134 +951,6 @@ struct ComposerCanvas: View { } } - /// Copy the whole board as one self-contained block (connectors expanded, and — when the user has - /// opted in and confirmed — `$(command)` substitution and `name=(value)` variables run at copy). - private func copyBoard() { - guard !isCopyingBoard else { return } - let plain = board.joinedPlainText() - guard !plain.trimmed.isEmpty else { - show(Toast(text: "Nothing to copy yet", symbol: "doc.on.doc", tint: .orange)) - return - } - let connectors = AppToken.scan(plain).filter { $0.selection != nil }.count - let shellCommands = ShellTemplate.commands(in: plain) - board.failedShellCommands = [] // a fresh copy clears last run's failure marks - - // Shell only runs behind the opt-in toggle, and even then each copy confirms what will execute. - var runShell = false - if !shellCommands.isEmpty, ComposerPreferences.resolveShellAtCopy { - guard confirmRunShellCommands(shellCommands) else { - show(Toast(text: "Copy cancelled \u{00b7} commands not run", symbol: "xmark.circle", tint: .orange)) - return - } - runShell = true - } - - if runShell { - show(Toast(text: "Running \(shellCommands.count) command\(shellCommands.count == 1 ? "" : "s")\u{2026}", symbol: "terminal", tint: .accentColor)) - } else if connectors > 0 { - show(Toast(text: "Resolving connectors\u{2026}", symbol: "arrow.triangle.2.circlepath", tint: .accentColor)) - } - // Commands run in the board's grounding folder when one is set, else the user's home. - let commandDirectory = agent.groundingDirectory?.path ?? NSHomeDirectory() - isCopyingBoard = true - Task { - defer { isCopyingBoard = false } - let rendered = await SelfContainedRenderer.render(plain, runShell: runShell, commandDirectory: commandDirectory) - guard !rendered.text.trimmed.isEmpty else { - show(Toast(text: "Composer rendered no text from the non-empty board. Nothing was copied.", symbol: "exclamationmark.triangle.fill", tint: .orange)) - return - } - guard copyToClipboard(rendered.text) else { - show(Toast(text: "macOS did not accept the clipboard contents. The board was not copied.", symbol: "exclamationmark.triangle.fill", tint: .orange)) - return - } - if !rendered.failures.isEmpty { - // Mark the commands that failed so their `$(…)` tokens light up amber on the board. - board.failedShellCommands = Set(shellCommands.filter { command in - rendered.failures.contains { $0.hasPrefix("`\(command)`") } - }) - show(Toast( - text: "Copied with error\(rendered.failures.count == 1 ? "" : "s"):\n" + rendered.failures.joined(separator: "\n"), - symbol: "exclamationmark.triangle.fill", - tint: .orange)) - return - } - var resolved: [String] = [] - if runShell { resolved.append("\(shellCommands.count) command\(shellCommands.count == 1 ? "" : "s") run") } - if connectors > 0 { resolved.append("\(connectors) connector\(connectors == 1 ? "" : "s") resolved") } - let message = resolved.isEmpty ? "Copied self-contained text" : "Copied \u{00b7} " + resolved.joined(separator: ", ") - show(Toast(text: message, symbol: "doc.on.doc.fill", tint: .accentColor)) - } - } - - /// Before running shell pulled from the board, show exactly what will execute. Returns true only - /// if the user clicks Run & Copy. Modal on the main thread, so the copy can't race ahead of it. - @MainActor - private func confirmRunShellCommands(_ commands: [String]) -> Bool { - let alert = NSAlert() - alert.alertStyle = .warning - let count = commands.count - alert.messageText = "Run \(count) shell command\(count == 1 ? "" : "s") before copying?" - let listed = commands.prefix(8).map { "• \($0)" }.joined(separator: "\n") - let more = count > 8 ? "\n…and \(count - 8) more" : "" - alert.informativeText = "These run on your Mac now, and their output is pasted into the copied draft:\n\n\(listed)\(more)" - alert.addButton(withTitle: "Run & Copy") - alert.addButton(withTitle: "Cancel") - return alert.runModal() == .alertFirstButtonReturn - } - - /// The toolbar Copy: snapshot the whole board state (text cards, shapes, diagrams, connections — - /// the same graph the canvas MCP `get_canvas` exposes), hand it to `claude -p`, and copy back a - /// self-contained description of everything the board holds. - private func describeBoard() { - guard !isWorking, !isDescribing else { return } - let graph = CanvasBridge.shared.snapshot() - guard !graph.nodes.isEmpty else { - show(Toast(text: "Add some cards to copy", symbol: "rectangle.dashed", tint: .orange)) - return - } - guard let engine = preferredEngine() else { - show(Toast(text: unavailableEngineMessage(), symbol: "exclamationmark.triangle.fill", tint: .orange)) - return - } - let state: String - do { - state = try Self.encodeBoardState(graph) - } catch { - show(Toast(text: UserFacingError.message(for: error, while: "Encoding the board for Claude"), symbol: "exclamationmark.triangle.fill", tint: .orange)) - return - } - isDescribing = true - isWorking = true - 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, model: ModelPreferences.describeModel) - if copyToClipboard(description) { - show(Toast(text: "Copied board description", symbol: "doc.on.doc.fill", tint: .accentColor)) - } else { - show(Toast(text: "macOS did not accept the clipboard contents. The board description was not copied.", symbol: "exclamationmark.triangle.fill", tint: .orange)) - } - } catch { - show(Toast(text: UserFacingError.message(for: error, while: "Describing the board with \(engine.title)"), symbol: "exclamationmark.triangle.fill", tint: .orange)) - } - isWorking = false - isDescribing = false - } - } - - /// Encode the board graph as the pretty-printed JSON the describe prompt reads. - private static func encodeBoardState(_ graph: CanvasGraph) throws -> String { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let data = try encoder.encode(graph) - guard let state = String(data: data, encoding: .utf8) else { - throw BoardDescriptionError.nonUTF8BoardState - } - return state - } - /// Refine the active card's current selection in place. private func refineSelection(_ engine: HeadlessEngine, card: CardInteraction) { let snapshot = card.selection @@ -1477,13 +1456,11 @@ private struct ActiveCardOverlays: View { let size: CGSize let isWorking: Bool let onRefine: (HeadlessEngine) -> Void - let onCopy: () -> Void let onApplyFix: (LintFlag, String) -> Void let onAskClaude: (LintFlag) -> Void init(card: CardInteraction, size: CGSize, isWorking: Bool, onRefine: @escaping (HeadlessEngine) -> Void, - onCopy: @escaping () -> Void, onApplyFix: @escaping (LintFlag, String) -> Void, onAskClaude: @escaping (LintFlag) -> Void) { self.card = card @@ -1493,7 +1470,6 @@ private struct ActiveCardOverlays: View { self.size = size self.isWorking = isWorking self.onRefine = onRefine - self.onCopy = onCopy self.onApplyFix = onApplyFix self.onAskClaude = onAskClaude } @@ -1515,7 +1491,7 @@ private struct ActiveCardOverlays: View { @ViewBuilder private var selectionBar: some View { if !card.selection.isEmpty, !mentions.isOpen, !appSearch.isOpen, let rect = card.selection.rectInView { - SelectionActionBar(isWorking: isWorking, onRefine: onRefine, onCopy: onCopy) + SelectionActionBar(isWorking: isWorking, onRefine: onRefine) .fixedSize() .position(x: clamp(rect.midX, 120, max(120, size.width - 120)), y: clamp(rect.minY - 22, 30, max(30, size.height - 28))) @@ -1669,20 +1645,126 @@ private struct DragSegment: Equatable { /// The board card is derived from its own AppKit window's current viewport. The auxiliary dock is /// deliberately absent here: it is a sibling window managed by `PanelController`. -private struct CanvasSurfaceLayout { - let cardSize: CGSize - let cardOrigin: CGPoint +/// One row of the hover board picker: click to switch; hover reveals rename (pencil) and delete +/// (trash) icons. Rename is inline; delete arms on first click (red) and fires on the second. +private struct BoardPickerRow: View { + let title: String + let isCurrent: Bool + let onPick: () -> Void + let onRename: (String) -> Void + var onDelete: (() -> Void)? + /// True while this row is renaming or confirming a delete — pins the panel open. + var onManaging: (Bool) -> Void + + @State private var hovering = false + @State private var isRenaming = false + @State private var confirmingDelete = false + @State private var draftName = "" + @FocusState private var nameFocused: Bool - init(windowSize: CGSize) { - let windowWidth = max(windowSize.width, 0) - let windowHeight = max(windowSize.height, 0) - let rail = Theme.Size.railGutter(in: windowWidth) - let toolbar = Theme.Size.toolbarGutter(in: windowHeight) - let cardWidth = max(windowWidth - rail, 1) - let cardHeight = max(windowHeight - toolbar, 1) + var body: some View { + Group { + if isRenaming { renameRow } else { pickRow } + } + .onHover { over in + hovering = over + if !over { setConfirmingDelete(false) } + } + .animation(.easeOut(duration: 0.1), value: hovering) + } + + private var pickRow: some View { + Button(action: onPick) { + HStack(spacing: 8) { + Circle() + .fill(isCurrent ? Theme.Palette.accent : Color.clear) + .frame(width: 5, height: 5) + Text(title) + .font(WindowChrome.labelFont) + .foregroundStyle(Theme.Palette.body) + .lineLimit(1) + Spacer(minLength: 10) + if hovering { + rowIcon("pencil", help: "Rename board", tint: nil) { beginRename() } + if let onDelete { + rowIcon("trash", help: confirmingDelete ? "Click again to permanently delete" : "Delete board", + tint: confirmingDelete ? .red : nil) { + if confirmingDelete { onDelete() } else { setConfirmingDelete(true) } + } + } + } + } + .padding(.horizontal, WindowChrome.labelPadH) + .frame(height: 30) + .background( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(hovering ? Theme.Palette.hoverWash : Color.clear) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + private var renameRow: some View { + HStack(spacing: 8) { + Circle() + .fill(isCurrent ? Theme.Palette.accent : Color.clear) + .frame(width: 5, height: 5) + TextField("Board name", text: $draftName) + .textFieldStyle(.plain) + .font(WindowChrome.labelFont) + .foregroundStyle(Theme.Palette.body) + .focused($nameFocused) + .onSubmit(commitRename) + .onExitCommand(perform: cancelRename) + } + .padding(.horizontal, WindowChrome.labelPadH) + .frame(height: 30) + .background( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(Theme.Palette.rowFill) + ) + // Defer a runloop tick: focusing straight from onAppear can miss while the panel animates in. + .onAppear { DispatchQueue.main.async { nameFocused = true } } + // Clicking away (focus leaves the field) commits, so the rename isn't lost. + .onChange(of: nameFocused) { _, focused in if !focused { commitRename() } } + } - cardSize = CGSize(width: cardWidth, height: cardHeight) - cardOrigin = CGPoint(x: rail, y: toolbar) + private func rowIcon(_ symbol: String, help: String, tint: Color?, action: @escaping () -> Void) -> some View { + Button(action: { Haptics.tap(); action() }) { + Image(systemName: symbol) + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(tint ?? Theme.Palette.title) + .frame(width: 20, height: 20) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help(help) + } + + private func beginRename() { + draftName = title + setConfirmingDelete(false) + isRenaming = true + onManaging(true) + } + + private func commitRename() { + guard isRenaming else { return } // guard so the focus-loss path doesn't re-fire after a cancel + isRenaming = false + onManaging(false) + onRename(draftName) + } + + private func cancelRename() { + isRenaming = false + onManaging(false) + } + + private func setConfirmingDelete(_ value: Bool) { + guard confirmingDelete != value else { return } + confirmingDelete = value + onManaging(value) } } @@ -1696,7 +1778,7 @@ private struct ElementDraftPreview: View { let r = CGRect(x: min(start.x, end.x), y: min(start.y, end.y), width: abs(end.x - start.x), height: abs(end.y - start.y)) path(in: r).stroke( - Color.accentColor.opacity(0.9), + Theme.Palette.accent.opacity(0.9), style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round, dash: dash)) } @@ -1728,14 +1810,3 @@ private struct Toast: Identifiable, Equatable { let symbol: String let tint: Color } - -private enum BoardDescriptionError: LocalizedError { - case nonUTF8BoardState - - var errorDescription: String? { - switch self { - case .nonUTF8BoardState: - return "The encoded board state was not valid UTF-8 text, so Composer did not send it to Claude." - } - } -} diff --git a/Sources/ComposerApp/Views/FreeWriteEditor.swift b/Sources/ComposerApp/Views/FreeWriteEditor.swift index f8918ff..7b22704 100644 --- a/Sources/ComposerApp/Views/FreeWriteEditor.swift +++ b/Sources/ComposerApp/Views/FreeWriteEditor.swift @@ -140,7 +140,7 @@ struct FreeWriteEditor: NSViewRepresentable { tv.isAutomaticSpellingCorrectionEnabled = false tv.smartInsertDeleteEnabled = false tv.allowsUndo = true - tv.insertionPointColor = .controlAccentColor + tv.insertionPointColor = Theme.Palette.nsAccent tv.isVerticallyResizable = true tv.isHorizontallyResizable = false tv.autoresizingMask = [.width] diff --git a/Sources/ComposerApp/Views/HistoryBar.swift b/Sources/ComposerApp/Views/HistoryBar.swift index 47ae8b2..5d01226 100644 --- a/Sources/ComposerApp/Views/HistoryBar.swift +++ b/Sources/ComposerApp/Views/HistoryBar.swift @@ -1,219 +1,4 @@ import SwiftUI -import SwiftData - -// MARK: - Rail surface - -extension View { - /// The rail floats over the desktop, so it can't use adaptive Liquid Glass (that turns - /// white over a light wallpaper). It mirrors the card's own material — HUD vibrancy blurring - /// the desktop + a matching dark tint — so it reads as the same glass, just detached. - func railSurface() -> some View { - let shape = Capsule(style: .continuous) - return self - .background { - ZStack { - VisualEffectBackground(material: .hudWindow, blending: .behindWindow, state: .active) - Color.black.opacity(0.6) - } - } - .clipShape(shape) - } -} - -// MARK: - History list - -/// The stack of dumps, newest first. Click to jump; hover to delete. A "New dump" row up top. -/// Sizes to its content (no empty void), scrolls past six. -struct HistoryList: View { - @ObservedObject var store: DumpStore - var onPick: (PersistentIdentifier) -> Void - var onDelete: (PersistentIdentifier) -> Void - var onRename: (PersistentIdentifier, String) -> Void - var onNew: () -> Void - - private let rowHeight: CGFloat = 38 - private var listHeight: CGFloat { - min(CGFloat(max(store.dumps.count, 1)), 6) * rowHeight + 12 - } - - var body: some View { - VStack(spacing: 0) { - HistoryNewRow(action: onNew) - Rectangle().fill(Theme.Palette.separator).frame(height: 1) - ScrollView(.vertical) { - VStack(spacing: 0) { - ForEach(store.dumps, id: \.persistentModelID) { dump in - HistoryRow( - dump: dump, - height: rowHeight, - isCurrent: dump.persistentModelID == store.currentID, - onPick: { onPick(dump.persistentModelID) }, - onDelete: store.dumps.count > 1 ? { onDelete(dump.persistentModelID) } : nil, - onRename: { onRename(dump.persistentModelID, $0) } - ) - } - } - .padding(.vertical, 6) - } - .scrollIndicators(.never) - .frame(height: listHeight) - } - .frame(width: 320) - .composerPopupSurface() - } -} - -private struct HistoryNewRow: View { - var action: () -> Void - @State private var hovering = false - - var body: some View { - Button(action: action) { - HStack(spacing: 10) { - Image(systemName: "plus").font(.body.weight(.semibold)).frame(width: 20) - Text("New board").font(.body.weight(.medium)) - Spacer(minLength: 0) - Text("⌘N").font(.caption.weight(.medium)).foregroundStyle(.tertiary) - } - .foregroundStyle(hovering ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(Theme.Palette.body)) - .padding(.horizontal, 14) - .frame(height: 42) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .onHover { hovering = $0 } - } -} - -private struct HistoryRow: View { - let dump: Dump - let height: CGFloat - let isCurrent: Bool - var onPick: () -> Void - var onDelete: (() -> Void)? - var onRename: (String) -> Void - @State private var hovering = false - @State private var confirmingDelete = false - @State private var isRenaming = false - @State private var draftName = "" - @FocusState private var nameFieldFocused: Bool - - var body: some View { - Group { - if isRenaming { renamingRow } else { pickRow } - } - .onHover { hovering = $0; if !$0 { confirmingDelete = false } } - } - - // MARK: Normal (pick / hover-actions) row - - private var pickRow: some View { - Button(action: onPick) { - HStack(spacing: 10) { - indicator - Text(dump.title.isEmpty ? "Empty draft" : dump.title) - .font(.body) - .foregroundStyle(dump.title.isEmpty ? Theme.Palette.menuDesc : Theme.Palette.body) - .lineLimit(1) - Spacer(minLength: 8) - trailing - } - .padding(.horizontal, 12) - .frame(height: height) - .background(rowBackground) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .contextMenu { Button("Rename", action: beginRename) } - } - - @ViewBuilder - private var trailing: some View { - if hovering && confirmingDelete, let onDelete { - // Second click confirms — a deleted board can't be recovered. - Button(action: onDelete) { - Text("Delete?") - .font(.caption.weight(.semibold)) - .foregroundStyle(.white) - .padding(.horizontal, 8).frame(height: 20) - .background(RoundedRectangle(cornerRadius: 6, style: .continuous).fill(Color.red.opacity(0.85))) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .help("Click again to permanently delete this board") - } else if hovering { - HStack(spacing: 2) { - rowIconButton("pencil", help: "Rename board", action: beginRename) - // The last board can't be deleted, so it has no ✕ — but it can still be renamed. - if onDelete != nil { - rowIconButton("xmark", help: "Delete board") { confirmingDelete = true } - } - } - } else { - Text(relativeDumpTime(dump.updatedAt)) - .font(.caption.monospacedDigit()) - .foregroundStyle(Theme.Palette.title) - } - } - - private func rowIconButton(_ symbol: String, help: String, action: @escaping () -> Void) -> some View { - Button(action: action) { - Image(systemName: symbol).font(.caption.weight(.bold)).foregroundStyle(.secondary) - .frame(width: 20, height: 20).contentShape(Rectangle()) - } - .buttonStyle(.plain) - .help(help) - } - - // MARK: Inline rename row - - private var renamingRow: some View { - HStack(spacing: 10) { - indicator - TextField("Board name", text: $draftName) - .textFieldStyle(.plain) - .font(.body) - .foregroundStyle(Theme.Palette.body) - .focused($nameFieldFocused) - .onSubmit(commitRename) - .onExitCommand(perform: cancelRename) - Spacer(minLength: 8) - } - .padding(.horizontal, 12) - .frame(height: height) - .background(rowBackground) - // Defer a runloop tick: focusing straight from onAppear can miss while the overlay animates in. - .onAppear { DispatchQueue.main.async { nameFieldFocused = true } } - // Clicking away (focus leaves the field) commits, so the rename isn't lost. - .onChange(of: nameFieldFocused) { _, focused in if !focused { commitRename() } } - } - - private func beginRename() { - draftName = dump.title - confirmingDelete = false - isRenaming = true - } - - private func commitRename() { - guard isRenaming else { return } // guard so the focus-loss path doesn't re-fire after a cancel - isRenaming = false - onRename(draftName) - } - - private func cancelRename() { isRenaming = false } - - // MARK: Shared chrome - - private var indicator: some View { - Circle().fill(isCurrent ? Color.accentColor : Color.clear).frame(width: 6, height: 6) - } - - private var rowBackground: some View { - RoundedRectangle(cornerRadius: Theme.Radius.row, style: .continuous) - .fill(isCurrent ? Theme.Palette.selectedRowFill : (hovering ? Theme.Palette.rowFill : Color.clear)) - .padding(.horizontal, 6) - } -} /// Compact relative time — "now", "5m", "2h", "3d", then a date. func relativeDumpTime(_ date: Date) -> String { diff --git a/Sources/ComposerApp/Views/MentionMenu.swift b/Sources/ComposerApp/Views/MentionMenu.swift index 8e122b3..edb0458 100644 --- a/Sources/ComposerApp/Views/MentionMenu.swift +++ b/Sources/ComposerApp/Views/MentionMenu.swift @@ -40,7 +40,7 @@ struct MentionMenu: View { HStack(spacing: 9) { Image(systemName: item.symbol) .font(.body) - .foregroundStyle(selected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(Theme.Palette.menuDesc)) + .foregroundStyle(selected ? AnyShapeStyle(Theme.Palette.accent) : AnyShapeStyle(Theme.Palette.menuDesc)) .frame(width: 16) Text(item.id) .font(Theme.Typography.menuName) diff --git a/Sources/ComposerApp/Views/RefineBar.swift b/Sources/ComposerApp/Views/RefineBar.swift index 58a7407..c14b3f7 100644 --- a/Sources/ComposerApp/Views/RefineBar.swift +++ b/Sources/ComposerApp/Views/RefineBar.swift @@ -29,7 +29,7 @@ private struct RefineMenuRow: View { Image(systemName: intent.symbol) .font(.body) .frame(width: 18) - .foregroundStyle(hovering ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(Theme.Palette.menuDesc)) + .foregroundStyle(hovering ? AnyShapeStyle(Theme.Palette.accent) : AnyShapeStyle(Theme.Palette.menuDesc)) VStack(alignment: .leading, spacing: 1) { Text(intent.label).font(.body.weight(.medium)).foregroundStyle(Theme.Palette.body) Text(intent.detail).font(.caption).foregroundStyle(Theme.Palette.menuDesc).lineLimit(1) @@ -60,7 +60,7 @@ struct RefineConfirmBar: View { var body: some View { HStack(spacing: 9) { - Image(systemName: intent.symbol).font(.caption).foregroundStyle(Color.accentColor) + Image(systemName: intent.symbol).font(.caption).foregroundStyle(Theme.Palette.accent) Text("Refined · \(intent.label)") .font(Theme.Typography.actionLabel) .foregroundStyle(Theme.Palette.body) @@ -85,7 +85,7 @@ private struct RefineBarButton: View { Button(action: action) { Text(title) .font(Theme.Typography.actionLabel) - .foregroundStyle(prominent ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(Theme.Palette.body)) + .foregroundStyle(prominent ? AnyShapeStyle(Theme.Palette.accent) : AnyShapeStyle(Theme.Palette.body)) .padding(.horizontal, 11) .frame(height: Theme.Size.actionBarItemHeight) .background( diff --git a/Sources/ComposerApp/Views/SelectionActionBar.swift b/Sources/ComposerApp/Views/SelectionActionBar.swift index 6cba6f3..ea5ed71 100644 --- a/Sources/ComposerApp/Views/SelectionActionBar.swift +++ b/Sources/ComposerApp/Views/SelectionActionBar.swift @@ -4,7 +4,6 @@ import SwiftUI struct SelectionActionBar: View { var isWorking: Bool var onRefine: (HeadlessEngine) -> Void - var onCopy: () -> Void @AppStorage(EnginePreferences.claudeEnabledKey) private var claudeEnabled = true @AppStorage(EnginePreferences.codexEnabledKey) private var codexEnabled = true @@ -43,7 +42,6 @@ struct SelectionActionBar: View { .frame(height: Theme.Size.actionBarItemHeight) } Divider().frame(height: 16).opacity(0.35) - iconAction(icon: "doc.on.doc", help: "Copy self-contained text", run: onCopy) } } .padding(.horizontal, 5) diff --git a/Sources/ComposerApp/Views/SettingsView.swift b/Sources/ComposerApp/Views/SettingsView.swift index bde14e2..d717b84 100644 --- a/Sources/ComposerApp/Views/SettingsView.swift +++ b/Sources/ComposerApp/Views/SettingsView.swift @@ -24,10 +24,8 @@ struct SettingsOverlay: View { } .frame(width: width) .frame(maxHeight: .infinity) - // Identical glass to the main window and the agent dock — same frosted treatment, tint, and - // corner radius — so Settings reads as a second panel beside the card. - .background(ComposerPanelBackground(radius: Theme.Radius.panel)) - .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.panel, style: .continuous)) + // Liquid Glass floating over the canvas. + .dockPanelSurface() .onExitCommand(perform: onClose) .animation(Theme.Motion.accessory, value: destination) } @@ -59,20 +57,27 @@ struct SettingsOverlay: View { // MARK: Tabs + /// Modern chip nav: inline icon + label capsules in a horizontal row. Selection is a filled + /// chip; everything else stays quiet until hover. private var tabStrip: some View { - HStack(spacing: 4) { - ForEach(SettingsDestination.allCases) { item in - SettingsTab(item: item, selected: destination == item) { destination = item } + ScrollView(.horizontal) { + HStack(spacing: 5) { + ForEach(SettingsDestination.allCases) { item in + SettingsTab(item: item, selected: destination == item) { + Haptics.tap() + destination = item + } + } } + .padding(.horizontal, 12) + .padding(.vertical, 9) } - .padding(.horizontal, 12) - .padding(.vertical, 10) + .scrollIndicators(.never) } } -/// One segment of the settings nav. Quiet by default, lights up on hover, and marks the selection -/// with an accent-tinted glyph over a neutral fill — the same "tint is the signal, no colored box" -/// rule the canvas rails follow. +/// One chip of the settings nav — icon + label inline in a capsule. The selected chip carries a +/// quiet filled background with accent-tinted content; the rest light up on hover. private struct SettingsTab: View { let item: SettingsDestination let selected: Bool @@ -81,18 +86,17 @@ private struct SettingsTab: View { var body: some View { Button(action: action) { - VStack(spacing: 5) { - Image(systemName: item.symbol).font(.system(size: 15, weight: .medium)) - Text(item.title).font(.system(size: 10.5, weight: .medium)) + HStack(spacing: 5) { + Image(systemName: item.symbol).font(.system(size: 11.5, weight: .medium)) + Text(item.title).font(.system(size: 11.5, weight: .medium)).fixedSize() } - .frame(maxWidth: .infinity) - .frame(height: 46) .foregroundStyle(foreground) + .padding(.horizontal, 10) + .frame(height: 26) .background( - RoundedRectangle(cornerRadius: 9, style: .continuous) - .fill(selected ? Color.white.opacity(0.08) : (hovering ? Color.white.opacity(0.045) : Color.clear)) + Capsule().fill(selected ? Theme.Palette.keycapFill : (hovering ? Theme.Palette.rowFill : Color.clear)) ) - .contentShape(Rectangle()) + .contentShape(Capsule()) } .buttonStyle(.plain) .onHover { hovering = $0 } @@ -101,7 +105,7 @@ private struct SettingsTab: View { } private var foreground: AnyShapeStyle { - if selected { return AnyShapeStyle(Color.accentColor) } + if selected { return AnyShapeStyle(Theme.Palette.accent) } return AnyShapeStyle(hovering ? Theme.Palette.body : Theme.Palette.menuDesc) } } @@ -144,7 +148,6 @@ private struct SettingsContent: View { ("Select all · duplicate", "⌘A ⌘D"), ("Group · ungroup", "⌘G ⇧⌘G"), ("Lock · unlock", "⌘L ⇧⌘L"), - ("Copy self-contained", "⇧⌘C"), ] @StateObject private var appIcons = AppIconStore() @@ -155,12 +158,15 @@ private struct SettingsContent: View { // 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 + @AppStorage(ComposerPreferences.themeKey) private var themeRaw = ComposerTheme.bonsaiDark.rawValue + @AppStorage(ComposerPreferences.canvasTransparencyKey) private var canvasTransparency = 0.0 /// Whether the agent has standing "Always Allow" tool grants - drives the reset control's /// visibility. Refreshed in `onAppear`; flipped false the moment the user resets. @State private var agentHasGrants = false + /// Bumped after an agent-skills install so `AgentSkillTarget.isInstalled` (a filesystem check, + /// not a published property) re-reads and the row badges refresh. + @State private var agentSkillsRevision = 0 + @State private var agentSkillsError: String? var body: some View { ScrollView { @@ -288,9 +294,8 @@ 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. + /// The agent chat's model. Mirrors the Agent panel's picker (same key). 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() @@ -299,24 +304,27 @@ private struct SettingsContent: View { 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 { + 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) { @@ -328,6 +336,8 @@ private struct SettingsContent: View { .pickerStyle(.menu) .fixedSize() .tint(Theme.Palette.body) + .disabled(!active) + .opacity(active ? 1 : 0.5) } .padding(.vertical, 11) } @@ -363,7 +373,7 @@ private struct SettingsContent: View { .background(RoundedRectangle(cornerRadius: 11, style: .continuous).fill(Theme.Palette.tagFill)) .overlay( RoundedRectangle(cornerRadius: 11, style: .continuous) - .strokeBorder(Color.white.opacity(0.06), lineWidth: 1) + .strokeBorder(Theme.Palette.panelInnerLine, lineWidth: 1) ) .opacity(available ? 1 : 0.4) .saturation(available ? 1 : 0.2) @@ -448,62 +458,62 @@ private struct SettingsContent: View { // MARK: Appearance private var appearancePage: some View { - VStack(alignment: .leading, spacing: 16) { - pageHeader("Panel glass", - "Let more of the desktop through without losing the contrast that keeps long drafts readable.") - - VStack(alignment: .leading, spacing: 12) { - // A live preview of the panel at the chosen transparency. - glassPreview - .frame(height: 64) - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - - VStack(spacing: 12) { - HStack(alignment: .firstTextBaseline) { - Text("Background transparency").font(.callout.weight(.semibold)).foregroundStyle(Theme.Palette.body) - Spacer(minLength: 12) - Text("\(transparencyPercent)%") - .font(.callout.monospacedDigit().weight(.semibold)) - .foregroundStyle(Theme.Palette.body) - } - Slider(value: $panelTransparency, in: 0...ComposerPreferences.maxPanelTransparency) - .tint(Color.accentColor) - HStack { - Text("Opaque") - Spacer() - Text("Glass") - } - .font(.caption2) - .foregroundStyle(Theme.Palette.count) - } - .padding(14) - .settingsCard() - } + VStack(alignment: .leading, spacing: 20) { + themeCard + canvasGlassCard } } - private var glassPreview: some View { - let glass = ComposerPreferences.clampedPanelTransparency(panelTransparency) / ComposerPreferences.maxPanelTransparency - let tint = 0.80 - 0.58 * glass - return ZStack { - VisualEffectBackground(material: .hudWindow, blending: .behindWindow, state: .active) - Color.black.opacity(tint) - HStack { - Text("The quick brown fox") - .font(.callout.weight(.medium)) - .foregroundStyle(.white.opacity(0.92)) - Spacer() + /// Canvas background transparency — solid by default; the board behind this panel updates live + /// as the slider moves, so it is its own preview. + private var canvasGlassCard: some View { + VStack(alignment: .leading, spacing: 8) { + pageHeader("Canvas", + "Let the desktop blur through the board surface. Solid keeps the flat canvas.") + VStack(spacing: 12) { + HStack(alignment: .firstTextBaseline) { + Text("Background transparency").font(.callout.weight(.semibold)).foregroundStyle(Theme.Palette.body) + Spacer(minLength: 12) + Text("\(canvasTransparencyPercent)%") + .font(.callout.monospacedDigit().weight(.semibold)) + .foregroundStyle(Theme.Palette.body) + } + Slider(value: $canvasTransparency, in: 0...ComposerPreferences.maxCanvasTransparency) + .tint(Theme.Palette.accent) + HStack { + Text("Solid") + Spacer() + Text("Glass") + } + .font(.caption2) + .foregroundStyle(Theme.Palette.count) } - .padding(.horizontal, 14) + .padding(14) + .settingsCard() } - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .strokeBorder(Color.white.opacity(0.08), lineWidth: 1) - ) } - private var transparencyPercent: Int { - Int((ComposerPreferences.clampedPanelTransparency(panelTransparency) / ComposerPreferences.maxPanelTransparency) * 100) + private var canvasTransparencyPercent: Int { + Int((ComposerPreferences.clampedCanvasTransparency(canvasTransparency) / ComposerPreferences.maxCanvasTransparency) * 100) + } + + /// The theme gallery: one live-preview card per flavor, painted from that flavor's own palette + /// (not the current one), so every option shows exactly what it looks like before you commit. + private var themeCard: some View { + VStack(alignment: .leading, spacing: 8) { + pageHeader("Theme", "Pick the palette for the whole app.") + LazyVGrid(columns: [GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10)], spacing: 10) { + ForEach(ComposerTheme.allCases) { theme in + ThemePreviewCard(theme: theme, selected: themeRaw == theme.rawValue) { + Haptics.level() + themeRaw = theme.rawValue + } + } + } + } + .onChange(of: themeRaw) { _, _ in + NotificationCenter.default.post(name: .composerThemeChanged, object: nil) + } } // MARK: Connectors @@ -513,7 +523,7 @@ private struct SettingsContent: View { pageHeader("Connectors", "Type @ in a card to attach live context. Copied drafts become self-contained text — the source is resolved at copy time.") - shellResolutionCard + agentSkillsCard ForEach(MentionCatalog.appsByCategory, id: \.category) { group in VStack(alignment: .leading, spacing: 8) { @@ -531,30 +541,61 @@ private struct SettingsContent: View { } } - /// Opt-in for copy-time shell. Off by default; even on, every copy confirms what will run. - private var shellResolutionCard: some View { + /// Lets coding agents (Claude Code, Codex CLI, Cursor) drive the board over the local canvas API. + /// Each row reflects a live filesystem check, not a stored preference — `agentSkillsRevision` + /// forces a re-read after install since SwiftUI has no other reason to invalidate this view. + private var agentSkillsCard: some View { VStack(alignment: .leading, spacing: 8) { - Text("COPY-TIME SHELL").sectionLabel() - HStack(spacing: 11) { - Image(systemName: "terminal") - .font(.system(size: 15, weight: .medium)) - .foregroundStyle(Theme.Palette.body) - .frame(width: 24, height: 24) - VStack(alignment: .leading, spacing: 2) { - Text("Resolve shell at copy time") - .font(.callout.weight(.medium)).foregroundStyle(Theme.Palette.body) - Text("Run $(command) blocks and name=(value) variables when you copy, pasting their output. Each copy confirms what will run.") - .font(.caption).foregroundStyle(Theme.Palette.menuDesc) - .fixedSize(horizontal: false, vertical: true) + Text("AGENT SKILLS").sectionLabel() + VStack(spacing: 0) { + ForEach(Array(AgentSkillTarget.allCases.enumerated()), id: \.element.id) { index, target in + if index > 0 { Divider().overlay(Theme.Palette.separator) } + agentSkillRow(target) } - Spacer(minLength: 8) - Toggle("", isOn: $resolveShellAtCopy) - .labelsHidden().toggleStyle(.switch).controlSize(.small) } .padding(.horizontal, 13) - .padding(.vertical, 11) .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 } private func connectorRow(_ app: MentionItem) -> some View { @@ -654,7 +695,7 @@ private struct SettingsContent: View { RoundedRectangle(cornerRadius: 5, style: .continuous) .fill(Theme.Palette.keycapFill) .overlay(RoundedRectangle(cornerRadius: 5, style: .continuous) - .strokeBorder(Color.white.opacity(0.06), lineWidth: 1)) + .strokeBorder(Theme.Palette.panelInnerLine, lineWidth: 1)) ) } .padding(.horizontal, 13) @@ -749,7 +790,7 @@ private struct ConnectorTokenField: View { Button("Save", action: save) .buttonStyle(.plain) .font(.caption.weight(.semibold)) - .foregroundStyle(draft.trimmed.isEmpty ? Theme.Palette.menuDesc : Color.accentColor) + .foregroundStyle(draft.trimmed.isEmpty ? Theme.Palette.menuDesc : Theme.Palette.accent) .disabled(draft.trimmed.isEmpty) if connected { Button("Clear", action: clear) @@ -769,7 +810,7 @@ private struct ConnectorTokenField: View { Spacer(minLength: 8) Link("Get a token ↗", destination: url) .font(.caption2.weight(.medium)) - .foregroundStyle(Color.accentColor) + .foregroundStyle(Theme.Palette.accent) } } } @@ -792,6 +833,76 @@ private struct ConnectorTokenField: View { } } +// MARK: - Theme preview + +/// A miniature of the app painted from a flavor's own palette: canvas, a floating pill with an +/// accent dot, and ink lines at three strengths. Selection rings in the flavor's accent. +private struct ThemePreviewCard: View { + let theme: ComposerTheme + let selected: Bool + var action: () -> Void + @State private var hovering = false + + var body: some View { + let flavor = theme.flavor + Button(action: action) { + VStack(spacing: 0) { + ZStack(alignment: .topLeading) { + Color(nsColor: flavor.base) + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 4) { + Circle().fill(Color(nsColor: flavor.accent)).frame(width: 6, height: 6) + Capsule().fill(Color(nsColor: flavor.surface1)).frame(width: 30, height: 7) + } + .padding(.horizontal, 7).padding(.vertical, 5) + .background(Capsule().fill(Color(nsColor: flavor.mantle))) + .overlay(Capsule().strokeBorder(Color(nsColor: flavor.surface2).opacity(0.6), lineWidth: 0.5)) + + VStack(alignment: .leading, spacing: 4) { + RoundedRectangle(cornerRadius: 2).fill(Color(nsColor: flavor.text)).frame(width: 56, height: 5) + RoundedRectangle(cornerRadius: 2).fill(Color(nsColor: flavor.subtext0)).frame(width: 40, height: 5) + RoundedRectangle(cornerRadius: 2).fill(Color(nsColor: flavor.overlay0)).frame(width: 47, height: 5) + } + .padding(.leading, 3) + } + .padding(9) + } + .frame(height: 82) + + HStack(spacing: 6) { + Text(theme.title) + .font(.caption.weight(.medium)) + .foregroundStyle(Theme.Palette.body) + .lineLimit(1) + Spacer(minLength: 0) + if selected { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 12)) + .foregroundStyle(Theme.Palette.accent) + } + } + .padding(.horizontal, 10) + .frame(height: 30) + .background(Theme.Palette.rowFill) + } + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder( + selected ? Theme.Palette.accent : (hovering ? Theme.Palette.panelInnerLine : Theme.Palette.panelHairline), + lineWidth: selected ? 2 : 1 + ) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hovering = $0 } + .help(theme.title) + .animation(.easeOut(duration: 0.12), value: hovering) + .animation(.easeOut(duration: 0.12), value: selected) + } +} + // MARK: - Styling helpers /// A quiet neutral pill — the rail/dock idiom (white-on-glass wash, hairline rim), not an accent @@ -800,9 +911,9 @@ private struct SettingsPillButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .background( - Capsule().fill(Color.white.opacity(configuration.isPressed ? 0.14 : 0.08)) + Capsule().fill(configuration.isPressed ? Theme.Palette.buttonHover : Theme.Palette.keycapFill) ) - .overlay(Capsule().strokeBorder(Color.white.opacity(0.10), lineWidth: 1)) + .overlay(Capsule().strokeBorder(Theme.Palette.panelHairline, lineWidth: 1)) .scaleEffect(configuration.isPressed ? 0.97 : 1) .animation(.easeOut(duration: 0.12), value: configuration.isPressed) } @@ -815,7 +926,7 @@ private extension View { RoundedRectangle(cornerRadius: radius, style: .continuous) .fill(Theme.Palette.rowFill) .overlay(RoundedRectangle(cornerRadius: radius, style: .continuous) - .strokeBorder(Color.white.opacity(0.06), lineWidth: 1)) + .strokeBorder(Theme.Palette.panelInnerLine, lineWidth: 1)) } } diff --git a/Sources/ComposerApp/Views/ShortcutRecorder.swift b/Sources/ComposerApp/Views/ShortcutRecorder.swift index eab8c9f..fd19b18 100644 --- a/Sources/ComposerApp/Views/ShortcutRecorder.swift +++ b/Sources/ComposerApp/Views/ShortcutRecorder.swift @@ -16,7 +16,7 @@ struct ShortcutRecorder: View { Button(action: toggle) { Text(recording ? "Type a shortcut…" : shortcut.displayString) .font(.system(size: 11, weight: .medium)) - .foregroundStyle(recording ? Color.accentColor : .secondary) + .foregroundStyle(recording ? Theme.Palette.accent : .secondary) .padding(.horizontal, 8).padding(.vertical, 3) .frame(minWidth: 78) .background( @@ -24,7 +24,7 @@ struct ShortcutRecorder: View { .fill(Color.primary.opacity(0.06)) .overlay( RoundedRectangle(cornerRadius: 5) - .strokeBorder(Color.accentColor.opacity(recording ? 0.9 : 0), lineWidth: 1) + .strokeBorder(Theme.Palette.accent.opacity(recording ? 0.9 : 0), lineWidth: 1) ) ) } diff --git a/Sources/ComposerApp/Views/Sidebar.swift b/Sources/ComposerApp/Views/Sidebar.swift index e77b9d2..e26c3b6 100644 --- a/Sources/ComposerApp/Views/Sidebar.swift +++ b/Sources/ComposerApp/Views/Sidebar.swift @@ -1,81 +1,26 @@ import SwiftUI -/// The floating liquid-glass rail on the left edge — the always-visible home for the -/// board/session actions. Grouped top→bottom: board lifecycle (new, history), the agent and the -/// folder it's grounded in, then settings pinned below. The canvas tools + zoom live in the top -/// `CanvasToolbar`. Quiet by default, lights up on hover. -struct Sidebar: View { - @ObservedObject var store: DumpStore - /// The grounded directory's display name, or nil when the agent is canvas-only. - var groundedFolder: String? - var agentOpen: Bool - var onNew: () -> Void - var onHistory: () -> Void - var onAgent: () -> Void - var onFolder: () -> Void - var onClearFolder: () -> Void - var onSettings: () -> Void - - var body: some View { - VStack(spacing: 9) { - SidebarButton(symbol: "square.and.pencil", help: "New board ⌘N", action: onNew) - SidebarButton(symbol: "clock.arrow.circlepath", help: "Past boards ⌘[ ⌘]", - active: store.isHistoryOpen, action: onHistory) - - divider - - // The agent and its grounding context. The agent wears its engine's brand mark; the folder - // is icon-only here (a vertical rail can't carry the expanding name pill) and tints to the - // accent once grounded. - SidebarAgentButton(active: agentOpen, action: onAgent) - SidebarButton(symbol: groundedFolder == nil ? "folder.badge.plus" : "folder.fill", - help: folderHelp, active: groundedFolder != nil, action: onFolder) - .contextMenu { - if groundedFolder == nil { - Button("Ground in Folder\u{2026}", action: onFolder) - } else { - Button("Change Folder\u{2026}", action: onFolder) - Button("Remove Grounding", role: .destructive, action: onClearFolder) - } - } - - divider - - SidebarButton(symbol: "gearshape", help: "Settings ⌘,", action: onSettings) - } - .padding(.vertical, 12) - .padding(.horizontal, 7) - .railSurface() - } - - private var divider: some View { - Rectangle().fill(Theme.Palette.separator).frame(width: 16, height: 1).padding(.vertical, 1) - } - - private var folderHelp: String { - groundedFolder.map { "Agent grounded in \($0) · click to change" } - ?? "Ground the agent in a folder it can read" - } -} +/// Shared icon-button components for the floating chrome (bottom command bar, action pills). struct SidebarButton: View { let symbol: String let help: String var active = false var disabled = false + var side: CGFloat = WindowChrome.controlHeight var action: () -> Void @State private var hovering = false var body: some View { - Button(action: action) { + Button(action: { Haptics.tap(); action() }) { Image(systemName: symbol) - .font(.system(size: 17, weight: .medium)) + .font(WindowChrome.iconFont) .foregroundStyle(foreground) - .frame(width: 38, height: 38) + .frame(width: side, height: side) .background( // Active reads through the accent-tinted icon (below) — no blue fill, just a neutral // hover wash so the control still feels live. - Circle().fill(hovering && !disabled ? Color.white.opacity(0.12) : Color.clear) + Circle().fill(hovering && !disabled ? Theme.Palette.hoverWash : Color.clear) ) .contentShape(Circle()) } @@ -86,28 +31,29 @@ struct SidebarButton: View { .animation(.easeOut(duration: 0.12), value: hovering) } - // Icons sit on the dark rail, so they're keyed to white — bright enough to read at rest. + // Adaptive chrome tokens: white-keyed on the dark rail, ink-keyed on light glass. private var foreground: AnyShapeStyle { - if disabled { return AnyShapeStyle(Color.white.opacity(0.26)) } - if active { return AnyShapeStyle(Color.accentColor) } - return AnyShapeStyle(Color.white.opacity(hovering ? 0.95 : 0.62)) + if disabled { return AnyShapeStyle(Theme.Palette.chromeGlyphDim) } + if active { return AnyShapeStyle(Theme.Palette.accent) } + return AnyShapeStyle(hovering ? Theme.Palette.chromeGlyphHover : Theme.Palette.chromeGlyph) } } -/// The agent toggle on the rail — shows the active engine's brand mark. Like its old home in the -/// toolbar, there's no active ring or fill; open/closed reads from the dock itself, and the mark -/// just brightens on hover or when the dock is open. -private struct SidebarAgentButton: View { +/// The agent toggle — shows the active engine's brand mark. There's no active ring or fill; +/// open/closed reads from the dock itself, and the mark just brightens on hover or when the dock +/// is open. Lives on the rail in floating mode, in the top-right actions pill in window mode. +struct SidebarAgentButton: View { var active: Bool + var side: CGFloat = WindowChrome.controlHeight var action: () -> Void @State private var hovering = false var body: some View { - Button(action: action) { + Button(action: { Haptics.tap(); action() }) { AgentEngineIcon(size: 18) - .frame(width: 38, height: 38) + .frame(width: side, height: side) .opacity(active ? 1 : (hovering ? 0.95 : 0.78)) - .background(Circle().fill(hovering ? Color.white.opacity(0.12) : Color.clear)) + .background(Circle().fill(hovering ? Theme.Palette.hoverWash : Color.clear)) .contentShape(Circle()) } .buttonStyle(.plain) diff --git a/Tests/ComposerAppTests/AgentSkillsInstallerTests.swift b/Tests/ComposerAppTests/AgentSkillsInstallerTests.swift new file mode 100644 index 0000000..c122618 --- /dev/null +++ b/Tests/ComposerAppTests/AgentSkillsInstallerTests.swift @@ -0,0 +1,101 @@ +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) + } + + 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")) + } + + 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. + 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") + } + } +}