@pellux/goodvibes-tui 1.1.0 → 1.7.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 (194) hide show
  1. package/CHANGELOG.md +85 -21
  2. package/README.md +20 -11
  3. package/docs/foundation-artifacts/operator-contract.json +1 -1
  4. package/package.json +2 -2
  5. package/src/core/alert-gating.ts +67 -0
  6. package/src/core/approval-alert.ts +72 -0
  7. package/src/core/budget-breach-notifier.ts +106 -0
  8. package/src/core/conversation-rendering.ts +19 -0
  9. package/src/core/conversation.ts +18 -0
  10. package/src/core/focus-tracker.ts +41 -0
  11. package/src/core/long-task-notifier.ts +33 -6
  12. package/src/core/system-message-router.ts +37 -51
  13. package/src/core/turn-cancellation.ts +20 -0
  14. package/src/core/turn-event-wiring.ts +64 -3
  15. package/src/export/cost-utils.ts +36 -0
  16. package/src/input/command-registry.ts +38 -1
  17. package/src/input/commands/checkpoint-runtime.ts +280 -0
  18. package/src/input/commands/codebase-runtime.ts +192 -0
  19. package/src/input/commands/eval.ts +1 -1
  20. package/src/input/commands/health-runtime.ts +1 -1
  21. package/src/input/commands/image-runtime.ts +112 -0
  22. package/src/input/commands/intelligence-runtime.ts +11 -1
  23. package/src/input/commands/local-auth-runtime.ts +4 -1
  24. package/src/input/commands/local-runtime.ts +9 -3
  25. package/src/input/commands/marketplace-runtime.ts +2 -2
  26. package/src/input/commands/memory.ts +6 -1
  27. package/src/input/commands/operator-panel-runtime.ts +40 -24
  28. package/src/input/commands/planning-runtime.ts +26 -3
  29. package/src/input/commands/platform-sandbox-runtime.ts +1 -6
  30. package/src/input/commands/plugin-runtime.ts +2 -2
  31. package/src/input/commands/policy-dispatch.ts +21 -0
  32. package/src/input/commands/provider-accounts-runtime.ts +1 -1
  33. package/src/input/commands/qrcode-runtime.ts +3 -3
  34. package/src/input/commands/recall-review.ts +35 -0
  35. package/src/input/commands/remote-runtime.ts +3 -3
  36. package/src/input/commands/runtime-services.ts +54 -13
  37. package/src/input/commands/services-runtime.ts +1 -1
  38. package/src/input/commands/session-workflow.ts +16 -1
  39. package/src/input/commands/settings-sync-runtime.ts +1 -1
  40. package/src/input/commands/shell-core.ts +15 -7
  41. package/src/input/commands/skills-runtime.ts +2 -8
  42. package/src/input/commands/subscription-runtime.ts +2 -2
  43. package/src/input/commands/test-runtime.ts +277 -0
  44. package/src/input/commands/websearch-runtime.ts +101 -0
  45. package/src/input/commands/work-plan-runtime.ts +36 -10
  46. package/src/input/commands/workstream-runtime.ts +338 -0
  47. package/src/input/commands.ts +12 -0
  48. package/src/input/config-modal-types.ts +161 -0
  49. package/src/input/config-modal.ts +377 -0
  50. package/src/input/feed-context-factory.ts +7 -8
  51. package/src/input/handler-command-route.ts +27 -17
  52. package/src/input/handler-feed-routes.ts +61 -23
  53. package/src/input/handler-feed.ts +47 -23
  54. package/src/input/handler-interactions.ts +1 -3
  55. package/src/input/handler-modal-routes.ts +65 -0
  56. package/src/input/handler-modal-stack.ts +3 -5
  57. package/src/input/handler-modal-token-routes.ts +16 -49
  58. package/src/input/handler-picker-routes.ts +11 -94
  59. package/src/input/handler-shortcuts.ts +24 -14
  60. package/src/input/handler-types.ts +2 -6
  61. package/src/input/handler-ui-state.ts +10 -22
  62. package/src/input/handler.ts +6 -28
  63. package/src/input/keybindings.ts +22 -8
  64. package/src/input/model-picker-types.ts +24 -6
  65. package/src/input/model-picker.ts +58 -0
  66. package/src/input/panel-integration-actions.ts +9 -170
  67. package/src/input/settings-modal-data.ts +95 -0
  68. package/src/input/settings-modal-mutations.ts +6 -4
  69. package/src/main.ts +24 -27
  70. package/src/panels/agent-inspector-shared.ts +2 -1
  71. package/src/panels/base-panel.ts +22 -1
  72. package/src/panels/builtin/agent.ts +21 -107
  73. package/src/panels/builtin/development.ts +15 -61
  74. package/src/panels/builtin/knowledge.ts +8 -32
  75. package/src/panels/builtin/operations.ts +84 -428
  76. package/src/panels/builtin/session.ts +21 -112
  77. package/src/panels/builtin/shared.ts +9 -43
  78. package/src/panels/builtin-modals.ts +218 -0
  79. package/src/panels/builtin-panels.ts +5 -0
  80. package/src/panels/cost-tracker-panel.ts +36 -10
  81. package/src/panels/diff-panel.ts +12 -43
  82. package/src/panels/eval-registry.ts +60 -0
  83. package/src/panels/fleet-panel.ts +800 -0
  84. package/src/panels/fleet-read-model.ts +477 -0
  85. package/src/panels/fleet-steer.ts +92 -0
  86. package/src/panels/fleet-stop.ts +125 -0
  87. package/src/panels/fleet-tabs.ts +230 -0
  88. package/src/panels/fleet-transcript.ts +356 -0
  89. package/src/panels/index.ts +7 -31
  90. package/src/panels/modals/hooks-modal.ts +187 -0
  91. package/src/panels/modals/keybindings-modal.ts +151 -0
  92. package/src/panels/modals/knowledge-modal.ts +158 -0
  93. package/src/panels/modals/local-auth-modal.ts +132 -0
  94. package/src/panels/modals/marketplace-modal.ts +218 -0
  95. package/src/panels/modals/memory-modal.ts +180 -0
  96. package/src/panels/modals/modal-surface-helpers.ts +54 -0
  97. package/src/panels/modals/modal-theme.ts +36 -0
  98. package/src/panels/modals/pairing-modal.ts +144 -0
  99. package/src/panels/modals/planning-modal.ts +282 -0
  100. package/src/panels/modals/plugins-modal.ts +174 -0
  101. package/src/panels/modals/policy-modal.ts +256 -0
  102. package/src/panels/modals/provider-health-modal.ts +136 -0
  103. package/src/panels/modals/remote-modal.ts +115 -0
  104. package/src/panels/modals/sandbox-modal.ts +150 -0
  105. package/src/panels/modals/security-modal.ts +217 -0
  106. package/src/panels/modals/services-modal.ts +179 -0
  107. package/src/panels/modals/settings-sync-modal.ts +142 -0
  108. package/src/panels/modals/skills-modal.ts +189 -0
  109. package/src/panels/modals/subscription-modal.ts +172 -0
  110. package/src/panels/modals/work-plan-modal.ts +149 -0
  111. package/src/panels/panel-confirm-overlay.ts +72 -0
  112. package/src/panels/panel-manager.ts +108 -5
  113. package/src/panels/project-planning-answer-actions.ts +4 -2
  114. package/src/panels/skills-panel.ts +7 -51
  115. package/src/panels/types.ts +13 -0
  116. package/src/permissions/hunk-selection.ts +179 -0
  117. package/src/permissions/prompt.ts +60 -4
  118. package/src/renderer/compaction-history-modal.ts +19 -3
  119. package/src/renderer/compaction-preview.ts +13 -2
  120. package/src/renderer/compaction-quality.ts +100 -0
  121. package/src/renderer/config-modal.ts +81 -0
  122. package/src/renderer/conversation-overlays.ts +10 -17
  123. package/src/renderer/fleet-tab-strip.ts +56 -0
  124. package/src/renderer/footer-tips.ts +10 -2
  125. package/src/renderer/git-status.ts +40 -0
  126. package/src/renderer/help-overlay.ts +2 -2
  127. package/src/renderer/model-workspace.ts +44 -1
  128. package/src/renderer/panel-workspace-bar.ts +8 -2
  129. package/src/renderer/turn-injection.ts +114 -0
  130. package/src/runtime/bootstrap-command-context.ts +21 -1
  131. package/src/runtime/bootstrap-command-parts.ts +34 -7
  132. package/src/runtime/bootstrap-core.ts +14 -4
  133. package/src/runtime/bootstrap-shell.ts +29 -16
  134. package/src/runtime/bootstrap.ts +11 -17
  135. package/src/runtime/code-index-services.ts +112 -0
  136. package/src/runtime/orchestrator-core-services.ts +49 -0
  137. package/src/runtime/process-lifecycle.ts +3 -1
  138. package/src/runtime/services.ts +91 -43
  139. package/src/runtime/ui-services.ts +8 -0
  140. package/src/runtime/workstream-services.ts +195 -0
  141. package/src/shell/blocking-input.ts +22 -1
  142. package/src/shell/ui-openers.ts +129 -17
  143. package/src/utils/format-duration.ts +8 -18
  144. package/src/utils/splash-lines.ts +1 -1
  145. package/src/version.ts +1 -1
  146. package/src/panels/agent-inspector-panel.ts +0 -786
  147. package/src/panels/approval-panel.ts +0 -252
  148. package/src/panels/automation-control-panel.ts +0 -479
  149. package/src/panels/cockpit-panel.ts +0 -481
  150. package/src/panels/cockpit-read-model.ts +0 -238
  151. package/src/panels/communication-panel.ts +0 -256
  152. package/src/panels/control-plane-panel.ts +0 -470
  153. package/src/panels/debug-panel.ts +0 -615
  154. package/src/panels/docs-panel.ts +0 -384
  155. package/src/panels/eval-panel.ts +0 -627
  156. package/src/panels/file-explorer-panel.ts +0 -673
  157. package/src/panels/file-preview-panel.ts +0 -517
  158. package/src/panels/hooks-panel.ts +0 -401
  159. package/src/panels/incident-review-panel.ts +0 -406
  160. package/src/panels/intelligence-panel.ts +0 -383
  161. package/src/panels/knowledge-graph-panel.ts +0 -515
  162. package/src/panels/marketplace-panel.ts +0 -399
  163. package/src/panels/memory-panel.ts +0 -558
  164. package/src/panels/ops-control-panel.ts +0 -329
  165. package/src/panels/ops-strategy-panel.ts +0 -321
  166. package/src/panels/orchestration-panel.ts +0 -465
  167. package/src/panels/panel-list-panel.ts +0 -566
  168. package/src/panels/plan-dashboard-panel.ts +0 -707
  169. package/src/panels/policy-panel.ts +0 -517
  170. package/src/panels/project-planning-panel.ts +0 -731
  171. package/src/panels/provider-health-panel.ts +0 -678
  172. package/src/panels/provider-health-tracker.ts +0 -310
  173. package/src/panels/provider-health-views.ts +0 -567
  174. package/src/panels/qr-panel.ts +0 -280
  175. package/src/panels/remote-panel.ts +0 -534
  176. package/src/panels/routes-panel.ts +0 -241
  177. package/src/panels/sandbox-panel.ts +0 -456
  178. package/src/panels/security-panel.ts +0 -447
  179. package/src/panels/services-panel.ts +0 -329
  180. package/src/panels/session-browser-panel.ts +0 -496
  181. package/src/panels/settings-sync-panel.ts +0 -398
  182. package/src/panels/subscription-panel.ts +0 -342
  183. package/src/panels/symbol-outline-panel.ts +0 -619
  184. package/src/panels/system-messages-panel.ts +0 -364
  185. package/src/panels/tasks-panel.ts +0 -608
  186. package/src/panels/thinking-panel.ts +0 -333
  187. package/src/panels/tool-inspector-panel.ts +0 -537
  188. package/src/panels/work-plan-panel.ts +0 -540
  189. package/src/panels/worktree-panel.ts +0 -360
  190. package/src/panels/wrfc-panel.ts +0 -790
  191. package/src/renderer/agent-detail-modal.ts +0 -466
  192. package/src/renderer/live-tail-modal.ts +0 -156
  193. package/src/renderer/process-modal.ts +0 -671
  194. package/src/renderer/qr-renderer.ts +0 -120
@@ -3,6 +3,10 @@
3
3
  // ---------------------------------------------------------------------------
4
4
 
5
5
  import type { Panel, PanelRegistration, PanelCategory } from './types.ts';
6
+ // Type-only, erased at runtime, routed through the `@/` alias (not a relative
7
+ // path) so it stays out of the relative-import graph the architecture
8
+ // cycle-checker walks — same discipline as the PanelManager import in types.ts.
9
+ import type { ConfigModalSurface } from '@/input/config-modal-types.ts';
6
10
 
7
11
  // ---------------------------------------------------------------------------
8
12
  // Pane
@@ -31,6 +35,29 @@ export class PanelManager {
31
35
  private retainedPanels = new Map<string, Panel>();
32
36
  /** Old/absorbed panel id -> merged target id (WO-1xx console merges). */
33
37
  private aliases = new Map<string, string>();
38
+ /**
39
+ * Retired panel id -> modal name (W6.1 purge, MIGRATE-TO-MODAL surfaces).
40
+ * Unlike `aliases` (panel -> panel), a hit here means no panel is ever
41
+ * constructed for this id — `open()` invokes `openModalCallback` instead
42
+ * and returns a sentinel. Registrations are added by WO-A/B; this map and
43
+ * the open()-time check are WO-C's mechanism only.
44
+ */
45
+ private modalRedirects = new Map<string, string>();
46
+ /**
47
+ * Modal name -> the surface the config-modal host renders (W6.1). Built once
48
+ * in builtin-modals.ts (closing over read-models) and looked up by
49
+ * ui-openers' openModal callback. Distinct from `modalRedirects` (panel id ->
50
+ * modal name): a redirect resolves a legacy panel id to a name; this map
51
+ * resolves that name to the actual surface data/actions.
52
+ */
53
+ private modalSurfaces = new Map<string, ConfigModalSurface>();
54
+ /**
55
+ * Late-bound: the modal stack is constructed after PanelManager (same
56
+ * ordering constraint as the openAgentDetail callback in
57
+ * builtin/shared.ts), so this is injected via a setter rather than the
58
+ * constructor.
59
+ */
60
+ private openModalCallback?: (modalName: string) => void;
34
61
  private _visible: boolean = false;
35
62
  private _splitRatio: number = 0.6;
36
63
 
@@ -99,10 +126,76 @@ export class PanelManager {
99
126
  this.aliases.set(aliasId, targetId);
100
127
  }
101
128
 
129
+ /**
130
+ * Register a redirect so `open(panelId)` opens the named modal instead of
131
+ * constructing a panel (MIGRATE-TO-MODAL surfaces). Checked in open()
132
+ * before alias/registry resolution.
133
+ */
134
+ registerModalRedirect(panelId: string, modalName: string): void {
135
+ this.modalRedirects.set(panelId, modalName);
136
+ }
137
+
138
+ /**
139
+ * Inject the callback `open()` invokes when it hits a modal redirect.
140
+ * Late-bound via setter because the modal stack is constructed after
141
+ * PanelManager (mirrors the openAgentDetail callback pattern).
142
+ */
143
+ setOpenModalCallback(callback: (modalName: string) => void): void {
144
+ this.openModalCallback = callback;
145
+ }
146
+
147
+ /**
148
+ * The modal name `panelIdOrAlias` redirects to, if any — lets callers
149
+ * (e.g. the `/panel` command) print "moved to the <name> modal" before or
150
+ * instead of calling open().
151
+ */
152
+ getModalRedirect(panelIdOrAlias: string): string | undefined {
153
+ return this.modalRedirects.get(panelIdOrAlias);
154
+ }
155
+
156
+ /**
157
+ * Register the surface (data + actions) the config-modal host renders for a
158
+ * modal name. Called from builtin-modals.ts alongside registerModalRedirect.
159
+ */
160
+ registerModalSurface(surface: ConfigModalSurface): void {
161
+ this.modalSurfaces.set(surface.name, surface);
162
+ }
163
+
164
+ /** Resolve a modal name to its registered surface, if any. */
165
+ getModalSurface(name: string): ConfigModalSurface | undefined {
166
+ return this.modalSurfaces.get(name);
167
+ }
168
+
102
169
  private _resolveId(panelId: string): string {
103
170
  return this.aliases.get(panelId) ?? panelId;
104
171
  }
105
172
 
173
+ /**
174
+ * Placeholder returned by open() for a modal-redirected id. Never pushed
175
+ * into a pane, never retained, never rendered — it exists only so open()
176
+ * can keep its non-null `Panel` return type without constructing the real
177
+ * (deleted) panel view. `name` carries the modal name so a caller that
178
+ * inspects the returned panel's `name` (rather than calling
179
+ * getModalRedirect() beforehand) still gets an honest answer.
180
+ */
181
+ private _modalRedirectSentinel(panelId: string, modalName: string): Panel {
182
+ return {
183
+ id: panelId,
184
+ name: modalName,
185
+ icon: '·',
186
+ category: 'session',
187
+ onActivate: () => {},
188
+ onDeactivate: () => {},
189
+ onDestroy: () => {},
190
+ render: () => [],
191
+ isTransient: true,
192
+ isPinned: false,
193
+ needsRender: false,
194
+ invalidate: () => {},
195
+ markRendered: () => {},
196
+ };
197
+ }
198
+
106
199
  getRegisteredTypes(): PanelRegistration[] {
107
200
  return [...this.registry];
108
201
  }
@@ -136,6 +229,11 @@ export class PanelManager {
136
229
  }
137
230
 
138
231
  open(panelIdOrAlias: string, pane?: 'top' | 'bottom'): Panel {
232
+ const modalName = this.modalRedirects.get(panelIdOrAlias);
233
+ if (modalName !== undefined) {
234
+ this.openModalCallback?.(modalName);
235
+ return this._modalRedirectSentinel(panelIdOrAlias, modalName);
236
+ }
139
237
  const panelId = this._resolveId(panelIdOrAlias);
140
238
  this._recordRecent(panelId);
141
239
  const existingPane = this._findPaneOf(panelId);
@@ -387,9 +485,12 @@ export class PanelManager {
387
485
  this.bottomPane.panels.push(panel);
388
486
  this.bottomPane.activeIndex = 0;
389
487
  } else {
390
- // Open a predictable default panel in the bottom pane (the panel list),
391
- // rather than an arbitrary registration-order-dependent panel.
392
- const defaultPanel = this._getRegistration('panel-list') ?? this.registry[0];
488
+ // Open a predictable default panel in the bottom pane. W6.1 purge:
489
+ // 'panel-list' was deleted (dead weight over a 5-panel registry —
490
+ // see the DELETE disposition), so the default is explicitly
491
+ // 'fleet' rather than falling back to registry[0] (whatever
492
+ // registers first, currently 'git' — see risk 5 in the brief).
493
+ const defaultPanel = this._getRegistration('fleet') ?? this.registry[0];
393
494
  if (defaultPanel) {
394
495
  this.open(defaultPanel.id, 'bottom');
395
496
  }
@@ -493,9 +594,11 @@ export class PanelManager {
493
594
 
494
595
  toggle(): void {
495
596
  this._visible = !this._visible;
496
- // Auto-open a default panel if toggling visible with nothing open
597
+ // Auto-open a default panel if toggling visible with nothing open.
598
+ // W6.1 purge: explicitly 'fleet' rather than registry[0] — see the
599
+ // matching comment in toggleBottomPane() above.
497
600
  if (this._visible && this.topPane.panels.length === 0 && this.bottomPane.panels.length === 0) {
498
- const defaultPanel = this._getRegistration('panel-list') ?? this.registry[0];
601
+ const defaultPanel = this._getRegistration('fleet') ?? this.registry[0];
499
602
  if (defaultPanel) this.open(defaultPanel.id);
500
603
  }
501
604
  }
@@ -125,8 +125,10 @@ export function buildAnswerActions(question: ProjectPlanningQuestion, draftAnswe
125
125
  });
126
126
  actions.push({
127
127
  id: 'dismiss-planning',
128
- label: 'Close planning and continue without it',
129
- detail: 'Pause project planning for this workspace. Normal chat continues; /plan can reopen it later.',
128
+ // Honest label: there is no /plan subcommand that pauses planning, so this
129
+ // row only closes the panel it does NOT change any planning state.
130
+ label: 'Close (planning unchanged)',
131
+ detail: 'Close this panel. Pausing project planning is not available as a command yet, so planning state is left unchanged; /plan reopens it later.',
130
132
  answer: 'Pause project planning for this workspace and continue without the planning panel.',
131
133
  kind: 'dismiss',
132
134
  });
@@ -5,8 +5,6 @@ import { createEmptyLine } from '../types/grid.ts';
5
5
  import { type ConfirmState, handleConfirmInput, renderConfirmLines } from './confirm-state.ts';
6
6
  import { getDisplayWidth, truncateDisplay } from '../utils/terminal-width.ts';
7
7
  import { ScrollableListPanel } from './scrollable-list-panel.ts';
8
- import { FilePreviewPanel } from './file-preview-panel.ts';
9
- import type { PanelIntegrationContext } from './types.ts';
10
8
  import type { ComponentHealthMonitor } from '../runtime/perf/panel-health-monitor.ts';
11
9
  import { listInstalledEcosystemEntries, type EcosystemCatalogPathOptions, type ShellPathService } from '@/runtime/index.ts';
12
10
  import {
@@ -257,11 +255,6 @@ export class SkillsPanel extends ScrollableListPanel<SkillRecord> {
257
255
  // I1: confirm state for destructive delete
258
256
  private confirm: ConfirmState | null = null;
259
257
  private readyPromise: Promise<void> | null = null;
260
- // Staged pending action consumed by handlePanelIntegrationAction (same
261
- // pattern as diff-panel.ts's pendingOpenPreview): Enter marks the intent
262
- // here, the actual PanelManager/preview wiring happens once the
263
- // integration context is available.
264
- private pendingOpenPreview = false;
265
258
 
266
259
  public constructor(options: SkillsPanelOptions) {
267
260
  super('skills', 'Skills', '▩', 'automation-control', options.componentHealthMonitor);
@@ -374,50 +367,13 @@ export class SkillsPanel extends ScrollableListPanel<SkillRecord> {
374
367
 
375
368
  public override onDestroy(): void {}
376
369
 
377
- protected override onSelect(_skill: SkillRecord): void {
378
- // Enter opens the skill's markdown source in the preview panel — see
379
- // handlePanelIntegrationAction for the actual PanelManager wiring
380
- // (needs the integration context, not available here). Selection itself
381
- // is read back from getVisibleItems() in that hook. onSelect is only
382
- // invoked by ScrollableListPanel's navigation handler when the filter is
383
- // not active, so no manual guard is needed here.
384
- this.pendingOpenPreview = true;
385
- }
386
-
387
- /**
388
- * Cross-panel integration hook — Enter opens the selected skill's markdown
389
- * source in the preview panel via the same open/focus bridge DiffPanel
390
- * uses (src/input/panel-integration-actions.ts), without this panel
391
- * needing to know about PanelManager pane/focus mechanics beyond what ctx
392
- * exposes.
393
- */
394
- public handlePanelIntegrationAction(_key: string, ctx: PanelIntegrationContext): boolean {
395
- if (!this.pendingOpenPreview) return false;
396
- this.pendingOpenPreview = false;
397
- const skill = this.getSelectedItem();
398
- if (!skill) return false;
399
-
400
- const pm = ctx.panelManager;
401
- let previewPanel = pm.getPanel('preview');
402
- if (previewPanel instanceof FilePreviewPanel) {
403
- const pane = pm.getPaneOf('preview');
404
- pm.activateById('preview');
405
- if (pane) pm.focusPane(pane);
406
- } else {
407
- const targetPane: 'top' | 'bottom' = pm.isBottomPaneVisible()
408
- ? (pm.getFocusedPane() === 'top' ? 'bottom' : 'top')
409
- : 'bottom';
410
- const opened = pm.open('preview', targetPane);
411
- pm.show();
412
- pm.focusPane(targetPane);
413
- previewPanel = opened instanceof FilePreviewPanel ? opened : null;
414
- }
415
- if (previewPanel instanceof FilePreviewPanel) {
416
- previewPanel.openFile(skill.path);
417
- return true;
418
- }
419
- return false;
420
- }
370
+ // W6.1 (the purge): Enter used to open the skill's markdown source in the
371
+ // preview panel via a staged pendingOpenPreview flag + handlePanelIntegrationAction
372
+ // (same pattern diff-panel.ts used). 'preview' is DELETE-disposition with no
373
+ // successor surface (no file-picker-overlay preview to repoint to either
374
+ // verified), so that cross-panel jump was removed rather than repointed;
375
+ // Enter is currently a no-op on this list (browse-only until WO-B migrates
376
+ // Skills to a modal).
421
377
 
422
378
  public handleInput(key: string): boolean {
423
379
  // I1: y/n confirmation dialog for delete
@@ -126,6 +126,19 @@ export interface Panel {
126
126
  * back to its built-in `instanceof` routing.
127
127
  */
128
128
  handlePanelIntegrationAction?(key: string, ctx: PanelIntegrationContext): boolean;
129
+
130
+ /**
131
+ * Optional: called by the global Ctrl+X (`panel-close`) shortcut BEFORE it
132
+ * closes this panel (handler-shortcuts.ts). Return `true` to consume
133
+ * Ctrl+X for an in-panel action instead — e.g. FleetPanel (Wave-3 session
134
+ * tabs) detaches its active tab and leaves the panel open. Return `false`
135
+ * (or omit this hook) to fall through to the ordinary close behavior.
136
+ * Ctrl+X never reaches a panel's own `handleInput` (ctrl/meta combos are
137
+ * intercepted earlier — see handlePanelFocusToken in
138
+ * src/input/handler-feed-routes.ts), so this is the one seam a panel has
139
+ * to intervene before the global shortcut acts.
140
+ */
141
+ interceptPanelClose?(): boolean;
129
142
  }
130
143
 
131
144
  export interface PanelRegistration extends Pick<Panel, 'id' | 'name' | 'icon' | 'category'> {
@@ -0,0 +1,179 @@
1
+ import type { PermissionPromptRequest } from '@pellux/goodvibes-sdk/platform/permissions';
2
+
3
+ /**
4
+ * Per-hunk accept/reject at the approval gate (W1.3 v1).
5
+ *
6
+ * Scoped to the `edit` tool only: EditInput.edits is already an array of
7
+ * independent, atomic find/replace units (each a natural "hunk"), so no
8
+ * diff/hunk-apply primitive is needed here. The `write` (whole-file
9
+ * overwrite) tool stays whole-payload accept/reject — true whole-file hunk
10
+ * splitting would require a genuinely new SDK diff/patch-apply primitive,
11
+ * flagged as an explicit v2 follow-up, not built here.
12
+ *
13
+ * NOTE ON SDK TYPES: the installed @pellux/goodvibes-sdk (0.36.0) does not
14
+ * yet export `EditItem`/`EditInput` from its `platform/tools` subpath (that
15
+ * export is added in the in-flight SDK change for this work order, landing
16
+ * in 0.37). Until then this module reads `request.args.edits` defensively
17
+ * off a narrow local structural type rather than importing a not-yet-published
18
+ * symbol, per the cross-repo compile contract for this wave.
19
+ */
20
+
21
+ /** Structural shape of one EditItem, read defensively — see module doc. */
22
+ export interface EditItemLike {
23
+ readonly path: string;
24
+ readonly find: string;
25
+ readonly replace: string;
26
+ readonly id?: string | undefined;
27
+ }
28
+
29
+ export interface HunkSelectionState {
30
+ readonly hunks: readonly EditItemLike[];
31
+ readonly cursor: number;
32
+ readonly selected: ReadonlySet<number>;
33
+ }
34
+
35
+ export type HunkCommit = 'apply' | 'cancel' | null;
36
+
37
+ /**
38
+ * Structural widening of the installed SDK's `PermissionPromptDecision`
39
+ * (0.36.0, no `modifiedArgs` field yet) so main.ts can resolve a decision
40
+ * carrying `modifiedArgs` without a nominal import of a not-yet-published
41
+ * SDK symbol. Assign to a variable of this type before calling the SDK's
42
+ * typed `resolve(decision: PermissionPromptDecision)` — passing a variable
43
+ * (not an object literal) sidesteps TypeScript's excess-property check, and
44
+ * the extra field is invisible to 0.36.0 callers, structurally compatible
45
+ * once 0.37 adds it for real.
46
+ */
47
+ export interface PermissionPromptDecisionWithModifiedArgs {
48
+ approved: boolean;
49
+ remember?: boolean;
50
+ modifiedArgs?: Record<string, unknown>;
51
+ }
52
+
53
+ export interface HunkKeyResult {
54
+ readonly state: HunkSelectionState;
55
+ readonly commit: HunkCommit;
56
+ }
57
+
58
+ /** Runtime guard: is `value` shaped like an EditItem? */
59
+ function isEditItemLike(value: unknown): value is EditItemLike {
60
+ if (typeof value !== 'object' || value === null) return false;
61
+ const candidate = value as Record<string, unknown>;
62
+ return (
63
+ typeof candidate['path'] === 'string' &&
64
+ typeof candidate['find'] === 'string' &&
65
+ typeof candidate['replace'] === 'string' &&
66
+ (candidate['id'] === undefined || typeof candidate['id'] === 'string')
67
+ );
68
+ }
69
+
70
+ /** Extracts a validated EditItemLike[] from a permission request's args, or null if not edit-shaped. */
71
+ function readEditItems(args: Record<string, unknown>): EditItemLike[] | null {
72
+ const edits = args['edits'];
73
+ if (!Array.isArray(edits) || edits.length === 0) return null;
74
+ const items: EditItemLike[] = [];
75
+ for (const entry of edits) {
76
+ if (!isEditItemLike(entry)) return null;
77
+ items.push(entry);
78
+ }
79
+ return items;
80
+ }
81
+
82
+ /**
83
+ * isHunkSelectable — true iff this is a multi-edit `edit` tool call. Single-edit
84
+ * `edit` calls, `write` calls, and everything else keep the exact current
85
+ * y/a/n flow untouched (zero behavior change for the common case).
86
+ */
87
+ export function isHunkSelectable(request: PermissionPromptRequest): boolean {
88
+ if (request.tool !== 'edit') return false;
89
+ const items = readEditItems(request.args);
90
+ return items !== null && items.length > 1;
91
+ }
92
+
93
+ /** Builds initial hunk-selection state — all hunks selected by default (matches today's "y = approve everything" expectation as the zero-navigation path). */
94
+ export function buildHunkSelectionState(request: PermissionPromptRequest): HunkSelectionState {
95
+ const hunks = readEditItems(request.args) ?? [];
96
+ return {
97
+ hunks,
98
+ cursor: 0,
99
+ selected: new Set(hunks.map((_, i) => i)),
100
+ };
101
+ }
102
+
103
+ /**
104
+ * applyHunkKey — pure reducer over raw stdin `data` for an active hunk-selection
105
+ * prompt. Mirrors the outer y/a/n prompt's raw-data key routing (not the
106
+ * normalized panel key vocabulary) since Space must be distinguishable from
107
+ * the trimmed/lowercased `key` the outer switch uses.
108
+ */
109
+ export function applyHunkKey(state: HunkSelectionState, data: string): HunkKeyResult {
110
+ const lower = data.toLowerCase().trim();
111
+
112
+ if (data === 'j' || data === '\x1b[B') {
113
+ return { state: { ...state, cursor: Math.min(state.hunks.length - 1, state.cursor + 1) }, commit: null };
114
+ }
115
+ if (data === 'k' || data === '\x1b[A') {
116
+ return { state: { ...state, cursor: Math.max(0, state.cursor - 1) }, commit: null };
117
+ }
118
+ if (data === ' ') {
119
+ const next = new Set(state.selected);
120
+ if (next.has(state.cursor)) {
121
+ next.delete(state.cursor);
122
+ } else {
123
+ next.add(state.cursor);
124
+ }
125
+ return { state: { ...state, selected: next }, commit: null };
126
+ }
127
+ if (lower === 'a') {
128
+ return { state: { ...state, selected: new Set(state.hunks.map((_, i) => i)) }, commit: null };
129
+ }
130
+ if (data === '\r' || data === '\n') {
131
+ if (state.selected.size === 0) {
132
+ // No-op: nothing selected, prompt stays open rather than applying an empty edit.
133
+ return { state, commit: null };
134
+ }
135
+ return { state, commit: 'apply' };
136
+ }
137
+ if (lower === 'y') {
138
+ // Bypass-navigation shortcut: preserves muscle memory for non-hunk prompts.
139
+ return { state: { ...state, selected: new Set(state.hunks.map((_, i) => i)) }, commit: 'apply' };
140
+ }
141
+ if (lower === 'n' || data === '\x1b' || data === '\x03') {
142
+ return { state, commit: 'cancel' };
143
+ }
144
+
145
+ return { state, commit: null };
146
+ }
147
+
148
+ /**
149
+ * buildModifiedEditArgs — returns request.args with `edits` filtered to exactly
150
+ * the selected hunks (in original order), preserving every other EditInput
151
+ * field (match/transaction/output/dry_run/validate) untouched.
152
+ */
153
+ export function buildModifiedEditArgs(
154
+ request: PermissionPromptRequest,
155
+ state: HunkSelectionState,
156
+ ): Record<string, unknown> {
157
+ const filtered = state.hunks.filter((_, i) => state.selected.has(i));
158
+ return { ...request.args, edits: filtered };
159
+ }
160
+
161
+ /**
162
+ * buildPendingPermissionExtras — the `hunkState` + wrapped `resolve` fields
163
+ * main.ts spreads onto its PendingPermissionState alongside `...request`.
164
+ * Bundled into one helper (rather than inlined in main.ts) to keep main.ts's
165
+ * net line count at zero for this change — main.ts sits at the 800-line
166
+ * architecture cap and must never grow it.
167
+ */
168
+ export function buildPendingPermissionExtras(
169
+ request: PermissionPromptRequest,
170
+ resolvePromise: (decision: PermissionPromptDecisionWithModifiedArgs) => void,
171
+ ): {
172
+ hunkState: HunkSelectionState | undefined;
173
+ resolve: (approved: boolean, remember?: boolean, modifiedArgs?: Record<string, unknown>) => void;
174
+ } {
175
+ return {
176
+ hunkState: isHunkSelectable(request) ? buildHunkSelectionState(request) : undefined,
177
+ resolve: (approved, remember = false, modifiedArgs) => resolvePromise({ approved, remember, modifiedArgs }),
178
+ };
179
+ }
@@ -2,10 +2,29 @@ import { type Line } from '../types/grid.ts';
2
2
  import { UIFactory } from '../renderer/ui-factory.ts';
3
3
  import type { PermissionCategory, PermissionRequestAnalysis } from '@pellux/goodvibes-sdk/platform/permissions';
4
4
  import { buildPermissionApprovalBrief, getDisplayArg } from '@pellux/goodvibes-sdk/platform/permissions';
5
+ import { DIFF_TONES, UI_TONES } from '../renderer/ui-primitives.ts';
6
+ import type { HunkSelectionState } from './hunk-selection.ts';
7
+ export { buildPendingPermissionExtras } from './hunk-selection.ts';
5
8
 
6
9
  import type { PermissionPromptRequest, PermissionPromptDecision, PermissionRequestHandler, PermissionRequest } from '@pellux/goodvibes-sdk/platform/permissions';
7
10
  export type { PermissionPromptRequest, PermissionPromptDecision, PermissionRequestHandler, PermissionRequest };
8
11
 
12
+ /** Visible hunk rows before a "+N more" trailer kicks in (Risk 3). */
13
+ const MAX_VISIBLE_HUNKS = 8;
14
+
15
+ /**
16
+ * Single source of truth for how many Line rows the hunk-list section
17
+ * occupies: 1 header row + up to MAX_VISIBLE_HUNKS checkbox rows + 1 trailer
18
+ * row (rendered as "+N more" when truncated, blank otherwise — always
19
+ * present so the row count never depends on which branch fires). Both
20
+ * getPromptHeight and createPromptLines call this SAME function, so they
21
+ * cannot drift apart (Risk 2 — main.ts's render loop reserves viewport
22
+ * space from getPromptHeight *before* the real render happens).
23
+ */
24
+ function hunkListRowCount(hunkState: HunkSelectionState): number {
25
+ return Math.min(hunkState.hunks.length, MAX_VISIBLE_HUNKS) + 2;
26
+ }
27
+
9
28
  /**
10
29
  * PermissionPromptUI - Renders a permission prompt as Line[] fragments.
11
30
  *
@@ -30,11 +49,12 @@ export class PermissionPromptUI {
30
49
  };
31
50
  }
32
51
 
33
- static getPromptHeight(request: PermissionPromptRequest): number {
52
+ static getPromptHeight(request: PermissionPromptRequest, hunkState?: HunkSelectionState): number {
34
53
  const analysis = this.fallbackAnalysis(request);
35
54
  const reasonLines = Math.min(2, Math.max(1, analysis.reasons.length));
36
55
  const extraLines = (analysis.host ? 1 : 0) + (analysis.surface ? 1 : 0) + (analysis.sideEffects && analysis.sideEffects.length > 0 ? 1 : 0);
37
- return 12 + reasonLines + extraLines;
56
+ const hunkLines = hunkState ? hunkListRowCount(hunkState) : 0;
57
+ return 12 + reasonLines + extraLines + hunkLines;
38
58
  }
39
59
 
40
60
  /** Returns the key argument to display for a given tool invocation. */
@@ -64,7 +84,7 @@ export class PermissionPromptUI {
64
84
  * createPromptLines - Renders the permission prompt as an array of Lines.
65
85
  * Injected into the viewport by the render function when a request is pending.
66
86
  */
67
- static createPromptLines(width: number, request: PermissionRequest): Line[] {
87
+ static createPromptLines(width: number, request: PermissionRequest, hunkState?: HunkSelectionState): Line[] {
68
88
  const lines: Line[] = [];
69
89
  const { tool, args, category } = request;
70
90
  const analysis = this.fallbackAnalysis(request);
@@ -153,8 +173,44 @@ export class PermissionPromptUI {
153
173
  // Blank spacer
154
174
  lines.push(UIFactory.stringToLine(' '.repeat(width), width));
155
175
 
176
+ if (hunkState) {
177
+ // Hunk list — see hunkListRowCount() for the row-count contract this
178
+ // block must match exactly (Risk 2: getPromptHeight/createPromptLines
179
+ // parity is what keeps main.ts's render loop from clipping the
180
+ // viewport).
181
+ const { hunks, cursor, selected } = hunkState;
182
+ const headerLine = ` Hunks (${selected.size}/${hunks.length} selected):`;
183
+ lines.push(UIFactory.stringToLine(headerLine.padEnd(width), width, { fg: TEXT, bold: true }));
184
+
185
+ const visible = hunks.slice(0, MAX_VISIBLE_HUNKS);
186
+ const maxPreviewLen = Math.max(6, Math.floor((width - 20) / 2));
187
+ for (let i = 0; i < visible.length; i++) {
188
+ const hunk = visible[i]!;
189
+ const box = selected.has(i) ? '[x]' : '[ ]';
190
+ const findPreview = hunk.find.replace(/\n/g, '⏎').slice(0, maxPreviewLen);
191
+ const replacePreview = hunk.replace.replace(/\n/g, '⏎').slice(0, maxPreviewLen);
192
+ const rowText = ` ${box} ${i + 1}. ${hunk.path} — "${findPreview}" -> "${replacePreview}"`;
193
+ const isCursor = i === cursor;
194
+ lines.push(UIFactory.stringToLine(
195
+ rowText.padEnd(width),
196
+ width,
197
+ {
198
+ fg: selected.has(i) ? DIFF_TONES.add : DIFF_TONES.del,
199
+ bg: isCursor ? UI_TONES.bg.selected : undefined,
200
+ bold: isCursor,
201
+ },
202
+ ));
203
+ }
204
+
205
+ const hiddenCount = hunks.length - visible.length;
206
+ const trailerLine = hiddenCount > 0 ? ` +${hiddenCount} more` : '';
207
+ lines.push(UIFactory.stringToLine(trailerLine.padEnd(width), width, { fg: DIM }));
208
+ }
209
+
156
210
  // Choices row
157
- const choicesLine = ` [Y] Allow once [A] Allow always (session) [N] Deny`;
211
+ const choicesLine = hunkState
212
+ ? ` [j/k] Navigate [Space] Toggle [A] All [Enter] Apply selected [N] Deny`
213
+ : ` [Y] Allow once [A] Allow always (session) [N] Deny`;
158
214
  lines.push(UIFactory.stringToLine(choicesLine.padEnd(width), width, { fg: ACCENT, bold: true }));
159
215
 
160
216
  // Bottom separator
@@ -10,11 +10,22 @@
10
10
  */
11
11
 
12
12
  import { getCompactionEvents } from '@pellux/goodvibes-sdk/platform/core';
13
+ import { getCompactionQualityScore } from './compaction-quality.ts';
13
14
  import type { CompactionEvent } from '@pellux/goodvibes-sdk/platform/core';
15
+ import type { CompactionQualityScore } from './compaction-quality.ts';
14
16
 
15
17
  // ─── formatCompactionEvent ────────────────────────────────────────────────────
16
18
 
17
- function formatCompactionEvent(ev: CompactionEvent, n: number): string {
19
+ /**
20
+ * Exported for direct unit testing (W5.4/B28) — the grade suffix is a pure
21
+ * function of the event and an optional score, independent of whichever
22
+ * runtime lookup (getCompactionQualityScore) supplies that score.
23
+ */
24
+ export function formatCompactionEvent(
25
+ ev: CompactionEvent,
26
+ n: number,
27
+ qualityScore?: CompactionQualityScore | null,
28
+ ): string {
18
29
  const date = new Date(ev.timestamp);
19
30
  const timeStr = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
20
31
  const savings = Math.max(0, ev.tokensBeforeEstimate - ev.tokensAfterEstimate);
@@ -22,11 +33,15 @@ function formatCompactionEvent(ev: CompactionEvent, n: number): string {
22
33
  ? Math.round((savings / ev.tokensBeforeEstimate) * 100)
23
34
  : 0;
24
35
  const trigger = ev.trigger === 'auto' ? 'auto' : 'manual';
36
+ // Honest omission: no score means none was computed for this event (e.g. it
37
+ // pre-dates this feature, or ran through the small-window path, which has
38
+ // no CompactionEvent to key a score by) — never fabricate a grade.
39
+ const qualityStr = qualityScore ? ` quality=${qualityScore.grade} (${qualityScore.score.toFixed(2)})` : '';
25
40
  return (
26
41
  `#${n} ${timeStr} [${trigger}] ` +
27
42
  `${ev.messagesBeforeCompaction}→${ev.messagesAfterCompaction} msgs ` +
28
43
  `~${fmtN(ev.tokensBeforeEstimate)}→~${fmtN(ev.tokensAfterEstimate)} tok ` +
29
- `saved ${savingsPct}%`
44
+ `saved ${savingsPct}%${qualityStr}`
30
45
  );
31
46
  }
32
47
 
@@ -48,7 +63,8 @@ export function buildCompactionHistoryText(): string {
48
63
  ];
49
64
  const ordered = [...events].reverse();
50
65
  for (let i = 0; i < ordered.length; i++) {
51
- lines.push(' ' + formatCompactionEvent(ordered[i], ordered.length - i));
66
+ const ev = ordered[i]!;
67
+ lines.push(' ' + formatCompactionEvent(ev, ordered.length - i, getCompactionQualityScore(ev.timestamp)));
52
68
  }
53
69
  lines.push(' (Restore not available — the SDK does not yet expose a snapshot restore API.)');
54
70
  return lines.join('\n');
@@ -18,8 +18,10 @@
18
18
 
19
19
  import { estimateConversationTokens } from '@pellux/goodvibes-sdk/platform/core';
20
20
  import { computeContextUsage } from '../core/context-usage.ts';
21
+ import { formatQualityScoreLine } from './compaction-quality.ts';
21
22
  import type { CompactionEvent } from '@pellux/goodvibes-sdk/platform/core';
22
23
  import type { ProviderMessage } from '@pellux/goodvibes-sdk/platform/providers';
24
+ import type { CompactionQualityScore } from './compaction-quality.ts';
23
25
 
24
26
  /**
25
27
  * Default compaction totalCeiling from context-compaction DEFAULT_COMPACTION_CONFIG.
@@ -44,6 +46,12 @@ export interface CompactionAfterOptions {
44
46
  readonly event: CompactionEvent;
45
47
  /** Number of session memories that survived compaction. */
46
48
  readonly pinnedMemoryCount: number;
49
+ /**
50
+ * Out-of-band quality-score grade for this run (W5.4/B28). Omitted or null
51
+ * when no score was computed (e.g. the small-window compaction path, which
52
+ * has no CompactionEvent to key a score by) — no line is rendered then.
53
+ */
54
+ readonly qualityScore?: CompactionQualityScore | null | undefined;
47
55
  }
48
56
 
49
57
  /**
@@ -82,7 +90,7 @@ export function buildCompactionPreview(opts: CompactionPreviewOptions): string {
82
90
  * after token counts. The trigger field controls wording.
83
91
  */
84
92
  export function buildCompactionAfterNotice(opts: CompactionAfterOptions): string {
85
- const { event, pinnedMemoryCount } = opts;
93
+ const { event, pinnedMemoryCount, qualityScore } = opts;
86
94
  const {
87
95
  messagesBeforeCompaction,
88
96
  messagesAfterCompaction,
@@ -102,11 +110,14 @@ export function buildCompactionAfterNotice(opts: CompactionAfterOptions): string
102
110
 
103
111
  const triggerStr = trigger === 'auto' ? 'Auto-compact complete' : 'Compact complete';
104
112
 
113
+ const qualityStr = qualityScore ? `\n ${formatQualityScoreLine(qualityScore)}` : '';
114
+
105
115
  return (
106
116
  `[Context] ${triggerStr}: ${messagesBeforeCompaction} → ${messagesAfterCompaction} messages,` +
107
117
  ` ~${fmtN(tokensBeforeEstimate)} → ~${fmtN(tokensAfterEstimate)} tokens` +
108
118
  ` (saved ~${fmtN(savings)}, ${savingsPct}%).` +
109
- (pinStr ? ` ${pinStr.trim()}` : '')
119
+ (pinStr ? ` ${pinStr.trim()}` : '') +
120
+ qualityStr
110
121
  );
111
122
  }
112
123