@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.
Files changed (94) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +143 -1613
  4. package/docs/README.md +4 -4
  5. package/docs/foundation-artifacts/operator-contract.json +1 -1
  6. package/package.json +7 -7
  7. package/src/cli/bundle-command.ts +1 -1
  8. package/src/cli/ensure-goodvibes-gitignore.ts +16 -4
  9. package/src/cli/entrypoint.ts +5 -1
  10. package/src/cli/launch-auto-update.ts +63 -4
  11. package/src/cli/management.ts +1 -1
  12. package/src/cli/tui-startup.ts +131 -15
  13. package/src/config/surface.ts +21 -0
  14. package/src/config/tui-extension-settings.ts +9 -1
  15. package/src/core/active-model-identity.ts +153 -0
  16. package/src/core/conversation-line-cache.ts +1 -1
  17. package/src/core/conversation-rendering.ts +70 -24
  18. package/src/core/conversation-tool-groups.ts +23 -10
  19. package/src/core/conversation-types.ts +9 -0
  20. package/src/core/conversation-user-receipts.ts +42 -0
  21. package/src/core/conversation.ts +20 -17
  22. package/src/core/rewind-turn-anchors.ts +12 -13
  23. package/src/core/session-recovery.ts +59 -2
  24. package/src/core/session-resume-core.ts +206 -0
  25. package/src/core/stream-event-wiring.ts +174 -14
  26. package/src/core/system-message-router.ts +22 -0
  27. package/src/core/transcript-journal.ts +32 -6
  28. package/src/core/turn-event-wiring.ts +55 -12
  29. package/src/input/command-args-hint.ts +11 -2
  30. package/src/input/command-registry.ts +19 -0
  31. package/src/input/commands/checkpoint-runtime.ts +3 -3
  32. package/src/input/commands/health-runtime.ts +1 -3
  33. package/src/input/commands/local-runtime.ts +1 -1
  34. package/src/input/commands/rewind-runtime.ts +132 -1
  35. package/src/input/commands/runtime-services.ts +11 -1
  36. package/src/input/commands/secret-runtime.ts +1 -1
  37. package/src/input/commands/session-content.ts +9 -1
  38. package/src/input/commands/session-picker-filter.ts +28 -0
  39. package/src/input/commands/session-workflow.ts +62 -73
  40. package/src/input/commands/session.ts +36 -1
  41. package/src/input/commands/shell-core.ts +57 -5
  42. package/src/input/commands/update-runtime.ts +120 -4
  43. package/src/input/commands/websearch-runtime.ts +1 -1
  44. package/src/input/handler-content-actions.ts +63 -39
  45. package/src/input/handler-feed-routes.ts +17 -3
  46. package/src/input/handler-feed.ts +3 -0
  47. package/src/input/handler-interactions.ts +19 -23
  48. package/src/input/handler-modal-token-routes.ts +5 -0
  49. package/src/input/handler-types.ts +4 -1
  50. package/src/input/handler-ui-state.ts +6 -2
  51. package/src/input/handler.ts +19 -9
  52. package/src/input/search.ts +94 -2
  53. package/src/input/selection-modal-queue.ts +176 -0
  54. package/src/main.ts +66 -61
  55. package/src/panels/builtin/operations.ts +2 -1
  56. package/src/panels/provider-health-domains.ts +2 -2
  57. package/src/{cli → providers}/provider-classification.ts +12 -0
  58. package/src/renderer/block-actions-overlay.ts +59 -0
  59. package/src/renderer/block-actions.ts +1 -3
  60. package/src/renderer/context-status-hint.ts +73 -5
  61. package/src/renderer/conversation-layout.ts +16 -0
  62. package/src/renderer/conversation-overlays.ts +6 -0
  63. package/src/renderer/header-line.ts +146 -0
  64. package/src/renderer/help-overlay.ts +69 -53
  65. package/src/renderer/selection-modal-overlay.ts +66 -10
  66. package/src/renderer/shell-surface.ts +51 -0
  67. package/src/renderer/tool-result-expanded-lines.ts +48 -0
  68. package/src/renderer/ui-factory.ts +26 -79
  69. package/src/runtime/bootstrap-command-context.ts +5 -1
  70. package/src/runtime/bootstrap-command-parts.ts +5 -2
  71. package/src/runtime/bootstrap-core.ts +3 -3
  72. package/src/runtime/bootstrap-hook-bridge.ts +62 -44
  73. package/src/runtime/bootstrap-shell.ts +31 -16
  74. package/src/runtime/bootstrap.ts +13 -18
  75. package/src/runtime/code-index-services.ts +19 -0
  76. package/src/runtime/durability-services.ts +11 -7
  77. package/src/runtime/index.ts +12 -0
  78. package/src/runtime/process-lifecycle.ts +63 -2
  79. package/src/runtime/recovery-autosave.ts +48 -0
  80. package/src/runtime/recovery-decisions.ts +126 -0
  81. package/src/runtime/recovery-offer-wiring.ts +89 -0
  82. package/src/runtime/recovery-prompt.ts +362 -0
  83. package/src/runtime/resume-notice.ts +28 -93
  84. package/src/runtime/services.ts +14 -16
  85. package/src/runtime/session-liveness-marker.ts +120 -0
  86. package/src/runtime/session-pointer-surface.ts +22 -0
  87. package/src/runtime/session-resume-liveness-confirm.ts +84 -0
  88. package/src/runtime/session-storage-services.ts +41 -0
  89. package/src/runtime/workspace-checkpointing.ts +10 -2
  90. package/src/shell/recovery-input-helpers.ts +7 -3
  91. package/src/shell/retry-affordance.ts +123 -0
  92. package/src/shell/ui-openers.ts +9 -0
  93. package/src/utils/splash-lines.ts +1 -1
  94. 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
- // `version` defaults to the live build VERSION; tests pass a pinned fixture so
79
- // golden snapshots don't break on every release bump (version-decoupled goldens,
80
- // mirroring the splash fixture-version pattern).
81
- public static createHeader(width: number, model: string, provider: string, title?: string, gitInfo?: GitHeaderInfo, version: string = VERSION): Line[] {
82
- const lines: Line[] = [];
83
- // Header/footer/thinking paint on the transparent terminal background, so
84
- // they read chrome.* (light-terminal-aware) — NOT fg.*/state.*, which stay
85
- // tuned for the opaque dark modal/panel boxes. Read live per render so a
86
- // mode flip re-resolves without any module reload (see theme.ts).
87
- const t = activeUiTones();
88
- const CYAN = t.accent.brand;
89
- const GREY = t.chrome.faint;
90
- const TITLE_COLOR = t.chrome.label;
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) ? ` ttft:${ttftMs}ms` : '';
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: 'tui',
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: 'tui',
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', 'tui', 'wrfc-chains.json'),
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 { replayJournalForSession } from '../core/session-recovery.ts';
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
- export function createResumeSessionHandler(options: ResumeSessionOptions): (sessionId: string) => void {
42
- return (sessionId: string): void => {
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
- const { messages, meta } = options.sessionManager.load(sessionId);
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: messages.length,
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
- options.runtime.sessionId = sessionId;
59
- replayJournalForSession({
60
- homeDirectory: options.homeDirectory,
61
- sessionId,
62
- snapshotTimestamp: meta.timestamp ?? 0,
92
+
93
+ const outcome = await resumeSessionCore(sessionId, {
94
+ sessionManager: options.sessionManager,
63
95
  conversation: options.conversation,
64
- persistSnapshot: (replayedMessages) => {
65
- options.sessionManager.save(sessionId, replayedMessages as never[], {
66
- title: options.conversation.title || meta.title,
67
- model: meta.model,
68
- provider: meta.provider,
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
- // Hydrate the footer's token counters from the resumed history now that
76
- // fromJSON()/journal replay are both applied — before requestRender() below.
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
- const reopenedPanels: string[] = [];
87
- if (meta.returnContext?.openPanels?.length) {
88
- for (const panelId of meta.returnContext.openPanels.slice(0, 4)) {
89
- try {
90
- options.panelManager.open(panelId);
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 (reopenedPanels.length > 0) {
104
- options.conversation.log(`Resume: Reopened panels: ${reopenedPanels.join(', ')}`, { fg: '244' });
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,