@taj-special/dravix-code 1.1.1

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.
@@ -0,0 +1,2833 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import * as os from 'os';
4
+ import chalk from 'chalk';
5
+ import { streamChat, fetchSystemPrompt } from '../services/ai.js';
6
+ import { buildContext } from '../services/context.js';
7
+ import { parseOps, executeSingleOp, computeDiff } from '../services/executor.js';
8
+ import { handleCommand } from './commands.js';
9
+ import { printOpResult, printError, printInfo, colors } from '../utils/display.js';
10
+ import { saveConversation, loadConversation, listConversations, generateTitle, generateId } from '../services/conversations.js';
11
+ import { getToken, getSavedUser } from '../services/auth.js';
12
+ import { checkUsage, reportUsage, estimateTokens, usageBar, fmtNum, formatResetTime } from '../services/usage.js';
13
+ // System prompt is fetched from server at runtime — not stored in this package
14
+ const BASE_PROMPT = ``;
15
+ // prompts removed
16
+ // Removed — fetched from server
17
+ const CREATOR_EXTRA = ``;
18
+ // Removed — fetched from server at runtime
19
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
20
+ function buildSystemPrompt(_userEmail) { return BASE_PROMPT; }
21
+ let _serverWebDesignerSkill = '';
22
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
23
+ const SLASH_COMMANDS = [
24
+ { name: '/files', desc: 'List project files and git status' },
25
+ { name: '/resume', desc: 'Resume a saved conversation' },
26
+ { name: '/retry', desc: 'Resend your last message' },
27
+ { name: '/copy', desc: 'Copy last AI response to clipboard' },
28
+ { name: '/model', desc: 'Show current AI model info' },
29
+ { name: '/clear', desc: 'Clear screen' },
30
+ { name: '/whoami', desc: 'Show signed-in user' },
31
+ { name: '/logout', desc: 'Sign out from Dravix Code' },
32
+ { name: '/help', desc: 'Show all commands and shortcuts' },
33
+ { name: '/exit', desc: 'Exit Dravix Code' },
34
+ ];
35
+ function detectActiveStreamingOp(response) {
36
+ const tags = [
37
+ { name: 'write_file', label: 'Writing', self: false },
38
+ { name: 'edit_file', label: 'Editing', self: false },
39
+ { name: 'create_folder', label: 'Creating', self: true },
40
+ { name: 'delete_file', label: 'Deleting', self: true },
41
+ { name: 'run_command', label: 'Running', self: false },
42
+ { name: 'read_file', label: 'Reading', self: true },
43
+ { name: 'read_folder', label: 'Reading', self: true },
44
+ { name: 'search_code', label: 'Searching', self: true },
45
+ ];
46
+ for (const { name, label, self } of tags) {
47
+ const openIdx = response.lastIndexOf(`<${name}`);
48
+ if (openIdx === -1)
49
+ continue;
50
+ const closeIdx = response.indexOf(`</${name}>`, openIdx);
51
+ if (closeIdx !== -1)
52
+ continue;
53
+ if (self && response.indexOf('/>', openIdx) !== -1)
54
+ continue;
55
+ const snippet = response.slice(openIdx, openIdx + 300);
56
+ const pathMatch = snippet.match(/path="([^"]+)"/);
57
+ if (pathMatch)
58
+ return `${label} ${path.basename(pathMatch[1])}`;
59
+ if (name === 'run_command') {
60
+ const m = snippet.match(/^<run_command(?:[^>]*)?>([^\n<]{0,55})/);
61
+ return `${label} ${m?.[1]?.trim() || '...'}`;
62
+ }
63
+ return `${label} ...`;
64
+ }
65
+ return null;
66
+ }
67
+ // ── Streaming tag stripper ──────────────────────────────────────
68
+ const OUR_TAGS = ['write_file', 'edit_file', 'create_folder', 'delete_file', 'run_command', 'read_file', 'read_folder', 'search_code', 'design_mode'];
69
+ const MAX_TAG_PREFIX = 15;
70
+ class TagStripper {
71
+ buf = '';
72
+ suppressing = false;
73
+ closingFor = '';
74
+ feed(chunk) {
75
+ let out = '';
76
+ this.buf += chunk;
77
+ while (true) {
78
+ if (this.suppressing) {
79
+ const closeTag = `</${this.closingFor}>`;
80
+ if (this.closingFor === 'create_folder' || this.closingFor === 'delete_file') {
81
+ const si = this.buf.indexOf('/>');
82
+ const ci = this.buf.indexOf(closeTag);
83
+ const end = si !== -1 && (ci === -1 || si < ci) ? si + 2
84
+ : ci !== -1 ? ci + closeTag.length : -1;
85
+ if (end !== -1) {
86
+ this.buf = this.buf.slice(end);
87
+ this.suppressing = false;
88
+ this.closingFor = '';
89
+ continue;
90
+ }
91
+ }
92
+ else {
93
+ const ci = this.buf.indexOf(closeTag);
94
+ if (ci !== -1) {
95
+ this.buf = this.buf.slice(ci + closeTag.length);
96
+ this.suppressing = false;
97
+ this.closingFor = '';
98
+ continue;
99
+ }
100
+ }
101
+ const guard = closeTag.length;
102
+ if (this.buf.length > guard)
103
+ this.buf = this.buf.slice(this.buf.length - guard);
104
+ break;
105
+ }
106
+ else {
107
+ const li = this.buf.indexOf('<');
108
+ if (li === -1) {
109
+ out += this.buf;
110
+ this.buf = '';
111
+ break;
112
+ }
113
+ out += this.buf.slice(0, li);
114
+ this.buf = this.buf.slice(li);
115
+ let found = false;
116
+ for (const name of OUR_TAGS) {
117
+ if (this.buf.startsWith(`<${name}`)) {
118
+ this.suppressing = true;
119
+ this.closingFor = name;
120
+ found = true;
121
+ break;
122
+ }
123
+ }
124
+ if (found)
125
+ continue;
126
+ const partial = OUR_TAGS.some(n => `<${n}`.startsWith(this.buf.slice(0, Math.min(this.buf.length, `<${n}`.length))));
127
+ if (partial && this.buf.length < MAX_TAG_PREFIX)
128
+ break;
129
+ out += '<';
130
+ this.buf = this.buf.slice(1);
131
+ }
132
+ }
133
+ return out;
134
+ }
135
+ flush() {
136
+ if (this.suppressing) {
137
+ this.buf = '';
138
+ return '';
139
+ }
140
+ const r = this.buf;
141
+ this.buf = '';
142
+ return r;
143
+ }
144
+ }
145
+ // ── Helpers ─────────────────────────────────────────────────────
146
+ function stripAnsi(s) {
147
+ return s.replace(/\x1b\[[0-9;]*m/g, '');
148
+ }
149
+ // ── Syntax highlighter ───────────────────────────────────────────
150
+ const SH = {
151
+ keyword: chalk.hex('#a78bfa'),
152
+ string: chalk.hex('#86efac'),
153
+ comment: chalk.hex('#4b5563'),
154
+ number: chalk.hex('#fb923c'),
155
+ type: chalk.hex('#67e8f9'),
156
+ fn: chalk.hex('#60a5fa'),
157
+ op: chalk.hex('#94a3b8'),
158
+ plain: chalk.hex('#e2e8f0'),
159
+ jsonKey: chalk.hex('#93c5fd'),
160
+ jsonVal: chalk.hex('#86efac'),
161
+ };
162
+ const KW_JS = new Set('break case catch class const continue debugger default delete do else export extends false finally for from function get if import in instanceof let new null of return set static super switch this throw true try typeof undefined var void while with yield async await abstract declare enum interface module namespace override private protected public readonly type'.split(' '));
163
+ const KW_PY = new Set('and as assert async await break class continue def del elif else except finally for from global if import in is lambda nonlocal not or pass raise return try while with yield None True False'.split(' '));
164
+ const KW_GO = new Set('break case chan const continue default defer else fallthrough for func go goto if import interface map package range return select struct switch type var true false nil'.split(' '));
165
+ const KW_RS = new Set('as async await break const continue crate dyn else enum extern false fn for if impl in let loop match mod move mut pub ref return self Self static struct super trait true type union unsafe use where while'.split(' '));
166
+ function highlightCode(line, lang) {
167
+ const L = lang.toLowerCase();
168
+ // ── JSON ─────────────────────────────────────────────────────
169
+ if (L === 'json' || L === 'jsonc') {
170
+ let r = '';
171
+ let i = 0;
172
+ while (i < line.length) {
173
+ if (line[i] === '"') {
174
+ let j = i + 1;
175
+ while (j < line.length) {
176
+ if (line[j] === '\\') {
177
+ j += 2;
178
+ continue;
179
+ }
180
+ if (line[j] === '"') {
181
+ j++;
182
+ break;
183
+ }
184
+ j++;
185
+ }
186
+ const tok = line.slice(i, j);
187
+ let k = j;
188
+ while (k < line.length && line[k] === ' ')
189
+ k++;
190
+ r += (line[k] === ':') ? SH.jsonKey(tok) : SH.jsonVal(tok);
191
+ i = j;
192
+ continue;
193
+ }
194
+ if (line.startsWith('//', i)) {
195
+ r += SH.comment(line.slice(i));
196
+ break;
197
+ }
198
+ const nm = line.slice(i).match(/^-?\d+\.?\d*(?:[eE][+-]?\d+)?/);
199
+ if (nm && (i === 0 || !/\w/.test(line[i - 1]))) {
200
+ r += SH.number(nm[0]);
201
+ i += nm[0].length;
202
+ continue;
203
+ }
204
+ const bm = line.slice(i).match(/^(true|false|null)\b/);
205
+ if (bm) {
206
+ r += SH.keyword(bm[0]);
207
+ i += bm[0].length;
208
+ continue;
209
+ }
210
+ r += SH.op(line[i]);
211
+ i++;
212
+ }
213
+ return r;
214
+ }
215
+ // ── Choose keyword set ────────────────────────────────────────
216
+ const KW = L === 'py' || L === 'python' ? KW_PY
217
+ : L === 'go' ? KW_GO
218
+ : L === 'rs' || L === 'rust' ? KW_RS
219
+ : KW_JS;
220
+ const lineComStarts = (L === 'py' || L === 'python' || L === 'sh' || L === 'bash' || L === 'shell' || L === 'zsh') ? ['#']
221
+ : (L === 'lua' || L === 'sql') ? ['--']
222
+ : ['//', '#'];
223
+ // ── Tokenize ─────────────────────────────────────────────────
224
+ let result = '';
225
+ let i = 0;
226
+ while (i < line.length) {
227
+ // Line comment
228
+ const lc = lineComStarts.find(s => line.startsWith(s, i));
229
+ if (lc) {
230
+ result += SH.comment(line.slice(i));
231
+ break;
232
+ }
233
+ // Block comment /* */
234
+ if (line.startsWith('/*', i)) {
235
+ const end = line.indexOf('*/', i + 2);
236
+ result += SH.comment(end >= 0 ? line.slice(i, end + 2) : line.slice(i));
237
+ i = end >= 0 ? end + 2 : line.length;
238
+ continue;
239
+ }
240
+ // String / template literal
241
+ if (line[i] === '"' || line[i] === "'" || line[i] === '`') {
242
+ const q = line[i];
243
+ let j = i + 1;
244
+ while (j < line.length) {
245
+ if (line[j] === '\\') {
246
+ j += 2;
247
+ continue;
248
+ }
249
+ if (line[j] === q) {
250
+ j++;
251
+ break;
252
+ }
253
+ j++;
254
+ }
255
+ result += SH.string(line.slice(i, j));
256
+ i = j;
257
+ continue;
258
+ }
259
+ // Number
260
+ if (/\d/.test(line[i]) && (i === 0 || !/[\w$]/.test(line[i - 1]))) {
261
+ const m = line.slice(i).match(/^(?:0x[\da-fA-F]+|0b[01]+|\d+\.?\d*(?:[eE][+-]?\d+)?)/);
262
+ if (m) {
263
+ result += SH.number(m[0]);
264
+ i += m[0].length;
265
+ continue;
266
+ }
267
+ }
268
+ // Identifier / keyword / type / function
269
+ if (/[a-zA-Z_$]/.test(line[i])) {
270
+ let j = i;
271
+ while (j < line.length && /[\w$]/.test(line[j]))
272
+ j++;
273
+ const word = line.slice(i, j);
274
+ if (KW.has(word)) {
275
+ result += SH.keyword(word);
276
+ }
277
+ else {
278
+ let k = j;
279
+ while (k < line.length && line[k] === ' ')
280
+ k++;
281
+ if (line[k] === '(')
282
+ result += SH.fn(word);
283
+ else if (/^[A-Z]/.test(word))
284
+ result += SH.type(word);
285
+ else
286
+ result += SH.plain(word);
287
+ }
288
+ i = j;
289
+ continue;
290
+ }
291
+ result += SH.op(line[i]);
292
+ i++;
293
+ }
294
+ return result;
295
+ }
296
+ // ── Markdown renderer (line-buffered, streaming-safe) ───────────
297
+ function applyInline(text) {
298
+ return text
299
+ .replace(/\*\*(.+?)\*\*|__(.+?)__/g, (_, a, b) => chalk.bold(a ?? b))
300
+ .replace(/`([^`\n]+)`/g, (_, c) => chalk.hex('#fbbf24')(c))
301
+ .replace(/(?<!\*)\*([^*\n]+)\*(?!\*)/g, (_, t) => chalk.italic(t));
302
+ }
303
+ class MarkdownRenderer {
304
+ lineBuf = '';
305
+ inCodeBlock = false;
306
+ codeLines = [];
307
+ codeLang = '';
308
+ blankCount = 0;
309
+ inTable = false;
310
+ tableRows = [];
311
+ format(chunk) {
312
+ // Strip \r to normalise Windows/mixed line endings
313
+ const clean = chunk.replace(/\r/g, '');
314
+ let out = '';
315
+ for (const c of clean) {
316
+ if (c === '\n') {
317
+ out += this.processLine(this.lineBuf);
318
+ this.lineBuf = '';
319
+ }
320
+ else {
321
+ this.lineBuf += c;
322
+ }
323
+ }
324
+ return out;
325
+ }
326
+ flush() {
327
+ let out = '';
328
+ if (this.lineBuf) {
329
+ out += this.processLine(this.lineBuf);
330
+ this.lineBuf = '';
331
+ }
332
+ if (this.inTable) {
333
+ out += this.renderTable();
334
+ this.inTable = false;
335
+ }
336
+ if (this.inCodeBlock) {
337
+ out += this.renderCodeBlock();
338
+ this.inCodeBlock = false;
339
+ }
340
+ return out;
341
+ }
342
+ processLine(raw) {
343
+ // ── Inside code block ──────────────────────────────────────
344
+ if (this.inCodeBlock) {
345
+ if (raw.trimEnd() === '```') {
346
+ this.inCodeBlock = false;
347
+ return this.renderCodeBlock();
348
+ }
349
+ this.codeLines.push(raw);
350
+ return '';
351
+ }
352
+ // ── Opening code fence ─────────────────────────────────────
353
+ if (raw.startsWith('```')) {
354
+ this.inCodeBlock = true;
355
+ this.codeLang = raw.slice(3).trim();
356
+ this.codeLines = [];
357
+ return '';
358
+ }
359
+ // ── Table row ─────────────────────────────────────────────
360
+ if (raw.startsWith('|')) {
361
+ this.inTable = true;
362
+ const allCells = raw.split('|').slice(1, -1).map(c => c.trim());
363
+ const isSep = allCells.length > 0 && allCells.every(c => /^:?-+:?$/.test(c));
364
+ if (!isSep && allCells.length > 0) {
365
+ this.tableRows.push(allCells);
366
+ }
367
+ return '';
368
+ }
369
+ if (this.inTable) {
370
+ this.inTable = false;
371
+ return this.renderTable() + this.processLine(raw);
372
+ }
373
+ // ── Blank line (collapse 3+ to 2) ─────────────────────────
374
+ if (!raw.trim()) {
375
+ this.blankCount++;
376
+ return this.blankCount <= 2 ? '\n' : '';
377
+ }
378
+ // ── Heading # / ## / ### ──────────────────────────────────
379
+ const hm = raw.match(/^(#{1,3})\s+(.+)$/);
380
+ if (hm) {
381
+ const lvl = hm[1].length;
382
+ const title = hm[2];
383
+ const paint = lvl === 1 ? chalk.bold.hex('#818cf8')
384
+ : lvl === 2 ? chalk.bold.hex('#a78bfa')
385
+ : chalk.bold.hex('#c4b5fd');
386
+ const prefix = this.blankCount === 0 ? '\n' : '';
387
+ this.blankCount = 0;
388
+ return prefix + ' ' + paint(title) + '\n';
389
+ }
390
+ this.blankCount = 0;
391
+ // ── Unordered list - or * ─────────────────────────────────
392
+ const ulm = raw.match(/^(\s*)[-*]\s+(.+)$/);
393
+ if (ulm) {
394
+ const indent = ' '.repeat(Math.floor(ulm[1].length / 2) + 1);
395
+ return indent + colors.muted('·') + ' ' + colors.ai(applyInline(ulm[2])) + '\n';
396
+ }
397
+ // ── Ordered list 1. ──────────────────────────────────────
398
+ const olm = raw.match(/^(\s*)(\d+)\.\s+(.+)$/);
399
+ if (olm) {
400
+ const indent = ' '.repeat(Math.floor(olm[1].length / 2) + 1);
401
+ return indent + colors.muted(olm[2] + '.') + ' ' + colors.ai(applyInline(olm[3])) + '\n';
402
+ }
403
+ // ── Blockquote > ─────────────────────────────────────────
404
+ const bqm = raw.match(/^>\s?(.*)$/);
405
+ if (bqm) {
406
+ return ' ' + colors.dim('│') + ' ' + colors.muted(applyInline(bqm[1])) + '\n';
407
+ }
408
+ // ── Horizontal rule --- / *** ─────────────────────────────
409
+ if (/^[-*_]{3,}\s*$/.test(raw.trim())) {
410
+ return ' ' + colors.dim('─'.repeat(Math.min((process.stdout.columns ?? 80) - 4, 72))) + '\n';
411
+ }
412
+ // ── Normal text ───────────────────────────────────────────
413
+ return ' ' + colors.ai(applyInline(raw)) + '\n';
414
+ }
415
+ renderTable() {
416
+ const rows = this.tableRows;
417
+ this.tableRows = [];
418
+ if (rows.length === 0)
419
+ return '';
420
+ const cols = Math.max(...rows.map(r => r.length));
421
+ const termW = process.stdout.columns ?? 80;
422
+ // max usable width per column (distribute evenly, min 6)
423
+ const maxColW = Math.max(6, Math.floor((termW - 4 - (cols + 1)) / cols));
424
+ const widths = Array(cols).fill(0);
425
+ for (const row of rows) {
426
+ for (let i = 0; i < cols; i++) {
427
+ const cellLen = Math.min((row[i] ?? '').length, maxColW);
428
+ widths[i] = Math.max(widths[i], cellLen + 2);
429
+ }
430
+ }
431
+ const bar = (l, m, r) => colors.dim(' ' + l + widths.map(w => '─'.repeat(w)).join(m) + r);
432
+ let out = '\n' + bar('┌', '┬', '┐') + '\n';
433
+ for (let ri = 0; ri < rows.length; ri++) {
434
+ const row = rows[ri];
435
+ const cells = widths.map((w, i) => {
436
+ const raw = row[i] ?? '';
437
+ const cell = raw.length > w - 2 ? raw.slice(0, w - 3) + '…' : raw;
438
+ const pad = w - cell.length - 1;
439
+ const text = ri === 0 ? chalk.bold.white(cell) : colors.ai(cell);
440
+ return ' ' + text + ' '.repeat(Math.max(pad, 0));
441
+ });
442
+ out += colors.dim(' │') + cells.join(colors.dim('│')) + colors.dim('│') + '\n';
443
+ if (ri === 0 && rows.length > 1)
444
+ out += bar('├', '┼', '┤') + '\n';
445
+ }
446
+ out += bar('└', '┴', '┘') + '\n';
447
+ return out;
448
+ }
449
+ renderCodeBlock() {
450
+ const lang = this.codeLang;
451
+ const lines = this.codeLines;
452
+ // If code block content is a markdown table, render it as a table
453
+ const nonEmpty = lines.filter(l => l.trim());
454
+ if (nonEmpty.length > 0 && nonEmpty.every(l => l.trimStart().startsWith('|'))) {
455
+ for (const line of lines) {
456
+ if (!line.trim())
457
+ continue;
458
+ const allCells = line.split('|').slice(1, -1).map(c => c.trim());
459
+ const isSep = allCells.length > 0 && allCells.every(c => /^:?-+:?$/.test(c));
460
+ if (!isSep && allCells.length > 0)
461
+ this.tableRows.push(allCells);
462
+ }
463
+ if (this.tableRows.length > 0)
464
+ return this.renderTable();
465
+ }
466
+ const termW = process.stdout.columns ?? 80;
467
+ const maxInner = Math.min(termW - 8, 80);
468
+ const maxLen = lines.length > 0 ? Math.max(...lines.map(l => l.length)) : 0;
469
+ const inner = Math.max(maxLen, lang.length + 4, 20, 0);
470
+ const capped = Math.min(inner, maxInner);
471
+ const dashAfterLang = Math.max(0, capped - lang.length - 2);
472
+ const top = lang
473
+ ? colors.dim(' ╭─ ') + chalk.hex('#818cf8')(lang) + colors.dim(' ' + '─'.repeat(dashAfterLang))
474
+ : colors.dim(' ╭' + '─'.repeat(capped + 2));
475
+ const bot = colors.dim(' ╰' + '─'.repeat(capped + 2));
476
+ const canHighlight = lang && !['text', 'txt', 'plain', 'output', 'log'].includes(lang.toLowerCase());
477
+ let out = '\n' + top + '\n';
478
+ for (const l of lines) {
479
+ const display = l.length > capped ? l.slice(0, capped - 1) + '…' : l;
480
+ const colorized = canHighlight ? highlightCode(display, lang) : chalk.hex('#e2e8f0')(display);
481
+ out += colors.dim(' │ ') + colorized + '\n';
482
+ }
483
+ out += bot + '\n';
484
+ return out;
485
+ }
486
+ }
487
+ function normalizeResponse(text) {
488
+ return text.replace(/```[\w]*\n?(<(?:write_file|edit_file|delete_file|create_folder|run_command|read_file|read_folder|search_code)[\s\S]*?(?:<\/(?:write_file|edit_file|run_command)>|<(?:delete_file|create_folder|read_file|read_folder|search_code)[^>]*\/>))\n?```/g, '$1');
489
+ }
490
+ function opLabel(op, cwd) {
491
+ const exists = op.path ? fs.existsSync(path.resolve(cwd, op.path)) : false;
492
+ switch (op.type) {
493
+ case 'write': return exists ? `Modify file: ${op.path}` : `Create file: ${op.path}`;
494
+ case 'edit': return `Edit file: ${op.path}`;
495
+ case 'mkdir': return `Create folder: ${op.path}`;
496
+ case 'delete': return `Delete: ${op.path}`;
497
+ case 'run': return `Run command: ${op.command}`;
498
+ default: return op.path ?? '';
499
+ }
500
+ }
501
+ function opKey(op, cwd) {
502
+ const exists = op.path ? fs.existsSync(path.resolve(cwd, op.path)) : false;
503
+ if (op.type === 'write')
504
+ return exists ? 'modify' : 'create';
505
+ return op.type;
506
+ }
507
+ // ── Permission prompt ───────────────────────────────────────────
508
+ function opStyle(label) {
509
+ const l = label.toLowerCase();
510
+ if (l.startsWith('create folder'))
511
+ return { icon: '+', paint: colors.success };
512
+ if (l.startsWith('create'))
513
+ return { icon: '+', paint: colors.success };
514
+ if (l.startsWith('delete'))
515
+ return { icon: '!', paint: colors.error };
516
+ if (l.startsWith('run'))
517
+ return { icon: '$', paint: colors.primary };
518
+ return { icon: '~', paint: colors.warn };
519
+ }
520
+ const OPT_LABELS_DEFAULT = ['Apply this change', 'Apply to all changes', 'Skip this change'];
521
+ const OPT_LABELS_RUN = ['Yes, run it', 'No, skip'];
522
+ const SEP = '─'.repeat(40);
523
+ function clearMenu(lines) {
524
+ process.stdout.write('\x1b[2K');
525
+ for (let i = 0; i < lines; i++) {
526
+ process.stdout.write('\x1b[1A\x1b[2K');
527
+ }
528
+ }
529
+ async function askPermission(label, key, alwaysAllowed, noAlways) {
530
+ if (!noAlways && alwaysAllowed.has(key))
531
+ return true;
532
+ return new Promise((resolve) => {
533
+ let sel = 0;
534
+ let drawn = false;
535
+ const ci = label.indexOf(': ');
536
+ const opType = ci !== -1 ? label.slice(0, ci) : label;
537
+ const opValue = ci !== -1 ? label.slice(ci + 2) : '';
538
+ const { icon, paint } = opStyle(label);
539
+ const optLabels = noAlways ? OPT_LABELS_RUN : OPT_LABELS_DEFAULT;
540
+ const optCount = optLabels.length;
541
+ // run: 3-line box (╭header + │cmd + ╰) + blank + options
542
+ // file ops: 4-line box (╭ + │type + │value + ╰) + blank + options
543
+ const menuLines = (noAlways ? 3 : 4) + 1 + optCount;
544
+ function printMenu() {
545
+ if (drawn)
546
+ clearMenu(menuLines);
547
+ drawn = true;
548
+ let boxLines;
549
+ if (noAlways) {
550
+ const dashes = SEP.length - 2 - 'run command'.length - 1;
551
+ boxLines = [
552
+ colors.dim(' ╭─ ') + colors.primary('run command') + colors.dim(' ' + '─'.repeat(Math.max(dashes, 1))),
553
+ ` │ ${chalk.hex('#fbbf24')('$')} ${chalk.white(opValue)}`,
554
+ colors.dim(' ╰' + SEP),
555
+ ];
556
+ }
557
+ else {
558
+ boxLines = [
559
+ colors.dim(' ╭' + SEP),
560
+ ` │ ${paint(icon + ' ' + opType)}`,
561
+ ` │ ${chalk.white(opValue)}`,
562
+ colors.dim(' ╰' + SEP),
563
+ ];
564
+ }
565
+ const lines = [
566
+ ...boxLines,
567
+ '',
568
+ ...optLabels.map((lbl, i) => i === sel
569
+ ? ` ${colors.primary('›')} ${colors.primary.bold(lbl)}`
570
+ : ` ${colors.muted(lbl)}`),
571
+ ];
572
+ process.stdout.write(lines.join('\n') + '\n');
573
+ }
574
+ printMenu();
575
+ function cleanup() {
576
+ process.stdin.removeListener('data', onData);
577
+ }
578
+ function confirm(idx) {
579
+ cleanup();
580
+ clearMenu(menuLines);
581
+ const skipIdx = noAlways ? 1 : 2;
582
+ if (idx === skipIdx) {
583
+ process.stdout.write(` ${colors.muted('○')} ${colors.muted('Skipped: ' + label)}\n`);
584
+ resolve(false);
585
+ }
586
+ else {
587
+ if (!noAlways && idx === 1)
588
+ alwaysAllowed.add(key);
589
+ resolve(true);
590
+ }
591
+ }
592
+ function onData(data) {
593
+ try {
594
+ switch (data) {
595
+ case '\x1b[A':
596
+ sel = (sel - 1 + optCount) % optCount;
597
+ printMenu();
598
+ break;
599
+ case '\x1b[B':
600
+ sel = (sel + 1) % optCount;
601
+ printMenu();
602
+ break;
603
+ case '\r':
604
+ case '\n':
605
+ confirm(sel);
606
+ break;
607
+ case 'y':
608
+ case 'Y':
609
+ confirm(0);
610
+ break;
611
+ case 'n':
612
+ case 'N':
613
+ confirm(noAlways ? 1 : 2);
614
+ break;
615
+ case 'a':
616
+ case 'A':
617
+ if (!noAlways)
618
+ confirm(1);
619
+ break;
620
+ case '\x03':
621
+ cleanup();
622
+ clearMenu(menuLines);
623
+ process.exit(0);
624
+ }
625
+ }
626
+ catch (e) {
627
+ cleanup();
628
+ resolve(false);
629
+ }
630
+ }
631
+ process.stdin.on('data', onData);
632
+ });
633
+ }
634
+ async function readLine(prompt, cwd) {
635
+ let SEP = colors.dim(' ' + '─'.repeat(Math.max((process.stdout.columns ?? 80) - 4, 40)));
636
+ let sepVisualLen = 2 + Math.max((process.stdout.columns ?? 80) - 4, 40); // track visual width for resize
637
+ process.stdout.write('\n' + SEP + '\n' + prompt);
638
+ // Draw bottom separator, then move cursor back up to input line
639
+ process.stdout.write('\r\n' + SEP + '\x1b[1A\r' + prompt);
640
+ return new Promise((resolve) => {
641
+ let prefix = '';
642
+ let pasteBlock = null;
643
+ let pasteCount = 0;
644
+ let suffix = '';
645
+ let ctrlCCount = 0;
646
+ let ctrlCTimer = null;
647
+ // @ file-completion state
648
+ let atMode = false;
649
+ let atQuery = '';
650
+ let atSel = 0;
651
+ let atBoxLines = 0; // total picker box lines on screen (header + items + footer)
652
+ let allFiles = [];
653
+ // / slash-command picker state
654
+ let slashMode = false;
655
+ let slashQuery = '';
656
+ let slashSel = 0;
657
+ let slashBoxLines = 0;
658
+ let slashDrawn = false;
659
+ // Tab path-autocomplete state
660
+ let tabMode = false;
661
+ let tabQuery = '';
662
+ let tabSel = 0;
663
+ let tabBoxLines = 0;
664
+ let tabDrawn = false;
665
+ let tabPreWord = ''; // prefix text before the tab-completed token
666
+ function onResize() {
667
+ const newCols = process.stdout.columns ?? 80;
668
+ const oldSepRows = Math.ceil(sepVisualLen / newCols);
669
+ SEP = colors.dim(' ' + '─'.repeat(Math.max(newCols - 4, 40)));
670
+ sepVisualLen = 2 + Math.max(newCols - 4, 40);
671
+ if (atMode) {
672
+ for (let i = 0; i < atBoxLines + 3; i++)
673
+ process.stdout.write('\x1b[1A');
674
+ process.stdout.write('\r\x1b[0J');
675
+ atDrawn = false;
676
+ atBoxLines = 0;
677
+ drawPicker();
678
+ }
679
+ else if (slashMode) {
680
+ for (let i = 0; i < slashBoxLines + 3; i++)
681
+ process.stdout.write('\x1b[1A');
682
+ process.stdout.write('\r\x1b[0J');
683
+ slashDrawn = false;
684
+ slashBoxLines = 0;
685
+ drawSlashPicker();
686
+ }
687
+ else if (tabMode) {
688
+ for (let i = 0; i < tabBoxLines + 3; i++)
689
+ process.stdout.write('\x1b[1A');
690
+ process.stdout.write('\r\x1b[0J');
691
+ tabDrawn = false;
692
+ tabBoxLines = 0;
693
+ drawTabPicker();
694
+ }
695
+ else {
696
+ // Clear input line, then bottom SEP below it
697
+ process.stdout.write('\r\x1b[K');
698
+ process.stdout.write('\x1b[1B\x1b[2K\x1b[1A');
699
+ // Go up clearing old top SEP rows + blank line above them
700
+ for (let i = 0; i < oldSepRows + 1; i++)
701
+ process.stdout.write('\x1b[1A\x1b[2K');
702
+ // Redraw: blank line + new SEP + input + bottom SEP
703
+ process.stdout.write('\n' + SEP + '\n');
704
+ redraw();
705
+ }
706
+ }
707
+ process.stdout.on('resize', onResize);
708
+ function cleanup() {
709
+ if (ctrlCTimer) {
710
+ clearTimeout(ctrlCTimer);
711
+ ctrlCTimer = null;
712
+ }
713
+ process.stdin.removeListener('data', onData);
714
+ process.stdout.removeListener('resize', onResize);
715
+ }
716
+ function loadFiles() {
717
+ if (allFiles.length > 0)
718
+ return;
719
+ const IGNORE = new Set([
720
+ 'node_modules', '.git', 'dist', '.next', 'build',
721
+ '__pycache__', '.cache', '.svn', 'coverage',
722
+ ]);
723
+ function walk(dir, depth, rel) {
724
+ if (depth > 4)
725
+ return;
726
+ let entries;
727
+ try {
728
+ entries = fs.readdirSync(dir, { withFileTypes: true });
729
+ }
730
+ catch {
731
+ return;
732
+ }
733
+ for (const e of entries) {
734
+ if (IGNORE.has(e.name))
735
+ continue;
736
+ if (e.name.startsWith('.') && e.name !== '.env' && e.name !== '.gitignore')
737
+ continue;
738
+ const p = rel ? `${rel}/${e.name}` : e.name;
739
+ if (e.isDirectory()) {
740
+ allFiles.push(p + '/');
741
+ walk(path.join(dir, e.name), depth + 1, p);
742
+ }
743
+ else {
744
+ allFiles.push(p);
745
+ }
746
+ }
747
+ }
748
+ walk(cwd, 0, '');
749
+ }
750
+ function filterFiles(q) {
751
+ const dirsFirst = (arr) => [
752
+ ...arr.filter(f => f.endsWith('/')),
753
+ ...arr.filter(f => !f.endsWith('/')),
754
+ ];
755
+ if (!q)
756
+ return dirsFirst(allFiles).slice(0, 10);
757
+ const lq = q.toLowerCase();
758
+ const a = allFiles.filter(f => f.toLowerCase().startsWith(lq));
759
+ const b = allFiles.filter(f => !f.toLowerCase().startsWith(lq) && f.toLowerCase().includes(lq));
760
+ return dirsFirst([...a, ...b]).slice(0, 10);
761
+ }
762
+ function filterSlash(q) {
763
+ if (!q)
764
+ return SLASH_COMMANDS;
765
+ const lq = q.toLowerCase();
766
+ return SLASH_COMMANDS.filter(c => c.name.slice(1).toLowerCase().startsWith(lq));
767
+ }
768
+ // Picker layout: input (A) | bot-sep (A+1) | box (A+2 .. A+1+atBoxLines)
769
+ // Clearing box: atBoxLines × \x1b[1A\x1b[2K → cursor at A+2
770
+ // then \x1b[2A\x1b[2K to skip bot-sep and clear input
771
+ let atDrawn = false;
772
+ function drawPicker() {
773
+ if (atDrawn) {
774
+ for (let i = 0; i < atBoxLines; i++)
775
+ process.stdout.write('\x1b[1A\x1b[2K');
776
+ process.stdout.write('\x1b[2A\x1b[2K');
777
+ }
778
+ else {
779
+ process.stdout.write('\r\x1b[K');
780
+ atDrawn = true;
781
+ }
782
+ atBoxLines = 0;
783
+ // Re-draw input line + bot sep, cursor lands at A+2
784
+ const inputLine = colors.primary(' › ') + chalk.white(prefix) +
785
+ (pasteBlock !== null
786
+ ? colors.muted(`[paste: ${pasteCount} lines]`) + chalk.white(suffix)
787
+ : '') +
788
+ chalk.hex('#818cf8')('@') + chalk.white(atQuery);
789
+ process.stdout.write(inputLine + '\r\n' + SEP + '\r\n');
790
+ // Box dimensions — DW = total visual width including borders
791
+ const DW = Math.min(Math.max((process.stdout.columns ?? 80) - 6, 44), 72);
792
+ const IW = DW - 6; // visible filename area width
793
+ const filtered = filterFiles(atQuery);
794
+ if (atSel >= filtered.length && filtered.length > 0)
795
+ atSel = filtered.length - 1;
796
+ const shown = filtered.slice(0, 10);
797
+ // ── Header ────────────────────────────────────────────────
798
+ const qLabel = atQuery ? `@${atQuery}` : 'files';
799
+ const hDashes = Math.max(DW - 5 - qLabel.length, 1);
800
+ process.stdout.write(colors.dim(' ╭─ ') + colors.primary(qLabel) +
801
+ colors.dim(' ' + '─'.repeat(hDashes) + '╮') + '\n');
802
+ atBoxLines++;
803
+ // ── Items ─────────────────────────────────────────────────
804
+ if (shown.length === 0) {
805
+ const msg = 'no matches';
806
+ const pad = ' '.repeat(Math.max(IW - msg.length, 0));
807
+ process.stdout.write(colors.dim(' │') + ' ' +
808
+ colors.muted(msg) + pad + colors.dim('│') + '\n');
809
+ atBoxLines++;
810
+ }
811
+ else {
812
+ for (let i = 0; i < shown.length; i++) {
813
+ const f = shown[i];
814
+ const isDir = f.endsWith('/');
815
+ const sel = i === atSel;
816
+ const mark = sel ? colors.primary('>') : ' ';
817
+ const clip = f.length > IW ? f.slice(0, IW - 1) + '…' : f;
818
+ const pad = ' '.repeat(Math.max(IW - clip.length, 0));
819
+ const name = sel && isDir ? colors.primary.bold(clip) + pad // selected folder: indigo bold
820
+ : sel ? colors.primary.bold(clip) + pad // selected file: indigo bold
821
+ : isDir ? chalk.hex('#38bdf8')(clip) + pad // folder: sky-blue
822
+ : chalk.hex('#94a3b8')(clip + pad); // file: slate-gray
823
+ process.stdout.write(colors.dim(' │') + ` ${mark} ` + name + colors.dim('│') + '\n');
824
+ atBoxLines++;
825
+ }
826
+ }
827
+ // ── Footer ────────────────────────────────────────────────
828
+ const hint = ' ↑↓ Enter Esc ';
829
+ const fInner = DW - 2 - hint.length;
830
+ const fLeft = Math.floor(fInner / 2);
831
+ const fRight = fInner - fLeft;
832
+ process.stdout.write(colors.dim(' ╰' + '─'.repeat(Math.max(fLeft, 0))) +
833
+ colors.muted(hint) +
834
+ colors.dim('─'.repeat(Math.max(fRight, 0)) + '╯') + '\n');
835
+ atBoxLines++;
836
+ // cursor at A+2+atBoxLines
837
+ }
838
+ function closePicker() {
839
+ if (atDrawn) {
840
+ for (let i = 0; i < atBoxLines; i++)
841
+ process.stdout.write('\x1b[1A\x1b[2K');
842
+ process.stdout.write('\x1b[2A\x1b[2K');
843
+ atDrawn = false;
844
+ }
845
+ else {
846
+ process.stdout.write('\r\x1b[K');
847
+ }
848
+ atBoxLines = 0;
849
+ atMode = false;
850
+ atQuery = '';
851
+ atSel = 0;
852
+ }
853
+ function drawSlashPicker() {
854
+ if (slashDrawn) {
855
+ for (let i = 0; i < slashBoxLines; i++)
856
+ process.stdout.write('\x1b[1A\x1b[2K');
857
+ process.stdout.write('\x1b[2A\x1b[2K');
858
+ }
859
+ else {
860
+ process.stdout.write('\r\x1b[K');
861
+ slashDrawn = true;
862
+ }
863
+ slashBoxLines = 0;
864
+ const inputLine = colors.primary(' › ') + colors.primary('/') + chalk.white(slashQuery);
865
+ process.stdout.write(inputLine + '\r\n' + SEP + '\r\n');
866
+ const DW = Math.min(Math.max((process.stdout.columns ?? 80) - 6, 44), 72);
867
+ const nameW = 10;
868
+ const descW = DW - nameW - 8;
869
+ const filtered = filterSlash(slashQuery);
870
+ if (slashSel >= filtered.length && filtered.length > 0)
871
+ slashSel = filtered.length - 1;
872
+ const qLabel = slashQuery ? `/${slashQuery}` : 'commands';
873
+ const hDashes = Math.max(DW - 5 - qLabel.length, 1);
874
+ process.stdout.write(colors.dim(' ╭─ ') + colors.primary(qLabel) +
875
+ colors.dim(' ' + '─'.repeat(hDashes) + '╮') + '\n');
876
+ slashBoxLines++;
877
+ if (filtered.length === 0) {
878
+ const msg = 'no matching commands';
879
+ const pad = ' '.repeat(Math.max(DW - 4 - msg.length, 0));
880
+ process.stdout.write(colors.dim(' │') + ' ' + colors.muted(msg) + pad + colors.dim('│') + '\n');
881
+ slashBoxLines++;
882
+ }
883
+ else {
884
+ for (let i = 0; i < filtered.length; i++) {
885
+ const cmd = filtered[i];
886
+ const sel = i === slashSel;
887
+ const mark = sel ? colors.primary('>') : ' ';
888
+ const nameStr = cmd.name.padEnd(nameW);
889
+ const descStr = cmd.desc.length > descW ? cmd.desc.slice(0, descW - 1) + '…' : cmd.desc;
890
+ const descPad = ' '.repeat(Math.max(descW - descStr.length, 0));
891
+ const namePaint = sel ? colors.primary.bold(nameStr) : chalk.hex('#818cf8')(nameStr);
892
+ const descPaint = sel ? colors.muted(descStr) : chalk.hex('#4b5563')(descStr);
893
+ process.stdout.write(colors.dim(' │') + ` ${mark} ` + namePaint + ' ' + descPaint + descPad + colors.dim('│') + '\n');
894
+ slashBoxLines++;
895
+ }
896
+ }
897
+ const hint = ' ↑↓ Enter Esc ';
898
+ const fInner = DW - 2 - hint.length;
899
+ const fLeft = Math.floor(fInner / 2);
900
+ const fRight = fInner - fLeft;
901
+ process.stdout.write(colors.dim(' ╰' + '─'.repeat(Math.max(fLeft, 0))) +
902
+ colors.muted(hint) +
903
+ colors.dim('─'.repeat(Math.max(fRight, 0)) + '╯') + '\n');
904
+ slashBoxLines++;
905
+ }
906
+ function closeSlashPicker() {
907
+ if (slashDrawn) {
908
+ for (let i = 0; i < slashBoxLines; i++)
909
+ process.stdout.write('\x1b[1A\x1b[2K');
910
+ process.stdout.write('\x1b[2A\x1b[2K');
911
+ slashDrawn = false;
912
+ }
913
+ else {
914
+ process.stdout.write('\r\x1b[K');
915
+ }
916
+ slashBoxLines = 0;
917
+ slashMode = false;
918
+ slashQuery = '';
919
+ slashSel = 0;
920
+ }
921
+ function drawTabPicker() {
922
+ if (tabDrawn) {
923
+ for (let i = 0; i < tabBoxLines; i++)
924
+ process.stdout.write('\x1b[1A\x1b[2K');
925
+ process.stdout.write('\x1b[2A\x1b[2K');
926
+ }
927
+ else {
928
+ process.stdout.write('\r\x1b[K');
929
+ tabDrawn = true;
930
+ }
931
+ tabBoxLines = 0;
932
+ const inputLine = colors.primary(' › ') + chalk.white(tabPreWord) +
933
+ chalk.hex('#34d399')(tabQuery);
934
+ process.stdout.write(inputLine + '\r\n' + SEP + '\r\n');
935
+ const DW = Math.min(Math.max((process.stdout.columns ?? 80) - 6, 44), 72);
936
+ const IW = DW - 6;
937
+ const filtered = filterFiles(tabQuery);
938
+ if (tabSel >= filtered.length && filtered.length > 0)
939
+ tabSel = filtered.length - 1;
940
+ const shown = filtered.slice(0, 10);
941
+ const TC = chalk.hex('#34d399');
942
+ const qLabel = tabQuery || 'tab complete';
943
+ const hDashes = Math.max(DW - 5 - qLabel.length, 1);
944
+ process.stdout.write(colors.dim(' ╭─ ') + TC(qLabel) +
945
+ colors.dim(' ' + '─'.repeat(hDashes) + '╮') + '\n');
946
+ tabBoxLines++;
947
+ if (shown.length === 0) {
948
+ const msg = 'no matches';
949
+ const pad = ' '.repeat(Math.max(IW - msg.length, 0));
950
+ process.stdout.write(colors.dim(' │') + ' ' + colors.muted(msg) + pad + colors.dim('│') + '\n');
951
+ tabBoxLines++;
952
+ }
953
+ else {
954
+ for (let i = 0; i < shown.length; i++) {
955
+ const f = shown[i];
956
+ const isDir = f.endsWith('/');
957
+ const sel = i === tabSel;
958
+ const mark = sel ? TC('>') : ' ';
959
+ const clip = f.length > IW ? f.slice(0, IW - 1) + '…' : f;
960
+ const pad = ' '.repeat(Math.max(IW - clip.length, 0));
961
+ const name = sel ? TC.bold(clip) + pad
962
+ : isDir ? chalk.hex('#38bdf8')(clip) + pad
963
+ : chalk.hex('#94a3b8')(clip + pad);
964
+ process.stdout.write(colors.dim(' │') + ` ${mark} ` + name + colors.dim('│') + '\n');
965
+ tabBoxLines++;
966
+ }
967
+ }
968
+ const hint = ' Tab/↑↓ Enter Esc ';
969
+ const fInner = DW - 2 - hint.length;
970
+ const fLeft = Math.floor(fInner / 2);
971
+ const fRight = fInner - fLeft;
972
+ process.stdout.write(colors.dim(' ╰' + '─'.repeat(Math.max(fLeft, 0))) +
973
+ colors.muted(hint) +
974
+ colors.dim('─'.repeat(Math.max(fRight, 0)) + '╯') + '\n');
975
+ tabBoxLines++;
976
+ }
977
+ function closeTabPicker() {
978
+ if (tabDrawn) {
979
+ for (let i = 0; i < tabBoxLines; i++)
980
+ process.stdout.write('\x1b[1A\x1b[2K');
981
+ process.stdout.write('\x1b[2A\x1b[2K');
982
+ tabDrawn = false;
983
+ }
984
+ else {
985
+ process.stdout.write('\r\x1b[K');
986
+ }
987
+ tabBoxLines = 0;
988
+ tabMode = false;
989
+ tabQuery = '';
990
+ tabSel = 0;
991
+ tabPreWord = '';
992
+ }
993
+ function redraw() {
994
+ const cols = process.stdout.columns ?? 80;
995
+ const maxLen = Math.max(cols - 6, 10);
996
+ // Truncate to terminal width — prevents line wrapping on resize
997
+ let displayPre = prefix;
998
+ if (displayPre.length > maxLen)
999
+ displayPre = '…' + displayPre.slice(-(maxLen - 1));
1000
+ let inp;
1001
+ if (pasteBlock !== null) {
1002
+ inp = colors.primary(' › ') + chalk.white(displayPre) +
1003
+ colors.muted(`[paste: ${pasteCount} lines]`) + chalk.white(suffix);
1004
+ }
1005
+ else {
1006
+ inp = colors.primary(' › ') + chalk.white(displayPre);
1007
+ }
1008
+ process.stdout.write('\r\x1b[K' + inp);
1009
+ process.stdout.write('\r\n' + SEP + '\x1b[1A\r' + inp);
1010
+ }
1011
+ function submit() {
1012
+ if (atMode)
1013
+ closePicker();
1014
+ if (tabMode) {
1015
+ prefix = tabPreWord + tabQuery;
1016
+ closeTabPicker();
1017
+ }
1018
+ const parts = [];
1019
+ if (prefix.trim())
1020
+ parts.push(prefix.trim());
1021
+ if (pasteBlock)
1022
+ parts.push(...pasteBlock.split('\n').filter(Boolean));
1023
+ if (suffix.trim())
1024
+ parts.push(suffix.trim());
1025
+ const text = parts.join('\n');
1026
+ const lns = parts.length > 0 ? parts : (text ? [text] : []);
1027
+ cleanup();
1028
+ // Clear input → bot sep → top sep, replace with styled sent message
1029
+ process.stdout.write('\r\x1b[K'); // clear input line
1030
+ process.stdout.write('\x1b[1B\x1b[2K'); // down to bot sep, clear
1031
+ process.stdout.write('\x1b[2A\x1b[2K'); // up 2 to top sep, clear
1032
+ if (!text) {
1033
+ resolve(null);
1034
+ return;
1035
+ }
1036
+ const cols = process.stdout.columns ?? 80;
1037
+ const msgBg = chalk.bgHex('#334155').hex('#f1f5f9');
1038
+ const fillMsg = (raw) => (' > ' + raw + ' ').padEnd(cols);
1039
+ if (pasteBlock !== null) {
1040
+ const parts = [
1041
+ prefix ? prefix : '',
1042
+ `[${pasteCount} lines]`,
1043
+ suffix.trim() ? suffix.trim() : '',
1044
+ ].filter(Boolean).join(' ');
1045
+ process.stdout.write(msgBg(fillMsg(parts)) + '\n');
1046
+ }
1047
+ else {
1048
+ process.stdout.write(msgBg(fillMsg(text)) + '\n');
1049
+ }
1050
+ resolve({ text, lines: lns });
1051
+ }
1052
+ function onData(data) {
1053
+ try {
1054
+ if (data === '\x03') {
1055
+ // Close any active picker first
1056
+ if (atMode)
1057
+ closePicker();
1058
+ if (slashMode)
1059
+ closeSlashPicker();
1060
+ if (tabMode)
1061
+ closeTabPicker();
1062
+ // If there is typed input — clear it (safe copy behavior, like bash)
1063
+ if (prefix || pasteBlock !== null || suffix) {
1064
+ prefix = '';
1065
+ pasteBlock = null;
1066
+ pasteCount = 0;
1067
+ suffix = '';
1068
+ ctrlCCount = 0;
1069
+ if (ctrlCTimer) {
1070
+ clearTimeout(ctrlCTimer);
1071
+ ctrlCTimer = null;
1072
+ }
1073
+ redraw();
1074
+ return;
1075
+ }
1076
+ // No input: require double Ctrl+C to exit (prevents accidental exit from copy)
1077
+ ctrlCCount++;
1078
+ if (ctrlCTimer)
1079
+ clearTimeout(ctrlCTimer);
1080
+ if (ctrlCCount >= 2) {
1081
+ cleanup();
1082
+ process.stdout.write('\n\n ' + colors.muted('Goodbye!') + '\n\n');
1083
+ process.exit(0);
1084
+ }
1085
+ process.stdout.write('\r\x1b[K ' + colors.muted('(Ctrl+C again to exit — use /exit or close window)'));
1086
+ ctrlCTimer = setTimeout(() => { ctrlCCount = 0; redraw(); }, 1500);
1087
+ return;
1088
+ }
1089
+ if (ctrlCCount > 0) {
1090
+ ctrlCCount = 0;
1091
+ if (ctrlCTimer) {
1092
+ clearTimeout(ctrlCTimer);
1093
+ ctrlCTimer = null;
1094
+ }
1095
+ }
1096
+ if (data === '\x04') {
1097
+ cleanup();
1098
+ resolve(null);
1099
+ return;
1100
+ }
1101
+ // Ctrl+L — clear screen, redraw input
1102
+ if (data === '\x0c') {
1103
+ if (atMode)
1104
+ closePicker();
1105
+ console.clear();
1106
+ process.stdout.write('\n' + SEP + '\n' + prompt);
1107
+ process.stdout.write('\r\n' + SEP + '\x1b[1A\r' + prompt);
1108
+ return;
1109
+ }
1110
+ // ── @ picker mode ─────────────────────────────────────────
1111
+ if (atMode) {
1112
+ if (data === '\x1b') { // Escape → cancel
1113
+ closePicker();
1114
+ redraw();
1115
+ return;
1116
+ }
1117
+ if (data === '\x1b[A') { // Arrow up
1118
+ const f = filterFiles(atQuery);
1119
+ if (f.length) {
1120
+ atSel = (atSel - 1 + f.length) % f.length;
1121
+ drawPicker();
1122
+ }
1123
+ return;
1124
+ }
1125
+ if (data === '\x1b[B') { // Arrow down
1126
+ const f = filterFiles(atQuery);
1127
+ if (f.length) {
1128
+ atSel = (atSel + 1) % f.length;
1129
+ drawPicker();
1130
+ }
1131
+ return;
1132
+ }
1133
+ if (data === '\x1b[C') { // Arrow right → enter folder
1134
+ const f = filterFiles(atQuery);
1135
+ if (f.length > 0) {
1136
+ const file = f[Math.min(atSel, f.length - 1)];
1137
+ if (file.endsWith('/')) {
1138
+ atQuery = file;
1139
+ atSel = 0;
1140
+ drawPicker();
1141
+ }
1142
+ }
1143
+ return;
1144
+ }
1145
+ if (data === '\x1b[D') { // Arrow left → go up one level
1146
+ const noTrail = atQuery.endsWith('/') ? atQuery.slice(0, -1) : atQuery;
1147
+ const lastSlash = noTrail.lastIndexOf('/');
1148
+ atQuery = lastSlash >= 0 ? noTrail.slice(0, lastSlash + 1) : '';
1149
+ atSel = 0;
1150
+ drawPicker();
1151
+ return;
1152
+ }
1153
+ if (data === '\r' || data === '\n' || data === '\t') { // Enter/Tab → drill into folder or insert file
1154
+ const filtered = filterFiles(atQuery);
1155
+ if (filtered.length > 0) {
1156
+ const file = filtered[Math.min(atSel, filtered.length - 1)];
1157
+ if (file.endsWith('/')) {
1158
+ atQuery = file;
1159
+ atSel = 0;
1160
+ drawPicker();
1161
+ }
1162
+ else {
1163
+ const mention = '@' + file;
1164
+ closePicker();
1165
+ if (pasteBlock !== null)
1166
+ suffix += mention;
1167
+ else
1168
+ prefix += mention;
1169
+ redraw();
1170
+ }
1171
+ }
1172
+ else {
1173
+ closePicker();
1174
+ redraw();
1175
+ }
1176
+ return;
1177
+ }
1178
+ if (data === ' ') { // Space → select current item (file or folder)
1179
+ const filtered = filterFiles(atQuery);
1180
+ if (filtered.length > 0) {
1181
+ const file = filtered[Math.min(atSel, filtered.length - 1)];
1182
+ const mention = '@' + file;
1183
+ closePicker();
1184
+ if (pasteBlock !== null)
1185
+ suffix += mention;
1186
+ else
1187
+ prefix += mention;
1188
+ redraw();
1189
+ }
1190
+ else {
1191
+ closePicker();
1192
+ redraw();
1193
+ }
1194
+ return;
1195
+ }
1196
+ if (data === '\x7f' || data === '\b') { // Backspace
1197
+ if (atQuery.length > 0) {
1198
+ atQuery = atQuery.slice(0, -1);
1199
+ atSel = 0;
1200
+ drawPicker();
1201
+ }
1202
+ else {
1203
+ closePicker();
1204
+ redraw();
1205
+ }
1206
+ return;
1207
+ }
1208
+ if (data.length === 1 && data >= ' ') { // type to filter
1209
+ atQuery += data;
1210
+ atSel = 0;
1211
+ drawPicker();
1212
+ return;
1213
+ }
1214
+ return;
1215
+ }
1216
+ // ── / slash picker mode ───────────────────────────────────
1217
+ if (slashMode) {
1218
+ if (data === '\x1b') {
1219
+ closeSlashPicker();
1220
+ redraw();
1221
+ return;
1222
+ }
1223
+ if (data === '\x1b[A') {
1224
+ const f = filterSlash(slashQuery);
1225
+ if (f.length) {
1226
+ slashSel = (slashSel - 1 + f.length) % f.length;
1227
+ drawSlashPicker();
1228
+ }
1229
+ return;
1230
+ }
1231
+ if (data === '\x1b[B') {
1232
+ const f = filterSlash(slashQuery);
1233
+ if (f.length) {
1234
+ slashSel = (slashSel + 1) % f.length;
1235
+ drawSlashPicker();
1236
+ }
1237
+ return;
1238
+ }
1239
+ if (data === '\r' || data === '\n' || data === '\t') {
1240
+ const filtered = filterSlash(slashQuery);
1241
+ if (filtered.length > 0) {
1242
+ const cmd = filtered[Math.min(slashSel, filtered.length - 1)];
1243
+ prefix = cmd.name + ' ';
1244
+ closeSlashPicker();
1245
+ redraw();
1246
+ }
1247
+ else {
1248
+ closeSlashPicker();
1249
+ redraw();
1250
+ }
1251
+ return;
1252
+ }
1253
+ if (data === '\x7f' || data === '\b') {
1254
+ if (slashQuery.length > 0) {
1255
+ slashQuery = slashQuery.slice(0, -1);
1256
+ slashSel = 0;
1257
+ drawSlashPicker();
1258
+ }
1259
+ else {
1260
+ closeSlashPicker();
1261
+ redraw();
1262
+ }
1263
+ return;
1264
+ }
1265
+ if (data === ' ') {
1266
+ const filtered = filterSlash(slashQuery);
1267
+ if (filtered.length > 0) {
1268
+ const cmd = filtered[Math.min(slashSel, filtered.length - 1)];
1269
+ prefix = cmd.name + ' ';
1270
+ }
1271
+ else {
1272
+ prefix = '/' + slashQuery + ' ';
1273
+ }
1274
+ closeSlashPicker();
1275
+ redraw();
1276
+ return;
1277
+ }
1278
+ if (data.length === 1 && data >= ' ') {
1279
+ slashQuery += data;
1280
+ slashSel = 0;
1281
+ drawSlashPicker();
1282
+ return;
1283
+ }
1284
+ return;
1285
+ }
1286
+ // ── Tab path-autocomplete mode ────────────────────────────
1287
+ if (tabMode) {
1288
+ if (data === '\x1b') { // Escape → cancel, restore text
1289
+ const saved = tabPreWord + tabQuery;
1290
+ closeTabPicker();
1291
+ prefix = saved;
1292
+ redraw();
1293
+ return;
1294
+ }
1295
+ if (data === '\x1b[A') { // Arrow up
1296
+ const f = filterFiles(tabQuery);
1297
+ if (f.length) {
1298
+ tabSel = (tabSel - 1 + f.length) % f.length;
1299
+ drawTabPicker();
1300
+ }
1301
+ return;
1302
+ }
1303
+ if (data === '\x1b[B' || data === '\t') { // Arrow down / Tab → next
1304
+ const f = filterFiles(tabQuery);
1305
+ if (f.length) {
1306
+ tabSel = (tabSel + 1) % f.length;
1307
+ drawTabPicker();
1308
+ }
1309
+ return;
1310
+ }
1311
+ if (data === '\x1b[C') { // Arrow right → enter folder
1312
+ const f = filterFiles(tabQuery);
1313
+ if (f.length > 0) {
1314
+ const file = f[Math.min(tabSel, f.length - 1)];
1315
+ if (file.endsWith('/')) {
1316
+ tabQuery = file;
1317
+ tabSel = 0;
1318
+ drawTabPicker();
1319
+ }
1320
+ }
1321
+ return;
1322
+ }
1323
+ if (data === '\x1b[D') { // Arrow left → go up
1324
+ const noTrail = tabQuery.endsWith('/') ? tabQuery.slice(0, -1) : tabQuery;
1325
+ const lastSlash = noTrail.lastIndexOf('/');
1326
+ tabQuery = lastSlash >= 0 ? noTrail.slice(0, lastSlash + 1) : '';
1327
+ tabSel = 0;
1328
+ drawTabPicker();
1329
+ return;
1330
+ }
1331
+ if (data === '\r' || data === '\n') { // Enter → insert or drill
1332
+ const filtered = filterFiles(tabQuery);
1333
+ if (filtered.length > 0) {
1334
+ const file = filtered[Math.min(tabSel, filtered.length - 1)];
1335
+ if (file.endsWith('/')) {
1336
+ tabQuery = file;
1337
+ tabSel = 0;
1338
+ drawTabPicker();
1339
+ }
1340
+ else {
1341
+ prefix = tabPreWord + file;
1342
+ closeTabPicker();
1343
+ redraw();
1344
+ }
1345
+ }
1346
+ else {
1347
+ prefix = tabPreWord + tabQuery;
1348
+ closeTabPicker();
1349
+ redraw();
1350
+ }
1351
+ return;
1352
+ }
1353
+ if (data === '\x7f' || data === '\b') { // Backspace
1354
+ if (tabQuery.length > 0) {
1355
+ tabQuery = tabQuery.slice(0, -1);
1356
+ tabSel = 0;
1357
+ drawTabPicker();
1358
+ }
1359
+ else {
1360
+ closeTabPicker();
1361
+ prefix = tabPreWord;
1362
+ redraw();
1363
+ }
1364
+ return;
1365
+ }
1366
+ if (data.length === 1 && data >= ' ') { // Type to filter
1367
+ tabQuery += data;
1368
+ tabSel = 0;
1369
+ drawTabPicker();
1370
+ return;
1371
+ }
1372
+ return;
1373
+ }
1374
+ // Enter
1375
+ if (data === '\r' || data === '\n' || data === '\r\n') {
1376
+ submit();
1377
+ return;
1378
+ }
1379
+ // ── Tab trigger ───────────────────────────────────────────
1380
+ if (data === '\t' && pasteBlock === null) {
1381
+ loadFiles();
1382
+ const spaceIdx = prefix.lastIndexOf(' ');
1383
+ tabPreWord = spaceIdx >= 0 ? prefix.slice(0, spaceIdx + 1) : '';
1384
+ tabQuery = spaceIdx >= 0 ? prefix.slice(spaceIdx + 1) : prefix;
1385
+ tabSel = 0;
1386
+ tabMode = true;
1387
+ drawTabPicker();
1388
+ return;
1389
+ }
1390
+ // ── @ trigger ─────────────────────────────────────────────
1391
+ if (data === '@') {
1392
+ loadFiles();
1393
+ atMode = true;
1394
+ atQuery = '';
1395
+ atSel = 0;
1396
+ drawPicker();
1397
+ return;
1398
+ }
1399
+ // ── / trigger ─────────────────────────────────────────────
1400
+ if (data === '/' && prefix === '' && pasteBlock === null) {
1401
+ slashMode = true;
1402
+ slashQuery = '';
1403
+ slashSel = 0;
1404
+ drawSlashPicker();
1405
+ return;
1406
+ }
1407
+ // ── Paste detection ───────────────────────────────────────
1408
+ const normalized = data.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
1409
+ if (normalized.includes('\n')) {
1410
+ prefix += suffix;
1411
+ suffix = '';
1412
+ const pasted = normalized.replace(/\n$/, '');
1413
+ pasteBlock = pasted;
1414
+ pasteCount = pasted.split('\n').filter(l => l.length > 0).length || 1;
1415
+ redraw();
1416
+ return;
1417
+ }
1418
+ // Backspace
1419
+ if (data === '\x7f' || data === '\b') {
1420
+ if (suffix.length > 0) {
1421
+ suffix = suffix.slice(0, -1);
1422
+ process.stdout.write('\b \b');
1423
+ }
1424
+ else if (pasteBlock !== null) {
1425
+ pasteBlock = null;
1426
+ pasteCount = 0;
1427
+ redraw();
1428
+ }
1429
+ else if (prefix.length > 0) {
1430
+ prefix = prefix.slice(0, -1);
1431
+ process.stdout.write('\b \b');
1432
+ }
1433
+ return;
1434
+ }
1435
+ // Ignore unrecognised escape sequences (arrow keys, Fn keys, etc.)
1436
+ if (data.startsWith('\x1b'))
1437
+ return;
1438
+ // Single-line paste (multiple printable chars arriving at once — no newline)
1439
+ if (data.length > 1) {
1440
+ const printable = data.split('').filter(c => c >= ' ').join('');
1441
+ if (printable) {
1442
+ if (pasteBlock !== null)
1443
+ suffix += printable;
1444
+ else
1445
+ prefix += printable;
1446
+ process.stdout.write(printable);
1447
+ }
1448
+ return;
1449
+ }
1450
+ // Regular single printable char
1451
+ if (data.length === 1 && data >= ' ') {
1452
+ if (pasteBlock !== null)
1453
+ suffix += data;
1454
+ else
1455
+ prefix += data;
1456
+ process.stdout.write(data);
1457
+ }
1458
+ }
1459
+ catch (e) {
1460
+ // If any keypress handler throws, restore terminal so keys don't get stuck
1461
+ cleanup();
1462
+ resolve(null);
1463
+ }
1464
+ }
1465
+ process.stdin.on('data', onData);
1466
+ });
1467
+ }
1468
+ // ── Main REPL ───────────────────────────────────────────────────
1469
+ function fmtDate(iso) {
1470
+ const d = new Date(iso);
1471
+ const mo = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][d.getMonth()];
1472
+ return `${mo} ${String(d.getDate()).padStart(2, ' ')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
1473
+ }
1474
+ function timeAgoShort(iso) {
1475
+ const diff = Date.now() - new Date(iso).getTime();
1476
+ const m = Math.floor(diff / 60000);
1477
+ if (m < 1)
1478
+ return 'now';
1479
+ if (m < 60)
1480
+ return `${m}m`;
1481
+ const h = Math.floor(m / 60);
1482
+ if (h < 24)
1483
+ return `${h}h`;
1484
+ const d = Math.floor(h / 24);
1485
+ if (d < 30)
1486
+ return `${d}d`;
1487
+ return `${Math.floor(d / 30)}mo`;
1488
+ }
1489
+ async function showConversationPicker() {
1490
+ const allConvs = listConversations(100);
1491
+ if (allConvs.length === 0) {
1492
+ printInfo('No saved conversations yet.');
1493
+ return null;
1494
+ }
1495
+ return new Promise((resolve) => {
1496
+ let sel = 0;
1497
+ let query = '';
1498
+ let drawnLines = 0;
1499
+ let lastDW = 0;
1500
+ const MAX_SHOWN = 10;
1501
+ let scroll = 0; // first visible index within filtered list
1502
+ function getAllFiltered() {
1503
+ if (!query)
1504
+ return allConvs;
1505
+ const lq = query.toLowerCase();
1506
+ return allConvs.filter(c => c.title.toLowerCase().includes(lq));
1507
+ }
1508
+ function draw() {
1509
+ const cols = process.stdout.columns ?? 80;
1510
+ const DW = Math.min(Math.max(cols - 6, 50), 84);
1511
+ lastDW = DW;
1512
+ const all = getAllFiltered();
1513
+ const total = all.length;
1514
+ if (total > 0 && sel >= total)
1515
+ sel = total - 1;
1516
+ if (sel < 0)
1517
+ sel = 0;
1518
+ // Keep sel in visible window
1519
+ if (sel < scroll)
1520
+ scroll = sel;
1521
+ if (sel >= scroll + MAX_SHOWN)
1522
+ scroll = sel - MAX_SHOWN + 1;
1523
+ scroll = Math.max(0, Math.min(scroll, Math.max(0, total - MAX_SHOWN)));
1524
+ const shown = all.slice(scroll, scroll + MAX_SHOWN);
1525
+ const N = shown.length;
1526
+ // Clear previous draw
1527
+ if (drawnLines > 0) {
1528
+ for (let i = 0; i < drawnLines; i++)
1529
+ process.stdout.write('\x1b[1A\x1b[2K');
1530
+ }
1531
+ drawnLines = 0;
1532
+ const BC = chalk.hex('#4338ca');
1533
+ const PC = chalk.hex('#818cf8');
1534
+ const DIM = chalk.hex('#4b5563');
1535
+ // ── Header ─────────────────────────────────────────────────
1536
+ // Visible: " ╭─" (4) + hLabel + " " + "─"*hDashes + "╮" (1) = DW+2
1537
+ const hLabel = query
1538
+ ? ` / ${query} ` + DIM(`${total} found`)
1539
+ : ` ${allConvs.length} conversation${allConvs.length !== 1 ? 's' : ''}`;
1540
+ const hLabelVis = stripAnsi(hLabel).length;
1541
+ const hDashes = Math.max(DW - 4 - hLabelVis, 1);
1542
+ process.stdout.write(BC(' ╭─') + PC(hLabel) + BC(' ' + '─'.repeat(hDashes) + '╮') + '\n');
1543
+ drawnLines++;
1544
+ // ── Search input ────────────────────────────────────────────
1545
+ // Visible: " │" (3) + " / " (5) + query + cursor(1) + pad + "│" (1) = DW+2
1546
+ // → pad = DW - 8 - query.length
1547
+ const sCursor = colors.primary('│');
1548
+ const sPad = ' '.repeat(Math.max(DW - 8 - query.length, 0));
1549
+ process.stdout.write(BC(' │') + colors.muted(' / ') + chalk.white(query) + sCursor + sPad + BC('│') + '\n');
1550
+ drawnLines++;
1551
+ // ── Separator ──────────────────────────────────────────────
1552
+ process.stdout.write(BC(' ├' + '─'.repeat(DW - 2) + '┤') + '\n');
1553
+ drawnLines++;
1554
+ // ── Items ──────────────────────────────────────────────────
1555
+ // Row visible: " │" (3) + " > " (4) + TITLE_W + " " (2) + msgs(7) + " " (2) + ago(3) + " │" (2) = DW+2
1556
+ // → 3+4+TITLE_W+2+7+2+3+2 = 23+TITLE_W = DW+2 → TITLE_W = DW-21
1557
+ const TITLE_W = Math.max(DW - 21, 12);
1558
+ if (N === 0) {
1559
+ const msg = query ? 'no matching conversations' : 'no conversations yet';
1560
+ const ipad = ' '.repeat(Math.max(DW - 6 - msg.length, 0));
1561
+ process.stdout.write(BC(' │') + ' ' + colors.muted(msg) + ipad + BC('│') + '\n');
1562
+ drawnLines++;
1563
+ }
1564
+ else {
1565
+ for (let i = 0; i < N; i++) {
1566
+ const c = shown[i];
1567
+ const isSel = (scroll + i) === sel;
1568
+ const mark = isSel ? PC('>') : ' ';
1569
+ const title = c.title.replace(/\s+/g, ' ');
1570
+ const disp = title.length > TITLE_W ? title.slice(0, TITLE_W - 1) + '…' : title;
1571
+ const gap = ' '.repeat(Math.max(TITLE_W - disp.length, 0));
1572
+ // msgs: always 7 chars — e.g. " 2 msgs" or "20 msgs"
1573
+ const msgs = String(c.messageCount).padStart(2) + ' msgs';
1574
+ // ago: always 3 chars
1575
+ const ago = timeAgoShort(c.updatedAt).padStart(3);
1576
+ const titleC = isSel ? PC.bold(disp) : chalk.hex('#94a3b8')(disp);
1577
+ const msgsC = isSel ? chalk.hex('#818cf8')(msgs) : DIM(msgs);
1578
+ const agoC = isSel ? PC(ago) : DIM(ago);
1579
+ process.stdout.write(BC(' │') + ` ${mark} ` + titleC + gap + ` ` + msgsC + ` ` + agoC + ` ` + BC('│') + '\n');
1580
+ drawnLines++;
1581
+ }
1582
+ }
1583
+ // ── Scroll indicator row (if needed) ───────────────────────
1584
+ if (total > MAX_SHOWN) {
1585
+ const above = scroll > 0;
1586
+ const below = scroll + MAX_SHOWN < total;
1587
+ const pos = `${sel + 1} / ${total}`;
1588
+ const arrows = (above ? '↑ ' : ' ') + pos + (below ? ' ↓' : ' ');
1589
+ const ipad = ' '.repeat(Math.max(DW - 4 - arrows.length, 0));
1590
+ process.stdout.write(BC(' │') + ipad + colors.muted(arrows) + ` ` + BC('│') + '\n');
1591
+ drawnLines++;
1592
+ }
1593
+ // ── Footer ─────────────────────────────────────────────────
1594
+ const hint = ' ↑↓ navigate Enter open Esc cancel ';
1595
+ const fInner = DW - 2 - hint.length;
1596
+ const fLeft = Math.max(Math.floor(fInner / 2), 0);
1597
+ const fRight = Math.max(fInner - fLeft, 0);
1598
+ process.stdout.write(BC(' ╰' + '─'.repeat(fLeft)) + colors.muted(hint) + BC('─'.repeat(fRight) + '╯') + '\n');
1599
+ drawnLines++;
1600
+ }
1601
+ draw();
1602
+ function onResize() {
1603
+ const newCols = process.stdout.columns ?? 80;
1604
+ const rowsPerLine = Math.max(1, Math.ceil((lastDW + 2) / newCols));
1605
+ const physRows = drawnLines * rowsPerLine;
1606
+ for (let i = 0; i < physRows; i++)
1607
+ process.stdout.write('\x1b[1A');
1608
+ process.stdout.write('\r\x1b[0J');
1609
+ drawnLines = 0;
1610
+ draw();
1611
+ }
1612
+ process.stdout.on('resize', onResize);
1613
+ function cleanup() {
1614
+ process.stdin.removeListener('data', onKey);
1615
+ process.stdout.removeListener('resize', onResize);
1616
+ }
1617
+ function close(id) {
1618
+ cleanup();
1619
+ for (let i = 0; i < drawnLines; i++)
1620
+ process.stdout.write('\x1b[1A\x1b[2K');
1621
+ resolve(id);
1622
+ }
1623
+ function onKey(data) {
1624
+ const all = getAllFiltered();
1625
+ const N = all.length;
1626
+ switch (data) {
1627
+ case '\x1b[A': // Arrow up
1628
+ if (N > 0) {
1629
+ sel = Math.max(sel - 1, 0);
1630
+ draw();
1631
+ }
1632
+ break;
1633
+ case '\x1b[B': // Arrow down
1634
+ if (N > 0) {
1635
+ sel = Math.min(sel + 1, N - 1);
1636
+ draw();
1637
+ }
1638
+ break;
1639
+ case '\r':
1640
+ case '\n':
1641
+ close(all[sel]?.id ?? null);
1642
+ break;
1643
+ case '\x1b':
1644
+ close(null);
1645
+ break;
1646
+ case '\x03':
1647
+ cleanup();
1648
+ process.exit(0);
1649
+ break;
1650
+ case '\x7f':
1651
+ case '\b':
1652
+ if (query.length > 0) {
1653
+ query = query.slice(0, -1);
1654
+ sel = 0;
1655
+ scroll = 0;
1656
+ draw();
1657
+ }
1658
+ break;
1659
+ default:
1660
+ if (data.length === 1 && data >= ' ') {
1661
+ query += data;
1662
+ sel = 0;
1663
+ scroll = 0;
1664
+ draw();
1665
+ }
1666
+ }
1667
+ }
1668
+ process.stdin.on('data', onKey);
1669
+ });
1670
+ }
1671
+ function printConversationHistory(messages) {
1672
+ const cols = process.stdout.columns ?? 80;
1673
+ const chatMsgs = messages.filter(m => m.role === 'user' || m.role === 'assistant');
1674
+ if (chatMsgs.length === 0)
1675
+ return;
1676
+ // Build map: filepath → last known content (from system read_file messages)
1677
+ const fileSnapshot = new Map();
1678
+ for (const msg of messages) {
1679
+ if (msg.role === 'system') {
1680
+ const text = String(msg.content);
1681
+ const match = text.match(/^\[File content — ([^\]]+)\]:\n([\s\S]*)$/);
1682
+ if (match)
1683
+ fileSnapshot.set(match[1], match[2]);
1684
+ }
1685
+ }
1686
+ const toShow = chatMsgs.length > 20 ? chatMsgs.slice(-20) : chatMsgs;
1687
+ if (chatMsgs.length > 20) {
1688
+ process.stdout.write(colors.muted(`\n … ${chatMsgs.length - 20} earlier messages …\n`));
1689
+ }
1690
+ const msgBg = chalk.bgHex('#334155').hex('#f1f5f9');
1691
+ const BC = chalk.hex('#4338ca');
1692
+ const FC = chalk.hex('#e2e8f0');
1693
+ for (const msg of toShow) {
1694
+ if (msg.role === 'user') {
1695
+ const raw = String(msg.content).replace(/\s+/g, ' ').trim();
1696
+ const display = raw.length > cols - 8 ? raw.slice(0, cols - 11) + '...' : raw;
1697
+ process.stdout.write('\n' + msgBg((' > ' + display + ' ').padEnd(cols)) + '\n');
1698
+ }
1699
+ else {
1700
+ const raw = String(msg.content);
1701
+ const stripper = new TagStripper();
1702
+ const renderer = new MarkdownRenderer();
1703
+ const stripped = stripper.feed(raw) + stripper.flush();
1704
+ const rendered = (renderer.format(stripped) + renderer.flush()).replace(/^\n+/, '');
1705
+ if (rendered.trim())
1706
+ process.stdout.write('\n' + rendered);
1707
+ const allOps = parseOps(normalizeResponse(raw));
1708
+ const readOps = allOps.filter(op => (op.type === 'read_file' && op.path) ||
1709
+ (op.type === 'read_folder' && op.path) ||
1710
+ (op.type === 'search_code' && op.pattern));
1711
+ const fileOps = allOps.filter(op => op.type !== 'read_file' && op.type !== 'read_folder' && op.type !== 'search_code');
1712
+ // ── File operation summaries ─────────────────────────────
1713
+ if (fileOps.length > 0) {
1714
+ const maxW = Math.min(cols - 12, 120);
1715
+ const clip = (t) => t.length > maxW ? t.slice(0, maxW - 1) + '…' : t;
1716
+ const renderDiffLines = (diff, maxLines = 60) => {
1717
+ const CONTEXT = 2;
1718
+ const show = new Uint8Array(diff.length);
1719
+ for (let i = 0; i < diff.length; i++) {
1720
+ if (diff[i].kind !== 'context') {
1721
+ for (let c = Math.max(0, i - CONTEXT); c <= Math.min(diff.length - 1, i + CONTEXT); c++)
1722
+ show[c] = 1;
1723
+ }
1724
+ }
1725
+ const renderLine = (line) => {
1726
+ const num = String(line.lineNo).padStart(5, ' ');
1727
+ if (line.kind === 'remove') {
1728
+ const content = ` ${num} ${chalk.hex('#f87171')('-')} ${clip(line.text)}`;
1729
+ process.stdout.write(' ' + chalk.hex('#7f1d1d')('▌') + chalk.bgHex('#1c0a0a')(content.padEnd(cols - 3)) + '\n');
1730
+ }
1731
+ else if (line.kind === 'add') {
1732
+ const content = ` ${num} ${chalk.hex('#34d399')('+')} ${clip(line.text)}`;
1733
+ process.stdout.write(' ' + chalk.hex('#065f46')('▌') + chalk.bgHex('#021a0e')(content.padEnd(cols - 3)) + '\n');
1734
+ }
1735
+ else {
1736
+ process.stdout.write(chalk.hex('#374151')(` ${num} ${clip(line.text)}\n`));
1737
+ }
1738
+ };
1739
+ // Build hunks
1740
+ const hunks = [];
1741
+ let curHunk = [];
1742
+ let lastIdx = -1;
1743
+ for (let i = 0; i < diff.length; i++) {
1744
+ if (!show[i])
1745
+ continue;
1746
+ if (lastIdx >= 0 && i > lastIdx + 1) {
1747
+ hunks.push(curHunk);
1748
+ curHunk = [];
1749
+ }
1750
+ lastIdx = i;
1751
+ curHunk.push(diff[i]);
1752
+ }
1753
+ if (curHunk.length > 0)
1754
+ hunks.push(curHunk);
1755
+ // Per-hunk: leading context, all removes, all adds, trailing context
1756
+ let shown = 0;
1757
+ for (let h = 0; h < hunks.length; h++) {
1758
+ if (shown >= maxLines)
1759
+ break;
1760
+ if (h > 0)
1761
+ process.stdout.write(chalk.hex('#374151')(' ╌╌╌\n'));
1762
+ const hunk = hunks[h];
1763
+ const before = [];
1764
+ const hRemoves = [];
1765
+ const hAdds = [];
1766
+ let pendingCtx = [];
1767
+ let seenChange = false;
1768
+ for (const line of hunk) {
1769
+ if (line.kind === 'context') {
1770
+ if (!seenChange)
1771
+ before.push(line);
1772
+ else
1773
+ pendingCtx.push(line);
1774
+ }
1775
+ else {
1776
+ seenChange = true;
1777
+ pendingCtx = [];
1778
+ if (line.kind === 'remove')
1779
+ hRemoves.push(line);
1780
+ else
1781
+ hAdds.push(line);
1782
+ }
1783
+ }
1784
+ for (const l of before)
1785
+ renderLine(l);
1786
+ for (const l of hRemoves) {
1787
+ if (shown >= maxLines)
1788
+ break;
1789
+ renderLine(l);
1790
+ shown++;
1791
+ }
1792
+ for (const l of hAdds) {
1793
+ if (shown >= maxLines)
1794
+ break;
1795
+ renderLine(l);
1796
+ shown++;
1797
+ }
1798
+ for (const l of pendingCtx)
1799
+ renderLine(l);
1800
+ }
1801
+ const total = diff.filter(d => d.kind !== 'context').length;
1802
+ if (total > maxLines) {
1803
+ process.stdout.write(chalk.hex('#4b5563')(` … ${total - maxLines} more lines\n`));
1804
+ }
1805
+ };
1806
+ process.stdout.write('\n');
1807
+ for (const op of fileOps) {
1808
+ if (op.type === 'write' && op.path && op.content !== undefined) {
1809
+ const oldContent = fileSnapshot.get(op.path) ?? fileSnapshot.get(op.path.replace(/\\/g, '/')) ?? '';
1810
+ const diff = computeDiff(oldContent, op.content);
1811
+ const adds = diff.filter(d => d.kind === 'add').length;
1812
+ const removes = diff.filter(d => d.kind === 'remove').length;
1813
+ const statStr = [
1814
+ removes > 0 ? chalk.hex('#f87171').bold(`-${removes}`) : '',
1815
+ adds > 0 ? chalk.hex('#34d399').bold(`+${adds}`) : '',
1816
+ ].filter(Boolean).join(' ');
1817
+ process.stdout.write(chalk.hex('#fbbf24')(' ~') + chalk.hex('#94a3b8')(' Updated ') +
1818
+ chalk.hex('#e2e8f0')(op.path) + (statStr ? ' ' + statStr : '') + '\n');
1819
+ renderDiffLines(diff);
1820
+ // Update snapshot so subsequent ops see the new content
1821
+ fileSnapshot.set(op.path, op.content);
1822
+ }
1823
+ else if (op.type === 'edit' && op.path && op.find !== undefined && op.replace !== undefined) {
1824
+ const diff = computeDiff(op.find, op.replace);
1825
+ const adds = diff.filter(d => d.kind === 'add').length;
1826
+ const removes = diff.filter(d => d.kind === 'remove').length;
1827
+ const statStr = [
1828
+ removes > 0 ? chalk.hex('#f87171').bold(`-${removes}`) : '',
1829
+ adds > 0 ? chalk.hex('#34d399').bold(`+${adds}`) : '',
1830
+ ].filter(Boolean).join(' ');
1831
+ process.stdout.write(chalk.hex('#fbbf24')(' ~') + chalk.hex('#94a3b8')(' Updated ') +
1832
+ chalk.hex('#e2e8f0')(op.path) + (statStr ? ' ' + statStr : '') + '\n');
1833
+ renderDiffLines(diff);
1834
+ }
1835
+ else if (op.type === 'run' && op.command) {
1836
+ process.stdout.write(colors.primary(' $') + ' ' + chalk.white(op.command) + '\n');
1837
+ }
1838
+ else if (op.type === 'delete' && op.path) {
1839
+ process.stdout.write(colors.error(' ✗') + chalk.hex('#94a3b8')(' Deleted ') + colors.muted(op.path) + '\n');
1840
+ }
1841
+ else if (op.type === 'mkdir' && op.path) {
1842
+ process.stdout.write(colors.success(' ✓') + chalk.hex('#94a3b8')(' Created ') + colors.muted(op.path) + '\n');
1843
+ }
1844
+ }
1845
+ }
1846
+ // ── Read / search / folder summary (compact box) ────────
1847
+ if (readOps.length > 0) {
1848
+ const total = readOps.length;
1849
+ const boxW = Math.min(Math.max(cols - 2, 52), 100);
1850
+ const maxFnameW = Math.max(boxW - 10, 8);
1851
+ const titleText = ` ${total} operation${total === 1 ? '' : 's'} `;
1852
+ const dashCount = Math.max(boxW - 3 - titleText.length, 2);
1853
+ process.stdout.write('\n ' + BC('╭─') + colors.muted(titleText) + BC('─'.repeat(dashCount) + '╮') + '\n');
1854
+ for (const op of readOps) {
1855
+ let label;
1856
+ if (op.type === 'search_code') {
1857
+ label = `search: "${op.pattern}"`;
1858
+ }
1859
+ else if (op.type === 'read_folder') {
1860
+ label = `folder: ${op.path}`;
1861
+ }
1862
+ else {
1863
+ label = op.path;
1864
+ }
1865
+ const disp = label.length > maxFnameW ? '…' + label.slice(-(maxFnameW - 1)) : label;
1866
+ const pad = ' '.repeat(Math.max(boxW - 7 - disp.length, 2));
1867
+ process.stdout.write(' ' + BC('│') + ' ' + colors.muted('✓') + ' ' + FC(disp) + pad + BC('│') + '\n');
1868
+ }
1869
+ process.stdout.write(' ' + BC('╰' + '─'.repeat(boxW - 2) + '╯') + '\n');
1870
+ }
1871
+ }
1872
+ }
1873
+ }
1874
+ async function generateAITitle(userMsg, aiMsg, token) {
1875
+ const msgs = [
1876
+ { role: 'system', content: 'Generate a short 2-5 word English title for this conversation. Reply with ONLY the title words. No punctuation, no quotes.' },
1877
+ { role: 'user', content: userMsg.slice(0, 400) },
1878
+ { role: 'assistant', content: aiMsg.slice(0, 400) },
1879
+ { role: 'user', content: 'Title:' },
1880
+ ];
1881
+ return new Promise((resolve) => {
1882
+ let out = '';
1883
+ streamChat(msgs, token, (chunk) => { out += chunk; }, () => {
1884
+ const title = out.replace(/['"]/g, '').replace(/\s+/g, ' ').trim().slice(0, 50);
1885
+ resolve(title || 'New Conversation');
1886
+ }, () => resolve('New Conversation'));
1887
+ });
1888
+ }
1889
+ async function showDesignModeLoader(skills) {
1890
+ const P = chalk.hex('#a78bfa');
1891
+ const G = chalk.hex('#34d399');
1892
+ const D = chalk.hex('#6b7280');
1893
+ const W = chalk.hex('#e2e8f0');
1894
+ const FR = SPINNER_FRAMES;
1895
+ const items = skills.length > 0 ? skills.slice(0, 8) : ['Preparing design system...'];
1896
+ process.stdout.write('\n');
1897
+ // ── Spinner ───────────────────────────────────────────────────
1898
+ let fi = 0;
1899
+ const spin = setInterval(() => {
1900
+ process.stdout.write(`\r ${P(FR[fi++ % FR.length])} ${D('Loading Web Designer skill...')}`);
1901
+ }, 60);
1902
+ await new Promise(r => setTimeout(r, 500));
1903
+ clearInterval(spin);
1904
+ process.stdout.write('\r\x1b[K');
1905
+ // ── Box with dynamic skill lines ─────────────────────────────
1906
+ const cols = process.stdout.columns ?? 80;
1907
+ const maxW = Math.max(...items.map(s => s.length), 30);
1908
+ const lineW = Math.min(maxW + 8, cols - 6);
1909
+ process.stdout.write(' ' + P('╭' + '─'.repeat(lineW)) + '\n');
1910
+ for (const skill of items) {
1911
+ let fi2 = 0;
1912
+ const sp2 = setInterval(() => {
1913
+ process.stdout.write(`\r ${P('│')} ${D(FR[fi2++ % FR.length])} ${D(skill)}`);
1914
+ }, 60);
1915
+ await new Promise(r => setTimeout(r, 200));
1916
+ clearInterval(sp2);
1917
+ process.stdout.write(`\r ${P('│')} ${G('✓')} ${W(skill)}\n`);
1918
+ }
1919
+ process.stdout.write(' ' + P('╰' + '─'.repeat(lineW)) + '\n');
1920
+ // ── Final badge ───────────────────────────────────────────────
1921
+ await new Promise(r => setTimeout(r, 100));
1922
+ process.stdout.write('\n ' + G('✓') + ' ' + chalk.hex('#c4b5fd').bold('Design') +
1923
+ W(' mode activated') + '\n\n');
1924
+ }
1925
+ export async function startRepl(cwd) {
1926
+ const alwaysAllowed = new Set();
1927
+ const { email: userEmail } = getSavedUser();
1928
+ // Fetch system prompt from server — never stored in package
1929
+ const token = getToken() ?? '';
1930
+ let SYSTEM_PROMPT = '';
1931
+ if (token) {
1932
+ const { prompt, webDesignerSkill } = await fetchSystemPrompt(token);
1933
+ SYSTEM_PROMPT = prompt;
1934
+ _serverWebDesignerSkill = webDesignerSkill;
1935
+ }
1936
+ // activeCwd can change when /resume loads a conversation from a different directory
1937
+ let activeCwd = cwd;
1938
+ const buildSystemMsg = (dir) => ({
1939
+ role: 'system',
1940
+ content: SYSTEM_PROMPT + '\n\nProject context:\n' + buildContext(dir),
1941
+ });
1942
+ const history = [buildSystemMsg(activeCwd)];
1943
+ let sessionId = generateId();
1944
+ const PROMPT = colors.primary(' › ');
1945
+ let queuedResult = null;
1946
+ let lastUserLine = '';
1947
+ let lastAIResponse = '';
1948
+ let readFileContinue = false;
1949
+ let readFileTurnCount = 0; // resets each user message; caps read loops
1950
+ let forcedEditMode = false; // blocks further read ops after limit
1951
+ let conversationTitle = null;
1952
+ let webDesignerActive = false;
1953
+ let pendingOutputTokens = 0;
1954
+ let pendingUserTokens = 0;
1955
+ let hasPendingReport = false;
1956
+ process.stdin.once('end', () => {
1957
+ console.log(colors.muted('\n Goodbye!\n'));
1958
+ process.exit(0);
1959
+ });
1960
+ // Prevent any unhandled rejection from crashing the process — log to file for debugging
1961
+ const ERROR_LOG = path.join(os.homedir(), '.dravix-code', 'error.log');
1962
+ const logError = (label, err) => {
1963
+ try {
1964
+ const line = `[${new Date().toISOString()}] ${label}: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`;
1965
+ fs.appendFileSync(ERROR_LOG, line);
1966
+ }
1967
+ catch { }
1968
+ };
1969
+ // Always restore terminal on any error — prevents keyboard from getting stuck
1970
+ const resetTerminal = () => {
1971
+ try {
1972
+ process.stdin.setRawMode(false);
1973
+ }
1974
+ catch { }
1975
+ try {
1976
+ process.stdin.pause();
1977
+ }
1978
+ catch { }
1979
+ };
1980
+ process.on('exit', resetTerminal);
1981
+ process.on('SIGINT', () => { resetTerminal(); process.exit(0); });
1982
+ process.on('SIGTERM', () => { resetTerminal(); process.exit(0); });
1983
+ process.on('unhandledRejection', (reason) => { logError('unhandledRejection', reason); resetTerminal(); });
1984
+ process.on('uncaughtException', (err) => { logError('uncaughtException', err); resetTerminal(); });
1985
+ // Set raw mode ONCE — never toggle it during the session to avoid CMD getting stuck
1986
+ process.stdin.setRawMode(true);
1987
+ process.stdin.resume();
1988
+ process.stdin.setEncoding('utf8');
1989
+ // Sequential loop — queued input lets user type during AI stream
1990
+ while (true) {
1991
+ try {
1992
+ const cliTok = getToken() ?? '';
1993
+ // Flush pending usage when starting a new user turn (all auto-continues are done)
1994
+ if (!readFileContinue && hasPendingReport && cliTok) {
1995
+ reportUsage(cliTok, Math.min(pendingUserTokens + pendingOutputTokens, 16000)).catch(() => { });
1996
+ hasPendingReport = false;
1997
+ pendingOutputTokens = 0;
1998
+ pendingUserTokens = 0;
1999
+ }
2000
+ let result;
2001
+ let skipInput = false;
2002
+ if (readFileContinue) {
2003
+ readFileContinue = false;
2004
+ readFileTurnCount++;
2005
+ if (readFileTurnCount > 3 && !forcedEditMode) {
2006
+ forcedEditMode = true;
2007
+ history.push({
2008
+ role: 'system',
2009
+ content: `[FORCED EDIT MODE] You have done ${readFileTurnCount} read/search operations — reads are now BLOCKED.\nYou MUST output a tag RIGHT NOW. No more explanation, no more searching:\n • <edit_file path="..."><find>EXACT text from file</find><replace>new content</replace></edit_file>\n • <write_file path="...">COMPLETE file content here</write_file>\nIf you are unsure of exact text → use <write_file> with the complete corrected content. OUTPUT THE TAG NOW.`,
2010
+ });
2011
+ process.stdout.write('\n' + colors.muted(' ⚠ Too many read operations — forced edit mode') + '\n');
2012
+ }
2013
+ else if (readFileTurnCount >= 2) {
2014
+ // Escalating signal: AI has read enough, must act now
2015
+ history.push({
2016
+ role: 'system',
2017
+ content: `[ACTION REQUIRED] You have done ${readFileTurnCount} read operations. STOP reading. You have enough context — output <edit_file> or <write_file> NOW. Do NOT search or read again. Output ONLY the tag.`,
2018
+ });
2019
+ }
2020
+ else {
2021
+ // First continuation: gentle signal
2022
+ history.push({ role: 'system', content: 'You now have the file content. Proceed with the task — output the edit or write tag directly, no explanation needed.' });
2023
+ }
2024
+ skipInput = true;
2025
+ result = { text: '', lines: [] };
2026
+ }
2027
+ else if (queuedResult) {
2028
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2029
+ const q = queuedResult;
2030
+ queuedResult = null;
2031
+ result = q;
2032
+ // Show queued message as if just submitted
2033
+ const cols = process.stdout.columns ?? 80;
2034
+ const msgBgQ = chalk.bgHex('#334155').hex('#f1f5f9');
2035
+ process.stdout.write(msgBgQ((' > ' + q.text + ' ').padEnd(cols)) + '\n');
2036
+ }
2037
+ else {
2038
+ result = await readLine(PROMPT, activeCwd);
2039
+ }
2040
+ if (!result)
2041
+ continue;
2042
+ const { text: line } = result;
2043
+ if (!skipInput)
2044
+ lastUserLine = line;
2045
+ // ── Slash commands ──────────────────────────────────────────
2046
+ if (!skipInput && line.trim() === '/resume') {
2047
+ const convId = await showConversationPicker();
2048
+ if (convId) {
2049
+ const conv = loadConversation(convId);
2050
+ if (conv) {
2051
+ // Restore the original working directory so file ops work correctly
2052
+ activeCwd = conv.cwd;
2053
+ // Rebuild system message with the correct directory
2054
+ history[0] = buildSystemMsg(activeCwd);
2055
+ history.splice(1);
2056
+ history.push(...conv.messages);
2057
+ // Warn AI that file states may have changed since the saved session
2058
+ history.push({
2059
+ role: 'system',
2060
+ content: `[Resumed session] Working directory: ${activeCwd}\nFile contents may have changed since the last session. ALWAYS use <read_file> or <search_code> to get the CURRENT file content before editing. Never assume files match the conversation history.`,
2061
+ });
2062
+ sessionId = convId;
2063
+ conversationTitle = conv.title;
2064
+ printConversationHistory(conv.messages);
2065
+ }
2066
+ }
2067
+ continue;
2068
+ }
2069
+ if (!skipInput && line.startsWith('/')) {
2070
+ await handleCommand(line, activeCwd, () => {
2071
+ activeCwd = cwd;
2072
+ history.splice(1);
2073
+ history[0] = buildSystemMsg(activeCwd);
2074
+ conversationTitle = null;
2075
+ sessionId = generateId();
2076
+ }, (filePath, content) => {
2077
+ const existing = history.findIndex(m => m.role === 'system' && m.content.startsWith(`[Added file: ${filePath}]`));
2078
+ const msg = { role: 'system', content: `[Added file: ${filePath}]\n${content}` };
2079
+ if (existing !== -1)
2080
+ history[existing] = msg;
2081
+ else
2082
+ history.push(msg);
2083
+ }, () => lastAIResponse || null, () => {
2084
+ if (lastUserLine)
2085
+ queuedResult = { text: lastUserLine, lines: [lastUserLine] };
2086
+ });
2087
+ continue;
2088
+ }
2089
+ if (!skipInput) {
2090
+ readFileTurnCount = 0; // reset loop counter for each new user message
2091
+ forcedEditMode = false; // reset forced edit mode for each new user message
2092
+ // ── Refresh project context (files may have changed since last turn) ──
2093
+ history[0] = buildSystemMsg(activeCwd);
2094
+ // ── Clear stale operational messages left from previous auto-continue turns ──
2095
+ // They pollute future user messages when prepareMessages merges consecutive roles.
2096
+ const STALE_PREFIXES = ['[File updated]', '[SYSTEM LIMIT]', '[BLOCKED]', 'Please proceed with the task'];
2097
+ for (let i = history.length - 1; i > 0; i--) {
2098
+ if (history[i].role === 'system' &&
2099
+ STALE_PREFIXES.some(p => history[i].content.startsWith(p))) {
2100
+ history.splice(i, 1);
2101
+ }
2102
+ }
2103
+ // ── Trim history: keep system[0] + last 20 messages ──
2104
+ if (history.length > 22) {
2105
+ history.splice(1, history.length - 22);
2106
+ }
2107
+ // ── Auto-inject mentioned file contents ──────────────────────
2108
+ // Files ≤ 3000 lines: full raw content injected — AI has exact text for <find>, no reads needed.
2109
+ // Files > 3000 lines: first 300 lines preview + AI does ONE <read_file> to get target section.
2110
+ const FILE_PATTERN = /[\w\-.\/\\]+\.\w{2,5}/g;
2111
+ const mentioned = [...new Set(line.match(FILE_PATTERN) ?? [])];
2112
+ let fileContext = '';
2113
+ const FULL_INJECT_LINES = 3000;
2114
+ for (const fname of mentioned) {
2115
+ const fullPath = path.resolve(activeCwd, fname);
2116
+ if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
2117
+ try {
2118
+ const content = fs.readFileSync(fullPath, 'utf-8');
2119
+ const lines = content.split('\n');
2120
+ const lineCount = lines.length;
2121
+ if (lineCount <= FULL_INJECT_LINES) {
2122
+ fileContext += `\n\n[File: ${fname} — FULL CONTENT — ${lineCount} lines]\n` +
2123
+ `⚠ Complete file is here — do NOT use <read_file> for this file. Use <edit_file> directly.\n\n` +
2124
+ content;
2125
+ }
2126
+ else {
2127
+ // Very large file (>3000 lines): preview + single read instruction
2128
+ const preview = lines.slice(0, 300).join('\n');
2129
+ fileContext += `\n\n[File: ${fname} — ${lineCount} lines — first 300 lines shown]\n` +
2130
+ `⚠ Use <read_file path="${fname}"/> (no lines=) to get full content, then edit.\n\n` +
2131
+ preview +
2132
+ `\n\n... (${lineCount - 300} more lines)`;
2133
+ }
2134
+ }
2135
+ catch { /* skip unreadable */ }
2136
+ }
2137
+ }
2138
+ history.push({ role: 'user', content: fileContext ? line + fileContext : line });
2139
+ }
2140
+ // ── Token limit check ────────────────────────────────────────
2141
+ if (cliTok && !skipInput) {
2142
+ const usage = await checkUsage(cliTok);
2143
+ if (usage && !usage.allowed) {
2144
+ const resetStr = formatResetTime(usage);
2145
+ process.stdout.write('\n');
2146
+ process.stdout.write(' ' + colors.error('✗') + ' ' + chalk.hex('#e2e8f0').bold('Daily token limit reached') + '\n' +
2147
+ ' ' + colors.muted(` ${fmtNum(usage.tokens_used)} / ${fmtNum(usage.limit)} tokens used`) + '\n' +
2148
+ ' ' + colors.muted(` Resets in ${resetStr}`) +
2149
+ (usage.is_plus ? '' : ' ' + chalk.hex('#fcd34d')('· Upgrade to Plus for 100k tokens/day')) + '\n\n');
2150
+ continue;
2151
+ }
2152
+ // Warning when >80% used
2153
+ if (usage && usage.tokens_used / usage.limit >= 0.8) {
2154
+ const { bar, pct } = usageBar(usage.tokens_used, usage.limit, 14);
2155
+ process.stdout.write('\n ' + colors.warn('⚠') + ' ' + chalk.hex('#fbbf24')(`Token limit: ${pct}% used`) + ' ' +
2156
+ chalk.hex('#f87171')(bar) + ' ' +
2157
+ colors.muted(`${fmtNum(usage.remaining)} remaining · resets in ${formatResetTime(usage)}`) + '\n');
2158
+ }
2159
+ }
2160
+ // ── Ctrl+C cancel during streaming ──────────────────────────
2161
+ let streamCancelled = false;
2162
+ const streamAbort = new AbortController();
2163
+ const onStreamKey = (data) => {
2164
+ try {
2165
+ if (data === '\x03') {
2166
+ streamCancelled = true;
2167
+ streamAbort.abort();
2168
+ }
2169
+ }
2170
+ catch { /* ignore */ }
2171
+ };
2172
+ process.stdin.on('data', onStreamKey);
2173
+ // ── Spinner ──────────────────────────────────────────────────
2174
+ if (!skipInput)
2175
+ process.stdout.write('\n');
2176
+ let frameIdx = 0;
2177
+ const aiStart = Date.now();
2178
+ const spinnerInterval = setInterval(() => {
2179
+ process.stdout.write(`\r\x1b[K ${colors.muted(SPINNER_FRAMES[frameIdx++ % SPINNER_FRAMES.length])} ${colors.muted('Thinking...')}`);
2180
+ }, 80);
2181
+ // ── Stream AI response ──────────────────────────────────────
2182
+ let fullResponse = '';
2183
+ let streamStarted = false;
2184
+ let inStreamingOp = false;
2185
+ let streamOpLabel = '';
2186
+ let streamOpFrame = 0;
2187
+ let streamOpInterval = null;
2188
+ let showingMidLoader = false;
2189
+ let midStreamFrame = 0;
2190
+ let midStreamInterval = null;
2191
+ // ── Inline op execution (write/edit/delete run inline as tags complete) ──
2192
+ const executedInlineOps = new Set();
2193
+ let inlineOpRunning = false;
2194
+ let inlineTextQueue = ''; // AI text buffered while inline op runs
2195
+ const inlineFileOpErrors = [];
2196
+ const inlineSkippedPaths = new Set();
2197
+ const inlineOpFingerprint = (op) => JSON.stringify({
2198
+ t: op.type, p: op.path ?? null,
2199
+ f: op.find ? op.find.slice(0, 200) : null,
2200
+ r: op.replace ? op.replace.slice(0, 200) : null,
2201
+ c: op.content ? op.content.length + ':' + op.content.slice(0, 100) : null,
2202
+ cmd: op.command ?? null,
2203
+ });
2204
+ const runInlineOp = async (op) => {
2205
+ inlineOpRunning = true;
2206
+ try {
2207
+ // Clear any active streaming spinner before showing the permission dialog
2208
+ if (streamOpInterval) {
2209
+ clearInterval(streamOpInterval);
2210
+ streamOpInterval = null;
2211
+ }
2212
+ if (inStreamingOp) {
2213
+ process.stdout.write('\r\x1b[K');
2214
+ inStreamingOp = false;
2215
+ streamOpLabel = '';
2216
+ streamOpFrame = 0;
2217
+ }
2218
+ if (midStreamTimer)
2219
+ clearTimeout(midStreamTimer);
2220
+ if (midStreamInterval) {
2221
+ clearInterval(midStreamInterval);
2222
+ midStreamInterval = null;
2223
+ showingMidLoader = false;
2224
+ }
2225
+ if (showingMidLoader) {
2226
+ process.stdout.write('\r\x1b[K');
2227
+ showingMidLoader = false;
2228
+ }
2229
+ if (hasRenderedContent && trailingNLs === 0)
2230
+ process.stdout.write('\n');
2231
+ const label = opLabel(op, activeCwd);
2232
+ const key = opKey(op, activeCwd);
2233
+ const depSkipped = op.type === 'run' && op.command &&
2234
+ [...inlineSkippedPaths].some(p => op.command.includes(path.basename(p)));
2235
+ if (depSkipped) {
2236
+ process.stdout.write(` ${colors.muted('○')} ${colors.muted('Skipped: ' + label + ' (dependency missing)')}\n`);
2237
+ executedInlineOps.add(inlineOpFingerprint(op));
2238
+ }
2239
+ else {
2240
+ const allowed = await askPermission(label, key, alwaysAllowed, op.type === 'run');
2241
+ executedInlineOps.add(inlineOpFingerprint(op));
2242
+ if (!allowed) {
2243
+ if (op.path)
2244
+ inlineSkippedPaths.add(op.path);
2245
+ }
2246
+ else {
2247
+ let stageSpinInterval2 = null;
2248
+ let stageSpinFrame2 = 0;
2249
+ let stageSpinMsg2 = '';
2250
+ const opResult = await executeSingleOp(op, activeCwd, (msg) => {
2251
+ stageSpinMsg2 = msg;
2252
+ if (!stageSpinInterval2) {
2253
+ stageSpinFrame2 = 0;
2254
+ stageSpinInterval2 = setInterval(() => {
2255
+ const f = SPINNER_FRAMES[stageSpinFrame2++ % SPINNER_FRAMES.length];
2256
+ process.stdout.write(`\r\x1b[K ${colors.muted(f)} ${chalk.hex('#94a3b8')(stageSpinMsg2)}`);
2257
+ }, 80);
2258
+ }
2259
+ });
2260
+ if (stageSpinInterval2) {
2261
+ clearInterval(stageSpinInterval2);
2262
+ stageSpinInterval2 = null;
2263
+ }
2264
+ process.stdout.write('\r\x1b[K');
2265
+ printOpResult(opResult);
2266
+ if (opResult.type === 'error' && op.type !== 'run') {
2267
+ // If text not found, include current file content so AI can retry accurately
2268
+ let errMsg = `[Operation failed — ${label}]: ${opResult.message ?? 'Unknown error'}`;
2269
+ if (opResult.message?.includes('not found') && op.path) {
2270
+ try {
2271
+ const fp = path.resolve(activeCwd, op.path);
2272
+ if (fs.existsSync(fp)) {
2273
+ const cur = fs.readFileSync(fp, 'utf-8');
2274
+ const nLines = cur.split('\n').length;
2275
+ if (nLines <= 400) {
2276
+ errMsg += `\n\nCurrent content of ${op.path}:\n${cur}\n\nUse <write_file> with the complete corrected content.`;
2277
+ }
2278
+ else {
2279
+ errMsg += `\n\nFile has ${nLines} lines. Use the correct workflow:\n 1. <search_code pattern="first few words of what you want to find" path="${op.path}"/> — get line number\n 2. <read_file path="${op.path}" lines="N-M"/> — read that exact section\n 3. Copy the EXACT text (after the tab, without line numbers) into <find>`;
2280
+ }
2281
+ }
2282
+ }
2283
+ catch { /* ignore */ }
2284
+ }
2285
+ inlineFileOpErrors.push(errMsg);
2286
+ }
2287
+ else if ((opResult.type === 'modified' || opResult.type === 'created') && op.path &&
2288
+ (op.type === 'edit' || op.type === 'write')) {
2289
+ // After a successful edit, inject a note so AI knows the file changed
2290
+ // This prevents subsequent <edit_file> ops from using stale <find> text
2291
+ history.push({
2292
+ role: 'system',
2293
+ content: `[File updated] ${op.path} was just modified by a previous operation in this response. If you have more edits for this file, use <read_file path="${op.path}"/> first to get the current content before using <edit_file>, or use <write_file> with the complete new content.`,
2294
+ });
2295
+ }
2296
+ }
2297
+ }
2298
+ }
2299
+ catch { /* ignore */ }
2300
+ inlineOpRunning = false;
2301
+ trailingNLs = 1;
2302
+ // Flush buffered AI text that arrived while op was running
2303
+ if (inlineTextQueue) {
2304
+ const vis = renderer.format(inlineTextQueue);
2305
+ inlineTextQueue = '';
2306
+ if (vis) {
2307
+ process.stdout.write(vis);
2308
+ hasRenderedContent = true;
2309
+ const trail = vis.match(/(\n+)$/);
2310
+ trailingNLs = trail ? trail[1].length : 0;
2311
+ }
2312
+ }
2313
+ // Check if more ops became complete while this op was running
2314
+ checkForNextInlineOp();
2315
+ };
2316
+ const checkForNextInlineOp = () => {
2317
+ if (inlineOpRunning)
2318
+ return;
2319
+ // Don't run inline ops in design_mode — let the post-stream flow handle them
2320
+ if (fullResponse.includes('<design_mode>'))
2321
+ return;
2322
+ const allParsed = parseOps(normalizeResponse(fullResponse));
2323
+ // Only write/edit/delete/mkdir ops are executed inline — run/read ops stay post-stream
2324
+ const writeOps = allParsed.filter(op => op.type === 'write' || op.type === 'edit' || op.type === 'delete' || op.type === 'mkdir');
2325
+ const nextOp = writeOps.find(op => !executedInlineOps.has(inlineOpFingerprint(op)));
2326
+ if (nextOp)
2327
+ runInlineOp(nextOp);
2328
+ };
2329
+ let midStreamTimer = null;
2330
+ let hasRenderedContent = false;
2331
+ let trailingNLs = 0; // trailing newlines written by AI text renders
2332
+ const stripper = new TagStripper();
2333
+ const renderer = new MarkdownRenderer();
2334
+ const token = getToken() ?? '';
2335
+ await new Promise((resolve) => {
2336
+ streamChat(history, token, (chunk) => {
2337
+ fullResponse += chunk;
2338
+ if (!streamStarted) {
2339
+ streamStarted = true;
2340
+ clearInterval(spinnerInterval);
2341
+ process.stdout.write('\r\x1b[K');
2342
+ trailingNLs = 1; // cursor on cleared line = effectively blank line start
2343
+ }
2344
+ // ── Clear mid-stream loader ────────────────────────────
2345
+ if (showingMidLoader) {
2346
+ if (midStreamInterval) {
2347
+ clearInterval(midStreamInterval);
2348
+ midStreamInterval = null;
2349
+ }
2350
+ process.stdout.write('\r\x1b[K');
2351
+ showingMidLoader = false;
2352
+ trailingNLs = 1;
2353
+ }
2354
+ // Reset mid-stream pause timer
2355
+ if (midStreamTimer)
2356
+ clearTimeout(midStreamTimer);
2357
+ midStreamTimer = setTimeout(() => {
2358
+ if (!inStreamingOp && !inlineOpRunning) {
2359
+ showingMidLoader = true;
2360
+ midStreamFrame = 0;
2361
+ midStreamInterval = setInterval(() => {
2362
+ const f = SPINNER_FRAMES[midStreamFrame++ % SPINNER_FRAMES.length];
2363
+ process.stdout.write(`\r\x1b[K ${colors.muted(f)} ${colors.muted('Processing...')}`);
2364
+ }, 80);
2365
+ }
2366
+ }, 900);
2367
+ // ── Visible text ────────────────────────────────────────
2368
+ const stripped = stripper.feed(chunk);
2369
+ const visible = stripped ? renderer.format(stripped) : '';
2370
+ if (visible) {
2371
+ if (inStreamingOp && !inlineOpRunning) {
2372
+ if (streamOpInterval) {
2373
+ clearInterval(streamOpInterval);
2374
+ streamOpInterval = null;
2375
+ }
2376
+ process.stdout.write('\r\x1b[K');
2377
+ inStreamingOp = false;
2378
+ streamOpLabel = '';
2379
+ streamOpFrame = 0;
2380
+ }
2381
+ const toPrint = hasRenderedContent ? visible : visible.replace(/^\n+/, '');
2382
+ if (toPrint) {
2383
+ if (toPrint.trim())
2384
+ hasRenderedContent = true;
2385
+ if (hasRenderedContent) {
2386
+ if (inlineOpRunning) {
2387
+ inlineTextQueue += toPrint; // buffer while op runs
2388
+ }
2389
+ else {
2390
+ process.stdout.write(toPrint);
2391
+ const trail = toPrint.match(/(\n+)$/);
2392
+ trailingNLs = trail ? trail[1].length : 0;
2393
+ }
2394
+ }
2395
+ }
2396
+ }
2397
+ // ── Check for newly complete inline ops (write/edit/delete) ──
2398
+ if (!inlineOpRunning)
2399
+ checkForNextInlineOp();
2400
+ // ── Animate spinner while tag is still being generated ──────
2401
+ if (!inlineOpRunning) {
2402
+ const currentOp = detectActiveStreamingOp(fullResponse);
2403
+ if (currentOp && !inStreamingOp) {
2404
+ inStreamingOp = true;
2405
+ streamOpLabel = currentOp;
2406
+ streamOpFrame = 0;
2407
+ if (hasRenderedContent && trailingNLs === 0)
2408
+ process.stdout.write('\n');
2409
+ process.stdout.write(`\r\x1b[K ${colors.muted(SPINNER_FRAMES[0])} ${chalk.hex('#94a3b8')(streamOpLabel)}`);
2410
+ streamOpInterval = setInterval(() => {
2411
+ streamOpFrame = (streamOpFrame + 1) % SPINNER_FRAMES.length;
2412
+ process.stdout.write(`\r\x1b[K ${colors.muted(SPINNER_FRAMES[streamOpFrame])} ${chalk.hex('#94a3b8')(streamOpLabel)}`);
2413
+ }, 80);
2414
+ }
2415
+ else if (currentOp && inStreamingOp && currentOp !== streamOpLabel) {
2416
+ streamOpLabel = currentOp;
2417
+ }
2418
+ else if (!currentOp && inStreamingOp) {
2419
+ if (streamOpInterval) {
2420
+ clearInterval(streamOpInterval);
2421
+ streamOpInterval = null;
2422
+ }
2423
+ process.stdout.write('\r\x1b[K');
2424
+ inStreamingOp = false;
2425
+ streamOpLabel = '';
2426
+ streamOpFrame = 0;
2427
+ trailingNLs = 1;
2428
+ }
2429
+ }
2430
+ }, async () => {
2431
+ clearInterval(spinnerInterval);
2432
+ if (midStreamTimer)
2433
+ clearTimeout(midStreamTimer);
2434
+ if (midStreamInterval) {
2435
+ clearInterval(midStreamInterval);
2436
+ midStreamInterval = null;
2437
+ }
2438
+ if (streamOpInterval) {
2439
+ clearInterval(streamOpInterval);
2440
+ streamOpInterval = null;
2441
+ }
2442
+ if (showingMidLoader || inStreamingOp)
2443
+ process.stdout.write('\r\x1b[K');
2444
+ showingMidLoader = false;
2445
+ inStreamingOp = false;
2446
+ const tail = stripper.flush();
2447
+ if (tail) {
2448
+ const tV = renderer.format(tail);
2449
+ if (tV) {
2450
+ hasRenderedContent = true;
2451
+ process.stdout.write(tV);
2452
+ }
2453
+ }
2454
+ const rest = renderer.flush();
2455
+ if (rest) {
2456
+ hasRenderedContent = true;
2457
+ process.stdout.write(rest);
2458
+ }
2459
+ if (!streamStarted) {
2460
+ clearInterval(spinnerInterval);
2461
+ process.stdout.write('\r\x1b[K\n');
2462
+ }
2463
+ // ── Cancelled by user (Ctrl+C) ────────────────────────
2464
+ if (streamCancelled) {
2465
+ process.stdout.write('\r\x1b[K');
2466
+ if (hasRenderedContent && trailingNLs === 0)
2467
+ process.stdout.write('\n');
2468
+ process.stdout.write('\n ' + colors.muted('◼ Cancelled\n'));
2469
+ if (fullResponse.trim()) {
2470
+ history.push({ role: 'assistant', content: normalizeResponse(fullResponse) });
2471
+ }
2472
+ resolve();
2473
+ return;
2474
+ }
2475
+ const normalized = normalizeResponse(fullResponse);
2476
+ lastAIResponse = normalized;
2477
+ history.push({ role: 'assistant', content: normalized });
2478
+ if (conversationTitle === null) {
2479
+ const _msgs = history.slice(1).filter(m => m.role === 'user' || m.role === 'assistant');
2480
+ const _u = _msgs.find(m => m.role === 'user');
2481
+ const _a = _msgs.find(m => m.role === 'assistant');
2482
+ if (_u && _a) {
2483
+ generateAITitle(String(_u.content), String(_a.content), token).then(t => {
2484
+ conversationTitle = t;
2485
+ saveConversation(sessionId, t, activeCwd, history.slice(1));
2486
+ }).catch(() => { });
2487
+ }
2488
+ }
2489
+ // Wait for any inline op still executing before touching stdout
2490
+ while (inlineOpRunning)
2491
+ await new Promise(r => setTimeout(r, 20));
2492
+ // ── design_mode tag → activate Web Designer skill ────────
2493
+ const designMatch = fullResponse.match(/<design_mode>([\s\S]*?)<\/design_mode>/);
2494
+ if (!webDesignerActive && designMatch) {
2495
+ webDesignerActive = true;
2496
+ history[0] = { role: 'system', content: history[0].content + _serverWebDesignerSkill };
2497
+ const skills = designMatch[1]
2498
+ .split('\n')
2499
+ .map(l => l.trim())
2500
+ .filter(Boolean);
2501
+ await showDesignModeLoader(skills);
2502
+ history.push({ role: 'system', content: '[Web Designer mode is now active. Apply all design guidelines from your system prompt. Proceed to build the website now.]' });
2503
+ readFileContinue = true;
2504
+ }
2505
+ // Run any ops that became complete just as stream ended
2506
+ checkForNextInlineOp();
2507
+ while (inlineOpRunning)
2508
+ await new Promise(r => setTimeout(r, 20));
2509
+ // Add inline op errors to history so AI can retry
2510
+ if (inlineFileOpErrors.length > 0) {
2511
+ history.push({ role: 'system', content: inlineFileOpErrors.join('\n\n') + '\n\nFix the above errors and retry the failed operations.' });
2512
+ readFileContinue = true;
2513
+ }
2514
+ const allOps = parseOps(normalized);
2515
+ // ── Detect incomplete plan: AI described steps but didn't output tags ──
2516
+ // Pattern: response has numbered items like "2. something" or "3. something"
2517
+ // but very few actual file operation tags were output → force continuation
2518
+ if (!readFileContinue && !streamCancelled) {
2519
+ const plannedSteps = (normalized.match(/^\s*[2-9]\.\s+\S/mg) ?? []).length;
2520
+ const executedOps = allOps.filter(op => op.type !== 'read_file' && op.type !== 'read_folder' && op.type !== 'search_code').length;
2521
+ if (plannedSteps >= 1 && executedOps === 0 && !allOps.length) {
2522
+ history.push({ role: 'system', content: 'You described the steps but did not output any <edit_file> or <write_file> tags. Output the actual tags NOW to apply the changes. No more descriptions — just the tags.' });
2523
+ readFileContinue = true;
2524
+ }
2525
+ }
2526
+ const readOps = allOps.filter(op => op.type === 'read_file' || op.type === 'read_folder' || op.type === 'search_code');
2527
+ // Skip ops already executed inline (write/edit/delete/mkdir)
2528
+ const ops = allOps.filter(op => op.type !== 'read_file' && op.type !== 'read_folder' && op.type !== 'search_code' &&
2529
+ !executedInlineOps.has(inlineOpFingerprint(op)));
2530
+ // Trailing newline — skip when read ops follow (they start on current line)
2531
+ if (hasRenderedContent && readOps.length === 0)
2532
+ process.stdout.write('\n');
2533
+ // ── Handle read_file ops ──────────────────────────────
2534
+ // forcedEditMode: block further reads — AI must use existing context
2535
+ if (readOps.length > 0 && forcedEditMode) {
2536
+ history.push({
2537
+ role: 'system',
2538
+ content: `[BLOCKED] Read/search ops are blocked (limit reached). Use the file content already in context to make edits. Do NOT request more reads.`,
2539
+ });
2540
+ readFileContinue = true;
2541
+ }
2542
+ else if (readOps.length > 0) {
2543
+ // Absorb blank lines so reading block starts immediately after user message
2544
+ if (hasRenderedContent) {
2545
+ // Ensure cursor is on a new line, then absorb excess trailing blank lines
2546
+ if (trailingNLs === 0)
2547
+ process.stdout.write('\n');
2548
+ const excess = Math.max((trailingNLs || 1) - 1, 0);
2549
+ for (let i = 0; i < excess; i++)
2550
+ process.stdout.write('\x1b[1A\x1b[2K');
2551
+ }
2552
+ else if (!skipInput) {
2553
+ // No AI text — absorb the blank line from main loop's \n
2554
+ const lines = streamStarted ? 1 : 2;
2555
+ for (let i = 0; i < lines; i++)
2556
+ process.stdout.write('\x1b[1A\x1b[2K');
2557
+ }
2558
+ (async () => {
2559
+ try {
2560
+ const validOps = readOps.filter(op => (op.type === 'read_file' && op.path) ||
2561
+ (op.type === 'read_folder' && op.path) ||
2562
+ (op.type === 'search_code' && op.pattern));
2563
+ const total = validOps.length;
2564
+ if (total === 0) {
2565
+ readFileContinue = true;
2566
+ resolve();
2567
+ return;
2568
+ }
2569
+ const cols = process.stdout.columns ?? 80;
2570
+ const boxW = Math.min(Math.max(cols - 2, 52), 100);
2571
+ const BC = chalk.hex('#4338ca');
2572
+ const FC = chalk.hex('#e2e8f0');
2573
+ const RIGHT_W = 22;
2574
+ const LABEL_W = Math.max(boxW - 11 - RIGHT_W, 8);
2575
+ const results = [];
2576
+ for (const op of validOps) {
2577
+ let spinLabel = '';
2578
+ let spinColor = chalk.hex('#818cf8');
2579
+ if (op.type === 'search_code') {
2580
+ spinLabel = `Searching ${chalk.white('"' + (op.pattern ?? '') + '"')}`;
2581
+ spinColor = chalk.hex('#f59e0b');
2582
+ }
2583
+ else if (op.type === 'read_folder') {
2584
+ spinLabel = `Reading ${chalk.white(op.path ?? '')}`;
2585
+ spinColor = chalk.hex('#818cf8');
2586
+ }
2587
+ else {
2588
+ spinLabel = `Reading ${chalk.white(op.path ?? '')}`;
2589
+ spinColor = chalk.hex('#818cf8');
2590
+ }
2591
+ let rfFrame = 0;
2592
+ const spinLine = () => `\r\x1b[K ${colors.muted(SPINNER_FRAMES[rfFrame])} ${spinColor(spinLabel)}`;
2593
+ process.stdout.write(spinLine());
2594
+ const rfInterval = setInterval(() => {
2595
+ rfFrame = (rfFrame + 1) % SPINNER_FRAMES.length;
2596
+ process.stdout.write(spinLine());
2597
+ }, 80);
2598
+ const t0 = Date.now();
2599
+ const res = await executeSingleOp(op, activeCwd);
2600
+ const ms = Date.now() - t0;
2601
+ clearInterval(rfInterval);
2602
+ if (res.type === 'run') {
2603
+ let ctxLabel = '';
2604
+ let displayLabel = '';
2605
+ let rightCol = '';
2606
+ if (op.type === 'search_code') {
2607
+ ctxLabel = `[Search results — "${op.pattern}"${op.path ? ` in "${op.path}"` : ''}]:\n${res.output ?? ''}`;
2608
+ const lastLine = (res.output ?? '').split('\n').filter(Boolean).pop() ?? '';
2609
+ rightCol = chalk.hex('#6b7280')(lastLine.startsWith('Total:') ? lastLine.slice(7).trim() : `${ms}ms`);
2610
+ displayLabel = `"${op.pattern}"`;
2611
+ }
2612
+ else if (op.type === 'read_folder') {
2613
+ ctxLabel = `[Folder contents — "${op.path}"]:\n${res.output ?? ''}`;
2614
+ const firstLine = (res.output ?? '').split('\n')[0] ?? '';
2615
+ const counts = firstLine.match(/\((.+)\)/);
2616
+ rightCol = chalk.hex('#6b7280')(counts ? counts[1] : `${ms}ms`);
2617
+ displayLabel = op.path ?? '';
2618
+ }
2619
+ else {
2620
+ const lineTag = op.lines ? `[lines=${op.lines}]` : '[full]';
2621
+ ctxLabel = `[File content — ${res.message ?? op.path}]${lineTag}:\n${res.output ?? ''}`;
2622
+ rightCol = chalk.hex('#6b7280')(op.lines ? `lines ${op.lines}` : `${ms}ms`);
2623
+ displayLabel = op.lines ? `${op.path ?? ''} :${op.lines}` : (op.path ?? '');
2624
+ // Auto-continue paginated reads: if file has more pages, queue next page automatically
2625
+ const pageMatch = (res.output ?? '').match(/\.\.\. \((\d+) more lines — next page: lines (\d+)-(\d+)\)/);
2626
+ if (pageMatch && op.path) {
2627
+ const [, , nextStart, nextEnd] = pageMatch;
2628
+ history.push({
2629
+ role: 'system',
2630
+ content: `[Auto-reading next page of ${op.path}...]`,
2631
+ });
2632
+ // Queue the next page read by injecting it as a system op
2633
+ validOps.push({ type: 'read_file', path: op.path, lines: `${nextStart}-${nextEnd}` });
2634
+ }
2635
+ }
2636
+ // Deduplicate: don't push the exact same read twice in recent history
2637
+ // For read_file with lines=, include the range so different sections are NOT deduped
2638
+ const dupKey = op.type === 'read_file'
2639
+ ? `[File content — ${res.message ?? op.path}]${op.lines ? `[lines=${op.lines}]` : '[full]'}`
2640
+ : op.type === 'read_folder'
2641
+ ? `[Folder contents — "${op.path}"]`
2642
+ : null;
2643
+ const alreadyInHistory = dupKey
2644
+ ? history.slice(-8).some(m => m.role === 'system' && m.content.includes(dupKey))
2645
+ : false;
2646
+ if (!alreadyInHistory) {
2647
+ history.push({ role: 'system', content: ctxLabel });
2648
+ }
2649
+ results.push({ label: displayLabel, right: rightCol, ok: true, contextMsg: ctxLabel });
2650
+ }
2651
+ else {
2652
+ const displayLabel = op.type === 'search_code'
2653
+ ? `"${op.pattern ?? ''}"`
2654
+ : (op.path ?? '');
2655
+ results.push({ label: displayLabel, right: chalk.hex('#f87171')('err'), ok: false, contextMsg: '' });
2656
+ }
2657
+ }
2658
+ // ── Phase 2: summary box ────────────────────────────────
2659
+ process.stdout.write('\r\x1b[K');
2660
+ const hasSearch = validOps.some(o => o.type === 'search_code');
2661
+ const hasFolder = validOps.some(o => o.type === 'read_folder');
2662
+ const hasFiles = validOps.some(o => o.type === 'read_file');
2663
+ const titleParts = [];
2664
+ if (hasSearch)
2665
+ titleParts.push(`Search`);
2666
+ if (hasFolder)
2667
+ titleParts.push(`Folder`);
2668
+ if (hasFiles)
2669
+ titleParts.push(total === 1 ? '1 file' : `${total} files`);
2670
+ const titleText = ` ${titleParts.join(' · ')} `;
2671
+ const dashCount = Math.max(boxW - 3 - titleText.length, 2);
2672
+ process.stdout.write(` ` + BC('╭─') + colors.primary.bold(titleText) + BC('─'.repeat(dashCount) + '╮') + `\n`);
2673
+ for (const r of results) {
2674
+ const icon = r.ok ? colors.success('✓') : colors.error('✗');
2675
+ const disp = r.label.length > LABEL_W
2676
+ ? '…' + r.label.slice(-(LABEL_W - 1))
2677
+ : r.label;
2678
+ const labelPad = ' '.repeat(Math.max(LABEL_W - disp.length, 0));
2679
+ const rightVis = stripAnsi(r.right);
2680
+ const rightPad = ' '.repeat(Math.max(RIGHT_W - rightVis.length, 0));
2681
+ process.stdout.write(` ` + BC('│') + ` ` + icon + ` ` + FC(disp) + labelPad + ` ` + r.right + rightPad + ` ` + BC('│') + `\n`);
2682
+ }
2683
+ process.stdout.write(` ` + BC('╰' + '─'.repeat(boxW - 2) + '╯') + `\n`);
2684
+ readFileContinue = true;
2685
+ resolve();
2686
+ }
2687
+ catch (e) {
2688
+ logError('readOps', e);
2689
+ resolve();
2690
+ }
2691
+ })();
2692
+ return;
2693
+ } // end else if (readOps.length > 0)
2694
+ if (ops.length > 0) {
2695
+ (async () => {
2696
+ try {
2697
+ const skippedPaths = new Set();
2698
+ const runOutputs = [];
2699
+ const fileOpErrors = [];
2700
+ const declinedCmds = [];
2701
+ for (const op of ops) {
2702
+ const label = opLabel(op, activeCwd);
2703
+ const key = opKey(op, activeCwd);
2704
+ if (op.type === 'run' && op.command) {
2705
+ const depSkipped = [...skippedPaths].some(p => op.command.includes(path.basename(p)));
2706
+ if (depSkipped) {
2707
+ process.stdout.write(` ${colors.muted('○')} ${colors.muted('Skipped: ' + label)} ${colors.muted('(dependency missing)')}\n`);
2708
+ continue;
2709
+ }
2710
+ }
2711
+ const allowed = await askPermission(label, key, alwaysAllowed, op.type === 'run');
2712
+ if (!allowed) {
2713
+ if (op.path)
2714
+ skippedPaths.add(op.path);
2715
+ if (op.type === 'run' && op.command)
2716
+ declinedCmds.push(op.command);
2717
+ continue;
2718
+ }
2719
+ let stageSpinInterval = null;
2720
+ let stageSpinFrame = 0;
2721
+ let stageSpinMsg = '';
2722
+ const opResult = await executeSingleOp(op, activeCwd, (stageMsg) => {
2723
+ stageSpinMsg = stageMsg;
2724
+ if (!stageSpinInterval) {
2725
+ stageSpinFrame = 0;
2726
+ stageSpinInterval = setInterval(() => {
2727
+ const f = SPINNER_FRAMES[stageSpinFrame++ % SPINNER_FRAMES.length];
2728
+ process.stdout.write(`\r\x1b[K ${colors.muted(f)} ${chalk.hex('#94a3b8')(stageSpinMsg)}`);
2729
+ }, 80);
2730
+ }
2731
+ });
2732
+ if (stageSpinInterval) {
2733
+ clearInterval(stageSpinInterval);
2734
+ stageSpinInterval = null;
2735
+ }
2736
+ process.stdout.write('\r\x1b[K');
2737
+ printOpResult(opResult);
2738
+ // Collect file operation errors for AI feedback
2739
+ if (opResult.type === 'error' && op.type !== 'run') {
2740
+ fileOpErrors.push(`[Operation failed — ${label}]: ${opResult.message ?? 'Unknown error'}`);
2741
+ }
2742
+ if (op.type === 'run' && op.command) {
2743
+ if (opResult.type === 'run') {
2744
+ runOutputs.push({ cmd: op.command, output: opResult.output || '(command executed successfully — no terminal output)', isError: false });
2745
+ }
2746
+ else if (opResult.type === 'error') {
2747
+ runOutputs.push({ cmd: op.command, output: opResult.message ?? 'Unknown error', isError: true });
2748
+ }
2749
+ }
2750
+ }
2751
+ if (runOutputs.length > 0) {
2752
+ const NO_OUTPUT_MARKER = '(command executed successfully — no terminal output)';
2753
+ const content = runOutputs.map(r => r.isError
2754
+ ? `[Command failed — ${r.cmd}]:\n${r.output}`
2755
+ : `[Command output — ${r.cmd}]:\n${r.output}`).join('\n\n');
2756
+ history.push({ role: 'system', content });
2757
+ // Only auto-continue if there is real output to react to, or an error to explain
2758
+ const needsReaction = runOutputs.some(r => r.isError || r.output !== NO_OUTPUT_MARKER);
2759
+ if (needsReaction)
2760
+ readFileContinue = true;
2761
+ }
2762
+ // ── Feed file operation errors back to AI so it can retry ──
2763
+ if (fileOpErrors.length > 0) {
2764
+ history.push({ role: 'system', content: fileOpErrors.join('\n\n') + '\n\nFix the above errors and retry the failed operations.' });
2765
+ readFileContinue = true;
2766
+ }
2767
+ if (declinedCmds.length > 0) {
2768
+ const content = declinedCmds.map(cmd => `[User declined to run — ${cmd}]: User pressed No. Do not retry or ask about this command.`).join('\n\n');
2769
+ history.push({ role: 'system', content });
2770
+ // No readFileContinue — AI already responded; wait for user's next message
2771
+ }
2772
+ resolve();
2773
+ }
2774
+ catch (e) {
2775
+ logError('ops', e);
2776
+ resolve();
2777
+ }
2778
+ })();
2779
+ }
2780
+ else {
2781
+ resolve();
2782
+ }
2783
+ }, (err) => {
2784
+ clearInterval(spinnerInterval);
2785
+ if (midStreamTimer)
2786
+ clearTimeout(midStreamTimer);
2787
+ if (midStreamInterval) {
2788
+ clearInterval(midStreamInterval);
2789
+ midStreamInterval = null;
2790
+ }
2791
+ if (streamOpInterval) {
2792
+ clearInterval(streamOpInterval);
2793
+ streamOpInterval = null;
2794
+ }
2795
+ process.stdout.write('\r\x1b[K');
2796
+ const raw = err.message;
2797
+ const msg = raw.includes('timed out')
2798
+ ? 'Connection timed out — check your internet and try again'
2799
+ : raw.includes('401') || raw.includes('403') || raw.includes('Unauthorized')
2800
+ ? 'Authentication error — run dravix --login to sign in again'
2801
+ : raw.includes('429') || raw.includes('rate')
2802
+ ? 'Rate limit reached — please wait a moment and try again'
2803
+ : raw.includes('500') || raw.includes('502') || raw.includes('503')
2804
+ ? 'Server error — the AI service is temporarily unavailable'
2805
+ : raw;
2806
+ printError(msg);
2807
+ resolve();
2808
+ }, streamAbort.signal);
2809
+ });
2810
+ // ── Remove streaming key listener ─────────────────────────
2811
+ process.stdin.removeListener('data', onStreamKey);
2812
+ // ── Accumulate token usage (deferred — report on next user turn) ──
2813
+ if (cliTok && fullResponse) {
2814
+ pendingOutputTokens += estimateTokens(normalizeResponse(fullResponse));
2815
+ if (!skipInput && lastUserLine) {
2816
+ pendingUserTokens = estimateTokens(lastUserLine);
2817
+ hasPendingReport = true;
2818
+ }
2819
+ }
2820
+ // ── Auto-save conversation ────────────────────────────────
2821
+ const chatMsgs = history.slice(1).filter(m => m.role === 'user' || m.role === 'assistant');
2822
+ if (chatMsgs.length > 0) {
2823
+ saveConversation(sessionId, conversationTitle ?? generateTitle(chatMsgs), activeCwd, history.slice(1));
2824
+ }
2825
+ }
2826
+ catch (loopErr) {
2827
+ // Never let an internal error crash the session — show it and continue
2828
+ const msg = loopErr instanceof Error ? loopErr.message : String(loopErr);
2829
+ printError(`Internal error: ${msg}`);
2830
+ readFileContinue = false;
2831
+ }
2832
+ }
2833
+ }