@pellux/goodvibes-tui 0.26.0 → 0.28.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 (148) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +12 -7
  3. package/docs/foundation-artifacts/operator-contract.json +4 -1
  4. package/package.json +2 -2
  5. package/src/cli/bundle-command.ts +2 -1
  6. package/src/cli/service-posture.ts +2 -1
  7. package/src/core/context-usage.ts +53 -0
  8. package/src/core/conversation-rendering.ts +2 -2
  9. package/src/core/conversation.ts +53 -8
  10. package/src/core/session-recovery.ts +26 -18
  11. package/src/core/system-message-router.ts +42 -26
  12. package/src/core/turn-event-wiring.ts +13 -16
  13. package/src/daemon/handlers/calendar/caldav-client.ts +2 -1
  14. package/src/daemon/handlers/inbox/index.ts +2 -1
  15. package/src/daemon/handlers/inbox/poller.ts +2 -1
  16. package/src/daemon/handlers/inbox/providers/discord.ts +2 -1
  17. package/src/daemon/handlers/inbox/providers/email.ts +2 -1
  18. package/src/daemon/handlers/inbox/providers/route-util.ts +2 -1
  19. package/src/daemon/handlers/inbox/providers/slack.ts +2 -1
  20. package/src/daemon/handlers/triage/integration.ts +2 -1
  21. package/src/daemon/handlers/triage/pipeline.ts +2 -1
  22. package/src/daemon/handlers/triage/tagger/discord.ts +2 -1
  23. package/src/daemon/handlers/triage/tagger/imap.ts +4 -3
  24. package/src/export/gist-uploader.ts +3 -1
  25. package/src/input/command-registry.ts +1 -0
  26. package/src/input/commands/cloudflare-runtime.ts +2 -1
  27. package/src/input/commands/eval.ts +4 -3
  28. package/src/input/commands/guidance-runtime.ts +2 -4
  29. package/src/input/commands/health-runtime.ts +13 -7
  30. package/src/input/commands/knowledge.ts +2 -1
  31. package/src/input/commands/runtime-services.ts +40 -10
  32. package/src/input/handler-command-route.ts +1 -1
  33. package/src/input/handler-feed-routes.ts +61 -4
  34. package/src/input/handler-feed.ts +13 -0
  35. package/src/input/handler-interactions.ts +2 -1
  36. package/src/input/handler-modal-routes.ts +2 -1
  37. package/src/input/handler-onboarding-cloudflare.ts +2 -1
  38. package/src/input/handler-onboarding.ts +8 -8
  39. package/src/input/handler-picker-routes.ts +18 -13
  40. package/src/input/handler-ui-state.ts +5 -1
  41. package/src/input/keybindings.ts +5 -0
  42. package/src/input/model-picker.ts +5 -0
  43. package/src/input/panel-integration-actions.ts +10 -0
  44. package/src/input/tts-settings-actions.ts +2 -1
  45. package/src/main.ts +52 -60
  46. package/src/panels/agent-inspector-panel.ts +90 -15
  47. package/src/panels/agent-inspector-shared.ts +3 -3
  48. package/src/panels/agent-logs-panel.ts +149 -72
  49. package/src/panels/approval-panel.ts +146 -95
  50. package/src/panels/automation-control-panel.ts +24 -1
  51. package/src/panels/base-panel.ts +28 -3
  52. package/src/panels/builtin/agent.ts +3 -1
  53. package/src/panels/builtin/development.ts +2 -1
  54. package/src/panels/builtin/session.ts +1 -1
  55. package/src/panels/cockpit-panel.ts +24 -5
  56. package/src/panels/cockpit-read-model.ts +2 -1
  57. package/src/panels/communication-panel.ts +108 -43
  58. package/src/panels/context-visualizer-panel.ts +49 -15
  59. package/src/panels/control-plane-panel.ts +27 -1
  60. package/src/panels/cost-tracker-panel.ts +87 -65
  61. package/src/panels/debug-panel.ts +61 -37
  62. package/src/panels/diff-panel.ts +59 -30
  63. package/src/panels/docs-panel.ts +30 -24
  64. package/src/panels/eval-panel.ts +130 -81
  65. package/src/panels/expandable-list-panel.ts +189 -0
  66. package/src/panels/file-explorer-panel.ts +42 -42
  67. package/src/panels/file-preview-panel.ts +11 -3
  68. package/src/panels/forensics-panel.ts +27 -13
  69. package/src/panels/git-panel.ts +65 -84
  70. package/src/panels/hooks-panel.ts +36 -2
  71. package/src/panels/incident-review-panel.ts +31 -10
  72. package/src/panels/intelligence-panel.ts +152 -71
  73. package/src/panels/knowledge-graph-panel.ts +89 -27
  74. package/src/panels/local-auth-panel.ts +17 -11
  75. package/src/panels/marketplace-panel.ts +33 -13
  76. package/src/panels/memory-panel.ts +7 -5
  77. package/src/panels/ops-control-panel.ts +69 -7
  78. package/src/panels/ops-strategy-panel.ts +61 -43
  79. package/src/panels/orchestration-panel.ts +34 -4
  80. package/src/panels/panel-list-panel.ts +33 -8
  81. package/src/panels/panel-manager.ts +23 -4
  82. package/src/panels/plan-dashboard-panel.ts +183 -66
  83. package/src/panels/plugins-panel.ts +48 -10
  84. package/src/panels/policy-panel.ts +60 -23
  85. package/src/panels/polish-core.ts +157 -0
  86. package/src/panels/polish-tables.ts +258 -0
  87. package/src/panels/polish.ts +44 -145
  88. package/src/panels/project-planning-panel.ts +27 -4
  89. package/src/panels/provider-accounts-panel.ts +55 -18
  90. package/src/panels/provider-health-panel.ts +118 -87
  91. package/src/panels/provider-stats-panel.ts +47 -22
  92. package/src/panels/qr-panel.ts +23 -11
  93. package/src/panels/remote-panel.ts +12 -5
  94. package/src/panels/routes-panel.ts +27 -5
  95. package/src/panels/sandbox-panel.ts +62 -27
  96. package/src/panels/schedule-panel.ts +44 -24
  97. package/src/panels/scrollable-list-panel.ts +124 -10
  98. package/src/panels/security-panel.ts +72 -20
  99. package/src/panels/services-panel.ts +68 -22
  100. package/src/panels/session-browser-panel.ts +42 -26
  101. package/src/panels/session-maintenance.ts +2 -2
  102. package/src/panels/settings-sync-panel.ts +30 -15
  103. package/src/panels/skills-panel.ts +2 -1
  104. package/src/panels/subscription-panel.ts +21 -3
  105. package/src/panels/symbol-outline-panel.ts +40 -47
  106. package/src/panels/system-messages-panel.ts +60 -27
  107. package/src/panels/tasks-panel.ts +36 -14
  108. package/src/panels/thinking-panel.ts +32 -36
  109. package/src/panels/token-budget-panel.ts +80 -53
  110. package/src/panels/tool-inspector-panel.ts +37 -39
  111. package/src/panels/types.ts +25 -0
  112. package/src/panels/watchers-panel.ts +25 -5
  113. package/src/panels/work-plan-panel.ts +127 -35
  114. package/src/panels/worktree-panel.ts +65 -20
  115. package/src/panels/wrfc-panel-format.ts +133 -0
  116. package/src/panels/wrfc-panel.ts +53 -147
  117. package/src/renderer/agent-detail-modal.ts +2 -2
  118. package/src/renderer/compaction-preview.ts +2 -1
  119. package/src/renderer/compositor.ts +46 -43
  120. package/src/renderer/modal-utils.ts +0 -10
  121. package/src/renderer/model-picker-overlay.ts +9 -4
  122. package/src/renderer/model-workspace.ts +2 -3
  123. package/src/renderer/panel-composite.ts +7 -20
  124. package/src/renderer/panel-workspace-bar.ts +14 -8
  125. package/src/renderer/process-modal.ts +2 -2
  126. package/src/renderer/progress.ts +3 -2
  127. package/src/renderer/tab-strip.ts +148 -34
  128. package/src/renderer/ui-factory.ts +4 -6
  129. package/src/runtime/bootstrap-command-context.ts +3 -0
  130. package/src/runtime/bootstrap-command-parts.ts +3 -1
  131. package/src/runtime/bootstrap-core.ts +31 -31
  132. package/src/runtime/bootstrap-shell.ts +1 -0
  133. package/src/runtime/onboarding/apply.ts +4 -3
  134. package/src/runtime/onboarding/snapshot.ts +4 -3
  135. package/src/runtime/process-lifecycle.ts +195 -0
  136. package/src/runtime/services.ts +4 -1
  137. package/src/runtime/ui/model-picker/health-enrichment.ts +3 -0
  138. package/src/runtime/ui/model-picker/types.ts +4 -0
  139. package/src/runtime/wrfc-persistence.ts +20 -5
  140. package/src/shell/ui-openers.ts +3 -2
  141. package/src/utils/format-duration.ts +55 -0
  142. package/src/utils/format-number.ts +71 -0
  143. package/src/verification/live-verifier.ts +4 -3
  144. package/src/version.ts +1 -1
  145. package/src/core/context-auto-compact.ts +0 -110
  146. package/src/panels/panel-picker.ts +0 -106
  147. package/src/renderer/panel-picker-overlay.ts +0 -202
  148. package/src/renderer/panel-tab-bar.ts +0 -69
@@ -1,8 +1,10 @@
1
1
  import type { Line } from '../types/grid.ts';
2
+ import { truncateDisplay } from '../utils/terminal-width.ts';
2
3
  import { ScrollableListPanel } from './scrollable-list-panel.ts';
3
4
  import type { TokenAuditResult } from '@pellux/goodvibes-sdk/platform/security';
4
5
  import type { UiReadModel, UiSecuritySnapshot } from '../runtime/ui-read-models.ts';
5
6
  import {
7
+ buildAlignedRow,
6
8
  buildEmptyState,
7
9
  buildGuidanceLine,
8
10
  buildPanelLine,
@@ -63,6 +65,8 @@ export class SecurityPanel extends ScrollableListPanel<TokenAuditResult> {
63
65
  public constructor(private readonly readModel: UiReadModel<UiSecuritySnapshot>) {
64
66
  super('security', 'Security', 'U', 'monitoring');
65
67
  this.showSelectionGutter = true; // I5: non-color selection affordance
68
+ this.filterEnabled = true;
69
+ this.filterLabel = 'Filter tokens';
66
70
  this.unsub = this.readModel.subscribe(() => this.markDirty());
67
71
  }
68
72
 
@@ -85,24 +89,38 @@ export class SecurityPanel extends ScrollableListPanel<TokenAuditResult> {
85
89
  }
86
90
 
87
91
  protected renderItem(result: TokenAuditResult, index: number, selected: boolean, width: number): Line {
88
- const bg = selected ? C.selectBg : undefined;
89
- return buildPanelLine(width, [
90
- [' ', C.label, bg],
91
- [result.label.padEnd(22), C.value, bg],
92
- [` ${result.tokenId.padEnd(12)}`, C.info, bg],
93
- [` ${result.scope.policyId.padEnd(10)}`, C.label, bg],
94
- [` ${resultSummary(result).slice(0, Math.max(0, width - 49))}`, resultColor(result), bg],
95
- ]);
92
+ return buildAlignedRow(
93
+ width,
94
+ [
95
+ { text: result.label, fg: C.value },
96
+ { text: result.tokenId, fg: C.info },
97
+ { text: result.scope.policyId, fg: C.label },
98
+ { text: resultSummary(result), fg: resultColor(result) },
99
+ ],
100
+ [
101
+ { width: 22 },
102
+ { width: 12 },
103
+ { width: 10 },
104
+ { width: Math.max(8, width - 50) },
105
+ ],
106
+ { selected, selectedBg: C.selectBg },
107
+ );
96
108
  }
97
109
 
98
110
  public handleInput(key: string): boolean {
99
- if (key === 'r') {
111
+ if (!this.filterActive && key === 'r') {
100
112
  this.markDirty();
101
113
  return true;
102
114
  }
103
115
  return super.handleInput(key);
104
116
  }
105
117
 
118
+ protected override filterMatches(result: TokenAuditResult, q: string): boolean {
119
+ return result.label.toLowerCase().includes(q)
120
+ || result.scope.policyId.toLowerCase().includes(q)
121
+ || result.tokenId.toLowerCase().includes(q);
122
+ }
123
+
106
124
  public render(width: number, height: number): Line[] {
107
125
  this.clampSelection();
108
126
  const snapshot = this.readModel.getSnapshot();
@@ -168,7 +186,7 @@ export class SecurityPanel extends ScrollableListPanel<TokenAuditResult> {
168
186
  [' critical ', C.label],
169
187
  ...buildStatusPill(attackPathReview.incoherentFindings > 0 ? 'warn' : 'good', String(attackPathReview.incoherentFindings)),
170
188
  [' review ', C.label],
171
- [attackPathReview.summary.slice(0, Math.max(0, width - 36)), C.dim],
189
+ [truncateDisplay(attackPathReview.summary, Math.max(0, width - 36)), C.dim],
172
190
  ]),
173
191
  ];
174
192
 
@@ -205,20 +223,41 @@ export class SecurityPanel extends ScrollableListPanel<TokenAuditResult> {
205
223
  attackPathLines.push(buildPanelLine(width, [[' MCP attack-path review', C.label]]));
206
224
  for (const finding of attackPathReview.findings.slice(0, 3)) {
207
225
  attackPathLines.push(buildPanelLine(width, [[
208
- ` ${finding.severity.toUpperCase()} ${finding.serverName}: ${finding.route}`.slice(0, width),
226
+ truncateDisplay(` ${finding.severity.toUpperCase()} ${finding.serverName}: ${finding.route}`, width),
209
227
  severityColor(finding.severity),
210
228
  ]]));
211
229
  attackPathLines.push(buildPanelLine(width, [[
212
- ` ${finding.reason}`.slice(0, width),
230
+ truncateDisplay(` ${finding.reason}`, width),
213
231
  C.dim,
214
232
  ]]));
215
233
  attackPathLines.push(buildPanelLine(width, [[
216
- ` evidence: ${finding.evidence.join(' | ')}`.slice(0, width),
234
+ truncateDisplay(` evidence: ${finding.evidence.join(' | ')}`, width),
217
235
  C.dim,
218
236
  ]]));
219
237
  }
220
238
  }
221
239
 
240
+ // Column header for the token audit list so the aligned columns are legible.
241
+ const listHeader: Line[] = [
242
+ ...governanceLines,
243
+ buildAlignedRow(
244
+ width,
245
+ [
246
+ { text: 'TOKEN LABEL', fg: C.label, bold: true },
247
+ { text: 'TOKEN ID', fg: C.label, bold: true },
248
+ { text: 'POLICY', fg: C.label, bold: true },
249
+ { text: `STATUS (${view.results.length} audited)`, fg: C.label, bold: true },
250
+ ],
251
+ [
252
+ { width: 22 },
253
+ { width: 12 },
254
+ { width: 10 },
255
+ { width: Math.max(8, width - 50) },
256
+ ],
257
+ {},
258
+ ),
259
+ ];
260
+
222
261
  const selected = view.results[this.selectedIndex];
223
262
  const detailLines: Line[] = [];
224
263
  if (selected) {
@@ -234,7 +273,7 @@ export class SecurityPanel extends ScrollableListPanel<TokenAuditResult> {
234
273
  [' Scope: ', C.label],
235
274
  [selected.scope.outcome, selected.scope.outcome === 'violation' ? C.error : C.ok],
236
275
  [' Excess: ', C.label],
237
- [(selected.scope.excessScopes.length > 0 ? selected.scope.excessScopes.join(', ') : 'none').slice(0, Math.max(0, width - 27)), selected.scope.excessScopes.length > 0 ? C.error : C.dim],
276
+ [truncateDisplay(selected.scope.excessScopes.length > 0 ? selected.scope.excessScopes.join(', ') : 'none', Math.max(0, width - 27)), selected.scope.excessScopes.length > 0 ? C.error : C.dim],
238
277
  ]));
239
278
  detailLines.push(buildPanelLine(width, [
240
279
  [' Rotation: ', C.label],
@@ -250,46 +289,59 @@ export class SecurityPanel extends ScrollableListPanel<TokenAuditResult> {
250
289
  ]]));
251
290
  if (preflightStatus !== 'n/a') {
252
291
  detailLines.push(buildPanelLine(width, [[
253
- `Policy preflight: ${preflightStatus} (${preflightIssueCount} issue${preflightIssueCount === 1 ? '' : 's'})`.slice(0, width),
292
+ truncateDisplay(`Policy preflight: ${preflightStatus} (${preflightIssueCount} issue${preflightIssueCount === 1 ? '' : 's'})`, width),
254
293
  preflightStatus === 'block' ? C.error : preflightStatus === 'warn' ? C.warn : C.dim,
255
294
  ]]));
256
295
  }
257
296
  if (quarantinedMcp.length > 0) {
258
297
  const server = quarantinedMcp[0]!;
259
298
  detailLines.push(buildPanelLine(width, [[
260
- `MCP quarantine: ${server.name} ${server.quarantineReason ?? 'unknown'}${server.quarantineDetail ? ` - ${server.quarantineDetail}` : ''}`.slice(0, width),
299
+ truncateDisplay(`MCP quarantine: ${server.name} ${server.quarantineReason ?? 'unknown'}${server.quarantineDetail ? ` - ${server.quarantineDetail}` : ''}`, width),
261
300
  C.error,
262
301
  ]]));
263
302
  }
264
303
  if (quarantinedPlugins.length > 0) {
265
304
  const plugin = quarantinedPlugins[0]!;
266
305
  detailLines.push(buildPanelLine(width, [[
267
- `Plugin quarantine: ${plugin.name} (${plugin.trustTier})`.slice(0, width),
306
+ truncateDisplay(`Plugin quarantine: ${plugin.name} (${plugin.trustTier})`, width),
268
307
  C.error,
269
308
  ]]));
270
309
  } else if (untrustedPlugins.length > 0) {
271
310
  const plugin = untrustedPlugins[0]!;
272
311
  detailLines.push(buildPanelLine(width, [[
273
- `Plugin trust warning: ${plugin.name} remains untrusted.`.slice(0, width),
312
+ truncateDisplay(`Plugin trust warning: ${plugin.name} remains untrusted.`, width),
274
313
  C.warn,
275
314
  ]]));
276
315
  }
277
316
  if (latestIncident) {
278
317
  detailLines.push(buildPanelLine(width, [[
279
- `Latest incident: ${latestIncident.classification} - ${latestIncident.summary}`.slice(0, width),
318
+ truncateDisplay(`Latest incident: ${latestIncident.classification} - ${latestIncident.summary}`, width),
280
319
  C.warn,
281
320
  ]]));
282
321
  }
283
322
  }
284
323
 
324
+ const hints = this.filterActive
325
+ ? [
326
+ { keys: 'type', label: 'filter tokens' },
327
+ { keys: 'Enter', label: 'apply' },
328
+ { keys: 'Esc', label: 'clear' },
329
+ ]
330
+ : [
331
+ { keys: '↑/↓', label: 'select token' },
332
+ { keys: '/', label: 'filter' },
333
+ { keys: 'r', label: 'refresh audit' },
334
+ ];
335
+
285
336
  return this.renderList(width, height, {
286
337
  title: 'Security Control Room',
287
- header: governanceLines,
338
+ header: listHeader,
288
339
  footer: [
289
340
  ...detailLines,
290
341
  ...attackPathLines,
291
342
  footerLine,
292
343
  ],
344
+ hints,
293
345
  });
294
346
  }
295
347
  }
@@ -1,5 +1,6 @@
1
1
  import type { Line } from '../types/grid.ts';
2
2
  import { createEmptyLine } from '../types/grid.ts';
3
+ import { truncateDisplay } from '../utils/terminal-width.ts';
3
4
  import { ScrollableListPanel } from './scrollable-list-panel.ts';
4
5
  import {
5
6
  type ServiceConfig,
@@ -8,7 +9,9 @@ import {
8
9
  } from '@pellux/goodvibes-sdk/platform/config';
9
10
  import type { ServiceInspectionQuery, SubscriptionAccessQuery } from '../runtime/ui-service-queries.ts';
10
11
  import {
11
- buildEmptyState,
12
+ buildDetailBlock,
13
+ buildKeyValueLine,
14
+ buildKeyboardHints,
12
15
  buildPanelLine,
13
16
  buildPanelWorkspace,
14
17
  buildStatusPill,
@@ -80,11 +83,18 @@ export class ServicesPanel extends ScrollableListPanel<ServicePanelEntry> {
80
83
  ) {
81
84
  super('services', 'Services', 'V', 'monitoring');
82
85
  this.showSelectionGutter = true; // I5: non-color selection affordance
86
+ this.filterEnabled = true;
87
+ this.filterLabel = 'Filter services';
83
88
  this.registry = registry;
84
89
  this.subscriptionManager = subscriptionManager;
85
90
  void this.refresh();
86
91
  }
87
92
 
93
+ protected override filterMatches(entry: ServicePanelEntry, q: string): boolean {
94
+ return entry.name.toLowerCase().includes(q)
95
+ || (entry.inspection.config.baseUrl ?? '').toLowerCase().includes(q);
96
+ }
97
+
88
98
  public override onActivate(): void {
89
99
  super.onActivate();
90
100
  if (this.entries.length === 0 && !this.loading) {
@@ -112,18 +122,20 @@ export class ServicesPanel extends ScrollableListPanel<ServicePanelEntry> {
112
122
  [entry.name.padEnd(16), C.value, bg],
113
123
  [` ${statusLabel(entry).padEnd(12)}`, statusColor(entry), bg],
114
124
  [` ${authSummary(entry.inspection.config, this.subscriptionManager).padEnd(18)}`, C.info, bg],
115
- [` ${entry.inspection.config.baseUrl ?? '(no baseUrl)'}`, C.dim, bg],
125
+ [` ${truncateDisplay(entry.inspection.config.baseUrl ?? '(no baseUrl)', Math.max(0, width - 48))}`, C.dim, bg],
116
126
  ]);
117
127
  }
118
128
 
119
129
  public handleInput(key: string): boolean {
120
- if (key === 'r') {
121
- void this.refresh();
122
- return true;
123
- }
124
- if (key === 't') {
125
- void this.testSelected();
126
- return true;
130
+ if (!this.filterActive) {
131
+ if (key === 'r') {
132
+ void this.refresh();
133
+ return true;
134
+ }
135
+ if (key === 't') {
136
+ void this.testSelected();
137
+ return true;
138
+ }
127
139
  }
128
140
  return super.handleInput(key);
129
141
  }
@@ -176,11 +188,29 @@ export class ServicesPanel extends ScrollableListPanel<ServicePanelEntry> {
176
188
  return lines;
177
189
  }
178
190
 
191
+ // Health-summary header — surface the most important counts first.
192
+ const counts = { healthy: 0, error: 0, configured: 0, unconfigured: 0 };
193
+ for (const entry of this.entries) {
194
+ const label = statusLabel(entry);
195
+ if (label === 'HEALTHY') counts.healthy++;
196
+ else if (label === 'ERROR') counts.error++;
197
+ else if (label === 'CONFIGURED') counts.configured++;
198
+ else counts.unconfigured++;
199
+ }
200
+ const headerLines: Line[] = [
201
+ buildKeyValueLine(width, [
202
+ { label: 'services', value: String(this.entries.length), valueColor: this.entries.length > 0 ? C.info : C.dim },
203
+ { label: 'healthy', value: String(counts.healthy), valueColor: counts.healthy > 0 ? C.ok : C.dim },
204
+ { label: 'errors', value: String(counts.error), valueColor: counts.error > 0 ? C.error : C.dim },
205
+ { label: 'unconfigured', value: String(counts.unconfigured), valueColor: counts.unconfigured > 0 ? C.warn : C.dim },
206
+ ], C),
207
+ ];
208
+
179
209
  const selected = this.entries[this.selectedIndex];
180
- const detailLines: Line[] = [];
210
+ const detailRows: Line[] = [];
181
211
  if (selected) {
182
212
  const inspect = selected.inspection;
183
- detailLines.push(buildPanelLine(width, [
213
+ detailRows.push(buildPanelLine(width, [
184
214
  [' Service: ', C.label],
185
215
  [selected.name, C.value],
186
216
  [' State: ', C.label],
@@ -188,7 +218,11 @@ export class ServicesPanel extends ScrollableListPanel<ServicePanelEntry> {
188
218
  [' Auth: ', C.label],
189
219
  [authSummary(inspect.config, this.subscriptionManager), C.info],
190
220
  ]));
191
- detailLines.push(buildPanelLine(width, [
221
+ detailRows.push(buildPanelLine(width, [
222
+ [' Base URL: ', C.label],
223
+ [truncateDisplay(inspect.config.baseUrl ?? '(no baseUrl)', Math.max(0, width - 13)), C.dim],
224
+ ]));
225
+ detailRows.push(buildPanelLine(width, [
192
226
  [' Primary credential: ', C.label],
193
227
  ...buildStatusPill(inspect.hasPrimaryCredential ? 'good' : 'bad', inspect.hasPrimaryCredential ? 'present' : 'missing'),
194
228
  [' Webhook URL: ', C.label],
@@ -199,32 +233,44 @@ export class ServicesPanel extends ScrollableListPanel<ServicePanelEntry> {
199
233
  ...buildStatusPill(inspect.hasAppToken ? 'good' : 'info', inspect.hasAppToken ? 'present' : 'missing'),
200
234
  ]));
201
235
  if (selected.lastTest) {
202
- detailLines.push(buildPanelLine(width, [
236
+ detailRows.push(buildPanelLine(width, [
203
237
  [' Last test: ', C.label],
204
238
  ...buildStatusPill(selected.lastTest.ok ? 'good' : 'bad', selected.lastTest.ok ? 'ok' : 'failed'),
205
239
  [' Status: ', C.label],
206
240
  [selected.lastTest.status != null ? String(selected.lastTest.status) : 'n/a', C.value],
207
241
  [' URL: ', C.label],
208
- [(selected.lastTest.testedUrl ?? 'n/a').slice(0, Math.max(0, width - 34)), C.dim],
242
+ [truncateDisplay(selected.lastTest.testedUrl ?? 'n/a', Math.max(0, width - 34)), C.dim],
209
243
  ]));
210
244
  if (selected.lastTest.error) {
211
- detailLines.push(buildPanelLine(width, [
245
+ detailRows.push(buildPanelLine(width, [
212
246
  [' Error: ', C.label],
213
- [selected.lastTest.error.slice(0, Math.max(0, width - 10)), C.error],
247
+ [truncateDisplay(selected.lastTest.error, Math.max(0, width - 10)), C.error],
214
248
  ]));
215
249
  }
216
250
  } else {
217
- detailLines.push(buildPanelLine(width, [[' Press t to test the selected service or r to refresh credential status.', C.dim]]));
251
+ detailRows.push(buildPanelLine(width, [[' Not tested yet press t to run a live connection check.', C.dim]]));
218
252
  }
219
- detailLines.push(buildPanelLine(width, [[' Services resolve credentials through hierarchy-aware secure storage, plaintext fallback policy, and project-local config.', C.dim]]));
220
253
  }
221
254
 
255
+ // Context-aware hints: filter mode swaps to filter-specific keys; the test
256
+ // key only makes sense when there is a service to test.
257
+ const hints = this.filterActive
258
+ ? [{ keys: 'type', label: 'filter' }, { keys: 'Enter', label: 'apply' }, { keys: 'Esc', label: 'clear' }]
259
+ : [
260
+ { keys: 'Up/Down', label: 'move' },
261
+ ...(selected ? [{ keys: 't', label: 'test selected' }] : []),
262
+ { keys: 'r', label: 'refresh' },
263
+ { keys: '/', label: 'filter' },
264
+ ];
265
+
266
+ const footer: Line[] = selected
267
+ ? [...buildDetailBlock(width, `Service · ${selected.name}`, detailRows, C), buildKeyboardHints(width, hints, C)]
268
+ : [buildKeyboardHints(width, hints, C)];
269
+
222
270
  return this.renderList(width, height, {
223
271
  title: 'Service Control Room',
224
- footer: [
225
- ...detailLines,
226
- buildPanelLine(width, [[' Up/Down move t test selected service r refresh inspections', C.dim]]),
227
- ],
272
+ header: headerLines,
273
+ footer,
228
274
  emptyMessage: intro,
229
275
  });
230
276
  }
@@ -9,15 +9,17 @@ import { logger } from '@pellux/goodvibes-sdk/platform/utils';
9
9
  import type { SessionBrowserQuery } from '../runtime/ui-service-queries.ts';
10
10
  import {
11
11
  buildEmptyState,
12
+ buildKeyboardHints,
12
13
  buildPanelLine,
13
14
  buildSearchInputLine,
14
15
  buildStyledPanelLine,
15
16
  buildPanelWorkspace,
16
17
  resolveScrollablePanelSection,
17
18
  DEFAULT_PANEL_PALETTE,
19
+ extendPalette,
18
20
  type PanelWorkspaceSection,
19
21
  } from './polish.ts';
20
- import { truncateDisplay } from '../utils/terminal-width.ts';
22
+ import { fitDisplay, truncateDisplay } from '../utils/terminal-width.ts';
21
23
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
22
24
  import {
23
25
  getPanelSearchFocusTransition,
@@ -28,24 +30,15 @@ import {
28
30
  } from './search-focus.ts';
29
31
  import { type ConfirmState, handleConfirmInput } from './confirm-state.ts';
30
32
 
31
- const C = {
32
- headerBg: '#1a1a2e',
33
- headerFg: '#ffffff',
34
- statusBar: '#222233',
35
- statusFg: '#aaaaaa',
36
- selected: '#00ffff',
37
- selectedBg: '#1a2a3a',
38
- normal: '#ccccdd',
39
- dim: '#555566',
40
- label: '#8888bb',
41
- value: '#ccccdd',
42
- dateFg: '#6699aa',
43
- modelFg: '#99aacc',
44
- countFg: '#88bbcc',
45
- warnFg: '#ffcc44',
46
- errorFg: '#ff6666',
47
- separator: '#333355',
48
- } as const;
33
+ // Panel-specific extras only; shared tones come from DEFAULT_PANEL_PALETTE so
34
+ // theme changes propagate. `selected` is a deliberate bright-cyan cursor accent
35
+ // and the date/model/count column tints have no clean shared equivalent.
36
+ const C = extendPalette(DEFAULT_PANEL_PALETTE, {
37
+ selected: '#00ffff',
38
+ dateFg: '#6699aa',
39
+ modelFg: '#99aacc',
40
+ countFg: '#88bbcc',
41
+ });
49
42
 
50
43
  function shortDate(ts: number): string {
51
44
  const d = new Date(ts);
@@ -92,6 +85,7 @@ export class SessionBrowserPanel extends BasePanel {
92
85
  private confirm: ConfirmState<string> | null = null;
93
86
  private deleteError = '';
94
87
  private loadError = '';
88
+ private hasLoaded = false;
95
89
  private refreshTimerId: ReturnType<typeof setInterval> | null = null;
96
90
 
97
91
  constructor(
@@ -177,8 +171,15 @@ export class SessionBrowserPanel extends BasePanel {
177
171
  if (height <= 0 || width <= 0) return [];
178
172
  const intro = 'Browse, search, resume, and prune saved conversations.';
179
173
 
174
+ // Lazily load on first render so the panel works even when rendered without
175
+ // onActivate(). The session list is a synchronous source, so this resolves
176
+ // immediately rather than flashing a loading screen.
177
+ if (!this.hasLoaded) this._load();
178
+
180
179
  const count = this.filtered.length;
181
180
  const total = this.sessions.length;
181
+ const hasSelection = this.filtered.length > 0;
182
+ // Search/status line — surfaces the active query or any error.
182
183
  const searchLine = this.searching
183
184
  ? ` Search: ${this.searchQuery}_`
184
185
  : this.loadError
@@ -186,11 +187,21 @@ export class SessionBrowserPanel extends BasePanel {
186
187
  : this.deleteError
187
188
  ? ` Error: ${this.deleteError}`
188
189
  : this.searchQuery
189
- ? ` Filter: ${this.searchQuery} (/ or up at top to edit)`
190
- : ` / or up at top to search Enter: resume d: delete r: refresh`;
190
+ ? ` Filter: ${this.searchQuery}`
191
+ : ` (no filter)`;
191
192
  const statusFg = this.loadError || this.deleteError ? DEFAULT_PANEL_PALETTE.bad : this.searching ? DEFAULT_PANEL_PALETTE.info : DEFAULT_PANEL_PALETTE.dim;
193
+ // Context-aware hints: only advertise keys that work in the current state.
194
+ const hints: Array<{ keys: string; label: string }> = [];
195
+ if (this.searching) {
196
+ hints.push({ keys: 'type', label: 'filter' }, { keys: 'Esc/Enter', label: 'apply' });
197
+ } else {
198
+ hints.push({ keys: '/', label: this.searchQuery ? 'edit search' : 'search' });
199
+ if (hasSelection) hints.push({ keys: 'Enter', label: 'resume' }, { keys: 'd', label: 'delete' });
200
+ hints.push({ keys: 'r', label: 'refresh' });
201
+ }
192
202
  const footerLines = [
193
203
  buildSearchInputLine(width, '', searchLine.trimStart(), DEFAULT_PANEL_PALETTE, { active: this.searching, valueColor: statusFg }),
204
+ buildKeyboardHints(width, hints, DEFAULT_PANEL_PALETTE),
194
205
  ];
195
206
 
196
207
  if (this.confirm) {
@@ -216,12 +227,15 @@ export class SessionBrowserPanel extends BasePanel {
216
227
  const emptyBody = this.searchQuery
217
228
  ? 'Clear or change the current filter to surface saved conversations again.'
218
229
  : 'Conversations are saved automatically. Once you have saved sessions, they appear here for review and resume.';
230
+ const emptyActions = this.searchQuery
231
+ ? [{ command: 'Esc', summary: 'clear the filter and show all sessions' }]
232
+ : [{ command: '/session save', summary: 'name and persist the current conversation' }];
219
233
  return buildPanelWorkspace(width, height, {
220
234
  title: ` Sessions [${count}/${total}]`,
221
235
  intro,
222
236
  sections: [
223
237
  {
224
- lines: buildEmptyState(width, emptyTitle, emptyBody, [], DEFAULT_PANEL_PALETTE),
238
+ lines: buildEmptyState(width, emptyTitle, emptyBody, emptyActions, DEFAULT_PANEL_PALETTE),
225
239
  },
226
240
  ],
227
241
  footerLines,
@@ -308,19 +322,19 @@ export class SessionBrowserPanel extends BasePanel {
308
322
  }
309
323
 
310
324
  private _renderSession(width: number, sess: SessionInfo, isCursor: boolean): Line {
311
- const bg = isCursor ? C.selectedBg : '';
325
+ const bg = isCursor ? C.selectBg : '';
312
326
  const date = shortDate(sess.timestamp);
313
327
  const cnt = String(sess.messageCount).padStart(3) + 'm ';
314
- const model = (sess.model || 'unknown').slice(0, 18).padEnd(18) + ' ';
328
+ const model = fitDisplay(sess.model || 'unknown', 18) + ' ';
315
329
  const prefixLength = 1 + 16 + 1 + 4 + 19;
316
330
  const title = truncateDisplay(sess.title || sess.name || '(untitled)', Math.max(0, width - prefixLength));
317
331
  return buildStyledPanelLine(width, [
318
332
  { text: isCursor ? '▸' : ' ', fg: C.selected, bg, bold: isCursor },
319
333
  { text: date, fg: C.dateFg, bg },
320
- { text: ' ', fg: C.normal, bg },
334
+ { text: ' ', fg: C.value, bg },
321
335
  { text: cnt, fg: C.countFg, bg },
322
336
  { text: model, fg: C.modelFg, bg },
323
- { text: title, fg: isCursor ? C.selected : C.normal, bg, bold: isCursor },
337
+ { text: title, fg: isCursor ? C.selected : C.value, bg, bold: isCursor },
324
338
  ]);
325
339
  }
326
340
 
@@ -329,10 +343,12 @@ export class SessionBrowserPanel extends BasePanel {
329
343
  this.sessions = this.sessionManager.list();
330
344
  this._filter();
331
345
  this.loadError = '';
346
+ this.hasLoaded = true;
332
347
  this.markDirty();
333
348
  } catch (e) {
334
349
  logger.debug('SessionBrowserPanel._load failed', { error: summarizeError(e) });
335
350
  this.loadError = 'Failed to load sessions';
351
+ this.hasLoaded = true;
336
352
  this.markDirty();
337
353
  }
338
354
  }
@@ -10,6 +10,7 @@
10
10
  * that passes configManager has a coherent call site.
11
11
  */
12
12
  import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
13
+ import { computeContextUsage } from '../core/context-usage.ts';
13
14
 
14
15
  export type PanelGuidanceMode = 'off' | 'minimal' | 'guided';
15
16
  export type PanelSessionMaintenanceLevel = 'stable' | 'watch' | 'suggest-compact' | 'compacting' | 'needs-repair' | 'unknown';
@@ -76,8 +77,7 @@ export function evaluateSessionMaintenance(input: PanelSessionMaintenanceInput):
76
77
  const thresholdPct = Math.max(0, Number.isFinite(rawThreshold) ? rawThreshold : 0);
77
78
  const autoCompactEnabled = thresholdPct > 0;
78
79
 
79
- const usagePct = input.contextWindow > 0 ? Math.min(100, Math.round((Math.max(0, input.currentTokens) / input.contextWindow) * 100)) : 0;
80
- const remainingTokens = Math.max(0, input.contextWindow - input.currentTokens);
80
+ const { pct: usagePct, remaining: remainingTokens } = computeContextUsage(input.currentTokens, input.contextWindow);
81
81
  const sessionMemoryCount = Math.max(0, input.sessionMemoryCount ?? 0);
82
82
  const compactionCount = Math.max(0, input.session?.lineage?.filter((entry) => entry.branchReason === 'compaction').length ?? 0);
83
83
  const lastCompactedAt = input.session?.lastCompactedAt;
@@ -1,8 +1,10 @@
1
1
  import type { Line } from '../types/grid.ts';
2
+ import { fitDisplay, truncateDisplay } from '../utils/terminal-width.ts';
2
3
  import { ScrollableListPanel } from './scrollable-list-panel.ts';
3
4
  import {
4
5
  buildDetailBlock,
5
6
  buildGuidanceLine,
7
+ buildKeyboardHints,
6
8
  buildPanelListRow,
7
9
  buildPanelLine,
8
10
  buildStatusPill,
@@ -28,6 +30,14 @@ export class SettingsSyncPanel extends ScrollableListPanel<ResolvedEntry> {
28
30
  public constructor(private readonly configManager: ConfigManager) {
29
31
  super('settings-sync', 'Settings Sync', 'S', 'monitoring');
30
32
  this.showSelectionGutter = true; // I5: non-color selection affordance
33
+ this.filterEnabled = true;
34
+ this.filterLabel = 'Filter settings';
35
+ }
36
+
37
+ protected override filterMatches(entry: ResolvedEntry, q: string): boolean {
38
+ return entry.key.toLowerCase().includes(q)
39
+ || String(entry.effectiveSource).toLowerCase().includes(q)
40
+ || String(entry.effectiveValue).toLowerCase().includes(q);
31
41
  }
32
42
 
33
43
  protected override getPalette(): PanelPalette {
@@ -40,9 +50,9 @@ export class SettingsSyncPanel extends ScrollableListPanel<ResolvedEntry> {
40
50
 
41
51
  protected renderItem(entry: ResolvedEntry, _index: number, selected: boolean, width: number): Line {
42
52
  return buildPanelListRow(width, [
43
- { text: entry.key.padEnd(32), fg: C.value },
53
+ { text: fitDisplay(entry.key, 32).padEnd(32), fg: C.value },
44
54
  { text: ` ${entry.effectiveSource}`.padEnd(11), fg: entry.effectiveSource === 'managed' ? C.warn : entry.effectiveSource === 'synced' ? C.ok : entry.effectiveSource === 'local' ? C.info : C.dim },
45
- { text: `${String(entry.effectiveValue)}`.slice(0, Math.max(0, width - 47)), fg: entry.locked ? C.warn : C.dim },
55
+ { text: truncateDisplay(`${String(entry.effectiveValue)}`, Math.max(0, width - 47)), fg: entry.locked ? C.warn : C.dim },
46
56
  ], C, { selected });
47
57
  }
48
58
 
@@ -69,28 +79,28 @@ export class SettingsSyncPanel extends ScrollableListPanel<ResolvedEntry> {
69
79
  ...(snapshot.stagedManagedBundle
70
80
  ? [
71
81
  buildPanelLine(width, [[' profile ', C.label], [snapshot.stagedManagedBundle.profileName, C.value], [' risk ', C.label], [snapshot.stagedManagedBundle.risk, snapshot.stagedManagedBundle.risk === 'high' ? C.error : snapshot.stagedManagedBundle.risk === 'medium' ? C.warn : C.ok], [' changes ', C.label], [String(snapshot.stagedManagedBundle.changeCount), C.info]]),
72
- buildPanelLine(width, [[' path ', C.label], [snapshot.stagedManagedBundle.path.slice(0, Math.max(0, width - 9)), C.dim]]),
82
+ buildPanelLine(width, [[' path ', C.label], [truncateDisplay(snapshot.stagedManagedBundle.path, Math.max(0, width - 9)), C.dim]]),
73
83
  ]
74
84
  : [buildPanelLine(width, [[' No staged managed settings bundle.', C.dim]])]),
75
85
  // Recent Events
76
86
  ...(snapshot.recentEvents.length > 0
77
- ? snapshot.recentEvents.map((event) => buildPanelLine(width, [[` ${event.surface}/${event.direction}`.padEnd(18), C.info], [` ${event.detail}`.slice(0, Math.max(0, width - 20)), C.dim]]))
87
+ ? snapshot.recentEvents.map((event) => buildPanelLine(width, [[fitDisplay(` ${event.surface}/${event.direction}`, 18).padEnd(18), C.info], [truncateDisplay(` ${event.detail}`, Math.max(0, width - 20)), C.dim]]))
78
88
  : [buildPanelLine(width, [[' No sync or managed-setting events recorded yet.', C.dim]])]),
79
89
  // Managed Locks
80
90
  ...(snapshot.managedLocks.length > 0
81
- ? snapshot.managedLocks.slice(0, 10).map((lock) => buildPanelLine(width, [[` ${lock.key}`.padEnd(30), C.value], [` source=${lock.source}`.padEnd(24), C.info], [` ${lock.reason}`.slice(0, Math.max(0, width - 56)), C.dim]]))
91
+ ? snapshot.managedLocks.slice(0, 10).map((lock) => buildPanelLine(width, [[fitDisplay(` ${lock.key}`, 30).padEnd(30), C.value], [fitDisplay(` source=${lock.source}`, 24).padEnd(24), C.info], [truncateDisplay(` ${lock.reason}`, Math.max(0, width - 56)), C.dim]]))
82
92
  : [buildPanelLine(width, [[' No managed locks are currently active.', C.dim]])]),
83
93
  // Failures
84
94
  ...(snapshot.recentFailures.length > 0
85
- ? snapshot.recentFailures.map((failure) => buildPanelLine(width, [[` ${failure.surface}`.padEnd(10), C.error], [` ${failure.message}`.slice(0, Math.max(0, width - 12)), C.dim]]))
95
+ ? snapshot.recentFailures.map((failure) => buildPanelLine(width, [[` ${failure.surface}`.padEnd(10), C.error], [truncateDisplay(` ${failure.message}`, Math.max(0, width - 12)), C.dim]]))
86
96
  : [buildPanelLine(width, [[' No recent sync or managed-setting failures.', C.dim]])]),
87
97
  // Conflicts
88
98
  ...(snapshot.conflicts.length > 0
89
- ? snapshot.conflicts.map((conflict) => buildPanelLine(width, [[` ${conflict.key}`.padEnd(30), C.value], [` ${conflict.source}`.padEnd(10), C.warn], [` resolve: /settings-sync resolve ${conflict.key} local|synced`.slice(0, Math.max(0, width - 42)), C.dim]]))
99
+ ? snapshot.conflicts.map((conflict) => buildPanelLine(width, [[fitDisplay(` ${conflict.key}`, 30).padEnd(30), C.value], [fitDisplay(` ${conflict.source}`, 10).padEnd(10), C.warn], [truncateDisplay(` resolve: /settings-sync resolve ${conflict.key} local|synced`, Math.max(0, width - 42)), C.dim]]))
90
100
  : [buildPanelLine(width, [[' No settings conflicts detected.', C.dim]])]),
91
101
  // Rollback History
92
102
  ...(snapshot.rollbackHistory.length > 0
93
- ? snapshot.rollbackHistory.map((entry) => buildPanelLine(width, [[` ${entry.token}`.padEnd(18), C.info], [` ${entry.profileName}`.padEnd(18), C.value], [` restored=${String(entry.restoredKeys.length).padEnd(4)}`, C.warn], [` ${new Date(entry.appliedAt).toLocaleString()}`.slice(0, Math.max(0, width - 46)), C.dim]]))
103
+ ? snapshot.rollbackHistory.map((entry) => buildPanelLine(width, [[fitDisplay(` ${entry.token}`, 18).padEnd(18), C.info], [fitDisplay(` ${entry.profileName}`, 18).padEnd(18), C.value], [` restored=${String(entry.restoredKeys.length).padEnd(4)}`, C.warn], [truncateDisplay(` ${new Date(entry.appliedAt).toLocaleString()}`, Math.max(0, width - 46)), C.dim]]))
94
104
  : [buildPanelLine(width, [[' No managed rollback records yet.', C.dim]])]),
95
105
  ];
96
106
 
@@ -101,14 +111,19 @@ export class SettingsSyncPanel extends ScrollableListPanel<ResolvedEntry> {
101
111
  ? buildDetailBlock(width, 'Selected setting', [
102
112
  buildPanelLine(width, [[' key ', C.label], [selectedEntry.key, C.value], [' category ', C.label], [selectedEntry.category, C.info]]),
103
113
  buildPanelLine(width, [[' effective ', C.label], [selectedEntry.effectiveSource, selectedEntry.effectiveSource === 'managed' ? C.warn : selectedEntry.effectiveSource === 'synced' ? C.ok : selectedEntry.effectiveSource === 'local' ? C.info : C.dim], [' locked ', C.label], [selectedEntry.locked ? 'yes' : 'no', selectedEntry.locked ? C.warn : C.dim], [' conflict ', C.label], [selectedEntry.conflict ? 'yes' : 'no', selectedEntry.conflict ? C.error : C.good]]),
104
- buildPanelLine(width, [[' source ', C.label], [(selectedEntry.sourceLabel ?? 'local/default').slice(0, Math.max(0, width - 10)), C.dim]]),
105
- buildPanelLine(width, [[' overrides ', C.label], [(selectedEntry.overriddenSources.length > 0 ? selectedEntry.overriddenSources.join(', ') : 'none').slice(0, Math.max(0, width - 13)), C.dim]]),
106
- buildPanelLine(width, [[' local ', C.label], [String(selectedEntry.localValue).slice(0, Math.max(0, width - 9)), C.dim]]),
107
- buildPanelLine(width, [[' synced ', C.label], [String(selectedEntry.syncedValue ?? '(unset)').slice(0, Math.max(0, width - 10)), C.ok]]),
108
- buildPanelLine(width, [[' managed ', C.label], [String(selectedEntry.managedValue ?? '(unset)').slice(0, Math.max(0, width - 11)), C.warn]]),
114
+ buildPanelLine(width, [[' source ', C.label], [truncateDisplay(selectedEntry.sourceLabel ?? 'local/default', Math.max(0, width - 10)), C.dim]]),
115
+ buildPanelLine(width, [[' overrides ', C.label], [truncateDisplay(selectedEntry.overriddenSources.length > 0 ? selectedEntry.overriddenSources.join(', ') : 'none', Math.max(0, width - 13)), C.dim]]),
116
+ buildPanelLine(width, [[' local ', C.label], [truncateDisplay(String(selectedEntry.localValue), Math.max(0, width - 9)), C.dim]]),
117
+ buildPanelLine(width, [[' synced ', C.label], [truncateDisplay(String(selectedEntry.syncedValue ?? '(unset)'), Math.max(0, width - 10)), C.ok]]),
118
+ buildPanelLine(width, [[' managed ', C.label], [truncateDisplay(String(selectedEntry.managedValue ?? '(unset)'), Math.max(0, width - 11)), C.warn]]),
109
119
  ], C)
110
- : []),
111
- buildPanelLine(width, [[' ↑/↓ browse /settings-sync show <key> /settings-sync resolve <key> <local|synced> /managed apply-staged [key...] ', C.dim]]),
120
+ : [buildPanelLine(width, [[' Select a setting above to inspect its effective value, source, and overrides.', C.dim]])]),
121
+ buildKeyboardHints(width, [
122
+ { keys: '↑/↓', label: 'browse' },
123
+ { keys: '/', label: 'filter' },
124
+ { keys: '/settings-sync show <key>', label: 'inspect' },
125
+ { keys: '/settings-sync resolve <key> <local|synced>', label: 'resolve conflict' },
126
+ ], C),
112
127
  ];
113
128
 
114
129
  return this.renderList(width, height, {
@@ -16,6 +16,7 @@ import {
16
16
  getPanelSearchFocusTransition,
17
17
  isPanelSearchCancel,
18
18
  } from './search-focus.ts';
19
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
19
20
 
20
21
  const C = {
21
22
  ...DEFAULT_PANEL_PALETTE,
@@ -259,7 +260,7 @@ export class SkillsPanel extends SearchableListPanel<SkillRecord> {
259
260
  this.invalidateFilter();
260
261
  });
261
262
  } catch (err) {
262
- this.setError(err instanceof Error ? err.message : String(err));
263
+ this.setError(summarizeError(err));
263
264
  }
264
265
  this.markDirty();
265
266
  })();