@pellux/goodvibes-tui 0.22.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -0
- package/README.md +17 -8
- package/package.json +1 -1
- package/src/cli/management-commands.ts +1 -1
- package/src/cli/management-utils.ts +352 -0
- package/src/cli/management.ts +116 -344
- package/src/cli/surface-command.ts +1 -1
- package/src/core/context-auto-compact.ts +43 -10
- package/src/core/conversation-rendering.ts +5 -2
- package/src/core/conversation-types.ts +24 -0
- package/src/core/conversation.ts +7 -12
- package/src/core/long-task-notifier.ts +145 -0
- package/src/core/session-recovery.ts +147 -0
- package/src/core/stream-event-wiring.ts +199 -7
- package/src/core/transcript-journal.ts +339 -0
- package/src/core/turn-event-wiring.ts +67 -4
- package/src/input/commands/channel-runtime.ts +139 -0
- 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/runtime-services.ts +30 -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/share-runtime.ts +1 -1
- package/src/input/commands/shell-core.ts +56 -6
- package/src/input/commands/work-plan-runtime.ts +8 -8
- package/src/input/commands.ts +2 -0
- 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 +50 -50
- 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/compaction-history-modal.ts +55 -0
- package/src/renderer/compaction-preview.ts +146 -0
- 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-helpers.ts +2 -2
- package/src/renderer/settings-modal.ts +1 -1
- package/src/renderer/ui-factory.ts +11 -0
- package/src/runtime/bootstrap-core.ts +92 -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/browser.ts +29 -0
- package/src/utils/terminal-width.ts +10 -3
- package/src/version.ts +1 -1
package/src/main.ts
CHANGED
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
writeRecoveryFile,
|
|
41
41
|
} from '@/runtime/index.ts';
|
|
42
42
|
import { handleBlockingShellInput, type PendingPermissionState } from './shell/blocking-input.ts';
|
|
43
|
+
import { createPersistRecoverySnapshot, createReopenRecoveryPanels, handleErrorAffordanceKey } from './shell/recovery-input-helpers.ts';
|
|
43
44
|
import { wireShellUiOpeners } from './shell/ui-openers.ts';
|
|
44
45
|
import { deriveComposerState } from './core/composer-state.ts';
|
|
45
46
|
import { buildPersistedSessionContext, formatReturnContextForDisplay, getReturnContextMode, maybeAssistReturnContextSummary } from '@/runtime/index.ts';
|
|
@@ -54,22 +55,16 @@ import { allowTerminalWrite, installTuiTerminalOutputGuard } from './runtime/ter
|
|
|
54
55
|
import { buildCommandArgsHint } from './input/command-args-hint.ts';
|
|
55
56
|
import { summarizeRunningAgents } from './renderer/process-summary.ts';
|
|
56
57
|
import { formatUserFacingErrorLine } from './core/format-user-error.ts';
|
|
57
|
-
import { wireStreamEventMetrics, type StreamMetrics } from './core/stream-event-wiring.ts';
|
|
58
|
+
import { wireStreamEventMetrics, type StreamMetrics, type WireStreamEventMetricsResult } from './core/stream-event-wiring.ts';
|
|
58
59
|
import { wireTurnEventHandlers } from './core/turn-event-wiring.ts';
|
|
59
60
|
import { buildContextStatusHint } from './renderer/context-status-hint.ts';
|
|
60
61
|
import { evaluateSessionMaintenance } from './panels/session-maintenance.ts';
|
|
61
62
|
|
|
62
|
-
const ALT_SCREEN_ENTER = '\x1b[?1049h';
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
const
|
|
66
|
-
const
|
|
67
|
-
const CURSOR_SHOW = '\x1b[?25h';
|
|
68
|
-
const CLEAR_SCREEN = '\x1b[2J\x1b[3J\x1b[H';
|
|
69
|
-
const KEYBOARD_EXT_ENABLE = '\x1b[>4;2m' + '\x1b[?1u';
|
|
70
|
-
const KEYBOARD_EXT_DISABLE = '\x1b[>4;0m' + '\x1b[?1l';
|
|
71
|
-
const PASTE_ENABLE = '\x1b[?2004h';
|
|
72
|
-
const PASTE_DISABLE = '\x1b[?2004l';
|
|
63
|
+
const ALT_SCREEN_ENTER = '\x1b[?1049h'; const ALT_SCREEN_EXIT = '\x1b[?1049l';
|
|
64
|
+
const MOUSE_ENABLE = '\x1b[?1000h\x1b[?1002h\x1b[?1006h'; const MOUSE_DISABLE = '\x1b[?1006l\x1b[?1002l\x1b[?1000l';
|
|
65
|
+
const CURSOR_HIDE = '\x1b[?25l'; const CURSOR_SHOW = '\x1b[?25h'; const CLEAR_SCREEN = '\x1b[2J\x1b[3J\x1b[H';
|
|
66
|
+
const KEYBOARD_EXT_ENABLE = '\x1b[>4;2m' + '\x1b[?1u'; const KEYBOARD_EXT_DISABLE = '\x1b[>4;0m' + '\x1b[?1l';
|
|
67
|
+
const PASTE_ENABLE = '\x1b[?2004h'; const PASTE_DISABLE = '\x1b[?2004l';
|
|
73
68
|
|
|
74
69
|
async function main() {
|
|
75
70
|
const stdout = process.stdout;
|
|
@@ -315,10 +310,11 @@ async function main() {
|
|
|
315
310
|
}
|
|
316
311
|
if (processedText || content) {
|
|
317
312
|
void (async () => {
|
|
318
|
-
|
|
319
|
-
if (options.spokenOutput && processedText) {
|
|
320
|
-
|
|
321
|
-
}
|
|
313
|
+
const inputOptions = options.spokenOutput ? createSpokenTurnInputOptions() : undefined;
|
|
314
|
+
if (options.spokenOutput && processedText) { spokenTurns.submitNextTurn(processedText); }
|
|
315
|
+
// Snapshot pre-submission state for failover retryTurn; also clears visited set.
|
|
316
|
+
retryCtx = { count: conversation.getMessageCount(), text: processedText, content, opts: inputOptions };
|
|
317
|
+
streamResult.clearFailoverVisited();
|
|
322
318
|
orchestrator.handleUserInput(processedText, content, inputOptions).catch((err: unknown) => {
|
|
323
319
|
logger.debug('handleUserInput safety catch (already handled by runTurn)', { error: summarizeError(err) });
|
|
324
320
|
});
|
|
@@ -689,7 +685,6 @@ async function main() {
|
|
|
689
685
|
render,
|
|
690
686
|
});
|
|
691
687
|
|
|
692
|
-
// --- Turn-completed / git-refresh event wiring ---
|
|
693
688
|
const { refreshGit, unsubs: turnUnsubs } = wireTurnEventHandlers({
|
|
694
689
|
events: uiServices.events,
|
|
695
690
|
conversation,
|
|
@@ -705,20 +700,37 @@ async function main() {
|
|
|
705
700
|
gitStatusProvider,
|
|
706
701
|
lastGitInfoRef,
|
|
707
702
|
buildSessionContinuityHints,
|
|
708
|
-
render,
|
|
703
|
+
render, webhookNotifier: ctx.services.webhookNotifier,
|
|
709
704
|
});
|
|
710
705
|
unsubs.push(...turnUnsubs);
|
|
711
706
|
|
|
712
|
-
//
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
707
|
+
// Stable turn context for failover retry — set in submitInput, read by retryTurn.
|
|
708
|
+
let retryCtx: { count: number; text: string; content?: ContentPart[]; opts?: Parameters<typeof orchestrator.handleUserInput>[2] } | null = null;
|
|
709
|
+
// One-key retry affordance: active immediately after a user-visible TURN_ERROR.
|
|
710
|
+
// While active, 'r' re-submits on the current provider, 'm' opens the model
|
|
711
|
+
// picker. Any other character clears the affordance and routes normally.
|
|
712
|
+
let errorAffordanceActive = false;
|
|
713
|
+
const retryTurn = (): void => {
|
|
714
|
+
if (!retryCtx) return;
|
|
715
|
+
const { count, text, content: rContent, opts: rOpts } = retryCtx;
|
|
716
|
+
// Roll back to pre-submission count, then re-submit. SDK gap — no retry-in-place (see handoff).
|
|
717
|
+
conversation.removeMessagesAfter(count);
|
|
718
|
+
orchestrator.handleUserInput(text, rContent, rOpts).catch((e: unknown) => logger.debug('retryTurn', { error: summarizeError(e) }));
|
|
719
|
+
};
|
|
720
|
+
const streamResult: WireStreamEventMetricsResult = wireStreamEventMetrics({
|
|
721
|
+
events: uiServices.events, orchestrator, providerRegistry,
|
|
722
|
+
systemMessageRouter, render, metrics: streamMetrics,
|
|
723
|
+
providerOptimizer: ctx.services.providerOptimizer, costLookup: providerRegistry, retryTurn,
|
|
724
|
+
});
|
|
725
|
+
unsubs.push(...streamResult.unsubs);
|
|
726
|
+
// Activate one-key retry affordance when a user-visible error surfaces.
|
|
727
|
+
streamResult.onErrorSurfaced((exhausted) => {
|
|
728
|
+
if (retryCtx) {
|
|
729
|
+
errorAffordanceActive = true;
|
|
730
|
+
systemMessageRouter.low(exhausted ? '[Retry] r retry same provider · m switch model' : '[Retry] r retry · m switch model');
|
|
731
|
+
render();
|
|
732
|
+
}
|
|
720
733
|
});
|
|
721
|
-
unsubs.push(...streamUnsubs);
|
|
722
734
|
|
|
723
735
|
// --- Terminal setup ---
|
|
724
736
|
stdin.setRawMode(true);
|
|
@@ -726,35 +738,17 @@ async function main() {
|
|
|
726
738
|
stdin.setEncoding('utf8');
|
|
727
739
|
allowTerminalWrite(() => stdout.write((cli.flags.noAltScreen ? '' : ALT_SCREEN_ENTER) + CLEAR_SCREEN + CURSOR_HIDE + MOUSE_ENABLE + KEYBOARD_EXT_ENABLE + PASTE_ENABLE));
|
|
728
740
|
|
|
729
|
-
applyInitialTuiCliState({
|
|
730
|
-
cli,
|
|
731
|
-
input,
|
|
732
|
-
commandRegistry,
|
|
733
|
-
commandContext,
|
|
734
|
-
shellPaths: ctx.services.shellPaths,
|
|
735
|
-
render,
|
|
736
|
-
});
|
|
741
|
+
applyInitialTuiCliState({ cli, input, commandRegistry, commandContext, shellPaths: ctx.services.shellPaths, render });
|
|
737
742
|
|
|
738
743
|
stdin.on('data', (data: string) => {
|
|
739
744
|
const blocking = handleBlockingShellInput({
|
|
740
|
-
data,
|
|
741
|
-
pendingPermission,
|
|
742
|
-
recoveryPending,
|
|
745
|
+
data, pendingPermission, recoveryPending, conversation, systemMessageRouter, render,
|
|
743
746
|
abortTurn: () => orchestrator.abort(),
|
|
744
|
-
conversation,
|
|
745
|
-
systemMessageRouter,
|
|
746
|
-
render,
|
|
747
747
|
loadRecoveryConversation: () => loadRecoveryConversation({ homeDirectory }),
|
|
748
748
|
deleteRecoveryFile: () => deleteRecoveryFile({ homeDirectory }),
|
|
749
|
-
|
|
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
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compaction history text builder.
|
|
3
|
+
*
|
|
4
|
+
* Renders a read-only list of past compaction events sourced from the SDK's
|
|
5
|
+
* module-level compaction event log (`getCompactionEvents()`).
|
|
6
|
+
*
|
|
7
|
+
* The SDK records CompactionEvent data (timestamps, token counts,
|
|
8
|
+
* trigger, message counts) but does not expose a snapshot restore API.
|
|
9
|
+
* Restore is list-only; users can view what compactions ran but cannot roll back.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { getCompactionEvents } from '@pellux/goodvibes-sdk/platform/core';
|
|
13
|
+
import type { CompactionEvent } from '@pellux/goodvibes-sdk/platform/core';
|
|
14
|
+
|
|
15
|
+
// ─── formatCompactionEvent ────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
function formatCompactionEvent(ev: CompactionEvent, n: number): string {
|
|
18
|
+
const date = new Date(ev.timestamp);
|
|
19
|
+
const timeStr = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
20
|
+
const savings = Math.max(0, ev.tokensBeforeEstimate - ev.tokensAfterEstimate);
|
|
21
|
+
const savingsPct = ev.tokensBeforeEstimate > 0
|
|
22
|
+
? Math.round((savings / ev.tokensBeforeEstimate) * 100)
|
|
23
|
+
: 0;
|
|
24
|
+
const trigger = ev.trigger === 'auto' ? 'auto' : 'manual';
|
|
25
|
+
return (
|
|
26
|
+
`#${n} ${timeStr} [${trigger}] ` +
|
|
27
|
+
`${ev.messagesBeforeCompaction}→${ev.messagesAfterCompaction} msgs ` +
|
|
28
|
+
`~${fmtN(ev.tokensBeforeEstimate)}→~${fmtN(ev.tokensAfterEstimate)} tok ` +
|
|
29
|
+
`saved ${savingsPct}%`
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function fmtN(n: number): string {
|
|
34
|
+
return n.toLocaleString();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Build a plain-text compaction history summary suitable for ctx.print().
|
|
39
|
+
* Useful as the output of /compact-history when not in overlay mode.
|
|
40
|
+
*/
|
|
41
|
+
export function buildCompactionHistoryText(): string {
|
|
42
|
+
const events = getCompactionEvents();
|
|
43
|
+
if (events.length === 0) {
|
|
44
|
+
return '[Context] No compactions recorded this session. (Restore is not available — the SDK does not yet expose a snapshot restore API.)';
|
|
45
|
+
}
|
|
46
|
+
const lines: string[] = [
|
|
47
|
+
`[Context] Compaction history (${events.length} total, most recent first):`,
|
|
48
|
+
];
|
|
49
|
+
const ordered = [...events].reverse();
|
|
50
|
+
for (let i = 0; i < ordered.length; i++) {
|
|
51
|
+
lines.push(' ' + formatCompactionEvent(ordered[i], ordered.length - i));
|
|
52
|
+
}
|
|
53
|
+
lines.push(' (Restore not available — the SDK does not yet expose a snapshot restore API.)');
|
|
54
|
+
return lines.join('\n');
|
|
55
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compaction preview and after-notice builders.
|
|
3
|
+
*
|
|
4
|
+
* Pre-compact: shows an honest estimate of what compaction will do
|
|
5
|
+
* (message count and token estimate before → after). The SDK has no dry-run
|
|
6
|
+
* API; we derive the after-estimate from the DEFAULT_COMPACTION_CONFIG
|
|
7
|
+
* totalCeiling (6500 tokens) — clearly labelled as an ESTIMATE.
|
|
8
|
+
*
|
|
9
|
+
* Post-compact: shows a before/after notice using the CompactionEvent
|
|
10
|
+
* data returned by compactMessages(), which contains the real
|
|
11
|
+
* tokensBeforeEstimate and tokensAfterEstimate figures.
|
|
12
|
+
*
|
|
13
|
+
* Honest wording policy:
|
|
14
|
+
* - Pre-compact notice says "estimate" every time; never claims certainty.
|
|
15
|
+
* - Post-compact notice uses "~N" prefix on every token figure.
|
|
16
|
+
* - Pinned session memories that survive are mentioned by count.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { estimateConversationTokens } from '@pellux/goodvibes-sdk/platform/core';
|
|
20
|
+
import type { CompactionEvent } from '@pellux/goodvibes-sdk/platform/core';
|
|
21
|
+
import type { ProviderMessage } from '@pellux/goodvibes-sdk/platform/providers';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Default compaction totalCeiling from context-compaction DEFAULT_COMPACTION_CONFIG.
|
|
25
|
+
* Kept local so we don't take a runtime dependency on the internal config object.
|
|
26
|
+
* Used only for the pre-compact ESTIMATE; the real figure comes from CompactionEvent.
|
|
27
|
+
*/
|
|
28
|
+
const COMPACTION_OUTPUT_CEILING_ESTIMATE = 6500;
|
|
29
|
+
|
|
30
|
+
export interface CompactionPreviewOptions {
|
|
31
|
+
/** Messages currently in the conversation. */
|
|
32
|
+
readonly messages: readonly ProviderMessage[];
|
|
33
|
+
/** Context window size for the current model (0 if unknown). */
|
|
34
|
+
readonly contextWindow: number;
|
|
35
|
+
/** Number of session memories that will survive compaction. */
|
|
36
|
+
readonly pinnedMemoryCount: number;
|
|
37
|
+
/** Whether this is triggered automatically or manually. */
|
|
38
|
+
readonly trigger: 'auto' | 'manual';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface CompactionAfterOptions {
|
|
42
|
+
/** The CompactionEvent returned by the SDK after compaction completes. */
|
|
43
|
+
readonly event: CompactionEvent;
|
|
44
|
+
/** Number of session memories that survived compaction. */
|
|
45
|
+
readonly pinnedMemoryCount: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Build the pre-compaction notice string.
|
|
50
|
+
*
|
|
51
|
+
* Returned as a plain string intended for `ctx.print()` or `systemMessageRouter`.
|
|
52
|
+
* Always labelled as an estimate; uses the SDK totalCeiling as the after-estimate.
|
|
53
|
+
*/
|
|
54
|
+
export function buildCompactionPreview(opts: CompactionPreviewOptions): string {
|
|
55
|
+
const { messages, contextWindow, pinnedMemoryCount, trigger } = opts;
|
|
56
|
+
const msgCount = messages.length;
|
|
57
|
+
const tokensBefore = estimateConversationTokens(messages as ProviderMessage[]);
|
|
58
|
+
const tokensAfterEstimate = COMPACTION_OUTPUT_CEILING_ESTIMATE;
|
|
59
|
+
|
|
60
|
+
const contextStr = contextWindow > 0
|
|
61
|
+
? ` (${Math.round((tokensBefore / contextWindow) * 100)}% of ${fmtN(contextWindow)} context window)`
|
|
62
|
+
: '';
|
|
63
|
+
|
|
64
|
+
const pinStr = pinnedMemoryCount > 0
|
|
65
|
+
? ` ${pinnedMemoryCount} pinned session memor${pinnedMemoryCount === 1 ? 'y' : 'ies'} will be preserved.`
|
|
66
|
+
: '';
|
|
67
|
+
|
|
68
|
+
const triggerStr = trigger === 'auto' ? 'Auto-compacting' : 'Compacting';
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
`[Context] ${triggerStr} conversation: ~${fmtN(tokensBefore)} tokens across ${msgCount} message${msgCount === 1 ? '' : 's'}${contextStr}.` +
|
|
72
|
+
` Estimated result: ~${fmtN(tokensAfterEstimate)} tokens (estimate — actual depends on content).` +
|
|
73
|
+
(pinStr ? ` ${pinStr.trim()}` : '')
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Build the post-compaction before/after notice string.
|
|
79
|
+
*
|
|
80
|
+
* Uses the real CompactionEvent figures (not estimates) for both before and
|
|
81
|
+
* after token counts. The trigger field controls wording.
|
|
82
|
+
*/
|
|
83
|
+
export function buildCompactionAfterNotice(opts: CompactionAfterOptions): string {
|
|
84
|
+
const { event, pinnedMemoryCount } = opts;
|
|
85
|
+
const {
|
|
86
|
+
messagesBeforeCompaction,
|
|
87
|
+
messagesAfterCompaction,
|
|
88
|
+
tokensBeforeEstimate,
|
|
89
|
+
tokensAfterEstimate,
|
|
90
|
+
trigger,
|
|
91
|
+
} = event;
|
|
92
|
+
|
|
93
|
+
const savings = Math.max(0, tokensBeforeEstimate - tokensAfterEstimate);
|
|
94
|
+
const savingsPct = tokensBeforeEstimate > 0
|
|
95
|
+
? Math.round((savings / tokensBeforeEstimate) * 100)
|
|
96
|
+
: 0;
|
|
97
|
+
|
|
98
|
+
const pinStr = pinnedMemoryCount > 0
|
|
99
|
+
? ` ${pinnedMemoryCount} pinned memor${pinnedMemoryCount === 1 ? 'y' : 'ies'} preserved.`
|
|
100
|
+
: '';
|
|
101
|
+
|
|
102
|
+
const triggerStr = trigger === 'auto' ? 'Auto-compact complete' : 'Compact complete';
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
`[Context] ${triggerStr}: ${messagesBeforeCompaction} → ${messagesAfterCompaction} messages,` +
|
|
106
|
+
` ~${fmtN(tokensBeforeEstimate)} → ~${fmtN(tokensAfterEstimate)} tokens` +
|
|
107
|
+
` (saved ~${fmtN(savings)}, ${savingsPct}%).` +
|
|
108
|
+
(pinStr ? ` ${pinStr.trim()}` : '')
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Format a number with thousands separators. */
|
|
113
|
+
function fmtN(n: number): string {
|
|
114
|
+
return n.toLocaleString();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Build the /keep command usage text (shown when no args are provided).
|
|
119
|
+
*
|
|
120
|
+
* Exported for testability — the shell-core handler renders this string directly.
|
|
121
|
+
*/
|
|
122
|
+
export function buildPinUsageText(): string {
|
|
123
|
+
return (
|
|
124
|
+
'[Pin] Usage: /keep <text>\n' +
|
|
125
|
+
'Pinned entries are stored as session memories and included in the compaction handoff as pinned memories.\n' +
|
|
126
|
+
'What pinning guarantees: the text survives the next compaction.\n' +
|
|
127
|
+
'What pinning does NOT guarantee: recovery after process restart (session memories are in-memory only).'
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Build the /keep command success text.
|
|
133
|
+
*
|
|
134
|
+
* @param id - The assigned memory ID (e.g. "mem-1")
|
|
135
|
+
* @param text - The pinned text
|
|
136
|
+
* @param count - Total pinned memory count after adding
|
|
137
|
+
*
|
|
138
|
+
* Exported for testability — the shell-core handler renders this string directly.
|
|
139
|
+
*/
|
|
140
|
+
export function buildPinSuccessText(id: string, text: string, count: number): string {
|
|
141
|
+
return (
|
|
142
|
+
`[Pin] Pinned as ${id}: "${text.slice(0, 60)}${text.length > 60 ? '...' : ''}"\n` +
|
|
143
|
+
` ${count} pinned memor${count === 1 ? 'y' : 'ies'} will survive the next compaction.\n` +
|
|
144
|
+
' Note: session memories are in-memory only and do not persist across restarts.'
|
|
145
|
+
);
|
|
146
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type Line, type Cell, createStyledCell } from '../types/grid.ts';
|
|
2
2
|
import { UIFactory } from './ui-factory.ts';
|
|
3
|
-
import { getDisplayWidth } from '../utils/terminal-width.ts';
|
|
3
|
+
import { getDisplayWidth, padDisplayEnd } from '../utils/terminal-width.ts';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* renderDiffView - Render a unified diff string as styled Line[].
|
|
@@ -13,7 +13,7 @@ export function renderDiffView(diffText: string, width: number, filename?: strin
|
|
|
13
13
|
// Filename header
|
|
14
14
|
if (filename) {
|
|
15
15
|
const header = ` ≡ ${filename} `;
|
|
16
|
-
lines.push(UIFactory.stringToLine(header
|
|
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));
|
package/src/renderer/progress.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type Line } from '../types/grid.ts';
|
|
2
2
|
import { UIFactory } from './ui-factory.ts';
|
|
3
|
-
import { getDisplayWidth } from '../utils/terminal-width.ts';
|
|
3
|
+
import { getDisplayWidth, padDisplayEnd } from '../utils/terminal-width.ts';
|
|
4
4
|
|
|
5
5
|
// Rich spinner frames (used by progress indicators)
|
|
6
6
|
export const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
@@ -17,7 +17,7 @@ export function renderSpinner(
|
|
|
17
17
|
fg: string = '135'
|
|
18
18
|
): Line {
|
|
19
19
|
const text = ` ${frame} ${label}`;
|
|
20
|
-
return UIFactory.stringToLine(text
|
|
20
|
+
return UIFactory.stringToLine(padDisplayEnd(text, width), width, { fg, bold: true });
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
/**
|
|
@@ -33,7 +33,7 @@ export function renderToolProgress(
|
|
|
33
33
|
const counter = `[${current}/${total}]`;
|
|
34
34
|
const text = ` ${counter} ${label}`;
|
|
35
35
|
return [
|
|
36
|
-
UIFactory.stringToLine(text
|
|
36
|
+
UIFactory.stringToLine(padDisplayEnd(text, width), width, { fg: '#ffcc00', bold: true }),
|
|
37
37
|
];
|
|
38
38
|
}
|
|
39
39
|
|
|
@@ -5,16 +5,19 @@ import { createBottomBarLine, writeBottomBarText } from './bottom-bar.ts';
|
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Render the search bar as a single Line[] overlay at the bottom of the viewport.
|
|
8
|
-
* Format: [ Find: <query> 3/17
|
|
8
|
+
* Format: [ Find: <query> 3/17 [n/N] next/prev [Esc] close ]
|
|
9
9
|
* The match count is dim grey; the rest of the bar is teal.
|
|
10
|
+
*
|
|
11
|
+
* Case-insensitive search (query lowercased before matching rendered cell text).
|
|
10
12
|
*/
|
|
11
13
|
export function renderSearchOverlay(
|
|
12
14
|
manager: SearchManager,
|
|
13
15
|
width: number
|
|
14
16
|
): Line[] {
|
|
15
|
-
// Match count text — displayed in dim grey, right of query
|
|
16
|
-
const
|
|
17
|
-
|
|
17
|
+
// Match count / status text — displayed in dim grey, right of query
|
|
18
|
+
const hasMatches = manager.matches.length > 0;
|
|
19
|
+
const matchCount = hasMatches
|
|
20
|
+
? `${manager.currentMatch + 1}/${manager.matches.length}${manager.wrapAround ? ' (wrap)' : ''}`
|
|
18
21
|
: manager.query.length > 0
|
|
19
22
|
? 'No matches'
|
|
20
23
|
: '';
|
|
@@ -23,7 +26,7 @@ export function renderSearchOverlay(
|
|
|
23
26
|
const cursor = locked ? '' : '█';
|
|
24
27
|
const queryDisplay = manager.query + cursor;
|
|
25
28
|
const hints = locked
|
|
26
|
-
? ' [
|
|
29
|
+
? ' [n/N] next/prev [jk] navigate [Bksp] edit [Esc] close'
|
|
27
30
|
: ' [Enter/Tab] lock [Esc] close';
|
|
28
31
|
const label = ' Find: ';
|
|
29
32
|
const matchStr = matchCount ? ` ${matchCount}` : '';
|