diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 345fe2966d..874e3869c0 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -285,7 +285,11 @@ Agent-core is a fork of OpenCode with three personas (Zee, Stanley, Johny) shari - [ ] Voice output (TTS responses via gateways) - mobile has TTS, gateways text-only - [ ] Multi-user support (shared Qdrant namespaces) - [ ] Plugin marketplace for personas -- [ ] Canvas/whiteboard for visual reasoning +- [x] Canvas/whiteboard for visual reasoning + - 7 canvas kinds: text, calendar, document, table, diagram, graph, mindmap + - WezTerm pane management with 67% width ratio + - Works alongside nvim/LSP + - Tools: canvas-show, canvas-spawn, canvas-update, canvas-close, canvas-list ### Platform Status - [x] Desktop → TUI (this repo) diff --git a/src/canvas/manager.ts b/src/canvas/manager.ts index 8ac68f31e6..7ece4aed20 100644 --- a/src/canvas/manager.ts +++ b/src/canvas/manager.ts @@ -11,7 +11,7 @@ import type { CanvasConfig, CanvasMessage } from "./types.js"; const execAsync = promisify(exec); -export type CanvasKind = "text" | "calendar" | "document" | "table"; +export type CanvasKind = "text" | "calendar" | "document" | "table" | "diagram" | "graph" | "mindmap"; export interface CanvasInstance { id: string; @@ -359,6 +359,12 @@ export class CanvasManager { return this.renderDocument(config); case "table": return this.renderTable(config); + case "diagram": + return this.renderDiagram(config); + case "graph": + return this.renderGraph(config); + case "mindmap": + return this.renderMindmap(config); default: return this.renderText({ content: `Unknown canvas kind: ${kind}` }); } @@ -615,6 +621,247 @@ export class CanvasManager { return lines.join("\n"); } + + /** + * Render diagram canvas (flowchart/architecture style) + * + * Config: + * - title: string + * - nodes: Array<{ id: string, label: string, type?: "box"|"diamond"|"oval" }> + * - edges: Array<{ from: string, to: string, label?: string }> + * - direction?: "TB" | "LR" (top-to-bottom or left-to-right) + */ + private renderDiagram(config: Record): string { + const title = (config.title as string) ?? "Diagram"; + const nodes = (config.nodes as Array<{ id: string; label: string; type?: string }>) ?? []; + const edges = (config.edges as Array<{ from: string; to: string; label?: string }>) ?? []; + const direction = (config.direction as string) ?? "TB"; + + const lines: string[] = []; + lines.push(ANSI.CLEAR + ANSI.HIDE_CURSOR); + + // Title + lines.push(ANSI.BOLD + ANSI.FG_CYAN + title + ANSI.RESET); + lines.push(ANSI.DIM + "─".repeat(title.length + 4) + ANSI.RESET); + lines.push(""); + + // Build node map for edge rendering + const nodeMap = new Map(nodes.map((n, i) => [n.id, { ...n, index: i }])); + + // Render nodes + const nodeWidth = 20; + const isHorizontal = direction === "LR"; + + if (isHorizontal) { + // Horizontal layout + let row1 = ""; + let row2 = ""; + let row3 = ""; + + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + const label = node.label.slice(0, nodeWidth - 4).padStart(Math.floor((nodeWidth - 4 + node.label.length) / 2)).padEnd(nodeWidth - 4); + + if (node.type === "diamond") { + row1 += " " + " ".repeat(Math.floor((nodeWidth - 6) / 2)) + "/" + "\\".padEnd(Math.ceil((nodeWidth - 6) / 2)) + " "; + row2 += " <" + label + "> "; + row3 += " " + " ".repeat(Math.floor((nodeWidth - 6) / 2)) + "\\" + "/".padEnd(Math.ceil((nodeWidth - 6) / 2)) + " "; + } else if (node.type === "oval") { + row1 += " (" + "─".repeat(nodeWidth - 4) + ") "; + row2 += " │" + label + "│ "; + row3 += " (" + "─".repeat(nodeWidth - 4) + ") "; + } else { + // Default: box + row1 += "┌" + "─".repeat(nodeWidth - 2) + "┐"; + row2 += "│" + ANSI.BOLD + label + ANSI.RESET + "│"; + row3 += "└" + "─".repeat(nodeWidth - 2) + "┘"; + } + + // Add arrow between nodes + if (i < nodes.length - 1) { + const edge = edges.find(e => e.from === node.id && e.to === nodes[i + 1]?.id); + if (edge) { + row1 += " "; + row2 += " ──▶ "; + row3 += edge.label ? ` ${edge.label.slice(0, 3)} ` : " "; + } else { + row1 += " "; + row2 += " "; + row3 += " "; + } + } + } + + lines.push(row1); + lines.push(row2); + lines.push(row3); + } else { + // Vertical layout (TB) + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + const label = node.label.slice(0, nodeWidth - 4); + const padLeft = Math.floor((nodeWidth - 4 - label.length) / 2); + const padRight = nodeWidth - 4 - label.length - padLeft; + + if (node.type === "diamond") { + const mid = Math.floor(nodeWidth / 2); + lines.push(" ".repeat(mid - 1) + "/" + "\\"); + lines.push("<" + " ".repeat(padLeft) + ANSI.BOLD + label + ANSI.RESET + " ".repeat(padRight) + ">"); + lines.push(" ".repeat(mid - 1) + "\\" + "/"); + } else if (node.type === "oval") { + lines.push("(" + "─".repeat(nodeWidth - 2) + ")"); + lines.push("│" + " ".repeat(padLeft) + ANSI.BOLD + label + ANSI.RESET + " ".repeat(padRight) + "│"); + lines.push("(" + "─".repeat(nodeWidth - 2) + ")"); + } else { + // Default: box + lines.push("┌" + "─".repeat(nodeWidth - 2) + "┐"); + lines.push("│" + " ".repeat(padLeft) + ANSI.BOLD + label + ANSI.RESET + " ".repeat(padRight) + "│"); + lines.push("└" + "─".repeat(nodeWidth - 2) + "┘"); + } + + // Add arrow to next node + if (i < nodes.length - 1) { + const edge = edges.find(e => e.from === node.id && e.to === nodes[i + 1]?.id); + const mid = Math.floor(nodeWidth / 2); + lines.push(" ".repeat(mid) + "│"); + if (edge?.label) { + lines.push(" ".repeat(mid) + "│ " + ANSI.DIM + edge.label + ANSI.RESET); + } + lines.push(" ".repeat(mid) + "▼"); + } + } + } + + // Legend for node types + lines.push(""); + lines.push(ANSI.DIM + "Legend: ┌─┐ process <> decision () terminal" + ANSI.RESET); + + return lines.join("\n"); + } + + /** + * Render graph canvas (nodes and edges) + * + * Config: + * - title: string + * - nodes: Array<{ id: string, label: string, color?: string }> + * - edges: Array<{ from: string, to: string, label?: string, weight?: number }> + */ + private renderGraph(config: Record): string { + const title = (config.title as string) ?? "Graph"; + const nodes = (config.nodes as Array<{ id: string; label: string; color?: string }>) ?? []; + const edges = (config.edges as Array<{ from: string; to: string; label?: string; weight?: number }>) ?? []; + + const lines: string[] = []; + lines.push(ANSI.CLEAR + ANSI.HIDE_CURSOR); + + // Title + lines.push(ANSI.BOLD + ANSI.FG_MAGENTA + "◉ " + title + ANSI.RESET); + lines.push(""); + + // Build adjacency representation + const adjacency = new Map>(); + for (const edge of edges) { + if (!adjacency.has(edge.from)) { + adjacency.set(edge.from, []); + } + adjacency.get(edge.from)!.push({ to: edge.to, label: edge.label, weight: edge.weight }); + } + + // Render nodes with their connections + const nodeColors: Record = { + red: ANSI.FG_RED, + green: ANSI.FG_GREEN, + blue: ANSI.FG_BLUE, + yellow: ANSI.FG_YELLOW, + cyan: ANSI.FG_CYAN, + magenta: ANSI.FG_MAGENTA, + }; + + for (const node of nodes) { + const color = node.color ? (nodeColors[node.color] ?? ANSI.FG_WHITE) : ANSI.FG_CYAN; + + // Node representation + lines.push(color + "◉" + ANSI.RESET + " " + ANSI.BOLD + node.label + ANSI.RESET + ANSI.DIM + ` (${node.id})` + ANSI.RESET); + + // Outgoing edges + const outEdges = adjacency.get(node.id) ?? []; + for (let i = 0; i < outEdges.length; i++) { + const edge = outEdges[i]; + const isLast = i === outEdges.length - 1; + const prefix = isLast ? " └─▶ " : " ├─▶ "; + const targetNode = nodes.find(n => n.id === edge.to); + const targetLabel = targetNode?.label ?? edge.to; + const edgeInfo = edge.label + ? ` ${ANSI.DIM}[${edge.label}${edge.weight ? ` w:${edge.weight}` : ""}]${ANSI.RESET}` + : edge.weight + ? ` ${ANSI.DIM}[w:${edge.weight}]${ANSI.RESET}` + : ""; + lines.push(prefix + targetLabel + edgeInfo); + } + + if (outEdges.length === 0) { + lines.push(" " + ANSI.DIM + "(no outgoing edges)" + ANSI.RESET); + } + lines.push(""); + } + + // Stats + lines.push(ANSI.DIM + "─".repeat(40) + ANSI.RESET); + lines.push(ANSI.DIM + `Nodes: ${nodes.length} Edges: ${edges.length}` + ANSI.RESET); + + return lines.join("\n"); + } + + /** + * Render mindmap canvas (hierarchical tree) + * + * Config: + * - title: string (root node) + * - children: Array where MindmapNode = { label: string, children?: MindmapNode[] } + */ + private renderMindmap(config: Record): string { + const title = (config.title as string) ?? "Mindmap"; + const children = (config.children as Array) ?? []; + + interface MindmapNode { + label: string; + children?: MindmapNode[]; + } + + const lines: string[] = []; + lines.push(ANSI.CLEAR + ANSI.HIDE_CURSOR); + + // Render tree recursively + const renderNode = (node: MindmapNode, prefix: string, isLast: boolean, isRoot: boolean): void => { + if (isRoot) { + lines.push(ANSI.BOLD + ANSI.FG_YELLOW + "◆ " + node.label + ANSI.RESET); + } else { + const connector = isLast ? "└── " : "├── "; + lines.push(prefix + connector + node.label); + } + + const childPrefix = isRoot ? "" : prefix + (isLast ? " " : "│ "); + const nodeChildren = node.children ?? []; + for (let i = 0; i < nodeChildren.length; i++) { + renderNode(nodeChildren[i], childPrefix, i === nodeChildren.length - 1, false); + } + }; + + // Root node + renderNode({ label: title, children }, "", true, true); + + // Count total nodes + const countNodes = (nodes: MindmapNode[]): number => { + return nodes.reduce((sum, n) => sum + 1 + countNodes(n.children ?? []), 0); + }; + const totalNodes = 1 + countNodes(children); + + lines.push(""); + lines.push(ANSI.DIM + `Total nodes: ${totalNodes}` + ANSI.RESET); + + return lines.join("\n"); + } } /** diff --git a/src/domain/shared/canvas-tool.ts b/src/domain/shared/canvas-tool.ts index 963fb6d3de..193f57830b 100644 --- a/src/domain/shared/canvas-tool.ts +++ b/src/domain/shared/canvas-tool.ts @@ -3,7 +3,7 @@ import type { ToolDefinition, ToolExecutionResult } from "../../mcp/types"; import { requestDaemon } from "../../daemon/ipc-client"; const ShowCanvasParams = z.object({ - kind: z.enum(["text", "calendar", "document", "table"]) + kind: z.enum(["text", "calendar", "document", "table", "diagram", "graph", "mindmap"]) .describe("Canvas kind/type"), id: z.string().describe("Unique canvas identifier"), scenario: z.enum(["display", "edit", "meeting-picker"]).optional() @@ -20,6 +20,9 @@ export const showCanvasTool: ToolDefinition = { - calendar: Monthly calendar view with events - document: Markdown-like document rendering - table: Tabular data display + - diagram: Flowchart/architecture diagrams (visual reasoning) + - graph: Nodes and edges visualization (knowledge graphs) + - mindmap: Hierarchical tree view (thinking/planning) Scenarios: - display: Read-only (default) @@ -28,10 +31,12 @@ export const showCanvasTool: ToolDefinition = { Canvas automatically spawns in a 67% width pane (Claude:Canvas = 1:2 ratio). Existing canvas panes are reused to avoid clutter. + Works alongside nvim/LSP in WezTerm. Examples: - Show text canvas: { kind: "text", id: "notes" } - - Show calendar: { kind: "calendar", id: "cal-1" }`, + - Show calendar: { kind: "calendar", id: "cal-1" } + - Show diagram: { kind: "diagram", id: "flow-1" }`, parameters: ShowCanvasParams, execute: async (args, ctx): Promise => { const { kind, id } = args; @@ -73,7 +78,7 @@ Start it with: bun run src/daemon/index.ts`, }; const SpawnCanvasParams = z.object({ - kind: z.enum(["text", "calendar", "document", "table"]) + kind: z.enum(["text", "calendar", "document", "table", "diagram", "graph", "mindmap"]) .describe("Canvas kind/type"), id: z.string().describe("Unique canvas identifier"), config: z.string() @@ -92,11 +97,14 @@ export const spawnCanvasTool: ToolDefinition = { - 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[] } + - diagram: { title: string, nodes: [{id, label, type?: "box"|"diamond"|"oval"}], edges: [{from, to, label?}], direction?: "TB"|"LR" } + - graph: { title: string, nodes: [{id, label, color?}], edges: [{from, to, label?, weight?}] } + - mindmap: { title: string, children: [{label, children?: [...]}] } Examples: - 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"]]}' }`, + - Spawn diagram: { kind: "diagram", id: "flow", config: '{"title": "Auth Flow", "nodes": [{"id": "start", "label": "Login"}, {"id": "check", "label": "Valid?", "type": "diamond"}], "edges": [{"from": "start", "to": "check"}]}' } + - Spawn mindmap: { kind: "mindmap", id: "plan", config: '{"title": "Project Plan", "children": [{"label": "Phase 1", "children": [{"label": "Task A"}]}]}' }`, parameters: SpawnCanvasParams, execute: async (args, ctx): Promise => { const { kind, id, config: configStr } = args;