@crouton-kit/humanloop 0.3.8 → 0.3.9

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
@@ -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
  },
@@ -6,6 +6,23 @@ 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
+ };
9
26
  }
10
27
  /**
11
28
  * Compact stdout rendering for the agent: per comment just the line:col
@@ -179,6 +179,9 @@ function reviewVimscript() {
179
179
  ``,
180
180
  `function! s:Submit() abort`,
181
181
  ` call s:Save()`,
182
+ ` if $HL_SUBMIT_FLAG !=# ''`,
183
+ ` call writefile([''], $HL_SUBMIT_FLAG)`,
184
+ ` endif`,
182
185
  ` qa!`,
183
186
  `endfunction`,
184
187
  ``,
@@ -285,7 +288,24 @@ function rangeLabel(c) {
285
288
  export function formatFeedbackSummary(result, feedbackJsonPath) {
286
289
  const n = result.comments.length;
287
290
  const out = [];
288
- if (n === 0) {
291
+ if (!result.submitted) {
292
+ if (n > 0) {
293
+ out.push(`hl propose — draft saved: ${n} comment${n === 1 ? '' : 's'} on ${result.file} (not yet submitted)`);
294
+ out.push('');
295
+ result.comments.forEach((c, i) => {
296
+ const original = c.quote && c.quote.length > 0 ? c.quote : c.lineText;
297
+ const lines = original.split('\n');
298
+ out.push(` ${i + 1}. ${rangeLabel(c)}`);
299
+ lines.forEach((ln, k) => out.push(k === 0 ? ` text: ${ln}` : ` ${ln}`));
300
+ out.push(` comment: ${c.comment}`);
301
+ out.push('');
302
+ });
303
+ }
304
+ else {
305
+ out.push(`hl propose — draft saved: no comments yet on ${result.file}`);
306
+ }
307
+ }
308
+ else if (n === 0) {
289
309
  out.push(`hl propose — approved: 0 comments on ${result.file} (human signalled looks-good; proceed)`);
290
310
  }
291
311
  else {
@@ -367,7 +387,12 @@ export async function launchReview(file, opts) {
367
387
  const dir = mkdtempSync(join(tmpdir(), 'hl-review-'));
368
388
  const initPath = join(dir, 'review.vim');
369
389
  writeFileSync(initPath, reviewVimscript());
370
- const env = { ...process.env, HL_OUTPUT: outPath, HL_SOURCE: absFile };
390
+ const env = {
391
+ ...process.env,
392
+ HL_OUTPUT: outPath,
393
+ HL_SOURCE: absFile,
394
+ ...(opts.submitFlagPath ? { HL_SUBMIT_FLAG: opts.submitFlagPath } : {}),
395
+ };
371
396
  // `-u NONE`: do NOT load the user's init.lua / LazyVim / plugins / keymaps.
372
397
  // Default runtimepath still includes the config dir (for the gloam
373
398
  // colorscheme) and the site dir (for the treesitter markdown parser), so the
@@ -382,16 +407,27 @@ export async function launchReview(file, opts) {
382
407
  if (inTmux) {
383
408
  // `exec env …` so the editor replaces the shell and becomes the pane's
384
409
  // process (so tmux `#{pane_current_command}` is `nvim`, not the shell).
410
+ const envPairs = [
411
+ `HL_OUTPUT=${shellQuote(outPath)}`,
412
+ `HL_SOURCE=${shellQuote(absFile)}`,
413
+ ...(opts.submitFlagPath ? [`HL_SUBMIT_FLAG=${shellQuote(opts.submitFlagPath)}`] : []),
414
+ ];
385
415
  const paneCmd = [
386
416
  'exec',
387
417
  'env',
388
- `HL_OUTPUT=${shellQuote(outPath)}`,
389
- `HL_SOURCE=${shellQuote(absFile)}`,
418
+ ...envPairs,
390
419
  shellQuote(bin),
391
420
  ...editorArgs.map(shellQuote),
392
421
  ].join(' ');
393
422
  try {
394
- await runInTmuxPane(paneCmd);
423
+ if (opts.tmuxPopup) {
424
+ const w = opts.popupSize ? opts.popupSize.w : '90%';
425
+ const h = opts.popupSize ? opts.popupSize.h : '90%';
426
+ execFileSync('tmux', ['display-popup', '-E', '-w', w, '-h', h, paneCmd], { stdio: 'inherit' });
427
+ }
428
+ else {
429
+ await runInTmuxPane(paneCmd);
430
+ }
395
431
  }
396
432
  catch (err) {
397
433
  process.stderr.write(`tmux dispatch failed, running in current terminal: ${err instanceof Error ? err.message : String(err)}\n`);
@@ -412,12 +448,13 @@ export async function launchReview(file, opts) {
412
448
  }
413
449
  }
414
450
  const now = new Date().toISOString();
451
+ const didSubmit = opts.submitFlagPath ? existsSync(opts.submitFlagPath) : true;
415
452
  const result = {
416
453
  file: absFile,
417
- submitted: true,
418
- approved: comments.length === 0,
454
+ submitted: didSubmit,
455
+ approved: didSubmit && comments.length === 0,
419
456
  comments,
420
- submittedAt: now,
457
+ ...(didSubmit ? { submittedAt: now } : {}),
421
458
  savedAt: now,
422
459
  };
423
460
  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.9",
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",