@crouton-kit/humanloop 0.3.8 → 0.3.10

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.
package/dist/api.js CHANGED
@@ -25,11 +25,17 @@ function buildSummary(deck, responses) {
25
25
  const ft = r.freetext !== undefined && r.freetext !== '' ? r.freetext : undefined;
26
26
  let picked;
27
27
  if (r.selectedOptionIds !== undefined) {
28
- const labels = r.selectedOptionIds
28
+ const oc = r.optionComments;
29
+ const parts = r.selectedOptionIds
29
30
  .map((id) => it.options.find((o) => o.id === id))
30
31
  .filter((o) => o !== undefined)
31
- .map((o) => o.label);
32
- picked = labels.length > 0 ? labels.join(', ') : undefined;
32
+ .map((o) => {
33
+ const note = oc !== undefined ? oc[o.id] : undefined;
34
+ return typeof note === 'string' && note.length > 0
35
+ ? `${o.label} ("${note}")`
36
+ : o.label;
37
+ });
38
+ picked = parts.length > 0 ? parts.join(', ') : undefined;
33
39
  }
34
40
  else if (r.selectedOptionId !== undefined) {
35
41
  picked = it.options.find((o) => o.id === r.selectedOptionId)?.label;
package/dist/cli.js CHANGED
@@ -5,7 +5,7 @@ import { readdirSync } from 'node:fs';
5
5
  import { tmpdir } from 'node:os';
6
6
  import { resolve, join, basename } from 'node:path';
7
7
  import { execFileSync } from 'node:child_process';
8
- import { launchReview } from './editor/review.js';
8
+ import { launchReview, reviewVimscript } from './editor/review.js';
9
9
  import { validateDeck } from './inbox/deck-schema.js';
10
10
  import { ask, inbox } from './api.js';
11
11
  import { display } from './surfaces/display.js';
@@ -244,6 +244,11 @@ const RESPONSE_SCHEMA = {
244
244
  selectedOptionId: { type: 'string' },
245
245
  selectedOptionIds: { type: 'array', items: { type: 'string' } },
246
246
  freetext: { type: 'string' },
247
+ optionComments: {
248
+ type: 'object',
249
+ description: 'Multi-select per-option comments, keyed by option id.',
250
+ additionalProperties: { type: 'string' },
251
+ },
247
252
  },
248
253
  },
249
254
  },
@@ -559,7 +564,13 @@ reviewCmd
559
564
  ' editor?: string|null, tmux?: bool=true }\n' +
560
565
  'stdout { job_id: string, output: string (absolute), follow_up: string }\n' +
561
566
  '\n' +
562
- 'Effects: spawns nvim/vim read-only; autosaves feedback JSON.\n')
567
+ 'Effects: spawns nvim/vim read-only DETACHED in a tmux pane when tmux=true\n' +
568
+ ' and $TMUX set; returns before the human starts. Writes\n' +
569
+ ' <dir>/review.vim, <dir>/feedback.json (on finish), <dir>/job.log.\n' +
570
+ ' autosaves feedback JSON; the open pane live-reloads the source on\n' +
571
+ ' disk edits. The review is open-ended (a human may take many\n' +
572
+ ' minutes) — collect via `hl job result` with wait:true, run\n' +
573
+ ' backgrounded so the call does not block on the human.\n')
563
574
  .helpOption('-h, --help', 'Show help')
564
575
  .action(async () => {
565
576
  const input = parseStdinJson();
@@ -571,15 +582,52 @@ reviewCmd
571
582
  emitError({ error: 'file_not_found', message: `File not found: ${absFile}`, field: 'file', next: 'Check the file path.' });
572
583
  }
573
584
  const output = resolve(input.output ? input.output : `${absFile}.feedback.json`);
574
- const noTmux = input.tmux === false;
575
- const jobDir = mkdtempSync(join(tmpdir(), 'hl-review-'));
585
+ const useTmux = input.tmux !== false;
586
+ // Shared job dir: the detached child reuses the parent's via input.dir;
587
+ // a top-level call mints one. The review.vim is written up front (and the
588
+ // job_started logged) so the job is recognizable as a live review — and so
589
+ // the child sources the exact vimscript — before any pane is spawned.
590
+ const jobDir = input.dir ? resolve(input.dir) : mkdtempSync(join(tmpdir(), 'hl-review-'));
591
+ mkdirSync(jobDir, { recursive: true });
576
592
  const jobId = basename(jobDir);
577
- appendJobLog(jobDir, { level: 'info', event: 'job_started', message: 'review open job started', data: { jobId, file: absFile } });
593
+ if (!input.dir) {
594
+ writeFileSync(join(jobDir, 'review.vim'), reviewVimscript());
595
+ appendJobLog(jobDir, { level: 'info', event: 'job_started', message: 'review open job started', data: { jobId, file: absFile } });
596
+ }
597
+ // tmux path: detach a child that owns the editor pane and return the handle
598
+ // now, mirroring `deck ask`. The child re-enters this leaf with tmux:false
599
+ // and the shared dir, falling through to the in-process branch below.
600
+ if (process.env['TMUX'] && useTmux) {
601
+ const scriptPath = process.argv[1];
602
+ if (!scriptPath) {
603
+ emitError({ error: 'internal', message: 'Cannot determine hl script path', next: 'Report this as a bug.' });
604
+ }
605
+ const sq = (s) => /^[a-zA-Z0-9_\-./:@%+=]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
606
+ const childInput = JSON.stringify({ file: absFile, output, editor: input.editor ?? null, tmux: false, dir: jobDir });
607
+ const cmd = `echo ${sq(childInput)} | ${sq(process.execPath)} ${sq(scriptPath)} review open`;
608
+ try {
609
+ execFileSync('tmux', ['split-window', '-d', '-h', cmd], { stdio: 'ignore' });
610
+ }
611
+ catch (spawnErr) {
612
+ const msg = spawnErr instanceof Error ? spawnErr.message : String(spawnErr);
613
+ appendJobLog(jobDir, { level: 'error', event: 'job_failed', message: `tmux spawn failed: ${msg}` });
614
+ emitError({ error: 'internal', message: `tmux spawn failed: ${msg}`, next: 'Check that $TMUX is set. Or pass tmux:false.' });
615
+ }
616
+ process.stdout.write(JSON.stringify({
617
+ job_id: jobId,
618
+ output,
619
+ follow_up: `Call hl job result with stdin {"job_id":"${jobId}","wait":true} to block until the human finishes the review. They may take many minutes — run this backgrounded; you will be notified on completion.`,
620
+ }) + '\n');
621
+ process.exit(0);
622
+ }
623
+ // In-process path: the detached child (this pane is its TTY), or a degraded
624
+ // top-level call with no tmux. launchReview blocks until the editor exits.
578
625
  try {
579
626
  const result = await launchReview(absFile, {
580
627
  output,
581
628
  editor: (input.editor && typeof input.editor === 'string') ? input.editor : undefined,
582
- noTmux,
629
+ noTmux: true,
630
+ jobDir,
583
631
  });
584
632
  appendJobLog(jobDir, { level: 'info', event: 'job_finished', message: 'review finished', data: { comments: result.comments.length } });
585
633
  writeFileSync(join(jobDir, 'feedback.json'), JSON.stringify(result, null, 2));
@@ -587,6 +635,7 @@ reviewCmd
587
635
  job_id: jobId,
588
636
  output,
589
637
  follow_up: `Call hl job result with stdin {"job_id":"${jobId}","wait":true} to retrieve the feedback.`,
638
+ ...(input.dir ? {} : { _note: 'No tmux: launchReview blocked synchronously. Result is already available.' }),
590
639
  }) + '\n');
591
640
  process.exit(0);
592
641
  }
@@ -6,7 +6,33 @@ export interface ReviewOptions {
6
6
  editor?: string;
7
7
  /** Force running in the current terminal even when $TMUX is set. */
8
8
  noTmux?: boolean;
9
+ /**
10
+ * When set, an explicit `<Space>s` / `:HLSubmit` invocation writes a sentinel
11
+ * file at this path. Node-side uses the file's existence to gate `submitted: true`.
12
+ * When omitted, any editor exit finalizes `submitted: true` (legacy behavior).
13
+ */
14
+ submitFlagPath?: string;
15
+ /**
16
+ * When true and $TMUX is set and !opts.noTmux, launch via
17
+ * `tmux display-popup -E` instead of `tmux split-window`.
18
+ * display-popup -E is synchronous so no poll loop is needed.
19
+ */
20
+ tmuxPopup?: boolean;
21
+ /** Override the default 90%/90% popup size. Only used when tmuxPopup is true. */
22
+ popupSize?: {
23
+ w: string;
24
+ h: string;
25
+ };
26
+ /**
27
+ * Reuse this directory for `review.vim` instead of minting a fresh temp dir.
28
+ * The async `review open` kickoff passes its job dir so the detached child
29
+ * sources the same vimscript the parent already wrote there (which also lets
30
+ * the job be recognized as a review while it's still live). When the file
31
+ * already exists it is left untouched.
32
+ */
33
+ jobDir?: string;
9
34
  }
35
+ export declare function reviewVimscript(): string;
10
36
  /**
11
37
  * Compact stdout rendering for the agent: per comment just the line:col
12
38
  * range, the original text in that span, and the note — plus the source
@@ -26,12 +26,13 @@ function resolveEditor(override) {
26
26
  // The entire review UX as a clean, minimal Vimscript config sourced via `-u`.
27
27
  // Works in both Neovim and Vim 8+. The source file is opened read-only; the
28
28
  // human anchors comments to real source lines/selections and quits to submit.
29
- function reviewVimscript() {
29
+ export function reviewVimscript() {
30
30
  return [
31
31
  `" hl propose — review layer. Runs on a CLEAN config (nvim -u NONE: no`,
32
32
  `" init.lua, no LazyVim, no plugins/keymaps). Look/feel is ONLY the user's`,
33
- `" 'gloam' colorscheme + built-in treesitter markdown highlighting, applied`,
34
- `" below. The rest is the read-only guard, comment commands, and autosave.`,
33
+ `" 'gloam' colorscheme + built-in treesitter markdown highlighting + (when`,
34
+ `" installed) render-markdown.nvim for GFM tables/headings, applied below.`,
35
+ `" The rest is the read-only guard, comment commands, and autosave.`,
35
36
  `let g:hl_out = $HL_OUTPUT`,
36
37
  `let g:hl_src = $HL_SOURCE`,
37
38
  `let s:comments = []`,
@@ -146,39 +147,103 @@ function reviewVimscript() {
146
147
  ` echo 'Removed last comment — ' . len(s:comments) . ' left'`,
147
148
  `endfunction`,
148
149
  ``,
149
- `function! s:List() abort`,
150
- ` let l:wid = bufwinid('__HL_Comments__')`,
151
- ` if l:wid != -1`,
152
- ` call win_gotoid(l:wid) | close | return`,
153
- ` endif`,
150
+ `" Build the list buffer lines plus a parallel map from buffer line -> comment`,
151
+ `" index (0-based; -1 for non-actionable rows like the quote continuation or`,
152
+ `" the empty-state hint). Stored on the buffer so the action maps can resolve`,
153
+ `" which comment the cursor is on.`,
154
+ `function! s:ListLines() abort`,
154
155
  ` let l:lines = []`,
156
+ ` let l:map = []`,
155
157
  ` if empty(s:comments)`,
156
- ` let l:lines = ['(no comments yet — select text or put the cursor on a line, then <Space>c or :HLComment)']`,
158
+ ` call add(l:lines, '(no comments yet — select text or put the cursor on a line, then <Space>c or :HLComment)')`,
159
+ ` call add(l:map, -1)`,
157
160
  ` else`,
158
- ` let l:i = 1`,
161
+ ` let l:idx = 0`,
159
162
  ` for l:c in s:comments`,
160
163
  ` let l:ln = get(l:c,'line',0)`,
161
164
  ` let l:end = get(l:c,'endLine',l:ln)`,
162
165
  ` let l:loc = l:ln == l:end ? ('L' . l:ln) : ('L' . l:ln . '-' . l:end)`,
163
- ` call add(l:lines, l:i . '. [' . l:loc . '] ' . get(l:c,'comment',''))`,
166
+ ` call add(l:lines, (l:idx+1) . '. [' . l:loc . '] ' . get(l:c,'comment',''))`,
167
+ ` call add(l:map, l:idx)`,
164
168
  ` if !empty(get(l:c,'quote',''))`,
165
169
  ` call add(l:lines, ' > ' . substitute(get(l:c,'quote',''), "\\n", ' / ', 'g'))`,
170
+ ` call add(l:map, l:idx)`,
166
171
  ` endif`,
167
- ` let l:i += 1`,
172
+ ` let l:idx += 1`,
168
173
  ` endfor`,
169
174
  ` endif`,
170
- ` botright 10split __HL_Comments__`,
171
- ` setlocal buftype=nofile bufhidden=wipe noswapfile nobuflisted`,
172
- ` setlocal nonumber nocursorline winfixheight signcolumn=no`,
175
+ ` return [l:lines, l:map]`,
176
+ `endfunction`,
177
+ ``,
178
+ `" Refill the (already-open) comments buffer in place and refresh the line map.`,
179
+ `function! s:RenderList() abort`,
180
+ ` let [l:lines, l:map] = s:ListLines()`,
181
+ ` let b:hl_map = l:map`,
173
182
  ` setlocal modifiable`,
183
+ ` silent! 1,$delete _`,
174
184
  ` call setline(1, l:lines)`,
175
185
  ` setlocal nomodifiable`,
186
+ `endfunction`,
187
+ ``,
188
+ `" Comment index (0-based) under the cursor in the comments buffer, or -1.`,
189
+ `function! s:ListIdx() abort`,
190
+ ` if !exists('b:hl_map') | return -1 | endif`,
191
+ ` let l:lnum = line('.')`,
192
+ ` if l:lnum < 1 || l:lnum > len(b:hl_map) | return -1 | endif`,
193
+ ` return b:hl_map[l:lnum - 1]`,
194
+ `endfunction`,
195
+ ``,
196
+ `function! s:ListDelete() abort`,
197
+ ` let l:idx = s:ListIdx()`,
198
+ ` if l:idx < 0`,
199
+ ` echohl WarningMsg | echo 'No comment on this line' | echohl NONE | return`,
200
+ ` endif`,
201
+ ` call remove(s:comments, l:idx)`,
202
+ ` call s:Save()`,
203
+ ` call s:Marks()`,
204
+ ` call s:RenderList()`,
205
+ ` echo 'Deleted comment — ' . len(s:comments) . ' left'`,
206
+ `endfunction`,
207
+ ``,
208
+ `function! s:ListEdit() abort`,
209
+ ` let l:idx = s:ListIdx()`,
210
+ ` if l:idx < 0`,
211
+ ` echohl WarningMsg | echo 'No comment on this line' | echohl NONE | return`,
212
+ ` endif`,
213
+ ` let l:cur = get(s:comments[l:idx], 'comment', '')`,
214
+ ` let l:txt = input('Edit comment: ', l:cur)`,
215
+ ` redraw`,
216
+ ` if empty(trim(l:txt))`,
217
+ ` echohl WarningMsg | echo 'Edit cancelled — comment unchanged' | echohl NONE | return`,
218
+ ` endif`,
219
+ ` let s:comments[l:idx]['comment'] = l:txt`,
220
+ ` call s:Save()`,
221
+ ` call s:RenderList()`,
222
+ ` echo 'Updated comment'`,
223
+ `endfunction`,
224
+ ``,
225
+ `function! s:List() abort`,
226
+ ` let l:wid = bufwinid('__HL_Comments__')`,
227
+ ` if l:wid != -1`,
228
+ ` call win_gotoid(l:wid) | close | return`,
229
+ ` endif`,
230
+ ` botright 10split __HL_Comments__`,
231
+ ` setlocal buftype=nofile bufhidden=wipe noswapfile nobuflisted`,
232
+ ` setlocal nonumber cursorline winfixheight signcolumn=no`,
233
+ ` call s:RenderList()`,
176
234
  ` nnoremap <buffer> <silent> q :close<CR>`,
177
235
  ` nnoremap <buffer> <silent> <Space>l :close<CR>`,
236
+ ` nnoremap <buffer> <silent> dd :call <SID>ListDelete()<CR>`,
237
+ ` nnoremap <buffer> <silent> e :call <SID>ListEdit()<CR>`,
238
+ ` nnoremap <buffer> <silent> <CR> :call <SID>ListEdit()<CR>`,
239
+ ` echohl Question | echo 'list — e/<CR> edit · dd delete · q/<Space>l close' | echohl NONE`,
178
240
  `endfunction`,
179
241
  ``,
180
242
  `function! s:Submit() abort`,
181
243
  ` call s:Save()`,
244
+ ` if $HL_SUBMIT_FLAG !=# ''`,
245
+ ` call writefile([''], $HL_SUBMIT_FLAG)`,
246
+ ` endif`,
182
247
  ` qa!`,
183
248
  `endfunction`,
184
249
  ``,
@@ -186,7 +251,7 @@ function reviewVimscript() {
186
251
  `command! HLList call <SID>List()`,
187
252
  `command! HLUndo call <SID>Undo()`,
188
253
  `command! HLSubmit call <SID>Submit()`,
189
- `command! HLHelp echo 'REVIEW <Space>c comment (visual or line) <Space>l list <Space>u undo-last <Space>s submit & quit or :HLComment/:HLList/:HLUndo/:HLSubmit (any quit submits)'`,
254
+ `command! HLHelp echo 'REVIEW <Space>c comment <Space>l list <Space>u undo-last <Space>s submit & quit | in list: e/<CR> edit · dd delete · q close (any quit submits)'`,
190
255
  ``,
191
256
  `" Highlights are (re)applied after any colorscheme so the user's theme`,
192
257
  `" (e.g. gloam) loads first, then our anchor highlight sits on top.`,
@@ -212,9 +277,51 @@ function reviewVimscript() {
212
277
  ` if &filetype !=# 'markdown' | setlocal filetype=markdown | endif`,
213
278
  ` " gloam only defines treesitter @markup.* highlight groups for markdown,`,
214
279
  ` " so the styling needs treesitter active. Built-in treesitter plus the`,
215
- ` " site-dir markdown parser render it with zero plugins.`,
280
+ ` " site-dir markdown parser render it with zero plugins. When the user has`,
281
+ ` " render-markdown.nvim installed (lazy dir / packpath), pull it in too so`,
282
+ ` " GFM tables and headings render the same as their normal editor — it only`,
283
+ ` " draws extmarks/conceal, so the read-only buffer text (and the line:col`,
284
+ ` " anchors comments hang off of) is never touched.`,
216
285
  ` if has('nvim')`,
217
- ` silent! lua pcall(vim.treesitter.start, 0, 'markdown')`,
286
+ ` silent! lua << HLLUA`,
287
+ `pcall(vim.treesitter.start, 0, 'markdown')`,
288
+ `pcall(function()`,
289
+ ` local path = vim.fn.stdpath('data') .. '/lazy/render-markdown.nvim'`,
290
+ ` if vim.fn.isdirectory(path) == 0 then`,
291
+ ` local hits = vim.fn.globpath(vim.o.packpath, '*/*/render-markdown.nvim', false, true)`,
292
+ ` path = hits[1]`,
293
+ ` end`,
294
+ ` if not path or vim.fn.isdirectory(path) == 0 then return end`,
295
+ ` vim.opt.runtimepath:prepend(path)`,
296
+ ` vim.g.render_markdown_config = {`,
297
+ ` file_types = { 'markdown' },`,
298
+ ` completions = { lsp = { enabled = false } },`,
299
+ ` heading = { border = false },`,
300
+ ` }`,
301
+ ` vim.cmd('silent! runtime plugin/render-markdown.lua')`,
302
+ ` -- Defer the initial paint to the next tick: during VimEnter the window`,
303
+ ` -- isn't laid out yet, so an immediate render no-ops and tables show raw`,
304
+ ` -- until the first cursor move. api.render() forces a paint for the`,
305
+ ` -- buffer's window (the live autocmds the plugin installed keep it updated`,
306
+ ` -- on edits/scroll thereafter). render-markdown refuses to draw while a`,
307
+ ` -- prompt mode is active ('r' hit-enter, 'rm' more, 'r?' confirm) — e.g. a`,
308
+ ` -- wrapped :echo — so re-defer until we're back in a normal state.`,
309
+ ` local function paint()`,
310
+ ` local m = vim.api.nvim_get_mode().mode`,
311
+ ` if m == 'r' or m == 'rm' or m == 'r?' then`,
312
+ ` vim.defer_fn(paint, 60)`,
313
+ ` return`,
314
+ ` end`,
315
+ ` pcall(function()`,
316
+ ` require('render-markdown.api').render({`,
317
+ ` buf = vim.api.nvim_get_current_buf(),`,
318
+ ` win = vim.api.nvim_get_current_win(),`,
319
+ ` })`,
320
+ ` end)`,
321
+ ` end`,
322
+ ` vim.schedule(paint)`,
323
+ `end)`,
324
+ `HLLUA`,
218
325
  ` endif`,
219
326
  ` " Buffer-local <Space> maps. Clean config has no which-key/<leader>`,
220
327
  ` " bindings to collide with, and these are gone outside this buffer.`,
@@ -227,11 +334,29 @@ function reviewVimscript() {
227
334
  ` call s:Load()`,
228
335
  ` call s:Marks()`,
229
336
  ` redraw`,
230
- ` echohl Question | echo 'hl review <Space>c comment <Space>l list <Space>u undo <Space>s submit & quit (:HLHelp)' | echohl NONE`,
337
+ ` " Keep this hint short: a line that wraps past the cmdline triggers the`,
338
+ ` " hit-enter prompt, whose mode blocks the deferred render-markdown paint.`,
339
+ ` " The full key list lives in :HLHelp (and is printed to stderr on launch).`,
340
+ ` echohl Question | echo 'hl review — <Space>c comment · l list · u undo · s submit (:HLHelp)' | echohl NONE`,
231
341
  `endfunction`,
232
342
  `autocmd VimEnter * call s:Setup()`,
233
343
  `autocmd VimLeavePre * call s:Save()`,
234
344
  ``,
345
+ `" Watchmode: the source is opened read-only here but may be rewritten on`,
346
+ `" disk by the agent while the human reviews. autoread + a periodic checktime`,
347
+ `" (timers fire even when the tmux pane is unfocused) reload the buffer`,
348
+ `" silently — it is never locally modified, so there is nothing to clobber.`,
349
+ `" On reload, re-run Setup (read-only guard, treesitter, maps) and redraw the`,
350
+ `" comment anchors, which the reload cleared. Comments live in s:comments /`,
351
+ `" the autosave file, not the buffer, so they survive; their line anchors may`,
352
+ `" drift if the edit moved text, which is expected — the human sees the latest.`,
353
+ `set autoread`,
354
+ `autocmd FocusGained,BufEnter,CursorHold * silent! checktime`,
355
+ `autocmd FileChangedShellPost * call s:Setup() | call s:Marks() | redraw`,
356
+ `if exists('*timer_start')`,
357
+ ` let s:hl_watch = timer_start(1000, {-> execute('silent! checktime')}, {'repeat': -1})`,
358
+ `endif`,
359
+ ``,
235
360
  ].join('\n');
236
361
  }
237
362
  function atomicWrite(path, data) {
@@ -285,7 +410,24 @@ function rangeLabel(c) {
285
410
  export function formatFeedbackSummary(result, feedbackJsonPath) {
286
411
  const n = result.comments.length;
287
412
  const out = [];
288
- if (n === 0) {
413
+ if (!result.submitted) {
414
+ if (n > 0) {
415
+ out.push(`hl propose — draft saved: ${n} comment${n === 1 ? '' : 's'} on ${result.file} (not yet submitted)`);
416
+ out.push('');
417
+ result.comments.forEach((c, i) => {
418
+ const original = c.quote && c.quote.length > 0 ? c.quote : c.lineText;
419
+ const lines = original.split('\n');
420
+ out.push(` ${i + 1}. ${rangeLabel(c)}`);
421
+ lines.forEach((ln, k) => out.push(k === 0 ? ` text: ${ln}` : ` ${ln}`));
422
+ out.push(` comment: ${c.comment}`);
423
+ out.push('');
424
+ });
425
+ }
426
+ else {
427
+ out.push(`hl propose — draft saved: no comments yet on ${result.file}`);
428
+ }
429
+ }
430
+ else if (n === 0) {
289
431
  out.push(`hl propose — approved: 0 comments on ${result.file} (human signalled looks-good; proceed)`);
290
432
  }
291
433
  else {
@@ -364,10 +506,18 @@ export async function launchReview(file, opts) {
364
506
  }
365
507
  const outPath = resolve(opts.output);
366
508
  const bin = resolveEditor(opts.editor);
367
- const dir = mkdtempSync(join(tmpdir(), 'hl-review-'));
509
+ // When the async kickoff passes its job dir, source the review.vim the parent
510
+ // already wrote there; otherwise mint a private temp dir as before.
511
+ const dir = opts.jobDir ?? mkdtempSync(join(tmpdir(), 'hl-review-'));
368
512
  const initPath = join(dir, 'review.vim');
369
- writeFileSync(initPath, reviewVimscript());
370
- const env = { ...process.env, HL_OUTPUT: outPath, HL_SOURCE: absFile };
513
+ if (!existsSync(initPath))
514
+ writeFileSync(initPath, reviewVimscript());
515
+ const env = {
516
+ ...process.env,
517
+ HL_OUTPUT: outPath,
518
+ HL_SOURCE: absFile,
519
+ ...(opts.submitFlagPath ? { HL_SUBMIT_FLAG: opts.submitFlagPath } : {}),
520
+ };
371
521
  // `-u NONE`: do NOT load the user's init.lua / LazyVim / plugins / keymaps.
372
522
  // Default runtimepath still includes the config dir (for the gloam
373
523
  // colorscheme) and the site dir (for the treesitter markdown parser), so the
@@ -382,16 +532,27 @@ export async function launchReview(file, opts) {
382
532
  if (inTmux) {
383
533
  // `exec env …` so the editor replaces the shell and becomes the pane's
384
534
  // process (so tmux `#{pane_current_command}` is `nvim`, not the shell).
535
+ const envPairs = [
536
+ `HL_OUTPUT=${shellQuote(outPath)}`,
537
+ `HL_SOURCE=${shellQuote(absFile)}`,
538
+ ...(opts.submitFlagPath ? [`HL_SUBMIT_FLAG=${shellQuote(opts.submitFlagPath)}`] : []),
539
+ ];
385
540
  const paneCmd = [
386
541
  'exec',
387
542
  'env',
388
- `HL_OUTPUT=${shellQuote(outPath)}`,
389
- `HL_SOURCE=${shellQuote(absFile)}`,
543
+ ...envPairs,
390
544
  shellQuote(bin),
391
545
  ...editorArgs.map(shellQuote),
392
546
  ].join(' ');
393
547
  try {
394
- await runInTmuxPane(paneCmd);
548
+ if (opts.tmuxPopup) {
549
+ const w = opts.popupSize ? opts.popupSize.w : '90%';
550
+ const h = opts.popupSize ? opts.popupSize.h : '90%';
551
+ execFileSync('tmux', ['display-popup', '-E', '-w', w, '-h', h, paneCmd], { stdio: 'inherit' });
552
+ }
553
+ else {
554
+ await runInTmuxPane(paneCmd);
555
+ }
395
556
  }
396
557
  catch (err) {
397
558
  process.stderr.write(`tmux dispatch failed, running in current terminal: ${err instanceof Error ? err.message : String(err)}\n`);
@@ -412,12 +573,13 @@ export async function launchReview(file, opts) {
412
573
  }
413
574
  }
414
575
  const now = new Date().toISOString();
576
+ const didSubmit = opts.submitFlagPath ? existsSync(opts.submitFlagPath) : true;
415
577
  const result = {
416
578
  file: absFile,
417
- submitted: true,
418
- approved: comments.length === 0,
579
+ submitted: didSubmit,
580
+ approved: didSubmit && comments.length === 0,
419
581
  comments,
420
- submittedAt: now,
582
+ ...(didSubmit ? { submittedAt: now } : {}),
421
583
  savedAt: now,
422
584
  };
423
585
  atomicWrite(outPath, JSON.stringify(result, null, 2) + '\n');
package/dist/tui/input.js CHANGED
@@ -177,15 +177,37 @@ function handleInteractionAction(input, key, state, interaction, render) {
177
177
  render();
178
178
  return;
179
179
  }
180
- // Comment mode: allowFreetext + options exist. Multi-select carries its
181
- // checked set on submit, so don't pre-attach a single option.
180
+ // Comment mode: allowFreetext + options exist.
181
+ //
182
+ // Single-select: pre-attach the focused option (the comment qualifies that
183
+ // pick). Multi-select: also pre-attach the focused option — the comment is
184
+ // saved under `optionComments[id]` (per-option note) and submitting
185
+ // auto-checks the option. From the [c] freetext row in multi-select,
186
+ // start with no attachment so the comment becomes the overall freetext.
182
187
  if (input === 'c' && interaction.allowFreetext && opts.length > 0) {
183
- const preselected = !interaction.multiSelect && state.selectedAction < opts.length
184
- ? opts[state.selectedAction].id
185
- : undefined;
186
- state.inputMode = preselected !== undefined
187
- ? { kind: 'comment', buffer: '', selectedOptionId: preselected }
188
- : { kind: 'comment', buffer: '' };
188
+ const onOption = state.selectedAction < opts.length;
189
+ if (onOption) {
190
+ const optId = opts[state.selectedAction].id;
191
+ let prefill = '';
192
+ if (interaction.multiSelect) {
193
+ const existing = state.responses.get(interaction.id);
194
+ const prior = existing?.optionComments?.[optId];
195
+ if (typeof prior === 'string')
196
+ prefill = prior;
197
+ }
198
+ state.inputMode = { kind: 'comment', buffer: prefill, selectedOptionId: optId };
199
+ }
200
+ else {
201
+ // On the [c] row in multi-select: pre-fill from existing overall freetext.
202
+ let prefill = '';
203
+ if (interaction.multiSelect) {
204
+ const existing = state.responses.get(interaction.id);
205
+ if (existing !== undefined && typeof existing.freetext === 'string') {
206
+ prefill = existing.freetext;
207
+ }
208
+ }
209
+ state.inputMode = { kind: 'comment', buffer: prefill };
210
+ }
189
211
  render();
190
212
  return;
191
213
  }
@@ -250,6 +272,18 @@ function handleInputMode(input, key, state, render) {
250
272
  }
251
273
  if (key.return) {
252
274
  const interaction = state.interactions[state.currentIndex];
275
+ const perOption = interaction.multiSelect === true
276
+ && mode.kind === 'comment'
277
+ && mode.selectedOptionId !== undefined;
278
+ if (perOption) {
279
+ // Per-option comment: save under optionComments[id], auto-check the
280
+ // option (idempotent), stay on this interaction so the human can comment
281
+ // on another option.
282
+ setOptionComment(state, interaction, mode.selectedOptionId, mode.buffer);
283
+ state.inputMode = null;
284
+ render();
285
+ return;
286
+ }
253
287
  if (interaction.multiSelect) {
254
288
  commitMulti(state, interaction, mode.buffer);
255
289
  }
@@ -361,14 +395,20 @@ function submitOption(state, interaction, selectedOptionId, freetext) {
361
395
  // submitOption immediacy); Enter just confirms the accumulated set + advances.
362
396
  function toggleMulti(state, interaction, optionId) {
363
397
  const existing = state.responses.get(interaction.id);
364
- const set = new Set(existing?.selectedOptionIds ?? []);
398
+ const priorIds = existing !== undefined && existing.selectedOptionIds !== undefined
399
+ ? existing.selectedOptionIds
400
+ : [];
401
+ const set = new Set(priorIds);
365
402
  if (set.has(optionId))
366
403
  set.delete(optionId);
367
404
  else
368
405
  set.add(optionId);
369
406
  const response = { id: interaction.id, selectedOptionIds: [...set] };
370
- if (existing?.freetext !== undefined)
407
+ if (existing !== undefined && existing.freetext !== undefined)
371
408
  response.freetext = existing.freetext;
409
+ if (existing !== undefined && existing.optionComments !== undefined) {
410
+ response.optionComments = { ...existing.optionComments };
411
+ }
372
412
  state.responses.set(interaction.id, response);
373
413
  // User edited the checked set — no longer a passive carry-over.
374
414
  state.preAnsweredIds.delete(interaction.id);
@@ -378,15 +418,52 @@ function toggleMulti(state, interaction, optionId) {
378
418
  * answered, optionally setting/replacing freetext. */
379
419
  function commitMulti(state, interaction, freetext) {
380
420
  const existing = state.responses.get(interaction.id);
421
+ const priorIds = existing !== undefined && existing.selectedOptionIds !== undefined
422
+ ? existing.selectedOptionIds
423
+ : [];
381
424
  const response = {
382
425
  id: interaction.id,
383
- selectedOptionIds: [...(existing?.selectedOptionIds ?? [])],
426
+ selectedOptionIds: [...priorIds],
384
427
  };
385
- const ft = freetext ?? existing?.freetext;
428
+ let ft;
429
+ if (freetext !== undefined)
430
+ ft = freetext;
431
+ else if (existing !== undefined)
432
+ ft = existing.freetext;
386
433
  if (ft !== undefined)
387
434
  response.freetext = ft;
435
+ if (existing !== undefined && existing.optionComments !== undefined) {
436
+ response.optionComments = { ...existing.optionComments };
437
+ }
388
438
  state.responses.set(interaction.id, response);
389
439
  // Explicit confirm overrides any preAnswered seed.
390
440
  state.preAnsweredIds.delete(interaction.id);
391
441
  state.persist?.();
392
442
  }
443
+ /**
444
+ * Multi-select per-option comment: write `comment` to optionComments[optionId]
445
+ * and auto-check the option. Empty comment still records the entry (the user
446
+ * committed deliberately via Enter; Esc cancels without write).
447
+ */
448
+ function setOptionComment(state, interaction, optionId, comment) {
449
+ const existing = state.responses.get(interaction.id);
450
+ const priorIds = existing !== undefined && existing.selectedOptionIds !== undefined
451
+ ? existing.selectedOptionIds
452
+ : [];
453
+ const set = new Set(priorIds);
454
+ set.add(optionId);
455
+ const priorComments = existing !== undefined && existing.optionComments !== undefined
456
+ ? existing.optionComments
457
+ : {};
458
+ const nextComments = { ...priorComments, [optionId]: comment };
459
+ const response = {
460
+ id: interaction.id,
461
+ selectedOptionIds: [...set],
462
+ optionComments: nextComments,
463
+ };
464
+ if (existing !== undefined && existing.freetext !== undefined)
465
+ response.freetext = existing.freetext;
466
+ state.responses.set(interaction.id, response);
467
+ state.preAnsweredIds.delete(interaction.id);
468
+ state.persist?.();
469
+ }
@@ -289,10 +289,11 @@ export function renderItemReview(state, cols, rows) {
289
289
  const label = interaction.freetextLabel !== undefined
290
290
  ? interaction.freetextLabel
291
291
  : state.inputMode.kind === 'comment' ? 'Comment' : 'Response';
292
- // Show attached option (single-select comment mode only) Tab cycles.
293
- // Multi-select comments carry the checked set, so no attach line.
292
+ // Show attached option in comment mode. For single-select the comment
293
+ // qualifies the pick; for multi-select an attached option means the
294
+ // comment is saved as a per-option note (and auto-checks the option).
294
295
  let attachedLine;
295
- if (state.inputMode.kind === 'comment' && !interaction.multiSelect) {
296
+ if (state.inputMode.kind === 'comment') {
296
297
  const attachedId = state.inputMode.selectedOptionId;
297
298
  const opts = interaction.options;
298
299
  if (opts.length > 0) {
@@ -301,7 +302,7 @@ export function renderItemReview(state, cols, rows) {
301
302
  : undefined;
302
303
  const valueText = attached !== undefined
303
304
  ? `${CYAN}${singleLine(attached.label)}${RESET}`
304
- : `${DIM}none${RESET}`;
305
+ : `${DIM}none (overall)${RESET}`;
305
306
  attachedLine = ` ${DIM}attached:${RESET} ${valueText} ${DIM}[tab to cycle]${RESET}`;
306
307
  }
307
308
  }
@@ -382,10 +383,11 @@ function renderActions(interaction, selectedAction, maxW, existing) {
382
383
  const prefixWidth = multi ? 12 : 8;
383
384
  const indent = ' '.repeat(prefixWidth);
384
385
  const contentMax = Math.max(20, maxW - prefixWidth);
386
+ const optionComments = existing !== undefined ? existing.optionComments : undefined;
385
387
  for (let i = 0; i < opts.length; i++) {
386
388
  const o = opts[i];
387
389
  const cursor = i === selectedAction ? `${CYAN}▸${RESET}` : ' ';
388
- const sc = o.shortcut ?? ' ';
390
+ const sc = o.shortcut === undefined ? ' ' : o.shortcut;
389
391
  const keyBadge = `${DIM}[${sc}]${RESET}`;
390
392
  const box = multi
391
393
  ? (checked.has(o.id) ? `${GREEN}[x]${RESET}` : `${DIM}[ ]${RESET}`) + ' '
@@ -401,10 +403,25 @@ function renderActions(interaction, selectedAction, maxW, existing) {
401
403
  lines.push(`${indent}${DIM}${dl}${RESET}`);
402
404
  }
403
405
  }
406
+ if (multi && optionComments !== undefined) {
407
+ const note = optionComments[o.id];
408
+ if (typeof note === 'string' && note.length > 0) {
409
+ const noteLines = wrap(`✎ ${sanitize(note)}`, contentMax);
410
+ for (const nl of noteLines) {
411
+ lines.push(`${indent}${YELLOW}${nl}${RESET}`);
412
+ }
413
+ }
414
+ }
404
415
  }
405
416
  if (interaction.allowFreetext && opts.length > 0) {
406
417
  const cursor = opts.length === selectedAction ? `${CYAN}▸${RESET}` : ' ';
407
- const label = interaction.freetextLabel !== undefined ? interaction.freetextLabel : 'Add comment';
418
+ let label;
419
+ if (interaction.freetextLabel !== undefined)
420
+ label = interaction.freetextLabel;
421
+ else if (multi)
422
+ label = 'Add overall comment (c on an option for per-option)';
423
+ else
424
+ label = 'Add comment';
408
425
  lines.push(` ${cursor} ${DIM}[c]${RESET} ${label}`);
409
426
  }
410
427
  else if (interaction.allowFreetext && opts.length === 0) {
@@ -463,11 +480,17 @@ export function renderFinal(state, cols, rows) {
463
480
  }
464
481
  export function responseSummary(r, interaction) {
465
482
  if (r.selectedOptionIds !== undefined) {
466
- const labels = r.selectedOptionIds
483
+ const oc = r.optionComments;
484
+ const parts = r.selectedOptionIds
467
485
  .map((id) => interaction.options.find((o) => o.id === id))
468
486
  .filter((o) => o !== undefined)
469
- .map((o) => sanitize(o.label));
470
- const picks = labels.length > 0 ? labels.join(', ') : '(none)';
487
+ .map((o) => {
488
+ const note = oc !== undefined ? oc[o.id] : undefined;
489
+ return typeof note === 'string' && note.length > 0
490
+ ? `${sanitize(o.label)} ("${sanitize(note)}")`
491
+ : sanitize(o.label);
492
+ });
493
+ const picks = parts.length > 0 ? parts.join(', ') : '(none)';
471
494
  if (r.freetext)
472
495
  return `${picks}: "${sanitize(r.freetext)}"`;
473
496
  return picks;
package/dist/types.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Key } from './tui/terminal.js';
2
- export type InteractionKind = 'notify' | 'validation' | 'decision' | 'context' | 'error';
2
+ export type InteractionKind = 'notify' | 'validation' | 'decision' | 'context' | 'error' | 'review';
3
3
  export interface InteractionOption {
4
4
  id: string;
5
5
  label: string;
@@ -45,6 +45,10 @@ export interface InteractionResponse {
45
45
  /** Multi-select picks (set only for `multiSelect` interactions). */
46
46
  selectedOptionIds?: string[];
47
47
  freetext?: string;
48
+ /** Multi-select per-option comments, keyed by option id. Each entry is a
49
+ * comment scoped to that specific option (independent of the overall
50
+ * `freetext`). Set only for `multiSelect` interactions. */
51
+ optionComments?: Record<string, string>;
48
52
  }
49
53
  export interface DeckSource {
50
54
  sessionName?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/humanloop",
3
- "version": "0.3.8",
3
+ "version": "0.3.10",
4
4
  "description": "Human-in-the-loop decision TUI — agents write questions, humans answer them",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",