@crouton-kit/humanloop 0.4.3 → 0.4.5
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 +54 -13
- package/dist/editor/terminal-review.js +269 -60
- package/dist/render/termrender.d.ts +14 -0
- package/dist/render/termrender.js +119 -0
- 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,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,24 @@ 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
|
-
*
|
|
73
|
-
*
|
|
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
|
+
* `space w` hands the review off to the browser surface (same park/take-back
|
|
111
|
+
* semantics as the Neovim path); the draft round-trips between surfaces via
|
|
112
|
+
* `opts.output`. Never edits the source file. Autosaves the comment draft to
|
|
113
|
+
* `opts.output` (the `progress.json` convention). Canonical ticket
|
|
114
|
+
* finalization stays with the caller's `onPropose` (the adapter calls
|
|
74
115
|
* `completeReview`); this function never writes `response.json`.
|
|
75
116
|
*/
|
|
76
117
|
export declare function launchTerminalReview(file: string, opts: ReviewOptions): Promise<FeedbackResult>;
|
|
@@ -1,17 +1,33 @@
|
|
|
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';
|
|
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';
|
|
4
9
|
import { buildDraftFeedbackResult, buildFinalFeedbackResult, readReviewDraft, writeReviewDraft } from './feedback.js';
|
|
5
|
-
import {
|
|
10
|
+
import { renderMarkdownWithMap } from '../render/termrender.js';
|
|
6
11
|
import { setupTerminal, restoreTerminal, getTerminalSize, parseKeypress } from '../tui/terminal.js';
|
|
7
12
|
import { diffFrame, renderInputBuffer } from '../tui/render.js';
|
|
8
|
-
import { BOLD, CYAN, DIM, RESET, YELLOW, hline, sanitize, truncate } from '../tui/ansi.js';
|
|
9
|
-
|
|
13
|
+
import { BOLD, CYAN, DIM, ESC, RESET, YELLOW, hline, sanitize, truncate } from '../tui/ansi.js';
|
|
14
|
+
/** Index of the block containing `line`; snaps forward across gaps (blank
|
|
15
|
+
* separator lines between blocks) and clamps to the last block. */
|
|
16
|
+
export function blockIndexForLine(blocks, line) {
|
|
17
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
18
|
+
if (blocks[i].end >= line)
|
|
19
|
+
return i;
|
|
20
|
+
}
|
|
21
|
+
return Math.max(0, blocks.length - 1);
|
|
22
|
+
}
|
|
23
|
+
export function initReviewState(sourceLines, blocks, comments, version) {
|
|
24
|
+
const safeBlocks = blocks.length > 0 ? blocks : [{ start: 1, end: Math.max(1, sourceLines.length) }];
|
|
10
25
|
return {
|
|
11
26
|
sourceLines,
|
|
27
|
+
blocks: safeBlocks,
|
|
12
28
|
comments: [...comments],
|
|
13
29
|
version,
|
|
14
|
-
|
|
30
|
+
activeBlock: comments.length > 0 ? blockIndexForLine(safeBlocks, comments[0].line) : 0,
|
|
15
31
|
selectionAnchor: null,
|
|
16
32
|
scroll: 0,
|
|
17
33
|
mode: 'view',
|
|
@@ -19,17 +35,84 @@ export function initReviewState(sourceLines, comments, version) {
|
|
|
19
35
|
listIndex: 0,
|
|
20
36
|
};
|
|
21
37
|
}
|
|
38
|
+
function selectedBlockBounds(state) {
|
|
39
|
+
const sel = state.selectionAnchor ?? state.activeBlock;
|
|
40
|
+
return { lo: Math.min(state.activeBlock, sel), hi: Math.max(state.activeBlock, sel) };
|
|
41
|
+
}
|
|
42
|
+
/** Source-line anchor of the current block selection — what a committed
|
|
43
|
+
* comment records as `line`/`endLine`. */
|
|
22
44
|
export function currentAnchor(state) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
45
|
+
const { lo, hi } = selectedBlockBounds(state);
|
|
46
|
+
const first = state.blocks[lo] ?? { start: 1, end: Math.max(1, state.sourceLines.length) };
|
|
47
|
+
const last = state.blocks[hi] ?? first;
|
|
48
|
+
return { line: first.start, endLine: last.end };
|
|
26
49
|
}
|
|
27
|
-
export function
|
|
28
|
-
const
|
|
29
|
-
const next = Math.max(
|
|
50
|
+
export function moveActiveBlock(state, delta, extend) {
|
|
51
|
+
const last = Math.max(0, state.blocks.length - 1);
|
|
52
|
+
const next = Math.max(0, Math.min(last, state.activeBlock + delta));
|
|
30
53
|
if (!extend)
|
|
31
|
-
return { ...state,
|
|
32
|
-
return { ...state,
|
|
54
|
+
return { ...state, activeBlock: next, selectionAnchor: null };
|
|
55
|
+
return { ...state, activeBlock: next, selectionAnchor: state.selectionAnchor ?? state.activeBlock };
|
|
56
|
+
}
|
|
57
|
+
/** Block indices overlapped by any existing comment's source range. A stale
|
|
58
|
+
* draft range lying wholly past the last block (the file shrank between draft
|
|
59
|
+
* save and reopen) still gets a marker on its clamped block, so every comment
|
|
60
|
+
* is visible somewhere in the document. */
|
|
61
|
+
export function commentedBlockSet(state) {
|
|
62
|
+
const out = new Set();
|
|
63
|
+
for (const c of state.comments) {
|
|
64
|
+
let hit = false;
|
|
65
|
+
state.blocks.forEach((b, i) => {
|
|
66
|
+
if (b.start <= c.endLine && b.end >= c.line) {
|
|
67
|
+
out.add(i);
|
|
68
|
+
hit = true;
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
if (!hit)
|
|
72
|
+
out.add(blockIndexForLine(state.blocks, c.line));
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
/** First/last rendered-row indices produced by blocks `lo..hi`, or null when
|
|
77
|
+
* no row maps into that range. */
|
|
78
|
+
export function anchorRowRange(rows, lo, hi) {
|
|
79
|
+
let first = -1;
|
|
80
|
+
let last = -1;
|
|
81
|
+
for (let r = 0; r < rows.length; r++) {
|
|
82
|
+
const b = rows[r];
|
|
83
|
+
if (b !== null && b >= lo && b <= hi) {
|
|
84
|
+
if (first === -1)
|
|
85
|
+
first = r;
|
|
86
|
+
last = r;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return first === -1 ? null : { first, last };
|
|
90
|
+
}
|
|
91
|
+
/** Minimal scroll adjustment that keeps rows `first..last` visible with
|
|
92
|
+
* `margin` rows of context (the margin yields whenever honoring it would push
|
|
93
|
+
* either edge of the span out of view); a taller-than-view span pins `first`
|
|
94
|
+
* at the top — one row down when scrolled, so the `↑ more above` indicator
|
|
95
|
+
* overwrites the preceding row, never the pinned one. Always returns a scroll
|
|
96
|
+
* clamped to the document. */
|
|
97
|
+
export function scrollToReveal(scroll, first, last, bodyHeight, total, margin = 2) {
|
|
98
|
+
const maxScroll = Math.max(0, total - bodyHeight);
|
|
99
|
+
let next = Math.max(0, Math.min(scroll, maxScroll));
|
|
100
|
+
if (last - first + 1 >= bodyHeight)
|
|
101
|
+
return Math.max(0, Math.min(Math.max(0, first - 1), maxScroll));
|
|
102
|
+
if (first - margin < next)
|
|
103
|
+
next = Math.max(first - margin, last - (bodyHeight - 1));
|
|
104
|
+
else if (last + margin > next + bodyHeight - 1)
|
|
105
|
+
next = Math.min(last + margin - (bodyHeight - 1), first);
|
|
106
|
+
return Math.max(0, Math.min(next, maxScroll));
|
|
107
|
+
}
|
|
108
|
+
/** Auto-scroll so the active block (the moving edge of a selection) is
|
|
109
|
+
* visible. Free-scrolling (`u`/`d`) is untouched until the next anchor move. */
|
|
110
|
+
export function ensureAnchorVisible(state, doc, bodyHeight) {
|
|
111
|
+
const range = anchorRowRange(doc.rows, state.activeBlock, state.activeBlock);
|
|
112
|
+
if (range === null)
|
|
113
|
+
return state;
|
|
114
|
+
const scroll = scrollToReveal(state.scroll, range.first, range.last, bodyHeight, doc.lines.length);
|
|
115
|
+
return scroll === state.scroll ? state : { ...state, scroll };
|
|
33
116
|
}
|
|
34
117
|
export function scrollBy(state, delta, bodyHeight, totalLines) {
|
|
35
118
|
const maxScroll = Math.max(0, totalLines - bodyHeight);
|
|
@@ -43,8 +126,12 @@ export function openComposeEdit(state, index) {
|
|
|
43
126
|
if (comment === undefined)
|
|
44
127
|
return state;
|
|
45
128
|
const buffer = comment.comment;
|
|
129
|
+
const lo = blockIndexForLine(state.blocks, comment.line);
|
|
130
|
+
const hi = blockIndexForLine(state.blocks, comment.endLine);
|
|
46
131
|
return {
|
|
47
132
|
...state,
|
|
133
|
+
activeBlock: hi,
|
|
134
|
+
selectionAnchor: lo === hi ? null : lo,
|
|
48
135
|
mode: 'compose',
|
|
49
136
|
compose: { buffer, cursor: [...buffer].length, editingId: comment.id, returnMode: 'list', anchor: { line: comment.line, endLine: comment.endLine } },
|
|
50
137
|
};
|
|
@@ -168,23 +255,40 @@ export function textVertical(buffer, cursor, delta) {
|
|
|
168
255
|
return Math.min(nextEnd, nextStart + col);
|
|
169
256
|
}
|
|
170
257
|
// ── Rendering (pure) ─────────────────────────────────────────────────────────
|
|
258
|
+
/** Width the document is rendered at for a given terminal width: 2-col left
|
|
259
|
+
* margin + 2-col gutter before the content, capped for readability. */
|
|
260
|
+
export function renderWidthForCols(cols) {
|
|
261
|
+
return Math.min(Math.max(20, cols - 6), 120);
|
|
262
|
+
}
|
|
263
|
+
// Single-hue anchor tint (a dark neutral step below the CYAN gutter bar).
|
|
264
|
+
// termrender's only reset is `\x1b[0m`, so re-arming the background after each
|
|
265
|
+
// reset keeps the tint alive across inner styling. Heading rows carry their
|
|
266
|
+
// own background and won't show the tint — the gutter bar still marks them.
|
|
267
|
+
const TINT_BG = `${ESC}48;5;236m`;
|
|
268
|
+
function tintRow(row, width) {
|
|
269
|
+
const pad = Math.max(0, width - stringWidth(sanitize(row)));
|
|
270
|
+
return TINT_BG + row.replaceAll(RESET, RESET + TINT_BG) + ' '.repeat(pad) + RESET;
|
|
271
|
+
}
|
|
171
272
|
/** 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
|
-
|
|
273
|
+
* sync with renderReviewFrame so scroll math can clamp before a repaint — the
|
|
274
|
+
* compose case counts the same hard-wrapped input lines the frame renders. */
|
|
275
|
+
export function reservedRows(state, cols) {
|
|
174
276
|
const header = 3; // blank, title, divider
|
|
175
277
|
if (state.mode === 'view')
|
|
176
|
-
return header +
|
|
278
|
+
return header + 1;
|
|
177
279
|
if (state.mode === 'compose') {
|
|
178
280
|
const c = state.compose;
|
|
179
|
-
const
|
|
281
|
+
const maxW = Math.min(Math.max(20, cols - 4), 120);
|
|
282
|
+
const bufLines = c ? renderInputBuffer(c.buffer, c.cursor, Math.max(10, maxW - 2)).length : 1;
|
|
180
283
|
return header + 3 + bufLines;
|
|
181
284
|
}
|
|
182
285
|
if (state.mode === 'list')
|
|
183
286
|
return header + Math.max(1, state.comments.length) + 2;
|
|
184
|
-
return header +
|
|
287
|
+
return header + 9; // help
|
|
185
288
|
}
|
|
186
|
-
export function renderReviewFrame(state, fileLabel,
|
|
289
|
+
export function renderReviewFrame(state, fileLabel, doc, cols, rows) {
|
|
187
290
|
const maxW = Math.min(Math.max(20, cols - 4), 120);
|
|
291
|
+
const docW = renderWidthForCols(cols);
|
|
188
292
|
const header = [
|
|
189
293
|
'',
|
|
190
294
|
` ${BOLD}${CYAN}Review — ${fileLabel}${RESET} ${DIM}${state.comments.length} comment${state.comments.length === 1 ? '' : 's'}${RESET}`,
|
|
@@ -217,30 +321,44 @@ export function renderReviewFrame(state, fileLabel, renderedLines, cols, rows) {
|
|
|
217
321
|
}
|
|
218
322
|
else if (state.mode === 'help') {
|
|
219
323
|
footer.push(' Keys:');
|
|
220
|
-
footer.push(` ${DIM}j/k${RESET} move anchor
|
|
324
|
+
footer.push(` ${DIM}j/k${RESET} move anchor block ${DIM}shift+j/k${RESET} extend selection`);
|
|
221
325
|
footer.push(` ${DIM}u/d, pgup/pgdn${RESET} scroll document`);
|
|
222
326
|
footer.push(` ${DIM}space c${RESET} compose comment ${DIM}space l${RESET} list comments`);
|
|
223
327
|
footer.push(` ${DIM}space u${RESET} undo last comment ${DIM}space s${RESET} submit review`);
|
|
328
|
+
footer.push(` ${DIM}space w${RESET} hand off to browser`);
|
|
224
329
|
footer.push(` ${DIM}?${RESET} toggle this help ${DIM}esc${RESET} close/cancel`);
|
|
225
330
|
footer.push('');
|
|
226
331
|
footer.push(` ${DIM}esc / ?${RESET} close`);
|
|
227
332
|
}
|
|
228
333
|
else {
|
|
229
|
-
|
|
230
|
-
|
|
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 ` +
|
|
234
|
-
`${DIM}space l${RESET} list ${DIM}space u${RESET} undo ${DIM}space s${RESET} submit ${DIM}?${RESET} help ${DIM}esc${RESET} close`);
|
|
334
|
+
footer.push(` ${DIM}j/k${RESET} block ${DIM}shift-j/k${RESET} extend ${DIM}u/d${RESET} scroll ${DIM}space c${RESET} comment ` +
|
|
335
|
+
`${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`);
|
|
235
336
|
}
|
|
236
337
|
const reserved = header.length + footer.length;
|
|
237
338
|
const bodyHeight = Math.max(1, rows - reserved);
|
|
238
|
-
const maxScroll = Math.max(0,
|
|
339
|
+
const maxScroll = Math.max(0, doc.lines.length - bodyHeight);
|
|
239
340
|
const scroll = Math.max(0, Math.min(state.scroll, maxScroll));
|
|
240
|
-
const
|
|
341
|
+
const { lo, hi } = selectedBlockBounds(state);
|
|
342
|
+
const commented = commentedBlockSet(state);
|
|
343
|
+
const body = [];
|
|
344
|
+
for (let i = 0; i < bodyHeight; i++) {
|
|
345
|
+
const abs = scroll + i;
|
|
346
|
+
if (abs >= doc.lines.length) {
|
|
347
|
+
body.push('');
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
const row = doc.lines[abs];
|
|
351
|
+
const b = doc.rows[abs] ?? null;
|
|
352
|
+
if (b !== null && b >= lo && b <= hi)
|
|
353
|
+
body.push(` ${CYAN}▌${RESET} ${tintRow(row, docW)}`);
|
|
354
|
+
else if (b !== null && commented.has(b))
|
|
355
|
+
body.push(` ${YELLOW}▎${RESET} ${row}`);
|
|
356
|
+
else
|
|
357
|
+
body.push(` ${row}`);
|
|
358
|
+
}
|
|
241
359
|
if (scroll > 0 && body.length > 0)
|
|
242
360
|
body[0] = ` ${DIM}↑ ${scroll} more above${RESET}`;
|
|
243
|
-
const remaining =
|
|
361
|
+
const remaining = doc.lines.length - (scroll + bodyHeight);
|
|
244
362
|
if (remaining > 0 && body.length > 0)
|
|
245
363
|
body[body.length - 1] = ` ${DIM}↓ ${remaining} more below${RESET}`;
|
|
246
364
|
while (body.length < bodyHeight)
|
|
@@ -250,40 +368,101 @@ export function renderReviewFrame(state, fileLabel, renderedLines, cols, rows) {
|
|
|
250
368
|
lines.push('');
|
|
251
369
|
return lines.slice(0, rows);
|
|
252
370
|
}
|
|
253
|
-
|
|
371
|
+
function diskDraftResult(absFile, outPath) {
|
|
372
|
+
const draft = readReviewDraft(outPath);
|
|
373
|
+
return buildDraftFeedbackResult(absFile, draft?.comments ?? [], draft?.savedAt);
|
|
374
|
+
}
|
|
254
375
|
/**
|
|
255
376
|
* Open a Markdown file in humanloop's own terminal review surface: the
|
|
256
|
-
* document renders via termrender (Mermaid included),
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
377
|
+
* document renders via termrender (Mermaid included), the anchor is a
|
|
378
|
+
* highlighted block in the rendered document itself (gutter bar + tint via
|
|
379
|
+
* termrender's row→source map), and the human explicitly submits a proposal.
|
|
380
|
+
* `space w` hands the review off to the browser surface (same park/take-back
|
|
381
|
+
* semantics as the Neovim path); the draft round-trips between surfaces via
|
|
382
|
+
* `opts.output`. Never edits the source file. Autosaves the comment draft to
|
|
383
|
+
* `opts.output` (the `progress.json` convention). Canonical ticket
|
|
384
|
+
* finalization stays with the caller's `onPropose` (the adapter calls
|
|
261
385
|
* `completeReview`); this function never writes `response.json`.
|
|
262
386
|
*/
|
|
263
387
|
export async function launchTerminalReview(file, opts) {
|
|
264
388
|
const absFile = resolve(file);
|
|
265
389
|
const content = readFileSync(absFile, 'utf8');
|
|
266
|
-
const sourceLines = content.split('\n');
|
|
267
390
|
const outPath = resolve(opts.output);
|
|
268
|
-
const draft = readReviewDraft(outPath);
|
|
269
|
-
let state = initReviewState(sourceLines, draft?.comments ?? [], draft?.version ?? 0);
|
|
270
391
|
const fileLabel = basename(absFile);
|
|
271
|
-
|
|
272
|
-
|
|
392
|
+
// Created lazily on the first browser handoff, reused across take-backs.
|
|
393
|
+
let jobDir = opts.jobDir ?? null;
|
|
394
|
+
while (true) {
|
|
395
|
+
if (opts.signal?.aborted)
|
|
396
|
+
return diskDraftResult(absFile, outPath);
|
|
397
|
+
const outcome = await runTerminalReviewSession(absFile, content, outPath, fileLabel, opts);
|
|
398
|
+
if (outcome.type === 'done')
|
|
399
|
+
return outcome.result;
|
|
400
|
+
// Browser handoff: the TUI is parked (terminal already restored, draft
|
|
401
|
+
// persisted); the browser is the editing authority until it submits, the
|
|
402
|
+
// human takes back with `w`, or the review is cancelled.
|
|
403
|
+
if (jobDir === null)
|
|
404
|
+
jobDir = mkdtempSync(join(tmpdir(), 'hl-review-'));
|
|
405
|
+
let resolveSubmitted;
|
|
406
|
+
const submitted = new Promise((resolveSubmittedPromise) => {
|
|
407
|
+
resolveSubmitted = resolveSubmittedPromise;
|
|
408
|
+
});
|
|
409
|
+
const server = await startReviewWebServer({
|
|
410
|
+
jobDir,
|
|
411
|
+
file: absFile,
|
|
412
|
+
output: outPath,
|
|
413
|
+
onSubmit: (result) => resolveSubmitted(result),
|
|
414
|
+
});
|
|
415
|
+
if (opts.signal?.aborted) {
|
|
416
|
+
await server.stop();
|
|
417
|
+
return diskDraftResult(absFile, outPath);
|
|
418
|
+
}
|
|
419
|
+
server.activate();
|
|
420
|
+
process.stderr.write(`humanloop: browser review handoff active — ${server.url}\n`);
|
|
421
|
+
openBrowser(server.url);
|
|
422
|
+
const action = await waitForParkedReviewSubmit(submitted, opts.signal, 'the terminal review');
|
|
423
|
+
if (action.type === 'submitted') {
|
|
424
|
+
await server.stop();
|
|
425
|
+
// An abort landing during the async stop() must not produce a submitted
|
|
426
|
+
// result whose canonical onPropose convergence was skipped — fall back
|
|
427
|
+
// to the disk draft (the browser submit already persisted its comments).
|
|
428
|
+
if (opts.signal?.aborted)
|
|
429
|
+
return diskDraftResult(absFile, outPath);
|
|
430
|
+
await opts.onPropose?.(action.result);
|
|
431
|
+
return action.result;
|
|
432
|
+
}
|
|
433
|
+
if (action.type === 'take-back') {
|
|
434
|
+
await server.requestTakeBack();
|
|
435
|
+
await server.stop();
|
|
436
|
+
process.stderr.write('humanloop: taking review back into the terminal review.\n');
|
|
437
|
+
continue; // re-enter the TUI; the next session re-reads the draft from disk
|
|
438
|
+
}
|
|
439
|
+
await server.stop();
|
|
440
|
+
return diskDraftResult(absFile, outPath);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
/** One TUI session over the document. Resolves when the review reaches a
|
|
444
|
+
* final result (submit/close/cancel) or the human requests browser handoff;
|
|
445
|
+
* either way the terminal is restored before resolution. Re-reads the draft
|
|
446
|
+
* from disk on entry so browser edits survive a take-back. */
|
|
447
|
+
function runTerminalReviewSession(absFile, content, outPath, fileLabel, opts) {
|
|
448
|
+
const sourceLines = content.split('\n');
|
|
449
|
+
const draft = readReviewDraft(outPath);
|
|
273
450
|
setupTerminal();
|
|
274
451
|
let { cols, rows } = getTerminalSize();
|
|
275
|
-
let
|
|
276
|
-
let
|
|
452
|
+
let doc = renderMarkdownWithMap(content, renderWidthForCols(cols));
|
|
453
|
+
let state = initReviewState(sourceLines, doc.blocks, draft?.comments ?? [], draft?.version ?? 0);
|
|
277
454
|
let prevFrame = [];
|
|
278
455
|
let spaceArmed = false;
|
|
279
456
|
let settled = false;
|
|
457
|
+
const bodyH = () => Math.max(1, rows - reservedRows(state, cols));
|
|
458
|
+
state = ensureAnchorVisible(state, doc, bodyH());
|
|
280
459
|
return new Promise((resolvePromise) => {
|
|
281
460
|
const persist = () => {
|
|
282
461
|
state = { ...state, version: state.version + 1 };
|
|
283
462
|
writeReviewDraft(outPath, state.comments, state.version);
|
|
284
463
|
};
|
|
285
464
|
const paint = (clear = false) => {
|
|
286
|
-
const lines = renderReviewFrame(state, fileLabel,
|
|
465
|
+
const lines = renderReviewFrame(state, fileLabel, doc, cols, rows);
|
|
287
466
|
if (clear) {
|
|
288
467
|
prevFrame = [];
|
|
289
468
|
process.stdout.write('\x1b[2J\x1b[H');
|
|
@@ -295,7 +474,7 @@ export async function launchTerminalReview(file, opts) {
|
|
|
295
474
|
process.stdout.write('\x1b[?2026l');
|
|
296
475
|
prevFrame = nextPrevFrame;
|
|
297
476
|
};
|
|
298
|
-
const
|
|
477
|
+
const finishWith = (outcome) => {
|
|
299
478
|
if (settled)
|
|
300
479
|
return;
|
|
301
480
|
settled = true;
|
|
@@ -303,12 +482,25 @@ export async function launchTerminalReview(file, opts) {
|
|
|
303
482
|
process.stdout.removeListener('resize', onResize);
|
|
304
483
|
opts.signal?.removeEventListener('abort', onAbort);
|
|
305
484
|
restoreTerminal();
|
|
306
|
-
resolvePromise(
|
|
485
|
+
resolvePromise(outcome);
|
|
307
486
|
};
|
|
487
|
+
const finish = (result) => finishWith({ type: 'done', result });
|
|
308
488
|
const onResize = () => {
|
|
309
489
|
({ cols, rows } = getTerminalSize());
|
|
310
|
-
|
|
311
|
-
|
|
490
|
+
doc = renderMarkdownWithMap(content, renderWidthForCols(cols));
|
|
491
|
+
// Blocks derive from the source, not the width, so a same-renderer
|
|
492
|
+
// re-render keeps the count — but a mid-session renderer→fallback
|
|
493
|
+
// transition can reshape the block list, so clamp the indices. Committed
|
|
494
|
+
// comments are untouched either way (their source lines are frozen).
|
|
495
|
+
const blocks = doc.blocks;
|
|
496
|
+
const lastIdx = Math.max(0, blocks.length - 1);
|
|
497
|
+
state = {
|
|
498
|
+
...state,
|
|
499
|
+
blocks,
|
|
500
|
+
activeBlock: Math.min(state.activeBlock, lastIdx),
|
|
501
|
+
selectionAnchor: state.selectionAnchor === null ? null : Math.min(state.selectionAnchor, lastIdx),
|
|
502
|
+
};
|
|
503
|
+
state = ensureAnchorVisible(state, doc, bodyH());
|
|
312
504
|
paint(true);
|
|
313
505
|
};
|
|
314
506
|
const onAbort = () => {
|
|
@@ -405,8 +597,10 @@ export async function launchTerminalReview(file, opts) {
|
|
|
405
597
|
return;
|
|
406
598
|
}
|
|
407
599
|
if (input === 'e' || key.return) {
|
|
408
|
-
if (state.comments.length > 0)
|
|
600
|
+
if (state.comments.length > 0) {
|
|
409
601
|
state = openComposeEdit(state, state.listIndex);
|
|
602
|
+
state = ensureAnchorVisible(state, doc, bodyH());
|
|
603
|
+
}
|
|
410
604
|
return;
|
|
411
605
|
}
|
|
412
606
|
if (input === 'd') {
|
|
@@ -432,8 +626,10 @@ export async function launchTerminalReview(file, opts) {
|
|
|
432
626
|
}
|
|
433
627
|
if (spaceArmed) {
|
|
434
628
|
spaceArmed = false;
|
|
435
|
-
if (input === 'c')
|
|
629
|
+
if (input === 'c') {
|
|
436
630
|
state = openComposeNew(state);
|
|
631
|
+
state = ensureAnchorVisible(state, doc, bodyH());
|
|
632
|
+
}
|
|
437
633
|
else if (input === 'l')
|
|
438
634
|
state = openList(state);
|
|
439
635
|
else if (input === 'u') {
|
|
@@ -444,6 +640,11 @@ export async function launchTerminalReview(file, opts) {
|
|
|
444
640
|
void submit();
|
|
445
641
|
return;
|
|
446
642
|
}
|
|
643
|
+
else if (input === 'w') {
|
|
644
|
+
persist();
|
|
645
|
+
finishWith({ type: 'handoff' });
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
447
648
|
paint();
|
|
448
649
|
return;
|
|
449
650
|
}
|
|
@@ -463,18 +664,26 @@ export async function launchTerminalReview(file, opts) {
|
|
|
463
664
|
spaceArmed = true;
|
|
464
665
|
return;
|
|
465
666
|
}
|
|
466
|
-
if (input === 'j' || key.downArrow)
|
|
467
|
-
state =
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
else if (input === '
|
|
471
|
-
state =
|
|
472
|
-
|
|
473
|
-
|
|
667
|
+
if (input === 'j' || key.downArrow) {
|
|
668
|
+
state = moveActiveBlock(state, 1, false);
|
|
669
|
+
state = ensureAnchorVisible(state, doc, bodyH());
|
|
670
|
+
}
|
|
671
|
+
else if (input === 'k' || key.upArrow) {
|
|
672
|
+
state = moveActiveBlock(state, -1, false);
|
|
673
|
+
state = ensureAnchorVisible(state, doc, bodyH());
|
|
674
|
+
}
|
|
675
|
+
else if (input === 'J') {
|
|
676
|
+
state = moveActiveBlock(state, 1, true);
|
|
677
|
+
state = ensureAnchorVisible(state, doc, bodyH());
|
|
678
|
+
}
|
|
679
|
+
else if (input === 'K') {
|
|
680
|
+
state = moveActiveBlock(state, -1, true);
|
|
681
|
+
state = ensureAnchorVisible(state, doc, bodyH());
|
|
682
|
+
}
|
|
474
683
|
else if (input === 'u' || key.pageUp)
|
|
475
|
-
state = scrollBy(state, -
|
|
684
|
+
state = scrollBy(state, -bodyH(), bodyH(), doc.lines.length);
|
|
476
685
|
else if (input === 'd' || key.pageDown)
|
|
477
|
-
state = scrollBy(state,
|
|
686
|
+
state = scrollBy(state, bodyH(), bodyH(), doc.lines.length);
|
|
478
687
|
else if (input === '?')
|
|
479
688
|
state = openHelp(state);
|
|
480
689
|
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';
|