@pellux/goodvibes-tui 0.26.0 → 0.28.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 (148) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +12 -7
  3. package/docs/foundation-artifacts/operator-contract.json +4 -1
  4. package/package.json +2 -2
  5. package/src/cli/bundle-command.ts +2 -1
  6. package/src/cli/service-posture.ts +2 -1
  7. package/src/core/context-usage.ts +53 -0
  8. package/src/core/conversation-rendering.ts +2 -2
  9. package/src/core/conversation.ts +53 -8
  10. package/src/core/session-recovery.ts +26 -18
  11. package/src/core/system-message-router.ts +42 -26
  12. package/src/core/turn-event-wiring.ts +13 -16
  13. package/src/daemon/handlers/calendar/caldav-client.ts +2 -1
  14. package/src/daemon/handlers/inbox/index.ts +2 -1
  15. package/src/daemon/handlers/inbox/poller.ts +2 -1
  16. package/src/daemon/handlers/inbox/providers/discord.ts +2 -1
  17. package/src/daemon/handlers/inbox/providers/email.ts +2 -1
  18. package/src/daemon/handlers/inbox/providers/route-util.ts +2 -1
  19. package/src/daemon/handlers/inbox/providers/slack.ts +2 -1
  20. package/src/daemon/handlers/triage/integration.ts +2 -1
  21. package/src/daemon/handlers/triage/pipeline.ts +2 -1
  22. package/src/daemon/handlers/triage/tagger/discord.ts +2 -1
  23. package/src/daemon/handlers/triage/tagger/imap.ts +4 -3
  24. package/src/export/gist-uploader.ts +3 -1
  25. package/src/input/command-registry.ts +1 -0
  26. package/src/input/commands/cloudflare-runtime.ts +2 -1
  27. package/src/input/commands/eval.ts +4 -3
  28. package/src/input/commands/guidance-runtime.ts +2 -4
  29. package/src/input/commands/health-runtime.ts +13 -7
  30. package/src/input/commands/knowledge.ts +2 -1
  31. package/src/input/commands/runtime-services.ts +40 -10
  32. package/src/input/handler-command-route.ts +1 -1
  33. package/src/input/handler-feed-routes.ts +61 -4
  34. package/src/input/handler-feed.ts +13 -0
  35. package/src/input/handler-interactions.ts +2 -1
  36. package/src/input/handler-modal-routes.ts +2 -1
  37. package/src/input/handler-onboarding-cloudflare.ts +2 -1
  38. package/src/input/handler-onboarding.ts +8 -8
  39. package/src/input/handler-picker-routes.ts +18 -13
  40. package/src/input/handler-ui-state.ts +5 -1
  41. package/src/input/keybindings.ts +5 -0
  42. package/src/input/model-picker.ts +5 -0
  43. package/src/input/panel-integration-actions.ts +10 -0
  44. package/src/input/tts-settings-actions.ts +2 -1
  45. package/src/main.ts +52 -60
  46. package/src/panels/agent-inspector-panel.ts +90 -15
  47. package/src/panels/agent-inspector-shared.ts +3 -3
  48. package/src/panels/agent-logs-panel.ts +149 -72
  49. package/src/panels/approval-panel.ts +146 -95
  50. package/src/panels/automation-control-panel.ts +24 -1
  51. package/src/panels/base-panel.ts +28 -3
  52. package/src/panels/builtin/agent.ts +3 -1
  53. package/src/panels/builtin/development.ts +2 -1
  54. package/src/panels/builtin/session.ts +1 -1
  55. package/src/panels/cockpit-panel.ts +24 -5
  56. package/src/panels/cockpit-read-model.ts +2 -1
  57. package/src/panels/communication-panel.ts +108 -43
  58. package/src/panels/context-visualizer-panel.ts +49 -15
  59. package/src/panels/control-plane-panel.ts +27 -1
  60. package/src/panels/cost-tracker-panel.ts +87 -65
  61. package/src/panels/debug-panel.ts +61 -37
  62. package/src/panels/diff-panel.ts +59 -30
  63. package/src/panels/docs-panel.ts +30 -24
  64. package/src/panels/eval-panel.ts +130 -81
  65. package/src/panels/expandable-list-panel.ts +189 -0
  66. package/src/panels/file-explorer-panel.ts +42 -42
  67. package/src/panels/file-preview-panel.ts +11 -3
  68. package/src/panels/forensics-panel.ts +27 -13
  69. package/src/panels/git-panel.ts +65 -84
  70. package/src/panels/hooks-panel.ts +36 -2
  71. package/src/panels/incident-review-panel.ts +31 -10
  72. package/src/panels/intelligence-panel.ts +152 -71
  73. package/src/panels/knowledge-graph-panel.ts +89 -27
  74. package/src/panels/local-auth-panel.ts +17 -11
  75. package/src/panels/marketplace-panel.ts +33 -13
  76. package/src/panels/memory-panel.ts +7 -5
  77. package/src/panels/ops-control-panel.ts +69 -7
  78. package/src/panels/ops-strategy-panel.ts +61 -43
  79. package/src/panels/orchestration-panel.ts +34 -4
  80. package/src/panels/panel-list-panel.ts +33 -8
  81. package/src/panels/panel-manager.ts +23 -4
  82. package/src/panels/plan-dashboard-panel.ts +183 -66
  83. package/src/panels/plugins-panel.ts +48 -10
  84. package/src/panels/policy-panel.ts +60 -23
  85. package/src/panels/polish-core.ts +157 -0
  86. package/src/panels/polish-tables.ts +258 -0
  87. package/src/panels/polish.ts +44 -145
  88. package/src/panels/project-planning-panel.ts +27 -4
  89. package/src/panels/provider-accounts-panel.ts +55 -18
  90. package/src/panels/provider-health-panel.ts +118 -87
  91. package/src/panels/provider-stats-panel.ts +47 -22
  92. package/src/panels/qr-panel.ts +23 -11
  93. package/src/panels/remote-panel.ts +12 -5
  94. package/src/panels/routes-panel.ts +27 -5
  95. package/src/panels/sandbox-panel.ts +62 -27
  96. package/src/panels/schedule-panel.ts +44 -24
  97. package/src/panels/scrollable-list-panel.ts +124 -10
  98. package/src/panels/security-panel.ts +72 -20
  99. package/src/panels/services-panel.ts +68 -22
  100. package/src/panels/session-browser-panel.ts +42 -26
  101. package/src/panels/session-maintenance.ts +2 -2
  102. package/src/panels/settings-sync-panel.ts +30 -15
  103. package/src/panels/skills-panel.ts +2 -1
  104. package/src/panels/subscription-panel.ts +21 -3
  105. package/src/panels/symbol-outline-panel.ts +40 -47
  106. package/src/panels/system-messages-panel.ts +60 -27
  107. package/src/panels/tasks-panel.ts +36 -14
  108. package/src/panels/thinking-panel.ts +32 -36
  109. package/src/panels/token-budget-panel.ts +80 -53
  110. package/src/panels/tool-inspector-panel.ts +37 -39
  111. package/src/panels/types.ts +25 -0
  112. package/src/panels/watchers-panel.ts +25 -5
  113. package/src/panels/work-plan-panel.ts +127 -35
  114. package/src/panels/worktree-panel.ts +65 -20
  115. package/src/panels/wrfc-panel-format.ts +133 -0
  116. package/src/panels/wrfc-panel.ts +53 -147
  117. package/src/renderer/agent-detail-modal.ts +2 -2
  118. package/src/renderer/compaction-preview.ts +2 -1
  119. package/src/renderer/compositor.ts +46 -43
  120. package/src/renderer/modal-utils.ts +0 -10
  121. package/src/renderer/model-picker-overlay.ts +9 -4
  122. package/src/renderer/model-workspace.ts +2 -3
  123. package/src/renderer/panel-composite.ts +7 -20
  124. package/src/renderer/panel-workspace-bar.ts +14 -8
  125. package/src/renderer/process-modal.ts +2 -2
  126. package/src/renderer/progress.ts +3 -2
  127. package/src/renderer/tab-strip.ts +148 -34
  128. package/src/renderer/ui-factory.ts +4 -6
  129. package/src/runtime/bootstrap-command-context.ts +3 -0
  130. package/src/runtime/bootstrap-command-parts.ts +3 -1
  131. package/src/runtime/bootstrap-core.ts +31 -31
  132. package/src/runtime/bootstrap-shell.ts +1 -0
  133. package/src/runtime/onboarding/apply.ts +4 -3
  134. package/src/runtime/onboarding/snapshot.ts +4 -3
  135. package/src/runtime/process-lifecycle.ts +195 -0
  136. package/src/runtime/services.ts +4 -1
  137. package/src/runtime/ui/model-picker/health-enrichment.ts +3 -0
  138. package/src/runtime/ui/model-picker/types.ts +4 -0
  139. package/src/runtime/wrfc-persistence.ts +20 -5
  140. package/src/shell/ui-openers.ts +3 -2
  141. package/src/utils/format-duration.ts +55 -0
  142. package/src/utils/format-number.ts +71 -0
  143. package/src/verification/live-verifier.ts +4 -3
  144. package/src/version.ts +1 -1
  145. package/src/core/context-auto-compact.ts +0 -110
  146. package/src/panels/panel-picker.ts +0 -106
  147. package/src/renderer/panel-picker-overlay.ts +0 -202
  148. package/src/renderer/panel-tab-bar.ts +0 -69
@@ -5,6 +5,7 @@ import { cleanupMarkerRegistry, expandPrompt, findMarkerAtPos, handleBlockCopy,
5
5
  import { clearModalStack, handleEscape, modalOpened } from './handler-modal-stack.ts';
6
6
  import { openOnboardingWizardState, type OpenOnboardingWizardOptions } from './handler-ui-state.ts';
7
7
  import type { InputHandlerLike as InputHandler } from './handler-types.ts';
8
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
8
9
 
9
10
  export function openOnboardingWizardForHandler(
10
11
  handler: InputHandler,
@@ -49,7 +50,7 @@ export async function hydrateOnboardingWizardFromRuntimeForHandler(handler: Inpu
49
50
  handler.requestRender();
50
51
  } catch (error) {
51
52
  if (!handler.onboardingWizard.active || hydrationSerial !== handler.onboardingHydrationSerial) return;
52
- const message = error instanceof Error ? error.message : String(error);
53
+ const message = summarizeError(error);
53
54
  handler.onboardingWizard.failRuntimeHydration(message);
54
55
  handler.commandContext?.print?.(`Onboarding runtime snapshot failed: ${message}`);
55
56
  handler.requestRender();
@@ -3,6 +3,7 @@ import type { SelectionResult, SelectionAction } from './selection-modal.ts';
3
3
  import type { CommandContext } from './command-registry.ts';
4
4
  import { openTtsProviderPicker, openTtsVoicePicker } from './tts-settings-actions.ts';
5
5
  import { isTextBackspace } from './delete-key-policy.ts';
6
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
6
7
 
7
8
  type SelectionRouteState = {
8
9
  selectionModal: {
@@ -285,7 +286,7 @@ function consumeSettingsPickerRequest(state: SettingsRouteState): void {
285
286
  return;
286
287
  }
287
288
  void openTtsVoicePicker(state.commandContext).catch((error: unknown) => {
288
- state.commandContext?.print(`Unable to list TTS voices: ${error instanceof Error ? error.message : String(error)}`);
289
+ state.commandContext?.print(`Unable to list TTS voices: ${summarizeError(error)}`);
289
290
  state.requestRender();
290
291
  });
291
292
  return;
@@ -22,6 +22,7 @@ import {
22
22
  getCloudflareSetupSource,
23
23
  shouldShowCloudflareStep,
24
24
  } from './onboarding/onboarding-wizard-cloudflare.ts';
25
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
25
26
 
26
27
  type CloudflareOnboardingAction = Extract<OnboardingWizardAction,
27
28
  | 'cloudflare-token-requirements'
@@ -44,7 +45,7 @@ function normalizeCloudflareActionError(error: unknown): string {
44
45
  if (error instanceof CloudflareDaemonRouteError) {
45
46
  return `${error.message} (HTTP ${error.status}, ${error.code})`;
46
47
  }
47
- return error instanceof Error ? error.message : String(error);
48
+ return summarizeError(error);
48
49
  }
49
50
 
50
51
  function setCloudflareWizardStatusForHandler(
@@ -1,6 +1,6 @@
1
1
  import { createOAuthLocalListener } from '@pellux/goodvibes-sdk/platform/config';
2
2
  import { beginOpenAICodexLogin, exchangeOpenAICodexCode } from '@pellux/goodvibes-sdk/platform/config';
3
- import { openExternalUrl } from '@pellux/goodvibes-sdk/platform/utils';
3
+ import { summarizeError, openExternalUrl } from '@pellux/goodvibes-sdk/platform/utils';
4
4
  import { getProviderIdFromModel } from '../config/provider-model.ts';
5
5
  import { buildProviderAccountSnapshot } from '@/runtime/index.ts';
6
6
  import { OnboardingWizardController, type OnboardingWizardAction, type OnboardingWizardApplyFeedback } from './onboarding/onboarding-wizard.ts';
@@ -258,7 +258,7 @@ export async function handleOnboardingActionForHandler(handler: InputHandler, ac
258
258
  deleteWizardProgress(handler.uiServices.environment.shellPaths);
259
259
  } catch (markerError) {
260
260
  handler.commandContext?.print?.(
261
- `Onboarding check marker could not be written: ${markerError instanceof Error ? markerError.message : String(markerError)}`,
261
+ `Onboarding check marker could not be written: ${summarizeError(markerError)}`,
262
262
  );
263
263
  }
264
264
  const activationVerification = await handler.restartOnboardingExternalServicesIfNeeded(request);
@@ -275,7 +275,7 @@ export async function handleOnboardingActionForHandler(handler: InputHandler, ac
275
275
  severity: 'error',
276
276
  title: 'Apply failed',
277
277
  summary: 'The wizard could not persist these settings. No service restart was attempted.',
278
- messages: [error instanceof Error ? error.message : String(error)],
278
+ messages: [summarizeError(error)],
279
279
  });
280
280
  return;
281
281
  } finally {
@@ -346,7 +346,7 @@ export async function refreshOnboardingHydrationForHandler(handler: InputHandler
346
346
  handler.requestRender();
347
347
  } catch (error) {
348
348
  if (!handler.onboardingWizard.active || hydrationSerial !== handler.onboardingHydrationSerial) return;
349
- const message = error instanceof Error ? error.message : String(error);
349
+ const message = summarizeError(error);
350
350
  handler.onboardingWizard.failRuntimeHydration(message);
351
351
  handler.requestRender();
352
352
  }
@@ -405,7 +405,7 @@ export async function handleOpenAiSubscriptionStartForHandler(handler: InputHand
405
405
  listener?.close();
406
406
  handler.commandContext?.print?.([
407
407
  'OpenAI subscription sign-in could not start.',
408
- ` ${error instanceof Error ? error.message : String(error)}`,
408
+ ` ${summarizeError(error)}`,
409
409
  ].join('\n'));
410
410
  handler.requestRender();
411
411
  } finally {
@@ -449,7 +449,7 @@ export async function completeOpenAiSubscriptionFromListenerForHandler(
449
449
  } catch (error) {
450
450
  handler.commandContext?.print?.([
451
451
  'OpenAI subscription listener could not complete automatically.',
452
- ` listener: ${error instanceof Error ? error.message : String(error)}`,
452
+ ` listener: ${summarizeError(error)}`,
453
453
  'Paste the callback code or URL into the OpenAI callback field to finish in onboarding.',
454
454
  ].join('\n'));
455
455
  handler.requestRender();
@@ -501,7 +501,7 @@ export async function handleOpenAiSubscriptionFinishForHandler(handler: InputHan
501
501
  } catch (error) {
502
502
  handler.commandContext?.print?.([
503
503
  'OpenAI subscription sign-in could not finish.',
504
- ` ${error instanceof Error ? error.message : String(error)}`,
504
+ ` ${summarizeError(error)}`,
505
505
  ].join('\n'));
506
506
  handler.requestRender();
507
507
  } finally {
@@ -663,7 +663,7 @@ export async function restartOnboardingExternalServicesIfNeededForHandler(handle
663
663
  return [{
664
664
  id: 'runtime:activation-restart',
665
665
  status: 'fail',
666
- message: `Background services could not restart: ${error instanceof Error ? error.message : String(error)}`,
666
+ message: `Background services could not restart: ${summarizeError(error)}`,
667
667
  target: 'service',
668
668
  }];
669
669
  }
@@ -98,13 +98,22 @@ export function handleModelPickerToken(state: ModelPickerRouteState, token: Inpu
98
98
  if (capModel) {
99
99
  const rawInput = state.modelPicker.contextCapQuery.trim();
100
100
  const parsedCap = rawInput.length > 0 ? parseInt(rawInput, 10) : null;
101
- const validCap = parsedCap !== null && parsedCap > 0 && parsedCap <= 10_000_000 ? parsedCap : null;
102
- const effort = state.commandContext?.session.runtime.reasoningEffort ?? 'medium';
103
- const handled = state.onModelPickerCommit?.() ?? false;
104
- if (!handled) state.commandContext?.completeModelSelection?.({ model: capModel, effort, contextCap: validCap, target: state.modelPicker.target });
101
+ if (parsedCap !== null && (parsedCap <= 0 || parsedCap > 10_000_000)) {
102
+ // Non-empty but out of valid range — surface notice; keep picker open for correction.
103
+ state.modelPicker.contextCapError = 'Context cap must be 1–10,000,000';
104
+ } else {
105
+ state.modelPicker.contextCapError = null;
106
+ const validCap = parsedCap !== null && parsedCap > 0 && parsedCap <= 10_000_000 ? parsedCap : null;
107
+ const effort = state.commandContext?.session.runtime.reasoningEffort ?? 'medium';
108
+ const handled = state.onModelPickerCommit?.() ?? false;
109
+ if (!handled) state.commandContext?.completeModelSelection?.({ model: capModel, effort, contextCap: validCap, target: state.modelPicker.target });
110
+ state.modelPicker.close();
111
+ if (state.modalStack[state.modalStack.length - 1] === 'modelPicker') state.modalStack.pop();
112
+ }
113
+ } else {
114
+ state.modelPicker.close();
115
+ if (state.modalStack[state.modalStack.length - 1] === 'modelPicker') state.modalStack.pop();
105
116
  }
106
- state.modelPicker.close();
107
- if (state.modalStack[state.modalStack.length - 1] === 'modelPicker') state.modalStack.pop();
108
117
  }
109
118
  } else if (token.logicalName === 'up') {
110
119
  if (state.modelPicker.focusPane === 'targets') {
@@ -158,14 +167,10 @@ export function handleModelPickerToken(state: ModelPickerRouteState, token: Inpu
158
167
  if (state.modelPicker.mode === 'contextCap') {
159
168
  if (token.value.length === 1) state.modelPicker.appendContextCapChar(token.value);
160
169
  } else if ((state.modelPicker.mode === 'model' || state.modelPicker.mode === 'provider') && state.modelPicker.searchFocused) {
170
+ // When search is focused every printable char — including space — goes to the query.
171
+ // The space=context-cap shortcut remains active only in the non-search branch below.
161
172
  const ch = token.value;
162
- if (ch === ' ' && state.modelPicker.mode === 'model') {
163
- const selected = state.modelPicker.getSelected();
164
- if (selected && state.modelPicker.isLocalModel(selected)) state.modelPicker.enterContextCapMode(selected);
165
- else if (ch.length === 1 && ch >= ' ') state.modelPicker.appendChar(ch);
166
- } else if (ch.length === 1 && ch >= ' ') {
167
- state.modelPicker.appendChar(ch);
168
- }
173
+ if (ch.length === 1 && ch >= ' ') state.modelPicker.appendChar(ch);
169
174
  } else if (token.value === ' ' && state.modelPicker.mode === 'model') {
170
175
  const selected = state.modelPicker.getSelected();
171
176
  if (selected && state.modelPicker.isLocalModel(selected)) state.modelPicker.enterContextCapMode(selected);
@@ -345,7 +345,7 @@ export function handleHistorySearchToken(state: HistorySearchRouteState, token:
345
345
  if (token.logicalName === 'escape' || (token.ctrl && token.logicalName === 'g')) {
346
346
  state.prompt = state.historySearch.cancel();
347
347
  state.cursorPos = state.prompt.length;
348
- } else if (token.logicalName === 'return') {
348
+ } else if (token.logicalName === 'enter' || token.logicalName === 'return') {
349
349
  state.prompt = state.historySearch.accept();
350
350
  state.cursorPos = state.prompt.length;
351
351
  } else if (token.logicalName === 'backspace') {
@@ -354,6 +354,10 @@ export function handleHistorySearchToken(state: HistorySearchRouteState, token:
354
354
  state.historySearch.stepOlder();
355
355
  } else if (token.ctrl && token.logicalName === 's') {
356
356
  state.historySearch.stepNewer();
357
+ } else if (token.logicalName === 'up') {
358
+ state.historySearch.stepOlder();
359
+ } else if (token.logicalName === 'down') {
360
+ state.historySearch.stepNewer();
357
361
  }
358
362
  }
359
363
 
@@ -38,6 +38,7 @@ export type KeyAction =
38
38
  | 'panel-close-all'
39
39
  | 'panel-tab-next'
40
40
  | 'panel-tab-prev'
41
+ | 'panel-focus-toggle'
41
42
  | 'history-search'
42
43
  | 'search'
43
44
  | 'block-copy'
@@ -69,6 +70,7 @@ export const ACTION_DESCRIPTIONS: Record<KeyAction, string> = {
69
70
  'panel-close-all': 'Close all open panels',
70
71
  'panel-tab-next': 'Next workspace panel tab',
71
72
  'panel-tab-prev': 'Previous workspace panel tab',
73
+ 'panel-focus-toggle': 'Switch keyboard focus between top and bottom pane',
72
74
  'history-search': 'Reverse input history search',
73
75
  'search': 'Toggle conversation search',
74
76
  'block-copy': 'Copy nearest block to clipboard',
@@ -101,6 +103,9 @@ export const DEFAULT_KEYBINDINGS: Record<KeyAction, KeyCombo[]> = {
101
103
  'panel-close-all': [{ key: 'x', ctrl: true, shift: true }],
102
104
  'panel-tab-next': [{ key: ']', ctrl: true }],
103
105
  'panel-tab-prev': [{ key: '[', ctrl: true }],
106
+ // Ctrl+G: toggle keyboard focus between the top and bottom panes. Ctrl+G is
107
+ // otherwise unbound in the default table.
108
+ 'panel-focus-toggle': [{ key: 'g', ctrl: true }],
104
109
  'history-search': [{ key: 'r', ctrl: true }],
105
110
  'search': [{ key: 'f', ctrl: true }],
106
111
  'block-copy': [{ key: 'y', ctrl: true }],
@@ -92,6 +92,8 @@ export class ModelPickerModal {
92
92
  public contextCapPendingModel: ModelDefinition | null = null;
93
93
  /** Current input string in contextCap mode. */
94
94
  public contextCapQuery = '';
95
+ /** Validation error from the last context cap commit (e.g. out-of-range value); null when no error. */
96
+ public contextCapError: string | null = null;
95
97
 
96
98
  // ── Search / filter ─────────────────────────────────────────────────────────────────────────────
97
99
  /** Current search query string (empty = no filter). */
@@ -215,6 +217,7 @@ export class ModelPickerModal {
215
217
  this.previousMode = 'model';
216
218
  this.contextCapPendingModel = model;
217
219
  this.contextCapQuery = '';
220
+ this.contextCapError = null;
218
221
  this.mode = 'contextCap';
219
222
  }
220
223
 
@@ -245,6 +248,7 @@ export class ModelPickerModal {
245
248
  this.query = '';
246
249
  this.categoryFilter = 'all';
247
250
  this.capabilityFilter = 'none';
251
+ this.availableOnly = true;
248
252
  const filtered = this.getFilteredModels();
249
253
  const idx = filtered.findIndex(m => m.id === currentModelId);
250
254
  this.selectedIndex = idx >= 0 ? idx : 0;
@@ -310,6 +314,7 @@ export class ModelPickerModal {
310
314
  this.pendingModel = null;
311
315
  this.contextCapPendingModel = null;
312
316
  this.contextCapQuery = '';
317
+ this.contextCapError = null;
313
318
  this.searchFocused = false;
314
319
  this.selectedIndex = 0;
315
320
  this.scrollOffset = 0;
@@ -41,6 +41,16 @@ export function handlePanelIntegrationAction(
41
41
  ): boolean {
42
42
  if (!activePanel) return false;
43
43
 
44
+ // Prefer the panel's own integration hook when it provides one. Panels migrated
45
+ // onto the formal hook opt out of the instanceof routing below.
46
+ if (activePanel.handlePanelIntegrationAction) {
47
+ const consumed = activePanel.handlePanelIntegrationAction(key, {
48
+ panelManager,
49
+ executeCommand: commandContext?.executeCommand,
50
+ });
51
+ if (consumed) return true;
52
+ }
53
+
44
54
  if ((key === 'enter' || key === 'return' || key === 'right') && activePanel instanceof FileExplorerPanel) {
45
55
  const filePath = activePanel.getFocusedFilePath();
46
56
  if (!filePath) return false;
@@ -1,6 +1,7 @@
1
1
  import type { ConfigKey } from '@pellux/goodvibes-sdk/platform/config';
2
2
  import type { CommandContext } from './command-registry.ts';
3
3
  import type { SelectionItem } from './selection-modal.ts';
4
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
4
5
 
5
6
  function getStreamingTtsProviders(ctx: CommandContext): Array<{ id: string; label: string; capabilities: readonly string[] }> {
6
7
  const registry = ctx.platform.voiceProviderRegistry;
@@ -94,7 +95,7 @@ export async function openTtsVoicePicker(ctx: CommandContext, providerArg?: stri
94
95
  });
95
96
  return true;
96
97
  } catch (error) {
97
- ctx.print(`Unable to list TTS voices: ${error instanceof Error ? error.message : String(error)}`);
98
+ ctx.print(`Unable to list TTS voices: ${summarizeError(error)}`);
98
99
  return true;
99
100
  }
100
101
  }
package/src/main.ts CHANGED
@@ -29,7 +29,6 @@ import { applyConversationOverlays } from './renderer/conversation-overlays.ts';
29
29
  import { buildPanelCompositeData } from './renderer/panel-composite.ts';
30
30
  import { logger } from '@pellux/goodvibes-sdk/platform/utils';
31
31
  import { registerBuiltinPanels } from './panels/builtin-panels.ts';
32
- import { renderPanelTabBar } from './renderer/panel-tab-bar.ts';
33
32
  import { bootstrapRuntime } from './runtime/bootstrap.ts';
34
33
  import type { BootstrapContext } from './runtime/bootstrap.ts';
35
34
  import type { HITLMode } from '@pellux/goodvibes-sdk/platform/state';
@@ -52,6 +51,7 @@ import { renderToolCallBlock } from './renderer/tool-call.ts';
52
51
  import { wireSpokenTurnRuntime } from './audio/spoken-turn-wiring.ts';
53
52
  import { attachSpokenTurnModelRouting, createSpokenTurnInputOptions } from './audio/spoken-turn-model-routing.ts';
54
53
  import { allowTerminalWrite, installTuiTerminalOutputGuard } from './runtime/terminal-output-guard.ts';
54
+ import { installProcessLifecycle } from './runtime/process-lifecycle.ts';
55
55
  import { buildCommandArgsHint } from './input/command-args-hint.ts';
56
56
  import { summarizeRunningAgents } from './renderer/process-summary.ts';
57
57
  import { formatUserFacingErrorLine } from './core/format-user-error.ts';
@@ -165,6 +165,9 @@ async function main() {
165
165
 
166
166
  let scrollTop = 0;
167
167
  let scrollLocked = true;
168
+ // Cached from the overlay-aware clamp the renderer computes (conversation-layout) each
169
+ // frame so scroll() clamps against exactly what is displayed, not a footer estimate.
170
+ let lastMaxScroll: number | null = null;
168
171
  // Stream and tool-timer state; mutated by wireStreamEventMetrics handlers, read during render.
169
172
  const streamMetrics: StreamMetrics = {
170
173
  startTime: 0,
@@ -187,18 +190,23 @@ async function main() {
187
190
  if (input.onboardingWizard.active) return stdout.rows || 24;
188
191
  const promptLines: number = input.getVisiblePromptLineCount(getPromptContentWidth());
189
192
  const currentModel = providerRegistry.getCurrentModel();
190
- return (stdout.rows || 24) - 2 - estimateShellFooterHeight(promptLines, currentModel.contextWindow);
193
+ const contextWindow = providerRegistry.getContextWindowForModel(currentModel);
194
+ return (stdout.rows || 24) - 2 - estimateShellFooterHeight(promptLines, contextWindow);
191
195
  };
192
196
 
193
197
  const scroll = (delta: number) => {
194
- const vHeight = getViewportHeight();
195
- const maxScroll = Math.max(0, conversation.history.getLineCount() - vHeight);
198
+ // Prefer the last clamp the renderer computed (overlay- and real-footer-aware).
199
+ // The footer estimate is only a fallback for the pre-first-render frame.
200
+ const maxScroll = lastMaxScroll ?? Math.max(0, conversation.history.getLineCount() - getViewportHeight());
196
201
  scrollTop = Math.max(0, Math.min(scrollTop + delta, maxScroll));
197
202
  // Re-lock if user scrolled to bottom, otherwise unlock
198
203
  scrollLocked = scrollTop >= maxScroll;
199
204
  };
200
205
 
201
206
  const scrollToEnd = (vHeight: number) => {
207
+ // Respect a manual scroll-up by the user: only auto-follow the tail when parked at the
208
+ // bottom (scrollLocked). submitInput re-locks on new input so turns resume following.
209
+ if (!scrollLocked) return;
202
210
  scrollTop = Math.max(0, conversation.history.getLineCount() - vHeight);
203
211
  };
204
212
 
@@ -207,59 +215,31 @@ async function main() {
207
215
  let stopSpokenOutputForExit: (() => void) | null = null;
208
216
  let recoveryPending = false;
209
217
 
210
- const sigintHandler = (): void => input.feed('\x03');
211
- let _unhandledRejectionCount = 0;
212
- let _unhandledRejectionWindowStart = Date.now();
213
- const unhandledRejectionHandler = (reason: unknown): void => {
214
- const now = Date.now();
215
- if (now - _unhandledRejectionWindowStart > 10000) {
216
- _unhandledRejectionCount = 0;
217
- _unhandledRejectionWindowStart = now;
218
- }
219
- _unhandledRejectionCount++;
220
- const msg = reason instanceof Error ? reason.message : String(reason);
221
- if (_unhandledRejectionCount > 3) {
222
- logger.error('CRITICAL: cascading unhandled rejections — consider restarting', {
223
- count: _unhandledRejectionCount,
224
- windowMs: now - _unhandledRejectionWindowStart,
225
- error: String(reason),
226
- });
227
- systemMessageRouter.high(
228
- `[Critical] Multiple errors detected (${_unhandledRejectionCount} in 10s). If the issue persists, please restart. Latest: ${msg}`
229
- );
230
- } else {
231
- const formatted = formatUserFacingErrorLine(reason);
232
- systemMessageRouter.high(`[Error] ${formatted}`);
233
- logger.error('unhandledRejection', { error: String(reason) });
234
- }
235
- render();
236
- };
237
- const resizeHandler = (): void => {
238
- input.setContentWidth(getPromptContentWidth());
239
- compositor.resetDiff();
240
- render();
241
- };
242
-
243
- const exitApp = (): void => {
244
- stopSpokenOutputForExit?.();
245
- unsubs.forEach(fn => fn());
246
- const snapshot = conversation.toJSON() as { messages: Array<import('./core/conversation.ts').ConversationMessageSnapshot>; timestamp?: number };
247
- ctx.shutdown({ ...snapshot, ...buildPersistedSessionContext(snapshot.messages, conversation.getTitleSource(), buildSessionContinuityHints()) }).catch((err) => {
248
- logger.debug('ctx.shutdown error during exitApp (non-fatal)', { error: summarizeError(err) });
249
- });
250
- if (recoveryInterval !== null) { clearInterval(recoveryInterval); recoveryInterval = null; }
251
- deleteRecoveryFile({ homeDirectory });
252
- stdin.removeAllListeners('data');
253
- stdout.removeListener('resize', resizeHandler);
254
- process.removeListener('SIGINT', sigintHandler);
255
- process.removeListener('unhandledRejection', unhandledRejectionHandler);
256
- const exitScreen = cli.flags.noAltScreen ? CLEAR_SCREEN : CLEAR_SCREEN + ALT_SCREEN_EXIT;
257
- allowTerminalWrite(() => stdout.write(PASTE_DISABLE + KEYBOARD_EXT_DISABLE + MOUSE_DISABLE + CURSOR_SHOW + exitScreen));
258
- terminalOutputGuard.dispose();
259
- stdin.setRawMode(false);
260
- process.exit(0);
261
- };
262
-
218
+ const lifecycle = installProcessLifecycle({
219
+ stdin,
220
+ stdout,
221
+ ctx,
222
+ noAltScreen: cli.flags.noAltScreen,
223
+ ansi: { CLEAR_SCREEN, ALT_SCREEN_EXIT, PASTE_DISABLE, KEYBOARD_EXT_DISABLE, MOUSE_DISABLE, CURSOR_SHOW },
224
+ getInput: () => input,
225
+ render: () => render(),
226
+ getPromptContentWidth,
227
+ getTerminalOutputGuard: () => terminalOutputGuard,
228
+ buildSessionContinuityHints,
229
+ unsubs,
230
+ getRecoveryInterval: () => recoveryInterval,
231
+ setRecoveryInterval: (value) => { recoveryInterval = value; },
232
+ getStopSpokenOutputForExit: () => stopSpokenOutputForExit,
233
+ });
234
+ const {
235
+ exitApp,
236
+ resizeHandler,
237
+ sigintHandler,
238
+ unhandledRejectionHandler,
239
+ uncaughtExceptionHandler,
240
+ terminationSignalHandler,
241
+ exitListener,
242
+ } = lifecycle;
263
243
  commandContext.exit = exitApp;
264
244
 
265
245
  const spokenTurns = wireSpokenTurnRuntime({
@@ -456,6 +436,9 @@ async function main() {
456
436
 
457
437
  // Cache the current model for consistent values across the entire render frame
458
438
  const currentModel = providerRegistry.getCurrentModel();
439
+ // Resolve the effective context window (provider_api / configured_cap overrides) once,
440
+ // so the footer meter, footer-height, and context inspector agree with the Tokens panel.
441
+ const contextWindow = providerRegistry.getContextWindowForModel(currentModel);
459
442
  const sessionSnapshot = uiServices.readModels.session.getSnapshot();
460
443
  const agentSnapshot = uiServices.readModels.agents.getSnapshot();
461
444
 
@@ -481,7 +464,7 @@ async function main() {
481
464
  const maintenanceStatus = evaluateSessionMaintenance({
482
465
  configManager,
483
466
  currentTokens: orchestrator.lastInputTokens,
484
- contextWindow: currentModel.contextWindow,
467
+ contextWindow,
485
468
  sessionMemoryCount: ctx.services.sessionMemoryStore.list().length,
486
469
  });
487
470
  const contextStatusHint = buildContextStatusHint({
@@ -505,7 +488,7 @@ async function main() {
505
488
  toolCount: toolRegistry.list().length,
506
489
  workingDir,
507
490
  provider: runtime.provider,
508
- contextWindow: currentModel.contextWindow,
491
+ contextWindow,
509
492
  contextStatusHint,
510
493
  // behavior.autoCompactThreshold is stored as a percent integer (e.g. 80);
511
494
  // the meter expects a fraction [0..1]. Clamp to [0,1] to guard nonsense values.
@@ -586,6 +569,7 @@ async function main() {
586
569
  overlayRows,
587
570
  });
588
571
  scrollTop = conversationViewport.nextScrollTop;
572
+ lastMaxScroll = conversationViewport.maxScroll;
589
573
  let viewport = conversationViewport.viewport;
590
574
 
591
575
  if (orchestrator.isThinking) {
@@ -628,7 +612,7 @@ async function main() {
628
612
  keybindingsManager: ctx.services.keybindingsManager,
629
613
  conversationWidth,
630
614
  viewportHeight: vHeight,
631
- contextWindow: currentModel.contextWindow,
615
+ contextWindow,
632
616
  });
633
617
 
634
618
  // Panel composite data
@@ -732,6 +716,14 @@ async function main() {
732
716
  }
733
717
  });
734
718
 
719
+ // Register terminal-restoring crash/termination handlers BEFORE entering raw mode so a
720
+ // throw during terminal setup or the initial render still restores the terminal; the
721
+ // 'exit' listener is the final safety net for any process.exit path.
722
+ process.on('uncaughtException', uncaughtExceptionHandler);
723
+ process.on('SIGTERM', terminationSignalHandler);
724
+ process.on('SIGHUP', terminationSignalHandler);
725
+ process.on('exit', exitListener);
726
+
735
727
  // --- Terminal setup ---
736
728
  stdin.setRawMode(true);
737
729
  stdin.resume();