@pellux/goodvibes-tui 1.19.5 → 1.20.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 +12 -0
- package/LICENSE +21 -0
- package/README.md +143 -1613
- package/docs/README.md +4 -4
- package/docs/foundation-artifacts/operator-contract.json +1 -1
- package/package.json +7 -7
- package/src/cli/ensure-goodvibes-gitignore.ts +16 -4
- package/src/cli/entrypoint.ts +5 -1
- package/src/cli/launch-auto-update.ts +63 -4
- package/src/cli/tui-startup.ts +131 -15
- package/src/config/surface.ts +21 -0
- package/src/config/tui-extension-settings.ts +9 -1
- package/src/core/bookmark-navigation.ts +44 -0
- package/src/core/conversation-line-cache.ts +87 -19
- package/src/core/conversation-rendering.ts +186 -28
- package/src/core/conversation-tool-groups.ts +152 -0
- package/src/core/conversation-types.ts +30 -3
- package/src/core/conversation.ts +17 -0
- package/src/core/rewind-turn-anchors.ts +12 -13
- package/src/core/session-recovery.ts +62 -7
- package/src/core/session-resume-core.ts +206 -0
- package/src/core/transcript-journal.ts +32 -6
- package/src/core/turn-event-wiring.ts +55 -12
- package/src/input/command-args-hint.ts +11 -2
- package/src/input/command-registry.ts +19 -0
- package/src/input/commands/checkpoint-runtime.ts +3 -3
- package/src/input/commands/health-runtime.ts +1 -3
- package/src/input/commands/local-runtime.ts +19 -2
- package/src/input/commands/rewind-runtime.ts +132 -1
- package/src/input/commands/runtime-services.ts +11 -1
- package/src/input/commands/secret-runtime.ts +1 -1
- package/src/input/commands/session-content.ts +9 -1
- package/src/input/commands/session-picker-filter.ts +28 -0
- package/src/input/commands/session-workflow.ts +62 -73
- package/src/input/commands/session.ts +36 -1
- package/src/input/commands/shell-core.ts +57 -5
- package/src/input/commands/update-runtime.ts +120 -4
- package/src/input/commands/websearch-runtime.ts +1 -1
- package/src/input/handler-content-actions.ts +63 -39
- package/src/input/handler-feed-routes.ts +17 -3
- package/src/input/handler-feed.ts +3 -0
- package/src/input/handler-interactions.ts +19 -23
- package/src/input/handler-modal-token-routes.ts +5 -0
- package/src/input/handler-types.ts +4 -1
- package/src/input/handler-ui-state.ts +6 -2
- package/src/input/handler.ts +19 -9
- package/src/input/search.ts +94 -2
- package/src/input/selection-modal-queue.ts +176 -0
- package/src/main.ts +55 -56
- package/src/panels/builtin/operations.ts +2 -1
- package/src/panels/provider-health-domains.ts +2 -2
- package/src/renderer/block-actions-overlay.ts +59 -0
- package/src/renderer/block-actions.ts +1 -3
- package/src/renderer/context-status-hint.ts +73 -5
- package/src/renderer/conversation-layout.ts +16 -0
- package/src/renderer/conversation-overlays.ts +6 -0
- package/src/renderer/help-overlay.ts +69 -53
- package/src/renderer/selection-modal-overlay.ts +66 -10
- package/src/renderer/shell-surface.ts +15 -0
- package/src/renderer/tool-result-expanded-lines.ts +48 -0
- package/src/renderer/ui-factory.ts +1 -1
- package/src/runtime/bootstrap-command-context.ts +5 -1
- package/src/runtime/bootstrap-command-parts.ts +5 -2
- package/src/runtime/bootstrap-core.ts +3 -3
- package/src/runtime/bootstrap-hook-bridge.ts +62 -44
- package/src/runtime/bootstrap-shell.ts +31 -16
- package/src/runtime/bootstrap.ts +13 -18
- package/src/runtime/code-index-services.ts +19 -0
- package/src/runtime/durability-services.ts +11 -7
- package/src/runtime/index.ts +12 -1
- package/src/runtime/process-lifecycle.ts +63 -2
- package/src/runtime/recovery-autosave.ts +48 -0
- package/src/runtime/recovery-offer-wiring.ts +89 -0
- package/src/runtime/recovery-prompt.ts +312 -0
- package/src/runtime/resume-notice.ts +26 -21
- package/src/runtime/services.ts +14 -16
- package/src/runtime/session-liveness-marker.ts +120 -0
- package/src/runtime/session-pointer-surface.ts +22 -0
- package/src/runtime/session-resume-liveness-confirm.ts +84 -0
- package/src/runtime/session-storage-services.ts +41 -0
- package/src/runtime/workspace-checkpointing.ts +10 -2
- package/src/shell/recovery-input-helpers.ts +17 -98
- package/src/shell/retry-affordance.ts +123 -0
- package/src/shell/ui-openers.ts +9 -0
- package/src/utils/splash-lines.ts +1 -1
- package/src/version.ts +1 -1
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-resume-liveness-confirm.ts — the "this session is open somewhere
|
|
3
|
+
* else" check for the session-browser / panel resume seam.
|
|
4
|
+
*
|
|
5
|
+
* `/session resume <id>` already refuses to resume a session whose liveness
|
|
6
|
+
* marker points at another running process, and tells the operator to re-run
|
|
7
|
+
* with `--force`. The panel seam had no such check at all: picking a session
|
|
8
|
+
* from the browser forked another terminal's live state with no warning. That
|
|
9
|
+
* seam has no argv to carry a `--force`, so it asks the same question as a
|
|
10
|
+
* modal instead.
|
|
11
|
+
*
|
|
12
|
+
* Semantics are unchanged from the text-command check and stay best-effort: a
|
|
13
|
+
* missing, stale, or unreadable marker means "we can't tell", and the resume
|
|
14
|
+
* proceeds exactly as it always did. The marker belonging to THIS process is
|
|
15
|
+
* ignored too — re-resuming the session already open here is not a fork.
|
|
16
|
+
*/
|
|
17
|
+
import { checkSessionLiveness } from './session-liveness-marker.ts';
|
|
18
|
+
import type { SessionSurface } from '@/runtime/index.ts';
|
|
19
|
+
import type { SelectionItem, SelectionResult } from '../input/selection-modal.ts';
|
|
20
|
+
|
|
21
|
+
export type LiveResumeSelectionOpener = (
|
|
22
|
+
title: string,
|
|
23
|
+
items: SelectionItem[],
|
|
24
|
+
opts: { preSelectId?: string; allowSearch?: boolean; primaryVerbLabel?: string } | undefined,
|
|
25
|
+
callback: (result: SelectionResult | null) => void,
|
|
26
|
+
) => void;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The confirm modal's title. Short by necessity: the selection overlay
|
|
30
|
+
* truncates its title to the box width (selection-modal-overlay.ts), so
|
|
31
|
+
* anything the operator must actually read — the pid, and what resuming would
|
|
32
|
+
* do — belongs in a row's `detail`, which that overlay wraps instead.
|
|
33
|
+
*/
|
|
34
|
+
export const LIVE_RESUME_CONFIRM_TITLE = 'Session open elsewhere';
|
|
35
|
+
|
|
36
|
+
export function buildLiveResumeConfirmItems(pid: number): SelectionItem[] {
|
|
37
|
+
return [
|
|
38
|
+
{
|
|
39
|
+
id: 'resume',
|
|
40
|
+
label: 'Resume anyway',
|
|
41
|
+
detail: `This session appears open in another terminal (pid ${pid}); resuming will fork its live state. Both terminals would hold their own copy of the conversation from this point on.`,
|
|
42
|
+
primaryAction: 'select',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: 'cancel',
|
|
46
|
+
label: 'Cancel',
|
|
47
|
+
detail: 'Leave the session to the terminal that already has it open.',
|
|
48
|
+
primaryAction: 'select',
|
|
49
|
+
},
|
|
50
|
+
];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ConfirmLiveResumeDeps {
|
|
54
|
+
readonly surface: SessionSurface;
|
|
55
|
+
/** Late-bound: the shell builds its selection opener after the resume handler exists. */
|
|
56
|
+
readonly openSelection: () => LiveResumeSelectionOpener | undefined;
|
|
57
|
+
/** Injectable for tests; defaults to this process's own pid. */
|
|
58
|
+
readonly selfPid?: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Resolve true when the resume should proceed. Proceeds without asking
|
|
63
|
+
* whenever the liveness signal says there is nothing to warn about, or when
|
|
64
|
+
* no selection surface exists to ask through (headless hosts keep the
|
|
65
|
+
* pre-existing behavior rather than silently refusing).
|
|
66
|
+
*
|
|
67
|
+
* A dismissed modal resolves false: the safe answer to an unanswered "should
|
|
68
|
+
* I fork another terminal's state?" is no.
|
|
69
|
+
*/
|
|
70
|
+
export async function confirmLiveResume(sessionId: string, deps: ConfirmLiveResumeDeps): Promise<boolean> {
|
|
71
|
+
const selfPid = deps.selfPid ?? process.pid;
|
|
72
|
+
const liveness = checkSessionLiveness(deps.surface, sessionId);
|
|
73
|
+
if (!liveness.live || liveness.pid === null || liveness.pid === selfPid) return true;
|
|
74
|
+
const open = deps.openSelection();
|
|
75
|
+
if (!open) return true;
|
|
76
|
+
return new Promise<boolean>((resolve) => {
|
|
77
|
+
open(
|
|
78
|
+
LIVE_RESUME_CONFIRM_TITLE,
|
|
79
|
+
buildLiveResumeConfirmItems(liveness.pid!),
|
|
80
|
+
{ allowSearch: false, primaryVerbLabel: 'Choose' },
|
|
81
|
+
(result) => resolve(result?.item.id === 'resume'),
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-storage-services.ts — the declare-once storage handle and the
|
|
3
|
+
* session store built on it.
|
|
4
|
+
*
|
|
5
|
+
* Every path the app uses for session state (the sessions directory, the
|
|
6
|
+
* last-session pointer, crash-recovery snapshots, the checkpoint store, the
|
|
7
|
+
* transcript journal, the multi-instance liveness markers) derives from ONE
|
|
8
|
+
* `SessionSurface`, built here, once, at composition time.
|
|
9
|
+
*
|
|
10
|
+
* The reason it is one object rather than a scope argument repeated at each
|
|
11
|
+
* call site: when each site derived its own scope, they drifted. A resume
|
|
12
|
+
* wrote the last-session pointer with no scope at all — into the shared,
|
|
13
|
+
* unscoped `.goodvibes/` directory — while `--continue` and the boot notice
|
|
14
|
+
* read a scoped path under `.goodvibes/tui/`. Nothing errored; the pointer
|
|
15
|
+
* simply never existed where anyone looked. `createSessionSurface` also
|
|
16
|
+
* throws synchronously on a bad scope instead of silently resolving to the
|
|
17
|
+
* unscoped fallback on first use, so a mistake here fails at boot rather than
|
|
18
|
+
* six months later as missing state.
|
|
19
|
+
*/
|
|
20
|
+
import { SessionManager } from '@pellux/goodvibes-sdk/platform/sessions';
|
|
21
|
+
import { createSessionSurface, type SessionSurface } from '@/runtime/index.ts';
|
|
22
|
+
import { GOODVIBES_TUI_SURFACE_ROOT } from '../config/surface.ts';
|
|
23
|
+
|
|
24
|
+
export interface SessionStorageServices {
|
|
25
|
+
readonly surface: SessionSurface;
|
|
26
|
+
readonly sessionManager: SessionManager;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createSessionStorageServices(input: {
|
|
30
|
+
readonly workingDirectory: string;
|
|
31
|
+
readonly homeDirectory: string;
|
|
32
|
+
}): SessionStorageServices {
|
|
33
|
+
// Also performs the SDK's one-time migration of pre-surface state (pointer
|
|
34
|
+
// copy-forward, journal moves) into the scoped location.
|
|
35
|
+
const surface = createSessionSurface({
|
|
36
|
+
surfaceRoot: GOODVIBES_TUI_SURFACE_ROOT,
|
|
37
|
+
workingDirectory: input.workingDirectory,
|
|
38
|
+
homeDirectory: input.homeDirectory,
|
|
39
|
+
});
|
|
40
|
+
return { surface, sessionManager: new SessionManager(input.workingDirectory, { surface }) };
|
|
41
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { WorkspaceCheckpointManager } from '@pellux/goodvibes-sdk/platform/workspace';
|
|
2
2
|
import { withCheckpointGuardSettings } from '../config/tui-extension-settings.ts';
|
|
3
3
|
import type { ConfigManager } from '../config/index.ts';
|
|
4
|
-
import type { RuntimeEventBus } from '@/runtime/index.ts';
|
|
4
|
+
import type { RuntimeEventBus, SessionSurface } from '@/runtime/index.ts';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* createWorkspaceCheckpointing — build the per-workspace checkpoint manager and
|
|
@@ -10,13 +10,21 @@ import type { RuntimeEventBus } from '@/runtime/index.ts';
|
|
|
10
10
|
*/
|
|
11
11
|
export function createWorkspaceCheckpointing(opts: {
|
|
12
12
|
workspaceRoot: string;
|
|
13
|
+
/**
|
|
14
|
+
* The app's declare-once storage handle. Passing it resolves the checkpoint
|
|
15
|
+
* git store to `surface.checkpointsDir` (`<workingDirectory>/.goodvibes/tui/
|
|
16
|
+
* checkpoints`) instead of the unscoped `<workspaceRoot>/.goodvibes/
|
|
17
|
+
* checkpoints` every product using this SDK would otherwise share. The SDK
|
|
18
|
+
* migrates an existing legacy store into the scoped location on first use.
|
|
19
|
+
*/
|
|
20
|
+
surface: SessionSurface;
|
|
13
21
|
runtimeBus: RuntimeEventBus;
|
|
14
22
|
configManager: ConfigManager;
|
|
15
23
|
}): WorkspaceCheckpointManager {
|
|
16
24
|
// Checkpoint root-guard from the user's `checkpoints.*` config (docs/configuration.md;
|
|
17
25
|
// inert until the platform SDK's checkpoint manager exposes the keys — see the reader's note).
|
|
18
26
|
const manager = new WorkspaceCheckpointManager(
|
|
19
|
-
withCheckpointGuardSettings({ workspaceRoot: opts.workspaceRoot, runtimeBus: opts.runtimeBus }, opts.configManager),
|
|
27
|
+
withCheckpointGuardSettings({ workspaceRoot: opts.workspaceRoot, surface: opts.surface, runtimeBus: opts.runtimeBus }, opts.configManager),
|
|
20
28
|
);
|
|
21
29
|
// Fire-and-forget: subscriptions go live immediately if init() succeeds.
|
|
22
30
|
// If it rejects, WorkspaceCheckpointManager caches that rejection on
|
|
@@ -1,102 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Helper
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* stays under the architecture line ceiling; main() wires these with its live
|
|
6
|
-
* services.
|
|
2
|
+
* Helper factory for main()'s stdin fast-path: the one-key error-retry
|
|
3
|
+
* affordance. Extracted from main.ts so the entrypoint stays under the
|
|
4
|
+
* architecture line ceiling; main() wires this with its live services.
|
|
7
5
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* never
|
|
6
|
+
* The silent startup auto-restore that used to live here
|
|
7
|
+
* (autoRestoreRecoverySession, plus its createPersistRecoverySnapshot /
|
|
8
|
+
* createReopenRecoveryPanels factories) has been removed: state restores
|
|
9
|
+
* happen ONLY when the user explicitly asks (a CLI flag, a slash command, or
|
|
10
|
+
* a prompt), never automatically at bare launch (owner ruling). A live
|
|
11
|
+
* recovery snapshot is now surfaced, never applied, by the boot resume
|
|
12
|
+
* notice — see announceResumeState in runtime/resume-notice.ts.
|
|
14
13
|
*/
|
|
15
14
|
|
|
16
|
-
import type { ConversationMessageSnapshot } from '../core/conversation.ts';
|
|
17
|
-
import type { SessionSnapshot } from '@/runtime/index.ts';
|
|
18
|
-
import { autoRestoreRecovery } from '@/runtime/index.ts';
|
|
19
|
-
import { replayJournalForSession } from '../core/session-recovery.ts';
|
|
20
|
-
|
|
21
|
-
export interface PersistRecoveryDeps {
|
|
22
|
-
readonly sessionManager: {
|
|
23
|
-
save(id: string, msgs: never[], meta: { title: string; model: string; provider: string; timestamp: number }): unknown;
|
|
24
|
-
};
|
|
25
|
-
readonly runtime: { readonly sessionId: string; readonly model: string; readonly provider: string };
|
|
26
|
-
readonly conversation: { readonly title?: string | null };
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** Persist a replayed/restored snapshot through the session manager. */
|
|
30
|
-
export function createPersistRecoverySnapshot(deps: PersistRecoveryDeps): (msgs: ConversationMessageSnapshot[]) => void {
|
|
31
|
-
return (msgs) => void deps.sessionManager.save(deps.runtime.sessionId, msgs as never[], {
|
|
32
|
-
title: deps.conversation.title ?? '',
|
|
33
|
-
model: deps.runtime.model,
|
|
34
|
-
provider: deps.runtime.provider,
|
|
35
|
-
timestamp: Date.now(),
|
|
36
|
-
});
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export interface ReopenPanelsDeps {
|
|
40
|
-
readonly panelManager: { open(id: string): void; show(): void };
|
|
41
|
-
readonly render: () => void;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** Reopen the panels recorded in a restored session's return context (capped at 4). */
|
|
45
|
-
export function createReopenRecoveryPanels(deps: ReopenPanelsDeps): (snapshot: SessionSnapshot) => void {
|
|
46
|
-
return (snapshot) => {
|
|
47
|
-
for (const panelId of (snapshot.returnContext?.openPanels ?? []).slice(0, 4)) {
|
|
48
|
-
try { deps.panelManager.open(panelId); } catch { /* unknown panel id */ }
|
|
49
|
-
}
|
|
50
|
-
if ((snapshot.returnContext?.openPanels?.length ?? 0) > 0) { deps.panelManager.show(); deps.render(); }
|
|
51
|
-
};
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export interface AutoRestoreRecoveryDeps {
|
|
55
|
-
readonly workingDirectory: string;
|
|
56
|
-
readonly homeDirectory: string;
|
|
57
|
-
/** The live conversation to restore INTO (fromJSON applies the snapshot). */
|
|
58
|
-
readonly conversation: {
|
|
59
|
-
fromJSON(input: { messages: ConversationMessageSnapshot[]; title?: string; titleSource?: SessionSnapshot['titleSource'] }): void;
|
|
60
|
-
};
|
|
61
|
-
/** Persist the post-replay snapshot so the WAL gap is durably closed. */
|
|
62
|
-
readonly persistSnapshot: (msgs: ConversationMessageSnapshot[]) => void;
|
|
63
|
-
/** Reopen the restored session's panels. */
|
|
64
|
-
readonly reopenPanels: (snapshot: SessionSnapshot) => void;
|
|
65
|
-
/** Surface the one-line receipt (the only user-visible sign of a restore). */
|
|
66
|
-
readonly systemMessageRouter: { high(message: string): void };
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Silent crash-recovery restore at startup — no prompt, no banner. Loads the
|
|
71
|
-
* newest crash snapshot (the SDK scopes the load AND the follow-up delete to
|
|
72
|
-
* that snapshot's own session id), applies it to the live conversation, replays
|
|
73
|
-
* any journal turns written after the snapshot, reopens its panels, and emits
|
|
74
|
-
* the SDK's one-line receipt. Returns true when a session was restored, false
|
|
75
|
-
* when there was nothing to restore.
|
|
76
|
-
*/
|
|
77
|
-
export function autoRestoreRecoverySession(deps: AutoRestoreRecoveryDeps): boolean {
|
|
78
|
-
const result = autoRestoreRecovery({ workingDirectory: deps.workingDirectory, homeDirectory: deps.homeDirectory });
|
|
79
|
-
if (!result) return false;
|
|
80
|
-
const { snapshot, info, receipt } = result;
|
|
81
|
-
deps.conversation.fromJSON({
|
|
82
|
-
messages: snapshot.messages as ConversationMessageSnapshot[],
|
|
83
|
-
title: snapshot.title,
|
|
84
|
-
titleSource: snapshot.titleSource,
|
|
85
|
-
});
|
|
86
|
-
// Replay journal records that post-date the snapshot so turns written after
|
|
87
|
-
// the last recovery-file write (but before the crash) are not dropped.
|
|
88
|
-
replayJournalForSession({
|
|
89
|
-
homeDirectory: deps.homeDirectory,
|
|
90
|
-
sessionId: info.sessionId,
|
|
91
|
-
snapshotTimestamp: info.timestamp ?? 0,
|
|
92
|
-
conversation: deps.conversation as never,
|
|
93
|
-
persistSnapshot: deps.persistSnapshot,
|
|
94
|
-
});
|
|
95
|
-
deps.reopenPanels(snapshot);
|
|
96
|
-
deps.systemMessageRouter.high(`[Recovery] ${receipt}`);
|
|
97
|
-
return true;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
15
|
export interface ErrorAffordanceDeps {
|
|
101
16
|
/** True when the failover retry context is armed (a retry is actually possible). */
|
|
102
17
|
readonly retryArmed: boolean;
|
|
@@ -109,11 +24,15 @@ export interface ErrorAffordanceDeps {
|
|
|
109
24
|
/**
|
|
110
25
|
* Handle one keypress while the error-retry affordance is active.
|
|
111
26
|
* 'r' retries on the current provider when armed; 'm' opens the model
|
|
112
|
-
* picker
|
|
113
|
-
*
|
|
27
|
+
* picker — also gated on armed, since switching models here only makes
|
|
28
|
+
* sense in service of the same retry this affordance is offering, not as a
|
|
29
|
+
* general-purpose model-picker shortcut. Returns true when the key was
|
|
30
|
+
* consumed; any other key (or 'm'/'r' while unarmed) returns false so the
|
|
31
|
+
* caller routes it as normal input.
|
|
114
32
|
*/
|
|
115
33
|
export function handleErrorAffordanceKey(data: string, deps: ErrorAffordanceDeps): boolean {
|
|
116
|
-
if (
|
|
34
|
+
if (!deps.retryArmed) return false;
|
|
35
|
+
if (data === 'r') {
|
|
117
36
|
deps.retry();
|
|
118
37
|
deps.render();
|
|
119
38
|
return true;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* retry-affordance.ts — the one-key retry/switch-model affordance shown
|
|
3
|
+
* right after a user-visible TURN_ERROR: 'r' re-submits on the current
|
|
4
|
+
* provider, 'm' opens the model picker, any other key disarms it silently.
|
|
5
|
+
*
|
|
6
|
+
* Rendered as a transient FOOTER status line (see retryAffordanceHint,
|
|
7
|
+
* wired into ShellFooterBuildOptions.retryHint in shell-surface.ts) rather
|
|
8
|
+
* than a permanent transcript message — it disappears the moment it's
|
|
9
|
+
* disarmed, instead of sitting in the scrollback claiming an affordance
|
|
10
|
+
* that no longer does anything. Extracted from main.ts to keep the
|
|
11
|
+
* entrypoint under the architecture line cap.
|
|
12
|
+
*
|
|
13
|
+
* The affordance is also time-bounded: arming starts a disarm timer so a
|
|
14
|
+
* stray 'r' hours later can never fire a real, paid retry against the
|
|
15
|
+
* provider. The timer's window is exactly what the footer hint's visibility
|
|
16
|
+
* implies — when it fires, the state disarms AND retryAffordanceHint(state)
|
|
17
|
+
* goes back to null in the same tick, so the key dies at the same moment
|
|
18
|
+
* the hint disappears.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** How long the affordance stays armed with no keypress before it silently expires. */
|
|
22
|
+
export const RETRY_AFFORDANCE_WINDOW_MS = 60_000;
|
|
23
|
+
|
|
24
|
+
/** Timer primitives injectable for tests, so arming doesn't require a real 60s wait. */
|
|
25
|
+
export interface RetryAffordanceSchedule {
|
|
26
|
+
readonly setTimeout: (cb: () => void, ms: number) => ReturnType<typeof setTimeout>;
|
|
27
|
+
readonly clearTimeout: (handle: ReturnType<typeof setTimeout>) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const REAL_SCHEDULE: RetryAffordanceSchedule = {
|
|
31
|
+
setTimeout: (cb, ms) => setTimeout(cb, ms),
|
|
32
|
+
clearTimeout: (handle) => clearTimeout(handle),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export interface RetryAffordanceState {
|
|
36
|
+
armed: boolean;
|
|
37
|
+
exhausted: boolean;
|
|
38
|
+
/** Internal: the pending disarm timer's handle, or null while unarmed. */
|
|
39
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
40
|
+
/** Internal: injected timer primitives — real setTimeout/clearTimeout in production. */
|
|
41
|
+
readonly schedule: RetryAffordanceSchedule;
|
|
42
|
+
/** Internal: the disarm window in ms. */
|
|
43
|
+
readonly windowMs: number;
|
|
44
|
+
/** Internal: invoked after the timer disarms the state, so the render loop can repaint the footer. */
|
|
45
|
+
readonly onExpire?: () => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface CreateRetryAffordanceStateOptions {
|
|
49
|
+
/** Overridable for tests; defaults to RETRY_AFFORDANCE_WINDOW_MS (60s). */
|
|
50
|
+
readonly windowMs?: number;
|
|
51
|
+
/** Overridable for tests so the disarm timer doesn't require a real 60s wait. */
|
|
52
|
+
readonly schedule?: RetryAffordanceSchedule;
|
|
53
|
+
/** Called once the window elapses with no keypress and the state has been disarmed — main.ts hooks this to its render loop so the footer hint clears the instant the key goes dead. */
|
|
54
|
+
readonly onExpire?: () => void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function createRetryAffordanceState(options: CreateRetryAffordanceStateOptions = {}): RetryAffordanceState {
|
|
58
|
+
return {
|
|
59
|
+
armed: false,
|
|
60
|
+
exhausted: false,
|
|
61
|
+
timer: null,
|
|
62
|
+
schedule: options.schedule ?? REAL_SCHEDULE,
|
|
63
|
+
windowMs: options.windowMs ?? RETRY_AFFORDANCE_WINDOW_MS,
|
|
64
|
+
onExpire: options.onExpire,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function clearPendingTimer(state: RetryAffordanceState): void {
|
|
69
|
+
if (state.timer !== null) {
|
|
70
|
+
state.schedule.clearTimeout(state.timer);
|
|
71
|
+
state.timer = null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Arm the affordance — call only when a retry is actually possible (a live retryCtx).
|
|
76
|
+
* Starts (or restarts, if already armed) the disarm timer. */
|
|
77
|
+
export function armRetryAffordance(state: RetryAffordanceState, exhausted: boolean): void {
|
|
78
|
+
clearPendingTimer(state);
|
|
79
|
+
state.armed = true;
|
|
80
|
+
state.exhausted = exhausted;
|
|
81
|
+
const timer = state.schedule.setTimeout(() => {
|
|
82
|
+
state.timer = null;
|
|
83
|
+
state.armed = false;
|
|
84
|
+
state.onExpire?.();
|
|
85
|
+
}, state.windowMs);
|
|
86
|
+
// Never let this timer hold the process open — a background retry-window
|
|
87
|
+
// countdown is not a reason to keep the event loop alive.
|
|
88
|
+
(timer as unknown as { unref?: () => void }).unref?.();
|
|
89
|
+
state.timer = timer;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Disarm the affordance — any key other than the ones it recognizes disarms it,
|
|
93
|
+
* and cancels the pending disarm timer so it never fires on a state that's
|
|
94
|
+
* already been reset (or re-armed by a subsequent error). */
|
|
95
|
+
export function disarmRetryAffordance(state: RetryAffordanceState): void {
|
|
96
|
+
clearPendingTimer(state);
|
|
97
|
+
state.armed = false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The transient footer hint text while armed; null once disarmed. */
|
|
101
|
+
export function retryAffordanceHint(state: RetryAffordanceState): string | null {
|
|
102
|
+
if (!state.armed) return null;
|
|
103
|
+
return state.exhausted
|
|
104
|
+
? '[Retry] r retry same provider · m switch model'
|
|
105
|
+
: '[Retry] r retry · m switch model';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Arm the affordance whenever a user-visible error surfaces AND a retry is
|
|
109
|
+
* actually possible — wired here (not inline in main.ts) to keep the
|
|
110
|
+
* entrypoint under the architecture line cap. */
|
|
111
|
+
export function wireRetryAffordanceOnError(
|
|
112
|
+
onErrorSurfaced: (cb: (exhausted: boolean) => void) => void,
|
|
113
|
+
state: RetryAffordanceState,
|
|
114
|
+
hasRetryCtx: () => boolean,
|
|
115
|
+
render: () => void,
|
|
116
|
+
): void {
|
|
117
|
+
onErrorSurfaced((exhausted) => {
|
|
118
|
+
if (hasRetryCtx()) {
|
|
119
|
+
armRetryAffordance(state, exhausted);
|
|
120
|
+
render();
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
package/src/shell/ui-openers.ts
CHANGED
|
@@ -369,6 +369,15 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
|
|
|
369
369
|
render();
|
|
370
370
|
};
|
|
371
371
|
|
|
372
|
+
// Mirrors the Ctrl+F chord's own toggle exactly (handler-shortcuts.ts's
|
|
373
|
+
// 'search' case) — the transcript search overlay isn't tracked on the
|
|
374
|
+
// modal stack, so this doesn't call modalOpened either.
|
|
375
|
+
commandContext.openTranscriptSearch = () => {
|
|
376
|
+
if (input.searchManager.active) input.searchManager.close();
|
|
377
|
+
else input.searchManager.open();
|
|
378
|
+
render();
|
|
379
|
+
};
|
|
380
|
+
|
|
372
381
|
commandContext.openShortcutsOverlay = () => {
|
|
373
382
|
if (!input.shortcutsOverlayActive) input.modalOpened('shortcuts');
|
|
374
383
|
input.shortcutsOverlayActive = !input.shortcutsOverlayActive;
|
|
@@ -85,7 +85,7 @@ export function getSplashLines(columns: number, opts: SplashOptions = {}): strin
|
|
|
85
85
|
|
|
86
86
|
// Last-session resume pointer (readLastSessionPointer result), when recorded.
|
|
87
87
|
if (opts.lastSessionId) {
|
|
88
|
-
lines.push(metaLine(`↩ resume last session — /
|
|
88
|
+
lines.push(metaLine(`↩ resume last session — /resume ${opts.lastSessionId}`, columns));
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
lines.push('');
|
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 = '1.
|
|
9
|
+
let _version = '1.20.0';
|
|
10
10
|
try {
|
|
11
11
|
const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
|
|
12
12
|
// Only trust a version read from OUR OWN package.json. A Bun single-file
|