@pellux/goodvibes-tui 1.19.6 → 1.20.1
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 +13 -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/bundle-command.ts +1 -1
- 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/management.ts +1 -1
- 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/active-model-identity.ts +153 -0
- package/src/core/conversation-line-cache.ts +1 -1
- package/src/core/conversation-rendering.ts +70 -24
- package/src/core/conversation-tool-groups.ts +23 -10
- package/src/core/conversation-types.ts +9 -0
- package/src/core/conversation-user-receipts.ts +42 -0
- package/src/core/conversation.ts +20 -17
- package/src/core/rewind-turn-anchors.ts +12 -13
- package/src/core/session-recovery.ts +59 -2
- package/src/core/session-resume-core.ts +206 -0
- package/src/core/stream-event-wiring.ts +174 -14
- package/src/core/system-message-router.ts +22 -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 +1 -1
- 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 +66 -61
- package/src/panels/builtin/operations.ts +2 -1
- package/src/panels/provider-health-domains.ts +2 -2
- package/src/{cli → providers}/provider-classification.ts +12 -0
- 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/header-line.ts +146 -0
- package/src/renderer/help-overlay.ts +69 -53
- package/src/renderer/selection-modal-overlay.ts +66 -10
- package/src/renderer/shell-surface.ts +51 -0
- package/src/renderer/tool-result-expanded-lines.ts +48 -0
- package/src/renderer/ui-factory.ts +26 -79
- 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 -0
- package/src/runtime/process-lifecycle.ts +63 -2
- package/src/runtime/recovery-autosave.ts +48 -0
- package/src/runtime/recovery-decisions.ts +126 -0
- package/src/runtime/recovery-offer-wiring.ts +89 -0
- package/src/runtime/recovery-prompt.ts +362 -0
- package/src/runtime/resume-notice.ts +28 -93
- 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 +7 -3
- 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
|
@@ -19,6 +19,13 @@ export interface ShellFooterBuildOptions {
|
|
|
19
19
|
readonly showExitNotice: boolean;
|
|
20
20
|
readonly lastCopyTime: number;
|
|
21
21
|
readonly model?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Divergence marker appended to the model segment while the serving backend
|
|
24
|
+
* is not the user's configured selection (core/active-model-identity.ts),
|
|
25
|
+
* e.g. "failover from abacusai:route-llm". Absent/empty in the normal case,
|
|
26
|
+
* where the context-info line renders exactly as it always has.
|
|
27
|
+
*/
|
|
28
|
+
readonly modelNote?: string;
|
|
22
29
|
readonly toolCount?: number;
|
|
23
30
|
readonly workingDir?: string;
|
|
24
31
|
readonly provider?: string;
|
|
@@ -53,6 +60,14 @@ export interface ShellFooterBuildOptions {
|
|
|
53
60
|
* below the context pressure hint.
|
|
54
61
|
*/
|
|
55
62
|
readonly scriptableStatusLine?: string | null;
|
|
63
|
+
/**
|
|
64
|
+
* The one-key retry/switch-model affordance's transient hint text (see
|
|
65
|
+
* retry-affordance.ts). Rendered as its own line ABOVE the context
|
|
66
|
+
* pressure hint while armed; present here only means "show it now" — the
|
|
67
|
+
* caller is responsible for passing null the instant it disarms, so this
|
|
68
|
+
* is a time-bounded status line, never a lingering claim.
|
|
69
|
+
*/
|
|
70
|
+
readonly retryHint?: string | null;
|
|
56
71
|
/**
|
|
57
72
|
* Compact footer posture for short terminals (~<30 rows). Collapses the
|
|
58
73
|
* footer to its essentials (prompt box + token/cost line + help) and drops
|
|
@@ -104,6 +119,34 @@ const DIM_STATUS_FG = '#64748b';
|
|
|
104
119
|
*/
|
|
105
120
|
let lastRenderedFooterHeight: { compact: boolean; height: number } | null = null;
|
|
106
121
|
|
|
122
|
+
/**
|
|
123
|
+
* The wrapped-prompt shape the composer reports each frame — the subset
|
|
124
|
+
* promptCursorOffset needs (see InputHandler.getWrappedPromptInfo).
|
|
125
|
+
*/
|
|
126
|
+
export interface WrappedPromptCursorInfo {
|
|
127
|
+
readonly visibleLines: readonly string[];
|
|
128
|
+
readonly visibleCursorLine: number;
|
|
129
|
+
readonly visibleCursorCol: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Flatten a wrapped-prompt cursor position (line + column) into the single
|
|
134
|
+
* character offset createFooter's `cursorPos` expects, counting one separator
|
|
135
|
+
* per wrapped line. Returns undefined when the cursor is not on a visible line
|
|
136
|
+
* (scrolled out of the composer's window), which suppresses the cursor overlay
|
|
137
|
+
* rather than drawing it in the wrong place.
|
|
138
|
+
*
|
|
139
|
+
* Lives here rather than inline at the render site so main.ts (a composition
|
|
140
|
+
* root already at the source-line gate) stays a wiring file.
|
|
141
|
+
*/
|
|
142
|
+
export function promptCursorOffset(info: WrappedPromptCursorInfo): number | undefined {
|
|
143
|
+
if (info.visibleCursorLine < 0) return undefined;
|
|
144
|
+
const precedingChars = info.visibleLines
|
|
145
|
+
.slice(0, info.visibleCursorLine)
|
|
146
|
+
.reduce((sum: number, line: string) => sum + line.length + 1, 0);
|
|
147
|
+
return precedingChars + info.visibleCursorCol;
|
|
148
|
+
}
|
|
149
|
+
|
|
107
150
|
export function estimateShellFooterHeight(
|
|
108
151
|
promptLineCount: number,
|
|
109
152
|
contextWindow?: number,
|
|
@@ -150,6 +193,7 @@ export function buildShellFooter(
|
|
|
150
193
|
options.permissionMode,
|
|
151
194
|
options.webSurfaceUrl,
|
|
152
195
|
options.powerKeepAwake,
|
|
196
|
+
options.modelNote,
|
|
153
197
|
);
|
|
154
198
|
// Compact posture drops the process indicator and context hint entirely so
|
|
155
199
|
// the footer fits within ~5 rows on short terminals.
|
|
@@ -173,6 +217,13 @@ export function buildShellFooter(
|
|
|
173
217
|
const hintLine = UIFactory.stringToLine(options.contextStatusHint, options.width, { fg: DIM_STATUS_FG });
|
|
174
218
|
lines.unshift(hintLine);
|
|
175
219
|
}
|
|
220
|
+
// Retry affordance — topmost of the passive hint lines while armed (an
|
|
221
|
+
// actionable, time-bounded prompt outranks the passive status hints
|
|
222
|
+
// below it); simply absent the instant it disarms.
|
|
223
|
+
if (options.retryHint) {
|
|
224
|
+
const retryLine = UIFactory.stringToLine(options.retryHint, options.width, { fg: DIM_STATUS_FG, bold: true });
|
|
225
|
+
lines.unshift(retryLine);
|
|
226
|
+
}
|
|
176
227
|
}
|
|
177
228
|
lastRenderedFooterHeight = { compact: options.compact ?? false, height: lines.length };
|
|
178
229
|
return { lines, height: lines.length };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tool-result-expanded-lines.ts — the single source of truth for how many
|
|
3
|
+
* screen lines a tool-result message's EXPANDED form actually renders to.
|
|
4
|
+
*
|
|
5
|
+
* Tool-result content gets pretty-printed (JSON.stringify(parsed, null, 2))
|
|
6
|
+
* or diff-rendered before display, so the raw message's `content.split('\n')`
|
|
7
|
+
* length is not what the user sees once expanded — a one-line JSON blob can
|
|
8
|
+
* pretty-print to 50 lines. Both the per-block "N lines" badge
|
|
9
|
+
* (conversation-rendering.ts) and the folded-group total (conversation-tool-
|
|
10
|
+
* groups.ts) must count the SAME post-render lines, or the two disagree with
|
|
11
|
+
* each other and both can disagree with what Tab actually reveals.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Line } from '../types/grid.ts';
|
|
15
|
+
import { renderMarkdownTracked } from './markdown.ts';
|
|
16
|
+
import { renderDiffView } from './diff-view.ts';
|
|
17
|
+
|
|
18
|
+
/** True when `content` looks like a unified diff (matches the detection used
|
|
19
|
+
* by renderConversationToolMessage to pick the diff block type). */
|
|
20
|
+
export function isDiffContent(content: string): boolean {
|
|
21
|
+
const contentLines = content.split('\n');
|
|
22
|
+
const hasDiffHeader = contentLines.some((l) => l.startsWith('--- ')) && contentLines.some((l) => l.startsWith('+++ '));
|
|
23
|
+
const hasHunk = contentLines.some((l) => l.startsWith('@@ '));
|
|
24
|
+
return hasDiffHeader && hasHunk;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Render a tool result's content exactly as its EXPANDED form would appear in
|
|
29
|
+
* the transcript (diff view for diffs; pretty-printed JSON, when parseable,
|
|
30
|
+
* for everything else). The caller decides whether to actually display these
|
|
31
|
+
* lines or just count them.
|
|
32
|
+
*/
|
|
33
|
+
export function renderExpandedToolResultLines(content: string, width: number): Line[] {
|
|
34
|
+
if (isDiffContent(content)) {
|
|
35
|
+
return renderDiffView(content, width);
|
|
36
|
+
}
|
|
37
|
+
let contentToRender = content;
|
|
38
|
+
const trimmed = contentToRender.trimStart();
|
|
39
|
+
if ((trimmed.startsWith('{') || trimmed.startsWith('[')) && contentToRender.length < 100_000) {
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(contentToRender);
|
|
42
|
+
contentToRender = `\`\`\`json\n${JSON.stringify(parsed, null, 2)}\n\`\`\``;
|
|
43
|
+
} catch {
|
|
44
|
+
// Leave invalid JSON as-is — falls through to the plain markdown render below.
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return renderMarkdownTracked(contentToRender, width).lines;
|
|
48
|
+
}
|
|
@@ -3,6 +3,7 @@ import { LAYOUT } from './layout.ts';
|
|
|
3
3
|
import { VERSION } from '../version.ts';
|
|
4
4
|
import { fitDisplay, getDisplayWidth, truncateDisplay, wrapText, interpolateColor, joinPrioritizedSegments, type PrioritizedSegment } from '../utils/terminal-width.ts';
|
|
5
5
|
import type { GitHeaderInfo } from './git-status.ts';
|
|
6
|
+
import { renderHeaderLine } from './header-line.ts';
|
|
6
7
|
import { renderConversationFragment, renderConversationStatusLine, type ConversationStatusSegment } from './conversation-surface.ts';
|
|
7
8
|
import { GLYPHS } from './ui-primitives.ts';
|
|
8
9
|
import { activeUiTones } from './theme.ts';
|
|
@@ -42,22 +43,6 @@ export interface ThinkingStallInfo {
|
|
|
42
43
|
readonly reconnect?: { readonly attempt: number; readonly maxAttempts: number };
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
/** Build the git segment string and its display width. Single source of truth for header layout. */
|
|
46
|
-
function buildGitSegment(gitInfo: GitHeaderInfo): { text: string; width: number } {
|
|
47
|
-
const branch = ` git:${gitInfo.branch}`;
|
|
48
|
-
if (gitInfo.dirty) {
|
|
49
|
-
const text = `${branch} * `;
|
|
50
|
-
return { text, width: getDisplayWidth(text) };
|
|
51
|
-
}
|
|
52
|
-
if (gitInfo.ahead > 0 || gitInfo.behind > 0) {
|
|
53
|
-
const arrows = (gitInfo.ahead > 0 ? ` +${gitInfo.ahead}` : '') + (gitInfo.behind > 0 ? ` -${gitInfo.behind}` : '');
|
|
54
|
-
const text = `${branch}${arrows} `;
|
|
55
|
-
return { text, width: getDisplayWidth(text) };
|
|
56
|
-
}
|
|
57
|
-
const text = `${branch} `;
|
|
58
|
-
return { text, width: getDisplayWidth(text) };
|
|
59
|
-
}
|
|
60
|
-
|
|
61
46
|
/** Format a number: up to 999, then 1.0k, 1.0M, 1.0B, 1.0T */
|
|
62
47
|
function fmtNum(n: number): string {
|
|
63
48
|
return abbreviateCount(n, { bSuffix: true });
|
|
@@ -75,68 +60,19 @@ function fmtCost(usd: number): string {
|
|
|
75
60
|
* UIFactory - Generates standard UI fragments without needing Ink/React overhead.
|
|
76
61
|
*/
|
|
77
62
|
export class UIFactory {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const brand = ` GoodVibes `;
|
|
92
|
-
const ver = `v${version} `;
|
|
93
|
-
const stats = ` ${model} `;
|
|
94
|
-
const prov = `(${provider}) `;
|
|
95
|
-
const line = createEmptyLine(width);
|
|
96
|
-
let curX = 0;
|
|
97
|
-
for (const char of brand) { line[curX++] = { char, fg: CYAN, bg: '', bold: true, dim: false, underline: false, italic: false, strikethrough: false }; }
|
|
98
|
-
for (const char of ver) { line[curX++] = { char, fg: GREY, bg: '', bold: false, dim: true, underline: false, italic: false, strikethrough: false }; }
|
|
99
|
-
// Optional conversation title — shown after brand/ver, truncated to fit
|
|
100
|
-
if (title) {
|
|
101
|
-
const titleStr = `│ ${title} `;
|
|
102
|
-
// Reserve space for git info (if present) + model/provider on the right
|
|
103
|
-
const gitReserved = gitInfo ? buildGitSegment(gitInfo).width : 0;
|
|
104
|
-
const rightReserved = getDisplayWidth(stats + prov) + gitReserved;
|
|
105
|
-
const maxTitleW = width - curX - rightReserved - 1;
|
|
106
|
-
let displayTitle: string;
|
|
107
|
-
if (getDisplayWidth(titleStr) <= maxTitleW) {
|
|
108
|
-
displayTitle = titleStr;
|
|
109
|
-
} else {
|
|
110
|
-
let truncated = '';
|
|
111
|
-
let w = 0;
|
|
112
|
-
for (const ch of titleStr) {
|
|
113
|
-
const cw = getDisplayWidth(ch);
|
|
114
|
-
if (w + cw > maxTitleW - 3) { truncated += '...'; break; }
|
|
115
|
-
truncated += ch;
|
|
116
|
-
w += cw;
|
|
117
|
-
}
|
|
118
|
-
displayTitle = truncated;
|
|
119
|
-
}
|
|
120
|
-
for (const char of displayTitle) { if (curX < width) line[curX++] = { char, fg: TITLE_COLOR, bg: '', bold: false, dim: true, underline: false, italic: false, strikethrough: false }; }
|
|
121
|
-
}
|
|
122
|
-
// Build git info segment
|
|
123
|
-
let gitStr = '';
|
|
124
|
-
let gitFg: string = t.chrome.faint;
|
|
125
|
-
if (gitInfo) {
|
|
126
|
-
gitStr = buildGitSegment(gitInfo).text;
|
|
127
|
-
if (gitInfo.dirty || gitInfo.ahead > 0 || gitInfo.behind > 0) {
|
|
128
|
-
gitFg = t.chrome.warn; // yellow when dirty or out-of-sync
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
const rightSideText = stats + prov;
|
|
132
|
-
const rightSideW = getDisplayWidth(rightSideText) + getDisplayWidth(gitStr);
|
|
133
|
-
let rightX = width - rightSideW;
|
|
134
|
-
for (const char of gitStr) { if (rightX >= 0 && rightX < width) line[rightX++] = { char, fg: gitFg, bg: '', bold: false, dim: !gitInfo?.dirty && !(gitInfo?.ahead || gitInfo?.behind), underline: false, italic: false, strikethrough: false }; }
|
|
135
|
-
for (const char of stats) { if (rightX < width) line[rightX++] = { char, fg: CYAN, bg: '', bold: true, dim: false, underline: false, italic: false, strikethrough: false }; }
|
|
136
|
-
for (const char of prov) { if (rightX < width) line[rightX++] = { char, fg: GREY, bg: '', bold: false, dim: true, underline: false, italic: false, strikethrough: false }; }
|
|
137
|
-
lines.push(line);
|
|
138
|
-
lines.push(this.stringToLine('━'.repeat(width), width, { fg: t.chrome.faint }));
|
|
139
|
-
return lines;
|
|
63
|
+
/**
|
|
64
|
+
* The header row. Delegates to renderHeaderLine (header-line.ts) — extracted
|
|
65
|
+
* when the header gained the failover divergence marker and this file was
|
|
66
|
+
* already on the 800-line source gate.
|
|
67
|
+
*
|
|
68
|
+
* `model`/`provider` are the SERVING backend and `note` the divergence
|
|
69
|
+
* marker, both resolved by core/active-model-identity.ts so the header and
|
|
70
|
+
* the footer answer "who is serving?" from one place. `version` defaults to
|
|
71
|
+
* the live build VERSION; tests pass a pinned fixture so golden snapshots
|
|
72
|
+
* don't break on every release bump.
|
|
73
|
+
*/
|
|
74
|
+
public static createHeader(width: number, model: string, provider: string, title?: string, gitInfo?: GitHeaderInfo, version: string = VERSION, note: string = ''): Line[] {
|
|
75
|
+
return renderHeaderLine(width, model, provider, title, gitInfo, version, note);
|
|
140
76
|
}
|
|
141
77
|
|
|
142
78
|
/**
|
|
@@ -221,6 +157,11 @@ export class UIFactory {
|
|
|
221
157
|
// "sleep disabled" chip in the posture block (the danger-mode idiom: a
|
|
222
158
|
// persistent, colored surface indicator, the safety mechanism itself).
|
|
223
159
|
powerKeepAwake?: boolean,
|
|
160
|
+
// Divergence marker for the model segment (core/active-model-identity.ts):
|
|
161
|
+
// set only while the serving backend is not the user's configured
|
|
162
|
+
// selection, e.g. "failover from abacusai:route-llm". Empty/undefined
|
|
163
|
+
// renders the context-info line exactly as it did before the marker existed.
|
|
164
|
+
modelNote?: string,
|
|
224
165
|
): Line[] {
|
|
225
166
|
const lines: Line[] = [];
|
|
226
167
|
const promptLines = prompt.split('\n');
|
|
@@ -499,6 +440,12 @@ export class UIFactory {
|
|
|
499
440
|
if (model) {
|
|
500
441
|
ctxParts.push({ text: model + (provider ? ` (${provider})` : ''), priority: 0 });
|
|
501
442
|
}
|
|
443
|
+
// Divergence marker — its own segment so a narrow line drops it WHOLE
|
|
444
|
+
// (joinPrioritizedSegments breaks priority ties toward the later
|
|
445
|
+
// segment) rather than truncating it into a half-word. Priority 0: it
|
|
446
|
+
// outranks every decorative segment, and what survives without it is
|
|
447
|
+
// still the SERVING backend, never a claim about the configured one.
|
|
448
|
+
if (modelNote) ctxParts.push({ text: modelNote, priority: 0 });
|
|
502
449
|
// Cross-surface spine posture — plain words, no blame. Adopted mode only.
|
|
503
450
|
if (sessionSpineStatus) ctxParts.push({ text: `spine:${sessionSpineStatus}`, priority: 1 });
|
|
504
451
|
// Web surface reachability — plain, persistent; dropped first under width pressure.
|
|
@@ -624,7 +571,7 @@ export class UIFactory {
|
|
|
624
571
|
// them; keep the elapsed timer since "how long the approval has waited" is honest.
|
|
625
572
|
const speedSuffix = (!approvalPending && tokenSpeed !== undefined && tokenSpeed > 0) ? ` (${Math.round(tokenSpeed)} tok/s)` : '';
|
|
626
573
|
const elapsedSuffix = elapsedMs !== undefined ? ` (${formatElapsed(elapsedMs)})` : '';
|
|
627
|
-
const ttftSuffix = (!approvalPending && ttftMs !== undefined && ttftMs > 0) ? `
|
|
574
|
+
const ttftSuffix = (!approvalPending && ttftMs !== undefined && ttftMs > 0) ? ` (first token ${formatElapsed(ttftMs)})` : '';
|
|
628
575
|
const text = ` ${spinner} ${phrase}${speedSuffix}${elapsedSuffix}${ttftSuffix} `;
|
|
629
576
|
|
|
630
577
|
const textWidth = Math.max(1, getDisplayWidth(text) - 1);
|
|
@@ -30,7 +30,7 @@ import type { HookWorkbench } from '@pellux/goodvibes-sdk/platform/hooks';
|
|
|
30
30
|
import type { WorktreeRegistry } from '@/runtime/index.ts';
|
|
31
31
|
import type { SandboxSessionRegistry } from '@/runtime/index.ts';
|
|
32
32
|
import type { UiReadModels } from './ui-read-models.ts';
|
|
33
|
-
import type { ShellPathService } from '@/runtime/index.ts';
|
|
33
|
+
import type { ShellPathService, SessionSurface } from '@/runtime/index.ts';
|
|
34
34
|
import type {
|
|
35
35
|
ShellAgentManagerService,
|
|
36
36
|
ShellAutomationManagerRuntimeService,
|
|
@@ -76,6 +76,8 @@ export type CreateBootstrapCommandContextOptions = {
|
|
|
76
76
|
policyRuntimeState: PolicyRuntimeState;
|
|
77
77
|
readModels: UiReadModels;
|
|
78
78
|
shellPaths: ShellPathService;
|
|
79
|
+
/** The runtime's declare-once session-storage handle (runtime/services.ts). */
|
|
80
|
+
surface: SessionSurface;
|
|
79
81
|
remoteRuntime?: RemoteCommandService;
|
|
80
82
|
planRuntime?: PlanRuntimeService;
|
|
81
83
|
fileUndoManager: FileUndoManager;
|
|
@@ -163,6 +165,7 @@ export function createBootstrapCommandContext(
|
|
|
163
165
|
policyRuntimeState,
|
|
164
166
|
readModels,
|
|
165
167
|
shellPaths,
|
|
168
|
+
surface,
|
|
166
169
|
remoteRuntime,
|
|
167
170
|
planRuntime,
|
|
168
171
|
fileUndoManager,
|
|
@@ -277,6 +280,7 @@ export function createBootstrapCommandContext(
|
|
|
277
280
|
benchmarkStore,
|
|
278
281
|
});
|
|
279
282
|
const workspace = createBootstrapCommandWorkspaceSection({
|
|
283
|
+
surface,
|
|
280
284
|
keybindingsManager,
|
|
281
285
|
fileUndoManager,
|
|
282
286
|
workspaceCheckpointManager,
|
|
@@ -32,7 +32,7 @@ import type { HookWorkbench } from '@pellux/goodvibes-sdk/platform/hooks';
|
|
|
32
32
|
import type { WorktreeRegistry } from '@/runtime/index.ts';
|
|
33
33
|
import type { SandboxSessionRegistry } from '@/runtime/index.ts';
|
|
34
34
|
import type { UiReadModels } from './ui-read-models.ts';
|
|
35
|
-
import type { ShellPathService } from '@/runtime/index.ts';
|
|
35
|
+
import type { ShellPathService, SessionSurface } from '@/runtime/index.ts';
|
|
36
36
|
import type {
|
|
37
37
|
ShellAgentManagerService,
|
|
38
38
|
ShellAutomationManagerRuntimeService,
|
|
@@ -95,6 +95,8 @@ export interface BootstrapCommandSectionOptions {
|
|
|
95
95
|
readonly policyRuntimeState: PolicyRuntimeState;
|
|
96
96
|
readonly readModels: UiReadModels;
|
|
97
97
|
readonly shellPaths: ShellPathService;
|
|
98
|
+
/** The runtime's declare-once session-storage handle (runtime/services.ts). */
|
|
99
|
+
readonly surface: SessionSurface;
|
|
98
100
|
readonly fileUndoManager: FileUndoManager;
|
|
99
101
|
readonly workspaceCheckpointManager?: WorkspaceCheckpointManager;
|
|
100
102
|
readonly gatewayMethods?: GatewayMethodCatalog;
|
|
@@ -372,12 +374,13 @@ export function createBootstrapCommandProviderSection(
|
|
|
372
374
|
export function createBootstrapCommandWorkspaceSection(
|
|
373
375
|
options: Pick<
|
|
374
376
|
BootstrapCommandSectionOptions,
|
|
375
|
-
'keybindingsManager' | 'fileUndoManager' | 'workspaceCheckpointManager' | 'gatewayMethods' | 'workspaceTrustManager' | 'workspaceRegistrationManager' | 'panelManager' | 'profileManager' | 'bookmarkManager'
|
|
377
|
+
'surface' | 'keybindingsManager' | 'fileUndoManager' | 'workspaceCheckpointManager' | 'gatewayMethods' | 'workspaceTrustManager' | 'workspaceRegistrationManager' | 'panelManager' | 'profileManager' | 'bookmarkManager'
|
|
376
378
|
| 'projectPlanningService' | 'projectPlanningProjectId' | 'workPlanStore'
|
|
377
379
|
>,
|
|
378
380
|
shellServices: BootstrapCommandShellServices,
|
|
379
381
|
): BootstrapCommandWorkspaceSection {
|
|
380
382
|
return {
|
|
383
|
+
surface: options.surface,
|
|
381
384
|
keybindingsManager: options.keybindingsManager,
|
|
382
385
|
fileUndoManager: options.fileUndoManager,
|
|
383
386
|
workspaceCheckpointManager: options.workspaceCheckpointManager,
|
|
@@ -350,7 +350,7 @@ export async function initializeBootstrapCore(
|
|
|
350
350
|
|
|
351
351
|
const toolRegistry = new ToolRegistry();
|
|
352
352
|
const { fileCache, projectIndex } = registerAllTools(toolRegistry, {
|
|
353
|
-
surfaceRoot:
|
|
353
|
+
surfaceRoot: services.surface.surfaceRoot,
|
|
354
354
|
localhostFetchApproval: services.localhostFetchApproval, // loopback-fetch ask, built once in the services composition
|
|
355
355
|
execPromptAnswerHandler: services.execPromptAnswerHandler, // terminal prompt-answer path, built once in the services composition
|
|
356
356
|
fileUndoManager: services.fileUndoManager,
|
|
@@ -384,7 +384,7 @@ export async function initializeBootstrapCore(
|
|
|
384
384
|
});
|
|
385
385
|
// Note: installWrfcAgentToolGuard is called after routeOrBuffer is defined (further below) so the onTrace callback routes guard decisions through the pre-router buffer.
|
|
386
386
|
services.agentOrchestrator.setDependencies({
|
|
387
|
-
surfaceRoot:
|
|
387
|
+
surfaceRoot: services.surface.surfaceRoot,
|
|
388
388
|
// setDependencies is a wholesale replace: re-install the prompt-answer handler and the loopback-fetch ask here or this rewire silently drops them.
|
|
389
389
|
execPromptAnswerHandler: services.execPromptAnswerHandler,
|
|
390
390
|
localhostFetchApproval: services.localhostFetchApproval,
|
|
@@ -507,7 +507,7 @@ export async function initializeBootstrapCore(
|
|
|
507
507
|
|
|
508
508
|
// ── WRFC chain persistence ──────────────────────────────────────────────────────────
|
|
509
509
|
const wrfcPersistence = createWrfcPersistence({
|
|
510
|
-
snapshotPath: join(workingDir, '.goodvibes',
|
|
510
|
+
snapshotPath: join(workingDir, '.goodvibes', services.surface.surfaceRoot, 'wrfc-chains.json'),
|
|
511
511
|
getSystemMessageRouter: () => systemMessageRouterRef.value,
|
|
512
512
|
controller: services.wrfcController,
|
|
513
513
|
});
|
|
@@ -14,7 +14,8 @@ import type { SessionManager } from '@pellux/goodvibes-sdk/platform/sessions';
|
|
|
14
14
|
import type { PanelManager } from '../panels/panel-manager.ts';
|
|
15
15
|
import type { ProviderRegistry } from '@pellux/goodvibes-sdk/platform/providers';
|
|
16
16
|
import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
17
|
-
import {
|
|
17
|
+
import { resumeSessionCore } from '../core/session-resume-core.ts';
|
|
18
|
+
import type { SessionSurface } from '@/runtime/index.ts';
|
|
18
19
|
|
|
19
20
|
export interface ResumeSessionOptions {
|
|
20
21
|
readonly runtimeBus: RuntimeEventBus;
|
|
@@ -31,77 +32,94 @@ export interface ResumeSessionOptions {
|
|
|
31
32
|
readonly hookDispatcher: HookDispatcher;
|
|
32
33
|
readonly sessionManager: SessionManager;
|
|
33
34
|
readonly panelManager: PanelManager;
|
|
35
|
+
/** The app's declare-once session-storage handle — the same one /session resume threads. */
|
|
36
|
+
readonly surface: SessionSurface;
|
|
37
|
+
/**
|
|
38
|
+
* Multi-instance guard for this seam. Resolves false when the operator
|
|
39
|
+
* declines to fork a session another terminal still has open. Optional so a
|
|
40
|
+
* host without a modal surface keeps the seam's original behavior.
|
|
41
|
+
*/
|
|
42
|
+
readonly confirmLiveResume?: (sessionId: string) => Promise<boolean>;
|
|
34
43
|
readonly configManager: Pick<ConfigManager, 'get' | 'getCategory'>;
|
|
35
44
|
readonly providerRegistry: Pick<ProviderRegistry, 'get' | 'getCurrentModel' | 'getForModel' | 'require' | 'resolveModelPricing'>;
|
|
36
|
-
readonly homeDirectory: string;
|
|
37
45
|
/** See CommandSessionServices.hydrateSessionUsage (command-registry.ts). */
|
|
38
46
|
readonly hydrateSessionUsage?: () => void;
|
|
47
|
+
/**
|
|
48
|
+
* Reselects the resumed session's saved model through the live provider
|
|
49
|
+
* registry (matches session-workflow.ts's `/session resume` reselection
|
|
50
|
+
* fallback — see core/session-resume-core.ts). Optional so a caller without
|
|
51
|
+
* a provider API wired still gets the direct-assignment behavior this seam
|
|
52
|
+
* always had.
|
|
53
|
+
*/
|
|
54
|
+
readonly selectModel?: (model: string) => Promise<{ readonly registryKey: string; readonly providerId: string }>;
|
|
39
55
|
}
|
|
40
56
|
|
|
41
|
-
|
|
42
|
-
|
|
57
|
+
/**
|
|
58
|
+
* Delegates the mechanical resume sequence to resumeSessionCore (the same
|
|
59
|
+
* routine session-workflow.ts's `/session resume` calls) so the two seams
|
|
60
|
+
* cannot diverge on restoreTurnAnchors, resetAll-before-fromJSON, model
|
|
61
|
+
* reselection, or the modal-redirect panel-reopen skip — see
|
|
62
|
+
* core/session-resume-core.ts's header doc for the full list of divergences
|
|
63
|
+
* this closes. Everything below the resumeSessionCore call is plumbing only
|
|
64
|
+
* this seam performs (hook fire, session-spine mirror, shared-broker
|
|
65
|
+
* reopen, last-session pointer) plus this seam's own log/render idiom.
|
|
66
|
+
*/
|
|
67
|
+
export function createResumeSessionHandler(options: ResumeSessionOptions): (sessionId: string) => Promise<void> {
|
|
68
|
+
return async (sessionId: string): Promise<void> => {
|
|
43
69
|
try {
|
|
44
|
-
|
|
70
|
+
// Multi-instance safety, matching what `/session resume` already does
|
|
71
|
+
// for the text path: never fork a session another terminal is holding
|
|
72
|
+
// open without the operator saying so.
|
|
73
|
+
if (options.confirmLiveResume && !(await options.confirmLiveResume(sessionId))) {
|
|
74
|
+
options.conversation.log('Resume cancelled — the session is still open in another terminal.', { fg: '244' });
|
|
75
|
+
options.requestRender();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
// Pre-read purely for the emitSessionResumed announcement's turnCount,
|
|
79
|
+
// which reports the raw saved-snapshot size — resumeSessionCore performs
|
|
80
|
+
// its own load() right after this (SessionManager.load is a cheap JSONL
|
|
81
|
+
// parse; the tiny duplicate read keeps this announcement's meaning
|
|
82
|
+
// unchanged rather than repurposing it to a post-replay count).
|
|
83
|
+
const { messages: rawMessages } = options.sessionManager.load(sessionId);
|
|
45
84
|
emitSessionResumed(options.runtimeBus, {
|
|
46
85
|
sessionId: options.runtime.sessionId,
|
|
47
86
|
traceId: `${options.runtime.sessionId}:session-resume:${sessionId}`,
|
|
48
87
|
source: 'bootstrap',
|
|
49
88
|
}, {
|
|
50
89
|
sessionId,
|
|
51
|
-
turnCount:
|
|
52
|
-
});
|
|
53
|
-
options.conversation.fromJSON({
|
|
54
|
-
messages: messages as Parameters<typeof options.conversation.fromJSON>[0]['messages'],
|
|
55
|
-
title: meta.title,
|
|
56
|
-
titleSource: meta.titleSource,
|
|
90
|
+
turnCount: rawMessages.length,
|
|
57
91
|
});
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
sessionId,
|
|
62
|
-
snapshotTimestamp: meta.timestamp ?? 0,
|
|
92
|
+
|
|
93
|
+
const outcome = await resumeSessionCore(sessionId, {
|
|
94
|
+
sessionManager: options.sessionManager,
|
|
63
95
|
conversation: options.conversation,
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
timestamp: Date.now(),
|
|
70
|
-
titleSource: meta.titleSource,
|
|
71
|
-
returnContext: meta.returnContext,
|
|
72
|
-
});
|
|
73
|
-
},
|
|
96
|
+
runtime: options.runtime,
|
|
97
|
+
surface: options.surface,
|
|
98
|
+
panelManager: options.panelManager,
|
|
99
|
+
selectModel: options.selectModel,
|
|
100
|
+
hydrateSessionUsage: options.hydrateSessionUsage,
|
|
74
101
|
});
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
options.hydrateSessionUsage?.();
|
|
102
|
+
const { meta, panels } = outcome;
|
|
103
|
+
|
|
78
104
|
options.onSessionIdChanged?.(sessionId);
|
|
79
|
-
if (meta?.model) options.runtime.model = meta.model;
|
|
80
|
-
if (meta?.provider) options.runtime.provider = meta.provider;
|
|
81
105
|
options.writeLastSessionPointer(sessionId);
|
|
82
106
|
void options.sharedSessionBroker.reopenSession(sessionId).catch((err) => { logger.debug('session broker reopen session failed', { err }); });
|
|
83
107
|
// Fire-and-forget spine mirror (reopen:true — the user resume verb).
|
|
84
108
|
options.sessionSpine.reopen({ sessionId, project: options.project, title: options.conversation.title || meta.title });
|
|
85
109
|
options.conversation.log(`Resumed session: ${sessionId}`, { fg: '135' });
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
reopenedPanels.push(panelId);
|
|
92
|
-
} catch {
|
|
93
|
-
// Ignore unavailable panels during restore.
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
if (reopenedPanels.length > 0) options.panelManager.show();
|
|
110
|
+
if (panels.movedToModal.length > 0) {
|
|
111
|
+
options.conversation.log(`Resume: ${panels.movedToModal.join(', ')} moved to a modal — reopen via its command instead of as a panel.`, { fg: '244' });
|
|
112
|
+
}
|
|
113
|
+
if (panels.notReopened.length > 0) {
|
|
114
|
+
options.conversation.log(`Resume: …and ${panels.notReopened.length} more not reopened (/panels to open)`, { fg: '244' });
|
|
97
115
|
}
|
|
98
116
|
const returnContextMode = getReturnContextMode(options.configManager);
|
|
99
117
|
if (returnContextMode !== 'off' && meta.returnContext) {
|
|
100
118
|
for (const line of formatReturnContextForDisplay(meta.returnContext)) {
|
|
101
119
|
options.conversation.log(`Resume: ${line}`, { fg: '244' });
|
|
102
120
|
}
|
|
103
|
-
if (
|
|
104
|
-
options.conversation.log(`Resume: Reopened panels: ${
|
|
121
|
+
if (panels.reopened.length > 0) {
|
|
122
|
+
options.conversation.log(`Resume: Reopened panels: ${panels.reopened.join(', ')}`, { fg: '244' });
|
|
105
123
|
}
|
|
106
124
|
if ((meta.returnContext.remoteRunners?.length ?? 0) > 0) {
|
|
107
125
|
options.conversation.log(`Resume: Remote re-entry -> /remote recover ${meta.returnContext.remoteRunners![0]}`, { fg: '244' });
|
|
@@ -22,6 +22,7 @@ import { createSystemMessageRouter, type SystemMessageRouter } from '../core/sys
|
|
|
22
22
|
import { getConfigSnapshot } from '../config/index.ts';
|
|
23
23
|
import { createBootstrapCommandContext } from './bootstrap-command-context.ts';
|
|
24
24
|
import { createResumeSessionHandler } from './bootstrap-hook-bridge.ts';
|
|
25
|
+
import { confirmLiveResume } from './session-resume-liveness-confirm.ts';
|
|
25
26
|
import { logger } from '@pellux/goodvibes-sdk/platform/utils';
|
|
26
27
|
import { loadBootstrapSystemPrompt } from '@/runtime/index.ts';
|
|
27
28
|
import { createShellPlanRuntime, createShellRemoteCommandService } from '@/runtime/index.ts';
|
|
@@ -102,6 +103,24 @@ export function createBootstrapShell(options: BootstrapShellOptions): BootstrapS
|
|
|
102
103
|
orchestrator.usage = usage;
|
|
103
104
|
orchestrator.lastInputTokens = lastInputTokens;
|
|
104
105
|
};
|
|
106
|
+
// Built before resumeSession below so its providerApi.selectModel is
|
|
107
|
+
// available for the resume handler's model-reselection step (matches
|
|
108
|
+
// session-workflow.ts's /session resume — see core/session-resume-core.ts).
|
|
109
|
+
const foundationClients = createRuntimeFoundationClients({
|
|
110
|
+
runtimeServices: services,
|
|
111
|
+
tasksReadModel: uiServices.readModels.tasks,
|
|
112
|
+
taskManager,
|
|
113
|
+
opsControlPlane,
|
|
114
|
+
});
|
|
115
|
+
const {
|
|
116
|
+
directTransport,
|
|
117
|
+
hookApi,
|
|
118
|
+
knowledgeApi,
|
|
119
|
+
mcpApi,
|
|
120
|
+
opsApi,
|
|
121
|
+
providerApi,
|
|
122
|
+
} = foundationClients;
|
|
123
|
+
|
|
105
124
|
const resumeSession = createResumeSessionHandler({
|
|
106
125
|
runtimeBus,
|
|
107
126
|
runtime,
|
|
@@ -115,26 +134,21 @@ export function createBootstrapShell(options: BootstrapShellOptions): BootstrapS
|
|
|
115
134
|
hookDispatcher: services.hookDispatcher,
|
|
116
135
|
sessionManager: services.sessionManager,
|
|
117
136
|
panelManager: services.panelManager,
|
|
137
|
+
surface: services.surface,
|
|
138
|
+
// Read lazily on purpose. The selection modal lives on the command
|
|
139
|
+
// context, which is composed further down this function (and whose
|
|
140
|
+
// openSelection is patched later still, by wireShellUiOpeners); a resume
|
|
141
|
+
// only ever happens long after both. An unpatched opener reads as "no
|
|
142
|
+
// surface to ask through", and the resume proceeds as it always did.
|
|
143
|
+
confirmLiveResume: (sessionId) => confirmLiveResume(sessionId, {
|
|
144
|
+
surface: services.surface,
|
|
145
|
+
openSelection: () => commandContext.openSelection,
|
|
146
|
+
}),
|
|
118
147
|
configManager,
|
|
119
148
|
providerRegistry: services.providerRegistry,
|
|
120
|
-
homeDirectory: services.homeDirectory,
|
|
121
149
|
hydrateSessionUsage,
|
|
150
|
+
selectModel: (model) => providerApi.selectModel(model),
|
|
122
151
|
});
|
|
123
|
-
|
|
124
|
-
const foundationClients = createRuntimeFoundationClients({
|
|
125
|
-
runtimeServices: services,
|
|
126
|
-
tasksReadModel: uiServices.readModels.tasks,
|
|
127
|
-
taskManager,
|
|
128
|
-
opsControlPlane,
|
|
129
|
-
});
|
|
130
|
-
const {
|
|
131
|
-
directTransport,
|
|
132
|
-
hookApi,
|
|
133
|
-
knowledgeApi,
|
|
134
|
-
mcpApi,
|
|
135
|
-
opsApi,
|
|
136
|
-
providerApi,
|
|
137
|
-
} = foundationClients;
|
|
138
152
|
const planRuntime = createShellPlanRuntime({
|
|
139
153
|
adaptivePlanner: services.adaptivePlanner,
|
|
140
154
|
runtimeBus,
|
|
@@ -263,6 +277,7 @@ export function createBootstrapShell(options: BootstrapShellOptions): BootstrapS
|
|
|
263
277
|
policyRuntimeState,
|
|
264
278
|
readModels: uiServices.readModels,
|
|
265
279
|
shellPaths: services.shellPaths,
|
|
280
|
+
surface: services.surface,
|
|
266
281
|
remoteRuntime,
|
|
267
282
|
planRuntime,
|
|
268
283
|
fileUndoManager: services.fileUndoManager,
|