@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
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* architecture cap. No layout logic lives here.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type { SettingEntry, McpEntry, SubscriptionEntry } from '../input/settings-modal.ts';
|
|
8
|
-
import { SETTINGS_CATEGORIES } from '../input/settings-modal.ts';
|
|
7
|
+
import type { SettingEntry, McpEntry, SubscriptionEntry } from '../input/settings-modal-types.ts';
|
|
8
|
+
import { SETTINGS_CATEGORIES } from '../input/settings-modal-types.ts';
|
|
9
9
|
import { isSecretConfigKey, isSecretReferenceValue } from '../config/secret-config.ts';
|
|
10
10
|
|
|
11
11
|
function maskSecretValue(value: string): string {
|
|
@@ -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 }> = [];
|
|
@@ -123,6 +123,88 @@ export interface BootstrapCoreState {
|
|
|
123
123
|
|
|
124
124
|
export type CompanionMessagePayload = Extract<SessionEvent, { type: 'COMPANION_MESSAGE_RECEIVED' }>;
|
|
125
125
|
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Operator narration of inbound channel events
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Narrate an inbound channel event to the operator via the SystemMessageRouter.
|
|
132
|
+
*
|
|
133
|
+
* When an external surface (GitHub, Slack, ntfy, etc.) triggers an agent turn,
|
|
134
|
+
* this function produces a human-readable system message so the operator can
|
|
135
|
+
* observe which event caused the turn. Returns null for internal/companion
|
|
136
|
+
* sources that do not need operator narration.
|
|
137
|
+
*
|
|
138
|
+
* @param event - The normalized inbound event descriptor.
|
|
139
|
+
* @returns A narration string, or null if no narration is appropriate.
|
|
140
|
+
*/
|
|
141
|
+
export function narrateInboundEvent(event: {
|
|
142
|
+
source: string;
|
|
143
|
+
metadata: Readonly<Record<string, unknown>> | undefined;
|
|
144
|
+
}): string | null {
|
|
145
|
+
const { source, metadata } = event;
|
|
146
|
+
if (!source) return null;
|
|
147
|
+
|
|
148
|
+
// Derive the effective surface — prefer metadata.surface, fall back to source.
|
|
149
|
+
const surface = typeof metadata?.surface === 'string' ? metadata.surface : source;
|
|
150
|
+
|
|
151
|
+
// Internal / companion sources do not need operator narration.
|
|
152
|
+
if (surface === 'companion' || source === 'companion') return null;
|
|
153
|
+
if (surface === 'internal' || source === 'internal') return null;
|
|
154
|
+
|
|
155
|
+
// Build a surface label for the log prefix.
|
|
156
|
+
const label = ((): string => {
|
|
157
|
+
switch (surface) {
|
|
158
|
+
case 'github': return '[GitHub]';
|
|
159
|
+
case 'slack': return '[Slack]';
|
|
160
|
+
case 'discord': return '[Discord]';
|
|
161
|
+
case 'ntfy': return '[ntfy]';
|
|
162
|
+
case 'homeassistant': return '[HomeAssistant]';
|
|
163
|
+
case 'telegram': return '[Telegram]';
|
|
164
|
+
case 'google-chat': return '[Google Chat]';
|
|
165
|
+
case 'signal': return '[Signal]';
|
|
166
|
+
case 'whatsapp': return '[WhatsApp]';
|
|
167
|
+
case 'msteams': return '[Teams]';
|
|
168
|
+
case 'imessage': return '[iMessage]';
|
|
169
|
+
case 'bluebubbles': return '[BlueBubbles]';
|
|
170
|
+
case 'mattermost': return '[Mattermost]';
|
|
171
|
+
case 'matrix': return '[Matrix]';
|
|
172
|
+
case 'webhook': return '[Webhook]';
|
|
173
|
+
default: return `[${surface[0]!.toUpperCase()}${surface.slice(1)}]`;
|
|
174
|
+
}
|
|
175
|
+
})();
|
|
176
|
+
|
|
177
|
+
const eventType = typeof metadata?.eventType === 'string' ? metadata.eventType : null;
|
|
178
|
+
const eventAction = typeof metadata?.eventAction === 'string' ? metadata.eventAction : null;
|
|
179
|
+
const topic = typeof metadata?.topic === 'string' ? metadata.topic : null;
|
|
180
|
+
const prNumber = typeof metadata?.prNumber === 'number' ? metadata.prNumber : null;
|
|
181
|
+
const issueNumber = typeof metadata?.issueNumber === 'number' ? metadata.issueNumber : null;
|
|
182
|
+
const repo = typeof metadata?.repo === 'string' ? metadata.repo : null;
|
|
183
|
+
|
|
184
|
+
// Build event-specific detail for GitHub events.
|
|
185
|
+
if (surface === 'github' && eventType) {
|
|
186
|
+
const actionPart = eventAction ? ` ${eventAction}` : '';
|
|
187
|
+
let detail = `${eventType}${actionPart} → agent triggered`;
|
|
188
|
+
if (prNumber !== null) {
|
|
189
|
+
detail = `PR #${prNumber}${repo ? ` (${repo})` : ''} ${eventAction ?? eventType} → agent triggered`;
|
|
190
|
+
} else if (issueNumber !== null) {
|
|
191
|
+
detail = `Issue #${issueNumber}${repo ? ` (${repo})` : ''} ${eventAction ?? eventType} → agent triggered`;
|
|
192
|
+
} else if (repo) {
|
|
193
|
+
detail = `${eventType}${actionPart} in ${repo} → agent triggered`;
|
|
194
|
+
}
|
|
195
|
+
return `${label} ${detail}`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ntfy: include topic when available.
|
|
199
|
+
if (surface === 'ntfy' && topic) {
|
|
200
|
+
return `${label} inbound message on topic '${topic}' → agent triggered`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Generic narration for all other surfaces.
|
|
204
|
+
const eventDetail = eventType ? ` ${eventType}${eventAction ? ` ${eventAction}` : ''}` : '';
|
|
205
|
+
return `${label}${eventDetail} inbound event → agent triggered`;
|
|
206
|
+
}
|
|
207
|
+
|
|
126
208
|
export function companionMessageToOrchestratorInputOptions(
|
|
127
209
|
payload: CompanionMessagePayload,
|
|
128
210
|
): OrchestratorUserInputOptions {
|
|
@@ -525,6 +607,16 @@ export async function initializeBootstrapCore(
|
|
|
525
607
|
runtimeUnsubs.push(runtimeBus.on<Extract<SessionEvent, { type: 'COMPANION_MESSAGE_RECEIVED' }>>(
|
|
526
608
|
'COMPANION_MESSAGE_RECEIVED',
|
|
527
609
|
({ payload }) => {
|
|
610
|
+
// Narrate inbound external events to the operator so they can observe
|
|
611
|
+
// which channel event triggered the agent turn.
|
|
612
|
+
const narration = narrateInboundEvent({
|
|
613
|
+
source: payload.source,
|
|
614
|
+
metadata: payload.metadata,
|
|
615
|
+
});
|
|
616
|
+
if (narration) {
|
|
617
|
+
routeOrBuffer(narration, 'low');
|
|
618
|
+
}
|
|
619
|
+
|
|
528
620
|
if (orchestratorHandleUserInputRef.value) {
|
|
529
621
|
// Delegate to the orchestrator: adds user message + fires a real LLM turn.
|
|
530
622
|
// Preserve surface origin metadata so the SDK can correlate replies back
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* browser.ts — cross-platform browser launcher utility.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from cli/management.ts so it can be used by input-layer commands
|
|
5
|
+
* without creating an upward cli→input dependency. Lives in utils (Layer 0)
|
|
6
|
+
* and has no imports from shell-UI or entrypoint layers.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Open the given URL in the user's default browser.
|
|
14
|
+
* Returns a status string (success or error description).
|
|
15
|
+
* Does not throw — errors are returned as a descriptive string.
|
|
16
|
+
*/
|
|
17
|
+
export function openBrowser(url: string): string {
|
|
18
|
+
const platform = process.platform;
|
|
19
|
+
const command = platform === 'darwin' ? 'open' : platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
20
|
+
const args = platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
21
|
+
try {
|
|
22
|
+
const child = spawn(command, args, { detached: true, stdio: 'ignore' });
|
|
23
|
+
child.once('error', () => {});
|
|
24
|
+
child.unref();
|
|
25
|
+
return 'browser open requested';
|
|
26
|
+
} catch (error) {
|
|
27
|
+
return `browser open failed: ${summarizeError(error)}`;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -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;
|