import React, { useState, useCallback, useRef, useEffect } from 'react'; import { render, Box, Text, useInput, useApp, useAnimation, usePaste } from 'ink'; import { KobApiClient } from '../utils/api.js'; import { getConfig } from '../utils/config.js'; import { writeFileSync, mkdirSync, existsSync, readFileSync, statSync, copyFileSync } from 'fs'; import { resolve, basename, join, dirname, extname } from 'path'; import { fileURLToPath } from 'url'; import { spawnSync } from 'child_process'; import { chmodSync } from 'fs'; import { tmpdir, homedir } from 'os'; import { c } from './colors.js'; import { ModelPicker } from './model-picker.js'; import { ConfigForm } from './config-form.js'; import { readEnvFile, writeEnvFile, describeEnvPath } from '../utils/env-file.js'; // ============================================================================ // COLOR TOKENS (moved to ./colors.ts so sub-components can share) // ============================================================================ function readVersion(): string { try { const here = dirname(fileURLToPath(import.meta.url)); const pkg = JSON.parse(readFileSync(join(here, '..', '..', 'package.json'), 'utf-8')); return pkg.version || '0.0.0'; } catch { return '0.0.0'; } } // ============================================================================ // UTILITIES (unchanged logic) // ============================================================================ function formatV2Model(provider: string, model?: string): string { const m = model || 'deepseek-chat'; return m.includes('/') ? m : `${provider.toLowerCase()}/${m}`; } // Detect whether the model supports vision/images. // Heuristic: any model name containing vision-related tokens OR // the well-known multimodal families (claude-3, gpt-4o, gpt-4-vision, gemini, deepseek-vl, etc.) export function modelSupportsVision(model: string): boolean { const m = model.toLowerCase(); if (!m) return false; const visionTokens = [ 'vision', 'vl', 'gpt-4o', 'gpt-4-vision', 'gpt-4-turbo', 'claude-3', 'claude-3.5', 'claude-3.7', 'claude-sonnet-4', 'claude-opus-4', 'gemini', 'gemini-1.5', 'gemini-2', 'llava', 'qwen-vl', 'pixtral', 'llama-3.2-vision', 'v4-flash', 'flash', // treat deepseek-v4-flash as multimodal ]; return visionTokens.some(t => m.includes(t)); } const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.heic', '.heif']); export function isImagePath(p: string): boolean { return IMAGE_EXTS.has(extname(p).toLowerCase()); } export function kobImagesDir(): string { const dir = join(homedir(), '.kob-cli', 'images'); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); return dir; } export function kobHistoryDir(): string { const dir = join(homedir(), '.kob-cli', 'history'); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); return dir; } export function getHistoryFilePath(): string { const cwd = process.cwd(); const slug = cwd.replace(/[\\/:\s]+/g, '_').slice(0, 120); return join(kobHistoryDir(), `${slug}.json`); } export function loadChatHistory(): { exchanges: Exchange[]; messages: { role: string; content: string }[]; } { const path = getHistoryFilePath(); if (!existsSync(path)) return { exchanges: [], messages: [] }; try { const raw = readFileSync(path, 'utf-8'); const data = JSON.parse(raw); if (data.exchanges && Array.isArray(data.exchanges)) { return { exchanges: data.exchanges, messages: data.messages || [], }; } } catch { /* ignore corrupted history */ } return { exchanges: [], messages: [] }; } export function saveChatHistory( exchanges: Exchange[], messages: { role: string; content: string }[] ): void { const path = getHistoryFilePath(); try { writeFileSync( path, JSON.stringify({ exchanges, messages, savedAt: Date.now() }, null, 2), 'utf-8' ); } catch { /* ignore write errors */ } } export function clearChatHistory(): void { const path = getHistoryFilePath(); try { if (existsSync(path)) writeFileSync(path, '{}', 'utf-8'); } catch { /* ignore write errors */ } } // Save a local file path into our images dir and return the absolute path. export function attachImagePath(srcPath: string): string { const abs = resolve(srcPath); if (!existsSync(abs)) throw new Error(`File not found: ${abs}`); if (!isImagePath(abs)) throw new Error(`Not an image: ${abs}`); const dest = join(kobImagesDir(), `pasted-${Date.now()}${extname(abs)}`); copyFileSync(abs, dest); return dest; } // Try to read an image from the system clipboard. // Returns the absolute path of the saved file, or null if no image in clipboard. // Best-effort: Windows via PowerShell, macOS via osascript, Linux via xclip. // // SECURITY: All external commands are invoked via spawnSync with shell:false // and argv-based parameters (no string interpolation). This prevents shell // injection from a malicious clipboard payload or crafted path. export function pasteImageFromClipboard(): string | null { const platform = process.platform; try { if (platform === 'win32') { // PowerShell: read clipboard image, save to temp png. // We write the PowerShell script to a temp file and invoke it by // path with -File, passing the destination path as a single argv // element. This avoids quote-escaping bugs entirely. const dest = join(kobImagesDir(), `clipboard-${Date.now()}.png`); const { writeFileSync, unlinkSync } = require('fs') as typeof import('fs'); const { tmpdir: osTmp } = require('os') as typeof import('os'); const scriptPath = join(osTmp(), `kob-clip-${process.pid}-${Date.now()}.ps1`); // The script accepts a single $args[0] = destination path. The path // is passed as an argument, never interpolated into the script text. const script = [ 'Add-Type -AssemblyName System.Windows.Forms', 'Add-Type -AssemblyName System.Drawing', `$img = [System.Windows.Forms.Clipboard]::GetImage()`, `if ($img -eq $null) { exit 1 }`, `$dest = $args[0]`, `$img.Save($dest, [System.Drawing.Imaging.ImageFormat]::Png)`, 'Write-Output "OK"', ].join('\n'); writeFileSync(scriptPath, script, 'utf-8'); try { const r = spawnSync('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, dest], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], }); if (r.stdout && r.stdout.toString().includes('OK') && existsSync(dest)) return dest; } finally { try { unlinkSync(scriptPath); } catch { /* ignore */ } } } else if (platform === 'darwin') { // macOS: write the clipboard PNG to dest via osascript. // Pass the path as an argv element; the script reads it from argv. const dest = join(kobImagesDir(), `clipboard-${Date.now()}.png`); const scriptPath = join(tmpdir(), `kob-clip-${process.pid}-${Date.now()}.scpt`); const { writeFileSync: wfs, unlinkSync: ufs } = require('fs') as typeof import('fs'); // AppleScript: read the destination from the first command-line arg. // We use `on run argv` so the path is never interpolated into source. const ascript = [ 'on run argv', ' set theFile to (open for access (item 1 of argv) with write permission)', ' write (the clipboard as «class PNGf») to theFile', ' close access theFile', 'end run', ].join('\n'); wfs(scriptPath, ascript, 'utf-8'); try { spawnSync('osascript', [scriptPath, dest], { stdio: 'ignore' }); if (existsSync(dest) && statSync(dest).size > 0) return dest; } finally { try { ufs(scriptPath); } catch { /* ignore */ } } } else { // Linux: xclip / wl-paste. Use spawnSync with argv; redirect stdout // to the destination file via a small wrapper so we don't need a shell. const dest = join(kobImagesDir(), `clipboard-${Date.now()}.png`); const tryBin = (bin: string, extraArgs: string[]): boolean => { // Use shell-free redirect by piping through Node's spawn. // We open dest for writing, then spawn the binary and pipe its // stdout to the file descriptor. const { openSync } = require('fs') as typeof import('fs'); const { spawn } = require('child_process') as typeof import('child_process'); const fd = openSync(dest, 'w'); try { const child = spawn(bin, extraArgs, { stdio: ['ignore', fd, 'ignore'] }); return new Promise((resolvePipe) => { child.on('close', (code) => resolvePipe(code === 0)); child.on('error', () => resolvePipe(false)); }) as unknown as boolean; } catch { return false; } }; // Try xclip first, then wl-paste if (existsSync('/usr/bin/xclip') || existsSync('/usr/local/bin/xclip')) { const ok = tryBin('xclip', ['-selection', 'clipboard', '-t', 'image/png', '-o']); if (ok && existsSync(dest) && statSync(dest).size > 0) return dest; } if (existsSync('/usr/bin/wl-paste') || existsSync('/usr/local/bin/wl-paste')) { const ok = tryBin('wl-paste', ['--type', 'image/png']); if (ok && existsSync(dest) && statSync(dest).size > 0) return dest; } } } catch { /* fall through */ } return null; } function countTokens(text: string): number { return Math.ceil(text.length / 4); } export interface FileChange { filename: string; content: string; addLines: number; delLines: number; } export function writeFiles(files: FileChange[]): string[] { const created: string[] = []; const cwd = resolve(process.cwd()); for (const f of files) { if (!f.content) continue; // Sanitize filename: strip leading slashes, backslashes, and "../" sequences // to prevent path traversal attacks (e.g. "../../etc/passwd") let safeName = f.filename .replace(/^[/\\]+/, '') // strip leading slashes .replace(/\.\.\//g, '') // strip ../ path traversal .replace(/\.\.\\/g, ''); // strip ..\ (Windows) const filePath = resolve(cwd, safeName); // Prevent writing outside the project directory if (!filePath.startsWith(cwd + '/') && !filePath.startsWith(cwd + '\\')) { throw new Error( `Security: Refusing to write file outside project directory: ${f.filename}` ); } const dir = dirname(filePath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } writeFileSync(filePath, f.content, 'utf-8'); created.push(f.filename); } return created; } export function parseFileChanges(content: string): FileChange[] { const files: FileChange[] = []; const lines = content.split('\n'); let currentFilename = ''; let inCode = false; let collected: string[] = []; let lineCount = 0; for (let i = 0; i < lines.length; i++) { const line = lines[i] || ''; const trimmed = line.trimStart(); if (trimmed.startsWith('```')) { if (inCode) { if (currentFilename) { files.push({ filename: currentFilename, content: collected.join('\n'), addLines: lineCount, delLines: 0 }); } currentFilename = ''; collected = []; lineCount = 0; inCode = false; } else { inCode = true; lineCount = 0; collected = []; const lang = trimmed.slice(3).trim(); for (let j = i - 1; j >= Math.max(0, i - 5); j--) { const prev = (lines[j] || '').trim(); const fnMatch = prev.match(/(?:file|filepath|filename|create|edit|update|write):\s*([\w./\\@-]+)/i); if (fnMatch && fnMatch[1]) { currentFilename = fnMatch[1]; break; } const fnMatch2 = prev.match(/^([\w./\\@-]+\.[a-z]+)\s*$/i); if (fnMatch2 && fnMatch2[1] && !prev.includes('```') && !prev.startsWith('#')) { currentFilename = fnMatch2[1]; break; } const fnMatch3 = prev.match(/^[#/]+\s*([\w./\\@-]+\.[a-z]+)/i); if (fnMatch3 && fnMatch3[1]) { currentFilename = fnMatch3[1]; break; } } if (!currentFilename) currentFilename = lang || 'file'; } continue; } if (inCode) { if (lineCount < 3) { const codeFn = line.match(/(?:file|filepath|filename):\s*([\w./\\@-]+\.[a-z0-9]+)/i); if (codeFn && codeFn[1]) { currentFilename = codeFn[1]; } } collected.push(line); lineCount++; } } if (inCode && currentFilename && lineCount > 0) { files.push({ filename: currentFilename, content: collected.join('\n'), addLines: lineCount, delLines: 0 }); } return files; } // ============================================================================ // SHELL COMMAND PARSING + EXECUTION // ============================================================================ export interface CommandResult { cmd: string; ok: boolean; stdout: string; stderr: string; durationMs: number; exitCode: number; } const SHELL_LANGS = new Set(['bash', 'sh', 'shell', 'zsh', 'powershell', 'ps1', 'cmd', 'bat', 'console']); export function parseShellCommands(content: string): string[] { const commands: string[] = []; const lines = content.split('\n'); let inShellBlock = false; let collected: string[] = []; for (const line of lines) { const trimmed = line.trimStart(); if (trimmed.startsWith('```')) { if (inShellBlock) { const cmd = collected.join('\n').trim(); if (cmd) commands.push(cmd); inShellBlock = false; collected = []; } else { const lang = trimmed.slice(3).trim().toLowerCase().split(/[\s,]+/)[0] || ''; if (SHELL_LANGS.has(lang)) { inShellBlock = true; collected = []; } } continue; } if (inShellBlock) { collected.push(line); } } if (inShellBlock) { const cmd = collected.join('\n').trim(); if (cmd) commands.push(cmd); } return commands; } // ============================================================================ // SHELL COMMAND EXECUTION (allowlist + user confirmation) // ============================================================================ // We do NOT use a regex blacklist for shell commands — those are trivially // bypassed (tabs, backticks, $VAR, base64, etc.). Instead we use an allowlist // of safe command prefixes, and we ALWAYS require explicit user confirmation // before executing anything that writes, installs, mutates state, or talks // to the network. Read-only commands can run unattended. const READ_ONLY_COMMANDS = new Set([ 'ls', 'dir', 'pwd', 'cat', 'head', 'tail', 'less', 'more', 'echo', 'printf', 'wc', 'stat', 'file', 'find', 'tree', 'git status', 'git log', 'git diff', 'git branch', 'git show', 'npm ls', 'npm list', 'npm view', 'npm outdated', 'node -v', 'node --version', 'bun --version', 'npm --version', 'which', 'where', 'type', 'env', 'printenv', ]); function classifyCommand(cmd: string): 'read-only' | 'mutating' | 'unknown' { const firstToken = cmd.trim().split(/\s+/)[0]?.toLowerCase() || ''; if (READ_ONLY_COMMANDS.has(firstToken)) return 'read-only'; // Common mutating subcommand prefixes const mutatingPrefixes = [ 'npm install', 'npm i ', 'npm add', 'npm remove', 'npm rm ', 'npm run', 'npm test', 'npm run', 'yarn add', 'yarn install', 'yarn remove', 'pnpm add', 'pnpm install', 'pnpm remove', 'bun add', 'bun install', 'bun remove', 'git add', 'git commit', 'git push', 'git pull', 'git merge', 'git rebase', 'git checkout', 'git branch -d', 'git branch -D', 'git stash', 'mkdir ', 'rmdir ', 'rm ', 'rm\t', 'mv ', 'cp ', 'touch ', 'chmod ', 'chown ', 'curl ', 'wget ', 'ssh ', 'scp ', 'rsync ', '>', '>>', 'tee ', 'sudo ', 'su ', ]; const lower = cmd.toLowerCase(); if (mutatingPrefixes.some(p => lower.startsWith(p))) return 'mutating'; return 'unknown'; } // In-memory queue of commands waiting for user approval. // Populated by runShellCommand() (which returns a "pending" result) and // resolved by confirmPendingCommand() when the user hits y/n in the TUI. type PendingCommand = { cmd: string; resolve: (approved: boolean) => void; }; const pendingCommands: PendingCommand[] = []; let pendingCommandListener: ((cmd: string) => void) | null = null; export function onPendingCommand(listener: (cmd: string) => void): void { pendingCommandListener = listener; } export function confirmPendingCommand(approved: boolean): void { const next = pendingCommands.shift(); if (next) next.resolve(approved); if (pendingCommandListener && pendingCommands.length === 0) { pendingCommandListener(''); } } function listPendingCommands(): string[] { return pendingCommands.map(p => p.cmd); } export function runShellCommand( cmd: string, cwd: string = process.cwd(), opts: { autoApproveReadOnly?: boolean; userApprover?: (cmd: string) => Promise } = {} ): CommandResult | Promise { const t0 = Date.now(); const cleaned = cmd.split('\n').map(l => l.replace(/^\s*[\$❯]\s?/, '')).join('\n'); const classification = classifyCommand(cleaned); // Read-only commands may run without confirmation if (classification === 'read-only' && opts.autoApproveReadOnly !== false) { return executeCommand(cleaned, cwd, t0); } // Mutating / unknown commands require user approval if (opts.userApprover) { return opts.userApprover(cleaned).then(approved => { if (!approved) { return { cmd: cleaned, ok: false, stdout: '', stderr: '[SECURITY] Command blocked: user denied execution.', durationMs: Date.now() - t0, exitCode: 1, } as CommandResult; } return executeCommand(cleaned, cwd, t0); }); } // No approver registered (e.g. running from the `code` CLI command, not the TUI). // Refuse to run anything mutating without explicit approval. return { cmd: cleaned, ok: false, stdout: '', stderr: `[SECURITY] Command refused: "${classifyCommand(cleaned)}" command requires user approval. Run from the interactive TUI to confirm.`, durationMs: Date.now() - t0, exitCode: 1, } as CommandResult; } function executeCommand(cleaned: string, cwd: string, t0: number): CommandResult { try { // Use spawn (not execSync with shell:true) so the command runs without // an extra shell layer. The first whitespace-separated token is the // binary; everything else is an argv. This eliminates shell-injection // vectors like `;`, `|`, backticks, $(). const { spawnSync } = require('child_process') as typeof import('child_process'); const parts = cleaned.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || []; const [bin, ...args] = parts; if (!bin) { return { cmd: cleaned, ok: false, stdout: '', stderr: '[SECURITY] Empty command.', durationMs: Date.now() - t0, exitCode: 1, }; } const result = spawnSync(bin, args, { encoding: 'utf-8', cwd, timeout: 60_000, stdio: ['ignore', 'pipe', 'pipe'], // shell: false (the default) — do NOT use a shell wrapper. shell: false, }); if (result.error) throw result.error; return { cmd: cleaned, ok: result.status === 0, stdout: (result.stdout || '').toString(), stderr: (result.stderr || '').toString(), durationMs: Date.now() - t0, exitCode: typeof result.status === 'number' ? result.status : 1, }; } catch (err: any) { return { cmd: cleaned, ok: false, stdout: err.stdout ? err.stdout.toString() : '', stderr: err.stderr ? err.stderr.toString() : (err.message || 'command failed'), durationMs: Date.now() - t0, exitCode: typeof err.status === 'number' ? err.status : 1, }; } } function formatDuration(ms: number): string { const total = Math.floor(ms / 1000); const h = Math.floor(total / 3600); const m = Math.floor((total % 3600) / 60); const s = total % 60; if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; } function formatNum(n: number): string { if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'; if (n >= 1_000) return (n / 1_000).toFixed(1) + 'k'; return n.toString(); } // ============================================================================ // MODE CONFIG — ask / plan / code // ============================================================================ type Mode = 'ask' | 'plan' | 'code'; interface ModeInfo { key: Mode; label: string; icon: string; shortcut: string; color: string; description: string; placeholder: string; statusMessages: string[]; systemPrompt: string; writesFiles: boolean; } const MODES: ModeInfo[] = [ { key: 'ask', label: 'Ask', icon: '💡', shortcut: '1', color: c.blue, description: 'Just answer questions. No files written.', placeholder: 'Ask me anything...', statusMessages: ['Thinking...', 'Looking up information', 'Composing answer', 'Reviewing', 'Finalizing'], systemPrompt: 'You are a helpful AI assistant. Answer questions concisely and accurately. Use plain text with light markdown for clarity. Do not produce code blocks unless the user explicitly asks for code.', writesFiles: false, }, { key: 'plan', label: 'Plan', icon: '◐', shortcut: '2', color: c.yellow, description: 'Plan first, then implement. Files only written after approval.', placeholder: 'Describe what you want to build...', statusMessages: ['Analyzing request', 'Researching approach', 'Drafting plan', 'Reviewing trade-offs', 'Finalizing plan'], systemPrompt: 'You are an expert software architect. When the user describes what to build, first present a clear implementation plan with bullet points covering: (1) files to create/modify, (2) key components and their responsibilities, (3) data flow, (4) any trade-offs. DO NOT write code yet. Wait for the user to confirm with "go" or similar, then in the next turn you may output code with proper filename comments like // filename: path/to/file.ext.', writesFiles: false, }, { key: 'code', label: 'Code', icon: '◉', shortcut: '3', color: c.green, description: 'Write code, run commands. Fully autonomous — 128k context.', placeholder: 'What do you want to build?', statusMessages: ['Analyzing request', 'Designing architecture', 'Writing code', 'Installing dependencies', 'Running tests', 'Reviewing output', 'Finalizing'], systemPrompt: `You are an expert programmer working in a fully autonomous CLI agent. The user wants hands-off execution — they will not confirm anything. OUTPUT FORMAT (strict): - For each file: open a code block with a FILENAME COMMENT on the first line inside the block. Example: \`\`\`ts\n// filename: src/server.ts\n\n\`\`\` Accepted filename comment styles: \`// filename:\`, \`# filename:\`, \`