@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,7 @@
1
1
  import { readFileSync, writeFileSync, existsSync } from 'node:fs';
2
2
  import { copyToClipboard, pasteFromClipboard, pasteImageFromClipboard } from '../utils/clipboard.ts';
3
3
  import type { InfiniteBuffer } from '../core/history.ts';
4
- import type { ConversationManager } from '../core/conversation';
4
+ import type { BlockMeta, ConversationManager } from '../core/conversation';
5
5
  import type { PermissionCategory } from '@pellux/goodvibes-sdk/platform/permissions';
6
6
  import type { ContentPart } from '@pellux/goodvibes-sdk/platform/providers';
7
7
  import type { CommandContext } from './command-registry.ts';
@@ -243,6 +243,30 @@ export function findMarkerAtPos(prompt: string, pos: number): { start: number; e
243
243
  return null;
244
244
  }
245
245
 
246
+ /**
247
+ * describeBlockForReceipt - Honest, human-readable name for a block, used by
248
+ * every anchor-based action's receipt (Ctrl+Y/B/S/A, Tab, block-actions menu)
249
+ * so the transcript always says exactly what was acted on — never just an
250
+ * internal collapseKey or a bare "done".
251
+ */
252
+ export function describeBlockForReceipt(block: BlockMeta): string {
253
+ const lineText = `${block.lineCount} line${block.lineCount === 1 ? '' : 's'}`;
254
+ switch (block.type) {
255
+ case 'tool':
256
+ return block.toolName ? `tool result: ${block.toolName} (${lineText})` : `tool result (${lineText})`;
257
+ case 'tool_group':
258
+ return `tool result group (${lineText})`;
259
+ case 'diff':
260
+ return block.filePath ? `diff: ${block.filePath} (${lineText})` : `diff (${lineText})`;
261
+ case 'code':
262
+ return `code block (${lineText})`;
263
+ case 'thinking':
264
+ return `thinking (${lineText})`;
265
+ default:
266
+ return `${block.type} (${lineText})`;
267
+ }
268
+ }
269
+
246
270
  export function handleCopy(
247
271
  selection: SelectionManager,
248
272
  getHistory: () => InfiniteBuffer,
@@ -256,30 +280,42 @@ export function handleCopy(
256
280
  setTimeout(() => requestRender(), 2005);
257
281
  }
258
282
 
283
+ /**
284
+ * handleBlockCopy - Ctrl+Y: copy the content of the block the user is
285
+ * actually looking at. `getAnchorLine` resolves to the viewport's
286
+ * bottom-most visible line (see getViewportBottomLine in
287
+ * conversation-layout.ts), not the raw scroll position — the top of the
288
+ * viewport is off-screen-above once the transcript is longer than one page.
289
+ */
259
290
  export function handleBlockCopy(
260
291
  conversationManager: ConversationManager | null,
261
- getScrollTop: () => number,
292
+ getAnchorLine: () => number,
262
293
  requestRender: () => void,
263
294
  onCopied: () => void,
264
295
  ): void {
265
296
  if (!conversationManager) return;
266
- const lineIndex = getScrollTop();
267
- const content = conversationManager.getBlockContentAtLine(lineIndex);
268
- if (!content) return;
269
- copyToClipboard(content);
297
+ const lineIndex = getAnchorLine();
298
+ const nearest = conversationManager.findNearestBlock(lineIndex);
299
+ if (!nearest) {
300
+ conversationManager.log('[Ctrl+Y: No block found nearby]', { fg: '240' });
301
+ requestRender();
302
+ return;
303
+ }
304
+ copyToClipboard(nearest.rawContent);
270
305
  onCopied();
306
+ conversationManager.log(`[Copied ${describeBlockForReceipt(nearest)}]`, { fg: '#22c55e' });
271
307
  requestRender();
272
308
  setTimeout(() => requestRender(), 2005);
273
309
  }
274
310
 
275
311
  export function handleBookmark(
276
312
  conversationManager: ConversationManager | null,
277
- getScrollTop: () => number,
313
+ getAnchorLine: () => number,
278
314
  requestRender: () => void,
279
315
  bookmarkManager: BookmarkManager,
280
316
  ): void {
281
317
  if (!conversationManager) return;
282
- const lineIndex = getScrollTop();
318
+ const lineIndex = getAnchorLine();
283
319
  const nearest = conversationManager.findNearestBlock(lineIndex);
284
320
  if (!nearest) {
285
321
  conversationManager.log('[Ctrl+B: No block found nearby]', { fg: '240' });
@@ -288,80 +324,68 @@ export function handleBookmark(
288
324
  }
289
325
  const label = `${nearest.type}: ${nearest.rawContent.slice(0, 40).replace(/\n/g, ' ')}`;
290
326
  const added = bookmarkManager.toggle(nearest.collapseKey, label);
327
+ const target = describeBlockForReceipt(nearest);
291
328
  const msg = added
292
- ? `[Bookmarked: ${nearest.collapseKey}]`
293
- : `[Bookmark removed: ${nearest.collapseKey}]`;
329
+ ? `[Bookmarked: ${target}]`
330
+ : `[Bookmark removed: ${target}]`;
294
331
  conversationManager.log(msg, { fg: added ? '#22c55e' : '244' });
295
332
  requestRender();
296
333
  }
297
334
 
298
335
  export function handleBlockSave(
299
336
  conversationManager: ConversationManager | null,
300
- getScrollTop: () => number,
337
+ getAnchorLine: () => number,
301
338
  requestRender: () => void,
302
339
  bookmarkManager: BookmarkManager,
303
340
  ): void {
304
341
  if (!conversationManager) return;
305
- const lineIndex = getScrollTop();
306
- const content = conversationManager.getBlockContentAtLine(lineIndex);
307
- if (!content) {
342
+ const lineIndex = getAnchorLine();
343
+ const nearest = conversationManager.findNearestBlock(lineIndex);
344
+ if (!nearest) {
308
345
  conversationManager.log('[Ctrl+S: No block found nearby]', { fg: '240' });
309
346
  requestRender();
310
347
  return;
311
348
  }
312
- const nearest = conversationManager.findNearestBlock(lineIndex);
313
- const label = nearest?.type ?? 'block';
349
+ const target = describeBlockForReceipt(nearest);
314
350
  try {
315
- const filePath = bookmarkManager.saveToFile(content, label);
351
+ const filePath = bookmarkManager.saveToFile(nearest.rawContent, nearest.type);
316
352
  const homePath = process.env.HOME || process.env.USERPROFILE || '';
317
353
  const displayPath = homePath ? filePath.replace(homePath, '~') : filePath;
318
- conversationManager.log(`[Saved to: ${displayPath}]`, { fg: '#22c55e' });
354
+ conversationManager.log(`[Saved ${target} to: ${displayPath}]`, { fg: '#22c55e' });
319
355
  } catch (err) {
320
356
  const msg = summarizeError(err);
321
- conversationManager.log(`[Save failed: ${msg}]`, { fg: '#ef4444' });
322
- }
323
- requestRender();
324
- }
325
-
326
- export function handleBlockRerun(
327
- conversationManager: ConversationManager | null,
328
- getScrollTop: () => number,
329
- requestRender: () => void,
330
- ): void {
331
- if (!conversationManager) return;
332
- const lineIndex = getScrollTop();
333
- const nearest = conversationManager.findNearestBlock(lineIndex, 'tool');
334
- if (!nearest) {
335
- conversationManager.log('[Re-run: No tool block found nearby]', { fg: '240' });
336
- requestRender();
337
- return;
357
+ conversationManager.log(`[Save failed (${target}): ${msg}]`, { fg: '#ef4444' });
338
358
  }
339
359
  requestRender();
340
360
  }
341
361
 
342
362
  export function handleBlockToggle(
343
363
  conversationManager: ConversationManager | null,
344
- getScrollTop: () => number,
364
+ getAnchorLine: () => number,
345
365
  requestRender: () => void,
346
366
  ): void {
347
367
  if (!conversationManager) return;
348
- const lineIndex = getScrollTop();
368
+ const lineIndex = getAnchorLine();
369
+ const nearest = conversationManager.findNearestBlock(lineIndex);
370
+ if (!nearest) return;
371
+ const wasCollapsed = conversationManager.isCollapsed(nearest.blockIndex);
349
372
  const blockIdx = conversationManager.toggleCollapseAtLine(lineIndex);
350
373
  if (blockIdx >= 0) {
374
+ conversationManager.log(`[${wasCollapsed ? 'Expanded' : 'Collapsed'} ${describeBlockForReceipt(nearest)}]`, { fg: '244' });
351
375
  requestRender();
352
376
  }
353
377
  }
354
378
 
355
379
  export function handleDiffApply(
356
380
  conversationManager: ConversationManager | null,
357
- getScrollTop: () => number,
381
+ getAnchorLine: () => number,
358
382
  commandContext: CommandContext | undefined,
359
383
  requestRender: () => void,
360
384
  getCallId: () => string,
361
385
  category: PermissionCategory,
362
386
  ): boolean {
363
387
  if (!conversationManager) return false;
364
- const lineIndex = getScrollTop();
388
+ const lineIndex = getAnchorLine();
365
389
  const diff = conversationManager.getDiffAtLine(lineIndex);
366
390
  if (!diff || !diff.filePath) return false;
367
391
  const projectRoot = commandContext?.workspace.shellPaths?.workingDirectory
@@ -344,6 +344,11 @@ export type KeyRouteState = {
344
344
  commandRegistry?: CommandRegistry | null;
345
345
  autocomplete: AutocompleteEngine | null;
346
346
  blockActionsMenu: { open: (block: BlockMeta) => void };
347
+ /** The absolute history line of the bottom-most visible block — the block
348
+ * the user is actually looking at (see getViewportBottomLine's doc).
349
+ * Anchors Enter-on-empty-composer's block-actions menu to that block
350
+ * instead of an arbitrary fixed line. */
351
+ getBlockAnchorLine: () => number;
347
352
  /** F2 opens+focuses the Fleet panel (which subsumes the retired process modal). */
348
353
  openFleetPanel: () => void;
349
354
  modalOpened: (name: string) => void;
@@ -430,7 +435,9 @@ export function handlePromptKeyToken(state: KeyRouteState, token: InputToken): {
430
435
 
431
436
  const text = prompt.trim();
432
437
  if (!text && !commandMode) {
433
- const lineIndex = 0;
438
+ // Target the block the user is actually reading, not the conversation's
439
+ // oldest block (a fixed lineIndex 0 would always resolve there).
440
+ const lineIndex = state.getBlockAnchorLine();
434
441
  const nearest = state.conversationManager?.findNearestBlock(lineIndex);
435
442
  if (nearest) {
436
443
  state.modalOpened('blockActions');
@@ -603,6 +610,13 @@ export function handlePromptKeyToken(state: KeyRouteState, token: InputToken): {
603
610
  }
604
611
 
605
612
  if (token.logicalName === 'down') {
613
+ // Announce the focus move honestly — this handoff is otherwise silent
614
+ // and easy to miss (the next keystrokes look like they're doing nothing,
615
+ // because they're now landing on the indicator, not the composer).
616
+ const announceIndicatorFocus = () => {
617
+ indicatorFocused = true;
618
+ state.commandContext?.print('[Down] Focus moved to the process indicator — Up or Esc returns to the composer.');
619
+ };
606
620
  const move = computeCursorVerticalMove(prompt, cursorPos, inputScrollTop, state.contentWidth, state.maxInputRows, 1);
607
621
  if (move.moved) {
608
622
  cursorPos = move.cursorPos;
@@ -617,10 +631,10 @@ export function handlePromptKeyToken(state: KeyRouteState, token: InputToken): {
617
631
  cursorPos = recalled.length;
618
632
  ensureLocalInputCursorVisible();
619
633
  } else {
620
- indicatorFocused = true;
634
+ announceIndicatorFocus();
621
635
  }
622
636
  } else {
623
- indicatorFocused = true;
637
+ announceIndicatorFocus();
624
638
  }
625
639
  }
626
640
  return { handled: true, prompt, cursorPos, inputScrollTop, commandMode, indicatorFocused };
@@ -34,6 +34,7 @@ import {
34
34
  } from './handler-feed-routes.ts';
35
35
  import type { PanelBurstGuardState } from './panel-paste-flood-guard.ts';
36
36
  import type { WrappedPromptInfo } from './handler-prompt-buffer.ts';
37
+ import { getViewportBottomLine } from '../renderer/conversation-layout.ts';
37
38
  import { handleModalTokenRoutes } from './handler-modal-token-routes.ts';
38
39
  import { handleCommandModeToken } from './handler-command-route.ts';
39
40
  import { handleGlobalShortcutToken } from './handler-shortcuts.ts';
@@ -224,6 +225,7 @@ export function feedInputTokens(context: InputFeedContext, tokens: readonly Inpu
224
225
 
225
226
  const modalRoute = handleModalTokenRoutes({
226
227
  history,
228
+ conversationManager: context.conversationManager,
227
229
  searchShortcutMatch: token.type === 'key' && keybindings.matches('search', token),
228
230
  selectionModal: context.selectionModal,
229
231
  selectionCallback: context.selectionCallback,
@@ -477,6 +479,7 @@ export function feedInputTokens(context: InputFeedContext, tokens: readonly Inpu
477
479
  submitConcealedInput: context.submitConcealedInput,
478
480
  autocomplete: context.autocomplete,
479
481
  blockActionsMenu: { open: (block: BlockMeta) => context.blockActionsMenu.open(block) },
482
+ getBlockAnchorLine: () => getViewportBottomLine(scrollTop, viewportHeight, lineCount),
480
483
  openFleetPanel,
481
484
  modalOpened: context.modalOpened,
482
485
  saveUndoState: context.saveUndoState,
@@ -2,7 +2,7 @@ import { buildProviderAccountSnapshot } from '@/runtime/index.ts';
2
2
  import { enrichProviderAccountsSnapshot } from '../runtime/onboarding/provider-key-capture.ts';
3
3
  import type { OnboardingWizardMode } from './onboarding/onboarding-wizard.ts';
4
4
  import { collectOnboardingSnapshot } from '../runtime/onboarding/index.ts';
5
- import { cleanupMarkerRegistry, expandPrompt, findMarkerAtPos, handleBlockCopy, handleBlockRerun, handleBlockSave, handleBlockToggle, handleBookmark, handleClipboardPaste, handleCopy, handleCtrlC, handleDiffApply, registerPaste } from './handler-content-actions.ts';
5
+ import { cleanupMarkerRegistry, expandPrompt, findMarkerAtPos, handleBlockCopy, handleBlockSave, handleBlockToggle, handleBookmark, handleClipboardPaste, handleCopy, handleCtrlC, handleDiffApply, registerPaste } from './handler-content-actions.ts';
6
6
  import { clearModalStack, handleEscape, modalOpened } from './handler-modal-stack.ts';
7
7
  import { openOnboardingWizardState, type OpenOnboardingWizardOptions } from './handler-ui-state.ts';
8
8
  import type { InputHandlerLike as InputHandler } from './handler-types.ts';
@@ -110,31 +110,34 @@ export function handleCopyForHandler(handler: InputHandler): void {
110
110
  }
111
111
 
112
112
  /**
113
- * handleBlockCopy - Ctrl+Y: Copy the content of the nearest code/tool block.
113
+ * handleBlockCopy - Ctrl+Y: Copy the content of the block the user is
114
+ * looking at (the viewport's bottom-most visible block — see
115
+ * InputHandler.getBlockAnchorLine).
114
116
  */
115
117
  export function handleBlockCopyForHandler(handler: InputHandler): void {
116
- handleBlockCopy(handler.conversationManager, handler.getScrollTop, handler.requestRender, () => {
118
+ handleBlockCopy(handler.conversationManager, () => handler.getBlockAnchorLine(), handler.requestRender, () => {
117
119
  handler.lastBlockCopyTime = Date.now();
118
120
  });
119
121
  }
120
122
 
121
123
  /**
122
- * handleBookmark - Ctrl+B: Toggle bookmark on the nearest block.
124
+ * handleBookmark - Ctrl+B: Toggle bookmark on the block the user is looking at.
123
125
  */
124
126
  export function handleBookmarkForHandler(handler: InputHandler): void {
125
- handleBookmark(handler.conversationManager, handler.getScrollTop, handler.requestRender, handler.uiServices.shell.bookmarkManager);
127
+ handleBookmark(handler.conversationManager, () => handler.getBlockAnchorLine(), handler.requestRender, handler.uiServices.shell.bookmarkManager);
126
128
  }
127
129
 
128
130
  /**
129
- * handleBlockSave - Ctrl+S: Save nearest block content to a file.
131
+ * handleBlockSave - Ctrl+S: Save the block the user is looking at to a file.
130
132
  */
131
133
  export function handleBlockSaveForHandler(handler: InputHandler): void {
132
- handleBlockSave(handler.conversationManager, handler.getScrollTop, handler.requestRender, handler.uiServices.shell.bookmarkManager);
134
+ handleBlockSave(handler.conversationManager, () => handler.getBlockAnchorLine(), handler.requestRender, handler.uiServices.shell.bookmarkManager);
133
135
  }
134
136
 
135
137
  /**
136
- * executeBlockAction - Execute a block action ID on the nearest block.
137
- * Called when the user selects an action from the BlockActionsMenu.
138
+ * executeBlockAction - Execute a block action ID on the block the
139
+ * BlockActionsMenu was opened for. Called when the user selects an action
140
+ * from the menu.
138
141
  */
139
142
  export function executeBlockActionForHandler(handler: InputHandler, actionId: string): void {
140
143
  switch (actionId) {
@@ -142,33 +145,26 @@ export function executeBlockActionForHandler(handler: InputHandler, actionId: st
142
145
  case 'bookmark': handler.handleBookmark(); break;
143
146
  case 'toggle': handler.handleBlockToggle(); break;
144
147
  case 'apply': handler.handleDiffApply(); break;
145
- case 'rerun': handler.handleBlockRerun(); break;
146
148
  }
147
149
  }
148
150
 
149
151
  /**
150
- * handleBlockRerun - Re-run the tool call for the nearest tool block.
151
- * Emits a tool-rerun event for the orchestrator to handle.
152
- */
153
- export function handleBlockRerunForHandler(handler: InputHandler): void {
154
- handleBlockRerun(handler.conversationManager, handler.getScrollTop, handler.requestRender);
155
- }
156
-
157
- /**
158
- * handleBlockToggle - Tab (non-command mode): Toggle collapse of nearest block.
152
+ * handleBlockToggle - Tab (non-command mode): Toggle collapse of the block
153
+ * the user is looking at.
159
154
  */
160
155
  export function handleBlockToggleForHandler(handler: InputHandler): void {
161
- handleBlockToggle(handler.conversationManager, handler.getScrollTop, handler.requestRender);
156
+ handleBlockToggle(handler.conversationManager, () => handler.getBlockAnchorLine(), handler.requestRender);
162
157
  }
163
158
 
164
159
  /**
165
- * handleDiffApply - Ctrl+A when a diff block is nearest: request approval and apply the diff.
166
- * Returns true if a diff was found and applied (so caller can skip default Ctrl+A).
160
+ * handleDiffApply - Ctrl+A when a diff block is the one the user is
161
+ * looking at: request approval and apply the diff. Returns true if a diff
162
+ * was found and applied (so caller can skip default Ctrl+A).
167
163
  */
168
164
  export function handleDiffApplyForHandler(handler: InputHandler): boolean {
169
165
  return handleDiffApply(
170
166
  handler.conversationManager,
171
- handler.getScrollTop,
167
+ () => handler.getBlockAnchorLine(),
172
168
  handler.commandContext,
173
169
  handler.requestRender,
174
170
  () => `diff-apply-${Date.now()}`,
@@ -1,5 +1,6 @@
1
1
  import type { InputToken } from '@pellux/goodvibes-sdk/platform/core';
2
2
  import type { InfiniteBuffer } from '../core/history.ts';
3
+ import type { ConversationManager } from '../core/conversation';
3
4
  import type { SelectionResult, SelectionModal } from './selection-modal.ts';
4
5
  import type { BookmarkModal } from './bookmark-modal.ts';
5
6
  import type { SettingsModal } from './settings-modal.ts';
@@ -34,6 +35,9 @@ import {
34
35
 
35
36
  export type ModalTokenRouteState = {
36
37
  history: InfiniteBuffer;
38
+ /** Threaded into SearchManager.search() so a matching but currently-
39
+ * collapsed block gets auto-expanded before the buffer is scanned. */
40
+ conversationManager?: ConversationManager | null;
37
41
  searchShortcutMatch: boolean;
38
42
  selectionModal: SelectionModal;
39
43
  selectionCallback: ((result: SelectionResult | null) => void) | null;
@@ -108,6 +112,7 @@ export function handleModalTokenRoutes(state: ModalTokenRouteState, token: Input
108
112
  scroll: state.scroll,
109
113
  getScrollTop: state.getScrollTop,
110
114
  getViewportHeight: state.getViewportHeight,
115
+ conversationManager: state.conversationManager,
111
116
  }, token, state.history, state.searchShortcutMatch)) {
112
117
  return withState(state, true);
113
118
  }
@@ -130,7 +130,10 @@ export interface InputHandlerLike {
130
130
  handleBookmark(): void;
131
131
  handleBlockToggle(): void;
132
132
  handleDiffApply(): boolean;
133
- handleBlockRerun(): void;
133
+ /** The absolute history line of the bottom-most visible block — the block
134
+ * the user is actually looking at — used to anchor every one of the
135
+ * actions above instead of the (possibly off-screen-above) raw scrollTop. */
136
+ getBlockAnchorLine(): number;
134
137
 
135
138
  // ── Methods: onboarding ───────────────────────────────────────────────────
136
139
  hydrateOnboardingWizardFromRuntime(hydrationSerial: number): Promise<void>;
@@ -2,6 +2,7 @@ import type { InputToken } from '@pellux/goodvibes-sdk/platform/core';
2
2
  import type { InfiniteBuffer } from '../core/history.ts';
3
3
  import type { SearchManager } from './search.ts';
4
4
  import type { HistorySearch } from './input-history.ts';
5
+ import type { ConversationManager } from '../core/conversation';
5
6
  import type {
6
7
  OnboardingWizardController,
7
8
  OnboardingWizardMode,
@@ -202,6 +203,9 @@ type SearchRouteState = {
202
203
  scroll: (delta: number) => void;
203
204
  getScrollTop: () => number;
204
205
  getViewportHeight: () => number;
206
+ /** Optional — when present, search() also expands collapsed blocks whose
207
+ * raw content matches (see SearchManager.search's doc). */
208
+ conversationManager?: ConversationManager | null;
205
209
  };
206
210
 
207
211
  export function handleSearchModeToken(
@@ -216,7 +220,7 @@ export function handleSearchModeToken(
216
220
  if (!searchManager.locked) {
217
221
  if (token.type === 'text') {
218
222
  const newQuery = searchManager.query + token.value;
219
- searchManager.search(newQuery, history);
223
+ searchManager.search(newQuery, history, state.conversationManager);
220
224
  } else if (token.type === 'key') {
221
225
  if (token.logicalName === 'escape') {
222
226
  searchManager.close();
@@ -230,7 +234,7 @@ export function handleSearchModeToken(
230
234
  }
231
235
  } else if (token.logicalName === 'backspace') {
232
236
  const newQuery = searchManager.query.slice(0, -1);
233
- searchManager.search(newQuery, history);
237
+ searchManager.search(newQuery, history, state.conversationManager);
234
238
  } else if (matchesSearchShortcut) {
235
239
  searchManager.close();
236
240
  }
@@ -2,7 +2,8 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node
2
2
  import { dirname } from 'node:path';
3
3
  import { InputTokenizer } from '@pellux/goodvibes-sdk/platform/core';
4
4
  import { createOAuthLocalListener } from '@pellux/goodvibes-sdk/platform/config';
5
- import { clearModalStackForHandler, cleanupMarkerRegistryForHandler, executeBlockActionForHandler, expandPromptForHandler, findMarkerAtPosForHandler, getImageAttachmentsForHandler, handleBlockCopyForHandler, handleBlockRerunForHandler, handleBlockSaveForHandler, handleBlockToggleForHandler, handleBookmarkForHandler, handleCopyForHandler, handleCtrlCForHandler, handleDiffApplyForHandler, handleEscapeForHandler, hydrateOnboardingWizardFromRuntimeForHandler, modalOpenedForHandler, openOnboardingWizardForHandler, registerPasteForHandler } from './handler-interactions.ts';
5
+ import { clearModalStackForHandler, cleanupMarkerRegistryForHandler, executeBlockActionForHandler, expandPromptForHandler, findMarkerAtPosForHandler, getImageAttachmentsForHandler, handleBlockCopyForHandler, handleBlockSaveForHandler, handleBlockToggleForHandler, handleBookmarkForHandler, handleCopyForHandler, handleCtrlCForHandler, handleDiffApplyForHandler, handleEscapeForHandler, hydrateOnboardingWizardFromRuntimeForHandler, modalOpenedForHandler, openOnboardingWizardForHandler, registerPasteForHandler } from './handler-interactions.ts';
6
+ import { getViewportBottomLine } from '../renderer/conversation-layout.ts';
6
7
  import { clearOnboardingModelPickerCancelStateForHandler, clearOnboardingPendingModelPickerTargetForHandler, completeOpenAiSubscriptionFromListenerForHandler, getOnboardingConfigValueForHandler, getOnboardingRuntimePostureForHandler, handleModelPickerCommitForHandler, handleOnboardingActionForHandler, handleOpenAiSubscriptionFinishForHandler, handleOpenAiSubscriptionStartForHandler, openModelPickerWithTargetForHandler, openProviderModelPickerWithTargetForHandler, refreshOnboardingHydrationForHandler, restartOnboardingExternalServicesIfNeededForHandler, restoreOnboardingModelPickerCancelStateForHandler, saveWizardProgressForHandler, syncRuntimeFromOnboardingRequestForHandler, verifyOnboardingRuntimePostureForHandler, } from './handler-onboarding.ts';
7
8
  import type { OnboardingRuntimePosture } from './handler-types.ts';
8
9
  import { beginOpenAICodexLogin, exchangeOpenAICodexCode } from '@pellux/goodvibes-sdk/platform/config';
@@ -48,7 +49,6 @@ import {
48
49
  findMarkerAtPos,
49
50
  formatFileSize,
50
51
  handleBlockCopy,
51
- handleBlockRerun,
52
52
  handleBlockSave,
53
53
  handleBlockToggle,
54
54
  handleBookmark,
@@ -83,6 +83,7 @@ import {
83
83
  import { KillRing } from './kill-ring.ts';
84
84
  import { maskConcealedText, beginConcealedInputFor, submitConcealedInputFor, cancelConcealedInputFor, type ConcealedInputRequest } from './concealed-input.ts';
85
85
  import { clearModalStack, handleEscape, modalOpened } from './handler-modal-stack.ts';
86
+ import { SelectionModalQueue, attachSelectionModalQueue } from './selection-modal-queue.ts';
86
87
  import { handleModalTokenRoutes } from './handler-modal-token-routes.ts';
87
88
  import {
88
89
  captureOnboardingWizardSnapshot,
@@ -222,6 +223,8 @@ export class InputHandler implements InputHandlerLike {
222
223
  public conversationManager: ConversationManager | null = null;
223
224
  public selectionCallback: SelectionModalCallback | null = null;
224
225
  public syncFeedSelectionCallback: ((callback: SelectionModalCallback | null) => void) | null = null;
226
+ /** FIFO queue for overlapping openSelection() calls — see selection-modal-queue.ts. */
227
+ private selectionQueue!: SelectionModalQueue;
225
228
  /** Time of last [COPIED] block feedback, for brief display. */
226
229
  public lastBlockCopyTime = 0;
227
230
  public mouseDownRow = -1;
@@ -282,6 +285,7 @@ export class InputHandler implements InputHandlerLike {
282
285
  this.bookmarkModal = new BookmarkModal(uiServices.shell.bookmarkManager);
283
286
  this.sessionPickerModal = new SessionPickerModal(uiServices.sessions.sessionManager, uiServices.sessions.sessionBroker);
284
287
  this.profilePickerModal = new ProfilePickerModal(uiServices.shell.profileManager);
288
+ this.selectionQueue = attachSelectionModalQueue(this); // wires exitApp teardown too — see selection-modal-queue.ts
285
289
  this.initFeedContext();
286
290
  }
287
291
 
@@ -404,6 +408,8 @@ export class InputHandler implements InputHandlerLike {
404
408
  /**
405
409
  * openSelection - Open the generic selection modal with a callback.
406
410
  * The callback receives SelectionResult on selection, or null on cancel/escape.
411
+ * A second call while one is already showing queues FIFO instead of
412
+ * overwriting it — see selection-modal-queue.ts.
407
413
  */
408
414
  public openSelection(
409
415
  title: string,
@@ -416,14 +422,9 @@ export class InputHandler implements InputHandlerLike {
416
422
  } | undefined,
417
423
  callback: SelectionModalCallback,
418
424
  ): void {
419
- this.modalOpened('selection');
420
- this.selectionModal.open(title, items, opts);
421
- this.selectionCallback = callback;
422
- this.syncFeedSelectionCallback?.(callback);
423
- this.requestRender();
425
+ this.selectionQueue.request({ title, items, opts, callback });
424
426
  }
425
427
 
426
-
427
428
  public openOnboardingWizard(
428
429
  modeOrOptions: OnboardingWizardMode | OpenOnboardingWizardOptions = 'new',
429
430
  ): void { openOnboardingWizardForHandler(this, modeOrOptions); }
@@ -439,7 +440,16 @@ export class InputHandler implements InputHandlerLike {
439
440
  public handleBookmark(): void { handleBookmarkForHandler(this); }
440
441
  public handleBlockSave(): void { handleBlockSaveForHandler(this); }
441
442
  public executeBlockAction(actionId: string): void { executeBlockActionForHandler(this, actionId); }
442
- public handleBlockRerun(): void { handleBlockRerunForHandler(this); }
443
+ /** The block the user is actually looking at: the viewport's bottom-most
444
+ * visible line (see getViewportBottomLine's doc) — not the raw scrollTop,
445
+ * which is off-screen-above once the transcript exceeds one page. Every
446
+ * anchor-based block action (Ctrl+Y/B/S/A, Tab, the block-actions menu)
447
+ * resolves its target through this, so they all agree on what "nearest
448
+ * block" means. */
449
+ public getBlockAnchorLine(): number {
450
+ const lineCount = this.conversationManager?.history.getLineCount() ?? 0;
451
+ return getViewportBottomLine(this.getScrollTop(), this.getViewportHeight(), lineCount);
452
+ }
443
453
  public handleBlockToggle(): void { handleBlockToggleForHandler(this); }
444
454
  public handleDiffApply(): boolean { return handleDiffApplyForHandler(this); }
445
455
  public handleCtrlC(): void { handleCtrlCForHandler(this); }
@@ -1,4 +1,5 @@
1
1
  import type { InfiniteBuffer } from '../core/history.ts';
2
+ import type { ConversationManager } from '../core/conversation';
2
3
 
3
4
  export interface SearchMatch {
4
5
  line: number;
@@ -50,8 +51,37 @@ export class SearchManager {
50
51
  this.locked = false;
51
52
  }
52
53
 
53
- /** Update query and find matches in the history buffer. */
54
- search(query: string, history: InfiniteBuffer): void {
54
+ /**
55
+ * Update query and find matches in the history buffer.
56
+ *
57
+ * A collapsed block (or folded tool-result group) renders 1-2 summary
58
+ * lines, so scanning only the rendered buffer misses text the user watched
59
+ * stream by before it folded. When `conversationManager` is provided, any
60
+ * currently-collapsed block whose searchable corpus contains the query is
61
+ * expanded first (never re-collapsed — a false "No matches" is a lie, an
62
+ * extra expand is not), so the match becomes a real, navigable line in the
63
+ * rendered buffer scanned below.
64
+ *
65
+ * That corpus is the block's own RAW content, plus — for a folded
66
+ * tool-result group — the raw content of every one of its member messages.
67
+ * A group's own rawContent is only its summary header line ("2 tool results
68
+ * folded (read exec, 18 lines total)") and its members push no BlockMeta of
69
+ * their own while folded (see conversation-tool-groups.ts), so without the
70
+ * member corpus a needle living inside a member matches nothing at all and
71
+ * the user gets "No matches" for text they watched stream by. Member text is
72
+ * read off the message snapshot, and is the same string the renderer stores
73
+ * as that member's own block rawContent once the group is expanded.
74
+ *
75
+ * A hit anywhere in that corpus expands the group's own collapseKey AND
76
+ * every member's `msg_<idx>` key — expanding the header alone would leave
77
+ * the member holding the hit invisible, and expanding all members keeps the
78
+ * behavior identical whether the hit came from the header or a member.
79
+ *
80
+ * `getDisplayBlocks()` is flushed before the buffer is scanned, and the
81
+ * overlay's match count is always derived from that same rendered scan, so
82
+ * it never claims more than it can actually navigate to.
83
+ */
84
+ search(query: string, history: InfiniteBuffer, conversationManager?: ConversationManager | null): void {
55
85
  this.query = query;
56
86
  this.matches = [];
57
87
  this.currentMatch = 0;
@@ -60,6 +90,68 @@ export class SearchManager {
60
90
  if (query.length === 0) return;
61
91
 
62
92
  const lowerQuery = query.toLowerCase();
93
+
94
+ if (conversationManager) {
95
+ const registry = conversationManager.getBlockRegistry();
96
+
97
+ // A folded tool-result group's members render nothing and register no
98
+ // BlockMeta while collapsed (see conversation-tool-groups.ts), so the
99
+ // group's searchable corpus is its own header rawContent PLUS each
100
+ // member's raw content — which only the message snapshot can supply,
101
+ // since there is no member block to read it from.
102
+ const memberIndexes: number[] = [];
103
+ for (const block of registry) {
104
+ if (block.type !== 'tool_group' || !block.groupMemberIndexes) continue;
105
+ if (!conversationManager.isCollapsed(block.blockIndex)) continue;
106
+ memberIndexes.push(...block.groupMemberIndexes);
107
+ }
108
+
109
+ // Read in ONE snapshot pass: getMessageSnapshot() clones the whole
110
+ // message array (~0.8ms on a 900-message conversation) and search() runs
111
+ // on every keystroke, so a per-member read would stall typing outright.
112
+ // Skipped entirely when nothing is folded.
113
+ const memberContent = new Map<number, string>();
114
+ if (memberIndexes.length > 0) {
115
+ const snapshot = conversationManager.getMessageSnapshot();
116
+ for (const memberIdx of memberIndexes) {
117
+ // This is the exact string the renderer stores as a member's own
118
+ // block rawContent (renderConversationToolMessage in
119
+ // conversation-rendering.ts) — the corpus is what expanding really
120
+ // reveals, not a second notion of "content". A member index that
121
+ // outlived its message (undo() splices the messages tail while a
122
+ // stale registry still names it) reads back undefined and is simply
123
+ // skipped rather than throwing.
124
+ const content = snapshot[memberIdx]?.content;
125
+ if (typeof content === 'string') memberContent.set(memberIdx, content.toLowerCase());
126
+ }
127
+ }
128
+
129
+ let expandedAny = false;
130
+ for (const block of registry) {
131
+ if (!conversationManager.isCollapsed(block.blockIndex)) continue;
132
+ const members = block.type === 'tool_group' ? (block.groupMemberIndexes ?? []) : [];
133
+ const hit = block.rawContent.toLowerCase().includes(lowerQuery)
134
+ || members.some((memberIdx) => (memberContent.get(memberIdx) ?? '').includes(lowerQuery));
135
+ if (!hit) continue;
136
+ conversationManager.setCollapsed(block.collapseKey, false);
137
+ // Expanding just the group header would still hide each member's own
138
+ // content — including the member the query actually matched — so open
139
+ // every member's own collapse key in the same pass. Already-expanded
140
+ // keys are unaffected; setting false over false is a no-op.
141
+ for (const memberIdx of members) {
142
+ conversationManager.setCollapsed(`msg_${memberIdx}`, false);
143
+ }
144
+ expandedAny = true;
145
+ }
146
+ if (expandedAny) {
147
+ // Flush the pending re-render so `history` (the same buffer
148
+ // instance) reflects the newly-expanded content before it's scanned
149
+ // below — mirrors the existing getDisplayBlocks()-before-read idiom
150
+ // used by jumpToBookmark/scrollToLine.
151
+ conversationManager.getDisplayBlocks();
152
+ }
153
+ }
154
+
63
155
  const lines = history.getAllLines();
64
156
 
65
157
  for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {