@pellux/goodvibes-tui 1.19.6 → 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.
Files changed (84) hide show
  1. package/CHANGELOG.md +6 -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/ensure-goodvibes-gitignore.ts +16 -4
  8. package/src/cli/entrypoint.ts +5 -1
  9. package/src/cli/launch-auto-update.ts +63 -4
  10. package/src/cli/tui-startup.ts +131 -15
  11. package/src/config/surface.ts +21 -0
  12. package/src/config/tui-extension-settings.ts +9 -1
  13. package/src/core/conversation-line-cache.ts +1 -1
  14. package/src/core/conversation-rendering.ts +70 -24
  15. package/src/core/conversation-tool-groups.ts +23 -10
  16. package/src/core/conversation-types.ts +9 -0
  17. package/src/core/rewind-turn-anchors.ts +12 -13
  18. package/src/core/session-recovery.ts +59 -2
  19. package/src/core/session-resume-core.ts +206 -0
  20. package/src/core/transcript-journal.ts +32 -6
  21. package/src/core/turn-event-wiring.ts +55 -12
  22. package/src/input/command-args-hint.ts +11 -2
  23. package/src/input/command-registry.ts +19 -0
  24. package/src/input/commands/checkpoint-runtime.ts +3 -3
  25. package/src/input/commands/health-runtime.ts +1 -3
  26. package/src/input/commands/local-runtime.ts +1 -1
  27. package/src/input/commands/rewind-runtime.ts +132 -1
  28. package/src/input/commands/runtime-services.ts +11 -1
  29. package/src/input/commands/secret-runtime.ts +1 -1
  30. package/src/input/commands/session-content.ts +9 -1
  31. package/src/input/commands/session-picker-filter.ts +28 -0
  32. package/src/input/commands/session-workflow.ts +62 -73
  33. package/src/input/commands/session.ts +36 -1
  34. package/src/input/commands/shell-core.ts +57 -5
  35. package/src/input/commands/update-runtime.ts +120 -4
  36. package/src/input/commands/websearch-runtime.ts +1 -1
  37. package/src/input/handler-content-actions.ts +63 -39
  38. package/src/input/handler-feed-routes.ts +17 -3
  39. package/src/input/handler-feed.ts +3 -0
  40. package/src/input/handler-interactions.ts +19 -23
  41. package/src/input/handler-modal-token-routes.ts +5 -0
  42. package/src/input/handler-types.ts +4 -1
  43. package/src/input/handler-ui-state.ts +6 -2
  44. package/src/input/handler.ts +19 -9
  45. package/src/input/search.ts +94 -2
  46. package/src/input/selection-modal-queue.ts +176 -0
  47. package/src/main.ts +48 -44
  48. package/src/panels/builtin/operations.ts +2 -1
  49. package/src/panels/provider-health-domains.ts +2 -2
  50. package/src/renderer/block-actions-overlay.ts +59 -0
  51. package/src/renderer/block-actions.ts +1 -3
  52. package/src/renderer/context-status-hint.ts +73 -5
  53. package/src/renderer/conversation-layout.ts +16 -0
  54. package/src/renderer/conversation-overlays.ts +6 -0
  55. package/src/renderer/help-overlay.ts +69 -53
  56. package/src/renderer/selection-modal-overlay.ts +66 -10
  57. package/src/renderer/shell-surface.ts +15 -0
  58. package/src/renderer/tool-result-expanded-lines.ts +48 -0
  59. package/src/renderer/ui-factory.ts +1 -1
  60. package/src/runtime/bootstrap-command-context.ts +5 -1
  61. package/src/runtime/bootstrap-command-parts.ts +5 -2
  62. package/src/runtime/bootstrap-core.ts +3 -3
  63. package/src/runtime/bootstrap-hook-bridge.ts +62 -44
  64. package/src/runtime/bootstrap-shell.ts +31 -16
  65. package/src/runtime/bootstrap.ts +13 -18
  66. package/src/runtime/code-index-services.ts +19 -0
  67. package/src/runtime/durability-services.ts +11 -7
  68. package/src/runtime/index.ts +12 -0
  69. package/src/runtime/process-lifecycle.ts +63 -2
  70. package/src/runtime/recovery-autosave.ts +48 -0
  71. package/src/runtime/recovery-offer-wiring.ts +89 -0
  72. package/src/runtime/recovery-prompt.ts +312 -0
  73. package/src/runtime/resume-notice.ts +28 -93
  74. package/src/runtime/services.ts +14 -16
  75. package/src/runtime/session-liveness-marker.ts +120 -0
  76. package/src/runtime/session-pointer-surface.ts +22 -0
  77. package/src/runtime/session-resume-liveness-confirm.ts +84 -0
  78. package/src/runtime/session-storage-services.ts +41 -0
  79. package/src/runtime/workspace-checkpointing.ts +10 -2
  80. package/src/shell/recovery-input-helpers.ts +7 -3
  81. package/src/shell/retry-affordance.ts +123 -0
  82. package/src/shell/ui-openers.ts +9 -0
  83. package/src/utils/splash-lines.ts +1 -1
  84. package/src/version.ts +1 -1
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * renderHelpOverlay — renders the help overlay with keyboard shortcuts and slash commands.
3
3
  *
4
- * Toggle with `?` key or `/help` command.
4
+ * Opened via `/commands`. `?` and `/help` open the searchable command
5
+ * browser (a selection modal that runs the picked command) instead — they
6
+ * are a different, complementary surface, not this one.
5
7
  */
6
8
 
7
9
  import { type Line } from '../types/grid.ts';
@@ -68,22 +70,27 @@ export function renderHelpOverlay(
68
70
  const shortcutRows: string[] = [
69
71
  ' Core Navigation',
70
72
  ' ' + '\u2500'.repeat(40),
71
- ` ${'Up / Down'.padEnd(20)} Scroll / history recall`,
73
+ ` ${'Up / Down'.padEnd(20)} Recall input history; Down at bottom focuses the process indicator`,
72
74
  ` ${'PageUp / PageDn'.padEnd(20)} Scroll by full page`,
73
75
  ` ${kb('search').padEnd(20)} Search conversation (Ctrl+F)`,
74
76
  '',
75
77
  ' Prompt And Editing',
76
78
  ' ' + '\u2500'.repeat(40),
77
- ` ${'Enter'.padEnd(20)} Submit message`,
79
+ ` ${'Enter'.padEnd(20)} Submit message (composer empty: open the block-actions menu)`,
78
80
  ` ${'Shift+Enter'.padEnd(20)} Insert newline`,
79
81
  ` ${kb('paste').padEnd(20)} Paste (image priority)`,
80
82
  ` ${(kb('undo') + ' / ' + kb('redo')).padEnd(20)} Undo / redo`,
83
+ shortcutLine('(paste >8 lines)', 'Folds to [TEXT: pN, M lines] — /pastes previews it before you submit'),
81
84
  '',
82
85
  ' Overlays And Panels',
83
86
  ' ' + '\u2500'.repeat(40),
84
- shortcutLine('?', 'Toggle help'),
85
- shortcutLine('/shortcuts', 'Full keyboard shortcuts'),
86
- shortcutLine('Tab', 'Swap focus between input and workspace'),
87
+ shortcutLine('? or /help', 'Open the command browser (search & run any command)'),
88
+ shortcutLine('/commands', 'This reference: keyboard shortcuts + full command list'),
89
+ shortcutLine('/shortcuts', 'Keyboard-shortcuts-only reference (a separate, generated overlay)'),
90
+ // Precedence (highest first): a visible panel workspace claims Tab for
91
+ // focus-swap; failing that, a partial path under the cursor claims it for
92
+ // completion; only then does it fall through to collapse/expand.
93
+ shortcutLine('Tab', 'Panel focus-swap, else path-complete, else collapse/expand block'),
87
94
  ...panelBindingRows,
88
95
  '',
89
96
  // The shared in-panel contract every workspace panel honors. These are not
@@ -149,7 +156,7 @@ export function renderHelpOverlay(
149
156
  ['sessions', 'Browse and resume saved sessions'],
150
157
  ['save', 'Save the current session'],
151
158
  ['compact', 'Compact the conversation history'],
152
- ['clear', 'Clear the conversation'],
159
+ ['clear', 'Clear the conversation display (keeps LLM context)'],
153
160
  ['keybindings', 'List and customize key bindings'],
154
161
  ['panel', 'Open, focus, or manage panels'],
155
162
  ];
@@ -197,10 +204,11 @@ export function renderHelpOverlay(
197
204
  }
198
205
  } else if (!hasCommand('help')) {
199
206
  commandRows.push('', ' Essentials', ' ' + '\u2500'.repeat(40));
200
- commandRows.push(' /help Show this help overlay');
207
+ commandRows.push(' /commands Show this help overlay');
208
+ commandRows.push(' /help Browse & run any command');
201
209
  commandRows.push(' /shortcuts Keyboard shortcut reference');
202
210
  commandRows.push(' /model Select LLM model');
203
- commandRows.push(' /clear Clear conversation');
211
+ commandRows.push(' /clear Clear the conversation display (keeps LLM context)');
204
212
  }
205
213
 
206
214
  const allRows = [...shortcutRows, ...commandRows];
@@ -255,61 +263,69 @@ export function renderShortcutsOverlay(
255
263
  return ` ${keyCol} ${desc}`;
256
264
  }
257
265
 
258
- // Helper: get the label for a bindable action, falling back to literal string.
259
- const kb = (action: Parameters<typeof keybindingsManager.getComboLabel>[0]) => keybindingsManager.getComboLabel(action);
266
+ // Keys that are real, but not KeyAction entries in keybindingsManager
267
+ // (context-dependent chords, or literal characters rather than bindable
268
+ // actions) \u2014 a maintained table rather than hand-picked getAll() calls, so
269
+ // adding a new KeyAction automatically shows up below without anyone
270
+ // remembering to also add a row for it here.
271
+ const HARDCODED_ROWS: ReadonlyArray<[key: string, desc: string]> = [
272
+ ['Up / Down', 'Recall input history; Down at bottom focuses the process indicator'],
273
+ ['PageUp / PageDn', 'Scroll by full page'],
274
+ ['Home / End', 'Jump to start / end of line'],
275
+ ['n / N (search)', 'Next / previous match'],
276
+ ['Mouse wheel', 'Scroll conversation or hovered panel'],
277
+ ['Enter', 'Submit message (composer empty: open the block-actions menu)'],
278
+ ['Shift+Enter', 'Insert newline'],
279
+ ['@', 'Open file picker'],
280
+ ['/', 'Slash command mode'],
281
+ ['Esc', 'Close overlay / cancel generation / clear prompt (context-dependent)'],
282
+ ['Shift+Tab', 'Cycle permission mode (auto-approve / prompt / manual)'],
283
+ ['F2', 'Open the Fleet panel'],
284
+ ['? or /help', 'Open the command browser (search & run any command)'],
285
+ // Precedence (highest first): panel focus-swap, else path-completion,
286
+ // else collapse/expand \u2014 see the Panels section's Tab row below for the
287
+ // full chain; this row is the "what does it do here" short form.
288
+ ['Tab', 'Collapse/expand block (composer empty \u2014 see Panels: Tab for full precedence)'],
289
+ ];
290
+
291
+ const IN_PANEL_ROWS: ReadonlyArray<[key: string, desc: string]> = [
292
+ ['j / k', 'Move selection down / up'],
293
+ ['g / G', 'Jump to top / bottom'],
294
+ ['/', 'Filter the list'],
295
+ ];
296
+
297
+ // Generated from the live keybindings table (default bindings AND any user
298
+ // overrides) so this listing can never drift from what a key actually
299
+ // does \u2014 no action is hand-picked, none can be silently missed.
300
+ const allBindings = keybindingsManager.getAll();
301
+ const panelActionRows = allBindings
302
+ .filter((entry) => entry.action.startsWith('panel-'))
303
+ .map((entry) => row(entry.combos.map((c) => keybindingsManager.formatCombo(c)).join(', '), entry.description));
304
+ const otherActionRows = allBindings
305
+ .filter((entry) => !entry.action.startsWith('panel-'))
306
+ .map((entry) => row(entry.combos.map((c) => keybindingsManager.formatCombo(c)).join(', '), entry.description));
260
307
 
261
308
  const allRows: string[] = [
262
- ' Navigation',
263
- ' ' + '\u2500'.repeat(40),
264
- row('Up / Down', 'Scroll / history recall'),
265
- row('PageUp / PageDn', 'Scroll by full page'),
266
- row('Home / End', 'Jump to start / end of line'),
267
- row(kb('search'), 'Search conversation'),
268
- row('n / N (search)', 'Next / previous match'),
269
- row('Mouse wheel', 'Scroll conversation or hovered panel'),
270
- '',
271
- ' Editing',
309
+ ' Navigation & Editing',
272
310
  ' ' + '\u2500'.repeat(40),
273
- row('Enter', 'Submit message'),
274
- row('Shift+Enter', 'Insert newline'),
275
- row('@', 'Open file picker'),
276
- row('/', 'Slash command mode'),
277
- row(kb('paste'), 'Paste (image priority)'),
278
- row(`${kb('undo')} / ${kb('redo')}`, 'Undo / redo'),
279
- row(kb('clear-prompt'), 'Clear prompt'),
280
- row(kb('delete-word'), 'Delete word backward'),
281
- row(kb('kill-line'), 'Kill to end of line'),
282
- row(kb('apply-diff-line-start'), 'Apply diff / line start'),
283
- row(kb('next-error-line-end'), 'Next error / line end'),
311
+ ...HARDCODED_ROWS.map(([key, desc]) => row(key, desc)),
284
312
  '',
285
- ' Actions',
313
+ ' Actions & Shortcuts (customize via /keybindings)',
286
314
  ' ' + '\u2500'.repeat(40),
287
- row('Tab', 'Collapse/expand block'),
288
- row(kb('bookmark'), 'Bookmark block'),
289
- row(kb('block-copy'), 'Copy block to clipboard'),
290
- row(kb('block-save'), 'Save block to file'),
291
- row(kb('copy-selection'), 'Copy selection'),
292
- row('F2', 'Open the Fleet panel'),
293
- row('?', 'Help overlay'),
294
- row(`${kb('clear-cancel')} x2`, 'Exit'),
315
+ ...otherActionRows,
295
316
  '',
296
317
  ' Panels',
297
318
  ' ' + '\u2500'.repeat(40),
298
- row('Tab', 'Swap focus between input and panel workspace'),
299
- row(kb('panel-picker'), 'Open / focus / hide panel workspace'),
300
- row(kb('panel-tab-next'), 'Next workspace panel tab'),
301
- row(kb('panel-tab-prev'), 'Previous workspace panel tab'),
302
- row(`${kb('panel-tab-1')}\u2026${kb('panel-tab-9')}`, 'Jump to workspace tab 1-9'),
303
- row(kb('panel-focus-toggle'), 'Swap focus between top / bottom pane'),
304
- row(kb('panel-close'), 'Close active panel'),
305
- row(kb('panel-close-all'), 'Close all panels'),
306
- row(kb('panel-ops'), 'Open and focus the Fleet panel'),
319
+ // Full Tab precedence, stated once here as the authoritative chain: a
320
+ // visible panel workspace claims Tab for focus-swap first; failing that,
321
+ // a partial path under the cursor claims it for completion; only then
322
+ // does it fall through to collapse/expand (the Actions section's row).
323
+ row('Tab', 'Panel focus-swap (1st) > path-complete (2nd) > collapse/expand block (3rd)'),
324
+ ...panelActionRows,
307
325
  '',
308
326
  ' In-Panel Controls',
309
327
  ' ' + '\u2500'.repeat(40),
310
- row('j / k', 'Move selection down / up'),
311
- row('g / G', 'Jump to top / bottom'),
312
- row('/', 'Filter the list'),
328
+ ...IN_PANEL_ROWS.map(([key, desc]) => row(key, desc)),
313
329
  '',
314
330
  ` Config: /keybindings to list and customize`,
315
331
  ];
@@ -69,6 +69,41 @@ function rowCostFor(item: SelectionItem, remaining: number): number {
69
69
  return 1 + (computeDetailLayout(item, remaining).wrappedDetail?.length ?? 0);
70
70
  }
71
71
 
72
+ const MODAL_MARGIN = 4;
73
+ const MODAL_MAX_WIDTH = 72;
74
+ const INDICATOR_WIDTH = 2;
75
+ /** Rows kept clear between the box and the top/bottom of the terminal. */
76
+ const VIEWPORT_INSET_ROWS = 2;
77
+
78
+ /**
79
+ * Rows the box spends on its own frame, counted as they are actually pushed
80
+ * below: top and bottom border, the title, the section row (a search label,
81
+ * search input and divider when the modal searches), the "Results" header and
82
+ * the footer hint. `chromeRows` is the budget-math constant the surface
83
+ * metrics subtract from a total; this is the physical count, and it is what
84
+ * decides how tall the box may really grow.
85
+ */
86
+ function frameRowsFor(modal: SelectionModal): number {
87
+ return modal.allowSearch ? 8 : 6;
88
+ }
89
+
90
+ /**
91
+ * Physical rows every row of the list needs, category headers included — the
92
+ * height the box would have to give the list for nothing to be scrolled away.
93
+ */
94
+ function totalContentRowsFor(modal: SelectionModal, remaining: number): number {
95
+ let rows = 0;
96
+ let lastCategory: string | undefined;
97
+ for (const item of modal.filteredItems) {
98
+ if (item.category && item.category !== lastCategory) {
99
+ lastCategory = item.category;
100
+ rows += 1;
101
+ }
102
+ rows += rowCostFor(item, remaining);
103
+ }
104
+ return rows;
105
+ }
106
+
72
107
  /**
73
108
  * Render the selection modal as Line[] for overlay in the viewport.
74
109
  */
@@ -79,9 +114,29 @@ export function renderSelectionModalOverlay(
79
114
  ): Line[] {
80
115
  const lines: Line[] = [];
81
116
  const chromeRows = modal.allowSearch ? 5 : 4;
117
+ // Width does not depend on the row budget, so the box layout is settled
118
+ // first: the list's real height can only be measured once the wrap width is
119
+ // known. `createOverlayBoxLayout` resolves the requested max width the same
120
+ // way `getOverlaySurfaceMetrics` does, so both agree on the box.
121
+ const layout = createOverlayBoxLayout(width, MODAL_MARGIN, MODAL_MAX_WIDTH);
122
+ const remaining = layout.innerWidth - INDICATOR_WIDTH;
123
+
124
+ // Size to content before falling back to the viewport's height ratio. When
125
+ // every row (and its wrapped detail) fits in the space the terminal
126
+ // actually has, ask for exactly that many rows, so a short list is never
127
+ // split behind a "(n below)" hint with screen still empty below the box —
128
+ // a two-item question in particular must always show both its answers. A
129
+ // list genuinely taller than the terminal keeps the ratio-derived budget
130
+ // and scrolls as before.
131
+ const neededContentRows = totalContentRowsFor(modal, remaining);
132
+ const fittableContentRows = viewportHeight - VIEWPORT_INSET_ROWS - frameRowsFor(modal);
133
+ const growToFitTotalRows = neededContentRows > 0 && neededContentRows <= fittableContentRows
134
+ ? neededContentRows + chromeRows
135
+ : undefined;
136
+
82
137
  const metrics = getOverlaySurfaceMetrics(width, viewportHeight, {
83
- margin: 4,
84
- maxWidth: 72,
138
+ margin: MODAL_MARGIN,
139
+ maxWidth: MODAL_MAX_WIDTH,
85
140
  chromeRows,
86
141
  minContentRows: 6,
87
142
  // Modals size to content: items whose detail wraps onto extra lines can
@@ -89,8 +144,11 @@ export function renderSelectionModalOverlay(
89
144
  // ceiling grows with the viewport rather than staying pinned at a small
90
145
  // constant (still bounded — never larger than the terminal can show).
91
146
  maxContentRows: Math.max(10, viewportHeight - chromeRows - 4),
147
+ // A floor only. The shared budget keeps its own target and ceiling, so a
148
+ // modal whose content is shorter than that target is unaffected: this can
149
+ // raise the budget to fit the list, never lower it.
150
+ minTotalRows: growToFitTotalRows,
92
151
  });
93
- const layout = createOverlayBoxLayout(width, metrics.margin, metrics.boxWidth);
94
152
 
95
153
  lines.push(createOverlayFilledBorderLine(width, layout, OVERLAY_GLYPHS.topLeft, OVERLAY_GLYPHS.horizontal, OVERLAY_GLYPHS.topRight, BORDER_FG, DEFAULT_OVERLAY_PALETTE.titleBg));
96
154
 
@@ -143,8 +201,6 @@ export function renderSelectionModalOverlay(
143
201
  putText(line, layout.margin + 2, layout.innerWidth, fitDisplay(message, layout.innerWidth), { fg: MUTED_FG, dim: true });
144
202
  lines.push(line);
145
203
  } else {
146
- const indicatorWidth = 2;
147
- const remaining = layout.innerWidth - indicatorWidth;
148
204
  const rowBudget = Math.max(1, metrics.contentRows);
149
205
 
150
206
  // Row-budget-aware windowing: rather than assuming one physical row per
@@ -210,12 +266,12 @@ export function renderSelectionModalOverlay(
210
266
  const detailLayout = computeDetailLayout(item, remaining);
211
267
  const labelWidth = detailLayout.labelWidth;
212
268
  const labelLine = createOverlayContentLine(width, layout, BORDER_FG, isSelected ? SELECTED_BG : DEFAULT_OVERLAY_PALETTE.bodyBg);
213
- putText(labelLine, layout.margin + 2, indicatorWidth, indicator, {
269
+ putText(labelLine, layout.margin + 2, INDICATOR_WIDTH, indicator, {
214
270
  fg: isSelected ? TITLE_FG : MUTED_FG,
215
271
  bg: isSelected ? SELECTED_BG : DEFAULT_OVERLAY_PALETTE.bodyBg,
216
272
  bold: isSelected,
217
273
  });
218
- putText(labelLine, layout.margin + 2 + indicatorWidth, labelWidth, fitDisplay(truncateDisplay(item.label, labelWidth), labelWidth), {
274
+ putText(labelLine, layout.margin + 2 + INDICATOR_WIDTH, labelWidth, fitDisplay(truncateDisplay(item.label, labelWidth), labelWidth), {
219
275
  fg: labelColor,
220
276
  bg: isSelected ? SELECTED_BG : DEFAULT_OVERLAY_PALETTE.bodyBg,
221
277
  bold: isSelected,
@@ -223,11 +279,11 @@ export function renderSelectionModalOverlay(
223
279
  if (item.detail) {
224
280
  if (detailLayout.wrappedDetail === null) {
225
281
  // Fits beside the label at a readable width — no truncation needed.
226
- putText(labelLine, layout.margin + 2 + indicatorWidth + labelWidth, 2, ' ', {
282
+ putText(labelLine, layout.margin + 2 + INDICATOR_WIDTH + labelWidth, 2, ' ', {
227
283
  fg: BODY_FG,
228
284
  bg: isSelected ? SELECTED_BG : DEFAULT_OVERLAY_PALETTE.bodyBg,
229
285
  });
230
- putText(labelLine, layout.margin + 2 + indicatorWidth + labelWidth + 2, detailLayout.detailWidth, fitDisplay(item.detail, detailLayout.detailWidth), {
286
+ putText(labelLine, layout.margin + 2 + INDICATOR_WIDTH + labelWidth + 2, detailLayout.detailWidth, fitDisplay(item.detail, detailLayout.detailWidth), {
231
287
  fg: detailColor,
232
288
  bg: isSelected ? SELECTED_BG : DEFAULT_OVERLAY_PALETTE.bodyBg,
233
289
  });
@@ -238,7 +294,7 @@ export function renderSelectionModalOverlay(
238
294
  lines.push(labelLine);
239
295
  for (const detailLineText of detailLayout.wrappedDetail) {
240
296
  const detailLine = createOverlayContentLine(width, layout, BORDER_FG, isSelected ? SELECTED_BG : DEFAULT_OVERLAY_PALETTE.bodyBg);
241
- putText(detailLine, layout.margin + 2 + indicatorWidth, remaining, fitDisplay(detailLineText, remaining), {
297
+ putText(detailLine, layout.margin + 2 + INDICATOR_WIDTH, remaining, fitDisplay(detailLineText, remaining), {
242
298
  fg: detailColor,
243
299
  bg: isSelected ? SELECTED_BG : DEFAULT_OVERLAY_PALETTE.bodyBg,
244
300
  dim: !isSelected,
@@ -53,6 +53,14 @@ export interface ShellFooterBuildOptions {
53
53
  * below the context pressure hint.
54
54
  */
55
55
  readonly scriptableStatusLine?: string | null;
56
+ /**
57
+ * The one-key retry/switch-model affordance's transient hint text (see
58
+ * retry-affordance.ts). Rendered as its own line ABOVE the context
59
+ * pressure hint while armed; present here only means "show it now" — the
60
+ * caller is responsible for passing null the instant it disarms, so this
61
+ * is a time-bounded status line, never a lingering claim.
62
+ */
63
+ readonly retryHint?: string | null;
56
64
  /**
57
65
  * Compact footer posture for short terminals (~<30 rows). Collapses the
58
66
  * footer to its essentials (prompt box + token/cost line + help) and drops
@@ -173,6 +181,13 @@ export function buildShellFooter(
173
181
  const hintLine = UIFactory.stringToLine(options.contextStatusHint, options.width, { fg: DIM_STATUS_FG });
174
182
  lines.unshift(hintLine);
175
183
  }
184
+ // Retry affordance — topmost of the passive hint lines while armed (an
185
+ // actionable, time-bounded prompt outranks the passive status hints
186
+ // below it); simply absent the instant it disarms.
187
+ if (options.retryHint) {
188
+ const retryLine = UIFactory.stringToLine(options.retryHint, options.width, { fg: DIM_STATUS_FG, bold: true });
189
+ lines.unshift(retryLine);
190
+ }
176
191
  }
177
192
  lastRenderedFooterHeight = { compact: options.compact ?? false, height: lines.length };
178
193
  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
+ }
@@ -624,7 +624,7 @@ export class UIFactory {
624
624
  // them; keep the elapsed timer since "how long the approval has waited" is honest.
625
625
  const speedSuffix = (!approvalPending && tokenSpeed !== undefined && tokenSpeed > 0) ? ` (${Math.round(tokenSpeed)} tok/s)` : '';
626
626
  const elapsedSuffix = elapsedMs !== undefined ? ` (${formatElapsed(elapsedMs)})` : '';
627
- const ttftSuffix = (!approvalPending && ttftMs !== undefined && ttftMs > 0) ? ` ttft:${ttftMs}ms` : '';
627
+ const ttftSuffix = (!approvalPending && ttftMs !== undefined && ttftMs > 0) ? ` (first token ${formatElapsed(ttftMs)})` : '';
628
628
  const text = ` ${spinner} ${phrase}${speedSuffix}${elapsedSuffix}${ttftSuffix} `;
629
629
 
630
630
  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' });