@crouton-kit/humanloop 0.4.4 → 0.4.6
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/dist/editor/parked.d.ts +16 -0
- package/dist/editor/parked.js +50 -0
- package/dist/editor/review.d.ts +2 -1
- package/dist/editor/review.js +13 -49
- package/dist/editor/terminal-review.d.ts +67 -28
- package/dist/editor/terminal-review.js +250 -89
- package/dist/render/termrender.d.ts +4 -0
- package/dist/render/termrender.js +46 -8
- package/dist/render/version.d.ts +1 -1
- package/dist/render/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { FeedbackResult } from '../types.js';
|
|
2
|
+
export type ParkedReviewAction = {
|
|
3
|
+
type: 'submitted';
|
|
4
|
+
result: FeedbackResult;
|
|
5
|
+
} | {
|
|
6
|
+
type: 'take-back';
|
|
7
|
+
} | {
|
|
8
|
+
type: 'cancel';
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Park the terminal while the browser owns the review. Watches raw stdin for
|
|
12
|
+
* `w` (take back) and Ctrl+C (cancel), racing those against the browser's
|
|
13
|
+
* submit promise. `takeBackTarget` names the surface `w` returns to in the
|
|
14
|
+
* parked notice (e.g. "nvim", "the terminal review").
|
|
15
|
+
*/
|
|
16
|
+
export declare function waitForParkedReviewSubmit(submitted: Promise<FeedbackResult>, signal: AbortSignal | undefined, takeBackTarget: string): Promise<ParkedReviewAction>;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Park the terminal while the browser owns the review. Watches raw stdin for
|
|
3
|
+
* `w` (take back) and Ctrl+C (cancel), racing those against the browser's
|
|
4
|
+
* submit promise. `takeBackTarget` names the surface `w` returns to in the
|
|
5
|
+
* parked notice (e.g. "nvim", "the terminal review").
|
|
6
|
+
*/
|
|
7
|
+
export async function waitForParkedReviewSubmit(submitted, signal, takeBackTarget) {
|
|
8
|
+
process.stderr.write('\nhumanloop: browser review handoff is active.\n' +
|
|
9
|
+
' The terminal surface is parked; the browser is the editing authority.\n' +
|
|
10
|
+
` Press w to take back into ${takeBackTarget}, or Ctrl+C to exit with an unsubmitted draft.\n\n`);
|
|
11
|
+
return new Promise((resolveAction) => {
|
|
12
|
+
const stdin = process.stdin;
|
|
13
|
+
const interactive = stdin.isTTY;
|
|
14
|
+
const wasRaw = stdin.isRaw;
|
|
15
|
+
let settled = false;
|
|
16
|
+
const cleanup = () => {
|
|
17
|
+
signal?.removeEventListener('abort', onAbort);
|
|
18
|
+
if (!interactive)
|
|
19
|
+
return;
|
|
20
|
+
stdin.off('data', onData);
|
|
21
|
+
if (stdin.setRawMode)
|
|
22
|
+
stdin.setRawMode(wasRaw);
|
|
23
|
+
stdin.pause();
|
|
24
|
+
};
|
|
25
|
+
const finish = (action) => {
|
|
26
|
+
if (settled)
|
|
27
|
+
return;
|
|
28
|
+
settled = true;
|
|
29
|
+
cleanup();
|
|
30
|
+
resolveAction(action);
|
|
31
|
+
};
|
|
32
|
+
const onAbort = () => finish({ type: 'cancel' });
|
|
33
|
+
const onData = (chunk) => {
|
|
34
|
+
const text = chunk.toString('utf8');
|
|
35
|
+
if (text === 'w' || text === 'W')
|
|
36
|
+
finish({ type: 'take-back' });
|
|
37
|
+
if (text === '\u0003')
|
|
38
|
+
finish({ type: 'cancel' });
|
|
39
|
+
};
|
|
40
|
+
if (interactive) {
|
|
41
|
+
stdin.setRawMode(true);
|
|
42
|
+
stdin.resume();
|
|
43
|
+
stdin.on('data', onData);
|
|
44
|
+
}
|
|
45
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
46
|
+
if (signal?.aborted)
|
|
47
|
+
onAbort();
|
|
48
|
+
submitted.then((result) => finish({ type: 'submitted', result }), () => finish({ type: 'cancel' }));
|
|
49
|
+
});
|
|
50
|
+
}
|
package/dist/editor/review.d.ts
CHANGED
|
@@ -12,7 +12,8 @@ export interface ReviewOptions {
|
|
|
12
12
|
onClose?: () => Promise<void> | void;
|
|
13
13
|
/** Controller cancellation closes the editor/browser handoff without proposing a result. */
|
|
14
14
|
signal?: AbortSignal;
|
|
15
|
-
/** Reuse this controller-owned directory for `review.vim
|
|
15
|
+
/** Reuse this controller-owned directory for review artifacts (`review.vim`,
|
|
16
|
+
* the browser-handoff job id); an existing script is left untouched. */
|
|
16
17
|
jobDir?: string;
|
|
17
18
|
}
|
|
18
19
|
export declare function reviewVimscript(): string;
|
package/dist/editor/review.js
CHANGED
|
@@ -6,6 +6,7 @@ import { openBrowser } from '../browser/open.js';
|
|
|
6
6
|
import { startReviewWebServer } from '../browser/server.js';
|
|
7
7
|
import { buildFinalFeedbackResult, readReviewDraft, } from './feedback.js';
|
|
8
8
|
import { launchTerminalReview } from './terminal-review.js';
|
|
9
|
+
import { waitForParkedReviewSubmit } from './parked.js';
|
|
9
10
|
function resolveEditor(override) {
|
|
10
11
|
const candidates = override ? [override] : ['nvim', 'vim'];
|
|
11
12
|
for (const bin of candidates) {
|
|
@@ -551,50 +552,6 @@ function draftFeedback(path, absFile) {
|
|
|
551
552
|
savedAt: draft?.savedAt ?? new Date().toISOString(),
|
|
552
553
|
};
|
|
553
554
|
}
|
|
554
|
-
async function waitForParkedReviewSubmit(submitted, signal) {
|
|
555
|
-
process.stderr.write('\nhumanloop: browser review handoff is active.\n' +
|
|
556
|
-
' The terminal editor is parked; the browser is the editing authority.\n' +
|
|
557
|
-
' Press w to take back into nvim, or Ctrl+C to exit with an unsubmitted draft.\n\n');
|
|
558
|
-
return new Promise((resolveAction) => {
|
|
559
|
-
const stdin = process.stdin;
|
|
560
|
-
const interactive = stdin.isTTY;
|
|
561
|
-
const wasRaw = stdin.isRaw;
|
|
562
|
-
let settled = false;
|
|
563
|
-
const cleanup = () => {
|
|
564
|
-
signal?.removeEventListener('abort', onAbort);
|
|
565
|
-
if (!interactive)
|
|
566
|
-
return;
|
|
567
|
-
stdin.off('data', onData);
|
|
568
|
-
if (stdin.setRawMode)
|
|
569
|
-
stdin.setRawMode(wasRaw);
|
|
570
|
-
stdin.pause();
|
|
571
|
-
};
|
|
572
|
-
const finish = (action) => {
|
|
573
|
-
if (settled)
|
|
574
|
-
return;
|
|
575
|
-
settled = true;
|
|
576
|
-
cleanup();
|
|
577
|
-
resolveAction(action);
|
|
578
|
-
};
|
|
579
|
-
const onAbort = () => finish({ type: 'cancel' });
|
|
580
|
-
const onData = (chunk) => {
|
|
581
|
-
const text = chunk.toString('utf8');
|
|
582
|
-
if (text === 'w' || text === 'W')
|
|
583
|
-
finish({ type: 'take-back' });
|
|
584
|
-
if (text === '\u0003')
|
|
585
|
-
finish({ type: 'cancel' });
|
|
586
|
-
};
|
|
587
|
-
if (interactive) {
|
|
588
|
-
stdin.setRawMode(true);
|
|
589
|
-
stdin.resume();
|
|
590
|
-
stdin.on('data', onData);
|
|
591
|
-
}
|
|
592
|
-
signal?.addEventListener('abort', onAbort, { once: true });
|
|
593
|
-
if (signal?.aborted)
|
|
594
|
-
onAbort();
|
|
595
|
-
submitted.then((result) => finish({ type: 'submitted', result }), () => finish({ type: 'cancel' }));
|
|
596
|
-
});
|
|
597
|
-
}
|
|
598
555
|
async function stopReviewServer(handle) {
|
|
599
556
|
if (handle !== null)
|
|
600
557
|
await handle.stop();
|
|
@@ -690,12 +647,16 @@ async function launchNeovimReview(file, opts) {
|
|
|
690
647
|
server.activate();
|
|
691
648
|
process.stderr.write(`humanloop: browser review handoff active — ${server.url}\n`);
|
|
692
649
|
openBrowser(server.url);
|
|
693
|
-
const action = await waitForParkedReviewSubmit(submitted, opts.signal);
|
|
650
|
+
const action = await waitForParkedReviewSubmit(submitted, opts.signal, 'nvim');
|
|
694
651
|
if (action.type === 'submitted') {
|
|
695
652
|
await stopReviewServer(server);
|
|
696
653
|
clearFlag(handoffFlagPath);
|
|
697
|
-
|
|
698
|
-
|
|
654
|
+
// An abort landing during the async stop must not produce a submitted
|
|
655
|
+
// result whose canonical onPropose convergence was skipped — fall
|
|
656
|
+
// back to the disk draft (the browser submit already persisted it).
|
|
657
|
+
if (opts.signal?.aborted)
|
|
658
|
+
return draftFeedback(outPath, absFile);
|
|
659
|
+
await opts.onPropose?.(action.result);
|
|
699
660
|
return action.result;
|
|
700
661
|
}
|
|
701
662
|
if (action.type === 'take-back') {
|
|
@@ -711,9 +672,12 @@ async function launchNeovimReview(file, opts) {
|
|
|
711
672
|
}
|
|
712
673
|
await stopReviewServer(server);
|
|
713
674
|
if (existsSync(submitFlagPath)) {
|
|
675
|
+
// Same abort-during-stop race as the parked branch above: an aborted
|
|
676
|
+
// review must resolve as a draft, never as an unconverged proposal.
|
|
677
|
+
if (opts.signal?.aborted)
|
|
678
|
+
return draftFeedback(outPath, absFile);
|
|
714
679
|
const proposal = proposedFeedback(outPath, absFile);
|
|
715
|
-
|
|
716
|
-
await opts.onPropose?.(proposal);
|
|
680
|
+
await opts.onPropose?.(proposal);
|
|
717
681
|
return proposal;
|
|
718
682
|
}
|
|
719
683
|
return draftFeedback(outPath, absFile);
|
|
@@ -1,11 +1,29 @@
|
|
|
1
1
|
import type { FeedbackComment, FeedbackResult } from '../types.js';
|
|
2
2
|
import type { ReviewOptions } from './review.js';
|
|
3
3
|
import { type RenderedDoc } from '../render/termrender.js';
|
|
4
|
-
/**
|
|
5
|
-
|
|
4
|
+
/** One leaf anchor unit: the finest step j/k moves by. Consecutive rendered
|
|
5
|
+
* rows sharing one source span collapse into a single unit that knows both
|
|
6
|
+
* its source-line bounds and its rendered-row bounds. */
|
|
7
|
+
export interface AnchorUnit {
|
|
8
|
+
/** 1-indexed inclusive source-line bounds. */
|
|
6
9
|
start: number;
|
|
7
10
|
end: number;
|
|
11
|
+
/** 0-indexed inclusive rendered-row bounds in the mapped doc. */
|
|
12
|
+
firstRow: number;
|
|
13
|
+
lastRow: number;
|
|
8
14
|
}
|
|
15
|
+
/** The anchorable shape of a rendered doc: ordered leaf units plus the
|
|
16
|
+
* per-row owning-unit index the painter keys off. */
|
|
17
|
+
export interface AnchorDoc {
|
|
18
|
+
units: AnchorUnit[];
|
|
19
|
+
/** Per rendered row: index into `units`, or null (separator rows). */
|
|
20
|
+
rowUnits: (number | null)[];
|
|
21
|
+
}
|
|
22
|
+
/** Derive leaf anchor units from a mapped render: dedupe runs of consecutive
|
|
23
|
+
* rows carrying the identical source span. A mapped row without a leaf span
|
|
24
|
+
* (an unmapped block) anchors at its whole block's range, so every content
|
|
25
|
+
* row belongs to a unit; separator rows belong to none. */
|
|
26
|
+
export declare function deriveAnchorUnits(doc: RenderedDoc): AnchorDoc;
|
|
9
27
|
export interface ComposeState {
|
|
10
28
|
buffer: string;
|
|
11
29
|
cursor: number;
|
|
@@ -20,13 +38,15 @@ export interface ComposeState {
|
|
|
20
38
|
}
|
|
21
39
|
export interface ReviewState {
|
|
22
40
|
sourceLines: string[];
|
|
23
|
-
/**
|
|
24
|
-
|
|
41
|
+
/** Ordered leaf anchor units. Never empty. */
|
|
42
|
+
units: AnchorUnit[];
|
|
43
|
+
/** Per rendered row: owning unit index, or null for separator rows. */
|
|
44
|
+
rowUnits: (number | null)[];
|
|
25
45
|
comments: FeedbackComment[];
|
|
26
46
|
version: number;
|
|
27
|
-
/** 0-based index of the anchored
|
|
28
|
-
|
|
29
|
-
/** Set while a Shift+j/k range is being extended from this
|
|
47
|
+
/** 0-based index of the anchored unit. */
|
|
48
|
+
activeUnit: number;
|
|
49
|
+
/** Set while a Shift+j/k range is being extended from this unit. */
|
|
30
50
|
selectionAnchor: number | null;
|
|
31
51
|
/** Scroll offset into the rendered (termrender) lines. */
|
|
32
52
|
scroll: number;
|
|
@@ -34,25 +54,41 @@ export interface ReviewState {
|
|
|
34
54
|
compose: ComposeState | null;
|
|
35
55
|
listIndex: number;
|
|
36
56
|
}
|
|
37
|
-
/**
|
|
38
|
-
*
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
*
|
|
57
|
+
/** Best unit for a source line: the NARROWEST unit containing it — code/table
|
|
58
|
+
* chrome units carry their whole block's range and sit before the per-line
|
|
59
|
+
* content units in rendered order, so "first whose end reaches the line"
|
|
60
|
+
* would wrongly pick the chrome over the exact code line. A line no unit
|
|
61
|
+
* contains (a blank separator) snaps forward to the next unit; past the end
|
|
62
|
+
* clamps to the last. */
|
|
63
|
+
export declare function unitIndexForLine(units: AnchorUnit[], line: number): number;
|
|
64
|
+
/** Remap a unit from an old unit list into a re-derived one: an exact
|
|
65
|
+
* source-range match wins (chrome and leaf units can share source lines),
|
|
66
|
+
* else the best unit for its first line. */
|
|
67
|
+
export declare function remapUnitIndex(units: AnchorUnit[], prev: AnchorUnit): number;
|
|
68
|
+
/** First/last unit indices overlapping the source range `line..endLine`;
|
|
69
|
+
* a range touching no unit snaps like unitIndexForLine. */
|
|
70
|
+
export declare function unitRangeForSpan(units: AnchorUnit[], line: number, endLine: number): {
|
|
71
|
+
lo: number;
|
|
72
|
+
hi: number;
|
|
73
|
+
};
|
|
74
|
+
export declare function initReviewState(sourceLines: string[], anchor: AnchorDoc, comments: FeedbackComment[], version: number): ReviewState;
|
|
75
|
+
/** Source-line anchor of the current unit selection — what a committed
|
|
76
|
+
* comment records as `line`/`endLine`. Whole-block chrome units can carry a
|
|
77
|
+
* wider range than their neighbors, so take the min/max over the selection
|
|
78
|
+
* rather than trusting the endpoints alone. */
|
|
43
79
|
export declare function currentAnchor(state: ReviewState): {
|
|
44
80
|
line: number;
|
|
45
81
|
endLine: number;
|
|
46
82
|
};
|
|
47
|
-
export declare function
|
|
48
|
-
/**
|
|
49
|
-
* draft range lying wholly past the last
|
|
50
|
-
* save and reopen) still gets a marker on its clamped
|
|
83
|
+
export declare function moveActiveUnit(state: ReviewState, delta: number, extend: boolean): ReviewState;
|
|
84
|
+
/** Unit indices overlapped by any existing comment's source range. A stale
|
|
85
|
+
* draft range lying wholly past the last unit (the file shrank between draft
|
|
86
|
+
* save and reopen) still gets a marker on its clamped unit, so every comment
|
|
51
87
|
* is visible somewhere in the document. */
|
|
52
|
-
export declare function
|
|
53
|
-
/** First/last rendered-row indices
|
|
54
|
-
*
|
|
55
|
-
export declare function anchorRowRange(
|
|
88
|
+
export declare function commentedUnitSet(state: ReviewState): Set<number>;
|
|
89
|
+
/** First/last rendered-row indices spanned by units `lo..hi`, or null when
|
|
90
|
+
* the indices fall outside the unit list. */
|
|
91
|
+
export declare function anchorRowRange(units: AnchorUnit[], lo: number, hi: number): {
|
|
56
92
|
first: number;
|
|
57
93
|
last: number;
|
|
58
94
|
} | null;
|
|
@@ -63,7 +99,7 @@ export declare function anchorRowRange(rows: (number | null)[], lo: number, hi:
|
|
|
63
99
|
* overwrites the preceding row, never the pinned one. Always returns a scroll
|
|
64
100
|
* clamped to the document. */
|
|
65
101
|
export declare function scrollToReveal(scroll: number, first: number, last: number, bodyHeight: number, total: number, margin?: number): number;
|
|
66
|
-
/** Auto-scroll so the active
|
|
102
|
+
/** Auto-scroll so the active unit (the moving edge of a selection) is
|
|
67
103
|
* visible. Free-scrolling (`u`/`d`) is untouched until the next anchor move. */
|
|
68
104
|
export declare function ensureAnchorVisible(state: ReviewState, doc: RenderedDoc, bodyHeight: number): ReviewState;
|
|
69
105
|
export declare function scrollBy(state: ReviewState, delta: number, bodyHeight: number, totalLines: number): ReviewState;
|
|
@@ -105,11 +141,14 @@ export declare function renderReviewFrame(state: ReviewState, fileLabel: string,
|
|
|
105
141
|
/**
|
|
106
142
|
* Open a Markdown file in humanloop's own terminal review surface: the
|
|
107
143
|
* document renders via termrender (Mermaid included), the anchor is a
|
|
108
|
-
* highlighted
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
* the
|
|
113
|
-
*
|
|
144
|
+
* highlighted leaf unit — a bullet, table row, code line, paragraph — in the
|
|
145
|
+
* rendered document itself (gutter bar + tint via termrender's per-row source
|
|
146
|
+
* spans), and the human explicitly submits a proposal.
|
|
147
|
+
* `space w` hands the review off to the browser surface (same park/take-back
|
|
148
|
+
* semantics as the Neovim path); the draft round-trips between surfaces via
|
|
149
|
+
* `opts.output`. Never edits the source file. Autosaves the comment draft to
|
|
150
|
+
* `opts.output` (the `progress.json` convention). Canonical ticket
|
|
151
|
+
* finalization stays with the caller's `onPropose` (the adapter calls
|
|
152
|
+
* `completeReview`); this function never writes `response.json`.
|
|
114
153
|
*/
|
|
115
154
|
export declare function launchTerminalReview(file: string, opts: ReviewOptions): Promise<FeedbackResult>;
|
|
@@ -1,29 +1,116 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { readFileSync } from 'node:fs';
|
|
3
|
-
import {
|
|
2
|
+
import { mkdtempSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { basename, join, resolve } from 'node:path';
|
|
4
5
|
import stringWidth from 'string-width';
|
|
6
|
+
import { waitForParkedReviewSubmit } from './parked.js';
|
|
7
|
+
import { openBrowser } from '../browser/open.js';
|
|
8
|
+
import { startReviewWebServer } from '../browser/server.js';
|
|
5
9
|
import { buildDraftFeedbackResult, buildFinalFeedbackResult, readReviewDraft, writeReviewDraft } from './feedback.js';
|
|
6
10
|
import { renderMarkdownWithMap } from '../render/termrender.js';
|
|
7
11
|
import { setupTerminal, restoreTerminal, getTerminalSize, parseKeypress } from '../tui/terminal.js';
|
|
8
12
|
import { diffFrame, renderInputBuffer } from '../tui/render.js';
|
|
9
13
|
import { BOLD, CYAN, DIM, ESC, RESET, YELLOW, hline, sanitize, truncate } from '../tui/ansi.js';
|
|
10
|
-
/**
|
|
11
|
-
*
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
/** Derive leaf anchor units from a mapped render: dedupe runs of consecutive
|
|
15
|
+
* rows carrying the identical source span. A mapped row without a leaf span
|
|
16
|
+
* (an unmapped block) anchors at its whole block's range, so every content
|
|
17
|
+
* row belongs to a unit; separator rows belong to none. */
|
|
18
|
+
export function deriveAnchorUnits(doc) {
|
|
19
|
+
const units = [];
|
|
20
|
+
const rowUnits = new Array(doc.lines.length).fill(null);
|
|
21
|
+
for (let r = 0; r < doc.lines.length; r++) {
|
|
22
|
+
let span = doc.spans[r] ?? null;
|
|
23
|
+
if (span === null) {
|
|
24
|
+
const b = doc.rows[r] ?? null;
|
|
25
|
+
const block = b !== null ? doc.blocks[b] : undefined;
|
|
26
|
+
if (block === undefined)
|
|
27
|
+
continue;
|
|
28
|
+
span = [block.start, block.end];
|
|
29
|
+
}
|
|
30
|
+
const prev = units[units.length - 1];
|
|
31
|
+
if (prev !== undefined && prev.lastRow === r - 1 && prev.start === span[0] && prev.end === span[1]) {
|
|
32
|
+
prev.lastRow = r;
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
units.push({ start: span[0], end: span[1], firstRow: r, lastRow: r });
|
|
36
|
+
}
|
|
37
|
+
rowUnits[r] = units.length - 1;
|
|
38
|
+
}
|
|
39
|
+
if (units.length === 0) {
|
|
40
|
+
// Degenerate map (nothing anchorable) — one whole-doc unit over all rows.
|
|
41
|
+
units.push({
|
|
42
|
+
start: doc.blocks[0]?.start ?? 1,
|
|
43
|
+
end: doc.blocks[doc.blocks.length - 1]?.end ?? 1,
|
|
44
|
+
firstRow: 0,
|
|
45
|
+
lastRow: Math.max(0, doc.lines.length - 1),
|
|
46
|
+
});
|
|
47
|
+
for (let r = 0; r < rowUnits.length; r++)
|
|
48
|
+
rowUnits[r] = 0;
|
|
49
|
+
}
|
|
50
|
+
return { units, rowUnits };
|
|
51
|
+
}
|
|
52
|
+
/** Best unit for a source line: the NARROWEST unit containing it — code/table
|
|
53
|
+
* chrome units carry their whole block's range and sit before the per-line
|
|
54
|
+
* content units in rendered order, so "first whose end reaches the line"
|
|
55
|
+
* would wrongly pick the chrome over the exact code line. A line no unit
|
|
56
|
+
* contains (a blank separator) snaps forward to the next unit; past the end
|
|
57
|
+
* clamps to the last. */
|
|
58
|
+
export function unitIndexForLine(units, line) {
|
|
59
|
+
let best = -1;
|
|
60
|
+
for (let i = 0; i < units.length; i++) {
|
|
61
|
+
const u = units[i];
|
|
62
|
+
if (u.start <= line && u.end >= line) {
|
|
63
|
+
if (best === -1 || u.end - u.start < units[best].end - units[best].start)
|
|
64
|
+
best = i;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (best !== -1)
|
|
68
|
+
return best;
|
|
69
|
+
for (let i = 0; i < units.length; i++) {
|
|
70
|
+
if (units[i].start > line)
|
|
15
71
|
return i;
|
|
16
72
|
}
|
|
17
|
-
return Math.max(0,
|
|
73
|
+
return Math.max(0, units.length - 1);
|
|
74
|
+
}
|
|
75
|
+
/** Remap a unit from an old unit list into a re-derived one: an exact
|
|
76
|
+
* source-range match wins (chrome and leaf units can share source lines),
|
|
77
|
+
* else the best unit for its first line. */
|
|
78
|
+
export function remapUnitIndex(units, prev) {
|
|
79
|
+
for (let i = 0; i < units.length; i++) {
|
|
80
|
+
if (units[i].start === prev.start && units[i].end === prev.end)
|
|
81
|
+
return i;
|
|
82
|
+
}
|
|
83
|
+
return unitIndexForLine(units, prev.start);
|
|
84
|
+
}
|
|
85
|
+
/** First/last unit indices overlapping the source range `line..endLine`;
|
|
86
|
+
* a range touching no unit snaps like unitIndexForLine. */
|
|
87
|
+
export function unitRangeForSpan(units, line, endLine) {
|
|
88
|
+
let lo = -1;
|
|
89
|
+
let hi = -1;
|
|
90
|
+
units.forEach((u, i) => {
|
|
91
|
+
if (u.start <= endLine && u.end >= line) {
|
|
92
|
+
if (lo === -1)
|
|
93
|
+
lo = i;
|
|
94
|
+
hi = i;
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
if (lo === -1) {
|
|
98
|
+
const snap = unitIndexForLine(units, line);
|
|
99
|
+
return { lo: snap, hi: snap };
|
|
100
|
+
}
|
|
101
|
+
return { lo, hi };
|
|
18
102
|
}
|
|
19
|
-
export function initReviewState(sourceLines,
|
|
20
|
-
const
|
|
103
|
+
export function initReviewState(sourceLines, anchor, comments, version) {
|
|
104
|
+
const safe = anchor.units.length > 0
|
|
105
|
+
? anchor
|
|
106
|
+
: { units: [{ start: 1, end: Math.max(1, sourceLines.length), firstRow: 0, lastRow: 0 }], rowUnits: anchor.rowUnits };
|
|
21
107
|
return {
|
|
22
108
|
sourceLines,
|
|
23
|
-
|
|
109
|
+
units: safe.units,
|
|
110
|
+
rowUnits: safe.rowUnits,
|
|
24
111
|
comments: [...comments],
|
|
25
112
|
version,
|
|
26
|
-
|
|
113
|
+
activeUnit: comments.length > 0 ? unitIndexForLine(safe.units, comments[0].line) : 0,
|
|
27
114
|
selectionAnchor: null,
|
|
28
115
|
scroll: 0,
|
|
29
116
|
mode: 'view',
|
|
@@ -31,58 +118,63 @@ export function initReviewState(sourceLines, blocks, comments, version) {
|
|
|
31
118
|
listIndex: 0,
|
|
32
119
|
};
|
|
33
120
|
}
|
|
34
|
-
function
|
|
35
|
-
const sel = state.selectionAnchor ?? state.
|
|
36
|
-
return { lo: Math.min(state.
|
|
121
|
+
function selectedUnitBounds(state) {
|
|
122
|
+
const sel = state.selectionAnchor ?? state.activeUnit;
|
|
123
|
+
return { lo: Math.min(state.activeUnit, sel), hi: Math.max(state.activeUnit, sel) };
|
|
37
124
|
}
|
|
38
|
-
/** Source-line anchor of the current
|
|
39
|
-
* comment records as `line`/`endLine`.
|
|
125
|
+
/** Source-line anchor of the current unit selection — what a committed
|
|
126
|
+
* comment records as `line`/`endLine`. Whole-block chrome units can carry a
|
|
127
|
+
* wider range than their neighbors, so take the min/max over the selection
|
|
128
|
+
* rather than trusting the endpoints alone. */
|
|
40
129
|
export function currentAnchor(state) {
|
|
41
|
-
const { lo, hi } =
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
130
|
+
const { lo, hi } = selectedUnitBounds(state);
|
|
131
|
+
let line = Number.POSITIVE_INFINITY;
|
|
132
|
+
let endLine = 0;
|
|
133
|
+
for (let i = lo; i <= hi; i++) {
|
|
134
|
+
const u = state.units[i];
|
|
135
|
+
if (u === undefined)
|
|
136
|
+
continue;
|
|
137
|
+
line = Math.min(line, u.start);
|
|
138
|
+
endLine = Math.max(endLine, u.end);
|
|
139
|
+
}
|
|
140
|
+
if (!Number.isFinite(line))
|
|
141
|
+
return { line: 1, endLine: Math.max(1, state.sourceLines.length) };
|
|
142
|
+
return { line, endLine: Math.max(line, endLine) };
|
|
143
|
+
}
|
|
144
|
+
export function moveActiveUnit(state, delta, extend) {
|
|
145
|
+
const last = Math.max(0, state.units.length - 1);
|
|
146
|
+
const next = Math.max(0, Math.min(last, state.activeUnit + delta));
|
|
49
147
|
if (!extend)
|
|
50
|
-
return { ...state,
|
|
51
|
-
return { ...state,
|
|
148
|
+
return { ...state, activeUnit: next, selectionAnchor: null };
|
|
149
|
+
return { ...state, activeUnit: next, selectionAnchor: state.selectionAnchor ?? state.activeUnit };
|
|
52
150
|
}
|
|
53
|
-
/**
|
|
54
|
-
* draft range lying wholly past the last
|
|
55
|
-
* save and reopen) still gets a marker on its clamped
|
|
151
|
+
/** Unit indices overlapped by any existing comment's source range. A stale
|
|
152
|
+
* draft range lying wholly past the last unit (the file shrank between draft
|
|
153
|
+
* save and reopen) still gets a marker on its clamped unit, so every comment
|
|
56
154
|
* is visible somewhere in the document. */
|
|
57
|
-
export function
|
|
155
|
+
export function commentedUnitSet(state) {
|
|
58
156
|
const out = new Set();
|
|
59
157
|
for (const c of state.comments) {
|
|
60
158
|
let hit = false;
|
|
61
|
-
state.
|
|
62
|
-
if (
|
|
159
|
+
state.units.forEach((u, i) => {
|
|
160
|
+
if (u.start <= c.endLine && u.end >= c.line) {
|
|
63
161
|
out.add(i);
|
|
64
162
|
hit = true;
|
|
65
163
|
}
|
|
66
164
|
});
|
|
67
165
|
if (!hit)
|
|
68
|
-
out.add(
|
|
166
|
+
out.add(unitIndexForLine(state.units, c.line));
|
|
69
167
|
}
|
|
70
168
|
return out;
|
|
71
169
|
}
|
|
72
|
-
/** First/last rendered-row indices
|
|
73
|
-
*
|
|
74
|
-
export function anchorRowRange(
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if (first === -1)
|
|
81
|
-
first = r;
|
|
82
|
-
last = r;
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
return first === -1 ? null : { first, last };
|
|
170
|
+
/** First/last rendered-row indices spanned by units `lo..hi`, or null when
|
|
171
|
+
* the indices fall outside the unit list. */
|
|
172
|
+
export function anchorRowRange(units, lo, hi) {
|
|
173
|
+
const a = units[lo];
|
|
174
|
+
const b = units[hi];
|
|
175
|
+
if (a === undefined || b === undefined)
|
|
176
|
+
return null;
|
|
177
|
+
return { first: a.firstRow, last: b.lastRow };
|
|
86
178
|
}
|
|
87
179
|
/** Minimal scroll adjustment that keeps rows `first..last` visible with
|
|
88
180
|
* `margin` rows of context (the margin yields whenever honoring it would push
|
|
@@ -101,10 +193,10 @@ export function scrollToReveal(scroll, first, last, bodyHeight, total, margin =
|
|
|
101
193
|
next = Math.min(last + margin - (bodyHeight - 1), first);
|
|
102
194
|
return Math.max(0, Math.min(next, maxScroll));
|
|
103
195
|
}
|
|
104
|
-
/** Auto-scroll so the active
|
|
196
|
+
/** Auto-scroll so the active unit (the moving edge of a selection) is
|
|
105
197
|
* visible. Free-scrolling (`u`/`d`) is untouched until the next anchor move. */
|
|
106
198
|
export function ensureAnchorVisible(state, doc, bodyHeight) {
|
|
107
|
-
const range = anchorRowRange(
|
|
199
|
+
const range = anchorRowRange(state.units, state.activeUnit, state.activeUnit);
|
|
108
200
|
if (range === null)
|
|
109
201
|
return state;
|
|
110
202
|
const scroll = scrollToReveal(state.scroll, range.first, range.last, bodyHeight, doc.lines.length);
|
|
@@ -122,11 +214,10 @@ export function openComposeEdit(state, index) {
|
|
|
122
214
|
if (comment === undefined)
|
|
123
215
|
return state;
|
|
124
216
|
const buffer = comment.comment;
|
|
125
|
-
const lo =
|
|
126
|
-
const hi = blockIndexForLine(state.blocks, comment.endLine);
|
|
217
|
+
const { lo, hi } = unitRangeForSpan(state.units, comment.line, comment.endLine);
|
|
127
218
|
return {
|
|
128
219
|
...state,
|
|
129
|
-
|
|
220
|
+
activeUnit: hi,
|
|
130
221
|
selectionAnchor: lo === hi ? null : lo,
|
|
131
222
|
mode: 'compose',
|
|
132
223
|
compose: { buffer, cursor: [...buffer].length, editingId: comment.id, returnMode: 'list', anchor: { line: comment.line, endLine: comment.endLine } },
|
|
@@ -280,7 +371,7 @@ export function reservedRows(state, cols) {
|
|
|
280
371
|
}
|
|
281
372
|
if (state.mode === 'list')
|
|
282
373
|
return header + Math.max(1, state.comments.length) + 2;
|
|
283
|
-
return header +
|
|
374
|
+
return header + 9; // help
|
|
284
375
|
}
|
|
285
376
|
export function renderReviewFrame(state, fileLabel, doc, cols, rows) {
|
|
286
377
|
const maxW = Math.min(Math.max(20, cols - 4), 120);
|
|
@@ -317,24 +408,25 @@ export function renderReviewFrame(state, fileLabel, doc, cols, rows) {
|
|
|
317
408
|
}
|
|
318
409
|
else if (state.mode === 'help') {
|
|
319
410
|
footer.push(' Keys:');
|
|
320
|
-
footer.push(` ${DIM}j/k${RESET} move anchor
|
|
411
|
+
footer.push(` ${DIM}j/k${RESET} move anchor ${DIM}shift+j/k${RESET} extend selection`);
|
|
321
412
|
footer.push(` ${DIM}u/d, pgup/pgdn${RESET} scroll document`);
|
|
322
413
|
footer.push(` ${DIM}space c${RESET} compose comment ${DIM}space l${RESET} list comments`);
|
|
323
414
|
footer.push(` ${DIM}space u${RESET} undo last comment ${DIM}space s${RESET} submit review`);
|
|
415
|
+
footer.push(` ${DIM}space w${RESET} hand off to browser`);
|
|
324
416
|
footer.push(` ${DIM}?${RESET} toggle this help ${DIM}esc${RESET} close/cancel`);
|
|
325
417
|
footer.push('');
|
|
326
418
|
footer.push(` ${DIM}esc / ?${RESET} close`);
|
|
327
419
|
}
|
|
328
420
|
else {
|
|
329
|
-
footer.push(` ${DIM}j/k${RESET}
|
|
330
|
-
`${DIM}space l${RESET} list ${DIM}space u${RESET} undo ${DIM}space s${RESET} submit ${DIM}?${RESET} help ${DIM}esc${RESET} close`);
|
|
421
|
+
footer.push(` ${DIM}j/k${RESET} move ${DIM}shift-j/k${RESET} extend ${DIM}u/d${RESET} scroll ${DIM}space c${RESET} comment ` +
|
|
422
|
+
`${DIM}space l${RESET} list ${DIM}space u${RESET} undo ${DIM}space s${RESET} submit ${DIM}space w${RESET} browser ${DIM}?${RESET} help ${DIM}esc${RESET} close`);
|
|
331
423
|
}
|
|
332
424
|
const reserved = header.length + footer.length;
|
|
333
425
|
const bodyHeight = Math.max(1, rows - reserved);
|
|
334
426
|
const maxScroll = Math.max(0, doc.lines.length - bodyHeight);
|
|
335
427
|
const scroll = Math.max(0, Math.min(state.scroll, maxScroll));
|
|
336
|
-
const { lo, hi } =
|
|
337
|
-
const commented =
|
|
428
|
+
const { lo, hi } = selectedUnitBounds(state);
|
|
429
|
+
const commented = commentedUnitSet(state);
|
|
338
430
|
const body = [];
|
|
339
431
|
for (let i = 0; i < bodyHeight; i++) {
|
|
340
432
|
const abs = scroll + i;
|
|
@@ -343,10 +435,10 @@ export function renderReviewFrame(state, fileLabel, doc, cols, rows) {
|
|
|
343
435
|
continue;
|
|
344
436
|
}
|
|
345
437
|
const row = doc.lines[abs];
|
|
346
|
-
const
|
|
347
|
-
if (
|
|
438
|
+
const u = state.rowUnits[abs] ?? null;
|
|
439
|
+
if (u !== null && u >= lo && u <= hi)
|
|
348
440
|
body.push(` ${CYAN}▌${RESET} ${tintRow(row, docW)}`);
|
|
349
|
-
else if (
|
|
441
|
+
else if (u !== null && commented.has(u))
|
|
350
442
|
body.push(` ${YELLOW}▎${RESET} ${row}`);
|
|
351
443
|
else
|
|
352
444
|
body.push(` ${row}`);
|
|
@@ -363,30 +455,90 @@ export function renderReviewFrame(state, fileLabel, doc, cols, rows) {
|
|
|
363
455
|
lines.push('');
|
|
364
456
|
return lines.slice(0, rows);
|
|
365
457
|
}
|
|
366
|
-
|
|
458
|
+
function diskDraftResult(absFile, outPath) {
|
|
459
|
+
const draft = readReviewDraft(outPath);
|
|
460
|
+
return buildDraftFeedbackResult(absFile, draft?.comments ?? [], draft?.savedAt);
|
|
461
|
+
}
|
|
367
462
|
/**
|
|
368
463
|
* Open a Markdown file in humanloop's own terminal review surface: the
|
|
369
464
|
* document renders via termrender (Mermaid included), the anchor is a
|
|
370
|
-
* highlighted
|
|
371
|
-
*
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
* the
|
|
375
|
-
*
|
|
465
|
+
* highlighted leaf unit — a bullet, table row, code line, paragraph — in the
|
|
466
|
+
* rendered document itself (gutter bar + tint via termrender's per-row source
|
|
467
|
+
* spans), and the human explicitly submits a proposal.
|
|
468
|
+
* `space w` hands the review off to the browser surface (same park/take-back
|
|
469
|
+
* semantics as the Neovim path); the draft round-trips between surfaces via
|
|
470
|
+
* `opts.output`. Never edits the source file. Autosaves the comment draft to
|
|
471
|
+
* `opts.output` (the `progress.json` convention). Canonical ticket
|
|
472
|
+
* finalization stays with the caller's `onPropose` (the adapter calls
|
|
473
|
+
* `completeReview`); this function never writes `response.json`.
|
|
376
474
|
*/
|
|
377
475
|
export async function launchTerminalReview(file, opts) {
|
|
378
476
|
const absFile = resolve(file);
|
|
379
477
|
const content = readFileSync(absFile, 'utf8');
|
|
380
|
-
const sourceLines = content.split('\n');
|
|
381
478
|
const outPath = resolve(opts.output);
|
|
382
|
-
const draft = readReviewDraft(outPath);
|
|
383
479
|
const fileLabel = basename(absFile);
|
|
384
|
-
|
|
385
|
-
|
|
480
|
+
// Created lazily on the first browser handoff, reused across take-backs.
|
|
481
|
+
let jobDir = opts.jobDir ?? null;
|
|
482
|
+
while (true) {
|
|
483
|
+
if (opts.signal?.aborted)
|
|
484
|
+
return diskDraftResult(absFile, outPath);
|
|
485
|
+
const outcome = await runTerminalReviewSession(absFile, content, outPath, fileLabel, opts);
|
|
486
|
+
if (outcome.type === 'done')
|
|
487
|
+
return outcome.result;
|
|
488
|
+
// Browser handoff: the TUI is parked (terminal already restored, draft
|
|
489
|
+
// persisted); the browser is the editing authority until it submits, the
|
|
490
|
+
// human takes back with `w`, or the review is cancelled.
|
|
491
|
+
if (jobDir === null)
|
|
492
|
+
jobDir = mkdtempSync(join(tmpdir(), 'hl-review-'));
|
|
493
|
+
let resolveSubmitted;
|
|
494
|
+
const submitted = new Promise((resolveSubmittedPromise) => {
|
|
495
|
+
resolveSubmitted = resolveSubmittedPromise;
|
|
496
|
+
});
|
|
497
|
+
const server = await startReviewWebServer({
|
|
498
|
+
jobDir,
|
|
499
|
+
file: absFile,
|
|
500
|
+
output: outPath,
|
|
501
|
+
onSubmit: (result) => resolveSubmitted(result),
|
|
502
|
+
});
|
|
503
|
+
if (opts.signal?.aborted) {
|
|
504
|
+
await server.stop();
|
|
505
|
+
return diskDraftResult(absFile, outPath);
|
|
506
|
+
}
|
|
507
|
+
server.activate();
|
|
508
|
+
process.stderr.write(`humanloop: browser review handoff active — ${server.url}\n`);
|
|
509
|
+
openBrowser(server.url);
|
|
510
|
+
const action = await waitForParkedReviewSubmit(submitted, opts.signal, 'the terminal review');
|
|
511
|
+
if (action.type === 'submitted') {
|
|
512
|
+
await server.stop();
|
|
513
|
+
// An abort landing during the async stop() must not produce a submitted
|
|
514
|
+
// result whose canonical onPropose convergence was skipped — fall back
|
|
515
|
+
// to the disk draft (the browser submit already persisted its comments).
|
|
516
|
+
if (opts.signal?.aborted)
|
|
517
|
+
return diskDraftResult(absFile, outPath);
|
|
518
|
+
await opts.onPropose?.(action.result);
|
|
519
|
+
return action.result;
|
|
520
|
+
}
|
|
521
|
+
if (action.type === 'take-back') {
|
|
522
|
+
await server.requestTakeBack();
|
|
523
|
+
await server.stop();
|
|
524
|
+
process.stderr.write('humanloop: taking review back into the terminal review.\n');
|
|
525
|
+
continue; // re-enter the TUI; the next session re-reads the draft from disk
|
|
526
|
+
}
|
|
527
|
+
await server.stop();
|
|
528
|
+
return diskDraftResult(absFile, outPath);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
/** One TUI session over the document. Resolves when the review reaches a
|
|
532
|
+
* final result (submit/close/cancel) or the human requests browser handoff;
|
|
533
|
+
* either way the terminal is restored before resolution. Re-reads the draft
|
|
534
|
+
* from disk on entry so browser edits survive a take-back. */
|
|
535
|
+
function runTerminalReviewSession(absFile, content, outPath, fileLabel, opts) {
|
|
536
|
+
const sourceLines = content.split('\n');
|
|
537
|
+
const draft = readReviewDraft(outPath);
|
|
386
538
|
setupTerminal();
|
|
387
539
|
let { cols, rows } = getTerminalSize();
|
|
388
540
|
let doc = renderMarkdownWithMap(content, renderWidthForCols(cols));
|
|
389
|
-
let state = initReviewState(sourceLines, doc
|
|
541
|
+
let state = initReviewState(sourceLines, deriveAnchorUnits(doc), draft?.comments ?? [], draft?.version ?? 0);
|
|
390
542
|
let prevFrame = [];
|
|
391
543
|
let spaceArmed = false;
|
|
392
544
|
let settled = false;
|
|
@@ -410,7 +562,7 @@ export async function launchTerminalReview(file, opts) {
|
|
|
410
562
|
process.stdout.write('\x1b[?2026l');
|
|
411
563
|
prevFrame = nextPrevFrame;
|
|
412
564
|
};
|
|
413
|
-
const
|
|
565
|
+
const finishWith = (outcome) => {
|
|
414
566
|
if (settled)
|
|
415
567
|
return;
|
|
416
568
|
settled = true;
|
|
@@ -418,22 +570,26 @@ export async function launchTerminalReview(file, opts) {
|
|
|
418
570
|
process.stdout.removeListener('resize', onResize);
|
|
419
571
|
opts.signal?.removeEventListener('abort', onAbort);
|
|
420
572
|
restoreTerminal();
|
|
421
|
-
resolvePromise(
|
|
573
|
+
resolvePromise(outcome);
|
|
422
574
|
};
|
|
575
|
+
const finish = (result) => finishWith({ type: 'done', result });
|
|
423
576
|
const onResize = () => {
|
|
424
577
|
({ cols, rows } = getTerminalSize());
|
|
425
578
|
doc = renderMarkdownWithMap(content, renderWidthForCols(cols));
|
|
426
|
-
//
|
|
427
|
-
//
|
|
428
|
-
// transition can reshape the
|
|
429
|
-
//
|
|
430
|
-
|
|
431
|
-
const
|
|
579
|
+
// Unit SOURCE ranges are width-independent under the same renderer (only
|
|
580
|
+
// their rendered-row bounds move), but a mid-session renderer→fallback
|
|
581
|
+
// transition can reshape the list — so remap the anchor by its source
|
|
582
|
+
// line rather than trusting the old index. Committed comments are
|
|
583
|
+
// untouched either way (their source lines are frozen).
|
|
584
|
+
const prevActive = state.units[state.activeUnit];
|
|
585
|
+
const prevSel = state.selectionAnchor !== null ? state.units[state.selectionAnchor] : undefined;
|
|
586
|
+
const { units, rowUnits } = deriveAnchorUnits(doc);
|
|
432
587
|
state = {
|
|
433
588
|
...state,
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
589
|
+
units,
|
|
590
|
+
rowUnits,
|
|
591
|
+
activeUnit: prevActive !== undefined ? remapUnitIndex(units, prevActive) : 0,
|
|
592
|
+
selectionAnchor: prevSel !== undefined ? remapUnitIndex(units, prevSel) : null,
|
|
437
593
|
};
|
|
438
594
|
state = ensureAnchorVisible(state, doc, bodyH());
|
|
439
595
|
paint(true);
|
|
@@ -575,6 +731,11 @@ export async function launchTerminalReview(file, opts) {
|
|
|
575
731
|
void submit();
|
|
576
732
|
return;
|
|
577
733
|
}
|
|
734
|
+
else if (input === 'w') {
|
|
735
|
+
persist();
|
|
736
|
+
finishWith({ type: 'handoff' });
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
578
739
|
paint();
|
|
579
740
|
return;
|
|
580
741
|
}
|
|
@@ -595,19 +756,19 @@ export async function launchTerminalReview(file, opts) {
|
|
|
595
756
|
return;
|
|
596
757
|
}
|
|
597
758
|
if (input === 'j' || key.downArrow) {
|
|
598
|
-
state =
|
|
759
|
+
state = moveActiveUnit(state, 1, false);
|
|
599
760
|
state = ensureAnchorVisible(state, doc, bodyH());
|
|
600
761
|
}
|
|
601
762
|
else if (input === 'k' || key.upArrow) {
|
|
602
|
-
state =
|
|
763
|
+
state = moveActiveUnit(state, -1, false);
|
|
603
764
|
state = ensureAnchorVisible(state, doc, bodyH());
|
|
604
765
|
}
|
|
605
766
|
else if (input === 'J') {
|
|
606
|
-
state =
|
|
767
|
+
state = moveActiveUnit(state, 1, true);
|
|
607
768
|
state = ensureAnchorVisible(state, doc, bodyH());
|
|
608
769
|
}
|
|
609
770
|
else if (input === 'K') {
|
|
610
|
-
state =
|
|
771
|
+
state = moveActiveUnit(state, -1, true);
|
|
611
772
|
state = ensureAnchorVisible(state, doc, bodyH());
|
|
612
773
|
}
|
|
613
774
|
else if (input === 'u' || key.pageUp)
|
|
@@ -22,6 +22,10 @@ export interface RenderedDoc {
|
|
|
22
22
|
lines: string[];
|
|
23
23
|
/** Per-row index into `blocks`; null for the blank separator rows between blocks. */
|
|
24
24
|
rows: (number | null)[];
|
|
25
|
+
/** Per-row finest 1-indexed inclusive source-line range the renderer knows
|
|
26
|
+
* (a bullet, a table row, a code line…); null for separator rows and rows
|
|
27
|
+
* of unmapped blocks. Same length as `lines`. */
|
|
28
|
+
spans: ([number, number] | null)[];
|
|
25
29
|
/** Per top-level block: 1-indexed inclusive source-line bounds. Never empty. */
|
|
26
30
|
blocks: {
|
|
27
31
|
start: number;
|
|
@@ -505,9 +505,9 @@ function parseRenderedDoc(out, source) {
|
|
|
505
505
|
return null;
|
|
506
506
|
}
|
|
507
507
|
const p = parsed;
|
|
508
|
-
if (!Array.isArray(p.lines) || !Array.isArray(p.rows) || !Array.isArray(p.blocks))
|
|
508
|
+
if (!Array.isArray(p.lines) || !Array.isArray(p.rows) || !Array.isArray(p.spans) || !Array.isArray(p.blocks))
|
|
509
509
|
return null;
|
|
510
|
-
if (p.rows.length !== p.lines.length)
|
|
510
|
+
if (p.rows.length !== p.lines.length || p.spans.length !== p.lines.length)
|
|
511
511
|
return null;
|
|
512
512
|
if (!p.lines.every((l) => typeof l === 'string'))
|
|
513
513
|
return null;
|
|
@@ -535,11 +535,42 @@ function parseRenderedDoc(out, source) {
|
|
|
535
535
|
}
|
|
536
536
|
if (blocks.length === 0)
|
|
537
537
|
blocks.push({ start: 1, end: totalSource });
|
|
538
|
-
|
|
538
|
+
const spans = [];
|
|
539
|
+
for (let r = 0; r < p.spans.length; r++) {
|
|
540
|
+
const raw = p.spans[r];
|
|
541
|
+
if (raw === null) {
|
|
542
|
+
spans.push(null);
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
// Same strictness as rows: a span is [start, end], 1-indexed inclusive
|
|
546
|
+
// integers in order, inside the source — these values flow straight into
|
|
547
|
+
// a recorded comment's line/endLine, so an out-of-range endpoint is tool
|
|
548
|
+
// fault to reject, never something to clamp into the wrong line.
|
|
549
|
+
if (!Array.isArray(raw) || raw.length !== 2)
|
|
550
|
+
return null;
|
|
551
|
+
const [s, e] = raw;
|
|
552
|
+
if (!Number.isInteger(s) || !Number.isInteger(e))
|
|
553
|
+
return null;
|
|
554
|
+
if (s < 1 || e < s || e > totalSource)
|
|
555
|
+
return null;
|
|
556
|
+
// Relational contract: a separator row (rows[r] === null) carries no
|
|
557
|
+
// span, and a leaf span stays inside its owning block's source range. A
|
|
558
|
+
// map that disagrees with its own rows/blocks would let a comment on a
|
|
559
|
+
// visible row record against unrelated source text.
|
|
560
|
+
const rowBlock = p.rows[r];
|
|
561
|
+
if (rowBlock === null)
|
|
562
|
+
return null;
|
|
563
|
+
const owner = blocks[rowBlock];
|
|
564
|
+
if (s < owner.start || e > owner.end)
|
|
565
|
+
return null;
|
|
566
|
+
spans.push([s, e]);
|
|
567
|
+
}
|
|
568
|
+
return { lines: p.lines, rows: p.rows, spans, blocks };
|
|
539
569
|
}
|
|
540
570
|
/** Renderer-free mapped render: group consecutive non-blank source lines into
|
|
541
571
|
* paragraph blocks and word-wrap each — the same shape as the termrender map,
|
|
542
|
-
* with degraded (plaintext) rendering.
|
|
572
|
+
* with degraded (plaintext) rendering. Each source line is wrapped on its own
|
|
573
|
+
* and spans exactly itself, so leaf anchoring degrades to true line-by-line. */
|
|
543
574
|
function fallbackDocWithMap(md, width) {
|
|
544
575
|
const src = sanitize(md).split('\n');
|
|
545
576
|
const blocks = [];
|
|
@@ -560,17 +591,24 @@ function fallbackDocWithMap(md, width) {
|
|
|
560
591
|
blocks.push({ start: 1, end: Math.max(1, src.length) });
|
|
561
592
|
const lines = [];
|
|
562
593
|
const rows = [];
|
|
594
|
+
const spans = [];
|
|
563
595
|
blocks.forEach((b, bi) => {
|
|
564
596
|
if (bi > 0) {
|
|
565
597
|
lines.push('');
|
|
566
598
|
rows.push(null);
|
|
599
|
+
spans.push(null);
|
|
567
600
|
}
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
601
|
+
// wrap() treats each '\n'-separated line as its own paragraph, so wrapping
|
|
602
|
+
// line-by-line emits the exact rows the joined-block wrap produced.
|
|
603
|
+
for (let ln = b.start; ln <= b.end; ln++) {
|
|
604
|
+
for (const l of wrap(src[ln - 1] ?? '', width)) {
|
|
605
|
+
lines.push(l);
|
|
606
|
+
rows.push(bi);
|
|
607
|
+
spans.push([ln, ln]);
|
|
608
|
+
}
|
|
571
609
|
}
|
|
572
610
|
});
|
|
573
|
-
return { lines, rows, blocks };
|
|
611
|
+
return { lines, rows, spans, blocks };
|
|
574
612
|
}
|
|
575
613
|
const _mapCache = new Map();
|
|
576
614
|
/** Render markdown with a row→source-line map via the pinned binary
|
package/dist/render/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const TERMRENDER_VERSION = "4.
|
|
1
|
+
export declare const TERMRENDER_VERSION = "4.12.0";
|
package/dist/render/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const TERMRENDER_VERSION = '4.
|
|
1
|
+
export const TERMRENDER_VERSION = '4.12.0';
|