pi-tab-focus 1.0.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.
package/src/index.ts ADDED
@@ -0,0 +1,1393 @@
1
+ import {
2
+ CustomEditor,
3
+ copyToClipboard,
4
+ getAgentDir,
5
+ type ExtensionAPI,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import {
8
+ Key,
9
+ isKeyRelease,
10
+ isKeyRepeat,
11
+ matchesKey,
12
+ stripTerminalSequences,
13
+ visibleWidth,
14
+ type Component,
15
+ type EditorComponent,
16
+ } from "@earendil-works/pi-tui";
17
+ import { resolveConfig } from "./config.ts";
18
+ import {
19
+ FullscreenLayoutController,
20
+ type FullscreenTui,
21
+ type NativeSelectionPoint,
22
+ type NativeSelectionRange,
23
+ type PrivateScrollView,
24
+ } from "./fullscreen-layout.ts";
25
+ import { installTranscriptEditorBorderStyle } from "./fullscreen-ui.ts";
26
+ import {
27
+ firstTranscriptLink,
28
+ transcriptLinkAtColumn,
29
+ } from "./transcript-links.ts";
30
+ import {
31
+ VimVisualNavigation,
32
+ compareVimPoints,
33
+ firstNonWhitespaceColumn,
34
+ type VimCell,
35
+ type VimPoint,
36
+ type VimTextSource,
37
+ } from "./vim-navigation.ts";
38
+
39
+ type TranscriptEditor = EditorComponent & {
40
+ getMode?: () => string;
41
+ actionHandlers?: Map<string, () => void>;
42
+ };
43
+
44
+ type AppKeybindings = {
45
+ matches?: (data: string, keybinding: string) => boolean;
46
+ };
47
+
48
+ type ClipboardWriter = (text: string) => Promise<void>;
49
+
50
+ type ComponentWithChildren = Component & {
51
+ children?: Component[];
52
+ };
53
+
54
+ type TranscriptItemKind =
55
+ | "prompt"
56
+ | "message"
57
+ | "tool"
58
+ | "bash"
59
+ | "skill"
60
+ | "summary"
61
+ | "custom";
62
+
63
+ type TranscriptItem = {
64
+ key: string;
65
+ semanticKey: string;
66
+ kind: TranscriptItemKind;
67
+ startRow: number;
68
+ endRow: number;
69
+ gutterStartRow: number;
70
+ gutterEndRow: number;
71
+ text: string;
72
+ linkSourceLines: string[];
73
+ };
74
+
75
+ const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, {
76
+ granularity: "grapheme",
77
+ });
78
+ const TRANSCRIPT_LAYOUT_ACTIONS = [
79
+ "app.tools.expand",
80
+ "app.thinking.toggle",
81
+ ] as const;
82
+
83
+ const TRANSCRIPT_COMPONENT_KINDS: Record<string, TranscriptItemKind> = {
84
+ UserMessageComponent: "prompt",
85
+ AssistantMessageComponent: "message",
86
+ ToolExecutionComponent: "tool",
87
+ BashExecutionComponent: "bash",
88
+ SkillInvocationMessageComponent: "skill",
89
+ CompactionSummaryMessageComponent: "summary",
90
+ BranchSummaryMessageComponent: "summary",
91
+ CustomMessageComponent: "custom",
92
+ CustomEntryComponent: "custom",
93
+ };
94
+
95
+ function componentName(component: Component): string {
96
+ return (
97
+ (component as { constructor?: { name?: string } }).constructor?.name ?? ""
98
+ );
99
+ }
100
+
101
+ function transcriptItemKind(
102
+ component: Component,
103
+ ): TranscriptItemKind | undefined {
104
+ return TRANSCRIPT_COMPONENT_KINDS[componentName(component)];
105
+ }
106
+
107
+ function componentChildren(component: Component): Component[] {
108
+ const children = (component as ComponentWithChildren).children;
109
+ return Array.isArray(children) ? children : [];
110
+ }
111
+
112
+ function trimRenderedText(lines: string[]): string {
113
+ const textLines = lines.map((line) =>
114
+ stripTerminalSequences(line).replace(/\s+$/u, ""),
115
+ );
116
+
117
+ while (textLines.length > 0 && textLines[0].trim().length === 0)
118
+ textLines.shift();
119
+ while (
120
+ textLines.length > 0 &&
121
+ textLines[textLines.length - 1].trim().length === 0
122
+ ) {
123
+ textLines.pop();
124
+ }
125
+
126
+ const indents = textLines
127
+ .filter((line) => line.trim().length > 0)
128
+ .map((line) => line.match(/^\s*/u)?.[0].length ?? 0);
129
+ const commonIndent = indents.length > 0 ? Math.min(...indents) : 0;
130
+
131
+ return textLines.map((line) => line.slice(commonIndent)).join("\n");
132
+ }
133
+
134
+ function visibleLineBounds(
135
+ lines: string[],
136
+ ): { first: number; last: number } | undefined {
137
+ let first = -1;
138
+ let last = -1;
139
+
140
+ for (let index = 0; index < lines.length; index++) {
141
+ if (stripTerminalSequences(lines[index] ?? "").trim().length === 0)
142
+ continue;
143
+ if (first < 0) first = index;
144
+ last = index;
145
+ }
146
+
147
+ return first < 0 ? undefined : { first, last };
148
+ }
149
+
150
+ function hashText(text: string): string {
151
+ let hash = 2166136261;
152
+ for (let index = 0; index < text.length; index++) {
153
+ hash ^= text.charCodeAt(index);
154
+ hash = Math.imul(hash, 16777619);
155
+ }
156
+ return (hash >>> 0).toString(36);
157
+ }
158
+
159
+ function semanticItemBaseKey(
160
+ component: Component,
161
+ kind: TranscriptItemKind,
162
+ text: string,
163
+ ): string {
164
+ if (kind === "tool") {
165
+ const toolCallId = (component as { toolCallId?: unknown }).toolCallId;
166
+ if (typeof toolCallId === "string" && toolCallId.length > 0)
167
+ return `tool:${toolCallId}`;
168
+ }
169
+
170
+ return `${kind}:${hashText(text)}`;
171
+ }
172
+
173
+ function findTranscriptContainer(
174
+ component: Component,
175
+ width: number,
176
+ startRow = 0,
177
+ ): { component: ComponentWithChildren; startRow: number } | undefined {
178
+ const children = componentChildren(component);
179
+ if (children.some((child) => transcriptItemKind(child) !== undefined)) {
180
+ return { component: component as ComponentWithChildren, startRow };
181
+ }
182
+
183
+ let childRow = startRow;
184
+ for (const child of children) {
185
+ const found = findTranscriptContainer(child, width, childRow);
186
+ if (found) return found;
187
+ childRow += child.render(width).length;
188
+ }
189
+
190
+ return undefined;
191
+ }
192
+
193
+ function findMountedTranscriptContainer(
194
+ roots: Component[],
195
+ width: number,
196
+ ): { component: ComponentWithChildren; startRow: number } | undefined {
197
+ for (const root of roots) {
198
+ const found = findTranscriptContainer(root, width, 0);
199
+ if (found) return found;
200
+ }
201
+
202
+ // Pi mounts documentContainer as the first TUI child with
203
+ // [headerContainer, loadedResourcesContainer, chatContainer]. During
204
+ // session_start the chat container can still be empty, so semantic discovery
205
+ // cannot find it yet. Use that stable mounted shape as a guarded fallback for
206
+ // early transcript-item discovery.
207
+ const document = roots[0];
208
+ if (!document) return undefined;
209
+
210
+ const documentChildren = componentChildren(document);
211
+ const chat = documentChildren[2];
212
+ if (!chat) return undefined;
213
+
214
+ return {
215
+ component: chat as ComponentWithChildren,
216
+ startRow:
217
+ (documentChildren[0]?.render(width).length ?? 0) +
218
+ (documentChildren[1]?.render(width).length ?? 0),
219
+ };
220
+ }
221
+
222
+ export default function transcriptFocus(
223
+ pi: ExtensionAPI,
224
+ writeClipboard: ClipboardWriter = copyToClipboard,
225
+ agentDir: string = getAgentDir(),
226
+ ): void {
227
+ let cleanupSession: (() => void) | undefined;
228
+ let refreshActiveTranscript: (() => void) | undefined;
229
+
230
+ const scheduleTranscriptRefresh = (): void => {
231
+ setTimeout(() => refreshActiveTranscript?.(), 0);
232
+ };
233
+
234
+ pi.on("message_end", scheduleTranscriptRefresh);
235
+ pi.on("tool_execution_end", scheduleTranscriptRefresh);
236
+
237
+ pi.on("session_shutdown", () => {
238
+ cleanupSession?.();
239
+ cleanupSession = undefined;
240
+ });
241
+
242
+ pi.on("session_start", (_event, ctx) => {
243
+ cleanupSession?.();
244
+ cleanupSession = undefined;
245
+
246
+ const {
247
+ focusKey,
248
+ hideDefaultScrollIndicator,
249
+ warnings: configWarnings,
250
+ } = resolveConfig(ctx, agentDir);
251
+ for (const warning of configWarnings) ctx.ui.notify(warning, "warning");
252
+
253
+ const previousFactory = ctx.ui.getEditorComponent();
254
+
255
+ let tui: FullscreenTui | undefined;
256
+ let editor: TranscriptEditor | undefined;
257
+ let appKeybindings: AppKeybindings | undefined;
258
+ let focused = false;
259
+ let selectedKey: string | undefined;
260
+ let selectedSemanticKey: string | undefined;
261
+ let transcriptItemsCache: TranscriptItem[] | undefined;
262
+ let transcriptContentHeight: number | undefined;
263
+ let fullscreenLayout: FullscreenLayoutController;
264
+ let restoreEditorBorderStyle: (() => void) | undefined;
265
+ let visualSourceRevision = 0;
266
+ const visualLineCache = new Map<number, VimCell[]>();
267
+ const componentKeys = new WeakMap<object, string>();
268
+ let nextComponentKey = 1;
269
+ let exDetour = false;
270
+ let exReturnArmed = false;
271
+ let exReturnCheck: ReturnType<typeof setTimeout> | undefined;
272
+ let visualNavigation: VimVisualNavigation | undefined;
273
+ let visualWidth: number | undefined;
274
+
275
+ const activeScrollView = (): PrivateScrollView | undefined =>
276
+ fullscreenLayout.activeScrollView();
277
+
278
+ const inVisualMode = (): boolean => Boolean(visualNavigation);
279
+ const isVisualSelecting = (): boolean =>
280
+ visualNavigation?.isSelecting() ?? false;
281
+ const visualSnapshot = () => visualNavigation?.snapshot();
282
+ const supportsExCommands = (): boolean =>
283
+ typeof editor?.getMode === "function";
284
+ const matchesFocusKey = (data: string): boolean => matchesKey(data, focusKey);
285
+
286
+ const transientUiHasFocus = (): boolean => {
287
+ const active = tui?.getFocusedComponent?.();
288
+ return active !== undefined && active !== null && active !== editor;
289
+ };
290
+
291
+ const reclaimTranscriptFocus = (): void => {
292
+ if (focused && tui?.getFocusedComponent?.() === editor) {
293
+ tui?.setFocus(null);
294
+ }
295
+ };
296
+
297
+ const transcriptWidth = (): number => fullscreenLayout.contentWidth();
298
+
299
+ const nativeSelectionPoint = (point: VimPoint): NativeSelectionPoint => ({
300
+ ...point,
301
+ scrollView: activeScrollView(),
302
+ });
303
+
304
+ const sourceLine = (row: number): string =>
305
+ tui?.getSelectionSourceLine?.(nativeSelectionPoint({ row, col: 0 })) ?? "";
306
+
307
+ const graphemeColumns = (line: string): VimCell[] => {
308
+ const stripped = stripTerminalSequences(line);
309
+ const columns: VimCell[] = [];
310
+ let col = 0;
311
+
312
+ for (const segment of GRAPHEME_SEGMENTER.segment(stripped)) {
313
+ const width = Math.max(0, visibleWidth(segment.segment));
314
+ columns.push({ start: col, end: col + width, text: segment.segment });
315
+ col += width;
316
+ }
317
+
318
+ return columns;
319
+ };
320
+
321
+ const clampRow = (row: number): number =>
322
+ transcriptContentHeight
323
+ ? Math.max(0, Math.min(transcriptContentHeight - 1, row))
324
+ : Math.max(0, row);
325
+
326
+ const visualTextSource: VimTextSource = {
327
+ get lineCount() {
328
+ return Math.max(1, transcriptContentHeight ?? 1);
329
+ },
330
+ get revision() {
331
+ return visualSourceRevision;
332
+ },
333
+ line: (row) => {
334
+ const clamped = clampRow(row);
335
+ const cached = visualLineCache.get(clamped);
336
+ if (cached) return cached;
337
+ const cells = graphemeColumns(sourceLine(clamped));
338
+ visualLineCache.set(clamped, cells);
339
+ return cells;
340
+ },
341
+ };
342
+
343
+ const invalidateVisualSource = (): void => {
344
+ visualSourceRevision++;
345
+ visualLineCache.clear();
346
+ };
347
+
348
+ fullscreenLayout = new FullscreenLayoutController({
349
+ getTui: () => tui,
350
+ getCursor: () => visualSnapshot()?.head,
351
+ isSelecting: () => isVisualSelecting(),
352
+ hideDefaultScrollIndicator,
353
+ onIntegrationInvalidated: () => {
354
+ transcriptItemsCache = undefined;
355
+ transcriptContentHeight = undefined;
356
+ invalidateVisualSource();
357
+ },
358
+ });
359
+
360
+ const graphemeEndPoint = (point: VimPoint): NativeSelectionPoint => {
361
+ const cells = visualTextSource.line(point.row);
362
+ const grapheme =
363
+ cells.find(
364
+ (candidate) =>
365
+ point.col >= candidate.start && point.col < candidate.end,
366
+ ) ??
367
+ cells.find((candidate) => candidate.start >= point.col) ??
368
+ cells[cells.length - 1];
369
+ return {
370
+ row: point.row,
371
+ col: grapheme?.end ?? point.col,
372
+ scrollView: activeScrollView(),
373
+ boundary: true,
374
+ };
375
+ };
376
+
377
+ const lineSelectionRange = (point: VimPoint): NativeSelectionRange => {
378
+ const cells = visualTextSource.line(point.row);
379
+ return {
380
+ start: nativeSelectionPoint({ row: point.row, col: 0 }),
381
+ end: {
382
+ row: point.row,
383
+ col: cells[cells.length - 1]?.end ?? 0,
384
+ scrollView: activeScrollView(),
385
+ boundary: true,
386
+ },
387
+ };
388
+ };
389
+
390
+ const componentKey = (
391
+ component: Component,
392
+ kind: TranscriptItemKind,
393
+ ): string => {
394
+ if (kind === "tool") {
395
+ const toolCallId = (component as { toolCallId?: unknown }).toolCallId;
396
+ if (typeof toolCallId === "string" && toolCallId.length > 0)
397
+ return `tool:${toolCallId}`;
398
+ }
399
+
400
+ const object = component as object;
401
+ let key = componentKeys.get(object);
402
+ if (!key) {
403
+ key = `${kind}:component:${nextComponentKey++}`;
404
+ componentKeys.set(object, key);
405
+ }
406
+ return key;
407
+ };
408
+
409
+ const refreshTranscriptItems = (): TranscriptItem[] => {
410
+ invalidateVisualSource();
411
+ if (!tui) {
412
+ transcriptContentHeight = undefined;
413
+ transcriptItemsCache = [];
414
+ return transcriptItemsCache;
415
+ }
416
+
417
+ const width = transcriptWidth();
418
+ const transcript = findMountedTranscriptContainer(tui.children, width);
419
+
420
+ if (!transcript) {
421
+ transcriptContentHeight = undefined;
422
+ fullscreenLayout.setSelection(undefined);
423
+ transcriptItemsCache = [];
424
+ return transcriptItemsCache;
425
+ }
426
+
427
+ const items: TranscriptItem[] = [];
428
+ const duplicateKeys = new Map<string, number>();
429
+ const renderedChildren: Array<{
430
+ child: Component;
431
+ kind: TranscriptItemKind | undefined;
432
+ lines: string[];
433
+ startRow: number;
434
+ }> = [];
435
+ let localRow = 0;
436
+
437
+ for (const child of transcript.component.children ?? []) {
438
+ const lines = child.render(width);
439
+ renderedChildren.push({
440
+ child,
441
+ kind: transcriptItemKind(child),
442
+ lines,
443
+ startRow: localRow,
444
+ });
445
+ localRow += lines.length;
446
+ }
447
+
448
+ transcriptContentHeight = localRow + transcript.startRow;
449
+
450
+ const transcriptLines = renderedChildren.flatMap(({ lines }) => lines);
451
+ const isBlankLine = (line: string | undefined): boolean =>
452
+ line !== undefined && stripTerminalSequences(line).trim().length === 0;
453
+
454
+ for (const {
455
+ child,
456
+ kind,
457
+ lines,
458
+ startRow: childStartRow,
459
+ } of renderedChildren) {
460
+ const bounds = kind ? visibleLineBounds(lines) : undefined;
461
+ if (!kind || !bounds) continue;
462
+
463
+ const text = trimRenderedText(lines);
464
+ const baseKey = semanticItemBaseKey(child, kind, text);
465
+ const occurrence = duplicateKeys.get(baseKey) ?? 0;
466
+ duplicateKeys.set(baseKey, occurrence + 1);
467
+
468
+ const visibleStartRow = childStartRow + bounds.first;
469
+ const visibleEndRow = childStartRow + bounds.last + 1;
470
+ const gutterStartRow =
471
+ visibleStartRow > 0 &&
472
+ isBlankLine(transcriptLines[visibleStartRow - 1])
473
+ ? visibleStartRow - 1
474
+ : visibleStartRow;
475
+ const gutterEndRow =
476
+ visibleEndRow < transcriptLines.length &&
477
+ isBlankLine(transcriptLines[visibleEndRow])
478
+ ? visibleEndRow + 1
479
+ : visibleEndRow;
480
+
481
+ items.push({
482
+ key: componentKey(child, kind),
483
+ semanticKey: `${baseKey}:${occurrence}`,
484
+ kind,
485
+ startRow: transcript.startRow + visibleStartRow,
486
+ endRow: transcript.startRow + visibleEndRow,
487
+ // Navigation stays anchored to visible content. The gutter absorbs at
488
+ // most one adjacent blank transcript row above and below, regardless
489
+ // of whether Pi owns that spacing inside or outside the component.
490
+ gutterStartRow: transcript.startRow + gutterStartRow,
491
+ gutterEndRow: transcript.startRow + gutterEndRow,
492
+ text,
493
+ linkSourceLines: lines,
494
+ });
495
+ }
496
+
497
+ transcriptItemsCache = items;
498
+ return items;
499
+ };
500
+
501
+ const transcriptItems = (): TranscriptItem[] =>
502
+ transcriptItemsCache ?? refreshTranscriptItems();
503
+
504
+ const showSelection = (item: TranscriptItem): void => {
505
+ fullscreenLayout.setSelection(item);
506
+ };
507
+
508
+ const hideSelectionDecoration = (): void => {
509
+ fullscreenLayout.setSelection(undefined);
510
+ tui?.requestRender();
511
+ };
512
+
513
+ const clearSelection = (): void => {
514
+ selectedKey = undefined;
515
+ selectedSemanticKey = undefined;
516
+ hideSelectionDecoration();
517
+ };
518
+
519
+ const selectedItemFrom = (
520
+ items: TranscriptItem[],
521
+ ): TranscriptItem | undefined => {
522
+ if (!selectedKey && !selectedSemanticKey) return undefined;
523
+ const item =
524
+ items.find((candidate) => candidate.key === selectedKey) ??
525
+ items.find(
526
+ (candidate) => candidate.semanticKey === selectedSemanticKey,
527
+ );
528
+ if (!item) {
529
+ selectedKey = undefined;
530
+ selectedSemanticKey = undefined;
531
+ fullscreenLayout.setSelection(undefined);
532
+ return undefined;
533
+ }
534
+
535
+ selectedKey = item.key;
536
+ selectedSemanticKey = item.semanticKey;
537
+ if ((focused || exDetour) && !inVisualMode()) showSelection(item);
538
+ return item;
539
+ };
540
+
541
+ const clearNativeTextSelection = (): void => {
542
+ tui?.clearTextSelection?.();
543
+ if (tui) {
544
+ tui.selectionAnchor = undefined;
545
+ tui.selectionFocus = undefined;
546
+ tui.selectionInitialRange = undefined;
547
+ tui.selectionGranularity = "character";
548
+ }
549
+ };
550
+
551
+ const reconcileFullscreenMode = (): boolean => {
552
+ const integrated = fullscreenLayout.ensure();
553
+ if (tui?.mode === "fullscreen") return integrated;
554
+
555
+ if (inVisualMode()) {
556
+ visualNavigation = undefined;
557
+ visualWidth = undefined;
558
+ clearNativeTextSelection();
559
+ }
560
+
561
+ if (focused) {
562
+ focused = false;
563
+ ctx.ui.setStatus("pi-tab-focus", undefined);
564
+ if (tui && editor) tui.setFocus(editor);
565
+ }
566
+
567
+ return false;
568
+ };
569
+
570
+ const applyVisualSelection = (): void => {
571
+ const visual = visualSnapshot();
572
+ if (!tui || !visual?.anchor || !visual.selectionKind) {
573
+ clearNativeTextSelection();
574
+ return;
575
+ }
576
+
577
+ const { anchor, head, selectionKind } = visual;
578
+ if (selectionKind === "line") {
579
+ const anchorRange = lineSelectionRange(anchor);
580
+ const headRange = lineSelectionRange(head);
581
+ const headBeforeAnchor = head.row < anchor.row;
582
+ tui.selectionGranularity = "line";
583
+ tui.selectionInitialRange = anchorRange;
584
+ tui.selectionAnchor = headBeforeAnchor ? anchorRange.end : anchorRange.start;
585
+ tui.selectionFocus = headBeforeAnchor ? headRange.start : headRange.end;
586
+ } else {
587
+ const anchorBeforeHead = compareVimPoints(anchor, head) <= 0;
588
+ tui.selectionGranularity = "character";
589
+ tui.selectionInitialRange = undefined;
590
+ tui.selectionAnchor = anchorBeforeHead
591
+ ? nativeSelectionPoint(anchor)
592
+ : nativeSelectionPoint(head);
593
+ tui.selectionFocus = anchorBeforeHead
594
+ ? graphemeEndPoint(head)
595
+ : graphemeEndPoint(anchor);
596
+ }
597
+
598
+ fullscreenLayout.setSelection(undefined);
599
+ tui.requestRender();
600
+ };
601
+
602
+ const syncSelectionToVisualHead = (direction: -1 | 1): void => {
603
+ const head = visualSnapshot()?.head;
604
+ if (!head) return;
605
+ const items = transcriptItems();
606
+ const row = head.row;
607
+ const direct = items.find(
608
+ (item) => row >= item.startRow && row < item.endRow,
609
+ );
610
+ const adjacent =
611
+ direction >= 0
612
+ ? (items.find((item) => item.startRow >= row) ??
613
+ items[items.length - 1])
614
+ : (items.toReversed().find((item) => item.endRow <= row) ?? items[0]);
615
+ const next = direct ?? adjacent;
616
+ if (!next) return;
617
+ selectedKey = next.key;
618
+ selectedSemanticKey = next.semanticKey;
619
+ };
620
+
621
+ const revealVisualHead = (): void => {
622
+ const head = visualSnapshot()?.head;
623
+ if (!tui || !head) return;
624
+ const scrollView = activeScrollView();
625
+ if (scrollView?.scrollTo && scrollView.viewportHeight > 0) {
626
+ const top = scrollView.scrollTop;
627
+ const bottom = top + scrollView.viewportHeight;
628
+ let target: number | undefined;
629
+ if (head.row < top) target = head.row;
630
+ else if (head.row >= bottom)
631
+ target = head.row - scrollView.viewportHeight + 1;
632
+ if (target !== undefined)
633
+ scrollView.scrollTo(Math.max(0, target), { disableFollow: true });
634
+ } else {
635
+ const top = tui.viewportTop ?? 0;
636
+ if (head.row < top || head.row >= top + effectiveViewportHeight()) {
637
+ tui.scrollBy?.(head.row - top);
638
+ }
639
+ }
640
+ };
641
+
642
+ const finishVisualMode = (
643
+ options: { restoreGutter: boolean } = { restoreGutter: true },
644
+ ): void => {
645
+ const head = visualSnapshot()?.head;
646
+ if (options.restoreGutter && head) syncSelectionToVisualHead(1);
647
+ visualNavigation = undefined;
648
+ visualWidth = undefined;
649
+ clearNativeTextSelection();
650
+ if (options.restoreGutter) {
651
+ const selected = selectedItemFrom(transcriptItems());
652
+ if (selected && focused) showSelection(selected);
653
+ } else {
654
+ fullscreenLayout.setSelection(undefined);
655
+ }
656
+ updateStatus();
657
+ tui?.requestRender();
658
+ };
659
+
660
+ const refreshSelectionGeometry = (): void => {
661
+ reconcileFullscreenMode();
662
+ transcriptItemsCache = undefined;
663
+ if (tui?.mode !== "fullscreen" || (!focused && !exDetour)) return;
664
+
665
+ const items = refreshTranscriptItems();
666
+ const selected = selectedItemFrom(items);
667
+ visualNavigation?.clamp(visualTextSource);
668
+ if (isVisualSelecting()) applyVisualSelection();
669
+ else if (!inVisualMode() && selected) showSelection(selected);
670
+ tui?.requestRender();
671
+ };
672
+
673
+ refreshActiveTranscript = refreshSelectionGeometry;
674
+
675
+ const updateStatus = (): void => {
676
+ if (!focused) {
677
+ ctx.ui.setStatus("pi-tab-focus", undefined);
678
+ return;
679
+ }
680
+
681
+ const items = transcriptItems();
682
+ const selected = selectedItemFrom(items);
683
+ const selectedIndex = selected ? items.indexOf(selected) : -1;
684
+ const selection =
685
+ selected && selectedIndex >= 0
686
+ ? ` • ${selected.kind} ${selectedIndex + 1}/${items.length}`
687
+ : "";
688
+ const visual = visualSnapshot();
689
+ const pending = visual?.pending ? ` • ${visual.pending}` : "";
690
+ const exHint = supportsExCommands() ? " • : command" : "";
691
+
692
+ ctx.ui.setStatus(
693
+ "pi-tab-focus",
694
+ visual
695
+ ? visual.selectionKind === "line"
696
+ ? `VISUAL LINE hjkl/wbe/WBE • fFtT • gg/G • y/c copy • esc/v cursor${pending}${selection}`
697
+ : visual.selectionKind
698
+ ? `VISUAL SELECT hjkl/wbe/WBE • iw/aw + quotes/brackets • fFtT • y/c copy • esc/v cursor${pending}${selection}`
699
+ : `VISUAL NAV hjkl/wbe/WBE • fFtT • gg/G • v select • V line • iw/aw objects • esc exit${pending}${selection}`
700
+ : `TRANSCRIPT ↑↓/jk scroll • u/d half-page • shift+↑↓/JK item • b/pgup up • f/pgdn down • c/y copy${selection} • v visual • V line • enter link${exHint} • ${focusKey}/esc exit`,
701
+ );
702
+ };
703
+
704
+ const setFocused = (next: boolean): boolean => {
705
+ if (next) fullscreenLayout.ensure();
706
+ if (next && tui?.mode !== "fullscreen") {
707
+ focused = false;
708
+ ctx.ui.setStatus("pi-tab-focus", undefined);
709
+ return false;
710
+ }
711
+
712
+ focused = next;
713
+
714
+ // Transcript focus is real TUI focus, not just an input-routing flag.
715
+ // Removing focus from the editor suppresses its CURSOR_MARKER, so Pi hides
716
+ // the hardware caret while scrolling the transcript. This avoids cursor
717
+ // show/hide/reposition flashes, which are particularly visible in tmux.
718
+ if (tui) {
719
+ tui.setFocus(focused ? null : (editor ?? null));
720
+ }
721
+
722
+ updateStatus();
723
+ return true;
724
+ };
725
+
726
+ const leaveTranscriptMode = (): void => {
727
+ // Keep the logical selection so re-entering transcript mode can restore it
728
+ // if it is still visible. The reserved gutter remains part of transcript
729
+ // layout; only the selection marker disappears while the editor owns focus.
730
+ hideSelectionDecoration();
731
+ setFocused(false);
732
+ };
733
+
734
+ const finishExDetour = (): void => {
735
+ exDetour = false;
736
+ exReturnArmed = false;
737
+ if (exReturnCheck !== undefined) {
738
+ clearTimeout(exReturnCheck);
739
+ exReturnCheck = undefined;
740
+ }
741
+ setFocused(true);
742
+ const items = refreshTranscriptItems();
743
+ const selected = selectedItemFrom(items);
744
+ if (inVisualMode()) {
745
+ visualNavigation?.clamp(visualTextSource);
746
+ if (isVisualSelecting()) applyVisualSelection();
747
+ else fullscreenLayout.setSelection(undefined);
748
+ } else if (selected) {
749
+ showSelection(selected);
750
+ }
751
+ tui?.requestRender();
752
+ };
753
+
754
+ const checkExReturnAfterInput = (): void => {
755
+ if (!exDetour || !exReturnArmed || !tui || !editor) return;
756
+
757
+ if (exReturnCheck !== undefined) clearTimeout(exReturnCheck);
758
+ exReturnCheck = setTimeout(() => {
759
+ exReturnCheck = undefined;
760
+ if (!exDetour || !exReturnArmed || !tui || !editor) return;
761
+
762
+ // EX commands may open a Pi overlay (for example a picker). Keep the
763
+ // editor focused while that UI is active. Once the command/cancel path
764
+ // has returned to the editor, restore transcript focus automatically.
765
+ if (!tui.hasOverlay() && tui.getFocusedComponent?.() === editor) {
766
+ finishExDetour();
767
+ }
768
+ }, 0);
769
+ };
770
+
771
+ const effectiveViewportHeight = (): number => {
772
+ if (!tui) return 1;
773
+ const viewportHeight = activeScrollView()?.viewportHeight;
774
+ return viewportHeight && viewportHeight > 0
775
+ ? viewportHeight
776
+ : Math.max(1, tui.terminal.rows - 5);
777
+ };
778
+
779
+ const page = (direction: -1 | 1): void => {
780
+ tui?.scrollBy?.(direction * effectiveViewportHeight());
781
+ };
782
+
783
+ const halfPage = (direction: -1 | 1): void => {
784
+ tui?.scrollBy?.(
785
+ direction * Math.max(1, Math.floor(effectiveViewportHeight() / 2)),
786
+ );
787
+ };
788
+
789
+ const revealItem = (item: TranscriptItem): void => {
790
+ if (!tui) return;
791
+
792
+ // Pi currently keeps the active layout frame private. When available, use
793
+ // its primary ScrollView to reveal only as much as necessary. If that
794
+ // implementation detail changes, fall back to moving the item to the top.
795
+ const scrollView = activeScrollView();
796
+ if (scrollView?.scrollTo && scrollView.viewportHeight > 0) {
797
+ const viewportTop = scrollView.scrollTop;
798
+ const viewportBottom = viewportTop + scrollView.viewportHeight;
799
+ let target: number | undefined;
800
+
801
+ if (item.startRow < viewportTop) {
802
+ target = item.startRow;
803
+ } else if (item.endRow > viewportBottom) {
804
+ target =
805
+ item.endRow - item.startRow > scrollView.viewportHeight
806
+ ? item.startRow
807
+ : item.endRow - scrollView.viewportHeight;
808
+ }
809
+
810
+ if (target !== undefined) {
811
+ scrollView.scrollTo(target, { disableFollow: true });
812
+ tui.requestRender();
813
+ }
814
+ return;
815
+ }
816
+
817
+ const viewportTop = tui.viewportTop ?? 0;
818
+ tui.scrollBy?.(item.startRow - viewportTop);
819
+ };
820
+
821
+ const viewportBounds = (): { top: number; bottom: number } | undefined => {
822
+ if (!tui) return undefined;
823
+
824
+ const scrollView = activeScrollView();
825
+ if (scrollView && scrollView.viewportHeight > 0) {
826
+ return {
827
+ top: scrollView.scrollTop,
828
+ bottom: scrollView.scrollTop + scrollView.viewportHeight,
829
+ };
830
+ }
831
+
832
+ const top = tui.viewportTop ?? 0;
833
+ return {
834
+ top,
835
+ bottom: top + effectiveViewportHeight(),
836
+ };
837
+ };
838
+
839
+ const itemIsVisible = (
840
+ item: TranscriptItem,
841
+ bounds: { top: number; bottom: number },
842
+ ): boolean => item.endRow > bounds.top && item.startRow < bounds.bottom;
843
+
844
+ const syncSelectionToViewport = (direction: -1 | 1): void => {
845
+ let items = transcriptItems();
846
+ const bounds = viewportBounds();
847
+ if (items.length === 0 || !bounds) {
848
+ clearSelection();
849
+ updateStatus();
850
+ return;
851
+ }
852
+
853
+ let current = selectedItemFrom(items);
854
+ if (current && itemIsVisible(current, bounds)) return;
855
+
856
+ // Item row ranges are expensive to calculate because Pi does not expose
857
+ // them directly. Keep line scrolling on the cached snapshot and only
858
+ // rebuild it when the current selection appears to leave the viewport.
859
+ // This also refreshes rows changed by a streaming assistant/tool block.
860
+ items = refreshTranscriptItems();
861
+ current = selectedItemFrom(items);
862
+ if (current && itemIsVisible(current, bounds)) return;
863
+
864
+ const visibleItems = items.filter((item) => itemIsVisible(item, bounds));
865
+ // Keep selection attached to the viewport edge it leaves through. When
866
+ // scrolling up, the old selection falls out of the bottom, so choose the
867
+ // bottom-most visible replacement. Scrolling down does the inverse.
868
+ let next =
869
+ direction < 0 ? visibleItems[visibleItems.length - 1] : visibleItems[0];
870
+
871
+ // A viewport can theoretically land entirely in spacing between items.
872
+ // Pick the nearest item at that same boundary rather than dropping
873
+ // selection until the next scroll event.
874
+ if (!next) {
875
+ next =
876
+ direction < 0
877
+ ? (items
878
+ .toReversed()
879
+ .find((item) => item.startRow < bounds.bottom) ?? items[0])
880
+ : (items.find((item) => item.endRow > bounds.top) ??
881
+ items[items.length - 1]);
882
+ }
883
+ if (!next) return;
884
+
885
+ selectedKey = next.key;
886
+ selectedSemanticKey = next.semanticKey;
887
+ showSelection(next);
888
+ updateStatus();
889
+ tui?.requestRender();
890
+ };
891
+
892
+ const enterTranscriptMode = (): void => {
893
+ if (!setFocused(true)) return;
894
+
895
+ const items = refreshTranscriptItems();
896
+ const bounds = viewportBounds();
897
+ if (items.length === 0 || !bounds) {
898
+ clearSelection();
899
+ updateStatus();
900
+ return;
901
+ }
902
+
903
+ // Crush-like entry behavior is intentionally independent of scroll
904
+ // direction: restore a previously selected visible item, otherwise choose
905
+ // the bottom-most visible item (normally the newest visible transcript item).
906
+ const current = selectedItemFrom(items);
907
+ if (current && itemIsVisible(current, bounds)) {
908
+ showSelection(current);
909
+ updateStatus();
910
+ tui?.requestRender();
911
+ return;
912
+ }
913
+
914
+ const visibleItems = items.filter((item) => itemIsVisible(item, bounds));
915
+ const next =
916
+ visibleItems[visibleItems.length - 1] ??
917
+ items.toReversed().find((item) => item.startRow < bounds.bottom) ??
918
+ items[items.length - 1];
919
+ if (!next) return;
920
+
921
+ selectedKey = next.key;
922
+ selectedSemanticKey = next.semanticKey;
923
+ showSelection(next);
924
+ updateStatus();
925
+ tui?.requestRender();
926
+ };
927
+
928
+ const selectItem = (direction: -1 | 1): void => {
929
+ const items = refreshTranscriptItems();
930
+ if (items.length === 0) {
931
+ clearSelection();
932
+ updateStatus();
933
+ ctx.ui.notify("No selectable transcript items.", "info");
934
+ return;
935
+ }
936
+
937
+ const current = selectedItemFrom(items);
938
+ const currentIndex = current ? items.indexOf(current) : -1;
939
+ const nextIndex =
940
+ currentIndex < 0
941
+ ? direction < 0
942
+ ? items.length - 1
943
+ : 0
944
+ : Math.max(0, Math.min(items.length - 1, currentIndex + direction));
945
+ const next = items[nextIndex];
946
+ if (!next) return;
947
+
948
+ selectedKey = next.key;
949
+ selectedSemanticKey = next.semanticKey;
950
+ showSelection(next);
951
+ revealItem(next);
952
+ updateStatus();
953
+ tui?.requestRender();
954
+ };
955
+
956
+ const copySelectedItem = (): void => {
957
+ const items = refreshTranscriptItems();
958
+ if (items.length === 0) {
959
+ ctx.ui.notify("No selectable transcript item.", "warning");
960
+ return;
961
+ }
962
+
963
+ let selected = selectedItemFrom(items);
964
+ if (!selected) {
965
+ selected = items[items.length - 1];
966
+ if (!selected) return;
967
+ selectedKey = selected.key;
968
+ selectedSemanticKey = selected.semanticKey;
969
+ showSelection(selected);
970
+ revealItem(selected);
971
+ updateStatus();
972
+ }
973
+
974
+ const text = selected.text;
975
+ const kind = selected.kind;
976
+
977
+ void writeClipboard(text).then(
978
+ () => ctx.ui.notify(`Copied selected ${kind}.`, "info"),
979
+ (error: unknown) =>
980
+ ctx.ui.notify(
981
+ `Failed to copy selected ${kind}: ${error instanceof Error ? error.message : String(error)}`,
982
+ "error",
983
+ ),
984
+ );
985
+ };
986
+
987
+ const ensureVisualWidth = (): boolean => {
988
+ if (!inVisualMode()) return true;
989
+ if (visualWidth === transcriptWidth()) return true;
990
+ finishVisualMode();
991
+ return false;
992
+ };
993
+
994
+ const selectedVisualStartPoint = (): VimPoint | undefined => {
995
+ const items = refreshTranscriptItems();
996
+ const selected = selectedItemFrom(items) ?? items[items.length - 1];
997
+ if (!selected) {
998
+ ctx.ui.notify("No selectable transcript item.", "warning");
999
+ return undefined;
1000
+ }
1001
+ selectedKey = selected.key;
1002
+ selectedSemanticKey = selected.semanticKey;
1003
+ const row = clampRow(selected.startRow);
1004
+ return {
1005
+ row,
1006
+ col: firstNonWhitespaceColumn(visualTextSource, row),
1007
+ };
1008
+ };
1009
+
1010
+ const enterVisualMode = (): void => {
1011
+ const point = selectedVisualStartPoint();
1012
+ if (!point) return;
1013
+ visualNavigation = new VimVisualNavigation(point);
1014
+ visualWidth = transcriptWidth();
1015
+ hideSelectionDecoration();
1016
+ clearNativeTextSelection();
1017
+ revealVisualHead();
1018
+ updateStatus();
1019
+ tui?.requestRender();
1020
+ };
1021
+
1022
+ const syncVisualNavigation = (before: VimPoint): void => {
1023
+ const after = visualSnapshot()?.head;
1024
+ if (!after) return;
1025
+ const comparison = compareVimPoints(after, before);
1026
+ if (comparison !== 0) {
1027
+ syncSelectionToVisualHead(comparison > 0 ? 1 : -1);
1028
+ revealVisualHead();
1029
+ }
1030
+ if (isVisualSelecting()) applyVisualSelection();
1031
+ else clearNativeTextSelection();
1032
+ updateStatus();
1033
+ tui?.requestRender();
1034
+ };
1035
+
1036
+ const copyVisualSelection = (): void => {
1037
+ if (!isVisualSelecting()) {
1038
+ ctx.ui.notify("Press v or V to start a selection.", "info");
1039
+ return;
1040
+ }
1041
+ void tui?.copyActiveSelectionToClipboard?.().then(
1042
+ (ok) =>
1043
+ ctx.ui.notify(
1044
+ ok ? "Copied selected text." : "No text selection to copy.",
1045
+ ok ? "info" : "warning",
1046
+ ),
1047
+ (error: unknown) =>
1048
+ ctx.ui.notify(
1049
+ `Failed to copy selection: ${error instanceof Error ? error.message : String(error)}`,
1050
+ "error",
1051
+ ),
1052
+ );
1053
+ finishVisualMode();
1054
+ };
1055
+
1056
+ const openUrl = (url: string): void => {
1057
+ try {
1058
+ tui?.openUrl?.(url);
1059
+ } catch {
1060
+ ctx.ui.notify(`Failed to open link: ${url}`, "error");
1061
+ }
1062
+ };
1063
+
1064
+ const openVisualLink = (): void => {
1065
+ const head = visualSnapshot()?.head;
1066
+ if (!head) return;
1067
+
1068
+ const url = transcriptLinkAtColumn(sourceLine(head.row), head.col);
1069
+ if (!url) {
1070
+ ctx.ui.notify("No link under visual cursor.", "info");
1071
+ return;
1072
+ }
1073
+ openUrl(url);
1074
+ };
1075
+
1076
+ const openSelectedItemLink = (): void => {
1077
+ const selected = selectedItemFrom(transcriptItems());
1078
+ const url = selected ? firstTranscriptLink(selected.linkSourceLines) : undefined;
1079
+ if (!url) {
1080
+ ctx.ui.notify("No link found in selected transcript item.", "info");
1081
+ return;
1082
+ }
1083
+ openUrl(url);
1084
+ };
1085
+
1086
+ const enterExDetour = (): void => {
1087
+ if (!editor?.getMode) {
1088
+ ctx.ui.notify("EX commands require pi-vim.", "info");
1089
+ return;
1090
+ }
1091
+
1092
+ exDetour = true;
1093
+ exReturnArmed = false;
1094
+ setFocused(false);
1095
+
1096
+ if (editor.getMode() !== "normal") editor.handleInput("\x1b");
1097
+ editor.handleInput(":");
1098
+ };
1099
+
1100
+ const normalizeVisualKey = (data: string): string => {
1101
+ if (matchesKey(data, Key.escape) || matchesKey(data, "ctrl+["))
1102
+ return "escape";
1103
+ if (matchesKey(data, Key.enter)) return "enter";
1104
+ if (matchesKey(data, Key.left)) return "h";
1105
+ if (matchesKey(data, Key.right)) return "l";
1106
+ if (matchesKey(data, Key.up)) return "k";
1107
+ if (matchesKey(data, Key.down)) return "j";
1108
+ if (data === ":" || matchesKey(data, Key.colon)) return ":";
1109
+ return data;
1110
+ };
1111
+
1112
+ const handleVisualInput = (data: string): boolean => {
1113
+ if (!visualNavigation || !ensureVisualWidth()) return false;
1114
+
1115
+ const before = visualNavigation.snapshot().head;
1116
+ const result = visualNavigation.handleKey(
1117
+ normalizeVisualKey(data),
1118
+ visualTextSource,
1119
+ );
1120
+ if (!result.handled) {
1121
+ updateStatus();
1122
+ return false;
1123
+ }
1124
+
1125
+ if (result.command === "copy") copyVisualSelection();
1126
+ else if (result.command === "open-link") {
1127
+ openVisualLink();
1128
+ updateStatus();
1129
+ } else if (result.command === "ex") enterExDetour();
1130
+ else if (result.command === "exit") finishVisualMode();
1131
+ else syncVisualNavigation(before);
1132
+
1133
+ return true;
1134
+ };
1135
+
1136
+ // Preserve any existing custom editor (including pi-vim). When Pi is using
1137
+ // its built-in editor, install an equivalent CustomEditor so this extension
1138
+ // can receive the TUI/keybinding objects without requiring another package.
1139
+ // Pi wires default editor callbacks, app actions, paste handling and extension
1140
+ // shortcuts onto returned CustomEditor instances.
1141
+ ctx.ui.setEditorComponent((nextTui, theme, keybindings) => {
1142
+ restoreEditorBorderStyle?.();
1143
+ restoreEditorBorderStyle = undefined;
1144
+
1145
+ tui = nextTui as FullscreenTui;
1146
+ appKeybindings = keybindings as AppKeybindings;
1147
+ editor = (previousFactory
1148
+ ? previousFactory(nextTui, theme, keybindings)
1149
+ : new CustomEditor(nextTui, theme, keybindings, {
1150
+ embedWorkingStatus: true,
1151
+ })) as TranscriptEditor;
1152
+
1153
+ // Keep Pi's editor rendering intact and only swap its horizontal border
1154
+ // glyph while transcript mode is active. This preserves border colours,
1155
+ // embedded working status, overflow labels and compatible custom editors.
1156
+ // The editor survives runtime TUI renderer switches, so install this once
1157
+ // regardless of the renderer mode in which the session started.
1158
+ restoreEditorBorderStyle = installTranscriptEditorBorderStyle(
1159
+ editor,
1160
+ () => focused,
1161
+ );
1162
+
1163
+ if (fullscreenLayout.ensure()) refreshTranscriptItems();
1164
+
1165
+ return editor;
1166
+ });
1167
+
1168
+ // Transcript focus is an input mode, not an editor implementation. Handle it
1169
+ // before input reaches the editor and consume only keys owned by transcript mode.
1170
+ const unsubscribeTerminalInput = ctx.ui.onTerminalInput((data) => {
1171
+ // Pi can replace the active renderer at runtime without rebuilding the
1172
+ // custom editor. Reconcile cached fullscreen integration before touching
1173
+ // focus, viewport or selection state for this input event.
1174
+ reconcileFullscreenMode();
1175
+
1176
+ // When another Pi/custom component has actual TUI focus, it owns input.
1177
+ // This covers select/confirm/input/editor prompts and capturing overlays.
1178
+ if (transientUiHasFocus()) {
1179
+ if (exDetour) checkExReturnAfterInput();
1180
+ return undefined;
1181
+ }
1182
+
1183
+ // Pi restores editor focus when a transient component closes. Transcript
1184
+ // mode is logically still active, so reclaim its normal null-focus state.
1185
+ if (!exDetour) reclaimTranscriptFocus();
1186
+
1187
+
1188
+ // Raw input listeners run before Pi filters key-release events.
1189
+ if (isKeyRelease(data)) return undefined;
1190
+
1191
+ // When pi-vim is present, `:` is a temporary detour into its EX mini-mode.
1192
+ // While it is active, let the editor/Pi own input normally. Enter or Escape
1193
+ // ends EX input; if the command opens an overlay, subsequent overlay input
1194
+ // keeps checking until Pi restores focus to the editor, then transcript
1195
+ // mode resumes.
1196
+ if (exDetour) {
1197
+ // The configured focus key keeps its transcript-focus meaning while the
1198
+ // EX mini-mode is active. Cancel EX and return to transcript navigation.
1199
+ if (matchesFocusKey(data)) {
1200
+ if (!isKeyRepeat(data)) {
1201
+ editor?.handleInput("\x1b");
1202
+ finishExDetour();
1203
+ }
1204
+ return { consume: true };
1205
+ }
1206
+
1207
+ if (
1208
+ matchesKey(data, Key.enter) ||
1209
+ matchesKey(data, Key.escape) ||
1210
+ matchesKey(data, "ctrl+[")
1211
+ ) {
1212
+ exReturnArmed = true;
1213
+ }
1214
+ checkExReturnAfterInput();
1215
+ return undefined;
1216
+ }
1217
+
1218
+ if (matchesFocusKey(data)) {
1219
+ // Outside fullscreen, the configured focus key belongs entirely to Pi.
1220
+ if (!focused && tui?.mode !== "fullscreen") return undefined;
1221
+
1222
+ // Holding the focus key must not repeatedly flip focus on key-repeat events.
1223
+ if (!isKeyRepeat(data)) {
1224
+ if (inVisualMode()) finishVisualMode({ restoreGutter: false });
1225
+ if (focused) leaveTranscriptMode();
1226
+ else enterTranscriptMode();
1227
+ }
1228
+ return { consume: true };
1229
+ }
1230
+
1231
+ if (!focused) return undefined;
1232
+
1233
+ const transcriptLayoutAction = TRANSCRIPT_LAYOUT_ACTIONS.find((action) =>
1234
+ appKeybindings?.matches?.(data, action),
1235
+ );
1236
+ if (transcriptLayoutAction) {
1237
+ if (!isKeyRepeat(data)) {
1238
+ if (inVisualMode()) finishVisualMode();
1239
+ const handler = editor?.actionHandlers?.get(transcriptLayoutAction);
1240
+ if (handler) handler();
1241
+ else editor?.handleInput(data);
1242
+ refreshSelectionGeometry();
1243
+ }
1244
+ return { consume: true };
1245
+ }
1246
+
1247
+ if (matchesKey(data, "ctrl+d")) {
1248
+ ctx.shutdown();
1249
+ return { consume: true };
1250
+ }
1251
+
1252
+ // Leave transcript focus on Ctrl+C; once the editor owns focus again,
1253
+ // subsequent Ctrl+C input follows Pi's normal handling.
1254
+ if (matchesKey(data, "ctrl+c")) {
1255
+ if (inVisualMode()) finishVisualMode({ restoreGutter: false });
1256
+ leaveTranscriptMode();
1257
+ return { consume: true };
1258
+ }
1259
+
1260
+ if (inVisualMode()) {
1261
+ return handleVisualInput(data) ? { consume: true } : undefined;
1262
+ }
1263
+
1264
+ if (matchesKey(data, Key.escape)) {
1265
+ leaveTranscriptMode();
1266
+ return { consume: true };
1267
+ }
1268
+
1269
+ if (data === "v") {
1270
+ enterVisualMode();
1271
+ return { consume: true };
1272
+ }
1273
+
1274
+ if (data === "V") {
1275
+ enterVisualMode();
1276
+ if (inVisualMode()) handleVisualInput("V");
1277
+ return { consume: true };
1278
+ }
1279
+
1280
+ if (matchesKey(data, Key.enter)) {
1281
+ openSelectedItemLink();
1282
+ return { consume: true };
1283
+ }
1284
+
1285
+ if (data === ":" || matchesKey(data, Key.colon)) {
1286
+ enterExDetour();
1287
+ return { consume: true };
1288
+ }
1289
+
1290
+ if (data === "j" || matchesKey(data, Key.down)) {
1291
+ tui?.scrollBy?.(1);
1292
+ syncSelectionToViewport(1);
1293
+ return { consume: true };
1294
+ }
1295
+
1296
+ if (data === "k" || matchesKey(data, Key.up)) {
1297
+ tui?.scrollBy?.(-1);
1298
+ syncSelectionToViewport(-1);
1299
+ return { consume: true };
1300
+ }
1301
+
1302
+ if (data === "u") {
1303
+ halfPage(-1);
1304
+ syncSelectionToViewport(-1);
1305
+ return { consume: true };
1306
+ }
1307
+
1308
+ if (data === "d") {
1309
+ halfPage(1);
1310
+ syncSelectionToViewport(1);
1311
+ return { consume: true };
1312
+ }
1313
+
1314
+ if (
1315
+ data === "J" ||
1316
+ matchesKey(data, Key.shift("j")) ||
1317
+ matchesKey(data, Key.shift("down"))
1318
+ ) {
1319
+ selectItem(1);
1320
+ return { consume: true };
1321
+ }
1322
+
1323
+ if (
1324
+ data === "K" ||
1325
+ matchesKey(data, Key.shift("k")) ||
1326
+ matchesKey(data, Key.shift("up"))
1327
+ ) {
1328
+ selectItem(-1);
1329
+ return { consume: true };
1330
+ }
1331
+
1332
+ if (data === "b" || matchesKey(data, Key.pageUp)) {
1333
+ page(-1);
1334
+ syncSelectionToViewport(-1);
1335
+ return { consume: true };
1336
+ }
1337
+
1338
+ if (data === "f" || matchesKey(data, Key.pageDown)) {
1339
+ page(1);
1340
+ syncSelectionToViewport(1);
1341
+ return { consume: true };
1342
+ }
1343
+
1344
+ if (data === "g" || matchesKey(data, Key.home)) {
1345
+ tui?.scrollToTop?.();
1346
+ syncSelectionToViewport(-1);
1347
+ return { consume: true };
1348
+ }
1349
+
1350
+ if (data === "G" || matchesKey(data, Key.end)) {
1351
+ tui?.scrollToBottom?.();
1352
+ syncSelectionToViewport(1);
1353
+ return { consume: true };
1354
+ }
1355
+
1356
+ if (data === "y" || data === "c") {
1357
+ copySelectedItem();
1358
+ return { consume: true };
1359
+ }
1360
+
1361
+ // Transcript mode keeps TUI focus at null, so unrecognised input cannot
1362
+ // edit the prompt editor. Leave it unconsumed so other extensions' raw
1363
+ // terminal-input listeners can handle their own shortcuts.
1364
+ return undefined;
1365
+ });
1366
+
1367
+ cleanupSession = () => {
1368
+ unsubscribeTerminalInput();
1369
+
1370
+ if (exReturnCheck !== undefined) {
1371
+ clearTimeout(exReturnCheck);
1372
+ exReturnCheck = undefined;
1373
+ }
1374
+
1375
+ exDetour = false;
1376
+ exReturnArmed = false;
1377
+ if (inVisualMode()) finishVisualMode({ restoreGutter: false });
1378
+ clearSelection();
1379
+ transcriptItemsCache = undefined;
1380
+ transcriptContentHeight = undefined;
1381
+ fullscreenLayout.restore();
1382
+ restoreEditorBorderStyle?.();
1383
+ restoreEditorBorderStyle = undefined;
1384
+ if (focused && tui && editor) tui.setFocus(editor);
1385
+ ctx.ui.setEditorComponent(previousFactory);
1386
+ if (refreshActiveTranscript === refreshSelectionGeometry)
1387
+ refreshActiveTranscript = undefined;
1388
+
1389
+ ctx.ui.setStatus("pi-tab-focus", undefined);
1390
+ focused = false;
1391
+ };
1392
+ });
1393
+ }