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.
- package/README.md +351 -0
- package/dist/cli/at-resolver.d.ts +10 -0
- package/dist/cli/at-resolver.js +138 -0
- package/dist/cli/at-resolver.js.map +1 -0
- package/dist/cli/banner.d.ts +1 -0
- package/dist/cli/banner.js +111 -0
- package/dist/cli/banner.js.map +1 -0
- package/dist/cli/commands.d.ts +25 -0
- package/dist/cli/commands.js +799 -0
- package/dist/cli/commands.js.map +1 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +142 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/input.d.ts +14 -0
- package/dist/cli/input.js +742 -0
- package/dist/cli/input.js.map +1 -0
- package/dist/cli/repl.d.ts +2 -0
- package/dist/cli/repl.js +217 -0
- package/dist/cli/repl.js.map +1 -0
- package/dist/cli/setup.d.ts +7 -0
- package/dist/cli/setup.js +82 -0
- package/dist/cli/setup.js.map +1 -0
- package/dist/cli/skills.d.ts +28 -0
- package/dist/cli/skills.js +299 -0
- package/dist/cli/skills.js.map +1 -0
- package/dist/cli/update.d.ts +7 -0
- package/dist/cli/update.js +135 -0
- package/dist/cli/update.js.map +1 -0
- package/dist/config.d.ts +19 -0
- package/dist/config.js +93 -0
- package/dist/config.js.map +1 -0
- package/dist/core/agent/loop.d.ts +17 -0
- package/dist/core/agent/loop.js +353 -0
- package/dist/core/agent/loop.js.map +1 -0
- package/dist/core/context/manager.d.ts +26 -0
- package/dist/core/context/manager.js +98 -0
- package/dist/core/context/manager.js.map +1 -0
- package/dist/core/context/project-guide.d.ts +1 -0
- package/dist/core/context/project-guide.js +17 -0
- package/dist/core/context/project-guide.js.map +1 -0
- package/dist/core/tools/edit_file.d.ts +2 -0
- package/dist/core/tools/edit_file.js +54 -0
- package/dist/core/tools/edit_file.js.map +1 -0
- package/dist/core/tools/execute_shell.d.ts +2 -0
- package/dist/core/tools/execute_shell.js +67 -0
- package/dist/core/tools/execute_shell.js.map +1 -0
- package/dist/core/tools/executor.d.ts +3 -0
- package/dist/core/tools/executor.js +86 -0
- package/dist/core/tools/executor.js.map +1 -0
- package/dist/core/tools/list_directory.d.ts +2 -0
- package/dist/core/tools/list_directory.js +74 -0
- package/dist/core/tools/list_directory.js.map +1 -0
- package/dist/core/tools/read_file.d.ts +2 -0
- package/dist/core/tools/read_file.js +40 -0
- package/dist/core/tools/read_file.js.map +1 -0
- package/dist/core/tools/registry.d.ts +4 -0
- package/dist/core/tools/registry.js +24 -0
- package/dist/core/tools/registry.js.map +1 -0
- package/dist/core/tools/search_files.d.ts +2 -0
- package/dist/core/tools/search_files.js +88 -0
- package/dist/core/tools/search_files.js.map +1 -0
- package/dist/core/tools/write_file.d.ts +2 -0
- package/dist/core/tools/write_file.js +43 -0
- package/dist/core/tools/write_file.js.map +1 -0
- package/dist/providers/deepseek/client.d.ts +8 -0
- package/dist/providers/deepseek/client.js +27 -0
- package/dist/providers/deepseek/client.js.map +1 -0
- package/dist/providers/deepseek/provider.d.ts +8 -0
- package/dist/providers/deepseek/provider.js +165 -0
- package/dist/providers/deepseek/provider.js.map +1 -0
- package/dist/types.d.ts +111 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/billing.d.ts +40 -0
- package/dist/utils/billing.js +165 -0
- package/dist/utils/billing.js.map +1 -0
- package/dist/utils/confirm.d.ts +3 -0
- package/dist/utils/confirm.js +242 -0
- package/dist/utils/confirm.js.map +1 -0
- package/dist/utils/display.d.ts +10 -0
- package/dist/utils/display.js +121 -0
- package/dist/utils/display.js.map +1 -0
- package/dist/utils/interrupt.d.ts +5 -0
- package/dist/utils/interrupt.js +13 -0
- package/dist/utils/interrupt.js.map +1 -0
- package/dist/utils/markdown.d.ts +18 -0
- package/dist/utils/markdown.js +223 -0
- package/dist/utils/markdown.js.map +1 -0
- 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
|