@pellux/goodvibes-tui 0.28.0 → 1.0.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 (144) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/core/conversation-line-cache.ts +432 -0
  5. package/src/core/conversation-rendering.ts +11 -3
  6. package/src/core/conversation.ts +50 -4
  7. package/src/input/commands/cost-runtime.ts +49 -0
  8. package/src/input/commands/operator-runtime.ts +5 -1
  9. package/src/input/commands.ts +2 -0
  10. package/src/input/handler-content-actions.ts +8 -3
  11. package/src/input/handler-feed-routes.ts +12 -35
  12. package/src/input/handler-feed.ts +4 -8
  13. package/src/input/handler-shortcuts.ts +51 -0
  14. package/src/input/handler.ts +43 -6
  15. package/src/input/input-history.ts +17 -4
  16. package/src/input/keybindings.ts +48 -8
  17. package/src/input/panel-integration-actions.ts +107 -1
  18. package/src/main.ts +19 -11
  19. package/src/panels/agent-inspector-panel.ts +125 -36
  20. package/src/panels/agent-inspector-shared.ts +144 -0
  21. package/src/panels/approval-panel.ts +67 -16
  22. package/src/panels/automation-control-panel.ts +368 -124
  23. package/src/panels/base-panel.ts +1 -1
  24. package/src/panels/builtin/agent.ts +28 -37
  25. package/src/panels/builtin/development.ts +40 -32
  26. package/src/panels/builtin/knowledge.ts +15 -6
  27. package/src/panels/builtin/operations.ts +178 -117
  28. package/src/panels/builtin/session.ts +35 -9
  29. package/src/panels/builtin/shared.ts +98 -6
  30. package/src/panels/cockpit-panel.ts +232 -73
  31. package/src/panels/communication-panel.ts +58 -20
  32. package/src/panels/confirm-state.ts +8 -1
  33. package/src/panels/control-plane-panel.ts +351 -118
  34. package/src/panels/cost-tracker-panel.ts +165 -7
  35. package/src/panels/debug-panel.ts +312 -159
  36. package/src/panels/diff-panel.ts +209 -57
  37. package/src/panels/docs-panel.ts +152 -66
  38. package/src/panels/eval-panel.ts +220 -42
  39. package/src/panels/file-explorer-panel.ts +202 -122
  40. package/src/panels/file-preview-panel.ts +132 -57
  41. package/src/panels/git-panel.ts +310 -129
  42. package/src/panels/hooks-panel.ts +150 -22
  43. package/src/panels/incident-review-panel.ts +223 -35
  44. package/src/panels/index.ts +0 -4
  45. package/src/panels/intelligence-panel.ts +212 -86
  46. package/src/panels/knowledge-graph-panel.ts +461 -101
  47. package/src/panels/local-auth-panel.ts +240 -28
  48. package/src/panels/marketplace-panel.ts +193 -26
  49. package/src/panels/memory-panel.ts +78 -77
  50. package/src/panels/ops-control-panel.ts +146 -29
  51. package/src/panels/ops-strategy-panel.ts +72 -4
  52. package/src/panels/orchestration-panel.ts +231 -69
  53. package/src/panels/panel-list-panel.ts +154 -131
  54. package/src/panels/panel-manager.ts +97 -9
  55. package/src/panels/plan-dashboard-panel.ts +333 -17
  56. package/src/panels/plugins-panel.ts +184 -29
  57. package/src/panels/policy-panel.ts +210 -38
  58. package/src/panels/polish-core.ts +7 -2
  59. package/src/panels/polish.ts +23 -4
  60. package/src/panels/project-planning-answer-actions.ts +134 -0
  61. package/src/panels/project-planning-panel.ts +62 -113
  62. package/src/panels/provider-health-panel.ts +434 -518
  63. package/src/panels/provider-health-routes.ts +203 -0
  64. package/src/panels/provider-health-tracker.ts +194 -6
  65. package/src/panels/provider-health-views.ts +560 -0
  66. package/src/panels/qr-panel.ts +116 -30
  67. package/src/panels/remote-panel.ts +114 -36
  68. package/src/panels/routes-panel.ts +63 -22
  69. package/src/panels/sandbox-panel.ts +174 -42
  70. package/src/panels/scrollable-list-panel.ts +19 -135
  71. package/src/panels/security-panel.ts +133 -33
  72. package/src/panels/services-panel.ts +116 -64
  73. package/src/panels/session-browser-panel.ts +73 -2
  74. package/src/panels/session-maintenance.ts +4 -122
  75. package/src/panels/settings-sync-panel.ts +335 -72
  76. package/src/panels/skills-panel.ts +157 -89
  77. package/src/panels/subscription-panel.ts +86 -33
  78. package/src/panels/symbol-outline-panel.ts +248 -108
  79. package/src/panels/system-messages-panel.ts +114 -13
  80. package/src/panels/tasks-panel.ts +326 -139
  81. package/src/panels/thinking-panel.ts +43 -10
  82. package/src/panels/token-budget-panel.ts +194 -18
  83. package/src/panels/tool-inspector-panel.ts +144 -34
  84. package/src/panels/types.ts +40 -4
  85. package/src/panels/work-plan-panel.ts +280 -17
  86. package/src/panels/worktree-panel.ts +175 -42
  87. package/src/panels/wrfc-panel.ts +116 -24
  88. package/src/renderer/agent-detail-modal.ts +37 -8
  89. package/src/renderer/code-block.ts +58 -28
  90. package/src/renderer/context-inspector.ts +3 -6
  91. package/src/renderer/conversation-overlays.ts +25 -11
  92. package/src/renderer/conversation-surface.ts +1 -21
  93. package/src/renderer/diff-view.ts +6 -4
  94. package/src/renderer/footer-tips.ts +41 -0
  95. package/src/renderer/fullscreen-primitives.ts +23 -17
  96. package/src/renderer/fullscreen-workspace.ts +2 -7
  97. package/src/renderer/help-overlay.ts +91 -14
  98. package/src/renderer/hint-grammar.ts +52 -0
  99. package/src/renderer/history-search-overlay.ts +10 -16
  100. package/src/renderer/layout.ts +7 -7
  101. package/src/renderer/markdown.ts +9 -1
  102. package/src/renderer/modal-factory.ts +23 -15
  103. package/src/renderer/model-picker-overlay.ts +8 -485
  104. package/src/renderer/model-workspace.ts +9 -24
  105. package/src/renderer/overlay-box.ts +23 -19
  106. package/src/renderer/process-indicator.ts +15 -25
  107. package/src/renderer/profile-picker-modal.ts +13 -14
  108. package/src/renderer/prompt-content-width.ts +16 -0
  109. package/src/renderer/search-overlay.ts +27 -6
  110. package/src/renderer/selection-modal-overlay.ts +3 -1
  111. package/src/renderer/semantic-diff.ts +1 -1
  112. package/src/renderer/session-picker-modal.ts +6 -4
  113. package/src/renderer/settings-modal-helpers.ts +10 -34
  114. package/src/renderer/settings-modal.ts +70 -10
  115. package/src/renderer/shell-surface.ts +54 -15
  116. package/src/renderer/status-glyphs.ts +11 -9
  117. package/src/renderer/surface-layout.ts +0 -12
  118. package/src/renderer/term-caps.ts +1 -1
  119. package/src/renderer/theme.ts +60 -3
  120. package/src/renderer/tool-call.ts +6 -20
  121. package/src/renderer/ui-factory.ts +48 -37
  122. package/src/renderer/ui-primitives.ts +41 -3
  123. package/src/runtime/bootstrap-shell.ts +35 -18
  124. package/src/runtime/diagnostics/panels/index.ts +0 -3
  125. package/src/runtime/render-scheduler.ts +80 -0
  126. package/src/shell/ui-openers.ts +14 -22
  127. package/src/utils/format-duration.ts +2 -2
  128. package/src/utils/splash-lines.ts +54 -5
  129. package/src/version.ts +1 -1
  130. package/src/work-plans/work-plan-store.ts +13 -0
  131. package/src/panels/agent-logs-panel.ts +0 -635
  132. package/src/panels/agent-logs-shared.ts +0 -129
  133. package/src/panels/context-visualizer-panel.ts +0 -238
  134. package/src/panels/forensics-panel.ts +0 -378
  135. package/src/panels/provider-account-snapshot.ts +0 -259
  136. package/src/panels/provider-accounts-panel.ts +0 -255
  137. package/src/panels/provider-stats-panel.ts +0 -391
  138. package/src/panels/schedule-panel.ts +0 -365
  139. package/src/panels/watchers-panel.ts +0 -213
  140. package/src/renderer/file-tree.ts +0 -153
  141. package/src/renderer/progress.ts +0 -99
  142. package/src/renderer/status-token.ts +0 -67
  143. package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
  144. 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,15 +1,15 @@
1
1
  import type { Line } from '../types/grid.ts';
2
2
  import { createEmptyLine } from '../types/grid.ts';
3
3
  import { fitDisplay } from '../utils/terminal-width.ts';
4
+ import { formatElapsed } from '../utils/format-elapsed.ts';
4
5
  import { ScrollableListPanel } from './scrollable-list-panel.ts';
5
- import type { KeyName } from './types.ts';
6
+ import type { KeyName, PanelIntegrationContext } from './types.ts';
6
7
  import type { ProviderSubscription, PendingSubscriptionLogin } from '@pellux/goodvibes-sdk/platform/config';
7
8
  import { listBuiltinSubscriptionProviders } from '@pellux/goodvibes-sdk/platform/config';
8
9
  import { type ConfirmState, handleConfirmInput } from './confirm-state.ts';
9
10
  import type { ServiceInspectionQuery, SubscriptionAccessQuery } from '../runtime/ui-service-queries.ts';
10
11
  import {
11
12
  buildEmptyState,
12
- buildGuidanceLine,
13
13
  buildKeyboardHints,
14
14
  buildKeyValueLine,
15
15
  buildPanelListRow,
@@ -19,17 +19,9 @@ import {
19
19
  DEFAULT_PANEL_PALETTE,
20
20
  } from './polish.ts';
21
21
 
22
- const C = {
23
- ...DEFAULT_PANEL_PALETTE,
24
- header: '#e2e8f0',
25
- headerBg: '#1e293b',
26
- dim: '#64748b',
27
- info: '#38bdf8',
28
- good: '#22c55e',
29
- warn: '#f59e0b',
30
- bad: '#ef4444',
31
- selectedBg: '#0f172a',
32
- } 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;
33
25
 
34
26
  interface SubscriptionRow {
35
27
  readonly provider: string;
@@ -57,18 +49,38 @@ function statusColor(status: ReturnType<typeof statusOf>): string {
57
49
  }
58
50
  }
59
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
+
60
63
  export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
61
64
  private readonly serviceRegistry: Pick<ServiceInspectionQuery, 'getAll'>;
62
65
  private readonly subscriptionManager: SubscriptionAccessQuery;
63
66
  private rows: SubscriptionRow[] = [];
64
67
  /** Pending logout confirmation — uses project-standard ConfirmState contract. */
65
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;
66
78
 
67
79
  public constructor(
68
80
  serviceRegistry: Pick<ServiceInspectionQuery, 'getAll'>,
69
81
  subscriptionManager: SubscriptionAccessQuery,
70
82
  ) {
71
- super('subscription', 'Subscriptions', 'B', 'monitoring');
83
+ super('subscription', 'Subscriptions', 'B', 'providers');
72
84
  this.showSelectionGutter = true; // I5: non-color selection affordance
73
85
  this.serviceRegistry = serviceRegistry;
74
86
  this.subscriptionManager = subscriptionManager;
@@ -77,6 +89,28 @@ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
77
89
  public override onActivate(): void {
78
90
  super.onActivate();
79
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;
80
114
  }
81
115
 
82
116
  protected override getPalette() { return C; }
@@ -95,12 +129,17 @@ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
95
129
 
96
130
  protected renderItem(row: SubscriptionRow, index: number, selected: boolean, width: number): Line {
97
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);
98
136
  return buildPanelListRow(width, [
99
137
  { text: fitDisplay(row.provider, 16), fg: C.value },
100
- { text: ` ${status.toUpperCase().padEnd(12)}`, fg: statusColor(status) },
138
+ { text: ` ${status.toUpperCase().padEnd(12)}`, fg: expiringSoon ? C.warn : statusColor(status) },
101
139
  { text: ` oauth=${row.hasOAuthConfig ? 'yes' : 'no'} `, fg: row.hasOAuthConfig ? C.info : C.dim },
102
140
  { text: ` override=${row.subscription ? 'active' : 'off'}`, fg: row.subscription ? C.good : C.dim },
103
- ], C, { selected, selectedBg: C.selectedBg });
141
+ ...(expiringSoon ? [{ text: ' expiring soon', fg: C.warn }] : []),
142
+ ], C, { selected, selectedBg: C.selectBg });
104
143
  }
105
144
 
106
145
  public handleInput(key: KeyName): boolean {
@@ -122,7 +161,7 @@ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
122
161
  if (confirmResult === 'absorbed') return true;
123
162
 
124
163
  if (this.rows.length === 0) return false;
125
- const selected = this.rows[this.selectedIndex] ?? null;
164
+ const selected = this.getSelectedItem() ?? null;
126
165
  if (key === 'up' || key === 'k') {
127
166
  this.selectedIndex = Math.max(0, this.selectedIndex - 1);
128
167
  this.confirm = null;
@@ -136,10 +175,18 @@ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
136
175
  return true;
137
176
  }
138
177
  if (key === 'enter' || key === 'return') {
139
- if (!selected?.subscription) return false;
140
- this.confirm = { subject: selected.provider, label: selected.provider };
141
- this.markDirty();
142
- 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;
143
190
  }
144
191
  if (key === 'r') {
145
192
  this.refresh();
@@ -187,17 +234,21 @@ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
187
234
  { keys: 'n/Esc', label: 'cancel' },
188
235
  ], C);
189
236
  }
190
- const selected = this.rows[this.selectedIndex];
237
+ const selected = this.getSelectedItem();
191
238
  const hints: Array<{ keys: string; label: string }> = [{ keys: 'Up/Down', label: 'select' }];
192
239
  if (selected?.subscription) hints.push({ keys: 'Enter', label: 'sign out' });
193
- else if (selected?.hasOAuthConfig) hints.push({ keys: '/subscription login <p> start', label: 'sign in' });
240
+ else if (selected?.hasOAuthConfig) hints.push({ keys: 'Enter', label: 'sign in' });
194
241
  hints.push({ keys: 'r', label: 'refresh' });
195
242
  return buildKeyboardHints(width, hints, C);
196
243
  }
197
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.
198
249
  public render(width: number, height: number): Line[] {
199
- this.refresh();
200
250
  this.clampSelection();
251
+ const selected = this.getSelectedItem();
201
252
  const intro = 'Review provider login state, subscription-backed routing, and pending browser auth handshakes.';
202
253
 
203
254
  const activeCount = this.rows.filter((row) => row.subscription).length;
@@ -210,10 +261,9 @@ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
210
261
  { label: 'providers', value: String(this.rows.length), valueColor: C.value },
211
262
  ], C),
212
263
  buildKeyValueLine(width, [
213
- { label: 'selected', value: (this.rows[this.selectedIndex]?.provider ?? 'none'), valueColor: this.rows[this.selectedIndex] ? C.value : C.dim },
214
- { 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 },
215
266
  ], C),
216
- buildGuidanceLine(width, '/subscription login <provider> start', 'start or repair browser login for the selected provider route', C),
217
267
  ];
218
268
 
219
269
  // Empty state: render posture + base empty state
@@ -231,7 +281,6 @@ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
231
281
  intro,
232
282
  sections: [{ lines: [...summaryLines, ...emptyLines] }],
233
283
  footerLines: [
234
- buildGuidanceLine(width, '/subscription login <provider> start', 'start browser-based provider login from the packaged subscription surface', C),
235
284
  this.buildFooterHint(width),
236
285
  ],
237
286
  palette: C,
@@ -240,7 +289,7 @@ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
240
289
  return workspace.slice(0, height);
241
290
  }
242
291
 
243
- const selectedRow = this.rows[this.selectedIndex];
292
+ const selectedRow = selected;
244
293
  const detailRows: Line[] = [];
245
294
  if (selectedRow) {
246
295
  detailRows.push(buildKeyValueLine(width, [
@@ -249,12 +298,13 @@ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
249
298
  { label: 'oauth config', value: selectedRow.hasOAuthConfig ? 'present' : 'missing', valueColor: selectedRow.hasOAuthConfig ? C.good : C.bad },
250
299
  ], C));
251
300
  if (selectedRow.subscription) {
301
+ const expiringSoon = isExpiringSoon(selectedRow.subscription);
252
302
  const expires = selectedRow.subscription.expiresAt
253
303
  ? new Date(selectedRow.subscription.expiresAt).toISOString()
254
304
  : 'n/a';
255
305
  detailRows.push(buildKeyValueLine(width, [
256
306
  { label: 'token type', value: selectedRow.subscription.tokenType, valueColor: C.info },
257
- { label: 'expires', value: expires, valueColor: C.dim },
307
+ { label: 'expires', value: expiringSoon ? `${expires} (expiring soon)` : expires, valueColor: expiringSoon ? C.warn : C.dim },
258
308
  ], C));
259
309
  detailRows.push(buildPanelLine(width, [[
260
310
  ` ${selectedRow.subscription.overrideAmbientApiKeys
@@ -262,13 +312,17 @@ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
262
312
  : 'Stored for subscription-backed flows. Ambient API-key resolution remains unchanged.'}`,
263
313
  C.dim,
264
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
+ }
265
318
  if (this.confirm?.subject === selectedRow.provider) {
266
319
  detailRows.push(buildPanelLine(width, [[` Sign out ${selectedRow.provider}? Press y or Enter to confirm, n or Esc to cancel.`, C.warn]]));
267
320
  }
268
321
  } else if (selectedRow.pending) {
269
- 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]]));
270
324
  } else if (selectedRow.hasOAuthConfig) {
271
- 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]]));
272
326
  } else {
273
327
  detailRows.push(buildPanelLine(width, [[' Add a provider-specific OAuth config or enable a built-in subscription provider to use subscription login.', C.bad]]));
274
328
  }
@@ -281,7 +335,6 @@ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
281
335
  header: headerLines,
282
336
  footer: [
283
337
  ...detailRows,
284
- buildGuidanceLine(width, '/subscription login <provider> start', 'start browser-based provider login from the packaged subscription surface', C),
285
338
  this.buildFooterHint(width),
286
339
  ],
287
340
  });