@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
package/src/main.ts CHANGED
@@ -40,6 +40,7 @@ import {
40
40
  writeRecoveryFile,
41
41
  } from '@/runtime/index.ts';
42
42
  import { handleBlockingShellInput, type PendingPermissionState } from './shell/blocking-input.ts';
43
+ import { createPersistRecoverySnapshot, createReopenRecoveryPanels, handleErrorAffordanceKey } from './shell/recovery-input-helpers.ts';
43
44
  import { wireShellUiOpeners } from './shell/ui-openers.ts';
44
45
  import { deriveComposerState } from './core/composer-state.ts';
45
46
  import { buildPersistedSessionContext, formatReturnContextForDisplay, getReturnContextMode, maybeAssistReturnContextSummary } from '@/runtime/index.ts';
@@ -54,22 +55,16 @@ import { allowTerminalWrite, installTuiTerminalOutputGuard } from './runtime/ter
54
55
  import { buildCommandArgsHint } from './input/command-args-hint.ts';
55
56
  import { summarizeRunningAgents } from './renderer/process-summary.ts';
56
57
  import { formatUserFacingErrorLine } from './core/format-user-error.ts';
57
- import { wireStreamEventMetrics, type StreamMetrics } from './core/stream-event-wiring.ts';
58
+ import { wireStreamEventMetrics, type StreamMetrics, type WireStreamEventMetricsResult } from './core/stream-event-wiring.ts';
58
59
  import { wireTurnEventHandlers } from './core/turn-event-wiring.ts';
59
60
  import { buildContextStatusHint } from './renderer/context-status-hint.ts';
60
61
  import { evaluateSessionMaintenance } from './panels/session-maintenance.ts';
61
62
 
62
- const ALT_SCREEN_ENTER = '\x1b[?1049h';
63
- const ALT_SCREEN_EXIT = '\x1b[?1049l';
64
- const MOUSE_ENABLE = '\x1b[?1000h\x1b[?1002h\x1b[?1006h';
65
- const MOUSE_DISABLE = '\x1b[?1006l\x1b[?1002l\x1b[?1000l';
66
- const CURSOR_HIDE = '\x1b[?25l';
67
- const CURSOR_SHOW = '\x1b[?25h';
68
- const CLEAR_SCREEN = '\x1b[2J\x1b[3J\x1b[H';
69
- const KEYBOARD_EXT_ENABLE = '\x1b[>4;2m' + '\x1b[?1u';
70
- const KEYBOARD_EXT_DISABLE = '\x1b[>4;0m' + '\x1b[?1l';
71
- const PASTE_ENABLE = '\x1b[?2004h';
72
- const PASTE_DISABLE = '\x1b[?2004l';
63
+ const ALT_SCREEN_ENTER = '\x1b[?1049h'; const ALT_SCREEN_EXIT = '\x1b[?1049l';
64
+ const MOUSE_ENABLE = '\x1b[?1000h\x1b[?1002h\x1b[?1006h'; const MOUSE_DISABLE = '\x1b[?1006l\x1b[?1002l\x1b[?1000l';
65
+ const CURSOR_HIDE = '\x1b[?25l'; const CURSOR_SHOW = '\x1b[?25h'; const CLEAR_SCREEN = '\x1b[2J\x1b[3J\x1b[H';
66
+ const KEYBOARD_EXT_ENABLE = '\x1b[>4;2m' + '\x1b[?1u'; const KEYBOARD_EXT_DISABLE = '\x1b[>4;0m' + '\x1b[?1l';
67
+ const PASTE_ENABLE = '\x1b[?2004h'; const PASTE_DISABLE = '\x1b[?2004l';
73
68
 
74
69
  async function main() {
75
70
  const stdout = process.stdout;
@@ -315,10 +310,11 @@ async function main() {
315
310
  }
316
311
  if (processedText || content) {
317
312
  void (async () => {
318
- let inputOptions = options.spokenOutput ? createSpokenTurnInputOptions() : undefined;
319
- if (options.spokenOutput && processedText) {
320
- spokenTurns.submitNextTurn(processedText);
321
- }
313
+ const inputOptions = options.spokenOutput ? createSpokenTurnInputOptions() : undefined;
314
+ if (options.spokenOutput && processedText) { spokenTurns.submitNextTurn(processedText); }
315
+ // Snapshot pre-submission state for failover retryTurn; also clears visited set.
316
+ retryCtx = { count: conversation.getMessageCount(), text: processedText, content, opts: inputOptions };
317
+ streamResult.clearFailoverVisited();
322
318
  orchestrator.handleUserInput(processedText, content, inputOptions).catch((err: unknown) => {
323
319
  logger.debug('handleUserInput safety catch (already handled by runTurn)', { error: summarizeError(err) });
324
320
  });
@@ -689,7 +685,6 @@ async function main() {
689
685
  render,
690
686
  });
691
687
 
692
- // --- Turn-completed / git-refresh event wiring ---
693
688
  const { refreshGit, unsubs: turnUnsubs } = wireTurnEventHandlers({
694
689
  events: uiServices.events,
695
690
  conversation,
@@ -705,20 +700,37 @@ async function main() {
705
700
  gitStatusProvider,
706
701
  lastGitInfoRef,
707
702
  buildSessionContinuityHints,
708
- render,
703
+ render, webhookNotifier: ctx.services.webhookNotifier,
709
704
  });
710
705
  unsubs.push(...turnUnsubs);
711
706
 
712
- // --- Stream metrics + tool-timer event wiring ---
713
- const streamUnsubs = wireStreamEventMetrics({
714
- events: uiServices.events,
715
- orchestrator,
716
- providerRegistry,
717
- systemMessageRouter,
718
- render,
719
- metrics: streamMetrics,
707
+ // Stable turn context for failover retry set in submitInput, read by retryTurn.
708
+ let retryCtx: { count: number; text: string; content?: ContentPart[]; opts?: Parameters<typeof orchestrator.handleUserInput>[2] } | null = null;
709
+ // One-key retry affordance: active immediately after a user-visible TURN_ERROR.
710
+ // While active, 'r' re-submits on the current provider, 'm' opens the model
711
+ // picker. Any other character clears the affordance and routes normally.
712
+ let errorAffordanceActive = false;
713
+ const retryTurn = (): void => {
714
+ if (!retryCtx) return;
715
+ const { count, text, content: rContent, opts: rOpts } = retryCtx;
716
+ // Roll back to pre-submission count, then re-submit. SDK gap — no retry-in-place (see handoff).
717
+ conversation.removeMessagesAfter(count);
718
+ orchestrator.handleUserInput(text, rContent, rOpts).catch((e: unknown) => logger.debug('retryTurn', { error: summarizeError(e) }));
719
+ };
720
+ const streamResult: WireStreamEventMetricsResult = wireStreamEventMetrics({
721
+ events: uiServices.events, orchestrator, providerRegistry,
722
+ systemMessageRouter, render, metrics: streamMetrics,
723
+ providerOptimizer: ctx.services.providerOptimizer, costLookup: providerRegistry, retryTurn,
724
+ });
725
+ unsubs.push(...streamResult.unsubs);
726
+ // Activate one-key retry affordance when a user-visible error surfaces.
727
+ streamResult.onErrorSurfaced((exhausted) => {
728
+ if (retryCtx) {
729
+ errorAffordanceActive = true;
730
+ systemMessageRouter.low(exhausted ? '[Retry] r retry same provider · m switch model' : '[Retry] r retry · m switch model');
731
+ render();
732
+ }
720
733
  });
721
- unsubs.push(...streamUnsubs);
722
734
 
723
735
  // --- Terminal setup ---
724
736
  stdin.setRawMode(true);
@@ -726,35 +738,17 @@ async function main() {
726
738
  stdin.setEncoding('utf8');
727
739
  allowTerminalWrite(() => stdout.write((cli.flags.noAltScreen ? '' : ALT_SCREEN_ENTER) + CLEAR_SCREEN + CURSOR_HIDE + MOUSE_ENABLE + KEYBOARD_EXT_ENABLE + PASTE_ENABLE));
728
740
 
729
- applyInitialTuiCliState({
730
- cli,
731
- input,
732
- commandRegistry,
733
- commandContext,
734
- shellPaths: ctx.services.shellPaths,
735
- render,
736
- });
741
+ applyInitialTuiCliState({ cli, input, commandRegistry, commandContext, shellPaths: ctx.services.shellPaths, render });
737
742
 
738
743
  stdin.on('data', (data: string) => {
739
744
  const blocking = handleBlockingShellInput({
740
- data,
741
- pendingPermission,
742
- recoveryPending,
745
+ data, pendingPermission, recoveryPending, conversation, systemMessageRouter, render,
743
746
  abortTurn: () => orchestrator.abort(),
744
- conversation,
745
- systemMessageRouter,
746
- render,
747
747
  loadRecoveryConversation: () => loadRecoveryConversation({ homeDirectory }),
748
748
  deleteRecoveryFile: () => deleteRecoveryFile({ homeDirectory }),
749
- reopenPanels: (snapshot) => {
750
- const panels = snapshot.returnContext?.openPanels;
751
- if (!panels || panels.length === 0) return;
752
- for (const panelId of panels.slice(0, 4)) {
753
- try { panelManager.open(panelId); } catch { /* unknown panel id — skip */ }
754
- }
755
- panelManager.show();
756
- render();
757
- },
749
+ homeDirectory, sessionId: runtime.sessionId,
750
+ persistSnapshot: createPersistRecoverySnapshot({ sessionManager: ctx.services.sessionManager, runtime, conversation }),
751
+ reopenPanels: createReopenRecoveryPanels({ panelManager, render }),
758
752
  });
759
753
  pendingPermission = blocking.pendingPermission;
760
754
  recoveryPending = blocking.recoveryPending;
@@ -762,6 +756,12 @@ async function main() {
762
756
  return;
763
757
  }
764
758
 
759
+ // One-key retry affordance: armed after a user-visible TURN_ERROR; any key other than r/m dismisses it and routes normally.
760
+ if (errorAffordanceActive) {
761
+ errorAffordanceActive = false;
762
+ if (handleErrorAffordanceKey(data, { retryArmed: retryCtx !== null, retry: retryTurn, openModelPicker: () => commandContext.openModelPicker?.(), render })) return;
763
+ }
764
+
765
765
  input.feed(data);
766
766
  });
767
767
  process.on('SIGINT', sigintHandler);
@@ -4,6 +4,7 @@ import type { ComponentResourceContract, ComponentHealthState } from '../runtime
4
4
  import type { ComponentHealthMonitor } from '../runtime/perf/panel-health-monitor.ts';
5
5
  import { UIFactory } from '../renderer/ui-factory.ts';
6
6
  import { SPINNER_FRAMES } from '../renderer/progress.ts';
7
+ import { fitDisplay } from '../utils/terminal-width.ts';
7
8
 
8
9
  export abstract class BasePanel implements Panel {
9
10
  public needsRender = true;
@@ -143,7 +144,7 @@ export abstract class BasePanel implements Panel {
143
144
  if (this.loadingState !== 'loading') return null;
144
145
  const spinner = SPINNER_FRAMES[frame % SPINNER_FRAMES.length] ?? SPINNER_FRAMES[0]!;
145
146
  const text = ` ${spinner} ${this._loadingLabel}`;
146
- return UIFactory.stringToLine(text.padEnd(width).slice(0, width), width, { fg: '135', bold: true });
147
+ return UIFactory.stringToLine(fitDisplay(text, width), width, { fg: '135', bold: true });
147
148
  }
148
149
 
149
150
  /**
@@ -73,7 +73,7 @@ export function buildProviderHealthDomainSummaries(
73
73
  : settingIssueCount > 0
74
74
  ? `${settings.conflictCount} conflicts / ${settings.recentFailureCount} failures${settings.hasStagedManagedBundle ? ' / staged bundle' : ''}`
75
75
  : 'settings control plane clean',
76
- next: settingIssueCount > 0 ? '/settingssync panel' : '/settingssync show <key>',
76
+ next: settingIssueCount > 0 ? '/settings-sync panel' : '/settings-sync show <key>',
77
77
  details: [
78
78
  settings.conflictCount > 0 ? `${settings.conflictCount} unresolved import conflict(s)` : '',
79
79
  settings.recentFailureCount > 0 ? `${settings.recentFailureCount} recent sync or managed failure(s)` : '',
@@ -81,8 +81,8 @@ export function buildProviderHealthDomainSummaries(
81
81
  settings.managedLockCount > 0 ? `${settings.managedLockCount} managed lock(s) enforced` : '',
82
82
  ].filter(Boolean),
83
83
  nextSteps: settingIssueCount > 0
84
- ? ['/settingssync panel', '/settingssync show <key>', '/managed staged']
85
- : ['/settingssync show <key>'],
84
+ ? ['/settings-sync panel', '/settings-sync show <key>', '/managed staged']
85
+ : ['/settings-sync show <key>'],
86
86
  });
87
87
 
88
88
  summaries.push({
@@ -88,18 +88,22 @@ const LATENCY_BAD_MS = 5_000;
88
88
 
89
89
  function statusDot(status: ProviderStatus): { char: string; color: string } {
90
90
  switch (status) {
91
- case 'online': return { char: '●', color: C.online };
92
- case 'rate-limited': return { char: '', color: C.rateLimit };
93
- case 'error': return { char: '', color: C.error };
91
+ case 'healthy': return { char: '●', color: C.online };
92
+ case 'degraded': return { char: '', color: C.rateLimit };
93
+ case 'rate_limited': return { char: '', color: C.rateLimit };
94
+ case 'auth_error': return { char: '✕', color: C.error };
95
+ case 'unavailable': return { char: '✕', color: C.error };
94
96
  default: return { char: '○', color: C.unknown };
95
97
  }
96
98
  }
97
99
 
98
100
  function statusLabel(status: ProviderStatus): string {
99
101
  switch (status) {
100
- case 'online': return 'online';
101
- case 'rate-limited': return 'rate-limited';
102
- case 'error': return 'error';
102
+ case 'healthy': return 'online';
103
+ case 'degraded': return 'degraded';
104
+ case 'rate_limited': return 'rate-limited';
105
+ case 'auth_error': return 'auth error';
106
+ case 'unavailable': return 'unavailable';
103
107
  default: return 'unknown';
104
108
  }
105
109
  }
@@ -562,9 +566,9 @@ export class ProviderHealthPanel extends BasePanel {
562
566
  let expiringAuth = 0;
563
567
  for (const name of providers) {
564
568
  const status = this.providerHealthTracker.get(name)?.status ?? 'unknown';
565
- if (status === 'online') online++;
566
- else if (status === 'rate-limited') rateLimited++;
567
- else if (status === 'error') errored++;
569
+ if (status === 'healthy') online++;
570
+ else if (status === 'rate_limited') rateLimited++;
571
+ else if (status === 'degraded' || status === 'auth_error' || status === 'unavailable') errored++;
568
572
  const account = this._accountRecords.get(name);
569
573
  if (account) {
570
574
  accountIssues += account.issues.length;
@@ -1,4 +1,7 @@
1
- export type ProviderStatus = 'online' | 'rate-limited' | 'error' | 'unknown';
1
+ // ProviderStatus is the shared SDK type imported from the runtime barrel
2
+ // to eliminate the duplicate local definition that diverged from the SDK shape.
3
+ import type { ProviderStatus } from '@/runtime/index.ts';
4
+ export type { ProviderStatus };
2
5
 
3
6
  export interface ProviderHealth {
4
7
  name: string;
@@ -81,7 +84,7 @@ export class ProviderHealthTracker {
81
84
 
82
85
  private recordSuccess(name: string, latencyMs?: number): void {
83
86
  const record = this.ensureRecord(name);
84
- record.status = 'online';
87
+ record.status = 'healthy';
85
88
  record.lastSuccessAt = Date.now();
86
89
  record.lastErrorMessage = undefined;
87
90
  if (latencyMs !== undefined) {
@@ -97,11 +100,11 @@ export class ProviderHealthTracker {
97
100
  record.lastErrorAt = Date.now();
98
101
  record.lastErrorMessage = message.slice(0, 120);
99
102
  if (isRateLimit) {
100
- record.status = 'rate-limited';
103
+ record.status = 'rate_limited';
101
104
  record.rateLimitExpiresAt = Date.now() + ProviderHealthTracker.DEFAULT_COOLDOWN_MS;
102
105
  return;
103
106
  }
104
- record.status = 'error';
107
+ record.status = 'degraded';
105
108
  }
106
109
 
107
110
  private isRateLimitMessage(message: string): boolean {
@@ -56,7 +56,7 @@ export class SettingsSyncPanel extends ScrollableListPanel<ResolvedEntry> {
56
56
  const postureLines: Line[] = [
57
57
  buildPanelLine(width, [[' resolved keys ', C.label], [String(snapshot.resolvedEntries.length), C.value], [' conflicts ', C.label], ...buildStatusPill(snapshot.conflicts.length > 0 ? 'bad' : 'good', String(snapshot.conflicts.length)), [' failures ', C.label], ...buildStatusPill(snapshot.recentFailures.length > 0 ? 'warn' : 'good', String(snapshot.recentFailures.length))]),
58
58
  buildPanelLine(width, [[' managed locks ', C.label], [String(snapshot.managedLockCount), snapshot.managedLockCount > 0 ? C.warn : C.dim], [' staged bundle ', C.label], [snapshot.stagedManagedBundle ? snapshot.stagedManagedBundle.profileName : 'none', snapshot.stagedManagedBundle ? C.info : C.dim]]),
59
- buildGuidanceLine(width, '/settingssync conflicts', 'review conflicting synced values before they silently shape effective configuration', C),
59
+ buildGuidanceLine(width, '/settings-sync conflicts', 'review conflicting synced values before they silently shape effective configuration', C),
60
60
  buildGuidanceLine(width, '/managed review', 'inspect staged managed changes, risk posture, and rollback records', C),
61
61
  ];
62
62
 
@@ -86,7 +86,7 @@ export class SettingsSyncPanel extends ScrollableListPanel<ResolvedEntry> {
86
86
  : [buildPanelLine(width, [[' No recent sync or managed-setting failures.', C.dim]])]),
87
87
  // Conflicts
88
88
  ...(snapshot.conflicts.length > 0
89
- ? snapshot.conflicts.map((conflict) => buildPanelLine(width, [[` ${conflict.key}`.padEnd(30), C.value], [` ${conflict.source}`.padEnd(10), C.warn], [` resolve: /settingssync resolve ${conflict.key} local|synced`.slice(0, Math.max(0, width - 42)), C.dim]]))
89
+ ? snapshot.conflicts.map((conflict) => buildPanelLine(width, [[` ${conflict.key}`.padEnd(30), C.value], [` ${conflict.source}`.padEnd(10), C.warn], [` resolve: /settings-sync resolve ${conflict.key} local|synced`.slice(0, Math.max(0, width - 42)), C.dim]]))
90
90
  : [buildPanelLine(width, [[' No settings conflicts detected.', C.dim]])]),
91
91
  // Rollback History
92
92
  ...(snapshot.rollbackHistory.length > 0
@@ -108,7 +108,7 @@ export class SettingsSyncPanel extends ScrollableListPanel<ResolvedEntry> {
108
108
  buildPanelLine(width, [[' managed ', C.label], [String(selectedEntry.managedValue ?? '(unset)').slice(0, Math.max(0, width - 11)), C.warn]]),
109
109
  ], C)
110
110
  : []),
111
- buildPanelLine(width, [[' ↑/↓ browse /settingssync show <key> /settingssync resolve <key> <local|synced> /managed apply-staged [key...] ', C.dim]]),
111
+ buildPanelLine(width, [[' ↑/↓ browse /settings-sync show <key> /settings-sync resolve <key> <local|synced> /managed apply-staged [key...] ', C.dim]]),
112
112
  ];
113
113
 
114
114
  return this.renderList(width, height, {
@@ -109,8 +109,8 @@ export class WorkPlanPanel extends ScrollableListPanel<WorkPlanItem> {
109
109
 
110
110
  protected getEmptyStateActions(): Array<{ command: string; summary: string }> {
111
111
  return [
112
- { command: '/workplan add <title>', summary: 'add a persistent item' },
113
- { command: '/workplan list', summary: 'print the current plan' },
112
+ { command: '/work-plan add <title>', summary: 'add a persistent item' },
113
+ { command: '/work-plan list', summary: 'print the current plan' },
114
114
  ];
115
115
  }
116
116
 
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Compaction history text builder.
3
+ *
4
+ * Renders a read-only list of past compaction events sourced from the SDK's
5
+ * module-level compaction event log (`getCompactionEvents()`).
6
+ *
7
+ * The SDK records CompactionEvent data (timestamps, token counts,
8
+ * trigger, message counts) but does not expose a snapshot restore API.
9
+ * Restore is list-only; users can view what compactions ran but cannot roll back.
10
+ */
11
+
12
+ import { getCompactionEvents } from '@pellux/goodvibes-sdk/platform/core';
13
+ import type { CompactionEvent } from '@pellux/goodvibes-sdk/platform/core';
14
+
15
+ // ─── formatCompactionEvent ────────────────────────────────────────────────────
16
+
17
+ function formatCompactionEvent(ev: CompactionEvent, n: number): string {
18
+ const date = new Date(ev.timestamp);
19
+ const timeStr = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
20
+ const savings = Math.max(0, ev.tokensBeforeEstimate - ev.tokensAfterEstimate);
21
+ const savingsPct = ev.tokensBeforeEstimate > 0
22
+ ? Math.round((savings / ev.tokensBeforeEstimate) * 100)
23
+ : 0;
24
+ const trigger = ev.trigger === 'auto' ? 'auto' : 'manual';
25
+ return (
26
+ `#${n} ${timeStr} [${trigger}] ` +
27
+ `${ev.messagesBeforeCompaction}→${ev.messagesAfterCompaction} msgs ` +
28
+ `~${fmtN(ev.tokensBeforeEstimate)}→~${fmtN(ev.tokensAfterEstimate)} tok ` +
29
+ `saved ${savingsPct}%`
30
+ );
31
+ }
32
+
33
+ function fmtN(n: number): string {
34
+ return n.toLocaleString();
35
+ }
36
+
37
+ /**
38
+ * Build a plain-text compaction history summary suitable for ctx.print().
39
+ * Useful as the output of /compact-history when not in overlay mode.
40
+ */
41
+ export function buildCompactionHistoryText(): string {
42
+ const events = getCompactionEvents();
43
+ if (events.length === 0) {
44
+ return '[Context] No compactions recorded this session. (Restore is not available — the SDK does not yet expose a snapshot restore API.)';
45
+ }
46
+ const lines: string[] = [
47
+ `[Context] Compaction history (${events.length} total, most recent first):`,
48
+ ];
49
+ const ordered = [...events].reverse();
50
+ for (let i = 0; i < ordered.length; i++) {
51
+ lines.push(' ' + formatCompactionEvent(ordered[i], ordered.length - i));
52
+ }
53
+ lines.push(' (Restore not available — the SDK does not yet expose a snapshot restore API.)');
54
+ return lines.join('\n');
55
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Compaction preview and after-notice builders.
3
+ *
4
+ * Pre-compact: shows an honest estimate of what compaction will do
5
+ * (message count and token estimate before → after). The SDK has no dry-run
6
+ * API; we derive the after-estimate from the DEFAULT_COMPACTION_CONFIG
7
+ * totalCeiling (6500 tokens) — clearly labelled as an ESTIMATE.
8
+ *
9
+ * Post-compact: shows a before/after notice using the CompactionEvent
10
+ * data returned by compactMessages(), which contains the real
11
+ * tokensBeforeEstimate and tokensAfterEstimate figures.
12
+ *
13
+ * Honest wording policy:
14
+ * - Pre-compact notice says "estimate" every time; never claims certainty.
15
+ * - Post-compact notice uses "~N" prefix on every token figure.
16
+ * - Pinned session memories that survive are mentioned by count.
17
+ */
18
+
19
+ import { estimateConversationTokens } from '@pellux/goodvibes-sdk/platform/core';
20
+ import type { CompactionEvent } from '@pellux/goodvibes-sdk/platform/core';
21
+ import type { ProviderMessage } from '@pellux/goodvibes-sdk/platform/providers';
22
+
23
+ /**
24
+ * Default compaction totalCeiling from context-compaction DEFAULT_COMPACTION_CONFIG.
25
+ * Kept local so we don't take a runtime dependency on the internal config object.
26
+ * Used only for the pre-compact ESTIMATE; the real figure comes from CompactionEvent.
27
+ */
28
+ const COMPACTION_OUTPUT_CEILING_ESTIMATE = 6500;
29
+
30
+ export interface CompactionPreviewOptions {
31
+ /** Messages currently in the conversation. */
32
+ readonly messages: readonly ProviderMessage[];
33
+ /** Context window size for the current model (0 if unknown). */
34
+ readonly contextWindow: number;
35
+ /** Number of session memories that will survive compaction. */
36
+ readonly pinnedMemoryCount: number;
37
+ /** Whether this is triggered automatically or manually. */
38
+ readonly trigger: 'auto' | 'manual';
39
+ }
40
+
41
+ export interface CompactionAfterOptions {
42
+ /** The CompactionEvent returned by the SDK after compaction completes. */
43
+ readonly event: CompactionEvent;
44
+ /** Number of session memories that survived compaction. */
45
+ readonly pinnedMemoryCount: number;
46
+ }
47
+
48
+ /**
49
+ * Build the pre-compaction notice string.
50
+ *
51
+ * Returned as a plain string intended for `ctx.print()` or `systemMessageRouter`.
52
+ * Always labelled as an estimate; uses the SDK totalCeiling as the after-estimate.
53
+ */
54
+ export function buildCompactionPreview(opts: CompactionPreviewOptions): string {
55
+ const { messages, contextWindow, pinnedMemoryCount, trigger } = opts;
56
+ const msgCount = messages.length;
57
+ const tokensBefore = estimateConversationTokens(messages as ProviderMessage[]);
58
+ const tokensAfterEstimate = COMPACTION_OUTPUT_CEILING_ESTIMATE;
59
+
60
+ const contextStr = contextWindow > 0
61
+ ? ` (${Math.round((tokensBefore / contextWindow) * 100)}% of ${fmtN(contextWindow)} context window)`
62
+ : '';
63
+
64
+ const pinStr = pinnedMemoryCount > 0
65
+ ? ` ${pinnedMemoryCount} pinned session memor${pinnedMemoryCount === 1 ? 'y' : 'ies'} will be preserved.`
66
+ : '';
67
+
68
+ const triggerStr = trigger === 'auto' ? 'Auto-compacting' : 'Compacting';
69
+
70
+ return (
71
+ `[Context] ${triggerStr} conversation: ~${fmtN(tokensBefore)} tokens across ${msgCount} message${msgCount === 1 ? '' : 's'}${contextStr}.` +
72
+ ` Estimated result: ~${fmtN(tokensAfterEstimate)} tokens (estimate — actual depends on content).` +
73
+ (pinStr ? ` ${pinStr.trim()}` : '')
74
+ );
75
+ }
76
+
77
+ /**
78
+ * Build the post-compaction before/after notice string.
79
+ *
80
+ * Uses the real CompactionEvent figures (not estimates) for both before and
81
+ * after token counts. The trigger field controls wording.
82
+ */
83
+ export function buildCompactionAfterNotice(opts: CompactionAfterOptions): string {
84
+ const { event, pinnedMemoryCount } = opts;
85
+ const {
86
+ messagesBeforeCompaction,
87
+ messagesAfterCompaction,
88
+ tokensBeforeEstimate,
89
+ tokensAfterEstimate,
90
+ trigger,
91
+ } = event;
92
+
93
+ const savings = Math.max(0, tokensBeforeEstimate - tokensAfterEstimate);
94
+ const savingsPct = tokensBeforeEstimate > 0
95
+ ? Math.round((savings / tokensBeforeEstimate) * 100)
96
+ : 0;
97
+
98
+ const pinStr = pinnedMemoryCount > 0
99
+ ? ` ${pinnedMemoryCount} pinned memor${pinnedMemoryCount === 1 ? 'y' : 'ies'} preserved.`
100
+ : '';
101
+
102
+ const triggerStr = trigger === 'auto' ? 'Auto-compact complete' : 'Compact complete';
103
+
104
+ return (
105
+ `[Context] ${triggerStr}: ${messagesBeforeCompaction} → ${messagesAfterCompaction} messages,` +
106
+ ` ~${fmtN(tokensBeforeEstimate)} → ~${fmtN(tokensAfterEstimate)} tokens` +
107
+ ` (saved ~${fmtN(savings)}, ${savingsPct}%).` +
108
+ (pinStr ? ` ${pinStr.trim()}` : '')
109
+ );
110
+ }
111
+
112
+ /** Format a number with thousands separators. */
113
+ function fmtN(n: number): string {
114
+ return n.toLocaleString();
115
+ }
116
+
117
+ /**
118
+ * Build the /keep command usage text (shown when no args are provided).
119
+ *
120
+ * Exported for testability — the shell-core handler renders this string directly.
121
+ */
122
+ export function buildPinUsageText(): string {
123
+ return (
124
+ '[Pin] Usage: /keep <text>\n' +
125
+ 'Pinned entries are stored as session memories and included in the compaction handoff as pinned memories.\n' +
126
+ 'What pinning guarantees: the text survives the next compaction.\n' +
127
+ 'What pinning does NOT guarantee: recovery after process restart (session memories are in-memory only).'
128
+ );
129
+ }
130
+
131
+ /**
132
+ * Build the /keep command success text.
133
+ *
134
+ * @param id - The assigned memory ID (e.g. "mem-1")
135
+ * @param text - The pinned text
136
+ * @param count - Total pinned memory count after adding
137
+ *
138
+ * Exported for testability — the shell-core handler renders this string directly.
139
+ */
140
+ export function buildPinSuccessText(id: string, text: string, count: number): string {
141
+ return (
142
+ `[Pin] Pinned as ${id}: "${text.slice(0, 60)}${text.length > 60 ? '...' : ''}"\n` +
143
+ ` ${count} pinned memor${count === 1 ? 'y' : 'ies'} will survive the next compaction.\n` +
144
+ ' Note: session memories are in-memory only and do not persist across restarts.'
145
+ );
146
+ }
@@ -1,6 +1,6 @@
1
1
  import { type Line, type Cell, createStyledCell } from '../types/grid.ts';
2
2
  import { UIFactory } from './ui-factory.ts';
3
- import { getDisplayWidth } from '../utils/terminal-width.ts';
3
+ import { getDisplayWidth, padDisplayEnd } from '../utils/terminal-width.ts';
4
4
 
5
5
  /**
6
6
  * renderDiffView - Render a unified diff string as styled Line[].
@@ -13,7 +13,7 @@ export function renderDiffView(diffText: string, width: number, filename?: strin
13
13
  // Filename header
14
14
  if (filename) {
15
15
  const header = ` ≡ ${filename} `;
16
- lines.push(UIFactory.stringToLine(header.padEnd(width), width, { fg: '#1a1a1a', bg: '#569cd6', bold: true }));
16
+ lines.push(UIFactory.stringToLine(padDisplayEnd(header, width), width, { fg: '#1a1a1a', bg: '#569cd6', bold: true }));
17
17
  }
18
18
 
19
19
  const diffLines = diffText.split('\n');
@@ -202,6 +202,7 @@ export function renderShortcutsOverlay(
202
202
  row('PageUp / PageDn', 'Scroll by full page'),
203
203
  row('Home / End', 'Jump to start / end of line'),
204
204
  row(kb('search'), 'Search conversation'),
205
+ row('n / N (search)', 'Next / previous match'),
205
206
  row('Mouse wheel', 'Scroll conversation or hovered panel'),
206
207
  '',
207
208
  ' Editing',
@@ -234,17 +234,29 @@ export function renderModelPickerOverlay(
234
234
  putRowText(providerLine, layout.margin + 2, contentW, fitDisplay(`Provider: ${selected.provider}`, contentW), '244');
235
235
  lines.push(providerLine);
236
236
 
237
- const caps = selected.capabilities ?? { reasoning: false, multimodal: false, toolCalling: false, codeEditing: false };
238
- const ctxStr = `Context: ${fmtContext(selected.contextWindow)}`;
239
- const capParts: string[] = [ctxStr];
240
- if (caps.reasoning) capParts.push('Reasoning: \u2713');
241
- if (caps.multimodal) capParts.push('Vision: \u2713');
242
- if (caps.toolCalling) capParts.push('Tools: \u2713');
243
- if (caps.codeEditing) capParts.push('Code: \u2713');
244
- const capText = capParts.join(' ');
245
- const capLine = createOverlayContentLine(width, layout, borderFg, DEFAULT_OVERLAY_PALETTE.bodyBg);
246
- putRowText(capLine, layout.margin + 2, contentW, fitDisplay(truncateDisplay(capText, contentW), contentW), '244');
247
- lines.push(capLine);
237
+ // Synthetic models show fallback ladder rungs instead of capabilities.
238
+ const syntheticChain = selected.provider === 'synthetic' ? picker.getSyntheticChain(selected.id) : null;
239
+ if (syntheticChain !== null && syntheticChain.length > 0) {
240
+ const rung0 = syntheticChain[0];
241
+ const rest = syntheticChain.length - 1;
242
+ const rung0Base = ` 0. ${rung0.provider}/${rung0.model}`;
243
+ const rung0Label = rest > 0 ? `${rung0Base} (+${rest} more)` : rung0Base;
244
+ const chainLine0 = createOverlayContentLine(width, layout, borderFg, DEFAULT_OVERLAY_PALETTE.bodyBg);
245
+ putRowText(chainLine0, layout.margin + 2, contentW, fitDisplay(truncateDisplay(rung0Label, contentW), contentW), '244');
246
+ lines.push(chainLine0);
247
+ } else {
248
+ const caps = selected.capabilities ?? { reasoning: false, multimodal: false, toolCalling: false, codeEditing: false };
249
+ const ctxStr = `Context: ${fmtContext(selected.contextWindow)}`;
250
+ const capParts: string[] = [ctxStr];
251
+ if (caps.reasoning) capParts.push('Reasoning: \u2713');
252
+ if (caps.multimodal) capParts.push('Vision: \u2713');
253
+ if (caps.toolCalling) capParts.push('Tools: \u2713');
254
+ if (caps.codeEditing) capParts.push('Code: \u2713');
255
+ const capText = capParts.join(' ');
256
+ const capLine = createOverlayContentLine(width, layout, borderFg, DEFAULT_OVERLAY_PALETTE.bodyBg);
257
+ putRowText(capLine, layout.margin + 2, contentW, fitDisplay(truncateDisplay(capText, contentW), contentW), '244');
258
+ lines.push(capLine);
259
+ }
248
260
  } else {
249
261
  lines.push(createOverlayContentLine(width, layout, borderFg, DEFAULT_OVERLAY_PALETTE.bodyBg));
250
262
  lines.push(createOverlayContentLine(width, layout, borderFg, DEFAULT_OVERLAY_PALETTE.bodyBg));
@@ -1,6 +1,6 @@
1
1
  import { type Line } from '../types/grid.ts';
2
2
  import { UIFactory } from './ui-factory.ts';
3
- import { getDisplayWidth } from '../utils/terminal-width.ts';
3
+ import { getDisplayWidth, padDisplayEnd } from '../utils/terminal-width.ts';
4
4
 
5
5
  // Rich spinner frames (used by progress indicators)
6
6
  export const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
@@ -17,7 +17,7 @@ export function renderSpinner(
17
17
  fg: string = '135'
18
18
  ): Line {
19
19
  const text = ` ${frame} ${label}`;
20
- return UIFactory.stringToLine(text.padEnd(width), width, { fg, bold: true });
20
+ return UIFactory.stringToLine(padDisplayEnd(text, width), width, { fg, bold: true });
21
21
  }
22
22
 
23
23
  /**
@@ -33,7 +33,7 @@ export function renderToolProgress(
33
33
  const counter = `[${current}/${total}]`;
34
34
  const text = ` ${counter} ${label}`;
35
35
  return [
36
- UIFactory.stringToLine(text.padEnd(width), width, { fg: '#ffcc00', bold: true }),
36
+ UIFactory.stringToLine(padDisplayEnd(text, width), width, { fg: '#ffcc00', bold: true }),
37
37
  ];
38
38
  }
39
39
 
@@ -5,16 +5,19 @@ import { createBottomBarLine, writeBottomBarText } from './bottom-bar.ts';
5
5
 
6
6
  /**
7
7
  * Render the search bar as a single Line[] overlay at the bottom of the viewport.
8
- * Format: [ Find: <query> 3/17 up/down [n] next [N] prev [Esc] close ]
8
+ * Format: [ Find: <query> 3/17 [n/N] next/prev [Esc] close ]
9
9
  * The match count is dim grey; the rest of the bar is teal.
10
+ *
11
+ * Case-insensitive search (query lowercased before matching rendered cell text).
10
12
  */
11
13
  export function renderSearchOverlay(
12
14
  manager: SearchManager,
13
15
  width: number
14
16
  ): Line[] {
15
- // Match count text — displayed in dim grey, right of query, left of hints
16
- const matchCount = manager.matches?.length > 0
17
- ? `${manager.currentMatch + 1}/${manager.matches.length} up/down`
17
+ // Match count / status text — displayed in dim grey, right of query
18
+ const hasMatches = manager.matches.length > 0;
19
+ const matchCount = hasMatches
20
+ ? `${manager.currentMatch + 1}/${manager.matches.length}${manager.wrapAround ? ' (wrap)' : ''}`
18
21
  : manager.query.length > 0
19
22
  ? 'No matches'
20
23
  : '';
@@ -23,7 +26,7 @@ export function renderSearchOverlay(
23
26
  const cursor = locked ? '' : '█';
24
27
  const queryDisplay = manager.query + cursor;
25
28
  const hints = locked
26
- ? ' [Up/Down] or [jk] navigate [Bksp] edit [Esc] close'
29
+ ? ' [n/N] next/prev [jk] navigate [Bksp] edit [Esc] close'
27
30
  : ' [Enter/Tab] lock [Esc] close';
28
31
  const label = ' Find: ';
29
32
  const matchStr = matchCount ? ` ${matchCount}` : '';