@crouton-kit/humanloop 0.4.3 → 0.4.4
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,5 +1,11 @@
|
|
|
1
1
|
import type { FeedbackComment, FeedbackResult } from '../types.js';
|
|
2
2
|
import type { ReviewOptions } from './review.js';
|
|
3
|
+
import { type RenderedDoc } from '../render/termrender.js';
|
|
4
|
+
/** 1-indexed inclusive source-line bounds of one top-level rendered block. */
|
|
5
|
+
export interface BlockRange {
|
|
6
|
+
start: number;
|
|
7
|
+
end: number;
|
|
8
|
+
}
|
|
3
9
|
export interface ComposeState {
|
|
4
10
|
buffer: string;
|
|
5
11
|
cursor: number;
|
|
@@ -14,11 +20,13 @@ export interface ComposeState {
|
|
|
14
20
|
}
|
|
15
21
|
export interface ReviewState {
|
|
16
22
|
sourceLines: string[];
|
|
23
|
+
/** Top-level block source ranges, in document order. Never empty. */
|
|
24
|
+
blocks: BlockRange[];
|
|
17
25
|
comments: FeedbackComment[];
|
|
18
26
|
version: number;
|
|
19
|
-
/**
|
|
20
|
-
|
|
21
|
-
/** Set while a Shift+j/k range is being extended from this
|
|
27
|
+
/** 0-based index of the anchored block. */
|
|
28
|
+
activeBlock: number;
|
|
29
|
+
/** Set while a Shift+j/k range is being extended from this block. */
|
|
22
30
|
selectionAnchor: number | null;
|
|
23
31
|
/** Scroll offset into the rendered (termrender) lines. */
|
|
24
32
|
scroll: number;
|
|
@@ -26,12 +34,38 @@ export interface ReviewState {
|
|
|
26
34
|
compose: ComposeState | null;
|
|
27
35
|
listIndex: number;
|
|
28
36
|
}
|
|
29
|
-
|
|
37
|
+
/** Index of the block containing `line`; snaps forward across gaps (blank
|
|
38
|
+
* separator lines between blocks) and clamps to the last block. */
|
|
39
|
+
export declare function blockIndexForLine(blocks: BlockRange[], line: number): number;
|
|
40
|
+
export declare function initReviewState(sourceLines: string[], blocks: BlockRange[], comments: FeedbackComment[], version: number): ReviewState;
|
|
41
|
+
/** Source-line anchor of the current block selection — what a committed
|
|
42
|
+
* comment records as `line`/`endLine`. */
|
|
30
43
|
export declare function currentAnchor(state: ReviewState): {
|
|
31
44
|
line: number;
|
|
32
45
|
endLine: number;
|
|
33
46
|
};
|
|
34
|
-
export declare function
|
|
47
|
+
export declare function moveActiveBlock(state: ReviewState, delta: number, extend: boolean): ReviewState;
|
|
48
|
+
/** Block indices overlapped by any existing comment's source range. A stale
|
|
49
|
+
* draft range lying wholly past the last block (the file shrank between draft
|
|
50
|
+
* save and reopen) still gets a marker on its clamped block, so every comment
|
|
51
|
+
* is visible somewhere in the document. */
|
|
52
|
+
export declare function commentedBlockSet(state: ReviewState): Set<number>;
|
|
53
|
+
/** First/last rendered-row indices produced by blocks `lo..hi`, or null when
|
|
54
|
+
* no row maps into that range. */
|
|
55
|
+
export declare function anchorRowRange(rows: (number | null)[], lo: number, hi: number): {
|
|
56
|
+
first: number;
|
|
57
|
+
last: number;
|
|
58
|
+
} | null;
|
|
59
|
+
/** Minimal scroll adjustment that keeps rows `first..last` visible with
|
|
60
|
+
* `margin` rows of context (the margin yields whenever honoring it would push
|
|
61
|
+
* either edge of the span out of view); a taller-than-view span pins `first`
|
|
62
|
+
* at the top — one row down when scrolled, so the `↑ more above` indicator
|
|
63
|
+
* overwrites the preceding row, never the pinned one. Always returns a scroll
|
|
64
|
+
* clamped to the document. */
|
|
65
|
+
export declare function scrollToReveal(scroll: number, first: number, last: number, bodyHeight: number, total: number, margin?: number): number;
|
|
66
|
+
/** Auto-scroll so the active block (the moving edge of a selection) is
|
|
67
|
+
* visible. Free-scrolling (`u`/`d`) is untouched until the next anchor move. */
|
|
68
|
+
export declare function ensureAnchorVisible(state: ReviewState, doc: RenderedDoc, bodyHeight: number): ReviewState;
|
|
35
69
|
export declare function scrollBy(state: ReviewState, delta: number, bodyHeight: number, totalLines: number): ReviewState;
|
|
36
70
|
export declare function openComposeNew(state: ReviewState): ReviewState;
|
|
37
71
|
export declare function openComposeEdit(state: ReviewState, index: number): ReviewState;
|
|
@@ -60,17 +94,22 @@ export declare function textDelete(buffer: string, cursor: number): {
|
|
|
60
94
|
export declare function textHome(buffer: string, cursor: number): number;
|
|
61
95
|
export declare function textEnd(buffer: string, cursor: number): number;
|
|
62
96
|
export declare function textVertical(buffer: string, cursor: number, delta: number): number;
|
|
97
|
+
/** Width the document is rendered at for a given terminal width: 2-col left
|
|
98
|
+
* margin + 2-col gutter before the content, capped for readability. */
|
|
99
|
+
export declare function renderWidthForCols(cols: number): number;
|
|
63
100
|
/** Reserved (non-body) rows for each mode's fixed header/footer chrome. Kept in
|
|
64
|
-
* sync with renderReviewFrame so scroll math can clamp before a repaint
|
|
65
|
-
|
|
66
|
-
export declare function
|
|
101
|
+
* sync with renderReviewFrame so scroll math can clamp before a repaint — the
|
|
102
|
+
* compose case counts the same hard-wrapped input lines the frame renders. */
|
|
103
|
+
export declare function reservedRows(state: ReviewState, cols: number): number;
|
|
104
|
+
export declare function renderReviewFrame(state: ReviewState, fileLabel: string, doc: RenderedDoc, cols: number, rows: number): string[];
|
|
67
105
|
/**
|
|
68
106
|
* Open a Markdown file in humanloop's own terminal review surface: the
|
|
69
|
-
* document renders via termrender (Mermaid included),
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
* comment draft to `opts.output`
|
|
73
|
-
* ticket finalization stays with
|
|
74
|
-
* `completeReview`); this function
|
|
107
|
+
* document renders via termrender (Mermaid included), the anchor is a
|
|
108
|
+
* highlighted block in the rendered document itself (gutter bar + tint via
|
|
109
|
+
* termrender's row→source map), and the human explicitly submits a proposal.
|
|
110
|
+
* Never edits the source file. Autosaves the comment draft to `opts.output`
|
|
111
|
+
* (the `progress.json` convention). Canonical ticket finalization stays with
|
|
112
|
+
* the caller's `onPropose` (the adapter calls `completeReview`); this function
|
|
113
|
+
* never writes `response.json`.
|
|
75
114
|
*/
|
|
76
115
|
export declare function launchTerminalReview(file: string, opts: ReviewOptions): Promise<FeedbackResult>;
|
|
@@ -1,17 +1,29 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
3
|
import { basename, resolve } from 'node:path';
|
|
4
|
+
import stringWidth from 'string-width';
|
|
4
5
|
import { buildDraftFeedbackResult, buildFinalFeedbackResult, readReviewDraft, writeReviewDraft } from './feedback.js';
|
|
5
|
-
import {
|
|
6
|
+
import { renderMarkdownWithMap } from '../render/termrender.js';
|
|
6
7
|
import { setupTerminal, restoreTerminal, getTerminalSize, parseKeypress } from '../tui/terminal.js';
|
|
7
8
|
import { diffFrame, renderInputBuffer } from '../tui/render.js';
|
|
8
|
-
import { BOLD, CYAN, DIM, RESET, YELLOW, hline, sanitize, truncate } from '../tui/ansi.js';
|
|
9
|
-
|
|
9
|
+
import { BOLD, CYAN, DIM, ESC, RESET, YELLOW, hline, sanitize, truncate } from '../tui/ansi.js';
|
|
10
|
+
/** Index of the block containing `line`; snaps forward across gaps (blank
|
|
11
|
+
* separator lines between blocks) and clamps to the last block. */
|
|
12
|
+
export function blockIndexForLine(blocks, line) {
|
|
13
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
14
|
+
if (blocks[i].end >= line)
|
|
15
|
+
return i;
|
|
16
|
+
}
|
|
17
|
+
return Math.max(0, blocks.length - 1);
|
|
18
|
+
}
|
|
19
|
+
export function initReviewState(sourceLines, blocks, comments, version) {
|
|
20
|
+
const safeBlocks = blocks.length > 0 ? blocks : [{ start: 1, end: Math.max(1, sourceLines.length) }];
|
|
10
21
|
return {
|
|
11
22
|
sourceLines,
|
|
23
|
+
blocks: safeBlocks,
|
|
12
24
|
comments: [...comments],
|
|
13
25
|
version,
|
|
14
|
-
|
|
26
|
+
activeBlock: comments.length > 0 ? blockIndexForLine(safeBlocks, comments[0].line) : 0,
|
|
15
27
|
selectionAnchor: null,
|
|
16
28
|
scroll: 0,
|
|
17
29
|
mode: 'view',
|
|
@@ -19,17 +31,84 @@ export function initReviewState(sourceLines, comments, version) {
|
|
|
19
31
|
listIndex: 0,
|
|
20
32
|
};
|
|
21
33
|
}
|
|
34
|
+
function selectedBlockBounds(state) {
|
|
35
|
+
const sel = state.selectionAnchor ?? state.activeBlock;
|
|
36
|
+
return { lo: Math.min(state.activeBlock, sel), hi: Math.max(state.activeBlock, sel) };
|
|
37
|
+
}
|
|
38
|
+
/** Source-line anchor of the current block selection — what a committed
|
|
39
|
+
* comment records as `line`/`endLine`. */
|
|
22
40
|
export function currentAnchor(state) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
41
|
+
const { lo, hi } = selectedBlockBounds(state);
|
|
42
|
+
const first = state.blocks[lo] ?? { start: 1, end: Math.max(1, state.sourceLines.length) };
|
|
43
|
+
const last = state.blocks[hi] ?? first;
|
|
44
|
+
return { line: first.start, endLine: last.end };
|
|
26
45
|
}
|
|
27
|
-
export function
|
|
28
|
-
const
|
|
29
|
-
const next = Math.max(
|
|
46
|
+
export function moveActiveBlock(state, delta, extend) {
|
|
47
|
+
const last = Math.max(0, state.blocks.length - 1);
|
|
48
|
+
const next = Math.max(0, Math.min(last, state.activeBlock + delta));
|
|
30
49
|
if (!extend)
|
|
31
|
-
return { ...state,
|
|
32
|
-
return { ...state,
|
|
50
|
+
return { ...state, activeBlock: next, selectionAnchor: null };
|
|
51
|
+
return { ...state, activeBlock: next, selectionAnchor: state.selectionAnchor ?? state.activeBlock };
|
|
52
|
+
}
|
|
53
|
+
/** Block indices overlapped by any existing comment's source range. A stale
|
|
54
|
+
* draft range lying wholly past the last block (the file shrank between draft
|
|
55
|
+
* save and reopen) still gets a marker on its clamped block, so every comment
|
|
56
|
+
* is visible somewhere in the document. */
|
|
57
|
+
export function commentedBlockSet(state) {
|
|
58
|
+
const out = new Set();
|
|
59
|
+
for (const c of state.comments) {
|
|
60
|
+
let hit = false;
|
|
61
|
+
state.blocks.forEach((b, i) => {
|
|
62
|
+
if (b.start <= c.endLine && b.end >= c.line) {
|
|
63
|
+
out.add(i);
|
|
64
|
+
hit = true;
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
if (!hit)
|
|
68
|
+
out.add(blockIndexForLine(state.blocks, c.line));
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
/** First/last rendered-row indices produced by blocks `lo..hi`, or null when
|
|
73
|
+
* no row maps into that range. */
|
|
74
|
+
export function anchorRowRange(rows, lo, hi) {
|
|
75
|
+
let first = -1;
|
|
76
|
+
let last = -1;
|
|
77
|
+
for (let r = 0; r < rows.length; r++) {
|
|
78
|
+
const b = rows[r];
|
|
79
|
+
if (b !== null && b >= lo && b <= hi) {
|
|
80
|
+
if (first === -1)
|
|
81
|
+
first = r;
|
|
82
|
+
last = r;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return first === -1 ? null : { first, last };
|
|
86
|
+
}
|
|
87
|
+
/** Minimal scroll adjustment that keeps rows `first..last` visible with
|
|
88
|
+
* `margin` rows of context (the margin yields whenever honoring it would push
|
|
89
|
+
* either edge of the span out of view); a taller-than-view span pins `first`
|
|
90
|
+
* at the top — one row down when scrolled, so the `↑ more above` indicator
|
|
91
|
+
* overwrites the preceding row, never the pinned one. Always returns a scroll
|
|
92
|
+
* clamped to the document. */
|
|
93
|
+
export function scrollToReveal(scroll, first, last, bodyHeight, total, margin = 2) {
|
|
94
|
+
const maxScroll = Math.max(0, total - bodyHeight);
|
|
95
|
+
let next = Math.max(0, Math.min(scroll, maxScroll));
|
|
96
|
+
if (last - first + 1 >= bodyHeight)
|
|
97
|
+
return Math.max(0, Math.min(Math.max(0, first - 1), maxScroll));
|
|
98
|
+
if (first - margin < next)
|
|
99
|
+
next = Math.max(first - margin, last - (bodyHeight - 1));
|
|
100
|
+
else if (last + margin > next + bodyHeight - 1)
|
|
101
|
+
next = Math.min(last + margin - (bodyHeight - 1), first);
|
|
102
|
+
return Math.max(0, Math.min(next, maxScroll));
|
|
103
|
+
}
|
|
104
|
+
/** Auto-scroll so the active block (the moving edge of a selection) is
|
|
105
|
+
* visible. Free-scrolling (`u`/`d`) is untouched until the next anchor move. */
|
|
106
|
+
export function ensureAnchorVisible(state, doc, bodyHeight) {
|
|
107
|
+
const range = anchorRowRange(doc.rows, state.activeBlock, state.activeBlock);
|
|
108
|
+
if (range === null)
|
|
109
|
+
return state;
|
|
110
|
+
const scroll = scrollToReveal(state.scroll, range.first, range.last, bodyHeight, doc.lines.length);
|
|
111
|
+
return scroll === state.scroll ? state : { ...state, scroll };
|
|
33
112
|
}
|
|
34
113
|
export function scrollBy(state, delta, bodyHeight, totalLines) {
|
|
35
114
|
const maxScroll = Math.max(0, totalLines - bodyHeight);
|
|
@@ -43,8 +122,12 @@ export function openComposeEdit(state, index) {
|
|
|
43
122
|
if (comment === undefined)
|
|
44
123
|
return state;
|
|
45
124
|
const buffer = comment.comment;
|
|
125
|
+
const lo = blockIndexForLine(state.blocks, comment.line);
|
|
126
|
+
const hi = blockIndexForLine(state.blocks, comment.endLine);
|
|
46
127
|
return {
|
|
47
128
|
...state,
|
|
129
|
+
activeBlock: hi,
|
|
130
|
+
selectionAnchor: lo === hi ? null : lo,
|
|
48
131
|
mode: 'compose',
|
|
49
132
|
compose: { buffer, cursor: [...buffer].length, editingId: comment.id, returnMode: 'list', anchor: { line: comment.line, endLine: comment.endLine } },
|
|
50
133
|
};
|
|
@@ -168,23 +251,40 @@ export function textVertical(buffer, cursor, delta) {
|
|
|
168
251
|
return Math.min(nextEnd, nextStart + col);
|
|
169
252
|
}
|
|
170
253
|
// ── Rendering (pure) ─────────────────────────────────────────────────────────
|
|
254
|
+
/** Width the document is rendered at for a given terminal width: 2-col left
|
|
255
|
+
* margin + 2-col gutter before the content, capped for readability. */
|
|
256
|
+
export function renderWidthForCols(cols) {
|
|
257
|
+
return Math.min(Math.max(20, cols - 6), 120);
|
|
258
|
+
}
|
|
259
|
+
// Single-hue anchor tint (a dark neutral step below the CYAN gutter bar).
|
|
260
|
+
// termrender's only reset is `\x1b[0m`, so re-arming the background after each
|
|
261
|
+
// reset keeps the tint alive across inner styling. Heading rows carry their
|
|
262
|
+
// own background and won't show the tint — the gutter bar still marks them.
|
|
263
|
+
const TINT_BG = `${ESC}48;5;236m`;
|
|
264
|
+
function tintRow(row, width) {
|
|
265
|
+
const pad = Math.max(0, width - stringWidth(sanitize(row)));
|
|
266
|
+
return TINT_BG + row.replaceAll(RESET, RESET + TINT_BG) + ' '.repeat(pad) + RESET;
|
|
267
|
+
}
|
|
171
268
|
/** Reserved (non-body) rows for each mode's fixed header/footer chrome. Kept in
|
|
172
|
-
* sync with renderReviewFrame so scroll math can clamp before a repaint
|
|
173
|
-
|
|
269
|
+
* sync with renderReviewFrame so scroll math can clamp before a repaint — the
|
|
270
|
+
* compose case counts the same hard-wrapped input lines the frame renders. */
|
|
271
|
+
export function reservedRows(state, cols) {
|
|
174
272
|
const header = 3; // blank, title, divider
|
|
175
273
|
if (state.mode === 'view')
|
|
176
|
-
return header +
|
|
274
|
+
return header + 1;
|
|
177
275
|
if (state.mode === 'compose') {
|
|
178
276
|
const c = state.compose;
|
|
179
|
-
const
|
|
277
|
+
const maxW = Math.min(Math.max(20, cols - 4), 120);
|
|
278
|
+
const bufLines = c ? renderInputBuffer(c.buffer, c.cursor, Math.max(10, maxW - 2)).length : 1;
|
|
180
279
|
return header + 3 + bufLines;
|
|
181
280
|
}
|
|
182
281
|
if (state.mode === 'list')
|
|
183
282
|
return header + Math.max(1, state.comments.length) + 2;
|
|
184
283
|
return header + 8; // help
|
|
185
284
|
}
|
|
186
|
-
export function renderReviewFrame(state, fileLabel,
|
|
285
|
+
export function renderReviewFrame(state, fileLabel, doc, cols, rows) {
|
|
187
286
|
const maxW = Math.min(Math.max(20, cols - 4), 120);
|
|
287
|
+
const docW = renderWidthForCols(cols);
|
|
188
288
|
const header = [
|
|
189
289
|
'',
|
|
190
290
|
` ${BOLD}${CYAN}Review — ${fileLabel}${RESET} ${DIM}${state.comments.length} comment${state.comments.length === 1 ? '' : 's'}${RESET}`,
|
|
@@ -217,7 +317,7 @@ export function renderReviewFrame(state, fileLabel, renderedLines, cols, rows) {
|
|
|
217
317
|
}
|
|
218
318
|
else if (state.mode === 'help') {
|
|
219
319
|
footer.push(' Keys:');
|
|
220
|
-
footer.push(` ${DIM}j/k${RESET} move anchor
|
|
320
|
+
footer.push(` ${DIM}j/k${RESET} move anchor block ${DIM}shift+j/k${RESET} extend selection`);
|
|
221
321
|
footer.push(` ${DIM}u/d, pgup/pgdn${RESET} scroll document`);
|
|
222
322
|
footer.push(` ${DIM}space c${RESET} compose comment ${DIM}space l${RESET} list comments`);
|
|
223
323
|
footer.push(` ${DIM}space u${RESET} undo last comment ${DIM}space s${RESET} submit review`);
|
|
@@ -226,21 +326,34 @@ export function renderReviewFrame(state, fileLabel, renderedLines, cols, rows) {
|
|
|
226
326
|
footer.push(` ${DIM}esc / ?${RESET} close`);
|
|
227
327
|
}
|
|
228
328
|
else {
|
|
229
|
-
|
|
230
|
-
const label = anchor.line === anchor.endLine ? `L${anchor.line}` : `L${anchor.line}-${anchor.endLine}`;
|
|
231
|
-
const preview = truncate(sanitize(state.sourceLines.slice(anchor.line - 1, anchor.endLine).join(' / ')), Math.max(10, maxW - 20));
|
|
232
|
-
footer.push(` ${DIM}Anchor:${RESET} ${CYAN}${label}${RESET} ${DIM}${preview}${RESET}`);
|
|
233
|
-
footer.push(` ${DIM}j/k${RESET} anchor ${DIM}shift-j/k${RESET} range ${DIM}u/d${RESET} scroll ${DIM}space c${RESET} comment ` +
|
|
329
|
+
footer.push(` ${DIM}j/k${RESET} block ${DIM}shift-j/k${RESET} extend ${DIM}u/d${RESET} scroll ${DIM}space c${RESET} comment ` +
|
|
234
330
|
`${DIM}space l${RESET} list ${DIM}space u${RESET} undo ${DIM}space s${RESET} submit ${DIM}?${RESET} help ${DIM}esc${RESET} close`);
|
|
235
331
|
}
|
|
236
332
|
const reserved = header.length + footer.length;
|
|
237
333
|
const bodyHeight = Math.max(1, rows - reserved);
|
|
238
|
-
const maxScroll = Math.max(0,
|
|
334
|
+
const maxScroll = Math.max(0, doc.lines.length - bodyHeight);
|
|
239
335
|
const scroll = Math.max(0, Math.min(state.scroll, maxScroll));
|
|
240
|
-
const
|
|
336
|
+
const { lo, hi } = selectedBlockBounds(state);
|
|
337
|
+
const commented = commentedBlockSet(state);
|
|
338
|
+
const body = [];
|
|
339
|
+
for (let i = 0; i < bodyHeight; i++) {
|
|
340
|
+
const abs = scroll + i;
|
|
341
|
+
if (abs >= doc.lines.length) {
|
|
342
|
+
body.push('');
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
const row = doc.lines[abs];
|
|
346
|
+
const b = doc.rows[abs] ?? null;
|
|
347
|
+
if (b !== null && b >= lo && b <= hi)
|
|
348
|
+
body.push(` ${CYAN}▌${RESET} ${tintRow(row, docW)}`);
|
|
349
|
+
else if (b !== null && commented.has(b))
|
|
350
|
+
body.push(` ${YELLOW}▎${RESET} ${row}`);
|
|
351
|
+
else
|
|
352
|
+
body.push(` ${row}`);
|
|
353
|
+
}
|
|
241
354
|
if (scroll > 0 && body.length > 0)
|
|
242
355
|
body[0] = ` ${DIM}↑ ${scroll} more above${RESET}`;
|
|
243
|
-
const remaining =
|
|
356
|
+
const remaining = doc.lines.length - (scroll + bodyHeight);
|
|
244
357
|
if (remaining > 0 && body.length > 0)
|
|
245
358
|
body[body.length - 1] = ` ${DIM}↓ ${remaining} more below${RESET}`;
|
|
246
359
|
while (body.length < bodyHeight)
|
|
@@ -253,12 +366,13 @@ export function renderReviewFrame(state, fileLabel, renderedLines, cols, rows) {
|
|
|
253
366
|
// ── Host loop (impure — the only part that touches the real TTY) ───────────
|
|
254
367
|
/**
|
|
255
368
|
* Open a Markdown file in humanloop's own terminal review surface: the
|
|
256
|
-
* document renders via termrender (Mermaid included),
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
* comment draft to `opts.output`
|
|
260
|
-
* ticket finalization stays with
|
|
261
|
-
* `completeReview`); this function
|
|
369
|
+
* document renders via termrender (Mermaid included), the anchor is a
|
|
370
|
+
* highlighted block in the rendered document itself (gutter bar + tint via
|
|
371
|
+
* termrender's row→source map), and the human explicitly submits a proposal.
|
|
372
|
+
* Never edits the source file. Autosaves the comment draft to `opts.output`
|
|
373
|
+
* (the `progress.json` convention). Canonical ticket finalization stays with
|
|
374
|
+
* the caller's `onPropose` (the adapter calls `completeReview`); this function
|
|
375
|
+
* never writes `response.json`.
|
|
262
376
|
*/
|
|
263
377
|
export async function launchTerminalReview(file, opts) {
|
|
264
378
|
const absFile = resolve(file);
|
|
@@ -266,24 +380,25 @@ export async function launchTerminalReview(file, opts) {
|
|
|
266
380
|
const sourceLines = content.split('\n');
|
|
267
381
|
const outPath = resolve(opts.output);
|
|
268
382
|
const draft = readReviewDraft(outPath);
|
|
269
|
-
let state = initReviewState(sourceLines, draft?.comments ?? [], draft?.version ?? 0);
|
|
270
383
|
const fileLabel = basename(absFile);
|
|
271
384
|
if (opts.signal?.aborted)
|
|
272
|
-
return buildDraftFeedbackResult(absFile,
|
|
385
|
+
return buildDraftFeedbackResult(absFile, draft?.comments ?? [], draft?.savedAt);
|
|
273
386
|
setupTerminal();
|
|
274
387
|
let { cols, rows } = getTerminalSize();
|
|
275
|
-
let
|
|
276
|
-
let
|
|
388
|
+
let doc = renderMarkdownWithMap(content, renderWidthForCols(cols));
|
|
389
|
+
let state = initReviewState(sourceLines, doc.blocks, draft?.comments ?? [], draft?.version ?? 0);
|
|
277
390
|
let prevFrame = [];
|
|
278
391
|
let spaceArmed = false;
|
|
279
392
|
let settled = false;
|
|
393
|
+
const bodyH = () => Math.max(1, rows - reservedRows(state, cols));
|
|
394
|
+
state = ensureAnchorVisible(state, doc, bodyH());
|
|
280
395
|
return new Promise((resolvePromise) => {
|
|
281
396
|
const persist = () => {
|
|
282
397
|
state = { ...state, version: state.version + 1 };
|
|
283
398
|
writeReviewDraft(outPath, state.comments, state.version);
|
|
284
399
|
};
|
|
285
400
|
const paint = (clear = false) => {
|
|
286
|
-
const lines = renderReviewFrame(state, fileLabel,
|
|
401
|
+
const lines = renderReviewFrame(state, fileLabel, doc, cols, rows);
|
|
287
402
|
if (clear) {
|
|
288
403
|
prevFrame = [];
|
|
289
404
|
process.stdout.write('\x1b[2J\x1b[H');
|
|
@@ -307,8 +422,20 @@ export async function launchTerminalReview(file, opts) {
|
|
|
307
422
|
};
|
|
308
423
|
const onResize = () => {
|
|
309
424
|
({ cols, rows } = getTerminalSize());
|
|
310
|
-
|
|
311
|
-
|
|
425
|
+
doc = renderMarkdownWithMap(content, renderWidthForCols(cols));
|
|
426
|
+
// Blocks derive from the source, not the width, so a same-renderer
|
|
427
|
+
// re-render keeps the count — but a mid-session renderer→fallback
|
|
428
|
+
// transition can reshape the block list, so clamp the indices. Committed
|
|
429
|
+
// comments are untouched either way (their source lines are frozen).
|
|
430
|
+
const blocks = doc.blocks;
|
|
431
|
+
const lastIdx = Math.max(0, blocks.length - 1);
|
|
432
|
+
state = {
|
|
433
|
+
...state,
|
|
434
|
+
blocks,
|
|
435
|
+
activeBlock: Math.min(state.activeBlock, lastIdx),
|
|
436
|
+
selectionAnchor: state.selectionAnchor === null ? null : Math.min(state.selectionAnchor, lastIdx),
|
|
437
|
+
};
|
|
438
|
+
state = ensureAnchorVisible(state, doc, bodyH());
|
|
312
439
|
paint(true);
|
|
313
440
|
};
|
|
314
441
|
const onAbort = () => {
|
|
@@ -405,8 +532,10 @@ export async function launchTerminalReview(file, opts) {
|
|
|
405
532
|
return;
|
|
406
533
|
}
|
|
407
534
|
if (input === 'e' || key.return) {
|
|
408
|
-
if (state.comments.length > 0)
|
|
535
|
+
if (state.comments.length > 0) {
|
|
409
536
|
state = openComposeEdit(state, state.listIndex);
|
|
537
|
+
state = ensureAnchorVisible(state, doc, bodyH());
|
|
538
|
+
}
|
|
410
539
|
return;
|
|
411
540
|
}
|
|
412
541
|
if (input === 'd') {
|
|
@@ -432,8 +561,10 @@ export async function launchTerminalReview(file, opts) {
|
|
|
432
561
|
}
|
|
433
562
|
if (spaceArmed) {
|
|
434
563
|
spaceArmed = false;
|
|
435
|
-
if (input === 'c')
|
|
564
|
+
if (input === 'c') {
|
|
436
565
|
state = openComposeNew(state);
|
|
566
|
+
state = ensureAnchorVisible(state, doc, bodyH());
|
|
567
|
+
}
|
|
437
568
|
else if (input === 'l')
|
|
438
569
|
state = openList(state);
|
|
439
570
|
else if (input === 'u') {
|
|
@@ -463,18 +594,26 @@ export async function launchTerminalReview(file, opts) {
|
|
|
463
594
|
spaceArmed = true;
|
|
464
595
|
return;
|
|
465
596
|
}
|
|
466
|
-
if (input === 'j' || key.downArrow)
|
|
467
|
-
state =
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
else if (input === '
|
|
471
|
-
state =
|
|
472
|
-
|
|
473
|
-
|
|
597
|
+
if (input === 'j' || key.downArrow) {
|
|
598
|
+
state = moveActiveBlock(state, 1, false);
|
|
599
|
+
state = ensureAnchorVisible(state, doc, bodyH());
|
|
600
|
+
}
|
|
601
|
+
else if (input === 'k' || key.upArrow) {
|
|
602
|
+
state = moveActiveBlock(state, -1, false);
|
|
603
|
+
state = ensureAnchorVisible(state, doc, bodyH());
|
|
604
|
+
}
|
|
605
|
+
else if (input === 'J') {
|
|
606
|
+
state = moveActiveBlock(state, 1, true);
|
|
607
|
+
state = ensureAnchorVisible(state, doc, bodyH());
|
|
608
|
+
}
|
|
609
|
+
else if (input === 'K') {
|
|
610
|
+
state = moveActiveBlock(state, -1, true);
|
|
611
|
+
state = ensureAnchorVisible(state, doc, bodyH());
|
|
612
|
+
}
|
|
474
613
|
else if (input === 'u' || key.pageUp)
|
|
475
|
-
state = scrollBy(state, -
|
|
614
|
+
state = scrollBy(state, -bodyH(), bodyH(), doc.lines.length);
|
|
476
615
|
else if (input === 'd' || key.pageDown)
|
|
477
|
-
state = scrollBy(state,
|
|
616
|
+
state = scrollBy(state, bodyH(), bodyH(), doc.lines.length);
|
|
478
617
|
else if (input === '?')
|
|
479
618
|
state = openHelp(state);
|
|
480
619
|
else if (key.escape) {
|
|
@@ -17,6 +17,20 @@ export declare function ensureRenderer(): void;
|
|
|
17
17
|
export declare function isRendererReady(): boolean;
|
|
18
18
|
/** Render markdown to terminal lines via the pinned binary; plaintext fallback. */
|
|
19
19
|
export declare function renderMarkdown(md: string, width: number): string[];
|
|
20
|
+
export interface RenderedDoc {
|
|
21
|
+
/** Rendered ANSI rows — byte-identical to the plain `doc render` output. */
|
|
22
|
+
lines: string[];
|
|
23
|
+
/** Per-row index into `blocks`; null for the blank separator rows between blocks. */
|
|
24
|
+
rows: (number | null)[];
|
|
25
|
+
/** Per top-level block: 1-indexed inclusive source-line bounds. Never empty. */
|
|
26
|
+
blocks: {
|
|
27
|
+
start: number;
|
|
28
|
+
end: number;
|
|
29
|
+
}[];
|
|
30
|
+
}
|
|
31
|
+
/** Render markdown with a row→source-line map via the pinned binary
|
|
32
|
+
* (`doc render --line-map`); plaintext paragraph-block fallback. */
|
|
33
|
+
export declare function renderMarkdownWithMap(md: string, width: number): RenderedDoc;
|
|
20
34
|
/** Validate markdown via `termrender doc check`. */
|
|
21
35
|
export declare function checkMarkdown(md: string): {
|
|
22
36
|
ok: true;
|
|
@@ -492,6 +492,125 @@ export function renderMarkdown(md, width) {
|
|
|
492
492
|
_bodyCache.set(key, fallback);
|
|
493
493
|
return fallback;
|
|
494
494
|
}
|
|
495
|
+
/** Validate + normalize `doc render --line-map` JSON into a RenderedDoc.
|
|
496
|
+
* Null block bounds (a scanner edge case) are filled from neighbors and
|
|
497
|
+
* clamped into the source range; an empty block list gets one whole-doc
|
|
498
|
+
* block so callers can always anchor. Malformed shape → null (tool fault). */
|
|
499
|
+
function parseRenderedDoc(out, source) {
|
|
500
|
+
let parsed;
|
|
501
|
+
try {
|
|
502
|
+
parsed = JSON.parse(out);
|
|
503
|
+
}
|
|
504
|
+
catch {
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
const p = parsed;
|
|
508
|
+
if (!Array.isArray(p.lines) || !Array.isArray(p.rows) || !Array.isArray(p.blocks))
|
|
509
|
+
return null;
|
|
510
|
+
if (p.rows.length !== p.lines.length)
|
|
511
|
+
return null;
|
|
512
|
+
if (!p.lines.every((l) => typeof l === 'string'))
|
|
513
|
+
return null;
|
|
514
|
+
const blockCount = p.blocks.length;
|
|
515
|
+
if (!p.rows.every((r) => r === null || (typeof r === 'number' && Number.isInteger(r) && r >= 0 && r < blockCount)))
|
|
516
|
+
return null;
|
|
517
|
+
const totalSource = Math.max(1, source.split('\n').length);
|
|
518
|
+
const blocks = [];
|
|
519
|
+
let prevEnd = 0;
|
|
520
|
+
for (const raw of p.blocks) {
|
|
521
|
+
if (typeof raw !== 'object' || raw === null)
|
|
522
|
+
return null;
|
|
523
|
+
// Bounds are integers or null — a fractional bound would otherwise flow
|
|
524
|
+
// into a recorded comment's line/endLine instead of tripping tool-fault.
|
|
525
|
+
if (raw.start != null && !Number.isInteger(raw.start))
|
|
526
|
+
return null;
|
|
527
|
+
if (raw.end != null && !Number.isInteger(raw.end))
|
|
528
|
+
return null;
|
|
529
|
+
const start = typeof raw.start === 'number' ? raw.start : prevEnd + 1;
|
|
530
|
+
const end = typeof raw.end === 'number' ? Math.max(raw.end, start) : start;
|
|
531
|
+
const s = Math.max(1, Math.min(start, totalSource));
|
|
532
|
+
const e = Math.max(s, Math.min(end, totalSource));
|
|
533
|
+
blocks.push({ start: s, end: e });
|
|
534
|
+
prevEnd = e;
|
|
535
|
+
}
|
|
536
|
+
if (blocks.length === 0)
|
|
537
|
+
blocks.push({ start: 1, end: totalSource });
|
|
538
|
+
return { lines: p.lines, rows: p.rows, blocks };
|
|
539
|
+
}
|
|
540
|
+
/** Renderer-free mapped render: group consecutive non-blank source lines into
|
|
541
|
+
* paragraph blocks and word-wrap each — the same shape as the termrender map,
|
|
542
|
+
* with degraded (plaintext) rendering. */
|
|
543
|
+
function fallbackDocWithMap(md, width) {
|
|
544
|
+
const src = sanitize(md).split('\n');
|
|
545
|
+
const blocks = [];
|
|
546
|
+
let openAt = null;
|
|
547
|
+
for (let i = 0; i < src.length; i++) {
|
|
548
|
+
if (src[i].trim() !== '') {
|
|
549
|
+
if (openAt === null)
|
|
550
|
+
openAt = i;
|
|
551
|
+
}
|
|
552
|
+
else if (openAt !== null) {
|
|
553
|
+
blocks.push({ start: openAt + 1, end: i });
|
|
554
|
+
openAt = null;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
if (openAt !== null)
|
|
558
|
+
blocks.push({ start: openAt + 1, end: src.length });
|
|
559
|
+
if (blocks.length === 0)
|
|
560
|
+
blocks.push({ start: 1, end: Math.max(1, src.length) });
|
|
561
|
+
const lines = [];
|
|
562
|
+
const rows = [];
|
|
563
|
+
blocks.forEach((b, bi) => {
|
|
564
|
+
if (bi > 0) {
|
|
565
|
+
lines.push('');
|
|
566
|
+
rows.push(null);
|
|
567
|
+
}
|
|
568
|
+
for (const l of wrap(src.slice(b.start - 1, b.end).join('\n'), width)) {
|
|
569
|
+
lines.push(l);
|
|
570
|
+
rows.push(bi);
|
|
571
|
+
}
|
|
572
|
+
});
|
|
573
|
+
return { lines, rows, blocks };
|
|
574
|
+
}
|
|
575
|
+
const _mapCache = new Map();
|
|
576
|
+
/** Render markdown with a row→source-line map via the pinned binary
|
|
577
|
+
* (`doc render --line-map`); plaintext paragraph-block fallback. */
|
|
578
|
+
export function renderMarkdownWithMap(md, width) {
|
|
579
|
+
const key = `${md}\0${width}`;
|
|
580
|
+
const cached = _mapCache.get(key);
|
|
581
|
+
if (cached)
|
|
582
|
+
return cached;
|
|
583
|
+
ensureRenderer();
|
|
584
|
+
if (rendererState === 'ready') {
|
|
585
|
+
try {
|
|
586
|
+
const out = execFileSync(VENV_BIN, ['doc', 'render', '--width', String(width), '--color', 'on', '--line-map'], {
|
|
587
|
+
input: md,
|
|
588
|
+
encoding: 'utf-8',
|
|
589
|
+
timeout: 5000,
|
|
590
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
591
|
+
// JSON-escaped ANSI inflates well past execFileSync's 1MB default.
|
|
592
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
593
|
+
});
|
|
594
|
+
const doc = parseRenderedDoc(out.trim(), md);
|
|
595
|
+
if (doc !== null) {
|
|
596
|
+
_mapCache.set(key, doc);
|
|
597
|
+
return doc;
|
|
598
|
+
}
|
|
599
|
+
// A working renderer never emits a malformed map — tool fault.
|
|
600
|
+
invalidateRenderer('doc render --line-map (malformed output)');
|
|
601
|
+
}
|
|
602
|
+
catch (err) {
|
|
603
|
+
// Same contract as renderMarkdown: non-timeout failure implicates the
|
|
604
|
+
// tool; a timeout is a slow/large doc — just fall to the local map.
|
|
605
|
+
if (!isTimeout(err))
|
|
606
|
+
invalidateRenderer('doc render --line-map');
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
// The fallback is cheap to recompute and deliberately NOT cached: a
|
|
610
|
+
// transient failure (e.g. a timeout) must not pin degraded block anchoring
|
|
611
|
+
// for the rest of the session when a later re-render could succeed.
|
|
612
|
+
return fallbackDocWithMap(md, width);
|
|
613
|
+
}
|
|
495
614
|
/** Validate markdown via `termrender doc check`. */
|
|
496
615
|
export function checkMarkdown(md) {
|
|
497
616
|
ensureRenderer();
|
package/dist/render/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const TERMRENDER_VERSION = "4.
|
|
1
|
+
export declare const TERMRENDER_VERSION = "4.11.0";
|
package/dist/render/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const TERMRENDER_VERSION = '4.
|
|
1
|
+
export const TERMRENDER_VERSION = '4.11.0';
|