@pellux/goodvibes-tui 0.18.23 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +1 -1
  4. package/package.json +7 -3
  5. package/src/core/conversation-rendering.ts +8 -6
  6. package/src/core/orchestrator.ts +1 -1
  7. package/src/input/commands/diff-runtime.ts +6 -5
  8. package/src/input/commands/guidance-runtime.ts +1 -1
  9. package/src/input/commands/health-runtime.ts +2 -2
  10. package/src/input/commands/local-setup-review.ts +1 -1
  11. package/src/input/commands/session-content.ts +1 -1
  12. package/src/input/commands/shell-core.ts +3 -2
  13. package/src/input/commands/skills-runtime.ts +2 -2
  14. package/src/input/commands/subscription-runtime.ts +4 -4
  15. package/src/input/handler.ts +8 -10
  16. package/src/input/panel-integration-actions.ts +2 -1
  17. package/src/input/settings-modal-types.ts +60 -0
  18. package/src/input/settings-modal.ts +83 -65
  19. package/src/panels/agent-inspector-panel.ts +10 -9
  20. package/src/panels/agent-logs-panel.ts +26 -6
  21. package/src/panels/approval-panel.ts +1 -0
  22. package/src/panels/automation-control-panel.ts +1 -0
  23. package/src/panels/base-panel.ts +108 -3
  24. package/src/panels/communication-panel.ts +1 -0
  25. package/src/panels/context-visualizer-panel.ts +2 -0
  26. package/src/panels/control-plane-panel.ts +1 -0
  27. package/src/panels/diff-panel.ts +2 -0
  28. package/src/panels/file-explorer-panel.ts +51 -31
  29. package/src/panels/file-preview-panel.ts +57 -35
  30. package/src/panels/git-panel.ts +12 -13
  31. package/src/panels/hooks-panel.ts +3 -1
  32. package/src/panels/incident-review-panel.ts +4 -2
  33. package/src/panels/knowledge-panel.ts +75 -107
  34. package/src/panels/local-auth-panel.ts +1 -0
  35. package/src/panels/marketplace-panel.ts +51 -69
  36. package/src/panels/mcp-panel.ts +3 -1
  37. package/src/panels/memory-panel.ts +90 -158
  38. package/src/panels/ops-control-panel.ts +1 -0
  39. package/src/panels/orchestration-panel.ts +70 -51
  40. package/src/panels/panel-list-panel.ts +5 -4
  41. package/src/panels/panel-manager.ts +3 -0
  42. package/src/panels/plan-dashboard-panel.ts +2 -0
  43. package/src/panels/plugins-panel.ts +1 -0
  44. package/src/panels/polish.ts +51 -2
  45. package/src/panels/provider-accounts-panel.ts +1 -0
  46. package/src/panels/provider-health-panel.ts +6 -8
  47. package/src/panels/routes-panel.ts +3 -1
  48. package/src/panels/schedule-panel.ts +7 -6
  49. package/src/panels/scrollable-list-panel.ts +19 -2
  50. package/src/panels/security-panel.ts +17 -15
  51. package/src/panels/services-panel.ts +6 -4
  52. package/src/panels/session-browser-panel.ts +19 -18
  53. package/src/panels/settings-sync-panel.ts +3 -1
  54. package/src/panels/skills-panel.ts +114 -230
  55. package/src/panels/subscription-panel.ts +1 -0
  56. package/src/panels/system-messages-panel.ts +147 -141
  57. package/src/panels/tasks-panel.ts +1 -0
  58. package/src/panels/token-budget-panel.ts +2 -0
  59. package/src/panels/watchers-panel.ts +1 -0
  60. package/src/panels/worktree-panel.ts +1 -0
  61. package/src/panels/wrfc-panel.ts +2 -0
  62. package/src/renderer/agent-detail-modal.ts +2 -2
  63. package/src/renderer/ansi-sanitize.ts +76 -0
  64. package/src/renderer/buffer.ts +12 -1
  65. package/src/renderer/help-overlay.ts +14 -3
  66. package/src/renderer/settings-modal-helpers.ts +27 -0
  67. package/src/renderer/settings-modal.ts +18 -1
  68. package/src/renderer/status-glyphs.ts +21 -0
  69. package/src/renderer/status-token.ts +4 -8
  70. package/src/renderer/tool-call.ts +4 -3
  71. package/src/runtime/bootstrap-core.ts +1 -1
  72. package/src/runtime/bootstrap-hook-bridge.ts +1 -1
  73. package/src/runtime/bootstrap.ts +7 -8
  74. package/src/runtime/diagnostics/panels/policy.ts +2 -1
  75. package/src/shell/ui-openers.ts +1 -1
  76. package/src/version.ts +1 -1
@@ -1,26 +1,20 @@
1
- import { existsSync, readdirSync, readFileSync } from 'node:fs';
1
+ import { promises as fsPromises } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import type { Line } from '../types/grid.ts';
4
4
  import { createEmptyLine } from '../types/grid.ts';
5
5
  import { type ConfirmState, handleConfirmInput, renderConfirmLines } from './confirm-state.ts';
6
6
  import { getDisplayWidth, truncateDisplay } from '../utils/terminal-width.ts';
7
- import { BasePanel } from './base-panel.ts';
7
+ import { SearchableListPanel } from './scrollable-list-panel.ts';
8
8
  import type { ComponentHealthMonitor } from '../runtime/perf/panel-health-monitor.ts';
9
9
  import type { ShellPathService } from '@pellux/goodvibes-sdk/platform/runtime/shell-paths';
10
10
  import {
11
- buildEmptyState,
12
11
  buildPanelLine,
13
- buildSearchInputLine,
14
12
  buildPanelWorkspace,
15
13
  DEFAULT_PANEL_PALETTE,
16
- resolvePrimaryScrollableSection,
17
- type PanelWorkspaceSection,
18
14
  } from './polish.ts';
19
15
  import {
20
16
  getPanelSearchFocusTransition,
21
- isPanelSearchBackspace,
22
17
  isPanelSearchCancel,
23
- isPanelSearchPrintable,
24
18
  } from './search-focus.ts';
25
19
 
26
20
  const C = {
@@ -81,11 +75,10 @@ function getSkillDirectories(cwd: string, homeDir: string): Array<{ root: string
81
75
  ];
82
76
  }
83
77
 
84
- function readSkillFile(path: string, origin: SkillOrigin): SkillRecord | null {
85
- if (!existsSync(path)) return null;
78
+ async function readSkillFile(path: string, origin: SkillOrigin): Promise<SkillRecord | null> {
86
79
  let content = '';
87
80
  try {
88
- content = readFileSync(path, 'utf-8');
81
+ content = await fsPromises.readFile(path, 'utf-8');
89
82
  } catch {
90
83
  return null;
91
84
  }
@@ -115,11 +108,10 @@ function readSkillFile(path: string, origin: SkillOrigin): SkillRecord | null {
115
108
  };
116
109
  }
117
110
 
118
- function scanSkillDirectory(root: string, origin: SkillOrigin): SkillRecord[] {
119
- if (!existsSync(root)) return [];
111
+ async function scanSkillDirectory(root: string, origin: SkillOrigin): Promise<SkillRecord[]> {
120
112
  let entries: string[] = [];
121
113
  try {
122
- entries = readdirSync(root);
114
+ entries = await fsPromises.readdir(root);
123
115
  } catch {
124
116
  return [];
125
117
  }
@@ -127,27 +119,27 @@ function scanSkillDirectory(root: string, origin: SkillOrigin): SkillRecord[] {
127
119
  const records: SkillRecord[] = [];
128
120
  for (const entry of entries.sort((a, b) => a.localeCompare(b))) {
129
121
  if (entry.endsWith('.md')) {
130
- const record = readSkillFile(join(root, entry), origin);
122
+ const record = await readSkillFile(join(root, entry), origin);
131
123
  if (record) records.push(record);
132
124
  continue;
133
125
  }
134
126
 
135
127
  const markerPath = join(root, entry, 'SKILL.md');
136
- const record = readSkillFile(markerPath, origin);
128
+ const record = await readSkillFile(markerPath, origin);
137
129
  if (record) records.push(record);
138
130
  }
139
131
 
140
132
  return records;
141
133
  }
142
134
 
143
- export function discoverSkills(shellPaths: Pick<ShellPathService, 'workingDirectory' | 'homeDirectory'>): SkillRecord[] {
135
+ export async function discoverSkills(shellPaths: Pick<ShellPathService, 'workingDirectory' | 'homeDirectory'>): Promise<SkillRecord[]> {
144
136
  const cwd = shellPaths.workingDirectory;
145
137
  const homeDir = shellPaths.homeDirectory;
146
138
  const seen = new Set<string>();
147
139
  const records: SkillRecord[] = [];
148
140
 
149
141
  for (const { root, origin } of getSkillDirectories(cwd, homeDir)) {
150
- for (const record of scanSkillDirectory(root, origin)) {
142
+ for (const record of await scanSkillDirectory(root, origin)) {
151
143
  if (seen.has(record.name.toLowerCase())) continue;
152
144
  seen.add(record.name.toLowerCase());
153
145
  records.push(record);
@@ -234,29 +226,99 @@ function originColor(origin: SkillOrigin): string {
234
226
  }
235
227
  }
236
228
 
237
- export class SkillsPanel extends BasePanel {
229
+ export class SkillsPanel extends SearchableListPanel<SkillRecord> {
238
230
  private readonly shellPaths: Pick<ShellPathService, 'workingDirectory' | 'homeDirectory'>;
239
- private query = '';
231
+ /** Whether the filter input row is focused for typing (vs. list navigation). */
240
232
  private filterFocused = false;
241
- private selectedIndex = 0;
242
- private scrollOffset = 0;
243
233
  private cached: SkillRecord[] | null = null;
244
234
  private cacheDirty = true;
245
235
  // I1: confirm state for destructive delete
246
236
  private confirm: ConfirmState | null = null;
237
+ private readyPromise: Promise<void> | null = null;
247
238
 
248
239
  public constructor(options: SkillsPanelOptions) {
249
240
  super('skills', 'Skills', 'K', 'monitoring', options.componentHealthMonitor);
241
+ this.showSelectionGutter = true; // I5: non-color selection affordance
250
242
  this.shellPaths = options.shellPaths;
251
243
  }
252
244
 
245
+ // -------------------------------------------------------------------------
246
+ // SearchableListPanel implementation
247
+ // -------------------------------------------------------------------------
248
+
249
+ protected getAllItems(): readonly SkillRecord[] {
250
+ return this.cached ?? [];
251
+ }
252
+
253
+ private _loadSkillsAsync(): Promise<void> {
254
+ const p = (async () => {
255
+ try {
256
+ await this.withLoading('Scanning skills\u2026', async () => {
257
+ this.cached = await discoverSkills(this.shellPaths);
258
+ this.cacheDirty = false;
259
+ this.invalidateFilter();
260
+ });
261
+ } catch (err) {
262
+ this.setError(err instanceof Error ? err.message : String(err));
263
+ }
264
+ this.markDirty();
265
+ })();
266
+ this.readyPromise = p;
267
+ return p;
268
+ }
269
+
270
+ /** Resolves when the current load cycle has settled. */
271
+ public awaitReady(): Promise<void> {
272
+ return this.readyPromise ?? Promise.resolve();
273
+ }
274
+
275
+ protected matchesSearch(skill: SkillRecord, query: string): boolean {
276
+ const q = query.trim().toLowerCase();
277
+ if (!q) return true;
278
+ const haystack = [
279
+ skill.name,
280
+ skill.description,
281
+ skill.path,
282
+ skill.origin,
283
+ skill.dependencies.join(' '),
284
+ skill.includes.join(' '),
285
+ ].join(' ').toLowerCase();
286
+ return haystack.includes(q);
287
+ }
288
+
289
+ protected renderItem(skill: SkillRecord, index: number, selected: boolean, width: number): Line {
290
+ const bg = selected ? C.selectBg : undefined;
291
+ const dot = skill.origin === 'project-local' ? '\u25c6' : '\u2022';
292
+ const desc = skill.description || 'No description provided.';
293
+ const descWidth = Math.max(1, width - 4 - skill.name.length - 6);
294
+ const descLines = wordWrap(desc, descWidth);
295
+ return buildPanelLine(width, [
296
+ [selected ? '\u25b8' : ' ', C.selectedFg, bg],
297
+ [' ', C.dim, bg],
298
+ [dot, originColor(skill.origin), bg],
299
+ [' ', C.dim, bg],
300
+ [skill.name, selected ? C.selectedFg : C.value, bg],
301
+ [' ', C.dim, bg],
302
+ [descLines[0] ?? '', selected ? C.selectedFg : C.dim, bg],
303
+ ]);
304
+ }
305
+
306
+ protected override getPalette() { return C; }
307
+ protected override getEmptyStateMessage() { return ' No skills discovered.'; }
308
+ protected override getEmptyStateActions() {
309
+ return [
310
+ { command: '.goodvibes/skills', summary: 'place skill .md files here (project-local) or ~/.goodvibes/skills (global)' },
311
+ { command: '/registry search skills', summary: 'inspect the same skill directories from the shell' },
312
+ ];
313
+ }
314
+
253
315
  public override onActivate(): void {
254
316
  super.onActivate();
255
- this.query = '';
317
+ this.searchQuery = '';
318
+ this.invalidateFilter();
256
319
  this.filterFocused = false;
257
- this.selectedIndex = 0;
258
- this.scrollOffset = 0;
259
320
  this.cacheDirty = true;
321
+ void this._loadSkillsAsync();
260
322
  }
261
323
 
262
324
  public override onDestroy(): void {}
@@ -280,110 +342,51 @@ export class SkillsPanel extends BasePanel {
280
342
  }
281
343
  if (confirmResult === 'absorbed') return true;
282
344
 
283
- const records = this._filteredSkills();
345
+ const items = this.getItems();
346
+
347
+ // Filter-focus mode: typing goes into the search query
284
348
  if (this.filterFocused) {
285
- const transition = getPanelSearchFocusTransition(key, { selectedIndex: this.selectedIndex, itemCount: records.length });
349
+ const transition = getPanelSearchFocusTransition(key, { selectedIndex: this.selectedIndex, itemCount: items.length });
286
350
  if (transition === 'focus-list') {
287
351
  this.filterFocused = false;
288
- this.selectedIndex = 0;
289
- this.scrollOffset = 0;
290
- this.markDirty();
291
- return true;
292
- }
293
- if (isPanelSearchBackspace(key)) {
294
- if (this.query.length === 0) return true;
295
- this.query = this.query.slice(0, -1);
296
- this.selectedIndex = 0;
297
- this.scrollOffset = 0;
298
352
  this.markDirty();
299
353
  return true;
300
354
  }
355
+ // Escape: also blur filter focus (clear + return to list navigation)
301
356
  if (isPanelSearchCancel(key)) {
302
357
  this.filterFocused = false;
303
- this.markDirty();
304
- return true;
358
+ // Delegate to super to clear the query. If the query is empty, super
359
+ // returns false and escape propagates to the panel dismissal handler —
360
+ // this is the intentional double-escape UX (blur filter, then close).
361
+ return super.handleInput(key);
305
362
  }
306
- if (isPanelSearchPrintable(key)) {
307
- this.query += key;
308
- this.selectedIndex = 0;
309
- this.scrollOffset = 0;
310
- this.markDirty();
311
- return true;
312
- }
313
- return false;
363
+ // Delegate backspace/printable to SearchableListPanel.handleInput
364
+ return super.handleInput(key);
314
365
  }
315
366
 
316
- const transition = getPanelSearchFocusTransition(key, { selectedIndex: this.selectedIndex, itemCount: records.length });
367
+ const transition = getPanelSearchFocusTransition(key, { selectedIndex: this.selectedIndex, itemCount: items.length });
317
368
  if (transition === 'focus-search') {
318
369
  this.filterFocused = true;
319
370
  this.markDirty();
320
371
  return true;
321
372
  }
322
373
 
323
- if (key === 'up' || key === 'k') {
324
- this.selectedIndex = Math.max(0, this.selectedIndex - 1);
325
- this.markDirty();
326
- return true;
327
- }
328
- if (key === 'down' || key === 'j') {
329
- this.selectedIndex = Math.min(Math.max(0, records.length - 1), this.selectedIndex + 1);
330
- this.markDirty();
331
- return true;
332
- }
333
- if (key === 'home') {
334
- this.selectedIndex = 0;
335
- this.markDirty();
336
- return true;
337
- }
338
- if (key === 'end') {
339
- this.selectedIndex = Math.max(0, records.length - 1);
340
- this.markDirty();
341
- return true;
342
- }
343
- if (key === 'pageup') {
344
- this.selectedIndex = Math.max(0, this.selectedIndex - 5);
345
- this.markDirty();
346
- return true;
347
- }
348
- if (key === 'pagedown') {
349
- this.selectedIndex = Math.min(Math.max(0, records.length - 1), this.selectedIndex + 5);
350
- this.markDirty();
351
- return true;
352
- }
353
374
  // I1: 'd' prompts delete confirmation
354
375
  if (key === 'd') {
355
- const skill = records[this.selectedIndex];
376
+ const skill = items[this.selectedIndex];
356
377
  if (skill) {
357
378
  this.confirm = { subject: skill.path, label: skill.name };
358
379
  this.markDirty();
359
380
  }
360
381
  return true;
361
382
  }
362
- if (isPanelSearchBackspace(key)) {
363
- if (this.query.length === 0) return false;
364
- this.query = this.query.slice(0, -1);
365
- this.selectedIndex = 0;
366
- this.scrollOffset = 0;
367
- this.markDirty();
368
- return true;
369
- }
370
- if (isPanelSearchCancel(key)) {
371
- if (this.query.length === 0) return false;
372
- this.query = '';
373
- this.selectedIndex = 0;
374
- this.scrollOffset = 0;
375
- this.markDirty();
376
- return true;
377
- }
378
- return false;
383
+
384
+ // Navigation + search: delegate to SearchableListPanel (up/down/g/G/page/enter + backspace/escape)
385
+ return super.handleInput(key);
379
386
  }
380
387
 
381
388
  public render(width: number, height: number): Line[] {
382
- if (!this.canRenderNow()) {
383
- return Array.from({ length: height }, () => createEmptyLine(width));
384
- }
385
-
386
- const start = Date.now();
389
+ return this.trackedRender(() => {
387
390
  this.needsRender = false;
388
391
 
389
392
  // I1: show confirm dialog in place of normal content
@@ -395,50 +398,15 @@ export class SkillsPanel extends BasePanel {
395
398
  palette: C,
396
399
  });
397
400
  while (lines.length < height) lines.push(createEmptyLine(width));
398
- this.reportRenderDuration(Date.now() - start);
399
- return lines.slice(0, height);
400
- }
401
-
402
- const intro = 'Discover project-local and global skill packs, filter by name or description, and inspect path, dependencies, and includes.';
403
- const skills = this._filteredSkills();
404
-
405
- if (skills.length === 0) {
406
- const lines = buildPanelWorkspace(width, height, {
407
- title: 'Skills - discover project-local and global skill packs',
408
- intro,
409
- sections: [{
410
- title: 'Filter',
411
- lines: [buildSearchInputLine(width, ' query: ', `${this.query}${this.filterFocused ? '_' : ''}`, C, {
412
- active: this.filterFocused,
413
- emptyLabel: this.filterFocused ? '(type to filter)' : '(/ or up at top)',
414
- valueColor: this.query ? C.searchFg : undefined,
415
- })],
416
- }, {
417
- lines: buildEmptyState(
418
- width,
419
- ' No skills discovered.',
420
- 'Create .goodvibes/skills or .goodvibes/tui/skills in this repo, or ~/.goodvibes/skills and ~/.goodvibes/tui/skills for global packs.',
421
- [{ command: '/registry search skills', summary: 'inspect the same skill directories from the shell' }],
422
- C,
423
- ),
424
- }],
425
- palette: C,
426
- });
427
- while (lines.length < height) lines.push(createEmptyLine(width));
428
- this.reportRenderDuration(Date.now() - start);
429
401
  return lines.slice(0, height);
430
402
  }
431
403
 
432
- this._clampSelection(skills);
433
- const selected = skills[this.selectedIndex];
434
- const fixedDiscoveryLines: Line[] = [
435
- buildSearchInputLine(width, ' query: ', `${this.query}${this.filterFocused ? '_' : ''}`, C, {
436
- active: this.filterFocused,
437
- emptyLabel: this.filterFocused ? '(type to filter)' : '(/ or up at top)',
438
- valueColor: this.query ? C.searchFg : undefined,
439
- }),
440
- ];
404
+ // Build filter input line (provided by SearchableListPanel base)
405
+ const filterLine = this.buildFilterInputLine(width, 'Filter', this.filterFocused);
441
406
 
407
+ // Build detail footer for the currently selected skill
408
+ const items = this.getItems();
409
+ const selected = items[this.selectedIndex];
442
410
  const detailLines: Line[] = [];
443
411
  if (selected) {
444
412
  detailLines.push(
@@ -448,99 +416,15 @@ export class SkillsPanel extends BasePanel {
448
416
  buildPanelLine(width, [[' Depends: ', C.label], [selected.dependencies.length > 0 ? selected.dependencies.join(', ') : 'none', C.dim]]),
449
417
  buildPanelLine(width, [[' Includes: ', C.label], [selected.includes.length > 0 ? selected.includes.join(', ') : 'none', C.dim]]),
450
418
  );
451
- } else {
452
- detailLines.push(buildPanelLine(width, [[' No selection.', C.dim]]));
453
419
  }
454
- const detailSection: PanelWorkspaceSection = { title: 'Selected Skill', lines: detailLines };
455
- const resolvedDiscoverySection = resolvePrimaryScrollableSection(width, height, {
456
- intro,
457
- footerLines: [buildPanelLine(width, [[' Up/Down navigate / or Up-at-top focus filter Esc blur Backspace clear', C.hint]])],
458
- palette: C,
459
- section: {
460
- title: 'Discovery',
461
- fixedLines: fixedDiscoveryLines,
462
- scrollableLines: skills.map((skill, absolute) => {
463
- const isSelected = absolute === this.selectedIndex;
464
- const bg = isSelected ? C.selectBg : undefined;
465
- const dot = skill.origin === 'project-local' ? '◆' : '•';
466
- const desc = skill.description || 'No description provided.';
467
- const descWidth = Math.max(1, width - 4 - skill.name.length - 6);
468
- const descLines = wordWrap(desc, descWidth);
469
- return buildPanelLine(width, [
470
- [isSelected ? '▸' : ' ', C.selectedFg, bg],
471
- [' ', C.dim, bg],
472
- [dot, originColor(skill.origin), bg],
473
- [' ', C.dim, bg],
474
- [skill.name, isSelected ? C.selectedFg : C.value, bg],
475
- [' ', C.dim, bg],
476
- [descLines[0] ?? '', isSelected ? C.selectedFg : C.dim, bg],
477
- ]);
478
- }),
479
- selectedIndex: this.selectedIndex,
480
- scrollOffset: this.scrollOffset,
481
- guardRows: 1,
482
- minRows: 4,
483
- appendWindowSummary: {
484
- dimColor: C.dim,
485
- formatter: (window) => buildPanelLine(width, [[` showing ${window.start + 1}-${window.end} of ${window.total}`, C.dim]]),
486
- },
487
- },
488
- afterSections: [detailSection],
489
- });
490
- this.scrollOffset = resolvedDiscoverySection.scrollOffset;
491
- this._clampScroll(skills, resolvedDiscoverySection.window.count);
420
+ detailLines.push(buildPanelLine(width, [[' Up/Down navigate / or Up-at-top focus filter Esc blur Backspace clear', C.hint]]));
492
421
 
493
- const sections: PanelWorkspaceSection[] = [
494
- resolvedDiscoverySection.section,
495
- detailSection,
496
- ];
497
- const lines = buildPanelWorkspace(width, height, {
422
+ const lines = this.renderList(width, height, {
498
423
  title: 'Skills - discover project-local and global skill packs',
499
- intro,
500
- sections,
501
- footerLines: [buildPanelLine(width, [[' Up/Down navigate / or Up-at-top focus filter Esc blur Backspace clear', C.hint]])],
502
- palette: C,
424
+ header: [filterLine],
425
+ footer: detailLines,
503
426
  });
504
- while (lines.length < height) lines.push(createEmptyLine(width));
505
- this.reportRenderDuration(Date.now() - start);
506
- return lines.slice(0, height);
507
- }
508
-
509
- private _filteredSkills(): SkillRecord[] {
510
- if (this.cached === null || this.cacheDirty) {
511
- this.cached = discoverSkills(this.shellPaths);
512
- this.cacheDirty = false;
513
- }
514
- const q = this.query.trim().toLowerCase();
515
- if (!q) return this.cached;
516
- return this.cached.filter((skill) => {
517
- const haystack = [
518
- skill.name,
519
- skill.description,
520
- skill.path,
521
- skill.origin,
522
- skill.dependencies.join(' '),
523
- skill.includes.join(' '),
524
- ].join(' ').toLowerCase();
525
- return haystack.includes(q);
427
+ return lines;
526
428
  });
527
429
  }
528
-
529
- private _clampSelection(records: SkillRecord[]): void {
530
- if (records.length === 0) {
531
- this.selectedIndex = 0;
532
- return;
533
- }
534
- this.selectedIndex = Math.max(0, Math.min(this.selectedIndex, records.length - 1));
535
- }
536
-
537
- private _clampScroll(records: SkillRecord[], listHeight: number): void {
538
- const maxScroll = Math.max(0, records.length - listHeight);
539
- if (this.selectedIndex < this.scrollOffset) {
540
- this.scrollOffset = this.selectedIndex;
541
- } else if (this.selectedIndex >= this.scrollOffset + listHeight) {
542
- this.scrollOffset = this.selectedIndex - listHeight + 1;
543
- }
544
- this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxScroll));
545
- }
546
430
  }
@@ -64,6 +64,7 @@ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
64
64
  subscriptionManager: SubscriptionAccessQuery,
65
65
  ) {
66
66
  super('subscription', 'Subscriptions', 'B', 'monitoring');
67
+ this.showSelectionGutter = true; // I5: non-color selection affordance
67
68
  this.serviceRegistry = serviceRegistry;
68
69
  this.subscriptionManager = subscriptionManager;
69
70
  }