@crouton-kit/humanloop 0.4.4 → 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
|
+
}
|
package/dist/editor/review.d.ts
CHANGED
|
@@ -12,7 +12,8 @@ export interface ReviewOptions {
|
|
|
12
12
|
onClose?: () => Promise<void> | void;
|
|
13
13
|
/** Controller cancellation closes the editor/browser handoff without proposing a result. */
|
|
14
14
|
signal?: AbortSignal;
|
|
15
|
-
/** Reuse this controller-owned directory for `review.vim
|
|
15
|
+
/** Reuse this controller-owned directory for review artifacts (`review.vim`,
|
|
16
|
+
* the browser-handoff job id); an existing script is left untouched. */
|
|
16
17
|
jobDir?: string;
|
|
17
18
|
}
|
|
18
19
|
export declare function reviewVimscript(): string;
|
package/dist/editor/review.js
CHANGED
|
@@ -6,6 +6,7 @@ import { openBrowser } from '../browser/open.js';
|
|
|
6
6
|
import { startReviewWebServer } from '../browser/server.js';
|
|
7
7
|
import { buildFinalFeedbackResult, readReviewDraft, } from './feedback.js';
|
|
8
8
|
import { launchTerminalReview } from './terminal-review.js';
|
|
9
|
+
import { waitForParkedReviewSubmit } from './parked.js';
|
|
9
10
|
function resolveEditor(override) {
|
|
10
11
|
const candidates = override ? [override] : ['nvim', 'vim'];
|
|
11
12
|
for (const bin of candidates) {
|
|
@@ -551,50 +552,6 @@ function draftFeedback(path, absFile) {
|
|
|
551
552
|
savedAt: draft?.savedAt ?? new Date().toISOString(),
|
|
552
553
|
};
|
|
553
554
|
}
|
|
554
|
-
async function waitForParkedReviewSubmit(submitted, signal) {
|
|
555
|
-
process.stderr.write('\nhumanloop: browser review handoff is active.\n' +
|
|
556
|
-
' The terminal editor is parked; the browser is the editing authority.\n' +
|
|
557
|
-
' Press w to take back into nvim, or Ctrl+C to exit with an unsubmitted draft.\n\n');
|
|
558
|
-
return new Promise((resolveAction) => {
|
|
559
|
-
const stdin = process.stdin;
|
|
560
|
-
const interactive = stdin.isTTY;
|
|
561
|
-
const wasRaw = stdin.isRaw;
|
|
562
|
-
let settled = false;
|
|
563
|
-
const cleanup = () => {
|
|
564
|
-
signal?.removeEventListener('abort', onAbort);
|
|
565
|
-
if (!interactive)
|
|
566
|
-
return;
|
|
567
|
-
stdin.off('data', onData);
|
|
568
|
-
if (stdin.setRawMode)
|
|
569
|
-
stdin.setRawMode(wasRaw);
|
|
570
|
-
stdin.pause();
|
|
571
|
-
};
|
|
572
|
-
const finish = (action) => {
|
|
573
|
-
if (settled)
|
|
574
|
-
return;
|
|
575
|
-
settled = true;
|
|
576
|
-
cleanup();
|
|
577
|
-
resolveAction(action);
|
|
578
|
-
};
|
|
579
|
-
const onAbort = () => finish({ type: 'cancel' });
|
|
580
|
-
const onData = (chunk) => {
|
|
581
|
-
const text = chunk.toString('utf8');
|
|
582
|
-
if (text === 'w' || text === 'W')
|
|
583
|
-
finish({ type: 'take-back' });
|
|
584
|
-
if (text === '\u0003')
|
|
585
|
-
finish({ type: 'cancel' });
|
|
586
|
-
};
|
|
587
|
-
if (interactive) {
|
|
588
|
-
stdin.setRawMode(true);
|
|
589
|
-
stdin.resume();
|
|
590
|
-
stdin.on('data', onData);
|
|
591
|
-
}
|
|
592
|
-
signal?.addEventListener('abort', onAbort, { once: true });
|
|
593
|
-
if (signal?.aborted)
|
|
594
|
-
onAbort();
|
|
595
|
-
submitted.then((result) => finish({ type: 'submitted', result }), () => finish({ type: 'cancel' }));
|
|
596
|
-
});
|
|
597
|
-
}
|
|
598
555
|
async function stopReviewServer(handle) {
|
|
599
556
|
if (handle !== null)
|
|
600
557
|
await handle.stop();
|
|
@@ -690,12 +647,16 @@ async function launchNeovimReview(file, opts) {
|
|
|
690
647
|
server.activate();
|
|
691
648
|
process.stderr.write(`humanloop: browser review handoff active — ${server.url}\n`);
|
|
692
649
|
openBrowser(server.url);
|
|
693
|
-
const action = await waitForParkedReviewSubmit(submitted, opts.signal);
|
|
650
|
+
const action = await waitForParkedReviewSubmit(submitted, opts.signal, 'nvim');
|
|
694
651
|
if (action.type === 'submitted') {
|
|
695
652
|
await stopReviewServer(server);
|
|
696
653
|
clearFlag(handoffFlagPath);
|
|
697
|
-
|
|
698
|
-
|
|
654
|
+
// An abort landing during the async stop must not produce a submitted
|
|
655
|
+
// result whose canonical onPropose convergence was skipped — fall
|
|
656
|
+
// back to the disk draft (the browser submit already persisted it).
|
|
657
|
+
if (opts.signal?.aborted)
|
|
658
|
+
return draftFeedback(outPath, absFile);
|
|
659
|
+
await opts.onPropose?.(action.result);
|
|
699
660
|
return action.result;
|
|
700
661
|
}
|
|
701
662
|
if (action.type === 'take-back') {
|
|
@@ -711,9 +672,12 @@ async function launchNeovimReview(file, opts) {
|
|
|
711
672
|
}
|
|
712
673
|
await stopReviewServer(server);
|
|
713
674
|
if (existsSync(submitFlagPath)) {
|
|
675
|
+
// Same abort-during-stop race as the parked branch above: an aborted
|
|
676
|
+
// review must resolve as a draft, never as an unconverged proposal.
|
|
677
|
+
if (opts.signal?.aborted)
|
|
678
|
+
return draftFeedback(outPath, absFile);
|
|
714
679
|
const proposal = proposedFeedback(outPath, absFile);
|
|
715
|
-
|
|
716
|
-
await opts.onPropose?.(proposal);
|
|
680
|
+
await opts.onPropose?.(proposal);
|
|
717
681
|
return proposal;
|
|
718
682
|
}
|
|
719
683
|
return draftFeedback(outPath, absFile);
|
|
@@ -107,9 +107,11 @@ export declare function renderReviewFrame(state: ReviewState, fileLabel: string,
|
|
|
107
107
|
* document renders via termrender (Mermaid included), the anchor is a
|
|
108
108
|
* highlighted block in the rendered document itself (gutter bar + tint via
|
|
109
109
|
* termrender's row→source map), and the human explicitly submits a proposal.
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
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
|
|
115
|
+
* `completeReview`); this function never writes `response.json`.
|
|
114
116
|
*/
|
|
115
117
|
export declare function launchTerminalReview(file: string, opts: ReviewOptions): Promise<FeedbackResult>;
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { readFileSync } from 'node:fs';
|
|
3
|
-
import {
|
|
2
|
+
import { mkdtempSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { basename, join, resolve } from 'node:path';
|
|
4
5
|
import stringWidth from 'string-width';
|
|
6
|
+
import { waitForParkedReviewSubmit } from './parked.js';
|
|
7
|
+
import { openBrowser } from '../browser/open.js';
|
|
8
|
+
import { startReviewWebServer } from '../browser/server.js';
|
|
5
9
|
import { buildDraftFeedbackResult, buildFinalFeedbackResult, readReviewDraft, writeReviewDraft } from './feedback.js';
|
|
6
10
|
import { renderMarkdownWithMap } from '../render/termrender.js';
|
|
7
11
|
import { setupTerminal, restoreTerminal, getTerminalSize, parseKeypress } from '../tui/terminal.js';
|
|
@@ -280,7 +284,7 @@ export function reservedRows(state, cols) {
|
|
|
280
284
|
}
|
|
281
285
|
if (state.mode === 'list')
|
|
282
286
|
return header + Math.max(1, state.comments.length) + 2;
|
|
283
|
-
return header +
|
|
287
|
+
return header + 9; // help
|
|
284
288
|
}
|
|
285
289
|
export function renderReviewFrame(state, fileLabel, doc, cols, rows) {
|
|
286
290
|
const maxW = Math.min(Math.max(20, cols - 4), 120);
|
|
@@ -321,13 +325,14 @@ export function renderReviewFrame(state, fileLabel, doc, cols, rows) {
|
|
|
321
325
|
footer.push(` ${DIM}u/d, pgup/pgdn${RESET} scroll document`);
|
|
322
326
|
footer.push(` ${DIM}space c${RESET} compose comment ${DIM}space l${RESET} list comments`);
|
|
323
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`);
|
|
324
329
|
footer.push(` ${DIM}?${RESET} toggle this help ${DIM}esc${RESET} close/cancel`);
|
|
325
330
|
footer.push('');
|
|
326
331
|
footer.push(` ${DIM}esc / ?${RESET} close`);
|
|
327
332
|
}
|
|
328
333
|
else {
|
|
329
334
|
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`);
|
|
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`);
|
|
331
336
|
}
|
|
332
337
|
const reserved = header.length + footer.length;
|
|
333
338
|
const bodyHeight = Math.max(1, rows - reserved);
|
|
@@ -363,26 +368,85 @@ export function renderReviewFrame(state, fileLabel, doc, cols, rows) {
|
|
|
363
368
|
lines.push('');
|
|
364
369
|
return lines.slice(0, rows);
|
|
365
370
|
}
|
|
366
|
-
|
|
371
|
+
function diskDraftResult(absFile, outPath) {
|
|
372
|
+
const draft = readReviewDraft(outPath);
|
|
373
|
+
return buildDraftFeedbackResult(absFile, draft?.comments ?? [], draft?.savedAt);
|
|
374
|
+
}
|
|
367
375
|
/**
|
|
368
376
|
* Open a Markdown file in humanloop's own terminal review surface: the
|
|
369
377
|
* document renders via termrender (Mermaid included), the anchor is a
|
|
370
378
|
* highlighted block in the rendered document itself (gutter bar + tint via
|
|
371
379
|
* termrender's row→source map), and the human explicitly submits a proposal.
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
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
|
|
385
|
+
* `completeReview`); this function never writes `response.json`.
|
|
376
386
|
*/
|
|
377
387
|
export async function launchTerminalReview(file, opts) {
|
|
378
388
|
const absFile = resolve(file);
|
|
379
389
|
const content = readFileSync(absFile, 'utf8');
|
|
380
|
-
const sourceLines = content.split('\n');
|
|
381
390
|
const outPath = resolve(opts.output);
|
|
382
|
-
const draft = readReviewDraft(outPath);
|
|
383
391
|
const fileLabel = basename(absFile);
|
|
384
|
-
|
|
385
|
-
|
|
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);
|
|
386
450
|
setupTerminal();
|
|
387
451
|
let { cols, rows } = getTerminalSize();
|
|
388
452
|
let doc = renderMarkdownWithMap(content, renderWidthForCols(cols));
|
|
@@ -410,7 +474,7 @@ export async function launchTerminalReview(file, opts) {
|
|
|
410
474
|
process.stdout.write('\x1b[?2026l');
|
|
411
475
|
prevFrame = nextPrevFrame;
|
|
412
476
|
};
|
|
413
|
-
const
|
|
477
|
+
const finishWith = (outcome) => {
|
|
414
478
|
if (settled)
|
|
415
479
|
return;
|
|
416
480
|
settled = true;
|
|
@@ -418,8 +482,9 @@ export async function launchTerminalReview(file, opts) {
|
|
|
418
482
|
process.stdout.removeListener('resize', onResize);
|
|
419
483
|
opts.signal?.removeEventListener('abort', onAbort);
|
|
420
484
|
restoreTerminal();
|
|
421
|
-
resolvePromise(
|
|
485
|
+
resolvePromise(outcome);
|
|
422
486
|
};
|
|
487
|
+
const finish = (result) => finishWith({ type: 'done', result });
|
|
423
488
|
const onResize = () => {
|
|
424
489
|
({ cols, rows } = getTerminalSize());
|
|
425
490
|
doc = renderMarkdownWithMap(content, renderWidthForCols(cols));
|
|
@@ -575,6 +640,11 @@ export async function launchTerminalReview(file, opts) {
|
|
|
575
640
|
void submit();
|
|
576
641
|
return;
|
|
577
642
|
}
|
|
643
|
+
else if (input === 'w') {
|
|
644
|
+
persist();
|
|
645
|
+
finishWith({ type: 'handoff' });
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
578
648
|
paint();
|
|
579
649
|
return;
|
|
580
650
|
}
|