Browse Source

feat(canvas-tui): Improve markdown rendering and UI polish

- Switch numbered list rendering from cli-markdown to custom handler
  (cli-markdown has bugs that drop/merge list items)
- Add info overlay with file metadata (press 'i')
- Redesign status bar: colored icon, dimmed text, position indicator
- Default mouse capture to off (use --mouse or 'm' to enable)
- Use light gray (white + dimColor) for secondary UI text
- Normalize line endings for cross-platform compatibility

Known issue: Intermittent numbered list regression under investigation.
See docs/canvas-tui-markdown-investigation.md for details.

🤖 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
733eaf68f6

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


+ 1 - 0
canvas-tui/package.json

@@ -38,6 +38,7 @@
     "@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",

+ 108 - 40
canvas-tui/src/app.tsx

@@ -19,7 +19,7 @@ interface AppProps {
 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 }) => {
+export const App: React.FC<AppProps> = ({ watchPath, watchDir, enableMouse = false }) => {
   const { exit } = useApp();
   const { stdout } = useStdout();
   const [content, setContent] = useState<string>('');
@@ -98,7 +98,7 @@ export const App: React.FC<AppProps> = ({ watchPath, watchDir, enableMouse = tru
   }, [watchedContent, watchDir]);
 
   // Info overlay using ANSI escape codes
-  useEffect(() => {
+  useLayoutEffect(() => {
     if (!isInfoOpen || !stdout) return;
 
     // Get file stats
@@ -123,52 +123,108 @@ export const App: React.FC<AppProps> = ({ watchPath, watchDir, enableMouse = tru
     const modified = stats ? formatDate(stats.mtime) : 'Unknown';
     const fileSize = stats ? `${stats.size} bytes` : 'Unknown';
 
-    // Build info panel content
-    const lines: string[] = [
-      '\x1B[1m  File Info  \x1B[0m',
+    // 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',
       '',
-      `  Created:    ${created}`,
-      `  Modified:   ${modified}`,
-      `  Size:       ${fileSize}`,
+      `Filename:   ${fileName}`,
+      `Created:    ${created}`,
+      `Modified:   ${modified}`,
+      `Size:       ${fileSize}`,
       '',
-      `  Lines:      ${lineCount.toLocaleString()}`,
-      `  Words:      ${wordCount.toLocaleString()}`,
-      `  Characters: ${charCount.toLocaleString()}`,
+      `Lines:      ${lineCount.toLocaleString()}`,
+      `Words:      ${wordCount.toLocaleString()}`,
+      `Characters: ${charCount.toLocaleString()}`,
       '',
-      '\x1B[2m  Press [i] to close  \x1B[0m',
+      '\x1B[2m[i] Close\x1B[0m',
     ];
 
-    // Calculate panel dimensions
-    const maxLen = Math.max(...lines.map(l => l.replace(/\x1B\[[0-9;]*m/g, '').length)) + 2;
-    const panelHeight = lines.length;
+    // Calculate inner content width
+    const innerWidth = Math.max(...contentLines.map(l => l.replace(/\x1B\[[0-9;]*m/g, '').length));
 
-    // Center the panel
-    const startRow = Math.floor((rows - panelHeight) / 2);
-    const startCol = Math.floor((cols - maxLen) / 2);
+    // 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));
 
-    // Render overlay
-    let output = '\x1B[s'; // Save cursor
+    // Top border: space + ┌ + ─ repeated + ┐ + space
+    panelLines.push(` ┌${'─'.repeat(boxWidth - 2)}┐ `);
 
-    lines.forEach((line, idx) => {
-      const row = startRow + idx;
+    // 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 padding = maxLen - plainLen;
-      // Dim background for panel
-      output += `\x1B[${row};${startCol}H\x1B[48;5;236m${line}${' '.repeat(padding)}\x1B[0m`;
+      const rightPad = innerWidth - plainLen;
+      panelLines.push(` │  ${line}${' '.repeat(rightPad)}  │ `);
     });
 
-    output += '\x1B[u'; // Restore cursor
-    process.stdout.write(output);
+    // 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;
+    };
 
-    // Cleanup: clear the info panel area
+    // 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';
-      lines.forEach((_, idx) => {
+      panelLines.forEach((_, idx) => {
         const row = startRow + idx;
-        clear += `\x1B[${row};${startCol}H${' '.repeat(maxLen)}`;
+        clear += `\x1B[${row};${startCol}H${' '.repeat(totalWidth)}`;
       });
       clear += '\x1B[u';
-      process.stdout.write(clear);
+      originalWrite.call(process.stdout, clear);
     };
   }, [isInfoOpen, currentFilePath, content, stdout, rows, cols]);
 
@@ -307,22 +363,34 @@ export const App: React.FC<AppProps> = ({ watchPath, watchDir, enableMouse = tru
 
   // Status info
   const currentFileName = currentFilePath.split(/[/\\]/).pop() || 'file';
-  const timestampStr = lastUpdate
-    ? `${lastUpdate.toLocaleDateString()} ${lastUpdate.toLocaleTimeString()}`
-    : null;
 
-  const scrollHint = totalLines > contentHeight
-    ? ` ${scrollOffset + 1}-${Math.min(scrollOffset + contentHeight, totalLines)}/${totalLines}`
-    : '';
+  // 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 mouseHint = mouseEnabled ? 'on' : 'off';
   const hints = isEditing
     ? 'Editing...'
     : isInfoOpen
     ? '[i] Close'
     : isDropdownOpen
     ? '[Tab] Close [↑↓] Nav [Enter] Open'
-    : `[Tab] Files [i] Info [e] Edit [m] ${mouseHint} [q] Quit${scrollHint}`;
+    : '[Tab] Files [i] Info [e] Edit [m] Mouse [q] Quit';
 
   return (
     <Box flexDirection="column" height={rows}>
@@ -359,8 +427,8 @@ export const App: React.FC<AppProps> = ({ watchPath, watchDir, enableMouse = tru
 
       <StatusBar
         status={syncStatus}
-        filename={currentFileName}
         timestamp={timestampStr}
+        position={positionStr}
         hints={hints}
         width={cols}
       />

+ 1 - 1
canvas-tui/src/components/Header.tsx

@@ -102,7 +102,7 @@ export const Header: React.FC<HeaderProps> = ({
         <Box width={width}>
           <Text bold color="blue">{leftContent}</Text>
           <Box flexGrow={1} />
-          <Text dimColor>{displayName} {isDropdownOpen ? '▲' : '▼'}</Text>
+          <Text color="white" dimColor>{displayName} {isDropdownOpen ? '▲' : '▼'}</Text>
           <Text> </Text>
         </Box>
       </Box>

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

@@ -1,5 +1,5 @@
 import React, { useMemo, useEffect } from 'react';
-import { Box, Text } from 'ink';
+import { Box, Text, useStdout } from 'ink';
 import { useMarkdown } from '../hooks/useMarkdown.js';
 
 interface MarkdownViewProps {
@@ -15,7 +15,9 @@ export const MarkdownView: React.FC<MarkdownViewProps> = ({
   maxHeight,
   onLineCount
 }) => {
-  const rendered = useMarkdown(content);
+  const { stdout } = useStdout();
+  const width = stdout?.columns || 80;
+  const rendered = useMarkdown(content, width);
 
   // Split into lines for scrolling
   const lines = useMemo(() => {

+ 11 - 6
canvas-tui/src/components/StatusBar.tsx

@@ -3,13 +3,13 @@ import { Box, Text } from 'ink';
 
 interface StatusBarProps {
   status: 'waiting' | 'synced' | 'watching';
-  filename: string;
   timestamp: string | null;
+  position: string | null;
   hints: string;
   width: number;
 }
 
-export const StatusBar: React.FC<StatusBarProps> = ({ status, filename, timestamp, hints, width }) => {
+export const StatusBar: React.FC<StatusBarProps> = ({ status, timestamp, position, hints, width }) => {
   const statusColors: Record<string, string> = {
     waiting: 'yellow',
     synced: 'green',
@@ -31,7 +31,11 @@ export const StatusBar: React.FC<StatusBarProps> = ({ status, filename, timestam
   const statusColor = statusColors[status] || 'white';
   const statusIcon = statusIcons[status] || ' ';
   const statusLabel = statusLabels[status] || '';
-  const timeStr = timestamp ? ` ${timestamp}` : '';
+
+  // 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
@@ -45,10 +49,11 @@ export const StatusBar: React.FC<StatusBarProps> = ({ status, filename, timestam
     >
       <Box width={width} justifyContent="space-between">
         <Box>
-          <Text color={statusColor}>[{statusIcon}]</Text>
-          <Text dimColor> {statusLabel}: {filename}{timeStr}</Text>
+          <Text color="white" dimColor>[</Text>
+          <Text color={statusColor}>{statusIcon}</Text>
+          <Text color="white" dimColor>] {leftParts.join(' ')}</Text>
         </Box>
-        <Text dimColor>{hints}</Text>
+        <Text color="white" dimColor>{hints}</Text>
       </Box>
     </Box>
   );

+ 134 - 76
canvas-tui/src/hooks/useMarkdown.ts

@@ -1,91 +1,149 @@
 import { useMemo } from 'react';
-import { marked } from 'marked';
-import { markedTerminal } from 'marked-terminal';
 import chalk from 'chalk';
 
-// Configure marked with terminal renderer
-// Using colors that work on both light and dark backgrounds
-marked.use(
-  markedTerminal({
-    // Colors for different elements - avoiding white/black for cross-theme support
-    code: chalk.cyan,
-    blockquote: chalk.gray.italic,
-    html: chalk.gray,
-    heading: chalk.bold.blue,
-    firstHeading: chalk.bold.blue.underline,
-    hr: chalk.gray,
-    listitem: chalk.reset,  // Use default terminal color
-    list: (body: string) => body,
-    table: chalk.reset,
-    paragraph: chalk.reset, // Use default terminal color
-    strong: chalk.bold,
-    em: chalk.italic,
-    codespan: chalk.magenta,
-    del: chalk.strikethrough.gray,
-    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
-  })
-);
+// @ts-ignore - no types for cli-markdown
+import markdown from 'cli-markdown';
 
-export function useMarkdown(content: string): string {
+export function useMarkdown(content: string, width: number = 80): 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') {
-        // Ensure proper spacing after headings
-        // ANSI underline is \x1B[4m, reset includes \x1B[0m or \x1B[24m
-        const lines = rendered.trim().split('\n');
-        const result: string[] = [];
-
-        for (let i = 0; i < lines.length; i++) {
-          result.push(lines[i]);
-
-          // If this line contains underline codes (h1) or bold (other headings)
-          // and next line exists and is not empty, add a blank line
-          const hasUnderline = lines[i].includes('\x1B[4m');
-          const nextLine = lines[i + 1];
-          if (hasUnderline && nextLine !== undefined && nextLine.trim() !== '') {
-            result.push('');
+      // 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);
         }
+      }
 
-        return result.join('\n');
+      // Flush any remaining content
+      if (inNumberedList) {
+        flushNumberedList();
       }
-      return content; // Fallback to raw content
+      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 parse error:', err);
-      return content; // Return raw content on error
+      console.error('Markdown render error:', err);
+      return content;
     }
-  }, [content]);
+  }, [content, width]);
 }

+ 4 - 3
canvas-tui/src/index.tsx

@@ -13,7 +13,7 @@ const cli = meow(`
   Options
     --watch, -w       Watch directory (default: .claude/canvas)
     --file, -f        Specific file to watch
-    --no-mouse        Disable mouse wheel scrolling
+    --mouse, -m       Enable mouse wheel scrolling (default: off)
     --help            Show this help
     --version         Show version
 
@@ -45,8 +45,9 @@ const cli = meow(`
       type: 'string',
       shortFlag: 'f'
     },
-    noMouse: {
+    mouse: {
       type: 'boolean',
+      shortFlag: 'm',
       default: false
     }
   }
@@ -114,4 +115,4 @@ function getInitialFile(watchDir: string, specificFile?: string): string {
 const watchDir = getWatchDir(cli.flags.watch);
 const watchPath = getInitialFile(watchDir, cli.flags.file);
 
-render(<App watchPath={watchPath} watchDir={watchDir} enableMouse={!cli.flags.noMouse} />);
+render(<App watchPath={watchPath} watchDir={watchDir} enableMouse={cli.flags.mouse} />);