@pellux/goodvibes-tui 1.19.6 → 1.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +143 -1613
- package/docs/README.md +4 -4
- package/docs/foundation-artifacts/operator-contract.json +1 -1
- package/package.json +7 -7
- package/src/cli/bundle-command.ts +1 -1
- package/src/cli/ensure-goodvibes-gitignore.ts +16 -4
- package/src/cli/entrypoint.ts +5 -1
- package/src/cli/launch-auto-update.ts +63 -4
- package/src/cli/management.ts +1 -1
- package/src/cli/tui-startup.ts +131 -15
- package/src/config/surface.ts +21 -0
- package/src/config/tui-extension-settings.ts +9 -1
- package/src/core/active-model-identity.ts +153 -0
- package/src/core/conversation-line-cache.ts +1 -1
- package/src/core/conversation-rendering.ts +70 -24
- package/src/core/conversation-tool-groups.ts +23 -10
- package/src/core/conversation-types.ts +9 -0
- package/src/core/conversation-user-receipts.ts +42 -0
- package/src/core/conversation.ts +20 -17
- package/src/core/rewind-turn-anchors.ts +12 -13
- package/src/core/session-recovery.ts +59 -2
- package/src/core/session-resume-core.ts +206 -0
- package/src/core/stream-event-wiring.ts +174 -14
- package/src/core/system-message-router.ts +22 -0
- package/src/core/transcript-journal.ts +32 -6
- package/src/core/turn-event-wiring.ts +55 -12
- package/src/input/command-args-hint.ts +11 -2
- package/src/input/command-registry.ts +19 -0
- package/src/input/commands/checkpoint-runtime.ts +3 -3
- package/src/input/commands/health-runtime.ts +1 -3
- package/src/input/commands/local-runtime.ts +1 -1
- package/src/input/commands/rewind-runtime.ts +132 -1
- package/src/input/commands/runtime-services.ts +11 -1
- package/src/input/commands/secret-runtime.ts +1 -1
- package/src/input/commands/session-content.ts +9 -1
- package/src/input/commands/session-picker-filter.ts +28 -0
- package/src/input/commands/session-workflow.ts +62 -73
- package/src/input/commands/session.ts +36 -1
- package/src/input/commands/shell-core.ts +57 -5
- package/src/input/commands/update-runtime.ts +120 -4
- package/src/input/commands/websearch-runtime.ts +1 -1
- package/src/input/handler-content-actions.ts +63 -39
- package/src/input/handler-feed-routes.ts +17 -3
- package/src/input/handler-feed.ts +3 -0
- package/src/input/handler-interactions.ts +19 -23
- package/src/input/handler-modal-token-routes.ts +5 -0
- package/src/input/handler-types.ts +4 -1
- package/src/input/handler-ui-state.ts +6 -2
- package/src/input/handler.ts +19 -9
- package/src/input/search.ts +94 -2
- package/src/input/selection-modal-queue.ts +176 -0
- package/src/main.ts +66 -61
- package/src/panels/builtin/operations.ts +2 -1
- package/src/panels/provider-health-domains.ts +2 -2
- package/src/{cli → providers}/provider-classification.ts +12 -0
- package/src/renderer/block-actions-overlay.ts +59 -0
- package/src/renderer/block-actions.ts +1 -3
- package/src/renderer/context-status-hint.ts +73 -5
- package/src/renderer/conversation-layout.ts +16 -0
- package/src/renderer/conversation-overlays.ts +6 -0
- package/src/renderer/header-line.ts +146 -0
- package/src/renderer/help-overlay.ts +69 -53
- package/src/renderer/selection-modal-overlay.ts +66 -10
- package/src/renderer/shell-surface.ts +51 -0
- package/src/renderer/tool-result-expanded-lines.ts +48 -0
- package/src/renderer/ui-factory.ts +26 -79
- package/src/runtime/bootstrap-command-context.ts +5 -1
- package/src/runtime/bootstrap-command-parts.ts +5 -2
- package/src/runtime/bootstrap-core.ts +3 -3
- package/src/runtime/bootstrap-hook-bridge.ts +62 -44
- package/src/runtime/bootstrap-shell.ts +31 -16
- package/src/runtime/bootstrap.ts +13 -18
- package/src/runtime/code-index-services.ts +19 -0
- package/src/runtime/durability-services.ts +11 -7
- package/src/runtime/index.ts +12 -0
- package/src/runtime/process-lifecycle.ts +63 -2
- package/src/runtime/recovery-autosave.ts +48 -0
- package/src/runtime/recovery-decisions.ts +126 -0
- package/src/runtime/recovery-offer-wiring.ts +89 -0
- package/src/runtime/recovery-prompt.ts +362 -0
- package/src/runtime/resume-notice.ts +28 -93
- package/src/runtime/services.ts +14 -16
- package/src/runtime/session-liveness-marker.ts +120 -0
- package/src/runtime/session-pointer-surface.ts +22 -0
- package/src/runtime/session-resume-liveness-confirm.ts +84 -0
- package/src/runtime/session-storage-services.ts +41 -0
- package/src/runtime/workspace-checkpointing.ts +10 -2
- package/src/shell/recovery-input-helpers.ts +7 -3
- package/src/shell/retry-affordance.ts +123 -0
- package/src/shell/ui-openers.ts +9 -0
- package/src/utils/splash-lines.ts +1 -1
- package/src/version.ts +1 -1
|
@@ -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
|
-
|
|
292
|
+
getAnchorLine: () => number,
|
|
262
293
|
requestRender: () => void,
|
|
263
294
|
onCopied: () => void,
|
|
264
295
|
): void {
|
|
265
296
|
if (!conversationManager) return;
|
|
266
|
-
const lineIndex =
|
|
267
|
-
const
|
|
268
|
-
if (!
|
|
269
|
-
|
|
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
|
-
|
|
313
|
+
getAnchorLine: () => number,
|
|
278
314
|
requestRender: () => void,
|
|
279
315
|
bookmarkManager: BookmarkManager,
|
|
280
316
|
): void {
|
|
281
317
|
if (!conversationManager) return;
|
|
282
|
-
const lineIndex =
|
|
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: ${
|
|
293
|
-
: `[Bookmark removed: ${
|
|
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
|
-
|
|
337
|
+
getAnchorLine: () => number,
|
|
301
338
|
requestRender: () => void,
|
|
302
339
|
bookmarkManager: BookmarkManager,
|
|
303
340
|
): void {
|
|
304
341
|
if (!conversationManager) return;
|
|
305
|
-
const lineIndex =
|
|
306
|
-
const
|
|
307
|
-
if (!
|
|
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
|
|
313
|
-
const label = nearest?.type ?? 'block';
|
|
349
|
+
const target = describeBlockForReceipt(nearest);
|
|
314
350
|
try {
|
|
315
|
-
const filePath = bookmarkManager.saveToFile(
|
|
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
|
-
|
|
364
|
+
getAnchorLine: () => number,
|
|
345
365
|
requestRender: () => void,
|
|
346
366
|
): void {
|
|
347
367
|
if (!conversationManager) return;
|
|
348
|
-
const lineIndex =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
634
|
+
announceIndicatorFocus();
|
|
621
635
|
}
|
|
622
636
|
} else {
|
|
623
|
-
|
|
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,
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
127
|
+
handleBookmark(handler.conversationManager, () => handler.getBlockAnchorLine(), handler.requestRender, handler.uiServices.shell.bookmarkManager);
|
|
126
128
|
}
|
|
127
129
|
|
|
128
130
|
/**
|
|
129
|
-
* handleBlockSave - Ctrl+S: Save
|
|
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.
|
|
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
|
|
137
|
-
* Called when the user selects an action
|
|
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
|
-
*
|
|
151
|
-
*
|
|
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.
|
|
156
|
+
handleBlockToggle(handler.conversationManager, () => handler.getBlockAnchorLine(), handler.requestRender);
|
|
162
157
|
}
|
|
163
158
|
|
|
164
159
|
/**
|
|
165
|
-
* handleDiffApply - Ctrl+A when a diff block is
|
|
166
|
-
*
|
|
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.
|
|
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
|
-
|
|
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
|
}
|
package/src/input/handler.ts
CHANGED
|
@@ -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,
|
|
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.
|
|
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
|
-
|
|
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); }
|
package/src/input/search.ts
CHANGED
|
@@ -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
|
-
/**
|
|
54
|
-
|
|
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++) {
|