fix(tui): restore exec for shell commands, fix qwen input splitting, fix fs imports
This commit is contained in:
@@ -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() || '<name>');
|
||||
|
||||
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()}\\<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',
|
||||
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
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openqode-tui",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.1",
|
||||
"author": "Trae & Gemini",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
|
||||
@@ -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)',
|
||||
|
||||
Reference in New Issue
Block a user