feat(mac): add agent-controlled Canvas panel

main
Peter Steinberger 2025-12-12 19:54:01 +00:00
parent c0abab226d
commit 27a7d9f9d1
14 changed files with 1237 additions and 0 deletions

View File

@ -149,6 +149,10 @@ final class AppState: ObservableObject {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.webChatPort, forKey: webChatPortKey) } } didSet { self.ifNotPreview { UserDefaults.standard.set(self.webChatPort, forKey: webChatPortKey) } }
} }
@Published var canvasEnabled: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
}
@Published var attachExistingGatewayOnly: Bool { @Published var attachExistingGatewayOnly: Bool {
didSet { didSet {
self.ifNotPreview { self.ifNotPreview {
@ -224,6 +228,7 @@ final class AppState: ObservableObject {
self.webChatSwiftUIEnabled = UserDefaults.standard.object(forKey: webChatSwiftUIEnabledKey) as? Bool ?? false self.webChatSwiftUIEnabled = UserDefaults.standard.object(forKey: webChatSwiftUIEnabledKey) as? Bool ?? false
let storedPort = UserDefaults.standard.integer(forKey: webChatPortKey) let storedPort = UserDefaults.standard.integer(forKey: webChatPortKey)
self.webChatPort = storedPort > 0 ? storedPort : 18788 self.webChatPort = storedPort > 0 ? storedPort : 18788
self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
self.attachExistingGatewayOnly = UserDefaults.standard.bool(forKey: attachExistingGatewayOnlyKey) self.attachExistingGatewayOnly = UserDefaults.standard.bool(forKey: attachExistingGatewayOnlyKey)
if !self.isPreview { if !self.isPreview {
@ -335,6 +340,7 @@ extension AppState {
state.webChatEnabled = true state.webChatEnabled = true
state.webChatSwiftUIEnabled = false state.webChatSwiftUIEnabled = false
state.webChatPort = 18788 state.webChatPort = 18788
state.canvasEnabled = true
state.remoteTarget = "user@example.com" state.remoteTarget = "user@example.com"
state.remoteIdentity = "~/.ssh/id_ed25519" state.remoteIdentity = "~/.ssh/id_ed25519"
state.remoteProjectRoot = "~/Projects/clawdis" state.remoteProjectRoot = "~/Projects/clawdis"
@ -367,6 +373,10 @@ enum AppStateStore {
return stored > 0 ? stored : 18788 return stored > 0 ? stored : 18788
} }
static var canvasEnabled: Bool {
UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
}
static var attachExistingGatewayOnly: Bool { static var attachExistingGatewayOnly: Bool {
UserDefaults.standard.bool(forKey: attachExistingGatewayOnlyKey) UserDefaults.standard.bool(forKey: attachExistingGatewayOnlyKey)
} }

View File

@ -0,0 +1,61 @@
import Foundation
import Darwin
final class CanvasFileWatcher: @unchecked Sendable {
private let url: URL
private let queue: DispatchQueue
private var source: DispatchSourceFileSystemObject?
private var fd: Int32 = -1
private var pending = false
private let onChange: () -> Void
init(url: URL, onChange: @escaping () -> Void) {
self.url = url
self.queue = DispatchQueue(label: "com.steipete.clawdis.canvaswatcher")
self.onChange = onChange
}
deinit {
self.stop()
}
func start() {
guard self.source == nil else { return }
let path = (self.url as NSURL).fileSystemRepresentation
let fd = open(path, O_EVTONLY)
guard fd >= 0 else { return }
self.fd = fd
let source = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: fd,
eventMask: [.write, .delete, .rename, .attrib, .extend, .link, .revoke],
queue: self.queue)
source.setEventHandler { [weak self] in
guard let self else { return }
if self.pending { return }
self.pending = true
self.queue.asyncAfter(deadline: .now() + 0.12) { [weak self] in
guard let self else { return }
self.pending = false
self.onChange()
}
}
source.setCancelHandler { [weak self] in
guard let self else { return }
if self.fd >= 0 {
close(self.fd)
self.fd = -1
}
}
self.source = source
source.resume()
}
func stop() {
self.source?.cancel()
self.source = nil
}
}

View File

@ -0,0 +1,77 @@
import AppKit
import Foundation
@MainActor
final class CanvasManager {
static let shared = CanvasManager()
private var panelController: CanvasWindowController?
private var panelSessionKey: String?
/// Optional anchor provider (e.g. menu bar status item). If nil, Canvas anchors to the mouse cursor.
var defaultAnchorProvider: (() -> NSRect?)?
private nonisolated static let canvasRoot: URL = {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
return base.appendingPathComponent("Clawdis/canvas", isDirectory: true)
}()
func show(sessionKey: String, path: String? = nil) throws -> String {
let anchorProvider = self.defaultAnchorProvider ?? Self.mouseAnchorProvider
let session = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
if let controller = self.panelController, self.panelSessionKey == session {
controller.presentAnchoredPanel(anchorProvider: anchorProvider)
controller.goto(path: path ?? "/")
return controller.directoryPath
}
self.panelController?.close()
self.panelController = nil
self.panelSessionKey = nil
try FileManager.default.createDirectory(at: Self.canvasRoot, withIntermediateDirectories: true)
let controller = try CanvasWindowController(
sessionKey: session,
root: Self.canvasRoot,
presentation: .panel(anchorProvider: anchorProvider))
self.panelController = controller
self.panelSessionKey = session
controller.showCanvas(path: path ?? "/")
return controller.directoryPath
}
func hide(sessionKey: String) {
let session = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard self.panelSessionKey == session else { return }
self.panelController?.hideCanvas()
}
func hideAll() {
self.panelController?.hideCanvas()
}
func goto(sessionKey: String, path: String) throws {
_ = try self.show(sessionKey: sessionKey, path: path)
}
func eval(sessionKey: String, javaScript: String) async throws -> String {
_ = try self.show(sessionKey: sessionKey, path: nil)
guard let controller = self.panelController else { return "" }
return await controller.eval(javaScript: javaScript)
}
func snapshot(sessionKey: String, outPath: String?) async throws -> String {
_ = try self.show(sessionKey: sessionKey, path: nil)
guard let controller = self.panelController else {
throw NSError(domain: "Canvas", code: 21, userInfo: [NSLocalizedDescriptionKey: "canvas not available"])
}
return try await controller.snapshot(to: outPath)
}
// MARK: - Anchoring
private static func mouseAnchorProvider() -> NSRect? {
let pt = NSEvent.mouseLocation
return NSRect(x: pt.x, y: pt.y, width: 1, height: 1)
}
}

View File

@ -0,0 +1,40 @@
import Foundation
enum CanvasScheme {
static let scheme = "clawdis-canvas"
static func makeURL(session: String, path: String? = nil) -> URL? {
var comps = URLComponents()
comps.scheme = Self.scheme
comps.host = session
let p = (path ?? "/").trimmingCharacters(in: .whitespacesAndNewlines)
if p.isEmpty || p == "/" {
comps.path = "/"
} else if p.hasPrefix("/") {
comps.path = p
} else {
comps.path = "/" + p
}
return comps.url
}
static func mimeType(forExtension ext: String) -> String {
switch ext.lowercased() {
case "html", "htm": "text/html; charset=utf-8"
case "js", "mjs": "application/javascript; charset=utf-8"
case "css": "text/css; charset=utf-8"
case "json", "map": "application/json; charset=utf-8"
case "svg": "image/svg+xml"
case "png": "image/png"
case "jpg", "jpeg": "image/jpeg"
case "gif": "image/gif"
case "ico": "image/x-icon"
case "woff2": "font/woff2"
case "woff": "font/woff"
case "ttf": "font/ttf"
case "wasm": "application/wasm"
default: "application/octet-stream"
}
}
}

View File

@ -0,0 +1,194 @@
import Foundation
import OSLog
import WebKit
private let canvasLogger = Logger(subsystem: "com.steipete.clawdis", category: "Canvas")
final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
private let root: URL
init(root: URL) {
self.root = root
}
func webView(_: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
guard let url = urlSchemeTask.request.url else {
urlSchemeTask.didFailWithError(NSError(domain: "Canvas", code: 1, userInfo: [
NSLocalizedDescriptionKey: "missing url",
]))
return
}
let response = self.response(for: url)
let mime = response.mime
let data = response.data
let urlResponse = URLResponse(
url: url,
mimeType: mime,
expectedContentLength: data.count,
textEncodingName: "utf-8")
urlSchemeTask.didReceive(urlResponse)
urlSchemeTask.didReceive(data)
urlSchemeTask.didFinish()
}
func webView(_: WKWebView, stop _: WKURLSchemeTask) {
// no-op
}
private struct CanvasResponse {
let mime: String
let data: Data
}
private func response(for url: URL) -> CanvasResponse {
guard url.scheme == CanvasScheme.scheme else {
return self.html("Invalid scheme.")
}
guard let session = url.host, !session.isEmpty else {
return self.html("Missing session.")
}
// Keep session component safe; don't allow slashes or traversal.
if session.contains("/") || session.contains("..") {
return self.html("Invalid session.")
}
let sessionRoot = self.root.appendingPathComponent(session, isDirectory: true)
// Path mapping: request path maps directly into the session dir.
var path = url.path
if let qIdx = path.firstIndex(of: "?") { path = String(path[..<qIdx]) }
if path.hasPrefix("/") { path.removeFirst() }
path = path.removingPercentEncoding ?? path
// Special-case: welcome page when root index is missing.
if path.isEmpty {
let indexA = sessionRoot.appendingPathComponent("index.html", isDirectory: false)
let indexB = sessionRoot.appendingPathComponent("index.htm", isDirectory: false)
if !FileManager.default.fileExists(atPath: indexA.path),
!FileManager.default.fileExists(atPath: indexB.path)
{
return self.welcomePage(sessionRoot: sessionRoot)
}
}
let resolved = self.resolveFileURL(sessionRoot: sessionRoot, requestPath: path)
guard let fileURL = resolved else {
return self.html("Not Found", title: "Canvas: 404")
}
// Directory traversal guard: served files must live under the session root.
let standardizedRoot = sessionRoot.standardizedFileURL
let standardizedFile = fileURL.standardizedFileURL
guard standardizedFile.path.hasPrefix(standardizedRoot.path) else {
return self.html("Forbidden", title: "Canvas: 403")
}
do {
let data = try Data(contentsOf: standardizedFile)
let mime = CanvasScheme.mimeType(forExtension: standardizedFile.pathExtension)
canvasLogger.debug(
"served \(session, privacy: .public)/\(path, privacy: .public) -> \(standardizedFile.path, privacy: .public)")
return CanvasResponse(mime: mime, data: data)
} catch {
canvasLogger.error("failed reading \(standardizedFile.path, privacy: .public): \(error.localizedDescription, privacy: .public)")
return self.html("Failed to read file.", title: "Canvas error")
}
}
private func resolveFileURL(sessionRoot: URL, requestPath: String) -> URL? {
let fm = FileManager.default
var candidate = sessionRoot.appendingPathComponent(requestPath, isDirectory: false)
var isDir: ObjCBool = false
if fm.fileExists(atPath: candidate.path, isDirectory: &isDir) {
if isDir.boolValue {
if let idx = self.resolveIndex(in: candidate) { return idx }
return nil
}
return candidate
}
// Directory index behavior:
// - "/yolo" serves "<yolo>/index.html" if that directory exists.
if !requestPath.isEmpty, !requestPath.hasSuffix("/") {
candidate = sessionRoot.appendingPathComponent(requestPath, isDirectory: true)
if fm.fileExists(atPath: candidate.path, isDirectory: &isDir), isDir.boolValue {
if let idx = self.resolveIndex(in: candidate) { return idx }
}
}
// Root fallback:
// - "/" serves "<sessionRoot>/index.html" if present.
if requestPath.isEmpty {
return self.resolveIndex(in: sessionRoot)
}
return nil
}
private func resolveIndex(in dir: URL) -> URL? {
let fm = FileManager.default
let a = dir.appendingPathComponent("index.html", isDirectory: false)
if fm.fileExists(atPath: a.path) { return a }
let b = dir.appendingPathComponent("index.htm", isDirectory: false)
if fm.fileExists(atPath: b.path) { return b }
return nil
}
private func html(_ body: String, title: String = "Canvas") -> CanvasResponse {
let html = """
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>\(title)</title>
<style>
:root { color-scheme: light; }
html,body { height:100%; margin:0; }
body {
font: 13px -apple-system, system-ui;
display:flex;
align-items:center;
justify-content:center;
background: #fff;
color:#111827;
}
.card {
max-width: 520px;
padding: 18px 18px;
border-radius: 12px;
border: 1px solid rgba(0,0,0,.08);
box-shadow: 0 10px 30px rgba(0,0,0,.08);
}
.muted { color:#6b7280; margin-top:8px; }
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
</style>
</head>
<body>
<div class="card">
<div>\(body)</div>
</div>
</body>
</html>
"""
return CanvasResponse(mime: "text/html; charset=utf-8", data: Data(html.utf8))
}
private func welcomePage(sessionRoot: URL) -> CanvasResponse {
let escaped = sessionRoot.path
.replacingOccurrences(of: "&", with: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
let body = """
<div style="font-weight:600; font-size:14px;">Canvas is ready.</div>
<div class="muted">Create <code>index.html</code> in:</div>
<div style="margin-top:10px;"><code>\(escaped)</code></div>
"""
return self.html(body, title: "Canvas")
}
}

View File

@ -0,0 +1,367 @@
import AppKit
import Foundation
import OSLog
import WebKit
import QuartzCore
private let canvasWindowLogger = Logger(subsystem: "com.steipete.clawdis", category: "Canvas")
private enum CanvasLayout {
static let panelSize = NSSize(width: 520, height: 680)
static let windowSize = NSSize(width: 1120, height: 840)
static let anchorPadding: CGFloat = 8
}
final class CanvasPanel: NSPanel {
override var canBecomeKey: Bool { true }
override var canBecomeMain: Bool { true }
}
enum CanvasPresentation {
case window
case panel(anchorProvider: () -> NSRect?)
var isPanel: Bool {
if case .panel = self { return true }
return false
}
}
@MainActor
final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NSWindowDelegate {
private let sessionKey: String
private let root: URL
private let sessionDir: URL
private let schemeHandler: CanvasSchemeHandler
private let webView: WKWebView
private let watcher: CanvasFileWatcher
let presentation: CanvasPresentation
var onVisibilityChanged: ((Bool) -> Void)?
init(sessionKey: String, root: URL, presentation: CanvasPresentation) throws {
self.sessionKey = sessionKey
self.root = root
self.presentation = presentation
let safeSessionKey = CanvasWindowController.sanitizeSessionKey(sessionKey)
self.sessionDir = root.appendingPathComponent(safeSessionKey, isDirectory: true)
try FileManager.default.createDirectory(at: self.sessionDir, withIntermediateDirectories: true)
self.schemeHandler = CanvasSchemeHandler(root: root)
let config = WKWebViewConfiguration()
config.userContentController = WKUserContentController()
config.preferences.isElementFullscreenEnabled = true
config.preferences.setValue(true, forKey: "developerExtrasEnabled")
config.setURLSchemeHandler(self.schemeHandler, forURLScheme: CanvasScheme.scheme)
self.webView = WKWebView(frame: .zero, configuration: config)
self.webView.setValue(false, forKey: "drawsBackground")
self.watcher = CanvasFileWatcher(url: self.sessionDir) { [weak webView] in
Task { @MainActor in
webView?.reload()
}
}
let content = HoverChromeContainerView(containing: self.webView)
let window = Self.makeWindow(for: presentation, contentView: content)
super.init(window: window)
self.webView.navigationDelegate = self
self.window?.delegate = self
self.watcher.start()
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
@MainActor deinit {
self.watcher.stop()
}
func showCanvas(path: String? = nil) {
if case .panel(let anchorProvider) = self.presentation {
self.presentAnchoredPanel(anchorProvider: anchorProvider)
if let path {
self.goto(path: path)
} else {
self.goto(path: "/")
}
return
}
self.showWindow(nil)
self.window?.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
if let path {
self.goto(path: path)
} else {
self.goto(path: "/")
}
self.onVisibilityChanged?(true)
}
func hideCanvas() {
if case .panel = self.presentation {
self.window?.orderOut(nil)
} else {
self.close()
}
self.onVisibilityChanged?(false)
}
func goto(path: String) {
guard let url = CanvasScheme.makeURL(session: CanvasWindowController.sanitizeSessionKey(self.sessionKey), path: path) else {
canvasWindowLogger.error("invalid canvas url session=\(self.sessionKey, privacy: .public) path=\(path, privacy: .public)")
return
}
canvasWindowLogger.debug("canvas goto \(url.absoluteString, privacy: .public)")
self.webView.load(URLRequest(url: url))
}
func eval(javaScript: String) async -> String {
await withCheckedContinuation { cont in
self.webView.evaluateJavaScript(javaScript) { result, error in
if let error {
cont.resume(returning: "error: \(error.localizedDescription)")
return
}
if let result {
cont.resume(returning: String(describing: result))
} else {
cont.resume(returning: "")
}
}
}
}
func snapshot(to outPath: String?) async throws -> String {
let image: NSImage = try await withCheckedThrowingContinuation { cont in
self.webView.takeSnapshot(with: nil) { image, error in
if let error {
cont.resume(throwing: error)
return
}
guard let image else {
cont.resume(throwing: NSError(domain: "Canvas", code: 11, userInfo: [
NSLocalizedDescriptionKey: "snapshot returned nil image",
]))
return
}
cont.resume(returning: image)
}
}
guard let tiff = image.tiffRepresentation,
let rep = NSBitmapImageRep(data: tiff),
let png = rep.representation(using: .png, properties: [:])
else {
throw NSError(domain: "Canvas", code: 12, userInfo: [
NSLocalizedDescriptionKey: "failed to encode png",
])
}
let path: String
if let outPath, !outPath.isEmpty {
path = outPath
} else {
let ts = Int(Date().timeIntervalSince1970)
path = "/tmp/clawdis-canvas-\(CanvasWindowController.sanitizeSessionKey(self.sessionKey))-\(ts).png"
}
try png.write(to: URL(fileURLWithPath: path), options: [.atomic])
return path
}
var directoryPath: String {
self.sessionDir.path
}
// MARK: - Window
private static func makeWindow(for presentation: CanvasPresentation, contentView: NSView) -> NSWindow {
switch presentation {
case .window:
let window = NSWindow(
contentRect: NSRect(origin: .zero, size: CanvasLayout.windowSize),
styleMask: [.titled, .closable, .resizable, .miniaturizable],
backing: .buffered,
defer: false)
window.title = "Clawdis Canvas"
window.contentView = contentView
window.center()
window.minSize = NSSize(width: 880, height: 680)
return window
case .panel:
let panel = CanvasPanel(
contentRect: NSRect(origin: .zero, size: CanvasLayout.panelSize),
styleMask: [.borderless],
backing: .buffered,
defer: false)
panel.level = .statusBar
panel.hasShadow = true
panel.isMovable = false
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
panel.titleVisibility = .hidden
panel.titlebarAppearsTransparent = true
panel.backgroundColor = .clear
panel.isOpaque = false
panel.contentView = contentView
panel.becomesKeyOnlyIfNeeded = true
panel.hidesOnDeactivate = false
return panel
}
}
func presentAnchoredPanel(anchorProvider: @escaping () -> NSRect?) {
guard case .panel = self.presentation, let window else { return }
self.repositionPanel(using: anchorProvider)
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
window.makeFirstResponder(self.webView)
self.onVisibilityChanged?(true)
}
private func repositionPanel(using anchorProvider: () -> NSRect?) {
guard let panel = self.window else { return }
guard let anchor = anchorProvider() else { return }
var frame = panel.frame
let screen = NSScreen.screens.first { screen in
screen.frame.contains(anchor.origin) || screen.frame.contains(NSPoint(x: anchor.midX, y: anchor.midY))
} ?? NSScreen.main
if let screen {
let minX = screen.frame.minX + CanvasLayout.anchorPadding
let maxX = screen.frame.maxX - frame.width - CanvasLayout.anchorPadding
frame.origin.x = min(max(round(anchor.midX - frame.width / 2), minX), maxX)
let desiredY = anchor.minY - frame.height - CanvasLayout.anchorPadding
frame.origin.y = max(desiredY, screen.frame.minY + CanvasLayout.anchorPadding)
} else {
frame.origin.x = round(anchor.midX - frame.width / 2)
frame.origin.y = anchor.minY - frame.height
}
panel.setFrame(frame, display: false)
}
// MARK: - WKNavigationDelegate
@MainActor
func webView(
_: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void)
{
guard let url = navigationAction.request.url else {
decisionHandler(.cancel)
return
}
if url.scheme == CanvasScheme.scheme {
decisionHandler(.allow)
return
}
NSWorkspace.shared.open(url)
decisionHandler(.cancel)
}
// MARK: - NSWindowDelegate
func windowWillClose(_: Notification) {
self.onVisibilityChanged?(false)
}
// MARK: - Helpers
private static func sanitizeSessionKey(_ key: String) -> String {
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return "main" }
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-+")
let scalars = trimmed.unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" }
return String(scalars)
}
}
// MARK: - Hover chrome container
private final class PassthroughView: NSView {
override func hitTest(_: NSPoint) -> NSView? { nil }
}
private final class HoverChromeContainerView: NSView {
private let content: NSView
private let chrome: NSView
private var tracking: NSTrackingArea?
init(containing content: NSView) {
self.content = content
self.chrome = PassthroughView(frame: .zero)
super.init(frame: .zero)
self.wantsLayer = true
self.layer?.cornerRadius = 12
self.layer?.masksToBounds = true
self.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor
self.content.translatesAutoresizingMaskIntoConstraints = false
self.addSubview(self.content)
self.chrome.translatesAutoresizingMaskIntoConstraints = false
self.chrome.wantsLayer = true
self.chrome.layer?.cornerRadius = 12
self.chrome.layer?.masksToBounds = true
self.chrome.layer?.borderWidth = 1
self.chrome.layer?.borderColor = NSColor.black.withAlphaComponent(0.18).cgColor
self.chrome.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.02).cgColor
self.chrome.alphaValue = 0
self.addSubview(self.chrome)
NSLayoutConstraint.activate([
self.content.leadingAnchor.constraint(equalTo: self.leadingAnchor),
self.content.trailingAnchor.constraint(equalTo: self.trailingAnchor),
self.content.topAnchor.constraint(equalTo: self.topAnchor),
self.content.bottomAnchor.constraint(equalTo: self.bottomAnchor),
self.chrome.leadingAnchor.constraint(equalTo: self.leadingAnchor),
self.chrome.trailingAnchor.constraint(equalTo: self.trailingAnchor),
self.chrome.topAnchor.constraint(equalTo: self.topAnchor),
self.chrome.bottomAnchor.constraint(equalTo: self.bottomAnchor),
])
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func updateTrackingAreas() {
super.updateTrackingAreas()
if let tracking {
self.removeTrackingArea(tracking)
}
let area = NSTrackingArea(
rect: self.bounds,
options: [.activeAlways, .mouseEnteredAndExited, .inVisibleRect],
owner: self,
userInfo: nil)
self.addTrackingArea(area)
self.tracking = area
}
override func mouseEntered(with _: NSEvent) {
NSAnimationContext.runAnimationGroup { ctx in
ctx.duration = 0.12
ctx.timingFunction = CAMediaTimingFunction(name: .easeOut)
self.chrome.animator().alphaValue = 1
}
}
override func mouseExited(with _: NSEvent) {
NSAnimationContext.runAnimationGroup { ctx in
ctx.duration = 0.16
ctx.timingFunction = CAMediaTimingFunction(name: .easeOut)
self.chrome.animator().alphaValue = 0
}
}
}

View File

@ -23,6 +23,7 @@ let remoteProjectRootKey = "clawdis.remoteProjectRoot"
let webChatEnabledKey = "clawdis.webChatEnabled" let webChatEnabledKey = "clawdis.webChatEnabled"
let webChatSwiftUIEnabledKey = "clawdis.webChatSwiftUIEnabled" let webChatSwiftUIEnabledKey = "clawdis.webChatSwiftUIEnabled"
let webChatPortKey = "clawdis.webChatPort" let webChatPortKey = "clawdis.webChatPort"
let canvasEnabledKey = "clawdis.canvasEnabled"
let modelCatalogPathKey = "clawdis.modelCatalogPath" let modelCatalogPathKey = "clawdis.modelCatalogPath"
let modelCatalogReloadKey = "clawdis.modelCatalogReload" let modelCatalogReloadKey = "clawdis.modelCatalogReload"
let attachExistingGatewayOnlyKey = "clawdis.gateway.attachExistingOnly" let attachExistingGatewayOnlyKey = "clawdis.gateway.attachExistingOnly"

View File

@ -12,6 +12,7 @@ enum ControlRequestHandler {
if paused { if paused {
return Response(ok: false, message: "clawdis paused") return Response(ok: false, message: "clawdis paused")
} }
let canvasEnabled = await MainActor.run { AppStateStore.canvasEnabled }
switch request { switch request {
case let .notify(title, body, sound, priority, delivery): case let .notify(title, body, sound, priority, delivery):
@ -83,6 +84,54 @@ enum ControlRequestHandler {
return rpcResult.ok return rpcResult.ok
? Response(ok: true, message: rpcResult.text ?? "sent") ? Response(ok: true, message: rpcResult.text ?? "sent")
: Response(ok: false, message: rpcResult.error ?? "failed to send") : Response(ok: false, message: rpcResult.error ?? "failed to send")
case let .canvasShow(session, path):
guard canvasEnabled else {
return Response(ok: false, message: "Canvas disabled by user")
}
do {
let dir = try await MainActor.run { try CanvasManager.shared.show(sessionKey: session, path: path) }
return Response(ok: true, message: dir)
} catch {
return Response(ok: false, message: error.localizedDescription)
}
case let .canvasHide(session):
await MainActor.run { CanvasManager.shared.hide(sessionKey: session) }
return Response(ok: true)
case let .canvasGoto(session, path):
guard canvasEnabled else {
return Response(ok: false, message: "Canvas disabled by user")
}
do {
try await MainActor.run { try CanvasManager.shared.goto(sessionKey: session, path: path) }
return Response(ok: true)
} catch {
return Response(ok: false, message: error.localizedDescription)
}
case let .canvasEval(session, javaScript):
guard canvasEnabled else {
return Response(ok: false, message: "Canvas disabled by user")
}
do {
let result = try await CanvasManager.shared.eval(sessionKey: session, javaScript: javaScript)
return Response(ok: true, payload: Data(result.utf8))
} catch {
return Response(ok: false, message: error.localizedDescription)
}
case let .canvasSnapshot(session, outPath):
guard canvasEnabled else {
return Response(ok: false, message: "Canvas disabled by user")
}
do {
let path = try await CanvasManager.shared.snapshot(sessionKey: session, outPath: outPath)
return Response(ok: true, message: path)
} catch {
return Response(ok: false, message: error.localizedDescription)
}
} }
} }
} }

View File

@ -7,6 +7,7 @@ struct DebugSettings: View {
@AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath @AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath
@AppStorage(modelCatalogReloadKey) private var modelCatalogReloadBump: Int = 0 @AppStorage(modelCatalogReloadKey) private var modelCatalogReloadBump: Int = 0
@AppStorage(iconOverrideKey) private var iconOverrideRaw: String = IconOverrideSelection.system.rawValue @AppStorage(iconOverrideKey) private var iconOverrideRaw: String = IconOverrideSelection.system.rawValue
@AppStorage(canvasEnabledKey) private var canvasEnabled: Bool = true
@State private var modelsCount: Int? @State private var modelsCount: Int?
@State private var modelsLoading = false @State private var modelsLoading = false
@State private var modelsError: String? @State private var modelsError: String?
@ -25,6 +26,13 @@ struct DebugSettings: View {
@AppStorage(webChatSwiftUIEnabledKey) private var webChatSwiftUIEnabled: Bool = false @AppStorage(webChatSwiftUIEnabledKey) private var webChatSwiftUIEnabled: Bool = false
@AppStorage(attachExistingGatewayOnlyKey) private var attachExistingGatewayOnly: Bool = false @AppStorage(attachExistingGatewayOnlyKey) private var attachExistingGatewayOnly: Bool = false
@State private var canvasSessionKey: String = "main"
@State private var canvasStatus: String?
@State private var canvasError: String?
@State private var canvasEvalJS: String = "document.title"
@State private var canvasEvalResult: String?
@State private var canvasSnapshotPath: String?
var body: some View { var body: some View {
ScrollView(.vertical) { ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
@ -260,6 +268,84 @@ struct DebugSettings: View {
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
Divider() Divider()
VStack(alignment: .leading, spacing: 8) {
Text("Canvas")
.font(.caption.weight(.semibold))
Toggle("Allow Canvas (agent)", isOn: self.$canvasEnabled)
.toggleStyle(.switch)
.help("When off, agent Canvas requests return “Canvas disabled by user”. Manual debug actions still work.")
HStack(spacing: 8) {
TextField("Session", text: self.$canvasSessionKey)
.textFieldStyle(.roundedBorder)
.font(.caption.monospaced())
.frame(width: 160)
Button("Show panel") {
Task { await self.canvasShow() }
}
.buttonStyle(.borderedProminent)
Button("Hide panel") {
CanvasManager.shared.hideAll()
self.canvasStatus = "hidden"
self.canvasError = nil
}
.buttonStyle(.bordered)
Button("Write sample page") {
Task { await self.canvasWriteSamplePage() }
}
.buttonStyle(.bordered)
}
HStack(spacing: 8) {
TextField("Eval JS", text: self.$canvasEvalJS)
.textFieldStyle(.roundedBorder)
.font(.caption.monospaced())
.frame(maxWidth: 420)
Button("Eval") {
Task { await self.canvasEval() }
}
.buttonStyle(.bordered)
Button("Snapshot") {
Task { await self.canvasSnapshot() }
}
.buttonStyle(.bordered)
}
if let canvasStatus {
Text(canvasStatus)
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
if let canvasEvalResult {
Text("eval → \(canvasEvalResult)")
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
.lineLimit(2)
.truncationMode(.middle)
.textSelection(.enabled)
}
if let canvasSnapshotPath {
HStack(spacing: 8) {
Text("snapshot → \(canvasSnapshotPath)")
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
.textSelection(.enabled)
Button("Reveal") {
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: canvasSnapshotPath)])
}
.buttonStyle(.bordered)
}
}
if let canvasError {
Text(canvasError)
.font(.caption2)
.foregroundStyle(.red)
} else {
Text("Tip: the session directory is returned by “Show panel”.")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
LabeledContent("Icon override") { LabeledContent("Icon override") {
Picker("Icon override", selection: self.bindingOverride) { Picker("Icon override", selection: self.bindingOverride) {
ForEach(IconOverrideSelection.allCases) { option in ForEach(IconOverrideSelection.allCases) { option in
@ -451,6 +537,117 @@ struct DebugSettings: View {
.appendingPathComponent(".clawdis") .appendingPathComponent(".clawdis")
.appendingPathComponent("clawdis.json") .appendingPathComponent("clawdis.json")
} }
// MARK: - Canvas debug actions
@MainActor
private func canvasShow() async {
self.canvasError = nil
let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
do {
let dir = try CanvasManager.shared.show(sessionKey: session.isEmpty ? "main" : session, path: "/")
self.canvasStatus = "dir: \(dir)"
} catch {
self.canvasError = error.localizedDescription
}
}
@MainActor
private func canvasWriteSamplePage() async {
self.canvasError = nil
let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
do {
let dir = try CanvasManager.shared.show(sessionKey: session.isEmpty ? "main" : session, path: "/")
let url = URL(fileURLWithPath: dir).appendingPathComponent("index.html", isDirectory: false)
let now = ISO8601DateFormatter().string(from: Date())
let html = """
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Canvas Debug</title>
<style>
:root { color-scheme: dark; }
html,body { height:100%; margin:0; background:#0b1020; color:#e5e7eb; }
body { font: 13px ui-monospace, SFMono-Regular, Menlo, monospace; }
.wrap { padding:16px; }
.row { display:flex; gap:12px; align-items:center; flex-wrap:wrap; }
.pill { padding:6px 10px; border-radius:999px; background:rgba(255,255,255,.08); border:1px solid rgba(255,255,255,.12); }
button { background:#22c55e; color:#04110a; border:0; border-radius:10px; padding:8px 10px; font-weight:700; cursor:pointer; }
button:active { transform: translateY(1px); }
.panel { margin-top:14px; padding:14px; border-radius:14px; background:rgba(255,255,255,.06); border:1px solid rgba(255,255,255,.1); }
.grid { display:grid; grid-template-columns: repeat(12, 1fr); gap:10px; margin-top:12px; }
.box { grid-column: span 4; height:80px; border-radius:14px; background: linear-gradient(135deg, rgba(59,130,246,.35), rgba(168,85,247,.25)); border:1px solid rgba(255,255,255,.12); }
.muted { color: rgba(229,231,235,.7); }
</style>
</head>
<body>
<div class="wrap">
<div class="row">
<div class="pill">Canvas Debug</div>
<div class="pill muted">generated: \(now)</div>
<div class="pill muted">userAgent: <span id="ua"></span></div>
<button id="btn">Click me</button>
<div class="pill">count: <span id="count">0</span></div>
</div>
<div class="panel">
<div class="muted">This is a local file served by the WKURLSchemeHandler.</div>
<div class="grid">
<div class="box"></div><div class="box"></div><div class="box"></div>
<div class="box"></div><div class="box"></div><div class="box"></div>
</div>
</div>
</div>
<script>
document.getElementById('ua').textContent = navigator.userAgent;
let n = 0;
document.getElementById('btn').addEventListener('click', () => {
n++;
document.getElementById('count').textContent = String(n);
document.title = 'Canvas Debug (' + n + ')';
});
</script>
</body>
</html>
"""
try html.write(to: url, atomically: true, encoding: .utf8)
self.canvasStatus = "wrote: \(url.path)"
try CanvasManager.shared.goto(sessionKey: session.isEmpty ? "main" : session, path: "/")
} catch {
self.canvasError = error.localizedDescription
}
}
@MainActor
private func canvasEval() async {
self.canvasError = nil
self.canvasEvalResult = nil
do {
let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
let result = try await CanvasManager.shared.eval(
sessionKey: session.isEmpty ? "main" : session,
javaScript: self.canvasEvalJS)
self.canvasEvalResult = result
} catch {
self.canvasError = error.localizedDescription
}
}
@MainActor
private func canvasSnapshot() async {
self.canvasError = nil
self.canvasSnapshotPath = nil
do {
let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
let path = try await CanvasManager.shared.snapshot(
sessionKey: session.isEmpty ? "main" : session,
outPath: nil)
self.canvasSnapshotPath = path
} catch {
self.canvasError = error.localizedDescription
}
}
} }
#if DEBUG #if DEBUG

View File

@ -79,6 +79,7 @@ struct ClawdisApp: App {
self.isPanelVisible = visible self.isPanelVisible = visible
self.updateStatusHighlight() self.updateStatusHighlight()
} }
CanvasManager.shared.defaultAnchorProvider = { [self] in self.statusButtonScreenFrame() }
let handler = StatusItemMouseHandlerView() let handler = StatusItemMouseHandlerView()
handler.translatesAutoresizingMaskIntoConstraints = false handler.translatesAutoresizingMaskIntoConstraints = false

View File

@ -37,6 +37,14 @@ struct MenuContent: View {
WebChatManager.shared.show(sessionKey: WebChatManager.shared.preferredSessionKey()) WebChatManager.shared.show(sessionKey: WebChatManager.shared.preferredSessionKey())
} }
} }
Toggle(isOn: Binding(get: { self.state.canvasEnabled }, set: { self.state.canvasEnabled = $0 })) {
Text("Allow Canvas")
}
.onChange(of: self.state.canvasEnabled) { _, enabled in
if !enabled {
CanvasManager.shared.hideAll()
}
}
Divider() Divider()
Button("Settings…") { self.open(tab: .general) } Button("Settings…") { self.open(tab: .general) }
.keyboardShortcut(",", modifiers: [.command]) .keyboardShortcut(",", modifiers: [.command])

View File

@ -163,6 +163,80 @@ struct ClawdisCLI {
guard let message else { throw CLIError.help } guard let message else { throw CLIError.help }
return .agent(message: message, thinking: thinking, session: session, deliver: deliver, to: to) return .agent(message: message, thinking: thinking, session: session, deliver: deliver, to: to)
case "canvas":
guard let sub = args.first else { throw CLIError.help }
args = Array(args.dropFirst())
switch sub {
case "show":
var session = "main"
var path: String?
while !args.isEmpty {
let arg = args.removeFirst()
switch arg {
case "--session": session = args.popFirst() ?? session
case "--path": path = args.popFirst()
default: break
}
}
return .canvasShow(session: session, path: path)
case "hide":
var session = "main"
while !args.isEmpty {
let arg = args.removeFirst()
switch arg {
case "--session": session = args.popFirst() ?? session
default: break
}
}
return .canvasHide(session: session)
case "goto":
var session = "main"
var path: String?
while !args.isEmpty {
let arg = args.removeFirst()
switch arg {
case "--session": session = args.popFirst() ?? session
case "--path": path = args.popFirst()
default: break
}
}
guard let path else { throw CLIError.help }
return .canvasGoto(session: session, path: path)
case "eval":
var session = "main"
var js: String?
while !args.isEmpty {
let arg = args.removeFirst()
switch arg {
case "--session": session = args.popFirst() ?? session
case "--js": js = args.popFirst()
default: break
}
}
guard let js else { throw CLIError.help }
return .canvasEval(session: session, javaScript: js)
case "snapshot":
var session = "main"
var outPath: String?
while !args.isEmpty {
let arg = args.removeFirst()
switch arg {
case "--session": session = args.popFirst() ?? session
case "--out": outPath = args.popFirst()
default: break
}
}
return .canvasSnapshot(session: session, outPath: outPath)
default:
throw CLIError.help
}
default: default:
throw CLIError.help throw CLIError.help
} }
@ -185,6 +259,11 @@ struct ClawdisCLI {
clawdis-mac rpc-status clawdis-mac rpc-status
clawdis-mac agent --message <text> [--thinking <low|default|high>] clawdis-mac agent --message <text> [--thinking <low|default|high>]
[--session <key>] [--deliver] [--to <E.164>] [--session <key>] [--deliver] [--to <E.164>]
clawdis-mac canvas show [--session <key>] [--path </...>]
clawdis-mac canvas hide [--session <key>]
clawdis-mac canvas goto --path </...> [--session <key>]
clawdis-mac canvas eval --js <code> [--session <key>]
clawdis-mac canvas snapshot [--out <path>] [--session <key>]
clawdis-mac --help clawdis-mac --help
Returns JSON to stdout: Returns JSON to stdout:

View File

@ -49,6 +49,11 @@ public enum Request: Sendable {
case status case status
case agent(message: String, thinking: String?, session: String?, deliver: Bool, to: String?) case agent(message: String, thinking: String?, session: String?, deliver: Bool, to: String?)
case rpcStatus case rpcStatus
case canvasShow(session: String, path: String?)
case canvasHide(session: String)
case canvasGoto(session: String, path: String)
case canvasEval(session: String, javaScript: String)
case canvasSnapshot(session: String, outPath: String?)
} }
// MARK: - Responses // MARK: - Responses
@ -77,6 +82,9 @@ extension Request: Codable {
case command, cwd, env, timeoutSec, needsScreenRecording case command, cwd, env, timeoutSec, needsScreenRecording
case message, thinking, session, deliver, to case message, thinking, session, deliver, to
case rpcStatus case rpcStatus
case path
case javaScript
case outPath
} }
private enum Kind: String, Codable { private enum Kind: String, Codable {
@ -87,6 +95,11 @@ extension Request: Codable {
case status case status
case agent case agent
case rpcStatus case rpcStatus
case canvasShow
case canvasHide
case canvasGoto
case canvasEval
case canvasSnapshot
} }
public func encode(to encoder: Encoder) throws { public func encode(to encoder: Encoder) throws {
@ -132,6 +145,30 @@ extension Request: Codable {
case .rpcStatus: case .rpcStatus:
try container.encode(Kind.rpcStatus, forKey: .type) try container.encode(Kind.rpcStatus, forKey: .type)
case let .canvasShow(session, path):
try container.encode(Kind.canvasShow, forKey: .type)
try container.encode(session, forKey: .session)
try container.encodeIfPresent(path, forKey: .path)
case let .canvasHide(session):
try container.encode(Kind.canvasHide, forKey: .type)
try container.encode(session, forKey: .session)
case let .canvasGoto(session, path):
try container.encode(Kind.canvasGoto, forKey: .type)
try container.encode(session, forKey: .session)
try container.encode(path, forKey: .path)
case let .canvasEval(session, javaScript):
try container.encode(Kind.canvasEval, forKey: .type)
try container.encode(session, forKey: .session)
try container.encode(javaScript, forKey: .javaScript)
case let .canvasSnapshot(session, outPath):
try container.encode(Kind.canvasSnapshot, forKey: .type)
try container.encode(session, forKey: .session)
try container.encodeIfPresent(outPath, forKey: .outPath)
} }
} }
@ -179,6 +216,30 @@ extension Request: Codable {
case .rpcStatus: case .rpcStatus:
self = .rpcStatus self = .rpcStatus
case .canvasShow:
let session = try container.decode(String.self, forKey: .session)
let path = try container.decodeIfPresent(String.self, forKey: .path)
self = .canvasShow(session: session, path: path)
case .canvasHide:
let session = try container.decode(String.self, forKey: .session)
self = .canvasHide(session: session)
case .canvasGoto:
let session = try container.decode(String.self, forKey: .session)
let path = try container.decode(String.self, forKey: .path)
self = .canvasGoto(session: session, path: path)
case .canvasEval:
let session = try container.decode(String.self, forKey: .session)
let javaScript = try container.decode(String.self, forKey: .javaScript)
self = .canvasEval(session: session, javaScript: javaScript)
case .canvasSnapshot:
let session = try container.decode(String.self, forKey: .session)
let outPath = try container.decodeIfPresent(String.self, forKey: .outPath)
self = .canvasSnapshot(session: session, outPath: outPath)
} }
} }
} }

92
docs/mac/canvas.md Normal file
View File

@ -0,0 +1,92 @@
---
summary: "Agent-controlled Canvas panel embedded via WKWebView + custom URL scheme"
read_when:
- Implementing the macOS Canvas panel
- Adding agent controls for visual workspace
- Debugging WKWebView canvas loads
---
# Canvas (macOS app)
Status: draft spec · Date: 2025-12-12
Clawdis can embed an agent-controlled “visual workspace” panel (“Canvas”) inside the macOS app using `WKWebView`, served via a **custom URL scheme** (no loopback HTTP port required).
This is designed for:
- Agent-written HTML/CSS/JS on disk (per-session directory).
- A real browser engine for layout, rendering, and basic interactivity.
- Agent-driven visibility (show/hide), navigation, DOM/JS queries, and snapshots.
- Minimal chrome: borderless panel; bezel/chrome appears only on hover.
## Why a custom scheme (vs. loopback HTTP)
Using `WKURLSchemeHandler` keeps Canvas entirely in-process:
- No port conflicts and no extra local server lifecycle.
- Easier to sandbox: only serve files we explicitly map.
- Works offline and can use an ephemeral data store (no persistent cookies/cache).
If a Canvas page truly needs “real web” semantics (CORS, fetch to loopback endpoints, service workers), consider the loopback-server variant instead (out of scope for this doc).
## URL ↔ directory mapping
The Canvas scheme is:
- `clawdis-canvas://<session>/<path>`
Routing model:
- `clawdis-canvas://main/``<canvasRoot>/main/index.html` (or `index.htm`)
- `clawdis-canvas://main/yolo``<canvasRoot>/main/yolo/index.html` (or `index.htm`)
- `clawdis-canvas://main/assets/app.css``<canvasRoot>/main/assets/app.css`
Directory listings are not served.
When `/` has no `index.html` yet, the handler serves a built-in welcome page with:
- The resolved on-disk session directory path.
- A short “create index.html” hint.
### Suggested on-disk location
Store Canvas state under the app support directory:
- `~/Library/Application Support/Clawdis/canvas/<session>/…`
This keeps it alongside other app-owned state and avoids mixing with `~/.clawdis/` gateway config.
## Panel behavior (agent-controlled)
Canvas is presented as a borderless `NSPanel` (similar to the existing WebChat panel):
- Can be shown/hidden at any time by the agent.
- Supports an “anchored” presentation (near the menu bar icon or another anchor rect).
- Uses a rounded container; shadow stays on, but **chrome/bezel only appears on hover**.
### Hover-only chrome
Implementation notes:
- Keep the window borderless at all times (dont toggle `styleMask`).
- Add an overlay view inside the content container for chrome (stroke + subtle gradient/material).
- Use an `NSTrackingArea` to fade the chrome in/out on `mouseEntered/mouseExited`.
- Optionally show close/drag affordances only while hovered.
## Agent API surface (proposed)
Expose Canvas via the existing `clawdis-mac` → XPC → app routing so the agent can:
- Show/hide the panel.
- Navigate to a path (relative to the session root).
- Evaluate JavaScript and optionally return results.
- Query/modify DOM (helpers mirroring “dom query/all/attr/click/type/wait” patterns).
- Capture a snapshot image of the current canvas view.
This should be modeled after `WebChatManager`/`WebChatWindowController` but targeting `clawdis-canvas://…` URLs.
## Security / guardrails
Recommended defaults:
- `WKWebsiteDataStore.nonPersistent()` for Canvas (ephemeral).
- Navigation policy: allow only `clawdis-canvas://…` (and optionally `about:blank`); open `http/https` externally.
- Scheme handler must prevent directory traversal: resolved file paths must stay under `<canvasRoot>/<session>/`.
- Disable or tightly scope any JS bridge; prefer query-string/bootstrap config over `window.webkit.messageHandlers` for sensitive data.
## Debugging
Suggested debugging hooks:
- Enable Web Inspector for Canvas builds (same approach as WebChat).
- Log scheme requests + resolution decisions to OSLog (subsystem `com.steipete.clawdis`, category `Canvas`).
- Provide a “copy canvas dir” action in debug settings to quickly reveal the session directory in Finder.