@hncode/hncode 0.6.6 → 0.6.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hncode/hncode",
3
- "version": "0.6.6",
3
+ "version": "0.6.7",
4
4
  "description": "hncode — A cyan-blue TUI AI coding agent with zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -171,6 +171,23 @@ function statusColor(tone) {
171
171
  return C.blue;
172
172
  }
173
173
 
174
+ /**
175
+ * The rows a tab SHOWS, after the search filter.
176
+ *
177
+ * The renderer and the action handler must agree on this or the list lies: with a query typed,
178
+ * `selectedIndex` counts entries in the FILTERED list, so looking it up in the unfiltered one acts on whatever
179
+ * happened to sit at that position instead of the row under the cursor.
180
+ *
181
+ * The tab COUNTS stay on the unfiltered list — a filter must never make a tab look empty.
182
+ */
183
+ export function visibleRowsForTab(state, tabId) {
184
+ const rows = rowsForTab(state, tabId);
185
+ const query = String(state && state.filter || '').trim().toLowerCase();
186
+ if (!query) return rows;
187
+ return rows.filter((r) => [r.label, r.description, r.name]
188
+ .map((v) => String(v == null ? '' : v)).join(' ').toLowerCase().includes(query));
189
+ }
190
+
174
191
  export function renderRegistryBrowser(state, cols, rows) {
175
192
  if (cols < MIN_WIDTH || rows < MIN_HEIGHT) {
176
193
  const lines = [fitExactly(C.red + 'Terminal too small (need >= ' + MIN_WIDTH + ' x ' + MIN_HEIGHT + ')' + C.reset, cols)];
@@ -181,7 +198,9 @@ export function renderRegistryBrowser(state, cols, rows) {
181
198
  const tabs = state.kind === 'skills' ? SKILL_TABS : PLUGIN_TABS;
182
199
  const tabIdx = Math.max(0, tabs.findIndex((t) => t.id === state.tab));
183
200
  const tab = tabs[tabIdx] || tabs[0];
184
- const all = rowsForTab(state, tab.id);
201
+ // The header row and the empty-state message both need to know whether a filter is on.
202
+ const query = String(state.filter || '').trim().toLowerCase();
203
+ const all = visibleRowsForTab(state, tab.id);
185
204
  const sel = Math.max(0, Math.min(all.length - 1, state.selectedIndex || 0));
186
205
 
187
206
  const out = [];
@@ -197,9 +216,16 @@ export function renderRegistryBrowser(state, cols, rows) {
197
216
  // The key hints stay visible while the fetch runs: the panel is usable during it
198
217
  // (the local tabs are already loaded), so replacing the hints with a status message
199
218
  // would hide the keys exactly when the user is most likely to press them.
200
- const hint = ' Tab switch \u00b7 \u2191\u2193 select \u00b7 Enter install/remove \u00b7 Esc close';
219
+ const hint = ' / search \u00b7 Tab switch \u00b7 \u2191\u2193 select \u00b7 Enter install/remove \u00b7 Esc close';
201
220
  out.push(fitExactly(C.gray + hint + (state.busy ? C.gray + ` ${state.busy}` : '') + C.reset, cols));
202
221
  out.push('');
222
+ // The search field, drawn whenever it is open or has text, so a filtered list always explains itself.
223
+ if (state.filtering || query) {
224
+ const shown = String(state.filter || '');
225
+ const caret = state.filtering ? '\u2588' : '';
226
+ out.push(C.gray + ' / ' + C.reset + C.white + shown + C.reset + C.cyan + caret + C.reset
227
+ + C.gray + (query ? ` ${all.length} match${all.length === 1 ? '' : 'es'}` : ' type to filter') + C.reset);
228
+ }
203
229
 
204
230
  // Tab strip, with the count of each tab's rows.
205
231
  {
@@ -240,22 +266,52 @@ export function renderRegistryBrowser(state, cols, rows) {
240
266
  : ' Nothing here.';
241
267
  out.push(C.gray + msg + C.reset);
242
268
  }
243
- for (let i = 0; i < all.length; i++) {
269
+ // How many LINES each row occupies: its label plus its wrapped description.
270
+ const descW = Math.max(8, cols - 6);
271
+ const rowH = all.map((r) => 1 + wrapText(r.description, descW).length);
272
+ const visibleLines = Math.max(1, bodyRows);
273
+
274
+ // The first row to draw. `state.listTop` persists across frames — recomputing it from scratch each time would
275
+ // snap the window back to the top on every key, and the cursor would appear to leave the list.
276
+ let first = Math.max(0, Math.min(state.listTop || 0, Math.max(0, all.length - 1)));
277
+ // Scroll down while the selection does not fit; scroll up if it sits above the window.
278
+ if (sel < first) first = sel;
279
+ for (;;) {
280
+ let used = 0;
281
+ for (let i = first; i <= sel && i < all.length; i++) used += rowH[i];
282
+ if (used <= visibleLines || first >= sel) break;
283
+ first++;
284
+ }
285
+ state.listTop = first;
286
+
287
+ // Draw from `first`, stopping when the window is full. A row is never split: a label whose description fell
288
+ // off the bottom reads as a row that has none.
289
+ let drawn = 0;
290
+ for (let i = first; i < all.length; i++) {
291
+ if (drawn > 0 && drawn + rowH[i] > visibleLines) break;
292
+ drawn += rowH[i];
244
293
  const r = all[i];
245
294
  const isSel = i === sel;
246
295
  const ptr = isSel ? C.cyan + '\u276f ' + C.reset : ' ';
247
296
  const nameStyle = isSel ? (C.cyan + C.bold) : C.white;
248
297
  const badge = r.status ? ' ' + statusColor(r.statusTone) + r.status + C.reset : '';
249
298
  const dis = r.disabled ? C.gray : '';
250
- const labelRow = out.length; // the clickable row for this item
299
+ const labelRow = out.length;
251
300
  out.push(fitExactly(ptr + nameStyle + dis + r.label + C.reset + badge, cols));
252
- // The whole label row is a click target: clicking selects the item (and a
253
- // second click / the footer activates it), mirroring the tasks browser.
301
+ // The whole label row is a click target: clicking selects the item (and a second click / the footer
302
+ // activates it), mirroring the tasks browser.
254
303
  hits.push({ row: labelRow, col0: 0, col1: cols - 1, kind: 'regRow', index: i });
255
- for (const line of wrapText(r.description, Math.max(8, cols - 6))) {
304
+ for (const line of wrapText(r.description, descW)) {
256
305
  out.push(C.gray + ' ' + line + C.reset);
257
306
  }
258
307
  }
308
+ // Say when the list continues: a clipped list that looks complete is how a skill goes missing.
309
+ let after = first;
310
+ let acc = 0;
311
+ while (after < all.length && acc < drawn) { acc += rowH[after]; after++; }
312
+ const hiddenBelow = all.length - after;
313
+ if (first > 0) out.push(C.gray + ` \u2191 \u2191 \u2191 ${first} above` + C.reset);
314
+ if (hiddenBelow > 0) out.push(C.gray + ` \u2193 \u2193 \u2193 ${hiddenBelow} below` + C.reset);
259
315
  // A fetch failure has to show even when the LOCAL half has rows, or the Available
260
316
  // tab silently looks empty and the user cannot tell why.
261
317
  if (all.length && state.data && state.data.remoteError && tab.id === 'available') {
@@ -291,9 +347,36 @@ export function renderRegistryBrowser(state, cols, rows) {
291
347
  * 'close' | 'switchTab' | 'select' | 'install' | 'remove' | 'reload' | null
292
348
  */
293
349
  export function handleRegistryBrowserKey(state, t) {
350
+
294
351
  const tabs = state.kind === 'skills' ? SKILL_TABS : PLUGIN_TABS;
295
352
  const all = rowsForTab(state, state.tab);
296
353
  const ch = t.ch;
354
+ // ---- filter input -------------------------------------------------------------------------
355
+ //
356
+ // While `state.filtering` is on, every printable key goes into the query instead of acting on the list. Esc
357
+ // leaves the field without clearing the filter, so a half-typed word is not lost by a stray key; a second Esc
358
+ // clears it. Enter accepts the filter and returns the keyboard to the list.
359
+ if (state.filtering) {
360
+ if (t.key === 'escape') { state.filtering = false; return 'select'; }
361
+ if (t.key === 'enter') { state.filtering = false; return 'select'; }
362
+ if (t.key === 'backspace') {
363
+ state.filter = String(state.filter || '').slice(0, -1);
364
+ state.selectedIndex = 0;
365
+ state.listTop = 0;
366
+ return 'select';
367
+ }
368
+ if (t.key === 'up' || t.key === 'down') { state.filtering = false; } // hand the arrows back to the list
369
+ else if (ch && ch.length === 1 && ch >= ' ') {
370
+ state.filter = String(state.filter || '') + ch;
371
+ state.selectedIndex = 0;
372
+ state.listTop = 0;
373
+ return 'select';
374
+ } else { return null; }
375
+ }
376
+
377
+ // `/` opens the field; a set filter stays visible in the header and Esc clears it from the list too.
378
+ if (ch === '/') { state.filtering = true; return 'select'; }
379
+ if (t.key === 'escape' && state.filter) { state.filter = ''; state.selectedIndex = 0; state.listTop = 0; return 'select'; }
297
380
 
298
381
  if (t.key === 'escape' || ch === 'q' || ch === 'Q') return 'close';
299
382
  if (t.key === 'tab') {
package/src/tui.js CHANGED
@@ -7125,7 +7125,8 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
7125
7125
  const nameField = col(label, selected ? (C.cyan + C.bold) : C.gray);
7126
7126
  const hint = cmd.argumentHint ? col(' ' + cmd.argumentHint, C.gray) : '';
7127
7127
  const pad = Math.max(2, 18 - visualCol(label) - visualCol(hint));
7128
- const desc = col(cmd.desc, C.gray);
7128
+ // `description` for a skill completion, `desc` for a command: both spellings exist, so accept either.
7129
+ const desc = col(cmd.desc || cmd.description || '', C.gray);
7129
7130
  addHit(lines.length, 2, mInner, { kind: 'menuItem', index: off + j });
7130
7131
  lines.push(menuRow(mark + nameField + hint + ' '.repeat(pad) + desc));
7131
7132
  });
@@ -12779,7 +12780,15 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
12779
12780
  if (!url) { appErr('No git remote named "origin" — cannot build a PR URL.'); return; }
12780
12781
  const branch = gitmod.currentBranch(cwd);
12781
12782
  sayPanel(`Open a pull request for ${branch} → ${base}:\n ${url}`);
12782
- app('PR link copied to the transcript (Ctrl+Shift+C to copy).');
12783
+ // ACTUALLY COPY IT, and report what happened.
12784
+ //
12785
+ // The notice used to claim the link was copied while the clipboard was left alone, so the paste that followed
12786
+ // produced whatever was copied last. `copyText` also signals failure by RETURNING false rather than throwing,
12787
+ // so its result is what decides the message — a try/catch around it announced success either way.
12788
+ let copied = false;
12789
+ try { copied = copyText(url); } catch { copied = false; }
12790
+ if (copied) app('PR link copied to your clipboard');
12791
+ else app('Could not reach the clipboard \u2014 the link is on screen above.');
12783
12792
  return;
12784
12793
  }
12785
12794
  case 'cache': {
@@ -14131,7 +14140,7 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
14131
14140
  // Tools that act ONLY inside the session: the roster, the mailbox, the task list, the memories.
14132
14141
  // `isInsideWorkspace` answers `false` for any tool it does not know, so without this they all asked for
14133
14142
  // approval in yolo — the one mode whose point is not asking.
14134
- const safeTools = ['Read', 'Grep', 'Glob', 'FetchURL', 'WebSearch', 'TaskOutput', 'TaskList', 'TaskStop', 'TaskWait', 'TodoList', 'Goal', 'FileLines', 'AgentList', 'AgentMessage', 'AgentInterrupt', 'AgentClose', 'TaskInput', 'SendMessage', 'TeamTaskCreate', 'TeamTaskUpdate', 'TeamTaskList', 'ListTeammates', 'Memory', 'AskUserQuestion'];
14143
+ const safeTools = ['Read', 'Grep', 'Glob', 'FetchURL', 'WebSearch', 'TaskOutput', 'TaskList', 'TaskStop', 'TaskWait', 'TodoList', 'Goal', 'ActivateSkill', 'FileLines', 'AgentList', 'AgentMessage', 'AgentInterrupt', 'AgentClose', 'TaskInput', 'SendMessage', 'TeamTaskCreate', 'TeamTaskUpdate', 'TeamTaskList', 'ListTeammates', 'Memory', 'AskUserQuestion'];
14135
14144
  if (rule !== 'ask' && safeTools.includes(toolName)) return true;
14136
14145
  // A read-only SHELL COMMAND (ls/git status/rg/cat …) is as safe as a
14137
14146
  // read-only TOOL, so it runs without a prompt in Ask mode too — the user
@@ -16554,9 +16563,9 @@ export async function startTUI(opts) {
16554
16563
  async function registryAct() {
16555
16564
  const b = state.registryBrowser;
16556
16565
  if (!b || !b.data) return;
16557
- const { rowsForTab } = await import('./registry-browser.js');
16566
+ const { visibleRowsForTab } = await import('./registry-browser.js');
16558
16567
  const remote = await import('./remote.js');
16559
- const rows = rowsForTab(b, b.tab);
16568
+ const rows = visibleRowsForTab(b, b.tab);
16560
16569
  const row = rows[Math.min(b.selectedIndex || 0, Math.max(0, rows.length - 1))];
16561
16570
  if (!row || row.disabled || row.action === 'none') return;
16562
16571
  const name = row.name;