@pellux/goodvibes-tui 0.23.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/README.md +17 -8
- package/package.json +1 -1
- package/src/cli/management.ts +80 -10
- package/src/core/long-task-notifier.ts +145 -0
- package/src/core/session-recovery.ts +147 -0
- package/src/core/stream-event-wiring.ts +77 -3
- package/src/core/transcript-journal.ts +339 -0
- package/src/core/turn-event-wiring.ts +67 -4
- package/src/input/commands/control-room-runtime.ts +0 -2
- package/src/input/commands/diff-runtime.ts +1 -1
- package/src/input/commands/eval.ts +1 -1
- package/src/input/commands/health-runtime.ts +23 -4
- package/src/input/commands/knowledge.ts +1 -1
- package/src/input/commands/local-runtime.ts +1 -2
- package/src/input/commands/memory-product-runtime.ts +2 -2
- package/src/input/commands/memory.ts +1 -1
- package/src/input/commands/onboarding-runtime.ts +0 -1
- package/src/input/commands/policy.ts +1 -1
- package/src/input/commands/profile-sync-runtime.ts +4 -3
- package/src/input/commands/provider.ts +1 -1
- package/src/input/commands/qrcode-runtime.ts +0 -1
- package/src/input/commands/session-content.ts +2 -2
- package/src/input/commands/session-workflow.ts +32 -2
- package/src/input/commands/session.ts +1 -1
- package/src/input/commands/settings-sync-runtime.ts +9 -9
- package/src/input/commands/shell-core.ts +2 -2
- package/src/input/commands/work-plan-runtime.ts +8 -8
- package/src/input/feed-context-factory.ts +6 -0
- package/src/input/handler-feed-routes.ts +19 -1
- package/src/input/handler-feed.ts +11 -0
- package/src/input/handler-prompt-buffer.ts +28 -0
- package/src/input/handler-shortcuts.ts +88 -2
- package/src/input/handler-ui-state.ts +2 -2
- package/src/input/handler.ts +39 -3
- package/src/input/keybindings.ts +33 -3
- package/src/input/kill-ring.ts +134 -0
- package/src/input/model-picker.ts +18 -1
- package/src/input/search.ts +18 -6
- package/src/input/settings-modal-activation.ts +134 -0
- package/src/input/settings-modal-adjustment.ts +124 -0
- package/src/input/settings-modal-data.ts +53 -0
- package/src/input/settings-modal.ts +48 -145
- package/src/main.ts +33 -33
- package/src/panels/base-panel.ts +2 -1
- package/src/panels/provider-health-domains.ts +3 -3
- package/src/panels/provider-health-panel.ts +13 -9
- package/src/panels/provider-health-tracker.ts +7 -4
- package/src/panels/settings-sync-panel.ts +3 -3
- package/src/panels/work-plan-panel.ts +2 -2
- package/src/renderer/diff-view.ts +2 -2
- package/src/renderer/help-overlay.ts +1 -0
- package/src/renderer/model-picker-overlay.ts +23 -11
- package/src/renderer/progress.ts +3 -3
- package/src/renderer/search-overlay.ts +8 -5
- package/src/renderer/settings-modal.ts +1 -1
- package/src/renderer/ui-factory.ts +11 -0
- package/src/runtime/bootstrap-hook-bridge.ts +18 -0
- package/src/runtime/bootstrap-shell.ts +1 -0
- package/src/shell/blocking-input.ts +32 -0
- package/src/shell/recovery-input-helpers.ts +71 -0
- package/src/utils/terminal-width.ts +10 -3
- 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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
this.
|
|
443
|
-
|
|
444
|
-
this.
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
this.
|
|
514
|
-
|
|
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
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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
|
-
|
|
750
|
-
|
|
751
|
-
|
|
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);
|
package/src/panels/base-panel.ts
CHANGED
|
@@ -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
|
|
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 ? '/
|
|
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
|
-
? ['/
|
|
85
|
-
: ['/
|
|
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 '
|
|
92
|
-
case '
|
|
93
|
-
case '
|
|
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 '
|
|
101
|
-
case '
|
|
102
|
-
case '
|
|
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 === '
|
|
566
|
-
else if (status === '
|
|
567
|
-
else if (status === '
|
|
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
|
-
|
|
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 = '
|
|
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 = '
|
|
103
|
+
record.status = 'rate_limited';
|
|
101
104
|
record.rateLimitExpiresAt = Date.now() + ProviderHealthTracker.DEFAULT_COOLDOWN_MS;
|
|
102
105
|
return;
|
|
103
106
|
}
|
|
104
|
-
record.status = '
|
|
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, '/
|
|
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: /
|
|
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 /
|
|
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: '/
|
|
113
|
-
{ command: '/
|
|
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
|
|
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
|
-
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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));
|