@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,11 +1,11 @@
1
1
  import type { Line } from '../types/grid.ts';
2
2
  import type { WrfcChain, WrfcState, QualityGateResult } from '@pellux/goodvibes-sdk/platform/agents';
3
- import type { Constraint, ConstraintFinding } from '@pellux/goodvibes-sdk/platform/agents';
4
3
  import type { WrfcController } from '@pellux/goodvibes-sdk/platform/agents';
5
4
  import { BasePanel } from './base-panel.ts';
6
5
  import type { WorkflowEvent } from '@/runtime/index.ts';
7
6
  import type { UiEventFeed } from '../runtime/ui-events.ts';
8
7
  import {
8
+ buildMeterLine,
9
9
  buildPanelLine,
10
10
  buildPanelWorkspace,
11
11
  resolveScrollablePanelSection,
@@ -20,6 +20,7 @@ import {
20
20
  type ConfirmState,
21
21
  handleConfirmInput,
22
22
  } from './confirm-state.ts';
23
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
23
24
 
24
25
  // ---------------------------------------------------------------------------
25
26
  // Constants
@@ -31,145 +32,33 @@ const STALL_THRESHOLD_MS = 5 * 60 * 1000;
31
32
  /** Terminal states — chains in these states cannot be resumed or cancelled. */
32
33
  const TERMINAL_STATES: readonly WrfcState[] = ['passed', 'failed'];
33
34
 
34
- /** States from which resume is permitted (pending = chain is awaiting retry). */
35
- const RESUMABLE_STATES: readonly WrfcState[] = ['pending'];
36
-
37
- // ---------------------------------------------------------------------------
38
- // Colour palette
39
- // ---------------------------------------------------------------------------
40
- const C = {
41
- // states
42
- passed: '#22c55e', // green
43
- failed: '#ef4444', // red
44
- reviewing: '#eab308', // yellow
45
- engineering:'#22d3ee', // cyan
46
- fixing: '#f97316', // orange
47
- pending: '#6b7280', // grey
48
- gating: '#a78bfa', // violet
49
- committing: '#38bdf8', // sky
50
-
51
- // UI chrome
52
- header: '#94a3b8',
53
- headerBold: '#e2e8f0',
54
- dim: '#4b5563',
55
- label: '#64748b',
56
- value: '#cbd5e1',
57
- selected: '#1e40af', // selection bg
58
- selectedFg: '#f8fafc',
59
- border: '#334155',
60
- spark: '#38bdf8',
61
- sparkLow: '#ef4444',
62
- sparkHigh: '#22c55e',
63
- issueCrit: '#ef4444',
64
- issueMaj: '#f97316',
65
- issueMin: '#eab308',
66
- issueSug: '#6b7280',
67
- gatePass: '#22c55e',
68
- gateFail: '#ef4444',
69
- // constraint status
70
- constraintSat: '#22c55e', // green — satisfied
71
- constraintUnsat:'#ef4444', // red — unsatisfied
72
- constraintUnv: '#4b5563', // grey — unverified (no finding yet)
73
- stalled: '#f59e0b', // amber — stalled / no recent event
74
- } as const;
75
-
76
- // ---------------------------------------------------------------------------
77
- // Helpers
78
- // ---------------------------------------------------------------------------
79
- const SPARKLINE_CHARS = '._-:=+*#';
80
-
81
- export function sparkline(scores: number[], maxScore = 10): string {
82
- if (scores.length === 0) return '';
83
- return scores
84
- .map(s => {
85
- const ratio = Math.max(0, Math.min(1, s / maxScore));
86
- const idx = Math.round(ratio * (SPARKLINE_CHARS.length - 1));
87
- return SPARKLINE_CHARS[idx];
88
- })
89
- .join('');
90
- }
91
-
92
- export function stateColor(state: WrfcState): string {
93
- switch (state) {
94
- case 'passed': return C.passed;
95
- case 'failed': return C.failed;
96
- case 'reviewing': return C.reviewing;
97
- case 'engineering': return C.engineering;
98
- case 'fixing': return C.fixing;
99
- case 'gating':
100
- case 'awaiting_gates': return C.gating;
101
- case 'committing': return C.committing;
102
- default: return C.pending;
103
- }
104
- }
105
-
106
- export function stateLabel(state: WrfcState): string {
107
- switch (state) {
108
- case 'engineering': return 'ENG';
109
- case 'reviewing': return 'REV';
110
- case 'fixing': return 'FIX';
111
- case 'gating': return 'GATE';
112
- case 'awaiting_gates': return 'WAIT';
113
- case 'committing': return 'COMMIT';
114
- case 'passed': return 'PASS';
115
- case 'failed': return 'FAIL';
116
- default: return 'PEND';
117
- }
118
- }
119
-
120
- function issueColor(severity: string): string {
121
- switch (severity) {
122
- case 'critical': return C.issueCrit;
123
- case 'major': return C.issueMaj;
124
- case 'minor': return C.issueMin;
125
- default: return C.issueSug;
126
- }
127
- }
128
-
129
- function issuePrefix(severity: string): string {
130
- switch (severity) {
131
- case 'critical': return '[CRIT] ';
132
- case 'major': return '[MAJR] ';
133
- case 'minor': return '[MINR] ';
134
- default: return '[SUGG] ';
135
- }
136
- }
137
-
138
- export function truncate(s: string, max: number): string {
139
- if (s.length <= max) return s;
140
- return s.slice(0, max - 3) + '...';
141
- }
35
+ /**
36
+ * States from which resume is permitted. These mirror the states
37
+ * WrfcController.resumeChain() actually recovers from: 'pending' (awaiting
38
+ * retry / start engineering), 'reviewing' and 'fixing' (re-spawn the
39
+ * interrupted child), and 'awaiting_gates' (re-run gates). resumeChain no-ops
40
+ * safely when a child is still running, so offering resume for these states is
41
+ * always safe — it is exactly the set an operator needs after a restart or a
42
+ * STALLED chain.
43
+ */
44
+ const RESUMABLE_STATES: readonly WrfcState[] = ['pending', 'reviewing', 'fixing', 'awaiting_gates'];
142
45
 
143
46
  // ---------------------------------------------------------------------------
144
- // Constraint helpers
47
+ // Colour palette + formatting helpers live in ./wrfc-panel-format.ts (extracted
48
+ // to keep this module under the architecture line-count cap). Re-exported here
49
+ // so ./wrfc-panel.ts remains their public import site.
145
50
  // ---------------------------------------------------------------------------
146
-
147
- /**
148
- * Returns display tag, foreground colour, and dim flag for a single constraint
149
- * based on whether a reviewer finding exists for it.
150
- */
151
- export function constraintStatusMarker(
152
- constraint: Constraint,
153
- findings: ConstraintFinding[] | undefined,
154
- ): { tag: string; fg: string; dim: boolean } {
155
- const finding = findings?.find(f => f.constraintId === constraint.id);
156
- if (!finding) {
157
- return { tag: '[UNV]', fg: C.constraintUnv, dim: true };
158
- }
159
- if (finding.satisfied) {
160
- return { tag: '[SAT]', fg: C.constraintSat, dim: false };
161
- }
162
- // Unsatisfied — use severity to pick colour and tag text
163
- const sev = finding.severity ?? 'major';
164
- let sevTag: string;
165
- let fg: string;
166
- switch (sev) {
167
- case 'critical': sevTag = '[UNS CRIT]'; fg = C.issueCrit; break;
168
- case 'minor': sevTag = '[UNS MINOR]'; fg = C.issueMin; break;
169
- default: sevTag = '[UNS MAJOR]'; fg = C.issueMaj; break;
170
- }
171
- return { tag: sevTag, fg, dim: false };
172
- }
51
+ import {
52
+ C,
53
+ sparkline,
54
+ stateColor,
55
+ stateLabel,
56
+ issueColor,
57
+ issuePrefix,
58
+ truncate,
59
+ constraintStatusMarker,
60
+ } from './wrfc-panel-format.ts';
61
+ export { sparkline, stateColor, stateLabel, truncate, constraintStatusMarker } from './wrfc-panel-format.ts';
173
62
 
174
63
  // ---------------------------------------------------------------------------
175
64
  // Panel
@@ -269,7 +158,9 @@ export class WrfcPanel extends BasePanel {
269
158
  width,
270
159
  ' No WRFC chains yet',
271
160
  'WRFC chains appear here as review/fix cycles execute. Expanded rows show scores, gates, issues, and failure detail.',
272
- [],
161
+ [
162
+ { command: '/wrfc run <task>', summary: 'start a write-review-fix-commit chain for a task' },
163
+ ],
273
164
  DEFAULT_PANEL_PALETTE,
274
165
  ),
275
166
  },
@@ -334,7 +225,7 @@ export class WrfcPanel extends BasePanel {
334
225
  const satisfied = findings ? findings.filter(f => f.satisfied).length : 0;
335
226
  const satFg = !findings || findings.length === 0
336
227
  ? DEFAULT_PANEL_PALETTE.dim
337
- : satisfied === total ? C.constraintSat : C.constraintUnsat;
228
+ : satisfied === total ? C.good : C.bad;
338
229
  return [
339
230
  [' Constraints ', DEFAULT_PANEL_PALETTE.label],
340
231
  [`${satisfied} sat / ${total} total`, satFg],
@@ -344,6 +235,10 @@ export class WrfcPanel extends BasePanel {
344
235
  ]
345
236
  : [];
346
237
 
238
+ const totalChains = this.chains.length;
239
+ const stalledCount = this.chains.filter((c) => this.isStalled(c, now)).length;
240
+ const completedChains = passedCount + failedCount;
241
+ const meterWidth = Math.max(10, Math.min(24, width - 34));
347
242
  const summarySection: PanelWorkspaceSection = {
348
243
  title: 'Summary',
349
244
  lines: [
@@ -354,7 +249,16 @@ export class WrfcPanel extends BasePanel {
354
249
  [String(passedCount), DEFAULT_PANEL_PALETTE.good],
355
250
  [' Failed ', DEFAULT_PANEL_PALETTE.label],
356
251
  [String(failedCount), failedCount > 0 ? DEFAULT_PANEL_PALETTE.bad : DEFAULT_PANEL_PALETTE.dim],
252
+ [' Stalled ', DEFAULT_PANEL_PALETTE.label],
253
+ [String(stalledCount), stalledCount > 0 ? C.warn : DEFAULT_PANEL_PALETTE.dim],
357
254
  ]),
255
+ buildMeterLine(
256
+ width,
257
+ totalChains > 0 ? Math.round((completedChains / totalChains) * meterWidth) : 0,
258
+ meterWidth,
259
+ { filled: failedCount > 0 ? DEFAULT_PANEL_PALETTE.bad : DEFAULT_PANEL_PALETTE.good, empty: C.border, label: DEFAULT_PANEL_PALETTE.label },
260
+ { prefix: ' Resolved ', suffix: ` ${completedChains}/${totalChains} ` },
261
+ ),
358
262
  ],
359
263
  };
360
264
  const selectedSection: PanelWorkspaceSection = {
@@ -450,7 +354,7 @@ export class WrfcPanel extends BasePanel {
450
354
  fg: string,
451
355
  stalled = false,
452
356
  ): Line[] {
453
- const stateCol = stalled ? C.stalled : stateColor(chain.state);
357
+ const stateCol = stalled ? C.warn : stateColor(chain.state);
454
358
  const stateTag = stalled
455
359
  ? ` STALLED`
456
360
  : ` ${stateLabel(chain.state).padEnd(6)}`;
@@ -483,7 +387,7 @@ export class WrfcPanel extends BasePanel {
483
387
  const remaining = width - usedWidth;
484
388
 
485
389
  const segments = [
486
- { text: prefix, fg: isSelected ? fg : C.header },
390
+ { text: prefix, fg: isSelected ? fg : C.label },
487
391
  { text: stateTag, fg: stateCol, bold: true },
488
392
  { text: ' ', fg: '' },
489
393
  { text: taskText, fg: isSelected ? fg : C.value },
@@ -498,11 +402,11 @@ export class WrfcPanel extends BasePanel {
498
402
  // Determine badge colour
499
403
  let badgeFg: string;
500
404
  if (!findings || findings.length === 0) {
501
- badgeFg = C.constraintUnv;
405
+ badgeFg = C.dim;
502
406
  } else if (satisfied === total) {
503
- badgeFg = C.constraintSat;
407
+ badgeFg = C.good;
504
408
  } else if (findings.some(f => !f.satisfied)) {
505
- badgeFg = C.constraintUnsat;
409
+ badgeFg = C.bad;
506
410
  } else {
507
411
  badgeFg = C.reviewing; // some unverified but none failed
508
412
  }
@@ -536,7 +440,7 @@ export class WrfcPanel extends BasePanel {
536
440
  if (chain.reviewScores.length > 0) {
537
441
  const spark = sparkline(chain.reviewScores);
538
442
  const latestScore = chain.reviewScores[chain.reviewScores.length - 1];
539
- const sparkColor = latestScore >= 8 ? C.sparkHigh : latestScore >= 5 ? C.spark : C.sparkLow;
443
+ const sparkColor = latestScore >= 8 ? C.good : latestScore >= 5 ? C.info : C.bad;
540
444
  lines.push(buildStyledPanelLine(width, [
541
445
  { text: `${indent}Scores `, fg: C.label },
542
446
  { text: spark, fg: sparkColor },
@@ -648,7 +552,7 @@ export class WrfcPanel extends BasePanel {
648
552
 
649
553
  private renderGateResult(gate: QualityGateResult, width: number, indent: string): Line {
650
554
  const icon = gate.passed ? '✓' : '✕';
651
- const iconFg = gate.passed ? C.gatePass : C.gateFail;
555
+ const iconFg = gate.passed ? C.good : C.bad;
652
556
  const dur = `${gate.durationMs}ms`;
653
557
  const nameMax = width - indent.length - 2 - dur.length - 4;
654
558
  const name = truncate(gate.gate, Math.max(8, nameMax));
@@ -740,7 +644,7 @@ export class WrfcPanel extends BasePanel {
740
644
  if (hadChains) {
741
645
  // Post-init error: the controller was working and now throws. Surface
742
646
  // a faint diagnostic rather than silently preserving stale state.
743
- const msg = err instanceof Error ? err.message : String(err);
647
+ const msg = summarizeError(err);
744
648
  this.controllerError = msg;
745
649
  console.debug('[WrfcPanel] controller.listChains() error post-init:', msg);
746
650
  }
@@ -773,6 +677,8 @@ export class WrfcPanel extends BasePanel {
773
677
  if (!chain) return;
774
678
  if (!RESUMABLE_STATES.includes(chain.state)) return;
775
679
  this.deps.controller.resumeChain(chain.id);
680
+ // Re-sync so the rows/footer reflect the state resumeChain transitioned to.
681
+ this.syncFromController();
776
682
  this.markDirty();
777
683
  }
778
684
 
@@ -4,7 +4,7 @@ import { ModalFactory } from './modal-factory.ts';
4
4
  import type { AgentManager } from '@pellux/goodvibes-sdk/platform/tools';
5
5
  import type { AgentMessageBus } from '@pellux/goodvibes-sdk/platform/agents';
6
6
  import type { WrfcController } from '@pellux/goodvibes-sdk/platform/agents';
7
- import { formatDuration } from './modal-utils.ts';
7
+ import { formatElapsed } from '../utils/format-elapsed.ts';
8
8
  import { logger } from '@pellux/goodvibes-sdk/platform/utils';
9
9
  import { getOverlaySurfaceMetrics, getStableOverlayContentRows } from './overlay-viewport.ts';
10
10
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
@@ -249,7 +249,7 @@ export function renderAgentDetailModal(
249
249
  sections.push({ type: 'text', content: `Model : ${modelStr}` });
250
250
  const isStalled = !MODAL_TERMINAL_STATUSES.has(rec.status) && (now - rec.startedAt) >= MODAL_STALL_THRESHOLD_MS;
251
251
  sections.push({ type: 'text', content: `Status : ${rec.status}${isStalled ? ' [STALLED — 5+ min no activity]' : ''}` });
252
- sections.push({ type: 'text', content: `Duration : ${formatDuration(elapsedMs)}` });
252
+ sections.push({ type: 'text', content: `Duration : ${formatElapsed(elapsedMs)}` });
253
253
  sections.push({ type: 'separator' });
254
254
 
255
255
  // Metrics
@@ -17,6 +17,7 @@
17
17
  */
18
18
 
19
19
  import { estimateConversationTokens } from '@pellux/goodvibes-sdk/platform/core';
20
+ import { computeContextUsage } from '../core/context-usage.ts';
20
21
  import type { CompactionEvent } from '@pellux/goodvibes-sdk/platform/core';
21
22
  import type { ProviderMessage } from '@pellux/goodvibes-sdk/platform/providers';
22
23
 
@@ -58,7 +59,7 @@ export function buildCompactionPreview(opts: CompactionPreviewOptions): string {
58
59
  const tokensAfterEstimate = COMPACTION_OUTPUT_CEILING_ESTIMATE;
59
60
 
60
61
  const contextStr = contextWindow > 0
61
- ? ` (${Math.round((tokensBefore / contextWindow) * 100)}% of ${fmtN(contextWindow)} context window)`
62
+ ? ` (${Math.round(computeContextUsage(tokensBefore, contextWindow).rawRatio * 100)}% of ${fmtN(contextWindow)} context window)`
62
63
  : '';
63
64
 
64
65
  const pinStr = pinnedMemoryCount > 0
@@ -6,9 +6,15 @@ import type { SearchManager } from '../input/search.ts';
6
6
  import { allowTerminalWrite } from '../runtime/terminal-output-guard.ts';
7
7
  import { probeTermCaps, type TermColorCaps } from './term-caps.ts';
8
8
  import { DARK_THEME } from './theme.ts';
9
+ import { UI_TONES } from './ui-primitives.ts';
9
10
 
10
11
  const T = DARK_THEME;
11
12
 
13
+ // Accent / dim colors for the panel focus border. The focused pane's left
14
+ // border column is drawn in the accent tone; the unfocused pane stays dim.
15
+ const PANEL_FOCUS_ACCENT = UI_TONES.state.active; // bright blue
16
+ const PANEL_BORDER_DIM = '238';
17
+
12
18
  export interface SelectionInfo {
13
19
  isCellSelected: (col: number, absoluteRow: number) => boolean;
14
20
  scrollTop: number;
@@ -22,16 +28,18 @@ export interface SearchInfo {
22
28
  }
23
29
 
24
30
  export interface PanelCompositeData {
25
- /** Workspace-level tab bar spanning all open panels. */
31
+ /**
32
+ * The single consolidated workspace tab bar spanning all open panels across
33
+ * both panes. There are no per-pane tab bars — pane focus is shown by the
34
+ * accent border (see `topFocused`/`bottomFocused`).
35
+ */
26
36
  workspaceBar: Line;
27
- /** Top pane: tab bar */
28
- topTabBar?: Line;
29
37
  /** Top pane: panel content lines */
30
38
  topContent: Line[];
31
- /** Whether the top pane is focused (affects separator color) */
39
+ /** Whether the top pane is focused (drives the accent border) */
32
40
  topFocused: boolean;
33
- /** Bottom pane tab bar. Undefined = no bottom pane. */
34
- bottomTabBar?: Line;
41
+ /** Whether a bottom pane is present (splits the panel area). */
42
+ hasBottomPane: boolean;
35
43
  /** Bottom pane content lines. Undefined = no bottom pane. */
36
44
  bottomContent?: Line[];
37
45
  /** Whether the bottom pane is focused */
@@ -113,29 +121,31 @@ export class Compositor {
113
121
  const offset = Math.max(0, vHeight - lineCount);
114
122
 
115
123
  // --- Pre-compute panel row layout when split pane is active ---
116
- // When both top and bottom panes are visible, the panel area is split:
124
+ // A single consolidated workspace bar heads the panel area; there are no
125
+ // per-pane tab bars. When both panes are visible the layout is:
117
126
  // row 0: workspace tab bar
118
- // row 1: top tab bar
119
- // rows 2..topH+1: top content
120
- // row topH+2: horizontal separator (───)
121
- // row topH+3: bottom tab bar
122
- // rows topH+4..end: bottom content
123
- const hasBottomPane = hasPanel && panel!.bottomTabBar !== undefined;
127
+ // rows 1..topH: top content
128
+ // row topH+1: horizontal separator (───)
129
+ // rows topH+2..end: bottom content
130
+ const hasBottomPane = hasPanel && panel!.hasBottomPane;
124
131
  let topPaneHeight = 0; // number of content rows in top pane
125
- let bottomPaneHeight = 0;
126
132
  let hSepRow = -1; // viewport row of the horizontal separator
127
133
  if (hasPanel && hasBottomPane) {
128
- const panelAreaRows = Math.max(0, vHeight - 1); // subtract workspace tab bar
129
- // top: 1 (tabbar) + topContent rows; bottom: 1 (sep) + 1 (tabbar) + bottomContent
130
- const contentRows = Math.max(0, panelAreaRows - 3); // subtract top-tabbar + h-sep + bottom-tabbar
134
+ const panelAreaRows = Math.max(0, vHeight - 1); // subtract workspace bar
135
+ const contentRows = Math.max(0, panelAreaRows - 1); // subtract h-separator
131
136
  topPaneHeight = Math.max(1, Math.floor(contentRows * panel!.verticalSplitRatio));
132
- bottomPaneHeight = Math.max(1, contentRows - topPaneHeight);
133
- hSepRow = 2 + topPaneHeight; // workspace bar + top tab bar + top content rows
137
+ hSepRow = 1 + topPaneHeight; // workspace bar + top content rows
134
138
  }
135
139
 
136
- const sepFg = hasPanel && panel!.separator
137
- ? (panel!.topFocused || panel!.bottomFocused ? '244' : '238')
138
- : '238';
140
+ const panelFocused = hasPanel && (panel!.topFocused || panel!.bottomFocused);
141
+ // Per-row left-border color: the focused pane's rows get the accent tone.
142
+ const borderFgForRow = (i: number): string => {
143
+ if (!hasPanel || !panel!.separator || !panelFocused) return PANEL_BORDER_DIM;
144
+ if (!hasBottomPane) return panel!.topFocused ? PANEL_FOCUS_ACCENT : PANEL_BORDER_DIM;
145
+ if (i === 0) return PANEL_FOCUS_ACCENT; // workspace bar — panel is focused
146
+ if (i <= topPaneHeight) return panel!.topFocused ? PANEL_FOCUS_ACCENT : PANEL_BORDER_DIM;
147
+ return panel!.bottomFocused ? PANEL_FOCUS_ACCENT : PANEL_BORDER_DIM;
148
+ };
139
149
 
140
150
  viewport.forEach((line, i) => {
141
151
  const screenY = viewportStartY + i;
@@ -163,9 +173,10 @@ export class Compositor {
163
173
 
164
174
  const p = panel!;
165
175
 
166
- // Separator column (vertical bar between left and panel area)
176
+ // Separator column (vertical bar between left and panel area).
177
+ // Colored per-row so the focused pane shows a bright accent border.
167
178
  if (p.separator) {
168
- newBuffer.setCell(sepX, screenY, createStyledCell('│', { fg: sepFg }));
179
+ newBuffer.setCell(sepX, screenY, createStyledCell('│', { fg: borderFgForRow(i) }));
169
180
  }
170
181
 
171
182
  const panelStartX = sepX + 1;
@@ -195,39 +206,31 @@ export class Compositor {
195
206
  const panelLine = i === 0 ? p.workspaceBar : p.topContent[i - 1];
196
207
  drawPanelLine(panelLine);
197
208
  } else {
198
- // --- Two pane mode ---
209
+ // --- Two pane mode (single consolidated workspace bar) ---
199
210
  // Row layout (by viewport row i):
200
211
  // i = 0: workspace tab bar
201
- // i = 1: top tab bar
202
- // 2 <= i <= topPaneHeight+1: top content[i-2]
212
+ // 1 <= i <= topPaneHeight: top content[i-1]
203
213
  // i = hSepRow: horizontal separator
204
- // i = hSepRow+1: bottom tab bar
205
- // i >= hSepRow+2: bottom content[i - (hSepRow+2)]
214
+ // i >= hSepRow+1: bottom content[i - (hSepRow+1)]
206
215
  let panelLine: Line | undefined;
207
216
 
208
217
  if (i === 0) {
209
218
  panelLine = p.workspaceBar;
210
- } else if (i === 1) {
211
- panelLine = p.topTabBar;
212
- } else if (i <= topPaneHeight + 1) {
213
- panelLine = p.topContent[i - 2];
219
+ } else if (i <= topPaneHeight) {
220
+ panelLine = p.topContent[i - 1];
214
221
  } else if (i === hSepRow) {
215
- // Horizontal separator between the two panes
216
- // Render chars across the panel width
217
- const focusFg = p.bottomFocused ? '36' : '238'; // cyan if bottom pane focused
222
+ // Horizontal separator between the two panes. Accent when the bottom
223
+ // pane has focus so the divider reinforces the focus border.
224
+ const focusFg = p.bottomFocused ? PANEL_FOCUS_ACCENT : PANEL_BORDER_DIM;
218
225
  for (let x = 0; x < panelWidth; x++) {
219
226
  newBuffer.setCell(panelStartX + x, screenY, createStyledCell('─', { fg: focusFg }));
220
227
  }
221
- // Also update the separator column char to T-junction (├):
222
- // ├ connects the vertical left-separator with the horizontal pane divider,
223
- // forming a clean T-shaped joint at the split point.
228
+ // T-junction (├) joins the vertical left-border with the pane divider.
224
229
  if (p.separator) {
225
230
  newBuffer.setCell(sepX, screenY, createStyledCell('├', { fg: focusFg }));
226
231
  }
227
- } else if (i === hSepRow + 1) {
228
- panelLine = p.bottomTabBar;
229
232
  } else {
230
- panelLine = p.bottomContent?.[i - (hSepRow + 2)];
233
+ panelLine = p.bottomContent?.[i - (hSepRow + 1)];
231
234
  }
232
235
 
233
236
  if (i !== hSepRow) {
@@ -269,7 +272,7 @@ export class Compositor {
269
272
  for (let i = viewport.length; i < vHeight; i++) {
270
273
  const screenY = viewportStartY + i;
271
274
  if (screenY >= height) break;
272
- newBuffer.setCell(sepX, screenY, createStyledCell('│', { fg: sepFg }));
275
+ newBuffer.setCell(sepX, screenY, createStyledCell('│', { fg: borderFgForRow(i) }));
273
276
  }
274
277
  }
275
278
 
@@ -2,16 +2,6 @@
2
2
  * Shared rendering utilities for modal components.
3
3
  */
4
4
 
5
- /** Format elapsed milliseconds as a compact duration string. */
6
- export function formatDuration(ms: number): string {
7
- if (ms < 1000) return `${ms}ms`;
8
- const secs = Math.floor(ms / 1000);
9
- if (secs < 60) return `${secs}s`;
10
- const mins = Math.floor(secs / 60);
11
- const remSecs = secs % 60;
12
- return `${mins}m${remSecs}s`;
13
- }
14
-
15
5
  /**
16
6
  * Format a Unix millisecond timestamp as YYYY-MM-DD HH:MM.
17
7
  * Returns '(unknown)' for falsy timestamps.
@@ -1,5 +1,6 @@
1
1
  import { type Line } from '../types/grid.ts';
2
2
  import { fitDisplay, getDisplayWidth, truncateDisplay } from '../utils/terminal-width.ts';
3
+ import { abbreviateCount } from '../utils/format-number.ts';
3
4
  import type { ModelPickerModal } from '../input/model-picker.ts';
4
5
  import { EFFORT_DESCRIPTIONS } from '@pellux/goodvibes-sdk/platform/providers';
5
6
  import { getQualityTier, getQualityTierFromScore } from '@pellux/goodvibes-sdk/platform/providers';
@@ -15,9 +16,7 @@ import { getOverlaySurfaceMetrics } from './overlay-viewport.ts';
15
16
 
16
17
  /** Format a context window number into a short human-readable string. */
17
18
  function fmtContext(n: number): string {
18
- if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(0)}M`;
19
- if (n >= 1000) return `${Math.round(n / 1000)}k`;
20
- return String(n);
19
+ return abbreviateCount(n, { rounding: 'round', decimals: 0 });
21
20
  }
22
21
 
23
22
  /** Title text per picker mode. */
@@ -360,6 +359,12 @@ export function renderModelPickerOverlay(
360
359
  putRowText(promptRow, layout.margin + 2, contentW, fitDisplay(`${promptLabel} ${inputDisplay}`, contentW), '#ffffff');
361
360
  lines.push(promptRow);
362
361
 
362
+ if (picker.contextCapError) {
363
+ const errRow = createOverlayContentLine(width, layout, borderFg, DEFAULT_OVERLAY_PALETTE.bodyBg);
364
+ putRowText(errRow, layout.margin + 2, contentW, fitDisplay(picker.contextCapError, contentW), '#ff6666');
365
+ lines.push(errRow);
366
+ }
367
+
363
368
  lines.push(createOverlayContentLine(width, layout, borderFg, DEFAULT_OVERLAY_PALETTE.bodyBg));
364
369
 
365
370
  const hintText = `Leave blank to use default (current: ${currentCtx}, source: ${provenance})`;
@@ -458,7 +463,7 @@ function getRenderCacheKey(
458
463
  } else if (picker.mode === 'effort') {
459
464
  base.push(objectId(picker.effortLevels), picker.effortLevels.join('\u001f'), picker.pendingModel?.registryKey ?? picker.pendingModel?.id ?? '');
460
465
  } else if (picker.mode === 'contextCap') {
461
- base.push(picker.contextCapQuery, picker.contextCapPendingModel?.registryKey ?? picker.contextCapPendingModel?.id ?? '');
466
+ base.push(picker.contextCapQuery, picker.contextCapPendingModel?.registryKey ?? picker.contextCapPendingModel?.id ?? '', picker.contextCapError ?? '');
462
467
  }
463
468
 
464
469
  return base.join('\u001e');
@@ -11,6 +11,7 @@ import type { ModelPickerTargetInfo } from '../input/model-picker.ts';
11
11
  import type { Line } from '../types/grid.ts';
12
12
  import { createEmptyLine, createStyledCell } from '../types/grid.ts';
13
13
  import { getDisplayWidth, wrapText } from '../utils/terminal-width.ts';
14
+ import { abbreviateCount } from '../utils/format-number.ts';
14
15
  import { GLYPHS, UI_TONES } from './ui-primitives.ts';
15
16
 
16
17
  const PALETTE = {
@@ -129,9 +130,7 @@ function stableWindow(total: number, selected: number, visible: number): { start
129
130
 
130
131
  function formatContext(value: number | undefined): string {
131
132
  if (!value) return '-';
132
- if (value >= 1_000_000) return `${Math.round(value / 1_000_000)}M`;
133
- if (value >= 1000) return `${Math.round(value / 1000)}k`;
134
- return String(value);
133
+ return abbreviateCount(value, { rounding: 'round', decimals: 0 });
135
134
  }
136
135
 
137
136
  function modelKey(model: ModelDefinition): string {
@@ -4,7 +4,6 @@ import type { InputHandler } from '../input/handler.ts';
4
4
  import type { PanelManager } from '../panels/panel-manager.ts';
5
5
  import type { PanelCompositeData } from './compositor.ts';
6
6
  import { createSplitPaneLayout } from './layout-engine.ts';
7
- import { renderPanelTabBar } from './panel-tab-bar.ts';
8
7
  import { renderPanelWorkspaceBar } from './panel-workspace-bar.ts';
9
8
 
10
9
  /**
@@ -108,33 +107,22 @@ export function buildPanelCompositeData(
108
107
  const workspaceBar = renderPanelWorkspaceBar(workspaceTabs, panelWidth, input.panelFocused);
109
108
 
110
109
  let topContent: Line[];
111
- let topTabBar: Line | undefined;
112
- let bottomTabBar: Line | undefined;
113
110
  let bottomContent: Line[] | undefined;
114
111
 
115
112
  const topActivePanel = topPane.panels[topPane.activeIndex] ?? null;
116
113
 
117
114
  if (hasBottom) {
118
- topTabBar = renderPanelTabBar(
119
- topPane.panels,
120
- topPane.activeIndex,
121
- panelWidth,
122
- input.panelFocused && focusedPane === 'top',
123
- 'top',
124
- );
125
- const paneLayout = createSplitPaneLayout(Math.max(0, panelHeight - 1), verticalSplitRatio);
115
+ // Single consolidated workspace bar (1 row) + h-separator (1 row); the rest
116
+ // splits between the two panes' content.
117
+ const paneLayout = createSplitPaneLayout(Math.max(0, panelHeight - 1), verticalSplitRatio, {
118
+ topTabRows: 0,
119
+ bottomTabRows: 0,
120
+ });
126
121
  const topH = paneLayout.topContentRows;
127
122
  const bottomH = paneLayout.bottomContentRows;
128
123
  topContent = topActivePanel ? renderPanel(topActivePanel, panelWidth, topH) : [];
129
124
 
130
125
  const bottomActivePanel = bottomPane.panels[bottomPane.activeIndex] ?? null;
131
- bottomTabBar = renderPanelTabBar(
132
- bottomPane.panels,
133
- bottomPane.activeIndex,
134
- panelWidth,
135
- input.panelFocused && focusedPane === 'bottom',
136
- 'bottom',
137
- );
138
126
  bottomContent = bottomActivePanel ? renderPanel(bottomActivePanel, panelWidth, bottomH) : [];
139
127
  } else {
140
128
  const topH = Math.max(0, panelHeight - 1);
@@ -144,10 +132,9 @@ export function buildPanelCompositeData(
144
132
  return {
145
133
  panelData: {
146
134
  workspaceBar,
147
- topTabBar,
148
135
  topContent,
149
136
  topFocused: input.panelFocused && focusedPane === 'top',
150
- bottomTabBar,
137
+ hasBottomPane: hasBottom,
151
138
  bottomContent,
152
139
  bottomFocused: input.panelFocused && focusedPane === 'bottom',
153
140
  separator: true,