@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
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}` : '';
|
|
@@ -150,7 +150,7 @@ function buildSettingContext(modal: SettingsModal, entry: SettingEntry): string[
|
|
|
150
150
|
];
|
|
151
151
|
|
|
152
152
|
if (entry.locked) lines.push(`Locked: ${entry.lockReason ?? 'This setting is locked by a higher-priority layer.'}`);
|
|
153
|
-
if (entry.conflict) lines.push(`Conflict: resolve with /
|
|
153
|
+
if (entry.conflict) lines.push(`Conflict: resolve with /settings-sync resolve ${entry.setting.key} local|synced.`);
|
|
154
154
|
|
|
155
155
|
lines.push('', entry.setting.description);
|
|
156
156
|
|
|
@@ -243,6 +243,17 @@ export class UIFactory {
|
|
|
243
243
|
});
|
|
244
244
|
const bottomLine = createBaseLine();
|
|
245
245
|
for (let x = 0; x < boxWidth; x++) bottomLine[boxStartX + x] = { char: GLYPHS.surface.bottom, fg: BORDER_COLOR, bg: '', bold: false, dim: false, underline: false, italic: false, strikethrough: false };
|
|
246
|
+
// Multi-line indicator lives inside the bottom border (right-aligned) so
|
|
247
|
+
// the footer height stays invariant while the user adds prompt lines.
|
|
248
|
+
if (promptLines.length > 1) {
|
|
249
|
+
const lineCountTag = ` ${promptLines.length}L `;
|
|
250
|
+
let tx = boxStartX + boxWidth - lineCountTag.length - 2;
|
|
251
|
+
for (const ch of lineCountTag) {
|
|
252
|
+
if (tx >= boxStartX + boxWidth - 1) break;
|
|
253
|
+
bottomLine[tx] = { char: ch, fg: '244', bg: '', bold: false, dim: true, underline: false, italic: false, strikethrough: false };
|
|
254
|
+
tx += 1;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
246
257
|
lines.push(bottomLine);
|
|
247
258
|
lines.push(createBaseLine());
|
|
248
259
|
const composerTokens: Array<{ text: string; fg: string; bold?: boolean; dim?: boolean }> = [];
|
|
@@ -13,6 +13,7 @@ import type { SessionManager } from '@pellux/goodvibes-sdk/platform/sessions';
|
|
|
13
13
|
import type { PanelManager } from '../panels/panel-manager.ts';
|
|
14
14
|
import type { ProviderRegistry } from '@pellux/goodvibes-sdk/platform/providers';
|
|
15
15
|
import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
16
|
+
import { replayJournalForSession } from '../core/session-recovery.ts';
|
|
16
17
|
|
|
17
18
|
export interface ResumeSessionOptions {
|
|
18
19
|
readonly runtimeBus: RuntimeEventBus;
|
|
@@ -27,6 +28,7 @@ export interface ResumeSessionOptions {
|
|
|
27
28
|
readonly panelManager: PanelManager;
|
|
28
29
|
readonly configManager: Pick<ConfigManager, 'get' | 'getCategory'>;
|
|
29
30
|
readonly providerRegistry: Pick<ProviderRegistry, 'get' | 'getCurrentModel' | 'getForModel' | 'require'>;
|
|
31
|
+
readonly homeDirectory: string;
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
export function createResumeSessionHandler(options: ResumeSessionOptions): (sessionId: string) => void {
|
|
@@ -47,6 +49,22 @@ export function createResumeSessionHandler(options: ResumeSessionOptions): (sess
|
|
|
47
49
|
titleSource: meta.titleSource,
|
|
48
50
|
});
|
|
49
51
|
options.runtime.sessionId = sessionId;
|
|
52
|
+
replayJournalForSession({
|
|
53
|
+
homeDirectory: options.homeDirectory,
|
|
54
|
+
sessionId,
|
|
55
|
+
snapshotTimestamp: meta.timestamp ?? 0,
|
|
56
|
+
conversation: options.conversation,
|
|
57
|
+
persistSnapshot: (replayedMessages) => {
|
|
58
|
+
options.sessionManager.save(sessionId, replayedMessages as never[], {
|
|
59
|
+
title: options.conversation.title || meta.title,
|
|
60
|
+
model: meta.model,
|
|
61
|
+
provider: meta.provider,
|
|
62
|
+
timestamp: Date.now(),
|
|
63
|
+
titleSource: meta.titleSource,
|
|
64
|
+
returnContext: meta.returnContext,
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
});
|
|
50
68
|
options.onSessionIdChanged?.(sessionId);
|
|
51
69
|
if (meta?.model) options.runtime.model = meta.model;
|
|
52
70
|
if (meta?.provider) options.runtime.provider = meta.provider;
|
|
@@ -106,6 +106,7 @@ export function createBootstrapShell(options: BootstrapShellOptions): BootstrapS
|
|
|
106
106
|
panelManager: services.panelManager,
|
|
107
107
|
configManager,
|
|
108
108
|
providerRegistry: services.providerRegistry,
|
|
109
|
+
homeDirectory: services.homeDirectory,
|
|
109
110
|
});
|
|
110
111
|
|
|
111
112
|
const openAgentDetailRef: { fn: (agentId: string) => void } = { fn: (_agentId: string) => {} };
|
|
@@ -2,6 +2,8 @@ import type { ConversationManager } from '../core/conversation';
|
|
|
2
2
|
import type { PermissionRequest } from '@pellux/goodvibes-sdk/platform/permissions';
|
|
3
3
|
import type { SessionSnapshot } from '@/runtime/index.ts';
|
|
4
4
|
import type { SystemMessageRouter } from '../core/system-message-router.ts';
|
|
5
|
+
import type { ConversationMessageSnapshot } from '@pellux/goodvibes-sdk/platform/core';
|
|
6
|
+
import { replayJournalForSession } from '../core/session-recovery.ts';
|
|
5
7
|
|
|
6
8
|
export type PendingPermissionState = PermissionRequest & {
|
|
7
9
|
resolve: (approved: boolean, remember?: boolean) => void;
|
|
@@ -17,6 +19,22 @@ export type BlockingInputHandlerOptions = {
|
|
|
17
19
|
render: () => void;
|
|
18
20
|
loadRecoveryConversation: () => SessionSnapshot | null;
|
|
19
21
|
deleteRecoveryFile: () => void;
|
|
22
|
+
/**
|
|
23
|
+
* Absolute home directory used to locate the transcript journal for this
|
|
24
|
+
* recovery session. Required for journal replay on Ctrl+R restore.
|
|
25
|
+
*/
|
|
26
|
+
homeDirectory: string;
|
|
27
|
+
/**
|
|
28
|
+
* The session ID that the recovery file belongs to. Required for journal
|
|
29
|
+
* replay so the correct journal path can be resolved.
|
|
30
|
+
*/
|
|
31
|
+
sessionId: string;
|
|
32
|
+
/**
|
|
33
|
+
* Persist the post-replay snapshot so the WAL gap is durably closed.
|
|
34
|
+
* Called with the replayed message list. Best-effort — failures are swallowed
|
|
35
|
+
* inside replayJournalForSession.
|
|
36
|
+
*/
|
|
37
|
+
persistSnapshot: (messages: ConversationMessageSnapshot[]) => void;
|
|
20
38
|
/**
|
|
21
39
|
* Optional callback invoked after Ctrl+R restore to reopen panels captured in
|
|
22
40
|
* the recovery snapshot's returnContext. When provided (as wired in main.ts),
|
|
@@ -47,6 +65,9 @@ export function handleBlockingShellInput(
|
|
|
47
65
|
render,
|
|
48
66
|
loadRecoveryConversation,
|
|
49
67
|
deleteRecoveryFile,
|
|
68
|
+
homeDirectory,
|
|
69
|
+
sessionId,
|
|
70
|
+
persistSnapshot,
|
|
50
71
|
reopenPanels,
|
|
51
72
|
} = options;
|
|
52
73
|
|
|
@@ -86,6 +107,17 @@ export function handleBlockingShellInput(
|
|
|
86
107
|
title: recovery.title,
|
|
87
108
|
titleSource: recovery.titleSource,
|
|
88
109
|
});
|
|
110
|
+
// Replay journal records that post-date the recovery snapshot so turns
|
|
111
|
+
// written after the last recovery-file write (but before SIGKILL) are
|
|
112
|
+
// not silently dropped. snapshotTimestamp=0 when timestamp is absent so
|
|
113
|
+
// all journal records are replayed — safer than dropping.
|
|
114
|
+
replayJournalForSession({
|
|
115
|
+
homeDirectory,
|
|
116
|
+
sessionId,
|
|
117
|
+
snapshotTimestamp: recovery.timestamp ?? 0,
|
|
118
|
+
conversation,
|
|
119
|
+
persistSnapshot,
|
|
120
|
+
});
|
|
89
121
|
reopenPanels?.(recovery);
|
|
90
122
|
systemMessageRouter.high('[Recovery] Session restored.');
|
|
91
123
|
deleteRecoveryFile();
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helper factories for main()'s stdin fast-path: the Ctrl+R recovery
|
|
3
|
+
* persistence/panel-reopen callbacks and the one-key error-retry
|
|
4
|
+
* affordance. Extracted from main.ts so the entrypoint stays under the
|
|
5
|
+
* architecture line ceiling; main() wires these with its live services.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ConversationMessageSnapshot } from '../core/conversation.ts';
|
|
9
|
+
import type { SessionSnapshot } from '@/runtime/index.ts';
|
|
10
|
+
|
|
11
|
+
export interface PersistRecoveryDeps {
|
|
12
|
+
readonly sessionManager: {
|
|
13
|
+
save(id: string, msgs: never[], meta: { title: string; model: string; provider: string; timestamp: number }): unknown;
|
|
14
|
+
};
|
|
15
|
+
readonly runtime: { readonly sessionId: string; readonly model: string; readonly provider: string };
|
|
16
|
+
readonly conversation: { readonly title?: string | null };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Persist a replayed/restored snapshot through the session manager. */
|
|
20
|
+
export function createPersistRecoverySnapshot(deps: PersistRecoveryDeps): (msgs: ConversationMessageSnapshot[]) => void {
|
|
21
|
+
return (msgs) => void deps.sessionManager.save(deps.runtime.sessionId, msgs as never[], {
|
|
22
|
+
title: deps.conversation.title ?? '',
|
|
23
|
+
model: deps.runtime.model,
|
|
24
|
+
provider: deps.runtime.provider,
|
|
25
|
+
timestamp: Date.now(),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ReopenPanelsDeps {
|
|
30
|
+
readonly panelManager: { open(id: string): void; show(): void };
|
|
31
|
+
readonly render: () => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Reopen the panels recorded in a restored session's return context (capped at 4). */
|
|
35
|
+
export function createReopenRecoveryPanels(deps: ReopenPanelsDeps): (snapshot: SessionSnapshot) => void {
|
|
36
|
+
return (snapshot) => {
|
|
37
|
+
for (const panelId of (snapshot.returnContext?.openPanels ?? []).slice(0, 4)) {
|
|
38
|
+
try { deps.panelManager.open(panelId); } catch { /* unknown panel id */ }
|
|
39
|
+
}
|
|
40
|
+
if ((snapshot.returnContext?.openPanels?.length ?? 0) > 0) { deps.panelManager.show(); deps.render(); }
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ErrorAffordanceDeps {
|
|
45
|
+
/** True when the failover retry context is armed (a retry is actually possible). */
|
|
46
|
+
readonly retryArmed: boolean;
|
|
47
|
+
/** Re-submit the failed turn via the shared failover retry path (no duplicate user messages). */
|
|
48
|
+
readonly retry: () => void;
|
|
49
|
+
readonly openModelPicker: () => void;
|
|
50
|
+
readonly render: () => void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Handle one keypress while the error-retry affordance is active.
|
|
55
|
+
* 'r' retries on the current provider when armed; 'm' opens the model
|
|
56
|
+
* picker. Returns true when the key was consumed; any other key returns
|
|
57
|
+
* false so the caller routes it as normal input.
|
|
58
|
+
*/
|
|
59
|
+
export function handleErrorAffordanceKey(data: string, deps: ErrorAffordanceDeps): boolean {
|
|
60
|
+
if (data === 'r' && deps.retryArmed) {
|
|
61
|
+
deps.retry();
|
|
62
|
+
deps.render();
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
if (data === 'm') {
|
|
66
|
+
deps.openModelPicker();
|
|
67
|
+
deps.render();
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
@@ -43,9 +43,16 @@ function stripAnsi(text: string): string {
|
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
45
|
* Calculates the visual width of a string in the terminal.
|
|
46
|
-
* Handles CJK characters, emoji
|
|
47
|
-
*
|
|
48
|
-
*
|
|
46
|
+
* Handles CJK characters, emoji, and variation selectors correctly as
|
|
47
|
+
* double-width. ANSI escape sequences (SGR/CSI/OSC-8) are stripped before
|
|
48
|
+
* measurement.
|
|
49
|
+
*
|
|
50
|
+
* NOTE: Width is measured per Unicode scalar value (code point), not per
|
|
51
|
+
* grapheme cluster. ZWJ sequences (e.g. 👨👩👧👦) are handled component-by-component:
|
|
52
|
+
* each component's width is summed and ZWJ/VS chars contribute zero width,
|
|
53
|
+
* so the total is accurate. However, truncation (truncateDisplay) may split
|
|
54
|
+
* a ZWJ family mid-sequence, leaving dangling ZWJ/VS characters. This is a
|
|
55
|
+
* cosmetic degradation only — line widths remain correct.
|
|
49
56
|
*/
|
|
50
57
|
export function getDisplayWidth(text: string): number {
|
|
51
58
|
text = stripAnsi(text);
|
package/src/version.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { join } from 'node:path';
|
|
|
6
6
|
// The prebuild script updates the fallback value before compilation.
|
|
7
7
|
// Uses import.meta.dir (Bun) to locate package.json relative to this file,
|
|
8
8
|
// which is correct regardless of the process working directory.
|
|
9
|
-
let _version = '0.
|
|
9
|
+
let _version = '0.24.0';
|
|
10
10
|
try {
|
|
11
11
|
const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
|
|
12
12
|
_version = pkg.version ?? _version;
|