@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.
- package/CHANGELOG.md +26 -58
- package/dist/types/app-keybindings.d.ts +1 -1
- package/dist/types/autocomplete.d.ts +5 -2
- package/dist/types/charts/chart-plan.d.ts +92 -0
- package/dist/types/charts/chart-svg.d.ts +30 -0
- package/dist/types/charts/table-data.d.ts +107 -0
- package/dist/types/chat/assistant-message.d.ts +4 -0
- package/dist/types/chat/chat-transcript-builder.d.ts +2 -0
- package/dist/types/chat/figure-markdown.d.ts +42 -0
- package/dist/types/chat/svg-figure.d.ts +33 -0
- package/dist/types/chat/svg-source.d.ts +49 -0
- package/dist/types/chat/table-chart.d.ts +88 -0
- package/dist/types/components/editor.d.ts +1 -1
- package/dist/types/components/image.d.ts +9 -0
- package/dist/types/components/markdown.d.ts +9 -0
- package/dist/types/fuzzy.d.ts +14 -0
- package/dist/types/keybindings.d.ts +1 -1
- package/dist/types/overlays/agents-hub.d.ts +9 -0
- package/dist/types/overlays/annotation-overlay.d.ts +5 -2
- package/dist/types/overlays/model-browser.d.ts +25 -3
- package/dist/types/overlays/plan-review-overlay.d.ts +3 -2
- package/dist/types/prompt/interactive-context-helpers.d.ts +2 -0
- package/dist/types/prompt/model-mention-autocomplete.d.ts +1 -1
- package/dist/types/render/composite.d.ts +2 -0
- package/dist/types/tools/glob.d.ts +4 -0
- package/dist/types/utils.d.ts +6 -0
- package/package.json +9 -9
- package/src/autocomplete.ts +8 -10
- package/src/charts/chart-plan.ts +367 -0
- package/src/charts/chart-svg.ts +693 -0
- package/src/charts/table-data.ts +436 -0
- package/src/chat/assistant-message.ts +139 -21
- package/src/chat/chat-transcript-builder.ts +4 -0
- package/src/chat/figure-markdown.ts +174 -0
- package/src/chat/svg-figure.ts +364 -0
- package/src/chat/svg-source.ts +188 -0
- package/src/chat/table-chart.ts +301 -0
- package/src/chrome/transcript-container.ts +7 -1
- package/src/components/editor.ts +52 -30
- package/src/components/image.ts +27 -1
- package/src/components/markdown.ts +6 -3
- package/src/fuzzy.ts +59 -19
- package/src/keybindings.ts +1 -1
- package/src/overlays/agent-transcript-viewer.ts +2 -0
- package/src/overlays/agents-hub.ts +14 -1
- package/src/overlays/annotation-overlay.ts +179 -80
- package/src/overlays/ask-dialog.ts +1 -1
- package/src/overlays/model-browser.ts +169 -76
- package/src/overlays/plan-review-overlay.ts +41 -4
- package/src/prompt/composer.ts +2 -14
- package/src/prompt/interactive-context-helpers.ts +3 -0
- package/src/prompt/model-mention-autocomplete.ts +14 -14
- package/src/render/composite.ts +62 -0
- package/src/status-line/segments.ts +3 -1
- package/src/tools/glob.ts +23 -6
- package/src/tui.ts +118 -105
- 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
|
|
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
|
|
327
|
-
* same query for every candidate
|
|
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
|
-
|
|
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 (
|
|
363
|
-
const match = scoreToken(
|
|
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
|
-
|
|
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[] {
|
package/src/keybindings.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
171
|
-
|
|
172
|
-
#
|
|
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.#
|
|
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.#
|
|
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
|
-
#
|
|
515
|
-
if (
|
|
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.#
|
|
521
|
-
const renderedRow = this.#
|
|
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
|
-
|
|
527
|
-
|
|
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
|
-
|
|
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
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
919
|
+
pushStaticLine(this.#theme.fg("dim", "Binary diff; no annotatable source rows"));
|
|
896
920
|
} else if (file.rows.length === 0) {
|
|
897
|
-
|
|
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
|
-
|
|
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
|
|
911
|
-
const
|
|
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
|
|
918
|
-
const
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
)
|
|
923
|
-
|
|
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[]):
|
|
1102
|
-
|
|
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 =
|
|
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 =
|
|
1132
|
-
const available =
|
|
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
|
-
|
|
1148
|
-
this.#
|
|
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.#
|
|
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 =
|
|
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
|
|
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.#
|
|
1470
|
-
|
|
1471
|
-
|
|
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
|
}
|