@crouton-kit/humanloop 0.4.5 → 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.
|
@@ -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,8 +141,9 @@ 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
|
-
*
|
|
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.
|
|
110
147
|
* `space w` hands the review off to the browser surface (same park/take-back
|
|
111
148
|
* semantics as the Neovim path); the draft round-trips between surfaces via
|
|
112
149
|
* `opts.output`. Never edits the source file. Autosaves the comment draft to
|
|
@@ -11,23 +11,106 @@ import { renderMarkdownWithMap } from '../render/termrender.js';
|
|
|
11
11
|
import { setupTerminal, restoreTerminal, getTerminalSize, parseKeypress } from '../tui/terminal.js';
|
|
12
12
|
import { diffFrame, renderInputBuffer } from '../tui/render.js';
|
|
13
13
|
import { BOLD, CYAN, DIM, ESC, RESET, YELLOW, hline, sanitize, truncate } from '../tui/ansi.js';
|
|
14
|
-
/**
|
|
15
|
-
*
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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)
|
|
19
71
|
return i;
|
|
20
72
|
}
|
|
21
|
-
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 };
|
|
22
102
|
}
|
|
23
|
-
export function initReviewState(sourceLines,
|
|
24
|
-
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 };
|
|
25
107
|
return {
|
|
26
108
|
sourceLines,
|
|
27
|
-
|
|
109
|
+
units: safe.units,
|
|
110
|
+
rowUnits: safe.rowUnits,
|
|
28
111
|
comments: [...comments],
|
|
29
112
|
version,
|
|
30
|
-
|
|
113
|
+
activeUnit: comments.length > 0 ? unitIndexForLine(safe.units, comments[0].line) : 0,
|
|
31
114
|
selectionAnchor: null,
|
|
32
115
|
scroll: 0,
|
|
33
116
|
mode: 'view',
|
|
@@ -35,58 +118,63 @@ export function initReviewState(sourceLines, blocks, comments, version) {
|
|
|
35
118
|
listIndex: 0,
|
|
36
119
|
};
|
|
37
120
|
}
|
|
38
|
-
function
|
|
39
|
-
const sel = state.selectionAnchor ?? state.
|
|
40
|
-
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) };
|
|
41
124
|
}
|
|
42
|
-
/** Source-line anchor of the current
|
|
43
|
-
* 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. */
|
|
44
129
|
export function currentAnchor(state) {
|
|
45
|
-
const { lo, hi } =
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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));
|
|
53
147
|
if (!extend)
|
|
54
|
-
return { ...state,
|
|
55
|
-
return { ...state,
|
|
148
|
+
return { ...state, activeUnit: next, selectionAnchor: null };
|
|
149
|
+
return { ...state, activeUnit: next, selectionAnchor: state.selectionAnchor ?? state.activeUnit };
|
|
56
150
|
}
|
|
57
|
-
/**
|
|
58
|
-
* draft range lying wholly past the last
|
|
59
|
-
* 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
|
|
60
154
|
* is visible somewhere in the document. */
|
|
61
|
-
export function
|
|
155
|
+
export function commentedUnitSet(state) {
|
|
62
156
|
const out = new Set();
|
|
63
157
|
for (const c of state.comments) {
|
|
64
158
|
let hit = false;
|
|
65
|
-
state.
|
|
66
|
-
if (
|
|
159
|
+
state.units.forEach((u, i) => {
|
|
160
|
+
if (u.start <= c.endLine && u.end >= c.line) {
|
|
67
161
|
out.add(i);
|
|
68
162
|
hit = true;
|
|
69
163
|
}
|
|
70
164
|
});
|
|
71
165
|
if (!hit)
|
|
72
|
-
out.add(
|
|
166
|
+
out.add(unitIndexForLine(state.units, c.line));
|
|
73
167
|
}
|
|
74
168
|
return out;
|
|
75
169
|
}
|
|
76
|
-
/** First/last rendered-row indices
|
|
77
|
-
*
|
|
78
|
-
export function anchorRowRange(
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (first === -1)
|
|
85
|
-
first = r;
|
|
86
|
-
last = r;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
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 };
|
|
90
178
|
}
|
|
91
179
|
/** Minimal scroll adjustment that keeps rows `first..last` visible with
|
|
92
180
|
* `margin` rows of context (the margin yields whenever honoring it would push
|
|
@@ -105,10 +193,10 @@ export function scrollToReveal(scroll, first, last, bodyHeight, total, margin =
|
|
|
105
193
|
next = Math.min(last + margin - (bodyHeight - 1), first);
|
|
106
194
|
return Math.max(0, Math.min(next, maxScroll));
|
|
107
195
|
}
|
|
108
|
-
/** Auto-scroll so the active
|
|
196
|
+
/** Auto-scroll so the active unit (the moving edge of a selection) is
|
|
109
197
|
* visible. Free-scrolling (`u`/`d`) is untouched until the next anchor move. */
|
|
110
198
|
export function ensureAnchorVisible(state, doc, bodyHeight) {
|
|
111
|
-
const range = anchorRowRange(
|
|
199
|
+
const range = anchorRowRange(state.units, state.activeUnit, state.activeUnit);
|
|
112
200
|
if (range === null)
|
|
113
201
|
return state;
|
|
114
202
|
const scroll = scrollToReveal(state.scroll, range.first, range.last, bodyHeight, doc.lines.length);
|
|
@@ -126,11 +214,10 @@ export function openComposeEdit(state, index) {
|
|
|
126
214
|
if (comment === undefined)
|
|
127
215
|
return state;
|
|
128
216
|
const buffer = comment.comment;
|
|
129
|
-
const lo =
|
|
130
|
-
const hi = blockIndexForLine(state.blocks, comment.endLine);
|
|
217
|
+
const { lo, hi } = unitRangeForSpan(state.units, comment.line, comment.endLine);
|
|
131
218
|
return {
|
|
132
219
|
...state,
|
|
133
|
-
|
|
220
|
+
activeUnit: hi,
|
|
134
221
|
selectionAnchor: lo === hi ? null : lo,
|
|
135
222
|
mode: 'compose',
|
|
136
223
|
compose: { buffer, cursor: [...buffer].length, editingId: comment.id, returnMode: 'list', anchor: { line: comment.line, endLine: comment.endLine } },
|
|
@@ -321,7 +408,7 @@ export function renderReviewFrame(state, fileLabel, doc, cols, rows) {
|
|
|
321
408
|
}
|
|
322
409
|
else if (state.mode === 'help') {
|
|
323
410
|
footer.push(' Keys:');
|
|
324
|
-
footer.push(` ${DIM}j/k${RESET} move anchor
|
|
411
|
+
footer.push(` ${DIM}j/k${RESET} move anchor ${DIM}shift+j/k${RESET} extend selection`);
|
|
325
412
|
footer.push(` ${DIM}u/d, pgup/pgdn${RESET} scroll document`);
|
|
326
413
|
footer.push(` ${DIM}space c${RESET} compose comment ${DIM}space l${RESET} list comments`);
|
|
327
414
|
footer.push(` ${DIM}space u${RESET} undo last comment ${DIM}space s${RESET} submit review`);
|
|
@@ -331,15 +418,15 @@ export function renderReviewFrame(state, fileLabel, doc, cols, rows) {
|
|
|
331
418
|
footer.push(` ${DIM}esc / ?${RESET} close`);
|
|
332
419
|
}
|
|
333
420
|
else {
|
|
334
|
-
footer.push(` ${DIM}j/k${RESET}
|
|
421
|
+
footer.push(` ${DIM}j/k${RESET} move ${DIM}shift-j/k${RESET} extend ${DIM}u/d${RESET} scroll ${DIM}space c${RESET} comment ` +
|
|
335
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`);
|
|
336
423
|
}
|
|
337
424
|
const reserved = header.length + footer.length;
|
|
338
425
|
const bodyHeight = Math.max(1, rows - reserved);
|
|
339
426
|
const maxScroll = Math.max(0, doc.lines.length - bodyHeight);
|
|
340
427
|
const scroll = Math.max(0, Math.min(state.scroll, maxScroll));
|
|
341
|
-
const { lo, hi } =
|
|
342
|
-
const commented =
|
|
428
|
+
const { lo, hi } = selectedUnitBounds(state);
|
|
429
|
+
const commented = commentedUnitSet(state);
|
|
343
430
|
const body = [];
|
|
344
431
|
for (let i = 0; i < bodyHeight; i++) {
|
|
345
432
|
const abs = scroll + i;
|
|
@@ -348,10 +435,10 @@ export function renderReviewFrame(state, fileLabel, doc, cols, rows) {
|
|
|
348
435
|
continue;
|
|
349
436
|
}
|
|
350
437
|
const row = doc.lines[abs];
|
|
351
|
-
const
|
|
352
|
-
if (
|
|
438
|
+
const u = state.rowUnits[abs] ?? null;
|
|
439
|
+
if (u !== null && u >= lo && u <= hi)
|
|
353
440
|
body.push(` ${CYAN}▌${RESET} ${tintRow(row, docW)}`);
|
|
354
|
-
else if (
|
|
441
|
+
else if (u !== null && commented.has(u))
|
|
355
442
|
body.push(` ${YELLOW}▎${RESET} ${row}`);
|
|
356
443
|
else
|
|
357
444
|
body.push(` ${row}`);
|
|
@@ -375,8 +462,9 @@ function diskDraftResult(absFile, outPath) {
|
|
|
375
462
|
/**
|
|
376
463
|
* Open a Markdown file in humanloop's own terminal review surface: the
|
|
377
464
|
* document renders via termrender (Mermaid included), the anchor is a
|
|
378
|
-
* highlighted
|
|
379
|
-
*
|
|
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.
|
|
380
468
|
* `space w` hands the review off to the browser surface (same park/take-back
|
|
381
469
|
* semantics as the Neovim path); the draft round-trips between surfaces via
|
|
382
470
|
* `opts.output`. Never edits the source file. Autosaves the comment draft to
|
|
@@ -450,7 +538,7 @@ function runTerminalReviewSession(absFile, content, outPath, fileLabel, opts) {
|
|
|
450
538
|
setupTerminal();
|
|
451
539
|
let { cols, rows } = getTerminalSize();
|
|
452
540
|
let doc = renderMarkdownWithMap(content, renderWidthForCols(cols));
|
|
453
|
-
let state = initReviewState(sourceLines, doc
|
|
541
|
+
let state = initReviewState(sourceLines, deriveAnchorUnits(doc), draft?.comments ?? [], draft?.version ?? 0);
|
|
454
542
|
let prevFrame = [];
|
|
455
543
|
let spaceArmed = false;
|
|
456
544
|
let settled = false;
|
|
@@ -488,17 +576,20 @@ function runTerminalReviewSession(absFile, content, outPath, fileLabel, opts) {
|
|
|
488
576
|
const onResize = () => {
|
|
489
577
|
({ cols, rows } = getTerminalSize());
|
|
490
578
|
doc = renderMarkdownWithMap(content, renderWidthForCols(cols));
|
|
491
|
-
//
|
|
492
|
-
//
|
|
493
|
-
// transition can reshape the
|
|
494
|
-
//
|
|
495
|
-
|
|
496
|
-
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);
|
|
497
587
|
state = {
|
|
498
588
|
...state,
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
589
|
+
units,
|
|
590
|
+
rowUnits,
|
|
591
|
+
activeUnit: prevActive !== undefined ? remapUnitIndex(units, prevActive) : 0,
|
|
592
|
+
selectionAnchor: prevSel !== undefined ? remapUnitIndex(units, prevSel) : null,
|
|
502
593
|
};
|
|
503
594
|
state = ensureAnchorVisible(state, doc, bodyH());
|
|
504
595
|
paint(true);
|
|
@@ -665,19 +756,19 @@ function runTerminalReviewSession(absFile, content, outPath, fileLabel, opts) {
|
|
|
665
756
|
return;
|
|
666
757
|
}
|
|
667
758
|
if (input === 'j' || key.downArrow) {
|
|
668
|
-
state =
|
|
759
|
+
state = moveActiveUnit(state, 1, false);
|
|
669
760
|
state = ensureAnchorVisible(state, doc, bodyH());
|
|
670
761
|
}
|
|
671
762
|
else if (input === 'k' || key.upArrow) {
|
|
672
|
-
state =
|
|
763
|
+
state = moveActiveUnit(state, -1, false);
|
|
673
764
|
state = ensureAnchorVisible(state, doc, bodyH());
|
|
674
765
|
}
|
|
675
766
|
else if (input === 'J') {
|
|
676
|
-
state =
|
|
767
|
+
state = moveActiveUnit(state, 1, true);
|
|
677
768
|
state = ensureAnchorVisible(state, doc, bodyH());
|
|
678
769
|
}
|
|
679
770
|
else if (input === 'K') {
|
|
680
|
-
state =
|
|
771
|
+
state = moveActiveUnit(state, -1, true);
|
|
681
772
|
state = ensureAnchorVisible(state, doc, bodyH());
|
|
682
773
|
}
|
|
683
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';
|