@crouton-kit/humanloop 0.4.4 → 0.4.6

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