mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-24 12:15:51 -04:00
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:
+126
-57
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user