@pellux/goodvibes-tui 0.22.0 → 0.24.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 (79) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +17 -8
  3. package/package.json +1 -1
  4. package/src/cli/management-commands.ts +1 -1
  5. package/src/cli/management-utils.ts +352 -0
  6. package/src/cli/management.ts +116 -344
  7. package/src/cli/surface-command.ts +1 -1
  8. package/src/core/context-auto-compact.ts +43 -10
  9. package/src/core/conversation-rendering.ts +5 -2
  10. package/src/core/conversation-types.ts +24 -0
  11. package/src/core/conversation.ts +7 -12
  12. package/src/core/long-task-notifier.ts +145 -0
  13. package/src/core/session-recovery.ts +147 -0
  14. package/src/core/stream-event-wiring.ts +199 -7
  15. package/src/core/transcript-journal.ts +339 -0
  16. package/src/core/turn-event-wiring.ts +67 -4
  17. package/src/input/commands/channel-runtime.ts +139 -0
  18. package/src/input/commands/control-room-runtime.ts +0 -2
  19. package/src/input/commands/diff-runtime.ts +1 -1
  20. package/src/input/commands/eval.ts +1 -1
  21. package/src/input/commands/health-runtime.ts +23 -4
  22. package/src/input/commands/knowledge.ts +1 -1
  23. package/src/input/commands/local-runtime.ts +1 -2
  24. package/src/input/commands/memory-product-runtime.ts +2 -2
  25. package/src/input/commands/memory.ts +1 -1
  26. package/src/input/commands/onboarding-runtime.ts +0 -1
  27. package/src/input/commands/policy.ts +1 -1
  28. package/src/input/commands/profile-sync-runtime.ts +4 -3
  29. package/src/input/commands/provider.ts +1 -1
  30. package/src/input/commands/qrcode-runtime.ts +0 -1
  31. package/src/input/commands/runtime-services.ts +30 -1
  32. package/src/input/commands/session-content.ts +2 -2
  33. package/src/input/commands/session-workflow.ts +32 -2
  34. package/src/input/commands/session.ts +1 -1
  35. package/src/input/commands/settings-sync-runtime.ts +9 -9
  36. package/src/input/commands/share-runtime.ts +1 -1
  37. package/src/input/commands/shell-core.ts +56 -6
  38. package/src/input/commands/work-plan-runtime.ts +8 -8
  39. package/src/input/commands.ts +2 -0
  40. package/src/input/feed-context-factory.ts +6 -0
  41. package/src/input/handler-feed-routes.ts +19 -1
  42. package/src/input/handler-feed.ts +11 -0
  43. package/src/input/handler-prompt-buffer.ts +28 -0
  44. package/src/input/handler-shortcuts.ts +88 -2
  45. package/src/input/handler-ui-state.ts +2 -2
  46. package/src/input/handler.ts +39 -3
  47. package/src/input/keybindings.ts +33 -3
  48. package/src/input/kill-ring.ts +134 -0
  49. package/src/input/model-picker.ts +18 -1
  50. package/src/input/search.ts +18 -6
  51. package/src/input/settings-modal-activation.ts +134 -0
  52. package/src/input/settings-modal-adjustment.ts +124 -0
  53. package/src/input/settings-modal-data.ts +53 -0
  54. package/src/input/settings-modal.ts +48 -145
  55. package/src/main.ts +50 -50
  56. package/src/panels/base-panel.ts +2 -1
  57. package/src/panels/provider-health-domains.ts +3 -3
  58. package/src/panels/provider-health-panel.ts +13 -9
  59. package/src/panels/provider-health-tracker.ts +7 -4
  60. package/src/panels/settings-sync-panel.ts +3 -3
  61. package/src/panels/work-plan-panel.ts +2 -2
  62. package/src/renderer/compaction-history-modal.ts +55 -0
  63. package/src/renderer/compaction-preview.ts +146 -0
  64. package/src/renderer/diff-view.ts +2 -2
  65. package/src/renderer/help-overlay.ts +1 -0
  66. package/src/renderer/model-picker-overlay.ts +23 -11
  67. package/src/renderer/progress.ts +3 -3
  68. package/src/renderer/search-overlay.ts +8 -5
  69. package/src/renderer/settings-modal-helpers.ts +2 -2
  70. package/src/renderer/settings-modal.ts +1 -1
  71. package/src/renderer/ui-factory.ts +11 -0
  72. package/src/runtime/bootstrap-core.ts +92 -0
  73. package/src/runtime/bootstrap-hook-bridge.ts +18 -0
  74. package/src/runtime/bootstrap-shell.ts +1 -0
  75. package/src/shell/blocking-input.ts +32 -0
  76. package/src/shell/recovery-input-helpers.ts +71 -0
  77. package/src/utils/browser.ts +29 -0
  78. package/src/utils/terminal-width.ts +10 -3
  79. package/src/version.ts +1 -1
@@ -1,5 +1,6 @@
1
1
  import { ServiceRegistry } from '@pellux/goodvibes-sdk/platform/config';
2
2
  import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
3
+ import { probeTermCaps } from '../../renderer/term-caps.ts';
3
4
  import { evaluateSessionMaintenance, formatSessionMaintenanceLines } from '@/runtime/index.ts';
4
5
  import { estimateConversationTokens } from '@pellux/goodvibes-sdk/platform/core';
5
6
  import type { CommandRegistry } from '../command-registry.ts';
@@ -39,7 +40,7 @@ export function registerHealthRuntimeCommands(registry: CommandRegistry): void {
39
40
  name: 'health',
40
41
  aliases: ['doctor'],
41
42
  description: 'Health workspace for startup posture, service readiness, sandbox posture, and provider health',
42
- usage: '[open|review|setup|services|sandbox|provider|accounts|auth|settings|intelligence|remote|mcp|continuity|worktrees|maintenance|repair [domain]]',
43
+ usage: '[open|review|setup|services|sandbox|provider|accounts|auth|settings|intelligence|remote|mcp|continuity|worktrees|maintenance|term|repair [domain]]',
43
44
  async handler(args, ctx) {
44
45
  const sub = (args[0] ?? 'review').toLowerCase();
45
46
  const readModels = requireReadModels(ctx);
@@ -127,8 +128,8 @@ export function registerHealthRuntimeCommands(registry: CommandRegistry): void {
127
128
  ` recent failures: ${settings.recentFailureCount}`,
128
129
  ` staged bundle: ${settings.hasStagedManagedBundle ? 'present' : 'none'}`,
129
130
  ...(issues.length > 0 ? issues.map((issue) => ` issue: ${issue}`) : [' no active settings-control issues detected']),
130
- ' next: /settingssync panel',
131
- ' next: /settingssync show <key>',
131
+ ' next: /settings-sync panel',
132
+ ' next: /settings-sync show <key>',
132
133
  ' next: /managed staged',
133
134
  ].join('\n'));
134
135
  return;
@@ -282,6 +283,23 @@ export function registerHealthRuntimeCommands(registry: CommandRegistry): void {
282
283
  return;
283
284
  }
284
285
 
286
+ if (sub === 'term') {
287
+ const caps = probeTermCaps(process.stdout as NodeJS.WriteStream);
288
+ const issues: string[] = [];
289
+ if (caps.capability === 'none') issues.push('terminal reports no color support — UI rendering will be degraded (no ANSI colors)');
290
+ if (caps.capability === 'basic16') issues.push('terminal limited to 16 ANSI colors — gradient and true-color UI elements will be approximated');
291
+ if (!caps.syncedOutput) issues.push('DEC Synchronized Output (mode 2026) is disabled — screen-tearing may be visible on slow connections');
292
+ ctx.print([
293
+ 'Health Review: Terminal Capabilities',
294
+ ` color capability: ${caps.capability}`,
295
+ ` synced output (mode 2026): ${caps.syncedOutput ? 'enabled' : 'disabled'}`,
296
+ ` NO_COLOR env: ${process.env['NO_COLOR'] !== undefined && process.env['NO_COLOR'] !== '' ? 'set (forces none)' : 'unset'}`,
297
+ ` TERM env: ${process.env['TERM'] ?? '(unset)'}`,
298
+ ...(issues.length > 0 ? issues.map((issue) => ` issue: ${issue}`) : [' no terminal capability issues detected']),
299
+ ].join('\n'));
300
+ return;
301
+ }
302
+
285
303
  if (sub === 'repair') {
286
304
  const domain = (args[1] ?? 'review').toLowerCase();
287
305
  const lines = ['Health Repair'];
@@ -290,7 +308,7 @@ export function registerHealthRuntimeCommands(registry: CommandRegistry): void {
290
308
  lines.push(' domain: settings');
291
309
  lines.push(...(
292
310
  settings.conflicts.length > 0
293
- ? [' /settingssync panel', ' /settingssync show <key>', ' /managed staged']
311
+ ? [' /settings-sync panel', ' /settings-sync show <key>', ' /managed staged']
294
312
  : [' no active settings repair actions suggested']
295
313
  ));
296
314
  lines.push(' verify: /health settings');
@@ -426,6 +444,7 @@ export function registerHealthRuntimeCommands(registry: CommandRegistry): void {
426
444
  ' /health remote',
427
445
  ' /health maintenance',
428
446
  ' /health worktrees',
447
+ ' /health term',
429
448
  ' /health repair <domain>',
430
449
  ' /setup onboarding',
431
450
  ].join('\n'));
@@ -132,7 +132,7 @@ function renderKnowledgeAskResult(result: KnowledgeAskResult): string {
132
132
  export const knowledgeCommand: SlashCommand = {
133
133
  name: 'knowledge',
134
134
  aliases: ['know'],
135
- description: 'Structured knowledge graph: ingest URLs/bookmarks, inspect issues, and build compact prompt packets.',
135
+ description: 'Structured knowledge graph: ingest URLs/bookmarks, inspect issues, and build compact prompt packets',
136
136
  usage: '<subcommand> [args]',
137
137
  argsHint: 'status|ask|ingest-url|import-bookmarks|import-urls|list|search|get|queue|review-issue|candidates|reports|schedules|lint|packet|explain|reindex|consolidate',
138
138
  handler: async (args: string[], context: CommandContext): Promise<void> => {
@@ -53,7 +53,6 @@ export function registerLocalRuntimeCommands(registry: CommandRegistry): void {
53
53
  name: 'incident-review',
54
54
  aliases: [],
55
55
  description: 'Alias for /incident open',
56
- usage: '',
57
56
  handler(_args, ctx) {
58
57
  if (ctx.openIncidentPanel) {
59
58
  ctx.openIncidentPanel();
@@ -249,7 +248,7 @@ export function registerLocalRuntimeCommands(registry: CommandRegistry): void {
249
248
  aliases: ['img'],
250
249
  description: 'Attach an image file to the next message',
251
250
  usage: '<path> [prompt text]',
252
- argsHint: '<path> [prompt]',
251
+ argsHint: '<path> [prompt text]',
253
252
  async handler(args, ctx) {
254
253
  if (args.length === 0) {
255
254
  ctx.print('Usage: /image <path> [prompt text]\nSupported formats: PNG, JPEG, WebP, GIF');
@@ -55,7 +55,7 @@ export function registerMemoryProductRuntimeCommands(registry: CommandRegistry):
55
55
 
56
56
  registry.register({
57
57
  name: 'session-memory',
58
- description: 'Dedicated front-door for session-scoped memory capture and review. All subcommands are filtered to scope=session.',
58
+ description: 'Dedicated front-door for session-scoped memory capture and review. All subcommands are filtered to scope=session',
59
59
  usage: '[queue [limit] | export <path> | add <class> <summary...>]',
60
60
  async handler(args, ctx) {
61
61
  const sub = (args[0] ?? 'queue').toLowerCase();
@@ -82,7 +82,7 @@ export function registerMemoryProductRuntimeCommands(registry: CommandRegistry):
82
82
 
83
83
  registry.register({
84
84
  name: 'team-memory',
85
- description: 'Dedicated front-door for team/shared memory review and exchange. The queue and export subcommands are filtered to scope=team.',
85
+ description: 'Dedicated front-door for team/shared memory review and exchange. The queue and export subcommands are filtered to scope=team',
86
86
  usage: '[queue [limit] | export <path> | import <path> | capture policy]',
87
87
  async handler(args, ctx) {
88
88
  const sub = (args[0] ?? 'queue').toLowerCase();
@@ -25,7 +25,7 @@ import { VALID_CLASSES, VALID_REVIEW_STATES, VALID_SCOPES } from './recall-share
25
25
  export const recallCommand: SlashCommand = {
26
26
  name: 'recall',
27
27
  aliases: ['rc'],
28
- description: 'Project memory: add decisions, constraints, incidents, and patterns with provenance.',
28
+ description: 'Project memory: add decisions, constraints, incidents, and patterns with provenance',
29
29
  usage: '<subcommand> [args]',
30
30
  argsHint: 'add|search|link|get|list|remove',
31
31
  handler: async (args: string[], context: CommandContext): Promise<void> => {
@@ -5,7 +5,6 @@ export function registerOnboardingRuntimeCommands(registry: CommandRegistry): vo
5
5
  registry.register({
6
6
  name: 'onboarding',
7
7
  description: 'Open the onboarding wizard with current settings preloaded for review and editing',
8
- usage: '',
9
8
  handler(_args, ctx) {
10
9
  openOnboardingWizard(ctx, { mode: 'edit', reset: true });
11
10
  ctx.print('Opening onboarding wizard.');
@@ -4,7 +4,7 @@ import { dispatchPolicyCommand } from './policy-dispatch.ts';
4
4
  export const policyCommand: SlashCommand = {
5
5
  name: 'policy',
6
6
  aliases: ['pol'],
7
- description: 'Open the policy panel or manage versioned policy bundles (load, simulate, diff, promote, rollback).',
7
+ description: 'Open the policy panel or manage versioned policy bundles (load, simulate, diff, promote, rollback)',
8
8
  usage: '<subcommand> [args]',
9
9
  argsHint: 'load|simulate|diff|lint|preflight|promote|rollback|status',
10
10
  handler: async (args: string[], context: CommandContext): Promise<void> => {
@@ -16,7 +16,8 @@ function inspectProfileSyncBundle(bundle: ProfileSyncBundle): string {
16
16
 
17
17
  export function registerProfileSyncRuntimeCommands(registry: CommandRegistry): void {
18
18
  registry.register({
19
- name: 'profilesync',
19
+ name: 'profile-sync',
20
+ aliases: ['profilesync'],
20
21
  description: 'Export, import, and inspect profile sync bundles',
21
22
  usage: '[list|export <path>|inspect <path>|import <path> [prefix]]',
22
23
  handler(args, ctx) {
@@ -36,7 +37,7 @@ export function registerProfileSyncRuntimeCommands(registry: CommandRegistry): v
36
37
 
37
38
  const pathArg = args[1];
38
39
  if (!pathArg) {
39
- ctx.print(`Usage: /profilesync ${sub} <path>${sub === 'import' ? ' [prefix]' : ''}`);
40
+ ctx.print(`Usage: /profile-sync ${sub} <path>${sub === 'import' ? ' [prefix]' : ''}`);
40
41
  return;
41
42
  }
42
43
  const targetPath = shellPaths.resolveWorkspacePath(pathArg);
@@ -93,7 +94,7 @@ export function registerProfileSyncRuntimeCommands(registry: CommandRegistry): v
93
94
  }
94
95
 
95
96
  recordSettingsSyncFailure('profiles', `unsupported subcommand: ${sub}`, controlPlaneConfigDir);
96
- ctx.print('Usage: /profilesync [list|export <path>|inspect <path>|import <path> [prefix]]');
97
+ ctx.print('Usage: /profile-sync [list|export <path>|inspect <path>|import <path> [prefix]]');
97
98
  },
98
99
  });
99
100
  }
@@ -365,7 +365,7 @@ function handleFallbackTest(
365
365
  export const providerCommand: SlashCommand = {
366
366
  name: 'provider-opt',
367
367
  aliases: ['prov-opt'],
368
- description: 'Manage provider routing optimizer (route, pin, explain, fallback).',
368
+ description: 'Manage provider routing optimizer (route, pin, explain, fallback)',
369
369
  usage: '<subcommand> [args]',
370
370
  argsHint: 'optimizer|route|explain-route|pin|fallback',
371
371
  handler: async (args: string[], context: CommandContext): Promise<void> => {
@@ -12,7 +12,6 @@ export function registerQrcodeRuntimeCommands(registry: CommandRegistry): void {
12
12
  name: 'qrcode',
13
13
  aliases: ['qr', 'pair'],
14
14
  description: 'Open the QR code panel for companion app pairing',
15
- usage: '',
16
15
  handler(_args, ctx) {
17
16
  openCommandPanel(ctx, 'qr-code');
18
17
  },
@@ -7,6 +7,8 @@ import type {
7
7
  CommandSessionServices,
8
8
  CommandWorkspaceServices,
9
9
  } from '../command-registry.ts';
10
+ import { getLastCompactionEvent } from '@pellux/goodvibes-sdk/platform/core';
11
+ import type { CompactionContext, CompactionEvent } from '@pellux/goodvibes-sdk/platform/core';
10
12
  import type { UiReadModels } from '../../runtime/ui-read-models.ts';
11
13
  import type { ShellPathService } from '@/runtime/index.ts';
12
14
  import type { EcosystemCatalogPathOptions } from '@/runtime/index.ts';
@@ -236,13 +238,40 @@ export function requireProviderApi(context: CommandContext): ProviderApi {
236
238
  return requireContextValue(context.clients?.providerApi, 'clients.providerApi');
237
239
  }
238
240
 
239
- export async function compactConversation(context: CommandContext): Promise<void> {
241
+ /**
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).
245
+ */
246
+ export async function compactConversation(context: CommandContext): Promise<CompactionEvent | null> {
247
+ const eventBefore = getLastCompactionEvent();
248
+ const sessionMemories = context.session.sessionMemoryStore?.list() ?? [];
249
+ const compactionCtx: CompactionContext = {
250
+ messages: context.session.conversationManager.getMessagesForLLM(),
251
+ sessionMemories,
252
+ agents: [],
253
+ wrfcChains: [],
254
+ activePlan: null,
255
+ lineageEntries: [],
256
+ compactionCount: 0,
257
+ contextWindow: 0,
258
+ trigger: 'manual',
259
+ extractionModelId: context.session.runtime.model,
260
+ extractionProvider: context.session.runtime.provider,
261
+ };
240
262
  await context.session.conversationManager.compact(
241
263
  context.provider.providerRegistry,
242
264
  context.session.runtime.model,
243
265
  'manual',
244
266
  context.session.runtime.provider,
267
+ compactionCtx,
245
268
  );
269
+ const eventAfter = getLastCompactionEvent();
270
+ // Return the new event only if it differs from the one recorded before the call.
271
+ if (eventAfter !== null && eventAfter !== eventBefore) {
272
+ return eventAfter;
273
+ }
274
+ return null;
246
275
  }
247
276
 
248
277
  export function requireKnowledgeApi(context: CommandContext): KnowledgeApi {
@@ -162,7 +162,7 @@ export function registerSessionContentCommands(registry: CommandRegistry): void
162
162
  registry.register({
163
163
  name: 'undo',
164
164
  aliases: [],
165
- description: 'Undo last action. /undo file — revert last file write/edit. /undo — remove last conversation turn.',
165
+ description: 'Undo last action. /undo file — revert last file write/edit. /undo — remove last conversation turn',
166
166
  usage: '[file]',
167
167
  argsHint: '[file]',
168
168
  handler(args, ctx) {
@@ -191,7 +191,7 @@ export function registerSessionContentCommands(registry: CommandRegistry): void
191
191
 
192
192
  registry.register({
193
193
  name: 'redo',
194
- description: 'Redo last undone action. /redo file — re-apply last reverted file. /redo — restore conversation turn.',
194
+ description: 'Redo last undone action. /redo file — re-apply last reverted file. /redo — restore conversation turn',
195
195
  usage: '[file]',
196
196
  argsHint: '[file]',
197
197
  handler(args, ctx) {
@@ -6,7 +6,8 @@ import type { TranscriptEventKind } from '@pellux/goodvibes-sdk/platform/core';
6
6
  import type { ConversationTitleSource } from '../../core/conversation';
7
7
  import type { SessionReturnContextSummary } from '@/runtime/index.ts';
8
8
  import { formatReturnContextForDisplay, getReturnContextMode, maybeAssistReturnContextSummary } from '@/runtime/index.ts';
9
- import { requirePanelManager, requireProviderApi, requireSessionManager } from './runtime-services.ts';
9
+ import { requirePanelManager, requireProviderApi, requireSessionManager, requireShellPaths } from './runtime-services.ts';
10
+ import { replayJournalForSession } from '../../core/session-recovery.ts';
10
11
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
11
12
 
12
13
  function parseTranscriptKind(raw: string | undefined): TranscriptEventKind | 'all' {
@@ -240,6 +241,26 @@ export async function handleSessionWorkflowCommand(args: string[], ctx: CommandC
240
241
  ctx.session.conversationManager.fromJSON({ messages: messages as never[], title: meta.title, titleSource: meta.titleSource });
241
242
  ctx.session.conversationManager.rebuildHistory();
242
243
  ctx.session.runtime.sessionId = found.name;
244
+
245
+ // Journal replay: recover turns that post-date the loaded snapshot.
246
+ const shellPaths = requireShellPaths(ctx);
247
+ const journalReplay = replayJournalForSession({
248
+ homeDirectory: shellPaths.homeDirectory,
249
+ snapshotTimestamp: meta.timestamp,
250
+ conversation: ctx.session.conversationManager,
251
+ sessionId: found.name,
252
+ persistSnapshot: (replayedMessages) => {
253
+ sm.save(found.name, replayedMessages as never[], {
254
+ title: ctx.session.conversationManager.title || meta.title,
255
+ model: meta.model,
256
+ provider: meta.provider,
257
+ timestamp: Date.now(),
258
+ titleSource: meta.titleSource,
259
+ returnContext: meta.returnContext,
260
+ });
261
+ },
262
+ });
263
+
243
264
  if (meta.model) {
244
265
  try {
245
266
  const selected = await providerApi.selectModel(meta.model);
@@ -252,7 +273,16 @@ export async function handleSessionWorkflowCommand(args: string[], ctx: CommandC
252
273
  }
253
274
  if (meta.provider) ctx.session.runtime.provider = meta.provider;
254
275
  ctx.renderRequest();
255
- ctx.print(`Resumed session: ${found.name}\n Name: ${meta.title || '(untitled)'}\n Messages: ${messages.length}\n Model: ${meta.model || ctx.session.runtime.model}`);
276
+ const resumedMsgCount = ctx.session.conversationManager.getMessageCount();
277
+ ctx.print(`Resumed session: ${found.name}\n Name: ${meta.title || '(untitled)'}\n Messages: ${resumedMsgCount}\n Model: ${meta.model || ctx.session.runtime.model}`);
278
+ if (journalReplay.replayed > 0) {
279
+ ctx.print(` [Recovery] Replayed ${journalReplay.replayed} journal record(s) — restored turns since last snapshot.`);
280
+ }
281
+ if (journalReplay.hadCorruptTail && journalReplay.replayed === 0) {
282
+ ctx.print(' [Recovery] Journal tail was corrupt or unrecognised (quarantined). Proceeding with snapshot only.');
283
+ } else if (journalReplay.hadCorruptTail) {
284
+ ctx.print(' [Recovery] Journal tail was partially corrupt (quarantined). Replay stopped at last good record.');
285
+ }
256
286
  const reopenedPanels = reopenPanelsFromReturnContext(ctx, meta.returnContext);
257
287
  const returnContextMode = getReturnContextMode(ctx.platform.configManager);
258
288
  if (returnContextMode !== 'off' && meta.returnContext) {
@@ -339,7 +339,7 @@ function handleCancel(args: string[], context: CommandContext): void {
339
339
  export const sessionCommand: SlashCommand = {
340
340
  name: 'session',
341
341
  aliases: ['sess'],
342
- description: 'Session lifecycle and orchestration: list, resume, fork, save, export, link-task, handoff, graph, cancel.',
342
+ description: 'Session lifecycle and orchestration: list, resume, fork, save, export, link-task, handoff, graph, cancel',
343
343
  usage: '<subcommand> [args]',
344
344
  argsHint: 'list|rename|resume|fork|save|info|export|search|delete|events|groups|hotspots|link-task|handoff|graph|cancel',
345
345
  handler: async (args: string[], context: CommandContext): Promise<void> => {
@@ -24,8 +24,8 @@ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
24
24
 
25
25
  export function registerSettingsSyncRuntimeCommands(registry: CommandRegistry): void {
26
26
  registry.register({
27
- name: 'settingssync',
28
- aliases: ['settings-sync'],
27
+ name: 'settings-sync',
28
+ aliases: ['settingssync'],
29
29
  description: 'Review sync posture, export/import settings-sync bundles, and open the settings sync workspace',
30
30
  usage: '[review|panel|show <key>|staged|conflicts|resolve <key> <local|synced>|failures|rollback-history|export <path>|inspect <path>|pull <path>|push <path>|lock <key> <source> <reason...>|unlock <key>]',
31
31
  handler(args, ctx) {
@@ -39,7 +39,7 @@ export function registerSettingsSyncRuntimeCommands(registry: CommandRegistry):
39
39
  if (sub === 'show') {
40
40
  const key = args[1] as ConfigKey | undefined;
41
41
  if (!key || !CONFIG_KEYS.has(key)) {
42
- ctx.print('Usage: /settingssync show <config-key>');
42
+ ctx.print('Usage: /settings-sync show <config-key>');
43
43
  return;
44
44
  }
45
45
  ctx.print(formatResolvedSettingReview(ctx.platform.configManager, key));
@@ -63,7 +63,7 @@ export function registerSettingsSyncRuntimeCommands(registry: CommandRegistry):
63
63
  const key = args[1] as ConfigKey | undefined;
64
64
  const resolution = (args[2] ?? '').toLowerCase();
65
65
  if (!key || !CONFIG_KEYS.has(key) || (resolution !== 'local' && resolution !== 'synced')) {
66
- ctx.print('Usage: /settingssync resolve <config-key> <local|synced>');
66
+ ctx.print('Usage: /settings-sync resolve <config-key> <local|synced>');
67
67
  return;
68
68
  }
69
69
  const changed = resolveSettingsSyncConflict(ctx.platform.configManager, key, resolution);
@@ -102,7 +102,7 @@ export function registerSettingsSyncRuntimeCommands(registry: CommandRegistry):
102
102
  if (sub === 'export' || sub === 'push') {
103
103
  const pathArg = args[1];
104
104
  if (!pathArg) {
105
- ctx.print(`Usage: /settingssync ${sub} <path>`);
105
+ ctx.print(`Usage: /settings-sync ${sub} <path>`);
106
106
  return;
107
107
  }
108
108
  const targetPath = shellPaths.resolveWorkspacePath(pathArg);
@@ -122,7 +122,7 @@ export function registerSettingsSyncRuntimeCommands(registry: CommandRegistry):
122
122
  if (sub === 'inspect') {
123
123
  const pathArg = args[1];
124
124
  if (!pathArg) {
125
- ctx.print('Usage: /settingssync inspect <path>');
125
+ ctx.print('Usage: /settings-sync inspect <path>');
126
126
  return;
127
127
  }
128
128
  const sourcePath = shellPaths.resolveWorkspacePath(pathArg);
@@ -133,7 +133,7 @@ export function registerSettingsSyncRuntimeCommands(registry: CommandRegistry):
133
133
  if (sub === 'pull') {
134
134
  const pathArg = args[1];
135
135
  if (!pathArg) {
136
- ctx.print('Usage: /settingssync pull <path>');
136
+ ctx.print('Usage: /settings-sync pull <path>');
137
137
  return;
138
138
  }
139
139
  const sourcePath = shellPaths.resolveWorkspacePath(pathArg);
@@ -152,7 +152,7 @@ export function registerSettingsSyncRuntimeCommands(registry: CommandRegistry):
152
152
  const source = args[2];
153
153
  const reason = args.slice(3).join(' ').trim();
154
154
  if (!key || !source || !reason || !CONFIG_KEYS.has(key)) {
155
- ctx.print('Usage: /settingssync lock <config-key> <source> <reason...>');
155
+ ctx.print('Usage: /settings-sync lock <config-key> <source> <reason...>');
156
156
  return;
157
157
  }
158
158
  setManagedSettingLock(key, source, reason, controlPlaneConfigDir);
@@ -162,7 +162,7 @@ export function registerSettingsSyncRuntimeCommands(registry: CommandRegistry):
162
162
  if (sub === 'unlock') {
163
163
  const key = args[1] as ConfigKey | undefined;
164
164
  if (!key || !CONFIG_KEYS.has(key)) {
165
- ctx.print('Usage: /settingssync unlock <config-key>');
165
+ ctx.print('Usage: /settings-sync unlock <config-key>');
166
166
  return;
167
167
  }
168
168
  ctx.print(clearManagedSettingLock(key, controlPlaneConfigDir) ? `Managed lock cleared for ${key}.` : `No managed lock found for ${key}.`);
@@ -17,7 +17,7 @@ import {
17
17
  resolveGithubToken,
18
18
  } from '../../export/gist-uploader.ts';
19
19
  import { copyToClipboard } from '../../utils/clipboard.ts';
20
- import { openBrowser } from '../../cli/management.ts';
20
+ import { openBrowser } from '../../utils/browser.ts';
21
21
 
22
22
  export function registerShareRuntimeCommands(registry: CommandRegistry): void {
23
23
  registry.register({
@@ -3,7 +3,9 @@ import type { SelectionItem } from '../selection-modal.ts';
3
3
  import { EFFORT_DESCRIPTIONS } from '@pellux/goodvibes-sdk/platform/providers';
4
4
  import { REASONING_BUDGET_MAP } from '@pellux/goodvibes-sdk/platform/providers';
5
5
  import { executeWriteQuit } from './quit-shared.ts';
6
- import { compactConversation, requireKeybindingsManager, requireProviderApi } from './runtime-services.ts';
6
+ import { compactConversation, requireKeybindingsManager, requireProviderApi, requireSessionMemoryStore } from './runtime-services.ts';
7
+ import { buildCompactionPreview, buildCompactionAfterNotice, buildPinUsageText, buildPinSuccessText } from '../../renderer/compaction-preview.ts';
8
+ import { buildCompactionHistoryText } from '../../renderer/compaction-history-modal.ts';
7
9
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
8
10
  import { logger } from '@pellux/goodvibes-sdk/platform/utils';
9
11
 
@@ -13,7 +15,7 @@ export function registerShellCoreCommands(registry: CommandRegistry): void {
13
15
  aliases: ['m'],
14
16
  description: 'Select or display the current LLM model',
15
17
  usage: '[model-id]',
16
- argsHint: '[name]',
18
+ argsHint: '[model-id]',
17
19
  async handler(args, ctx) {
18
20
  const providerApi = requireProviderApi(ctx);
19
21
  if (args.length === 0) {
@@ -204,9 +206,57 @@ export function registerShellCoreCommands(registry: CommandRegistry): void {
204
206
  aliases: [],
205
207
  description: 'Summarize conversation to free context window',
206
208
  async handler(_args, ctx) {
207
- ctx.print('Compacting conversation...');
208
- await compactConversation(ctx);
209
- ctx.print('Conversation compacted.');
209
+ 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;
213
+ const memStore = ctx.session.sessionMemoryStore;
214
+ const pinnedMemoryCount = memStore ? memStore.list().length : 0;
215
+ // Pre-compact preview: honest estimate, clearly labelled.
216
+ const preview = buildCompactionPreview({ messages, contextWindow, pinnedMemoryCount, trigger: 'manual' });
217
+ 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 }));
222
+ } else {
223
+ ctx.print('[Context] Compact complete.');
224
+ }
225
+ ctx.renderRequest();
226
+ },
227
+ });
228
+
229
+ registry.register({
230
+ name: 'compact-history',
231
+ aliases: ['compaction-history'],
232
+ description: 'Show compaction history for this session',
233
+ handler(_args, ctx) {
234
+ ctx.print(buildCompactionHistoryText());
235
+ ctx.renderRequest();
236
+ },
237
+ });
238
+
239
+ registry.register({
240
+ name: 'keep',
241
+ aliases: [],
242
+ description: 'Pin text to session memory (survives compaction)',
243
+ usage: '<text>',
244
+ argsHint: '<text to preserve>',
245
+ handler(args, ctx) {
246
+ const text = args.join(' ').trim();
247
+ if (!text) {
248
+ ctx.print(buildPinUsageText());
249
+ ctx.renderRequest();
250
+ return;
251
+ }
252
+ const memStore = requireSessionMemoryStore(ctx);
253
+ const id = memStore.add(text);
254
+ if (!id) {
255
+ ctx.print('[Pin] Nothing pinned — text was blank.');
256
+ } else {
257
+ const count = memStore.list().length;
258
+ ctx.print(buildPinSuccessText(id, text, count));
259
+ }
210
260
  ctx.renderRequest();
211
261
  },
212
262
  });
@@ -244,7 +294,7 @@ export function registerShellCoreCommands(registry: CommandRegistry): void {
244
294
  aliases: ['e'],
245
295
  description: 'Show or set reasoning effort level',
246
296
  usage: '[level]',
247
- argsHint: '<instant|low|medium|high>',
297
+ argsHint: '[instant|low|medium|high]',
248
298
  async handler(args, ctx) {
249
299
  const currentModel = await requireProviderApi(ctx).getCurrentModel();
250
300
  const validLevels = currentModel.reasoningEffort ?? [];
@@ -37,7 +37,7 @@ function openPanel(ctx: import('../command-registry.ts').CommandContext): void {
37
37
 
38
38
  function formatList(store: WorkPlanStore): string {
39
39
  const items = store.listItems();
40
- if (items.length === 0) return 'Work plan is empty. Add one with /workplan add <title>.';
40
+ if (items.length === 0) return 'Work plan is empty. Add one with /work-plan add <title>.';
41
41
  return [
42
42
  `Work Plan (${items.length})`,
43
43
  ...items.map((item) => {
@@ -78,8 +78,8 @@ function parseAddArgs(args: string[]): { title: string; owner?: string; source?:
78
78
 
79
79
  export function registerWorkPlanRuntimeCommands(registry: CommandRegistry): void {
80
80
  registry.register({
81
- name: 'workplan',
82
- aliases: ['wp', 'todo'],
81
+ name: 'work-plan',
82
+ aliases: ['wp', 'todo', 'workplan'],
83
83
  description: 'Track a persistent workspace-scoped work plan',
84
84
  usage: '[panel|list|show|add <title> [--owner name] [--source label] [--notes text]|done <id>|start <id>|block <id>|fail <id>|cancel <id>|pending <id>|remove <id>|clear-done]',
85
85
  argsHint: '[panel|add|list|done]',
@@ -107,7 +107,7 @@ export function registerWorkPlanRuntimeCommands(registry: CommandRegistry): void
107
107
  if (subcommand === 'add') {
108
108
  const parsed = parseAddArgs(args.slice(1));
109
109
  if (!parsed.title) {
110
- ctx.print('Usage: /workplan add <title> [--owner name] [--source label] [--notes text]');
110
+ ctx.print('Usage: /work-plan add <title> [--owner name] [--source label] [--notes text]');
111
111
  return;
112
112
  }
113
113
  const addOptions = {
@@ -123,7 +123,7 @@ export function registerWorkPlanRuntimeCommands(registry: CommandRegistry): void
123
123
  if (subcommand === 'remove' || subcommand === 'delete' || subcommand === 'rm') {
124
124
  const id = args[1];
125
125
  if (!id) {
126
- ctx.print(`Usage: /workplan ${subcommand} <id>`);
126
+ ctx.print(`Usage: /work-plan ${subcommand} <id>`);
127
127
  return;
128
128
  }
129
129
  const item = store.removeItem(id);
@@ -138,7 +138,7 @@ export function registerWorkPlanRuntimeCommands(registry: CommandRegistry): void
138
138
  if (subcommand === 'cycle' || subcommand === 'toggle') {
139
139
  const id = args[1];
140
140
  if (!id) {
141
- ctx.print(`Usage: /workplan ${subcommand} <id>`);
141
+ ctx.print(`Usage: /work-plan ${subcommand} <id>`);
142
142
  return;
143
143
  }
144
144
  const item = store.cycleItemStatus(id);
@@ -149,7 +149,7 @@ export function registerWorkPlanRuntimeCommands(registry: CommandRegistry): void
149
149
  if (status) {
150
150
  const id = args[1];
151
151
  if (!id) {
152
- ctx.print(`Usage: /workplan ${subcommand} <id>`);
152
+ ctx.print(`Usage: /work-plan ${subcommand} <id>`);
153
153
  return;
154
154
  }
155
155
  const item = store.setItemStatus(id, status);
@@ -157,7 +157,7 @@ export function registerWorkPlanRuntimeCommands(registry: CommandRegistry): void
157
157
  return;
158
158
  }
159
159
  if (WORK_PLAN_STATUSES.includes(subcommand as WorkPlanItemStatus)) {
160
- ctx.print(`Usage: /workplan ${subcommand} <id>`);
160
+ ctx.print(`Usage: /work-plan ${subcommand} <id>`);
161
161
  return;
162
162
  }
163
163
  ctx.print(`Unknown workplan subcommand: ${subcommand}`);
@@ -18,6 +18,7 @@ import { registerGitRuntimeCommands } from './commands/git-runtime.ts';
18
18
  import { registerNotifyRuntimeCommands } from './commands/notify-runtime.ts';
19
19
  import { registerReplayRuntimeCommands } from './commands/replay-runtime.ts';
20
20
  import { registerShareRuntimeCommands } from './commands/share-runtime.ts';
21
+ import { registerChannelRuntimeCommands } from './commands/channel-runtime.ts';
21
22
  import { registerLocalSetupCommands } from './commands/local-setup.ts';
22
23
  import { registerProductRuntimeCommands } from './commands/product-runtime.ts';
23
24
  import { registerPlatformRuntimeCommands } from './commands/platform-runtime.ts';
@@ -70,6 +71,7 @@ export function registerBuiltinCommands(registry: CommandRegistry): void {
70
71
  registerNotifyRuntimeCommands(registry);
71
72
  registerReplayRuntimeCommands(registry);
72
73
  registerShareRuntimeCommands(registry);
74
+ registerChannelRuntimeCommands(registry);
73
75
  registerLocalSetupCommands(registry);
74
76
  registerProductRuntimeCommands(registry);
75
77
  registerPlatformRuntimeCommands(registry);
@@ -38,6 +38,7 @@ import type { Panel } from '../panels/types.ts';
38
38
  import type { PanelManager } from '../panels/panel-manager.ts';
39
39
  import type { KeybindingsManager } from './keybindings.ts';
40
40
  import type { ModelPickerTarget } from './model-picker.ts';
41
+ import type { KillRing } from './kill-ring.ts';
41
42
  import type { PanelMouseLayout } from './handler-feed-routes.ts';
42
43
 
43
44
  /**
@@ -124,6 +125,7 @@ export interface FeedContextStableRefs {
124
125
  conversationManager: ConversationManager | null;
125
126
  panelManager: PanelManager;
126
127
  keybindingsManager: KeybindingsManager;
128
+ killRing: KillRing;
127
129
  getHistory: () => InfiniteBuffer;
128
130
  getViewportHeight: () => number;
129
131
  getScrollTop: () => number;
@@ -145,6 +147,10 @@ export interface FeedContextClosures {
145
147
  handleRedo: () => void;
146
148
  handlePaste: () => void;
147
149
  saveUndoState: () => void;
150
+ /** Save undo state with text-insertion coalescing (burst typing merges into one group). */
151
+ saveUndoStateForText: () => void;
152
+ /** Break the current coalescing group (call on cursor moves). */
153
+ breakUndoCoalesce: () => void;
148
154
  ensureInputCursorVisible: (contentWidth?: number) => void;
149
155
  registerPaste: (content: string) => string;
150
156
  executeBlockAction: (id: string) => void;