fix(tui): restore exec for shell commands, fix qwen input splitting, fix fs imports

This commit is contained in:
Gemini AI
2025-12-14 02:01:40 +04:00
Unverified
parent 4481895678
commit dc4d7cbef2
3 changed files with 180 additions and 40 deletions

View File

@@ -12,7 +12,7 @@ import Spinner from 'ink-spinner';
import SelectInput from 'ink-select-input'; import SelectInput from 'ink-select-input';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { exec } from 'child_process'; import { exec, spawn } from 'child_process';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import clipboard from 'clipboardy'; import clipboard from 'clipboardy';
// ESM-native Markdown component (replaces CommonJS ink-markdown) // ESM-native Markdown component (replaces CommonJS ink-markdown)
@@ -242,6 +242,43 @@ const getModelsByGroup = () => {
return groups; 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) // Current free model state (default to grok-code-fast-1)
let currentFreeModel = '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 // RECENT PROJECTS
@@ -1970,6 +1996,11 @@ const App = () => {
// NEW: Project Creation State // NEW: Project Creation State
const [newProjectName, setNewProjectName] = useState(''); 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 // NEW: Multi-line buffer
const [inputBuffer, setInputBuffer] = useState(''); 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) // Extract files for AUTO-WRITE (Magic File Writer)
const files = extractCodeBlocks(responseText); 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) { if (files.length > 0) {
// AUTO-WRITE: Actually create the files! // AUTO-WRITE: Actually create the files!
const results = []; const results = [];
@@ -3147,24 +3186,62 @@ This gives the user a chance to refine requirements before implementation.
const handleCreateProject = () => { const handleCreateProject = () => {
if (!newProjectName.trim()) return; 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 { try {
if (fs.existsSync(newPath)) { if (fs.existsSync(newPath)) {
setMessages(prev => [...prev, { role: 'error', content: `❌ Folder already exists: ${safeName}` }]); // If it exists, just switch to it (user might want to open existing folder)
// Still switch to it? Maybe user wants that. setMessages(prev => [...prev, { role: 'system', content: `✨ Opening existing folder: ${newPath}` }]);
} else { } else {
fs.mkdirSync(newPath, { recursive: true }); 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 // Proceed to select it
handleProjectSelect({ value: newPath }); handleProjectSelect({ value: newPath });
} catch (e) { } 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 // Handle project selection
const handleProjectSelect = (item) => { const handleProjectSelect = (item) => {
let targetPath = item.value; let targetPath = item.value;
@@ -3216,11 +3293,15 @@ This gives the user a chance to refine requirements before implementation.
// Project Creation Screen // Project Creation Screen
if (appState === 'create_project') { if (appState === 'create_project') {
const resolvedPath = path.isAbsolute(newProjectName.trim())
? newProjectName.trim()
: path.join(process.cwd(), newProjectName.trim() || '<name>');
return h(Box, { flexDirection: 'column', padding: 1 }, return h(Box, { flexDirection: 'column', padding: 1 },
h(Box, { borderStyle: 'round', borderColor: 'green', paddingX: 1, marginBottom: 1 }, h(Box, { borderStyle: 'round', borderColor: 'green', paddingX: 1, marginBottom: 1 },
h(Text, { bold: true, color: 'green' }, '🆕 Create New Project') 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(Box, { borderStyle: 'single', borderColor: 'gray', paddingX: 1 },
h(TextInput, { h(TextInput, {
value: newProjectName, value: newProjectName,
@@ -3230,10 +3311,10 @@ This gives the user a chance to refine requirements before implementation.
}) })
), ),
h(Box, { marginTop: 1, gap: 2 }, 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, { dimColor: true }, '| Esc to cancel (Ctrl+C to exit)')
), ),
h(Text, { color: 'gray', marginTop: 1 }, `Location: ${process.cwd()}\\<name>`) 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', alignItems: 'center',
justifyContent: 'center' justifyContent: 'center'
}, },
// ... (ModelSelector implementation) ...
h(ModelSelector, { h(ModelSelector, {
isOpen: true, isOpen: true,
currentModel: provider === 'opencode-free' ? freeModel : 'qwen-coder-plus', 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 // MAIN DASHBOARD LAYOUT
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════

View File

@@ -1,6 +1,6 @@
{ {
"name": "openqode-tui", "name": "openqode-tui",
"version": "1.2.0", "version": "1.2.1",
"author": "Trae & Gemini", "author": "Trae & Gemini",
"private": true, "private": true,
"dependencies": { "dependencies": {

View File

@@ -7,7 +7,8 @@
*/ */
import crypto from 'crypto'; 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 path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { createRequire } from 'module'; import { createRequire } from 'module';
@@ -82,7 +83,7 @@ class QwenOAuth {
/** Load stored tokens */ /** Load stored tokens */
async loadTokens() { async loadTokens() {
try { try {
const data = await fs.readFile(TOKEN_FILE, 'utf8'); const data = await readFile(TOKEN_FILE, 'utf8');
this.tokens = JSON.parse(data); this.tokens = JSON.parse(data);
return this.tokens; return this.tokens;
} catch (error) { } catch (error) {
@@ -98,7 +99,7 @@ class QwenOAuth {
if (tokens.expires_in && !tokens.expiry_date) { if (tokens.expires_in && !tokens.expiry_date) {
tokens.expiry_date = Date.now() + (tokens.expires_in * 1000); 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 */ /** Clear tokens */
@@ -107,7 +108,7 @@ class QwenOAuth {
this.deviceCodeData = null; this.deviceCodeData = null;
this.codeVerifier = null; this.codeVerifier = null;
try { try {
await fs.unlink(TOKEN_FILE); await unlink(TOKEN_FILE);
} catch (error) { } } catch (error) { }
} }
@@ -362,7 +363,13 @@ IMPORTANT RULES:
// Prepend system context ONLY for build/create commands (detected by keywords) // Prepend system context ONLY for build/create commands (detected by keywords)
let finalMessage = message; 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; finalMessage = systemContext + message;
} }
@@ -370,17 +377,31 @@ IMPORTANT RULES:
try { try {
console.log('Sending message via qwen CLI:', finalMessage.substring(0, 50) + '...'); 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 // Run in current project directory to allow context access
const neutralCwd = process.cwd(); const neutralCwd = process.cwd();
// Use spawn with stdin for long messages // WINDOWS FIX: Execute JS directly to avoid cmd.exe argument splitting limits/bugs
const child = spawn('qwen', ['-p', `@${tempFile}`], { // 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, cwd: neutralCwd,
shell: true, shell: false,
env: { env: {
...process.env, ...process.env,
FORCE_COLOR: '0' FORCE_COLOR: '0'
@@ -404,9 +425,6 @@ IMPORTANT RULES:
}); });
child.on('close', (code) => { child.on('close', (code) => {
// Clean up temp file
try { fsSync.unlinkSync(tempFile); } catch (e) { }
// Clean up ANSI codes // Clean up ANSI codes
const cleanResponse = stdout.replace(/[\u001b\u009b][[\]()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '').trim(); 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) => { child.on('error', (error) => {
try { fsSync.unlinkSync(tempFile); } catch (e) { }
console.error('Qwen CLI spawn error:', error.message); console.error('Qwen CLI spawn error:', error.message);
resolve({ resolve({
success: false, success: false,
@@ -440,7 +457,6 @@ IMPORTANT RULES:
// Timeout after 120 seconds for long prompts // Timeout after 120 seconds for long prompts
setTimeout(() => { setTimeout(() => {
child.kill('SIGTERM'); child.kill('SIGTERM');
try { fsSync.unlinkSync(tempFile); } catch (e) { }
resolve({ resolve({
success: false, success: false,
error: 'Request timed out (120s)', error: 'Request timed out (120s)',