feat(canvas): add visual reasoning canvas types

Add three new canvas kinds for visual reasoning in WezTerm:
- diagram: flowcharts, architecture diagrams with box/diamond/oval nodes
- graph: nodes and edges with colors and weights (knowledge graphs)
- mindmap: hierarchical tree view for thinking/planning

Canvas features:
- ASCII art rendering with ANSI colors
- Works alongside nvim/LSP in WezTerm panes
- 67% width ratio (Claude:Canvas = 1:2)
- Reusable pane management

Updated tools with new kinds and config documentation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Artur Do Lago
2026-01-11 13:28:53 +01:00
parent d627583dc4
commit 8a2440d640
3 changed files with 266 additions and 7 deletions
+5 -1
View File
@@ -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)
+248 -1
View File
@@ -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, unknown>): 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, unknown>): 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<string, Array<{ to: string; label?: string; weight?: number }>>();
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<string, string> = {
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<MindmapNode> where MindmapNode = { label: string, children?: MindmapNode[] }
*/
private renderMindmap(config: Record<string, unknown>): string {
const title = (config.title as string) ?? "Mindmap";
const children = (config.children as Array<MindmapNode>) ?? [];
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");
}
}
/**
+13 -5
View File
@@ -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<ToolExecutionResult> => {
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<ToolExecutionResult> => {
const { kind, id, config: configStr } = args;