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 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
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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)',
|
||||||
|
|||||||
Reference in New Issue
Block a user