@pellux/goodvibes-tui 1.0.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 (209) hide show
  1. package/CHANGELOG.md +130 -127
  2. package/README.md +32 -14
  3. package/docs/foundation-artifacts/operator-contract.json +1 -1
  4. package/package.json +2 -2
  5. package/src/cli/ensure-goodvibes-gitignore.ts +32 -0
  6. package/src/cli/entrypoint.ts +2 -0
  7. package/src/core/alert-gating.ts +67 -0
  8. package/src/core/approval-alert.ts +72 -0
  9. package/src/core/budget-breach-notifier.ts +106 -0
  10. package/src/core/conversation-rendering.ts +19 -0
  11. package/src/core/conversation.ts +58 -0
  12. package/src/core/focus-tracker.ts +41 -0
  13. package/src/core/long-task-notifier.ts +33 -6
  14. package/src/core/stream-event-wiring.ts +104 -2
  15. package/src/core/stream-stall-watchdog.ts +41 -17
  16. package/src/core/system-message-router.ts +37 -51
  17. package/src/core/turn-cancellation.ts +20 -0
  18. package/src/core/turn-event-wiring.ts +64 -3
  19. package/src/export/cost-utils.ts +126 -8
  20. package/src/input/command-registry.ts +54 -1
  21. package/src/input/commands/checkpoint-runtime.ts +280 -0
  22. package/src/input/commands/codebase-runtime.ts +192 -0
  23. package/src/input/commands/diff-runtime.ts +61 -30
  24. package/src/input/commands/eval.ts +1 -1
  25. package/src/input/commands/health-runtime.ts +1 -1
  26. package/src/input/commands/image-runtime.ts +112 -0
  27. package/src/input/commands/intelligence-runtime.ts +11 -1
  28. package/src/input/commands/local-auth-runtime.ts +4 -1
  29. package/src/input/commands/local-runtime.ts +9 -3
  30. package/src/input/commands/marketplace-runtime.ts +2 -2
  31. package/src/input/commands/memory.ts +6 -1
  32. package/src/input/commands/operator-panel-runtime.ts +40 -24
  33. package/src/input/commands/planning-runtime.ts +26 -3
  34. package/src/input/commands/platform-sandbox-runtime.ts +1 -6
  35. package/src/input/commands/plugin-runtime.ts +2 -2
  36. package/src/input/commands/policy-dispatch.ts +21 -0
  37. package/src/input/commands/provider-accounts-runtime.ts +1 -1
  38. package/src/input/commands/qrcode-runtime.ts +3 -3
  39. package/src/input/commands/recall-review.ts +35 -0
  40. package/src/input/commands/remote-runtime.ts +3 -3
  41. package/src/input/commands/runtime-services.ts +54 -13
  42. package/src/input/commands/services-runtime.ts +1 -1
  43. package/src/input/commands/session-content.ts +12 -0
  44. package/src/input/commands/session-workflow.ts +19 -1
  45. package/src/input/commands/settings-sync-runtime.ts +1 -1
  46. package/src/input/commands/share-runtime.ts +9 -2
  47. package/src/input/commands/shell-core.ts +15 -7
  48. package/src/input/commands/skills-runtime.ts +2 -8
  49. package/src/input/commands/subscription-runtime.ts +2 -2
  50. package/src/input/commands/test-runtime.ts +277 -0
  51. package/src/input/commands/websearch-runtime.ts +101 -0
  52. package/src/input/commands/work-plan-runtime.ts +36 -10
  53. package/src/input/commands/workstream-runtime.ts +338 -0
  54. package/src/input/commands.ts +12 -0
  55. package/src/input/config-modal-types.ts +161 -0
  56. package/src/input/config-modal.ts +377 -0
  57. package/src/input/feed-context-factory.ts +7 -8
  58. package/src/input/handler-command-route.ts +27 -17
  59. package/src/input/handler-content-actions.ts +22 -8
  60. package/src/input/handler-feed-routes.ts +107 -7
  61. package/src/input/handler-feed.ts +49 -13
  62. package/src/input/handler-interactions.ts +3 -3
  63. package/src/input/handler-modal-routes.ts +65 -0
  64. package/src/input/handler-modal-stack.ts +3 -5
  65. package/src/input/handler-modal-token-routes.ts +16 -49
  66. package/src/input/handler-picker-routes.ts +11 -94
  67. package/src/input/handler-shortcuts.ts +24 -14
  68. package/src/input/handler-types.ts +3 -6
  69. package/src/input/handler-ui-state.ts +10 -22
  70. package/src/input/handler.ts +10 -28
  71. package/src/input/keybindings.ts +22 -8
  72. package/src/input/model-picker-types.ts +24 -6
  73. package/src/input/model-picker.ts +58 -0
  74. package/src/input/panel-integration-actions.ts +9 -170
  75. package/src/input/settings-modal-data.ts +95 -0
  76. package/src/input/settings-modal-mutations.ts +6 -4
  77. package/src/main.ts +41 -44
  78. package/src/panels/agent-inspector-shared.ts +35 -11
  79. package/src/panels/base-panel.ts +22 -1
  80. package/src/panels/builtin/agent.ts +21 -107
  81. package/src/panels/builtin/development.ts +16 -62
  82. package/src/panels/builtin/knowledge.ts +8 -32
  83. package/src/panels/builtin/operations.ts +84 -428
  84. package/src/panels/builtin/session.ts +21 -112
  85. package/src/panels/builtin/shared.ts +9 -43
  86. package/src/panels/builtin-modals.ts +218 -0
  87. package/src/panels/builtin-panels.ts +5 -0
  88. package/src/panels/cost-tracker-panel.ts +63 -14
  89. package/src/panels/diff-panel.ts +123 -60
  90. package/src/panels/eval-registry.ts +60 -0
  91. package/src/panels/fleet-panel.ts +800 -0
  92. package/src/panels/fleet-read-model.ts +477 -0
  93. package/src/panels/fleet-steer.ts +92 -0
  94. package/src/panels/fleet-stop.ts +125 -0
  95. package/src/panels/fleet-tabs.ts +230 -0
  96. package/src/panels/fleet-transcript.ts +356 -0
  97. package/src/panels/git-panel.ts +9 -9
  98. package/src/panels/index.ts +7 -31
  99. package/src/panels/local-auth-panel.ts +10 -0
  100. package/src/panels/modals/hooks-modal.ts +187 -0
  101. package/src/panels/modals/keybindings-modal.ts +151 -0
  102. package/src/panels/modals/knowledge-modal.ts +158 -0
  103. package/src/panels/modals/local-auth-modal.ts +132 -0
  104. package/src/panels/modals/marketplace-modal.ts +218 -0
  105. package/src/panels/modals/memory-modal.ts +180 -0
  106. package/src/panels/modals/modal-surface-helpers.ts +54 -0
  107. package/src/panels/modals/modal-theme.ts +36 -0
  108. package/src/panels/modals/pairing-modal.ts +144 -0
  109. package/src/panels/modals/planning-modal.ts +282 -0
  110. package/src/panels/modals/plugins-modal.ts +174 -0
  111. package/src/panels/modals/policy-modal.ts +256 -0
  112. package/src/panels/modals/provider-health-modal.ts +136 -0
  113. package/src/panels/modals/remote-modal.ts +115 -0
  114. package/src/panels/modals/sandbox-modal.ts +150 -0
  115. package/src/panels/modals/security-modal.ts +217 -0
  116. package/src/panels/modals/services-modal.ts +179 -0
  117. package/src/panels/modals/settings-sync-modal.ts +142 -0
  118. package/src/panels/modals/skills-modal.ts +189 -0
  119. package/src/panels/modals/subscription-modal.ts +172 -0
  120. package/src/panels/modals/work-plan-modal.ts +149 -0
  121. package/src/panels/panel-confirm-overlay.ts +72 -0
  122. package/src/panels/panel-manager.ts +108 -5
  123. package/src/panels/project-planning-answer-actions.ts +4 -2
  124. package/src/panels/scrollable-list-panel.ts +9 -0
  125. package/src/panels/skills-panel.ts +7 -51
  126. package/src/panels/token-budget-panel.ts +5 -3
  127. package/src/panels/types.ts +26 -0
  128. package/src/permissions/hunk-selection.ts +179 -0
  129. package/src/permissions/prompt.ts +60 -4
  130. package/src/renderer/compaction-history-modal.ts +19 -3
  131. package/src/renderer/compaction-preview.ts +13 -2
  132. package/src/renderer/compaction-quality.ts +100 -0
  133. package/src/renderer/config-modal.ts +81 -0
  134. package/src/renderer/conversation-overlays.ts +10 -17
  135. package/src/renderer/fleet-tab-strip.ts +56 -0
  136. package/src/renderer/footer-tips.ts +10 -2
  137. package/src/renderer/git-status.ts +40 -0
  138. package/src/renderer/help-overlay.ts +2 -2
  139. package/src/renderer/model-workspace.ts +44 -1
  140. package/src/renderer/panel-workspace-bar.ts +8 -2
  141. package/src/renderer/shell-surface.ts +8 -1
  142. package/src/renderer/turn-injection.ts +114 -0
  143. package/src/renderer/ui-factory.ts +91 -7
  144. package/src/runtime/bootstrap-command-context.ts +24 -1
  145. package/src/runtime/bootstrap-command-parts.ts +36 -7
  146. package/src/runtime/bootstrap-core.ts +19 -4
  147. package/src/runtime/bootstrap-hook-bridge.ts +5 -0
  148. package/src/runtime/bootstrap-shell.ts +41 -17
  149. package/src/runtime/bootstrap.ts +11 -17
  150. package/src/runtime/code-index-services.ts +112 -0
  151. package/src/runtime/orchestrator-core-services.ts +49 -0
  152. package/src/runtime/process-lifecycle.ts +3 -1
  153. package/src/runtime/services.ts +91 -43
  154. package/src/runtime/ui-services.ts +8 -0
  155. package/src/runtime/workstream-services.ts +195 -0
  156. package/src/shell/blocking-input.ts +22 -1
  157. package/src/shell/ui-openers.ts +129 -17
  158. package/src/utils/format-duration.ts +8 -18
  159. package/src/utils/splash-lines.ts +1 -1
  160. package/src/version.ts +1 -1
  161. package/src/panels/agent-inspector-panel.ts +0 -786
  162. package/src/panels/approval-panel.ts +0 -252
  163. package/src/panels/automation-control-panel.ts +0 -479
  164. package/src/panels/cockpit-panel.ts +0 -481
  165. package/src/panels/cockpit-read-model.ts +0 -233
  166. package/src/panels/communication-panel.ts +0 -256
  167. package/src/panels/control-plane-panel.ts +0 -470
  168. package/src/panels/debug-panel.ts +0 -609
  169. package/src/panels/docs-panel.ts +0 -375
  170. package/src/panels/eval-panel.ts +0 -627
  171. package/src/panels/file-explorer-panel.ts +0 -664
  172. package/src/panels/file-preview-panel.ts +0 -517
  173. package/src/panels/hooks-panel.ts +0 -401
  174. package/src/panels/incident-review-panel.ts +0 -406
  175. package/src/panels/intelligence-panel.ts +0 -383
  176. package/src/panels/knowledge-graph-panel.ts +0 -506
  177. package/src/panels/marketplace-panel.ts +0 -399
  178. package/src/panels/memory-panel.ts +0 -558
  179. package/src/panels/ops-control-panel.ts +0 -329
  180. package/src/panels/ops-strategy-panel.ts +0 -321
  181. package/src/panels/orchestration-panel.ts +0 -465
  182. package/src/panels/panel-list-panel.ts +0 -557
  183. package/src/panels/plan-dashboard-panel.ts +0 -707
  184. package/src/panels/policy-panel.ts +0 -517
  185. package/src/panels/project-planning-panel.ts +0 -721
  186. package/src/panels/provider-health-panel.ts +0 -678
  187. package/src/panels/provider-health-tracker.ts +0 -306
  188. package/src/panels/provider-health-views.ts +0 -560
  189. package/src/panels/qr-panel.ts +0 -280
  190. package/src/panels/remote-panel.ts +0 -534
  191. package/src/panels/routes-panel.ts +0 -241
  192. package/src/panels/sandbox-panel.ts +0 -456
  193. package/src/panels/security-panel.ts +0 -447
  194. package/src/panels/services-panel.ts +0 -329
  195. package/src/panels/session-browser-panel.ts +0 -487
  196. package/src/panels/settings-sync-panel.ts +0 -398
  197. package/src/panels/subscription-panel.ts +0 -342
  198. package/src/panels/symbol-outline-panel.ts +0 -619
  199. package/src/panels/system-messages-panel.ts +0 -364
  200. package/src/panels/tasks-panel.ts +0 -608
  201. package/src/panels/thinking-panel.ts +0 -333
  202. package/src/panels/tool-inspector-panel.ts +0 -537
  203. package/src/panels/work-plan-panel.ts +0 -530
  204. package/src/panels/worktree-panel.ts +0 -360
  205. package/src/panels/wrfc-panel.ts +0 -790
  206. package/src/renderer/agent-detail-modal.ts +0 -465
  207. package/src/renderer/live-tail-modal.ts +0 -156
  208. package/src/renderer/process-modal.ts +0 -656
  209. package/src/renderer/qr-renderer.ts +0 -120
@@ -9,6 +9,8 @@ import type {
9
9
  } from '../command-registry.ts';
10
10
  import { compactSmallWindow, getLastCompactionEvent, SMALL_WINDOW_THRESHOLD } from '@pellux/goodvibes-sdk/platform/core';
11
11
  import type { CompactionContext, CompactionEvent } from '@pellux/goodvibes-sdk/platform/core';
12
+ import { recordCompactionQualityScore, scoreCompactionRun } from '../../renderer/compaction-quality.ts';
13
+ import type { CompactionQualityScore } from '../../renderer/compaction-quality.ts';
12
14
  import type { UiReadModels } from '../../runtime/ui-read-models.ts';
13
15
  import type { ShellPathService } from '@/runtime/index.ts';
14
16
  import type { EcosystemCatalogPathOptions } from '@/runtime/index.ts';
@@ -101,6 +103,21 @@ export function openCommandPanel(
101
103
  showPanel(panelId, pane);
102
104
  }
103
105
 
106
+ /**
107
+ * W6.1 (group-B migration): open a config-modal surface by name via the
108
+ * ctx.openModal seam (ui-openers wires it; the WO-A host replaces the interim
109
+ * "not available yet" implementation with real dispatch). Front-doors for
110
+ * panels that migrated to modals call this instead of openCommandPanel — the
111
+ * modal is the surface's new home. Stubbed in tests by setting ctx.openModal.
112
+ */
113
+ export function openModalCommand(
114
+ context: Pick<CommandContext, 'openModal'>,
115
+ modalName: string,
116
+ ): void {
117
+ const openModal = requireContextValue(context.openModal, 'openModal');
118
+ openModal(modalName);
119
+ }
120
+
104
121
  export function openOnboardingWizard(
105
122
  context: Pick<CommandContext, 'openOnboardingWizard'>,
106
123
  modeOrOptions?: import('../onboarding/onboarding-wizard.ts').OnboardingWizardMode
@@ -238,12 +255,19 @@ export function requireProviderApi(context: CommandContext): ProviderApi {
238
255
  return requireContextValue(context.clients?.providerApi, 'clients.providerApi');
239
256
  }
240
257
 
258
+ /** Result of a manual /compact run: the SDK's CompactionEvent, plus an
259
+ * out-of-band quality score (W5.4/B28) when one could be computed. */
260
+ export interface CompactionRunResult {
261
+ readonly event: CompactionEvent;
262
+ readonly qualityScore: CompactionQualityScore | null;
263
+ }
264
+
241
265
  /**
242
- * Compact the conversation and return the CompactionEvent recorded by the SDK,
243
- * or null if no event was recorded (e.g. compaction was skipped or produced no
244
- * change).
266
+ * Compact the conversation and return the CompactionEvent recorded by the SDK
267
+ * (plus an out-of-band quality score), or null if no event was recorded (e.g.
268
+ * compaction was skipped or produced no change).
245
269
  */
246
- export async function compactConversation(context: CommandContext): Promise<CompactionEvent | null> {
270
+ export async function compactConversation(context: CommandContext): Promise<CompactionRunResult | null> {
247
271
  const conversationManager = context.session.conversationManager;
248
272
  const providerRegistry = context.provider.providerRegistry;
249
273
  // Resolve the live context window for the current model so manual /compact
@@ -263,20 +287,20 @@ export async function compactConversation(context: CommandContext): Promise<Comp
263
287
  }
264
288
 
265
289
  const eventBefore = getLastCompactionEvent();
290
+ const messagesBefore = conversationManager.getMessagesForLLM();
266
291
  const sessionMemories = context.session.sessionMemoryStore?.list() ?? [];
267
292
  const lineageTracker = context.session.sessionLineageTracker;
268
293
  const agentManager = context.ops.agentManager;
269
294
  const planManager = context.ops.planManager;
270
295
  const compactionCtx: CompactionContext = {
271
- messages: conversationManager.getMessagesForLLM(),
296
+ messages: messagesBefore,
272
297
  sessionMemories,
273
- // Mirror buildAutoCompactionContext: pass the running/pending agents, the
274
- // active execution plan, and the session-lineage log so the handoff summary
275
- // and lineage section are populated correctly (not "compaction #0").
276
- agents:
277
- agentManager
278
- ?.exportState()
279
- .filter((agent) => agent.status === 'running' || agent.status === 'pending') ?? [],
298
+ // Mirror buildAutoCompactionContext: pass ALL agent records (not just
299
+ // running/pending) so completed/failed subagent work reaches the
300
+ // "Completed Agent Work" / "Currently Running" compaction sections, plus
301
+ // the active execution plan and the session-lineage log so the handoff
302
+ // summary and lineage section are populated correctly (not "compaction #0").
303
+ agents: agentManager?.exportState() ?? [],
280
304
  // Mirror buildAutoCompactionContext: include the live WRFC chains (wired
281
305
  // through context.session.wrfcController) so the handoff summary's
282
306
  // orchestration section matches the SDK auto-compaction path.
@@ -284,6 +308,11 @@ export async function compactConversation(context: CommandContext): Promise<Comp
284
308
  activePlan: planManager?.getActive(context.session.runtime.sessionId) ?? null,
285
309
  lineageEntries: lineageTracker?.getEntries() ?? [],
286
310
  compactionCount: lineageTracker?.getCompactionCount() ?? 0,
311
+ // Mirror buildAutoCompactionContext: pass the recorded original task so
312
+ // the "Original task" lineage line reflects the session's actual first
313
+ // task instead of falling back to the current message on every manual
314
+ // compaction after the first.
315
+ originalTask: lineageTracker?.getOriginalTask() ?? undefined,
287
316
  contextWindow,
288
317
  trigger: 'manual',
289
318
  extractionModelId: context.session.runtime.model,
@@ -299,7 +328,19 @@ export async function compactConversation(context: CommandContext): Promise<Comp
299
328
  const eventAfter = getLastCompactionEvent();
300
329
  // Return the new event only if it differs from the one recorded before the call.
301
330
  if (eventAfter !== null && eventAfter !== eventBefore) {
302
- return eventAfter;
331
+ // W5.4/B28: score this run out-of-band (see compaction-quality.ts for why
332
+ // this can't just subscribe to the SDK's own CompactionManager pipeline)
333
+ // and keep the score keyed by this event's timestamp for /compact-history.
334
+ const qualityScore = scoreCompactionRun({
335
+ sessionId: context.session.runtime.sessionId,
336
+ contextWindow,
337
+ messagesBefore,
338
+ messagesAfter: conversationManager.getMessagesForLLM(),
339
+ tokensBefore: eventAfter.tokensBeforeEstimate,
340
+ tokensAfter: eventAfter.tokensAfterEstimate,
341
+ });
342
+ recordCompactionQualityScore(eventAfter.timestamp, qualityScore);
343
+ return { event: eventAfter, qualityScore };
303
344
  }
304
345
  return null;
305
346
  }
@@ -16,7 +16,7 @@ export function registerServicesRuntimeCommands(registry: CommandRegistry): void
16
16
  const sub = args[0] ?? 'open';
17
17
  const shellPaths = requireShellPaths(ctx);
18
18
  if (sub === 'open' || sub === 'panel') {
19
- openCommandPanel(ctx, 'services');
19
+ ctx.openModal?.('services-modal'); // W6.1: services panel -> config modal
20
20
  return;
21
21
  }
22
22
  const svcRegistry = requireServiceRegistry(ctx);
@@ -7,6 +7,7 @@ import { exportToMarkdown } from '@pellux/goodvibes-sdk/platform/export';
7
7
  import { TemplateManager, parseTemplateArgs } from '@pellux/goodvibes-sdk/platform/templates';
8
8
  import { requireSessionManager, requireSessionMemoryStore, requireShellPaths } from './runtime-services.ts';
9
9
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
10
+ import { sessionCommand } from './session.ts';
10
11
 
11
12
  export function registerSessionContentCommands(registry: CommandRegistry): void {
12
13
  registry.register({
@@ -238,7 +239,18 @@ export function registerSessionContentCommands(registry: CommandRegistry): void
238
239
  registry.register({
239
240
  name: 'sessions',
240
241
  description: 'List saved sessions',
242
+ usage: '[resume <id|name>]',
243
+ argsHint: '[resume <id|name>]',
241
244
  async handler(_args, ctx) {
245
+ // Splash's "resume last session" hint and users' muscle memory both say
246
+ // `/sessions resume <id>` (plural, matching this command's own name) —
247
+ // but subcommands like `resume` are only implemented on the singular
248
+ // `/session` command. Forward instead of silently listing and dropping
249
+ // the subcommand+id on the floor.
250
+ if (_args.length > 0) {
251
+ await sessionCommand.handler(_args, ctx);
252
+ return;
253
+ }
242
254
  const sessionManager = requireSessionManager(ctx);
243
255
  const sessions = sessionManager.list();
244
256
  if (ctx.openSelection) {
@@ -79,11 +79,23 @@ function buildTranscriptReviewLines(
79
79
  ];
80
80
  }
81
81
 
82
- function reopenPanelsFromReturnContext(ctx: CommandContext, summary: SessionReturnContextSummary | undefined): string[] {
82
+ // Exported (test-only concern) so the MIGRATE-TO-MODAL redirect-skip honesty
83
+ // (W6 review, finding 3: saved-layout restore with 'sessions' must not lie)
84
+ // can be unit-tested directly instead of through a full /resume harness.
85
+ export function reopenPanelsFromReturnContext(ctx: CommandContext, summary: SessionReturnContextSummary | undefined): string[] {
83
86
  if (!summary?.openPanels || summary.openPanels.length === 0) return [];
84
87
  const panelManager = requirePanelManager(ctx);
85
88
  const reopened: string[] = [];
89
+ const movedToModal: string[] = [];
86
90
  for (const panelId of summary.openPanels.slice(0, 4)) {
91
+ // W6.1 (the purge): a MIGRATE-TO-MODAL id has no panel to restore — a modal
92
+ // is not part of the saved panel layout. Skip it (don't pop a modal
93
+ // mid-resume) and note it once, rather than firing openModal + revealing an
94
+ // empty workspace during resume.
95
+ if (panelManager.getModalRedirect(panelId) !== undefined) {
96
+ movedToModal.push(panelId);
97
+ continue;
98
+ }
87
99
  try {
88
100
  panelManager.open(panelId);
89
101
  reopened.push(panelId);
@@ -92,6 +104,9 @@ function reopenPanelsFromReturnContext(ctx: CommandContext, summary: SessionRetu
92
104
  }
93
105
  }
94
106
  if (reopened.length > 0) panelManager.show();
107
+ if (movedToModal.length > 0) {
108
+ ctx.print(`Note: ${movedToModal.join(', ')} moved to a modal — reopen via its command instead of as a panel.`);
109
+ }
95
110
  return reopened;
96
111
  }
97
112
 
@@ -260,6 +275,9 @@ export async function handleSessionWorkflowCommand(args: string[], ctx: CommandC
260
275
  });
261
276
  },
262
277
  });
278
+ // Hydrate the footer's token counters from the resumed (+ journal-replayed)
279
+ // history now, before ctx.renderRequest() below (W0.9).
280
+ ctx.session.hydrateSessionUsage?.();
263
281
 
264
282
  if (meta.model) {
265
283
  try {
@@ -33,7 +33,7 @@ export function registerSettingsSyncRuntimeCommands(registry: CommandRegistry):
33
33
  const controlPlaneConfigDir = ctx.platform.configManager.getControlPlaneConfigDir();
34
34
  const sub = (args[0] ?? 'review').toLowerCase();
35
35
  if (sub === 'panel' || sub === 'open') {
36
- openCommandPanel(ctx, 'settings-sync');
36
+ ctx.openModal?.('settings-sync-modal'); // W6.1: settings-sync panel -> config modal
37
37
  return;
38
38
  }
39
39
  if (sub === 'show') {
@@ -10,7 +10,7 @@ import {
10
10
  import { logger } from '@pellux/goodvibes-sdk/platform/utils';
11
11
  import { requireShellPaths } from './runtime-services.ts';
12
12
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
13
- import { calcSessionCost } from '../../export/cost-utils.ts';
13
+ import { calcSessionCost, isModelPriced } from '../../export/cost-utils.ts';
14
14
  import {
15
15
  GistUploadTarget,
16
16
  NO_TOKEN_GUIDANCE,
@@ -113,6 +113,13 @@ export function registerShareRuntimeCommands(registry: CommandRegistry): void {
113
113
  const sessionCostUsd = calcSessionCost(
114
114
  totalInput, totalOutput, totalCacheRead, totalCacheWrite, sessionModel,
115
115
  );
116
+ // The exported document has no field for "this cost is a placeholder" —
117
+ // omit `cost` entirely rather than embed a misleading 0 when the model
118
+ // never resolved to a real price (WO-315).
119
+ const costIsUnpriced = !isModelPriced(sessionModel);
120
+ if (costIsUnpriced) {
121
+ ctx.print(`Note: cost omitted from export — no pricing data for model "${sessionModel}".`);
122
+ }
116
123
 
117
124
  const metadata = {
118
125
  model: sessionModel,
@@ -120,7 +127,7 @@ export function registerShareRuntimeCommands(registry: CommandRegistry): void {
120
127
  sessionId: ctx.session.runtime.sessionId,
121
128
  title: ctx.session.conversationManager.title || undefined,
122
129
  };
123
- const options = { redact, cost: sessionCostUsd };
130
+ const options = { redact, cost: costIsUnpriced ? undefined : sessionCostUsd };
124
131
 
125
132
  let outputContent: string;
126
133
  try {
@@ -153,6 +153,8 @@ export function registerShellCoreCommands(registry: CommandRegistry): void {
153
153
  { id: '/template save', label: '/template save <name>', detail: 'Save prompt as template', category: 'Templates' },
154
154
  { id: '/template use', label: '/template use <name>', detail: 'Execute template', category: 'Templates' },
155
155
  { id: '/tools', label: '/tools', detail: 'List available tools', category: 'Tools & System' },
156
+ { id: '/search', label: '/search <query> [--limit <n>]', detail: 'Search the web and show ranked results', category: 'Tools & System' },
157
+ { id: '/imagine', label: '/imagine <prompt>', detail: 'Generate an image from a prompt', category: 'Tools & System' },
156
158
  { id: '/paste', label: '/paste', detail: 'Insert clipboard text or image into the prompt', category: 'Tools & System' },
157
159
  { id: '/shortcuts', label: '/shortcuts', detail: 'View keyboard shortcuts reference', category: 'Tools & System' },
158
160
  { id: '/commands', label: '/commands', detail: 'Browse all commands in a scrollable list', category: 'Tools & System' },
@@ -207,18 +209,24 @@ export function registerShellCoreCommands(registry: CommandRegistry): void {
207
209
  description: 'Summarize conversation to free context window',
208
210
  async handler(_args, ctx) {
209
211
  const messages = ctx.session.conversationManager.getMessagesForLLM();
210
- // contextWindow is not on CommandContext; preview shows message/token counts
211
- // without the capacity-% clause (still honest; no fabricated value).
212
- const contextWindow = 0;
212
+ // W5.4 (wo803): contextWindow IS reachable from CommandContext the same
213
+ // call compactConversation() (runtime-services.ts) already makes to scale
214
+ // its own behaviour by window size. Previously hardcoded to 0 here, which
215
+ // silently suppressed buildCompactionPreview()'s capacity-% clause even
216
+ // though the builder has always supported it.
217
+ const contextWindow = ctx.provider.providerRegistry.getContextWindowForModel(
218
+ ctx.provider.providerRegistry.getCurrentModel(),
219
+ );
213
220
  const memStore = ctx.session.sessionMemoryStore;
214
221
  const pinnedMemoryCount = memStore ? memStore.list().length : 0;
215
222
  // Pre-compact preview: honest estimate, clearly labelled.
216
223
  const preview = buildCompactionPreview({ messages, contextWindow, pinnedMemoryCount, trigger: 'manual' });
217
224
  ctx.print(preview);
218
- const event = await compactConversation(ctx);
219
- if (event) {
220
- // Post-compact notice: uses real CompactionEvent figures.
221
- ctx.print(buildCompactionAfterNotice({ event, pinnedMemoryCount }));
225
+ const result = await compactConversation(ctx);
226
+ if (result) {
227
+ // Post-compact notice: uses real CompactionEvent figures, plus an
228
+ // out-of-band quality-score grade line when one was computed (W5.4/B28).
229
+ ctx.print(buildCompactionAfterNotice({ event: result.event, pinnedMemoryCount, qualityScore: result.qualityScore }));
222
230
  } else {
223
231
  ctx.print('[Context] Compact complete.');
224
232
  }
@@ -11,7 +11,7 @@ import {
11
11
  updateInstalledEcosystemEntry,
12
12
  upsertEcosystemCatalogEntry,
13
13
  } from '@/runtime/index.ts';
14
- import { requireEcosystemCatalogPaths, requirePanelManager, requireShellPaths } from './runtime-services.ts';
14
+ import { openModalCommand, requireEcosystemCatalogPaths, requireShellPaths } from './runtime-services.ts';
15
15
 
16
16
  export function registerSkillsRuntimeCommands(registry: CommandRegistry): void {
17
17
  registry.register({
@@ -22,13 +22,7 @@ export function registerSkillsRuntimeCommands(registry: CommandRegistry): void {
22
22
  async handler(args, ctx) {
23
23
  const sub = args[0] ?? 'open';
24
24
  if (sub === 'open' || sub === 'panel') {
25
- if (ctx.showPanel) ctx.showPanel('skills');
26
- else {
27
- const panelManager = requirePanelManager(ctx);
28
- panelManager.open('skills');
29
- panelManager.show();
30
- ctx.renderRequest();
31
- }
25
+ openModalCommand(ctx, 'skills-modal');
32
26
  return;
33
27
  }
34
28
  const skills = await discoverSkills(requireShellPaths(ctx));
@@ -79,8 +79,8 @@ export function registerSubscriptionRuntimeCommands(registry: CommandRegistry):
79
79
  usage: '[review|list|providers|inspect <provider>|login <provider> start [--no-browser] [--manual]|finish <code-or-url>|logout <provider>|bundle export <path>|bundle inspect <path>]',
80
80
  async handler(args, ctx) {
81
81
  const shellPaths = requireShellPaths(ctx);
82
- if (args.length === 0 && ctx.openSubscriptionPanel) {
83
- ctx.openSubscriptionPanel();
82
+ if (args.length === 0 && ctx.openModal) {
83
+ ctx.openModal('subscription-modal'); // W6.1: subscription panel -> config modal
84
84
  return;
85
85
  }
86
86
  const sub = (args[0] ?? 'review').toLowerCase();
@@ -0,0 +1,277 @@
1
+ import { loadPackageScripts, getSkippedGateReason } from '@pellux/goodvibes-sdk/platform/agents';
2
+ import type { ToolCall } from '@pellux/goodvibes-sdk/platform/types';
3
+ import type { CommandContext, CommandRegistry } from '../command-registry.ts';
4
+ import { requireShellPaths } from './runtime-services.ts';
5
+
6
+ /**
7
+ * Timeout for a /test run, in milliseconds. Deliberately much longer than
8
+ * WRFC's WRFC_GATE_TIMEOUT_MS (120s, tuned for CI gates) — this repo's own
9
+ * suite is 600+ test files (scripts/run-tests.ts), and /test is an
10
+ * interactive command with no other caller waiting on it.
11
+ *
12
+ * Exported (rather than a private const) so tests can pass a shorter override
13
+ * straight through to runTestCommand's optional third parameter instead of
14
+ * waiting out the real timeout.
15
+ */
16
+ export const TEST_RUN_TIMEOUT_MS = 300_000;
17
+
18
+ /** How often buffered stdout/stderr lines are flushed to the transcript during
19
+ * a run. Batching avoids hammering the transcript.append_one perf budget
20
+ * (6ms, scripts/perf-baseline.json) with one ctx.print() call per output line. */
21
+ const STREAM_FLUSH_INTERVAL_MS = 250;
22
+
23
+ /** Cap on individually-listed failing test file names before truncating. */
24
+ const MAX_FAILING_NAMES_SHOWN = 20;
25
+
26
+ /** Lines of raw output shown when structured results could not be parsed. */
27
+ const RAW_TAIL_LINES = 30;
28
+
29
+ const FAIL_LINE_RE = /^==> (.+?)\s+\[FAIL\]$/gm;
30
+ const SUMMARY_LINE_RE = /^Test files: (\d+), passed: (\d+), failed: (\d+)$/m;
31
+ // Plain `bun test` output: " N pass" / " N fail" counters (tests, not files),
32
+ // "Ran N tests across M files.", and "(fail) suite > name" per failing test.
33
+ const BUN_PASS_RE = /^\s*(\d+) pass\s*$/m;
34
+ const BUN_FAIL_RE = /^\s*(\d+) fail\s*$/m;
35
+ const BUN_FILES_RE = /across (\d+) files?\./;
36
+ const BUN_FAIL_TEST_RE = /^\(fail\) (.+)$/gm;
37
+
38
+ interface ParsedTestResults {
39
+ totalFiles: number;
40
+ passed: number;
41
+ failed: number;
42
+ failingFiles: string[];
43
+ /** Whether passed/failed count files (goodvibes runner) or tests (bun test). */
44
+ unit: 'files' | 'tests';
45
+ }
46
+
47
+ /**
48
+ * Parse this repo's own scripts/run-tests.ts output shape:
49
+ * per-file: `==> path/to/file.test.ts [FAIL]` (only present on failure)
50
+ * summary: `Test files: N, passed: P, failed: F`
51
+ * Returns null when the summary line isn't present at all — the fallback
52
+ * path (raw tail, no fabricated counts) covers a different project's
53
+ * jest/vitest/pytest output, or any run that bypassed run-tests.ts.
54
+ */
55
+ export function parseTestOutput(output: string): ParsedTestResults | null {
56
+ const summaryMatch = SUMMARY_LINE_RE.exec(output);
57
+ if (summaryMatch) {
58
+ const failingFiles: string[] = [];
59
+ FAIL_LINE_RE.lastIndex = 0;
60
+ let match: RegExpExecArray | null;
61
+ while ((match = FAIL_LINE_RE.exec(output)) !== null) {
62
+ failingFiles.push(match[1]!);
63
+ }
64
+ return {
65
+ totalFiles: Number(summaryMatch[1]),
66
+ passed: Number(summaryMatch[2]),
67
+ failed: Number(summaryMatch[3]),
68
+ failingFiles,
69
+ unit: 'files',
70
+ };
71
+ }
72
+ // Plain `bun test`: counts are per-test, failing entries are test names.
73
+ const bunPass = BUN_PASS_RE.exec(output);
74
+ const bunFail = BUN_FAIL_RE.exec(output);
75
+ if (bunPass && bunFail) {
76
+ const failingTests: string[] = [];
77
+ BUN_FAIL_TEST_RE.lastIndex = 0;
78
+ let match: RegExpExecArray | null;
79
+ while ((match = BUN_FAIL_TEST_RE.exec(output)) !== null) {
80
+ failingTests.push(match[1]!);
81
+ }
82
+ return {
83
+ totalFiles: Number(BUN_FILES_RE.exec(output)?.[1] ?? 0),
84
+ passed: Number(bunPass[1]),
85
+ failed: Number(bunFail[1]),
86
+ failingFiles: failingTests,
87
+ unit: 'tests',
88
+ };
89
+ }
90
+ return null;
91
+ }
92
+
93
+ /**
94
+ * Single-quote shell-escape a value for safe interpolation into a
95
+ * `/bin/sh -c` string: wrap in single quotes, turning any embedded single
96
+ * quote into `'\''` (close quote, escaped literal quote, reopen quote).
97
+ * Mirrors the injection-risk class already present in the SDK's
98
+ * wrfc-gates.ts (executeGateCommand) and this repo's git-runtime.ts /
99
+ * diff-runtime.ts, which build shell command strings the same way — a
100
+ * pattern arg is never string-concatenated into the command raw.
101
+ */
102
+ export function shQuote(value: string): string {
103
+ return `'${value.replace(/'/g, `'\\''`)}'`;
104
+ }
105
+
106
+ /** Reads a ReadableStream<Uint8Array> incrementally, decoding and forwarding
107
+ * each chunk of text as it arrives (display-only accumulation happens in the
108
+ * caller; this just pumps the stream to completion). */
109
+ async function pumpStream(
110
+ stream: ReadableStream<Uint8Array> | undefined,
111
+ onChunk: (text: string) => void,
112
+ ): Promise<void> {
113
+ if (!stream) return;
114
+ const decoder = new TextDecoder();
115
+ for await (const chunk of stream) {
116
+ onChunk(decoder.decode(chunk, { stream: true }));
117
+ }
118
+ }
119
+
120
+ export function registerTestRuntimeCommands(registry: CommandRegistry): void {
121
+ registry.register({
122
+ name: 'test',
123
+ aliases: [],
124
+ description: 'Run the project test script and show pass/fail results',
125
+ usage: '[pattern]',
126
+ argsHint: '[pattern]',
127
+ async handler(args, ctx) {
128
+ await runTestCommand(args, ctx);
129
+ },
130
+ });
131
+ }
132
+
133
+ /**
134
+ * Core /test implementation, factored out of the registered handler so tests
135
+ * can call it directly with a shortened `timeoutMs` override (the timeout
136
+ * path would otherwise take TEST_RUN_TIMEOUT_MS to exercise).
137
+ */
138
+ export async function runTestCommand(
139
+ args: string[],
140
+ ctx: CommandContext,
141
+ timeoutMs: number = TEST_RUN_TIMEOUT_MS,
142
+ ): Promise<void> {
143
+ const cwd = requireShellPaths(ctx).workingDirectory;
144
+ const pkgScripts = await loadPackageScripts(cwd);
145
+ const skip = getSkippedGateReason('test', cwd, pkgScripts);
146
+ if (skip) {
147
+ ctx.print(skip);
148
+ return;
149
+ }
150
+
151
+ const pattern = args.length > 0 ? args.join(' ') : undefined;
152
+ const command = args.length > 0
153
+ ? `${pkgScripts.test} ${args.map(shQuote).join(' ')}`
154
+ : pkgScripts.test!;
155
+ const toolCall: ToolCall = { id: 'test-run', name: 'test', arguments: pattern ? { pattern } : {} };
156
+
157
+ ctx.print(`Running: ${command}`);
158
+
159
+ const startedAt = Date.now();
160
+ const proc = Bun.spawn(['/bin/sh', '-c', command], { cwd, stdout: 'pipe', stderr: 'pipe' });
161
+
162
+ let timedOut = false;
163
+ const timer = setTimeout(() => {
164
+ timedOut = true;
165
+ proc.kill();
166
+ }, timeoutMs);
167
+ timer.unref?.();
168
+
169
+ let combinedOutput = '';
170
+ let pendingDisplay = '';
171
+ const appendChunk = (text: string): void => {
172
+ combinedOutput += text;
173
+ pendingDisplay += text;
174
+ };
175
+ const flushDisplay = (force = false): void => {
176
+ if (!pendingDisplay) return;
177
+ if (force) {
178
+ ctx.print(pendingDisplay);
179
+ pendingDisplay = '';
180
+ return;
181
+ }
182
+ const lastNewline = pendingDisplay.lastIndexOf('\n');
183
+ if (lastNewline === -1) return; // no complete line buffered yet
184
+ const toPrint = pendingDisplay.slice(0, lastNewline);
185
+ pendingDisplay = pendingDisplay.slice(lastNewline + 1);
186
+ if (toPrint) ctx.print(toPrint);
187
+ };
188
+ const flushTimer = setInterval(() => flushDisplay(false), STREAM_FLUSH_INTERVAL_MS);
189
+
190
+ let exitCode: number;
191
+ try {
192
+ [exitCode] = await Promise.all([
193
+ proc.exited,
194
+ pumpStream(proc.stdout, appendChunk),
195
+ pumpStream(proc.stderr, appendChunk),
196
+ ]);
197
+ } finally {
198
+ clearTimeout(timer);
199
+ clearInterval(flushTimer);
200
+ }
201
+
202
+ const durationMs = Date.now() - startedAt;
203
+
204
+ if (timedOut) {
205
+ flushDisplay(true);
206
+ const timeoutSeconds = Math.round(timeoutMs / 1000);
207
+ ctx.print(`Test run timed out after ${timeoutSeconds}s and was killed.`);
208
+ ctx.session.conversationManager.logToolResultBlock(
209
+ toolCall,
210
+ 'error',
211
+ 'timed out',
212
+ durationMs,
213
+ `timed out after ${timeoutSeconds}s`,
214
+ );
215
+ // Durable end state: logToolResultBlock (above) renders straight into the
216
+ // display-only history buffer and never touches the real message list, so
217
+ // it does not survive the next full rebuildHistory() (which rebuilds
218
+ // strictly from conversationManager.getMessageSnapshot()) — the very next
219
+ // dirty render silently wipes it back to how the transcript looked before
220
+ // /test ran. addSystemMessage persists the same content as a real message
221
+ // (same durable pattern as /rewind's confirm notice in
222
+ // checkpoint-runtime.ts) so the timeout outcome survives like any other
223
+ // command's output. The streamed output during the run is allowed to stay
224
+ // transient — only this final state needs to persist.
225
+ ctx.session.conversationManager.addSystemMessage(
226
+ `[Test] Timed out after ${timeoutSeconds}s and was killed.`,
227
+ );
228
+ ctx.renderRequest();
229
+ return;
230
+ }
231
+
232
+ flushDisplay(true);
233
+
234
+ const ok = exitCode === 0;
235
+ const parsed = parseTestOutput(combinedOutput);
236
+
237
+ if (parsed) {
238
+ const summary = parsed.unit === 'files'
239
+ ? `${parsed.passed}/${parsed.totalFiles} files passed`
240
+ : `${parsed.passed} passed, ${parsed.failed} failed${parsed.totalFiles ? ` across ${parsed.totalFiles} file${parsed.totalFiles === 1 ? '' : 's'}` : ''}`;
241
+ const failedNoun = parsed.unit === 'files' ? 'file' : 'test';
242
+ const errorMsg = ok ? undefined : `${parsed.failed} ${failedNoun}${parsed.failed === 1 ? '' : 's'} failed`;
243
+ ctx.session.conversationManager.logToolResultBlock(toolCall, ok ? 'done' : 'error', summary, durationMs, errorMsg);
244
+ const durableLines = [`[Test] ${summary}${errorMsg ? ` — ${errorMsg}` : ''}`];
245
+ if (parsed.failingFiles.length > 0) {
246
+ const shown = parsed.failingFiles.slice(0, MAX_FAILING_NAMES_SHOWN);
247
+ const failListHeader = parsed.unit === 'files' ? 'Failing test files:' : 'Failing tests:';
248
+ const lines = [failListHeader, ...shown.map((f) => ` - ${f}`)];
249
+ const remaining = parsed.failingFiles.length - shown.length;
250
+ if (remaining > 0) lines.push(` ...and ${remaining} more`);
251
+ ctx.print(lines.join('\n'));
252
+ durableLines.push(...lines);
253
+ }
254
+ // See the timeout branch's comment above: logToolResultBlock's tool-styled
255
+ // render is display-only and does not survive the next full history
256
+ // rebuild. addSystemMessage persists the pass/fail summary AND the
257
+ // failing-file detail as a real message so both remain in the transcript
258
+ // after the command completes, not just during the ~1s before the next
259
+ // render wipes the display-only copy.
260
+ ctx.session.conversationManager.addSystemMessage(durableLines.join('\n'));
261
+ } else {
262
+ ctx.session.conversationManager.logToolResultBlock(
263
+ toolCall,
264
+ ok ? 'done' : 'error',
265
+ `exit code ${exitCode}`,
266
+ durationMs,
267
+ ok ? undefined : `exit code ${exitCode}`,
268
+ );
269
+ const tail = combinedOutput.split('\n').slice(-RAW_TAIL_LINES).join('\n');
270
+ ctx.print(`(could not parse structured test results; showing raw tail)\n${tail}`);
271
+ ctx.session.conversationManager.addSystemMessage(
272
+ `[Test] exit code ${exitCode} (could not parse structured test results)`,
273
+ );
274
+ }
275
+
276
+ ctx.renderRequest();
277
+ }