@pellux/goodvibes-tui 0.27.0 → 0.29.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 (143) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/core/context-usage.ts +53 -0
  5. package/src/core/conversation.ts +4 -6
  6. package/src/core/session-recovery.ts +2 -0
  7. package/src/core/turn-event-wiring.ts +1 -0
  8. package/src/input/commands/cost-runtime.ts +49 -0
  9. package/src/input/commands/eval.ts +4 -3
  10. package/src/input/commands/operator-runtime.ts +5 -1
  11. package/src/input/commands.ts +2 -0
  12. package/src/input/handler-feed-routes.ts +60 -26
  13. package/src/input/handler-feed.ts +17 -8
  14. package/src/input/handler-picker-routes.ts +18 -13
  15. package/src/input/handler-shortcuts.ts +51 -0
  16. package/src/input/handler-ui-state.ts +4 -0
  17. package/src/input/handler.ts +43 -6
  18. package/src/input/keybindings.ts +53 -8
  19. package/src/input/model-picker.ts +5 -0
  20. package/src/input/panel-integration-actions.ts +117 -1
  21. package/src/main.ts +5 -2
  22. package/src/panels/agent-inspector-panel.ts +139 -45
  23. package/src/panels/agent-inspector-shared.ts +147 -3
  24. package/src/panels/approval-panel.ts +204 -102
  25. package/src/panels/automation-control-panel.ts +370 -103
  26. package/src/panels/base-panel.ts +26 -2
  27. package/src/panels/builtin/agent.ts +28 -37
  28. package/src/panels/builtin/development.ts +40 -32
  29. package/src/panels/builtin/knowledge.ts +15 -6
  30. package/src/panels/builtin/operations.ts +178 -117
  31. package/src/panels/builtin/session.ts +35 -9
  32. package/src/panels/builtin/shared.ts +98 -6
  33. package/src/panels/cockpit-panel.ts +253 -75
  34. package/src/panels/cockpit-read-model.ts +2 -1
  35. package/src/panels/communication-panel.ts +159 -56
  36. package/src/panels/confirm-state.ts +8 -1
  37. package/src/panels/control-plane-panel.ts +354 -95
  38. package/src/panels/cost-tracker-panel.ts +243 -57
  39. package/src/panels/debug-panel.ts +347 -159
  40. package/src/panels/diff-panel.ts +257 -82
  41. package/src/panels/docs-panel.ts +166 -64
  42. package/src/panels/eval-panel.ts +336 -109
  43. package/src/panels/expandable-list-panel.ts +189 -0
  44. package/src/panels/file-explorer-panel.ts +238 -159
  45. package/src/panels/file-preview-panel.ts +141 -59
  46. package/src/panels/git-panel.ts +360 -191
  47. package/src/panels/hooks-panel.ts +181 -19
  48. package/src/panels/incident-review-panel.ts +252 -43
  49. package/src/panels/index.ts +0 -4
  50. package/src/panels/intelligence-panel.ts +310 -103
  51. package/src/panels/knowledge-graph-panel.ts +480 -58
  52. package/src/panels/local-auth-panel.ts +251 -33
  53. package/src/panels/marketplace-panel.ts +219 -33
  54. package/src/panels/memory-panel.ts +83 -81
  55. package/src/panels/ops-control-panel.ts +211 -32
  56. package/src/panels/ops-strategy-panel.ts +131 -45
  57. package/src/panels/orchestration-panel.ts +259 -67
  58. package/src/panels/panel-list-panel.ts +184 -136
  59. package/src/panels/panel-manager.ts +120 -13
  60. package/src/panels/plan-dashboard-panel.ts +507 -74
  61. package/src/panels/plugins-panel.ts +227 -34
  62. package/src/panels/policy-panel.ts +264 -55
  63. package/src/panels/polish-core.ts +162 -0
  64. package/src/panels/polish-tables.ts +258 -0
  65. package/src/panels/polish.ts +67 -149
  66. package/src/panels/project-planning-answer-actions.ts +134 -0
  67. package/src/panels/project-planning-panel.ts +84 -113
  68. package/src/panels/provider-health-panel.ts +438 -480
  69. package/src/panels/provider-health-routes.ts +203 -0
  70. package/src/panels/provider-health-tracker.ts +194 -6
  71. package/src/panels/provider-health-views.ts +560 -0
  72. package/src/panels/qr-panel.ts +136 -38
  73. package/src/panels/remote-panel.ts +123 -38
  74. package/src/panels/routes-panel.ts +87 -24
  75. package/src/panels/sandbox-panel.ts +232 -65
  76. package/src/panels/scrollable-list-panel.ts +143 -145
  77. package/src/panels/security-panel.ts +204 -52
  78. package/src/panels/services-panel.ts +174 -76
  79. package/src/panels/session-browser-panel.ts +101 -6
  80. package/src/panels/session-maintenance.ts +4 -122
  81. package/src/panels/settings-sync-panel.ts +346 -68
  82. package/src/panels/skills-panel.ts +157 -89
  83. package/src/panels/subscription-panel.ts +105 -34
  84. package/src/panels/symbol-outline-panel.ts +287 -154
  85. package/src/panels/system-messages-panel.ts +172 -38
  86. package/src/panels/tasks-panel.ts +348 -134
  87. package/src/panels/thinking-panel.ts +66 -32
  88. package/src/panels/token-budget-panel.ts +233 -46
  89. package/src/panels/tool-inspector-panel.ts +170 -54
  90. package/src/panels/types.ts +65 -4
  91. package/src/panels/work-plan-panel.ts +393 -39
  92. package/src/panels/worktree-panel.ts +239 -61
  93. package/src/panels/wrfc-panel-format.ts +133 -0
  94. package/src/panels/wrfc-panel.ts +146 -150
  95. package/src/renderer/compaction-preview.ts +2 -1
  96. package/src/renderer/compositor.ts +46 -43
  97. package/src/renderer/conversation-overlays.ts +25 -11
  98. package/src/renderer/footer-tips.ts +41 -0
  99. package/src/renderer/fullscreen-primitives.ts +22 -16
  100. package/src/renderer/help-overlay.ts +91 -14
  101. package/src/renderer/hint-grammar.ts +52 -0
  102. package/src/renderer/layout.ts +7 -7
  103. package/src/renderer/modal-factory.ts +23 -15
  104. package/src/renderer/model-picker-overlay.ts +30 -11
  105. package/src/renderer/model-workspace.ts +2 -3
  106. package/src/renderer/overlay-box.ts +16 -10
  107. package/src/renderer/panel-composite.ts +7 -20
  108. package/src/renderer/panel-workspace-bar.ts +14 -8
  109. package/src/renderer/process-indicator.ts +3 -1
  110. package/src/renderer/progress.ts +8 -6
  111. package/src/renderer/search-overlay.ts +27 -6
  112. package/src/renderer/session-picker-modal.ts +6 -4
  113. package/src/renderer/settings-modal.ts +70 -10
  114. package/src/renderer/shell-surface.ts +41 -15
  115. package/src/renderer/status-glyphs.ts +11 -9
  116. package/src/renderer/tab-strip.ts +148 -34
  117. package/src/renderer/theme.ts +60 -3
  118. package/src/renderer/ui-factory.ts +45 -36
  119. package/src/renderer/ui-primitives.ts +23 -2
  120. package/src/runtime/bootstrap-shell.ts +35 -18
  121. package/src/runtime/diagnostics/panels/index.ts +0 -3
  122. package/src/runtime/ui/model-picker/health-enrichment.ts +3 -0
  123. package/src/runtime/ui/model-picker/types.ts +4 -0
  124. package/src/shell/ui-openers.ts +14 -22
  125. package/src/utils/format-duration.ts +55 -0
  126. package/src/utils/format-number.ts +71 -0
  127. package/src/utils/splash-lines.ts +44 -3
  128. package/src/version.ts +1 -1
  129. package/src/work-plans/work-plan-store.ts +13 -0
  130. package/src/panels/agent-logs-panel.ts +0 -666
  131. package/src/panels/agent-logs-shared.ts +0 -129
  132. package/src/panels/context-visualizer-panel.ts +0 -214
  133. package/src/panels/forensics-panel.ts +0 -364
  134. package/src/panels/panel-picker.ts +0 -106
  135. package/src/panels/provider-account-snapshot.ts +0 -259
  136. package/src/panels/provider-accounts-panel.ts +0 -218
  137. package/src/panels/provider-stats-panel.ts +0 -366
  138. package/src/panels/schedule-panel.ts +0 -342
  139. package/src/panels/watchers-panel.ts +0 -193
  140. package/src/renderer/panel-picker-overlay.ts +0 -202
  141. package/src/renderer/panel-tab-bar.ts +0 -69
  142. package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
  143. package/src/runtime/diagnostics/panels/policy.ts +0 -177
@@ -1,41 +1,28 @@
1
1
  import { promises as fsPromises } from 'node:fs';
2
- import { join } from 'node:path';
2
+ import { join, sep } 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 { SearchableListPanel } from './scrollable-list-panel.ts';
7
+ import { ScrollableListPanel } from './scrollable-list-panel.ts';
8
+ import { FilePreviewPanel } from './file-preview-panel.ts';
9
+ import type { PanelIntegrationContext } from './types.ts';
8
10
  import type { ComponentHealthMonitor } from '../runtime/perf/panel-health-monitor.ts';
9
- import type { ShellPathService } from '@/runtime/index.ts';
11
+ import { listInstalledEcosystemEntries, type EcosystemCatalogPathOptions, type ShellPathService } from '@/runtime/index.ts';
10
12
  import {
11
13
  buildPanelLine,
12
14
  buildPanelWorkspace,
13
15
  DEFAULT_PANEL_PALETTE,
16
+ extendPalette,
14
17
  } from './polish.ts';
15
- import {
16
- getPanelSearchFocusTransition,
17
- isPanelSearchCancel,
18
- } from './search-focus.ts';
19
18
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
20
19
 
21
- const C = {
22
- ...DEFAULT_PANEL_PALETTE,
23
- header: '#94a3b8',
24
- headerBg: '#1e293b',
25
- searchFg: '#f97316',
26
- searchBg: '#1e293b',
27
- label: '#64748b',
28
- value: '#e2e8f0',
29
- dim: '#64748b',
30
- empty: '#334155',
31
- selectedFg: '#e2e8f0',
32
- selectedBg: '#1e3a5f',
33
- project: '#38bdf8',
34
- global: '#a78bfa',
35
- hint: '#475569',
36
- path: '#94a3b8',
37
- selectBg: '#1e3a5f',
38
- } as const;
20
+ // Domain accents only; base chrome (header/headerBg/label/value/dim/empty/
21
+ // selectBg) comes from DEFAULT_PANEL_PALETTE.
22
+ const C = extendPalette(DEFAULT_PANEL_PALETTE, {
23
+ project: '#38bdf8', // project-local skill origin
24
+ global: '#a78bfa', // global skill origin
25
+ } as const);
39
26
 
40
27
  export type SkillOrigin = 'project-local' | 'global' | 'custom';
41
28
 
@@ -47,11 +34,20 @@ export interface SkillRecord {
47
34
  dependencies: string[];
48
35
  includes: string[];
49
36
  frontmatter: Record<string, string>;
37
+ /**
38
+ * Set when this skill's file lives under an installed ecosystem-marketplace
39
+ * receipt's targetPath — the receipt's provenance summary (e.g. curated
40
+ * source / signature info), so marketplace-installed skills read as such
41
+ * instead of looking identical to hand-authored ones.
42
+ */
43
+ marketplaceProvenance?: string;
50
44
  }
51
45
 
52
46
  export interface SkillsPanelOptions {
53
47
  shellPaths: Pick<ShellPathService, 'workingDirectory' | 'homeDirectory'>;
54
48
  componentHealthMonitor?: ComponentHealthMonitor;
49
+ /** When provided, installed skill entries are tagged with marketplace provenance. */
50
+ ecosystemPaths?: EcosystemCatalogPathOptions;
55
51
  }
56
52
 
57
53
  function parseFrontmatter(content: string): Record<string, string> {
@@ -133,7 +129,31 @@ async function scanSkillDirectory(root: string, origin: SkillOrigin): Promise<Sk
133
129
  return records;
134
130
  }
135
131
 
136
- export async function discoverSkills(shellPaths: Pick<ShellPathService, 'workingDirectory' | 'homeDirectory'>): Promise<SkillRecord[]> {
132
+ /**
133
+ * Tags records whose file lives under an installed ecosystem-marketplace
134
+ * receipt's targetPath with that receipt's provenance summary. Best-effort:
135
+ * a lookup failure (e.g. no ecosystem catalogs configured) leaves records
136
+ * untagged rather than failing skill discovery.
137
+ */
138
+ function applyMarketplaceProvenance(records: SkillRecord[], ecosystemPaths: EcosystemCatalogPathOptions): SkillRecord[] {
139
+ let receipts: ReturnType<typeof listInstalledEcosystemEntries>;
140
+ try {
141
+ receipts = listInstalledEcosystemEntries('skill', ecosystemPaths);
142
+ } catch {
143
+ return records;
144
+ }
145
+ if (receipts.length === 0) return records;
146
+ return records.map((record) => {
147
+ const receipt = receipts.find((candidate) => record.path === candidate.targetPath || record.path.startsWith(`${candidate.targetPath}${sep}`));
148
+ if (!receipt) return record;
149
+ return { ...record, marketplaceProvenance: receipt.provenanceSummary };
150
+ });
151
+ }
152
+
153
+ export async function discoverSkills(
154
+ shellPaths: Pick<ShellPathService, 'workingDirectory' | 'homeDirectory'>,
155
+ ecosystemPaths?: EcosystemCatalogPathOptions,
156
+ ): Promise<SkillRecord[]> {
137
157
  const cwd = shellPaths.workingDirectory;
138
158
  const homeDir = shellPaths.homeDirectory;
139
159
  const seen = new Set<string>();
@@ -147,7 +167,9 @@ export async function discoverSkills(shellPaths: Pick<ShellPathService, 'working
147
167
  }
148
168
  }
149
169
 
150
- return records.sort((a, b) => {
170
+ const tagged = ecosystemPaths ? applyMarketplaceProvenance(records, ecosystemPaths) : records;
171
+
172
+ return tagged.sort((a, b) => {
151
173
  const originRank = a.origin === b.origin
152
174
  ? 0
153
175
  : a.origin === 'project-local'
@@ -227,27 +249,34 @@ function originColor(origin: SkillOrigin): string {
227
249
  }
228
250
  }
229
251
 
230
- export class SkillsPanel extends SearchableListPanel<SkillRecord> {
252
+ export class SkillsPanel extends ScrollableListPanel<SkillRecord> {
231
253
  private readonly shellPaths: Pick<ShellPathService, 'workingDirectory' | 'homeDirectory'>;
232
- /** Whether the filter input row is focused for typing (vs. list navigation). */
233
- private filterFocused = false;
254
+ private readonly ecosystemPaths: EcosystemCatalogPathOptions | undefined;
234
255
  private cached: SkillRecord[] | null = null;
235
256
  private cacheDirty = true;
236
257
  // I1: confirm state for destructive delete
237
258
  private confirm: ConfirmState | null = null;
238
259
  private readyPromise: Promise<void> | null = null;
260
+ // Staged pending action consumed by handlePanelIntegrationAction (same
261
+ // pattern as diff-panel.ts's pendingOpenPreview): Enter marks the intent
262
+ // here, the actual PanelManager/preview wiring happens once the
263
+ // integration context is available.
264
+ private pendingOpenPreview = false;
239
265
 
240
266
  public constructor(options: SkillsPanelOptions) {
241
- super('skills', 'Skills', 'K', 'monitoring', options.componentHealthMonitor);
267
+ super('skills', 'Skills', '', 'automation-control', options.componentHealthMonitor);
242
268
  this.showSelectionGutter = true; // I5: non-color selection affordance
269
+ this.filterEnabled = true; // WO-153: converged modal '/' filter
270
+ this.filterLabel = 'Filter';
243
271
  this.shellPaths = options.shellPaths;
272
+ this.ecosystemPaths = options.ecosystemPaths;
244
273
  }
245
274
 
246
275
  // -------------------------------------------------------------------------
247
- // SearchableListPanel implementation
276
+ // ScrollableListPanel implementation
248
277
  // -------------------------------------------------------------------------
249
278
 
250
- protected getAllItems(): readonly SkillRecord[] {
279
+ protected getItems(): readonly SkillRecord[] {
251
280
  return this.cached ?? [];
252
281
  }
253
282
 
@@ -255,9 +284,9 @@ export class SkillsPanel extends SearchableListPanel<SkillRecord> {
255
284
  const p = (async () => {
256
285
  try {
257
286
  await this.withLoading('Scanning skills\u2026', async () => {
258
- this.cached = await discoverSkills(this.shellPaths);
287
+ this.cached = await discoverSkills(this.shellPaths, this.ecosystemPaths);
259
288
  this.cacheDirty = false;
260
- this.invalidateFilter();
289
+ this.markDirty();
261
290
  });
262
291
  } catch (err) {
263
292
  this.setError(summarizeError(err));
@@ -273,14 +302,31 @@ export class SkillsPanel extends SearchableListPanel<SkillRecord> {
273
302
  return this.readyPromise ?? Promise.resolve();
274
303
  }
275
304
 
276
- protected matchesSearch(skill: SkillRecord, query: string): boolean {
277
- const q = query.trim().toLowerCase();
278
- if (!q) return true;
305
+ /**
306
+ * REAL delete: removes the confirmed skill's markdown file from disk, then
307
+ * rescans so the list reflects the change immediately. Replaces the old
308
+ * setError('Delete via shell: rm …') signpost, which never actually
309
+ * deleted anything.
310
+ */
311
+ private async _deleteSkill(path: string): Promise<void> {
312
+ try {
313
+ await fsPromises.rm(path);
314
+ this.cacheDirty = true;
315
+ await this._loadSkillsAsync();
316
+ } catch (err) {
317
+ this.setError(summarizeError(err));
318
+ this.markDirty();
319
+ }
320
+ }
321
+
322
+ /** `q` arrives already trimmed + lower-cased from ScrollableListPanel.getVisibleItems(). */
323
+ protected override filterMatches(skill: SkillRecord, q: string): boolean {
279
324
  const haystack = [
280
325
  skill.name,
281
326
  skill.description,
282
327
  skill.path,
283
328
  skill.origin,
329
+ skill.marketplaceProvenance ?? '',
284
330
  skill.dependencies.join(' '),
285
331
  skill.includes.join(' '),
286
332
  ].join(' ').toLowerCase();
@@ -290,17 +336,22 @@ export class SkillsPanel extends SearchableListPanel<SkillRecord> {
290
336
  protected renderItem(skill: SkillRecord, index: number, selected: boolean, width: number): Line {
291
337
  const bg = selected ? C.selectBg : undefined;
292
338
  const dot = skill.origin === 'project-local' ? '\u25c6' : '\u2022';
339
+ // Marketplace-installed skills get a badge glyph; unbadged column stays a
340
+ // single blank cell so rows stay aligned either way.
341
+ const marketplaceBadge = skill.marketplaceProvenance ? '\u2605' : ' ';
293
342
  const desc = skill.description || 'No description provided.';
294
- const descWidth = Math.max(1, width - 4 - skill.name.length - 6);
343
+ const descWidth = Math.max(1, width - 6 - skill.name.length - 6);
295
344
  const descLines = wordWrap(desc, descWidth);
296
345
  return buildPanelLine(width, [
297
- [selected ? '\u25b8' : ' ', C.selectedFg, bg],
346
+ [selected ? '\u25b8' : ' ', C.value, bg],
298
347
  [' ', C.dim, bg],
299
348
  [dot, originColor(skill.origin), bg],
300
349
  [' ', C.dim, bg],
301
- [skill.name, selected ? C.selectedFg : C.value, bg],
350
+ [marketplaceBadge, C.info, bg],
351
+ [' ', C.dim, bg],
352
+ [skill.name, selected ? C.value : C.value, bg],
302
353
  [' ', C.dim, bg],
303
- [descLines[0] ?? '', selected ? C.selectedFg : C.dim, bg],
354
+ [descLines[0] ?? '', selected ? C.value : C.dim, bg],
304
355
  ]);
305
356
  }
306
357
 
@@ -315,24 +366,66 @@ export class SkillsPanel extends SearchableListPanel<SkillRecord> {
315
366
 
316
367
  public override onActivate(): void {
317
368
  super.onActivate();
318
- this.searchQuery = '';
319
- this.invalidateFilter();
320
- this.filterFocused = false;
369
+ this.filterQuery = '';
370
+ this.filterActive = false;
321
371
  this.cacheDirty = true;
322
372
  void this._loadSkillsAsync();
323
373
  }
324
374
 
325
375
  public override onDestroy(): void {}
326
376
 
377
+ protected override onSelect(_skill: SkillRecord): void {
378
+ // Enter opens the skill's markdown source in the preview panel — see
379
+ // handlePanelIntegrationAction for the actual PanelManager wiring
380
+ // (needs the integration context, not available here). Selection itself
381
+ // is read back from getVisibleItems() in that hook. onSelect is only
382
+ // invoked by ScrollableListPanel's navigation handler when the filter is
383
+ // not active, so no manual guard is needed here.
384
+ this.pendingOpenPreview = true;
385
+ }
386
+
387
+ /**
388
+ * Cross-panel integration hook — Enter opens the selected skill's markdown
389
+ * source in the preview panel via the same open/focus bridge DiffPanel
390
+ * uses (src/input/panel-integration-actions.ts), without this panel
391
+ * needing to know about PanelManager pane/focus mechanics beyond what ctx
392
+ * exposes.
393
+ */
394
+ public handlePanelIntegrationAction(_key: string, ctx: PanelIntegrationContext): boolean {
395
+ if (!this.pendingOpenPreview) return false;
396
+ this.pendingOpenPreview = false;
397
+ const skill = this.getSelectedItem();
398
+ if (!skill) return false;
399
+
400
+ const pm = ctx.panelManager;
401
+ let previewPanel = pm.getPanel('preview');
402
+ if (previewPanel instanceof FilePreviewPanel) {
403
+ const pane = pm.getPaneOf('preview');
404
+ pm.activateById('preview');
405
+ if (pane) pm.focusPane(pane);
406
+ } else {
407
+ const targetPane: 'top' | 'bottom' = pm.isBottomPaneVisible()
408
+ ? (pm.getFocusedPane() === 'top' ? 'bottom' : 'top')
409
+ : 'bottom';
410
+ const opened = pm.open('preview', targetPane);
411
+ pm.show();
412
+ pm.focusPane(targetPane);
413
+ previewPanel = opened instanceof FilePreviewPanel ? opened : null;
414
+ }
415
+ if (previewPanel instanceof FilePreviewPanel) {
416
+ previewPanel.openFile(skill.path);
417
+ return true;
418
+ }
419
+ return false;
420
+ }
421
+
327
422
  public handleInput(key: string): boolean {
328
423
  // I1: y/n confirmation dialog for delete
329
424
  const confirmResult = handleConfirmInput(this.confirm, key);
330
425
  if (confirmResult === 'confirmed') {
331
426
  const toDelete = this.confirm!.subject;
332
427
  this.confirm = null;
333
- // Skills are read from the filesystem — deletion requires a shell command.
334
- // Surface an error directing the user to remove the file manually.
335
- this.setError(`Delete via shell: rm "${toDelete}"`);
428
+ void this._deleteSkill(toDelete);
336
429
  this.markDirty();
337
430
  return true;
338
431
  }
@@ -343,38 +436,11 @@ export class SkillsPanel extends SearchableListPanel<SkillRecord> {
343
436
  }
344
437
  if (confirmResult === 'absorbed') return true;
345
438
 
346
- const items = this.getItems();
347
-
348
- // Filter-focus mode: typing goes into the search query
349
- if (this.filterFocused) {
350
- const transition = getPanelSearchFocusTransition(key, { selectedIndex: this.selectedIndex, itemCount: items.length });
351
- if (transition === 'focus-list') {
352
- this.filterFocused = false;
353
- this.markDirty();
354
- return true;
355
- }
356
- // Escape: also blur filter focus (clear + return to list navigation)
357
- if (isPanelSearchCancel(key)) {
358
- this.filterFocused = false;
359
- // Delegate to super to clear the query. If the query is empty, super
360
- // returns false and escape propagates to the panel dismissal handler —
361
- // this is the intentional double-escape UX (blur filter, then close).
362
- return super.handleInput(key);
363
- }
364
- // Delegate backspace/printable to SearchableListPanel.handleInput
365
- return super.handleInput(key);
366
- }
367
-
368
- const transition = getPanelSearchFocusTransition(key, { selectedIndex: this.selectedIndex, itemCount: items.length });
369
- if (transition === 'focus-search') {
370
- this.filterFocused = true;
371
- this.markDirty();
372
- return true;
373
- }
374
-
375
- // I1: 'd' prompts delete confirmation
376
- if (key === 'd') {
377
- const skill = items[this.selectedIndex];
439
+ // I1: 'd' prompts delete confirmation — only outside filter mode, so 'd'
440
+ // remains typeable into the filter query while it is active (WO-153:
441
+ // converged modal '/' filter coexists with single-letter action keys).
442
+ if (!this.filterActive && key === 'd') {
443
+ const skill = this.getSelectedItem();
378
444
  if (skill) {
379
445
  this.confirm = { subject: skill.path, label: skill.name };
380
446
  this.markDirty();
@@ -382,7 +448,8 @@ export class SkillsPanel extends SearchableListPanel<SkillRecord> {
382
448
  return true;
383
449
  }
384
450
 
385
- // Navigation + search: delegate to SearchableListPanel (up/down/g/G/page/enter + backspace/escape)
451
+ // Navigation + filter: delegate to ScrollableListPanel ('/' enters
452
+ // filter, typing narrows, Esc clears, up/down/g/G/page/enter navigate).
386
453
  return super.handleInput(key);
387
454
  }
388
455
 
@@ -402,27 +469,28 @@ export class SkillsPanel extends SearchableListPanel<SkillRecord> {
402
469
  return lines.slice(0, height);
403
470
  }
404
471
 
405
- // Build filter input line (provided by SearchableListPanel base)
406
- const filterLine = this.buildFilterInputLine(width, 'Filter', this.filterFocused);
407
-
408
472
  // Build detail footer for the currently selected skill
409
- const items = this.getItems();
410
- const selected = items[this.selectedIndex];
473
+ const selected = this.getSelectedItem();
411
474
  const detailLines: Line[] = [];
412
475
  if (selected) {
413
476
  detailLines.push(
414
477
  buildPanelLine(width, [[' Selected: ', C.label], [selected.name, C.value], [' [', C.dim], [originLabel(selected.origin), originColor(selected.origin)], [']', C.dim]]),
415
- buildPanelLine(width, [[' Path: ', C.label], [truncatePathDisplay(selected.path, Math.max(1, width - 8)), C.path]]),
478
+ buildPanelLine(width, [[' Path: ', C.label], [truncatePathDisplay(selected.path, Math.max(1, width - 8)), C.label]]),
416
479
  buildPanelLine(width, [[' Desc: ', C.label], [selected.description || 'No description provided.', C.value]]),
417
480
  buildPanelLine(width, [[' Depends: ', C.label], [selected.dependencies.length > 0 ? selected.dependencies.join(', ') : 'none', C.dim]]),
418
481
  buildPanelLine(width, [[' Includes: ', C.label], [selected.includes.length > 0 ? selected.includes.join(', ') : 'none', C.dim]]),
482
+ buildPanelLine(width, [
483
+ [' Provenance: ', C.label],
484
+ [selected.marketplaceProvenance ?? 'not installed via marketplace', selected.marketplaceProvenance ? C.info : C.dim],
485
+ ]),
419
486
  );
420
487
  }
421
- detailLines.push(buildPanelLine(width, [[' Up/Down navigate / or Up-at-top focus filter Esc blur Backspace clear', C.hint]]));
488
+ detailLines.push(buildPanelLine(width, [[' Up/Down navigate / filter Esc clear Backspace edit', C.dim]]));
489
+ detailLines.push(buildPanelLine(width, [[' Enter open in preview d delete (confirm)', C.dim]]));
422
490
 
491
+ // Filter input line is auto-injected by renderList() (filterEnabled=true).
423
492
  const lines = this.renderList(width, height, {
424
493
  title: 'Skills - discover project-local and global skill packs',
425
- header: [filterLine],
426
494
  footer: detailLines,
427
495
  });
428
496
  return lines;
@@ -1,14 +1,16 @@
1
1
  import type { Line } from '../types/grid.ts';
2
2
  import { createEmptyLine } from '../types/grid.ts';
3
+ import { fitDisplay } from '../utils/terminal-width.ts';
4
+ import { formatElapsed } from '../utils/format-elapsed.ts';
3
5
  import { ScrollableListPanel } from './scrollable-list-panel.ts';
4
- import type { KeyName } from './types.ts';
6
+ import type { KeyName, PanelIntegrationContext } from './types.ts';
5
7
  import type { ProviderSubscription, PendingSubscriptionLogin } from '@pellux/goodvibes-sdk/platform/config';
6
8
  import { listBuiltinSubscriptionProviders } from '@pellux/goodvibes-sdk/platform/config';
7
9
  import { type ConfirmState, handleConfirmInput } from './confirm-state.ts';
8
10
  import type { ServiceInspectionQuery, SubscriptionAccessQuery } from '../runtime/ui-service-queries.ts';
9
11
  import {
10
12
  buildEmptyState,
11
- buildGuidanceLine,
13
+ buildKeyboardHints,
12
14
  buildKeyValueLine,
13
15
  buildPanelListRow,
14
16
  buildPanelLine,
@@ -17,17 +19,9 @@ import {
17
19
  DEFAULT_PANEL_PALETTE,
18
20
  } from './polish.ts';
19
21
 
20
- const C = {
21
- ...DEFAULT_PANEL_PALETTE,
22
- header: '#e2e8f0',
23
- headerBg: '#1e293b',
24
- dim: '#64748b',
25
- info: '#38bdf8',
26
- good: '#22c55e',
27
- warn: '#f59e0b',
28
- bad: '#ef4444',
29
- selectedBg: '#0f172a',
30
- } as const;
22
+ // Base chrome only — title band, state colors, and text tokens all come
23
+ // straight from DEFAULT_PANEL_PALETTE (WO-002).
24
+ const C = DEFAULT_PANEL_PALETTE;
31
25
 
32
26
  interface SubscriptionRow {
33
27
  readonly provider: string;
@@ -55,18 +49,38 @@ function statusColor(status: ReturnType<typeof statusOf>): string {
55
49
  }
56
50
  }
57
51
 
52
+ // Highlight window for "expiring soon" — mirrors the provider-console
53
+ // convention of flagging tokens inside their last day of validity so the
54
+ // operator can refresh/re-login before the subscription actually lapses.
55
+ const EXPIRING_SOON_MS = 24 * 60 * 60 * 1000;
56
+
57
+ function isExpiringSoon(subscription: ProviderSubscription | null): boolean {
58
+ if (!subscription?.expiresAt) return false;
59
+ const remaining = subscription.expiresAt - Date.now();
60
+ return remaining > 0 && remaining <= EXPIRING_SOON_MS;
61
+ }
62
+
58
63
  export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
59
64
  private readonly serviceRegistry: Pick<ServiceInspectionQuery, 'getAll'>;
60
65
  private readonly subscriptionManager: SubscriptionAccessQuery;
61
66
  private rows: SubscriptionRow[] = [];
62
67
  /** Pending logout confirmation — uses project-standard ConfirmState contract. */
63
68
  private confirm: ConfirmState<string> | null = null;
69
+ // Set by handleInput (Enter on an 'available' row) and consumed on the very
70
+ // next handlePanelIntegrationAction dispatch of that same key — handleInput
71
+ // has no ctx.executeCommand, so the actual login-start dispatch happens via
72
+ // the bridge (remote-panel.ts / token-budget-panel.ts pattern).
73
+ private pendingCommand: { name: string; args: string[] } | null = null;
74
+ // Background posture refresh while the panel is visible, so a login/logout
75
+ // that completes elsewhere (e.g. the browser OAuth handshake finishing)
76
+ // shows up without requiring an explicit 'r'.
77
+ private refreshTimer: ReturnType<typeof setInterval> | null = null;
64
78
 
65
79
  public constructor(
66
80
  serviceRegistry: Pick<ServiceInspectionQuery, 'getAll'>,
67
81
  subscriptionManager: SubscriptionAccessQuery,
68
82
  ) {
69
- super('subscription', 'Subscriptions', 'B', 'monitoring');
83
+ super('subscription', 'Subscriptions', 'B', 'providers');
70
84
  this.showSelectionGutter = true; // I5: non-color selection affordance
71
85
  this.serviceRegistry = serviceRegistry;
72
86
  this.subscriptionManager = subscriptionManager;
@@ -75,6 +89,28 @@ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
75
89
  public override onActivate(): void {
76
90
  super.onActivate();
77
91
  this.refresh();
92
+ if (this.refreshTimer === null) {
93
+ this.refreshTimer = this.registerTimer(setInterval(() => {
94
+ this.refresh();
95
+ this.markDirty();
96
+ }, 5_000));
97
+ }
98
+ }
99
+
100
+ public override onDeactivate(): void {
101
+ if (this.refreshTimer !== null) {
102
+ this.clearTimer(this.refreshTimer);
103
+ this.refreshTimer = null;
104
+ }
105
+ }
106
+
107
+ /** Consumes the login-start command queued by handleInput's Enter-on-'available' branch. */
108
+ public handlePanelIntegrationAction(_key: string, ctx: PanelIntegrationContext): boolean {
109
+ if (!this.pendingCommand) return false;
110
+ const { name, args } = this.pendingCommand;
111
+ this.pendingCommand = null;
112
+ void ctx.executeCommand?.(name, args);
113
+ return true;
78
114
  }
79
115
 
80
116
  protected override getPalette() { return C; }
@@ -93,12 +129,17 @@ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
93
129
 
94
130
  protected renderItem(row: SubscriptionRow, index: number, selected: boolean, width: number): Line {
95
131
  const status = statusOf(row);
132
+ // Expiring-soon highlight: the STATUS text stays 'ACTIVE' (the row is
133
+ // still active), but it's colored as a warning and gets a trailing
134
+ // marker so an about-to-lapse token stands out from the list.
135
+ const expiringSoon = isExpiringSoon(row.subscription);
96
136
  return buildPanelListRow(width, [
97
- { text: row.provider.padEnd(16).slice(0, 16), fg: C.value },
98
- { text: ` ${status.toUpperCase().padEnd(12)}`, fg: statusColor(status) },
137
+ { text: fitDisplay(row.provider, 16), fg: C.value },
138
+ { text: ` ${status.toUpperCase().padEnd(12)}`, fg: expiringSoon ? C.warn : statusColor(status) },
99
139
  { text: ` oauth=${row.hasOAuthConfig ? 'yes' : 'no'} `, fg: row.hasOAuthConfig ? C.info : C.dim },
100
140
  { text: ` override=${row.subscription ? 'active' : 'off'}`, fg: row.subscription ? C.good : C.dim },
101
- ], C, { selected, selectedBg: C.selectedBg });
141
+ ...(expiringSoon ? [{ text: ' expiring soon', fg: C.warn }] : []),
142
+ ], C, { selected, selectedBg: C.selectBg });
102
143
  }
103
144
 
104
145
  public handleInput(key: KeyName): boolean {
@@ -120,7 +161,7 @@ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
120
161
  if (confirmResult === 'absorbed') return true;
121
162
 
122
163
  if (this.rows.length === 0) return false;
123
- const selected = this.rows[this.selectedIndex] ?? null;
164
+ const selected = this.getSelectedItem() ?? null;
124
165
  if (key === 'up' || key === 'k') {
125
166
  this.selectedIndex = Math.max(0, this.selectedIndex - 1);
126
167
  this.confirm = null;
@@ -134,10 +175,18 @@ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
134
175
  return true;
135
176
  }
136
177
  if (key === 'enter' || key === 'return') {
137
- if (!selected?.subscription) return false;
138
- this.confirm = { subject: selected.provider, label: selected.provider };
139
- this.markDirty();
140
- return true;
178
+ if (selected?.subscription) {
179
+ this.confirm = { subject: selected.provider, label: selected.provider };
180
+ this.markDirty();
181
+ return true;
182
+ }
183
+ // Enter on an 'available' row starts the real login flow via the
184
+ // bridge instead of leaving it as a printed signpost.
185
+ if (selected && statusOf(selected) === 'available') {
186
+ this.pendingCommand = { name: 'subscription', args: ['login', selected.provider, 'start'] };
187
+ return true;
188
+ }
189
+ return false;
141
190
  }
142
191
  if (key === 'r') {
143
192
  this.refresh();
@@ -177,9 +226,29 @@ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
177
226
  this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.rows.length - 1));
178
227
  }
179
228
 
229
+ /** Footer hints that reflect the current view: confirm-pending vs browsing. */
230
+ private buildFooterHint(width: number): Line {
231
+ if (this.confirm) {
232
+ return buildKeyboardHints(width, [
233
+ { keys: 'y/Enter', label: 'confirm sign out' },
234
+ { keys: 'n/Esc', label: 'cancel' },
235
+ ], C);
236
+ }
237
+ const selected = this.getSelectedItem();
238
+ const hints: Array<{ keys: string; label: string }> = [{ keys: 'Up/Down', label: 'select' }];
239
+ if (selected?.subscription) hints.push({ keys: 'Enter', label: 'sign out' });
240
+ else if (selected?.hasOAuthConfig) hints.push({ keys: 'Enter', label: 'sign in' });
241
+ hints.push({ keys: 'r', label: 'refresh' });
242
+ return buildKeyboardHints(width, hints, C);
243
+ }
244
+
245
+ // WO-139: refresh() moved out of render() (hooks-panel.ts:109 pattern) — it
246
+ // now runs on onActivate(), on 'r', and on the 5s background timer above,
247
+ // instead of rebuilding the row set (service registry scan + builtin
248
+ // provider list + subscription-manager reads) on every single frame.
180
249
  public render(width: number, height: number): Line[] {
181
- this.refresh();
182
250
  this.clampSelection();
251
+ const selected = this.getSelectedItem();
183
252
  const intro = 'Review provider login state, subscription-backed routing, and pending browser auth handshakes.';
184
253
 
185
254
  const activeCount = this.rows.filter((row) => row.subscription).length;
@@ -192,10 +261,9 @@ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
192
261
  { label: 'providers', value: String(this.rows.length), valueColor: C.value },
193
262
  ], C),
194
263
  buildKeyValueLine(width, [
195
- { label: 'selected', value: (this.rows[this.selectedIndex]?.provider ?? 'none'), valueColor: this.rows[this.selectedIndex] ? C.value : C.dim },
196
- { label: 'status', value: this.rows[this.selectedIndex] ? statusOf(this.rows[this.selectedIndex]!) : 'n/a', valueColor: this.rows[this.selectedIndex] ? statusColor(statusOf(this.rows[this.selectedIndex]!)) : C.dim },
264
+ { label: 'selected', value: (selected?.provider ?? 'none'), valueColor: selected ? C.value : C.dim },
265
+ { label: 'status', value: selected ? statusOf(selected) : 'n/a', valueColor: selected ? statusColor(statusOf(selected)) : C.dim },
197
266
  ], C),
198
- buildGuidanceLine(width, '/subscription login <provider> start', 'start or repair browser login for the selected provider route', C),
199
267
  ];
200
268
 
201
269
  // Empty state: render posture + base empty state
@@ -213,8 +281,7 @@ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
213
281
  intro,
214
282
  sections: [{ lines: [...summaryLines, ...emptyLines] }],
215
283
  footerLines: [
216
- buildGuidanceLine(width, '/subscription login <provider> start', 'start browser-based provider login from the packaged subscription surface', C),
217
- buildPanelLine(width, [[' Up/Down move Enter sign out selected provider y/Esc confirm/cancel r refresh', C.dim]]),
284
+ this.buildFooterHint(width),
218
285
  ],
219
286
  palette: C,
220
287
  });
@@ -222,7 +289,7 @@ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
222
289
  return workspace.slice(0, height);
223
290
  }
224
291
 
225
- const selectedRow = this.rows[this.selectedIndex];
292
+ const selectedRow = selected;
226
293
  const detailRows: Line[] = [];
227
294
  if (selectedRow) {
228
295
  detailRows.push(buildKeyValueLine(width, [
@@ -231,12 +298,13 @@ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
231
298
  { label: 'oauth config', value: selectedRow.hasOAuthConfig ? 'present' : 'missing', valueColor: selectedRow.hasOAuthConfig ? C.good : C.bad },
232
299
  ], C));
233
300
  if (selectedRow.subscription) {
301
+ const expiringSoon = isExpiringSoon(selectedRow.subscription);
234
302
  const expires = selectedRow.subscription.expiresAt
235
303
  ? new Date(selectedRow.subscription.expiresAt).toISOString()
236
304
  : 'n/a';
237
305
  detailRows.push(buildKeyValueLine(width, [
238
306
  { label: 'token type', value: selectedRow.subscription.tokenType, valueColor: C.info },
239
- { label: 'expires', value: expires, valueColor: C.dim },
307
+ { label: 'expires', value: expiringSoon ? `${expires} (expiring soon)` : expires, valueColor: expiringSoon ? C.warn : C.dim },
240
308
  ], C));
241
309
  detailRows.push(buildPanelLine(width, [[
242
310
  ` ${selectedRow.subscription.overrideAmbientApiKeys
@@ -244,13 +312,17 @@ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
244
312
  : 'Stored for subscription-backed flows. Ambient API-key resolution remains unchanged.'}`,
245
313
  C.dim,
246
314
  ]]));
315
+ if (expiringSoon) {
316
+ detailRows.push(buildPanelLine(width, [[' Token expires within 24h — sign out and re-login (Enter) to refresh it before it lapses.', C.warn]]));
317
+ }
247
318
  if (this.confirm?.subject === selectedRow.provider) {
248
319
  detailRows.push(buildPanelLine(width, [[` Sign out ${selectedRow.provider}? Press y or Enter to confirm, n or Esc to cancel.`, C.warn]]));
249
320
  }
250
321
  } else if (selectedRow.pending) {
251
- detailRows.push(buildPanelLine(width, [[' Login is pending. Finish with /subscription login <provider> finish <code>.', C.warn]]));
322
+ const age = formatElapsed(Date.now() - selectedRow.pending.createdAt);
323
+ detailRows.push(buildPanelLine(width, [[` Login is pending (started ${age} ago). Finish with /subscription login <provider> finish <code>.`, C.warn]]));
252
324
  } else if (selectedRow.hasOAuthConfig) {
253
- detailRows.push(buildPanelLine(width, [[' Ready for login. Start with /subscription login <provider> start.', C.dim]]));
325
+ detailRows.push(buildPanelLine(width, [[' Ready for login. Press Enter to start it here.', C.dim]]));
254
326
  } else {
255
327
  detailRows.push(buildPanelLine(width, [[' Add a provider-specific OAuth config or enable a built-in subscription provider to use subscription login.', C.bad]]));
256
328
  }
@@ -263,8 +335,7 @@ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
263
335
  header: headerLines,
264
336
  footer: [
265
337
  ...detailRows,
266
- buildGuidanceLine(width, '/subscription login <provider> start', 'start browser-based provider login from the packaged subscription surface', C),
267
- buildPanelLine(width, [[' Up/Down move Enter sign out selected provider y/Esc confirm/cancel r refresh', C.dim]]),
338
+ this.buildFooterHint(width),
268
339
  ],
269
340
  });
270
341
  }