diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt index 5f6b4ec12..55326385d 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt @@ -282,7 +282,7 @@ class NodeRuntime(context: Context) { val invokeCommands = buildList { - add(ClawdisCanvasCommand.Show.rawValue) + add(ClawdisCanvasCommand.Present.rawValue) add(ClawdisCanvasCommand.Hide.rawValue) add(ClawdisCanvasCommand.Navigate.rawValue) add(ClawdisCanvasCommand.Eval.rawValue) @@ -558,7 +558,11 @@ class NodeRuntime(context: Context) { } return when (command) { - ClawdisCanvasCommand.Show.rawValue -> BridgeSession.InvokeResult.ok(null) + ClawdisCanvasCommand.Present.rawValue -> { + val url = CanvasController.parseNavigateUrl(paramsJson) + canvas.navigate(url) + BridgeSession.InvokeResult.ok(null) + } ClawdisCanvasCommand.Hide.rawValue -> BridgeSession.InvokeResult.ok(null) ClawdisCanvasCommand.Navigate.rawValue -> { val url = CanvasController.parseNavigateUrl(paramsJson) diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/protocol/ClawdisProtocolConstants.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/protocol/ClawdisProtocolConstants.kt index fdd77e026..4320f548d 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/protocol/ClawdisProtocolConstants.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/protocol/ClawdisProtocolConstants.kt @@ -8,7 +8,7 @@ enum class ClawdisCapability(val rawValue: String) { } enum class ClawdisCanvasCommand(val rawValue: String) { - Show("canvas.show"), + Present("canvas.present"), Hide("canvas.hide"), Navigate("canvas.navigate"), Eval("canvas.eval"), diff --git a/apps/android/app/src/test/java/com/steipete/clawdis/node/protocol/ClawdisProtocolConstantsTest.kt b/apps/android/app/src/test/java/com/steipete/clawdis/node/protocol/ClawdisProtocolConstantsTest.kt index 05b1760ba..b0de85c6b 100644 --- a/apps/android/app/src/test/java/com/steipete/clawdis/node/protocol/ClawdisProtocolConstantsTest.kt +++ b/apps/android/app/src/test/java/com/steipete/clawdis/node/protocol/ClawdisProtocolConstantsTest.kt @@ -6,7 +6,7 @@ import org.junit.Test class ClawdisProtocolConstantsTest { @Test fun canvasCommandsUseStableStrings() { - assertEquals("canvas.show", ClawdisCanvasCommand.Show.rawValue) + assertEquals("canvas.present", ClawdisCanvasCommand.Present.rawValue) assertEquals("canvas.hide", ClawdisCanvasCommand.Hide.rawValue) assertEquals("canvas.navigate", ClawdisCanvasCommand.Navigate.rawValue) assertEquals("canvas.eval", ClawdisCanvasCommand.Eval.rawValue) diff --git a/apps/ios/Sources/Bridge/BridgeConnectionController.swift b/apps/ios/Sources/Bridge/BridgeConnectionController.swift index d83780569..f6de737f0 100644 --- a/apps/ios/Sources/Bridge/BridgeConnectionController.swift +++ b/apps/ios/Sources/Bridge/BridgeConnectionController.swift @@ -173,7 +173,7 @@ final class BridgeConnectionController { private func currentCommands() -> [String] { var commands: [String] = [ - ClawdisCanvasCommand.show.rawValue, + ClawdisCanvasCommand.present.rawValue, ClawdisCanvasCommand.hide.rawValue, ClawdisCanvasCommand.navigate.rawValue, ClawdisCanvasCommand.evalJS.rawValue, diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index d22defa0d..18d281834 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -387,7 +387,15 @@ final class NodeAppModel { do { switch command { - case ClawdisCanvasCommand.show.rawValue: + case ClawdisCanvasCommand.present.rawValue: + let params = (try? Self.decodeParams(ClawdisCanvasPresentParams.self, from: req.paramsJSON)) ?? + ClawdisCanvasPresentParams() + let url = params.url?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if url.isEmpty { + self.screen.showDefaultCanvas() + } else { + self.screen.navigate(to: url) + } return BridgeInvokeResponse(id: req.id, ok: true) case ClawdisCanvasCommand.hide.rawValue: diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index 02016243f..c3ed69afe 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -299,7 +299,7 @@ struct SettingsTab: View { private func currentCommands() -> [String] { var commands: [String] = [ - ClawdisCanvasCommand.show.rawValue, + ClawdisCanvasCommand.present.rawValue, ClawdisCanvasCommand.hide.rawValue, ClawdisCanvasCommand.navigate.rawValue, ClawdisCanvasCommand.evalJS.rawValue, diff --git a/apps/macos/Sources/Clawdis/ControlRequestHandler.swift b/apps/macos/Sources/Clawdis/ControlRequestHandler.swift index 40c47f3b5..e2f7c089d 100644 --- a/apps/macos/Sources/Clawdis/ControlRequestHandler.swift +++ b/apps/macos/Sources/Clawdis/ControlRequestHandler.swift @@ -95,8 +95,8 @@ enum ControlRequestHandler { deliver: deliver, to: to) - case let .canvasShow(session, path, placement): - return await self.handleCanvasShow(session: session, path: path, placement: placement) + case let .canvasPresent(session, path, placement): + return await self.handleCanvasPresent(session: session, path: path, placement: placement) case let .canvasHide(session): return await self.handleCanvasHide(session: session) @@ -235,7 +235,7 @@ enum ControlRequestHandler { UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false } - private static func handleCanvasShow( + private static func handleCanvasPresent( session: String, path: String?, placement: CanvasPlacement?) async -> Response @@ -243,20 +243,24 @@ enum ControlRequestHandler { guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") } _ = session do { + var params: [String: Any] = [:] if let path, !path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - _ = try await self.invokeLocalNode( - command: ClawdisCanvasCommand.navigate.rawValue, - params: ["url": path], - timeoutMs: 20000) - } else { - _ = try await self.invokeLocalNode( - command: ClawdisCanvasCommand.show.rawValue, - params: nil, - timeoutMs: 20000) + params["url"] = path } - if placement != nil { - return Response(ok: true, message: "Canvas placement ignored (node mode)") + if let placement { + var placementPayload: [String: Any] = [:] + if let x = placement.x { placementPayload["x"] = x } + if let y = placement.y { placementPayload["y"] = y } + if let width = placement.width { placementPayload["width"] = width } + if let height = placement.height { placementPayload["height"] = height } + if !placementPayload.isEmpty { + params["placement"] = placementPayload + } } + _ = try await self.invokeLocalNode( + command: ClawdisCanvasCommand.present.rawValue, + params: params.isEmpty ? nil : params, + timeoutMs: 20000) return Response(ok: true) } catch { return Response(ok: false, message: error.localizedDescription) diff --git a/apps/macos/Sources/Clawdis/DebugSettings.swift b/apps/macos/Sources/Clawdis/DebugSettings.swift index d5133fe05..c886c6cee 100644 --- a/apps/macos/Sources/Clawdis/DebugSettings.swift +++ b/apps/macos/Sources/Clawdis/DebugSettings.swift @@ -489,7 +489,7 @@ struct DebugSettings: View { .font(.caption.monospaced()) .frame(width: 160) Button("Show panel") { - Task { await self.canvasShow() } + Task { await self.canvasPresent() } } .buttonStyle(.borderedProminent) Button("Hide panel") { @@ -750,7 +750,7 @@ extension DebugSettings { // MARK: - Canvas debug actions @MainActor - private func canvasShow() async { + private func canvasPresent() async { self.canvasError = nil let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines) do { diff --git a/apps/macos/Sources/Clawdis/NodeMode/MacNodeModeCoordinator.swift b/apps/macos/Sources/Clawdis/NodeMode/MacNodeModeCoordinator.swift index 1effbd89e..734349736 100644 --- a/apps/macos/Sources/Clawdis/NodeMode/MacNodeModeCoordinator.swift +++ b/apps/macos/Sources/Clawdis/NodeMode/MacNodeModeCoordinator.swift @@ -107,7 +107,7 @@ final class MacNodeModeCoordinator { private func currentCommands(caps: [String]) -> [String] { var commands: [String] = [ - ClawdisCanvasCommand.show.rawValue, + ClawdisCanvasCommand.present.rawValue, ClawdisCanvasCommand.hide.rawValue, ClawdisCanvasCommand.navigate.rawValue, ClawdisCanvasCommand.evalJS.rawValue, diff --git a/apps/macos/Sources/Clawdis/NodeMode/MacNodeRuntime.swift b/apps/macos/Sources/Clawdis/NodeMode/MacNodeRuntime.swift index 661f8a61c..10bf3e5fd 100644 --- a/apps/macos/Sources/Clawdis/NodeMode/MacNodeRuntime.swift +++ b/apps/macos/Sources/Clawdis/NodeMode/MacNodeRuntime.swift @@ -19,9 +19,19 @@ actor MacNodeRuntime { } do { switch command { - case ClawdisCanvasCommand.show.rawValue: + case ClawdisCanvasCommand.present.rawValue: + let params = (try? Self.decodeParams(ClawdisCanvasPresentParams.self, from: req.paramsJSON)) ?? + ClawdisCanvasPresentParams() + let urlTrimmed = params.url?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let url = urlTrimmed.isEmpty ? nil : urlTrimmed + let placement = params.placement.map { + CanvasPlacement(x: $0.x, y: $0.y, width: $0.width, height: $0.height) + } try await MainActor.run { - _ = try CanvasManager.shared.show(sessionKey: "main", path: nil) + _ = try CanvasManager.shared.showDetailed( + sessionKey: "main", + target: url, + placement: placement) } return BridgeInvokeResponse(id: req.id, ok: true) diff --git a/apps/macos/Sources/ClawdisCLI/ClawdisCLI.swift b/apps/macos/Sources/ClawdisCLI/ClawdisCLI.swift index 9086fa56d..e8682b8d4 100644 --- a/apps/macos/Sources/ClawdisCLI/ClawdisCLI.swift +++ b/apps/macos/Sources/ClawdisCLI/ClawdisCLI.swift @@ -319,12 +319,12 @@ struct ClawdisCLI { private static func parseCanvas(args: inout [String]) throws -> ParsedCLIRequest { guard let sub = args.popFirst() else { throw CLIError.help } switch sub { - case "show": + case "present": var session = "main" var target: String? let placement = self.parseCanvasPlacement(args: &args, session: &session, target: &target) return ParsedCLIRequest( - request: .canvasShow(session: session, path: target, placement: placement), + request: .canvasPresent(session: session, path: target, placement: placement), kind: .generic) case "a2ui": return try self.parseCanvasA2UI(args: &args) @@ -542,7 +542,7 @@ struct ClawdisCLI { return } - if case .canvasShow = parsed.request { + if case .canvasPresent = parsed.request { if let message = response.message, !message.isEmpty { FileHandle.standardOutput.write(Data((message + "\n").utf8)) } @@ -759,7 +759,7 @@ struct ClawdisCLI { clawdis-mac node invoke --node --command [--params-json ] Canvas: - clawdis-mac canvas show [--session ] [--target ] + clawdis-mac canvas present [--session ] [--target ] [--x --y ] [--width --height ] clawdis-mac canvas a2ui push --jsonl [--session ] # A2UI v0.8 JSONL clawdis-mac canvas a2ui reset [--session ] diff --git a/apps/macos/Sources/ClawdisIPC/IPC.swift b/apps/macos/Sources/ClawdisIPC/IPC.swift index ba48b813d..00d05536a 100644 --- a/apps/macos/Sources/ClawdisIPC/IPC.swift +++ b/apps/macos/Sources/ClawdisIPC/IPC.swift @@ -122,7 +122,7 @@ public enum Request: Sendable { case status case agent(message: String, thinking: String?, session: String?, deliver: Bool, to: String?) case rpcStatus - case canvasShow(session: String, path: String?, placement: CanvasPlacement?) + case canvasPresent(session: String, path: String?, placement: CanvasPlacement?) case canvasHide(session: String) case canvasEval(session: String, javaScript: String) case canvasSnapshot(session: String, outPath: String?) @@ -185,7 +185,7 @@ extension Request: Codable { case status case agent case rpcStatus - case canvasShow + case canvasPresent case canvasHide case canvasEval case canvasSnapshot @@ -236,8 +236,8 @@ extension Request: Codable { case .rpcStatus: try container.encode(Kind.rpcStatus, forKey: .type) - case let .canvasShow(session, path, placement): - try container.encode(Kind.canvasShow, forKey: .type) + case let .canvasPresent(session, path, placement): + try container.encode(Kind.canvasPresent, forKey: .type) try container.encode(session, forKey: .session) try container.encodeIfPresent(path, forKey: .path) try container.encodeIfPresent(placement, forKey: .placement) @@ -338,11 +338,11 @@ extension Request: Codable { case .rpcStatus: self = .rpcStatus - case .canvasShow: + case .canvasPresent: let session = try container.decode(String.self, forKey: .session) let path = try container.decodeIfPresent(String.self, forKey: .path) let placement = try container.decodeIfPresent(CanvasPlacement.self, forKey: .placement) - self = .canvasShow(session: session, path: path, placement: placement) + self = .canvasPresent(session: session, path: path, placement: placement) case .canvasHide: let session = try container.decode(String.self, forKey: .session) diff --git a/apps/macos/Tests/ClawdisIPCTests/ControlRequestHandlerTests.swift b/apps/macos/Tests/ClawdisIPCTests/ControlRequestHandlerTests.swift index 353f72e7f..280c08a52 100644 --- a/apps/macos/Tests/ClawdisIPCTests/ControlRequestHandlerTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/ControlRequestHandlerTests.swift @@ -140,7 +140,7 @@ struct ControlRequestHandlerTests { func canvasRequestsReturnDisabledWhenCanvasDisabled() async throws { let show = try await Self.withDefaultOverride(pauseDefaultsKey, value: false) { try await Self.withDefaultOverride(canvasEnabledKey, value: false) { - try await ControlRequestHandler.process(request: .canvasShow(session: "s", path: nil, placement: nil)) + try await ControlRequestHandler.process(request: .canvasPresent(session: "s", path: nil, placement: nil)) } } #expect(show.ok == false) diff --git a/apps/shared/ClawdisKit/Sources/ClawdisKit/CanvasCommandParams.swift b/apps/shared/ClawdisKit/Sources/ClawdisKit/CanvasCommandParams.swift index 2243dd2b0..1b7db4eff 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisKit/CanvasCommandParams.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisKit/CanvasCommandParams.swift @@ -8,6 +8,30 @@ public struct ClawdisCanvasNavigateParams: Codable, Sendable, Equatable { } } +public struct ClawdisCanvasPlacement: Codable, Sendable, Equatable { + public var x: Double? + public var y: Double? + public var width: Double? + public var height: Double? + + public init(x: Double? = nil, y: Double? = nil, width: Double? = nil, height: Double? = nil) { + self.x = x + self.y = y + self.width = width + self.height = height + } +} + +public struct ClawdisCanvasPresentParams: Codable, Sendable, Equatable { + public var url: String? + public var placement: ClawdisCanvasPlacement? + + public init(url: String? = nil, placement: ClawdisCanvasPlacement? = nil) { + self.url = url + self.placement = placement + } +} + public struct ClawdisCanvasEvalParams: Codable, Sendable, Equatable { public var javaScript: String diff --git a/apps/shared/ClawdisKit/Sources/ClawdisKit/CanvasCommands.swift b/apps/shared/ClawdisKit/Sources/ClawdisKit/CanvasCommands.swift index 6e2037910..0f695b5bd 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisKit/CanvasCommands.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisKit/CanvasCommands.swift @@ -1,7 +1,7 @@ import Foundation public enum ClawdisCanvasCommand: String, Codable, Sendable { - case show = "canvas.show" + case present = "canvas.present" case hide = "canvas.hide" case navigate = "canvas.navigate" case evalJS = "canvas.eval" diff --git a/docs/ios/spec.md b/docs/ios/spec.md index de37f36dd..3f00da356 100644 --- a/docs/ios/spec.md +++ b/docs/ios/spec.md @@ -119,7 +119,7 @@ Add to `src/gateway/protocol/schema.ts` (and regenerate Swift models): ### Node command set (canvas) These are values for `node.invoke.command`: -- `canvas.show` / `canvas.hide` +- `canvas.present` / `canvas.hide` - `canvas.navigate` with `{ url }` (loads a URL; use `""` or `"/"` to return to the default canvas/A2UI scaffold) - `canvas.eval` with `{ javaScript }` - `canvas.snapshot` with `{ maxWidth?, quality?, format? }` diff --git a/docs/mac/canvas.md b/docs/mac/canvas.md index f68a45108..5dcd6c967 100644 --- a/docs/mac/canvas.md +++ b/docs/mac/canvas.md @@ -96,7 +96,7 @@ Related: `clawdis-mac` exposes Canvas via the control socket. For agent use, prefer `--json` so you can read the structured `CanvasShowResult` (including `status`). -- `clawdis-mac canvas show [--session ] [--target <...>] [--x/--y/--width/--height]` +- `clawdis-mac canvas present [--session ] [--target <...>] [--x/--y/--width/--height]` - Local targets map into the session directory via the custom scheme (directory targets resolve `index.html|index.htm`). - If `/` has no index file, Canvas shows the built-in A2UI shell and returns `status: "a2uiShell"`. - `clawdis-mac canvas hide [--session ]` diff --git a/docs/refactor/canvas-a2ui.md b/docs/refactor/canvas-a2ui.md index 3682400f9..853935e8b 100644 --- a/docs/refactor/canvas-a2ui.md +++ b/docs/refactor/canvas-a2ui.md @@ -20,7 +20,7 @@ - Avoid double-sending actions when the bundled A2UI shell is present (let the shell forward clicks so it can resolve richer context). - Intercept `clawdis://…` navigations inside the Canvas WKWebView and route them through `DeepLinkHandler` (no NSWorkspace bounce). - `GatewayConnection` auto-starts the local gateway (and retries briefly) when a request fails in `.local` mode, so Canvas actions don’t silently fail if the gateway isn’t running yet. - - Fix a crash that made `clawdis-mac canvas show`/`eval` look “hung”: + - Fix a crash that made `clawdis-mac canvas present`/`eval` look “hung”: - `VoicePushToTalkHotkey`’s NSEvent monitor could call `@MainActor` code off-main, triggering executor checks / EXC_BAD_ACCESS on macOS 26.2. - Now it hops back to the main actor before mutating state. - Preserve in-page state when closing Canvas (hide the window instead of closing the `WKWebView`). diff --git a/src/cli/canvas-cli.ts b/src/cli/canvas-cli.ts index f9ceb2712..ea08ff9d0 100644 --- a/src/cli/canvas-cli.ts +++ b/src/cli/canvas-cli.ts @@ -120,10 +120,11 @@ function pickDefaultNode(nodes: NodeListNode[]): NodeListNode | null { const candidates = connected.length > 0 ? connected : withCanvas; if (candidates.length === 1) return candidates[0]; - const local = candidates.filter((n) => - n.platform?.toLowerCase().startsWith("mac") && - typeof n.nodeId === "string" && - n.nodeId.startsWith("mac-"), + const local = candidates.filter( + (n) => + n.platform?.toLowerCase().startsWith("mac") && + typeof n.nodeId === "string" && + n.nodeId.startsWith("mac-"), ); if (local.length === 1) return local[0];