ikie-cli 0.1.0

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/dist/repl.js ADDED
@@ -0,0 +1,948 @@
1
+ import readline from 'readline';
2
+ import { execSync } from 'child_process';
3
+ import { restoreStdinListeners } from './agent.js';
4
+ import { c, PROMPT, CONTINUE_PROMPT, printPromptHeader, drawBanner, infoLine, successLine, errorLine, THEMES, setTheme, stripAnsi, } from './theme.js';
5
+ import { renderMarkdown } from './renderer.js';
6
+ import { loadAllMemory } from './memory.js';
7
+ import { HOME_DIR, saveConfig, DEFAULT_MODEL, FIREWORKS_BASE_URL, IKIE_HOST } from './config.js';
8
+ import { join as pathJoin } from 'path';
9
+ import { deleteSession, listSessions, loadSession, normalizeSessionName, saveSession } from './session.js';
10
+ import { buildUserContent, formatBytes, loadClipboardImageAttachment, loadImageAttachment, hasClipboardImage } from './attachments.js';
11
+ async function fetchModelsFromServer(config) {
12
+ const baseUrl = config.baseURL || FIREWORKS_BASE_URL;
13
+ const apiUrl = baseUrl.includes('/api/v1')
14
+ ? baseUrl.replace('/api/v1', '/api/models')
15
+ : `${IKIE_HOST}/api/models`;
16
+ const response = await fetch(apiUrl);
17
+ if (!response.ok)
18
+ throw new Error(`Failed to fetch models (${response.status})`);
19
+ const data = await response.json();
20
+ return data.models ?? [];
21
+ }
22
+ async function fetchAndDisplayModels(config) {
23
+ try {
24
+ console.log(c.muted('Fetching available models...'));
25
+ const models = await fetchModelsFromServer(config);
26
+ if (!models || models.length === 0) {
27
+ console.log(infoLine('No models available.'));
28
+ console.log(infoLine(`Current model: ${config.model}`));
29
+ return;
30
+ }
31
+ console.log(`\n${c.primary.bold('Available Models')}\n`);
32
+ for (const model of models) {
33
+ const defaultLabel = model.is_default ? c.success(' [DEFAULT]') : '';
34
+ const contextInfo = c.muted(`(${(model.context_window / 1024).toFixed(0)}K context)`);
35
+ console.log(` ${c.secondary(model.name.padEnd(24))} ${c.white(model.display_name)}${defaultLabel} ${contextInfo}`);
36
+ console.log(` ${c.dim(model.provider_model_id)}`);
37
+ }
38
+ console.log(`\n${c.muted('To switch models:')}`);
39
+ console.log(` ${c.accent('/model')} ${c.muted('<name>')}`);
40
+ console.log(`\n${c.muted('Current model:')} ${c.white(config.model)}\n`);
41
+ }
42
+ catch (err) {
43
+ console.log(errorLine(`Error fetching models: ${err instanceof Error ? err.message : String(err)}`));
44
+ console.log(infoLine(`Current model: ${config.model}`));
45
+ }
46
+ }
47
+ const HISTORY_FILE = pathJoin(HOME_DIR, '.repl_history');
48
+ const MAX_HISTORY = 500;
49
+ const SLASH_CMDS = [
50
+ { name: 'help', desc: 'Show commands' },
51
+ { name: 'clear', desc: 'Clear conversation' },
52
+ { name: 'memory', desc: 'View/save memory', subs: [{ name: 'save', desc: 'Save a note' }] },
53
+ {
54
+ name: 'image',
55
+ desc: 'Attach image to next prompt',
56
+ args: '[path|list|clear]',
57
+ subs: [
58
+ { name: 'paste', desc: 'Attach clipboard image' },
59
+ { name: 'list', desc: 'List queued images' },
60
+ { name: 'clear', desc: 'Clear queued images' },
61
+ ],
62
+ },
63
+ {
64
+ name: 'session',
65
+ desc: 'Save/load chat sessions',
66
+ subs: [
67
+ { name: 'list', desc: 'List sessions' },
68
+ { name: 'save', desc: 'Save current session' },
69
+ { name: 'load', desc: 'Load a session' },
70
+ { name: 'new', desc: 'Start fresh session' },
71
+ { name: 'delete', desc: 'Delete a session' },
72
+ ],
73
+ },
74
+ { name: 'context', desc: 'Show project context' },
75
+ {
76
+ name: 'models',
77
+ desc: 'List available models',
78
+ subs: [
79
+ { name: 'list', desc: 'List available models' },
80
+ { name: 'refresh', desc: 'Refresh models from server' },
81
+ ],
82
+ },
83
+ { name: 'model', desc: 'Switch model', args: '<name>' },
84
+ {
85
+ name: 'settings',
86
+ desc: 'View/change settings',
87
+ subs: [
88
+ { name: 'show', desc: 'Show all settings' },
89
+ { name: 'model', desc: 'Change default model' },
90
+ { name: 'reset', desc: 'Reset to defaults' },
91
+ ],
92
+ },
93
+ { name: 'theme', desc: 'Change visual theme', args: '[name]' },
94
+ { name: 'rpm', desc: 'Set request limit', args: '[number]' },
95
+ { name: 'tokens', desc: 'Token estimate' },
96
+ { name: 'exit', desc: 'Exit Ikie' },
97
+ ];
98
+ function slashCompleter(line) {
99
+ if (!line.startsWith('/'))
100
+ return [[], line];
101
+ const parts = line.slice(1).split(/\s+/);
102
+ const cmd = parts[0]?.toLowerCase() ?? '';
103
+ if (parts.length >= 2) {
104
+ const match = SLASH_CMDS.find(c => c.name === cmd);
105
+ if (match?.subs) {
106
+ const sub = parts[1].toLowerCase();
107
+ const hits = match.subs.filter(s => s.name.startsWith(sub)).map(s => `/${cmd} ${s.name}`);
108
+ return [hits, line];
109
+ }
110
+ return [[], line];
111
+ }
112
+ const hits = SLASH_CMDS.filter(c => c.name.startsWith(cmd)).map(c => '/' + c.name);
113
+ return [hits, line];
114
+ }
115
+ function printHelp() {
116
+ console.log(`
117
+ ${c.primary.bold('Ikie Commands')}
118
+
119
+ ${c.warning('/help')} Show this help
120
+ ${c.warning('/clear')} Clear conversation history
121
+ ${c.warning('/memory')} Show loaded memory
122
+ ${c.warning('/memory save')} Save a note
123
+ ${c.warning('/image <path>')} Attach image to next prompt
124
+ ${c.warning('/image paste')} Attach clipboard image
125
+ ${c.warning('/image list')} List queued images
126
+ ${c.warning('/image clear')} Clear queued images
127
+ ${c.warning('/session list')} List saved sessions
128
+ ${c.warning('/session save')} Save current chat
129
+ ${c.warning('/session load')} Load saved chat
130
+ ${c.warning('/session new')} Start a fresh chat
131
+ ${c.warning('/context')} Show current project context
132
+ ${c.warning('/models')} List available models
133
+ ${c.warning('/model <name>')} Switch AI model
134
+ ${c.warning('/settings')} View all settings
135
+ ${c.warning('/settings model')} Change default model (persisted)
136
+ ${c.warning('/settings temperature')} Set temperature (0-2)
137
+ ${c.warning('/settings max-tokens')} Set max tokens
138
+ ${c.warning('/settings top-p')} Set top P (0-1)
139
+ ${c.warning('/settings top-k')} Set top K
140
+ ${c.warning('/settings auto-approve')} Toggle auto-approve
141
+ ${c.warning('/settings rpm')} Set requests per minute
142
+ ${c.warning('/settings theme')} Set visual theme
143
+ ${c.warning('/settings reset')} Reset settings to defaults
144
+ ${c.warning('/theme <name>')} Switch visual theme
145
+ ${c.warning('/theme')} Pick a theme interactively
146
+ ${c.warning('/rpm [number]')} Show or set request limit
147
+ ${c.warning('/tokens')} Show conversation token estimate
148
+ ${c.warning('/exit')} Exit Ikie
149
+
150
+ ${c.primary.bold('Shortcuts')}
151
+
152
+ ${c.accent('!')}${c.muted('<command>')} Run shell command directly
153
+ ${c.accent('Ctrl+V')} Paste image from clipboard
154
+ ${c.accent('Tab')} Complete commands
155
+ ${c.accent('Esc')} Cancel current operation
156
+ ${c.accent('Ctrl+C')} Cancel or quit on second press
157
+ ${c.accent('Ctrl+D')} Exit
158
+ `);
159
+ }
160
+ async function handleSlashCommand(input, agent, config, projectContext, rl, sessionState, imageState) {
161
+ const [cmd, ...args] = input.slice(1).trim().split(/\s+/);
162
+ if (!cmd)
163
+ return true;
164
+ switch (cmd.toLowerCase()) {
165
+ case 'help':
166
+ case '?':
167
+ printHelp();
168
+ return true;
169
+ case 'clear':
170
+ agent.clearConversation();
171
+ console.log(infoLine('Conversation cleared.'));
172
+ return true;
173
+ case 'memory': {
174
+ const sub = args[0];
175
+ if (sub === 'save') {
176
+ process.stdout.write(c.primary(' Note: '));
177
+ const note = await promptLine('');
178
+ if (note.trim()) {
179
+ const scope = args[1] === 'global' ? 'global' : 'project';
180
+ if (scope === 'global') {
181
+ const { appendGlobalMemory } = await import('./memory.js');
182
+ appendGlobalMemory(note);
183
+ }
184
+ else {
185
+ const { appendProjectMemory } = await import('./memory.js');
186
+ appendProjectMemory(note);
187
+ }
188
+ console.log(successLine(`Saved to ${scope} memory.`));
189
+ }
190
+ }
191
+ else {
192
+ const mem = loadAllMemory();
193
+ if (mem.global || mem.project) {
194
+ if (mem.global) {
195
+ console.log(`\n${c.primary.bold('Global Memory')} ${c.muted('(~/.ikie/memory.md)')}`);
196
+ console.log(renderMarkdown(mem.global));
197
+ }
198
+ if (mem.project) {
199
+ console.log(`\n${c.primary.bold('Project Memory')} ${c.muted('(.ikie/memory.md)')}`);
200
+ console.log(renderMarkdown(mem.project));
201
+ }
202
+ }
203
+ else {
204
+ console.log(infoLine('No memory saved yet. Use /memory save to add a note.'));
205
+ }
206
+ }
207
+ return true;
208
+ }
209
+ case 'image': {
210
+ const subOrPath = args.join(' ').trim();
211
+ if (!subOrPath || subOrPath === 'list') {
212
+ if (!imageState.pending.length) {
213
+ console.log(infoLine('No images queued. Use /image <path>.'));
214
+ return true;
215
+ }
216
+ console.log(`\n${c.primary.bold('Queued Images')}`);
217
+ for (const image of imageState.pending) {
218
+ console.log(` ${c.secondary(`[Image #${image.id}]`)} ${c.white(image.name)} ${c.muted(formatBytes(image.bytes))}`);
219
+ console.log(` ${c.dim(image.path)}`);
220
+ }
221
+ return true;
222
+ }
223
+ if (subOrPath === 'clear') {
224
+ const count = imageState.pending.length;
225
+ imageState.pending = [];
226
+ console.log(successLine(`Cleared ${count} queued image${count === 1 ? '' : 's'}.`));
227
+ return true;
228
+ }
229
+ if (subOrPath === 'paste' || subOrPath === 'clipboard') {
230
+ try {
231
+ const image = loadClipboardImageAttachment(imageState.nextId++);
232
+ imageState.pending.push(image);
233
+ console.log(successLine(`Queued [Image #${image.id}] clipboard image (${formatBytes(image.bytes)}). Add text, then press Enter to send.`));
234
+ }
235
+ catch (err) {
236
+ console.log(errorLine(err instanceof Error ? err.message : String(err)));
237
+ }
238
+ return true;
239
+ }
240
+ try {
241
+ const image = loadImageAttachment(subOrPath, imageState.nextId++);
242
+ imageState.pending.push(image);
243
+ console.log(successLine(`Queued [Image #${image.id}] ${image.name} (${formatBytes(image.bytes)}). Add text, then press Enter to send.`));
244
+ }
245
+ catch (err) {
246
+ console.log(errorLine(err instanceof Error ? err.message : String(err)));
247
+ }
248
+ return true;
249
+ }
250
+ case 'session': {
251
+ const sub = (args[0] ?? 'list').toLowerCase();
252
+ const nameArg = args.slice(1).join(' ').trim();
253
+ if (sub === 'list' || sub === 'ls') {
254
+ const sessions = listSessions();
255
+ if (!sessions.length) {
256
+ console.log(infoLine('No saved sessions yet. Use /session save <name>.'));
257
+ return true;
258
+ }
259
+ console.log(`\n${c.primary.bold('Sessions')}`);
260
+ for (const s of sessions.slice(0, 30)) {
261
+ const active = s.name === sessionState.activeName ? c.success('*') : ' ';
262
+ const when = new Date(s.updatedAt).toLocaleString();
263
+ const model = s.model ? ` ${c.muted(s.model.split('/').pop() ?? s.model)}` : '';
264
+ console.log(` ${active} ${c.secondary(s.name.padEnd(24))} ${c.muted(String(s.messageCount).padStart(3) + ' msgs')} ${c.dim(when)}${model}`);
265
+ }
266
+ return true;
267
+ }
268
+ if (sub === 'save') {
269
+ const name = normalizeSessionName(nameArg || sessionState.activeName);
270
+ const saved = saveSession(name, config.model, agent.getConversation());
271
+ sessionState.activeName = saved.name;
272
+ console.log(successLine(`Session saved as "${saved.name}".`));
273
+ return true;
274
+ }
275
+ if (sub === 'load') {
276
+ if (!nameArg) {
277
+ console.log(errorLine('Usage: /session load <name>'));
278
+ return true;
279
+ }
280
+ try {
281
+ const session = loadSession(nameArg);
282
+ agent.setConversation(session.messages);
283
+ sessionState.activeName = session.name;
284
+ if (session.model)
285
+ config.model = session.model;
286
+ console.log(successLine(`Loaded session "${session.name}" (${session.messages.length} messages).`));
287
+ }
288
+ catch (err) {
289
+ console.log(errorLine(err instanceof Error ? err.message : String(err)));
290
+ }
291
+ return true;
292
+ }
293
+ if (sub === 'new') {
294
+ agent.clearConversation();
295
+ sessionState.activeName = nameArg ? normalizeSessionName(nameArg) : undefined;
296
+ console.log(successLine(sessionState.activeName ? `Started new session "${sessionState.activeName}".` : 'Started a fresh session.'));
297
+ return true;
298
+ }
299
+ if (sub === 'delete' || sub === 'rm') {
300
+ if (!nameArg) {
301
+ console.log(errorLine('Usage: /session delete <name>'));
302
+ return true;
303
+ }
304
+ try {
305
+ const name = normalizeSessionName(nameArg);
306
+ deleteSession(name);
307
+ if (sessionState.activeName === name)
308
+ sessionState.activeName = undefined;
309
+ console.log(successLine(`Deleted session "${name}".`));
310
+ }
311
+ catch (err) {
312
+ console.log(errorLine(err instanceof Error ? err.message : String(err)));
313
+ }
314
+ return true;
315
+ }
316
+ console.log(errorLine('Usage: /session list|save [name]|load <name>|new [name]|delete <name>'));
317
+ return true;
318
+ }
319
+ case 'context':
320
+ console.log(`\n${c.primary.bold('Project Context')}\n`);
321
+ console.log(renderMarkdown(projectContext));
322
+ return true;
323
+ case 'models': {
324
+ const sub = args[0]?.toLowerCase();
325
+ if (sub === 'refresh' || !sub || sub === 'list') {
326
+ await fetchAndDisplayModels(config);
327
+ }
328
+ return true;
329
+ }
330
+ case 'model': {
331
+ const model = args[0];
332
+ if (!model) {
333
+ await selectModelInteractively(rl, config);
334
+ }
335
+ else {
336
+ config.model = model;
337
+ saveConfig({ model });
338
+ console.log(successLine(`Default model set to: ${model}`));
339
+ console.log(infoLine('Persisted to settings — will be used for all new sessions.'));
340
+ }
341
+ return true;
342
+ }
343
+ case 'settings': {
344
+ const sub = (args[0] ?? 'show').toLowerCase();
345
+ const value = args.slice(1).join(' ').trim();
346
+ if (sub === 'show' || sub === 'list') {
347
+ console.log(`\n${c.primary.bold('Current Settings')}\n`);
348
+ console.log(` ${c.secondary('Model:')} ${c.white(config.model)}`);
349
+ console.log(` ${c.secondary('Max Tokens:')} ${c.white(config.maxTokens.toLocaleString())}`);
350
+ console.log(` ${c.secondary('Temperature:')} ${c.white(config.temperature)}`);
351
+ console.log(` ${c.secondary('Top P:')} ${c.white(config.topP)}`);
352
+ console.log(` ${c.secondary('Top K:')} ${c.white(config.topK)}`);
353
+ console.log(` ${c.secondary('Auto Approve:')} ${c.white(config.autoApprove ? 'Yes' : 'No')}`);
354
+ console.log(` ${c.secondary('Requests/min:')} ${c.white(config.requestsPerMinute)}`);
355
+ console.log(` ${c.secondary('Theme:')} ${c.white(config.theme)}`);
356
+ console.log(` ${c.secondary('Base URL:')} ${c.white(config.baseURL ?? FIREWORKS_BASE_URL)}`);
357
+ console.log(`\n ${c.muted('Use')} ${c.warning('/settings <option> <value>')} ${c.muted('to change settings')}`);
358
+ console.log(` ${c.muted('Options:')} model, temperature, max-tokens, top-p, top-k, auto-approve, rpm, theme`);
359
+ return true;
360
+ }
361
+ if (sub === 'model') {
362
+ if (!value) {
363
+ console.log(errorLine('Usage: /settings model <model-name>'));
364
+ console.log(`\n${c.muted(' Examples:')}`);
365
+ console.log(` ${c.accent('accounts/fireworks/models/kimi-k2p7-code')}`);
366
+ console.log(` ${c.accent('accounts/fireworks/models/llama-v3p3-70b-instruct')}`);
367
+ console.log(`\n ${c.muted('Current:')} ${c.white(config.model)}`);
368
+ console.log(` ${c.muted('Default:')} ${c.dim(DEFAULT_MODEL)}`);
369
+ return true;
370
+ }
371
+ config.model = value;
372
+ saveConfig({ model: value });
373
+ console.log(successLine(`Default model changed to: ${value}`));
374
+ return true;
375
+ }
376
+ if (sub === 'temperature') {
377
+ const temp = parseFloat(value);
378
+ if (isNaN(temp) || temp < 0 || temp > 2) {
379
+ console.log(errorLine('Temperature must be a number between 0 and 2'));
380
+ return true;
381
+ }
382
+ config.temperature = temp;
383
+ saveConfig({ temperature: temp });
384
+ console.log(successLine(`Temperature set to ${temp}`));
385
+ return true;
386
+ }
387
+ if (sub === 'max-tokens') {
388
+ const tokens = parseInt(value, 10);
389
+ if (isNaN(tokens) || tokens < 1) {
390
+ console.log(errorLine('Max tokens must be a positive number'));
391
+ return true;
392
+ }
393
+ config.maxTokens = tokens;
394
+ saveConfig({ maxTokens: tokens });
395
+ console.log(successLine(`Max tokens set to ${tokens.toLocaleString()}`));
396
+ return true;
397
+ }
398
+ if (sub === 'top-p') {
399
+ const topP = parseFloat(value);
400
+ if (isNaN(topP) || topP < 0 || topP > 1) {
401
+ console.log(errorLine('Top P must be a number between 0 and 1'));
402
+ return true;
403
+ }
404
+ config.topP = topP;
405
+ saveConfig({ topP });
406
+ console.log(successLine(`Top P set to ${topP}`));
407
+ return true;
408
+ }
409
+ if (sub === 'top-k') {
410
+ const topK = parseInt(value, 10);
411
+ if (isNaN(topK) || topK < 1) {
412
+ console.log(errorLine('Top K must be a positive number'));
413
+ return true;
414
+ }
415
+ config.topK = topK;
416
+ saveConfig({ topK });
417
+ console.log(successLine(`Top K set to ${topK}`));
418
+ return true;
419
+ }
420
+ if (sub === 'auto-approve') {
421
+ const on = value === 'true' || value === 'on' || value === 'yes' || value === '1';
422
+ config.autoApprove = on;
423
+ saveConfig({ autoApprove: on });
424
+ console.log(successLine(`Auto approve ${on ? 'enabled' : 'disabled'}`));
425
+ return true;
426
+ }
427
+ if (sub === 'rpm') {
428
+ const rpm = parseInt(value, 10);
429
+ if (isNaN(rpm) || rpm < 1) {
430
+ console.log(errorLine('RPM must be a positive number'));
431
+ return true;
432
+ }
433
+ config.requestsPerMinute = rpm;
434
+ saveConfig({ requestsPerMinute: rpm });
435
+ console.log(successLine(`Request limit set to ${rpm} rpm`));
436
+ return true;
437
+ }
438
+ if (sub === 'theme') {
439
+ if (!value) {
440
+ await selectThemeInteractively(rl, config);
441
+ }
442
+ else if (setTheme(value)) {
443
+ config.theme = value;
444
+ saveConfig({ theme: value });
445
+ drawBanner(config.model);
446
+ console.log(successLine(`Theme switched to "${value}".`));
447
+ }
448
+ else {
449
+ console.log(errorLine(`Unknown theme "${value}". Available: ${Object.keys(THEMES).join(', ')}`));
450
+ }
451
+ return true;
452
+ }
453
+ if (sub === 'reset') {
454
+ config.model = DEFAULT_MODEL;
455
+ config.maxTokens = 32768;
456
+ config.temperature = 0.6;
457
+ config.topP = 0.95;
458
+ config.topK = 40;
459
+ config.autoApprove = false;
460
+ config.requestsPerMinute = 10;
461
+ config.theme = 'nebula';
462
+ saveConfig({
463
+ model: DEFAULT_MODEL,
464
+ maxTokens: 32768,
465
+ temperature: 0.6,
466
+ topP: 0.95,
467
+ topK: 40,
468
+ autoApprove: false,
469
+ requestsPerMinute: 10,
470
+ theme: 'nebula',
471
+ });
472
+ drawBanner(config.model);
473
+ console.log(successLine('Settings reset to defaults'));
474
+ return true;
475
+ }
476
+ console.log(errorLine('Usage: /settings [show|model|temperature|max-tokens|top-p|top-k|auto-approve|rpm|theme|reset]'));
477
+ return true;
478
+ }
479
+ case 'theme': {
480
+ const themeName = args[0]?.toLowerCase();
481
+ if (!themeName) {
482
+ await selectThemeInteractively(rl, config);
483
+ }
484
+ else if (setTheme(themeName)) {
485
+ config.theme = themeName;
486
+ drawBanner(config.model);
487
+ console.log(successLine(`Theme switched to "${themeName}".`));
488
+ }
489
+ else {
490
+ console.log(errorLine(`Unknown theme "${themeName}". Available themes: ${Object.keys(THEMES).join(', ')}`));
491
+ }
492
+ return true;
493
+ }
494
+ case 'rpm': {
495
+ const value = args[0];
496
+ if (!value) {
497
+ console.log(infoLine(`Request limit: ${config.requestsPerMinute} rpm`));
498
+ return true;
499
+ }
500
+ const rpm = Number(value);
501
+ if (!Number.isFinite(rpm) || rpm < 1) {
502
+ console.log(errorLine('Usage: /rpm <number greater than 0>'));
503
+ return true;
504
+ }
505
+ config.requestsPerMinute = Math.floor(rpm);
506
+ console.log(successLine(`Request limit set to ${config.requestsPerMinute} rpm.`));
507
+ return true;
508
+ }
509
+ case 'tokens': {
510
+ const msgs = agent.getConversation();
511
+ const approxTokens = JSON.stringify(msgs).length / 4;
512
+ console.log(infoLine(`~${Math.round(approxTokens).toLocaleString()} tokens in conversation`));
513
+ return true;
514
+ }
515
+ case 'exit':
516
+ case 'quit':
517
+ case 'q':
518
+ printGoodbye(sessionState, agent, config);
519
+ process.exit(0);
520
+ default:
521
+ console.log(errorLine(`Unknown command: /${cmd}. Type /help for help.`));
522
+ return true;
523
+ }
524
+ }
525
+ function promptLine(prompt) {
526
+ return new Promise((resolve) => {
527
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
528
+ rl.question(prompt, (answer) => {
529
+ rl.close();
530
+ resolve(answer);
531
+ });
532
+ });
533
+ }
534
+ function printGoodbye(sessionState, agent, config) {
535
+ const msgs = agent.getConversation();
536
+ const hasConversation = msgs.length > 0;
537
+ // Auto-save if there's an active session and we have config
538
+ if (sessionState.activeName && hasConversation && config) {
539
+ try {
540
+ saveSession(sessionState.activeName, config.model, msgs);
541
+ }
542
+ catch (err) {
543
+ // Silently fail if save doesn't work
544
+ }
545
+ }
546
+ console.log(`\n${c.primary.bold('Goodbye!')}\n`);
547
+ if (sessionState.activeName) {
548
+ console.log(` ${c.muted('Your session')} ${c.secondary.bold(sessionState.activeName)} ${c.muted('has been saved.')}`);
549
+ console.log(`\n ${c.muted('To continue:')}`);
550
+ console.log(` ${c.accent('ikie')} ${c.warning(`/session load ${sessionState.activeName}`)}\n`);
551
+ }
552
+ else if (hasConversation) {
553
+ console.log(` ${c.warning('\u26A0')} ${c.muted('Your current conversation was not saved.')}`);
554
+ console.log(`\n ${c.muted('To save next time:')}`);
555
+ console.log(` ${c.warning('/session save')} ${c.muted('<name>')}\n`);
556
+ }
557
+ console.log(` ${c.muted('Happy coding!')}\n`);
558
+ }
559
+ function formatElapsed(ms) {
560
+ if (ms < 1000)
561
+ return `${ms}ms`;
562
+ const seconds = ms / 1000;
563
+ if (seconds < 60)
564
+ return `${seconds.toFixed(1)}s`;
565
+ const minutes = Math.floor(seconds / 60);
566
+ const rest = Math.floor(seconds % 60).toString().padStart(2, '0');
567
+ return `${minutes}:${rest}`;
568
+ }
569
+ function printRightStatus(message) {
570
+ const cols = process.stdout.columns ?? 80;
571
+ const width = stripAnsi(message).length;
572
+ process.stdout.write(`${' '.repeat(Math.max(0, cols - width - 1))}${message}\n`);
573
+ }
574
+ function formatTaskTimeline(agent, elapsed, state) {
575
+ const stats = agent.getLastTurnStats();
576
+ const modelLabel = `${stats.modelCalls} model`;
577
+ const toolLabel = `${stats.toolCalls} tools`;
578
+ const filesLabel = stats.filesChanged > 0 ? ` · ${stats.filesChanged} files` : '';
579
+ const tail = state === 'cancelled' ? `cancelled after ${elapsed}` : `${state} in ${elapsed}`;
580
+ return `${modelLabel} · ${toolLabel}${filesLabel} · ${tail}`;
581
+ }
582
+ function selectModelInteractively(rl, config) {
583
+ return new Promise((resolve) => {
584
+ const models = [];
585
+ const prompt = () => {
586
+ rl.question(c.muted('Enter number (or 0 to cancel): '), (answer) => {
587
+ const num = parseInt(answer, 10);
588
+ if (isNaN(num) || num < 0 || num > models.length) {
589
+ prompt();
590
+ return;
591
+ }
592
+ if (num === 0) {
593
+ console.log(infoLine('Model selection cancelled.'));
594
+ resolve();
595
+ return;
596
+ }
597
+ const chosen = models[num - 1];
598
+ config.model = chosen.provider_model_id;
599
+ console.log(successLine(`Switched to ${chosen.name} — ${chosen.display_name}`));
600
+ resolve();
601
+ });
602
+ };
603
+ console.log(`\n ${c.accent.bold('Select a Model')}`);
604
+ fetchModelsFromServer(config).then((fetched) => {
605
+ models.push(...fetched);
606
+ if (models.length === 0) {
607
+ console.log(` ${c.error('No models available.')}`);
608
+ resolve();
609
+ return;
610
+ }
611
+ models.forEach((m, i) => {
612
+ const num = c.primary((i + 1).toString().padStart(2));
613
+ const def = m.is_default ? ` ${c.success('[DEFAULT]')}` : '';
614
+ console.log(` ${num}) ${c.bold(m.name.padEnd(24))} ${c.dim(m.display_name)} ${c.muted(`(${(m.context_window / 1024).toFixed(0)}K)`)}${def}`);
615
+ });
616
+ console.log(` ${c.muted(' 0)')} Cancel`);
617
+ prompt();
618
+ }).catch(() => {
619
+ console.log(` ${c.error('Failed to load models.')}`);
620
+ resolve();
621
+ });
622
+ });
623
+ }
624
+ function selectThemeInteractively(rl, config) {
625
+ return new Promise((resolve) => {
626
+ const themesList = Object.keys(THEMES);
627
+ const currentIndex = themesList.indexOf(config.theme);
628
+ let lastIndex = currentIndex === -1 ? 0 : currentIndex;
629
+ const prompt = () => {
630
+ rl.question(c.muted('Enter number (or 0 to cancel): '), (answer) => {
631
+ const num = parseInt(answer, 10);
632
+ if (isNaN(num) || num < 0 || num > themesList.length) {
633
+ prompt();
634
+ return;
635
+ }
636
+ if (num === 0) {
637
+ console.log(infoLine('Theme selection cancelled.'));
638
+ resolve();
639
+ return;
640
+ }
641
+ const chosen = themesList[num - 1];
642
+ setTheme(chosen);
643
+ config.theme = chosen;
644
+ drawBanner(config.model);
645
+ console.log(successLine(`Theme switched to "${chosen}".`));
646
+ resolve();
647
+ });
648
+ };
649
+ console.log(`\n ${c.accent.bold('Select a Theme')}`);
650
+ themesList.forEach((name, i) => {
651
+ const theme = THEMES[name];
652
+ const num = c.primary((i + 1).toString().padStart(2));
653
+ const active = i === currentIndex ? ` ${c.success('[ACTIVE]')}` : '';
654
+ console.log(` ${num}) ${c.bold(name.padEnd(12))} ${c.muted('-')} ${c.dim(theme.description)}${active}`);
655
+ });
656
+ console.log(` ${c.muted(' 0)')} Cancel`);
657
+ prompt();
658
+ });
659
+ }
660
+ export async function startREPL(agent, config, projectContext, oneShot) {
661
+ void HISTORY_FILE;
662
+ if (oneShot) {
663
+ try {
664
+ await agent.send(oneShot, { autoApprove: config.autoApprove });
665
+ }
666
+ catch (err) {
667
+ console.error(errorLine(`Agent error: ${err}`));
668
+ process.exit(1);
669
+ }
670
+ return;
671
+ }
672
+ drawBanner(config.model);
673
+ const rl = readline.createInterface({
674
+ input: process.stdin,
675
+ output: process.stdout,
676
+ terminal: true,
677
+ historySize: MAX_HISTORY,
678
+ prompt: PROMPT,
679
+ completer: slashCompleter,
680
+ });
681
+ let multilineBuffer = '';
682
+ let busy = false;
683
+ let ctrlCCount = 0;
684
+ const sessionState = {};
685
+ const imageState = { nextId: 1, pending: [] };
686
+ let pasteSeq = 0;
687
+ let pasteMode = false;
688
+ let pasteBuffer = '';
689
+ const pastedBlocks = new Map();
690
+ const readlineDataListeners = process.stdin.rawListeners('data').slice();
691
+ for (const fn of readlineDataListeners) {
692
+ process.stdin.removeListener('data', fn);
693
+ }
694
+ const forwardToReadline = (data) => {
695
+ for (const fn of readlineDataListeners) {
696
+ fn.call(process.stdin, data);
697
+ }
698
+ };
699
+ const pasteSummary = (content) => {
700
+ const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
701
+ const lines = normalized ? normalized.split('\n').length : 0;
702
+ if (lines > 1)
703
+ return `~${lines} lines`;
704
+ return `${normalized.length} chars`;
705
+ };
706
+ const insertPasteBlock = (content) => {
707
+ const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
708
+ if (!normalized)
709
+ return;
710
+ const token = `[Pasted #${++pasteSeq}: ${pasteSummary(normalized)}]`;
711
+ pastedBlocks.set(token, normalized);
712
+ rl.write(token);
713
+ };
714
+ const routeInputData = (data) => {
715
+ const text = data.toString('utf8');
716
+ // Check for Ctrl+V (0x16) - try to paste image from clipboard
717
+ if (data.length === 1 && data[0] === 0x16) {
718
+ // Check if clipboard has an image
719
+ if (hasClipboardImage()) {
720
+ try {
721
+ const image = loadClipboardImageAttachment(imageState.nextId++);
722
+ imageState.pending.push(image);
723
+ // Show feedback
724
+ process.stdout.write(`\r\x1b[2K${rl.getPrompt()}`);
725
+ const line = rl.line || '';
726
+ process.stdout.write(line);
727
+ process.stdout.write(`\n ${c.success('✓')} ${c.muted('Image pasted from clipboard')} ${c.secondary(`[Image #${image.id}]`)} ${c.dim(image.name)} ${c.muted(formatBytes(image.bytes))}\n`);
728
+ process.stdout.write(rl.getPrompt() + line);
729
+ return;
730
+ }
731
+ catch (err) {
732
+ // Silently fall through to normal paste if clipboard image load fails
733
+ // This allows Ctrl+V to still work for text paste
734
+ }
735
+ }
736
+ // If no image in clipboard, forward Ctrl+V to readline for text paste
737
+ forwardToReadline(data);
738
+ return;
739
+ }
740
+ if (!pasteMode && !text.includes('\x1b[200~') && /[\r\n]/.test(text.trim()) && text.length > 3) {
741
+ insertPasteBlock(text);
742
+ return;
743
+ }
744
+ let rest = text;
745
+ while (rest) {
746
+ if (pasteMode) {
747
+ const end = rest.indexOf('\x1b[201~');
748
+ if (end === -1) {
749
+ pasteBuffer += rest;
750
+ return;
751
+ }
752
+ pasteBuffer += rest.slice(0, end);
753
+ insertPasteBlock(pasteBuffer);
754
+ pasteBuffer = '';
755
+ pasteMode = false;
756
+ rest = rest.slice(end + '\x1b[201~'.length);
757
+ continue;
758
+ }
759
+ const start = rest.indexOf('\x1b[200~');
760
+ if (start === -1) {
761
+ forwardToReadline(Buffer.from(rest, 'utf8'));
762
+ return;
763
+ }
764
+ if (start > 0) {
765
+ forwardToReadline(Buffer.from(rest.slice(0, start), 'utf8'));
766
+ }
767
+ pasteMode = true;
768
+ rest = rest.slice(start + '\x1b[200~'.length);
769
+ }
770
+ };
771
+ process.stdin.on('data', routeInputData);
772
+ const expandPastedBlocks = (input) => {
773
+ let expanded = input;
774
+ const used = [];
775
+ for (const [token, content] of pastedBlocks) {
776
+ if (!expanded.includes(token))
777
+ continue;
778
+ used.push(token);
779
+ expanded = expanded.split(token).join(`[pasted content ${token}]`);
780
+ }
781
+ if (!used.length)
782
+ return input;
783
+ const attachments = used.map((token, i) => {
784
+ const content = pastedBlocks.get(token) ?? '';
785
+ return `Pasted content ${i + 1} (${token}):\n${content}`;
786
+ });
787
+ for (const token of used)
788
+ pastedBlocks.delete(token);
789
+ return `${expanded}\n\n${attachments.join('\n\n')}`;
790
+ };
791
+ const showPrompt = () => {
792
+ if (multilineBuffer) {
793
+ rl.setPrompt(CONTINUE_PROMPT);
794
+ }
795
+ else {
796
+ printPromptHeader();
797
+ if (imageState.pending.length) {
798
+ const labels = imageState.pending.map(image => `[Image #${image.id}]`).join(' ');
799
+ process.stdout.write(`${c.muted(' attached')} ${c.secondary(labels)}\n`);
800
+ }
801
+ rl.setPrompt(PROMPT);
802
+ }
803
+ rl.prompt();
804
+ };
805
+ rl.on('close', () => {
806
+ printGoodbye(sessionState, agent, config);
807
+ process.exit(0);
808
+ });
809
+ rl.on('SIGINT', () => {
810
+ if (busy)
811
+ return;
812
+ ctrlCCount++;
813
+ if (ctrlCCount >= 2) {
814
+ rl.close();
815
+ return;
816
+ }
817
+ multilineBuffer = '';
818
+ process.stdout.write(`\n${c.muted(' (press Ctrl+C again or type /exit to quit)')}\n`);
819
+ setTimeout(() => { ctrlCCount = 0; }, 2000);
820
+ showPrompt();
821
+ });
822
+ const handleLine = async (raw) => {
823
+ const line = raw.trim();
824
+ if (!line) {
825
+ showPrompt();
826
+ return;
827
+ }
828
+ if (line.endsWith('\\')) {
829
+ multilineBuffer += (multilineBuffer ? '\n' : '') + line.slice(0, -1);
830
+ showPrompt();
831
+ return;
832
+ }
833
+ const fullInput = multilineBuffer ? multilineBuffer + '\n' + line : line;
834
+ multilineBuffer = '';
835
+ if (fullInput.startsWith('!')) {
836
+ const cmd = fullInput.slice(1).trim();
837
+ if (cmd) {
838
+ try {
839
+ process.stdout.write(execSync(cmd, { encoding: 'utf-8' }));
840
+ }
841
+ catch (err) {
842
+ const e = err;
843
+ if (e.stdout)
844
+ process.stdout.write(e.stdout);
845
+ if (e.stderr)
846
+ process.stdout.write(e.stderr);
847
+ else
848
+ console.error(errorLine(e.message ?? 'Command failed'));
849
+ }
850
+ process.stdout.write('\n');
851
+ }
852
+ showPrompt();
853
+ return;
854
+ }
855
+ if (fullInput.startsWith('/')) {
856
+ await handleSlashCommand(fullInput, agent, config, projectContext, rl, sessionState, imageState);
857
+ process.stdout.write('\n');
858
+ showPrompt();
859
+ return;
860
+ }
861
+ const agentInput = expandPastedBlocks(fullInput);
862
+ const imagesForTurn = [...imageState.pending];
863
+ const userContent = buildUserContent(agentInput, imagesForTurn);
864
+ imageState.pending = [];
865
+ let restoredImages = false;
866
+ process.stdout.write('\r\x1b[2K');
867
+ process.stdout.write(`${c.muted(' Working. Press Esc to interrupt.')}\n`);
868
+ busy = true;
869
+ rl.pause();
870
+ const taskStartedAt = Date.now();
871
+ let taskFailed = false;
872
+ const abortController = new AbortController();
873
+ const savedDataListeners = process.stdin.rawListeners('data').slice();
874
+ const savedKeypressListeners = process.stdin.rawListeners('keypress').slice();
875
+ process.stdin.removeAllListeners('data');
876
+ process.stdin.removeAllListeners('keypress');
877
+ if (process.stdin.isTTY)
878
+ process.stdin.setRawMode(true);
879
+ process.stdin.resume();
880
+ const cancelHandler = (data) => {
881
+ const b = data[0];
882
+ if (b === 0x1b && data.length === 1) {
883
+ if (!abortController.signal.aborted) {
884
+ abortController.abort();
885
+ process.stdout.write(`\n${c.warning(' Cancelled')}\n`);
886
+ }
887
+ }
888
+ else if (b === 0x03) {
889
+ ctrlCCount++;
890
+ if (ctrlCCount >= 2)
891
+ process.exit(0);
892
+ if (!abortController.signal.aborted) {
893
+ abortController.abort();
894
+ process.stdout.write(`\n${c.warning(' Cancelled')}\n`);
895
+ }
896
+ setTimeout(() => { ctrlCCount = 0; }, 2000);
897
+ }
898
+ };
899
+ process.stdin.on('data', cancelHandler);
900
+ try {
901
+ await agent.send(userContent, {
902
+ autoApprove: config.autoApprove,
903
+ signal: abortController.signal,
904
+ startedAt: taskStartedAt,
905
+ });
906
+ if (sessionState.activeName && !abortController.signal.aborted) {
907
+ saveSession(sessionState.activeName, config.model, agent.getConversation());
908
+ }
909
+ }
910
+ catch (err) {
911
+ if (!abortController.signal.aborted) {
912
+ taskFailed = true;
913
+ if (imagesForTurn.length) {
914
+ imageState.pending = [...imagesForTurn, ...imageState.pending];
915
+ restoredImages = true;
916
+ }
917
+ const e = err;
918
+ console.error(`\n${errorLine(`Error: ${e.message ?? String(err)}`)}`);
919
+ }
920
+ }
921
+ finally {
922
+ if (abortController.signal.aborted && imagesForTurn.length && !restoredImages) {
923
+ imageState.pending = [...imagesForTurn, ...imageState.pending];
924
+ }
925
+ const elapsed = formatElapsed(Date.now() - taskStartedAt);
926
+ process.stdin.removeListener('data', cancelHandler);
927
+ if (process.stdin.isTTY)
928
+ process.stdin.setRawMode(false);
929
+ process.stdin.pause();
930
+ restoreStdinListeners(savedDataListeners, savedKeypressListeners);
931
+ busy = false;
932
+ ctrlCCount = 0;
933
+ rl.resume();
934
+ const status = abortController.signal.aborted
935
+ ? c.warning(formatTaskTimeline(agent, elapsed, 'cancelled'))
936
+ : taskFailed
937
+ ? c.error(formatTaskTimeline(agent, elapsed, 'failed'))
938
+ : c.muted(formatTaskTimeline(agent, elapsed, 'done'));
939
+ printRightStatus(status);
940
+ showPrompt();
941
+ }
942
+ };
943
+ let chain = Promise.resolve();
944
+ rl.on('line', (raw) => {
945
+ chain = chain.then(() => handleLine(raw));
946
+ });
947
+ showPrompt();
948
+ }