@pellux/goodvibes-tui 0.23.0 → 0.24.1

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 (64) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +20 -12
  3. package/docs/foundation-artifacts/operator-contract.json +304 -230
  4. package/package.json +2 -2
  5. package/src/cli/management.ts +80 -10
  6. package/src/core/long-task-notifier.ts +145 -0
  7. package/src/core/session-recovery.ts +147 -0
  8. package/src/core/stream-event-wiring.ts +77 -3
  9. package/src/core/transcript-journal.ts +339 -0
  10. package/src/core/turn-event-wiring.ts +67 -4
  11. package/src/input/commands/control-room-runtime.ts +0 -2
  12. package/src/input/commands/diff-runtime.ts +1 -1
  13. package/src/input/commands/eval.ts +1 -1
  14. package/src/input/commands/health-runtime.ts +23 -4
  15. package/src/input/commands/knowledge.ts +1 -1
  16. package/src/input/commands/local-runtime.ts +1 -2
  17. package/src/input/commands/memory-product-runtime.ts +2 -2
  18. package/src/input/commands/memory.ts +1 -1
  19. package/src/input/commands/onboarding-runtime.ts +0 -1
  20. package/src/input/commands/policy.ts +1 -1
  21. package/src/input/commands/profile-sync-runtime.ts +4 -3
  22. package/src/input/commands/provider.ts +1 -1
  23. package/src/input/commands/qrcode-runtime.ts +0 -1
  24. package/src/input/commands/session-content.ts +2 -2
  25. package/src/input/commands/session-workflow.ts +32 -2
  26. package/src/input/commands/session.ts +1 -1
  27. package/src/input/commands/settings-sync-runtime.ts +9 -9
  28. package/src/input/commands/shell-core.ts +2 -2
  29. package/src/input/commands/work-plan-runtime.ts +8 -8
  30. package/src/input/feed-context-factory.ts +6 -0
  31. package/src/input/handler-feed-routes.ts +19 -1
  32. package/src/input/handler-feed.ts +11 -0
  33. package/src/input/handler-prompt-buffer.ts +28 -0
  34. package/src/input/handler-shortcuts.ts +88 -2
  35. package/src/input/handler-ui-state.ts +2 -2
  36. package/src/input/handler.ts +39 -3
  37. package/src/input/keybindings.ts +33 -3
  38. package/src/input/kill-ring.ts +134 -0
  39. package/src/input/model-picker.ts +18 -1
  40. package/src/input/search.ts +18 -6
  41. package/src/input/settings-modal-activation.ts +134 -0
  42. package/src/input/settings-modal-adjustment.ts +124 -0
  43. package/src/input/settings-modal-data.ts +53 -0
  44. package/src/input/settings-modal.ts +48 -145
  45. package/src/main.ts +33 -33
  46. package/src/panels/base-panel.ts +2 -1
  47. package/src/panels/provider-health-domains.ts +3 -3
  48. package/src/panels/provider-health-panel.ts +13 -9
  49. package/src/panels/provider-health-tracker.ts +7 -4
  50. package/src/panels/settings-sync-panel.ts +3 -3
  51. package/src/panels/work-plan-panel.ts +2 -2
  52. package/src/renderer/diff-view.ts +2 -2
  53. package/src/renderer/help-overlay.ts +1 -0
  54. package/src/renderer/model-picker-overlay.ts +23 -11
  55. package/src/renderer/progress.ts +3 -3
  56. package/src/renderer/search-overlay.ts +8 -5
  57. package/src/renderer/settings-modal.ts +1 -1
  58. package/src/renderer/ui-factory.ts +11 -0
  59. package/src/runtime/bootstrap-hook-bridge.ts +18 -0
  60. package/src/runtime/bootstrap-shell.ts +1 -0
  61. package/src/shell/blocking-input.ts +32 -0
  62. package/src/shell/recovery-input-helpers.ts +71 -0
  63. package/src/utils/terminal-width.ts +10 -3
  64. package/src/version.ts +1 -1
@@ -122,6 +122,14 @@ export function buildSettingGroups(
122
122
  ttsEntries.push(buildTtsSpeedSyntheticEntry(configManager));
123
123
  }
124
124
 
125
+ // Inject the synthetic behavior.notifyAfterSeconds entry into the behavior
126
+ // category. This key is TUI-local (not in the SDK ConfigKey union) and
127
+ // controls the long-task push notification threshold.
128
+ const behaviorEntries = groups.get('behavior');
129
+ if (behaviorEntries && !behaviorEntries.some((e) => e.setting.key === ('behavior.notifyAfterSeconds' as ConfigKey))) {
130
+ behaviorEntries.push(buildNotifyAfterSecondsSyntheticEntry(configManager));
131
+ }
132
+
125
133
  return groups;
126
134
  }
127
135
 
@@ -171,6 +179,51 @@ export function buildTtsSpeedSyntheticEntry(configManager: Pick<ConfigManager, '
171
179
  };
172
180
  }
173
181
 
182
+ // ---------------------------------------------------------------------------
183
+ // behavior.notifyAfterSeconds synthetic setting
184
+ // ---------------------------------------------------------------------------
185
+
186
+ /** Default threshold in seconds for the synthetic notifyAfterSeconds setting. */
187
+ export const NOTIFY_AFTER_SECONDS_DEFAULT_SETTING = 60;
188
+
189
+ /**
190
+ * The synthetic ConfigSetting descriptor for behavior.notifyAfterSeconds.
191
+ *
192
+ * This key is TUI-local and is not yet in the SDK ConfigKey union. The
193
+ * descriptor is injected into the behavior settings group so users can
194
+ * configure the long-task push notification threshold from /config behavior.
195
+ *
196
+ * 0 = off (no notifications). Any positive integer = threshold in seconds.
197
+ * Default 60s matches the default in long-task-notifier.ts.
198
+ *
199
+ * The key is cast to ConfigKey because ConfigSetting requires it. The cast
200
+ * is safe: configManager.get returns undefined for unknown keys rather than
201
+ * throwing.
202
+ */
203
+ export const NOTIFY_AFTER_SECONDS_SYNTHETIC_SETTING: ConfigSetting = {
204
+ key: 'behavior.notifyAfterSeconds' as ConfigKey,
205
+ type: 'number',
206
+ default: NOTIFY_AFTER_SECONDS_DEFAULT_SETTING,
207
+ description: 'Seconds a turn must run before a push notification fires (0 = off). Delivers to desktop (notify-send/osascript) and configured ntfy/webhook URLs.',
208
+ };
209
+
210
+ /**
211
+ * Build the synthetic SettingEntry for behavior.notifyAfterSeconds.
212
+ *
213
+ * Reads the raw value from configManager using a cast key. Falls back to
214
+ * NOTIFY_AFTER_SECONDS_DEFAULT_SETTING when absent or invalid.
215
+ */
216
+ export function buildNotifyAfterSecondsSyntheticEntry(configManager: Pick<ConfigManager, 'get'>): SettingEntry {
217
+ const raw = configManager.get('behavior.notifyAfterSeconds' as ConfigKey);
218
+ const parsed = typeof raw === 'number' ? raw : parseFloat(String(raw ?? ''));
219
+ const currentValue: number = Number.isFinite(parsed) && parsed >= 0 ? Math.round(parsed) : NOTIFY_AFTER_SECONDS_DEFAULT_SETTING;
220
+ return {
221
+ setting: NOTIFY_AFTER_SECONDS_SYNTHETIC_SETTING,
222
+ currentValue,
223
+ isDefault: currentValue === NOTIFY_AFTER_SECONDS_DEFAULT_SETTING,
224
+ };
225
+ }
226
+
174
227
  // ---------------------------------------------------------------------------
175
228
  // buildFlagEntries — snapshot of current feature flag states
176
229
  // ---------------------------------------------------------------------------
@@ -14,23 +14,16 @@
14
14
  */
15
15
 
16
16
  import { type ConfigKey } from '@pellux/goodvibes-sdk/platform/config';
17
- import { handleConfirmInput } from '../panels/confirm-state.ts';
18
17
  import type { ModelPickerTarget } from './model-picker.ts';
19
18
  import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
20
19
  import type { SubscriptionManager } from '@pellux/goodvibes-sdk/platform/config';
21
20
  import type { ServiceInspectionQuery } from '../runtime/ui-service-queries.ts';
22
21
  import { isSecretConfigKey } from '../config/secret-config.ts';
23
- import {
24
- getNumericAdjustmentMeta,
25
- modelPickerLaunchForKey,
26
- roundToPrecision,
27
- } from './settings-modal-behavior.ts';
28
22
  import {
29
23
  setSecretBackedSettingValue,
30
24
  type SettingsSecretsManager,
31
25
  } from './settings-modal-secrets.ts';
32
26
  import type { FeatureFlagManager } from '@/runtime/index.ts';
33
- import type { FlagState } from '@/runtime/index.ts';
34
27
  import type { McpRegistry } from '@pellux/goodvibes-sdk/platform/mcp';
35
28
 
36
29
  import {
@@ -50,16 +43,21 @@ import {
50
43
  buildSubscriptionEntries,
51
44
  buildNetworkFilteredItems,
52
45
  refreshEntryValues,
53
- updateEntryForKey,
54
46
  searchSettingEntries,
55
47
  } from './settings-modal-data.ts';
56
48
  import { getSettingLabel } from '../renderer/settings-modal-helpers.ts';
57
49
  import {
58
50
  applySettingValue,
59
- applyFlagState,
60
- persistFlagState,
61
51
  type SettingAppliedCallback,
62
52
  } from './settings-modal-mutations.ts';
53
+ import {
54
+ activateSelected as _activateSelected,
55
+ handleSubscriptionLogoutKey as _handleSubscriptionLogoutKey,
56
+ } from './settings-modal-activation.ts';
57
+ import {
58
+ adjustSelected as _adjustSelected,
59
+ toggleSelectedFlag as _toggleSelectedFlag,
60
+ } from './settings-modal-adjustment.ts';
63
61
  import {
64
62
  resetSelected as _resetSelected,
65
63
  initiateResetCategory as _initiateResetCategory,
@@ -436,63 +434,21 @@ export class SettingsModal {
436
434
  * Toggle boolean or begin cycling enum values, or enter edit mode for string/number.
437
435
  */
438
436
  activateSelected(): void {
439
- if (this.currentCategory === 'mcp') {
440
- const entry = this.getSelectedMcp();
441
- if (!entry) return;
442
- this.editingMode = true;
443
- this.editBuffer = entry.trustMode;
444
- this.mcpAllowAllConfirmationTarget = null;
445
- return;
446
- }
447
-
448
- if (this.currentCategory === 'subscriptions') {
449
- const entry = this.getSelectedSubscription();
450
- if (!entry) return;
451
- if (entry.state === 'active' || entry.state === 'pending') {
452
- // First press: arm the confirm gate. Subsequent key handling routes
453
- // through handleSubscriptionLogoutKey() before normal dispatch.
454
- this.subscriptionLogoutConfirmationTarget = entry.provider;
455
- }
456
- return;
457
- }
458
-
459
- const entry = this.getSelected();
460
- if (!entry || !this.configManager) return;
461
-
462
- const { setting } = entry;
463
-
464
- // Delegate provider/model picker settings to the model picker UI
465
- if (setting.key === 'tts.provider') {
466
- this.pendingSettingsPickerAction = 'tts-provider';
467
- return;
468
- }
469
- if (setting.key === 'tts.voice') {
470
- this.pendingSettingsPickerAction = 'tts-voice';
471
- return;
472
- }
473
-
474
- const pickerLaunch = modelPickerLaunchForKey(setting.key);
475
- if (pickerLaunch !== null) {
476
- if (pickerLaunch.flow === 'providerModel') {
477
- this.pendingProviderModelPickerTarget = pickerLaunch.target;
478
- } else {
479
- this.pendingModelPickerTarget = pickerLaunch.target;
480
- }
481
- return;
482
- }
483
-
484
- if (setting.type === 'boolean') {
485
- const newVal = !entry.currentValue;
486
- this._setValue(setting.key as ConfigKey, newVal);
487
- } else if (setting.type === 'enum' && setting.enumValues) {
488
- const idx = setting.enumValues.indexOf(entry.currentValue as string);
489
- const nextIdx = (idx + 1) % setting.enumValues.length;
490
- this._setValue(setting.key as ConfigKey, setting.enumValues[nextIdx]);
491
- } else if (setting.type === 'string' || setting.type === 'number') {
492
- // Enter inline edit mode
493
- this.editingMode = true;
494
- this.editBuffer = String(entry.currentValue ?? '');
495
- }
437
+ _activateSelected({
438
+ currentCategory: this.currentCategory,
439
+ configManager: this.configManager,
440
+ getSelectedMcp: () => this.getSelectedMcp(),
441
+ getSelectedSubscription: () => this.getSelectedSubscription(),
442
+ getSelected: () => this.getSelected(),
443
+ setValue: (key, value) => this._setValue(key, value),
444
+ setEditingMode: (v) => { this.editingMode = v; },
445
+ setEditBuffer: (v) => { this.editBuffer = v; },
446
+ setMcpAllowAllConfirmationTarget: (v) => { this.mcpAllowAllConfirmationTarget = v; },
447
+ setSubscriptionLogoutConfirmationTarget: (v) => { this.subscriptionLogoutConfirmationTarget = v; },
448
+ setPendingSettingsPickerAction: (v) => { this.pendingSettingsPickerAction = v; },
449
+ setPendingModelPickerTarget: (v) => { this.pendingModelPickerTarget = v; },
450
+ setPendingProviderModelPickerTarget: (v) => { this.pendingProviderModelPickerTarget = v; },
451
+ });
496
452
  }
497
453
 
498
454
  /**
@@ -505,77 +461,29 @@ export class SettingsModal {
505
461
  * - INACTIVE: no confirm pending → returns 'inactive' (caller continues)
506
462
  */
507
463
  handleSubscriptionLogoutKey(key: string): 'confirmed' | 'cancelled' | 'absorbed' | 'inactive' {
508
- const target = this.subscriptionLogoutConfirmationTarget;
509
- if (!target) return 'inactive';
510
- const confirmState = { subject: target, label: target };
511
- const result = handleConfirmInput(confirmState, key);
512
- if (result === 'confirmed') {
513
- this.subscriptionManager?.logout(target);
514
- this.subscriptionEntries = buildSubscriptionEntries(this.subscriptionManager, this.serviceRegistry);
515
- this.subscriptionLogoutConfirmationTarget = null;
516
- } else if (result === 'cancelled') {
517
- this.subscriptionLogoutConfirmationTarget = null;
518
- }
519
- // 'absorbed': confirm remains pending
520
- return result;
464
+ return _handleSubscriptionLogoutKey({
465
+ subscriptionLogoutConfirmationTarget: this.subscriptionLogoutConfirmationTarget,
466
+ subscriptionManager: this.subscriptionManager,
467
+ serviceRegistry: this.serviceRegistry,
468
+ setSubscriptionEntries: (entries) => { this.subscriptionEntries = entries; },
469
+ setSubscriptionLogoutConfirmationTarget: (v) => { this.subscriptionLogoutConfirmationTarget = v; },
470
+ }, key);
521
471
  }
522
472
 
523
473
  adjustSelected(direction: 'left' | 'right', step = 1): void {
524
- if (this.editingMode) return;
525
-
526
- if (this.currentCategory === 'flags') {
527
- const flagEntry = this.getSelectedFlag();
528
- if (!flagEntry || flagEntry.state === 'killed' || !this.featureFlagManager || !this.configManager) return;
529
- const targetState: FlagState = direction === 'right' ? 'enabled' : 'disabled';
530
- if (flagEntry.state !== targetState) applyFlagState(flagEntry, targetState, this.featureFlagManager, this.configManager);
531
- return;
532
- }
533
-
534
- if (this.currentCategory === 'mcp') {
535
- const entry = this.getSelectedMcp();
536
- if (!entry || !this.mcpRegistry) return;
537
- const modes: McpEntry['trustMode'][] = ['constrained', 'ask-on-risk', 'allow-all', 'blocked'];
538
- const currentIndex = Math.max(0, modes.indexOf(entry.trustMode));
539
- const nextIndex = direction === 'right'
540
- ? (currentIndex + 1) % modes.length
541
- : (currentIndex - 1 + modes.length) % modes.length;
542
- this.mcpRegistry.setServerTrustMode(entry.name, modes[nextIndex]!);
543
- this.mcpEntries = buildMcpEntries(this.mcpRegistry);
544
- this.mcpAllowAllConfirmationTarget = null;
545
- return;
546
- }
547
-
548
- const entry = this.getSelected();
549
- if (!entry || !this.configManager) return;
550
- const { setting } = entry;
551
-
552
- if (setting.type === 'boolean') {
553
- this._setValue(setting.key as ConfigKey, direction === 'right');
554
- return;
555
- }
556
-
557
- if (setting.type === 'enum' && setting.enumValues && setting.enumValues.length > 0) {
558
- const currentIndex = Math.max(0, setting.enumValues.indexOf(String(entry.currentValue)));
559
- const nextIndex = direction === 'right'
560
- ? (currentIndex + 1) % setting.enumValues.length
561
- : (currentIndex - 1 + setting.enumValues.length) % setting.enumValues.length;
562
- this._setValue(setting.key as ConfigKey, setting.enumValues[nextIndex]!);
563
- return;
564
- }
565
-
566
- if (setting.type === 'number') {
567
- const currentNumber = Number(entry.currentValue ?? 0);
568
- if (!Number.isFinite(currentNumber)) return;
569
- const adjustment = getNumericAdjustmentMeta(setting);
570
- const delta = adjustment.step * step;
571
- const rounded = roundToPrecision(currentNumber + (direction === 'right' ? delta : -delta), adjustment.precision);
572
- const nextValue = Math.min(
573
- adjustment.max ?? rounded,
574
- Math.max(adjustment.min ?? rounded, rounded),
575
- );
576
- if (setting.validate && !setting.validate(nextValue)) return;
577
- this._setValue(setting.key as ConfigKey, nextValue);
578
- }
474
+ _adjustSelected({
475
+ editingMode: this.editingMode,
476
+ currentCategory: this.currentCategory,
477
+ configManager: this.configManager,
478
+ featureFlagManager: this.featureFlagManager,
479
+ mcpRegistry: this.mcpRegistry,
480
+ getSelectedFlag: () => this.getSelectedFlag(),
481
+ getSelectedMcp: () => this.getSelectedMcp(),
482
+ getSelected: () => this.getSelected(),
483
+ setValue: (key, value) => this._setValue(key, value),
484
+ setMcpEntries: (entries) => { this.mcpEntries = entries; },
485
+ setMcpAllowAllConfirmationTarget: (v) => { this.mcpAllowAllConfirmationTarget = v; },
486
+ }, direction, step);
579
487
  }
580
488
 
581
489
  /**
@@ -585,16 +493,11 @@ export class SettingsModal {
585
493
  * only (require restart). runtimeToggleable flags toggle immediately.
586
494
  */
587
495
  toggleSelectedFlag(): void {
588
- const flagEntry = this.getSelectedFlag();
589
- if (!flagEntry || !this.featureFlagManager || !this.configManager) return;
590
-
591
- const { state } = flagEntry;
592
-
593
- // Killed flags are blocked
594
- if (state === 'killed') return;
595
-
596
- const newState: FlagState = state === 'enabled' ? 'disabled' : 'enabled';
597
- applyFlagState(flagEntry, newState, this.featureFlagManager, this.configManager);
496
+ _toggleSelectedFlag({
497
+ featureFlagManager: this.featureFlagManager,
498
+ configManager: this.configManager,
499
+ getSelectedFlag: () => this.getSelectedFlag(),
500
+ });
598
501
  }
599
502
 
600
503
  /**
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';
@@ -699,26 +700,37 @@ async function main() {
699
700
  gitStatusProvider,
700
701
  lastGitInfoRef,
701
702
  buildSessionContinuityHints,
702
- render,
703
+ render, webhookNotifier: ctx.services.webhookNotifier,
703
704
  });
704
705
  unsubs.push(...turnUnsubs);
705
706
 
706
707
  // Stable turn context for failover retry — set in submitInput, read by retryTurn.
707
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
+ };
708
720
  const streamResult: WireStreamEventMetricsResult = wireStreamEventMetrics({
709
721
  events: uiServices.events, orchestrator, providerRegistry,
710
722
  systemMessageRouter, render, metrics: streamMetrics,
711
- providerOptimizer: ctx.services.providerOptimizer,
712
- retryTurn: () => {
713
- if (!retryCtx) return;
714
- const { count, text, content: rContent, opts: rOpts } = retryCtx;
715
- // Roll back to pre-submission count (strips error system messages), then
716
- // re-submit. SDK gap — no retry-in-place; see HANDOFF item (Issue 2).
717
- conversation.removeMessagesAfter(count);
718
- orchestrator.handleUserInput(text, rContent, rOpts).catch((e: unknown) => logger.debug('retryTurn', { error: summarizeError(e) }));
719
- },
723
+ providerOptimizer: ctx.services.providerOptimizer, costLookup: providerRegistry, retryTurn,
720
724
  });
721
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
+ }
733
+ });
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
 
@@ -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));