Browse Source

feat(canvas): Add mouse wheel scrolling and theme fixes

- Add mouse wheel scroll support with SGR extended mouse mode
- Make in-app scrolling default (--no-mouse to disable)
- Fix theme compatibility: use chalk.reset for body text
- Add onLineCount callback for accurate scroll bounds
- Format email template headers as code block
- Add canvas session directory to gitignore

🤖 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
44d6ed933b

+ 1 - 0
.gitignore

@@ -10,6 +10,7 @@
 .claude/claude-state.json
 .claude/claude-state.json
 .claude/claude-progress.md
 .claude/claude-progress.md
 .claude/sync-cache.json
 .claude/sync-cache.json
+.claude/canvas/
 
 
 # Backup files
 # Backup files
 *.bak
 *.bak

+ 48 - 14
canvas-tui/README.md

@@ -2,6 +2,8 @@
 
 
 Terminal canvas for Claude Code - live markdown preview in split panes.
 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
 ## Installation
 
 
 ```bash
 ```bash
@@ -26,19 +28,65 @@ canvas-tui --watch ./my-canvas
 # Watch specific file
 # Watch specific file
 canvas-tui --file ./draft.md
 canvas-tui --file ./draft.md
 
 
+# Enable in-app scrolling (arrow keys instead of terminal scroll)
+canvas-tui --watch --scroll
+
 # Show help
 # Show help
 canvas-tui --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
+```
+
 ## Keyboard Shortcuts
 ## Keyboard Shortcuts
 
 
+**Default mode (terminal scroll):**
+
 | Key | Action |
 | Key | Action |
 |-----|--------|
 |-----|--------|
 | `q` | Quit |
 | `q` | Quit |
 | `Ctrl+C` | Quit |
 | `Ctrl+C` | Quit |
+| `r` | Refresh |
+
+**With `--scroll` flag (in-app scroll):**
+
+| Key | Action |
+|-----|--------|
 | `Up/Down` | Scroll |
 | `Up/Down` | Scroll |
 | `g` | Go to top |
 | `g` | Go to top |
 | `G` | Go to bottom |
 | `G` | Go to bottom |
+| `q` | Quit |
 | `r` | Refresh |
 | `r` | Refresh |
 
 
 ## Integration with Claude Code
 ## Integration with Claude Code
@@ -50,20 +98,6 @@ This TUI works with the `/canvas` command in Claude Code:
 3. Run `canvas-tui --watch` in the split pane
 3. Run `canvas-tui --watch` in the split pane
 4. Claude writes content, you see live preview
 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
 ## File Structure
 
 
 ```
 ```

+ 75 - 25
canvas-tui/src/app.tsx

@@ -1,4 +1,4 @@
-import React, { useState, useEffect } from 'react';
+import React, { useState, useEffect, useLayoutEffect } from 'react';
 import { Box, Text, useApp, useInput, useStdout } from 'ink';
 import { Box, Text, useApp, useInput, useStdout } from 'ink';
 import { Header } from './components/Header.js';
 import { Header } from './components/Header.js';
 import { MarkdownView } from './components/MarkdownView.js';
 import { MarkdownView } from './components/MarkdownView.js';
@@ -9,9 +9,14 @@ import { readMeta, type CanvasMeta } from './lib/ipc.js';
 interface AppProps {
 interface AppProps {
   watchPath: string;
   watchPath: string;
   watchDir: string;
   watchDir: string;
+  enableMouse?: boolean;
 }
 }
 
 
-export const App: React.FC<AppProps> = ({ watchPath, watchDir }) => {
+// 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 = true }) => {
   const { exit } = useApp();
   const { exit } = useApp();
   const { stdout } = useStdout();
   const { stdout } = useStdout();
   const [content, setContent] = useState<string>('');
   const [content, setContent] = useState<string>('');
@@ -19,22 +24,57 @@ export const App: React.FC<AppProps> = ({ watchPath, watchDir }) => {
   const [syncStatus, setSyncStatus] = useState<'waiting' | 'synced' | 'watching'>('waiting');
   const [syncStatus, setSyncStatus] = useState<'waiting' | 'synced' | 'watching'>('waiting');
   const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
   const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
   const [scrollOffset, setScrollOffset] = useState(0);
   const [scrollOffset, setScrollOffset] = useState(0);
+  const [totalLines, setTotalLines] = useState(0);
 
 
   // Terminal dimensions
   // Terminal dimensions
   const rows = stdout?.rows || 24;
   const rows = stdout?.rows || 24;
   const cols = stdout?.columns || 80;
   const cols = stdout?.columns || 80;
+  const contentHeight = rows - 4; // Header (2) + Footer (2)
 
 
   // File watcher
   // File watcher
   const { content: watchedContent, error } = useFileWatcher(watchPath);
   const { content: watchedContent, error } = useFileWatcher(watchPath);
 
 
+  // Enable mouse tracking
+  useLayoutEffect(() => {
+    if (enableMouse) {
+      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);
+      };
+    }
+  }, [enableMouse, totalLines, contentHeight]);
+
   // Update content when file changes
   // Update content when file changes
   useEffect(() => {
   useEffect(() => {
     if (watchedContent !== null) {
     if (watchedContent !== null) {
       setContent(watchedContent);
       setContent(watchedContent);
       setSyncStatus('synced');
       setSyncStatus('synced');
       setLastUpdate(new Date());
       setLastUpdate(new Date());
+      setScrollOffset(0);
 
 
-      // Also read meta file
       const metaPath = watchDir + '/meta.json';
       const metaPath = watchDir + '/meta.json';
       readMeta(metaPath).then(setMeta).catch(() => {});
       readMeta(metaPath).then(setMeta).catch(() => {});
     }
     }
@@ -43,51 +83,61 @@ export const App: React.FC<AppProps> = ({ watchPath, watchDir }) => {
   // Keyboard input
   // Keyboard input
   useInput((input, key) => {
   useInput((input, key) => {
     if (input === 'q' || (key.ctrl && input === 'c')) {
     if (input === 'q' || (key.ctrl && input === 'c')) {
+      if (enableMouse) {
+        process.stdout.write(DISABLE_MOUSE);
+      }
       exit();
       exit();
     }
     }
+
     if (key.upArrow) {
     if (key.upArrow) {
       setScrollOffset(prev => Math.max(0, prev - 1));
       setScrollOffset(prev => Math.max(0, prev - 1));
     }
     }
     if (key.downArrow) {
     if (key.downArrow) {
-      setScrollOffset(prev => prev + 1);
+      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));
     }
     }
     if (input === 'g') {
     if (input === 'g') {
-      setScrollOffset(0); // Go to top
+      setScrollOffset(0);
     }
     }
     if (input === 'G') {
     if (input === 'G') {
-      // Go to bottom - handled in MarkdownView
-      setScrollOffset(999999);
+      setScrollOffset(Math.max(0, totalLines - contentHeight));
     }
     }
     if (input === 'r') {
     if (input === 'r') {
-      // Force refresh - re-read file
       setSyncStatus('watching');
       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()}`;
-  }
+  // Status message
+  const statusMessage = error
+    ? `Error: ${error}`
+    : syncStatus === 'waiting'
+    ? `Waiting for ${watchPath}...`
+    : `Updated: ${lastUpdate?.toLocaleTimeString() || ''}`;
+
+  const scrollHint = totalLines > contentHeight
+    ? ` | ${scrollOffset + 1}-${Math.min(scrollOffset + contentHeight, totalLines)}/${totalLines}`
+    : '';
+
+  const hints = enableMouse
+    ? `q: quit | scroll: arrows/mouse${scrollHint}`
+    : `q: quit | scroll: arrows${scrollHint}`;
 
 
   return (
   return (
     <Box flexDirection="column" height={rows}>
     <Box flexDirection="column" height={rows}>
-      <Header
-        title="Canvas"
-        contentType={meta?.contentType || 'doc'}
-        width={cols}
-      />
+      <Header title="Canvas" contentType={meta?.contentType || 'doc'} width={cols} />
 
 
       <Box flexGrow={1} flexDirection="column" overflow="hidden">
       <Box flexGrow={1} flexDirection="column" overflow="hidden">
         {content ? (
         {content ? (
           <MarkdownView
           <MarkdownView
             content={content}
             content={content}
             scrollOffset={scrollOffset}
             scrollOffset={scrollOffset}
-            maxHeight={rows - 4}
+            maxHeight={contentHeight}
+            onLineCount={setTotalLines}
           />
           />
         ) : (
         ) : (
           <Box padding={1}>
           <Box padding={1}>
@@ -95,7 +145,7 @@ export const App: React.FC<AppProps> = ({ watchPath, watchDir }) => {
               {error ? (
               {error ? (
                 <Text color="red">{error}</Text>
                 <Text color="red">{error}</Text>
               ) : (
               ) : (
-                `Watching ${watchPath} for changes...\n\nUse /canvas write in Claude Code to send content here.`
+                `Watching ${watchPath}...\n\nUse /canvas write in Claude Code to send content.`
               )}
               )}
             </Text>
             </Text>
           </Box>
           </Box>
@@ -105,7 +155,7 @@ export const App: React.FC<AppProps> = ({ watchPath, watchDir }) => {
       <StatusBar
       <StatusBar
         status={syncStatus}
         status={syncStatus}
         message={statusMessage}
         message={statusMessage}
-        hints="q: quit | arrows: scroll | r: refresh"
+        hints={hints}
         width={cols}
         width={cols}
       />
       />
     </Box>
     </Box>

+ 9 - 2
canvas-tui/src/components/MarkdownView.tsx

@@ -1,4 +1,4 @@
-import React, { useMemo } from 'react';
+import React, { useMemo, useEffect } from 'react';
 import { Box, Text } from 'ink';
 import { Box, Text } from 'ink';
 import { useMarkdown } from '../hooks/useMarkdown.js';
 import { useMarkdown } from '../hooks/useMarkdown.js';
 
 
@@ -6,12 +6,14 @@ interface MarkdownViewProps {
   content: string;
   content: string;
   scrollOffset: number;
   scrollOffset: number;
   maxHeight: number;
   maxHeight: number;
+  onLineCount?: (count: number) => void;
 }
 }
 
 
 export const MarkdownView: React.FC<MarkdownViewProps> = ({
 export const MarkdownView: React.FC<MarkdownViewProps> = ({
   content,
   content,
   scrollOffset,
   scrollOffset,
-  maxHeight
+  maxHeight,
+  onLineCount
 }) => {
 }) => {
   const rendered = useMarkdown(content);
   const rendered = useMarkdown(content);
 
 
@@ -22,6 +24,11 @@ export const MarkdownView: React.FC<MarkdownViewProps> = ({
 
 
   // Calculate visible window
   // Calculate visible window
   const totalLines = lines.length;
   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 clampedOffset = Math.min(scrollOffset, Math.max(0, totalLines - maxHeight));
   const visibleLines = lines.slice(clampedOffset, clampedOffset + maxHeight);
   const visibleLines = lines.slice(clampedOffset, clampedOffset + maxHeight);
 
 

+ 7 - 6
canvas-tui/src/hooks/useMarkdown.ts

@@ -4,23 +4,24 @@ import { markedTerminal } from 'marked-terminal';
 import chalk from 'chalk';
 import chalk from 'chalk';
 
 
 // Configure marked with terminal renderer
 // Configure marked with terminal renderer
+// Using colors that work on both light and dark backgrounds
 marked.use(
 marked.use(
   markedTerminal({
   markedTerminal({
-    // Colors for different elements
+    // Colors for different elements - avoiding white/black for cross-theme support
     code: chalk.cyan,
     code: chalk.cyan,
     blockquote: chalk.gray.italic,
     blockquote: chalk.gray.italic,
     html: chalk.gray,
     html: chalk.gray,
     heading: chalk.bold.blue,
     heading: chalk.bold.blue,
     firstHeading: chalk.bold.blue.underline,
     firstHeading: chalk.bold.blue.underline,
     hr: chalk.gray,
     hr: chalk.gray,
-    listitem: chalk.white,
+    listitem: chalk.reset,  // Use default terminal color
     list: (body: string) => body,
     list: (body: string) => body,
-    table: chalk.white,
-    paragraph: chalk.white,
+    table: chalk.reset,
+    paragraph: chalk.reset, // Use default terminal color
     strong: chalk.bold,
     strong: chalk.bold,
     em: chalk.italic,
     em: chalk.italic,
-    codespan: chalk.yellow,
-    del: chalk.strikethrough,
+    codespan: chalk.magenta,
+    del: chalk.strikethrough.gray,
     link: chalk.blue.underline,
     link: chalk.blue.underline,
     href: chalk.blue.underline,
     href: chalk.blue.underline,
 
 

+ 22 - 6
canvas-tui/src/index.tsx

@@ -9,15 +9,27 @@ const cli = meow(`
     $ canvas-tui [options]
     $ canvas-tui [options]
 
 
   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
+    --watch, -w       Watch directory for changes (default: .claude/canvas)
+    --file, -f        Specific file to watch
+    --no-mouse        Disable mouse wheel scrolling
+    --help            Show this help message
+    --version         Show version
 
 
   Examples
   Examples
     $ canvas-tui --watch
     $ canvas-tui --watch
-    $ canvas-tui --watch .claude/canvas
     $ canvas-tui --file ./draft.md
     $ canvas-tui --file ./draft.md
+    $ canvas-tui --watch --no-mouse
+
+  Controls
+    Arrow keys / Mouse wheel    Scroll content
+    g / G                       Go to top / bottom
+    q / Ctrl+C                  Quit
+    r                           Refresh
+
+  Terminal Setup
+    Warp:     Ctrl+Shift+D to split, run canvas-tui in new pane
+    tmux:     tmux split-window -h 'canvas-tui --watch'
+    iTerm2:   Cmd+D to split, run canvas-tui in new pane
 `, {
 `, {
   importMeta: import.meta,
   importMeta: import.meta,
   flags: {
   flags: {
@@ -29,10 +41,14 @@ const cli = meow(`
     file: {
     file: {
       type: 'string',
       type: 'string',
       shortFlag: 'f'
       shortFlag: 'f'
+    },
+    noMouse: {
+      type: 'boolean',
+      default: false
     }
     }
   }
   }
 });
 });
 
 
 const watchPath = cli.flags.file || `${cli.flags.watch}/content.md`;
 const watchPath = cli.flags.file || `${cli.flags.watch}/content.md`;
 
 
-render(<App watchPath={watchPath} watchDir={cli.flags.watch} />);
+render(<App watchPath={watchPath} watchDir={cli.flags.watch} enableMouse={!cli.flags.noMouse} />);

+ 6 - 2
canvas-tui/src/lib/templates.ts

@@ -10,8 +10,12 @@ export const templates: Record<string, ContentTemplate> = {
     description: 'Professional email format with subject, greeting, and signature',
     description: 'Professional email format with subject, greeting, and signature',
     template: `# Email Draft
     template: `# Email Draft
 
 
-**To:**
-**Subject:**
+\`\`\`
+To:
+CC:
+BCC:
+Subject:
+\`\`\`
 
 
 ---
 ---