@pellux/goodvibes-tui 0.20.2 → 0.21.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 (120) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +23 -2
  3. package/docs/foundation-artifacts/operator-contract.json +78 -1
  4. package/package.json +3 -2
  5. package/src/audio/spoken-turn-controller.ts +31 -1
  6. package/src/audio/spoken-turn-wiring.ts +26 -4
  7. package/src/cli/bundle-command.ts +1 -1
  8. package/src/cli/completions/generate.ts +662 -0
  9. package/src/cli/config-overrides.ts +68 -0
  10. package/src/cli/help.ts +4 -2
  11. package/src/cli/management-commands.ts +1 -1
  12. package/src/cli/management.ts +1 -8
  13. package/src/cli/parser.ts +14 -18
  14. package/src/cli/service-command.ts +1 -1
  15. package/src/cli/surface-command.ts +1 -1
  16. package/src/cli/tui-startup.ts +72 -10
  17. package/src/cli/types.ts +12 -3
  18. package/src/cli-flags.ts +1 -0
  19. package/src/config/atomic-write.ts +70 -0
  20. package/src/config/read-versioned.ts +115 -0
  21. package/src/core/conversation-rendering.ts +49 -15
  22. package/src/core/conversation.ts +101 -16
  23. package/src/core/format-user-error.ts +192 -0
  24. package/src/core/stream-event-wiring.ts +144 -0
  25. package/src/core/stream-stall-watchdog.ts +103 -0
  26. package/src/core/system-message-router.ts +5 -1
  27. package/src/export/cost-utils.ts +71 -0
  28. package/src/export/gist-uploader.ts +136 -0
  29. package/src/input/command-registry.ts +31 -1
  30. package/src/input/commands/control-room-runtime.ts +5 -5
  31. package/src/input/commands/experience-runtime.ts +5 -4
  32. package/src/input/commands/knowledge.ts +1 -1
  33. package/src/input/commands/local-auth-runtime.ts +27 -5
  34. package/src/input/commands/local-setup.ts +4 -6
  35. package/src/input/commands/memory-product-runtime.ts +8 -6
  36. package/src/input/commands/operator-panel-runtime.ts +1 -1
  37. package/src/input/commands/operator-runtime.ts +3 -10
  38. package/src/input/commands/platform-sandbox-qemu.ts +60 -16
  39. package/src/input/commands/{integration-runtime.ts → plugin-runtime.ts} +1 -1
  40. package/src/input/commands/recall-review.ts +26 -2
  41. package/src/input/commands/services-runtime.ts +2 -2
  42. package/src/input/commands/session-workflow.ts +3 -3
  43. package/src/input/commands/share-runtime.ts +99 -12
  44. package/src/input/commands/tts-runtime.ts +30 -4
  45. package/src/input/commands.ts +2 -2
  46. package/src/input/delete-key-policy.ts +46 -0
  47. package/src/input/feed-context-factory.ts +2 -0
  48. package/src/input/handler-feed.ts +3 -0
  49. package/src/input/handler-interactions.ts +2 -15
  50. package/src/input/handler-modal-routes.ts +91 -12
  51. package/src/input/handler-modal-token-routes.ts +3 -0
  52. package/src/input/handler-onboarding-cloudflare.ts +1 -1
  53. package/src/input/handler-onboarding.ts +55 -69
  54. package/src/input/handler-types.ts +163 -0
  55. package/src/input/handler.ts +5 -2
  56. package/src/input/input-history.ts +76 -6
  57. package/src/input/model-picker-filter.ts +265 -0
  58. package/src/input/model-picker-items.ts +208 -0
  59. package/src/input/model-picker.ts +92 -325
  60. package/src/input/onboarding/handler-onboarding-routes.ts +7 -2
  61. package/src/input/onboarding/onboarding-verification-helpers.ts +76 -0
  62. package/src/input/onboarding/onboarding-wizard-apply.ts +4 -4
  63. package/src/input/onboarding/onboarding-wizard-cloudflare-step.ts +2 -2
  64. package/src/input/onboarding/onboarding-wizard-cloudflare.ts +8 -8
  65. package/src/input/onboarding/onboarding-wizard-external-surface-extra-specs.ts +1 -1
  66. package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +2 -29
  67. package/src/input/onboarding/onboarding-wizard-rules.ts +28 -28
  68. package/src/input/onboarding/onboarding-wizard-state.ts +20 -20
  69. package/src/input/onboarding/onboarding-wizard-steps.ts +18 -25
  70. package/src/input/onboarding/onboarding-wizard-types.ts +145 -3
  71. package/src/input/onboarding/onboarding-wizard.ts +3 -3
  72. package/src/input/settings-modal-data.ts +304 -0
  73. package/src/input/settings-modal-mutations.ts +154 -0
  74. package/src/input/settings-modal.ts +182 -220
  75. package/src/main.ts +57 -57
  76. package/src/panels/builtin/agent.ts +4 -1
  77. package/src/panels/builtin/development.ts +4 -1
  78. package/src/panels/confirm-state.ts +27 -12
  79. package/src/panels/cost-tracker-panel.ts +23 -67
  80. package/src/panels/eval-panel.ts +10 -9
  81. package/src/panels/knowledge-panel.ts +3 -5
  82. package/src/panels/local-auth-panel.ts +124 -4
  83. package/src/panels/project-planning-panel.ts +42 -4
  84. package/src/panels/search-focus.ts +11 -5
  85. package/src/panels/subscription-panel.ts +33 -25
  86. package/src/panels/types.ts +28 -1
  87. package/src/panels/wrfc-panel.ts +224 -41
  88. package/src/renderer/agent-detail-modal.ts +11 -10
  89. package/src/renderer/code-block.ts +10 -2
  90. package/src/renderer/compositor.ts +18 -4
  91. package/src/renderer/context-inspector.ts +1 -5
  92. package/src/renderer/diff.ts +94 -21
  93. package/src/renderer/markdown.ts +29 -13
  94. package/src/renderer/settings-modal-helpers.ts +1 -1
  95. package/src/renderer/settings-modal.ts +77 -8
  96. package/src/renderer/syntax-highlighter.ts +10 -3
  97. package/src/renderer/term-caps.ts +318 -0
  98. package/src/renderer/theme.ts +158 -0
  99. package/src/renderer/tool-call.ts +12 -2
  100. package/src/renderer/ui-factory.ts +50 -6
  101. package/src/runtime/bootstrap-command-context.ts +1 -0
  102. package/src/runtime/bootstrap-command-parts.ts +14 -0
  103. package/src/runtime/bootstrap-core.ts +121 -13
  104. package/src/runtime/bootstrap.ts +2 -0
  105. package/src/runtime/onboarding/apply.ts +4 -6
  106. package/src/runtime/onboarding/index.ts +1 -0
  107. package/src/runtime/onboarding/markers.ts +42 -49
  108. package/src/runtime/onboarding/progress.ts +148 -0
  109. package/src/runtime/onboarding/state.ts +133 -55
  110. package/src/runtime/onboarding/types.ts +20 -0
  111. package/src/runtime/sandbox-qemu-templates.ts +15 -0
  112. package/src/runtime/services.ts +21 -0
  113. package/src/runtime/wrfc-persistence.ts +237 -0
  114. package/src/shell/blocking-input.ts +20 -5
  115. package/src/tools/wrfc-agent-guard.ts +64 -3
  116. package/src/utils/format-elapsed.ts +30 -0
  117. package/src/utils/terminal-width.ts +45 -0
  118. package/src/version.ts +1 -1
  119. package/src/work-plans/work-plan-store.ts +4 -6
  120. package/src/planning/project-planning-coordinator.ts +0 -543
@@ -16,6 +16,23 @@ import {
16
16
  buildEmptyState,
17
17
  } from './polish.ts';
18
18
  import { getDisplayWidth } from '../utils/terminal-width.ts';
19
+ import {
20
+ type ConfirmState,
21
+ handleConfirmInput,
22
+ } from './confirm-state.ts';
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Constants
26
+ // ---------------------------------------------------------------------------
27
+
28
+ /** Chains in non-terminal state with no event for this long are shown as STALLED. */
29
+ const STALL_THRESHOLD_MS = 5 * 60 * 1000;
30
+
31
+ /** Terminal states — chains in these states cannot be resumed or cancelled. */
32
+ const TERMINAL_STATES: readonly WrfcState[] = ['passed', 'failed'];
33
+
34
+ /** States from which resume is permitted (pending = chain is awaiting retry). */
35
+ const RESUMABLE_STATES: readonly WrfcState[] = ['pending'];
19
36
 
20
37
  // ---------------------------------------------------------------------------
21
38
  // Colour palette
@@ -53,6 +70,7 @@ const C = {
53
70
  constraintSat: '#22c55e', // green — satisfied
54
71
  constraintUnsat:'#ef4444', // red — unsatisfied
55
72
  constraintUnv: '#4b5563', // grey — unverified (no finding yet)
73
+ stalled: '#f59e0b', // amber — stalled / no recent event
56
74
  } as const;
57
75
 
58
76
  // ---------------------------------------------------------------------------
@@ -157,7 +175,9 @@ export function constraintStatusMarker(
157
175
  // Panel
158
176
  // ---------------------------------------------------------------------------
159
177
  export interface WrfcPanelDeps {
160
- readonly controller: Pick<WrfcController, 'listChains'>;
178
+ readonly controller: Pick<WrfcController, 'listChains' | 'resumeChain'>;
179
+ /** Cancel the active agent for a chain by its ownerAgentId. Returns true if cancelled. */
180
+ readonly cancelChain: (agentId: string) => boolean;
161
181
  }
162
182
 
163
183
  export class WrfcPanel extends BasePanel {
@@ -166,6 +186,12 @@ export class WrfcPanel extends BasePanel {
166
186
  private scrollOffset = 0;
167
187
  private expandedChainIds = new Set<string>();
168
188
  private unsubscribers: Array<() => void> = [];
189
+ /** Last event timestamp per chain id, for stall detection. */
190
+ private lastEventAt = new Map<string, number>();
191
+ /** Pending cancel confirmation: subject is the chain id to cancel. */
192
+ private confirmCancel: ConfirmState<string> | null = null;
193
+ /** Controller error seen after initialization (distinct from pre-init silence). */
194
+ private controllerError: string | null = null;
169
195
 
170
196
  constructor(
171
197
  private readonly workflowEvents: UiEventFeed<WorkflowEvent>,
@@ -193,11 +219,35 @@ export class WrfcPanel extends BasePanel {
193
219
  // Input
194
220
  // -------------------------------------------------------------------------
195
221
  handleInput(key: string): boolean {
222
+ // Confirm-cancel flow takes priority over all other keys.
223
+ // Enter and y both confirm; n, escape both cancel; any other key is absorbed.
224
+ if (this.confirmCancel) {
225
+ const confirmResult = handleConfirmInput(this.confirmCancel, key);
226
+ if (confirmResult === 'confirmed') {
227
+ const chain = this.chains.find(c => c.id === this.confirmCancel!.subject);
228
+ if (chain && !TERMINAL_STATES.includes(chain.state)) {
229
+ this.deps.cancelChain(chain.ownerAgentId);
230
+ }
231
+ this.confirmCancel = null;
232
+ this.markDirty();
233
+ return true;
234
+ }
235
+ if (confirmResult === 'cancelled') {
236
+ this.confirmCancel = null;
237
+ this.markDirty();
238
+ }
239
+ // absorbed: confirm stays pending, key swallowed
240
+ return true;
241
+ }
242
+
243
+ // Normal key dispatch.
196
244
  switch (key) {
197
245
  case 'up': this.moveSelection(-1); return true;
198
246
  case 'down': this.moveSelection(1); return true;
199
247
  case 'return':
200
248
  case 'enter': this.toggleExpanded(); return true;
249
+ case 'c': this.beginCancelConfirm(); return true;
250
+ case 'r': this.doResume(); return true;
201
251
  default: return false;
202
252
  }
203
253
  }
@@ -207,25 +257,37 @@ export class WrfcPanel extends BasePanel {
207
257
  // -------------------------------------------------------------------------
208
258
  render(width: number, height: number): Line[] {
209
259
  return this.trackedRender(() => {
210
- const activeCount = this.chains.filter(c => !['passed', 'failed'].includes(c.state)).length;
260
+ const now = Date.now();
261
+ const activeCount = this.chains.filter(c => !TERMINAL_STATES.includes(c.state)).length;
211
262
  const passedCount = this.chains.filter(c => c.state === 'passed').length;
212
263
  const failedCount = this.chains.filter(c => c.state === 'failed').length;
213
264
 
214
265
  if (this.chains.length === 0) {
266
+ const emptySections: PanelWorkspaceSection[] = [
267
+ {
268
+ lines: buildEmptyState(
269
+ width,
270
+ ' No WRFC chains yet',
271
+ 'WRFC chains appear here as review/fix cycles execute. Expanded rows show scores, gates, issues, and failure detail.',
272
+ [],
273
+ DEFAULT_PANEL_PALETTE,
274
+ ),
275
+ },
276
+ ];
277
+ if (this.controllerError) {
278
+ emptySections.push({
279
+ lines: [
280
+ buildPanelLine(width, [
281
+ [' controller: ', DEFAULT_PANEL_PALETTE.dim],
282
+ [truncate(this.controllerError, width - 16), DEFAULT_PANEL_PALETTE.warn],
283
+ ]),
284
+ ],
285
+ });
286
+ }
215
287
  return buildPanelWorkspace(width, height, {
216
288
  title: ' WRFC Chain Monitor',
217
289
  intro: 'Track WRFC engineering, review, fixing, gating, and final chain outcomes.',
218
- sections: [
219
- {
220
- lines: buildEmptyState(
221
- width,
222
- ' No WRFC chains yet',
223
- 'WRFC chains appear here as review/fix cycles execute. Expanded rows show scores, gates, issues, and failure detail.',
224
- [],
225
- DEFAULT_PANEL_PALETTE,
226
- ),
227
- },
228
- ],
290
+ sections: emptySections,
229
291
  palette: DEFAULT_PANEL_PALETTE,
230
292
  });
231
293
  }
@@ -238,9 +300,10 @@ export class WrfcPanel extends BasePanel {
238
300
  const isExpanded = this.expandedChainIds.has(chain.id);
239
301
  const rowBg = isSelected ? C.selected : '';
240
302
  const rowFg = isSelected ? C.selectedFg : '';
303
+ const stalled = this.isStalled(chain, now);
241
304
 
242
305
  if (isSelected) selectedLineIndex = chainLines.length;
243
- chainLines.push(...this.renderChainRow(chain, width, isSelected, isExpanded, rowBg, rowFg));
306
+ chainLines.push(...this.renderChainRow(chain, width, isSelected, isExpanded, rowBg, rowFg, stalled));
244
307
 
245
308
  if (isExpanded) {
246
309
  chainLines.push(...this.renderChainDetail(chain, width, 12));
@@ -298,11 +361,39 @@ export class WrfcPanel extends BasePanel {
298
361
  title: 'Selected',
299
362
  lines: selectedLines,
300
363
  };
364
+ // Confirm-cancel overlay section.
365
+ const confirmSection: PanelWorkspaceSection | null = this.confirmCancel ? {
366
+ title: 'Confirm Cancel',
367
+ lines: [
368
+ buildPanelLine(width, [
369
+ [' Cancel chain "', DEFAULT_PANEL_PALETTE.warn],
370
+ [truncate(this.confirmCancel.label, Math.max(8, width - 32)), DEFAULT_PANEL_PALETTE.value],
371
+ ['"?', DEFAULT_PANEL_PALETTE.warn],
372
+ ]),
373
+ buildPanelLine(width, [
374
+ [' y', DEFAULT_PANEL_PALETTE.info], [' confirm', DEFAULT_PANEL_PALETTE.dim],
375
+ [' Enter', DEFAULT_PANEL_PALETTE.info], [' confirm', DEFAULT_PANEL_PALETTE.dim],
376
+ [' n / Esc', DEFAULT_PANEL_PALETTE.info], [' cancel', DEFAULT_PANEL_PALETTE.dim],
377
+ ]),
378
+ ],
379
+ } : null;
380
+
381
+ // Footer: show resume-disabled reason for the selected chain.
382
+ const selectedForFooter = this.chains[this.selectedIndex];
383
+ const resumeReason = selectedForFooter ? this.resumeDisabledReason(selectedForFooter) : null;
384
+ const footerLines: Line[] = [
385
+ buildPanelLine(width, [
386
+ [' Up/Down', DEFAULT_PANEL_PALETTE.info], [' navigate', DEFAULT_PANEL_PALETTE.dim],
387
+ [' Enter', DEFAULT_PANEL_PALETTE.info], [' expand', DEFAULT_PANEL_PALETTE.dim],
388
+ [' c', DEFAULT_PANEL_PALETTE.info], [' cancel', DEFAULT_PANEL_PALETTE.dim],
389
+ [' r', resumeReason ? DEFAULT_PANEL_PALETTE.dim : DEFAULT_PANEL_PALETTE.info],
390
+ [resumeReason ? ` resume (${resumeReason})` : ' resume', DEFAULT_PANEL_PALETTE.dim],
391
+ ]),
392
+ ];
393
+
301
394
  const chainsSection = resolveScrollablePanelSection(width, height, {
302
395
  intro: 'Track WRFC engineering, review, fixing, gating, and final chain outcomes.',
303
- footerLines: [
304
- buildPanelLine(width, [[' Up/Down', DEFAULT_PANEL_PALETTE.info], [' navigate', DEFAULT_PANEL_PALETTE.dim], [' Enter', DEFAULT_PANEL_PALETTE.info], [' expand', DEFAULT_PANEL_PALETTE.dim]]),
305
- ],
396
+ footerLines,
306
397
  palette: DEFAULT_PANEL_PALETTE,
307
398
  beforeSections: [summarySection],
308
399
  section: {
@@ -312,23 +403,41 @@ export class WrfcPanel extends BasePanel {
312
403
  scrollOffset: this.scrollOffset,
313
404
  minRows: 8,
314
405
  },
315
- afterSections: [selectedSection],
406
+ afterSections: confirmSection
407
+ ? [selectedSection, confirmSection]
408
+ : [selectedSection],
316
409
  });
317
410
  this.scrollOffset = chainsSection.scrollOffset;
318
- const sections: PanelWorkspaceSection[] = [summarySection, chainsSection.section, selectedSection];
411
+ const sections: PanelWorkspaceSection[] = [
412
+ summarySection,
413
+ chainsSection.section,
414
+ selectedSection,
415
+ ...(confirmSection ? [confirmSection] : []),
416
+ ];
417
+
418
+ // Controller error section (post-init only).
419
+ if (this.controllerError) {
420
+ sections.push({
421
+ lines: [
422
+ buildPanelLine(width, [
423
+ [' controller: ', DEFAULT_PANEL_PALETTE.dim],
424
+ [truncate(this.controllerError, width - 16), DEFAULT_PANEL_PALETTE.warn],
425
+ ]),
426
+ ],
427
+ });
428
+ }
319
429
 
320
430
  return buildPanelWorkspace(width, height, {
321
431
  title: ' WRFC Chain Monitor',
322
432
  intro: 'Track WRFC engineering, review, fixing, gating, and final chain outcomes.',
323
433
  sections,
324
- footerLines: [
325
- buildPanelLine(width, [[' Up/Down', DEFAULT_PANEL_PALETTE.info], [' navigate', DEFAULT_PANEL_PALETTE.dim], [' Enter', DEFAULT_PANEL_PALETTE.info], [' expand', DEFAULT_PANEL_PALETTE.dim]]),
326
- ],
434
+ footerLines,
327
435
  palette: DEFAULT_PANEL_PALETTE,
328
436
  });
329
437
  });
330
438
  }
331
439
 
440
+
332
441
  // -------------------------------------------------------------------------
333
442
  // Rendering helpers
334
443
  // -------------------------------------------------------------------------
@@ -339,9 +448,12 @@ export class WrfcPanel extends BasePanel {
339
448
  isExpanded: boolean,
340
449
  bg: string,
341
450
  fg: string,
451
+ stalled = false,
342
452
  ): Line[] {
343
- const stateCol = stateColor(chain.state);
344
- const stateTag = ` ${stateLabel(chain.state).padEnd(6)}`;
453
+ const stateCol = stalled ? C.stalled : stateColor(chain.state);
454
+ const stateTag = stalled
455
+ ? ` STALLED`
456
+ : ` ${stateLabel(chain.state).padEnd(6)}`;
345
457
  const arrow = isExpanded ? '▾' : '▸';
346
458
  const chainIdShort = chain.id.slice(-6);
347
459
  const prefix = ` ${arrow} [${chainIdShort}] `;
@@ -350,6 +462,7 @@ export class WrfcPanel extends BasePanel {
350
462
  const latestScore = chain.reviewScores.length > 0
351
463
  ? ` ${chain.reviewScores[chain.reviewScores.length - 1].toFixed(1)}/10`
352
464
  : '';
465
+ const stalledBadge = stalled ? ' [STALLED]' : '';
353
466
  // Constraint badge: c:sat/total — only when constraints exist
354
467
  let constraintBadge = '';
355
468
  if (chain.constraints.length > 0) {
@@ -358,7 +471,7 @@ export class WrfcPanel extends BasePanel {
358
471
  const satisfied = findings ? findings.filter(f => f.satisfied).length : 0;
359
472
  constraintBadge = ` c:${satisfied}/${total}`;
360
473
  }
361
- const rightInfo = `${latestScore}${fixes}${cycles}${constraintBadge} `;
474
+ const rightInfo = `${stalledBadge}${latestScore}${fixes}${cycles}${constraintBadge} `;
362
475
 
363
476
  // Compute how much space the task text can use, then check if rightInfo fits.
364
477
  // If the terminal is narrow and rightInfo would overflow, omit it entirely
@@ -572,28 +685,49 @@ export class WrfcPanel extends BasePanel {
572
685
  // Event subscriptions
573
686
  // -------------------------------------------------------------------------
574
687
  private subscribeToEvents(): void {
575
- const refresh = () => { this.syncFromController(); this.markDirty(); };
688
+ const refreshWithTimestamp = (chainId?: string) => {
689
+ if (chainId) this.lastEventAt.set(chainId, Date.now());
690
+ this.syncFromController();
691
+ this.markDirty();
692
+ };
693
+
694
+ // Each workflow event carries the chainId in its payload — record it for
695
+ // stall tracking. The handler signature is (event) => void; we extract
696
+ // chainId where present using a narrow type guard so we never guess.
697
+ const timestamped = (e: WorkflowEvent) => {
698
+ const chainId = 'chainId' in e && typeof e.chainId === 'string' ? e.chainId : undefined;
699
+ refreshWithTimestamp(chainId);
700
+ };
576
701
 
577
702
  this.unsubscribers.push(
578
- this.workflowEvents.on('WORKFLOW_CHAIN_CREATED', refresh),
579
- this.workflowEvents.on('WORKFLOW_STATE_CHANGED', refresh),
580
- this.workflowEvents.on('WORKFLOW_REVIEW_COMPLETED', refresh),
581
- this.workflowEvents.on('WORKFLOW_FIX_ATTEMPTED', refresh),
582
- this.workflowEvents.on('WORKFLOW_GATE_RESULT', refresh),
583
- this.workflowEvents.on('WORKFLOW_CHAIN_PASSED', refresh),
584
- this.workflowEvents.on('WORKFLOW_CHAIN_FAILED', refresh),
585
- this.workflowEvents.on('WORKFLOW_AUTO_COMMITTED', refresh),
586
- this.workflowEvents.on('WORKFLOW_CASCADE_ABORTED', refresh),
587
- this.workflowEvents.on('WORKFLOW_CONSTRAINTS_ENUMERATED', refresh),
703
+ this.workflowEvents.on('WORKFLOW_CHAIN_CREATED', timestamped),
704
+ this.workflowEvents.on('WORKFLOW_STATE_CHANGED', timestamped),
705
+ this.workflowEvents.on('WORKFLOW_REVIEW_COMPLETED', timestamped),
706
+ this.workflowEvents.on('WORKFLOW_FIX_ATTEMPTED', timestamped),
707
+ this.workflowEvents.on('WORKFLOW_GATE_RESULT', timestamped),
708
+ this.workflowEvents.on('WORKFLOW_CHAIN_PASSED', timestamped),
709
+ this.workflowEvents.on('WORKFLOW_CHAIN_FAILED', timestamped),
710
+ this.workflowEvents.on('WORKFLOW_AUTO_COMMITTED', timestamped),
711
+ this.workflowEvents.on('WORKFLOW_CASCADE_ABORTED', timestamped),
712
+ this.workflowEvents.on('WORKFLOW_CONSTRAINTS_ENUMERATED', timestamped),
588
713
  );
589
714
  }
590
715
 
591
716
  private syncFromController(): void {
717
+ // Distinguish two failure modes:
718
+ // 1. Controller not yet initialized — chains is empty Map, listChains
719
+ // returns [] with no error. This is the normal pre-ready path.
720
+ // 2. Controller throws after initialization — an actual error we surface.
721
+ // We detect (2) by checking whether we previously had chains: if chains
722
+ // were present and listChains now throws, that is a post-init error.
723
+ const hadChains = this.chains.length > 0;
592
724
  try {
593
- // Sort: active first (by createdAt desc), then completed
594
725
  const all = this.deps.controller.listChains();
595
- const active = all.filter(c => !['passed', 'failed'].includes(c.state));
596
- const done = all.filter(c => ['passed', 'failed'].includes(c.state));
726
+ this.controllerError = null;
727
+
728
+ // Sort: active first (by createdAt desc), then completed
729
+ const active = all.filter(c => !TERMINAL_STATES.includes(c.state));
730
+ const done = all.filter(c => TERMINAL_STATES.includes(c.state));
597
731
  active.sort((a, b) => b.createdAt - a.createdAt);
598
732
  done.sort( (a, b) => (b.completedAt ?? 0) - (a.completedAt ?? 0));
599
733
  this.chains = [...active, ...done];
@@ -602,8 +736,57 @@ export class WrfcPanel extends BasePanel {
602
736
  if (this.chains.length > 0) {
603
737
  this.selectedIndex = Math.min(this.selectedIndex, this.chains.length - 1);
604
738
  }
605
- } catch {
606
- // WrfcController not yet initialized — leave chain list empty
739
+ } catch (err) {
740
+ if (hadChains) {
741
+ // Post-init error: the controller was working and now throws. Surface
742
+ // a faint diagnostic rather than silently preserving stale state.
743
+ const msg = err instanceof Error ? err.message : String(err);
744
+ this.controllerError = msg;
745
+ console.debug('[WrfcPanel] controller.listChains() error post-init:', msg);
746
+ }
747
+ // Pre-init: controller not ready yet, leave chain list empty (no error).
607
748
  }
608
749
  }
750
+
751
+ // -------------------------------------------------------------------------
752
+ // Cancel / resume actions
753
+ // -------------------------------------------------------------------------
754
+
755
+ /** Initiate cancel-confirm flow for the selected chain (noop if terminal). */
756
+ private beginCancelConfirm(): void {
757
+ const chain = this.chains[this.selectedIndex];
758
+ if (!chain || TERMINAL_STATES.includes(chain.state)) return;
759
+ this.confirmCancel = {
760
+ subject: chain.id,
761
+ label: truncate(chain.task, 40),
762
+ };
763
+ this.markDirty();
764
+ }
765
+
766
+ /**
767
+ * Resume the selected chain via the controller.
768
+ * Only permitted when the chain state is in RESUMABLE_STATES.
769
+ * Emits a visible noop reason when the chain is not resumable.
770
+ */
771
+ private doResume(): void {
772
+ const chain = this.chains[this.selectedIndex];
773
+ if (!chain) return;
774
+ if (!RESUMABLE_STATES.includes(chain.state)) return;
775
+ this.deps.controller.resumeChain(chain.id);
776
+ this.markDirty();
777
+ }
778
+
779
+ /** Returns a human-readable reason why resume is disabled for a chain, or null if it is allowed. */
780
+ private resumeDisabledReason(chain: WrfcChain): string | null {
781
+ if (TERMINAL_STATES.includes(chain.state)) return 'chain is complete';
782
+ if (!RESUMABLE_STATES.includes(chain.state)) return `active (${stateLabel(chain.state)})`;
783
+ return null;
784
+ }
785
+
786
+ /** Returns whether a chain is considered stalled (non-terminal, no recent event). */
787
+ private isStalled(chain: WrfcChain, now: number): boolean {
788
+ if (TERMINAL_STATES.includes(chain.state)) return false;
789
+ const last = this.lastEventAt.get(chain.id) ?? chain.createdAt;
790
+ return (now - last) >= STALL_THRESHOLD_MS;
791
+ }
609
792
  }
@@ -11,7 +11,6 @@ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
11
11
 
12
12
  // ─── Constants ────────────────────────────────────────────────────────────────
13
13
 
14
- const TOKENS_PER_TOOL_CALL = 400;
15
14
  const MAX_LOG_ENTRIES = 10;
16
15
  const AGENT_ID_DISPLAY_LENGTH = 16;
17
16
 
@@ -100,13 +99,6 @@ export class AgentDetailModal {
100
99
  }
101
100
  }
102
101
 
103
- // ─── Helpers ──────────────────────────────────────────────────────────────────
104
-
105
- /** Rough token estimate: toolCallCount * avg tokens per tool exchange. */
106
- function estimateTokens(toolCallCount: number): number {
107
- return toolCallCount * TOKENS_PER_TOOL_CALL;
108
- }
109
-
110
102
  // ─── renderAgentDetailModal ───────────────────────────────────────────────────
111
103
 
112
104
  /**
@@ -150,7 +142,6 @@ export function renderAgentDetailModal(
150
142
 
151
143
  const now = Date.now();
152
144
  const elapsedMs = (rec.completedAt ?? now) - rec.startedAt;
153
- const tokenEst = estimateTokens(rec.toolCallCount);
154
145
 
155
146
  // ── Build sections ────────────────────────────────────────────────────────
156
147
 
@@ -176,7 +167,17 @@ export function renderAgentDetailModal(
176
167
 
177
168
  // Metrics
178
169
  sections.push({ type: 'text', content: `Tool calls : ${rec.toolCallCount}` });
179
- sections.push({ type: 'text', content: `Est tokens : ~${tokenEst.toLocaleString()}` });
170
+ if (rec.usage) {
171
+ const totalIn = rec.usage.inputTokens + (rec.usage.cacheReadTokens ?? 0) + (rec.usage.cacheWriteTokens ?? 0);
172
+ sections.push({ type: 'text', content: `Tokens in : ${totalIn.toLocaleString()}` });
173
+ sections.push({ type: 'text', content: `Tokens out : ${rec.usage.outputTokens.toLocaleString()}` });
174
+ } else {
175
+ sections.push({
176
+ type: 'text',
177
+ content: 'Tokens : n/a (agent running)',
178
+ style: { dim: true },
179
+ });
180
+ }
180
181
 
181
182
  // SDK 0.23.0: systemPromptAddendum indicator — confirms WRFC constraint addendum was injected
182
183
  if (rec.systemPromptAddendum) {
@@ -268,6 +268,14 @@ function tokenizePlain(line: string): SyntaxToken[] {
268
268
 
269
269
  // ─── Main Renderer ───────────────────────────────────────────────────────────
270
270
 
271
+ /**
272
+ * Module-level SyntaxHighlighter singleton.
273
+ * Shared across all renderCodeBlock calls so the parse cache and pending-dedup
274
+ * set persist between renders. Creating a new instance per call was throwing away
275
+ * cached results and defeating the FIFO-200 cache.
276
+ */
277
+ const _sharedHighlighter = new SyntaxHighlighter();
278
+
271
279
  /**
272
280
  * renderCodeBlock - Render lines of code with syntax highlighting and line numbers.
273
281
  * Returns Line[] for the cell-based pipeline.
@@ -276,7 +284,7 @@ export function renderCodeBlock(
276
284
  codeLines: string[],
277
285
  lang: string,
278
286
  width: number,
279
- opts: { showLineNumbers?: boolean } = {},
287
+ opts: { showLineNumbers?: boolean; isStreaming?: boolean } = {},
280
288
  ): Line[] {
281
289
  const lines: Line[] = [];
282
290
  const language = detectLanguage(lang);
@@ -291,7 +299,7 @@ export function renderCodeBlock(
291
299
  // Try tree-sitter highlight cache first (populated asynchronously).
292
300
  // Falls back to regex tokenizer when parser not yet ready or language unsupported.
293
301
  const fullCode = codeLines.join('\n');
294
- const hlLines = lang ? new SyntaxHighlighter().highlight(fullCode, lang) : null;
302
+ const hlLines = lang ? _sharedHighlighter.highlight(fullCode, lang, opts.isStreaming ?? false) : null;
295
303
 
296
304
  // Regex tokenizer fallback (used when tree-sitter not ready)
297
305
  const regexTokenize = (line: string): SyntaxToken[] => {
@@ -4,6 +4,10 @@ import { type Line, createEmptyCell, createStyledCell } from '../types/grid.ts';
4
4
  import { getDisplayWidth } from '../utils/terminal-width.ts';
5
5
  import type { SearchManager } from '../input/search.ts';
6
6
  import { allowTerminalWrite } from '../runtime/terminal-output-guard.ts';
7
+ import { probeTermCaps, type TermColorCaps } from './term-caps.ts';
8
+ import { DARK_THEME } from './theme.ts';
9
+
10
+ const T = DARK_THEME;
7
11
 
8
12
  export interface SelectionInfo {
9
13
  isCellSelected: (col: number, absoluteRow: number) => boolean;
@@ -58,9 +62,19 @@ export class Compositor {
58
62
  /** Double-buffer reuse: back is written, front is the last-rendered reference. */
59
63
  private frontBuffer: TerminalBuffer | null = null;
60
64
  private backBuffer: TerminalBuffer | null = null;
61
- private diffEngine = new DiffEngine();
65
+ private readonly caps: TermColorCaps;
66
+ private diffEngine: DiffEngine;
67
+
68
+ constructor(private stdout: NodeJS.WriteStream) {
69
+ // Probe terminal capabilities once at construction time.
70
+ this.caps = probeTermCaps(stdout);
71
+ this.diffEngine = new DiffEngine(this.caps);
72
+ }
62
73
 
63
- constructor(private stdout: NodeJS.WriteStream) {}
74
+ /** Exposed for unit tests — returns the detected color capability. */
75
+ public get termCapsForTest(): TermColorCaps {
76
+ return this.caps;
77
+ }
64
78
 
65
79
  /** Exposed for unit tests — returns the last composited buffer. */
66
80
  public get lastBufferForTest(): TerminalBuffer | null {
@@ -241,9 +255,9 @@ export class Compositor {
241
255
  const isCurrent = search.manager.isCurrentMatch(absoluteRow, match.col);
242
256
  for (let x = match.col; x < match.col + match.length && x < leftWidth; x++) {
243
257
  if (isCurrent) {
244
- newBuffer.setCell(x, screenY, { bg: '#ffff00', fg: '#000000', bold: true, dim: false });
258
+ newBuffer.setCell(x, screenY, { bg: T.searchCurrentBg, fg: T.searchCurrentFg, bold: true, dim: false });
245
259
  } else {
246
- newBuffer.setCell(x, screenY, { bg: '#806600', fg: '#ffffff', bold: false, dim: false });
260
+ newBuffer.setCell(x, screenY, { bg: T.searchMatchBg, fg: T.searchMatchFg, bold: false, dim: false });
247
261
  }
248
262
  }
249
263
  }
@@ -2,6 +2,7 @@ import { type Line } from '../types/grid.ts';
2
2
  import { ModalFactory } from './modal-factory.ts';
3
3
  import type { ConversationManager } from '../core/conversation';
4
4
  import { getOverlayContentBudget, getOverlaySurfaceMetrics, getStableOverlayContentRows } from './overlay-viewport.ts';
5
+ import { estimateTokens } from '@pellux/goodvibes-sdk/platform/core';
5
6
 
6
7
  // ─── ContextInspectorModal ────────────────────────────────────────────────────
7
8
 
@@ -22,11 +23,6 @@ export class ContextInspectorModal {
22
23
 
23
24
  // ─── renderContextInspector ───────────────────────────────────────────────────
24
25
 
25
- /** Rough token estimate: 4 chars ≈ 1 token. */
26
- function estimateTokens(text: string): number {
27
- return Math.ceil(text.length / 4);
28
- }
29
-
30
26
  /** Format a number with thousands separators. */
31
27
  function fmtN(n: number): string {
32
28
  return n.toLocaleString();