@oh-my-pi/pi-tui 18.6.3 → 18.7.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 (57) hide show
  1. package/CHANGELOG.md +26 -58
  2. package/dist/types/app-keybindings.d.ts +1 -1
  3. package/dist/types/autocomplete.d.ts +5 -2
  4. package/dist/types/charts/chart-plan.d.ts +92 -0
  5. package/dist/types/charts/chart-svg.d.ts +30 -0
  6. package/dist/types/charts/table-data.d.ts +107 -0
  7. package/dist/types/chat/assistant-message.d.ts +4 -0
  8. package/dist/types/chat/chat-transcript-builder.d.ts +2 -0
  9. package/dist/types/chat/figure-markdown.d.ts +42 -0
  10. package/dist/types/chat/svg-figure.d.ts +33 -0
  11. package/dist/types/chat/svg-source.d.ts +49 -0
  12. package/dist/types/chat/table-chart.d.ts +88 -0
  13. package/dist/types/components/editor.d.ts +1 -1
  14. package/dist/types/components/image.d.ts +9 -0
  15. package/dist/types/components/markdown.d.ts +9 -0
  16. package/dist/types/fuzzy.d.ts +14 -0
  17. package/dist/types/keybindings.d.ts +1 -1
  18. package/dist/types/overlays/agents-hub.d.ts +9 -0
  19. package/dist/types/overlays/annotation-overlay.d.ts +5 -2
  20. package/dist/types/overlays/model-browser.d.ts +25 -3
  21. package/dist/types/overlays/plan-review-overlay.d.ts +3 -2
  22. package/dist/types/prompt/interactive-context-helpers.d.ts +2 -0
  23. package/dist/types/prompt/model-mention-autocomplete.d.ts +1 -1
  24. package/dist/types/render/composite.d.ts +2 -0
  25. package/dist/types/tools/glob.d.ts +4 -0
  26. package/dist/types/utils.d.ts +6 -0
  27. package/package.json +9 -9
  28. package/src/autocomplete.ts +8 -10
  29. package/src/charts/chart-plan.ts +367 -0
  30. package/src/charts/chart-svg.ts +693 -0
  31. package/src/charts/table-data.ts +436 -0
  32. package/src/chat/assistant-message.ts +139 -21
  33. package/src/chat/chat-transcript-builder.ts +4 -0
  34. package/src/chat/figure-markdown.ts +174 -0
  35. package/src/chat/svg-figure.ts +364 -0
  36. package/src/chat/svg-source.ts +188 -0
  37. package/src/chat/table-chart.ts +301 -0
  38. package/src/chrome/transcript-container.ts +7 -1
  39. package/src/components/editor.ts +52 -30
  40. package/src/components/image.ts +27 -1
  41. package/src/components/markdown.ts +6 -3
  42. package/src/fuzzy.ts +59 -19
  43. package/src/keybindings.ts +1 -1
  44. package/src/overlays/agent-transcript-viewer.ts +2 -0
  45. package/src/overlays/agents-hub.ts +14 -1
  46. package/src/overlays/annotation-overlay.ts +179 -80
  47. package/src/overlays/ask-dialog.ts +1 -1
  48. package/src/overlays/model-browser.ts +169 -76
  49. package/src/overlays/plan-review-overlay.ts +41 -4
  50. package/src/prompt/composer.ts +2 -14
  51. package/src/prompt/interactive-context-helpers.ts +3 -0
  52. package/src/prompt/model-mention-autocomplete.ts +14 -14
  53. package/src/render/composite.ts +62 -0
  54. package/src/status-line/segments.ts +3 -1
  55. package/src/tools/glob.ts +23 -6
  56. package/src/tui.ts +118 -105
  57. package/src/utils.ts +19 -0
package/src/fuzzy.ts CHANGED
@@ -307,11 +307,11 @@ function scoreTokenDirect(token: string, index: SearchIndex): FuzzyMatch {
307
307
  return best ?? { matches: false, score: 0 };
308
308
  }
309
309
 
310
- function scoreToken(token: string, index: SearchIndex): FuzzyMatch {
310
+ function scoreToken(token: string, swapVariants: readonly string[], index: SearchIndex): FuzzyMatch {
311
311
  let best = scoreTokenDirect(token, index);
312
312
  if (best.matches) return best;
313
313
 
314
- for (const variant of buildAlphanumericSwapQueries(token)) {
314
+ for (const variant of swapVariants) {
315
315
  const match = scoreTokenDirect(variant, index);
316
316
  if (!match.matches) continue;
317
317
  const score = match.score + ALPHANUMERIC_SWAP_PENALTY;
@@ -323,18 +323,26 @@ function scoreToken(token: string, index: SearchIndex): FuzzyMatch {
323
323
  return best;
324
324
  }
325
325
 
326
- /** A query normalized and split once, so `fuzzyRank` doesn't re-normalize the
327
- * same query for every candidate in the list. */
326
+ /** A query normalized and split once, so ranking doesn't re-normalize the
327
+ * same query (or rebuild its alphanumeric-swap variants) for every candidate. */
328
328
  interface PreparedQuery {
329
329
  normalized: string;
330
330
  tokens: string[];
331
+ /** `buildAlphanumericSwapQueries` of each token, index-aligned with `tokens`. */
332
+ tokenSwaps: string[][];
331
333
  compact: string;
332
334
  }
333
335
 
334
336
  function prepareQuery(query: string): PreparedQuery | null {
335
337
  const normalized = normalizeForSearch(query);
336
338
  if (normalized.length === 0) return null;
337
- return { normalized, tokens: normalized.split(" "), compact: normalized.replaceAll(" ", "") };
339
+ const tokens = normalized.split(" ");
340
+ return {
341
+ normalized,
342
+ tokens,
343
+ tokenSwaps: tokens.map(buildAlphanumericSwapQueries),
344
+ compact: normalized.replaceAll(" ", ""),
345
+ };
338
346
  }
339
347
 
340
348
  function fuzzyMatchCore(pq: PreparedQuery | null, index: SearchIndex): FuzzyMatch {
@@ -359,8 +367,8 @@ function fuzzyMatchCore(pq: PreparedQuery | null, index: SearchIndex): FuzzyMatc
359
367
  totalScore += compactPhraseIndex * 0.01;
360
368
  }
361
369
 
362
- for (const token of pq.tokens) {
363
- const match = scoreToken(token, index);
370
+ for (let i = 0; i < pq.tokens.length; i++) {
371
+ const match = scoreToken(pq.tokens[i]!, pq.tokenSwaps[i]!, index);
364
372
  if (!match.matches) {
365
373
  return { matches: false, score: 0 };
366
374
  }
@@ -399,6 +407,49 @@ export class FuzzyText {
399
407
  }
400
408
  }
401
409
 
410
+ /**
411
+ * Items prepared once for repeated {@link fuzzyRank}-equivalent ranking.
412
+ *
413
+ * `fuzzyRank` resolves each candidate's search index through the module cache,
414
+ * which stops admitting entries at {@link INDEX_CACHE_MAX}; larger candidate
415
+ * lists (the full model catalog) rebuild the overflow on every query. Hold one
416
+ * corpus per stable candidate list and call {@link rank} per keystroke instead.
417
+ */
418
+ export class FuzzyCorpus<T> {
419
+ readonly #items: readonly T[];
420
+ readonly #indexes: SearchIndex[];
421
+
422
+ constructor(items: readonly T[], getText: (item: T) => string) {
423
+ this.#items = items;
424
+ this.#indexes = items.map(item => buildUncachedSearchIndex(getText(item)));
425
+ }
426
+
427
+ /** Same results as `fuzzyRank(items, query, getText)` for the prepared items. */
428
+ rank(query: string): FuzzyFilterResult<T>[] {
429
+ if (!query.trim()) return this.#items.map(item => ({ item, score: 0 }));
430
+ const indexes = this.#indexes;
431
+ return rankIndexed(this.#items, prepareQuery(query), i => indexes[i]!);
432
+ }
433
+ }
434
+
435
+ function rankIndexed<T>(
436
+ items: readonly T[],
437
+ pq: PreparedQuery | null,
438
+ indexAt: (i: number) => SearchIndex,
439
+ ): FuzzyFilterResult<T>[] {
440
+ const results: FuzzyFilterResult<T>[] = [];
441
+ for (let i = 0; i < items.length; i++) {
442
+ const index = indexAt(i);
443
+ const match = pq === null ? { matches: true, score: 0 } : fuzzyMatchCore(pq, index);
444
+ if (match.matches) {
445
+ results.push({ item: items[i]!, score: match.score });
446
+ }
447
+ }
448
+
449
+ results.sort((a, b) => a.score - b.score);
450
+ return results;
451
+ }
452
+
402
453
  /**
403
454
  * Filter and sort items by fuzzy match quality (best matches first).
404
455
  * Supports space-separated tokens: all tokens must match.
@@ -411,18 +462,7 @@ export function fuzzyRank<T>(items: readonly T[], query: string, getText: (item:
411
462
  // A non-blank query that normalizes to empty (pure punctuation) matches
412
463
  // everything with score 0, but still calls getText per item — consumers rely
413
464
  // on its side effects (see fuzzy-cache.test.ts).
414
- const pq = prepareQuery(query);
415
- const results: FuzzyFilterResult<T>[] = [];
416
- for (const item of items) {
417
- const text = getText(item);
418
- const match = pq === null ? { matches: true, score: 0 } : fuzzyMatchCore(pq, buildSearchIndex(text));
419
- if (match.matches) {
420
- results.push({ item, score: match.score });
421
- }
422
- }
423
-
424
- results.sort((a, b) => a.score - b.score);
425
- return results;
465
+ return rankIndexed(items, prepareQuery(query), i => buildSearchIndex(getText(items[i]!)));
426
466
  }
427
467
 
428
468
  export function fuzzyFilter<T>(items: T[], query: string, getText: (item: T) => string): T[] {
@@ -105,7 +105,7 @@ export const TUI_KEYBINDINGS = {
105
105
  description: "Delete word backward",
106
106
  },
107
107
  "tui.editor.deleteWordForward": {
108
- defaultKeys: ["alt+delete", "alt+d", "super+alt+delete", "super+alt+d"],
108
+ defaultKeys: ["alt+delete", "alt+d", "ctrl+delete", "super+alt+delete", "super+alt+d"],
109
109
  description: "Delete word forward",
110
110
  },
111
111
  "tui.editor.deleteToLineStart": {
@@ -201,6 +201,8 @@ export class AgentTranscriptViewer implements Component {
201
201
  hideThinkingBlock: deps.hideThinkingBlock,
202
202
  proseOnlyThinking: deps.proseOnlyThinking,
203
203
  expandThinkingBlocks: deps.expandThinkingBlocks,
204
+ // Charts are for the main session's answers, not parked subagent, advisor, or guest transcripts.
205
+ tableCharts: false,
204
206
  requestRender: deps.requestRender,
205
207
  });
206
208
  this.#browser = new TranscriptBrowser({
@@ -141,6 +141,11 @@ export interface AgentsHubCallbacks {
141
141
  const IDENTIFIER_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+){1,5}$/;
142
142
 
143
143
  function extractJsonObject(raw: string): string {
144
+ // A bare JSON object may legitimately contain code fences inside string values; keep it intact.
145
+ try {
146
+ JSON.parse(raw);
147
+ return raw;
148
+ } catch {}
144
149
  const fenceMatch = raw.match(/```(?:json)?\s*([\s\S]*?)```/i);
145
150
  if (fenceMatch?.[1]) return fenceMatch[1].trim();
146
151
  const start = raw.indexOf("{");
@@ -149,7 +154,15 @@ function extractJsonObject(raw: string): string {
149
154
  return raw.trim();
150
155
  }
151
156
 
152
- function parseGeneratedAgentSpec(raw: string): GeneratedAgentSpec {
157
+ /**
158
+ * Parse model output into a {@link GeneratedAgentSpec}. Accepts a bare JSON object
159
+ * (tried first) or one wrapped in a code fence. Returns the spec with trimmed fields.
160
+ * Throws if the output is not valid JSON or not an object, if `identifier`, `whenToUse`
161
+ * or `systemPrompt` is missing or not a string, if the identifier is not lowercase
162
+ * kebab-case with 2+ words, if `whenToUse` does not start with "Use this agent when",
163
+ * or if `systemPrompt` is empty.
164
+ */
165
+ export function parseGeneratedAgentSpec(raw: string): GeneratedAgentSpec {
153
166
  const parsed = JSON.parse(extractJsonObject(raw)) as Partial<GeneratedAgentSpec>;
154
167
  if (!parsed || typeof parsed !== "object") {
155
168
  throw new Error("Model output is not a JSON object");
@@ -1,5 +1,5 @@
1
1
  import {
2
- type Component,
2
+ type Focusable,
3
3
  Editor,
4
4
  Ellipsis,
5
5
  matchesKey,
@@ -11,6 +11,8 @@ import {
11
11
  visibleWidth,
12
12
  wrapTextWithAnsi,
13
13
  } from "../index";
14
+ import { compositeLineAt } from "../render/composite";
15
+ import { wrapLiteralLine } from "../utils";
14
16
  import { appKey, editorKey } from "../chrome/keybinding-hints";
15
17
  import { formatKeyHint, formatKeyHints, type KeybindingsManager } from "../app-keybindings";
16
18
  import type { Keybinding } from "../keybindings";
@@ -94,7 +96,8 @@ interface CommittedTextAnnotation {
94
96
  interface RenderedDiffBody {
95
97
  lines: string[];
96
98
  renderedRowBySource: number[];
97
- selectedSourceLines: string[];
99
+ sourceIndexByRenderedRow: Array<number | undefined>;
100
+ selectedSourceLines: Array<string | undefined>;
98
101
  }
99
102
 
100
103
  interface RenderedBody {
@@ -145,7 +148,8 @@ function isTextSource(value: readonly ReviewDiffFile[] | TextReviewSource): valu
145
148
  }
146
149
 
147
150
  /** A fullscreen, annotated diff picker that only relies on public OMP APIs. */
148
- export class AnnotationOverlay implements Component {
151
+ export class AnnotationOverlay implements Focusable {
152
+ focused = false;
149
153
  #scrollView: ScrollView;
150
154
  #editor: Editor;
151
155
  #focus: FocusRegion = "files";
@@ -167,9 +171,11 @@ export class AnnotationOverlay implements Component {
167
171
  #actions: readonly string[] = CODE_REVIEW_ACTIONS;
168
172
  #textSource: TextReviewSource | undefined;
169
173
  #textLines: readonly string[] = [];
170
- #textViewportDriven = false;
171
- #textRenderedRowBySource: readonly number[] = [];
172
- #staticRenderedDiffBodies = new WeakMap<ReviewDiffFile, RenderedDiffBody>();
174
+ #viewportDriven = false;
175
+ /** Last viewport move went toward the end, so a viewport resting at its maximum offset selects the last visible row. */
176
+ #viewportTowardEnd = false;
177
+ #renderedRowBySource: readonly number[] = [];
178
+ #staticRenderedDiffBodies = new WeakMap<ReviewDiffFile, { width: number; body: RenderedDiffBody }>();
173
179
  /** Bumped on every annotation change (each one pushes an undo snapshot) and on undo. */
174
180
  #annotationRev = 0;
175
181
  #nativeBody: NativeBody | undefined;
@@ -266,15 +272,16 @@ export class AnnotationOverlay implements Component {
266
272
  });
267
273
  this.#editor.setBorderVisible(false);
268
274
  this.#editor.setPromptGutter("> ");
269
- this.#editor.setUseTerminalCursor(false);
270
- // Keep the editor's CURSOR_MARKER for terminal cursor placement, but
271
- // replace its visible end-of-input caret with a stable blank cell.
272
- this.#editor.cursorOverride = " ";
273
275
  this.#editor.setScrollbarVisible(true);
274
276
  this.#editor.onSubmit = value => this.#commitAnnotation(value);
275
277
  this.#resetSourceCursor();
276
278
  }
277
279
 
280
+ /** Keep the nested editor's cursor mode aligned with the overlay focus target. */
281
+ setUseTerminalCursor(useTerminalCursor: boolean): void {
282
+ this.#editor.setUseTerminalCursor(useTerminalCursor);
283
+ }
284
+
278
285
  invalidate(): void {
279
286
  this.#staticRenderedDiffBodies = new WeakMap();
280
287
  }
@@ -417,34 +424,48 @@ export class AnnotationOverlay implements Component {
417
424
  return;
418
425
  }
419
426
  if (this.#keybindings.matches(data, "tui.select.pageUp")) {
420
- if (this.#textSource) {
421
- this.#scrollView.page(-1);
422
- this.#textViewportDriven = true;
423
- this.#syncTextCursorToViewport();
424
- } else {
427
+ if (this.#tui.nativeRendering && !this.#textSource) {
425
428
  this.#moveSourceCursor(-Math.max(1, this.#bodyHeight - 1));
429
+ } else {
430
+ this.#scrollView.page(-1);
431
+ this.#viewportDriven = true;
432
+ this.#viewportTowardEnd = false;
433
+ this.#syncCursorToViewport();
426
434
  }
427
435
  return;
428
436
  }
429
437
  if (this.#keybindings.matches(data, "tui.select.pageDown")) {
430
- if (this.#textSource) {
431
- this.#scrollView.page(1);
432
- this.#textViewportDriven = true;
433
- this.#syncTextCursorToViewport();
434
- } else {
438
+ if (this.#tui.nativeRendering && !this.#textSource) {
435
439
  this.#moveSourceCursor(Math.max(1, this.#bodyHeight - 1));
440
+ } else {
441
+ this.#scrollView.page(1);
442
+ this.#viewportDriven = true;
443
+ this.#viewportTowardEnd = true;
444
+ this.#syncCursorToViewport();
436
445
  }
437
446
  return;
438
447
  }
439
448
  if (data === "g" || matchesKey(data, "home")) {
440
449
  this.#sourceIndex = 0;
441
- this.#textViewportDriven = false;
450
+ if (this.#textSource || this.#tui.nativeRendering) {
451
+ this.#viewportDriven = false;
452
+ } else {
453
+ this.#scrollView.scrollToTop();
454
+ this.#viewportDriven = true;
455
+ this.#viewportTowardEnd = false;
456
+ }
442
457
  } else if (data === "G" || matchesKey(data, "end")) {
443
458
  this.#sourceIndex = Math.max(
444
459
  0,
445
460
  (this.#textSource ? this.#textLines.length : this.#currentSourceRows().length) - 1,
446
461
  );
447
- this.#textViewportDriven = false;
462
+ if (this.#textSource || this.#tui.nativeRendering) {
463
+ this.#viewportDriven = false;
464
+ } else {
465
+ this.#scrollView.scrollToBottom();
466
+ this.#viewportDriven = true;
467
+ this.#viewportTowardEnd = true;
468
+ }
448
469
  }
449
470
  }
450
471
 
@@ -501,6 +522,7 @@ export class AnnotationOverlay implements Component {
501
522
 
502
523
  #resetSourceCursor(): void {
503
524
  this.#sourceIndex = 0;
525
+ this.#viewportDriven = false;
504
526
  }
505
527
 
506
528
  #currentFile(): ReviewDiffFile | undefined {
@@ -511,20 +533,20 @@ export class AnnotationOverlay implements Component {
511
533
  const file = this.#currentFile();
512
534
  return file ? file.rows.filter(isSourceRow) : [];
513
535
  }
514
- #syncTextCursorToViewport(): void {
515
- if (!this.#textSource || this.#textRenderedRowBySource.length === 0) return;
536
+ #syncCursorToViewport(): void {
537
+ if (this.#renderedRowBySource.length === 0) return;
516
538
  const viewportTop = this.#scrollView.getScrollOffset();
517
539
  const viewportBottom = viewportTop + Math.max(0, this.#bodyHeight - 1);
518
540
  let topSourceIndex = 0;
519
541
  let lastVisibleSourceIndex = 0;
520
- for (let index = 1; index < this.#textRenderedRowBySource.length; index++) {
521
- const renderedRow = this.#textRenderedRowBySource[index];
542
+ for (let index = 1; index < this.#renderedRowBySource.length; index++) {
543
+ const renderedRow = this.#renderedRowBySource[index];
522
544
  if (renderedRow === undefined || renderedRow > viewportBottom) break;
523
545
  lastVisibleSourceIndex = index;
524
546
  if (renderedRow <= viewportTop) topSourceIndex = index;
525
547
  }
526
- const atBottom =
527
- this.#scrollView.getMaxScrollOffset() > 0 && viewportTop === this.#scrollView.getMaxScrollOffset();
548
+ // A short pane has no scroll range, so paging toward the end must still reach the last row.
549
+ const atBottom = this.#viewportTowardEnd && viewportTop >= this.#scrollView.getMaxScrollOffset();
528
550
  this.#sourceIndex = atBottom ? lastVisibleSourceIndex : topSourceIndex;
529
551
  }
530
552
 
@@ -532,7 +554,7 @@ export class AnnotationOverlay implements Component {
532
554
  const rowCount = this.#textSource ? this.#textLines.length : this.#currentSourceRows().length;
533
555
  if (rowCount === 0) return;
534
556
  this.#sourceIndex = Math.max(0, Math.min(rowCount - 1, this.#sourceIndex + delta));
535
- if (this.#textSource) this.#textViewportDriven = false;
557
+ this.#viewportDriven = false;
536
558
  }
537
559
 
538
560
  #startAnnotation(scope: AnnotationScope, existingIndex?: number): void {
@@ -809,7 +831,7 @@ export class AnnotationOverlay implements Component {
809
831
  lines: [this.#theme.fg("dim", "No reviewable files")],
810
832
  renderedRowBySource: [],
811
833
  };
812
- const staticBody = this.#getStaticRenderedBody(file);
834
+ const staticBody = this.#getStaticRenderedBody(file, contentWidth);
813
835
  const lines: string[] = [];
814
836
  const renderedRowBySource: number[] = [];
815
837
 
@@ -821,12 +843,10 @@ export class AnnotationOverlay implements Component {
821
843
  }
822
844
  }
823
845
 
824
- let sourceIndex = 0;
825
846
  for (let staticRow = 0; staticRow < staticBody.lines.length; staticRow++) {
826
- const currentSourceIndex =
827
- staticBody.renderedRowBySource[sourceIndex] === staticRow ? sourceIndex++ : undefined;
847
+ const currentSourceIndex = staticBody.sourceIndexByRenderedRow[staticRow];
828
848
  let renderedLine = staticBody.lines[staticRow] ?? "";
829
- if (currentSourceIndex !== undefined) {
849
+ if (currentSourceIndex !== undefined && staticBody.renderedRowBySource[currentSourceIndex] === staticRow) {
830
850
  // Line notes belong immediately before their source row. This keeps
831
851
  // the selected row and all notes in a stable, readable order.
832
852
  for (const entry of this.#annotations) {
@@ -839,12 +859,10 @@ export class AnnotationOverlay implements Component {
839
859
  }
840
860
  }
841
861
  renderedRowBySource[currentSourceIndex] = lines.length;
842
- if (this.#focus === "diff" && currentSourceIndex === this.#sourceIndex) {
843
- renderedLine = this.#theme.bg(
844
- "selectedBg",
845
- fit(staticBody.selectedSourceLines[currentSourceIndex] ?? "", contentWidth),
846
- );
847
- }
862
+ }
863
+ if (this.#focus === "diff" && currentSourceIndex === this.#sourceIndex) {
864
+ renderedLine = staticBody.selectedSourceLines[staticRow] ?? renderedLine;
865
+ renderedLine = this.#theme.bg("selectedBg", fit(renderedLine, contentWidth));
848
866
  }
849
867
  lines.push(truncateToWidth(renderedLine, contentWidth));
850
868
  }
@@ -870,8 +888,7 @@ export class AnnotationOverlay implements Component {
870
888
  }
871
889
  renderedRowBySource[sourceIndex] = lines.length;
872
890
  const displayLine = replaceTabs(sanitizeText(sourceLine));
873
- const wrapped = wrapTextWithAnsi(displayLine, textWidth);
874
- const visualRows = wrapped.length > 0 ? wrapped : [""];
891
+ const visualRows = wrapLiteralLine(displayLine, textWidth);
875
892
  for (const [rowIndex, visualRow] of visualRows.entries()) {
876
893
  const selected = this.#focus === "diff" && sourceIndex === this.#sourceIndex;
877
894
  const gutter =
@@ -885,47 +902,63 @@ export class AnnotationOverlay implements Component {
885
902
  return { lines, renderedRowBySource };
886
903
  }
887
904
 
888
- #getStaticRenderedBody(file: ReviewDiffFile): RenderedDiffBody {
905
+ #getStaticRenderedBody(file: ReviewDiffFile, contentWidth: number): RenderedDiffBody {
906
+ const normalizedWidth = Math.max(1, Math.floor(contentWidth));
889
907
  const cached = this.#staticRenderedDiffBodies.get(file);
890
- if (cached) return cached;
908
+ if (cached?.width === normalizedWidth) return cached.body;
891
909
  const lines: string[] = [];
892
910
  const renderedRowBySource: number[] = [];
893
- const selectedSourceLines: string[] = [];
911
+ const sourceIndexByRenderedRow: Array<number | undefined> = [];
912
+ const selectedSourceLines: Array<string | undefined> = [];
913
+ const pushStaticLine = (line: string) => {
914
+ lines.push(line);
915
+ sourceIndexByRenderedRow.push(undefined);
916
+ selectedSourceLines.push(undefined);
917
+ };
894
918
  if (file.isBinary) {
895
- lines.push(this.#theme.fg("dim", "Binary diff; no annotatable source rows"));
919
+ pushStaticLine(this.#theme.fg("dim", "Binary diff; no annotatable source rows"));
896
920
  } else if (file.rows.length === 0) {
897
- lines.push(this.#theme.fg("dim", "No diff hunks; this may be a rename-only change"));
921
+ pushStaticLine(this.#theme.fg("dim", "No diff hunks; this may be a rename-only change"));
898
922
  } else {
899
923
  let sourceIndex = 0;
900
924
  for (const diffRow of file.rows) {
901
925
  if (!isSourceRow(diffRow)) {
902
- lines.push(
926
+ pushStaticLine(
903
927
  this.#theme.fg(diffRow.kind === "hunk" ? "accent" : "dim", replaceTabs(sanitizeText(diffRow.raw))),
904
928
  );
905
929
  continue;
906
930
  }
907
- renderedRowBySource[sourceIndex] = lines.length;
908
931
  const marker = diffRow.kind === "added" ? "+" : diffRow.kind === "removed" ? "-" : " ";
909
932
  const lineNumber = diffRow.kind === "removed" ? diffRow.oldLine : (diffRow.newLine ?? diffRow.oldLine);
910
- const suffix = `${marker}${String(lineNumber ?? "").padStart(4)} ${replaceTabs(sanitizeText(diffRow.content))}`;
911
- const color: DiffColor =
933
+ const prefix = `${marker}${String(lineNumber ?? "").padStart(4)} `;
934
+ const prefixWidth = visibleWidth(prefix);
935
+ const contentColor: DiffColor =
912
936
  diffRow.kind === "added"
913
937
  ? "toolDiffAdded"
914
938
  : diffRow.kind === "removed"
915
939
  ? "toolDiffRemoved"
916
940
  : "toolDiffContext";
917
- const coloredSuffix = this.#theme.fg(color, suffix);
918
- const gutter = " ".repeat(SOURCE_SELECTION_GUTTER_WIDTH);
919
- selectedSourceLines[sourceIndex] = `${fit(
920
- `${this.#theme.nav.cursor} `,
921
- SOURCE_SELECTION_GUTTER_WIDTH,
922
- )}${coloredSuffix}`;
923
- lines.push(`${gutter}${coloredSuffix}`);
941
+ const sourceContent = replaceTabs(sanitizeText(diffRow.content));
942
+ const contentWidthForRow = Math.max(1, normalizedWidth - SOURCE_SELECTION_GUTTER_WIDTH - prefixWidth);
943
+ const visualRows = wrapLiteralLine(sourceContent, contentWidthForRow);
944
+ const firstRenderedRow = lines.length;
945
+ renderedRowBySource[sourceIndex] = firstRenderedRow;
946
+ for (const [rowIndex, visualRow] of visualRows.entries()) {
947
+ const first = rowIndex === 0;
948
+ const gutter = " ".repeat(SOURCE_SELECTION_GUTTER_WIDTH);
949
+ const continuationPrefix = " ".repeat(prefixWidth);
950
+ const prefixForRow = first ? this.#theme.fg(contentColor, prefix) : continuationPrefix;
951
+ const selectedGutter = first ? fit(`${this.#theme.nav.cursor} `, SOURCE_SELECTION_GUTTER_WIDTH) : gutter;
952
+ const styledRow = this.#theme.fg(contentColor, visualRow);
953
+ lines.push(`${gutter}${prefixForRow}${styledRow}`);
954
+ sourceIndexByRenderedRow.push(sourceIndex);
955
+ selectedSourceLines.push(`${selectedGutter}${prefixForRow}${styledRow}`);
956
+ }
924
957
  sourceIndex++;
925
958
  }
926
959
  }
927
- const body = { lines, renderedRowBySource, selectedSourceLines };
928
- this.#staticRenderedDiffBodies.set(file, body);
960
+ const body = { lines, renderedRowBySource, sourceIndexByRenderedRow, selectedSourceLines };
961
+ this.#staticRenderedDiffBodies.set(file, { width: normalizedWidth, body });
929
962
  return body;
930
963
  }
931
964
 
@@ -1027,6 +1060,7 @@ export class AnnotationOverlay implements Component {
1027
1060
  }
1028
1061
 
1029
1062
  #renderFooter(width: number, maxChooserRows = Number.POSITIVE_INFINITY): string[] {
1063
+ this.#editor.focused = this.focused && this.#annotating;
1030
1064
  if (this.#annotationChooser) {
1031
1065
  return this.#renderAnnotationChooser(width, maxChooserRows);
1032
1066
  }
@@ -1044,7 +1078,6 @@ export class AnnotationOverlay implements Component {
1044
1078
  ];
1045
1079
  const externalEditorKey = appKey(this.#keybindings, "app.editor.external");
1046
1080
  if (externalEditorKey) hints.push(`${externalEditorKey} editor`);
1047
- this.#editor.focused = true;
1048
1081
  return [caption, ...this.#editor.render(width), this.#theme.fg("dim", hints.join(" · "))];
1049
1082
  }
1050
1083
  return [this.#theme.fg("dim", this.#helpText())];
@@ -1098,13 +1131,19 @@ export class AnnotationOverlay implements Component {
1098
1131
  return `${focusHelp} · ${formatKeyHint("u")} undo · ${formatKeyHint("tab")} regions · ${this.#key("tui.select.cancel")} cancel`;
1099
1132
  }
1100
1133
 
1101
- #ensureCursorVisible(renderedRowBySource: readonly number[]): void {
1102
- if (this.#textSource && this.#textViewportDriven) return;
1134
+ #ensureCursorVisible(renderedRowBySource: readonly number[]): boolean {
1135
+ this.#renderedRowBySource = renderedRowBySource;
1136
+ if (this.#viewportDriven) {
1137
+ const sourceIndex = this.#sourceIndex;
1138
+ if (!this.#annotating && !this.#annotationChooser) this.#syncCursorToViewport();
1139
+ return this.#sourceIndex !== sourceIndex;
1140
+ }
1103
1141
  const rowIndex = renderedRowBySource[this.#sourceIndex];
1104
- if (rowIndex === undefined) return;
1142
+ if (rowIndex === undefined) return false;
1105
1143
  const offset = this.#scrollView.getScrollOffset();
1106
1144
  if (rowIndex < offset) this.#scrollView.setScrollOffset(rowIndex);
1107
1145
  else if (rowIndex >= offset + this.#bodyHeight) this.#scrollView.setScrollOffset(rowIndex - this.#bodyHeight + 1);
1146
+ return false;
1108
1147
  }
1109
1148
 
1110
1149
  #sidebarWidth(width: number): number {
@@ -1117,19 +1156,31 @@ export class AnnotationOverlay implements Component {
1117
1156
  );
1118
1157
  }
1119
1158
 
1159
+ #sidebarStart(rows: number): number {
1160
+ return Math.max(0, Math.min(this.#fileIndex - Math.floor(rows / 2), Math.max(0, this.#files.length - rows)));
1161
+ }
1162
+
1163
+ #renderSidebarBadge(file: ReviewDiffFile, count: number): string {
1164
+ const changes = ` +${file.linesAdded}/-${file.linesRemoved}`;
1165
+ const annotations = count ? ` ✎${count}` : "";
1166
+ return this.#theme.fg("dim", changes) + (annotations ? this.#theme.fg("warning", annotations) : "");
1167
+ }
1168
+
1169
+ #sidebarLabelWidth(file: ReviewDiffFile, count: number, width: number): number {
1170
+ const badge = this.#renderSidebarBadge(file, count);
1171
+ return Math.max(0, width - visibleWidth(badge) - 2);
1172
+ }
1173
+
1120
1174
  #renderSidebar(rows: number, width: number): string[] {
1121
- const start = Math.max(
1122
- 0,
1123
- Math.min(this.#fileIndex - Math.floor(rows / 2), Math.max(0, this.#files.length - rows)),
1124
- );
1175
+ const start = this.#sidebarStart(rows);
1125
1176
  return Array.from({ length: rows }, (_, rowIndex) => {
1126
1177
  const index = start + rowIndex;
1127
1178
  const file = this.#files[index];
1128
1179
  if (!file) return "";
1129
1180
  const selected = index === this.#fileIndex;
1130
1181
  const count = this.#annotationCount(index);
1131
- const badge = `${this.#theme.fg("dim", ` +${file.linesAdded}/-${file.linesRemoved}`)}${count ? this.#theme.fg("warning", ` ✎${count}`) : ""}`;
1132
- const available = Math.max(0, width - visibleWidth(badge) - 2);
1182
+ const badge = this.#renderSidebarBadge(file, count);
1183
+ const available = this.#sidebarLabelWidth(file, count, width);
1133
1184
  const label = truncateToWidth(sanitizeStatusText(displayFileLabel(file)), available, Ellipsis.Unicode);
1134
1185
  const cursor = selected ? (this.#focus === "files" ? "› " : "▎ ") : " ";
1135
1186
  const line = fit(`${cursor}${label}${badge}`, width);
@@ -1139,13 +1190,59 @@ export class AnnotationOverlay implements Component {
1139
1190
  });
1140
1191
  }
1141
1192
 
1193
+ #revealSelectedFile(output: string[], width: number): void {
1194
+ const file = this.#currentFile();
1195
+ if (
1196
+ !file ||
1197
+ !this.#sidebarShown ||
1198
+ this.#textSource ||
1199
+ this.#focus !== "files" ||
1200
+ this.#annotating ||
1201
+ this.#annotationChooser
1202
+ ) {
1203
+ return;
1204
+ }
1205
+
1206
+ const pathColumn = 4;
1207
+ const lineWidth = Math.max(0, width - pathColumn - 1);
1208
+ if (lineWidth === 0) return;
1209
+
1210
+ const selectedRow = 1 + this.#fileIndex - this.#sidebarStart(this.#bodyHeight + 1);
1211
+ const interiorTop = 1;
1212
+ const lastBodyRow = this.#bodyHeight + 1;
1213
+ const interiorRows = lastBodyRow - interiorTop + 1;
1214
+ if (selectedRow < interiorTop || selectedRow > lastBodyRow || interiorRows <= 0) return;
1215
+
1216
+ const label = replaceTabs(sanitizeStatusText(displayFileLabel(file)));
1217
+ if (!label) return;
1218
+ const count = this.#annotationCount(this.#fileIndex);
1219
+ if (visibleWidth(label) <= this.#sidebarLabelWidth(file, count, this.#sidebarWidth(width))) return;
1220
+
1221
+ let wrapped = wrapTextWithAnsi(label, lineWidth);
1222
+ if (wrapped.length > interiorRows) {
1223
+ wrapped = wrapped.slice(-interiorRows);
1224
+ wrapped[0] = truncateToWidth(`…${wrapped[0] ?? ""}`, lineWidth, Ellipsis.Omit);
1225
+ }
1226
+ const overlayTop = Math.max(interiorTop, Math.min(selectedRow, lastBodyRow - wrapped.length + 1));
1227
+ const overlayWidth = Math.min(lineWidth, Math.max(0, ...wrapped.map(visibleWidth)) + 1);
1228
+ for (const [index, line] of wrapped.entries()) {
1229
+ const highlighted = this.#theme.bg("selectedBg", this.#theme.bold(fit(line, overlayWidth)));
1230
+ const targetRow = overlayTop + index;
1231
+ const baseLine = output[targetRow];
1232
+ if (overlayWidth > 0 && baseLine !== undefined) {
1233
+ output[targetRow] = compositeLineAt(baseLine, highlighted, pathColumn, overlayWidth, width);
1234
+ }
1235
+ }
1236
+ }
1237
+
1142
1238
  describe(): NativeNode {
1143
1239
  // The terminal lays out the file sidebar; diffs always have one, text never does.
1144
1240
  this.#sidebarShown = !this.#textSource;
1145
1241
  if (!this.#sidebarShown && this.#focus === "files") this.#focus = "diff";
1146
1242
  // Page size for PgUp/PgDn, matching the rendered body height.
1147
- this.#bodyHeight = Math.max(MIN_BODY_ROWS, (process.stdout.rows || 40) - (this.#actions.length + 8));
1148
- this.#editor.focused = this.#annotating;
1243
+ const terminalHeight = this.#tui.terminal.rows;
1244
+ this.#bodyHeight = Math.max(MIN_BODY_ROWS, terminalHeight - (this.#actions.length + 8));
1245
+ this.#editor.focused = this.focused && this.#annotating;
1149
1246
  const body = this.#describeBodyItems();
1150
1247
  const chooser = this.#annotationChooser;
1151
1248
  const sig = [
@@ -1244,7 +1341,7 @@ export class AnnotationOverlay implements Component {
1244
1341
  if (index < 0 || index >= rowCount) return;
1245
1342
  this.#focus = "diff";
1246
1343
  this.#sourceIndex = index;
1247
- this.#textViewportDriven = false;
1344
+ this.#viewportDriven = false;
1248
1345
  if (activate) this.#focus = "actions";
1249
1346
  return;
1250
1347
  }
@@ -1445,14 +1542,14 @@ export class AnnotationOverlay implements Component {
1445
1542
  }
1446
1543
 
1447
1544
  render(width: number): readonly string[] {
1448
- const terminalHeight = process.stdout.rows || 40;
1545
+ const terminalHeight = this.#tui.terminal.rows;
1449
1546
  this.#sidebarShown = this.#textSource ? false : this.#canShowSidebar(width);
1450
1547
  if (!this.#sidebarShown && this.#focus === "files") this.#focus = "diff";
1451
1548
  const sidebarWidth = this.#sidebarShown ? this.#sidebarWidth(width) : 0;
1452
1549
  const innerWidth = Math.max(1, width - 4);
1453
1550
  const bodyWidth = this.#sidebarShown ? splitBodyWidth(width, sidebarWidth) : innerWidth;
1454
1551
  this.#editor.setMaxHeight(Math.max(1, Math.min(MAX_ANNOTATION_EDITOR_ROWS, terminalHeight - 12)));
1455
- this.#editor.focused = this.#annotating;
1552
+ this.#editor.focused = this.focused && this.#annotating;
1456
1553
  const chooserBaseRows = terminalHeight - (this.#actions.length + 7);
1457
1554
  const chooserFooterRows = this.#annotationChooser
1458
1555
  ? Math.max(0, Math.min(chooserBaseRows, Math.max(3, chooserBaseRows - MIN_BODY_ROWS)))
@@ -1463,14 +1560,15 @@ export class AnnotationOverlay implements Component {
1463
1560
  this.#bodyHeight = this.#annotationChooser
1464
1561
  ? Math.max(0, availableBodyRows)
1465
1562
  : Math.max(MIN_BODY_ROWS, availableBodyRows);
1466
- const renderedBody = this.#renderBody(this.#textSource ? Math.max(1, bodyWidth - 1) : bodyWidth);
1563
+ const bodyContentWidth = Math.max(1, bodyWidth - 1);
1564
+ let renderedBody = this.#renderBody(bodyContentWidth);
1467
1565
  this.#scrollView.setLines(renderedBody.lines);
1468
1566
  this.#scrollView.setHeight(this.#bodyHeight);
1469
- if (this.#textSource) {
1470
- this.#textRenderedRowBySource = renderedBody.renderedRowBySource;
1471
- if (this.#textViewportDriven) this.#syncTextCursorToViewport();
1567
+ if (this.#ensureCursorVisible(renderedBody.renderedRowBySource)) {
1568
+ renderedBody = this.#renderBody(bodyContentWidth);
1569
+ this.#scrollView.setLines(renderedBody.lines);
1570
+ this.#renderedRowBySource = renderedBody.renderedRowBySource;
1472
1571
  }
1473
- this.#ensureCursorVisible(renderedBody.renderedRowBySource);
1474
1572
  const body = this.#scrollView.render(bodyWidth);
1475
1573
  const output: string[] = [];
1476
1574
  if (this.#sidebarShown) {
@@ -1492,6 +1590,7 @@ export class AnnotationOverlay implements Component {
1492
1590
  output.push(divider(width));
1493
1591
  for (const footerLine of footer) output.push(row(footerLine, width));
1494
1592
  output.push(bottomBorder(width));
1593
+ this.#revealSelectedFile(output, width);
1495
1594
  return output;
1496
1595
  }
1497
1596
  }