@pellux/goodvibes-tui 0.27.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/core/context-usage.ts +53 -0
  5. package/src/core/conversation.ts +4 -6
  6. package/src/core/session-recovery.ts +2 -0
  7. package/src/core/turn-event-wiring.ts +1 -0
  8. package/src/input/commands/cost-runtime.ts +49 -0
  9. package/src/input/commands/eval.ts +4 -3
  10. package/src/input/commands/operator-runtime.ts +5 -1
  11. package/src/input/commands.ts +2 -0
  12. package/src/input/handler-feed-routes.ts +60 -26
  13. package/src/input/handler-feed.ts +17 -8
  14. package/src/input/handler-picker-routes.ts +18 -13
  15. package/src/input/handler-shortcuts.ts +51 -0
  16. package/src/input/handler-ui-state.ts +4 -0
  17. package/src/input/handler.ts +43 -6
  18. package/src/input/keybindings.ts +53 -8
  19. package/src/input/model-picker.ts +5 -0
  20. package/src/input/panel-integration-actions.ts +117 -1
  21. package/src/main.ts +5 -2
  22. package/src/panels/agent-inspector-panel.ts +139 -45
  23. package/src/panels/agent-inspector-shared.ts +147 -3
  24. package/src/panels/approval-panel.ts +204 -102
  25. package/src/panels/automation-control-panel.ts +370 -103
  26. package/src/panels/base-panel.ts +26 -2
  27. package/src/panels/builtin/agent.ts +28 -37
  28. package/src/panels/builtin/development.ts +40 -32
  29. package/src/panels/builtin/knowledge.ts +15 -6
  30. package/src/panels/builtin/operations.ts +178 -117
  31. package/src/panels/builtin/session.ts +35 -9
  32. package/src/panels/builtin/shared.ts +98 -6
  33. package/src/panels/cockpit-panel.ts +253 -75
  34. package/src/panels/cockpit-read-model.ts +2 -1
  35. package/src/panels/communication-panel.ts +159 -56
  36. package/src/panels/confirm-state.ts +8 -1
  37. package/src/panels/control-plane-panel.ts +354 -95
  38. package/src/panels/cost-tracker-panel.ts +243 -57
  39. package/src/panels/debug-panel.ts +347 -159
  40. package/src/panels/diff-panel.ts +257 -82
  41. package/src/panels/docs-panel.ts +166 -64
  42. package/src/panels/eval-panel.ts +336 -109
  43. package/src/panels/expandable-list-panel.ts +189 -0
  44. package/src/panels/file-explorer-panel.ts +238 -159
  45. package/src/panels/file-preview-panel.ts +141 -59
  46. package/src/panels/git-panel.ts +360 -191
  47. package/src/panels/hooks-panel.ts +181 -19
  48. package/src/panels/incident-review-panel.ts +252 -43
  49. package/src/panels/index.ts +0 -4
  50. package/src/panels/intelligence-panel.ts +310 -103
  51. package/src/panels/knowledge-graph-panel.ts +480 -58
  52. package/src/panels/local-auth-panel.ts +251 -33
  53. package/src/panels/marketplace-panel.ts +219 -33
  54. package/src/panels/memory-panel.ts +83 -81
  55. package/src/panels/ops-control-panel.ts +211 -32
  56. package/src/panels/ops-strategy-panel.ts +131 -45
  57. package/src/panels/orchestration-panel.ts +259 -67
  58. package/src/panels/panel-list-panel.ts +184 -136
  59. package/src/panels/panel-manager.ts +120 -13
  60. package/src/panels/plan-dashboard-panel.ts +507 -74
  61. package/src/panels/plugins-panel.ts +227 -34
  62. package/src/panels/policy-panel.ts +264 -55
  63. package/src/panels/polish-core.ts +162 -0
  64. package/src/panels/polish-tables.ts +258 -0
  65. package/src/panels/polish.ts +67 -149
  66. package/src/panels/project-planning-answer-actions.ts +134 -0
  67. package/src/panels/project-planning-panel.ts +84 -113
  68. package/src/panels/provider-health-panel.ts +438 -480
  69. package/src/panels/provider-health-routes.ts +203 -0
  70. package/src/panels/provider-health-tracker.ts +194 -6
  71. package/src/panels/provider-health-views.ts +560 -0
  72. package/src/panels/qr-panel.ts +136 -38
  73. package/src/panels/remote-panel.ts +123 -38
  74. package/src/panels/routes-panel.ts +87 -24
  75. package/src/panels/sandbox-panel.ts +232 -65
  76. package/src/panels/scrollable-list-panel.ts +143 -145
  77. package/src/panels/security-panel.ts +204 -52
  78. package/src/panels/services-panel.ts +174 -76
  79. package/src/panels/session-browser-panel.ts +101 -6
  80. package/src/panels/session-maintenance.ts +4 -122
  81. package/src/panels/settings-sync-panel.ts +346 -68
  82. package/src/panels/skills-panel.ts +157 -89
  83. package/src/panels/subscription-panel.ts +105 -34
  84. package/src/panels/symbol-outline-panel.ts +287 -154
  85. package/src/panels/system-messages-panel.ts +172 -38
  86. package/src/panels/tasks-panel.ts +348 -134
  87. package/src/panels/thinking-panel.ts +66 -32
  88. package/src/panels/token-budget-panel.ts +233 -46
  89. package/src/panels/tool-inspector-panel.ts +170 -54
  90. package/src/panels/types.ts +65 -4
  91. package/src/panels/work-plan-panel.ts +393 -39
  92. package/src/panels/worktree-panel.ts +239 -61
  93. package/src/panels/wrfc-panel-format.ts +133 -0
  94. package/src/panels/wrfc-panel.ts +146 -150
  95. package/src/renderer/compaction-preview.ts +2 -1
  96. package/src/renderer/compositor.ts +46 -43
  97. package/src/renderer/conversation-overlays.ts +25 -11
  98. package/src/renderer/footer-tips.ts +41 -0
  99. package/src/renderer/fullscreen-primitives.ts +22 -16
  100. package/src/renderer/help-overlay.ts +91 -14
  101. package/src/renderer/hint-grammar.ts +52 -0
  102. package/src/renderer/layout.ts +7 -7
  103. package/src/renderer/modal-factory.ts +23 -15
  104. package/src/renderer/model-picker-overlay.ts +30 -11
  105. package/src/renderer/model-workspace.ts +2 -3
  106. package/src/renderer/overlay-box.ts +16 -10
  107. package/src/renderer/panel-composite.ts +7 -20
  108. package/src/renderer/panel-workspace-bar.ts +14 -8
  109. package/src/renderer/process-indicator.ts +3 -1
  110. package/src/renderer/progress.ts +8 -6
  111. package/src/renderer/search-overlay.ts +27 -6
  112. package/src/renderer/session-picker-modal.ts +6 -4
  113. package/src/renderer/settings-modal.ts +70 -10
  114. package/src/renderer/shell-surface.ts +41 -15
  115. package/src/renderer/status-glyphs.ts +11 -9
  116. package/src/renderer/tab-strip.ts +148 -34
  117. package/src/renderer/theme.ts +60 -3
  118. package/src/renderer/ui-factory.ts +45 -36
  119. package/src/renderer/ui-primitives.ts +23 -2
  120. package/src/runtime/bootstrap-shell.ts +35 -18
  121. package/src/runtime/diagnostics/panels/index.ts +0 -3
  122. package/src/runtime/ui/model-picker/health-enrichment.ts +3 -0
  123. package/src/runtime/ui/model-picker/types.ts +4 -0
  124. package/src/shell/ui-openers.ts +14 -22
  125. package/src/utils/format-duration.ts +55 -0
  126. package/src/utils/format-number.ts +71 -0
  127. package/src/utils/splash-lines.ts +44 -3
  128. package/src/version.ts +1 -1
  129. package/src/work-plans/work-plan-store.ts +13 -0
  130. package/src/panels/agent-logs-panel.ts +0 -666
  131. package/src/panels/agent-logs-shared.ts +0 -129
  132. package/src/panels/context-visualizer-panel.ts +0 -214
  133. package/src/panels/forensics-panel.ts +0 -364
  134. package/src/panels/panel-picker.ts +0 -106
  135. package/src/panels/provider-account-snapshot.ts +0 -259
  136. package/src/panels/provider-accounts-panel.ts +0 -218
  137. package/src/panels/provider-stats-panel.ts +0 -366
  138. package/src/panels/schedule-panel.ts +0 -342
  139. package/src/panels/watchers-panel.ts +0 -193
  140. package/src/renderer/panel-picker-overlay.ts +0 -202
  141. package/src/renderer/panel-tab-bar.ts +0 -69
  142. package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
  143. package/src/runtime/diagnostics/panels/policy.ts +0 -177
@@ -16,7 +16,7 @@ import {
16
16
  DEFAULT_PANEL_PALETTE,
17
17
  type PanelWorkspaceSection,
18
18
  } from './polish.ts';
19
- import { truncateDisplay } from '../utils/terminal-width.ts';
19
+ import { truncateDisplay, wrapText } from '../utils/terminal-width.ts';
20
20
 
21
21
  const C = extendPalette(DEFAULT_PANEL_PALETTE, {
22
22
  // Domain-specific reasoning tones (purples) and the active-stream cyan have no
@@ -28,34 +28,33 @@ const C = extendPalette(DEFAULT_PANEL_PALETTE, {
28
28
  });
29
29
 
30
30
  interface ReasoningBlock {
31
- turnId: number;
31
+ // Real turn id from the TURN_*/STREAM_* event envelope — joins this block
32
+ // to the same turn's rows in TasksPanel and the tool inspector, which key
33
+ // off the identical turnId string.
34
+ turnId: string;
35
+ // Distributed-tracing correlation id (EventEnvelope.traceId), when the
36
+ // originating event carried one — same field RuntimeTask.correlationId
37
+ // joins against.
38
+ correlationId?: string;
32
39
  content: string;
33
40
  active: boolean; // true = currently streaming
34
41
  collapsed: boolean;
42
+ startedAt: number;
43
+ updatedAt: number;
35
44
  }
36
45
 
37
- function wrapLines(text: string, width: number): string[] {
38
- const result: string[] = [];
39
- const raw = text.split('\n');
40
- for (const line of raw) {
41
- if (line.length <= width) {
42
- result.push(line);
43
- } else {
44
- let start = 0;
45
- while (start < line.length) {
46
- result.push(line.slice(start, start + width));
47
- start += width;
48
- }
49
- }
50
- }
51
- return result;
46
+ function fmtClock(ts: number): string {
47
+ const d = new Date(ts);
48
+ const h = String(d.getHours()).padStart(2, '0');
49
+ const m = String(d.getMinutes()).padStart(2, '0');
50
+ const s = String(d.getSeconds()).padStart(2, '0');
51
+ return `${h}:${m}:${s}`;
52
52
  }
53
53
 
54
54
  type FlatRow = { kind: 'header'; blockIndex: number; text: string } | { kind: 'content'; text: string };
55
55
 
56
56
  export class ThinkingPanel extends BasePanel {
57
57
  private blocks: ReasoningBlock[] = [];
58
- private nextTurnId = 1;
59
58
  private unsubs: Array<() => void> = [];
60
59
  private cursorIndex = 0;
61
60
  private scrollOffset = 0;
@@ -121,8 +120,8 @@ export class ThinkingPanel extends BasePanel {
121
120
  lines: buildEmptyState(
122
121
  width,
123
122
  ' No reasoning content yet',
124
- 'When the model emits thinking or reasoning deltas, they accumulate here in expandable blocks.',
125
- [],
123
+ 'When the model emits thinking or reasoning deltas during a turn, they accumulate here in expandable per-turn blocks.',
124
+ [{ command: '/chat <prompt>', summary: 'start a turn with a reasoning-capable model to stream thinking here' }],
126
125
  DEFAULT_PANEL_PALETTE,
127
126
  ),
128
127
  },
@@ -152,12 +151,38 @@ export class ThinkingPanel extends BasePanel {
152
151
  ],
153
152
  };
154
153
 
155
- const selectedRow = flat[this.cursorIndex];
154
+ // Resolve the reasoning block that owns the cursor row (walk back to its
155
+ // header) so the detail block describes the actual turn, not just row kind.
156
+ let selectedBlockIndex = -1;
157
+ for (let i = Math.min(this.cursorIndex, flat.length - 1); i >= 0; i--) {
158
+ const row = flat[i];
159
+ if (row?.kind === 'header') { selectedBlockIndex = row.blockIndex; break; }
160
+ }
161
+ const selectedBlock = selectedBlockIndex >= 0 ? this.blocks[selectedBlockIndex] : undefined;
156
162
  const selectedSection: PanelWorkspaceSection = {
157
163
  title: 'Selected',
158
- lines: [
159
- buildPanelLine(width, [[' Row Type ', DEFAULT_PANEL_PALETTE.label], [selectedRow?.kind ?? 'none', DEFAULT_PANEL_PALETTE.value]]),
160
- ],
164
+ lines: selectedBlock
165
+ ? [
166
+ buildPanelLine(width, [
167
+ [' Turn ', DEFAULT_PANEL_PALETTE.label],
168
+ [selectedBlock.turnId, DEFAULT_PANEL_PALETTE.value],
169
+ [' State ', DEFAULT_PANEL_PALETTE.label],
170
+ [selectedBlock.active ? 'streaming' : 'complete', selectedBlock.active ? DEFAULT_PANEL_PALETTE.warn : DEFAULT_PANEL_PALETTE.good],
171
+ [' View ', DEFAULT_PANEL_PALETTE.label],
172
+ [selectedBlock.collapsed ? 'collapsed' : 'expanded', DEFAULT_PANEL_PALETTE.info],
173
+ [' Chars ', DEFAULT_PANEL_PALETTE.label],
174
+ [String(selectedBlock.content.length), DEFAULT_PANEL_PALETTE.dim],
175
+ ]),
176
+ buildPanelLine(width, [
177
+ [' Correlation ', DEFAULT_PANEL_PALETTE.label],
178
+ [selectedBlock.correlationId ?? 'n/a', DEFAULT_PANEL_PALETTE.dim],
179
+ [' Started ', DEFAULT_PANEL_PALETTE.label],
180
+ [fmtClock(selectedBlock.startedAt), DEFAULT_PANEL_PALETTE.dim],
181
+ [' Updated ', DEFAULT_PANEL_PALETTE.label],
182
+ [fmtClock(selectedBlock.updatedAt), DEFAULT_PANEL_PALETTE.dim],
183
+ ]),
184
+ ]
185
+ : [buildPanelLine(width, [[' No block selected', DEFAULT_PANEL_PALETTE.dim]])],
161
186
  };
162
187
 
163
188
  const reasoningSection = resolveScrollablePanelSection(width, height, {
@@ -209,10 +234,10 @@ export class ThinkingPanel extends BasePanel {
209
234
  const rows: FlatRow[] = [];
210
235
  for (let i = 0; i < this.blocks.length; i++) {
211
236
  const block = this.blocks[i]!;
212
- const turnLabel = `Turn ${block.turnId}${block.active ? ' (streaming)' : ''}`;
237
+ const turnLabel = `Turn ${block.turnId} ${fmtClock(block.startedAt)}${block.active ? ' (streaming)' : ''}`;
213
238
  rows.push({ kind: 'header', blockIndex: i, text: turnLabel });
214
239
  if (!block.collapsed) {
215
- const wrapped = wrapLines(block.content || '(empty)', Math.max(1, width - 2));
240
+ const wrapped = wrapText(block.content || '(empty)', Math.max(1, width - 2));
216
241
  for (const line of wrapped) {
217
242
  rows.push({ kind: 'content', text: line });
218
243
  }
@@ -249,12 +274,18 @@ export class ThinkingPanel extends BasePanel {
249
274
 
250
275
  let currentBlock: ReasoningBlock | null = null;
251
276
 
252
- this.unsubs.push(this.turnEvents.on('STREAM_START', () => {
277
+ // onEnvelope (not on) so real turnId/traceId/timestamp — the same fields
278
+ // TasksPanel and the tool inspector join their own rows on — stamp each
279
+ // block instead of a locally-incrementing counter.
280
+ this.unsubs.push(this.turnEvents.onEnvelope('STREAM_START', (envelope) => {
253
281
  const block: ReasoningBlock = {
254
- turnId: this.nextTurnId++,
282
+ turnId: envelope.turnId ?? envelope.payload.turnId,
283
+ correlationId: envelope.traceId,
255
284
  content: '',
256
285
  active: true,
257
286
  collapsed: false,
287
+ startedAt: envelope.ts,
288
+ updatedAt: envelope.ts,
258
289
  };
259
290
  // Cap block count to prevent unbounded growth
260
291
  if (this.blocks.length >= ThinkingPanel.MAX_BLOCKS) {
@@ -266,26 +297,29 @@ export class ThinkingPanel extends BasePanel {
266
297
  this.markDirty();
267
298
  }));
268
299
 
269
- this.unsubs.push(this.turnEvents.on('STREAM_DELTA', (env) => {
270
- const reasoning = env.reasoning;
300
+ this.unsubs.push(this.turnEvents.onEnvelope('STREAM_DELTA', (envelope) => {
301
+ const reasoning = envelope.payload.reasoning;
271
302
  if (reasoning && currentBlock) {
272
303
  currentBlock.content += reasoning;
304
+ currentBlock.updatedAt = envelope.ts;
273
305
  this.autoScroll = true;
274
306
  this.markDirty();
275
307
  }
276
308
  }));
277
309
 
278
- this.unsubs.push(this.turnEvents.on('STREAM_END', () => {
310
+ this.unsubs.push(this.turnEvents.onEnvelope('STREAM_END', (envelope) => {
279
311
  if (currentBlock) {
280
312
  currentBlock.active = false;
313
+ currentBlock.updatedAt = envelope.ts;
281
314
  currentBlock = null;
282
315
  this.markDirty();
283
316
  }
284
317
  }));
285
318
 
286
- this.unsubs.push(this.turnEvents.on('TURN_COMPLETED', () => {
319
+ this.unsubs.push(this.turnEvents.onEnvelope('TURN_COMPLETED', (envelope) => {
287
320
  if (currentBlock) {
288
321
  currentBlock.active = false;
322
+ currentBlock.updatedAt = envelope.ts;
289
323
  currentBlock = null;
290
324
  this.markDirty();
291
325
  }
@@ -3,12 +3,21 @@ import { BasePanel } from './base-panel.ts';
3
3
  import { createEmptyLine, createStyledCell, type Line } from '../types/grid.ts';
4
4
  import type { Orchestrator } from '../core/orchestrator';
5
5
  import { evaluateSessionMaintenance } from '@/runtime/index.ts';
6
+ import type { TurnEvent } from '@/runtime/index.ts';
7
+ import type { UiEventFeed } from '../runtime/ui-events.ts';
6
8
  import type { UiReadModel, UiSessionSnapshot } from '../runtime/ui-read-models.ts';
7
9
  import type { SessionMemoryQuery } from '../runtime/ui-service-queries.ts';
10
+ import { calcSessionCost } from '../export/cost-utils.ts';
11
+ import { type ConfirmState, handleConfirmInput, renderConfirmLines } from './confirm-state.ts';
12
+ import type { PanelIntegrationContext } from './types.ts';
8
13
  import {
9
14
  buildEmptyState,
10
15
  buildGuidanceLine,
16
+ buildKeyboardHints,
17
+ buildMeterLine,
18
+ buildStatusPill,
11
19
  buildStyledPanelLine,
20
+ buildTable,
12
21
  buildPanelWorkspace,
13
22
  resolveScrollablePanelSection,
14
23
  DEFAULT_PANEL_PALETTE,
@@ -51,6 +60,9 @@ const WARN_RED = 0.90;
51
60
  // Maximum turns to keep in per-turn history
52
61
  const MAX_TURN_HISTORY = 10;
53
62
 
63
+ // Rows scrolled per pageup/pagedown key press through the Recent Turns history.
64
+ const PAGE_SCROLL_ROWS = 5;
65
+
54
66
  // ---------------------------------------------------------------------------
55
67
  // Helpers
56
68
  // ---------------------------------------------------------------------------
@@ -71,10 +83,15 @@ function fmtTok(n: number): string {
71
83
  *
72
84
  * Displays:
73
85
  * - Stacked bar: input / output / cache-read / cache-write
74
- * - Context window fill percentage with progress bar
75
- * - Per-turn token usage (last 10 turns)
76
- * - Cumulative session totals
86
+ * - Context window fill percentage with progress bar + pressure pill
87
+ * - Per-turn token usage (last 10 turns), scrollable
88
+ * - Cumulative session totals + inline cost estimate
77
89
  * - Warning threshold indicators (yellow at 70%, red at 90%)
90
+ *
91
+ * Absorbs the former ContextVisualizerPanel ('context'): the pressure pill,
92
+ * TURN_SUBMITTED/TURN_COMPLETED-driven refresh, and the compact-now action
93
+ * all live here now. `PanelManager` aliases the retired `context` id to
94
+ * `tokens` so old `/panel open context` calls keep working.
78
95
  */
79
96
  export class TokenBudgetPanel extends BasePanel {
80
97
  /** Snapshot of cumulative usage from the Orchestrator after each turn. */
@@ -92,19 +109,38 @@ export class TokenBudgetPanel extends BasePanel {
92
109
  private orchestrator: Orchestrator | null = null;
93
110
  private getContextWindow: (() => number) | null = null;
94
111
  private sessionReadModel: UiReadModel<UiSessionSnapshot> | null = null;
112
+ /** Resolver for the active model id, for inline cost display. */
113
+ private getCurrentModelId: (() => string) | null = null;
95
114
  private readonly sessionMemoryStore: SessionMemoryQuery;
96
115
  private readonly configManager: Pick<ConfigManager, 'get'>;
97
116
  private readonly requestRender: () => void;
98
117
 
118
+ /** Turn-event bus subscriptions — absorbed from ContextVisualizerPanel. */
119
+ private unsubs: Array<() => void> = [];
120
+
121
+ /** Manual scroll cursor into turnHistory; -1 = auto-follow the latest turn. */
122
+ private turnScrollIndex = -1;
123
+
124
+ /** Pending confirm dialog (currently only used for the compact-now action). */
125
+ private confirm: ConfirmState<'compact'> | null = null;
126
+ /**
127
+ * Set when a confirm resolves to 'confirmed'; consumed by
128
+ * handlePanelIntegrationAction on the very next dispatch of that same key
129
+ * so it can reach the ctx.executeCommand bridge (handleInput has no ctx).
130
+ */
131
+ private compactConfirmedPending = false;
132
+
99
133
  constructor(
100
134
  sessionMemoryStore: SessionMemoryQuery,
101
135
  configManager: Pick<ConfigManager, 'get'>,
102
136
  requestRender: () => void = () => {},
137
+ turnEvents?: UiEventFeed<TurnEvent>,
103
138
  ) {
104
- super('tokens', 'Tokens', 'T', 'monitoring');
139
+ super('tokens', 'Tokens', '', 'providers');
105
140
  this.sessionMemoryStore = sessionMemoryStore;
106
141
  this.configManager = configManager;
107
142
  this.requestRender = requestRender;
143
+ if (turnEvents) this._attachBus(turnEvents);
108
144
  }
109
145
 
110
146
  // ---------------------------------------------------------------------------
@@ -114,17 +150,21 @@ export class TokenBudgetPanel extends BasePanel {
114
150
  /**
115
151
  * Wire the panel to live data sources. Call once after construction.
116
152
  *
117
- * @param orchestrator The main Orchestrator instance (for usage + lastInputTokens).
118
- * @param getCtxWindow Callback returning the current model's contextWindow value.
153
+ * @param orchestrator The main Orchestrator instance (for usage + lastInputTokens).
154
+ * @param getCtxWindow Callback returning the current model's contextWindow value.
155
+ * @param sessionReadModel Session read model (for the Maintenance status line).
156
+ * @param getCurrentModelId Callback returning the active model id, for the inline cost line.
119
157
  */
120
158
  wire(
121
159
  orchestrator: Orchestrator,
122
160
  getCtxWindow: () => number,
123
161
  sessionReadModel?: UiReadModel<UiSessionSnapshot>,
162
+ getCurrentModelId?: () => string,
124
163
  ): void {
125
164
  this.orchestrator = orchestrator;
126
165
  this.getContextWindow = getCtxWindow;
127
166
  this.sessionReadModel = sessionReadModel ?? null;
167
+ this.getCurrentModelId = getCurrentModelId ?? null;
128
168
  }
129
169
 
130
170
  /**
@@ -149,10 +189,27 @@ export class TokenBudgetPanel extends BasePanel {
149
189
  if (this.turnHistory.length > MAX_TURN_HISTORY) {
150
190
  this.turnHistory.shift();
151
191
  }
192
+ // A freshly completed turn always pulls the view back to the latest entry.
193
+ this.turnScrollIndex = -1;
152
194
 
153
195
  this._refreshAndRender();
154
196
  }
155
197
 
198
+ // ---------------------------------------------------------------------------
199
+ // Turn-event bus (absorbed from ContextVisualizerPanel)
200
+ // ---------------------------------------------------------------------------
201
+
202
+ private _attachBus(turnEvents: UiEventFeed<TurnEvent>): void {
203
+ if (this.unsubs.length > 0) return;
204
+ this.unsubs.push(turnEvents.on('TURN_COMPLETED', () => this.recordTurn()));
205
+ this.unsubs.push(turnEvents.on('TURN_SUBMITTED', () => this._refreshAndRender()));
206
+ }
207
+
208
+ private _detachBus(): void {
209
+ for (const unsub of this.unsubs) unsub();
210
+ this.unsubs = [];
211
+ }
212
+
156
213
  // ---------------------------------------------------------------------------
157
214
  // Lifecycle
158
215
  // ---------------------------------------------------------------------------
@@ -167,6 +224,7 @@ export class TokenBudgetPanel extends BasePanel {
167
224
 
168
225
  override onDeactivate(): void {
169
226
  super.onDeactivate();
227
+ this.confirm = null;
170
228
  // Stop the off-screen poll so a backgrounded Tokens tab does not keep
171
229
  // refreshing (and, once requestRender is wired, force a repaint) every 2 s.
172
230
  if (this.refreshTimer !== null) {
@@ -180,6 +238,78 @@ export class TokenBudgetPanel extends BasePanel {
180
238
  clearInterval(this.refreshTimer);
181
239
  this.refreshTimer = null;
182
240
  }
241
+ this._detachBus();
242
+ }
243
+
244
+ // ---------------------------------------------------------------------------
245
+ // Input
246
+ // ---------------------------------------------------------------------------
247
+
248
+ private _pressureRatio(): number {
249
+ return this.contextWindow > 0 ? this.lastInputTokens / this.contextWindow : 0;
250
+ }
251
+
252
+ private _pressureElevated(): boolean {
253
+ return this.contextWindow > 0 && this._pressureRatio() >= WARN_YELLOW;
254
+ }
255
+
256
+ private _scrollTurns(delta: number): void {
257
+ const current = this.turnScrollIndex === -1 ? this.turnHistory.length : this.turnScrollIndex;
258
+ const next = current + delta;
259
+ this.turnScrollIndex = Math.max(0, Math.min(this.turnHistory.length, next));
260
+ this.markDirty();
261
+ }
262
+
263
+ handleInput(key: string): boolean {
264
+ // Confirmation dialog — use the shared handleConfirmInput for y/n/Esc UX
265
+ const confirmResult = handleConfirmInput(this.confirm, key);
266
+ if (confirmResult === 'confirmed') {
267
+ this.confirm = null;
268
+ this.compactConfirmedPending = true;
269
+ this.markDirty();
270
+ return true;
271
+ }
272
+ if (confirmResult === 'cancelled') {
273
+ this.confirm = null;
274
+ this.markDirty();
275
+ return true;
276
+ }
277
+ if (confirmResult === 'absorbed') return true;
278
+
279
+ if (key === 'C' && this._pressureElevated()) {
280
+ this.confirm = { subject: 'compact', label: 'Compact context now', verb: 'Compact' };
281
+ this.markDirty();
282
+ return true;
283
+ }
284
+
285
+ if (this.turnHistory.length > 0) {
286
+ switch (key) {
287
+ case 'up': this._scrollTurns(-1); return true;
288
+ case 'down': this._scrollTurns(1); return true;
289
+ case 'pageup': this._scrollTurns(-PAGE_SCROLL_ROWS); return true;
290
+ case 'pagedown': this._scrollTurns(PAGE_SCROLL_ROWS); return true;
291
+ }
292
+ }
293
+
294
+ if (key === 'r') {
295
+ this._refreshAndRender();
296
+ return true;
297
+ }
298
+
299
+ return false;
300
+ }
301
+
302
+ /**
303
+ * Executes the compact-now action once the confirm dialog resolves. This
304
+ * lives in the integration hook (rather than inline in handleInput)
305
+ * because only this hook carries the `ctx.executeCommand` bridge into the
306
+ * real `/compact` command.
307
+ */
308
+ handlePanelIntegrationAction(_key: string, ctx: PanelIntegrationContext): boolean {
309
+ if (!this.compactConfirmedPending) return false;
310
+ this.compactConfirmedPending = false;
311
+ void ctx.executeCommand?.('compact', []);
312
+ return true;
183
313
  }
184
314
 
185
315
  // ---------------------------------------------------------------------------
@@ -216,6 +346,17 @@ export class TokenBudgetPanel extends BasePanel {
216
346
 
217
347
  override render(width: number, height: number): Line[] {
218
348
  return this.trackedRender(() => {
349
+ if (this.confirm) {
350
+ return buildPanelWorkspace(width, height, {
351
+ title: ' Token Budget',
352
+ sections: [{
353
+ title: 'Confirmation',
354
+ lines: renderConfirmLines(width, this.confirm),
355
+ }],
356
+ palette: DEFAULT_PANEL_PALETTE,
357
+ });
358
+ }
359
+
219
360
  const sections: PanelWorkspaceSection[] = [];
220
361
 
221
362
  if (this.contextWindow > 0) {
@@ -233,16 +374,33 @@ export class TokenBudgetPanel extends BasePanel {
233
374
  ],
234
375
  });
235
376
 
377
+ const elevated = this._pressureElevated();
378
+ const footerHints = elevated
379
+ ? [
380
+ { keys: 'C', label: 'compact now' },
381
+ { keys: 'up/down', label: 'scroll turns' },
382
+ { keys: 'r', label: 'refresh' },
383
+ ]
384
+ : [
385
+ { keys: 'up/down', label: 'scroll turns' },
386
+ { keys: 'r', label: 'refresh' },
387
+ { keys: '/compact', label: 'compress context' },
388
+ ];
389
+
236
390
  if (this.turnHistory.length > 0) {
237
391
  const priorSections = [...sections];
392
+ const scrollIndex = this.turnScrollIndex === -1 ? this.turnHistory.length : this.turnScrollIndex;
238
393
  const turnsSection = resolveScrollablePanelSection(width, height, {
239
394
  intro: 'Live context pressure, session token composition, cache usage, and recent turn deltas.',
395
+ footerLines: [
396
+ buildKeyboardHints(width, footerHints, DEFAULT_PANEL_PALETTE),
397
+ ],
240
398
  palette: DEFAULT_PANEL_PALETTE,
241
399
  beforeSections: priorSections,
242
400
  section: {
243
401
  title: 'Recent Turns',
244
402
  scrollableLines: this.renderTurnHistory(width, this.turnHistory.length + 1),
245
- scrollOffset: Math.max(0, this.turnHistory.length),
403
+ scrollOffset: scrollIndex,
246
404
  minRows: 6,
247
405
  },
248
406
  afterSections: [{
@@ -273,6 +431,9 @@ export class TokenBudgetPanel extends BasePanel {
273
431
  title: ' Token Budget',
274
432
  intro: 'Live context pressure, session token composition, cache usage, and recent turn deltas.',
275
433
  sections,
434
+ footerLines: [
435
+ buildKeyboardHints(width, footerHints, DEFAULT_PANEL_PALETTE),
436
+ ],
276
437
  palette: DEFAULT_PANEL_PALETTE,
277
438
  });
278
439
  });
@@ -298,7 +459,17 @@ export class TokenBudgetPanel extends BasePanel {
298
459
  }
299
460
 
300
461
  if (status.guidanceMode !== 'off' && status.nextSteps.length > 0) {
301
- lines.push(buildGuidanceLine(width, status.nextSteps[0]!, 'open the next maintenance action directly', DEFAULT_PANEL_PALETTE));
462
+ const nextStep = status.nextSteps[0]!;
463
+ // WO-160: when the recommended next step is /compact and C is armed
464
+ // (see handleInput's elevated-only C branch and the footer hint),
465
+ // advertise the key instead of the command — pressing C already
466
+ // dispatches it for real, so printing '/compact' here was a redundant
467
+ // action substitute.
468
+ if (nextStep === '/compact' && this._pressureElevated()) {
469
+ lines.push(buildGuidanceLine(width, 'C', 'compact context now — the recommended next maintenance action', DEFAULT_PANEL_PALETTE));
470
+ } else {
471
+ lines.push(buildGuidanceLine(width, nextStep, 'open the next maintenance action directly', DEFAULT_PANEL_PALETTE));
472
+ }
302
473
  }
303
474
 
304
475
  return lines;
@@ -373,7 +544,7 @@ export class TokenBudgetPanel extends BasePanel {
373
544
  return lines;
374
545
  }
375
546
 
376
- /** Renders a full-width progress bar for context window fill. */
547
+ /** Renders a full-width progress bar for context window fill, plus a pressure pill (absorbed from ContextVisualizerPanel). */
377
548
  private renderContextBar(width: number): Line[] {
378
549
  const lines: Line[] = [];
379
550
  const pct = this.contextWindow > 0
@@ -398,16 +569,26 @@ export class TokenBudgetPanel extends BasePanel {
398
569
  const suffix = ` ${fmtTok(this.lastInputTokens)}/${fmtTok(this.contextWindow)} (${pctInt}%)${warnSuffix}`;
399
570
  const BAR_W = Math.max(8, width - label.length - suffix.length - 2);
400
571
  const filled = Math.round(pct * BAR_W);
572
+ // Context fill is the headline metric — render it with the shared meter
573
+ // primitive so the bar glyphs and width handling match every other panel.
574
+ lines.push(buildMeterLine(width, filled, BAR_W,
575
+ { filled: barColor, empty: C.barBg, label: C.label },
576
+ { prefix: label, suffix },
577
+ ));
578
+
579
+ // Pressure pill — absorbed from ContextVisualizerPanel's headline indicator.
580
+ const overLimit = this.contextWindow > 0 && this.lastInputTokens > this.contextWindow;
581
+ const pressureState = overLimit || pct >= WARN_RED ? 'bad' : pct >= WARN_YELLOW ? 'warn' : 'good';
582
+ const pressureLabel = overLimit ? 'over limit' : pct >= WARN_RED ? 'critical' : pct >= WARN_YELLOW ? 'elevated' : 'healthy';
401
583
  lines.push(buildStyledPanelLine(width, [
402
- { text: label, fg: C.label },
403
- { text: '#'.repeat(filled), fg: barColor, dim: pct < WARN_YELLOW },
404
- { text: '.'.repeat(Math.max(0, BAR_W - filled)), fg: C.barBg, dim: pct < WARN_YELLOW },
405
- { text: suffix, fg: barColor, dim: pct < WARN_YELLOW },
584
+ { text: ' ', fg: C.dim },
585
+ ...buildStatusPill(pressureState, pressureLabel),
406
586
  ]));
587
+
407
588
  return lines;
408
589
  }
409
590
 
410
- /** Session cumulative totals with color-coded labels. */
591
+ /** Session cumulative totals with color-coded labels, plus an inline cost estimate. */
411
592
  private renderTotals(width: number): Line[] {
412
593
  const lines: Line[] = [];
413
594
  lines.push(this.paintTextLine(' Session Totals:', width, C.label));
@@ -431,46 +612,52 @@ export class TokenBudgetPanel extends BasePanel {
431
612
  ]));
432
613
  }
433
614
 
615
+ // Inline cost estimate — absorbed pricing wiring (cost-utils.ts), shown
616
+ // whenever the active model id is known.
617
+ if (this.getCurrentModelId) {
618
+ const cost = calcSessionCost(u.input, u.output, u.cacheRead, u.cacheWrite, this.getCurrentModelId());
619
+ lines.push(buildStyledPanelLine(width, [
620
+ { text: ' Cost: ', fg: C.label },
621
+ { text: `$${cost.toFixed(4)}`, fg: C.value, bold: true },
622
+ ]));
623
+ }
624
+
434
625
  return lines;
435
626
  }
436
627
 
437
628
  /** Last N turns table: turn#, in, out, CR, CW, total. */
438
629
  private renderTurnHistory(width: number, maxRows: number): Line[] {
439
- const lines: Line[] = [];
440
-
441
- // Column headers
442
- const colLine = createEmptyLine(width);
443
- const headers = [' #', ' Input', ' Output', ' CR', ' CW', ' Total'];
444
- const cols = [3, 9, 9, 6, 6, 9];
445
- let hx = 0;
446
- for (let i = 0; i < headers.length; i++) {
447
- const h = headers[i]!;
448
- for (const ch of h.slice(0, cols[i]!)) {
449
- if (hx >= width) break;
450
- colLine[hx++] = createStyledCell(ch, { fg: C.turnHeader, dim: true });
451
- }
452
- }
453
- lines.push(colLine);
454
-
455
630
  const available = Math.max(0, maxRows - 1); // minus col header
456
631
  const toShow = this.turnHistory.slice(-Math.max(0, available));
457
632
 
458
- toShow.forEach((t, i) => {
459
- if (lines.length >= maxRows) return;
460
- const turnNum = this.turnHistory.length - toShow.length + i + 1;
461
- const total = t.input + t.output + t.cacheRead + t.cacheWrite;
462
- const cells: Array<[string, string]> = [
463
- [String(turnNum).padStart(3), C.dim],
464
- [fmtTok(t.input).padStart(9), C.input],
465
- [fmtTok(t.output).padStart(9), C.output],
466
- [fmtTok(t.cacheRead).padStart(6), C.cacheRead],
467
- [fmtTok(t.cacheWrite).padStart(6), C.cacheWrite],
468
- [fmtTok(total).padStart(9), C.value],
469
- ];
470
- lines.push(buildStyledPanelLine(width, cells.map(([val, color]) => ({ text: val, fg: color }))));
471
- });
472
-
473
- return lines;
633
+ // Width-aware aligned table via the shared primitive (replaces manual
634
+ // per-cell column math, which miscounts on wide glyphs).
635
+ return buildTable(
636
+ width,
637
+ [
638
+ { label: '#', width: 4, align: 'right' },
639
+ { label: 'Input', width: 9, align: 'right' },
640
+ { label: 'Output', width: 9, align: 'right' },
641
+ { label: 'CR', width: 7, align: 'right' },
642
+ { label: 'CW', width: 7, align: 'right' },
643
+ { label: 'Total', align: 'right' },
644
+ ],
645
+ toShow.map((t, i) => {
646
+ const turnNum = this.turnHistory.length - toShow.length + i + 1;
647
+ const total = t.input + t.output + t.cacheRead + t.cacheWrite;
648
+ return {
649
+ cells: [
650
+ { text: String(turnNum), fg: C.dim },
651
+ { text: fmtTok(t.input), fg: C.input },
652
+ { text: fmtTok(t.output), fg: C.output },
653
+ { text: fmtTok(t.cacheRead), fg: C.cacheRead },
654
+ { text: fmtTok(t.cacheWrite), fg: C.cacheWrite },
655
+ { text: fmtTok(total), fg: C.value },
656
+ ],
657
+ };
658
+ }),
659
+ { ...DEFAULT_PANEL_PALETTE, label: C.turnHeader },
660
+ );
474
661
  }
475
662
 
476
663
  /** Paint a plain text string into a new Line. */