@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.
@@ -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,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
- /** 1-based active source line. */
20
- activeLine: number;
21
- /** Set while a Shift+j/k range is being extended from this line. */
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
- export declare function initReviewState(sourceLines: string[], comments: FeedbackComment[], version: number): ReviewState;
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 moveActiveLine(state: ReviewState, delta: number, extend: boolean): ReviewState;
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
- export declare function reservedRows(state: ReviewState): number;
66
- export declare function renderReviewFrame(state: ReviewState, fileLabel: string, renderedLines: string[], cols: number, rows: number): string[];
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), source-line anchors
70
- * drive comments independent of rendered scroll position, and the human
71
- * explicitly submits a proposal. Never edits the source file. Autosaves the
72
- * comment draft to `opts.output` (the `progress.json` convention). Canonical
73
- * ticket finalization stays with the caller's `onPropose` (the adapter calls
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 { 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';
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 { renderMarkdown } from '../render/termrender.js';
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
- export function initReviewState(sourceLines, comments, version) {
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
- activeLine: comments.length > 0 ? comments[0].line : 1,
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
- if (state.selectionAnchor === null)
24
- return { line: state.activeLine, endLine: state.activeLine };
25
- return { line: Math.min(state.selectionAnchor, state.activeLine), endLine: Math.max(state.selectionAnchor, state.activeLine) };
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 moveActiveLine(state, delta, extend) {
28
- const total = Math.max(1, state.sourceLines.length);
29
- const next = Math.max(1, Math.min(total, state.activeLine + delta));
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, activeLine: next, selectionAnchor: null };
32
- return { ...state, activeLine: next, selectionAnchor: state.selectionAnchor ?? state.activeLine };
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
- export function reservedRows(state) {
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 + 2;
278
+ return header + 1;
177
279
  if (state.mode === 'compose') {
178
280
  const c = state.compose;
179
- const bufLines = c ? Math.max(1, c.buffer.split('\n').length) : 1;
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 + 8; // help
287
+ return header + 9; // help
185
288
  }
186
- export function renderReviewFrame(state, fileLabel, renderedLines, cols, rows) {
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 line ${DIM}shift+j/k${RESET} extend range`);
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
- const anchor = currentAnchor(state);
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 ` +
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, renderedLines.length - bodyHeight);
339
+ const maxScroll = Math.max(0, doc.lines.length - bodyHeight);
239
340
  const scroll = Math.max(0, Math.min(state.scroll, maxScroll));
240
- const body = renderedLines.slice(scroll, scroll + bodyHeight).map((l) => ` ${l}`);
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 = renderedLines.length - (scroll + bodyHeight);
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
- // ── Host loop (impure — the only part that touches the real TTY) ───────────
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), source-line anchors
257
- * drive comments independent of rendered scroll position, and the human
258
- * explicitly submits a proposal. Never edits the source file. Autosaves the
259
- * comment draft to `opts.output` (the `progress.json` convention). Canonical
260
- * ticket finalization stays with the caller's `onPropose` (the adapter calls
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
- if (opts.signal?.aborted)
272
- return buildDraftFeedbackResult(absFile, state.comments, draft?.savedAt);
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 renderedWidth = Math.min(Math.max(20, cols - 4), 120);
276
- let renderedLines = renderMarkdown(content, renderedWidth);
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, renderedLines, cols, rows);
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 finish = (result) => {
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(result);
485
+ resolvePromise(outcome);
307
486
  };
487
+ const finish = (result) => finishWith({ type: 'done', result });
308
488
  const onResize = () => {
309
489
  ({ cols, rows } = getTerminalSize());
310
- renderedWidth = Math.min(Math.max(20, cols - 4), 120);
311
- renderedLines = renderMarkdown(content, renderedWidth);
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 = moveActiveLine(state, 1, false);
468
- else if (input === 'k' || key.upArrow)
469
- state = moveActiveLine(state, -1, false);
470
- else if (input === 'J')
471
- state = moveActiveLine(state, 1, true);
472
- else if (input === 'K')
473
- state = moveActiveLine(state, -1, true);
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, -Math.max(1, rows - reservedRows(state)), Math.max(1, rows - reservedRows(state)), renderedLines.length);
684
+ state = scrollBy(state, -bodyH(), bodyH(), doc.lines.length);
476
685
  else if (input === 'd' || key.pageDown)
477
- state = scrollBy(state, Math.max(1, rows - reservedRows(state)), Math.max(1, rows - reservedRows(state)), renderedLines.length);
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();
@@ -1 +1 @@
1
- export declare const TERMRENDER_VERSION = "4.10.7";
1
+ export declare const TERMRENDER_VERSION = "4.11.0";
@@ -1 +1 @@
1
- export const TERMRENDER_VERSION = '4.10.7';
1
+ export const TERMRENDER_VERSION = '4.11.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/humanloop",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "Human-in-the-loop decision TUI — agents write questions, humans answer them",
5
5
  "engines": {
6
6
  "node": ">=22.19.0"