codegrunt 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +351 -0
  2. package/dist/cli/at-resolver.d.ts +10 -0
  3. package/dist/cli/at-resolver.js +138 -0
  4. package/dist/cli/at-resolver.js.map +1 -0
  5. package/dist/cli/banner.d.ts +1 -0
  6. package/dist/cli/banner.js +111 -0
  7. package/dist/cli/banner.js.map +1 -0
  8. package/dist/cli/commands.d.ts +25 -0
  9. package/dist/cli/commands.js +799 -0
  10. package/dist/cli/commands.js.map +1 -0
  11. package/dist/cli/index.d.ts +2 -0
  12. package/dist/cli/index.js +142 -0
  13. package/dist/cli/index.js.map +1 -0
  14. package/dist/cli/input.d.ts +14 -0
  15. package/dist/cli/input.js +742 -0
  16. package/dist/cli/input.js.map +1 -0
  17. package/dist/cli/repl.d.ts +2 -0
  18. package/dist/cli/repl.js +217 -0
  19. package/dist/cli/repl.js.map +1 -0
  20. package/dist/cli/setup.d.ts +7 -0
  21. package/dist/cli/setup.js +82 -0
  22. package/dist/cli/setup.js.map +1 -0
  23. package/dist/cli/skills.d.ts +28 -0
  24. package/dist/cli/skills.js +299 -0
  25. package/dist/cli/skills.js.map +1 -0
  26. package/dist/cli/update.d.ts +7 -0
  27. package/dist/cli/update.js +135 -0
  28. package/dist/cli/update.js.map +1 -0
  29. package/dist/config.d.ts +19 -0
  30. package/dist/config.js +93 -0
  31. package/dist/config.js.map +1 -0
  32. package/dist/core/agent/loop.d.ts +17 -0
  33. package/dist/core/agent/loop.js +353 -0
  34. package/dist/core/agent/loop.js.map +1 -0
  35. package/dist/core/context/manager.d.ts +26 -0
  36. package/dist/core/context/manager.js +98 -0
  37. package/dist/core/context/manager.js.map +1 -0
  38. package/dist/core/context/project-guide.d.ts +1 -0
  39. package/dist/core/context/project-guide.js +17 -0
  40. package/dist/core/context/project-guide.js.map +1 -0
  41. package/dist/core/tools/edit_file.d.ts +2 -0
  42. package/dist/core/tools/edit_file.js +54 -0
  43. package/dist/core/tools/edit_file.js.map +1 -0
  44. package/dist/core/tools/execute_shell.d.ts +2 -0
  45. package/dist/core/tools/execute_shell.js +67 -0
  46. package/dist/core/tools/execute_shell.js.map +1 -0
  47. package/dist/core/tools/executor.d.ts +3 -0
  48. package/dist/core/tools/executor.js +86 -0
  49. package/dist/core/tools/executor.js.map +1 -0
  50. package/dist/core/tools/list_directory.d.ts +2 -0
  51. package/dist/core/tools/list_directory.js +74 -0
  52. package/dist/core/tools/list_directory.js.map +1 -0
  53. package/dist/core/tools/read_file.d.ts +2 -0
  54. package/dist/core/tools/read_file.js +40 -0
  55. package/dist/core/tools/read_file.js.map +1 -0
  56. package/dist/core/tools/registry.d.ts +4 -0
  57. package/dist/core/tools/registry.js +24 -0
  58. package/dist/core/tools/registry.js.map +1 -0
  59. package/dist/core/tools/search_files.d.ts +2 -0
  60. package/dist/core/tools/search_files.js +88 -0
  61. package/dist/core/tools/search_files.js.map +1 -0
  62. package/dist/core/tools/write_file.d.ts +2 -0
  63. package/dist/core/tools/write_file.js +43 -0
  64. package/dist/core/tools/write_file.js.map +1 -0
  65. package/dist/providers/deepseek/client.d.ts +8 -0
  66. package/dist/providers/deepseek/client.js +27 -0
  67. package/dist/providers/deepseek/client.js.map +1 -0
  68. package/dist/providers/deepseek/provider.d.ts +8 -0
  69. package/dist/providers/deepseek/provider.js +165 -0
  70. package/dist/providers/deepseek/provider.js.map +1 -0
  71. package/dist/types.d.ts +111 -0
  72. package/dist/types.js +3 -0
  73. package/dist/types.js.map +1 -0
  74. package/dist/utils/billing.d.ts +40 -0
  75. package/dist/utils/billing.js +165 -0
  76. package/dist/utils/billing.js.map +1 -0
  77. package/dist/utils/confirm.d.ts +3 -0
  78. package/dist/utils/confirm.js +242 -0
  79. package/dist/utils/confirm.js.map +1 -0
  80. package/dist/utils/display.d.ts +10 -0
  81. package/dist/utils/display.js +121 -0
  82. package/dist/utils/display.js.map +1 -0
  83. package/dist/utils/interrupt.d.ts +5 -0
  84. package/dist/utils/interrupt.js +13 -0
  85. package/dist/utils/interrupt.js.map +1 -0
  86. package/dist/utils/markdown.d.ts +18 -0
  87. package/dist/utils/markdown.js +223 -0
  88. package/dist/utils/markdown.js.map +1 -0
  89. package/package.json +42 -0
@@ -0,0 +1,742 @@
1
+ import { readdir, access } from 'fs/promises';
2
+ import { resolve, dirname, basename, join } from 'path';
3
+ import chalk from 'chalk';
4
+ import stringWidth from 'string-width';
5
+ import { getSessionUsage } from '../core/agent/loop.js';
6
+ const SLASH_COMMANDS = [
7
+ { name: '/init', desc: 'Analyze codebase and generate SEEK.md' },
8
+ { name: '/model', desc: 'Switch model' },
9
+ { name: '/config', desc: 'View or change config (temperature, reasoning, etc.)' },
10
+ { name: '/skills', desc: 'List and manage skills' },
11
+ { name: '/token', desc: 'Update API key' },
12
+ { name: '/compact', desc: 'Compress conversation history' },
13
+ { name: '/review', desc: 'Review session changes for logic issues' },
14
+ { name: '/clear', desc: 'Clear conversation context' },
15
+ { name: '/balance', desc: 'Show account balance & usage' },
16
+ { name: '/exit', desc: 'Exit CodeGrunt (or exit current skill)' },
17
+ { name: '/help', desc: 'Show help' },
18
+ ];
19
+ async function getFileCompletions(partial, cwd) {
20
+ const SKIP = new Set(['node_modules', '.git', 'dist', '.next', '__pycache__']);
21
+ try {
22
+ const dir = partial.includes('/') ? resolve(cwd, dirname(partial)) : cwd;
23
+ const prefix = partial.includes('/') ? basename(partial) : partial;
24
+ const entries = await readdir(dir, { withFileTypes: true });
25
+ return entries
26
+ .filter((e) => !SKIP.has(e.name) && e.name.startsWith(prefix))
27
+ .slice(0, 8)
28
+ .map((e) => {
29
+ const suffix = e.isDirectory() ? '/' : '';
30
+ return partial.includes('/')
31
+ ? dirname(partial) + '/' + e.name + suffix
32
+ : e.name + suffix;
33
+ });
34
+ }
35
+ catch {
36
+ return [];
37
+ }
38
+ }
39
+ /** Detect which context file is active (CODEGRUNT.md > CLAUDE.md > none) */
40
+ async function detectContextFile(cwd) {
41
+ for (const name of ['CODEGRUNT.md', 'CLAUDE.md']) {
42
+ try {
43
+ await access(join(cwd, name));
44
+ return name;
45
+ }
46
+ catch {
47
+ // not found
48
+ }
49
+ }
50
+ return null;
51
+ }
52
+ // Session history shared across calls
53
+ const history = [];
54
+ /** Split a code-point array into visual lines that each fit within maxW display columns. */
55
+ function wrapLine(cps, maxW) {
56
+ if (cps.length === 0)
57
+ return [[]];
58
+ const result = [];
59
+ let current = [];
60
+ let currentW = 0;
61
+ for (const cp of cps) {
62
+ const cw = stringWidth(cp);
63
+ if (currentW + cw > maxW && current.length > 0) {
64
+ result.push(current);
65
+ current = [cp];
66
+ currentW = cw;
67
+ }
68
+ else {
69
+ current.push(cp);
70
+ currentW += cw;
71
+ }
72
+ }
73
+ result.push(current);
74
+ return result;
75
+ }
76
+ export function readMultilineInput(cwd = process.cwd(), model, skills = [], activeSkill) {
77
+ return new Promise((resolve_p, reject) => {
78
+ const { stdin, stdout } = process;
79
+ // Non-TTY: read until EOF from stdin
80
+ if (!stdin.isTTY) {
81
+ let buf = '';
82
+ const onData = (chunk) => { buf += chunk.toString(); };
83
+ const onEnd = () => {
84
+ stdin.removeListener('data', onData);
85
+ resolve_p({ text: buf.trim(), cancelled: false });
86
+ };
87
+ stdin.resume();
88
+ stdin.on('data', onData);
89
+ stdin.once('end', onEnd);
90
+ return;
91
+ }
92
+ // ── Raw-mode interactive input ──────────────────────────────────────────
93
+ //
94
+ // Panel layout:
95
+ // ╭──────────────────────────────────────────────────────────────────╮
96
+ // │ > <user input here> │
97
+ // ╰─ model · ↑ tokens · Ctrl+J 换行 Enter 发送 ────────────────╯
98
+ //
99
+ // cpBuf — flat array of Unicode code points for the whole buffer
100
+ // cursor — code-point index into cpBuf
101
+ let cpBuf = [];
102
+ let buffer = ''; // cpBuf.join('') — kept in sync
103
+ let cursor = 0; // code-point index
104
+ let historyIdx = history.length;
105
+ let historySavedDraft = null;
106
+ let dropdownVisible = false;
107
+ let dropdownIdx = 0;
108
+ let dropdownMode = 'slash';
109
+ let atCompletions = [];
110
+ // Terminal rows above the cursor that belong to our rendered block.
111
+ let linesAboveCursor = 0;
112
+ // Cached context file name (populated async before first render)
113
+ let contextFile = null;
114
+ const syncBuffer = () => { buffer = cpBuf.join(''); };
115
+ // ── Cleanup helper ──────────────────────────────────────────────────────
116
+ // Every exit path (commit, Ctrl+C, dropdown select) must call cleanup()
117
+ // before resolving/rejecting the promise. This guarantees raw mode is
118
+ // always restored even if an async callback throws after setRawMode(true).
119
+ const cleanup = () => {
120
+ stdin.setRawMode(false);
121
+ stdin.pause();
122
+ stdin.removeListener('data', onData);
123
+ };
124
+ stdin.setRawMode(true);
125
+ stdin.resume();
126
+ stdin.setEncoding('utf8');
127
+ const PROMPT = chalk.blue('> ');
128
+ const PROMPT_W = 2; // display width of "> "
129
+ const CONT_PREFIX = ' '; // continuation line prefix (same width)
130
+ // Split cpBuf into logical lines at '\n' code points
131
+ const getLines = () => {
132
+ const lines = [[]];
133
+ for (const cp of cpBuf) {
134
+ if (cp === '\n')
135
+ lines.push([]);
136
+ else
137
+ lines[lines.length - 1].push(cp);
138
+ }
139
+ return lines;
140
+ };
141
+ // Given a flat cursor position, return { lineIdx, colIdx } within getLines()
142
+ const cursorToLineCol = () => {
143
+ let remaining = cursor;
144
+ const lines = getLines();
145
+ for (let i = 0; i < lines.length; i++) {
146
+ const lineLen = lines[i].length + (i < lines.length - 1 ? 1 : 0);
147
+ if (remaining <= lines[i].length)
148
+ return { lineIdx: i, colIdx: remaining };
149
+ remaining -= lineLen;
150
+ }
151
+ const last = lines.length - 1;
152
+ return { lineIdx: last, colIdx: lines[last].length };
153
+ };
154
+ /** Extract the @partial token immediately before the cursor, or null */
155
+ const getAtPartial = () => {
156
+ const before = cpBuf.slice(0, cursor).join('');
157
+ const match = before.match(/@(\S*)$/);
158
+ return match ? match[1] : null;
159
+ };
160
+ /** Replace the @partial before cursor with the completed value */
161
+ const completeAt = (completion) => {
162
+ const before = cpBuf.slice(0, cursor).join('');
163
+ const match = before.match(/@(\S*)$/);
164
+ if (!match)
165
+ return;
166
+ const partialLen = match[1].length;
167
+ // Remove the partial and insert the completion
168
+ cpBuf.splice(cursor - partialLen, partialLen, ...completion.split(''));
169
+ cursor = cursor - partialLen + completion.length;
170
+ syncBuffer();
171
+ };
172
+ /** Build the bottom status bar content for the panel footer */
173
+ const buildStatusBar = (maxWidth) => {
174
+ const usage = getSessionUsage();
175
+ const totalTokens = usage.inputTokens + usage.outputTokens;
176
+ const tokenStr = totalTokens > 0
177
+ ? (totalTokens >= 1000 ? (totalTokens / 1000).toFixed(1) + 'k' : String(totalTokens)) + ' tokens'
178
+ : '';
179
+ const skillPart = activeSkill ? chalk.bgHex('#6C63FF').hex('#FFFFFF').bold(' SKILL ') + ' ' + chalk.hex('#6C63FF').bold(activeSkill) : '';
180
+ const modelPart = model ? chalk.hex('#4A90D9')(model) : '';
181
+ const tokenPart = tokenStr ? chalk.gray('↑ ' + tokenStr) : '';
182
+ const ctxPart = contextFile ? chalk.gray('In ' + contextFile) : '';
183
+ const hintPart = activeSkill
184
+ ? chalk.gray('Ctrl+J 换行 Enter 发送 /exit 退出')
185
+ : chalk.gray('Ctrl+J 换行 Enter 发送');
186
+ // Build parts from most to least important; drop trailing parts if too wide
187
+ const sep = chalk.gray(' · ');
188
+ const sepW = 5; // ' · ' visible width
189
+ const candidates = [skillPart, modelPart, tokenPart, ctxPart, hintPart].filter(Boolean);
190
+ if (!maxWidth)
191
+ return candidates.join(sep);
192
+ let result = '';
193
+ let usedW = 0;
194
+ for (const part of candidates) {
195
+ const plain = part.replace(/\x1b\[[0-9;]*m/g, '');
196
+ const partW = stringWidth(plain);
197
+ const addW = usedW === 0 ? partW : sepW + partW;
198
+ if (usedW + addW > maxWidth)
199
+ break;
200
+ result += usedW === 0 ? part : sep + part;
201
+ usedW += addW;
202
+ }
203
+ return result;
204
+ };
205
+ const render = () => {
206
+ const lines = getLines();
207
+ const termW = stdout.columns || 80;
208
+ // Reserve the rightmost column to avoid terminal auto-wrap "phantom cursor"
209
+ // state, which would corrupt our \r\n positioning and break linesAboveCursor.
210
+ const panelW = termW - 1;
211
+ const innerW = panelW - 4; // inside "│ " and " │"
212
+ // Build dropdown items before moving cursor so we know the height
213
+ const dropItems = dropdownVisible
214
+ ? (dropdownMode === 'slash'
215
+ ? getFilteredCommands().map(c => ({ label: c.name, desc: formatCommandDesc(c), kind: c.kind }))
216
+ : atCompletions.map(c => ({ label: c, desc: '', kind: 'builtin' })))
217
+ : [];
218
+ const numDrop = dropItems.length;
219
+ // Hide cursor during repaint to prevent visible jump to top border.
220
+ stdout.write('\x1B[?25l');
221
+ // Move up to the top of our previously rendered block, then erase downward.
222
+ // linesAboveCursor already includes dropdown rows from the previous render.
223
+ if (linesAboveCursor > 0)
224
+ stdout.write(`\x1B[${linesAboveCursor}A`);
225
+ stdout.write('\r\x1B[J');
226
+ // ── Dropdown rendered ABOVE the panel ──────────────────────────────────
227
+ // This avoids pushing the panel downward (and scrolling the logo off screen).
228
+ if (numDrop > 0) {
229
+ for (let i = 0; i < numDrop; i++) {
230
+ const sel = i === dropdownIdx;
231
+ const cur = sel ? chalk.blue(' ❯ ') : ' ';
232
+ const labelText = dropItems[i].label;
233
+ const descText = dropItems[i].desc;
234
+ // Total visible width: "│ " (2) + cur (4) + label + " " + desc
235
+ const fixedW = 2 + 4 + stringWidth(labelText);
236
+ const descSpace = Math.max(0, panelW - fixedW - 3);
237
+ const descTrim = descText
238
+ ? descText.slice(0, descSpace).replace(/\s+$/, '')
239
+ : '';
240
+ const label = sel
241
+ ? (dropItems[i].kind === 'builtin' ? chalk.bold.blue(labelText) : chalk.bold.white(labelText))
242
+ : (dropItems[i].kind === 'builtin' ? chalk.blue(labelText) : chalk.white(labelText));
243
+ const desc = descTrim ? chalk.gray(' ' + descTrim) : '';
244
+ stdout.write(chalk.gray('│ ') + cur + label + desc + '\r\n');
245
+ }
246
+ }
247
+ // Wrap each logical line into visual lines that fit within availTextW.
248
+ const availTextW = innerW - PROMPT_W;
249
+ const visualLinesPerLogical = lines.map(l => wrapLine(l, availTextW));
250
+ const rowsPerLine = visualLinesPerLogical.map(vls => vls.length);
251
+ // Compute cursor logical position before rendering.
252
+ const { lineIdx, colIdx } = cursorToLineCol();
253
+ // Top border
254
+ stdout.write(chalk.gray('╭' + '─'.repeat(panelW - 2) + '╮') + '\r\n');
255
+ // Input lines — each logical line may span multiple visual rows.
256
+ for (let i = 0; i < lines.length; i++) {
257
+ const vls = visualLinesPerLogical[i];
258
+ for (let j = 0; j < vls.length; j++) {
259
+ const prefix = (i === 0 && j === 0) ? PROMPT : CONT_PREFIX;
260
+ const text = vls[j].join('');
261
+ const padLen = Math.max(0, availTextW - stringWidth(text));
262
+ stdout.write(chalk.gray('│ ') + prefix + text + ' '.repeat(padLen) + chalk.gray(' │') + '\r\n');
263
+ }
264
+ }
265
+ const BOTTOM_GAP = 1;
266
+ // Bottom border with status bar
267
+ // "╰─ " (3) + content + " " (1) + "─"*fill + "╯" (1) = panelW
268
+ // Available width for content: panelW - 5
269
+ {
270
+ const available = panelW - 5; // panelW - len("╰─ ") - len(" ╯")
271
+ const statusContent = buildStatusBar(available);
272
+ const statusPlain = statusContent.replace(/\x1b\[[0-9;]*m/g, '');
273
+ const statusLen = stringWidth(statusPlain);
274
+ const borderFill = Math.max(0, available - statusLen);
275
+ stdout.write(chalk.gray('╰─ ') + statusContent + chalk.gray(' ' + '─'.repeat(borderFill) + '╯') + '\r\n');
276
+ // Keep distance from terminal bottom (1 line ≈ ~14-16px)
277
+ for (let i = 0; i < BOTTOM_GAP; i++) {
278
+ stdout.write('\r\n');
279
+ }
280
+ }
281
+ // After writing bottom border + BOTTOM_GAP \r\n's, move up to the cursor's visual row.
282
+ // Find which visual line within lines[lineIdx] the cursor falls on.
283
+ const vls = visualLinesPerLogical[lineIdx];
284
+ let remaining = colIdx;
285
+ let visualLineIdx = 0;
286
+ while (visualLineIdx < vls.length - 1 && remaining >= vls[visualLineIdx].length) {
287
+ remaining -= vls[visualLineIdx].length;
288
+ visualLineIdx++;
289
+ }
290
+ const visualColIdx = remaining;
291
+ const cursorTextW = stringWidth(vls[visualLineIdx].slice(0, visualColIdx).join(''));
292
+ const visualCursorCol = 2 + PROMPT_W + cursorTextW;
293
+ // Visual rows of lines[lineIdx] above and below the cursor row.
294
+ const currentLinePartialRows = visualLineIdx;
295
+ const rowsBelowOnCursorLine = vls.length - visualLineIdx - 1;
296
+ let rowsBelowCursor = rowsBelowOnCursorLine + 1 /* bottom border */ + BOTTOM_GAP + 1 /* line after last \r\n */;
297
+ for (let i = lineIdx + 1; i < lines.length; i++) {
298
+ rowsBelowCursor += rowsPerLine[i];
299
+ }
300
+ stdout.write(`\x1B[${rowsBelowCursor}A`);
301
+ const colW = visualCursorCol % termW;
302
+ stdout.write('\r' + (colW > 0 ? `\x1B[${colW}C` : ''));
303
+ // Rows above cursor in our rendered block (used to erase on next render).
304
+ linesAboveCursor = numDrop + 1 /* top border */ + currentLinePartialRows;
305
+ for (let i = 0; i < lineIdx; i++) {
306
+ linesAboveCursor += rowsPerLine[i];
307
+ }
308
+ stdout.write('\x1B[?25h');
309
+ };
310
+ const getFilteredCommands = () => {
311
+ const allCommands = [
312
+ ...SLASH_COMMANDS.map(c => ({ ...c, kind: 'builtin' })),
313
+ ...skills.map((s) => ({ name: '/' + s.name, desc: s.description ?? `skill (${s.source})`, kind: 'skill' })),
314
+ ];
315
+ if (buffer === '/')
316
+ return allCommands;
317
+ if (buffer.startsWith('/') && buffer.length > 1 && buffer[1] !== ' ') {
318
+ return allCommands.filter((c) => c.name.startsWith(buffer));
319
+ }
320
+ return [];
321
+ };
322
+ /** Format a command's description for dropdown display, with kind tag */
323
+ const formatCommandDesc = (cmd) => {
324
+ const tag = cmd.kind === 'skill' ? chalk.dim.gray('[skill] ') : '';
325
+ return tag + (cmd.desc || '');
326
+ };
327
+ const isSlashMode = () => buffer === '/' || (buffer.startsWith('/') && buffer.length > 1 && buffer[1] !== ' ');
328
+ const showDropdown = (mode = 'slash') => {
329
+ dropdownMode = mode;
330
+ dropdownVisible = true;
331
+ dropdownIdx = 0;
332
+ render();
333
+ };
334
+ const hideDropdown = () => { dropdownVisible = false; render(); };
335
+ const selectDropdownItem = () => {
336
+ if (dropdownMode === 'slash') {
337
+ const items = getFilteredCommands();
338
+ if (items.length === 0 || dropdownIdx >= items.length)
339
+ return;
340
+ const selected = items[dropdownIdx].name;
341
+ dropdownVisible = false;
342
+ clearPanel();
343
+ stdout.write('\x1B[?25h');
344
+ cleanup();
345
+ history.push(selected);
346
+ resolve_p({ text: selected, cancelled: false });
347
+ }
348
+ else {
349
+ // @ mode — complete the partial path
350
+ if (atCompletions.length === 0 || dropdownIdx >= atCompletions.length)
351
+ return;
352
+ const selected = atCompletions[dropdownIdx];
353
+ completeAt(selected);
354
+ dropdownVisible = false;
355
+ render();
356
+ }
357
+ };
358
+ /** Erase the entire rendered panel and move cursor to a clean line */
359
+ const clearPanel = () => {
360
+ if (linesAboveCursor > 0)
361
+ stdout.write(`\x1B[${linesAboveCursor}A`);
362
+ stdout.write('\r\x1B[J');
363
+ linesAboveCursor = 0;
364
+ };
365
+ const commitInput = () => {
366
+ const text = buffer.trim();
367
+ if (text === '/') {
368
+ // Hand off to the full-screen selector. We must stop listening on stdin
369
+ // before showSlashCommandSelector sets up its own listener, otherwise both
370
+ // handlers would fire on the same keystrokes. cleanup() also restores raw
371
+ // mode so selectFromList can re-enter it cleanly via its own setRawMode(true).
372
+ cleanup();
373
+ dropdownVisible = false;
374
+ clearPanel();
375
+ void showSlashCommandSelector(skills).then((selected) => {
376
+ stdout.write('\x1B[?25h');
377
+ if (selected) {
378
+ history.push(selected);
379
+ resolve_p({ text: selected, cancelled: false });
380
+ }
381
+ else
382
+ resolve_p({ text: '', cancelled: false });
383
+ });
384
+ return;
385
+ }
386
+ dropdownVisible = false;
387
+ clearPanel();
388
+ stdout.write('\x1B[?25h');
389
+ cleanup();
390
+ if (text)
391
+ history.push(text);
392
+ resolve_p({ text, cancelled: false });
393
+ };
394
+ // Detect context file async, then render.
395
+ // Use .finally() so the panel always renders even if detection fails.
396
+ detectContextFile(cwd).then((f) => {
397
+ contextFile = f;
398
+ }).catch(() => {
399
+ contextFile = null;
400
+ }).finally(() => {
401
+ render();
402
+ });
403
+ const onData = (key) => {
404
+ // Ctrl+C — hard exit; restore terminal state before killing the process
405
+ if (key === '\x03') {
406
+ clearPanel();
407
+ stdout.write('\x1B[?25h');
408
+ cleanup();
409
+ process.exit(0);
410
+ }
411
+ // Ctrl+J / Shift+Enter — insert newline
412
+ if (key === '\n' || key === '\x1B[13;2u' || key === '\x1B[27;2;13~') {
413
+ if (dropdownVisible) {
414
+ hideDropdown();
415
+ return;
416
+ }
417
+ if (isSlashMode()) {
418
+ commitInput();
419
+ return;
420
+ }
421
+ cpBuf.splice(cursor, 0, '\n');
422
+ syncBuffer();
423
+ cursor++;
424
+ render();
425
+ return;
426
+ }
427
+ // Alt+Enter / Ctrl+D — submit (alternative)
428
+ if (key === '\x1B\r' || key === '\x1B[13;3u' || key === '\x1B[27;3;13~' || key === '\x04') {
429
+ if (dropdownVisible) {
430
+ selectDropdownItem();
431
+ return;
432
+ }
433
+ commitInput();
434
+ return;
435
+ }
436
+ // Bare Escape — cancel dropdown or clear buffer
437
+ if (key === '\x1B') {
438
+ if (dropdownVisible) {
439
+ hideDropdown();
440
+ return;
441
+ }
442
+ cpBuf = [];
443
+ syncBuffer();
444
+ cursor = 0;
445
+ historyIdx = history.length;
446
+ historySavedDraft = null;
447
+ render();
448
+ return;
449
+ }
450
+ // Arrow up — history prev / dropdown navigate
451
+ if (key === '\x1B[A') {
452
+ if (dropdownVisible) {
453
+ const len = dropdownMode === 'slash' ? getFilteredCommands().length : atCompletions.length;
454
+ dropdownIdx = (dropdownIdx - 1 + len) % len;
455
+ render();
456
+ return;
457
+ }
458
+ if (historyIdx > 0) {
459
+ if (historySavedDraft === null)
460
+ historySavedDraft = [...cpBuf];
461
+ historyIdx--;
462
+ cpBuf = [...(history[historyIdx] ?? '')];
463
+ syncBuffer();
464
+ cursor = cpBuf.length;
465
+ render();
466
+ }
467
+ return;
468
+ }
469
+ // Arrow down — history next / dropdown navigate
470
+ if (key === '\x1B[B') {
471
+ if (dropdownVisible) {
472
+ const len = dropdownMode === 'slash' ? getFilteredCommands().length : atCompletions.length;
473
+ dropdownIdx = (dropdownIdx + 1) % len;
474
+ render();
475
+ return;
476
+ }
477
+ if (historyIdx < history.length) {
478
+ historyIdx++;
479
+ if (historyIdx === history.length) {
480
+ cpBuf = historySavedDraft ?? [];
481
+ historySavedDraft = null;
482
+ }
483
+ else {
484
+ cpBuf = [...history[historyIdx]];
485
+ }
486
+ syncBuffer();
487
+ cursor = cpBuf.length;
488
+ render();
489
+ }
490
+ return;
491
+ }
492
+ // Arrow right
493
+ if (key === '\x1B[C') {
494
+ if (cursor < cpBuf.length) {
495
+ cursor++;
496
+ render();
497
+ }
498
+ return;
499
+ }
500
+ // Arrow left
501
+ if (key === '\x1B[D') {
502
+ if (cursor > 0) {
503
+ cursor--;
504
+ render();
505
+ }
506
+ return;
507
+ }
508
+ // Enter — send message
509
+ if (key === '\r') {
510
+ if (dropdownVisible) {
511
+ selectDropdownItem();
512
+ return;
513
+ }
514
+ commitInput();
515
+ return;
516
+ }
517
+ // Backspace
518
+ if (key === '\x7F' || key === '\b') {
519
+ if (cursor > 0) {
520
+ cpBuf.splice(cursor - 1, 1);
521
+ syncBuffer();
522
+ cursor--;
523
+ if (dropdownMode === 'at') {
524
+ const partial = getAtPartial();
525
+ if (partial !== null) {
526
+ getFileCompletions(partial, cwd).then((completions) => {
527
+ if (completions.length > 0) {
528
+ atCompletions = completions;
529
+ dropdownVisible = true;
530
+ dropdownIdx = 0;
531
+ }
532
+ else {
533
+ dropdownVisible = false;
534
+ }
535
+ render();
536
+ });
537
+ return;
538
+ }
539
+ else {
540
+ dropdownVisible = false;
541
+ }
542
+ }
543
+ else if (!isSlashMode()) {
544
+ dropdownVisible = false;
545
+ }
546
+ else {
547
+ const items = getFilteredCommands();
548
+ if (items.length === 0)
549
+ dropdownVisible = false;
550
+ else if (dropdownIdx >= items.length)
551
+ dropdownIdx = items.length - 1;
552
+ }
553
+ render();
554
+ }
555
+ return;
556
+ }
557
+ // Tab
558
+ if (key === '\t') {
559
+ if (dropdownVisible) {
560
+ selectDropdownItem();
561
+ }
562
+ return;
563
+ }
564
+ // Printable input — ASCII, CJK, emoji, IME batch
565
+ if (!key.startsWith('\x1B') && key.charCodeAt(0) >= 0x20 && key.charCodeAt(0) !== 0x7F) {
566
+ let incoming = [...key];
567
+ // Normalize Windows line endings from paste: \r\n -> \n, then lone \r -> \n
568
+ const normalized = [];
569
+ for (let i = 0; i < incoming.length; i++) {
570
+ if (incoming[i] === '\r' && incoming[i + 1] === '\n') {
571
+ normalized.push('\n');
572
+ i++;
573
+ }
574
+ else if (incoming[i] === '\r') {
575
+ normalized.push('\n');
576
+ }
577
+ else {
578
+ normalized.push(incoming[i]);
579
+ }
580
+ }
581
+ incoming = normalized;
582
+ cpBuf.splice(cursor, 0, ...incoming);
583
+ syncBuffer();
584
+ cursor += incoming.length;
585
+ if (buffer === '/' && !dropdownVisible) {
586
+ showDropdown('slash');
587
+ }
588
+ else if (isSlashMode() && dropdownVisible && dropdownMode === 'slash') {
589
+ const items = getFilteredCommands();
590
+ if (items.length === 0)
591
+ dropdownVisible = false;
592
+ else if (dropdownIdx >= items.length)
593
+ dropdownIdx = 0;
594
+ render();
595
+ }
596
+ else if (!isSlashMode() && dropdownVisible && dropdownMode === 'slash') {
597
+ hideDropdown();
598
+ }
599
+ else {
600
+ // Check for @ completion trigger
601
+ const partial = getAtPartial();
602
+ if (partial !== null) {
603
+ getFileCompletions(partial, cwd).then((completions) => {
604
+ if (completions.length > 0) {
605
+ atCompletions = completions;
606
+ dropdownMode = 'at';
607
+ dropdownVisible = true;
608
+ dropdownIdx = 0;
609
+ }
610
+ else {
611
+ if (dropdownMode === 'at')
612
+ dropdownVisible = false;
613
+ }
614
+ render();
615
+ });
616
+ }
617
+ else {
618
+ if (dropdownMode === 'at')
619
+ dropdownVisible = false;
620
+ render();
621
+ }
622
+ }
623
+ return;
624
+ }
625
+ };
626
+ stdin.on('data', onData);
627
+ });
628
+ }
629
+ /**
630
+ * Show an interactive selector with all available slash commands
631
+ * when the user types just "/" and presses Enter.
632
+ */
633
+ async function showSlashCommandSelector(skills = []) {
634
+ const builtinItems = SLASH_COMMANDS.map((cmd) => ({
635
+ value: cmd.name,
636
+ label: cmd.name,
637
+ desc: cmd.desc,
638
+ kind: 'builtin',
639
+ }));
640
+ const skillItems = skills.map((s) => ({
641
+ value: '/' + s.name,
642
+ label: '/' + s.name,
643
+ desc: `${chalk.dim.gray('[skill]')} ${s.description ?? `(${s.source})`}`,
644
+ kind: 'skill',
645
+ }));
646
+ const items = [...builtinItems, ...skillItems];
647
+ const selected = await selectFromList(skillItems.length > 0
648
+ ? `Slash Commands ${chalk.gray('(built-in + ' + skillItems.length + ' skills)')}`
649
+ : 'Slash Commands', items);
650
+ if (selected) {
651
+ process.stdout.write(chalk.blue('> ') + selected + '\n');
652
+ }
653
+ return selected;
654
+ }
655
+ // ── Arrow-key list selector ───────────────────────────────────────────────────
656
+ function clearLines(n) {
657
+ if (n <= 0)
658
+ return;
659
+ process.stdout.write(`\x1B[${n}A\r\x1B[J`);
660
+ }
661
+ export function selectFromList(title, items, currentValue) {
662
+ return new Promise((resolve_p) => {
663
+ const { stdin, stdout } = process;
664
+ if (!stdin.isTTY) {
665
+ resolve_p(items[0]?.value ?? null);
666
+ return;
667
+ }
668
+ let idx = Math.max(0, items.findIndex((i) => i.value === currentValue));
669
+ let lastHeight = 0;
670
+ const buildLines = () => {
671
+ const out = [];
672
+ out.push(chalk.bold(' ' + title));
673
+ out.push('');
674
+ for (let i = 0; i < items.length; i++) {
675
+ const item = items[i];
676
+ const sel = i === idx;
677
+ const cur = sel ? chalk.blue(' ❯ ') : ' ';
678
+ const label = sel
679
+ ? (item.kind === 'builtin' ? chalk.bold.blue(item.label) : chalk.bold.white(item.label))
680
+ : (item.kind === 'builtin' ? chalk.blue(item.label) : chalk.white(item.label));
681
+ const desc = item.desc
682
+ ? chalk.gray(' ' + item.desc.slice(0, (stdout.columns || 80) - item.label.length - 12))
683
+ : '';
684
+ out.push(cur + label + desc);
685
+ }
686
+ out.push('');
687
+ out.push(chalk.gray(' ↑↓ navigate Enter select Esc cancel'));
688
+ return out;
689
+ };
690
+ const render = () => {
691
+ clearLines(lastHeight);
692
+ const outputLines = buildLines();
693
+ stdout.write(outputLines.join('\r\n'));
694
+ lastHeight = outputLines.length - 1;
695
+ };
696
+ const cleanup = () => {
697
+ stdin.setRawMode(false);
698
+ stdin.pause();
699
+ stdin.removeListener('data', onData);
700
+ stdout.write('\n');
701
+ };
702
+ stdin.setRawMode(true);
703
+ stdin.resume();
704
+ stdin.setEncoding('utf8');
705
+ stdout.write('\n');
706
+ lastHeight = 0;
707
+ render();
708
+ const onData = (key) => {
709
+ // Ctrl+C inside a selector cancels the selection (same as Esc).
710
+ // We do NOT exit the process here — the caller decides what to do with null.
711
+ // Rejecting with AbortError would require every call site to add a catch,
712
+ // which is easy to forget and causes unhandled-rejection crashes.
713
+ if (key === '\x03') {
714
+ cleanup();
715
+ resolve_p(null);
716
+ return;
717
+ }
718
+ if (key === '\x1B') {
719
+ cleanup();
720
+ resolve_p(null);
721
+ return;
722
+ }
723
+ if (key === '\x1B[A') {
724
+ idx = (idx - 1 + items.length) % items.length;
725
+ render();
726
+ return;
727
+ }
728
+ if (key === '\x1B[B') {
729
+ idx = (idx + 1) % items.length;
730
+ render();
731
+ return;
732
+ }
733
+ if (key === '\r' || key === '\n') {
734
+ cleanup();
735
+ resolve_p(items[idx].value);
736
+ return;
737
+ }
738
+ };
739
+ stdin.on('data', onData);
740
+ });
741
+ }
742
+ //# sourceMappingURL=input.js.map