From dc4d7cbef299e54bb7ffc23687a921bf73d5da1f Mon Sep 17 00:00:00 2001 From: Gemini AI Date: Sun, 14 Dec 2025 02:01:40 +0400 Subject: [PATCH] fix(tui): restore exec for shell commands, fix qwen input splitting, fix fs imports --- bin/opencode-ink.mjs | 168 +++++++++++++++++++++++++++++++++++++------ package.json | 2 +- qwen-oauth.mjs | 50 ++++++++----- 3 files changed, 180 insertions(+), 40 deletions(-) diff --git a/bin/opencode-ink.mjs b/bin/opencode-ink.mjs index 7e2fe39..a1f0bbf 100644 --- a/bin/opencode-ink.mjs +++ b/bin/opencode-ink.mjs @@ -12,7 +12,7 @@ import Spinner from 'ink-spinner'; import SelectInput from 'ink-select-input'; import fs from 'fs'; import path from 'path'; -import { exec } from 'child_process'; +import { exec, spawn } from 'child_process'; import { fileURLToPath } from 'url'; import clipboard from 'clipboardy'; // ESM-native Markdown component (replaces CommonJS ink-markdown) @@ -242,6 +242,43 @@ const getModelsByGroup = () => { return groups; }; +// ═══════════════════════════════════════════════════════════════ +// AGENTIC COMMAND EXECUTION +// ═══════════════════════════════════════════════════════════════ + +const extractCommands = (text) => { + const commands = []; + const regex = /```(?:bash|shell|cmd|sh|powershell|ps1)(?::run)?[\s\n]+([\s\S]*?)```/gi; + let match; + while ((match = regex.exec(text)) !== null) { + const content = match[1].trim(); + if (content) { + content.split('\n').forEach(line => { + const cmd = line.trim(); + if (cmd && !cmd.startsWith('#')) commands.push(cmd); + }); + } + } + return commands; +}; + +const runShellCommand = (cmd, cwd = process.cwd()) => { + return new Promise((resolve) => { + // Use exec which handles shell command strings (quotes, spaces) correctly + exec(cmd, { + cwd, + env: { ...process.env, FORCE_COLOR: '1' }, + maxBuffer: 1024 * 1024 * 5 // 5MB buffer for larger outputs + }, (error, stdout, stderr) => { + resolve({ + success: !error, + output: stdout + (stderr ? '\n' + stderr : ''), + code: error ? (error.code || 1) : 0 + }); + }); + }); +}; + // Current free model state (default to grok-code-fast-1) let currentFreeModel = 'grok-code-fast-1'; @@ -514,18 +551,7 @@ const writeFile = (projectPath, filename, content) => { } }; -// Run shell command -const runShellCommand = (cmd, cwd) => { - return new Promise((resolve) => { - exec(cmd, { cwd }, (error, stdout, stderr) => { - resolve({ - success: !error, - output: stdout + (stderr ? '\n' + stderr : ''), - error: error ? error.message : null - }); - }); - }); -}; + // ═══════════════════════════════════════════════════════════════ // RECENT PROJECTS @@ -1970,6 +1996,11 @@ const App = () => { // NEW: Project Creation State const [newProjectName, setNewProjectName] = useState(''); + // NEW: Command Execution State + const [detectedCommands, setDetectedCommands] = useState([]); + const [isExecutingCommands, setIsExecutingCommands] = useState(false); + const [commandResults, setCommandResults] = useState([]); + // NEW: Multi-line buffer const [inputBuffer, setInputBuffer] = useState(''); @@ -3022,6 +3053,14 @@ This gives the user a chance to refine requirements before implementation. // Extract files for AUTO-WRITE (Magic File Writer) const files = extractCodeBlocks(responseText); + + // NEW: Extract & Detect Commands + const cmds = extractCommands(responseText); + if (cmds.length > 0) { + setDetectedCommands(cmds); + } + + // Extract files logic continues... if (files.length > 0) { // AUTO-WRITE: Actually create the files! const results = []; @@ -3147,24 +3186,62 @@ This gives the user a chance to refine requirements before implementation. const handleCreateProject = () => { if (!newProjectName.trim()) return; - const safeName = newProjectName.trim().replace(/[^a-zA-Z0-9-_\s]/g, '_'); // Sanitize - const newPath = path.join(process.cwd(), safeName); + + // Support Absolute Paths (e.g., E:\Test\Project or /home/user/project) + const isAbsolute = path.isAbsolute(newProjectName.trim()); + let newPath; + let safeName; + + if (isAbsolute) { + newPath = newProjectName.trim(); + safeName = path.basename(newPath); // Use the last folder name as the project name + } else { + safeName = newProjectName.trim().replace(/[^a-zA-Z0-9-_\s]/g, '_'); // Sanitize relative names + newPath = path.join(process.cwd(), safeName); + } try { if (fs.existsSync(newPath)) { - setMessages(prev => [...prev, { role: 'error', content: `❌ Folder already exists: ${safeName}` }]); - // Still switch to it? Maybe user wants that. + // If it exists, just switch to it (user might want to open existing folder) + setMessages(prev => [...prev, { role: 'system', content: `✨ Opening existing folder: ${newPath}` }]); } else { fs.mkdirSync(newPath, { recursive: true }); - setMessages(prev => [...prev, { role: 'system', content: `✨ Created project folder: ${safeName}` }]); + setMessages(prev => [...prev, { role: 'system', content: `✨ Created project folder: ${newPath}` }]); } // Proceed to select it handleProjectSelect({ value: newPath }); } catch (e) { - setMessages(prev => [...prev, { role: 'error', content: `❌ Failed to create folder: ${e.message}` }]); + setMessages(prev => [...prev, { role: 'error', content: `❌ Failed to create/open folder: ${e.message}` }]); } }; + const handleExecuteCommands = async (confirmed) => { + if (!confirmed) { + setDetectedCommands([]); + return; + } + + setIsExecutingCommands(true); + // setAppState('executing'); + + const results = []; + for (const cmd of detectedCommands) { + setMessages(prev => [...prev, { role: 'system', content: `▶ Running: ${cmd}` }]); + + const res = await runShellCommand(cmd, project || process.cwd()); + results.push({ cmd, ...res }); + + if (res.success) { + setMessages(prev => [...prev, { role: 'system', content: `✅ Output:\n${res.output}` }]); + } else { + setMessages(prev => [...prev, { role: 'error', content: `❌ Failed (Exit ${res.code}):\n${res.output}` }]); + } + } + + setDetectedCommands([]); + setIsExecutingCommands(false); + }; + // Handle project selection const handleProjectSelect = (item) => { let targetPath = item.value; @@ -3216,11 +3293,15 @@ This gives the user a chance to refine requirements before implementation. // Project Creation Screen if (appState === 'create_project') { + const resolvedPath = path.isAbsolute(newProjectName.trim()) + ? newProjectName.trim() + : path.join(process.cwd(), newProjectName.trim() || ''); + return h(Box, { flexDirection: 'column', padding: 1 }, h(Box, { borderStyle: 'round', borderColor: 'green', paddingX: 1, marginBottom: 1 }, h(Text, { bold: true, color: 'green' }, '🆕 Create New Project') ), - h(Text, { color: 'cyan', marginBottom: 1 }, 'Project Name (folder will be created in current dir):'), + h(Text, { color: 'cyan', marginBottom: 1 }, 'Enter Project Name OR Full Path (e.g., E:\\Test\\NewApp):'), h(Box, { borderStyle: 'single', borderColor: 'gray', paddingX: 1 }, h(TextInput, { value: newProjectName, @@ -3230,10 +3311,10 @@ This gives the user a chance to refine requirements before implementation. }) ), h(Box, { marginTop: 1, gap: 2 }, - h(Text, { color: 'green' }, 'Press Enter to create'), + h(Text, { color: 'green' }, 'Press Enter to create/open'), h(Text, { dimColor: true }, '| Esc to cancel (Ctrl+C to exit)') ), - h(Text, { color: 'gray', marginTop: 1 }, `Location: ${process.cwd()}\\`) + h(Text, { color: 'gray', marginTop: 1 }, `Target: ${resolvedPath}`) ); } @@ -3561,6 +3642,7 @@ This gives the user a chance to refine requirements before implementation. alignItems: 'center', justifyContent: 'center' }, + // ... (ModelSelector implementation) ... h(ModelSelector, { isOpen: true, currentModel: provider === 'opencode-free' ? freeModel : 'qwen-coder-plus', @@ -3590,6 +3672,48 @@ This gives the user a chance to refine requirements before implementation. ); } + // ═══════════════════════════════════════════════════════════════ + // CONDITIONAL RENDER: Command Execution Overlay + // ═══════════════════════════════════════════════════════════════ + if (detectedCommands.length > 0) { + return h(Box, { + flexDirection: 'column', + width: columns, + height: rows, + alignItems: 'center', + justifyContent: 'center', + borderStyle: 'double', + borderColor: 'magenta' + }, + h(Box, { flexDirection: 'column', padding: 2, borderStyle: 'single', borderColor: 'magenta', minWidth: 50 }, + h(Text, { bold: true, color: 'magenta', marginBottom: 1 }, '🖥️ COMMANDS DETECTED'), + h(Text, { color: 'white', marginBottom: 1 }, 'The AI suggested the following commands. Execute them?'), + h(Box, { flexDirection: 'column', marginBottom: 2, paddingLeft: 2 }, + detectedCommands.map((cmd, i) => + h(Text, { key: i, color: 'cyan' }, `${i + 1}. ${cmd}`) + ) + ), + isExecutingCommands + ? h(Text, { color: 'yellow' }, '⏳ Executing...') + : h(Box, { flexDirection: 'column', gap: 1 }, + h(Text, { color: 'green', bold: true }, '[Y] Yes (Run All)'), + h(Text, { color: 'red', bold: true }, '[N] No (Skip)'), + ), + + // Hidden Input for Y/N handling + !isExecutingCommands && h(TextInput, { + value: '', + onChange: (val) => { + const v = val.toLowerCase(); + if (v === 'y') handleExecuteCommands(true); + if (v === 'n') handleExecuteCommands(false); + }, + onSubmit: () => { } + }) + ) + ); + } + // ═══════════════════════════════════════════════════════════════ // MAIN DASHBOARD LAYOUT // ═══════════════════════════════════════════════════════════════ diff --git a/package.json b/package.json index 77f8195..5bc5c32 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openqode-tui", - "version": "1.2.0", + "version": "1.2.1", "author": "Trae & Gemini", "private": true, "dependencies": { diff --git a/qwen-oauth.mjs b/qwen-oauth.mjs index d05b0d4..df49564 100644 --- a/qwen-oauth.mjs +++ b/qwen-oauth.mjs @@ -7,7 +7,8 @@ */ import crypto from 'crypto'; -import fs from 'fs/promises'; +import fs from 'fs'; +import { readFile, writeFile, unlink } from 'fs/promises'; import path from 'path'; import { fileURLToPath } from 'url'; import { createRequire } from 'module'; @@ -82,7 +83,7 @@ class QwenOAuth { /** Load stored tokens */ async loadTokens() { try { - const data = await fs.readFile(TOKEN_FILE, 'utf8'); + const data = await readFile(TOKEN_FILE, 'utf8'); this.tokens = JSON.parse(data); return this.tokens; } catch (error) { @@ -98,7 +99,7 @@ class QwenOAuth { if (tokens.expires_in && !tokens.expiry_date) { tokens.expiry_date = Date.now() + (tokens.expires_in * 1000); } - await fs.writeFile(TOKEN_FILE, JSON.stringify(tokens, null, 2)); + await writeFile(TOKEN_FILE, JSON.stringify(tokens, null, 2)); } /** Clear tokens */ @@ -107,7 +108,7 @@ class QwenOAuth { this.deviceCodeData = null; this.codeVerifier = null; try { - await fs.unlink(TOKEN_FILE); + await unlink(TOKEN_FILE); } catch (error) { } } @@ -362,7 +363,13 @@ IMPORTANT RULES: // Prepend system context ONLY for build/create commands (detected by keywords) let finalMessage = message; - if (message.includes('CREATE:') || message.includes('ROLE:') || message.includes('Generate all necessary files')) { + const lowerMsg = message.toLowerCase(); + if (message.includes('CREATE:') || + message.includes('ROLE:') || + message.includes('Generate all necessary files') || + lowerMsg.includes('open ') || + lowerMsg.includes('run ') || + lowerMsg.includes('computer use')) { finalMessage = systemContext + message; } @@ -370,17 +377,31 @@ IMPORTANT RULES: try { console.log('Sending message via qwen CLI:', finalMessage.substring(0, 50) + '...'); - // For long messages, write to temp file to avoid ENAMETOOLONG error - const tempFile = path.join(os.tmpdir(), `qwen-prompt-${Date.now()}.txt`); - fsSync.writeFileSync(tempFile, finalMessage, 'utf8'); - // Run in current project directory to allow context access const neutralCwd = process.cwd(); - // Use spawn with stdin for long messages - const child = spawn('qwen', ['-p', `@${tempFile}`], { + // WINDOWS FIX: Execute JS directly to avoid cmd.exe argument splitting limits/bugs + // We derived this path from `where qwen` -> qwen.cmd -> cli.js location + const isWin = process.platform === 'win32'; + let command = 'qwen'; + let args = ['-p', finalMessage]; + + if (isWin) { + const appData = process.env.APPDATA || ''; + const cliPath = path.join(appData, 'npm', 'node_modules', '@qwen-code', 'qwen-code', 'cli.js'); + if (fs.existsSync(cliPath)) { + command = 'node'; + args = [cliPath, '-p', finalMessage]; + } else { + // Fallback if standard path fails (though known to exist on this machine) + command = 'qwen.cmd'; + } + } + + // Use spawn with shell: false (REQUIRED for clean argument passing) + const child = spawn(command, args, { cwd: neutralCwd, - shell: true, + shell: false, env: { ...process.env, FORCE_COLOR: '0' @@ -404,9 +425,6 @@ IMPORTANT RULES: }); child.on('close', (code) => { - // Clean up temp file - try { fsSync.unlinkSync(tempFile); } catch (e) { } - // Clean up ANSI codes const cleanResponse = stdout.replace(/[\u001b\u009b][[\]()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '').trim(); @@ -428,7 +446,6 @@ IMPORTANT RULES: }); child.on('error', (error) => { - try { fsSync.unlinkSync(tempFile); } catch (e) { } console.error('Qwen CLI spawn error:', error.message); resolve({ success: false, @@ -440,7 +457,6 @@ IMPORTANT RULES: // Timeout after 120 seconds for long prompts setTimeout(() => { child.kill('SIGTERM'); - try { fsSync.unlinkSync(tempFile); } catch (e) { } resolve({ success: false, error: 'Request timed out (120s)',