Browse Source

feat(canvas): Add terminal canvas for content drafting

Adds a split-pane canvas experience for Warp terminal:

- canvas-tui: Ink/React TUI app with live markdown preview
  - File watching via chokidar with 100ms debounce
  - ANSI-styled markdown rendering via marked-terminal
  - Scroll navigation and keyboard shortcuts
  - Email, message, and doc content templates

- /canvas command: start, write, read, clear, close subcommands
- Warp launch configuration for one-click split pane setup
- File-based IPC via .claude/canvas/content.md

Tech stack: Ink 5, React 18, TypeScript, chokidar, marked-terminal

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
0xDarkMatter 3 months ago
parent
commit
8fdd35c190

+ 7 - 5
.claude-plugin/plugin.json

@@ -1,7 +1,7 @@
 {
   "name": "claude-mods",
-  "version": "1.3.0",
-  "description": "Custom commands, skills, and agents for Claude Code - session continuity, 23 expert agents, 30 skills, 4 rules, modern CLI tools",
+  "version": "1.4.0",
+  "description": "Custom commands, skills, and agents for Claude Code - session continuity, terminal canvas, 22 expert agents, 30 skills, 4 rules, modern CLI tools",
   "author": "0xDarkMatter",
   "repository": "https://github.com/0xDarkMatter/claude-mods",
   "license": "MIT",
@@ -12,7 +12,9 @@
     "skills",
     "session-management",
     "cli-tools",
-    "output-styles"
+    "output-styles",
+    "canvas",
+    "terminal-ui"
   ],
   "components": {
     "commands": [
@@ -25,7 +27,8 @@
       "commands/conclave.md",
       "commands/atomise.md",
       "commands/setperms.md",
-      "commands/pulse.md"
+      "commands/pulse.md",
+      "commands/canvas.md"
     ],
     "agents": [
       "agents/astro-expert.md",
@@ -41,7 +44,6 @@
       "agents/javascript-expert.md",
       "agents/laravel-expert.md",
       "agents/payloadcms-expert.md",
-      "agents/playwright-roulette-expert.md",
       "agents/postgres-expert.md",
       "agents/project-organizer.md",
       "agents/python-expert.md",

+ 4 - 0
canvas-tui/.gitignore

@@ -0,0 +1,4 @@
+node_modules/
+dist/
+*.log
+.DS_Store

+ 82 - 0
canvas-tui/README.md

@@ -0,0 +1,82 @@
+# @claude-mods/canvas-tui
+
+Terminal canvas for Claude Code - live markdown preview in split panes.
+
+## Installation
+
+```bash
+npm install -g @claude-mods/canvas-tui
+```
+
+Or run directly with npx:
+
+```bash
+npx @claude-mods/canvas-tui --watch
+```
+
+## Usage
+
+```bash
+# Watch default location (.claude/canvas/content.md)
+canvas-tui --watch
+
+# Watch specific directory
+canvas-tui --watch ./my-canvas
+
+# Watch specific file
+canvas-tui --file ./draft.md
+
+# Show help
+canvas-tui --help
+```
+
+## Keyboard Shortcuts
+
+| Key | Action |
+|-----|--------|
+| `q` | Quit |
+| `Ctrl+C` | Quit |
+| `Up/Down` | Scroll |
+| `g` | Go to top |
+| `G` | Go to bottom |
+| `r` | Refresh |
+
+## Integration with Claude Code
+
+This TUI works with the `/canvas` command in Claude Code:
+
+1. Run `/canvas start --type email` in Claude Code
+2. Open a split pane in your terminal
+3. Run `canvas-tui --watch` in the split pane
+4. Claude writes content, you see live preview
+
+## Warp Terminal
+
+For Warp users, install the launch configuration:
+
+```bash
+# Windows
+copy templates\warp\claude-canvas.yaml %APPDATA%\warp\Warp\data\launch_configurations\
+
+# macOS/Linux
+cp templates/warp/claude-canvas.yaml ~/.warp/launch_configurations/
+```
+
+Then open Warp Command Palette and search "Claude Canvas".
+
+## File Structure
+
+```
+.claude/canvas/
+├── content.md      # Shared content (Claude writes, TUI renders)
+└── meta.json       # Session metadata
+```
+
+## Requirements
+
+- Node.js >= 18.0.0
+- Terminal with ANSI color support
+
+## License
+
+MIT

+ 2 - 0
canvas-tui/bin/canvas.js

@@ -0,0 +1,2 @@
+#!/usr/bin/env node
+import('../dist/index.js');

File diff suppressed because it is too large
+ 1305 - 0
canvas-tui/package-lock.json


+ 55 - 0
canvas-tui/package.json

@@ -0,0 +1,55 @@
+{
+  "name": "@claude-mods/canvas-tui",
+  "version": "0.1.0",
+  "description": "Terminal canvas for Claude Code - live markdown preview in split panes",
+  "type": "module",
+  "main": "dist/index.js",
+  "bin": {
+    "canvas-tui": "./bin/canvas.js"
+  },
+  "scripts": {
+    "build": "tsc",
+    "dev": "tsc --watch",
+    "start": "node bin/canvas.js",
+    "test": "node --test"
+  },
+  "keywords": [
+    "claude",
+    "claude-code",
+    "terminal",
+    "tui",
+    "canvas",
+    "markdown",
+    "ink",
+    "react"
+  ],
+  "author": "0xDarkMatter",
+  "license": "MIT",
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/0xDarkMatter/claude-mods.git",
+    "directory": "canvas-tui"
+  },
+  "engines": {
+    "node": ">=18.0.0"
+  },
+  "dependencies": {
+    "ink": "^5.0.1",
+    "@inkjs/ui": "^2.0.0",
+    "react": "^18.3.1",
+    "chokidar": "^4.0.3",
+    "marked": "^15.0.4",
+    "marked-terminal": "^7.2.1",
+    "chalk": "^5.3.0",
+    "meow": "^13.2.0"
+  },
+  "devDependencies": {
+    "@types/node": "^22.10.5",
+    "@types/react": "^18.3.18",
+    "typescript": "^5.7.2"
+  },
+  "files": [
+    "dist",
+    "bin"
+  ]
+}

+ 113 - 0
canvas-tui/src/app.tsx

@@ -0,0 +1,113 @@
+import React, { useState, useEffect } from 'react';
+import { Box, Text, useApp, useInput, useStdout } from 'ink';
+import { Header } from './components/Header.js';
+import { MarkdownView } from './components/MarkdownView.js';
+import { StatusBar } from './components/StatusBar.js';
+import { useFileWatcher } from './hooks/useFileWatcher.js';
+import { readMeta, type CanvasMeta } from './lib/ipc.js';
+
+interface AppProps {
+  watchPath: string;
+  watchDir: string;
+}
+
+export const App: React.FC<AppProps> = ({ watchPath, watchDir }) => {
+  const { exit } = useApp();
+  const { stdout } = useStdout();
+  const [content, setContent] = useState<string>('');
+  const [meta, setMeta] = useState<CanvasMeta | null>(null);
+  const [syncStatus, setSyncStatus] = useState<'waiting' | 'synced' | 'watching'>('waiting');
+  const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
+  const [scrollOffset, setScrollOffset] = useState(0);
+
+  // Terminal dimensions
+  const rows = stdout?.rows || 24;
+  const cols = stdout?.columns || 80;
+
+  // File watcher
+  const { content: watchedContent, error } = useFileWatcher(watchPath);
+
+  // Update content when file changes
+  useEffect(() => {
+    if (watchedContent !== null) {
+      setContent(watchedContent);
+      setSyncStatus('synced');
+      setLastUpdate(new Date());
+
+      // Also read meta file
+      const metaPath = watchDir + '/meta.json';
+      readMeta(metaPath).then(setMeta).catch(() => {});
+    }
+  }, [watchedContent, watchDir]);
+
+  // Keyboard input
+  useInput((input, key) => {
+    if (input === 'q' || (key.ctrl && input === 'c')) {
+      exit();
+    }
+    if (key.upArrow) {
+      setScrollOffset(prev => Math.max(0, prev - 1));
+    }
+    if (key.downArrow) {
+      setScrollOffset(prev => prev + 1);
+    }
+    if (input === 'g') {
+      setScrollOffset(0); // Go to top
+    }
+    if (input === 'G') {
+      // Go to bottom - handled in MarkdownView
+      setScrollOffset(999999);
+    }
+    if (input === 'r') {
+      // Force refresh - re-read file
+      setSyncStatus('watching');
+    }
+  });
+
+  // Determine status message
+  let statusMessage = '';
+  if (error) {
+    statusMessage = `Error: ${error}`;
+  } else if (syncStatus === 'waiting') {
+    statusMessage = `Waiting for content at ${watchPath}...`;
+  } else if (syncStatus === 'synced' && lastUpdate) {
+    statusMessage = `Last updated: ${lastUpdate.toLocaleTimeString()}`;
+  }
+
+  return (
+    <Box flexDirection="column" height={rows}>
+      <Header
+        title="Canvas"
+        contentType={meta?.contentType || 'doc'}
+        width={cols}
+      />
+
+      <Box flexGrow={1} flexDirection="column" overflow="hidden">
+        {content ? (
+          <MarkdownView
+            content={content}
+            scrollOffset={scrollOffset}
+            maxHeight={rows - 4}
+          />
+        ) : (
+          <Box padding={1}>
+            <Text color="gray">
+              {error ? (
+                <Text color="red">{error}</Text>
+              ) : (
+                `Watching ${watchPath} for changes...\n\nUse /canvas write in Claude Code to send content here.`
+              )}
+            </Text>
+          </Box>
+        )}
+      </Box>
+
+      <StatusBar
+        status={syncStatus}
+        message={statusMessage}
+        hints="q: quit | arrows: scroll | r: refresh"
+        width={cols}
+      />
+    </Box>
+  );
+};

+ 28 - 0
canvas-tui/src/components/Header.tsx

@@ -0,0 +1,28 @@
+import React from 'react';
+import { Box, Text } from 'ink';
+
+interface HeaderProps {
+  title: string;
+  contentType: string;
+  width: number;
+}
+
+export const Header: React.FC<HeaderProps> = ({ title, contentType, width }) => {
+  const typeLabel = contentType.charAt(0).toUpperCase() + contentType.slice(1);
+  const leftContent = ` ${title} `;
+  const rightContent = ` ${typeLabel} `;
+
+  // Calculate padding for centering
+  const totalContentLength = leftContent.length + rightContent.length;
+  const padding = Math.max(0, width - totalContentLength - 2);
+
+  return (
+    <Box borderStyle="single" borderBottom={true} borderTop={false} borderLeft={false} borderRight={false}>
+      <Box width={width}>
+        <Text bold color="blue">{leftContent}</Text>
+        <Text>{' '.repeat(padding)}</Text>
+        <Text color="gray">{rightContent}</Text>
+      </Box>
+    </Box>
+  );
+};

+ 47 - 0
canvas-tui/src/components/MarkdownView.tsx

@@ -0,0 +1,47 @@
+import React, { useMemo } from 'react';
+import { Box, Text } from 'ink';
+import { useMarkdown } from '../hooks/useMarkdown.js';
+
+interface MarkdownViewProps {
+  content: string;
+  scrollOffset: number;
+  maxHeight: number;
+}
+
+export const MarkdownView: React.FC<MarkdownViewProps> = ({
+  content,
+  scrollOffset,
+  maxHeight
+}) => {
+  const rendered = useMarkdown(content);
+
+  // Split into lines for scrolling
+  const lines = useMemo(() => {
+    return rendered.split('\n');
+  }, [rendered]);
+
+  // Calculate visible window
+  const totalLines = lines.length;
+  const clampedOffset = Math.min(scrollOffset, Math.max(0, totalLines - maxHeight));
+  const visibleLines = lines.slice(clampedOffset, clampedOffset + maxHeight);
+
+  // Scroll indicator
+  const showScrollUp = clampedOffset > 0;
+  const showScrollDown = clampedOffset + maxHeight < totalLines;
+
+  return (
+    <Box flexDirection="column" paddingX={1}>
+      {showScrollUp && (
+        <Text color="gray">--- more above ({clampedOffset} lines) ---</Text>
+      )}
+
+      {visibleLines.map((line, index) => (
+        <Text key={`${clampedOffset}-${index}`}>{line || ' '}</Text>
+      ))}
+
+      {showScrollDown && (
+        <Text color="gray">--- more below ({totalLines - clampedOffset - maxHeight} lines) ---</Text>
+      )}
+    </Box>
+  );
+};

+ 45 - 0
canvas-tui/src/components/StatusBar.tsx

@@ -0,0 +1,45 @@
+import React from 'react';
+import { Box, Text } from 'ink';
+
+interface StatusBarProps {
+  status: 'waiting' | 'synced' | 'watching';
+  message: string;
+  hints: string;
+  width: number;
+}
+
+export const StatusBar: React.FC<StatusBarProps> = ({ status, message, hints, width }) => {
+  const statusColors: Record<string, string> = {
+    waiting: 'yellow',
+    synced: 'green',
+    watching: 'cyan'
+  };
+
+  const statusIcons: Record<string, string> = {
+    waiting: '...',
+    synced: '***',
+    watching: '>>>'
+  };
+
+  const statusColor = statusColors[status] || 'white';
+  const statusIcon = statusIcons[status] || '?';
+
+  return (
+    <Box
+      borderStyle="single"
+      borderTop={true}
+      borderBottom={false}
+      borderLeft={false}
+      borderRight={false}
+      flexDirection="column"
+    >
+      <Box width={width} justifyContent="space-between">
+        <Box>
+          <Text color={statusColor}>[{statusIcon}]</Text>
+          <Text> {message}</Text>
+        </Box>
+        <Text color="gray">{hints}</Text>
+      </Box>
+    </Box>
+  );
+};

+ 78 - 0
canvas-tui/src/hooks/useFileWatcher.ts

@@ -0,0 +1,78 @@
+import { useState, useEffect } from 'react';
+import { watch } from 'chokidar';
+import { readFile } from 'fs/promises';
+import { existsSync } from 'fs';
+
+interface FileWatcherResult {
+  content: string | null;
+  error: string | null;
+  isWatching: boolean;
+}
+
+export function useFileWatcher(filePath: string): FileWatcherResult {
+  const [content, setContent] = useState<string | null>(null);
+  const [error, setError] = useState<string | null>(null);
+  const [isWatching, setIsWatching] = useState(false);
+
+  useEffect(() => {
+    let watcher: ReturnType<typeof watch> | null = null;
+
+    const readContent = async () => {
+      try {
+        if (existsSync(filePath)) {
+          const data = await readFile(filePath, 'utf-8');
+          setContent(data);
+          setError(null);
+        }
+      } catch (err) {
+        setError(`Failed to read file: ${err instanceof Error ? err.message : String(err)}`);
+      }
+    };
+
+    const startWatching = () => {
+      // Initial read
+      readContent();
+
+      // Set up watcher
+      watcher = watch(filePath, {
+        persistent: true,
+        ignoreInitial: false,
+        awaitWriteFinish: {
+          stabilityThreshold: 100,
+          pollInterval: 50
+        }
+      });
+
+      watcher.on('add', () => {
+        readContent();
+        setIsWatching(true);
+      });
+
+      watcher.on('change', () => {
+        readContent();
+      });
+
+      watcher.on('unlink', () => {
+        setContent(null);
+        setError('File was deleted');
+      });
+
+      watcher.on('error', (err: unknown) => {
+        const message = err instanceof Error ? err.message : String(err);
+        setError(`Watcher error: ${message}`);
+      });
+
+      setIsWatching(true);
+    };
+
+    startWatching();
+
+    return () => {
+      if (watcher) {
+        watcher.close();
+      }
+    };
+  }, [filePath]);
+
+  return { content, error, isWatching };
+}

+ 73 - 0
canvas-tui/src/hooks/useMarkdown.ts

@@ -0,0 +1,73 @@
+import { useMemo } from 'react';
+import { marked } from 'marked';
+import { markedTerminal } from 'marked-terminal';
+import chalk from 'chalk';
+
+// Configure marked with terminal renderer
+marked.use(
+  markedTerminal({
+    // Colors for different elements
+    code: chalk.cyan,
+    blockquote: chalk.gray.italic,
+    html: chalk.gray,
+    heading: chalk.bold.blue,
+    firstHeading: chalk.bold.blue.underline,
+    hr: chalk.gray,
+    listitem: chalk.white,
+    list: (body: string) => body,
+    table: chalk.white,
+    paragraph: chalk.white,
+    strong: chalk.bold,
+    em: chalk.italic,
+    codespan: chalk.yellow,
+    del: chalk.strikethrough,
+    link: chalk.blue.underline,
+    href: chalk.blue.underline,
+
+    // Table rendering
+    tableOptions: {
+      chars: {
+        top: '-',
+        'top-mid': '+',
+        'top-left': '+',
+        'top-right': '+',
+        bottom: '-',
+        'bottom-mid': '+',
+        'bottom-left': '+',
+        'bottom-right': '+',
+        left: '|',
+        'left-mid': '+',
+        mid: '-',
+        'mid-mid': '+',
+        right: '|',
+        'right-mid': '+',
+        middle: '|'
+      }
+    },
+
+    // Misc settings
+    reflowText: true,
+    width: 80,
+    showSectionPrefix: false,
+    tab: 2
+  })
+);
+
+export function useMarkdown(content: string): string {
+  return useMemo(() => {
+    if (!content) return '';
+
+    try {
+      // Parse markdown to terminal-formatted string
+      const rendered = marked.parse(content);
+      // marked returns Promise in some configs, but sync with markedTerminal
+      if (typeof rendered === 'string') {
+        return rendered.trim();
+      }
+      return content; // Fallback to raw content
+    } catch (err) {
+      console.error('Markdown parse error:', err);
+      return content; // Return raw content on error
+    }
+  }, [content]);
+}

+ 38 - 0
canvas-tui/src/index.tsx

@@ -0,0 +1,38 @@
+#!/usr/bin/env node
+import React from 'react';
+import { render } from 'ink';
+import meow from 'meow';
+import { App } from './app.js';
+
+const cli = meow(`
+  Usage
+    $ canvas-tui [options]
+
+  Options
+    --watch, -w     Watch directory for changes (default: .claude/canvas)
+    --file, -f      Specific file to watch
+    --help          Show this help message
+    --version       Show version
+
+  Examples
+    $ canvas-tui --watch
+    $ canvas-tui --watch .claude/canvas
+    $ canvas-tui --file ./draft.md
+`, {
+  importMeta: import.meta,
+  flags: {
+    watch: {
+      type: 'string',
+      shortFlag: 'w',
+      default: '.claude/canvas'
+    },
+    file: {
+      type: 'string',
+      shortFlag: 'f'
+    }
+  }
+});
+
+const watchPath = cli.flags.file || `${cli.flags.watch}/content.md`;
+
+render(<App watchPath={watchPath} watchDir={cli.flags.watch} />);

+ 105 - 0
canvas-tui/src/lib/ipc.ts

@@ -0,0 +1,105 @@
+import { readFile, writeFile, mkdir } from 'fs/promises';
+import { existsSync } from 'fs';
+import { dirname } from 'path';
+
+export interface CanvasMeta {
+  version: string;
+  contentType: 'email' | 'message' | 'doc';
+  mode: 'view' | 'edit';
+  claudeLastWrite: string | null;
+  userLastEdit: string | null;
+  title?: string;
+}
+
+const DEFAULT_META: CanvasMeta = {
+  version: '1.0',
+  contentType: 'doc',
+  mode: 'view',
+  claudeLastWrite: null,
+  userLastEdit: null
+};
+
+/**
+ * Read meta.json from canvas directory
+ */
+export async function readMeta(metaPath: string): Promise<CanvasMeta> {
+  try {
+    if (!existsSync(metaPath)) {
+      return DEFAULT_META;
+    }
+    const data = await readFile(metaPath, 'utf-8');
+    return { ...DEFAULT_META, ...JSON.parse(data) };
+  } catch {
+    return DEFAULT_META;
+  }
+}
+
+/**
+ * Write meta.json to canvas directory
+ */
+export async function writeMeta(metaPath: string, meta: Partial<CanvasMeta>): Promise<void> {
+  const dir = dirname(metaPath);
+  if (!existsSync(dir)) {
+    await mkdir(dir, { recursive: true });
+  }
+
+  const existing = await readMeta(metaPath);
+  const updated: CanvasMeta = {
+    ...existing,
+    ...meta
+  };
+
+  await writeFile(metaPath, JSON.stringify(updated, null, 2), 'utf-8');
+}
+
+/**
+ * Read content from canvas content file
+ */
+export async function readContent(contentPath: string): Promise<string | null> {
+  try {
+    if (!existsSync(contentPath)) {
+      return null;
+    }
+    return await readFile(contentPath, 'utf-8');
+  } catch {
+    return null;
+  }
+}
+
+/**
+ * Write content to canvas content file
+ */
+export async function writeContent(contentPath: string, content: string): Promise<void> {
+  const dir = dirname(contentPath);
+  if (!existsSync(dir)) {
+    await mkdir(dir, { recursive: true });
+  }
+  await writeFile(contentPath, content, 'utf-8');
+}
+
+/**
+ * Initialize canvas directory with default files
+ */
+export async function initCanvas(
+  canvasDir: string,
+  contentType: CanvasMeta['contentType'] = 'doc'
+): Promise<void> {
+  const contentPath = `${canvasDir}/content.md`;
+  const metaPath = `${canvasDir}/meta.json`;
+
+  // Ensure directory exists
+  if (!existsSync(canvasDir)) {
+    await mkdir(canvasDir, { recursive: true });
+  }
+
+  // Create meta file
+  await writeMeta(metaPath, {
+    contentType,
+    claudeLastWrite: new Date().toISOString()
+  });
+
+  // Create empty content file if it doesn't exist
+  if (!existsSync(contentPath)) {
+    await writeContent(contentPath, '');
+  }
+}

+ 93 - 0
canvas-tui/src/lib/templates.ts

@@ -0,0 +1,93 @@
+export interface ContentTemplate {
+  name: string;
+  description: string;
+  template: string;
+}
+
+export const templates: Record<string, ContentTemplate> = {
+  email: {
+    name: 'Email',
+    description: 'Professional email format with subject, greeting, and signature',
+    template: `# Email Draft
+
+**To:**
+**Subject:**
+
+---
+
+Hi [Name],
+
+[Your message here]
+
+Best regards,
+[Your name]
+
+---
+*Draft started: ${new Date().toLocaleString()}*
+`
+  },
+
+  message: {
+    name: 'Message',
+    description: 'Casual message format for Slack, Teams, or Discord',
+    template: `# Message Draft
+
+**To:** #channel / @person
+
+---
+
+[Your message here]
+
+---
+*Draft started: ${new Date().toLocaleString()}*
+`
+  },
+
+  doc: {
+    name: 'Document',
+    description: 'Structured markdown document with sections',
+    template: `# Document Title
+
+## Overview
+
+[Brief description of the document's purpose]
+
+## Details
+
+[Main content here]
+
+## Summary
+
+[Key takeaways]
+
+---
+*Draft started: ${new Date().toLocaleString()}*
+`
+  }
+};
+
+/**
+ * Get a template by type, with current timestamp
+ */
+export function getTemplate(type: keyof typeof templates): string {
+  const template = templates[type];
+  if (!template) {
+    return templates.doc.template;
+  }
+  // Replace timestamp placeholder with current time
+  return template.template.replace(
+    /\$\{new Date\(\)\.toLocaleString\(\)\}/g,
+    new Date().toLocaleString()
+  );
+}
+
+/**
+ * List available template types
+ */
+export function listTemplates(): Array<{ type: string; name: string; description: string }> {
+  return Object.entries(templates).map(([type, template]) => ({
+    type,
+    name: template.name,
+    description: template.description
+  }));
+}

+ 31 - 0
canvas-tui/src/types/marked-terminal.d.ts

@@ -0,0 +1,31 @@
+declare module 'marked-terminal' {
+  import { MarkedExtension } from 'marked';
+
+  interface MarkedTerminalOptions {
+    code?: (code: string) => string;
+    blockquote?: (quote: string) => string;
+    html?: (html: string) => string;
+    heading?: (text: string, level: number) => string;
+    firstHeading?: (text: string) => string;
+    hr?: () => string;
+    listitem?: (text: string) => string;
+    list?: (body: string, ordered: boolean) => string;
+    table?: (header: string, body: string) => string;
+    paragraph?: (text: string) => string;
+    strong?: (text: string) => string;
+    em?: (text: string) => string;
+    codespan?: (code: string) => string;
+    del?: (text: string) => string;
+    link?: (href: string, title: string, text: string) => string;
+    href?: (href: string) => string;
+    tableOptions?: {
+      chars?: Record<string, string>;
+    };
+    reflowText?: boolean;
+    width?: number;
+    showSectionPrefix?: boolean;
+    tab?: number;
+  }
+
+  export function markedTerminal(options?: MarkedTerminalOptions): MarkedExtension;
+}

+ 22 - 0
canvas-tui/tsconfig.json

@@ -0,0 +1,22 @@
+{
+  "compilerOptions": {
+    "target": "ES2022",
+    "module": "NodeNext",
+    "moduleResolution": "NodeNext",
+    "lib": ["ES2022"],
+    "outDir": "./dist",
+    "rootDir": "./src",
+    "strict": true,
+    "esModuleInterop": true,
+    "skipLibCheck": true,
+    "forceConsistentCasingInFileNames": true,
+    "declaration": true,
+    "declarationMap": true,
+    "sourceMap": true,
+    "jsx": "react-jsx",
+    "resolveJsonModule": true,
+    "isolatedModules": true
+  },
+  "include": ["src/**/*"],
+  "exclude": ["node_modules", "dist"]
+}

+ 343 - 0
commands/canvas.md

@@ -0,0 +1,343 @@
+---
+description: "Terminal canvas for content drafting with live preview. Start split-pane sessions for email, message, and document composition. Triggers on: canvas, draft, compose, write content."
+---
+
+# Canvas - Terminal Content Drafting
+
+Terminal canvas for interactive content drafting with Claude. Creates a split-pane experience in Warp terminal where Claude writes content and you see live markdown preview.
+
+## Arguments
+
+$ARGUMENTS
+
+- `start [--type email|message|doc]`: Initialize canvas session
+- `write "content"`: Write/update content in canvas
+- `read`: Read current canvas content back
+- `clear`: Clear canvas content
+- `close`: End canvas session and clean up
+
+## Architecture
+
+```
+/canvas <subcommand> [options]
+    │
+    ├─→ /canvas start [--type email|message|doc]
+    │     ├─ Create .claude/canvas/ directory
+    │     ├─ Initialize content.md with template
+    │     ├─ Initialize meta.json with state
+    │     ├─ Detect Warp terminal
+    │     └─ Output setup instructions
+    │
+    ├─→ /canvas write "content"
+    │     ├─ Write content to .claude/canvas/content.md
+    │     ├─ Update meta.json timestamp
+    │     └─ Canvas TUI auto-refreshes
+    │
+    ├─→ /canvas read
+    │     ├─ Read .claude/canvas/content.md
+    │     └─ Return content for Claude to process
+    │
+    ├─→ /canvas clear
+    │     ├─ Clear content.md (keep structure)
+    │     └─ Reset meta.json
+    │
+    └─→ /canvas close
+          ├─ Optional: copy content to clipboard
+          └─ Clean up .claude/canvas/
+```
+
+---
+
+## Workflow
+
+### Starting a Canvas Session
+
+```
+User: "Help me draft an email to my manager about the project delay"
+
+Claude: I'll help you draft that email. Starting canvas mode...
+
+[Executes internally:]
+1. mkdir -p .claude/canvas
+2. Write email template to .claude/canvas/content.md
+3. Write meta.json with contentType: "email"
+
+[Output:]
+Canvas initialized with email template.
+
+To see live preview, open Warp and:
+1. Press Cmd+Shift+D (or Ctrl+Shift+D on Windows) to split pane
+2. In the new pane, run: npx @claude-mods/canvas-tui --watch
+
+Or use the launch configuration:
+  warp://launch/claude-canvas
+```
+
+### Writing Content
+
+```
+[Claude writes the email draft:]
+
+/canvas write "# Email Draft
+
+**To:** manager@company.com
+**Subject:** Project Timeline Update
+
+---
+
+Hi Sarah,
+
+I wanted to give you a heads-up about a delay in the Phoenix project...
+
+Best regards,
+[Name]"
+
+[Canvas TUI instantly shows the rendered markdown]
+```
+
+### Reading Edits
+
+```
+User: "I edited the email in the canvas, can you make it more formal?"
+
+[Claude reads current content:]
+/canvas read
+
+[Returns content from .claude/canvas/content.md with user's edits]
+
+[Claude can now rewrite based on user's changes]
+```
+
+---
+
+## Execution
+
+### /canvas start
+
+**Step 1: Create IPC Directory**
+
+```bash
+mkdir -p .claude/canvas
+```
+
+**Step 2: Select Template**
+
+Based on `--type` flag (default: doc):
+
+| Type | Template |
+|------|----------|
+| email | Subject line, To/CC fields, greeting, body, signature |
+| message | Casual format for Slack/Teams/Discord |
+| doc | Structured markdown with sections |
+
+**Step 3: Initialize Files**
+
+Write `.claude/canvas/content.md`:
+```markdown
+# Email Draft
+
+**To:**
+**Subject:**
+
+---
+
+Hi [Name],
+
+[Your message here]
+
+Best regards,
+[Your name]
+```
+
+Write `.claude/canvas/meta.json`:
+```json
+{
+  "version": "1.0",
+  "contentType": "email",
+  "mode": "view",
+  "claudeLastWrite": "2025-01-08T10:30:00Z",
+  "userLastEdit": null
+}
+```
+
+**Step 4: Output Instructions**
+
+```
+Canvas ready with email template.
+
+Setup (one-time):
+  npm install -g @claude-mods/canvas-tui
+
+To view canvas:
+  1. Split your terminal (Cmd+Shift+D in Warp)
+  2. Run: canvas-tui --watch
+
+I'll write your content and you'll see it update in real-time.
+```
+
+### /canvas write
+
+**Parameters:**
+- Content (required): Markdown string to write
+
+**Execution:**
+
+1. Ensure `.claude/canvas/` exists
+2. Write content to `.claude/canvas/content.md`
+3. Update `meta.json` with `claudeLastWrite` timestamp
+4. Canvas TUI detects change via chokidar and re-renders
+
+**Output:**
+```
+Content updated in canvas.
+```
+
+### /canvas read
+
+**Execution:**
+
+1. Read `.claude/canvas/content.md`
+2. Return content as string
+
+**Use Case:** After user edits content in canvas, Claude reads it back to incorporate changes.
+
+### /canvas clear
+
+**Execution:**
+
+1. Read current `meta.json` to preserve contentType
+2. Write empty template to `content.md`
+3. Reset timestamps in `meta.json`
+
+### /canvas close
+
+**Execution:**
+
+1. Optionally copy final content to clipboard (if requested)
+2. Remove `.claude/canvas/` directory
+3. Confirm cleanup
+
+---
+
+## Templates
+
+### Email Template
+
+```markdown
+# Email Draft
+
+**To:**
+**CC:**
+**Subject:**
+
+---
+
+Hi [Name],
+
+[Your message here]
+
+Best regards,
+[Your name]
+
+---
+*Draft started: {timestamp}*
+```
+
+### Message Template
+
+```markdown
+# Message Draft
+
+**To:** #channel / @person
+
+---
+
+[Your message here]
+
+---
+*Draft started: {timestamp}*
+```
+
+### Document Template
+
+```markdown
+# Document Title
+
+## Overview
+
+[Brief description]
+
+## Details
+
+[Main content]
+
+## Summary
+
+[Key takeaways]
+
+---
+*Draft started: {timestamp}*
+```
+
+---
+
+## Integration
+
+### Warp Launch Configuration
+
+Install the launch config for one-click split pane setup:
+
+**Location:** `~/.warp/launch_configurations/claude-canvas.yaml`
+
+```yaml
+name: Claude Canvas
+windows:
+  - tabs:
+      - title: Claude Canvas
+        color: Blue
+        layout:
+          split_direction: vertical
+          panes:
+            - is_focused: true
+            - commands:
+                - exec: "npx @claude-mods/canvas-tui --watch"
+```
+
+**Usage:**
+1. Open Warp Command Palette (Cmd+P)
+2. Search "Claude Canvas"
+3. Select to open split layout
+
+### Canvas TUI Package
+
+```bash
+# Install globally
+npm install -g @claude-mods/canvas-tui
+
+# Or run via npx
+npx @claude-mods/canvas-tui --watch
+
+# Options
+canvas-tui --watch              # Watch .claude/canvas/content.md
+canvas-tui --file ./draft.md    # Watch specific file
+canvas-tui --help               # Show help
+```
+
+---
+
+## File Locations
+
+| File | Purpose |
+|------|---------|
+| `.claude/canvas/content.md` | Shared content file |
+| `.claude/canvas/meta.json` | Session metadata |
+| `~/.warp/launch_configurations/claude-canvas.yaml` | Warp split config |
+
+---
+
+## Notes
+
+- Canvas TUI is view-only in MVP; edit mode planned for Phase 2
+- File watching uses chokidar with 100ms debounce
+- Works best with Warp terminal but compatible with any terminal that supports split panes
+- Content persists until `/canvas close` is called

+ 30 - 0
templates/warp/claude-canvas.yaml

@@ -0,0 +1,30 @@
+# Warp Launch Configuration for Claude Canvas
+#
+# Installation:
+#   Windows: Copy to %APPDATA%\warp\Warp\data\launch_configurations\
+#   macOS:   Copy to ~/.warp/launch_configurations/
+#   Linux:   Copy to ~/.warp/launch_configurations/
+#
+# Usage:
+#   1. Open Warp Command Palette (Cmd+P / Ctrl+Shift+P)
+#   2. Search "Claude Canvas"
+#   3. Select to open split layout
+#
+# Or trigger via URI:
+#   warp://launch/claude-canvas
+#
+---
+name: Claude Canvas
+windows:
+  - active_tab_index: 0
+    tabs:
+      - title: Claude Canvas
+        color: Blue
+        layout:
+          split_direction: vertical
+          panes:
+            # Left pane: Your main terminal (Claude Code runs here)
+            - is_focused: true
+            # Right pane: Canvas TUI watching for content
+            - commands:
+                - exec: "npx @claude-mods/canvas-tui --watch"