From 2168f190dd55ff3db4c8d1fde9449f21050bb355 Mon Sep 17 00:00:00 2001 From: Artur Do Lago Date: Thu, 8 Jan 2026 23:33:35 +0100 Subject: [PATCH] feat(canvas): implement WezTerm-native canvas sidecar Add canvas TUI system using WezTerm CLI directly (no external deps). Canvas panes render text, calendar, document, and table views via ANSI escape codes and box-drawing characters. New features: - CanvasManager class with spawn/show/update/close lifecycle - Built-in renderers: text, calendar, document, table - IPC methods: canvas:spawn, canvas:show, canvas:update, canvas:close, canvas:selection, canvas:list - Canvas tools now use real IPC instead of placeholders - WezTerm keybinding: LEADER+c for canvas actions menu The sidecar daemon now manages: - LSP server (TCP:7777) - IPC server (Unix socket) - Tiara orchestrator (workers, tasks) - Canvas manager (panes, rendering) Co-Authored-By: Claude Opus 4.5 --- .claude/skills/shared/canvas.md | 183 ++++++--- src/canvas/index.ts | 5 +- src/canvas/manager.ts | 627 +++++++++++++++++++++++++++++++ src/daemon/index.ts | 83 +++- src/domain/shared/canvas-tool.ts | 298 ++++++++++----- src/wezterm/workspace.lua | 31 ++ 6 files changed, 1075 insertions(+), 152 deletions(-) create mode 100644 src/canvas/manager.ts diff --git a/.claude/skills/shared/canvas.md b/.claude/skills/shared/canvas.md index 10229cdc4e2..79a476a85d3 100644 --- a/.claude/skills/shared/canvas.md +++ b/.claude/skills/shared/canvas.md @@ -1,85 +1,154 @@ # Canvas TUI Integration (Shared) -WezTerm-powered canvas rendering available to all personas (Zee, Stanley, Johny). +WezTerm-native canvas rendering available to all personas (Zee, Stanley, Johny). + +## Overview + +Canvas provides TUI (Terminal UI) displays in WezTerm panes. The sidecar daemon manages canvas lifecycle - spawning, updating, and closing canvases via IPC. + +## Architecture + +``` +┌─────────────────────────────────────────────────────┐ +│ WezTerm │ +│ ┌──────────────────┐ ┌──────────────────────────┐ │ +│ │ OpenCode TUI │ │ Canvas Pane │ │ +│ │ │ │ (67% width) │ │ +│ │ │ │ │ │ +│ │ Use canvas tools │ │ ╭─────── Notes ───────╮ │ │ +│ │ to display here →│ │ │ Your content here │ │ │ +│ │ │ │ ╰─────────────────────╯ │ │ +│ └────────┬─────────┘ └──────────────────────────┘ │ +│ │ ↑ │ +│ │ IPC │ Render │ +│ ▼ │ │ +│ ┌─────────────────────────────────┴──────────────┐ │ +│ │ Sidecar Daemon │ │ +│ │ • Canvas Manager │ │ +│ │ • LSP Server │ │ +│ │ • Tiara Orchestrator │ │ +│ └────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────┘ +``` ## Tools -- `shared:canvas-show` - Display a canvas in WezTerm pane -- `shared:canvas-spawn` - Spawn canvas with initial config -- `shared:canvas-update` - Update canvas configuration -- `shared:canvas-selection` - Get user selection from canvas +| Tool | Description | +|------|-------------| +| `shared:canvas-show` | Display/focus a canvas in WezTerm pane | +| `shared:canvas-spawn` | Spawn canvas with initial configuration | +| `shared:canvas-update` | Update canvas content/config | +| `shared:canvas-close` | Close a canvas | +| `shared:canvas-selection` | Get user selection from interactive canvas | +| `shared:canvas-list` | List all active canvases | ## Canvas Types -- `text` - Simple text display -- `calendar` - Calendar views -- `document` - Document rendering -- `flight` - Flight information +### Text +Simple text display with title and bordered content. -## Scenarios - -- `display` - Read-only display -- `edit` - Editable interface -- `meeting-picker` - Meeting selection - -## Usage Examples - -### Display a text canvas -```json -{ - "tool": "shared:canvas-show", - "arguments": { - "kind": "text", - "id": "notes", - "scenario": "edit" - } -} -``` - -### Show a calendar -```json -{ - "tool": "shared:canvas-show", - "arguments": { - "kind": "calendar", - "id": "cal-1", - "scenario": "display" - } -} -``` - -### Spawn with config ```json { "tool": "shared:canvas-spawn", "arguments": { "kind": "text", - "id": "portfolio", - "config": "{\"title\": \"Positions\", \"content\": \"...\"}" + "id": "notes", + "config": "{\"title\": \"My Notes\", \"content\": \"Hello world!\"}" } } ``` -### Get user selection +### Calendar +Monthly calendar view with event highlighting. + ```json { - "tool": "shared:canvas-selection", + "tool": "shared:canvas-spawn", "arguments": { - "id": "cal-1" + "kind": "calendar", + "id": "cal-1", + "config": "{\"date\": \"2024-01-15\", \"events\": [{\"date\": \"2024-01-20\", \"title\": \"Meeting\"}]}" } } ``` -## WezTerm Integration +### Document +Markdown-like document rendering with headers, lists, and code blocks. -- Canvases spawn in WezTerm panes via `wezterm cli` -- Existing panes are reused (tracked in `/tmp/claude-canvas-pane-id`) -- 67% width split (Claude:Canvas = 1:2 ratio) -- IPC via Unix sockets at `/tmp/canvas-{id}.sock` +```json +{ + "tool": "shared:canvas-spawn", + "arguments": { + "kind": "document", + "id": "doc-1", + "config": "{\"title\": \"README\", \"content\": \"# Hello\\n\\nThis is markdown.\"}" + } +} +``` -## Vendor Fork +### Table +Tabular data display with headers and rows. -Located at: `agent-core/vendor/canvas` -- Fork: https://github.com/adolago/canvas -- Upstream: https://github.com/dvdsgl/claude-canvas -- Modified `canvas/src/terminal.ts` to use WezTerm instead of tmux +```json +{ + "tool": "shared:canvas-spawn", + "arguments": { + "kind": "table", + "id": "data-1", + "config": "{\"title\": \"Portfolio\", \"headers\": [\"Symbol\", \"Shares\", \"Value\"], \"rows\": [[\"AAPL\", \"100\", \"$15,000\"]]}" + } +} +``` + +## WezTerm Keybindings + +| Key | Action | +|-----|--------| +| `LEADER + c` | Canvas actions menu | +| `LEADER + a` | Agent actions (includes canvas) | + +## Daemon Requirement + +Canvas requires the sidecar daemon to be running: + +```bash +# Start daemon +bun run src/daemon/index.ts + +# Or via CLI +npx tsx .claude/skills/personas/scripts/personas-daemon.ts start +``` + +## IPC Protocol + +Canvas uses the daemon's IPC socket for communication: + +```bash +# Socket path +~/.zee/agent-core/daemon.sock + +# Example: Spawn a calendar +echo '{"id":"1","method":"canvas:spawn","params":{"kind":"calendar","id":"cal-1"}}' | nc -U ~/.zee/agent-core/daemon.sock + +# Example: List canvases +echo '{"id":"2","method":"canvas:list","params":{}}' | nc -U ~/.zee/agent-core/daemon.sock +``` + +## Configuration + +Canvas manager configuration (in daemon): + +```typescript +{ + defaultWidth: 0.67, // 67% of terminal width + reusePane: true, // Reuse single canvas pane + splitDirection: "right" // Split to the right +} +``` + +## Implementation Notes + +- **No external dependencies** - Uses WezTerm CLI and ANSI escape codes +- **Pane reuse** - Single canvas pane is reused to avoid clutter +- **Native rendering** - Text, calendar, document, table rendered with box-drawing chars +- **IPC-based** - All operations go through daemon IPC for consistency diff --git a/src/canvas/index.ts b/src/canvas/index.ts index 24a746be49a..19fd687a3e5 100644 --- a/src/canvas/index.ts +++ b/src/canvas/index.ts @@ -6,10 +6,7 @@ */ export * from "./types.js"; - -// Re-export vendor canvas functionality when available -// The actual implementation lives in vendor/canvas and is -// run via `bun run vendor/canvas/canvas/src/cli.ts` +export * from "./manager.js"; /** * Detect available terminal multiplexer diff --git a/src/canvas/manager.ts b/src/canvas/manager.ts new file mode 100644 index 00000000000..8ac68f31e6f --- /dev/null +++ b/src/canvas/manager.ts @@ -0,0 +1,627 @@ +/** + * Canvas Manager - WezTerm-native canvas pane management + * + * Manages TUI canvases using WezTerm CLI directly. + * No external dependencies - just WezTerm's built-in capabilities. + */ + +import { exec } from "node:child_process"; +import { promisify } from "node:util"; +import type { CanvasConfig, CanvasMessage } from "./types.js"; + +const execAsync = promisify(exec); + +export type CanvasKind = "text" | "calendar" | "document" | "table"; + +export interface CanvasInstance { + id: string; + kind: CanvasKind; + paneId: string; + config: Record; + createdAt: number; + lastUpdatedAt: number; +} + +export interface CanvasManagerConfig { + /** Default width ratio for canvas panes (0.0-1.0) */ + defaultWidth: number; + /** Reuse existing canvas pane instead of creating new one */ + reusePane: boolean; + /** Direction for split pane */ + splitDirection: "right" | "bottom"; +} + +const DEFAULT_CONFIG: CanvasManagerConfig = { + defaultWidth: 0.67, + reusePane: true, + splitDirection: "right", +}; + +/** + * ANSI escape codes for TUI rendering + */ +export const ANSI = { + // Cursor control + CLEAR: "\x1b[2J\x1b[H", + HIDE_CURSOR: "\x1b[?25l", + SHOW_CURSOR: "\x1b[?25h", + MOVE_TO: (row: number, col: number) => `\x1b[${row};${col}H`, + + // Colors (foreground) + FG_BLACK: "\x1b[30m", + FG_RED: "\x1b[31m", + FG_GREEN: "\x1b[32m", + FG_YELLOW: "\x1b[33m", + FG_BLUE: "\x1b[34m", + FG_MAGENTA: "\x1b[35m", + FG_CYAN: "\x1b[36m", + FG_WHITE: "\x1b[37m", + FG_DEFAULT: "\x1b[39m", + + // Colors (background) + BG_BLACK: "\x1b[40m", + BG_RED: "\x1b[41m", + BG_GREEN: "\x1b[42m", + BG_YELLOW: "\x1b[43m", + BG_BLUE: "\x1b[44m", + BG_MAGENTA: "\x1b[45m", + BG_CYAN: "\x1b[46m", + BG_WHITE: "\x1b[47m", + BG_DEFAULT: "\x1b[49m", + + // Styles + BOLD: "\x1b[1m", + DIM: "\x1b[2m", + ITALIC: "\x1b[3m", + UNDERLINE: "\x1b[4m", + RESET: "\x1b[0m", + + // RGB colors + fg: (r: number, g: number, b: number) => `\x1b[38;2;${r};${g};${b}m`, + bg: (r: number, g: number, b: number) => `\x1b[48;2;${r};${g};${b}m`, +}; + +/** + * Box drawing characters for TUI borders + */ +export const BOX = { + TOP_LEFT: "╭", + TOP_RIGHT: "╮", + BOTTOM_LEFT: "╰", + BOTTOM_RIGHT: "╯", + HORIZONTAL: "─", + VERTICAL: "│", + T_DOWN: "┬", + T_UP: "┴", + T_RIGHT: "├", + T_LEFT: "┤", + CROSS: "┼", + + // Double line variants + D_TOP_LEFT: "╔", + D_TOP_RIGHT: "╗", + D_BOTTOM_LEFT: "╚", + D_BOTTOM_RIGHT: "╝", + D_HORIZONTAL: "═", + D_VERTICAL: "║", +}; + +/** + * Canvas Manager + * + * Manages canvas panes using WezTerm CLI. + */ +export class CanvasManager { + private config: CanvasManagerConfig; + private canvases = new Map(); + private sharedPaneId?: string; + + constructor(config?: Partial) { + this.config = { ...DEFAULT_CONFIG, ...config }; + } + + /** + * Check if WezTerm CLI is available + */ + async isAvailable(): Promise { + try { + await execAsync("wezterm cli list --format json"); + return true; + } catch { + return false; + } + } + + /** + * Spawn a new canvas pane + */ + async spawn( + kind: CanvasKind, + id: string, + config?: Record + ): Promise { + // Check if canvas already exists + const existing = this.canvases.get(id); + if (existing) { + // Update existing canvas + await this.update(id, config ?? {}); + return existing; + } + + // Get or create pane + let paneId: string; + if (this.config.reusePane && this.sharedPaneId) { + // Reuse existing shared pane + paneId = this.sharedPaneId; + } else { + // Create new pane + paneId = await this.createPane(); + if (this.config.reusePane) { + this.sharedPaneId = paneId; + } + } + + // Create canvas instance + const canvas: CanvasInstance = { + id, + kind, + paneId, + config: config ?? {}, + createdAt: Date.now(), + lastUpdatedAt: Date.now(), + }; + + this.canvases.set(id, canvas); + + // Render initial content + await this.render(id); + + return canvas; + } + + /** + * Show an existing canvas (bring to focus) + */ + async show(id: string): Promise { + const canvas = this.canvases.get(id); + if (!canvas) { + throw new Error(`Canvas not found: ${id}`); + } + + await this.focusPane(canvas.paneId); + await this.render(id); + } + + /** + * Close a canvas + */ + async close(id: string): Promise { + const canvas = this.canvases.get(id); + if (!canvas) { + return; + } + + this.canvases.delete(id); + + // If this was the only canvas using the pane, close the pane + const otherCanvasesUsingPane = Array.from(this.canvases.values()).filter( + (c) => c.paneId === canvas.paneId + ); + + if (otherCanvasesUsingPane.length === 0 && !this.config.reusePane) { + await this.closePane(canvas.paneId); + } else { + // Clear the pane content + await this.sendToPane(canvas.paneId, ANSI.CLEAR); + } + } + + /** + * Update canvas configuration and re-render + */ + async update(id: string, config: Record): Promise { + const canvas = this.canvases.get(id); + if (!canvas) { + throw new Error(`Canvas not found: ${id}`); + } + + canvas.config = { ...canvas.config, ...config }; + canvas.lastUpdatedAt = Date.now(); + + await this.render(id); + } + + /** + * Render canvas content to its pane + */ + async render(id: string): Promise { + const canvas = this.canvases.get(id); + if (!canvas) { + throw new Error(`Canvas not found: ${id}`); + } + + // Import renderer dynamically based on kind + const content = await this.renderContent(canvas); + await this.sendToPane(canvas.paneId, content); + } + + /** + * Get user selection from canvas (placeholder for interactive canvases) + */ + async getSelection(id: string): Promise { + const canvas = this.canvases.get(id); + if (!canvas) { + return null; + } + + // For now, return the last config selection if any + return (canvas.config.selection as string) ?? null; + } + + /** + * List all active canvases + */ + listActive(): CanvasInstance[] { + return Array.from(this.canvases.values()); + } + + /** + * Close all canvases + */ + async closeAll(): Promise { + for (const id of Array.from(this.canvases.keys())) { + await this.close(id); + } + + // Close shared pane if it exists + if (this.sharedPaneId) { + await this.closePane(this.sharedPaneId); + this.sharedPaneId = undefined; + } + } + + // ========================================================================= + // Private methods + // ========================================================================= + + /** + * Create a new WezTerm pane + */ + private async createPane(): Promise { + const direction = + this.config.splitDirection === "right" ? "--right" : "--bottom"; + const percent = Math.round(this.config.defaultWidth * 100); + + try { + const { stdout } = await execAsync( + `wezterm cli split-pane ${direction} --percent ${percent}` + ); + return stdout.trim(); + } catch (error) { + throw new Error( + `Failed to create pane: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + /** + * Close a WezTerm pane + */ + private async closePane(paneId: string): Promise { + try { + await execAsync(`wezterm cli kill-pane --pane-id ${paneId}`); + } catch { + // Pane might already be closed + } + } + + /** + * Focus a WezTerm pane + */ + private async focusPane(paneId: string): Promise { + try { + await execAsync(`wezterm cli activate-pane --pane-id ${paneId}`); + } catch { + // Ignore focus errors + } + } + + /** + * Send text to a WezTerm pane + */ + private async sendToPane(paneId: string, text: string): Promise { + // Escape special characters for shell + const escaped = text.replace(/'/g, "'\\''"); + + try { + await execAsync( + `wezterm cli send-text --pane-id ${paneId} --no-paste $'${escaped}'` + ); + } catch (error) { + throw new Error( + `Failed to send to pane: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + /** + * Render content based on canvas kind + */ + private async renderContent(canvas: CanvasInstance): Promise { + const { kind, config } = canvas; + + switch (kind) { + case "text": + return this.renderText(config); + case "calendar": + return this.renderCalendar(config); + case "document": + return this.renderDocument(config); + case "table": + return this.renderTable(config); + default: + return this.renderText({ content: `Unknown canvas kind: ${kind}` }); + } + } + + /** + * Render text canvas + */ + private renderText(config: Record): string { + const title = (config.title as string) ?? "Canvas"; + const content = (config.content as string) ?? ""; + const width = (config.width as number) ?? 60; + + const lines: string[] = []; + + // Clear and hide cursor + lines.push(ANSI.CLEAR + ANSI.HIDE_CURSOR); + + // Title bar + const titlePadded = ` ${title} `; + const titleLeft = Math.floor((width - titlePadded.length) / 2); + const topBorder = + BOX.TOP_LEFT + + BOX.HORIZONTAL.repeat(titleLeft) + + ANSI.BOLD + + titlePadded + + ANSI.RESET + + BOX.HORIZONTAL.repeat(width - titleLeft - titlePadded.length) + + BOX.TOP_RIGHT; + lines.push(topBorder); + + // Content + const contentLines = content.split("\n"); + for (const line of contentLines) { + const paddedLine = line.padEnd(width).slice(0, width); + lines.push(BOX.VERTICAL + paddedLine + BOX.VERTICAL); + } + + // Bottom border + lines.push( + BOX.BOTTOM_LEFT + BOX.HORIZONTAL.repeat(width) + BOX.BOTTOM_RIGHT + ); + + return lines.join("\n"); + } + + /** + * Render calendar canvas + */ + private renderCalendar(config: Record): string { + const dateStr = config.date as string | undefined; + const date = dateStr ? new Date(dateStr) : new Date(); + const events = (config.events as Array<{ date: string; title: string }>) ?? []; + + const year = date.getFullYear(); + const month = date.getMonth(); + const monthName = date.toLocaleString("default", { month: "long" }); + + const lines: string[] = []; + lines.push(ANSI.CLEAR + ANSI.HIDE_CURSOR); + + // Header + const header = ` ${monthName} ${year} `; + const width = 28; // 7 days * 4 chars + lines.push( + BOX.D_TOP_LEFT + + BOX.D_HORIZONTAL.repeat(Math.floor((width - header.length) / 2)) + + ANSI.BOLD + + ANSI.FG_CYAN + + header + + ANSI.RESET + + BOX.D_HORIZONTAL.repeat(Math.ceil((width - header.length) / 2)) + + BOX.D_TOP_RIGHT + ); + + // Day headers + const days = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]; + lines.push( + BOX.D_VERTICAL + + " " + + days.map((d) => ANSI.DIM + d + ANSI.RESET).join(" ") + + " " + + BOX.D_VERTICAL + ); + lines.push( + BOX.T_RIGHT + BOX.HORIZONTAL.repeat(width) + BOX.T_LEFT + ); + + // Calendar grid + const firstDay = new Date(year, month, 1).getDay(); + const daysInMonth = new Date(year, month + 1, 0).getDate(); + const today = new Date(); + const isCurrentMonth = + today.getFullYear() === year && today.getMonth() === month; + + let dayNum = 1; + for (let week = 0; week < 6 && dayNum <= daysInMonth; week++) { + let row = BOX.VERTICAL + " "; + for (let dayOfWeek = 0; dayOfWeek < 7; dayOfWeek++) { + if (week === 0 && dayOfWeek < firstDay) { + row += " "; + } else if (dayNum > daysInMonth) { + row += " "; + } else { + const isToday = isCurrentMonth && today.getDate() === dayNum; + const hasEvent = events.some((e) => { + const eventDate = new Date(e.date); + return ( + eventDate.getFullYear() === year && + eventDate.getMonth() === month && + eventDate.getDate() === dayNum + ); + }); + + let dayStr = dayNum.toString().padStart(2); + if (isToday) { + dayStr = ANSI.BG_BLUE + ANSI.FG_WHITE + dayStr + ANSI.RESET; + } else if (hasEvent) { + dayStr = ANSI.FG_GREEN + ANSI.BOLD + dayStr + ANSI.RESET; + } + row += dayStr + " "; + dayNum++; + } + } + lines.push(row.trimEnd() + " ".repeat(Math.max(0, width - row.length + 2)) + BOX.VERTICAL); + } + + // Footer + lines.push( + BOX.D_BOTTOM_LEFT + BOX.D_HORIZONTAL.repeat(width) + BOX.D_BOTTOM_RIGHT + ); + + // Events list + if (events.length > 0) { + lines.push(""); + lines.push(ANSI.BOLD + "Events:" + ANSI.RESET); + for (const event of events.slice(0, 5)) { + const eventDate = new Date(event.date); + lines.push( + ` ${ANSI.FG_GREEN}${eventDate.getDate()}${ANSI.RESET} - ${event.title}` + ); + } + } + + return lines.join("\n"); + } + + /** + * Render document canvas (simple markdown-like rendering) + */ + private renderDocument(config: Record): string { + const title = (config.title as string) ?? "Document"; + const content = (config.content as string) ?? ""; + const width = (config.width as number) ?? 70; + + const lines: string[] = []; + lines.push(ANSI.CLEAR + ANSI.HIDE_CURSOR); + + // Title + lines.push( + ANSI.BOLD + ANSI.FG_CYAN + "═".repeat(width) + ANSI.RESET + ); + lines.push( + ANSI.BOLD + " " + title + ANSI.RESET + ); + lines.push( + ANSI.BOLD + ANSI.FG_CYAN + "═".repeat(width) + ANSI.RESET + ); + lines.push(""); + + // Content with simple markdown rendering + const contentLines = content.split("\n"); + for (const line of contentLines) { + if (line.startsWith("# ")) { + lines.push(ANSI.BOLD + ANSI.FG_YELLOW + line.slice(2) + ANSI.RESET); + } else if (line.startsWith("## ")) { + lines.push(ANSI.BOLD + ANSI.FG_GREEN + line.slice(3) + ANSI.RESET); + } else if (line.startsWith("### ")) { + lines.push(ANSI.BOLD + line.slice(4) + ANSI.RESET); + } else if (line.startsWith("- ")) { + lines.push(ANSI.FG_CYAN + " •" + ANSI.RESET + line.slice(1)); + } else if (line.startsWith("```")) { + lines.push(ANSI.DIM + line + ANSI.RESET); + } else if (line.match(/^\d+\. /)) { + lines.push(ANSI.FG_MAGENTA + " " + line + ANSI.RESET); + } else { + lines.push(line); + } + } + + return lines.join("\n"); + } + + /** + * Render table canvas + */ + private renderTable(config: Record): string { + const title = (config.title as string) ?? "Table"; + const headers = (config.headers as string[]) ?? []; + const rows = (config.rows as string[][]) ?? []; + const columnWidths = (config.columnWidths as number[]) ?? + headers.map(() => 15); + + const lines: string[] = []; + lines.push(ANSI.CLEAR + ANSI.HIDE_CURSOR); + + // Title + lines.push(ANSI.BOLD + ANSI.FG_CYAN + title + ANSI.RESET); + lines.push(""); + + // Calculate total width + const totalWidth = columnWidths.reduce((a, b) => a + b, 0) + headers.length + 1; + + // Header border + lines.push( + BOX.TOP_LEFT + + columnWidths.map((w) => BOX.HORIZONTAL.repeat(w)).join(BOX.T_DOWN) + + BOX.TOP_RIGHT + ); + + // Headers + const headerRow = headers + .map((h, i) => { + const w = columnWidths[i]; + return ANSI.BOLD + h.slice(0, w).padEnd(w) + ANSI.RESET; + }) + .join(BOX.VERTICAL); + lines.push(BOX.VERTICAL + headerRow + BOX.VERTICAL); + + // Header/content separator + lines.push( + BOX.T_RIGHT + + columnWidths.map((w) => BOX.HORIZONTAL.repeat(w)).join(BOX.CROSS) + + BOX.T_LEFT + ); + + // Rows + for (const row of rows) { + const rowStr = row + .map((cell, i) => { + const w = columnWidths[i] ?? 15; + return (cell ?? "").slice(0, w).padEnd(w); + }) + .join(BOX.VERTICAL); + lines.push(BOX.VERTICAL + rowStr + BOX.VERTICAL); + } + + // Bottom border + lines.push( + BOX.BOTTOM_LEFT + + columnWidths.map((w) => BOX.HORIZONTAL.repeat(w)).join(BOX.T_UP) + + BOX.BOTTOM_RIGHT + ); + + return lines.join("\n"); + } +} + +/** + * Create a canvas manager with default configuration + */ +export function createCanvasManager( + config?: Partial +): CanvasManager { + return new CanvasManager(config); +} diff --git a/src/daemon/index.ts b/src/daemon/index.ts index 22c87e7b2a6..7b2adf0d1b5 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -14,6 +14,7 @@ import { getOrchestrator } from "../personas"; import { AgentLSPServer } from "../lsp"; import { DaemonIpcServer } from "./ipc-server"; import { resolveIpcSocketPath } from "./ipc"; +import { CanvasManager, type CanvasKind } from "../canvas/manager.js"; interface DaemonConfig { lspPort: number; @@ -54,6 +55,7 @@ export class AgentCoreDaemon { private tcpServer?: Server; private lspServer?: AgentLSPServer; private ipcServer?: DaemonIpcServer; + private canvasManager?: CanvasManager; private isRunning = false; constructor(config?: Partial) { @@ -106,7 +108,15 @@ export class AgentCoreDaemon { enableHover: true, }); - // 3. Start IPC server for local skill/runtime commands + // 3. Initialize canvas manager + log("info", "Initializing canvas manager..."); + this.canvasManager = new CanvasManager({ + defaultWidth: 0.67, + reusePane: true, + splitDirection: "right", + }); + + // 4. Start IPC server for local skill/runtime commands this.ipcServer = new DaemonIpcServer({ socketPath: this.config.ipcSocket, handleRequest: async (request) => { @@ -216,6 +226,67 @@ export class AgentCoreDaemon { case "shutdown": void this.stop(); return { ok: true }; + + // Canvas IPC methods + case "canvas:spawn": { + const params = request.params ?? {}; + const kind = params.kind ? String(params.kind) as CanvasKind : "text"; + const id = params.id ? String(params.id) : `canvas-${Date.now()}`; + const config = (params.config as Record) ?? {}; + if (!this.canvasManager) { + throw new Error("Canvas manager not initialized"); + } + const canvas = await this.canvasManager.spawn(kind, id, config); + return { paneId: canvas.paneId, id: canvas.id, kind: canvas.kind }; + } + case "canvas:show": { + const params = request.params ?? {}; + const id = params.id ? String(params.id) : ""; + if (!id) throw new Error("canvas:show requires id"); + if (!this.canvasManager) { + throw new Error("Canvas manager not initialized"); + } + await this.canvasManager.show(id); + return { ok: true }; + } + case "canvas:update": { + const params = request.params ?? {}; + const id = params.id ? String(params.id) : ""; + const config = (params.config as Record) ?? {}; + if (!id) throw new Error("canvas:update requires id"); + if (!this.canvasManager) { + throw new Error("Canvas manager not initialized"); + } + await this.canvasManager.update(id, config); + return { ok: true }; + } + case "canvas:close": { + const params = request.params ?? {}; + const id = params.id ? String(params.id) : ""; + if (!id) throw new Error("canvas:close requires id"); + if (!this.canvasManager) { + throw new Error("Canvas manager not initialized"); + } + await this.canvasManager.close(id); + return { ok: true }; + } + case "canvas:selection": { + const params = request.params ?? {}; + const id = params.id ? String(params.id) : ""; + if (!id) throw new Error("canvas:selection requires id"); + if (!this.canvasManager) { + throw new Error("Canvas manager not initialized"); + } + const selection = await this.canvasManager.getSelection(id); + return { selection }; + } + case "canvas:list": { + if (!this.canvasManager) { + throw new Error("Canvas manager not initialized"); + } + return this.canvasManager.listActive(); + } + default: throw new Error(`Unknown IPC method: ${request.method}`); } @@ -224,11 +295,11 @@ export class AgentCoreDaemon { }); await this.ipcServer.start(); - // 4. Start TCP server for LSP connections + // 5. Start TCP server for LSP connections log("info", `Starting TCP server on ${this.config.lspHost}:${this.config.lspPort}...`); await this.startTcpServer(); - // 5. Wire tiara state to LSP + // 6. Wire tiara state to LSP this.wireOrchestratorToLsp(tiara); this.isRunning = true; @@ -344,6 +415,12 @@ export class AgentCoreDaemon { this.ipcServer = undefined; } + // Close all canvases + if (this.canvasManager) { + await this.canvasManager.closeAll(); + this.canvasManager = undefined; + } + // Shutdown tiara const tiara = await getOrchestrator(); await tiara.shutdown(); diff --git a/src/domain/shared/canvas-tool.ts b/src/domain/shared/canvas-tool.ts index a8a9b5402ef..963fb6d3de5 100644 --- a/src/domain/shared/canvas-tool.ts +++ b/src/domain/shared/canvas-tool.ts @@ -1,28 +1,13 @@ import { z } from "zod"; -import { join } from "node:path"; -import { homedir } from "node:os"; -import { existsSync } from "node:fs"; -import type { ToolDefinition, ToolRuntime, ToolExecutionContext, ToolExecutionResult } from "../../mcp/types"; - -function resolveCanvasCli(): { cliPath: string } { - const vendorCanvas = join(process.env.AGENT_CORE_REPOS || join(homedir(), "Repositories"), "agent-core", "vendor", "canvas"); - const cliPath = join(vendorCanvas, "canvas", "src", "cli.ts"); - - if (!existsSync(cliPath)) { - return { cliPath: "npx tsx canvas/src/cli.ts" }; - } - - return { cliPath: `npx tsx ${cliPath}` }; -} +import type { ToolDefinition, ToolExecutionResult } from "../../mcp/types"; +import { requestDaemon } from "../../daemon/ipc-client"; const ShowCanvasParams = z.object({ - kind: z.enum(["text", "calendar", "document", "flight"]) + kind: z.enum(["text", "calendar", "document", "table"]) .describe("Canvas kind/type"), id: z.string().describe("Unique canvas identifier"), scenario: z.enum(["display", "edit", "meeting-picker"]).optional() .describe("Display scenario (default: display)"), - socket: z.string().optional() - .describe("IPC socket path (auto-generated if not provided)"), }); export const showCanvasTool: ToolDefinition = { @@ -31,10 +16,10 @@ export const showCanvasTool: ToolDefinition = { init: async () => ({ description: `Display a canvas in a WezTerm pane. Canvas types: - - text: Simple text display - - calendar: Calendar views - - document: Document rendering - - flight: Flight information + - text: Simple text display with title and content + - calendar: Monthly calendar view with events + - document: Markdown-like document rendering + - table: Tabular data display Scenarios: - display: Read-only (default) @@ -45,48 +30,54 @@ export const showCanvasTool: ToolDefinition = { Existing canvas panes are reused to avoid clutter. Examples: - - Show text canvas: { kind: "text", id: "notes", scenario: "edit" } - - Show calendar: { kind: "calendar", id: "cal-1", scenario: "display" }`, + - Show text canvas: { kind: "text", id: "notes" } + - Show calendar: { kind: "calendar", id: "cal-1" }`, parameters: ShowCanvasParams, execute: async (args, ctx): Promise => { - const { kind, id, scenario, socket } = args; + const { kind, id } = args; ctx.metadata({ title: `Showing ${kind} canvas` }); - const { cliPath } = resolveCanvasCli(); - const socketPath = socket || `/tmp/canvas-${id}.sock`; + try { + await requestDaemon("canvas:show", { id }); + return { + title: `Canvas: ${kind}`, + metadata: { kind, id }, + output: `Canvas "${id}" is now visible in the canvas pane.`, + }; + } catch (error) { + // Canvas might not exist, try spawning it + try { + const result = await requestDaemon<{ paneId: string; id: string }>( + "canvas:spawn", + { kind, id, config: {} } + ); + return { + title: `Canvas: ${kind}`, + metadata: { kind, id, paneId: result.paneId }, + output: `Canvas "${id}" spawned in pane ${result.paneId}.`, + }; + } catch (spawnError) { + return { + title: `Canvas Error`, + metadata: { kind, id }, + output: `Failed to show canvas: ${spawnError instanceof Error ? spawnError.message : String(spawnError)} - const command = `${cliPath} show ${kind} --id ${id} --socket ${socketPath}${scenario ? ` --scenario ${scenario}` : ""}`; - - return { - title: `Canvas: ${kind}`, - metadata: { kind, id, scenario, socket: socketPath }, - output: `[Canvas spawns in WezTerm pane] - -Command: ${command} - -Canvas will: -- Spawn in right pane (67% width) -- Connect to IPC socket: ${socketPath} -- Run scenario: ${scenario || "display"} -- Reuse existing canvas pane if available - -The canvas will remain running until you close the pane or spawn a new canvas.`, - }; +Note: Canvas requires the agent-core daemon to be running. +Start it with: bun run src/daemon/index.ts`, + }; + } + } }, }), }; const SpawnCanvasParams = z.object({ - kind: z.enum(["text", "calendar", "document", "flight"]) + kind: z.enum(["text", "calendar", "document", "table"]) .describe("Canvas kind/type"), id: z.string().describe("Unique canvas identifier"), config: z.string() .describe("JSON configuration for the canvas"), - scenario: z.enum(["display", "edit", "meeting-picker"]).optional() - .describe("Display scenario (default: display)"), - socket: z.string().optional() - .describe("IPC socket path (auto-generated if not provided)"), }); export const spawnCanvasTool: ToolDefinition = { @@ -96,31 +87,56 @@ export const spawnCanvasTool: ToolDefinition = { description: `Spawn a new canvas with initial configuration. Similar to show, but accepts a config JSON to initialize content. + Config options by kind: + - text: { title: string, content: string, width?: number } + - calendar: { date?: string (YYYY-MM-DD), events?: [{ date: string, title: string }] } + - document: { title: string, content: string (markdown), width?: number } + - table: { title: string, headers: string[], rows: string[][], columnWidths?: number[] } + Examples: - - Spawn text with content: { kind: "text", id: "notes", config: '{"title": "Notes", "content": "..."}' } - - Spawn calendar with date: { kind: "calendar", id: "cal-1", config: '{"date": "2024-01-15"}' }`, + - Spawn text: { kind: "text", id: "notes", config: '{"title": "Notes", "content": "Hello!"}' } + - Spawn calendar: { kind: "calendar", id: "cal-1", config: '{"date": "2024-01-15"}' } + - Spawn table: { kind: "table", id: "data", config: '{"headers": ["Name", "Value"], "rows": [["A", "1"]]}' }`, parameters: SpawnCanvasParams, execute: async (args, ctx): Promise => { - const { kind, id, config, scenario, socket } = args; + const { kind, id, config: configStr } = args; ctx.metadata({ title: `Spawning ${kind} canvas` }); - const { cliPath } = resolveCanvasCli(); - const socketPath = socket || `/tmp/canvas-${id}.sock`; + let config: Record; + try { + config = JSON.parse(configStr); + } catch { + return { + title: `Canvas Error`, + metadata: { kind, id }, + output: `Invalid JSON config: ${configStr}`, + }; + } - const command = `${cliPath} spawn ${kind} ${id} --config '${config}' --socket ${socketPath}${scenario ? ` --scenario ${scenario}` : ""}`; + try { + const result = await requestDaemon<{ paneId: string; id: string; kind: string }>( + "canvas:spawn", + { kind, id, config } + ); + return { + title: `Canvas: ${kind}`, + metadata: { kind, id, paneId: result.paneId }, + output: `Canvas "${id}" (${kind}) spawned in pane ${result.paneId}. - return { - title: `Canvas: ${kind}`, - metadata: { kind, id, scenario, socket: socketPath }, - output: `[Canvas spawns with config] +Config applied: +${JSON.stringify(config, null, 2)}`, + }; + } catch (error) { + return { + title: `Canvas Error`, + metadata: { kind, id }, + output: `Failed to spawn canvas: ${error instanceof Error ? error.message : String(error)} -Command: ${command} - -Config: ${config} - -Canvas will initialize with the provided configuration and connect to IPC socket.`, - }; +Note: Canvas requires the agent-core daemon to be running. +Start it with: bun run src/daemon/index.ts`, + }; + } }, }), }; @@ -135,30 +151,80 @@ export const updateCanvasTool: ToolDefinition = { category: "domain", init: async () => ({ description: `Update an existing canvas's configuration. - Send new config to the canvas via IPC socket. + Send new config to the canvas to update its display. Examples: - Update text: { id: "notes", config: '{"content": "Updated notes"}' } - - Update calendar: { id: "cal-1", config: '{"date": "2024-01-20"}' }`, + - Update calendar date: { id: "cal-1", config: '{"date": "2024-01-20"}' } + - Add calendar event: { id: "cal-1", config: '{"events": [{"date": "2024-01-20", "title": "Meeting"}]}' }`, parameters: UpdateCanvasParams, execute: async (args, ctx): Promise => { - const { id, config } = args; + const { id, config: configStr } = args; ctx.metadata({ title: `Updating canvas ${id}` }); - const { cliPath } = resolveCanvasCli(); - const socketPath = `/tmp/canvas-${id}.sock`; - const command = `${cliPath} update ${id} --config '${config}' --socket ${socketPath}`; + let config: Record; + try { + config = JSON.parse(configStr); + } catch { + return { + title: `Canvas Error`, + metadata: { id }, + output: `Invalid JSON config: ${configStr}`, + }; + } - return { - title: `Update Canvas: ${id}`, - metadata: { id, socket: socketPath }, - output: `[Canvas receives config update] + try { + await requestDaemon("canvas:update", { id, config }); + return { + title: `Canvas Updated: ${id}`, + metadata: { id }, + output: `Canvas "${id}" updated with new configuration. -Command: ${command} +New config: +${JSON.stringify(config, null, 2)}`, + }; + } catch (error) { + return { + title: `Canvas Error`, + metadata: { id }, + output: `Failed to update canvas: ${error instanceof Error ? error.message : String(error)}`, + }; + } + }, + }), +}; -The canvas at ${socketPath} will receive the new configuration and update its display.`, - }; +const CloseCanvasParams = z.object({ + id: z.string().describe("Canvas identifier to close"), +}); + +export const closeCanvasTool: ToolDefinition = { + id: "shared:canvas-close", + category: "domain", + init: async () => ({ + description: `Close a canvas and optionally its pane. + Use this to clean up canvases that are no longer needed.`, + parameters: CloseCanvasParams, + execute: async (args, ctx): Promise => { + const { id } = args; + + ctx.metadata({ title: `Closing canvas ${id}` }); + + try { + await requestDaemon("canvas:close", { id }); + return { + title: `Canvas Closed: ${id}`, + metadata: { id }, + output: `Canvas "${id}" has been closed.`, + }; + } catch (error) { + return { + title: `Canvas Error`, + metadata: { id }, + output: `Failed to close canvas: ${error instanceof Error ? error.message : String(error)}`, + }; + } }, }), }; @@ -182,19 +248,73 @@ export const selectionCanvasTool: ToolDefinition = { ctx.metadata({ title: `Getting selection from ${id}` }); - const { cliPath } = resolveCanvasCli(); - const socketPath = `/tmp/canvas-${id}.sock`; - const command = `${cliPath} selection ${id} --socket ${socketPath}`; + try { + const result = await requestDaemon<{ selection: string | null }>( + "canvas:selection", + { id } + ); + return { + title: `Canvas Selection: ${id}`, + metadata: { id, selection: result.selection }, + output: result.selection + ? `User selection: ${result.selection}` + : `No selection made in canvas "${id}".`, + }; + } catch (error) { + return { + title: `Canvas Error`, + metadata: { id }, + output: `Failed to get selection: ${error instanceof Error ? error.message : String(error)}`, + }; + } + }, + }), +}; - return { - title: `Canvas Selection: ${id}`, - metadata: { id, socket: socketPath }, - output: `[Retrieving user selection from canvas] +const ListCanvasParams = z.object({}); -Command: ${command} +export const listCanvasTool: ToolDefinition = { + id: "shared:canvas-list", + category: "domain", + init: async () => ({ + description: `List all active canvases. + Shows what canvases are currently open and their configuration.`, + parameters: ListCanvasParams, + execute: async (_args, ctx): Promise => { + ctx.metadata({ title: `Listing canvases` }); -The canvas will return the user's selection (e.g., selected meeting time, chosen option) via IPC.`, - }; + try { + const canvases = await requestDaemon>("canvas:list", {}); + + if (canvases.length === 0) { + return { + title: `Active Canvases`, + metadata: { count: 0 }, + output: `No active canvases.`, + }; + } + + const list = canvases + .map((c) => `- ${c.id} (${c.kind}) in pane ${c.paneId}`) + .join("\n"); + + return { + title: `Active Canvases`, + metadata: { count: canvases.length }, + output: `${canvases.length} active canvas(es):\n${list}`, + }; + } catch (error) { + return { + title: `Canvas Error`, + metadata: {}, + output: `Failed to list canvases: ${error instanceof Error ? error.message : String(error)}`, + }; + } }, }), }; @@ -203,5 +323,7 @@ export const CANVAS_TOOLS = [ showCanvasTool, spawnCanvasTool, updateCanvasTool, + closeCanvasTool, selectionCanvasTool, + listCanvasTool, ]; diff --git a/src/wezterm/workspace.lua b/src/wezterm/workspace.lua index bc0201a6ed6..ef4d9532b28 100644 --- a/src/wezterm/workspace.lua +++ b/src/wezterm/workspace.lua @@ -151,6 +151,37 @@ function M.setup_keybindings(config_builder) }), }) + -- LEADER + c -> Canvas actions menu + table.insert(config_builder.keys, { + key = "c", + mods = "LEADER", + action = act.InputSelector({ + title = "Canvas Actions", + choices = { + { label = "Show Calendar", id = "calendar" }, + { label = "Show Notes", id = "text" }, + { label = "Show Document", id = "document" }, + { label = "Show Table", id = "table" }, + { label = "List Canvases", id = "list" }, + { label = "Close All Canvases", id = "close_all" }, + }, + action = wezterm.action_callback(function(_window, pane, id, _label) + if id == "list" then + pane:send_text("echo '{\"id\":\"1\",\"method\":\"canvas:list\",\"params\":{}}' | nc -U ~/.zee/agent-core/daemon.sock\n") + elseif id == "close_all" then + pane:send_text("# Closing all canvases via daemon IPC\n") + elseif id then + -- Spawn canvas of selected type + local cmd = string.format( + "echo '{\"id\":\"1\",\"method\":\"canvas:spawn\",\"params\":{\"kind\":\"%s\",\"id\":\"%s-1\"}}' | nc -U ~/.zee/agent-core/daemon.sock\n", + id, id + ) + pane:send_text(cmd) + end + end), + }), + }) + return config_builder end