@hncode/hncode 0.3.0 → 0.3.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.
- package/README_CN.md +0 -1
- package/package.json +1 -1
- package/src/agent.js +5 -0
- package/src/config.js +63 -10
- package/src/index.js +24 -7
- package/src/llm.js +60 -11
- package/src/plugin.js +13 -0
- package/src/session.js +257 -24
- package/src/tools/agent-swarm.js +6 -1
- package/src/tools/agent.js +8 -1
- package/src/tools/bash.js +7 -1
- package/src/tools/edit.js +12 -12
- package/src/tools/file-lines.js +41 -8
- package/src/tools/glob.js +9 -4
- package/src/tools/grep.js +16 -3
- package/src/tools/index.js +13 -2
- package/src/tools/matchers.js +75 -71
- package/src/tools/read-media-file.js +17 -3
- package/src/tools/read.js +2 -0
- package/src/tools/tasks.js +2 -1
- package/src/tools/utils.js +11 -1
- package/src/tui.js +209 -47
- package/src/update.js +2 -14
- package/src/version.js +20 -0
package/src/tools/matchers.js
CHANGED
|
@@ -1,71 +1,75 @@
|
|
|
1
|
-
// Shared glob matching used by Glob, Grep and the .gitignore matcher.
|
|
2
|
-
// Semantics: `*` matches within a path segment; `**` (optionally followed by `/`)
|
|
3
|
-
// matches across segments; `?` matches one non-separator char; `[class]` char
|
|
4
|
-
// classes; `{a,b}` brace expansion. `/` is the path separator used for matching.
|
|
5
|
-
|
|
6
|
-
export function expandBraces(str) {
|
|
7
|
-
const i = str.indexOf('{');
|
|
8
|
-
if (i === -1) return [str];
|
|
9
|
-
let depth = 1, j = i + 1, parts = [], part = '', ok = false;
|
|
10
|
-
while (j < str.length && depth > 0) {
|
|
11
|
-
const c = str[j];
|
|
12
|
-
if (c === '{') { depth++; part += c; }
|
|
13
|
-
else if (c === '}') { depth--; if (depth === 0) { parts.push(part); ok = true; break; } else part += c; }
|
|
14
|
-
else if (c === ',' && depth === 1) { parts.push(part); part = ''; }
|
|
15
|
-
else part += c;
|
|
16
|
-
j++;
|
|
17
|
-
}
|
|
18
|
-
if (!ok) return [str];
|
|
19
|
-
const out = [];
|
|
20
|
-
const before = str.slice(0, i), after = str.slice(j + 1);
|
|
21
|
-
for (const p of parts) {
|
|
22
|
-
for (const b of expandBraces(before)) for (const pp of expandBraces(p)) for (const a of expandBraces(after)) out.push(b + pp + a);
|
|
23
|
-
}
|
|
24
|
-
return out;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function escapeRegex(c) {
|
|
28
|
-
return /[.*+?^${}()|[\]\\]/.test(c) ? '\\' + c : c;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
1
|
+
// Shared glob matching used by Glob, Grep and the .gitignore matcher.
|
|
2
|
+
// Semantics: `*` matches within a path segment; `**` (optionally followed by `/`)
|
|
3
|
+
// matches across segments; `?` matches one non-separator char; `[class]` char
|
|
4
|
+
// classes; `{a,b}` brace expansion. `/` is the path separator used for matching.
|
|
5
|
+
|
|
6
|
+
export function expandBraces(str) {
|
|
7
|
+
const i = str.indexOf('{');
|
|
8
|
+
if (i === -1) return [str];
|
|
9
|
+
let depth = 1, j = i + 1, parts = [], part = '', ok = false;
|
|
10
|
+
while (j < str.length && depth > 0) {
|
|
11
|
+
const c = str[j];
|
|
12
|
+
if (c === '{') { depth++; part += c; }
|
|
13
|
+
else if (c === '}') { depth--; if (depth === 0) { parts.push(part); ok = true; break; } else part += c; }
|
|
14
|
+
else if (c === ',' && depth === 1) { parts.push(part); part = ''; }
|
|
15
|
+
else part += c;
|
|
16
|
+
j++;
|
|
17
|
+
}
|
|
18
|
+
if (!ok) return [str];
|
|
19
|
+
const out = [];
|
|
20
|
+
const before = str.slice(0, i), after = str.slice(j + 1);
|
|
21
|
+
for (const p of parts) {
|
|
22
|
+
for (const b of expandBraces(before)) for (const pp of expandBraces(p)) for (const a of expandBraces(after)) out.push(b + pp + a);
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function escapeRegex(c) {
|
|
28
|
+
return /[.*+?^${}()|[\]\\]/.test(c) ? '\\' + c : c;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Regex SOURCE (anchored, with ^ and $) for a single plain pattern.
|
|
32
|
+
export function globToRegexSource(pattern) {
|
|
33
|
+
let src = '^';
|
|
34
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
35
|
+
const c = pattern[i];
|
|
36
|
+
if (c === '*' && pattern[i + 1] === '*') {
|
|
37
|
+
src += '[\\s\\S]*'; i++;
|
|
38
|
+
if (pattern[i + 1] === '/') i++;
|
|
39
|
+
} else if (c === '*') {
|
|
40
|
+
src += '[^/]*';
|
|
41
|
+
} else if (c === '?') {
|
|
42
|
+
src += '[^/]';
|
|
43
|
+
} else if (c === '[') {
|
|
44
|
+
let j = i + 1, negated = false;
|
|
45
|
+
if (pattern[j] === '!' || pattern[j] === '^') { negated = true; j++; }
|
|
46
|
+
let body = '';
|
|
47
|
+
while (j < pattern.length && pattern[j] !== ']') body += pattern[j++];
|
|
48
|
+
if (pattern[j] === ']') {
|
|
49
|
+
if (negated) body = '/' + body;
|
|
50
|
+
src += negated ? '[^' + body + ']' : '[' + body + ']';
|
|
51
|
+
i = j;
|
|
52
|
+
} else {
|
|
53
|
+
src += escapeRegex(c);
|
|
54
|
+
}
|
|
55
|
+
} else {
|
|
56
|
+
src += escapeRegex(c);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return src + '$';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Match the whole string against a single pattern (anchored, like globToRegex).
|
|
63
|
+
// The single-alternative path wraps globToRegexSource directly; the multi
|
|
64
|
+
// (brace) path joins every alternative in braces so a `{a,b}` pattern cannot
|
|
65
|
+
// match a substring that only matches one branch.
|
|
66
|
+
export function globRegex(pattern) {
|
|
67
|
+
const alts = expandBraces(pattern);
|
|
68
|
+
if (alts.length === 1) return new RegExp(globToRegexSource(alts[0]));
|
|
69
|
+
return new RegExp('^(?:' + alts.map((a) => globToRegexSource(a)).join('|') + ')$');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// True if `s` matches `pattern` (full match on `s`).
|
|
73
|
+
export function matchGlob(pattern, s) {
|
|
74
|
+
return globRegex(pattern).test(s);
|
|
75
|
+
}
|
|
@@ -36,15 +36,29 @@ export const spec = {
|
|
|
36
36
|
if (stat.size > MAX_IMAGE_SIZE) {
|
|
37
37
|
return `Error: image too large (${(stat.size / 1024 / 1024).toFixed(1)}MB, max 20MB): ${args.path}`;
|
|
38
38
|
}
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
// A tool result is plain TEXT that goes back into the model's context on
|
|
40
|
+
// the next request. A 20MB image becomes ~27MB of base64, which blows the
|
|
41
|
+
// context window and (for OpenAI/Anthropic chat completions) is not a
|
|
42
|
+
// recognised image payload anyway — the model only sees a wall of text.
|
|
43
|
+
// Return a REFERENCE the caller can act on instead of the bytes.
|
|
41
44
|
const mimeType = ext === '.jpg' || ext === '.jpeg' ? 'image/jpeg'
|
|
42
45
|
: ext === '.png' ? 'image/png'
|
|
43
46
|
: ext === '.gif' ? 'image/gif'
|
|
44
47
|
: ext === '.webp' ? 'image/webp'
|
|
45
48
|
: ext === '.bmp' ? 'image/bmp'
|
|
46
49
|
: 'image/unknown';
|
|
47
|
-
|
|
50
|
+
const sizeKB = Math.max(1, Math.round(stat.size / 1024));
|
|
51
|
+
const abs = p.replace(/\\/g, '/');
|
|
52
|
+
// Keep a small inline preview ONLY for tiny files (< 64KB), where the
|
|
53
|
+
// base64 is at most ~87KB and genuinely usable by a multimodal client.
|
|
54
|
+
if (stat.size <= 64 * 1024) {
|
|
55
|
+
const base64 = fs.readFileSync(p).toString('base64');
|
|
56
|
+
return `data:${mimeType};base64,${base64}`;
|
|
57
|
+
}
|
|
58
|
+
return `[Image: ${mimeType}] ${args.path} (${sizeKB}KB, ${stat.width || '?'}x${stat.height || '?'} unknown)\n`
|
|
59
|
+
+ `Path: ${abs}\n`
|
|
60
|
+
+ `This image is too large to inline as base64 (would be ~${Math.round(stat.size * 4 / 3 / 1024)}KB of text). `
|
|
61
|
+
+ `To view it, use a client that renders image paths, or resize it first.`;
|
|
48
62
|
}
|
|
49
63
|
|
|
50
64
|
const fileType = VIDEO_EXT.includes(ext) ? 'video' : AUDIO_EXT.includes(ext) ? 'audio' : 'unknown';
|
package/src/tools/read.js
CHANGED
|
@@ -22,6 +22,8 @@ function hashLines(lines, start, end) {
|
|
|
22
22
|
const slice = (lines.slice(start - 1, end) || []).join('\n');
|
|
23
23
|
return hashStr(slice);
|
|
24
24
|
}
|
|
25
|
+
// Public alias so edit.js (and the line-range path) share ONE implementation.
|
|
26
|
+
export const hashRegion = hashLines;
|
|
25
27
|
|
|
26
28
|
export const spec = {
|
|
27
29
|
name: 'Read',
|
package/src/tools/tasks.js
CHANGED
|
@@ -43,7 +43,8 @@ export const TaskOutputSpec = {
|
|
|
43
43
|
const capped = args.head_limit ? lines.slice(0, args.head_limit) : lines;
|
|
44
44
|
const tail = lines.length > capped.length;
|
|
45
45
|
const label = STATUS_LABEL[t.status] || t.status;
|
|
46
|
-
|
|
46
|
+
const head = `#${t.taskId} [${label}] (pid ${t.pid || '-'})`;
|
|
47
|
+
let out = capped.length ? `${head}\n${capped.join('\n')}` : `${head}\n(no output yet)`;
|
|
47
48
|
if (tail) out += `\n... [${lines.length - capped.length} more lines; use head_limit]`;
|
|
48
49
|
return out;
|
|
49
50
|
},
|
package/src/tools/utils.js
CHANGED
|
@@ -13,7 +13,17 @@ export function truncateBuf(buf) {
|
|
|
13
13
|
}
|
|
14
14
|
const s = String(buf);
|
|
15
15
|
if (s.length <= MAX_OUTPUT_BYTES) return s;
|
|
16
|
-
|
|
16
|
+
// The cap is a BYTE budget, but `s.length` is UTF-16 code units. Slicing by
|
|
17
|
+
// code units both under-counts non-ASCII (a 3-byte CJK char costs 1 unit)
|
|
18
|
+
// and can split a surrogate pair, emitting a lone half. Truncate by BYTES
|
|
19
|
+
// and back off to the nearest code-point boundary.
|
|
20
|
+
const bytes = Buffer.byteLength(s, 'utf8');
|
|
21
|
+
if (bytes <= MAX_OUTPUT_BYTES) return s;
|
|
22
|
+
const cut = Buffer.from(s, 'utf8').subarray(0, MAX_OUTPUT_BYTES).toString('utf8');
|
|
23
|
+
// A trailing lone surrogate (from a split pair) is dropped by decoding; the
|
|
24
|
+
// replacement char U+FFFD at the tail is also stripped so the marker is clean.
|
|
25
|
+
const safe = cut.endsWith('\uFFFD') ? cut.slice(0, -1) : cut;
|
|
26
|
+
return safe + `\n...[truncated ${bytes - MAX_OUTPUT_BYTES} bytes]`;
|
|
17
27
|
}
|
|
18
28
|
|
|
19
29
|
// Expand ~ and map Git-Bash /c/x -> C:\x style paths, then resolve against cwd.
|
package/src/tui.js
CHANGED
|
@@ -34,9 +34,10 @@ import { renderTasksBrowser, handleTasksBrowserKey, visibleTasks } from './tasks
|
|
|
34
34
|
import { renderTaskOutputViewer, handleViewerKey, makeViewerState } from './task-output-viewer.js';
|
|
35
35
|
import { renderSwarmProgress } from './swarm-progress.js';
|
|
36
36
|
import { sortedTasks, getTask, settleTask, STATUS_LABEL } from './agent-task.js';
|
|
37
|
+
import { localVersion } from './version.js';
|
|
37
38
|
|
|
38
39
|
const ESC = '\x1b';
|
|
39
|
-
const VERSION =
|
|
40
|
+
const VERSION = localVersion();
|
|
40
41
|
|
|
41
42
|
// ---- Slash command registry ----
|
|
42
43
|
// Mirrors kimi-code-cli's BUILTIN_SLASH_COMMANDS (registry.ts): name, aliases,
|
|
@@ -2412,7 +2413,13 @@ export function composeFrame(state, cols, rows) {
|
|
|
2412
2413
|
// topTrim below), which eats the box's own top border and header and leaves a
|
|
2413
2414
|
// dangling `│`. Cap it so the box plus the composer always fit, and let the
|
|
2414
2415
|
// transcript shrink to zero instead.
|
|
2415
|
-
|
|
2416
|
+
// The `@file` list occupies the same rows as the `/` menu, so it counts against
|
|
2417
|
+
// the same budget — otherwise the frame overflows and the top trim eats the
|
|
2418
|
+
// transcript. Only one of the two can be open at a time (see refreshMention).
|
|
2419
|
+
const mentionItems = (state.mentionOpen && state.mentionList.length && !dialog)
|
|
2420
|
+
? Math.min(state.mentionList.length, MAX_MENU) + 1
|
|
2421
|
+
: 0;
|
|
2422
|
+
const chromeH = todoH + queueH + workingH + composerBoxH + menuItems + mentionItems + STATUS_H + confirmH + CTX_H;
|
|
2416
2423
|
// NOTE the extra `- 1`: `bodyH` below is clamped to a MINIMUM of 1 row, so that
|
|
2417
2424
|
// row has to be reserved here too. Without it the total came out one over and
|
|
2418
2425
|
// the top trim ate the box's `╭` border.
|
|
@@ -3109,6 +3116,31 @@ export function composeFrame(state, cols, rows) {
|
|
|
3109
3116
|
lines.push(col('│' + fitAnsi(col(`(${sel}/${totalMatches})`, C.gray), insideW) + '│', C.border));
|
|
3110
3117
|
}
|
|
3111
3118
|
|
|
3119
|
+
// Inline `@file` candidate list — the same widget as the `/` menu above, so the
|
|
3120
|
+
// two read as one mechanism. Shows the path, dims the directory prefix of a
|
|
3121
|
+
// nested entry so the BASENAME is what your eye lands on.
|
|
3122
|
+
if (state.mentionOpen && state.mentionList.length && !dialog && !state.menuOpen) {
|
|
3123
|
+
const totalMatches = state.mentionList.length;
|
|
3124
|
+
const sel = state.mentionSel + 1;
|
|
3125
|
+
const off = state.mentionOffset || 0;
|
|
3126
|
+
const shown = state.mentionList.slice(off, off + MAX_MENU);
|
|
3127
|
+
shown.forEach((it, j) => {
|
|
3128
|
+
const selected = off + j === state.mentionSel;
|
|
3129
|
+
const mark = selected ? col('❯ ', C.cyan) : ' ';
|
|
3130
|
+
const rel = String(it.rel || it.label || '');
|
|
3131
|
+
const cut = rel.lastIndexOf('/') + 1;
|
|
3132
|
+
const dirPart = cut > 0 ? rel.slice(0, cut) : '';
|
|
3133
|
+
const basePart = rel.slice(cut);
|
|
3134
|
+
const label = (dirPart ? col(dirPart, C.gray) : '')
|
|
3135
|
+
+ col(basePart, selected ? (C.cyan + C.bold) : C.gray);
|
|
3136
|
+
const kind = it.isDir ? col(' dir', C.gray) : '';
|
|
3137
|
+
const pad = Math.max(2, 40 - visualCol(rel) - visualCol(kind));
|
|
3138
|
+
addHit(lines.length, 1, insideW, { kind: 'mentionItem', index: off + j });
|
|
3139
|
+
lines.push(col('│' + fitAnsi(mark + label + kind + ' '.repeat(pad), insideW) + '│', C.border));
|
|
3140
|
+
});
|
|
3141
|
+
lines.push(col('│' + fitAnsi(col(`(${sel}/${totalMatches}) Enter or Tab to insert · Esc to dismiss`, C.gray), insideW) + '│', C.border));
|
|
3142
|
+
}
|
|
3143
|
+
|
|
3112
3144
|
const cwd = state.cwd || '';
|
|
3113
3145
|
const thinking = state.reasoning
|
|
3114
3146
|
? (state.effort && state.effort !== 'on' ? ` thinking: ${state.effort}` : ' thinking')
|
|
@@ -3216,7 +3248,15 @@ export function composeFrame(state, cols, rows) {
|
|
|
3216
3248
|
} else {
|
|
3217
3249
|
const menuH = (state.menuOpen && state.menuList.length && !dialog)
|
|
3218
3250
|
? Math.min(state.menuList.length, MAX_MENU) + 1 : 0;
|
|
3219
|
-
|
|
3251
|
+
// The `@file` list occupies the same rows as the `/` menu, and it pushes the
|
|
3252
|
+
// composer UP by exactly that many. This formula derives the caret's screen row
|
|
3253
|
+
// from the chrome BELOW the composer, so leaving the list out of `bottomChrome`
|
|
3254
|
+
// kept the caret at its old row while the input box moved up — the caret sat
|
|
3255
|
+
// several rows below the text it was editing. Must mirror `mentionItems` in the
|
|
3256
|
+
// layout budget above.
|
|
3257
|
+
const mentionH = (state.mentionOpen && state.mentionList.length && !dialog)
|
|
3258
|
+
? Math.min(state.mentionList.length, MAX_MENU) + 1 : 0;
|
|
3259
|
+
const bottomChrome = STATUS_H + (state.confirmExit ? 1 : 0) + CTX_H + menuH + mentionH;
|
|
3220
3260
|
const caretScreenRow = h - bottomChrome - 2 - (composer.rows.length - 1 - composer.caretRow);
|
|
3221
3261
|
cursor = {
|
|
3222
3262
|
row: Math.max(0, Math.min(caretScreenRow, h - 1)),
|
|
@@ -3384,6 +3424,13 @@ export function makeState({ cfg, session, opts }) {
|
|
|
3384
3424
|
menuList: [],
|
|
3385
3425
|
menuSel: 0,
|
|
3386
3426
|
menuOffset: 0,
|
|
3427
|
+
// Inline `@file` candidate list — the same widget as the `/` menu, rendered in
|
|
3428
|
+
// the same place. Kept separate from menuList so the two can never both claim
|
|
3429
|
+
// the ↑↓ keys.
|
|
3430
|
+
mentionOpen: false,
|
|
3431
|
+
mentionList: [],
|
|
3432
|
+
mentionSel: 0,
|
|
3433
|
+
mentionOffset: 0,
|
|
3387
3434
|
objective: '',
|
|
3388
3435
|
goalPaused: false,
|
|
3389
3436
|
plan: !!(session && session.plan),
|
|
@@ -5168,6 +5215,121 @@ export async function startTUI(opts) {
|
|
|
5168
5215
|
renderFrame();
|
|
5169
5216
|
}
|
|
5170
5217
|
|
|
5218
|
+
// ---- @ file completion ----------------------------------------------------
|
|
5219
|
+
// Typing `@` shows an INLINE candidate list above the composer — the same widget
|
|
5220
|
+
// `/` uses (state.mention*) — rather than taking over the screen. It used to be a
|
|
5221
|
+
// silent rewrite of the input to the FIRST match of a single-directory listing:
|
|
5222
|
+
// you could not see the candidates, could not descend into a subdirectory
|
|
5223
|
+
// (`@src/` matched nothing), and hidden files were dropped.
|
|
5224
|
+
//
|
|
5225
|
+
// `walk` is bounded (depth + entry count) because it runs on a keystroke.
|
|
5226
|
+
function collectWorkspaceFiles(root, { limit = 4000, maxDepth = 8 } = {}) {
|
|
5227
|
+
const out = [];
|
|
5228
|
+
const skip = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'target', '__pycache__']);
|
|
5229
|
+
const walk = (dir, rel, depth) => {
|
|
5230
|
+
if (out.length >= limit || depth > maxDepth) return;
|
|
5231
|
+
let entries;
|
|
5232
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
5233
|
+
// Deterministic order: directories first, then files, each alphabetical, so
|
|
5234
|
+
// the list does not jump around between keystrokes.
|
|
5235
|
+
entries.sort((a, b) => (b.isDirectory() - a.isDirectory()) || a.name.localeCompare(b.name));
|
|
5236
|
+
for (const e of entries) {
|
|
5237
|
+
if (out.length >= limit) return;
|
|
5238
|
+
if (skip.has(e.name)) continue;
|
|
5239
|
+
const relPath = rel ? rel + '/' + e.name : e.name;
|
|
5240
|
+
if (e.isDirectory()) {
|
|
5241
|
+
out.push({ label: relPath + '/', isDir: true, rel: relPath + '/' });
|
|
5242
|
+
walk(path.join(dir, e.name), relPath, depth + 1);
|
|
5243
|
+
} else {
|
|
5244
|
+
out.push({ label: relPath, isDir: false, rel: relPath });
|
|
5245
|
+
}
|
|
5246
|
+
}
|
|
5247
|
+
};
|
|
5248
|
+
walk(root, '', 0);
|
|
5249
|
+
return out;
|
|
5250
|
+
}
|
|
5251
|
+
|
|
5252
|
+
// Everything after the last `@` that has no whitespace in it is the query.
|
|
5253
|
+
function atQueryAt(input) {
|
|
5254
|
+
const s = String(input || '');
|
|
5255
|
+
const at = s.lastIndexOf('@');
|
|
5256
|
+
if (at < 0) return null;
|
|
5257
|
+
const after = s.slice(at + 1);
|
|
5258
|
+
if (/\s/.test(after)) return null; // an email-ish "@foo bar" is not a path
|
|
5259
|
+
// Only treat it as a mention when `@` starts a word (start of input or after space).
|
|
5260
|
+
if (at > 0 && !/\s/.test(s[at - 1])) return null;
|
|
5261
|
+
return { at, query: after };
|
|
5262
|
+
}
|
|
5263
|
+
|
|
5264
|
+
// The file index is CACHED per workspace root. Walking a real project on every
|
|
5265
|
+
// keystroke measured 82 ms on a 3k-entry tree — a visible stall while typing —
|
|
5266
|
+
// and a monorepo with node_modules is far worse. The cache is refreshed when it
|
|
5267
|
+
// is older than MENTION_CACHE_MS or the root changes, so the list still tracks
|
|
5268
|
+
// files created during the session without paying the walk on every character.
|
|
5269
|
+
let mentionCache = null; // { root, at, files }
|
|
5270
|
+
const MENTION_CACHE_MS = 5000;
|
|
5271
|
+
|
|
5272
|
+
function mentionFiles(root) {
|
|
5273
|
+
const now = Date.now();
|
|
5274
|
+
if (mentionCache && mentionCache.root === root && now - mentionCache.at < MENTION_CACHE_MS) {
|
|
5275
|
+
return mentionCache.files;
|
|
5276
|
+
}
|
|
5277
|
+
const files = collectWorkspaceFiles(root);
|
|
5278
|
+
mentionCache = { root, at: now, files };
|
|
5279
|
+
return files;
|
|
5280
|
+
}
|
|
5281
|
+
|
|
5282
|
+
// Refresh the inline @ list from the current input. Called on every keystroke
|
|
5283
|
+
// that touches the mention, and on Tab. Closes the list when the caret leaves the
|
|
5284
|
+
// mention (e.g. a space was typed after it).
|
|
5285
|
+
function refreshMention() {
|
|
5286
|
+
const hit = atQueryAt(state.input || '');
|
|
5287
|
+
if (!hit) {
|
|
5288
|
+
state.mentionOpen = false;
|
|
5289
|
+
state.mentionList = [];
|
|
5290
|
+
state.mentionSel = 0;
|
|
5291
|
+
state.mentionOffset = 0;
|
|
5292
|
+
return;
|
|
5293
|
+
}
|
|
5294
|
+
const root = state.cwd || state.workspace || process.cwd();
|
|
5295
|
+
const q = hit.query.toLowerCase();
|
|
5296
|
+
const all = mentionFiles(root);
|
|
5297
|
+
// Precompute the lowercase label + basename once per entry instead of inside
|
|
5298
|
+
// the comparator: they were recomputed on every comparison of every sort.
|
|
5299
|
+
const ranked = [];
|
|
5300
|
+
for (const f of all) {
|
|
5301
|
+
const label = f.label.toLowerCase();
|
|
5302
|
+
if (q && !label.includes(q)) continue;
|
|
5303
|
+
const base = label.slice(label.lastIndexOf('/') + 1);
|
|
5304
|
+
ranked.push({ f, nameHit: q && base.startsWith(q) ? 0 : 1 });
|
|
5305
|
+
}
|
|
5306
|
+
ranked.sort((a, b) => {
|
|
5307
|
+
if (a.nameHit !== b.nameHit) return a.nameHit - b.nameHit;
|
|
5308
|
+
if (a.f.isDir !== b.f.isDir) return a.f.isDir ? -1 : 1;
|
|
5309
|
+
return a.f.label.length - b.f.label.length || (a.f.label < b.f.label ? -1 : a.f.label > b.f.label ? 1 : 0);
|
|
5310
|
+
});
|
|
5311
|
+
state.mentionList = ranked.slice(0, 200).map((r) => r.f);
|
|
5312
|
+
state.mentionSel = Math.min(state.mentionSel || 0, Math.max(0, state.mentionList.length - 1));
|
|
5313
|
+
// Hitting Tab again after the list closed should start from the top.
|
|
5314
|
+
state.mentionOffset = 0;
|
|
5315
|
+
state.mentionOpen = state.mentionList.length > 0;
|
|
5316
|
+
}
|
|
5317
|
+
|
|
5318
|
+
// Accept the highlighted candidate: replace the whole `@query` token.
|
|
5319
|
+
function acceptMention() {
|
|
5320
|
+
const it = (state.mentionList || [])[state.mentionSel || 0];
|
|
5321
|
+
if (!it) return false;
|
|
5322
|
+
const hit = atQueryAt(state.input || '');
|
|
5323
|
+
if (!hit) return false;
|
|
5324
|
+
state.input = String(state.input || '').slice(0, hit.at) + '@' + it.rel;
|
|
5325
|
+
state.caret = state.input.length;
|
|
5326
|
+
state.mentionOpen = false;
|
|
5327
|
+
state.mentionList = [];
|
|
5328
|
+
state.mentionSel = 0;
|
|
5329
|
+
renderFrame();
|
|
5330
|
+
return true;
|
|
5331
|
+
}
|
|
5332
|
+
|
|
5171
5333
|
function openForm(spec) {
|
|
5172
5334
|
const fields = (spec.fields || []).map((f) => ({
|
|
5173
5335
|
key: f.key, label: f.label || f.key || '', value: f.value || '',
|
|
@@ -6538,6 +6700,8 @@ export async function startTUI(opts) {
|
|
|
6538
6700
|
if (t.key === 'escape') {
|
|
6539
6701
|
state.menuOpen = false; state.menuList = []; state.menuSel = 0;
|
|
6540
6702
|
state.menuOffset = 0;
|
|
6703
|
+
state.mentionOpen = false; state.mentionList = []; state.mentionSel = 0;
|
|
6704
|
+
state.mentionOffset = 0;
|
|
6541
6705
|
state.input = ''; state.caret = 0;
|
|
6542
6706
|
state.pastes.clear(); state.pasteCounter = 0;
|
|
6543
6707
|
state.composerSel = null;
|
|
@@ -6554,50 +6718,28 @@ export async function startTUI(opts) {
|
|
|
6554
6718
|
return;
|
|
6555
6719
|
}
|
|
6556
6720
|
}
|
|
6557
|
-
|
|
6558
|
-
//
|
|
6559
|
-
if (
|
|
6560
|
-
const
|
|
6561
|
-
|
|
6562
|
-
|
|
6563
|
-
|
|
6564
|
-
|
|
6565
|
-
|
|
6566
|
-
|
|
6567
|
-
const cwd = state.cwd || state.workspace || process.cwd();
|
|
6568
|
-
let items = [];
|
|
6569
|
-
try {
|
|
6570
|
-
const entries = fs.readdirSync(cwd, { withFileTypes: true });
|
|
6571
|
-
for (const entry of entries) {
|
|
6572
|
-
const name = entry.name;
|
|
6573
|
-
// Skip hidden files by default
|
|
6574
|
-
if (name.startsWith('.')) continue;
|
|
6575
|
-
|
|
6576
|
-
const fullPath = path.join(cwd, name);
|
|
6577
|
-
if (entry.isDirectory()) {
|
|
6578
|
-
items.push({ label: name + '/', type: 'dir', path: fullPath });
|
|
6579
|
-
} else {
|
|
6580
|
-
items.push({ label: name, type: 'file', path: fullPath });
|
|
6581
|
-
}
|
|
6582
|
-
}
|
|
6583
|
-
} catch (e) {
|
|
6584
|
-
// If we can't read the directory, just ignore
|
|
6585
|
-
}
|
|
6586
|
-
|
|
6587
|
-
// Filter by suffix
|
|
6588
|
-
if (suffix) {
|
|
6589
|
-
const lowerSuffix = suffix.toLowerCase();
|
|
6590
|
-
items = items.filter((it) => it.label.toLowerCase().startsWith(lowerSuffix));
|
|
6591
|
-
}
|
|
6592
|
-
|
|
6593
|
-
if (items.length > 0) {
|
|
6594
|
-
// Auto-complete to first match
|
|
6595
|
-
const match = items[0];
|
|
6596
|
-
state.input = prefix + match.label;
|
|
6597
|
-
state.caret = state.input.length;
|
|
6721
|
+
// The inline `@file` list owns ↑↓/Tab/Enter/Esc while it is open, exactly like
|
|
6722
|
+
// the `/` menu above. Enter would otherwise SUBMIT the half-typed prompt.
|
|
6723
|
+
if (state.mentionOpen) {
|
|
6724
|
+
const n = state.mentionList.length;
|
|
6725
|
+
if (t.key === 'up' || t.key === 'down') {
|
|
6726
|
+
if (n) {
|
|
6727
|
+
state.mentionSel = (state.mentionSel + (t.key === 'down' ? 1 : n - 1)) % n;
|
|
6728
|
+
// Keep the selection inside the visible window.
|
|
6729
|
+
if (state.mentionSel < state.mentionOffset) state.mentionOffset = state.mentionSel;
|
|
6730
|
+
else if (state.mentionSel >= state.mentionOffset + MAX_MENU) state.mentionOffset = state.mentionSel - MAX_MENU + 1;
|
|
6598
6731
|
renderFrame();
|
|
6599
|
-
return;
|
|
6600
6732
|
}
|
|
6733
|
+
return;
|
|
6734
|
+
}
|
|
6735
|
+
if (t.key === 'tab' || t.key === 'enter') { acceptMention(); return; }
|
|
6736
|
+
if (t.key === 'escape') {
|
|
6737
|
+
state.mentionOpen = false;
|
|
6738
|
+
state.mentionList = [];
|
|
6739
|
+
state.mentionSel = 0;
|
|
6740
|
+
state.mentionOffset = 0;
|
|
6741
|
+
renderFrame();
|
|
6742
|
+
return;
|
|
6601
6743
|
}
|
|
6602
6744
|
}
|
|
6603
6745
|
if (t.key === 'up' || t.key === 'down') {
|
|
@@ -6789,7 +6931,11 @@ export async function startTUI(opts) {
|
|
|
6789
6931
|
state.caret--;
|
|
6790
6932
|
}
|
|
6791
6933
|
}
|
|
6792
|
-
|
|
6934
|
+
// Deleting can also change the mention (or delete the `@` itself), so the
|
|
6935
|
+
// inline list is refreshed the same way as on a printable key.
|
|
6936
|
+
refreshMention();
|
|
6937
|
+
if (!state.mentionOpen) refreshMenu(state);
|
|
6938
|
+
renderFrame(); state.composerSel = null; return;
|
|
6793
6939
|
}
|
|
6794
6940
|
if (t.key === 'delete') {
|
|
6795
6941
|
const sel = state.composerSel;
|
|
@@ -6819,10 +6965,16 @@ export async function startTUI(opts) {
|
|
|
6819
6965
|
state.input = state.input.slice(0, state.caret) + t.ch + state.input.slice(state.caret);
|
|
6820
6966
|
state.caret++;
|
|
6821
6967
|
state.composerSel = null;
|
|
6822
|
-
|
|
6968
|
+
// Any printable character can extend or start an `@mention`, so refresh the
|
|
6969
|
+
// inline list on every keystroke: typing `@` opens it, and typing after it
|
|
6970
|
+
// narrows it — the same live-feedback the `/` menu gives.
|
|
6971
|
+
refreshMention();
|
|
6972
|
+
if (!state.mentionOpen) refreshMenu(state);
|
|
6973
|
+
renderFrame();
|
|
6823
6974
|
}
|
|
6824
6975
|
}
|
|
6825
6976
|
|
|
6977
|
+
|
|
6826
6978
|
function rowStartOffsets(text, rowCount, insideW) {
|
|
6827
6979
|
const starts = [];
|
|
6828
6980
|
const prefix = ' > ';
|
|
@@ -6870,6 +7022,8 @@ export async function startTUI(opts) {
|
|
|
6870
7022
|
state.pastes.clear(); state.pasteCounter = 0;
|
|
6871
7023
|
}
|
|
6872
7024
|
state.menuOpen = false; state.menuList = []; state.menuSel = 0;
|
|
7025
|
+
state.mentionOpen = false; state.mentionList = []; state.mentionSel = 0;
|
|
7026
|
+
state.mentionOffset = 0;
|
|
6873
7027
|
state.historyIdx = -1;
|
|
6874
7028
|
if (!text) { renderFrame(); return; }
|
|
6875
7029
|
if (!state.running) {
|
|
@@ -7184,10 +7338,18 @@ export async function startTUI(opts) {
|
|
|
7184
7338
|
return;
|
|
7185
7339
|
}
|
|
7186
7340
|
if (e.type === 'tool_start') {
|
|
7341
|
+
// A tool call means the model finished this step's reasoning and is now
|
|
7342
|
+
// acting on it. Settle the still-pending thinking block so its spinner
|
|
7343
|
+
// stops instead of turning forever while the tool runs, and so the block
|
|
7344
|
+
// reads as "reasoned, then acted" rather than as one endless thought.
|
|
7345
|
+
// The NEXT `think` event opens a new block under the tool output.
|
|
7346
|
+
const t = [...state.chat].reverse().find((m) => m.role === 'thinking' && m.pending);
|
|
7347
|
+
if (t) t.pending = false;
|
|
7187
7348
|
const dup = state.chat.some((m) => m.role === 'tool' && m.pending && m.id === e.id);
|
|
7188
7349
|
if (!dup) {
|
|
7189
7350
|
state.chat.push({ role: 'tool', toolName: e.name, toolArgs: {}, pending: true, id: e.id, streamContent: '', startAt: Date.now() });
|
|
7190
7351
|
}
|
|
7352
|
+
} else if (e.type === 'tool_args') {
|
|
7191
7353
|
} else if (e.type === 'tool_args') {
|
|
7192
7354
|
const entry = [...state.chat].reverse().find((m) => m.role === 'tool' && m.pending && m.id === e.id);
|
|
7193
7355
|
if (entry) {
|
package/src/update.js
CHANGED
|
@@ -11,24 +11,12 @@
|
|
|
11
11
|
// timers / startup without awaiting.
|
|
12
12
|
|
|
13
13
|
import { spawn, execSync } from 'node:child_process';
|
|
14
|
-
import
|
|
15
|
-
import path from 'node:path';
|
|
16
|
-
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import { localVersion } from './version.js';
|
|
17
15
|
|
|
18
16
|
const PKG_NAME = '@hncode/hncode';
|
|
19
17
|
// How often the auto-check runs once we are past the startup check.
|
|
20
18
|
export const AUTO_UPDATE_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
|
|
21
|
-
|
|
22
|
-
// The version installed locally, read from this package's own package.json.
|
|
23
|
-
export function localVersion() {
|
|
24
|
-
try {
|
|
25
|
-
const here = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
|
|
26
|
-
const pkg = JSON.parse(fs.readFileSync(here, 'utf8'));
|
|
27
|
-
return pkg.version || '';
|
|
28
|
-
} catch {
|
|
29
|
-
return '';
|
|
30
|
-
}
|
|
31
|
-
}
|
|
19
|
+
export { localVersion };
|
|
32
20
|
|
|
33
21
|
// Latest version published to npm, or '' if the registry is unreachable /
|
|
34
22
|
// not installed / not a package.
|
package/src/version.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Single source of truth for the displayed version. The alternative was a
|
|
2
|
+
// hard-coded literal in index.js and tui.js that drifted apart from
|
|
3
|
+
// package.json (index said 0.1.0 while package.json was 0.3.0). Read the
|
|
4
|
+
// version from this package's own package.json instead, like update.js does.
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
export function localVersion() {
|
|
11
|
+
try {
|
|
12
|
+
const here = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
|
|
13
|
+
const pkg = JSON.parse(fs.readFileSync(here, 'utf8'));
|
|
14
|
+
return pkg.version || '';
|
|
15
|
+
} catch {
|
|
16
|
+
return '0.0.0';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default localVersion();
|