@pellux/goodvibes-tui 1.19.5 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/CHANGELOG.md +12 -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/bookmark-navigation.ts +44 -0
  14. package/src/core/conversation-line-cache.ts +87 -19
  15. package/src/core/conversation-rendering.ts +186 -28
  16. package/src/core/conversation-tool-groups.ts +152 -0
  17. package/src/core/conversation-types.ts +30 -3
  18. package/src/core/conversation.ts +17 -0
  19. package/src/core/rewind-turn-anchors.ts +12 -13
  20. package/src/core/session-recovery.ts +62 -7
  21. package/src/core/session-resume-core.ts +206 -0
  22. package/src/core/transcript-journal.ts +32 -6
  23. package/src/core/turn-event-wiring.ts +55 -12
  24. package/src/input/command-args-hint.ts +11 -2
  25. package/src/input/command-registry.ts +19 -0
  26. package/src/input/commands/checkpoint-runtime.ts +3 -3
  27. package/src/input/commands/health-runtime.ts +1 -3
  28. package/src/input/commands/local-runtime.ts +19 -2
  29. package/src/input/commands/rewind-runtime.ts +132 -1
  30. package/src/input/commands/runtime-services.ts +11 -1
  31. package/src/input/commands/secret-runtime.ts +1 -1
  32. package/src/input/commands/session-content.ts +9 -1
  33. package/src/input/commands/session-picker-filter.ts +28 -0
  34. package/src/input/commands/session-workflow.ts +62 -73
  35. package/src/input/commands/session.ts +36 -1
  36. package/src/input/commands/shell-core.ts +57 -5
  37. package/src/input/commands/update-runtime.ts +120 -4
  38. package/src/input/commands/websearch-runtime.ts +1 -1
  39. package/src/input/handler-content-actions.ts +63 -39
  40. package/src/input/handler-feed-routes.ts +17 -3
  41. package/src/input/handler-feed.ts +3 -0
  42. package/src/input/handler-interactions.ts +19 -23
  43. package/src/input/handler-modal-token-routes.ts +5 -0
  44. package/src/input/handler-types.ts +4 -1
  45. package/src/input/handler-ui-state.ts +6 -2
  46. package/src/input/handler.ts +19 -9
  47. package/src/input/search.ts +94 -2
  48. package/src/input/selection-modal-queue.ts +176 -0
  49. package/src/main.ts +55 -56
  50. package/src/panels/builtin/operations.ts +2 -1
  51. package/src/panels/provider-health-domains.ts +2 -2
  52. package/src/renderer/block-actions-overlay.ts +59 -0
  53. package/src/renderer/block-actions.ts +1 -3
  54. package/src/renderer/context-status-hint.ts +73 -5
  55. package/src/renderer/conversation-layout.ts +16 -0
  56. package/src/renderer/conversation-overlays.ts +6 -0
  57. package/src/renderer/help-overlay.ts +69 -53
  58. package/src/renderer/selection-modal-overlay.ts +66 -10
  59. package/src/renderer/shell-surface.ts +15 -0
  60. package/src/renderer/tool-result-expanded-lines.ts +48 -0
  61. package/src/renderer/ui-factory.ts +1 -1
  62. package/src/runtime/bootstrap-command-context.ts +5 -1
  63. package/src/runtime/bootstrap-command-parts.ts +5 -2
  64. package/src/runtime/bootstrap-core.ts +3 -3
  65. package/src/runtime/bootstrap-hook-bridge.ts +62 -44
  66. package/src/runtime/bootstrap-shell.ts +31 -16
  67. package/src/runtime/bootstrap.ts +13 -18
  68. package/src/runtime/code-index-services.ts +19 -0
  69. package/src/runtime/durability-services.ts +11 -7
  70. package/src/runtime/index.ts +12 -1
  71. package/src/runtime/process-lifecycle.ts +63 -2
  72. package/src/runtime/recovery-autosave.ts +48 -0
  73. package/src/runtime/recovery-offer-wiring.ts +89 -0
  74. package/src/runtime/recovery-prompt.ts +312 -0
  75. package/src/runtime/resume-notice.ts +26 -21
  76. package/src/runtime/services.ts +14 -16
  77. package/src/runtime/session-liveness-marker.ts +120 -0
  78. package/src/runtime/session-pointer-surface.ts +22 -0
  79. package/src/runtime/session-resume-liveness-confirm.ts +84 -0
  80. package/src/runtime/session-storage-services.ts +41 -0
  81. package/src/runtime/workspace-checkpointing.ts +10 -2
  82. package/src/shell/recovery-input-helpers.ts +17 -98
  83. package/src/shell/retry-affordance.ts +123 -0
  84. package/src/shell/ui-openers.ts +9 -0
  85. package/src/utils/splash-lines.ts +1 -1
  86. package/src/version.ts +1 -1
@@ -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++) {
@@ -0,0 +1,176 @@
1
+ /**
2
+ * selection-modal-queue.ts — Serializes overlapping InputHandler.openSelection() calls.
3
+ *
4
+ * openSelection (handler.ts) writes into a singleton SelectionModal plus a
5
+ * single callback slot. A second openSelection call while one is already
6
+ * showing used to silently overwrite both — the pre-empted caller's callback
7
+ * was never invoked, not even with null, so any caller that awaits it (see
8
+ * the `ask()` helper in runtime/recovery-prompt.ts, which wraps the callback
9
+ * in a Promise) hung forever. This is reachable in production: the
10
+ * `--continue` liveness-confirm modal (session-resume-liveness-confirm.ts)
11
+ * races the startup recovery offer, which is scheduled on a 0ms macrotask
12
+ * (scheduleRecoveryOffer in runtime/recovery-prompt.ts).
13
+ *
14
+ * Fix: queue overlapping requests FIFO and open each automatically once the
15
+ * previous one resolves, through the SAME callback the caller already
16
+ * supplied — openSelection's public signature never changes.
17
+ *
18
+ * Why the drain is deferred to a microtask instead of running inline inside
19
+ * the resolving callback: both resolution paths — the select/enter path in
20
+ * handler-modal-routes.ts's dispatchSelectionAction, and the Escape path in
21
+ * handler-modal-stack.ts's handleEscape/closeSelection — take a snapshot of
22
+ * the current selectionCallback before dispatch and write some form of that
23
+ * snapshot back into handler/context state AFTER invoking the callback (the
24
+ * select path re-reads via getSelectionCallback; the Escape path threads a
25
+ * local variable back out through handleEscapeForHandler in
26
+ * handler-interactions.ts). If this module opened the next queued request
27
+ * synchronously inside the resolving callback, those post-callback
28
+ * write-backs would immediately clobber the freshly-set callback for the
29
+ * newly-opened modal. Deferring past the end of the synchronous resolution
30
+ * (queueMicrotask) lets all of that settle first, then opens the next
31
+ * request cleanly — the same shape as the production race this fixes, which
32
+ * itself resolves via a deferred (macrotask) call.
33
+ */
34
+ import type { SelectionItem, SelectionAction, SelectionResult, SelectionModal } from './selection-modal.ts';
35
+
36
+ export type SelectionModalCallback = (result: SelectionResult | null) => void;
37
+
38
+ export interface SelectionOpenOpts {
39
+ preSelectId?: string;
40
+ allowSearch?: boolean;
41
+ customActions?: Map<string, SelectionAction>;
42
+ primaryVerbLabel?: string;
43
+ }
44
+
45
+ export interface PendingSelectionRequest {
46
+ readonly title: string;
47
+ readonly items: SelectionItem[];
48
+ readonly opts: SelectionOpenOpts | undefined;
49
+ readonly callback: SelectionModalCallback;
50
+ }
51
+
52
+ /**
53
+ * Maximum number of openSelection() calls held while one is already showing.
54
+ * In practice at most two calls ever race in production (the liveness-confirm
55
+ * modal vs. the recovery offer's 0ms macrotask), so 8 is generous headroom
56
+ * for any future chained-picker sequence without letting a misbehaving
57
+ * caller queue without bound. Once full, the overflowing call is resolved
58
+ * with `null` immediately — a bounded queue must still give every caller a
59
+ * definite answer, never silently drop one.
60
+ */
61
+ export const MAX_QUEUED_SELECTIONS = 8;
62
+
63
+ /** Dependencies the queue needs from InputHandler, kept minimal and explicit. */
64
+ export interface SelectionQueueHost {
65
+ readonly selectionModal: Pick<SelectionModal, 'active' | 'open'>;
66
+ /** Assigns InputHandler.selectionCallback (the live resolution slot). */
67
+ setSelectionCallback(callback: SelectionModalCallback | null): void;
68
+ /** Mirrors the callback into the in-flight feed context, when a feed() call is in progress (no-op otherwise). */
69
+ syncFeedSelectionCallback(callback: SelectionModalCallback | null): void;
70
+ modalOpened(name: string): void;
71
+ requestRender(): void;
72
+ }
73
+
74
+ /** Owns the FIFO of pending openSelection() requests for one InputHandler instance. */
75
+ export class SelectionModalQueue {
76
+ private readonly pending: PendingSelectionRequest[] = [];
77
+
78
+ constructor(private readonly host: SelectionQueueHost) {}
79
+
80
+ /** Number of requests currently queued (excludes whichever modal is actively showing, if any). */
81
+ public get size(): number {
82
+ return this.pending.length;
83
+ }
84
+
85
+ /**
86
+ * Open `request` immediately if no selection modal is currently showing;
87
+ * otherwise queue it FIFO. If the queue is already at MAX_QUEUED_SELECTIONS,
88
+ * the request is resolved with `null` right away instead of being queued.
89
+ */
90
+ public request(request: PendingSelectionRequest): void {
91
+ if (this.host.selectionModal.active) {
92
+ if (this.pending.length >= MAX_QUEUED_SELECTIONS) {
93
+ request.callback(null);
94
+ return;
95
+ }
96
+ this.pending.push(request);
97
+ return;
98
+ }
99
+ this.openNow(request);
100
+ }
101
+
102
+ /**
103
+ * Resolve every still-queued request with `null`. Called when the handler
104
+ * is torn down (see the exitApp wrap in handler.ts's constructor) so no
105
+ * queued caller is ever left permanently pending.
106
+ */
107
+ public clear(): void {
108
+ while (this.pending.length > 0) {
109
+ const next = this.pending.shift();
110
+ next?.callback(null);
111
+ }
112
+ }
113
+
114
+ private openNow(request: PendingSelectionRequest): void {
115
+ const wrapped: SelectionModalCallback = (result) => {
116
+ request.callback(result);
117
+ // Only a terminal resolution (select/cancel/escape) actually closes
118
+ // the modal — dispatchSelectionAction's toggle/increment/decrement
119
+ // branch invokes the callback but leaves the modal open for further
120
+ // interaction, so only drain the queue once the modal has gone
121
+ // inactive, never on every callback invocation.
122
+ if (!this.host.selectionModal.active) {
123
+ queueMicrotask(() => this.drain());
124
+ }
125
+ };
126
+ this.host.modalOpened('selection');
127
+ this.host.selectionModal.open(request.title, request.items, request.opts);
128
+ this.host.setSelectionCallback(wrapped);
129
+ this.host.syncFeedSelectionCallback(wrapped);
130
+ this.host.requestRender();
131
+ }
132
+
133
+ private drain(): void {
134
+ // Something else already opened a modal in the meantime (e.g. a
135
+ // synchronously chained openSelection call from inside the resolving
136
+ // callback) — leave the queue alone; that modal's own resolution will
137
+ // drain it in turn.
138
+ if (this.host.selectionModal.active) return;
139
+ const next = this.pending.shift();
140
+ if (!next) return;
141
+ this.openNow(next);
142
+ }
143
+ }
144
+
145
+ /** InputHandler's actual field/method shape — satisfies this structurally, no import needed. */
146
+ export interface SelectionQueueOwner {
147
+ readonly selectionModal: Pick<SelectionModal, 'active' | 'open'>;
148
+ selectionCallback: SelectionModalCallback | null;
149
+ syncFeedSelectionCallback: ((callback: SelectionModalCallback | null) => void) | null;
150
+ modalOpened(name: string): void;
151
+ requestRender(): void;
152
+ exitApp: () => void;
153
+ }
154
+
155
+ /**
156
+ * Builds the queue for one InputHandler and wires teardown: wraps
157
+ * `owner.exitApp` (the one path guaranteed to run when the handler is torn
158
+ * down) so every still-queued selection request resolves with `null`
159
+ * instead of being left pending. Keeps this bookkeeping out of handler.ts,
160
+ * which only needs `this.selectionQueue = attachSelectionModalQueue(this);`.
161
+ */
162
+ export function attachSelectionModalQueue(owner: SelectionQueueOwner): SelectionModalQueue {
163
+ const queue = new SelectionModalQueue({
164
+ selectionModal: owner.selectionModal,
165
+ setSelectionCallback: (callback) => { owner.selectionCallback = callback; },
166
+ syncFeedSelectionCallback: (callback) => { owner.syncFeedSelectionCallback?.(callback); },
167
+ modalOpened: (name) => owner.modalOpened(name),
168
+ requestRender: () => owner.requestRender(),
169
+ });
170
+ const suppliedExitApp = owner.exitApp;
171
+ owner.exitApp = () => {
172
+ queue.clear();
173
+ suppliedExitApp();
174
+ };
175
+ return queue;
176
+ }