@pellux/goodvibes-tui 0.25.0 → 0.27.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 (146) hide show
  1. package/CHANGELOG.md +22 -3
  2. package/README.md +12 -7
  3. package/docs/foundation-artifacts/operator-contract.json +2422 -1040
  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/conversation-rendering.ts +2 -2
  8. package/src/core/conversation.ts +49 -2
  9. package/src/core/session-recovery.ts +24 -18
  10. package/src/core/system-message-router.ts +42 -26
  11. package/src/core/turn-event-wiring.ts +12 -16
  12. package/src/daemon/{calendar → handlers/calendar}/caldav-client.ts +29 -24
  13. package/src/daemon/handlers/calendar/index.ts +316 -0
  14. package/src/daemon/handlers/context.ts +29 -0
  15. package/src/daemon/handlers/contracts.ts +77 -0
  16. package/src/daemon/{channels → handlers}/drafts/draft-store.ts +114 -50
  17. package/src/daemon/handlers/drafts/index.ts +17 -0
  18. package/src/daemon/handlers/drafts/register.ts +331 -0
  19. package/src/daemon/handlers/email/config.ts +164 -0
  20. package/src/daemon/handlers/email/index.ts +43 -0
  21. package/src/daemon/handlers/email/read-handlers.ts +80 -0
  22. package/src/daemon/handlers/email/runtime.ts +140 -0
  23. package/src/daemon/handlers/email/validation.ts +147 -0
  24. package/src/daemon/handlers/email/write-handlers.ts +133 -0
  25. package/src/daemon/handlers/errors.ts +18 -0
  26. package/src/daemon/{channels → handlers}/inbox/cursor-store.ts +58 -66
  27. package/src/daemon/handlers/inbox/index.ts +211 -0
  28. package/src/daemon/{channels → handlers}/inbox/mapping.ts +8 -6
  29. package/src/daemon/{channels → handlers}/inbox/poller.ts +6 -5
  30. package/src/daemon/{channels → handlers}/inbox/provider-adapter.ts +14 -10
  31. package/src/daemon/{channels → handlers}/inbox/providers/discord.ts +10 -11
  32. package/src/daemon/{channels → handlers}/inbox/providers/email.ts +2 -1
  33. package/src/daemon/{channels → handlers}/inbox/providers/imap-client.ts +1 -1
  34. package/src/daemon/{channels → handlers}/inbox/providers/route-util.ts +4 -3
  35. package/src/daemon/{channels → handlers}/inbox/providers/slack.ts +11 -12
  36. package/src/daemon/handlers/index.ts +107 -0
  37. package/src/daemon/handlers/register.ts +161 -0
  38. package/src/daemon/{remote → handlers/remote}/backends/cloud-terminal.ts +8 -3
  39. package/src/daemon/{remote → handlers/remote}/backends/docker.ts +2 -3
  40. package/src/daemon/handlers/remote/backends/index.ts +40 -0
  41. package/src/daemon/{remote → handlers/remote}/backends/process-runner.ts +16 -40
  42. package/src/daemon/{remote → handlers/remote}/backends/ssh.ts +8 -3
  43. package/src/daemon/{remote → handlers/remote}/backends/types.ts +29 -3
  44. package/src/daemon/{remote → handlers/remote}/dispatcher.ts +27 -6
  45. package/src/daemon/handlers/remote/index.ts +119 -0
  46. package/src/daemon/{remote → handlers/remote}/peer-registry.ts +47 -11
  47. package/src/daemon/handlers/remote/service.ts +191 -0
  48. package/src/daemon/{channels → handlers}/routing/inbox-bridge.ts +33 -20
  49. package/src/daemon/handlers/routing/index.ts +261 -0
  50. package/src/daemon/{channels → handlers}/routing/route-store.ts +57 -16
  51. package/src/daemon/{channels → handlers}/routing/routing-resolver.ts +1 -1
  52. package/src/daemon/{operator → handlers}/sqlite-store.ts +5 -5
  53. package/src/daemon/handlers/triage/index.ts +57 -0
  54. package/src/daemon/handlers/triage/integration.ts +213 -0
  55. package/src/daemon/{triage → handlers/triage}/pipeline.ts +60 -71
  56. package/src/daemon/{triage → handlers/triage}/scorer.ts +21 -21
  57. package/src/daemon/handlers/triage/tagger/discord.ts +187 -0
  58. package/src/daemon/handlers/triage/tagger/imap.ts +384 -0
  59. package/src/daemon/handlers/triage/tagger/index.ts +184 -0
  60. package/src/daemon/handlers/triage/tagger/shared.ts +70 -0
  61. package/src/daemon/handlers/triage/tagger/slack.ts +69 -0
  62. package/src/daemon/handlers/triage/types.ts +50 -0
  63. package/src/export/gist-uploader.ts +3 -1
  64. package/src/input/command-registry.ts +1 -0
  65. package/src/input/commands/cloudflare-runtime.ts +2 -1
  66. package/src/input/commands/guidance-runtime.ts +2 -4
  67. package/src/input/commands/health-runtime.ts +13 -7
  68. package/src/input/commands/knowledge.ts +2 -1
  69. package/src/input/commands/runtime-services.ts +40 -10
  70. package/src/input/handler-command-route.ts +1 -1
  71. package/src/input/handler-interactions.ts +2 -1
  72. package/src/input/handler-modal-routes.ts +2 -1
  73. package/src/input/handler-onboarding-cloudflare.ts +2 -1
  74. package/src/input/handler-onboarding.ts +8 -8
  75. package/src/input/handler-ui-state.ts +1 -1
  76. package/src/input/tts-settings-actions.ts +2 -1
  77. package/src/main.ts +52 -59
  78. package/src/panels/agent-inspector-panel.ts +72 -2
  79. package/src/panels/agent-logs-panel.ts +127 -19
  80. package/src/panels/base-panel.ts +2 -1
  81. package/src/panels/builtin/agent.ts +3 -1
  82. package/src/panels/builtin/development.ts +1 -0
  83. package/src/panels/builtin/session.ts +1 -1
  84. package/src/panels/context-visualizer-panel.ts +24 -14
  85. package/src/panels/cost-tracker-panel.ts +7 -13
  86. package/src/panels/debug-panel.ts +11 -22
  87. package/src/panels/docs-panel.ts +14 -24
  88. package/src/panels/file-explorer-panel.ts +2 -1
  89. package/src/panels/file-preview-panel.ts +2 -1
  90. package/src/panels/git-panel.ts +10 -17
  91. package/src/panels/marketplace-panel.ts +2 -1
  92. package/src/panels/memory-panel.ts +2 -1
  93. package/src/panels/project-planning-panel.ts +5 -4
  94. package/src/panels/provider-health-panel.ts +56 -67
  95. package/src/panels/schedule-panel.ts +4 -7
  96. package/src/panels/session-browser-panel.ts +13 -21
  97. package/src/panels/skills-panel.ts +2 -1
  98. package/src/panels/tasks-panel.ts +2 -7
  99. package/src/panels/thinking-panel.ts +6 -11
  100. package/src/panels/token-budget-panel.ts +31 -15
  101. package/src/panels/tool-inspector-panel.ts +10 -18
  102. package/src/panels/work-plan-panel.ts +2 -1
  103. package/src/panels/wrfc-panel.ts +37 -35
  104. package/src/renderer/agent-detail-modal.ts +2 -2
  105. package/src/renderer/modal-utils.ts +0 -10
  106. package/src/renderer/process-modal.ts +2 -2
  107. package/src/runtime/bootstrap-command-context.ts +3 -0
  108. package/src/runtime/bootstrap-command-parts.ts +3 -1
  109. package/src/runtime/bootstrap-core.ts +31 -31
  110. package/src/runtime/bootstrap-shell.ts +1 -0
  111. package/src/runtime/onboarding/apply.ts +4 -3
  112. package/src/runtime/onboarding/snapshot.ts +4 -3
  113. package/src/runtime/process-lifecycle.ts +195 -0
  114. package/src/runtime/services.ts +52 -36
  115. package/src/runtime/wrfc-persistence.ts +20 -5
  116. package/src/shell/ui-openers.ts +3 -2
  117. package/src/verification/live-verifier.ts +4 -3
  118. package/src/version.ts +1 -1
  119. package/src/core/context-auto-compact.ts +0 -110
  120. package/src/daemon/calendar/index.ts +0 -52
  121. package/src/daemon/calendar/register.ts +0 -527
  122. package/src/daemon/channels/drafts/index.ts +0 -22
  123. package/src/daemon/channels/drafts/register.ts +0 -449
  124. package/src/daemon/channels/inbox/index.ts +0 -58
  125. package/src/daemon/channels/inbox/register.ts +0 -247
  126. package/src/daemon/channels/routing/index.ts +0 -39
  127. package/src/daemon/channels/routing/register.ts +0 -296
  128. package/src/daemon/email/index.ts +0 -68
  129. package/src/daemon/email/register.ts +0 -715
  130. package/src/daemon/operator/index.ts +0 -43
  131. package/src/daemon/operator/register-helper.ts +0 -150
  132. package/src/daemon/operator/surfaces.ts +0 -137
  133. package/src/daemon/operator/types.ts +0 -207
  134. package/src/daemon/remote/backends/index.ts +0 -34
  135. package/src/daemon/remote/index.ts +0 -74
  136. package/src/daemon/remote/register.ts +0 -411
  137. package/src/daemon/triage/index.ts +0 -59
  138. package/src/daemon/triage/integration.ts +0 -179
  139. package/src/daemon/triage/register.ts +0 -231
  140. package/src/daemon/triage/tagger.ts +0 -777
  141. /package/src/daemon/{calendar → handlers/calendar}/ics.ts +0 -0
  142. /package/src/daemon/{operator/credential-store.ts → handlers/credentials.ts} +0 -0
  143. /package/src/daemon/{email → handlers/email}/imap-connector.ts +0 -0
  144. /package/src/daemon/{email → handlers/email}/imap-parsing.ts +0 -0
  145. /package/src/daemon/{email → handlers/email}/smtp-connector.ts +0 -0
  146. /package/src/daemon/{remote → handlers/remote}/backends/local-process.ts +0 -0
@@ -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
  }
@@ -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') {
@@ -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
@@ -52,6 +52,7 @@ import { renderToolCallBlock } from './renderer/tool-call.ts';
52
52
  import { wireSpokenTurnRuntime } from './audio/spoken-turn-wiring.ts';
53
53
  import { attachSpokenTurnModelRouting, createSpokenTurnInputOptions } from './audio/spoken-turn-model-routing.ts';
54
54
  import { allowTerminalWrite, installTuiTerminalOutputGuard } from './runtime/terminal-output-guard.ts';
55
+ import { installProcessLifecycle } from './runtime/process-lifecycle.ts';
55
56
  import { buildCommandArgsHint } from './input/command-args-hint.ts';
56
57
  import { summarizeRunningAgents } from './renderer/process-summary.ts';
57
58
  import { formatUserFacingErrorLine } from './core/format-user-error.ts';
@@ -165,6 +166,9 @@ async function main() {
165
166
 
166
167
  let scrollTop = 0;
167
168
  let scrollLocked = true;
169
+ // Cached from the overlay-aware clamp the renderer computes (conversation-layout) each
170
+ // frame so scroll() clamps against exactly what is displayed, not a footer estimate.
171
+ let lastMaxScroll: number | null = null;
168
172
  // Stream and tool-timer state; mutated by wireStreamEventMetrics handlers, read during render.
169
173
  const streamMetrics: StreamMetrics = {
170
174
  startTime: 0,
@@ -187,18 +191,23 @@ async function main() {
187
191
  if (input.onboardingWizard.active) return stdout.rows || 24;
188
192
  const promptLines: number = input.getVisiblePromptLineCount(getPromptContentWidth());
189
193
  const currentModel = providerRegistry.getCurrentModel();
190
- return (stdout.rows || 24) - 2 - estimateShellFooterHeight(promptLines, currentModel.contextWindow);
194
+ const contextWindow = providerRegistry.getContextWindowForModel(currentModel);
195
+ return (stdout.rows || 24) - 2 - estimateShellFooterHeight(promptLines, contextWindow);
191
196
  };
192
197
 
193
198
  const scroll = (delta: number) => {
194
- const vHeight = getViewportHeight();
195
- const maxScroll = Math.max(0, conversation.history.getLineCount() - vHeight);
199
+ // Prefer the last clamp the renderer computed (overlay- and real-footer-aware).
200
+ // The footer estimate is only a fallback for the pre-first-render frame.
201
+ const maxScroll = lastMaxScroll ?? Math.max(0, conversation.history.getLineCount() - getViewportHeight());
196
202
  scrollTop = Math.max(0, Math.min(scrollTop + delta, maxScroll));
197
203
  // Re-lock if user scrolled to bottom, otherwise unlock
198
204
  scrollLocked = scrollTop >= maxScroll;
199
205
  };
200
206
 
201
207
  const scrollToEnd = (vHeight: number) => {
208
+ // Respect a manual scroll-up by the user: only auto-follow the tail when parked at the
209
+ // bottom (scrollLocked). submitInput re-locks on new input so turns resume following.
210
+ if (!scrollLocked) return;
202
211
  scrollTop = Math.max(0, conversation.history.getLineCount() - vHeight);
203
212
  };
204
213
 
@@ -207,59 +216,31 @@ async function main() {
207
216
  let stopSpokenOutputForExit: (() => void) | null = null;
208
217
  let recoveryPending = false;
209
218
 
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
-
219
+ const lifecycle = installProcessLifecycle({
220
+ stdin,
221
+ stdout,
222
+ ctx,
223
+ noAltScreen: cli.flags.noAltScreen,
224
+ ansi: { CLEAR_SCREEN, ALT_SCREEN_EXIT, PASTE_DISABLE, KEYBOARD_EXT_DISABLE, MOUSE_DISABLE, CURSOR_SHOW },
225
+ getInput: () => input,
226
+ render: () => render(),
227
+ getPromptContentWidth,
228
+ getTerminalOutputGuard: () => terminalOutputGuard,
229
+ buildSessionContinuityHints,
230
+ unsubs,
231
+ getRecoveryInterval: () => recoveryInterval,
232
+ setRecoveryInterval: (value) => { recoveryInterval = value; },
233
+ getStopSpokenOutputForExit: () => stopSpokenOutputForExit,
234
+ });
235
+ const {
236
+ exitApp,
237
+ resizeHandler,
238
+ sigintHandler,
239
+ unhandledRejectionHandler,
240
+ uncaughtExceptionHandler,
241
+ terminationSignalHandler,
242
+ exitListener,
243
+ } = lifecycle;
263
244
  commandContext.exit = exitApp;
264
245
 
265
246
  const spokenTurns = wireSpokenTurnRuntime({
@@ -456,6 +437,9 @@ async function main() {
456
437
 
457
438
  // Cache the current model for consistent values across the entire render frame
458
439
  const currentModel = providerRegistry.getCurrentModel();
440
+ // Resolve the effective context window (provider_api / configured_cap overrides) once,
441
+ // so the footer meter, footer-height, and context inspector agree with the Tokens panel.
442
+ const contextWindow = providerRegistry.getContextWindowForModel(currentModel);
459
443
  const sessionSnapshot = uiServices.readModels.session.getSnapshot();
460
444
  const agentSnapshot = uiServices.readModels.agents.getSnapshot();
461
445
 
@@ -481,7 +465,7 @@ async function main() {
481
465
  const maintenanceStatus = evaluateSessionMaintenance({
482
466
  configManager,
483
467
  currentTokens: orchestrator.lastInputTokens,
484
- contextWindow: currentModel.contextWindow,
468
+ contextWindow,
485
469
  sessionMemoryCount: ctx.services.sessionMemoryStore.list().length,
486
470
  });
487
471
  const contextStatusHint = buildContextStatusHint({
@@ -505,7 +489,7 @@ async function main() {
505
489
  toolCount: toolRegistry.list().length,
506
490
  workingDir,
507
491
  provider: runtime.provider,
508
- contextWindow: currentModel.contextWindow,
492
+ contextWindow,
509
493
  contextStatusHint,
510
494
  // behavior.autoCompactThreshold is stored as a percent integer (e.g. 80);
511
495
  // the meter expects a fraction [0..1]. Clamp to [0,1] to guard nonsense values.
@@ -586,6 +570,7 @@ async function main() {
586
570
  overlayRows,
587
571
  });
588
572
  scrollTop = conversationViewport.nextScrollTop;
573
+ lastMaxScroll = conversationViewport.maxScroll;
589
574
  let viewport = conversationViewport.viewport;
590
575
 
591
576
  if (orchestrator.isThinking) {
@@ -628,7 +613,7 @@ async function main() {
628
613
  keybindingsManager: ctx.services.keybindingsManager,
629
614
  conversationWidth,
630
615
  viewportHeight: vHeight,
631
- contextWindow: currentModel.contextWindow,
616
+ contextWindow,
632
617
  });
633
618
 
634
619
  // Panel composite data
@@ -732,6 +717,14 @@ async function main() {
732
717
  }
733
718
  });
734
719
 
720
+ // Register terminal-restoring crash/termination handlers BEFORE entering raw mode so a
721
+ // throw during terminal setup or the initial render still restores the terminal; the
722
+ // 'exit' listener is the final safety net for any process.exit path.
723
+ process.on('uncaughtException', uncaughtExceptionHandler);
724
+ process.on('SIGTERM', terminationSignalHandler);
725
+ process.on('SIGHUP', terminationSignalHandler);
726
+ process.on('exit', exitListener);
727
+
735
728
  // --- Terminal setup ---
736
729
  stdin.setRawMode(true);
737
730
  stdin.resume();
@@ -91,6 +91,15 @@ export interface AgentInspectorPanelDeps {
91
91
  readonly workingDirectory: string;
92
92
  /** Cancel the agent by id. Uses the same orphan-free path as WRFC. Returns true if cancelled. */
93
93
  readonly cancelAgent: (agentId: string) => boolean;
94
+ /**
95
+ * Request a compositor repaint. The 500ms refresh timer and other async
96
+ * update paths call this (via markDirty) so live agent output is painted
97
+ * while the main thread is idle — render() is otherwise only invoked on
98
+ * input/turn/resize, which makes a running agent's timeline look frozen.
99
+ * Optional: when omitted the panel still marks dirty and repaints on the
100
+ * next event.
101
+ */
102
+ readonly requestRender?: () => void;
94
103
  }
95
104
 
96
105
  export class AgentInspectorPanel extends BasePanel {
@@ -115,6 +124,9 @@ export class AgentInspectorPanel extends BasePanel {
115
124
  /** Pending cancel confirmation — subject is the agent id to cancel. */
116
125
  private confirmCancel: ConfirmState<string> | null = null;
117
126
 
127
+ /** True while this panel is the active view — gates async repaint requests. */
128
+ private _active = false;
129
+
118
130
  constructor(private readonly deps: AgentInspectorPanelDeps) {
119
131
  super('inspector', 'Inspector', 'I', 'agent');
120
132
  }
@@ -131,6 +143,10 @@ export class AgentInspectorPanel extends BasePanel {
131
143
  override markDirty(): void {
132
144
  this._cachedRows = null;
133
145
  super.markDirty();
146
+ // T17: a bare markDirty() does not repaint — render() only runs on
147
+ // input/turn/resize. While active, ask the compositor for a frame so
148
+ // timer/async refreshes (live streaming output) are not stuck off-screen.
149
+ if (this._active) this.deps.requestRender?.();
134
150
  }
135
151
 
136
152
  // -------------------------------------------------------------------------
@@ -152,11 +168,13 @@ export class AgentInspectorPanel extends BasePanel {
152
168
  // -------------------------------------------------------------------------
153
169
 
154
170
  override onActivate(): void {
171
+ this._active = true;
155
172
  this.needsRender = true;
156
173
  this._startRefresh();
157
174
  }
158
175
 
159
176
  override onDeactivate(): void {
177
+ this._active = false;
160
178
  this._stopRefresh();
161
179
  }
162
180
 
@@ -441,8 +459,21 @@ export class AgentInspectorPanel extends BasePanel {
441
459
 
442
460
  // Merge bus messages (live) + JSONL (historical), sorted by timestamp
443
461
  const busEntries = this._busToTimeline();
444
- const merged = [...this.timeline, ...busEntries]
445
- .sort((a, b) => a.timestamp - b.timestamp);
462
+ const merged = [...this.timeline, ...busEntries];
463
+
464
+ // T13: the JSONL timeline only records coarse per-turn summaries
465
+ // ([assistant] N chars, M tool calls) and never the model's actual text,
466
+ // so a mid-turn agent looks idle here. Surface the selected agent's live
467
+ // streaming output while it runs, and its final output once terminal.
468
+ const liveRec = this.selectedAgentId
469
+ ? this.deps.agentManager.getStatus(this.selectedAgentId)
470
+ : null;
471
+ if (liveRec) {
472
+ const liveEntry = this._buildLiveOutputEntry(liveRec);
473
+ if (liveEntry) merged.push(liveEntry);
474
+ }
475
+
476
+ merged.sort((a, b) => a.timestamp - b.timestamp);
446
477
 
447
478
  // Deduplicate: bus messages that already appear in JSONL will have
448
479
  // approximate timestamps. We just show all — bus msgs tend to have
@@ -505,6 +536,45 @@ export class AgentInspectorPanel extends BasePanel {
505
536
  return result;
506
537
  }
507
538
 
539
+ /**
540
+ * Build a synthetic timeline entry exposing the selected agent's live model
541
+ * output. While the agent runs this is the streaming token buffer
542
+ * (tail-truncated like AgentDetailModal); once it terminates it is the final
543
+ * assistant text (expandable). Exactly one is produced — the SDK clears
544
+ * streamingContent on completion — so live and final rows never coexist.
545
+ * Returns null when there is nothing to show.
546
+ */
547
+ private _buildLiveOutputEntry(rec: AgentRecord): TimelineEntry | null {
548
+ const STREAM_MAX_CHARS = 500;
549
+ if (rec.status === 'running' && rec.streamingContent) {
550
+ const sc = rec.streamingContent;
551
+ const truncated = sc.length > STREAM_MAX_CHARS;
552
+ const tail = (truncated ? sc.slice(-STREAM_MAX_CHARS) : sc)
553
+ .replace(/\s+/g, ' ')
554
+ .trim();
555
+ return {
556
+ kind: 'assistant',
557
+ timestamp: Date.now(),
558
+ label: 'streaming',
559
+ content: `(live) ${truncated ? '…' : ''}${tail}`,
560
+ expanded: false,
561
+ } satisfies TimelineEntry;
562
+ }
563
+ if (AGENT_TERMINAL_STATUSES.has(rec.status) && rec.fullOutput) {
564
+ const fo = rec.fullOutput;
565
+ const firstLine = (fo.split('\n').find((l) => l.trim().length > 0) ?? '').trim();
566
+ return {
567
+ kind: 'assistant',
568
+ timestamp: rec.completedAt ?? Date.now(),
569
+ label: 'output',
570
+ content: `(final) ${firstLine}`,
571
+ detail: fo,
572
+ expanded: false,
573
+ } satisfies TimelineEntry;
574
+ }
575
+ return null;
576
+ }
577
+
508
578
  // -------------------------------------------------------------------------
509
579
  // Private — refresh
510
580
  // -------------------------------------------------------------------------
@@ -29,8 +29,17 @@ import {
29
29
  const POLL_INTERVAL_MS = 500;
30
30
 
31
31
  export interface AgentLogsPanelDeps {
32
- readonly agentManager: Pick<AgentManager, 'list'>;
32
+ readonly agentManager: Pick<AgentManager, 'list'> & Partial<Pick<AgentManager, 'getStatus'>>;
33
33
  readonly workingDirectory: string;
34
+ /**
35
+ * Request a compositor repaint. The 500ms poll timer, fs.watch callback and
36
+ * other async update paths call this (via markDirty) so new log lines and
37
+ * live streaming output are painted while the main thread is idle — render()
38
+ * is otherwise only invoked on input/turn/resize, which makes a running
39
+ * agent's log tail look frozen. Optional: when omitted the panel still marks
40
+ * dirty and repaints on the next event.
41
+ */
42
+ readonly requestRender?: () => void;
34
43
  }
35
44
 
36
45
 
@@ -47,12 +56,22 @@ export class AgentLogsPanel extends ScrollableListPanel<LogEntry> {
47
56
  private allEntries: LogEntry[] = []; // raw parsed JSONL for selected agent
48
57
  private filteredEntries: LogEntry[] = []; // after filter applied
49
58
  private lastFileSize = 0;
59
+ /**
60
+ * Length of the selected agent's live streamingContent buffer at the last
61
+ * poll. During an LLM turn the record's streamingContent grows token-by-token
62
+ * with NO JSONL append, so the file-size gate alone never repaints the live
63
+ * tail; we also watch this length and markDirty when it advances.
64
+ */
65
+ private lastStreamLen = 0;
50
66
 
51
67
  // ── Modes ────────────────────────────────────────────────────────────────
52
68
  private autoFollow = true;
53
69
  private paused = false;
54
70
  private filter: FilterType = 'all';
55
71
 
72
+ /** True while this panel is the active view — gates async repaint requests. */
73
+ private _active = false;
74
+
56
75
  // ── Infrastructure ───────────────────────────────────────────────────────
57
76
  private pollTimer: ReturnType<typeof setInterval> | null = null;
58
77
  private fsWatcher: FSWatcher | null = null;
@@ -81,12 +100,14 @@ export class AgentLogsPanel extends ScrollableListPanel<LogEntry> {
81
100
  // ── Lifecycle ─────────────────────────────────────────────────────────────
82
101
 
83
102
  override onActivate(): void {
103
+ this._active = true;
84
104
  super.onActivate();
85
105
  this._refreshAgents();
86
106
  this._pollCurrentAgent();
87
107
  }
88
108
 
89
109
  override onDeactivate(): void {
110
+ this._active = false;
90
111
  super.onDeactivate();
91
112
  }
92
113
 
@@ -95,6 +116,18 @@ export class AgentLogsPanel extends ScrollableListPanel<LogEntry> {
95
116
  this._unsubscribeEvents();
96
117
  }
97
118
 
119
+ /**
120
+ * T17: a bare markDirty() does not repaint — render() only runs on
121
+ * input/turn/resize. While active, ask the compositor for a frame so the
122
+ * background poll / fs.watch updates (new log lines, live streaming output)
123
+ * are not stuck off-screen. Gated on active state so the always-on poller
124
+ * does not force full-frame rebuilds while this panel is hidden.
125
+ */
126
+ protected override markDirty(): void {
127
+ super.markDirty();
128
+ if (this._active) this.deps.requestRender?.();
129
+ }
130
+
98
131
  // ── Input ─────────────────────────────────────────────────────────────────
99
132
 
100
133
  handleInput(key: string): boolean {
@@ -180,7 +213,17 @@ export class AgentLogsPanel extends ScrollableListPanel<LogEntry> {
180
213
  ]));
181
214
  }
182
215
 
183
- if (this.filteredEntries.length === 0) {
216
+ // T13: surface the selected agent's live streaming output (running) or
217
+ // final output (terminal) — the per-turn JSONL tail never carries it. Pull
218
+ // the freshest record via getStatus so the streaming buffer reflects the
219
+ // current turn; the list() snapshot in this.agents only refreshes on
220
+ // spawn/complete/fail events, never mid-stream.
221
+ const liveAgent = selectedAgent
222
+ ? (this.deps.agentManager.getStatus?.(selectedAgent.id) ?? selectedAgent)
223
+ : null;
224
+ const liveLines = liveAgent ? this._buildLiveOutputLines(width, liveAgent) : [];
225
+
226
+ if (this.filteredEntries.length === 0 && liveLines.length === 0) {
184
227
  return buildPanelWorkspace(width, height, {
185
228
  title: ' Agents',
186
229
  intro: 'View-only live session stream for running agents, with per-agent switching and filtered event tails.',
@@ -202,9 +245,13 @@ export class AgentLogsPanel extends ScrollableListPanel<LogEntry> {
202
245
  });
203
246
  }
204
247
 
248
+ const scrollableLines = [
249
+ ...this.filteredEntries.map((entry) => this._renderEntry(entry, width)),
250
+ ...liveLines,
251
+ ];
205
252
  const focusIndex = this.autoFollow
206
- ? Math.max(0, this.filteredEntries.length - 1)
207
- : Math.min(this.selectedIndex, Math.max(0, this.filteredEntries.length - 1));
253
+ ? Math.max(0, scrollableLines.length - 1)
254
+ : Math.min(this.selectedIndex, Math.max(0, scrollableLines.length - 1));
208
255
  const summarySection = { title: 'Summary', lines: summaryLines } as const;
209
256
  const agentsSection = { title: 'Agents', lines: [selectorLine] } as const;
210
257
  const logStreamSection = resolveScrollablePanelSection(width, height, {
@@ -214,7 +261,7 @@ export class AgentLogsPanel extends ScrollableListPanel<LogEntry> {
214
261
  beforeSections: [summarySection, agentsSection],
215
262
  section: {
216
263
  title: 'Log Stream',
217
- scrollableLines: this.filteredEntries.map((entry) => this._renderEntry(entry, width)),
264
+ scrollableLines,
218
265
  selectedIndex: focusIndex,
219
266
  scrollOffset: this.scrollStart,
220
267
  minRows: 8,
@@ -264,28 +311,50 @@ export class AgentLogsPanel extends ScrollableListPanel<LogEntry> {
264
311
  const agent = this._selectedAgent();
265
312
  if (!agent) return;
266
313
 
314
+ // Detect live streaming growth even when no JSONL is written this tick.
315
+ // During an LLM turn the record's streamingContent grows token-by-token
316
+ // with no session-log append, so the file-size gate below would never fire
317
+ // and the live tail (_buildLiveOutputLines) would never repaint.
318
+ let streamChanged = false;
319
+ const live = this.deps.agentManager.getStatus?.(agent.id);
320
+ if (live) {
321
+ const streamLen = (live.streamingContent ?? '').length;
322
+ if (streamLen !== this.lastStreamLen) {
323
+ this.lastStreamLen = streamLen;
324
+ streamChanged = true;
325
+ }
326
+ }
327
+
267
328
  const sessionFile = this._sessionFilePath(agent.id);
329
+ let fileExists = true;
268
330
  try {
269
331
  await fsPromises.access(sessionFile);
270
332
  } catch {
271
- return;
333
+ fileExists = false;
272
334
  }
273
335
 
274
- try {
275
- const content = await fsPromises.readFile(sessionFile, 'utf-8');
276
- if (content.length === this.lastFileSize) return;
277
- this.lastFileSize = content.length;
278
-
279
- // Re-parse all lines (simple: no partial-line tracking needed at 500ms)
280
- this.allEntries = parseAgentJsonl(content);
281
- this._applyFilter();
282
- if (this.autoFollow) {
283
- this.selectedIndex = Math.max(0, this.filteredEntries.length - 1);
336
+ if (fileExists) {
337
+ try {
338
+ const content = await fsPromises.readFile(sessionFile, 'utf-8');
339
+ if (content.length !== this.lastFileSize) {
340
+ this.lastFileSize = content.length;
341
+
342
+ // Re-parse all lines (simple: no partial-line tracking needed at 500ms)
343
+ this.allEntries = parseAgentJsonl(content);
344
+ this._applyFilter();
345
+ if (this.autoFollow) {
346
+ this.selectedIndex = Math.max(0, this.filteredEntries.length - 1);
347
+ }
348
+ this.markDirty();
349
+ return;
350
+ }
351
+ } catch {
352
+ // Non-fatal: file may be mid-write
284
353
  }
285
- this.markDirty();
286
- } catch {
287
- // Non-fatal: file may be mid-write
288
354
  }
355
+
356
+ // File unchanged or absent, but the live stream advanced — repaint the tail.
357
+ if (streamChanged) this.markDirty();
289
358
  }
290
359
 
291
360
  // ── Private: fs.watch (supplemental) ─────────────────────────────────────
@@ -382,6 +451,7 @@ export class AgentLogsPanel extends ScrollableListPanel<LogEntry> {
382
451
  this.allEntries = [];
383
452
  this.filteredEntries = [];
384
453
  this.lastFileSize = 0;
454
+ this.lastStreamLen = 0;
385
455
  this.selectedIndex = 0;
386
456
  this.scrollStart = 0;
387
457
  this.autoFollow = true;
@@ -414,6 +484,7 @@ export class AgentLogsPanel extends ScrollableListPanel<LogEntry> {
414
484
  this.allEntries = [];
415
485
  this.filteredEntries = [];
416
486
  this.lastFileSize = 0;
487
+ this.lastStreamLen = 0;
417
488
  this.markDirty();
418
489
  return;
419
490
  }
@@ -545,6 +616,43 @@ export class AgentLogsPanel extends ScrollableListPanel<LogEntry> {
545
616
  return buildStyledPanelLine(width, [{ text: fullText, fg: entry.color, bold: entry.bold }]);
546
617
  }
547
618
 
619
+ /**
620
+ * Build trailing lines exposing the selected agent's live model output. The
621
+ * JSONL tail is written per-turn, so while an agent is mid-turn its actual
622
+ * work is invisible here; this surfaces the streaming token buffer
623
+ * (tail-truncated like AgentDetailModal) for a running agent, and the final
624
+ * assistant text once it terminates. Empty when there is nothing live to show.
625
+ */
626
+ private _buildLiveOutputLines(width: number, agent: AgentRecord): Line[] {
627
+ const STREAM_MAX_CHARS = 500;
628
+ if (agent.status === 'running' && agent.streamingContent) {
629
+ const sc = agent.streamingContent;
630
+ const truncated = sc.length > STREAM_MAX_CHARS;
631
+ const display = truncated ? sc.slice(-STREAM_MAX_CHARS) : sc;
632
+ const header = truncated
633
+ ? ` streaming (last ${STREAM_MAX_CHARS} of ${sc.length} chars):`
634
+ : ' streaming:';
635
+ const lines: Line[] = [
636
+ buildStyledPanelLine(width, [{ text: header, fg: COLOR.auto_follow, bold: true }]),
637
+ ];
638
+ for (const raw of display.split('\n')) {
639
+ lines.push(buildStyledPanelLine(width, [{ text: ` ${raw}`, fg: COLOR.assistant }]));
640
+ }
641
+ return lines;
642
+ }
643
+ if (agent.status !== 'running' && agent.status !== 'pending' && agent.fullOutput) {
644
+ const fo = agent.fullOutput;
645
+ const lines: Line[] = [
646
+ buildStyledPanelLine(width, [{ text: ' output:', fg: COLOR.header_accent, bold: true }]),
647
+ ];
648
+ for (const raw of fo.split('\n')) {
649
+ lines.push(buildStyledPanelLine(width, [{ text: ` ${raw}`, fg: COLOR.assistant }]));
650
+ }
651
+ return lines;
652
+ }
653
+ return [];
654
+ }
655
+
548
656
  private _agentStatusColor(status: AgentRecord['status']): string {
549
657
  switch (status) {
550
658
  case 'running': return COLOR.agent_running;
@@ -142,7 +142,8 @@ export abstract class BasePanel implements Panel {
142
142
  */
143
143
  protected renderLoadingLine(width: number, frame = 0): Line | null {
144
144
  if (this.loadingState !== 'loading') return null;
145
- const spinner = SPINNER_FRAMES[frame % SPINNER_FRAMES.length] ?? SPINNER_FRAMES[0]!;
145
+ const idx = (frame || Math.floor(Date.now() / 100)) % SPINNER_FRAMES.length;
146
+ const spinner = SPINNER_FRAMES[idx] ?? SPINNER_FRAMES[0]!;
146
147
  const text = ` ${spinner} ${this._loadingLabel}`;
147
148
  return UIFactory.stringToLine(fitDisplay(text, width), width, { fg: '135', bold: true });
148
149
  }