@crouton-kit/humanloop 0.4.3 → 0.4.4

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