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 <noreply@anthropic.com>
This commit is contained in:
Artur Do Lago
2026-01-08 23:33:35 +01:00
parent 0c000c0f8a
commit 2168f190dd
6 changed files with 1075 additions and 152 deletions
+126 -57
View File
@@ -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
+1 -4
View File
@@ -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
+627
View File
@@ -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<string, unknown>;
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<string, CanvasInstance>();
private sharedPaneId?: string;
constructor(config?: Partial<CanvasManagerConfig>) {
this.config = { ...DEFAULT_CONFIG, ...config };
}
/**
* Check if WezTerm CLI is available
*/
async isAvailable(): Promise<boolean> {
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<string, unknown>
): Promise<CanvasInstance> {
// 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<void> {
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<void> {
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<string, unknown>): Promise<void> {
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<void> {
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<string | null> {
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<void> {
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<string> {
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<void> {
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<void> {
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<void> {
// 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<string> {
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, unknown>): 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, unknown>): 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, unknown>): 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, unknown>): 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<CanvasManagerConfig>
): CanvasManager {
return new CanvasManager(config);
}
+80 -3
View File
@@ -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<DaemonConfig>) {
@@ -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<string, unknown>) ?? {};
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<string, unknown>) ?? {};
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();
+210 -88
View File
@@ -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<ToolExecutionResult> => {
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<ToolExecutionResult> => {
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<string, unknown>;
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<ToolExecutionResult> => {
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<string, unknown>;
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<ToolExecutionResult> => {
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<ToolExecutionResult> => {
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<Array<{
id: string;
kind: string;
paneId: string;
createdAt: number;
}>>("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,
];
+31
View File
@@ -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