@crouton-kit/humanloop 0.4.2 → 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.
@@ -24,9 +24,10 @@ export declare function reviewVimscript(): string;
24
24
  */
25
25
  export declare function formatFeedbackSummary(result: FeedbackResult, feedbackJsonPath: string): string;
26
26
  /**
27
- * Open a markdown file in a clean, read-only Neovim/Vim review session. The
28
- * human anchors comments to source lines/selections with native vim motions
29
- * and explicitly submits a proposal. Blocks until the editor exits. Autosaved
30
- * drafts survive exit; canonical ticket finalization belongs to the controller.
27
+ * Open a markdown file for review. Humanloop's own terminal surface
28
+ * (`launchTerminalReview`) is the default: it renders Markdown (including
29
+ * Mermaid) via termrender and anchors comments to source lines. Passing an
30
+ * explicit `opts.editor` opts into the legacy read-only Neovim/Vim session
31
+ * (`launchNeovimReview`), kept only as an explicit compatibility path.
31
32
  */
32
33
  export declare function launchReview(file: string, opts: ReviewOptions): Promise<FeedbackResult>;
@@ -5,6 +5,7 @@ import { resolve, join } from 'path';
5
5
  import { openBrowser } from '../browser/open.js';
6
6
  import { startReviewWebServer } from '../browser/server.js';
7
7
  import { buildFinalFeedbackResult, readReviewDraft, } from './feedback.js';
8
+ import { launchTerminalReview } from './terminal-review.js';
8
9
  function resolveEditor(override) {
9
10
  const candidates = override ? [override] : ['nvim', 'vim'];
10
11
  for (const bin of candidates) {
@@ -598,13 +599,25 @@ async function stopReviewServer(handle) {
598
599
  if (handle !== null)
599
600
  await handle.stop();
600
601
  }
602
+ /**
603
+ * Open a markdown file for review. Humanloop's own terminal surface
604
+ * (`launchTerminalReview`) is the default: it renders Markdown (including
605
+ * Mermaid) via termrender and anchors comments to source lines. Passing an
606
+ * explicit `opts.editor` opts into the legacy read-only Neovim/Vim session
607
+ * (`launchNeovimReview`), kept only as an explicit compatibility path.
608
+ */
609
+ export async function launchReview(file, opts) {
610
+ if (opts.editor !== undefined)
611
+ return launchNeovimReview(file, opts);
612
+ return launchTerminalReview(file, opts);
613
+ }
601
614
  /**
602
615
  * Open a markdown file in a clean, read-only Neovim/Vim review session. The
603
616
  * human anchors comments to source lines/selections with native vim motions
604
617
  * and explicitly submits a proposal. Blocks until the editor exits. Autosaved
605
618
  * drafts survive exit; canonical ticket finalization belongs to the controller.
606
619
  */
607
- export async function launchReview(file, opts) {
620
+ async function launchNeovimReview(file, opts) {
608
621
  const absFile = resolve(file);
609
622
  if (!existsSync(absFile)) {
610
623
  throw new Error(`Markdown file not found: ${absFile}`);
@@ -0,0 +1,115 @@
1
+ import type { FeedbackComment, FeedbackResult } from '../types.js';
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
+ }
9
+ export interface ComposeState {
10
+ buffer: string;
11
+ cursor: number;
12
+ /** Id of the comment being edited, or null when composing a new comment. */
13
+ editingId: string | null;
14
+ /** Submode to return to once compose closes (Escape or a successful save). */
15
+ returnMode: 'view' | 'list';
16
+ anchor: {
17
+ line: number;
18
+ endLine: number;
19
+ };
20
+ }
21
+ export interface ReviewState {
22
+ sourceLines: string[];
23
+ /** Top-level block source ranges, in document order. Never empty. */
24
+ blocks: BlockRange[];
25
+ comments: FeedbackComment[];
26
+ 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. */
30
+ selectionAnchor: number | null;
31
+ /** Scroll offset into the rendered (termrender) lines. */
32
+ scroll: number;
33
+ mode: 'view' | 'list' | 'compose' | 'help';
34
+ compose: ComposeState | null;
35
+ listIndex: number;
36
+ }
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`. */
43
+ export declare function currentAnchor(state: ReviewState): {
44
+ line: number;
45
+ endLine: number;
46
+ };
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;
69
+ export declare function scrollBy(state: ReviewState, delta: number, bodyHeight: number, totalLines: number): ReviewState;
70
+ export declare function openComposeNew(state: ReviewState): ReviewState;
71
+ export declare function openComposeEdit(state: ReviewState, index: number): ReviewState;
72
+ export declare function closeCompose(state: ReviewState): ReviewState;
73
+ /** Commits the current compose buffer as a new comment or an edit to an existing one. Empty text cancels. */
74
+ export declare function commitCompose(state: ReviewState, now?: string): ReviewState;
75
+ export declare function openList(state: ReviewState): ReviewState;
76
+ export declare function closeList(state: ReviewState): ReviewState;
77
+ export declare function moveListIndex(state: ReviewState, delta: number): ReviewState;
78
+ export declare function deleteAtListIndex(state: ReviewState): ReviewState;
79
+ export declare function undoLast(state: ReviewState): ReviewState;
80
+ export declare function openHelp(state: ReviewState): ReviewState;
81
+ export declare function closeHelp(state: ReviewState): ReviewState;
82
+ export declare function textInsert(buffer: string, cursor: number, text: string): {
83
+ buffer: string;
84
+ cursor: number;
85
+ };
86
+ export declare function textBackspace(buffer: string, cursor: number): {
87
+ buffer: string;
88
+ cursor: number;
89
+ };
90
+ export declare function textDelete(buffer: string, cursor: number): {
91
+ buffer: string;
92
+ cursor: number;
93
+ };
94
+ export declare function textHome(buffer: string, cursor: number): number;
95
+ export declare function textEnd(buffer: string, cursor: number): number;
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;
100
+ /** Reserved (non-body) rows for each mode's fixed header/footer chrome. Kept in
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[];
105
+ /**
106
+ * Open a Markdown file in humanloop's own terminal review surface: the
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`.
114
+ */
115
+ export declare function launchTerminalReview(file: string, opts: ReviewOptions): Promise<FeedbackResult>;
@@ -0,0 +1,630 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
3
+ import { basename, resolve } from 'node:path';
4
+ import stringWidth from 'string-width';
5
+ import { buildDraftFeedbackResult, buildFinalFeedbackResult, readReviewDraft, writeReviewDraft } from './feedback.js';
6
+ import { renderMarkdownWithMap } from '../render/termrender.js';
7
+ import { setupTerminal, restoreTerminal, getTerminalSize, parseKeypress } from '../tui/terminal.js';
8
+ import { diffFrame, renderInputBuffer } from '../tui/render.js';
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) }];
21
+ return {
22
+ sourceLines,
23
+ blocks: safeBlocks,
24
+ comments: [...comments],
25
+ version,
26
+ activeBlock: comments.length > 0 ? blockIndexForLine(safeBlocks, comments[0].line) : 0,
27
+ selectionAnchor: null,
28
+ scroll: 0,
29
+ mode: 'view',
30
+ compose: null,
31
+ listIndex: 0,
32
+ };
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`. */
40
+ 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));
49
+ if (!extend)
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 };
112
+ }
113
+ export function scrollBy(state, delta, bodyHeight, totalLines) {
114
+ const maxScroll = Math.max(0, totalLines - bodyHeight);
115
+ return { ...state, scroll: Math.max(0, Math.min(state.scroll + delta, maxScroll)) };
116
+ }
117
+ export function openComposeNew(state) {
118
+ return { ...state, mode: 'compose', compose: { buffer: '', cursor: 0, editingId: null, returnMode: 'view', anchor: currentAnchor(state) } };
119
+ }
120
+ export function openComposeEdit(state, index) {
121
+ const comment = state.comments[index];
122
+ if (comment === undefined)
123
+ return state;
124
+ const buffer = comment.comment;
125
+ const lo = blockIndexForLine(state.blocks, comment.line);
126
+ const hi = blockIndexForLine(state.blocks, comment.endLine);
127
+ return {
128
+ ...state,
129
+ activeBlock: hi,
130
+ selectionAnchor: lo === hi ? null : lo,
131
+ mode: 'compose',
132
+ compose: { buffer, cursor: [...buffer].length, editingId: comment.id, returnMode: 'list', anchor: { line: comment.line, endLine: comment.endLine } },
133
+ };
134
+ }
135
+ export function closeCompose(state) {
136
+ const returnMode = state.compose?.returnMode ?? 'view';
137
+ return { ...state, mode: returnMode, compose: null };
138
+ }
139
+ /** Commits the current compose buffer as a new comment or an edit to an existing one. Empty text cancels. */
140
+ export function commitCompose(state, now = new Date().toISOString()) {
141
+ const c = state.compose;
142
+ if (c === null || c === undefined)
143
+ return state;
144
+ const text = c.buffer.trim();
145
+ if (text.length === 0)
146
+ return closeCompose(state);
147
+ if (c.editingId !== null) {
148
+ const comments = state.comments.map((cm) => (cm.id === c.editingId ? { ...cm, comment: text } : cm));
149
+ return { ...state, comments, mode: c.returnMode, compose: null };
150
+ }
151
+ const lineText = state.sourceLines.slice(c.anchor.line - 1, c.anchor.endLine).join('\n');
152
+ const comment = { id: randomUUID(), line: c.anchor.line, endLine: c.anchor.endLine, lineText, comment: text, createdAt: now };
153
+ return { ...state, comments: [...state.comments, comment], mode: c.returnMode, compose: null };
154
+ }
155
+ export function openList(state) {
156
+ return { ...state, mode: 'list', listIndex: Math.max(0, Math.min(state.listIndex, state.comments.length - 1)) };
157
+ }
158
+ export function closeList(state) {
159
+ return { ...state, mode: 'view' };
160
+ }
161
+ export function moveListIndex(state, delta) {
162
+ if (state.comments.length === 0)
163
+ return state;
164
+ const next = Math.max(0, Math.min(state.comments.length - 1, state.listIndex + delta));
165
+ return { ...state, listIndex: next };
166
+ }
167
+ export function deleteAtListIndex(state) {
168
+ if (state.comments.length === 0)
169
+ return state;
170
+ const comments = state.comments.filter((_, i) => i !== state.listIndex);
171
+ const listIndex = Math.max(0, Math.min(state.listIndex, comments.length - 1));
172
+ return { ...state, comments, listIndex };
173
+ }
174
+ export function undoLast(state) {
175
+ if (state.comments.length === 0)
176
+ return state;
177
+ return { ...state, comments: state.comments.slice(0, -1) };
178
+ }
179
+ export function openHelp(state) {
180
+ return { ...state, mode: 'help' };
181
+ }
182
+ export function closeHelp(state) {
183
+ return { ...state, mode: 'view' };
184
+ }
185
+ // ── Multi-line text-buffer helpers (flat string + code-point cursor) ────────
186
+ function codePoints(s) {
187
+ return [...s];
188
+ }
189
+ // A single physical keypress (including Enter) almost always arrives as its
190
+ // own 'data' event, so parseKeypress's per-event key flags cover the normal
191
+ // typing path. A chunk longer than one character is a paste (or, rarely,
192
+ // several keystrokes the OS coalesced into one read) and is never re-parsed
193
+ // for key semantics — an embedded CR there means a pasted newline, not a
194
+ // commit. Mirrors tui/input.ts's paste cleaning: strip escape sequences,
195
+ // normalize CR/CRLF to LF, drop remaining control bytes, keep LF.
196
+ function cleanPastedText(input) {
197
+ return input
198
+ .replace(/\x1b\[20[01]~/g, '')
199
+ .replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')
200
+ .replace(/\r\n?/g, '\n')
201
+ .replace(/[\x00-\x09\x0B-\x1F\x7F]/g, '');
202
+ }
203
+ export function textInsert(buffer, cursor, text) {
204
+ const chars = codePoints(buffer);
205
+ const at = Math.max(0, Math.min(cursor, chars.length));
206
+ const next = [...chars.slice(0, at), ...codePoints(text), ...chars.slice(at)].join('');
207
+ return { buffer: next, cursor: at + codePoints(text).length };
208
+ }
209
+ export function textBackspace(buffer, cursor) {
210
+ if (cursor <= 0)
211
+ return { buffer, cursor };
212
+ const chars = codePoints(buffer);
213
+ return { buffer: [...chars.slice(0, cursor - 1), ...chars.slice(cursor)].join(''), cursor: cursor - 1 };
214
+ }
215
+ export function textDelete(buffer, cursor) {
216
+ const chars = codePoints(buffer);
217
+ if (cursor >= chars.length)
218
+ return { buffer, cursor };
219
+ return { buffer: [...chars.slice(0, cursor), ...chars.slice(cursor + 1)].join(''), cursor };
220
+ }
221
+ function lineBoundsAt(chars, cursor) {
222
+ let start = cursor;
223
+ while (start > 0 && chars[start - 1] !== '\n')
224
+ start--;
225
+ let end = cursor;
226
+ while (end < chars.length && chars[end] !== '\n')
227
+ end++;
228
+ return { start, end };
229
+ }
230
+ export function textHome(buffer, cursor) {
231
+ return lineBoundsAt(codePoints(buffer), cursor).start;
232
+ }
233
+ export function textEnd(buffer, cursor) {
234
+ return lineBoundsAt(codePoints(buffer), cursor).end;
235
+ }
236
+ export function textVertical(buffer, cursor, delta) {
237
+ const chars = codePoints(buffer);
238
+ const { start, end } = lineBoundsAt(chars, cursor);
239
+ const col = cursor - start;
240
+ if (delta < 0) {
241
+ if (start === 0)
242
+ return cursor;
243
+ const prevEnd = start - 1; // the '\n' just before this line
244
+ const prevStart = lineBoundsAt(chars, prevEnd).start;
245
+ return Math.min(prevEnd, prevStart + col);
246
+ }
247
+ if (end >= chars.length)
248
+ return cursor;
249
+ const nextStart = end + 1;
250
+ const nextEnd = lineBoundsAt(chars, nextStart).end;
251
+ return Math.min(nextEnd, nextStart + col);
252
+ }
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
+ }
268
+ /** Reserved (non-body) rows for each mode's fixed header/footer chrome. Kept in
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) {
272
+ const header = 3; // blank, title, divider
273
+ if (state.mode === 'view')
274
+ return header + 1;
275
+ if (state.mode === 'compose') {
276
+ const c = state.compose;
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;
279
+ return header + 3 + bufLines;
280
+ }
281
+ if (state.mode === 'list')
282
+ return header + Math.max(1, state.comments.length) + 2;
283
+ return header + 8; // help
284
+ }
285
+ export function renderReviewFrame(state, fileLabel, doc, cols, rows) {
286
+ const maxW = Math.min(Math.max(20, cols - 4), 120);
287
+ const docW = renderWidthForCols(cols);
288
+ const header = [
289
+ '',
290
+ ` ${BOLD}${CYAN}Review — ${fileLabel}${RESET} ${DIM}${state.comments.length} comment${state.comments.length === 1 ? '' : 's'}${RESET}`,
291
+ ` ${DIM}${hline(maxW)}${RESET}`,
292
+ ];
293
+ const footer = [];
294
+ if (state.mode === 'compose' && state.compose) {
295
+ const c = state.compose;
296
+ const label = c.anchor.line === c.anchor.endLine ? `L${c.anchor.line}` : `L${c.anchor.line}-${c.anchor.endLine}`;
297
+ footer.push(` ${YELLOW}${c.editingId !== null ? 'Edit comment' : 'Comment'} on ${label}:${RESET}`);
298
+ for (const l of renderInputBuffer(c.buffer, c.cursor, Math.max(10, maxW - 2)))
299
+ footer.push(` ${l}`);
300
+ footer.push('');
301
+ footer.push(` ${DIM}enter${RESET} save ${DIM}^J/⌥⏎${RESET} newline ${DIM}esc${RESET} cancel`);
302
+ }
303
+ else if (state.mode === 'list') {
304
+ if (state.comments.length === 0) {
305
+ footer.push(` ${DIM}(no comments yet — space c to add one)${RESET}`);
306
+ }
307
+ else {
308
+ state.comments.forEach((c, i) => {
309
+ const loc = c.line === c.endLine ? `L${c.line}` : `L${c.line}-${c.endLine}`;
310
+ const cursor = i === state.listIndex ? `${CYAN}▸${RESET}` : ' ';
311
+ const text = truncate(c.comment.replace(/\n/g, ' / '), Math.max(10, maxW - 14));
312
+ footer.push(` ${cursor} ${DIM}[${loc}]${RESET} ${text}`);
313
+ });
314
+ }
315
+ footer.push('');
316
+ footer.push(` ${DIM}j/k${RESET} move ${DIM}enter/e${RESET} edit ${DIM}d${RESET} delete ${DIM}q/esc${RESET} close`);
317
+ }
318
+ else if (state.mode === 'help') {
319
+ footer.push(' Keys:');
320
+ footer.push(` ${DIM}j/k${RESET} move anchor block ${DIM}shift+j/k${RESET} extend selection`);
321
+ footer.push(` ${DIM}u/d, pgup/pgdn${RESET} scroll document`);
322
+ footer.push(` ${DIM}space c${RESET} compose comment ${DIM}space l${RESET} list comments`);
323
+ footer.push(` ${DIM}space u${RESET} undo last comment ${DIM}space s${RESET} submit review`);
324
+ footer.push(` ${DIM}?${RESET} toggle this help ${DIM}esc${RESET} close/cancel`);
325
+ footer.push('');
326
+ footer.push(` ${DIM}esc / ?${RESET} close`);
327
+ }
328
+ 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`);
331
+ }
332
+ const reserved = header.length + footer.length;
333
+ const bodyHeight = Math.max(1, rows - reserved);
334
+ const maxScroll = Math.max(0, doc.lines.length - bodyHeight);
335
+ const scroll = Math.max(0, Math.min(state.scroll, maxScroll));
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
+ }
354
+ if (scroll > 0 && body.length > 0)
355
+ body[0] = ` ${DIM}↑ ${scroll} more above${RESET}`;
356
+ const remaining = doc.lines.length - (scroll + bodyHeight);
357
+ if (remaining > 0 && body.length > 0)
358
+ body[body.length - 1] = ` ${DIM}↓ ${remaining} more below${RESET}`;
359
+ while (body.length < bodyHeight)
360
+ body.push('');
361
+ const lines = [...header, ...body, ...footer];
362
+ while (lines.length < rows)
363
+ lines.push('');
364
+ return lines.slice(0, rows);
365
+ }
366
+ // ── Host loop (impure — the only part that touches the real TTY) ───────────
367
+ /**
368
+ * Open a Markdown file in humanloop's own terminal review surface: the
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`.
376
+ */
377
+ export async function launchTerminalReview(file, opts) {
378
+ const absFile = resolve(file);
379
+ const content = readFileSync(absFile, 'utf8');
380
+ const sourceLines = content.split('\n');
381
+ const outPath = resolve(opts.output);
382
+ const draft = readReviewDraft(outPath);
383
+ const fileLabel = basename(absFile);
384
+ if (opts.signal?.aborted)
385
+ return buildDraftFeedbackResult(absFile, draft?.comments ?? [], draft?.savedAt);
386
+ setupTerminal();
387
+ let { cols, rows } = getTerminalSize();
388
+ let doc = renderMarkdownWithMap(content, renderWidthForCols(cols));
389
+ let state = initReviewState(sourceLines, doc.blocks, draft?.comments ?? [], draft?.version ?? 0);
390
+ let prevFrame = [];
391
+ let spaceArmed = false;
392
+ let settled = false;
393
+ const bodyH = () => Math.max(1, rows - reservedRows(state, cols));
394
+ state = ensureAnchorVisible(state, doc, bodyH());
395
+ return new Promise((resolvePromise) => {
396
+ const persist = () => {
397
+ state = { ...state, version: state.version + 1 };
398
+ writeReviewDraft(outPath, state.comments, state.version);
399
+ };
400
+ const paint = (clear = false) => {
401
+ const lines = renderReviewFrame(state, fileLabel, doc, cols, rows);
402
+ if (clear) {
403
+ prevFrame = [];
404
+ process.stdout.write('\x1b[2J\x1b[H');
405
+ }
406
+ const { writes, nextPrevFrame } = diffFrame(prevFrame, lines, rows, cols);
407
+ process.stdout.write('\x1b[?2026h');
408
+ for (const w of writes)
409
+ process.stdout.write(w);
410
+ process.stdout.write('\x1b[?2026l');
411
+ prevFrame = nextPrevFrame;
412
+ };
413
+ const finish = (result) => {
414
+ if (settled)
415
+ return;
416
+ settled = true;
417
+ process.stdin.removeListener('data', onData);
418
+ process.stdout.removeListener('resize', onResize);
419
+ opts.signal?.removeEventListener('abort', onAbort);
420
+ restoreTerminal();
421
+ resolvePromise(result);
422
+ };
423
+ const onResize = () => {
424
+ ({ cols, rows } = getTerminalSize());
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());
439
+ paint(true);
440
+ };
441
+ const onAbort = () => {
442
+ finish(buildDraftFeedbackResult(absFile, state.comments));
443
+ };
444
+ const submit = async () => {
445
+ persist();
446
+ const proposal = buildFinalFeedbackResult(absFile, state.comments);
447
+ if (!opts.signal?.aborted)
448
+ await opts.onPropose?.(proposal);
449
+ finish(proposal);
450
+ };
451
+ const requestClose = async () => {
452
+ persist();
453
+ await opts.onClose?.();
454
+ finish(buildDraftFeedbackResult(absFile, state.comments));
455
+ };
456
+ const handleComposeKey = (input, key) => {
457
+ const c = state.compose;
458
+ if (c === null)
459
+ return;
460
+ if (key.escape) {
461
+ state = closeCompose(state);
462
+ return;
463
+ }
464
+ if (key.return) {
465
+ state = commitCompose(state);
466
+ persist();
467
+ return;
468
+ }
469
+ if (key.newline) {
470
+ const r = textInsert(c.buffer, c.cursor, '\n');
471
+ state = { ...state, compose: { ...c, ...r } };
472
+ return;
473
+ }
474
+ if (key.backspace) {
475
+ const r = textBackspace(c.buffer, c.cursor);
476
+ state = { ...state, compose: { ...c, ...r } };
477
+ return;
478
+ }
479
+ if (key.del) {
480
+ const r = textDelete(c.buffer, c.cursor);
481
+ state = { ...state, compose: { ...c, ...r } };
482
+ return;
483
+ }
484
+ if (key.leftArrow) {
485
+ state = { ...state, compose: { ...c, cursor: Math.max(0, c.cursor - 1) } };
486
+ return;
487
+ }
488
+ if (key.rightArrow) {
489
+ state = { ...state, compose: { ...c, cursor: Math.min([...c.buffer].length, c.cursor + 1) } };
490
+ return;
491
+ }
492
+ if (key.home) {
493
+ state = { ...state, compose: { ...c, cursor: textHome(c.buffer, c.cursor) } };
494
+ return;
495
+ }
496
+ if (key.end) {
497
+ state = { ...state, compose: { ...c, cursor: textEnd(c.buffer, c.cursor) } };
498
+ return;
499
+ }
500
+ if (key.upArrow) {
501
+ state = { ...state, compose: { ...c, cursor: textVertical(c.buffer, c.cursor, -1) } };
502
+ return;
503
+ }
504
+ if (key.downArrow) {
505
+ state = { ...state, compose: { ...c, cursor: textVertical(c.buffer, c.cursor, 1) } };
506
+ return;
507
+ }
508
+ if (input.length === 1 && !key.ctrl) {
509
+ const r = textInsert(c.buffer, c.cursor, input);
510
+ state = { ...state, compose: { ...c, ...r } };
511
+ return;
512
+ }
513
+ if (input.length > 1) {
514
+ const clean = cleanPastedText(input);
515
+ if (clean.length === 0)
516
+ return;
517
+ const r = textInsert(c.buffer, c.cursor, clean);
518
+ state = { ...state, compose: { ...c, ...r } };
519
+ }
520
+ };
521
+ const handleListKey = (input, key) => {
522
+ if (key.escape || input === 'q') {
523
+ state = closeList(state);
524
+ return;
525
+ }
526
+ if (input === 'j' || key.downArrow) {
527
+ state = moveListIndex(state, 1);
528
+ return;
529
+ }
530
+ if (input === 'k' || key.upArrow) {
531
+ state = moveListIndex(state, -1);
532
+ return;
533
+ }
534
+ if (input === 'e' || key.return) {
535
+ if (state.comments.length > 0) {
536
+ state = openComposeEdit(state, state.listIndex);
537
+ state = ensureAnchorVisible(state, doc, bodyH());
538
+ }
539
+ return;
540
+ }
541
+ if (input === 'd') {
542
+ if (state.comments.length > 0) {
543
+ state = deleteAtListIndex(state);
544
+ persist();
545
+ }
546
+ return;
547
+ }
548
+ };
549
+ const onData = (data) => {
550
+ if (settled)
551
+ return;
552
+ const { input, key } = parseKeypress(data);
553
+ if (key.meta && input === 'i') {
554
+ void requestClose();
555
+ return;
556
+ }
557
+ if (state.mode === 'compose') {
558
+ handleComposeKey(input, key);
559
+ paint();
560
+ return;
561
+ }
562
+ if (spaceArmed) {
563
+ spaceArmed = false;
564
+ if (input === 'c') {
565
+ state = openComposeNew(state);
566
+ state = ensureAnchorVisible(state, doc, bodyH());
567
+ }
568
+ else if (input === 'l')
569
+ state = openList(state);
570
+ else if (input === 'u') {
571
+ state = undoLast(state);
572
+ persist();
573
+ }
574
+ else if (input === 's') {
575
+ void submit();
576
+ return;
577
+ }
578
+ paint();
579
+ return;
580
+ }
581
+ if (state.mode === 'list') {
582
+ handleListKey(input, key);
583
+ paint();
584
+ return;
585
+ }
586
+ if (state.mode === 'help') {
587
+ if (input === '?' || key.escape)
588
+ state = closeHelp(state);
589
+ paint();
590
+ return;
591
+ }
592
+ // view mode
593
+ if (input === ' ') {
594
+ spaceArmed = true;
595
+ return;
596
+ }
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
+ }
613
+ else if (input === 'u' || key.pageUp)
614
+ state = scrollBy(state, -bodyH(), bodyH(), doc.lines.length);
615
+ else if (input === 'd' || key.pageDown)
616
+ state = scrollBy(state, bodyH(), bodyH(), doc.lines.length);
617
+ else if (input === '?')
618
+ state = openHelp(state);
619
+ else if (key.escape) {
620
+ finish(buildDraftFeedbackResult(absFile, state.comments));
621
+ return;
622
+ }
623
+ paint();
624
+ };
625
+ process.stdin.on('data', onData);
626
+ process.stdout.on('resize', onResize);
627
+ opts.signal?.addEventListener('abort', onAbort, { once: true });
628
+ paint(true);
629
+ });
630
+ }
@@ -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.2",
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"