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.
@@ -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, resolve } from 'path';
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
- const brand = '#38bdf8';
10
- const accent = '#a78bfa';
11
- const green = '#34d399';
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
- const statusMessages = [
112
- 'Analyzing request...',
113
- 'Designing architecture...',
114
- 'Writing code...',
115
- 'Optimizing implementation...',
116
- 'Reviewing output...',
117
- 'Finalizing...',
118
- ];
119
-
120
- interface InputAreaProps {
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
- function InputArea({ onSubmit, placeholder = "What's next?" }: InputAreaProps) {
126
- const [value, setValue] = useState('');
213
+ const SHELL_LANGS = new Set(['bash', 'sh', 'shell', 'zsh', 'powershell', 'ps1', 'cmd', 'bat', 'console']);
127
214
 
128
- useInput((char, key) => {
129
- if (key.ctrl && char === 'c') {
130
- process.stdout.write('\x1B[?25h');
131
- process.exit(0);
132
- }
133
- if (key.return) {
134
- const trimmed = value.trim();
135
- if (trimmed) {
136
- if (trimmed === '/exit' || trimmed === '/quit') {
137
- process.stdout.write('\x1B[?25h');
138
- process.exit(0);
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
- return;
144
- }
145
- if (key.backspace || key.delete) {
146
- setValue(prev => prev.slice(0, -1));
147
- return;
236
+ continue;
148
237
  }
149
- if (char && char.length === 1) {
150
- setValue(prev => prev + char);
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 flexDirection="column" marginTop={1}>
156
- <Box>
157
- <Text color={accent}>{' ┃ '}</Text>
158
- <Text color={gray}>{placeholder}</Text>
159
- </Box>
160
- <Box marginLeft={5}>
161
- <Text color="white">{value}</Text>
162
- <Text color={brand}>{'█'}</Text>
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
- const spinnerChars = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
169
- const shimmerColors = ['#38bdf8', '#34d399', '#a78bfa', '#f472b6', '#fbbf24', '#a78bfa', '#34d399', '#38bdf8'];
170
- const stepInterval = 2500;
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
- function GeneratingAnimation({ messages, elapsed }: { messages: string[]; elapsed: number }) {
173
- const { frame } = useAnimation({ interval: 80 });
431
+ // ============================================================================
432
+ // BRAND HEADER big logo + info bar + quick start (always at top)
433
+ // ============================================================================
434
+ type Phase = 'input' | 'generating';
174
435
 
175
- const spinner = spinnerChars[frame % spinnerChars.length]!;
176
- const colorIdx = frame % shimmerColors.length;
177
- const secs = Math.floor(elapsed / 1000);
178
- const timeStr = secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m ${secs % 60}s`;
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 currentStep = Math.min(Math.floor(elapsed / stepInterval), messages.length - 1);
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" marginTop={1}>
184
- <Box>
185
- <Text color={gray}>{' ╭'}{'─'.repeat(44)}{'╮'}</Text>
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
- return (
207
- <Box key={i}>
208
- <Text color={gray}>{' │'}</Text>
209
- <Text>{' '}</Text>
210
- <Text color={iconColor}>{icon}</Text>
211
- <Text>{' '}</Text>
212
- <Text color={textColor}>{msg}</Text>
213
- <Text color={gray}>{' │'}</Text>
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
- <Box>
218
- <Text color={gray}>{' │'}</Text>
219
- <Text>{' '}</Text>
220
- <Text color={gray}>{'⏱ '}{timeStr}</Text>
221
- <Text color={gray}>{' │'}</Text>
222
- </Box>
223
- <Box>
224
- <Text color={gray}>{' ╰'}{'─'.repeat(44)}{'╮'}</Text>
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
- interface FileChangesProps {
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 FileChanges({ files, created }: FileChangesProps) {
236
- if (files.length === 0) {
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 marginLeft={3} marginTop={1}>
239
- <Text color={gray}>No file changes detected</Text>
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" marginTop={1}>
246
- {files.map((f, i) => {
247
- const isCreated = created.includes(f.filename);
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} marginLeft={3}>
250
- {isCreated ? (
251
- <Text color={green}>{'✅ '}</Text>
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
- <Text color={yellow}>{'📄 '}</Text>
815
+ <Box marginTop={1} marginLeft={2}>
816
+ <Text color={c.red}>✗ (no response received)</Text>
817
+ </Box>
254
818
  )}
255
- <Text bold color={isCreated ? green : 'white'}>{f.filename}</Text>
256
- <Text>{' '}</Text>
257
- <Text color={green}>{'+' + f.addLines}</Text>
258
- {f.delLines > 0 && (
259
- <>
260
- <Text>{' '}</Text>
261
- <Text color={red}>{'-' + f.delLines}</Text>
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
- interface UsageBarProps {
272
- model: string;
273
- inTokens: number;
274
- outTokens: number;
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 UsageBar({ model, inTokens, outTokens }: UsageBarProps) {
278
- const total = inTokens + outTokens;
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" marginTop={1}>
973
+ <Box flexDirection="column" paddingX={1} paddingY={1}>
974
+ {/* Big greeting line */}
281
975
  <Box>
282
- <Text color={gray}>{' ╭'}{'─'.repeat(44)}{'╮'}</Text>
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={gray}>{' │'}</Text>
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={gray}>{' │'}</Text>
292
- <Text>{' '}</Text>
293
- <Text color={gray}>{'📥 '}{inTokens}{' tok'}</Text>
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
- <Box>
301
- <Text color={gray}>{' ╰'}{'─'.repeat(44)}{'╯'}</Text>
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
- interface Exchange {
308
- input: string;
309
- output: string;
310
- model: string;
311
- inTokens: number;
312
- outTokens: number;
313
- files: FileChange[];
314
- created: string[];
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
- function ConversationView({ exchanges }: { exchanges: Exchange[] }) {
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
- {exchanges.map((exc, i) => (
321
- <Box key={i} flexDirection="column" marginTop={1}>
322
- <Box marginLeft={2} marginBottom={1}>
323
- <Text color={accent}>{'✦ Round '}{i + 1}</Text>
324
- </Box>
325
- <FileChanges files={exc.files} created={exc.created} />
326
- <UsageBar model={exc.model} inTokens={exc.inTokens} outTokens={exc.outTokens} />
327
- {i < exchanges.length - 1 && (
328
- <Box marginTop={1} marginLeft={2}>
329
- <Text color={gray}>{''.repeat(44)}</Text>
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<'input' | 'generating'>('input');
348
- const [startMs, setStartMs] = useState(0);
349
- const model = formatV2Model('DeepSeek', getConfig().modelId);
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
- const handleSubmit = useCallback(async (input: string) => {
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(Date.now());
1565
+ setStartMs(t0);
355
1566
 
356
- messagesRef.current.push({ role: 'user', content: input });
357
- const inTokens = countTokens(input);
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 config = getConfig();
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
- let usedModel = model;
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
- { temperature: 0.3, max_tokens: 8192, system_prompt: sysPrompt }
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
- messagesRef.current.push({ role: 'assistant', content: fullContent });
383
- const files = parseFileChanges(fullContent);
384
- const created = writeFiles(files);
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" padding={1}>
404
- <Box flexDirection="column" marginBottom={1}>
405
- <Box>
406
- <Text color={brand}>{' ╭'}{'─'.repeat(44)}{'╮'}</Text>
407
- </Box>
408
- <Box>
409
- <Text color={brand}>{' │'}</Text>
410
- <Text>{' '}</Text>
411
- <Text color={brand} bold>KOB</Text>
412
- <Text color={gray}>{' Code Engineer'}</Text>
413
- <Text color={brand}>{' │'}</Text>
414
- </Box>
415
- <Box>
416
- <Text color={brand}>{' │'}</Text>
417
- <Text>{' '}</Text>
418
- <Text color={gray}>{'multi-turn code generation'}</Text>
419
- <Text color={brand}>{' │'}</Text>
420
- </Box>
421
- <Box>
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
- <ConversationView exchanges={exchanges} />
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
- {phase === 'generating' && (
429
- <GeneratingAnimation messages={statusMessages} elapsed={Date.now() - startMs} />
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 === 'input' && (
433
- <InputArea
1704
+ {phase === 'generating' ? (
1705
+ <GeneratingPanel messages={getMode(mode).statusMessages} elapsed={now - startMs} />
1706
+ ) : (
1707
+ <InputBox
434
1708
  onSubmit={handleSubmit}
435
- placeholder={exchanges.length === 0 ? "What do you want to build?" : "What's next? (or /exit)"}
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
  }