kob-cli 1.0.4 → 1.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/index.ts +18 -55
- package/src/ui/code-tui.tsx +1494 -215
- package/src/ui/colors.ts +19 -0
- package/src/ui/config-form.tsx +187 -0
- package/src/ui/model-picker.tsx +183 -0
- package/src/utils/env-file.ts +123 -0
package/src/ui/code-tui.tsx
CHANGED
|
@@ -1,24 +1,116 @@
|
|
|
1
|
-
import React, { useState, useCallback, useRef } from 'react';
|
|
2
|
-
import { render, Box, Text, useInput, useApp, useAnimation } from 'ink';
|
|
1
|
+
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
|
2
|
+
import { render, Box, Text, useInput, useApp, useAnimation, usePaste } from 'ink';
|
|
3
3
|
import { KobApiClient } from '../utils/api.js';
|
|
4
4
|
import { getConfig } from '../utils/config.js';
|
|
5
5
|
import { handleApiError } from '../utils/errors.js';
|
|
6
|
-
import { writeFileSync, mkdirSync, existsSync } from 'fs';
|
|
7
|
-
import { dirname,
|
|
6
|
+
import { writeFileSync, mkdirSync, existsSync, readFileSync, statSync, copyFileSync } from 'fs';
|
|
7
|
+
import { resolve, basename, join, dirname, extname } from 'path';
|
|
8
|
+
import { fileURLToPath } from 'url';
|
|
9
|
+
import { execSync } from 'child_process';
|
|
10
|
+
import { tmpdir, homedir } from 'os';
|
|
11
|
+
import { c } from './colors.js';
|
|
12
|
+
import { ModelPicker } from './model-picker.js';
|
|
13
|
+
import { ConfigForm } from './config-form.js';
|
|
14
|
+
import { readEnvFile, writeEnvFile, describeEnvPath } from '../utils/env-file.js';
|
|
8
15
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const pink = '#f472b6';
|
|
13
|
-
const yellow = '#fbbf24';
|
|
14
|
-
const red = '#ef4444';
|
|
15
|
-
const gray = '#6b7280';
|
|
16
|
+
// ============================================================================
|
|
17
|
+
// COLOR TOKENS (moved to ./colors.ts so sub-components can share)
|
|
18
|
+
// ============================================================================
|
|
16
19
|
|
|
20
|
+
function readVersion(): string {
|
|
21
|
+
try {
|
|
22
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const pkg = JSON.parse(readFileSync(join(here, '..', '..', 'package.json'), 'utf-8'));
|
|
24
|
+
return pkg.version || '0.0.0';
|
|
25
|
+
} catch {
|
|
26
|
+
return '0.0.0';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ============================================================================
|
|
31
|
+
// UTILITIES (unchanged logic)
|
|
32
|
+
// ============================================================================
|
|
17
33
|
function formatV2Model(provider: string, model?: string): string {
|
|
18
34
|
const m = model || 'deepseek-chat';
|
|
19
35
|
return m.includes('/') ? m : `${provider.toLowerCase()}/${m}`;
|
|
20
36
|
}
|
|
21
37
|
|
|
38
|
+
// Detect whether the model supports vision/images.
|
|
39
|
+
// Heuristic: any model name containing vision-related tokens OR
|
|
40
|
+
// the well-known multimodal families (claude-3, gpt-4o, gpt-4-vision, gemini, deepseek-vl, etc.)
|
|
41
|
+
export function modelSupportsVision(model: string): boolean {
|
|
42
|
+
const m = model.toLowerCase();
|
|
43
|
+
if (!m) return false;
|
|
44
|
+
const visionTokens = [
|
|
45
|
+
'vision', 'vl', 'gpt-4o', 'gpt-4-vision', 'gpt-4-turbo',
|
|
46
|
+
'claude-3', 'claude-3.5', 'claude-3.7', 'claude-sonnet-4', 'claude-opus-4',
|
|
47
|
+
'gemini', 'gemini-1.5', 'gemini-2',
|
|
48
|
+
'llava', 'qwen-vl', 'pixtral', 'llama-3.2-vision',
|
|
49
|
+
'v4-flash', 'flash', // treat deepseek-v4-flash as multimodal
|
|
50
|
+
];
|
|
51
|
+
return visionTokens.some(t => m.includes(t));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.heic', '.heif']);
|
|
55
|
+
export function isImagePath(p: string): boolean {
|
|
56
|
+
return IMAGE_EXTS.has(extname(p).toLowerCase());
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function kobImagesDir(): string {
|
|
60
|
+
const dir = join(homedir(), '.kob-cli', 'images');
|
|
61
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
62
|
+
return dir;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Save a local file path into our images dir and return the absolute path.
|
|
66
|
+
export function attachImagePath(srcPath: string): string {
|
|
67
|
+
const abs = resolve(srcPath);
|
|
68
|
+
if (!existsSync(abs)) throw new Error(`File not found: ${abs}`);
|
|
69
|
+
if (!isImagePath(abs)) throw new Error(`Not an image: ${abs}`);
|
|
70
|
+
const dest = join(kobImagesDir(), `pasted-${Date.now()}${extname(abs)}`);
|
|
71
|
+
copyFileSync(abs, dest);
|
|
72
|
+
return dest;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Try to read an image from the system clipboard.
|
|
76
|
+
// Returns the absolute path of the saved file, or null if no image in clipboard.
|
|
77
|
+
// Best-effort: Windows via PowerShell, macOS via osascript, Linux via xclip.
|
|
78
|
+
export function pasteImageFromClipboard(): string | null {
|
|
79
|
+
const platform = process.platform;
|
|
80
|
+
try {
|
|
81
|
+
if (platform === 'win32') {
|
|
82
|
+
// PowerShell: read clipboard image, save to temp png
|
|
83
|
+
const dest = join(kobImagesDir(), `clipboard-${Date.now()}.png`);
|
|
84
|
+
const script = `
|
|
85
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
86
|
+
Add-Type -AssemblyName System.Drawing
|
|
87
|
+
$img = [System.Windows.Forms.Clipboard]::GetImage()
|
|
88
|
+
if ($img -eq $null) { exit 1 }
|
|
89
|
+
$img.Save('${dest.replace(/\\/g, '\\\\')}', [System.Drawing.Imaging.ImageFormat]::Png)
|
|
90
|
+
Write-Output "OK"
|
|
91
|
+
`;
|
|
92
|
+
const out = execSync(`powershell -NoProfile -Command "${script.replace(/\n/g, '; ')}"`, { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
93
|
+
if (out.toString().includes('OK') && existsSync(dest)) return dest;
|
|
94
|
+
} else if (platform === 'darwin') {
|
|
95
|
+
const dest = join(kobImagesDir(), `clipboard-${Date.now()}.png`);
|
|
96
|
+
execSync(`osascript -e 'set theFile to (open for access "${dest}" with write permission)
|
|
97
|
+
write (the clipboard as «class PNGf») to theFile
|
|
98
|
+
close access theFile'`, { stdio: 'ignore' });
|
|
99
|
+
if (existsSync(dest) && statSync(dest).size > 0) return dest;
|
|
100
|
+
} else {
|
|
101
|
+
// Linux: xclip / wl-paste
|
|
102
|
+
const dest = join(kobImagesDir(), `clipboard-${Date.now()}.png`);
|
|
103
|
+
try {
|
|
104
|
+
execSync(`xclip -selection clipboard -t image/png -o > "${dest}"`, { stdio: 'ignore', shell: '/bin/sh' });
|
|
105
|
+
} catch {
|
|
106
|
+
execSync(`wl-paste --type image/png > "${dest}"`, { stdio: 'ignore', shell: '/bin/sh' });
|
|
107
|
+
}
|
|
108
|
+
if (existsSync(dest) && statSync(dest).size > 0) return dest;
|
|
109
|
+
}
|
|
110
|
+
} catch { /* fall through */ }
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
22
114
|
function countTokens(text: string): number {
|
|
23
115
|
return Math.ceil(text.length / 4);
|
|
24
116
|
}
|
|
@@ -72,7 +164,6 @@ export function parseFileChanges(content: string): FileChange[] {
|
|
|
72
164
|
lineCount = 0;
|
|
73
165
|
collected = [];
|
|
74
166
|
const lang = trimmed.slice(3).trim();
|
|
75
|
-
// Look for filename in lines before code block
|
|
76
167
|
for (let j = i - 1; j >= Math.max(0, i - 5); j--) {
|
|
77
168
|
const prev = (lines[j] || '').trim();
|
|
78
169
|
const fnMatch = prev.match(/(?:file|filepath|filename|create|edit|update|write):\s*([\w./\\@-]+)/i);
|
|
@@ -90,7 +181,6 @@ export function parseFileChanges(content: string): FileChange[] {
|
|
|
90
181
|
}
|
|
91
182
|
|
|
92
183
|
if (inCode) {
|
|
93
|
-
// Detect filename from first code lines (e.g. # filename: app.py)
|
|
94
184
|
if (lineCount < 3) {
|
|
95
185
|
const codeFn = line.match(/(?:file|filepath|filename):\s*([\w./\\@-]+\.[a-z0-9]+)/i);
|
|
96
186
|
if (codeFn && codeFn[1]) {
|
|
@@ -108,233 +198,1218 @@ export function parseFileChanges(content: string): FileChange[] {
|
|
|
108
198
|
return files;
|
|
109
199
|
}
|
|
110
200
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
onSubmit: (text: string) => void;
|
|
122
|
-
placeholder?: string;
|
|
201
|
+
// ============================================================================
|
|
202
|
+
// SHELL COMMAND PARSING + EXECUTION
|
|
203
|
+
// ============================================================================
|
|
204
|
+
export interface CommandResult {
|
|
205
|
+
cmd: string;
|
|
206
|
+
ok: boolean;
|
|
207
|
+
stdout: string;
|
|
208
|
+
stderr: string;
|
|
209
|
+
durationMs: number;
|
|
210
|
+
exitCode: number;
|
|
123
211
|
}
|
|
124
212
|
|
|
125
|
-
|
|
126
|
-
const [value, setValue] = useState('');
|
|
213
|
+
const SHELL_LANGS = new Set(['bash', 'sh', 'shell', 'zsh', 'powershell', 'ps1', 'cmd', 'bat', 'console']);
|
|
127
214
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
215
|
+
export function parseShellCommands(content: string): string[] {
|
|
216
|
+
const commands: string[] = [];
|
|
217
|
+
const lines = content.split('\n');
|
|
218
|
+
let inShellBlock = false;
|
|
219
|
+
let collected: string[] = [];
|
|
220
|
+
|
|
221
|
+
for (const line of lines) {
|
|
222
|
+
const trimmed = line.trimStart();
|
|
223
|
+
if (trimmed.startsWith('```')) {
|
|
224
|
+
if (inShellBlock) {
|
|
225
|
+
const cmd = collected.join('\n').trim();
|
|
226
|
+
if (cmd) commands.push(cmd);
|
|
227
|
+
inShellBlock = false;
|
|
228
|
+
collected = [];
|
|
229
|
+
} else {
|
|
230
|
+
const lang = trimmed.slice(3).trim().toLowerCase().split(/[\s,]+/)[0] || '';
|
|
231
|
+
if (SHELL_LANGS.has(lang)) {
|
|
232
|
+
inShellBlock = true;
|
|
233
|
+
collected = [];
|
|
139
234
|
}
|
|
140
|
-
onSubmit(trimmed);
|
|
141
|
-
setValue('');
|
|
142
235
|
}
|
|
143
|
-
|
|
144
|
-
}
|
|
145
|
-
if (key.backspace || key.delete) {
|
|
146
|
-
setValue(prev => prev.slice(0, -1));
|
|
147
|
-
return;
|
|
236
|
+
continue;
|
|
148
237
|
}
|
|
149
|
-
if (
|
|
150
|
-
|
|
238
|
+
if (inShellBlock) {
|
|
239
|
+
collected.push(line);
|
|
151
240
|
}
|
|
152
|
-
}
|
|
241
|
+
}
|
|
242
|
+
if (inShellBlock) {
|
|
243
|
+
const cmd = collected.join('\n').trim();
|
|
244
|
+
if (cmd) commands.push(cmd);
|
|
245
|
+
}
|
|
246
|
+
return commands;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function runShellCommand(cmd: string, cwd: string = process.cwd()): CommandResult {
|
|
250
|
+
const t0 = Date.now();
|
|
251
|
+
// Strip a leading "$ " or "❯ " prompt if the model wrote one
|
|
252
|
+
const cleaned = cmd.split('\n').map(l => l.replace(/^\s*[\$❯]\s?/, '')).join('\n');
|
|
253
|
+
try {
|
|
254
|
+
const stdout = execSync(cleaned, {
|
|
255
|
+
encoding: 'utf-8',
|
|
256
|
+
timeout: 60_000,
|
|
257
|
+
cwd,
|
|
258
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
259
|
+
shell: process.platform === 'win32' ? 'cmd.exe' : '/bin/sh',
|
|
260
|
+
});
|
|
261
|
+
return {
|
|
262
|
+
cmd: cleaned,
|
|
263
|
+
ok: true,
|
|
264
|
+
stdout: (stdout || '').toString(),
|
|
265
|
+
stderr: '',
|
|
266
|
+
durationMs: Date.now() - t0,
|
|
267
|
+
exitCode: 0,
|
|
268
|
+
};
|
|
269
|
+
} catch (err: any) {
|
|
270
|
+
const e = err as any;
|
|
271
|
+
return {
|
|
272
|
+
cmd: cleaned,
|
|
273
|
+
ok: false,
|
|
274
|
+
stdout: e.stdout ? e.stdout.toString() : '',
|
|
275
|
+
stderr: e.stderr ? e.stderr.toString() : (e.message || 'command failed'),
|
|
276
|
+
durationMs: Date.now() - t0,
|
|
277
|
+
exitCode: typeof e.status === 'number' ? e.status : 1,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function formatDuration(ms: number): string {
|
|
283
|
+
const total = Math.floor(ms / 1000);
|
|
284
|
+
const h = Math.floor(total / 3600);
|
|
285
|
+
const m = Math.floor((total % 3600) / 60);
|
|
286
|
+
const s = total % 60;
|
|
287
|
+
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
|
288
|
+
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function formatNum(n: number): string {
|
|
292
|
+
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
|
|
293
|
+
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'k';
|
|
294
|
+
return n.toString();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ============================================================================
|
|
298
|
+
// MODE CONFIG — ask / plan / code
|
|
299
|
+
// ============================================================================
|
|
300
|
+
type Mode = 'ask' | 'plan' | 'code';
|
|
301
|
+
|
|
302
|
+
interface ModeInfo {
|
|
303
|
+
key: Mode;
|
|
304
|
+
label: string;
|
|
305
|
+
icon: string;
|
|
306
|
+
shortcut: string;
|
|
307
|
+
color: string;
|
|
308
|
+
description: string;
|
|
309
|
+
placeholder: string;
|
|
310
|
+
statusMessages: string[];
|
|
311
|
+
systemPrompt: string;
|
|
312
|
+
writesFiles: boolean;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const MODES: ModeInfo[] = [
|
|
316
|
+
{
|
|
317
|
+
key: 'ask',
|
|
318
|
+
label: 'Ask',
|
|
319
|
+
icon: '💡',
|
|
320
|
+
shortcut: '1',
|
|
321
|
+
color: c.blue,
|
|
322
|
+
description: 'Just answer questions. No files written.',
|
|
323
|
+
placeholder: 'Ask me anything...',
|
|
324
|
+
statusMessages: ['Thinking...', 'Looking up information', 'Composing answer', 'Reviewing', 'Finalizing'],
|
|
325
|
+
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.',
|
|
326
|
+
writesFiles: false,
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
key: 'plan',
|
|
330
|
+
label: 'Plan',
|
|
331
|
+
icon: '◐',
|
|
332
|
+
shortcut: '2',
|
|
333
|
+
color: c.yellow,
|
|
334
|
+
description: 'Plan first, then implement. Files only written after approval.',
|
|
335
|
+
placeholder: 'Describe what you want to build...',
|
|
336
|
+
statusMessages: ['Analyzing request', 'Researching approach', 'Drafting plan', 'Reviewing trade-offs', 'Finalizing plan'],
|
|
337
|
+
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.',
|
|
338
|
+
writesFiles: false,
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
key: 'code',
|
|
342
|
+
label: 'Code',
|
|
343
|
+
icon: '◉',
|
|
344
|
+
shortcut: '3',
|
|
345
|
+
color: c.green,
|
|
346
|
+
description: 'Write code, run commands. Fully autonomous — 128k context.',
|
|
347
|
+
placeholder: 'What do you want to build?',
|
|
348
|
+
statusMessages: ['Analyzing request', 'Designing architecture', 'Writing code', 'Installing dependencies', 'Running tests', 'Reviewing output', 'Finalizing'],
|
|
349
|
+
systemPrompt: `You are an expert programmer working in a fully autonomous CLI agent. The user wants hands-off execution — they will not confirm anything.
|
|
350
|
+
|
|
351
|
+
OUTPUT FORMAT (strict):
|
|
352
|
+
- For each file: open a code block with a FILENAME COMMENT on the first line inside the block.
|
|
353
|
+
Example: \`\`\`ts\n// filename: src/server.ts\n<code here>\n\`\`\`
|
|
354
|
+
Accepted filename comment styles: \`// filename:\`, \`# filename:\`, \`<!-- filename:\` — pick the one that matches the file's language.
|
|
355
|
+
- For each shell command you want to run (install deps, run tests, start servers, etc.): open a code block with language "bash".
|
|
356
|
+
Example: \`\`\`bash\nnpm install express\n\`\`\`
|
|
357
|
+
These will be EXECUTED AUTOMATICALLY. Never ask for permission.
|
|
358
|
+
- Mix files and commands in any order. Do not write any other prose — only files, bash blocks, and one short summary line at the end.
|
|
359
|
+
- Keep going iteratively. If a command fails, fix the code and try again. Loop until the task is fully done.
|
|
360
|
+
|
|
361
|
+
IMPORTANT:
|
|
362
|
+
- You can run MANY rounds in one turn. The user has a 128k context window — use it.
|
|
363
|
+
- Never end with "let me know if you want me to..." — just complete the task.`,
|
|
364
|
+
writesFiles: true,
|
|
365
|
+
},
|
|
366
|
+
];
|
|
367
|
+
|
|
368
|
+
function getMode(key: Mode): ModeInfo {
|
|
369
|
+
return MODES.find(m => m.key === key) || MODES[2]!;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ============================================================================
|
|
373
|
+
// SHARED ATOMS
|
|
374
|
+
// ============================================================================
|
|
375
|
+
// PanelTitle and Field helpers were used by the old right-side panel that has
|
|
376
|
+
// been merged into the top BrandHeader. The header now renders the same info
|
|
377
|
+
// inline, so the helpers are intentionally gone.
|
|
378
|
+
|
|
379
|
+
// ============================================================================
|
|
380
|
+
// SLASH COMMANDS — shown as autocomplete when user types "/"
|
|
381
|
+
// ============================================================================
|
|
382
|
+
interface SlashCommand {
|
|
383
|
+
name: string;
|
|
384
|
+
desc: string;
|
|
385
|
+
icon: string;
|
|
386
|
+
group: 'mode' | 'session' | 'meta';
|
|
387
|
+
}
|
|
388
|
+
const SLASH_COMMANDS: SlashCommand[] = [
|
|
389
|
+
{ name: 'ask', desc: 'switch to Ask mode (questions only)', icon: '💡', group: 'mode' },
|
|
390
|
+
{ name: 'plan', desc: 'switch to Plan mode (design first)', icon: '○', group: 'mode' },
|
|
391
|
+
{ name: 'code', desc: 'switch to Code mode (full agent)', icon: '●', group: 'mode' },
|
|
392
|
+
{ name: 'clear', desc: 'clear this session (forget history)', icon: '⌫', group: 'session' },
|
|
393
|
+
{ name: 'reset', desc: 'reset model to the .env default', icon: '↺', group: 'session' },
|
|
394
|
+
{ name: 'models', desc: 'pick a model from the catalog', icon: '◆', group: 'session' },
|
|
395
|
+
{ name: 'config', desc: 'edit base_url, key, model → .env', icon: '⚙', group: 'meta' },
|
|
396
|
+
{ name: 'help', desc: 'list every slash command', icon: '?', group: 'meta' },
|
|
397
|
+
{ name: 'exit', desc: 'quit KOB CLI', icon: '⎋', group: 'meta' },
|
|
398
|
+
];
|
|
399
|
+
|
|
400
|
+
// ============================================================================
|
|
401
|
+
// MODE SELECTOR — Tab/1/2/3 to switch
|
|
402
|
+
// ============================================================================
|
|
403
|
+
// Each mode button has a fixed visual width so the tab bar lines up
|
|
404
|
+
const MODE_BTN_WIDTH = 11;
|
|
153
405
|
|
|
406
|
+
function ModeButton({ m, isActive }: { m: ModeInfo; isActive: boolean }) {
|
|
407
|
+
// Wrap in a fixed-width Box so columns line up
|
|
154
408
|
return (
|
|
155
|
-
<Box
|
|
156
|
-
<
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
</Box>
|
|
409
|
+
<Box width={MODE_BTN_WIDTH} justifyContent="center">
|
|
410
|
+
<Text
|
|
411
|
+
color={isActive ? c.bg : c.textMuted}
|
|
412
|
+
bold={isActive}
|
|
413
|
+
backgroundColor={isActive ? m.color : undefined}
|
|
414
|
+
>
|
|
415
|
+
{' '}{m.icon} {m.label}{' '}
|
|
416
|
+
</Text>
|
|
164
417
|
</Box>
|
|
165
418
|
);
|
|
166
419
|
}
|
|
167
420
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
421
|
+
function ModeSelector({ mode }: { mode: Mode }) {
|
|
422
|
+
return (
|
|
423
|
+
<Box>
|
|
424
|
+
{MODES.map((m) => (
|
|
425
|
+
<ModeButton key={m.key} m={m} isActive={m.key === mode} />
|
|
426
|
+
))}
|
|
427
|
+
</Box>
|
|
428
|
+
);
|
|
429
|
+
}
|
|
171
430
|
|
|
172
|
-
|
|
173
|
-
|
|
431
|
+
// ============================================================================
|
|
432
|
+
// BRAND HEADER — big logo + info bar + quick start (always at top)
|
|
433
|
+
// ============================================================================
|
|
434
|
+
type Phase = 'input' | 'generating';
|
|
174
435
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
436
|
+
function BrandHeader({
|
|
437
|
+
phase,
|
|
438
|
+
modelName,
|
|
439
|
+
provider,
|
|
440
|
+
version,
|
|
441
|
+
session,
|
|
442
|
+
}: {
|
|
443
|
+
phase: Phase;
|
|
444
|
+
modelName: string;
|
|
445
|
+
provider: string;
|
|
446
|
+
version: string;
|
|
447
|
+
session: {
|
|
448
|
+
rounds: number;
|
|
449
|
+
files: number;
|
|
450
|
+
commands: number;
|
|
451
|
+
elapsed: number;
|
|
452
|
+
totalIn: number;
|
|
453
|
+
totalOut: number;
|
|
454
|
+
};
|
|
455
|
+
}) {
|
|
456
|
+
const { frame } = useAnimation({ interval: 600 });
|
|
457
|
+
const isThinking = phase === 'generating';
|
|
458
|
+
const dotColor = isThinking ? c.yellow : c.green;
|
|
459
|
+
const statusText = isThinking ? 'thinking' : 'ready';
|
|
460
|
+
const statusIcon = isThinking ? '⟳' : '✓';
|
|
179
461
|
|
|
180
|
-
const
|
|
462
|
+
const K = c.brand; // cyan for KOB
|
|
463
|
+
const C = c.pink; // pink for CLI
|
|
181
464
|
|
|
182
465
|
return (
|
|
183
|
-
<Box flexDirection="column"
|
|
184
|
-
|
|
185
|
-
|
|
466
|
+
<Box flexDirection="column">
|
|
467
|
+
{/* Big ASCII KOB CLI logo */}
|
|
468
|
+
<Box flexDirection="column" alignItems="center">
|
|
469
|
+
<Box>
|
|
470
|
+
<Text color={K}>██╗ ██╗ ██████╗ ██████╗ </Text>
|
|
471
|
+
<Text color={C}>██████╗██╗ ██╗</Text>
|
|
472
|
+
</Box>
|
|
473
|
+
<Box>
|
|
474
|
+
<Text color={K}>██║ ██╔╝██╔═══██╗██╔══██╗ </Text>
|
|
475
|
+
<Text color={C}>██╔════╝██║ ██║</Text>
|
|
476
|
+
</Box>
|
|
477
|
+
<Box>
|
|
478
|
+
<Text color={K}>█████╔╝ ██║ ██║██████╔╝ </Text>
|
|
479
|
+
<Text color={C}>██║ ██║ ██║</Text>
|
|
480
|
+
</Box>
|
|
481
|
+
<Box>
|
|
482
|
+
<Text color={K}>██╔═██╗ ██║ ██║██╔══██╗ </Text>
|
|
483
|
+
<Text color={C}>██║ ██║ ██║</Text>
|
|
484
|
+
</Box>
|
|
485
|
+
<Box>
|
|
486
|
+
<Text color={K}>██║ ██╗╚██████╔╝██████╔╝ </Text>
|
|
487
|
+
<Text color={C}>╚██████╗███████╗██║</Text>
|
|
488
|
+
</Box>
|
|
489
|
+
<Box>
|
|
490
|
+
<Text color={K}>╚═╝ ╚═╝ ╚═════╝ ╚═════╝ </Text>
|
|
491
|
+
<Text color={C}>╚═════╝╚══════╝╚═╝</Text>
|
|
492
|
+
</Box>
|
|
186
493
|
</Box>
|
|
187
|
-
{messages.map((msg, i) => {
|
|
188
|
-
const isDone = i < currentStep;
|
|
189
|
-
const isActive = i === currentStep;
|
|
190
|
-
const isPending = i > currentStep;
|
|
191
|
-
|
|
192
|
-
let icon = '○';
|
|
193
|
-
let textColor = gray;
|
|
194
|
-
let iconColor = gray;
|
|
195
|
-
|
|
196
|
-
if (isDone) {
|
|
197
|
-
icon = '✅';
|
|
198
|
-
textColor = green;
|
|
199
|
-
iconColor = green;
|
|
200
|
-
} else if (isActive) {
|
|
201
|
-
icon = spinner!;
|
|
202
|
-
textColor = shimmerColors[(colorIdx + i * 3) % shimmerColors.length]!;
|
|
203
|
-
iconColor = shimmerColors[colorIdx]!;
|
|
204
|
-
}
|
|
205
494
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
495
|
+
{/* Info bar with brand identity + live status */}
|
|
496
|
+
<Box
|
|
497
|
+
borderStyle="round"
|
|
498
|
+
borderColor={c.brand}
|
|
499
|
+
paddingX={1}
|
|
500
|
+
flexDirection="column"
|
|
501
|
+
marginTop={1}
|
|
502
|
+
>
|
|
503
|
+
<Box>
|
|
504
|
+
<Text color={c.text} bold>KOB CLI</Text>
|
|
505
|
+
<Text color={c.textDim}> </Text>
|
|
506
|
+
<Text color={c.pink} italic>Thailand</Text>
|
|
507
|
+
<Text> </Text>
|
|
508
|
+
<Text backgroundColor="red" color="red">▰▰</Text>
|
|
509
|
+
<Text> </Text>
|
|
510
|
+
<Text color="white" bold>▰</Text>
|
|
511
|
+
<Text> </Text>
|
|
512
|
+
<Text backgroundColor="blue" color="blue">▰▰</Text>
|
|
513
|
+
<Text color={c.textDim}> · </Text>
|
|
514
|
+
<Text color={c.brand} bold>powered by</Text>
|
|
515
|
+
<Text> </Text>
|
|
516
|
+
<Text color={c.green} bold>Tavon Seesenpila</Text>
|
|
517
|
+
<Text color={c.textDim}> </Text>
|
|
518
|
+
<Text color={c.textMuted} italic>Founder of Kob AI</Text>
|
|
519
|
+
</Box>
|
|
520
|
+
|
|
521
|
+
{/* Row 2: version + model info */}
|
|
522
|
+
<Box>
|
|
523
|
+
<Box>
|
|
524
|
+
<Text color={c.textDim}>v{version} · AI Command-Line Interface</Text>
|
|
214
525
|
</Box>
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
526
|
+
<Box flexGrow={1} />
|
|
527
|
+
<Box>
|
|
528
|
+
<Text color={c.textDim}>model </Text>
|
|
529
|
+
<Text color={c.pink} bold>{modelName}</Text>
|
|
530
|
+
<Text color={c.textDim}> · </Text>
|
|
531
|
+
<Text color={c.text}>{provider}</Text>
|
|
532
|
+
<Text color={c.textDim}> · </Text>
|
|
533
|
+
<Text color={c.textMuted}>/v2/chat</Text>
|
|
534
|
+
<Text color={c.textDim}> · </Text>
|
|
535
|
+
<Text color={c.green}>bearer</Text>
|
|
536
|
+
<Text color={c.textDim}> · </Text>
|
|
537
|
+
{modelSupportsVision(modelName) ? (
|
|
538
|
+
<Text color={c.accent} bold>👁 vision</Text>
|
|
539
|
+
) : (
|
|
540
|
+
<Text color={c.borderDim}>◌ text-only</Text>
|
|
541
|
+
)}
|
|
542
|
+
<Text color={c.textDim}> · </Text>
|
|
543
|
+
<Text color={c.green}>stream ✓</Text>
|
|
544
|
+
</Box>
|
|
545
|
+
</Box>
|
|
546
|
+
|
|
547
|
+
{/* Row 3: session stats */}
|
|
548
|
+
<Box>
|
|
549
|
+
<Box>
|
|
550
|
+
<Text color={c.textDim}>session </Text>
|
|
551
|
+
<Text color={c.text}>{session.rounds}</Text>
|
|
552
|
+
<Text color={c.textDim}> rounds · </Text>
|
|
553
|
+
<Text color={c.green}>{session.files}</Text>
|
|
554
|
+
<Text color={c.textDim}> files · </Text>
|
|
555
|
+
<Text color={c.yellow}>{session.commands}</Text>
|
|
556
|
+
<Text color={c.textDim}> cmds · </Text>
|
|
557
|
+
<Text color={c.yellow}>{formatDuration(session.elapsed)}</Text>
|
|
558
|
+
</Box>
|
|
559
|
+
<Box flexGrow={1} />
|
|
560
|
+
<Box>
|
|
561
|
+
<Text color={c.textDim}>ctx </Text>
|
|
562
|
+
<Text color={c.brand}>{formatNum(session.totalIn + session.totalOut)}</Text>
|
|
563
|
+
<Text color={c.textDim}>/{formatNum(131072)}</Text>
|
|
564
|
+
<Text color={c.textDim}> · </Text>
|
|
565
|
+
<Text color={c.blue}>↓ {formatNum(session.totalIn)}</Text>
|
|
566
|
+
<Text color={c.textDim}> </Text>
|
|
567
|
+
<Text color={c.pink}>↑ {formatNum(session.totalOut)}</Text>
|
|
568
|
+
<Text color={c.textDim}> · </Text>
|
|
569
|
+
<Text color={dotColor}>{statusIcon} {statusText}</Text>
|
|
570
|
+
</Box>
|
|
571
|
+
</Box>
|
|
225
572
|
</Box>
|
|
573
|
+
|
|
574
|
+
{/* Quick start REMOVED — user said it's redundant */}
|
|
226
575
|
</Box>
|
|
227
576
|
);
|
|
228
577
|
}
|
|
229
578
|
|
|
230
|
-
|
|
579
|
+
// ============================================================================
|
|
580
|
+
// LEFT PANEL — conversation history
|
|
581
|
+
// ============================================================================
|
|
582
|
+
interface Exchange {
|
|
583
|
+
input: string;
|
|
584
|
+
output: string;
|
|
585
|
+
model: string;
|
|
586
|
+
inTokens: number;
|
|
587
|
+
outTokens: number;
|
|
231
588
|
files: FileChange[];
|
|
232
589
|
created: string[];
|
|
590
|
+
commandResults: CommandResult[];
|
|
591
|
+
durationMs: number;
|
|
592
|
+
mode: Mode;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function ResponseBox({ content, modeColor, maxLines = 14 }: { content: string; modeColor: string; maxLines?: number }) {
|
|
596
|
+
const MAX_CHARS = 4000;
|
|
597
|
+
const wasCharTruncated = content.length > MAX_CHARS;
|
|
598
|
+
let text = wasCharTruncated ? content.slice(0, MAX_CHARS) : content;
|
|
599
|
+
const allLines = text.split('\n');
|
|
600
|
+
const wasLineTruncated = allLines.length > maxLines;
|
|
601
|
+
if (wasLineTruncated) {
|
|
602
|
+
text = allLines.slice(0, maxLines).join('\n');
|
|
603
|
+
}
|
|
604
|
+
const finalLines = text.split('\n');
|
|
605
|
+
|
|
606
|
+
return (
|
|
607
|
+
<Box flexDirection="column" marginTop={1} marginLeft={2}>
|
|
608
|
+
{finalLines.map((line, i) => (
|
|
609
|
+
<Box key={i}>
|
|
610
|
+
{i === 0 ? (
|
|
611
|
+
<Text color={modeColor} bold>◀ </Text>
|
|
612
|
+
) : (
|
|
613
|
+
<Text>{' '}</Text>
|
|
614
|
+
)}
|
|
615
|
+
<Text color={c.text}>{line.length === 0 ? ' ' : line}</Text>
|
|
616
|
+
</Box>
|
|
617
|
+
))}
|
|
618
|
+
{(wasCharTruncated || wasLineTruncated) && (
|
|
619
|
+
<Text color={c.textDim}> … (truncated, full response: {content.length} chars / {allLines.length} lines)</Text>
|
|
620
|
+
)}
|
|
621
|
+
</Box>
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function CommandResultBox({ result }: { result: CommandResult }) {
|
|
626
|
+
const MAX_OUT_CHARS = 1200;
|
|
627
|
+
const MAX_OUT_LINES = 8;
|
|
628
|
+
const out = (result.stdout || '') + (result.stderr ? '\n' + result.stderr : '');
|
|
629
|
+
const trimmed = out.length > MAX_OUT_CHARS ? out.slice(0, MAX_OUT_CHARS) : out;
|
|
630
|
+
const lines = trimmed.split('\n');
|
|
631
|
+
const truncated = lines.length > MAX_OUT_LINES ? lines.slice(0, MAX_OUT_LINES) : lines;
|
|
632
|
+
const statusColor = result.ok ? c.green : c.red;
|
|
633
|
+
const statusIcon = result.ok ? '✓' : '✗';
|
|
634
|
+
|
|
635
|
+
return (
|
|
636
|
+
<Box flexDirection="column" borderStyle="single" borderColor={statusColor} paddingX={1} marginTop={1} marginLeft={2}>
|
|
637
|
+
<Box>
|
|
638
|
+
<Text color={statusColor} bold>{statusIcon} </Text>
|
|
639
|
+
<Text color={c.textDim}>$ </Text>
|
|
640
|
+
<Text color={c.text} bold>{result.cmd.length > 80 ? result.cmd.slice(0, 77) + '...' : result.cmd}</Text>
|
|
641
|
+
<Text color={c.textDim}> · </Text>
|
|
642
|
+
<Text color={statusColor}>exit {result.exitCode}</Text>
|
|
643
|
+
<Text color={c.textDim}> · {formatDuration(result.durationMs)}</Text>
|
|
644
|
+
</Box>
|
|
645
|
+
{truncated.length > 0 && (
|
|
646
|
+
<Box flexDirection="column" marginTop={1}>
|
|
647
|
+
{truncated.map((line, i) => (
|
|
648
|
+
<Text key={i} color={c.textMuted}>{line.length === 0 ? ' ' : line}</Text>
|
|
649
|
+
))}
|
|
650
|
+
{lines.length > MAX_OUT_LINES && (
|
|
651
|
+
<Text color={c.textDim}> … ({lines.length - MAX_OUT_LINES} more lines)</Text>
|
|
652
|
+
)}
|
|
653
|
+
</Box>
|
|
654
|
+
)}
|
|
655
|
+
</Box>
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function estimateRoundHeight(exc: Exchange, respMax: number): number {
|
|
660
|
+
const totalResp = exc.output === '' ? 0 : exc.output.split('\n').length;
|
|
661
|
+
const shownResp = Math.min(respMax, totalResp);
|
|
662
|
+
const truncMarker = exc.output.trim().length > 0 && totalResp > respMax ? 1 : 0;
|
|
663
|
+
const noRespLine = exc.output.trim().length === 0 ? 1 : 0;
|
|
664
|
+
|
|
665
|
+
let h = 0;
|
|
666
|
+
h += 1; // round header
|
|
667
|
+
h += 1; // input line
|
|
668
|
+
h += 1; // response top margin
|
|
669
|
+
h += shownResp + truncMarker + noRespLine;
|
|
670
|
+
|
|
671
|
+
if (exc.files.length > 0) h += 1 + exc.files.length;
|
|
672
|
+
|
|
673
|
+
for (const r of exc.commandResults) {
|
|
674
|
+
h += 1; // top margin
|
|
675
|
+
h += 2; // top + bottom border
|
|
676
|
+
h += 1; // header
|
|
677
|
+
const out = (r.stdout || '') + (r.stderr ? '\n' + r.stderr : '');
|
|
678
|
+
const outLines = Math.min(8, out.split('\n').length);
|
|
679
|
+
h += outLines;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
h += 1; // meta margin
|
|
683
|
+
h += 1; // meta line
|
|
684
|
+
h += 2; // blank + separator
|
|
685
|
+
return h;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function getVisibleWindow(
|
|
689
|
+
exchanges: Exchange[],
|
|
690
|
+
maxHeight: number,
|
|
691
|
+
scrollOffset: number,
|
|
692
|
+
defaultRespMax: number
|
|
693
|
+
): { visible: Array<{ exc: Exchange; respMax: number; isFirst: boolean; isLast: boolean }>; hiddenAbove: number; hiddenBelow: number } {
|
|
694
|
+
if (exchanges.length === 0 || maxHeight <= 0) {
|
|
695
|
+
return { visible: [], hiddenAbove: 0, hiddenBelow: 0 };
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const items = exchanges.map((exc) => ({
|
|
699
|
+
exc,
|
|
700
|
+
respMax: defaultRespMax,
|
|
701
|
+
height: estimateRoundHeight(exc, defaultRespMax),
|
|
702
|
+
}));
|
|
703
|
+
const totalH = items.reduce((s, it) => s + it.height, 0);
|
|
704
|
+
|
|
705
|
+
if (totalH <= maxHeight) {
|
|
706
|
+
return {
|
|
707
|
+
visible: items.map((it) => ({ exc: it.exc, respMax: it.respMax, isFirst: false, isLast: false })),
|
|
708
|
+
hiddenAbove: 0,
|
|
709
|
+
hiddenBelow: 0,
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// scrollOffset = lines hidden from top (0 = bottom-aligned)
|
|
714
|
+
const maxScroll = totalH - maxHeight;
|
|
715
|
+
const effOffset = Math.max(0, Math.min(scrollOffset, maxScroll));
|
|
716
|
+
const startLine = totalH - maxHeight - effOffset;
|
|
717
|
+
const endLine = startLine + maxHeight;
|
|
718
|
+
|
|
719
|
+
const visible: Array<{ exc: Exchange; respMax: number; isFirst: boolean; isLast: boolean }> = [];
|
|
720
|
+
let accum = 0;
|
|
721
|
+
let isFirst = true;
|
|
722
|
+
for (const it of items) {
|
|
723
|
+
const itemStart = accum;
|
|
724
|
+
const itemEnd = accum + it.height;
|
|
725
|
+
accum = itemEnd;
|
|
726
|
+
|
|
727
|
+
if (itemEnd <= startLine) continue; // fully above
|
|
728
|
+
if (itemStart >= endLine) break; // fully below
|
|
729
|
+
|
|
730
|
+
const visTop = Math.max(0, startLine - itemStart);
|
|
731
|
+
const visBot = Math.min(it.height, endLine - itemStart);
|
|
732
|
+
|
|
733
|
+
if (visTop === 0 && visBot === it.height) {
|
|
734
|
+
// fully visible
|
|
735
|
+
visible.push({ exc: it.exc, respMax: it.respMax, isFirst, isLast: false });
|
|
736
|
+
} else {
|
|
737
|
+
// partially visible — slice response to fit
|
|
738
|
+
const linesBefore = 3; // round header + input + response top margin
|
|
739
|
+
const respTotal = it.exc.output.split('\n').length;
|
|
740
|
+
const respTrunc = it.exc.output.trim().length > 0 && respTotal > it.respMax ? 1 : 0;
|
|
741
|
+
const noResp = it.exc.output.trim().length === 0 ? 1 : 0;
|
|
742
|
+
const respBlock = Math.min(it.respMax, respTotal) + respTrunc + noResp;
|
|
743
|
+
const linesAfter = Math.max(0, it.height - linesBefore - respBlock);
|
|
744
|
+
|
|
745
|
+
const available = visBot - visTop;
|
|
746
|
+
const slicedRespMax = Math.max(0, available - linesBefore - linesAfter);
|
|
747
|
+
visible.push({ exc: it.exc, respMax: slicedRespMax, isFirst, isLast: false });
|
|
748
|
+
}
|
|
749
|
+
isFirst = false;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
return {
|
|
753
|
+
visible,
|
|
754
|
+
hiddenAbove: startLine,
|
|
755
|
+
hiddenBelow: Math.max(0, totalH - endLine),
|
|
756
|
+
};
|
|
233
757
|
}
|
|
234
758
|
|
|
235
|
-
function
|
|
236
|
-
|
|
759
|
+
function ConversationView({
|
|
760
|
+
exchanges,
|
|
761
|
+
maxHeight,
|
|
762
|
+
scrollOffset,
|
|
763
|
+
defaultRespMax,
|
|
764
|
+
}: {
|
|
765
|
+
exchanges: Exchange[];
|
|
766
|
+
maxHeight: number;
|
|
767
|
+
scrollOffset: number;
|
|
768
|
+
defaultRespMax: number;
|
|
769
|
+
}) {
|
|
770
|
+
if (exchanges.length === 0) {
|
|
237
771
|
return (
|
|
238
|
-
<Box
|
|
239
|
-
<Text color={
|
|
772
|
+
<Box flexDirection="column" paddingX={1} paddingY={1}>
|
|
773
|
+
<Text color={c.textDim}>No rounds yet.</Text>
|
|
774
|
+
<Text color={c.textMuted}> Ask the model to start generating code.</Text>
|
|
240
775
|
</Box>
|
|
241
776
|
);
|
|
242
777
|
}
|
|
243
778
|
|
|
779
|
+
const { visible, hiddenAbove, hiddenBelow } = getVisibleWindow(
|
|
780
|
+
exchanges,
|
|
781
|
+
maxHeight,
|
|
782
|
+
scrollOffset,
|
|
783
|
+
defaultRespMax
|
|
784
|
+
);
|
|
785
|
+
|
|
244
786
|
return (
|
|
245
|
-
<Box flexDirection="column"
|
|
246
|
-
{
|
|
247
|
-
|
|
787
|
+
<Box flexDirection="column" paddingX={1} paddingY={1}>
|
|
788
|
+
{hiddenAbove > 0 && (
|
|
789
|
+
<Box>
|
|
790
|
+
<Text color={c.textDim}> ↑ {hiddenAbove} line{hiddenAbove === 1 ? '' : 's'} above (PgUp to scroll)</Text>
|
|
791
|
+
</Box>
|
|
792
|
+
)}
|
|
793
|
+
{visible.map((v, i) => {
|
|
794
|
+
const m = getMode(v.exc.mode);
|
|
795
|
+
const isLast = i === visible.length - 1;
|
|
248
796
|
return (
|
|
249
|
-
<Box key={i}
|
|
250
|
-
|
|
251
|
-
<Text color={
|
|
797
|
+
<Box key={i} flexDirection="column" marginBottom={isLast ? 0 : 1}>
|
|
798
|
+
<Box>
|
|
799
|
+
<Text color={c.accent} bold>✦ Round {i + 1}</Text>
|
|
800
|
+
<Text color={c.textDim}> · {formatDuration(v.exc.durationMs)}</Text>
|
|
801
|
+
<Text color={c.textDim}> · </Text>
|
|
802
|
+
<Text color={m.color}>{m.icon} {m.label}</Text>
|
|
803
|
+
</Box>
|
|
804
|
+
|
|
805
|
+
{/* Input line */}
|
|
806
|
+
<Box marginTop={1} marginLeft={2}>
|
|
807
|
+
<Text color={c.textDim}>❯ </Text>
|
|
808
|
+
<Text color={c.text}>{v.exc.input.length > 70 ? v.exc.input.slice(0, 67) + '...' : v.exc.input}</Text>
|
|
809
|
+
</Box>
|
|
810
|
+
|
|
811
|
+
{/* Response content — the actual AI output */}
|
|
812
|
+
{v.exc.output.trim().length > 0 ? (
|
|
813
|
+
<ResponseBox content={v.exc.output} modeColor={m.color} maxLines={v.respMax} />
|
|
252
814
|
) : (
|
|
253
|
-
<
|
|
815
|
+
<Box marginTop={1} marginLeft={2}>
|
|
816
|
+
<Text color={c.red}>✗ (no response received)</Text>
|
|
817
|
+
</Box>
|
|
254
818
|
)}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
819
|
+
|
|
820
|
+
{/* Files written (code mode only) */}
|
|
821
|
+
{v.exc.files.length > 0 && (
|
|
822
|
+
<Box flexDirection="column" marginTop={1} marginLeft={2}>
|
|
823
|
+
{v.exc.files.map((f, j) => {
|
|
824
|
+
const isCreated = v.exc.created.includes(f.filename);
|
|
825
|
+
return (
|
|
826
|
+
<Box key={j}>
|
|
827
|
+
<Text color={isCreated ? c.green : c.yellow}>{isCreated ? '✓' : '●'}</Text>
|
|
828
|
+
<Text> </Text>
|
|
829
|
+
<Text color={c.text} bold>{f.filename}</Text>
|
|
830
|
+
<Text color={c.textDim}> </Text>
|
|
831
|
+
<Text color={c.green}>+{f.addLines}</Text>
|
|
832
|
+
{f.delLines > 0 && (
|
|
833
|
+
<>
|
|
834
|
+
<Text color={c.textDim}> </Text>
|
|
835
|
+
<Text color={c.red}>-{f.delLines}</Text>
|
|
836
|
+
</>
|
|
837
|
+
)}
|
|
838
|
+
</Box>
|
|
839
|
+
);
|
|
840
|
+
})}
|
|
841
|
+
</Box>
|
|
842
|
+
)}
|
|
843
|
+
|
|
844
|
+
{/* Commands run (auto-executed bash blocks) */}
|
|
845
|
+
{v.exc.commandResults.length > 0 && (
|
|
846
|
+
<Box flexDirection="column">
|
|
847
|
+
{v.exc.commandResults.map((r, j) => (
|
|
848
|
+
<CommandResultBox key={j} result={r} />
|
|
849
|
+
))}
|
|
850
|
+
</Box>
|
|
851
|
+
)}
|
|
852
|
+
|
|
853
|
+
{/* Meta line */}
|
|
854
|
+
<Box marginTop={1} marginLeft={2}>
|
|
855
|
+
<Text color={c.textDim}>↳ </Text>
|
|
856
|
+
<Text color={c.textMuted}>{v.exc.output.length} chars</Text>
|
|
857
|
+
<Text color={c.textDim}> · </Text>
|
|
858
|
+
<Text color={c.textMuted}>↓ {formatNum(v.exc.inTokens)} ↑ {formatNum(v.exc.outTokens)} tok</Text>
|
|
859
|
+
<Text color={c.textDim}> · </Text>
|
|
860
|
+
<Text color={c.brand}>{v.exc.model}</Text>
|
|
861
|
+
</Box>
|
|
862
|
+
|
|
863
|
+
{!isLast && (
|
|
864
|
+
<Box marginTop={1}>
|
|
865
|
+
<Text color={c.borderDim}>{'─'.repeat(60)}</Text>
|
|
866
|
+
</Box>
|
|
263
867
|
)}
|
|
264
868
|
</Box>
|
|
265
869
|
);
|
|
266
870
|
})}
|
|
871
|
+
{hiddenBelow > 0 && (
|
|
872
|
+
<Box>
|
|
873
|
+
<Text color={c.textDim}> ↓ {hiddenBelow} line{hiddenBelow === 1 ? '' : 's'} below (PgDn to scroll)</Text>
|
|
874
|
+
</Box>
|
|
875
|
+
)}
|
|
267
876
|
</Box>
|
|
268
877
|
);
|
|
269
878
|
}
|
|
270
879
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
880
|
+
function ConversationPanel({
|
|
881
|
+
exchanges,
|
|
882
|
+
maxHeight,
|
|
883
|
+
scrollOffset,
|
|
884
|
+
defaultRespMax,
|
|
885
|
+
onScrollChange,
|
|
886
|
+
}: {
|
|
887
|
+
exchanges: Exchange[];
|
|
888
|
+
maxHeight: number;
|
|
889
|
+
scrollOffset: number;
|
|
890
|
+
defaultRespMax: number;
|
|
891
|
+
onScrollChange: (newOffset: number) => void;
|
|
892
|
+
}) {
|
|
893
|
+
const { hiddenAbove, hiddenBelow, totalScrollable } = getScrollStats(
|
|
894
|
+
exchanges,
|
|
895
|
+
maxHeight,
|
|
896
|
+
defaultRespMax
|
|
897
|
+
);
|
|
898
|
+
|
|
899
|
+
// Detect mouse wheel on the panel (only when exchanges overflow)
|
|
900
|
+
useInput((input, key) => {
|
|
901
|
+
if (exchanges.length === 0 || totalScrollable <= 0) return;
|
|
902
|
+
|
|
903
|
+
const STEP = 3;
|
|
904
|
+
if (key.pageUp) {
|
|
905
|
+
onScrollChange(Math.min(totalScrollable, scrollOffset + STEP));
|
|
906
|
+
} else if (key.pageDown) {
|
|
907
|
+
onScrollChange(Math.max(0, scrollOffset - STEP));
|
|
908
|
+
} else if (input === 'g' && key.shift) {
|
|
909
|
+
onScrollChange(totalScrollable);
|
|
910
|
+
} else if (input === 'G') {
|
|
911
|
+
onScrollChange(totalScrollable);
|
|
912
|
+
} else if (input === 'g' && !key.shift) {
|
|
913
|
+
onScrollChange(0);
|
|
914
|
+
}
|
|
915
|
+
}, { isActive: true });
|
|
916
|
+
|
|
917
|
+
return (
|
|
918
|
+
<Box
|
|
919
|
+
flexDirection="column"
|
|
920
|
+
flexGrow={1}
|
|
921
|
+
borderStyle="round"
|
|
922
|
+
borderColor={c.border}
|
|
923
|
+
>
|
|
924
|
+
<Box paddingX={1} borderStyle="single" borderColor={c.borderDim} borderTop={false} borderLeft={false} borderRight={false}>
|
|
925
|
+
<Text color={c.brand} bold>◇ </Text>
|
|
926
|
+
<Text color={c.text} bold>Conversation</Text>
|
|
927
|
+
<Box flexGrow={1} />
|
|
928
|
+
<Text color={c.textDim}>{exchanges.length} round{exchanges.length === 1 ? '' : 's'}</Text>
|
|
929
|
+
{totalScrollable > 0 && (
|
|
930
|
+
<Text color={c.textDim}>
|
|
931
|
+
{' '}↑ {hiddenAbove}/{hiddenAbove + hiddenBelow + maxHeight}
|
|
932
|
+
</Text>
|
|
933
|
+
)}
|
|
934
|
+
</Box>
|
|
935
|
+
{exchanges.length === 0 ? (
|
|
936
|
+
<WelcomeHero />
|
|
937
|
+
) : (
|
|
938
|
+
<ConversationView
|
|
939
|
+
exchanges={exchanges}
|
|
940
|
+
maxHeight={maxHeight}
|
|
941
|
+
scrollOffset={scrollOffset}
|
|
942
|
+
defaultRespMax={defaultRespMax}
|
|
943
|
+
/>
|
|
944
|
+
)}
|
|
945
|
+
</Box>
|
|
946
|
+
);
|
|
275
947
|
}
|
|
276
948
|
|
|
277
|
-
function
|
|
278
|
-
|
|
949
|
+
function getScrollStats(
|
|
950
|
+
exchanges: Exchange[],
|
|
951
|
+
maxHeight: number,
|
|
952
|
+
defaultRespMax: number
|
|
953
|
+
): { hiddenAbove: number; hiddenBelow: number; totalScrollable: number } {
|
|
954
|
+
if (exchanges.length === 0 || maxHeight <= 0) {
|
|
955
|
+
return { hiddenAbove: 0, hiddenBelow: 0, totalScrollable: 0 };
|
|
956
|
+
}
|
|
957
|
+
const totalH = exchanges.reduce((s, e) => s + estimateRoundHeight(e, defaultRespMax), 0);
|
|
958
|
+
if (totalH <= maxHeight) {
|
|
959
|
+
return { hiddenAbove: 0, hiddenBelow: 0, totalScrollable: 0 };
|
|
960
|
+
}
|
|
961
|
+
return { hiddenAbove: 0, hiddenBelow: totalH - maxHeight, totalScrollable: totalH - maxHeight };
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// ============================================================================
|
|
965
|
+
// WELCOME HERO — shown when no rounds yet
|
|
966
|
+
// ============================================================================
|
|
967
|
+
function WelcomeHero() {
|
|
968
|
+
const { frame } = useAnimation({ interval: 1000 });
|
|
969
|
+
const pulse = ['█', '▓', '▒', '░', '▒', '▓'];
|
|
970
|
+
const wave = pulse[frame % pulse.length]!;
|
|
971
|
+
|
|
279
972
|
return (
|
|
280
|
-
<Box flexDirection="column"
|
|
973
|
+
<Box flexDirection="column" paddingX={1} paddingY={1}>
|
|
974
|
+
{/* Big greeting line */}
|
|
281
975
|
<Box>
|
|
282
|
-
<Text color={
|
|
976
|
+
<Text color={c.brand} bold>{wave} </Text>
|
|
977
|
+
<Text color={c.text} bold>Welcome to KOB Code Engine</Text>
|
|
283
978
|
</Box>
|
|
284
|
-
<Box>
|
|
285
|
-
<Text color={
|
|
286
|
-
<Text>{' '}</Text>
|
|
287
|
-
<Text color={brand}>{'🤖 '}{model}</Text>
|
|
288
|
-
<Text color={gray}>{' │'}</Text>
|
|
979
|
+
<Box marginTop={1}>
|
|
980
|
+
<Text color={c.textMuted}> Multi-turn code generation powered by AI.</Text>
|
|
289
981
|
</Box>
|
|
290
982
|
<Box>
|
|
291
|
-
<Text color={
|
|
292
|
-
<Text
|
|
293
|
-
<Text color={
|
|
294
|
-
<Text>{' · '}</Text>
|
|
295
|
-
<Text color={gray}>{'📤 '}{outTokens}{' tok'}</Text>
|
|
296
|
-
<Text>{' · '}</Text>
|
|
297
|
-
<Text color={gray}>{'📊 '}{total}{' tok'}</Text>
|
|
298
|
-
<Text color={gray}>{' │'}</Text>
|
|
983
|
+
<Text color={c.textMuted}> Choose a mode and start chatting. Switch any time with </Text>
|
|
984
|
+
<Text color={c.pink}>Tab</Text>
|
|
985
|
+
<Text color={c.textMuted}>.</Text>
|
|
299
986
|
</Box>
|
|
300
|
-
|
|
301
|
-
|
|
987
|
+
|
|
988
|
+
{/* Modes */}
|
|
989
|
+
<Box marginTop={2}>
|
|
990
|
+
<Text color={c.accent} bold>✦ Three modes</Text>
|
|
991
|
+
</Box>
|
|
992
|
+
<Box flexDirection="column" marginTop={1} marginLeft={2}>
|
|
993
|
+
{MODES.map(m => (
|
|
994
|
+
<Box key={m.key}>
|
|
995
|
+
<Text color={m.color} bold>{m.icon} {m.label}</Text>
|
|
996
|
+
<Text color={c.textDim}> [{m.shortcut}] </Text>
|
|
997
|
+
<Text color={c.textMuted}>{m.description}</Text>
|
|
998
|
+
</Box>
|
|
999
|
+
))}
|
|
1000
|
+
</Box>
|
|
1001
|
+
|
|
1002
|
+
{/* Try saying REMOVED */}
|
|
1003
|
+
|
|
1004
|
+
{/* Tips */}
|
|
1005
|
+
<Box marginTop={2}>
|
|
1006
|
+
<Text color={c.yellow} bold>✦ Tips</Text>
|
|
1007
|
+
</Box>
|
|
1008
|
+
<Box flexDirection="column" marginTop={1} marginLeft={2}>
|
|
1009
|
+
<Box>
|
|
1010
|
+
<Text color={c.borderAccent}>• </Text>
|
|
1011
|
+
<Text color={c.textMuted}>Press </Text>
|
|
1012
|
+
<Text color={c.pink}>Tab</Text>
|
|
1013
|
+
<Text color={c.textMuted}> to cycle modes, or </Text>
|
|
1014
|
+
<Text color={c.pink}>1</Text>
|
|
1015
|
+
<Text color={c.textDim}>/</Text>
|
|
1016
|
+
<Text color={c.pink}>2</Text>
|
|
1017
|
+
<Text color={c.textDim}>/</Text>
|
|
1018
|
+
<Text color={c.pink}>3</Text>
|
|
1019
|
+
<Text color={c.textMuted}> to jump</Text>
|
|
1020
|
+
</Box>
|
|
1021
|
+
<Box>
|
|
1022
|
+
<Text color={c.borderAccent}>• </Text>
|
|
1023
|
+
<Text color={c.textMuted}>Mode can be changed any time between rounds</Text>
|
|
1024
|
+
</Box>
|
|
1025
|
+
<Box>
|
|
1026
|
+
<Text color={c.borderAccent}>• </Text>
|
|
1027
|
+
<Text color={c.textMuted}>Type </Text>
|
|
1028
|
+
<Text color={c.pink}>/exit</Text>
|
|
1029
|
+
<Text color={c.textMuted}> to quit, </Text>
|
|
1030
|
+
<Text color={c.pink}>Esc</Text>
|
|
1031
|
+
<Text color={c.textMuted}> to clear the input</Text>
|
|
1032
|
+
</Box>
|
|
302
1033
|
</Box>
|
|
303
1034
|
</Box>
|
|
304
1035
|
);
|
|
305
1036
|
}
|
|
306
1037
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
1038
|
+
// ============================================================================
|
|
1039
|
+
// GENERATING ANIMATION
|
|
1040
|
+
// ============================================================================
|
|
1041
|
+
const statusMessages = [
|
|
1042
|
+
'Analyzing request',
|
|
1043
|
+
'Designing architecture',
|
|
1044
|
+
'Writing code',
|
|
1045
|
+
'Optimizing implementation',
|
|
1046
|
+
'Reviewing output',
|
|
1047
|
+
'Finalizing',
|
|
1048
|
+
];
|
|
1049
|
+
const spinnerChars = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
1050
|
+
const shimmerColors = [c.brand, c.green, c.accent, c.pink, c.yellow, c.accent, c.green, c.brand];
|
|
1051
|
+
const stepInterval = 2500;
|
|
1052
|
+
|
|
1053
|
+
function GeneratingPanel({ messages, elapsed }: { messages: string[]; elapsed: number }) {
|
|
1054
|
+
const { frame } = useAnimation({ interval: 80 });
|
|
1055
|
+
const spinner = spinnerChars[frame % spinnerChars.length]!;
|
|
1056
|
+
const colorIdx = frame % shimmerColors.length;
|
|
1057
|
+
const currentStep = Math.min(Math.floor(elapsed / stepInterval), messages.length - 1);
|
|
1058
|
+
const timeStr = formatDuration(elapsed);
|
|
1059
|
+
|
|
1060
|
+
return (
|
|
1061
|
+
<Box flexDirection="column" borderStyle="round" borderColor={c.yellow} marginTop={1}>
|
|
1062
|
+
<Box paddingX={1} borderStyle="single" borderColor={c.borderDim} borderTop={false} borderLeft={false} borderRight={false}>
|
|
1063
|
+
<Text color={c.yellow} bold>⟳ </Text>
|
|
1064
|
+
<Text color={c.text} bold>Generating</Text>
|
|
1065
|
+
<Box flexGrow={1} />
|
|
1066
|
+
<Text color={c.yellow}>{spinner} {timeStr}</Text>
|
|
1067
|
+
</Box>
|
|
1068
|
+
<Box flexDirection="column" paddingX={1} paddingY={1}>
|
|
1069
|
+
{messages.map((msg, i) => {
|
|
1070
|
+
const isDone = i < currentStep;
|
|
1071
|
+
const isActive = i === currentStep;
|
|
1072
|
+
const isPending = i > currentStep;
|
|
1073
|
+
|
|
1074
|
+
let icon: string;
|
|
1075
|
+
let iconColor: string;
|
|
1076
|
+
let textColor: string;
|
|
1077
|
+
|
|
1078
|
+
if (isDone) {
|
|
1079
|
+
icon = '✓';
|
|
1080
|
+
iconColor = c.green;
|
|
1081
|
+
textColor = c.textMuted;
|
|
1082
|
+
} else if (isActive) {
|
|
1083
|
+
icon = spinner;
|
|
1084
|
+
iconColor = shimmerColors[colorIdx] || c.brand;
|
|
1085
|
+
textColor = shimmerColors[(colorIdx + i * 3) % shimmerColors.length] || c.brand;
|
|
1086
|
+
} else {
|
|
1087
|
+
icon = '○';
|
|
1088
|
+
iconColor = c.borderDim;
|
|
1089
|
+
textColor = c.borderDim;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
return (
|
|
1093
|
+
<Box key={i}>
|
|
1094
|
+
<Text color={iconColor}>{icon} </Text>
|
|
1095
|
+
<Text color={textColor}>{msg}</Text>
|
|
1096
|
+
</Box>
|
|
1097
|
+
);
|
|
1098
|
+
})}
|
|
1099
|
+
</Box>
|
|
1100
|
+
</Box>
|
|
1101
|
+
);
|
|
315
1102
|
}
|
|
316
1103
|
|
|
317
|
-
|
|
1104
|
+
// ============================================================================
|
|
1105
|
+
// INPUT BOX
|
|
1106
|
+
// ============================================================================
|
|
1107
|
+
interface InputAreaProps {
|
|
1108
|
+
onSubmit: (text: string, attachments: string[]) => void;
|
|
1109
|
+
mode: Mode;
|
|
1110
|
+
onModeChange: (m: Mode) => void;
|
|
1111
|
+
visionSupported: boolean;
|
|
1112
|
+
placeholder?: string;
|
|
1113
|
+
disabled?: boolean;
|
|
1114
|
+
/**
|
|
1115
|
+
* When false, this input ignores all keystrokes. Used to surrender
|
|
1116
|
+
* focus to overlays like the model picker or config form, so an
|
|
1117
|
+
* Enter inside the overlay doesn't simultaneously fire onSubmit here.
|
|
1118
|
+
*/
|
|
1119
|
+
isActive?: boolean;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
function InputBox({ onSubmit, mode, onModeChange, visionSupported, placeholder, disabled = false, isActive = true }: InputAreaProps) {
|
|
1123
|
+
const [value, setValue] = useState('');
|
|
1124
|
+
const [attachments, setAttachments] = useState<string[]>([]);
|
|
1125
|
+
const [pasteHint, setPasteHint] = useState<string | null>(null);
|
|
1126
|
+
const [slashIdx, setSlashIdx] = useState<number>(0);
|
|
1127
|
+
const { frame } = useAnimation({ interval: 500 });
|
|
1128
|
+
const cursorVisible = frame % 2 === 0;
|
|
1129
|
+
const modeInfo = getMode(mode);
|
|
1130
|
+
const ph = placeholder ?? modeInfo.placeholder;
|
|
1131
|
+
|
|
1132
|
+
// Slash-command autocomplete: only show when the user typed "/" and
|
|
1133
|
+
// has not yet pressed space (which would mean they're past the command).
|
|
1134
|
+
const isSlashQuery = value.startsWith('/') && !/\s/.test(value);
|
|
1135
|
+
const slashQuery = value.slice(1).toLowerCase();
|
|
1136
|
+
const slashMatches = isSlashQuery
|
|
1137
|
+
? SLASH_COMMANDS.filter((c) => c.name.toLowerCase().startsWith(slashQuery))
|
|
1138
|
+
: [];
|
|
1139
|
+
const slashOpen = isSlashQuery && slashMatches.length > 0;
|
|
1140
|
+
// Reset highlight whenever the filter set or query changes
|
|
1141
|
+
useEffect(() => { setSlashIdx(0); }, [value]);
|
|
1142
|
+
// Clamp highlight if matches shrink
|
|
1143
|
+
useEffect(() => {
|
|
1144
|
+
if (slashIdx >= slashMatches.length) setSlashIdx(0);
|
|
1145
|
+
}, [slashMatches.length, slashIdx]);
|
|
1146
|
+
|
|
1147
|
+
// Ink-native text paste (handles multi-line pastes as a single insert).
|
|
1148
|
+
// This does NOT capture images — those arrive through Ctrl+V in useInput below.
|
|
1149
|
+
usePaste((text) => {
|
|
1150
|
+
if (disabled) return;
|
|
1151
|
+
if (!isActive) return;
|
|
1152
|
+
if (!text) return;
|
|
1153
|
+
// If the pasted text looks like a single image path, attach it instead of inserting
|
|
1154
|
+
const trimmed = text.trim();
|
|
1155
|
+
if (visionSupported && isImagePath(trimmed) && existsSync(resolve(trimmed))) {
|
|
1156
|
+
try {
|
|
1157
|
+
const dest = attachImagePath(trimmed);
|
|
1158
|
+
setAttachments(prev => [...prev, dest]);
|
|
1159
|
+
setPasteHint(`📎 ${basename(dest)}`);
|
|
1160
|
+
setTimeout(() => setPasteHint(null), 2500);
|
|
1161
|
+
return;
|
|
1162
|
+
} catch {/* fall through to text insert */}
|
|
1163
|
+
}
|
|
1164
|
+
setValue(prev => prev + text);
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
useInput((char, key) => {
|
|
1168
|
+
if (disabled) return;
|
|
1169
|
+
if (!isActive) return;
|
|
1170
|
+
if (key.ctrl && char === 'c') {
|
|
1171
|
+
process.stdout.write('\x1B[?25h');
|
|
1172
|
+
process.exit(0);
|
|
1173
|
+
}
|
|
1174
|
+
// Ctrl+V → try to read an image from the system clipboard
|
|
1175
|
+
if (key.ctrl && (char === 'v' || key.meta)) {
|
|
1176
|
+
if (!visionSupported) {
|
|
1177
|
+
setPasteHint('⚠ model does not support images');
|
|
1178
|
+
setTimeout(() => setPasteHint(null), 2500);
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
const path = pasteImageFromClipboard();
|
|
1182
|
+
if (path) {
|
|
1183
|
+
setAttachments(prev => [...prev, path]);
|
|
1184
|
+
setPasteHint(`📎 pasted ${basename(path)}`);
|
|
1185
|
+
} else {
|
|
1186
|
+
setPasteHint('⚠ no image in clipboard');
|
|
1187
|
+
}
|
|
1188
|
+
setTimeout(() => setPasteHint(null), 2500);
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
if (key.escape) {
|
|
1192
|
+
// ESC clears the input AND any attachments
|
|
1193
|
+
if (value.length > 0 || attachments.length > 0) {
|
|
1194
|
+
setValue('');
|
|
1195
|
+
setAttachments([]);
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
// Tab: cycle modes UNLESS the slash popup is open (then it fills the highlighted command)
|
|
1201
|
+
if (key.tab) {
|
|
1202
|
+
if (slashOpen) {
|
|
1203
|
+
const cmd = slashMatches[slashIdx];
|
|
1204
|
+
if (cmd) setValue('/' + cmd.name + ' ');
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
const i = MODES.findIndex(m => m.key === mode);
|
|
1208
|
+
const next = MODES[(i + 1) % MODES.length]!;
|
|
1209
|
+
onModeChange(next.key);
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
if (!key.shift && (char === '1' || char === '2' || char === '3') && value.length === 0) {
|
|
1213
|
+
const target = MODES.find(m => m.shortcut === char);
|
|
1214
|
+
if (target) {
|
|
1215
|
+
onModeChange(target.key);
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
if (key.upArrow) {
|
|
1220
|
+
if (slashOpen) {
|
|
1221
|
+
setSlashIdx((i) => (i - 1 + slashMatches.length) % slashMatches.length);
|
|
1222
|
+
return;
|
|
1223
|
+
}
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
if (key.downArrow) {
|
|
1227
|
+
if (slashOpen) {
|
|
1228
|
+
setSlashIdx((i) => (i + 1) % slashMatches.length);
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
if (key.return) {
|
|
1234
|
+
const trimmed = value.trim();
|
|
1235
|
+
// If the popup is open, Enter fills the highlighted command (or executes it
|
|
1236
|
+
// if there's an exact match)
|
|
1237
|
+
if (slashOpen) {
|
|
1238
|
+
const cmd = slashMatches[slashIdx];
|
|
1239
|
+
if (cmd) {
|
|
1240
|
+
if (cmd.name === slashQuery) {
|
|
1241
|
+
// Exact match — execute immediately
|
|
1242
|
+
onSubmit('/' + cmd.name, attachments);
|
|
1243
|
+
setValue('');
|
|
1244
|
+
setAttachments([]);
|
|
1245
|
+
} else {
|
|
1246
|
+
// Partial — fill in the rest, user can keep typing
|
|
1247
|
+
setValue('/' + cmd.name + ' ');
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
return;
|
|
1251
|
+
}
|
|
1252
|
+
if (trimmed || attachments.length > 0) {
|
|
1253
|
+
if (trimmed === '/exit' || trimmed === '/quit') {
|
|
1254
|
+
process.stdout.write('\x1B[?25h');
|
|
1255
|
+
process.exit(0);
|
|
1256
|
+
}
|
|
1257
|
+
onSubmit(trimmed, attachments);
|
|
1258
|
+
setValue('');
|
|
1259
|
+
setAttachments([]);
|
|
1260
|
+
}
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
1263
|
+
if (key.backspace || key.delete) {
|
|
1264
|
+
if (value.length > 0) {
|
|
1265
|
+
setValue(prev => prev.slice(0, -1));
|
|
1266
|
+
} else if (attachments.length > 0) {
|
|
1267
|
+
setAttachments(prev => prev.slice(0, -1));
|
|
1268
|
+
}
|
|
1269
|
+
return;
|
|
1270
|
+
}
|
|
1271
|
+
if (char && char.length === 1) {
|
|
1272
|
+
setValue(prev => prev + char);
|
|
1273
|
+
}
|
|
1274
|
+
}, { isActive });
|
|
1275
|
+
|
|
1276
|
+
const isEmpty = value.length === 0 && attachments.length === 0;
|
|
1277
|
+
const borderColor = disabled ? c.borderDim : modeInfo.color;
|
|
1278
|
+
const showCursor = !disabled && isActive && cursorVisible;
|
|
1279
|
+
|
|
318
1280
|
return (
|
|
319
|
-
<Box flexDirection="column">
|
|
320
|
-
{
|
|
321
|
-
<Box
|
|
322
|
-
<
|
|
323
|
-
|
|
324
|
-
</
|
|
325
|
-
<
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
1281
|
+
<Box flexDirection="column" borderStyle="round" borderColor={borderColor} marginTop={1}>
|
|
1282
|
+
<Box paddingX={1} borderStyle="single" borderColor={c.borderDim} borderTop={false} borderLeft={false} borderRight={false} justifyContent="space-between">
|
|
1283
|
+
<Box>
|
|
1284
|
+
<Text color={modeInfo.color} bold>▶ </Text>
|
|
1285
|
+
<Text color={c.text} bold>Input</Text>
|
|
1286
|
+
<Text color={c.textDim}> · </Text>
|
|
1287
|
+
<ModeSelector mode={mode} />
|
|
1288
|
+
</Box>
|
|
1289
|
+
<Box>
|
|
1290
|
+
<Text color={c.textDim}>{visionSupported ? '⌘V ' : ''}</Text>
|
|
1291
|
+
<Text color={c.textMuted}>{visionSupported ? 'paste image' : 'text only'}</Text>
|
|
1292
|
+
<Text color={c.borderDim}> · </Text>
|
|
1293
|
+
<Text color={c.textDim}>Tab </Text>
|
|
1294
|
+
<Text color={c.textMuted}>switch</Text>
|
|
1295
|
+
</Box>
|
|
1296
|
+
</Box>
|
|
1297
|
+
|
|
1298
|
+
{/* Attachment chips */}
|
|
1299
|
+
{attachments.length > 0 && (
|
|
1300
|
+
<Box paddingX={1} paddingTop={1} flexWrap="wrap">
|
|
1301
|
+
{attachments.map((a, i) => (
|
|
1302
|
+
<Box key={i} marginRight={1}>
|
|
1303
|
+
<Text color={c.accent}>📎 </Text>
|
|
1304
|
+
<Text color={c.text}>{basename(a)}</Text>
|
|
1305
|
+
<Text color={c.textDim}> </Text>
|
|
330
1306
|
</Box>
|
|
331
|
-
)}
|
|
1307
|
+
))}
|
|
332
1308
|
</Box>
|
|
333
|
-
)
|
|
1309
|
+
)}
|
|
1310
|
+
|
|
1311
|
+
{/* Toast for paste feedback */}
|
|
1312
|
+
{pasteHint && (
|
|
1313
|
+
<Box paddingX={1}>
|
|
1314
|
+
<Text color={c.yellow}>{pasteHint}</Text>
|
|
1315
|
+
</Box>
|
|
1316
|
+
)}
|
|
1317
|
+
|
|
1318
|
+
<Box paddingX={1} paddingY={1}>
|
|
1319
|
+
<Text color={modeInfo.color}>❯ </Text>
|
|
1320
|
+
{isEmpty ? (
|
|
1321
|
+
<>
|
|
1322
|
+
<Text color={c.borderDim}>{ph}</Text>
|
|
1323
|
+
{showCursor && <Text color={modeInfo.color}>{'▌'}</Text>}
|
|
1324
|
+
</>
|
|
1325
|
+
) : (
|
|
1326
|
+
<>
|
|
1327
|
+
<Text color={c.text}>{value}</Text>
|
|
1328
|
+
{showCursor && <Text color={modeInfo.color}>{'▌'}</Text>}
|
|
1329
|
+
</>
|
|
1330
|
+
)}
|
|
1331
|
+
</Box>
|
|
1332
|
+
|
|
1333
|
+
{/* Slash-command autocomplete popup */}
|
|
1334
|
+
{slashOpen && (
|
|
1335
|
+
<Box
|
|
1336
|
+
flexDirection="column"
|
|
1337
|
+
borderStyle="single"
|
|
1338
|
+
borderColor={c.borderAccent}
|
|
1339
|
+
borderTop={true}
|
|
1340
|
+
borderBottom={false}
|
|
1341
|
+
borderLeft={false}
|
|
1342
|
+
borderRight={false}
|
|
1343
|
+
paddingX={1}
|
|
1344
|
+
marginBottom={0}
|
|
1345
|
+
>
|
|
1346
|
+
<Box>
|
|
1347
|
+
<Text color={c.borderAccent} bold>◆ commands</Text>
|
|
1348
|
+
<Text color={c.textDim}> {slashMatches.length} match{slashMatches.length === 1 ? '' : 'es'}</Text>
|
|
1349
|
+
<Box flexGrow={1} />
|
|
1350
|
+
<Text color={c.textDim}>↑↓ move · ↵ fill · Tab fill · esc close</Text>
|
|
1351
|
+
</Box>
|
|
1352
|
+
{slashMatches.map((cmd, i) => {
|
|
1353
|
+
const isSel = i === slashIdx;
|
|
1354
|
+
const groupColor = cmd.group === 'mode' ? c.brand : cmd.group === 'session' ? c.green : c.accent;
|
|
1355
|
+
return (
|
|
1356
|
+
<Box key={cmd.name}>
|
|
1357
|
+
<Text color={isSel ? c.brand : c.textDim}>{isSel ? '▶ ' : ' '}</Text>
|
|
1358
|
+
<Text color={isSel ? c.text : c.text} bold={isSel}>
|
|
1359
|
+
/{cmd.name.padEnd(7)}
|
|
1360
|
+
</Text>
|
|
1361
|
+
<Text color={groupColor}> {cmd.icon}</Text>
|
|
1362
|
+
<Text color={c.textDim}> {cmd.desc}</Text>
|
|
1363
|
+
</Box>
|
|
1364
|
+
);
|
|
1365
|
+
})}
|
|
1366
|
+
</Box>
|
|
1367
|
+
)}
|
|
334
1368
|
</Box>
|
|
335
1369
|
);
|
|
336
1370
|
}
|
|
337
1371
|
|
|
1372
|
+
// ============================================================================
|
|
1373
|
+
// BOTTOM BAR
|
|
1374
|
+
// ============================================================================
|
|
1375
|
+
function BottomBar({ phase, mode }: { phase: Phase; mode: Mode }) {
|
|
1376
|
+
const modeInfo = getMode(mode);
|
|
1377
|
+
return (
|
|
1378
|
+
<Box
|
|
1379
|
+
marginTop={1}
|
|
1380
|
+
borderStyle="round"
|
|
1381
|
+
borderColor={c.borderDim}
|
|
1382
|
+
paddingX={1}
|
|
1383
|
+
justifyContent="space-between"
|
|
1384
|
+
>
|
|
1385
|
+
<Box>
|
|
1386
|
+
<Text color={c.green}>⏎ </Text>
|
|
1387
|
+
<Text color={c.textDim}>submit</Text>
|
|
1388
|
+
<Text color={c.borderDim}> · </Text>
|
|
1389
|
+
<Text color={c.yellow}>Esc</Text>
|
|
1390
|
+
<Text color={c.textDim}> clear</Text>
|
|
1391
|
+
<Text color={c.borderDim}> · </Text>
|
|
1392
|
+
<Text color={c.pink}>Tab</Text>
|
|
1393
|
+
<Text color={c.textDim}> mode</Text>
|
|
1394
|
+
<Text color={c.borderDim}> · </Text>
|
|
1395
|
+
<Text color={c.brand}>/models</Text>
|
|
1396
|
+
<Text color={c.textDim}> switch</Text>
|
|
1397
|
+
<Text color={c.borderDim}> · </Text>
|
|
1398
|
+
<Text color={c.accent}>Ctrl+C</Text>
|
|
1399
|
+
<Text color={c.textDim}> quit</Text>
|
|
1400
|
+
</Box>
|
|
1401
|
+
<Box>
|
|
1402
|
+
<Text color={modeInfo.color}>{modeInfo.icon} {modeInfo.label} mode</Text>
|
|
1403
|
+
<Text color={c.borderDim}> · </Text>
|
|
1404
|
+
<Text color={c.textDim}>{phase === 'generating' ? '⏳ streaming...' : '💤 idle'}</Text>
|
|
1405
|
+
</Box>
|
|
1406
|
+
</Box>
|
|
1407
|
+
);
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
// ============================================================================
|
|
1411
|
+
// MAIN APP
|
|
1412
|
+
// ============================================================================
|
|
338
1413
|
export function runCodeTui(): Promise<void> {
|
|
339
1414
|
return new Promise((resolve) => {
|
|
340
1415
|
const { waitUntilExit } = render(<CodeEngine />, { exitOnCtrlC: false });
|
|
@@ -344,45 +1419,206 @@ export function runCodeTui(): Promise<void> {
|
|
|
344
1419
|
|
|
345
1420
|
function CodeEngine() {
|
|
346
1421
|
const [exchanges, setExchanges] = useState<Exchange[]>([]);
|
|
347
|
-
const [phase, setPhase] = useState<
|
|
348
|
-
const [startMs, setStartMs] = useState(0);
|
|
349
|
-
const
|
|
1422
|
+
const [phase, setPhase] = useState<Phase>('input');
|
|
1423
|
+
const [startMs, setStartMs] = useState<number>(0);
|
|
1424
|
+
const [now, setNow] = useState<number>(Date.now());
|
|
1425
|
+
const [mode, setMode] = useState<Mode>('code');
|
|
1426
|
+
const [scrollOffset, setScrollOffset] = useState<number>(0);
|
|
1427
|
+
const version = readVersion();
|
|
1428
|
+
const initialConfig = getConfig();
|
|
1429
|
+
const [model, setModel] = useState<string>(formatV2Model('DeepSeek', initialConfig.modelId));
|
|
1430
|
+
const [palette, setPalette] = useState<null | 'models'>(null);
|
|
1431
|
+
const [configOpen, setConfigOpen] = useState<boolean>(false);
|
|
1432
|
+
const [banner, setBanner] = useState<string | null>(null);
|
|
350
1433
|
const messagesRef = useRef<{ role: string; content: string }[]>([]);
|
|
1434
|
+
const modeRef = useRef<Mode>(mode);
|
|
1435
|
+
const exchangesLenRef = useRef<number>(0);
|
|
1436
|
+
const configRef = useRef(initialConfig);
|
|
1437
|
+
|
|
1438
|
+
const showBanner = useCallback((msg: string, ms = 2200) => {
|
|
1439
|
+
setBanner(msg);
|
|
1440
|
+
setTimeout(() => setBanner((cur) => (cur === msg ? null : cur)), ms);
|
|
1441
|
+
}, []);
|
|
1442
|
+
|
|
1443
|
+
// Live viewport height — listen to terminal resize events
|
|
1444
|
+
const [viewportRows, setViewportRows] = useState<number>(
|
|
1445
|
+
typeof process !== 'undefined' && process.stdout && process.stdout.rows
|
|
1446
|
+
? process.stdout.rows
|
|
1447
|
+
: 30
|
|
1448
|
+
);
|
|
1449
|
+
useEffect(() => {
|
|
1450
|
+
const onResize = () => {
|
|
1451
|
+
const r = (process.stdout && process.stdout.rows) || 30;
|
|
1452
|
+
setViewportRows(r);
|
|
1453
|
+
};
|
|
1454
|
+
process.stdout.on('resize', onResize);
|
|
1455
|
+
return () => {
|
|
1456
|
+
process.stdout.off('resize', onResize);
|
|
1457
|
+
};
|
|
1458
|
+
}, []);
|
|
351
1459
|
|
|
352
|
-
|
|
1460
|
+
// Keep ref in sync so async handleSubmit reads the latest mode
|
|
1461
|
+
useEffect(() => { modeRef.current = mode; }, [mode]);
|
|
1462
|
+
|
|
1463
|
+
// Tick once per second to update the elapsed time in side panel
|
|
1464
|
+
useEffect(() => {
|
|
1465
|
+
const t = setInterval(() => setNow(Date.now()), 1000);
|
|
1466
|
+
return () => clearInterval(t);
|
|
1467
|
+
}, []);
|
|
1468
|
+
|
|
1469
|
+
// Auto-scroll to bottom on each new round
|
|
1470
|
+
useEffect(() => {
|
|
1471
|
+
if (exchanges.length !== exchangesLenRef.current) {
|
|
1472
|
+
exchangesLenRef.current = exchanges.length;
|
|
1473
|
+
setScrollOffset(0);
|
|
1474
|
+
}
|
|
1475
|
+
}, [exchanges.length]);
|
|
1476
|
+
|
|
1477
|
+
const totalIn = exchanges.reduce((s, e) => s + e.inTokens, 0);
|
|
1478
|
+
const totalOut = exchanges.reduce((s, e) => s + e.outTokens, 0);
|
|
1479
|
+
const totalFiles = exchanges.reduce((s, e) => s + e.created.length, 0);
|
|
1480
|
+
const totalCommands = exchanges.reduce((s, e) => s + e.commandResults.length, 0);
|
|
1481
|
+
const elapsed = phase === 'generating'
|
|
1482
|
+
? now - startMs
|
|
1483
|
+
: (exchanges.length > 0 ? exchanges.reduce((s, e) => s + e.durationMs, 0) : 0);
|
|
1484
|
+
|
|
1485
|
+
// Reserve rows for: brand header (~10), input area (~3), bottom bar (~1), margins/padding (~6)
|
|
1486
|
+
const RESERVED_ROWS = 22;
|
|
1487
|
+
const convMaxHeight = Math.max(8, viewportRows - RESERVED_ROWS);
|
|
1488
|
+
const DEFAULT_RESP_MAX = 14;
|
|
1489
|
+
|
|
1490
|
+
// Dispatch a /slash command. Returns true if the input was a slash command
|
|
1491
|
+
// (and therefore should NOT be sent to the model).
|
|
1492
|
+
const handleSlashCommand = useCallback((raw: string): boolean => {
|
|
1493
|
+
const trimmed = raw.trim();
|
|
1494
|
+
if (!trimmed.startsWith('/')) return false;
|
|
1495
|
+
const body = trimmed.slice(1);
|
|
1496
|
+
const space = body.indexOf(' ');
|
|
1497
|
+
const name = (space === -1 ? body : body.slice(0, space)).toLowerCase();
|
|
1498
|
+
const arg = space === -1 ? '' : body.slice(space + 1).trim();
|
|
1499
|
+
|
|
1500
|
+
switch (name) {
|
|
1501
|
+
case 'ask':
|
|
1502
|
+
setMode('ask');
|
|
1503
|
+
showBanner('◆ mode → Ask');
|
|
1504
|
+
return true;
|
|
1505
|
+
case 'plan':
|
|
1506
|
+
setMode('plan');
|
|
1507
|
+
showBanner('◆ mode → Plan');
|
|
1508
|
+
return true;
|
|
1509
|
+
case 'code':
|
|
1510
|
+
setMode('code');
|
|
1511
|
+
showBanner('◆ mode → Code');
|
|
1512
|
+
return true;
|
|
1513
|
+
case 'clear':
|
|
1514
|
+
setExchanges([]);
|
|
1515
|
+
messagesRef.current = [];
|
|
1516
|
+
exchangesLenRef.current = 0;
|
|
1517
|
+
showBanner('◆ session cleared');
|
|
1518
|
+
return true;
|
|
1519
|
+
case 'reset': {
|
|
1520
|
+
// Re-read the .env file to recover the original model id,
|
|
1521
|
+
// and re-snapshot the config so the rest of the app reverts.
|
|
1522
|
+
const envModelId = readEnvFile().KOB_MODEL_ID;
|
|
1523
|
+
const newModel = formatV2Model('DeepSeek', envModelId);
|
|
1524
|
+
setModel(newModel);
|
|
1525
|
+
if (envModelId) {
|
|
1526
|
+
configRef.current = { ...configRef.current, modelId: envModelId };
|
|
1527
|
+
}
|
|
1528
|
+
setExchanges([]);
|
|
1529
|
+
messagesRef.current = [];
|
|
1530
|
+
exchangesLenRef.current = 0;
|
|
1531
|
+
showBanner(`◆ reset → model ${newModel}`);
|
|
1532
|
+
return true;
|
|
1533
|
+
}
|
|
1534
|
+
case 'models':
|
|
1535
|
+
setPalette('models');
|
|
1536
|
+
return true;
|
|
1537
|
+
case 'config':
|
|
1538
|
+
setConfigOpen(true);
|
|
1539
|
+
return true;
|
|
1540
|
+
case 'help':
|
|
1541
|
+
case '?':
|
|
1542
|
+
showBanner('◆ /ask /plan /code /clear /reset /models /config /help /exit');
|
|
1543
|
+
return true;
|
|
1544
|
+
case 'exit':
|
|
1545
|
+
case 'quit':
|
|
1546
|
+
process.stdout.write('\x1B[?25h');
|
|
1547
|
+
process.exit(0);
|
|
1548
|
+
return true;
|
|
1549
|
+
default:
|
|
1550
|
+
showBanner(`◆ unknown command: /${name} (try /help)`);
|
|
1551
|
+
return true;
|
|
1552
|
+
}
|
|
1553
|
+
}, [showBanner]);
|
|
1554
|
+
|
|
1555
|
+
const handleSubmit = useCallback(async (input: string, attachments: string[] = []) => {
|
|
1556
|
+
// Slash commands are intercepted before the model call
|
|
1557
|
+
if (input.startsWith('/')) {
|
|
1558
|
+
handleSlashCommand(input);
|
|
1559
|
+
return;
|
|
1560
|
+
}
|
|
1561
|
+
const t0 = Date.now();
|
|
1562
|
+
const currentMode = modeRef.current;
|
|
1563
|
+
const modeInfo = getMode(currentMode);
|
|
353
1564
|
setPhase('generating');
|
|
354
|
-
setStartMs(
|
|
1565
|
+
setStartMs(t0);
|
|
355
1566
|
|
|
356
|
-
|
|
357
|
-
|
|
1567
|
+
// If images are attached, include them as a clear hint in the user message
|
|
1568
|
+
// (proper multimodal sending would require extending the API client)
|
|
1569
|
+
const fullInput = attachments.length > 0
|
|
1570
|
+
? `${input}\n\n[Attached images — paths saved for reference:]\n${attachments.map(a => ` - ${a}`).join('\n')}`
|
|
1571
|
+
: input;
|
|
1572
|
+
messagesRef.current.push({ role: 'user', content: fullInput });
|
|
1573
|
+
const inTokens = countTokens(fullInput);
|
|
358
1574
|
|
|
359
1575
|
try {
|
|
360
|
-
const
|
|
361
|
-
const client = new KobApiClient(config);
|
|
1576
|
+
const client = new KobApiClient(configRef.current);
|
|
362
1577
|
|
|
363
1578
|
let outTokens = 0;
|
|
364
1579
|
let fullContent = '';
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
const sysPrompt = 'You are an expert programmer. Generate clean, production-ready code. Always indicate the filename at the top of each code block with a comment like: // filename: path/to/file.ext or # filename: path/to/file.ext. Respond with code blocks only, keep explanations brief.';
|
|
1580
|
+
const usedModel = model; // capture the model that was active when streaming started
|
|
368
1581
|
|
|
369
1582
|
for await (const chunk of client.chatStream(
|
|
370
1583
|
model,
|
|
371
1584
|
messagesRef.current,
|
|
372
|
-
{
|
|
1585
|
+
{
|
|
1586
|
+
temperature: currentMode === 'code' ? 0.3 : 0.5,
|
|
1587
|
+
max_tokens: 16384, // 128k context, plenty of room for long output
|
|
1588
|
+
system_prompt: modeInfo.systemPrompt,
|
|
1589
|
+
}
|
|
373
1590
|
)) {
|
|
374
1591
|
const delta = chunk.choices?.[0]?.delta?.content;
|
|
375
1592
|
if (delta) {
|
|
376
1593
|
fullContent += delta;
|
|
377
1594
|
outTokens += countTokens(delta);
|
|
378
1595
|
}
|
|
379
|
-
if (chunk.model) usedModel = chunk.model;
|
|
380
1596
|
}
|
|
381
1597
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
const
|
|
1598
|
+
// After the response finishes, run files AND shell commands automatically
|
|
1599
|
+
// (no prompts, no confirmations — the user wants hands-off execution)
|
|
1600
|
+
const files = modeInfo.writesFiles ? parseFileChanges(fullContent) : [];
|
|
1601
|
+
const created = modeInfo.writesFiles ? writeFiles(files) : [];
|
|
1602
|
+
const shellCommands = currentMode === 'code' ? parseShellCommands(fullContent) : [];
|
|
1603
|
+
const commandResults: CommandResult[] = [];
|
|
1604
|
+
for (const cmd of shellCommands) {
|
|
1605
|
+
commandResults.push(runShellCommand(cmd));
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
// Build a follow-up assistant message that summarises the command outputs
|
|
1609
|
+
// so the model has full context of what happened in the next turn
|
|
1610
|
+
if (commandResults.length > 0) {
|
|
1611
|
+
const summary = commandResults.map(r => {
|
|
1612
|
+
const out = (r.stdout || r.stderr || '(no output)').trim().slice(0, 1500);
|
|
1613
|
+
return `[ran] $ ${r.cmd}\n[exit ${r.exitCode}, ${r.durationMs}ms]\n${out}`;
|
|
1614
|
+
}).join('\n\n');
|
|
1615
|
+
messagesRef.current.push({
|
|
1616
|
+
role: 'assistant',
|
|
1617
|
+
content: `Command execution results:\n\n${summary}`,
|
|
1618
|
+
});
|
|
1619
|
+
}
|
|
385
1620
|
|
|
1621
|
+
messagesRef.current.push({ role: 'assistant', content: fullContent });
|
|
386
1622
|
setExchanges(prev => [...prev, {
|
|
387
1623
|
input,
|
|
388
1624
|
output: fullContent,
|
|
@@ -391,50 +1627,93 @@ function CodeEngine() {
|
|
|
391
1627
|
outTokens,
|
|
392
1628
|
files,
|
|
393
1629
|
created,
|
|
1630
|
+
commandResults,
|
|
1631
|
+
mode: currentMode,
|
|
1632
|
+
durationMs: Date.now() - t0,
|
|
394
1633
|
}]);
|
|
395
1634
|
setPhase('input');
|
|
396
1635
|
} catch (error) {
|
|
397
1636
|
handleApiError(error);
|
|
398
1637
|
process.exit(1);
|
|
399
1638
|
}
|
|
400
|
-
}, [model]);
|
|
1639
|
+
}, [model, handleSlashCommand]);
|
|
401
1640
|
|
|
402
1641
|
return (
|
|
403
|
-
<Box flexDirection="column"
|
|
404
|
-
<
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
<Text color={brand}>{' ╰'}{'─'.repeat(44)}{'╯'}</Text>
|
|
1642
|
+
<Box flexDirection="column" paddingX={1}>
|
|
1643
|
+
<BrandHeader
|
|
1644
|
+
phase={phase}
|
|
1645
|
+
modelName={model}
|
|
1646
|
+
provider="DeepSeek"
|
|
1647
|
+
version={version}
|
|
1648
|
+
session={{
|
|
1649
|
+
rounds: exchanges.length,
|
|
1650
|
+
files: totalFiles,
|
|
1651
|
+
commands: totalCommands,
|
|
1652
|
+
elapsed,
|
|
1653
|
+
totalIn,
|
|
1654
|
+
totalOut,
|
|
1655
|
+
}}
|
|
1656
|
+
/>
|
|
1657
|
+
|
|
1658
|
+
{banner && (
|
|
1659
|
+
<Box marginTop={1}>
|
|
1660
|
+
<Text color={c.brand}>{banner}</Text>
|
|
423
1661
|
</Box>
|
|
1662
|
+
)}
|
|
1663
|
+
|
|
1664
|
+
<Box flexDirection="column" marginTop={1} flexGrow={1}>
|
|
1665
|
+
<ConversationPanel
|
|
1666
|
+
exchanges={exchanges}
|
|
1667
|
+
maxHeight={convMaxHeight}
|
|
1668
|
+
scrollOffset={scrollOffset}
|
|
1669
|
+
defaultRespMax={DEFAULT_RESP_MAX}
|
|
1670
|
+
onScrollChange={setScrollOffset}
|
|
1671
|
+
/>
|
|
424
1672
|
</Box>
|
|
425
1673
|
|
|
426
|
-
|
|
1674
|
+
{palette === 'models' && (
|
|
1675
|
+
<ModelPicker
|
|
1676
|
+
onSelect={(modelId, displayName) => {
|
|
1677
|
+
setModel(modelId);
|
|
1678
|
+
configRef.current = { ...configRef.current, modelId };
|
|
1679
|
+
setPalette(null);
|
|
1680
|
+
setExchanges([]);
|
|
1681
|
+
messagesRef.current = [];
|
|
1682
|
+
exchangesLenRef.current = 0;
|
|
1683
|
+
showBanner(`◆ model → ${displayName} (${modelId})`);
|
|
1684
|
+
}}
|
|
1685
|
+
onClose={() => setPalette(null)}
|
|
1686
|
+
currentModel={model}
|
|
1687
|
+
/>
|
|
1688
|
+
)}
|
|
427
1689
|
|
|
428
|
-
{
|
|
429
|
-
<
|
|
1690
|
+
{configOpen && (
|
|
1691
|
+
<ConfigForm onDone={(saved) => {
|
|
1692
|
+
setConfigOpen(false);
|
|
1693
|
+
if (saved) {
|
|
1694
|
+
// After saving, refresh our in-memory model from the new env
|
|
1695
|
+
const env = readEnvFile();
|
|
1696
|
+
if (env.KOB_MODEL_ID) {
|
|
1697
|
+
setModel(formatV2Model('DeepSeek', env.KOB_MODEL_ID));
|
|
1698
|
+
configRef.current = { ...configRef.current, modelId: env.KOB_MODEL_ID };
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
}} />
|
|
430
1702
|
)}
|
|
431
1703
|
|
|
432
|
-
{phase === '
|
|
433
|
-
<
|
|
1704
|
+
{phase === 'generating' ? (
|
|
1705
|
+
<GeneratingPanel messages={getMode(mode).statusMessages} elapsed={now - startMs} />
|
|
1706
|
+
) : (
|
|
1707
|
+
<InputBox
|
|
434
1708
|
onSubmit={handleSubmit}
|
|
435
|
-
|
|
1709
|
+
mode={mode}
|
|
1710
|
+
onModeChange={setMode}
|
|
1711
|
+
visionSupported={modelSupportsVision(model)}
|
|
1712
|
+
isActive={palette === null && !configOpen && phase === 'input'}
|
|
436
1713
|
/>
|
|
437
1714
|
)}
|
|
1715
|
+
|
|
1716
|
+
<BottomBar phase={phase} mode={mode} />
|
|
438
1717
|
</Box>
|
|
439
1718
|
);
|
|
440
1719
|
}
|