From 63de8fc2d110fc6f9d54b078f14c07986366d24e Mon Sep 17 00:00:00 2001 From: Gemini AI Date: Sun, 14 Dec 2025 04:02:03 +0400 Subject: [PATCH] TUI Gen 5: Major overhaul - Todo List, improved streaming, ESC to abort, cleaner UI --- bin/opencode-ink.mjs | 1769 +++++++----- bin/opencode-ink.mjs.backup | 4101 +++++++++++++++++++++++++++ bin/opencode-tui.cjs | 35 +- bin/ui/components/ChatBubble.mjs | 51 +- bin/ui/components/ThinkingBlock.mjs | 48 +- bin/ui/components/TodoList.mjs | 101 + 6 files changed, 5417 insertions(+), 688 deletions(-) create mode 100644 bin/opencode-ink.mjs.backup create mode 100644 bin/ui/components/TodoList.mjs diff --git a/bin/opencode-ink.mjs b/bin/opencode-ink.mjs index 25c2721..71f74cf 100644 --- a/bin/opencode-ink.mjs +++ b/bin/opencode-ink.mjs @@ -41,6 +41,7 @@ import FileTree from './ui/components/FileTree.mjs'; import DiffView from './ui/components/DiffView.mjs'; import ThinkingBlock from './ui/components/ThinkingBlock.mjs'; import ChatBubble from './ui/components/ChatBubble.mjs'; +import TodoList from './ui/components/TodoList.mjs'; const { useState, useCallback, useEffect, useRef, useMemo } = React; @@ -68,11 +69,26 @@ const h = React.createElement; // ═══════════════════════════════════════════════════════════════ // CUSTOM MULTI-LINE INPUT COMPONENT -// Properly handles pasted multi-line text unlike ink-text-input +// Properly handles pasted multi-line text unlike ink-text-input with enhanced Claude Code TUI quality // ═══════════════════════════════════════════════════════════════ const MultiLineInput = ({ value, onChange, onSubmit, placeholder, isActive = true }) => { const [cursorVisible, setCursorVisible] = useState(true); const [pastedChars, setPastedChars] = useState(0); + const [inputWidth, setInputWidth] = useState(80); // Default width + const [inputHeight, setInputHeight] = useState(1); // Track input height dynamically + + // Get terminal size for responsive input width + const [columns, rows] = useTerminalSize(); + useEffect(() => { + // Calculate input width accounting for margins and borders + const safeWidth = Math.max(20, columns - 10); // Leave margin for borders + setInputWidth(safeWidth); + + // Calculate height based on content but cap it to avoid taking too much space + const lines = value.split('\n'); + const newHeight = Math.min(Math.max(3, lines.length + 1), 10); // Min 3 lines, max 10 + setInputHeight(newHeight); + }, [columns, rows, value]); // Blink cursor useEffect(() => { @@ -84,8 +100,20 @@ const MultiLineInput = ({ value, onChange, onSubmit, placeholder, isActive = tru useInput((input, key) => { if (!isActive) return; - // Submit on Enter + // Submit on Enter (but only if not in multiline mode with Shift) if (key.return && !key.shift) { + // If we have multi-line content, require Ctrl+Enter to submit + if (value.includes('\n') && !key.ctrl) { + // Don't submit, just add a line break + return; + } + onSubmit(value); + setPastedChars(0); + return; + } + + // Ctrl+Enter for multi-line content submission + if (key.return && key.ctrl) { onSubmit(value); setPastedChars(0); return; @@ -97,6 +125,13 @@ const MultiLineInput = ({ value, onChange, onSubmit, placeholder, isActive = tru return; } + // Ctrl+V for paste (explicit paste detection) + if (key.ctrl && input.toLowerCase() === 'v') { + // This is handled by the system paste, so we just detect it + setPastedChars(value.length > 0 ? value.length * 2 : 100); // Estimate pasted chars + return; + } + // Backspace if (key.backspace || key.delete) { onChange(value.slice(0, -1)); @@ -110,19 +145,19 @@ const MultiLineInput = ({ value, onChange, onSubmit, placeholder, isActive = tru return; } - // Ignore control keys + // Ignore control keys except for specific shortcuts if (key.ctrl || key.meta) return; if (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow) return; // Append character(s) if (input) { - // Detect paste: if >5 chars arrive at once - if (input.length > 5) { - setPastedChars(input.length); + // Detect paste: if >5 chars arrive at once or contains newlines + if (input.length > 5 || input.includes('\n')) { + setPastedChars(input.length + (input.match(/\n/g) || []).length * 10); // Weight newlines } onChange(value + input); } - }, { isActive }); + }, [isActive, value]); // Reset paste indicator when input is cleared useEffect(() => { @@ -136,28 +171,78 @@ const MultiLineInput = ({ value, onChange, onSubmit, placeholder, isActive = tru const lineCount = lines.length; // Show paste indicator only if we detected a paste burst - if (pastedChars > 0) { + if (pastedChars > 10) { // Only show for significant pastes const indicator = lineCount > 1 - ? `[Pasted ~${lineCount} lines]` - : `[Pasted ~${pastedChars} chars]`; + ? `[Pasted: ${lineCount} lines, ${pastedChars} chars]` + : `[Pasted: ${pastedChars} chars]`; - return h(Box, { flexDirection: 'column' }, + return h(Box, { flexDirection: 'column', width: inputWidth }, h(Box, { borderStyle: 'round', borderColor: 'yellow', - paddingX: 1 + paddingX: 1, + width: inputWidth }, h(Text, { color: 'yellow', bold: true }, indicator) ), - isActive && cursorVisible ? h(Text, { backgroundColor: 'white', color: 'black' }, ' ') : null + h(Box, { + borderStyle: 'single', + borderColor: 'cyan', + paddingX: 1, + minHeight: inputHeight, + maxHeight: 10 + }, + lines.map((line, i) => + h(Text, { key: i, color: 'white', wrap: 'truncate' }, + i === lines.length - 1 && isActive && cursorVisible ? `${line}█` : line + ) + ) + ) ); } - // Normal short input - show inline - return h(Box, { flexDirection: 'row' }, - h(Text, { color: 'white' }, displayValue), - isActive && cursorVisible ? h(Text, { backgroundColor: 'white', color: 'black' }, ' ') : null, - !displayValue && placeholder ? h(Text, { dimColor: true }, placeholder) : null + // Multi-line input - render with proper height and scrolling + if (lineCount > 1 || value.length > 50) { // Show as multi-line if more than 1 line or long text + return h(Box, { + flexDirection: 'column', + width: inputWidth, + minHeight: inputHeight, + maxHeight: 10 + }, + h(Box, { + borderStyle: lineCount > 1 ? 'round' : 'single', + borderColor: 'cyan', + paddingX: 1, + flexGrow: 1, + maxHeight: inputHeight + }, + lines.map((line, i) => + h(Text, { + key: i, + color: 'white', + wrap: 'truncate', + maxWidth: inputWidth - 4 // Account for borders and padding + }, + i === lines.length - 1 && isActive && cursorVisible ? `${line}█` : line + ) + ) + ), + h(Box, { marginTop: 0.5 }, + h(Text, { color: 'gray', dimColor: true, fontSize: 0.8 }, + `${lineCount} line${lineCount > 1 ? 's' : ''} | ${value.length} chars | Shift+Enter: new line, Enter: submit`) + ) + ); + } + + // Normal single-line input - show inline with proper truncation + return h(Box, { flexDirection: 'row', width: inputWidth }, + h(Box, { borderStyle: 'single', borderColor: 'cyan', paddingX: 1, flexGrow: 1 }, + h(Text, { color: 'white', wrap: 'truncate' }, + displayValue + (isActive && cursorVisible && displayValue.length > 0 ? '█' : '') + ), + !displayValue && placeholder ? h(Text, { dimColor: true }, placeholder) : null, + isActive && !displayValue && cursorVisible ? h(Text, { backgroundColor: 'white', color: 'black' }, '█') : null + ) ); }; @@ -262,6 +347,50 @@ const extractCommands = (text) => { return commands; }; +// CRITICAL: runShellCommandStreaming for real-time output & abort control +const runShellCommandStreaming = (cmd, cwd = process.cwd(), onData = () => { }) => { + return new Promise((resolve) => { + // Use spawn with shell option for compatibility + const child = spawn(cmd, { + cwd, + shell: true, + std: ['ignore', 'pipe', 'pipe'], // Ignore stdin, pipe stdout/stderr + env: { ...process.env, FORCE_COLOR: '1' } + }); + + // Capture stdout + child.stdout.on('data', (data) => { + const str = data.toString(); + onData(str); + }); + + // Capture stderr + child.stderr.on('data', (data) => { + const str = data.toString(); + onData(str); + }); + + child.on('close', (code) => { + resolve({ + success: code === 0, + code: code || 0 + }); + }); + + child.on('error', (err) => { + onData(`\nERROR: ${err.message}\n`); + resolve({ + success: false, + code: 1, + error: err.message + }); + }); + + // Expose the child process via the promise (unconventional but useful here) + resolve.child = child; + }); +}; + const runShellCommand = (cmd, cwd = process.cwd()) => { return new Promise((resolve) => { // Use exec which handles shell command strings (quotes, spaces) correctly @@ -608,49 +737,76 @@ const loadRecentProjects = () => { }; // ═══════════════════════════════════════════════════════════════ -// POWER FEATURE 1: TODO TRACKER -// Parses TODO/FIXME comments from project files -// ═══════════════════════════════════════════════════════════════ -const parseTodos = (projectPath) => { - const todos = []; - const extensions = ['.js', '.ts', '.jsx', '.tsx', '.py', '.md', '.mjs']; - const todoPattern = /(?:\/\/|#|