Browse Source

chore: bump to v2.5.0 — remove /canvas command and canvas-tui package

The canvas command was experimental, Warp-terminal-specific, and unused.
Removing it eliminates the only npm runtime-dep surface in claude-mods,
leaving the repo as markdown + bash only.

Version: 2.4.12 -> 2.5.0
Minor bump (not patch) because removing a documented public API (/canvas)
is a breaking change for anyone who'd integrated it.

Removed:
  - commands/canvas.md (340 lines)
  - canvas-tui/ entire directory (17 source files, 2096-line package-lock.json,
    117-line bundled README, 57-line package.json, tsconfig, etc.)

Manifest changes (.claude-plugin/plugin.json):
  - version: 2.4.12 -> 2.5.0
  - description: "3 commands" -> "2 commands"
  - keywords: drop "canvas", "terminal-ui"
  - components.commands: drop commands/canvas.md

Doc updates:
  - AGENTS.md: "3 commands ... and experimental (/canvas)" -> "2 commands"
  - README.md: removed canvas table row, updated commands-paragraph,
    added v2.5.0 Recent Updates entry covering the removal
  - docs/COMMAND-SKILL-PATTERN.md: tree showing 2 files, dropped
    "Experimental/WIP -> Command (canvas)" row
  - docs/RESERVED-COMMANDS.md: dropped canvas from current-claude-mods list
  - docs/SKILL-SUBAGENT-REFERENCE.md: dropped /canvas row from session
    commands analysis
  - docs/PLAN.md: Commands count 3->2, dropped "Experimental: /canvas"
    line and "/canvas - Experimental (Warp-specific)" footer

This integrates the canvas-teardown work originally done in sibling
branch claude/sad-almeida-20699c by an Opus 4.7 session, applied on
top of v2.4.12 (which already shipped portless-ops, process-compose-ops,
and the summon+fleet-ops manifest catch-up).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
0xDarkMatter 2 weeks ago
parent
commit
e43e60c08f

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

@@ -1,7 +1,7 @@
 {
   "name": "claude-mods",
-  "version": "2.4.12",
-  "description": "Custom commands, skills, and agents for Claude Code - session continuity, 23 expert agents, 75 skills, 3 commands, 6 rules, 4 hooks, 13 output styles, modern CLI tools",
+  "version": "2.5.0",
+  "description": "Custom commands, skills, and agents for Claude Code - session continuity, 23 expert agents, 75 skills, 2 commands, 6 rules, 4 hooks, 13 output styles, modern CLI tools",
   "author": "0xDarkMatter",
   "repository": "https://github.com/0xDarkMatter/claude-mods",
   "license": "MIT",
@@ -12,15 +12,12 @@
     "skills",
     "session-management",
     "cli-tools",
-    "output-styles",
-    "canvas",
-    "terminal-ui"
+    "output-styles"
   ],
   "components": {
     "commands": [
       "commands/sync.md",
-      "commands/save.md",
-      "commands/canvas.md"
+      "commands/save.md"
     ],
     "agents": [
       "agents/astro-expert.md",

+ 1 - 1
AGENTS.md

@@ -4,7 +4,7 @@
 
 This is **claude-mods** - a collection of custom extensions for Claude Code:
 - **23 expert agents** for specialized domains (React, Python, Go, Rust, AWS, git, etc.)
-- **3 commands** for session management (/sync, /save) and experimental features (/canvas)
+- **2 commands** for session management (/sync, /save)
 - **75 skills** for CLI tools, patterns, workflows, and development tasks
 - **13 output styles** for response personality (Vesper, Spartan, Mentor, Executive, Pair, Atlas, Coach, Harbour, Meridian, Noir, Roast, Sage, Scout)
 - **4 hooks** for pre-commit linting, post-edit formatting, dangerous command warnings, and pmail notifications

File diff suppressed because it is too large
+ 3 - 3
README.md


+ 0 - 5
canvas-tui/.gitignore

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

+ 0 - 117
canvas-tui/README.md

@@ -1,117 +0,0 @@
-# @claude-mods/canvas-tui
-
-> **Beta** - Under active development. Features may change.
-
-Terminal canvas for Claude Code - live markdown preview in split panes.
-
-Works with any terminal that supports split panes: **Warp**, **tmux**, **iTerm2**, **Windows Terminal**, and more.
-
-## Installation
-
-### From claude-mods repo (local)
-
-```bash
-cd claude-mods/canvas-tui
-npm link
-```
-
-Then use `canvas-tui` from anywhere on your machine.
-
-### From npm (coming soon)
-
-```bash
-npm install -g @claude-mods/canvas-tui
-```
-
-## 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
-
-# Disable mouse scroll capture (keyboard only)
-canvas-tui --watch --no-mouse
-
-# Show help
-canvas-tui --help
-```
-
-## Terminal Setup
-
-### Warp
-```bash
-# Split pane: Ctrl+Shift+D (Windows/Linux) or Cmd+Shift+D (Mac)
-# Or right-click > Split Pane
-canvas-tui --watch
-```
-
-### tmux
-```bash
-# Split horizontally and run canvas
-tmux split-window -h 'canvas-tui --watch'
-
-# Or split vertically
-tmux split-window -v 'canvas-tui --watch'
-
-# From existing tmux session, split current pane
-# Ctrl+B then % (horizontal) or " (vertical)
-```
-
-### iTerm2
-```bash
-# Split pane: Cmd+D (vertical) or Cmd+Shift+D (horizontal)
-canvas-tui --watch
-```
-
-### Windows Terminal
-```bash
-# Split pane: Alt+Shift+D
-# Or use wt command:
-wt split-pane --horizontal canvas-tui --watch
-```
-
-## Controls
-
-| Key | Action |
-|-----|--------|
-| `Up/Down` | Scroll line by line |
-| `Page Up/Down` | Scroll by page |
-| `g` | Go to top |
-| `G` | Go to bottom |
-| `q` / `Ctrl+C` | Quit |
-| `r` | Refresh |
-| Mouse wheel | Scroll (terminal-dependent) |
-
-**Note:** Mouse wheel scrolling works in iTerm2, Terminal.app, and most xterm-compatible terminals. In Warp, use arrow keys or Warp's native scrollbar instead.
-
-## 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
-
-## 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

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

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

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


+ 0 - 57
canvas-tui/package.json

@@ -1,57 +0,0 @@
-{
-  "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": "./bin/canvas.js",
-    "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": {
-    "@inkjs/ui": "^2.0.0",
-    "chalk": "^5.3.0",
-    "chokidar": "^4.0.3",
-    "cli-markdown": "^3.5.1",
-    "ink": "^5.0.1",
-    "marked": "^15.0.4",
-    "marked-terminal": "^7.2.1",
-    "meow": "^13.2.0",
-    "react": "^18.3.1"
-  },
-  "devDependencies": {
-    "@types/node": "^22.10.5",
-    "@types/react": "^18.3.18",
-    "typescript": "^5.7.2"
-  },
-  "files": [
-    "dist",
-    "bin"
-  ]
-}

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

@@ -1,437 +0,0 @@
-import React, { useState, useEffect, useLayoutEffect } from 'react';
-import { Box, Text, useApp, useInput, useStdout } from 'ink';
-import fs from 'fs';
-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 { useDirectoryFiles } from './hooks/useDirectoryFiles.js';
-import { readMeta, type CanvasMeta } from './lib/ipc.js';
-import { editInExternalEditor } from './lib/editor.js';
-
-interface AppProps {
-  watchPath: string;
-  watchDir: string;
-  enableMouse?: boolean;
-}
-
-// ANSI escape sequences for mouse support
-const ENABLE_MOUSE = '\x1B[?1000h\x1B[?1002h\x1B[?1006h';
-const DISABLE_MOUSE = '\x1B[?1000l\x1B[?1002l\x1B[?1006l';
-
-export const App: React.FC<AppProps> = ({ watchPath, watchDir, enableMouse = false }) => {
-  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);
-  const [totalLines, setTotalLines] = useState(0);
-  const [mouseEnabled, setMouseEnabled] = useState(enableMouse);
-  const [isEditing, setIsEditing] = useState(false);
-
-  // File selector state
-  const [currentFilePath, setCurrentFilePath] = useState(watchPath);
-  const [isDropdownOpen, setIsDropdownOpen] = useState(false);
-  const [isDropdownFocused, setIsDropdownFocused] = useState(false);
-  const [selectedFileIndex, setSelectedFileIndex] = useState(0);
-
-  // Info overlay state
-  const [isInfoOpen, setIsInfoOpen] = useState(false);
-
-  // Terminal dimensions
-  const rows = stdout?.rows || 24;
-  const cols = stdout?.columns || 80;
-  const contentHeight = rows - 4; // Header (2) + Footer (2)
-
-  // Get files in directory
-  const files = useDirectoryFiles(watchDir);
-
-  // File watcher - watch the current file
-  const { content: watchedContent, error } = useFileWatcher(currentFilePath);
-
-  // Enable mouse tracking
-  useLayoutEffect(() => {
-    if (mouseEnabled) {
-      process.stdout.write(ENABLE_MOUSE);
-
-      // Listen for mouse events on stdin
-      const handleData = (data: Buffer) => {
-        const str = data.toString();
-
-        // SGR mouse format: \x1B[<button;x;yM or \x1B[<button;x;ym
-        // Button 64 = scroll up, Button 65 = scroll down
-        const sgrMatch = str.match(/\x1B\[<(\d+);(\d+);(\d+)([Mm])/);
-        if (sgrMatch) {
-          const button = parseInt(sgrMatch[1], 10);
-          if (button === 64) {
-            // Scroll up
-            setScrollOffset(prev => Math.max(0, prev - 3));
-          } else if (button === 65) {
-            // Scroll down
-            setScrollOffset(prev => Math.min(Math.max(0, totalLines - contentHeight), prev + 3));
-          }
-        }
-      };
-
-      process.stdin.on('data', handleData);
-
-      return () => {
-        process.stdout.write(DISABLE_MOUSE);
-        process.stdin.off('data', handleData);
-      };
-    }
-  }, [mouseEnabled, totalLines, contentHeight]);
-
-  // Update content when file changes
-  useEffect(() => {
-    if (watchedContent !== null) {
-      setContent(watchedContent);
-      setSyncStatus('synced');
-      setLastUpdate(new Date());
-      setScrollOffset(0);
-
-      const metaPath = watchDir + '/meta.json';
-      readMeta(metaPath).then(setMeta).catch(() => {});
-    }
-  }, [watchedContent, watchDir]);
-
-  // Info overlay using ANSI escape codes
-  useLayoutEffect(() => {
-    if (!isInfoOpen || !stdout) return;
-
-    // Get file stats
-    let stats: fs.Stats | null = null;
-    try {
-      stats = fs.statSync(currentFilePath);
-    } catch {
-      // File may not exist yet
-    }
-
-    // Calculate content stats
-    const lineCount = content ? content.split('\n').length : 0;
-    const wordCount = content ? content.split(/\s+/).filter(w => w.length > 0).length : 0;
-    const charCount = content ? content.length : 0;
-
-    // Format dates
-    const formatDate = (date: Date) => {
-      return `${date.toLocaleDateString()} ${date.toLocaleTimeString()}`;
-    };
-
-    const created = stats ? formatDate(stats.birthtime) : 'Unknown';
-    const modified = stats ? formatDate(stats.mtime) : 'Unknown';
-    const fileSize = stats ? `${stats.size} bytes` : 'Unknown';
-
-    // Get filename from path
-    const fileName = currentFilePath.split(/[/\\]/).pop() || 'Unknown';
-
-    // Content lines (without border/padding - we'll add those)
-    const contentLines: string[] = [
-      '\x1B[1mFile Metadata\x1B[0m',
-      '',
-      `Filename:   ${fileName}`,
-      `Created:    ${created}`,
-      `Modified:   ${modified}`,
-      `Size:       ${fileSize}`,
-      '',
-      `Lines:      ${lineCount.toLocaleString()}`,
-      `Words:      ${wordCount.toLocaleString()}`,
-      `Characters: ${charCount.toLocaleString()}`,
-      '',
-      '\x1B[2m[i] Close\x1B[0m',
-    ];
-
-    // Calculate inner content width
-    const innerWidth = Math.max(...contentLines.map(l => l.replace(/\x1B\[[0-9;]*m/g, '').length));
-
-    // Build the full panel with border and padding
-    // Horizontal: 2 space padding, Vertical: 1 space padding
-    const boxWidth = innerWidth + 6; // 2 space padding each side inside border, +2 for border chars
-    const totalWidth = boxWidth + 2; // +2 for outer spacing
-
-    const panelLines: string[] = [];
-
-    // Outer top padding
-    panelLines.push(' '.repeat(totalWidth));
-
-    // Top border: space + ┌ + ─ repeated + ┐ + space
-    panelLines.push(` ┌${'─'.repeat(boxWidth - 2)}┐ `);
-
-    // Inner top padding row (1 line vertical padding)
-    panelLines.push(` │${' '.repeat(boxWidth - 2)}│ `);
-
-    // Content rows with 2-space horizontal padding
-    contentLines.forEach(line => {
-      const plainLen = line.replace(/\x1B\[[0-9;]*m/g, '').length;
-      const rightPad = innerWidth - plainLen;
-      panelLines.push(` │  ${line}${' '.repeat(rightPad)}  │ `);
-    });
-
-    // Inner bottom padding row (1 line vertical padding)
-    panelLines.push(` │${' '.repeat(boxWidth - 2)}│ `);
-
-    // Bottom border
-    panelLines.push(` └${'─'.repeat(boxWidth - 2)}┘ `);
-
-    // Outer bottom padding
-    panelLines.push(' '.repeat(totalWidth));
-
-    // Center the panel
-    const panelHeight = panelLines.length;
-    const startRow = Math.floor((rows - panelHeight) / 2);
-    const startCol = Math.floor((cols - totalWidth) / 2);
-
-    // Guard against re-entry when we write our own overlay
-    let isRenderingOverlay = false;
-
-    // Function to render the overlay
-    const renderOverlay = () => {
-      if (isRenderingOverlay) return;
-      isRenderingOverlay = true;
-
-      let output = '\x1B[s'; // Save cursor
-      panelLines.forEach((line, idx) => {
-        const row = startRow + idx;
-        output += `\x1B[${row};${startCol}H${line}`;
-      });
-      output += '\x1B[u'; // Restore cursor
-      originalWrite.call(process.stdout, output);
-
-      isRenderingOverlay = false;
-    };
-
-    // Intercept stdout.write to repaint overlay after Ink renders
-    const originalWrite = process.stdout.write;
-    process.stdout.write = function(chunk: any, encoding?: any, callback?: any) {
-      const result = originalWrite.call(process.stdout, chunk, encoding, callback);
-      if (!isRenderingOverlay) {
-        setImmediate(renderOverlay);
-      }
-      return result;
-    } as typeof process.stdout.write;
-
-    // Initial render
-    renderOverlay();
-
-    // Cleanup: restore stdout.write and clear the panel area
-    return () => {
-      process.stdout.write = originalWrite;
-
-      let clear = '\x1B[s';
-      panelLines.forEach((_, idx) => {
-        const row = startRow + idx;
-        clear += `\x1B[${row};${startCol}H${' '.repeat(totalWidth)}`;
-      });
-      clear += '\x1B[u';
-      originalWrite.call(process.stdout, clear);
-    };
-  }, [isInfoOpen, currentFilePath, content, stdout, rows, cols]);
-
-  // Keyboard input
-  useInput((input, key) => {
-    // Quit
-    if (input === 'q' || (key.ctrl && input === 'c')) {
-      if (mouseEnabled) {
-        process.stdout.write(DISABLE_MOUSE);
-      }
-      exit();
-    }
-
-    // Other files (excluding current) for dropdown navigation
-    const otherFiles = files.filter(f => f.path !== currentFilePath);
-
-    // Tab - toggle file selector focus
-    if (key.tab) {
-      if (isDropdownFocused) {
-        // Close dropdown and unfocus
-        setIsDropdownFocused(false);
-        setIsDropdownOpen(false);
-      } else {
-        // Focus and open dropdown
-        setIsDropdownFocused(true);
-        setIsDropdownOpen(true);
-        // Start at first file
-        setSelectedFileIndex(0);
-      }
-      return;
-    }
-
-    // Escape - close dropdown or info
-    if (key.escape) {
-      if (isInfoOpen) {
-        setIsInfoOpen(false);
-        return;
-      }
-      if (isDropdownOpen) {
-        setIsDropdownOpen(false);
-        setIsDropdownFocused(false);
-        return;
-      }
-    }
-
-    // When dropdown is open, arrow keys navigate files
-    if (isDropdownOpen) {
-      if (key.upArrow) {
-        setSelectedFileIndex(prev => Math.max(0, prev - 1));
-        return;
-      }
-      if (key.downArrow) {
-        setSelectedFileIndex(prev => Math.min(otherFiles.length - 1, prev + 1));
-        return;
-      }
-      if (key.return && otherFiles[selectedFileIndex]) {
-        // Select file and close dropdown
-        setCurrentFilePath(otherFiles[selectedFileIndex].path);
-        setIsDropdownOpen(false);
-        setIsDropdownFocused(false);
-        setScrollOffset(0);
-        return;
-      }
-    }
-
-    // Scrolling (only when dropdown and info are closed)
-    if (!isInfoOpen) {
-      if (key.upArrow) {
-        setScrollOffset(prev => Math.max(0, prev - 1));
-      }
-      if (key.downArrow) {
-        setScrollOffset(prev => Math.min(Math.max(0, totalLines - contentHeight), prev + 1));
-      }
-      if (key.pageUp) {
-        setScrollOffset(prev => Math.max(0, prev - contentHeight));
-      }
-      if (key.pageDown) {
-        setScrollOffset(prev => Math.min(Math.max(0, totalLines - contentHeight), prev + contentHeight));
-      }
-
-      // Home/End and vim-style navigation
-      if (input === 'g' || key.meta && key.upArrow) {
-        setScrollOffset(0);
-      }
-      if (input === 'G' || key.meta && key.downArrow) {
-        setScrollOffset(Math.max(0, totalLines - contentHeight));
-      }
-    }
-
-    // Refresh
-    if (input === 'r') {
-      setSyncStatus('watching');
-    }
-
-    // Toggle mouse capture
-    if (input === 'm') {
-      setMouseEnabled(prev => {
-        const newValue = !prev;
-        if (newValue) {
-          process.stdout.write(ENABLE_MOUSE);
-        } else {
-          process.stdout.write(DISABLE_MOUSE);
-        }
-        return newValue;
-      });
-    }
-
-    // Toggle info overlay
-    if (input === 'i' && !isEditing && !isDropdownOpen) {
-      setIsInfoOpen(prev => !prev);
-    }
-
-    // Edit in external editor
-    if (input === 'e' && !isEditing && content) {
-      setIsEditing(true);
-      // Disable mouse while editing
-      if (mouseEnabled) {
-        process.stdout.write(DISABLE_MOUSE);
-      }
-      editInExternalEditor(currentFilePath)
-        .then(() => {
-          setIsEditing(false);
-          // Re-enable mouse if it was on
-          if (mouseEnabled) {
-            process.stdout.write(ENABLE_MOUSE);
-          }
-        })
-        .catch(() => {
-          setIsEditing(false);
-          if (mouseEnabled) {
-            process.stdout.write(ENABLE_MOUSE);
-          }
-        });
-    }
-  });
-
-  // Status info
-  const currentFileName = currentFilePath.split(/[/\\]/).pop() || 'file';
-
-  // Format timestamp: DD.MM.YYYY HH:MMam (no seconds)
-  const formatTimestamp = (date: Date) => {
-    const day = String(date.getDate()).padStart(2, '0');
-    const month = String(date.getMonth() + 1).padStart(2, '0');
-    const year = date.getFullYear();
-    let hours = date.getHours();
-    const ampm = hours >= 12 ? 'pm' : 'am';
-    hours = hours % 12 || 12;
-    const minutes = String(date.getMinutes()).padStart(2, '0');
-    return `${day}.${month}.${year} ${hours}:${minutes}${ampm}`;
-  };
-
-  const timestampStr = lastUpdate ? formatTimestamp(lastUpdate) : null;
-
-  // Position indicator: Pos: [001-056/168]
-  const pad3 = (n: number) => String(n).padStart(3, '0');
-  const positionStr = totalLines > 0
-    ? `Pos: [${pad3(scrollOffset + 1)}-${pad3(Math.min(scrollOffset + contentHeight, totalLines))}/${totalLines}]`
-    : null;
-
-  const hints = isEditing
-    ? 'Editing...'
-    : isInfoOpen
-    ? '[i] Close'
-    : isDropdownOpen
-    ? '[Tab] Close [↑↓] Nav [Enter] Open'
-    : '[Tab] Files [i] Info [e] Edit [m] Mouse [q] Quit';
-
-  return (
-    <Box flexDirection="column" height={rows}>
-      <Header
-        title="✿ CANVAS"
-        width={cols}
-        files={files}
-        currentFile={currentFilePath}
-        selectedFileIndex={selectedFileIndex}
-        isDropdownOpen={isDropdownOpen}
-        isDropdownFocused={isDropdownFocused}
-      />
-
-      <Box flexGrow={1} flexDirection="column" overflow="hidden">
-        {content ? (
-          <MarkdownView
-            content={content}
-            scrollOffset={scrollOffset}
-            maxHeight={contentHeight}
-            onLineCount={setTotalLines}
-          />
-        ) : (
-          <Box padding={1}>
-            <Text color="gray">
-              {error ? (
-                <Text color="red">{error}</Text>
-              ) : (
-                `Watching ${watchPath}...\n\nUse /canvas write in Claude Code to send content.`
-              )}
-            </Text>
-          </Box>
-        )}
-      </Box>
-
-      <StatusBar
-        status={syncStatus}
-        timestamp={timestampStr}
-        position={positionStr}
-        hints={hints}
-        width={cols}
-      />
-    </Box>
-  );
-};

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

@@ -1,111 +0,0 @@
-import React, { useEffect } from 'react';
-import { Box, Text, useStdout } from 'ink';
-import { FileInfo, truncateFilename } from '../hooks/useDirectoryFiles.js';
-
-interface HeaderProps {
-  title: string;
-  width: number;
-  // File selector props
-  files: FileInfo[];
-  currentFile: string;
-  selectedFileIndex: number;
-  isDropdownOpen: boolean;
-  isDropdownFocused: boolean;
-}
-
-const MAX_DISPLAY_LENGTH = 30;
-
-export const Header: React.FC<HeaderProps> = ({
-  title,
-  width,
-  files,
-  currentFile,
-  selectedFileIndex,
-  isDropdownOpen,
-  isDropdownFocused,
-}) => {
-  const { stdout } = useStdout();
-  const leftContent = ` ${title} `;
-
-  // Get current filename from path
-  const currentFileName = currentFile.split(/[/\\]/).pop() || 'No file';
-  const displayName = truncateFilename(currentFileName, MAX_DISPLAY_LENGTH);
-
-  // Filter out current file for dropdown
-  const otherFiles = files.filter(f => f.path !== currentFile);
-
-  // Render dropdown as overlay using ANSI escape codes
-  useEffect(() => {
-    if (!isDropdownOpen || !stdout) return;
-
-    const cols = stdout.columns || 80;
-    const startRow = 3; // Below header border line
-
-    // Build dropdown content (right-aligned)
-    const lines: string[] = [];
-
-    if (otherFiles.length === 0) {
-      lines.push('(no other files)');
-    } else {
-      otherFiles.slice(0, 6).forEach((file, index) => {
-        const isSelected = index === selectedFileIndex;
-        const name = truncateFilename(file.name, MAX_DISPLAY_LENGTH);
-        if (isSelected) {
-          // Reversed video for selected item
-          lines.push(`\x1B[7m ${name} \x1B[0m`);
-        } else {
-          // Dim text for unselected
-          lines.push(`\x1B[2m ${name} \x1B[0m`);
-        }
-      });
-
-      if (otherFiles.length > 6) {
-        lines.push(`\x1B[2m +${otherFiles.length - 6} more \x1B[0m`);
-      }
-    }
-
-    // Find max line length for right-alignment
-    const maxLen = Math.max(...lines.map(l => l.replace(/\x1B\[[0-9;]*m/g, '').length));
-
-    // Save cursor, render dropdown at absolute position (right-aligned), restore cursor
-    let output = '\x1B[s'; // Save cursor position
-
-    lines.forEach((line, idx) => {
-      const row = startRow + idx;
-      const plainLen = line.replace(/\x1B\[[0-9;]*m/g, '').length;
-      const padding = maxLen - plainLen;
-      const startCol = cols - maxLen - 1; // Right margin of 1 (matches header padding)
-      output += `\x1B[${row};${startCol}H${' '.repeat(padding)}${line}`;
-    });
-
-    output += '\x1B[u'; // Restore cursor position
-
-    process.stdout.write(output);
-
-    // Cleanup: clear the dropdown area when closing
-    return () => {
-      let clear = '\x1B[s';
-      lines.forEach((_, idx) => {
-        const row = startRow + idx;
-        const startCol = cols - maxLen - 1;
-        clear += `\x1B[${row};${startCol}H${' '.repeat(maxLen + 1)}`;
-      });
-      clear += '\x1B[u';
-      process.stdout.write(clear);
-    };
-  }, [isDropdownOpen, selectedFileIndex, otherFiles, stdout]);
-
-  return (
-    <Box flexDirection="column">
-      {/* Header bar - always single line with border */}
-      <Box borderStyle="single" borderBottom={true} borderTop={false} borderLeft={false} borderRight={false} borderDimColor>
-        <Box width={width}>
-          <Text bold color="blue">{leftContent}</Text>
-          <Box flexGrow={1} />
-          <Text color="white" dimColor>{displayName} {isDropdownOpen ? '▲' : '▼'}</Text>
-          <Text> </Text>
-        </Box>
-      </Box>
-    </Box>
-  );
-};

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

@@ -1,56 +0,0 @@
-import React, { useMemo, useEffect } from 'react';
-import { Box, Text, useStdout } from 'ink';
-import { useMarkdown } from '../hooks/useMarkdown.js';
-
-interface MarkdownViewProps {
-  content: string;
-  scrollOffset: number;
-  maxHeight: number;
-  onLineCount?: (count: number) => void;
-}
-
-export const MarkdownView: React.FC<MarkdownViewProps> = ({
-  content,
-  scrollOffset,
-  maxHeight,
-  onLineCount
-}) => {
-  const { stdout } = useStdout();
-  const width = stdout?.columns || 80;
-  const rendered = useMarkdown(content, width);
-
-  // Split into lines for scrolling
-  const lines = useMemo(() => {
-    return rendered.split('\n');
-  }, [rendered]);
-
-  // Calculate visible window
-  const totalLines = lines.length;
-
-  // Report line count to parent for scroll bounds
-  useEffect(() => {
-    onLineCount?.(totalLines);
-  }, [totalLines, onLineCount]);
-  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} paddingY={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>
-  );
-};

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

@@ -1,60 +0,0 @@
-import React from 'react';
-import { Box, Text } from 'ink';
-
-interface StatusBarProps {
-  status: 'waiting' | 'synced' | 'watching';
-  timestamp: string | null;
-  position: string | null;
-  hints: string;
-  width: number;
-}
-
-export const StatusBar: React.FC<StatusBarProps> = ({ status, timestamp, position, hints, width }) => {
-  const statusColors: Record<string, string> = {
-    waiting: 'yellow',
-    synced: 'green',
-    watching: 'cyan'
-  };
-
-  const statusIcons: Record<string, string> = {
-    waiting: '...',
-    synced: '***',
-    watching: '...'
-  };
-
-  const statusLabels: Record<string, string> = {
-    waiting: 'Waiting',
-    synced: 'Synced',
-    watching: 'Watching'
-  };
-
-  const statusColor = statusColors[status] || 'white';
-  const statusIcon = statusIcons[status] || ' ';
-  const statusLabel = statusLabels[status] || '';
-
-  // Build left side: [icon] Synced: 09.01.2026 10:18am | Pos: [001-056/168]
-  const leftParts = [`${statusLabel}:`];
-  if (timestamp) leftParts.push(timestamp);
-  if (position) leftParts.push('|', position);
-
-  return (
-    <Box
-      borderStyle="single"
-      borderTop={true}
-      borderBottom={false}
-      borderLeft={false}
-      borderRight={false}
-      borderDimColor
-      flexDirection="column"
-    >
-      <Box width={width} justifyContent="space-between">
-        <Box>
-          <Text color="white" dimColor>[</Text>
-          <Text color={statusColor}>{statusIcon}</Text>
-          <Text color="white" dimColor>] {leftParts.join(' ')}</Text>
-        </Box>
-        <Text color="white" dimColor>{hints}</Text>
-      </Box>
-    </Box>
-  );
-};

+ 0 - 97
canvas-tui/src/hooks/useDirectoryFiles.ts

@@ -1,97 +0,0 @@
-import { useState, useEffect } from 'react';
-import fs from 'fs';
-import path from 'path';
-
-export interface FileInfo {
-  name: string;
-  path: string;
-  mtime: Date;
-}
-
-/**
- * Scans a directory's 'drafts' subdirectory for markdown and text files.
- * Returns sorted list (most recently modified first).
- */
-export function useDirectoryFiles(dirPath: string): FileInfo[] {
-  const [files, setFiles] = useState<FileInfo[]>([]);
-
-  useEffect(() => {
-    const scanDirectory = () => {
-      try {
-        const draftsDir = path.join(dirPath, 'drafts');
-
-        // Create drafts dir if it doesn't exist
-        if (!fs.existsSync(draftsDir)) {
-          fs.mkdirSync(draftsDir, { recursive: true });
-        }
-
-        const entries = fs.readdirSync(draftsDir, { withFileTypes: true });
-        const fileInfos: FileInfo[] = [];
-
-        for (const entry of entries) {
-          if (!entry.isFile()) continue;
-
-          const ext = path.extname(entry.name).toLowerCase();
-          if (ext !== '.md' && ext !== '.txt') continue;
-
-          const filePath = path.join(draftsDir, entry.name);
-          const stats = fs.statSync(filePath);
-
-          fileInfos.push({
-            name: entry.name,
-            path: filePath,
-            mtime: stats.mtime,
-          });
-        }
-
-        // Sort by modified time (most recent first)
-        fileInfos.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
-        setFiles(fileInfos);
-      } catch {
-        setFiles([]);
-      }
-    };
-
-    scanDirectory();
-    const interval = setInterval(scanDirectory, 2000);
-    return () => clearInterval(interval);
-  }, [dirPath]);
-
-  return files;
-}
-
-/**
- * Format relative time for display (e.g., "now", "1h", "3d")
- */
-export function formatRelativeTime(date: Date): string {
-  const now = Date.now();
-  const diff = now - date.getTime();
-
-  const seconds = Math.floor(diff / 1000);
-  const minutes = Math.floor(seconds / 60);
-  const hours = Math.floor(minutes / 60);
-  const days = Math.floor(hours / 24);
-
-  if (seconds < 60) return 'now';
-  if (minutes < 60) return `${minutes}m`;
-  if (hours < 24) return `${hours}h`;
-  if (days < 30) return `${days}d`;
-  return `${Math.floor(days / 30)}mo`;
-}
-
-/**
- * Truncate filename to fit width, preserving extension
- */
-export function truncateFilename(name: string, maxLength: number): string {
-  if (name.length <= maxLength) return name;
-
-  const ext = path.extname(name);
-  const base = path.basename(name, ext);
-  const availableLength = maxLength - ext.length - 3; // -3 for "..."
-
-  if (availableLength <= 0) {
-    return name.slice(0, maxLength - 3) + '...';
-  }
-
-  return base.slice(0, availableLength) + '...' + ext;
-}

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

@@ -1,78 +0,0 @@
-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 };
-}

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

@@ -1,149 +0,0 @@
-import { useMemo } from 'react';
-import chalk from 'chalk';
-
-// @ts-ignore - no types for cli-markdown
-import markdown from 'cli-markdown';
-
-export function useMarkdown(content: string, width: number = 80): string {
-  return useMemo(() => {
-    if (!content) return '';
-
-    try {
-      // Normalize line endings (Windows CRLF -> LF)
-      const normalizedContent = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
-
-      // Helper to render inline markdown formatting
-      const renderInline = (text: string): string => {
-        let result = text;
-        // Bold
-        result = result.replace(/\*\*([^*]+)\*\*/g, (_, c) => chalk.bold(c));
-        // Italic
-        result = result.replace(/\*([^*]+)\*/g, (_, c) => chalk.italic(c));
-        // Code
-        result = result.replace(/`([^`]+)`/g, (_, c) => chalk.magenta(c));
-        return result;
-      };
-
-      // Process content in sections - handle numbered lists ourselves, delegate rest to cli-markdown
-      const lines = normalizedContent.split('\n');
-      const outputSections: string[] = [];
-      let currentSection: string[] = [];
-      let inNumberedList = false;
-      let numberedListItems: string[] = [];
-
-      const flushSection = () => {
-        if (currentSection.length > 0) {
-          // Process non-list section with cli-markdown
-          const sectionContent = currentSection.join('\n');
-          if (sectionContent.trim()) {
-            outputSections.push(markdown(sectionContent));
-          }
-          currentSection = [];
-        }
-      };
-
-      const flushNumberedList = () => {
-        if (numberedListItems.length > 0) {
-          // Render numbered list ourselves (blank line before for spacing)
-          const listOutput = numberedListItems.map((item, i) =>
-            `  ${i + 1}. ${renderInline(item)}`
-          ).join('\n');
-          outputSections.push('\n' + listOutput);
-          numberedListItems = [];
-        }
-        inNumberedList = false;
-      };
-
-      for (const rawLine of lines) {
-        const line = rawLine.trimEnd();
-        const numMatch = line.match(/^(\s*)(\d+)\.\s+(.+)$/);
-
-        if (numMatch) {
-          if (!inNumberedList) {
-            flushSection(); // Flush any pending non-list content
-            inNumberedList = true;
-          }
-          numberedListItems.push(numMatch[3]);
-        } else {
-          if (inNumberedList) {
-            flushNumberedList(); // Flush the numbered list
-          }
-          currentSection.push(line);
-        }
-      }
-
-      // Flush any remaining content
-      if (inNumberedList) {
-        flushNumberedList();
-      }
-      flushSection();
-
-      let rendered = outputSections.join('\n');
-
-      // Normalize blank lines - collapse multiple consecutive blank lines into one
-      rendered = rendered.replace(/\n{3,}/g, '\n\n');
-
-      // Post-process headings - cli-markdown doesn't style them properly
-      // Strip ANSI codes for matching, then apply our own styles
-      const stripAnsi = (str: string) => str.replace(/\x1B\[[0-9;]*m/g, '');
-
-      const outputLines = rendered.split('\n');
-      const result: string[] = [];
-      let wasInBulletList = false;
-
-      for (const line of outputLines) {
-        const plain = stripAnsi(line);
-        const isBulletStart = /^\s*[•\-\*]\s/.test(plain) || /^\s*\d+\.\s/.test(plain);
-        const isHeading = /^#{1,6}\s/.test(plain);
-        const isBlankLine = plain.trim() === '';
-
-        // Track if we're in a bullet list context
-        if (isBulletStart) {
-          wasInBulletList = true;
-        }
-
-        // Add blank line when transitioning from bullet list to a heading
-        if (wasInBulletList && isHeading) {
-          result.push('');
-          wasInBulletList = false;
-        }
-
-        // Reset bullet context on blank lines (list has ended)
-        if (isBlankLine) {
-          wasInBulletList = false;
-        }
-
-        // H1: # Heading
-        const h1Match = plain.match(/^# (.+)$/);
-        if (h1Match) {
-          result.push(chalk.bold.blue.underline(h1Match[1]));
-          continue;
-        }
-        // H2: ## Heading
-        const h2Match = plain.match(/^## (.+)$/);
-        if (h2Match) {
-          result.push(chalk.bold.blue(h2Match[1]));
-          continue;
-        }
-        // H3: ### Heading
-        const h3Match = plain.match(/^### (.+)$/);
-        if (h3Match) {
-          result.push(chalk.bold.cyan(h3Match[1]));
-          continue;
-        }
-        // H4+: #### Heading
-        const h4Match = plain.match(/^#{4,} (.+)$/);
-        if (h4Match) {
-          result.push(chalk.bold(h4Match[1]));
-          continue;
-        }
-        result.push(line);
-      }
-
-      return result.join('\n');
-    } catch (err) {
-      console.error('Markdown render error:', err);
-      return content;
-    }
-  }, [content, width]);
-}

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

@@ -1,118 +0,0 @@
-#!/usr/bin/env node
-import React from 'react';
-import fs from 'fs';
-import path from 'path';
-import { render } from 'ink';
-import meow from 'meow';
-import { App } from './app.js';
-
-const cli = meow(`
-  Usage
-    $ canvas [options]
-
-  Options
-    --watch, -w       Watch directory (default: .claude/canvas)
-    --file, -f        Specific file to watch
-    --mouse, -m       Enable mouse wheel scrolling (default: off)
-    --help            Show this help
-    --version         Show version
-
-  Examples
-    $ canvas                     # Just run it - defaults work
-    $ canvas --file ./draft.md   # Watch specific file
-
-  Controls
-    ↑↓ / Mouse wheel    Scroll content
-    g / G               Top / bottom
-    Tab                 Open file selector
-    e                   Edit in external editor
-    m                   Toggle mouse capture
-    q                   Quit
-
-  Terminal Setup
-    Warp:     Ctrl+Shift+D to split, run 'canvas' in new pane
-    tmux:     tmux split-window -h 'canvas'
-    iTerm2:   Cmd+D to split, run 'canvas' in new pane
-`, {
-  importMeta: import.meta,
-  flags: {
-    watch: {
-      type: 'string',
-      shortFlag: 'w',
-      default: '.claude/canvas'
-    },
-    file: {
-      type: 'string',
-      shortFlag: 'f'
-    },
-    mouse: {
-      type: 'boolean',
-      shortFlag: 'm',
-      default: false
-    }
-  }
-});
-
-// Find canvas directory by searching up from CWD
-function findCanvasDir(startDir: string = process.cwd()): string | null {
-  let current = startDir;
-  const root = path.parse(current).root;
-
-  while (current !== root) {
-    const candidate = path.join(current, '.claude', 'canvas');
-    if (fs.existsSync(candidate)) {
-      return candidate;
-    }
-    current = path.dirname(current);
-  }
-  return null;
-}
-
-// Determine watch directory - find it automatically or use explicit path
-function getWatchDir(explicitPath: string): string {
-  // If user specified absolute path, use it
-  if (path.isAbsolute(explicitPath)) {
-    return explicitPath;
-  }
-
-  // Try to find .claude/canvas by searching up
-  const found = findCanvasDir();
-  if (found) {
-    return found;
-  }
-
-  // Fall back to relative path from CWD
-  return path.resolve(explicitPath);
-}
-
-// Determine initial file to watch
-function getInitialFile(watchDir: string, specificFile?: string): string {
-  if (specificFile) return path.resolve(specificFile);
-
-  // Check drafts directory first
-  const draftsDir = path.join(watchDir, 'drafts');
-  try {
-    if (fs.existsSync(draftsDir)) {
-      const files = fs.readdirSync(draftsDir)
-        .filter(f => f.endsWith('.md') || f.endsWith('.txt'))
-        .sort((a, b) => {
-          const statA = fs.statSync(path.join(draftsDir, a));
-          const statB = fs.statSync(path.join(draftsDir, b));
-          return statB.mtime.getTime() - statA.mtime.getTime();
-        });
-      if (files.length > 0) {
-        return path.join(draftsDir, files[0]);
-      }
-    }
-  } catch {
-    // Fall through to default
-  }
-
-  // Fallback to content.md in watch dir
-  return path.join(watchDir, 'content.md');
-}
-
-const watchDir = getWatchDir(cli.flags.watch);
-const watchPath = getInitialFile(watchDir, cli.flags.file);
-
-render(<App watchPath={watchPath} watchDir={watchDir} enableMouse={cli.flags.mouse} />);

+ 0 - 51
canvas-tui/src/lib/editor.ts

@@ -1,51 +0,0 @@
-import { spawn } from 'child_process';
-import path from 'path';
-
-/**
- * Opens the file in the user's preferred editor and waits for them to close it.
- * Checks $VISUAL, $EDITOR, then falls back to platform defaults.
- */
-export async function editInExternalEditor(filePath: string): Promise<void> {
-  const { cmd, args } = getEditor();
-  const absolutePath = path.resolve(filePath);
-
-  return new Promise((resolve, reject) => {
-    const child = spawn(cmd, [...args, absolutePath], {
-      stdio: 'inherit',
-      shell: true,
-    });
-
-    child.on('error', (err) => {
-      reject(new Error(`Failed to open editor: ${err.message}`));
-    });
-
-    child.on('close', (code) => {
-      if (code === 0) {
-        resolve();
-      } else {
-        reject(new Error(`Editor exited with code ${code}`));
-      }
-    });
-  });
-}
-
-/**
- * Get the editor/opener command to use.
- * Priority: $VISUAL > $EDITOR > platform default (opens with associated app)
- */
-function getEditor(): { cmd: string; args: string[] } {
-  if (process.env.VISUAL) return { cmd: process.env.VISUAL, args: [] };
-  if (process.env.EDITOR) return { cmd: process.env.EDITOR, args: [] };
-
-  // Platform defaults - open with associated application
-  if (process.platform === 'win32') {
-    // 'start' opens with default app, '' is window title, /wait makes it blocking
-    return { cmd: 'start', args: ['""', '/wait'] };
-  }
-  if (process.platform === 'darwin') {
-    // macOS: open -W waits for app to close
-    return { cmd: 'open', args: ['-W'] };
-  }
-  // Linux: xdg-open (doesn't wait, but best we can do)
-  return { cmd: 'xdg-open', args: [] };
-}

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

@@ -1,105 +0,0 @@
-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, '');
-  }
-}

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

@@ -1,97 +0,0 @@
-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:
-CC:
-BCC:
-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
-  }));
-}

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

@@ -1,31 +0,0 @@
-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;
-}

+ 0 - 22
canvas-tui/tsconfig.json

@@ -1,22 +0,0 @@
-{
-  "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"]
-}

+ 0 - 340
commands/canvas.md

@@ -1,340 +0,0 @@
----
-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."
-experimental: true
----
-
-# Canvas - Terminal Content Drafting
-
-> ⚠️ **EXPERIMENTAL** - This feature is under active development. APIs may change. Requires Warp terminal for best experience.
-
-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:
-1. Press Cmd+Shift+D (or Ctrl+Shift+D on Windows) to split pane
-2. In the new pane, run: canvas-tui --watch
-```
-
-### 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):
-  cd claude-mods/canvas-tui && npm link
-
-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: "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
-# Link globally from claude-mods repo
-cd claude-mods/canvas-tui && npm link
-
-# Then use from anywhere
-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

+ 1 - 3
docs/COMMAND-SKILL-PATTERN.md

@@ -16,10 +16,9 @@ Skills get slash-hint discovery via trigger keywords in their description and lo
 Most functionality lives in **skills**, not commands. Only session management and experimental features remain as commands.
 
 ```
-commands/           # Minimal (3 files)
+commands/           # Minimal (2 files)
   sync.md           # Session bootstrap
   save.md           # Session persistence
-  canvas.md         # Experimental TUI
 
 skills/             # Everything else (38 directories)
   explain/
@@ -81,7 +80,6 @@ skills/testgen/
 | Scenario | Use |
 |----------|-----|
 | Session management | Command (sync, save) |
-| Experimental/WIP | Command (canvas) |
 | Everything else | Skill |
 
 ## When to Use Skills

+ 2 - 4
docs/PLAN.md

@@ -14,7 +14,7 @@
 |-----------|-------|-------|
 | Agents | 23 | Domain experts + git-agent background worker |
 | Skills | 64 | Operational skills, CLI tools, workflows, dev tasks |
-| Commands | 3 | Session management (sync, save) + experimental (canvas) |
+| Commands | 2 | Session management (sync, save) |
 | Rules | 5 | CLI tools, thinking, commit style, naming, skill-agent-updates |
 | Output Styles | 4 | Vesper, Spartan, Mentor, Executive |
 | Hooks | 3 | pre-commit-lint, post-edit-format, dangerous-cmd-warn |
@@ -45,9 +45,8 @@
 - [x] Patterns: REST, SQL, security, testing, tailwind
 - [x] Development: explain, spawn, atomise, setperms, introspect, review, testgen
 
-### Commands (3)
+### Commands (2)
 - [x] Session: `/save`, `/sync`
-- [x] Experimental: `/canvas`
 
 ### Documentation
 - [x] ARCHITECTURE.md - Extension system guide with authority levels
@@ -162,7 +161,6 @@ Most commands have been converted to skills for better discovery and on-demand l
 **Remaining as commands:**
 - `/sync` - Session bootstrap (paired with /save)
 - `/save` - Session persistence (paired with /sync)
-- `/canvas` - Experimental (Warp-specific)
 
 ---
 

+ 1 - 1
docs/RESERVED-COMMANDS.md

@@ -47,7 +47,7 @@ Also avoid:
 Current claude-mods skills/commands (verified no conflicts):
 
 **Commands:**
-- atomise, canvas, explain, introspect, save, setperms, spawn, sync
+- atomise, explain, introspect, save, setperms, spawn, sync
 
 **Skills:**
 - review, testgen, code-stats, doc-scanner, file-search, find-replace, git-ops, tool-discovery, task-runner, python-env, structural-search, data-processing, markitdown, etc.

+ 0 - 1
docs/SKILL-SUBAGENT-REFERENCE.md

@@ -111,6 +111,5 @@ hooks:
 |---------|---------|-----------|
 | `/sync` | Main | Must restore session state (tasks, context) |
 | `/save` | Main | Must access current tasks via TaskList |
-| `/canvas` | Main | Interactive TUI requires real-time feedback |
 
 These MUST run in main context - subagent isolation would break their core functionality.