@hncode/hncode 0.3.0 → 0.3.2

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.
@@ -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
- export function globToRegexSource(pattern) {
32
- let src = '^';
33
- for (let i = 0; i < pattern.length; i++) {
34
- const c = pattern[i];
35
- if (c === '*' && pattern[i + 1] === '*') {
36
- src += '[\\s\\S]*'; i++;
37
- if (pattern[i + 1] === '/') i++;
38
- } else if (c === '*') {
39
- src += '[^/]*';
40
- } else if (c === '?') {
41
- src += '[^/]';
42
- } else if (c === '[') {
43
- let j = i + 1, negated = false;
44
- if (pattern[j] === '!' || pattern[j] === '^') { negated = true; j++; }
45
- let body = '';
46
- while (j < pattern.length && pattern[j] !== ']') body += pattern[j++];
47
- if (pattern[j] === ']') {
48
- if (negated) body = '/' + body;
49
- src += negated ? '[^' + body + ']' : '[' + body + ']';
50
- i = j;
51
- } else {
52
- src += escapeRegex(c);
53
- }
54
- } else {
55
- src += escapeRegex(c);
56
- }
57
- }
58
- return src + '$';
59
- }
60
-
61
- // Match the whole string against a single pattern (anchored, like globToRegex).
62
- export function globRegex(pattern) {
63
- const alts = expandBraces(pattern);
64
- if (alts.length === 1) return new RegExp('^' + globToRegexSource(alts[0]));
65
- return new RegExp('^(?:' + alts.map(globToRegexSource).join('|') + ')');
66
- }
67
-
68
- // True if `s` matches `pattern` (full match on `s`).
69
- export function matchGlob(pattern, s) {
70
- return globRegex(pattern).test(s);
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
- const data = fs.readFileSync(p);
40
- const base64 = data.toString('base64');
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
- return `data:${mimeType};base64,${base64}`;
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',
@@ -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
- let out = `#${t.taskId} [${label}] (pid ${t.pid || '-'})\n${capped.join('\n')}`;
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
  },
@@ -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
- return s.slice(0, MAX_OUTPUT_BYTES) + `\n...[truncated ${s.length - MAX_OUTPUT_BYTES} chars]`;
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 = '0.1.0';
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
- const chromeH = todoH + queueH + workingH + composerBoxH + menuItems + STATUS_H + confirmH + CTX_H;
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
- const bottomChrome = STATUS_H + (state.confirmExit ? 1 : 0) + CTX_H + menuH;
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
- // @ tab-completion for files/folders
6559
- if (t.key === 'tab' && !state.menuOpen) {
6560
- const input = state.input || '';
6561
- const atIdx = input.lastIndexOf('@');
6562
- if (atIdx >= 0) {
6563
- const prefix = input.slice(0, atIdx + 1); // include @
6564
- const suffix = input.slice(atIdx + 1); // text after @
6565
-
6566
- // Collect files and folders from current workspace
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
- refreshMenu(state); renderFrame(); state.composerSel = null; return;
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
- refreshMenu(state); renderFrame();
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 fs from 'node:fs';
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();