@crouton-kit/humanloop 0.1.4 → 0.3.1

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/tui/input.js CHANGED
@@ -92,7 +92,12 @@ function handleOverview(input, key, state, render, exit) {
92
92
  if (interaction !== undefined) {
93
93
  const matched = interaction.options.find((o) => o.shortcut === input);
94
94
  if (matched !== undefined) {
95
- submitOption(state, interaction, matched.id, undefined);
95
+ if (interaction.multiSelect) {
96
+ toggleMulti(state, interaction, matched.id);
97
+ }
98
+ else {
99
+ submitOption(state, interaction, matched.id, undefined);
100
+ }
96
101
  // Don't auto-advance the cursor — users may want to re-answer the same
97
102
  // question. The response icon flips ✓ and they can j/k away when ready.
98
103
  render();
@@ -117,6 +122,13 @@ function handleItemReview(input, key, state, render) {
117
122
  render();
118
123
  return;
119
124
  }
125
+ // Space toggles the focused option for multi-select; otherwise expand context.
126
+ if (input === ' ' && interaction.multiSelect
127
+ && state.selectedAction < interaction.options.length) {
128
+ toggleMulti(state, interaction, interaction.options[state.selectedAction].id);
129
+ render();
130
+ return;
131
+ }
120
132
  if (input === ' ') {
121
133
  state.detailExpanded = !state.detailExpanded;
122
134
  render();
@@ -151,18 +163,23 @@ function handleItemReview(input, key, state, render) {
151
163
  }
152
164
  function handleInteractionAction(input, key, state, interaction, render) {
153
165
  const opts = interaction.options;
154
- // Match by shortcut
166
+ // Match by shortcut. Multi-select toggles (stay put); single-select submits.
155
167
  const matched = opts.find((o) => o.shortcut === input);
156
168
  if (matched !== undefined) {
169
+ if (interaction.multiSelect) {
170
+ toggleMulti(state, interaction, matched.id);
171
+ render();
172
+ return;
173
+ }
157
174
  submitOption(state, interaction, matched.id, undefined);
158
175
  advanceItem(state, 1);
159
176
  render();
160
177
  return;
161
178
  }
162
- // Comment mode: allowFreetext + options exist
163
- // If the cursor is on an option row, pre-attach that option to the comment.
179
+ // Comment mode: allowFreetext + options exist. Multi-select carries its
180
+ // checked set on submit, so don't pre-attach a single option.
164
181
  if (input === 'c' && interaction.allowFreetext && opts.length > 0) {
165
- const preselected = state.selectedAction < opts.length
182
+ const preselected = !interaction.multiSelect && state.selectedAction < opts.length
166
183
  ? opts[state.selectedAction].id
167
184
  : undefined;
168
185
  state.inputMode = preselected !== undefined
@@ -181,8 +198,15 @@ function handleInteractionAction(input, key, state, interaction, render) {
181
198
  return;
182
199
  }
183
200
  }
184
- // Enter on selected option row
201
+ // Enter on selected option row. Multi-select confirms the accumulated set
202
+ // and advances; single-select picks that one option.
185
203
  if (key.return && state.selectedAction < opts.length) {
204
+ if (interaction.multiSelect) {
205
+ commitMulti(state, interaction);
206
+ advanceItem(state, 1);
207
+ render();
208
+ return;
209
+ }
186
210
  const o = opts[state.selectedAction];
187
211
  submitOption(state, interaction, o.id, undefined);
188
212
  advanceItem(state, 1);
@@ -225,8 +249,13 @@ function handleInputMode(input, key, state, render) {
225
249
  }
226
250
  if (key.return) {
227
251
  const interaction = state.interactions[state.currentIndex];
228
- const attached = mode.kind === 'comment' ? mode.selectedOptionId : undefined;
229
- submitOption(state, interaction, attached, mode.buffer);
252
+ if (interaction.multiSelect) {
253
+ commitMulti(state, interaction, mode.buffer);
254
+ }
255
+ else {
256
+ const attached = mode.kind === 'comment' ? mode.selectedOptionId : undefined;
257
+ submitOption(state, interaction, attached, mode.buffer);
258
+ }
230
259
  state.inputMode = null;
231
260
  advanceItem(state, 1);
232
261
  render();
@@ -285,3 +314,33 @@ function submitOption(state, interaction, selectedOptionId, freetext) {
285
314
  state.responses.set(interaction.id, response);
286
315
  state.persist?.();
287
316
  }
317
+ // ── Multi-select ─────────────────────────────────────────────────────────────
318
+ // Toggle/commit write progressively into state.responses (mirrors single-select
319
+ // submitOption immediacy); Enter just confirms the accumulated set + advances.
320
+ function toggleMulti(state, interaction, optionId) {
321
+ const existing = state.responses.get(interaction.id);
322
+ const set = new Set(existing?.selectedOptionIds ?? []);
323
+ if (set.has(optionId))
324
+ set.delete(optionId);
325
+ else
326
+ set.add(optionId);
327
+ const response = { id: interaction.id, selectedOptionIds: [...set] };
328
+ if (existing?.freetext !== undefined)
329
+ response.freetext = existing.freetext;
330
+ state.responses.set(interaction.id, response);
331
+ state.persist?.();
332
+ }
333
+ /** Ensure a (possibly empty) response exists so the interaction counts as
334
+ * answered, optionally setting/replacing freetext. */
335
+ function commitMulti(state, interaction, freetext) {
336
+ const existing = state.responses.get(interaction.id);
337
+ const response = {
338
+ id: interaction.id,
339
+ selectedOptionIds: [...(existing?.selectedOptionIds ?? [])],
340
+ };
341
+ const ft = freetext ?? existing?.freetext;
342
+ if (ft !== undefined)
343
+ response.freetext = ft;
344
+ state.responses.set(interaction.id, response);
345
+ state.persist?.();
346
+ }
@@ -1,45 +1,5 @@
1
- import { execFileSync } from 'node:child_process';
2
1
  import stringWidth from 'string-width';
3
- // ── Termrender body rendering ────────────────────────────────────────────────
4
- let _termrenderAvail = null;
5
- function isTermrenderAvailable() {
6
- if (_termrenderAvail !== null)
7
- return _termrenderAvail;
8
- try {
9
- execFileSync('termrender', ['--version'], { stdio: 'pipe', timeout: 3000 });
10
- _termrenderAvail = true;
11
- }
12
- catch {
13
- _termrenderAvail = false;
14
- }
15
- return _termrenderAvail;
16
- }
17
- const _bodyCache = new Map();
18
- function renderBody(text, width) {
19
- const key = `${text}\0${width}`;
20
- const cached = _bodyCache.get(key);
21
- if (cached)
22
- return cached;
23
- if (isTermrenderAvailable()) {
24
- try {
25
- const out = execFileSync('termrender', ['--width', String(width)], {
26
- input: text,
27
- encoding: 'utf-8',
28
- timeout: 5000,
29
- stdio: ['pipe', 'pipe', 'pipe'],
30
- });
31
- const lines = out.split('\n');
32
- if (lines.length > 0 && lines[lines.length - 1] === '')
33
- lines.pop();
34
- _bodyCache.set(key, lines);
35
- return lines;
36
- }
37
- catch { /* fall through */ }
38
- }
39
- const fallback = wrap(sanitize(text), width);
40
- _bodyCache.set(key, fallback);
41
- return fallback;
42
- }
2
+ import { renderMarkdown } from '../render/termrender.js';
43
3
  // ── ANSI helpers ─────────────────────────────────────────────────────────────
44
4
  const ESC = '\x1b[';
45
5
  const RESET = `${ESC}0m`;
@@ -282,7 +242,7 @@ export function renderItemReview(state, cols, rows) {
282
242
  const bodyLines = [];
283
243
  if (interaction.body) {
284
244
  bodyLines.push('');
285
- for (const line of renderBody(interaction.body, maxW)) {
245
+ for (const line of renderMarkdown(interaction.body, maxW)) {
286
246
  bodyLines.push(` ${line}`);
287
247
  }
288
248
  }
@@ -316,9 +276,10 @@ export function renderItemReview(state, cols, rows) {
316
276
  const label = interaction.freetextLabel !== undefined
317
277
  ? interaction.freetextLabel
318
278
  : state.inputMode.kind === 'comment' ? 'Comment' : 'Response';
319
- // Show attached option (comment mode only) — Tab cycles
279
+ // Show attached option (single-select comment mode only) — Tab cycles.
280
+ // Multi-select comments carry the checked set, so no attach line.
320
281
  let attachedLine;
321
- if (state.inputMode.kind === 'comment') {
282
+ if (state.inputMode.kind === 'comment' && !interaction.multiSelect) {
322
283
  const attachedId = state.inputMode.selectedOptionId;
323
284
  const opts = interaction.options;
324
285
  if (opts.length > 0) {
@@ -370,11 +331,18 @@ export function renderItemReview(state, cols, rows) {
370
331
  visibleBody = bodyLines;
371
332
  }
372
333
  // Footer hint — mention scroll keys when body overflows
373
- const footerParts = [
374
- `${DIM}n/p${RESET} prev/next`,
375
- `${DIM}space${RESET} expand`,
376
- `${DIM}q${RESET} overview`,
377
- ];
334
+ const footerParts = interaction.multiSelect === true
335
+ ? [
336
+ `${DIM}n/p${RESET} prev/next`,
337
+ `${DIM}space${RESET} toggle`,
338
+ `${DIM}enter${RESET} confirm`,
339
+ `${DIM}q${RESET} overview`,
340
+ ]
341
+ : [
342
+ `${DIM}n/p${RESET} prev/next`,
343
+ `${DIM}space${RESET} expand`,
344
+ `${DIM}q${RESET} overview`,
345
+ ];
378
346
  if (overflows)
379
347
  footerParts.unshift(`${DIM}u/d${RESET} scroll`);
380
348
  const footer = ` ${footerParts.join(' ')}`;
@@ -396,7 +364,9 @@ function renderActions(interaction, selectedAction, maxW, existing) {
396
364
  const opts = interaction.options;
397
365
  // Prefix on first row: " X [s] " — 2 + 1 (cursor) + 1 + 3 ([s]) + 1 = 8 visible cols.
398
366
  // Continuation rows align under the label so each option reads as a block.
399
- const prefixWidth = 8;
367
+ const multi = interaction.multiSelect === true;
368
+ const checked = new Set(existing?.selectedOptionIds ?? []);
369
+ const prefixWidth = multi ? 12 : 8;
400
370
  const indent = ' '.repeat(prefixWidth);
401
371
  const contentMax = Math.max(20, maxW - prefixWidth);
402
372
  for (let i = 0; i < opts.length; i++) {
@@ -404,9 +374,12 @@ function renderActions(interaction, selectedAction, maxW, existing) {
404
374
  const cursor = i === selectedAction ? `${CYAN}▸${RESET}` : ' ';
405
375
  const sc = o.shortcut ?? ' ';
406
376
  const keyBadge = `${DIM}[${sc}]${RESET}`;
377
+ const box = multi
378
+ ? (checked.has(o.id) ? `${GREEN}[x]${RESET}` : `${DIM}[ ]${RESET}`) + ' '
379
+ : '';
407
380
  const labelLines = wrap(sanitize(o.label), contentMax);
408
381
  for (let j = 0; j < labelLines.length; j++) {
409
- const prefix = j === 0 ? ` ${cursor} ${keyBadge} ` : indent;
382
+ const prefix = j === 0 ? ` ${cursor} ${box}${keyBadge} ` : indent;
410
383
  lines.push(`${prefix}${labelLines[j]}`);
411
384
  }
412
385
  if (o.description) {
@@ -473,6 +446,16 @@ export function renderFinal(state, cols, rows) {
473
446
  return centerHorizontal(lines.slice(0, rows), cols, maxW + 2);
474
447
  }
475
448
  export function responseSummary(r, interaction) {
449
+ if (r.selectedOptionIds !== undefined) {
450
+ const labels = r.selectedOptionIds
451
+ .map((id) => interaction.options.find((o) => o.id === id))
452
+ .filter((o) => o !== undefined)
453
+ .map((o) => sanitize(o.label));
454
+ const picks = labels.length > 0 ? labels.join(', ') : '(none)';
455
+ if (r.freetext)
456
+ return `${picks}: "${sanitize(r.freetext)}"`;
457
+ return picks;
458
+ }
476
459
  const opt = r.selectedOptionId
477
460
  ? interaction.options.find((o) => o.id === r.selectedOptionId)
478
461
  : undefined;
@@ -1,10 +1,8 @@
1
- import type { InteractionResponse } from '../types.js';
2
- export interface TuiOutput {
3
- responses: InteractionResponse[];
4
- completedAt: string;
5
- }
1
+ import type { ResolutionEnvelope } from '../types.js';
6
2
  export interface TmuxDispatchOpts {
7
3
  sessionId?: string;
8
4
  visuals: boolean;
5
+ /** Interaction dir forwarded to the child so response.json lands there. */
6
+ dir: string;
9
7
  }
10
- export declare function dispatchToTmuxPane(file: string, opts: TmuxDispatchOpts): Promise<TuiOutput>;
8
+ export declare function dispatchToTmuxPane(file: string, opts: TmuxDispatchOpts): Promise<ResolutionEnvelope>;
package/dist/tui/tmux.js CHANGED
@@ -15,8 +15,10 @@ function buildChildCmd(file, resultPath, opts) {
15
15
  const parts = [
16
16
  shellQuote(process.execPath),
17
17
  shellQuote(scriptPath),
18
- 'create',
18
+ 'ask',
19
19
  shellQuote(file),
20
+ '--dir',
21
+ shellQuote(opts.dir),
20
22
  '--write-to',
21
23
  shellQuote(resultPath),
22
24
  ];
@@ -29,8 +31,8 @@ function buildChildCmd(file, resultPath, opts) {
29
31
  return parts.join(' ');
30
32
  }
31
33
  export async function dispatchToTmuxPane(file, opts) {
32
- const dir = mkdtempSync(join(tmpdir(), 'hl-'));
33
- const resultPath = join(dir, 'result.json');
34
+ const parentTmp = mkdtempSync(join(tmpdir(), 'hl-'));
35
+ const resultPath = join(parentTmp, 'result.json');
34
36
  const cmd = buildChildCmd(file, resultPath, opts);
35
37
  // Capture the spawned pane id so we can detect if the user closes it
36
38
  // without finishing — otherwise the parent would poll forever.
@@ -65,7 +67,7 @@ export async function dispatchToTmuxPane(file, opts) {
65
67
  }
66
68
  catch { /* ignore */ }
67
69
  try {
68
- rmdirSync(dir);
70
+ rmdirSync(parentTmp);
69
71
  }
70
72
  catch { /* ignore */ }
71
73
  return JSON.parse(json);
package/dist/types.d.ts CHANGED
@@ -13,13 +13,19 @@ export interface Interaction {
13
13
  body?: string;
14
14
  bodyPath?: string;
15
15
  options: InteractionOption[];
16
+ /** When true the human can check multiple options; the response carries
17
+ * `selectedOptionIds`. Absent/false = single-select (unchanged). */
18
+ multiSelect?: boolean;
16
19
  allowFreetext?: boolean;
17
20
  freetextLabel?: string;
18
21
  kind?: InteractionKind;
19
22
  }
20
23
  export interface InteractionResponse {
21
24
  id: string;
25
+ /** Single-select pick. */
22
26
  selectedOptionId?: string;
27
+ /** Multi-select picks (set only for `multiSelect` interactions). */
28
+ selectedOptionIds?: string[];
23
29
  freetext?: string;
24
30
  }
25
31
  export interface DeckSource {
@@ -32,6 +38,32 @@ export interface Deck {
32
38
  source?: DeckSource;
33
39
  interactions: Interaction[];
34
40
  }
41
+ export interface FeedbackComment {
42
+ id: string;
43
+ /** 1-based source line where the comment is anchored (start). */
44
+ line: number;
45
+ /** 1-based source line where the anchored range ends (== line for one line). */
46
+ endLine: number;
47
+ /** Exact selected substring when the human made a visual selection. */
48
+ quote?: string;
49
+ /** 0-based byte column where a partial (charwise) selection starts on `line`. */
50
+ colStart?: number;
51
+ /** 0-based exclusive byte column where the selection ends on `endLine`. */
52
+ colEnd?: number;
53
+ /** Full source text of the anchored line(s) — context for the agent. */
54
+ lineText: string;
55
+ comment: string;
56
+ createdAt: string;
57
+ }
58
+ export interface FeedbackResult {
59
+ file: string;
60
+ submitted: boolean;
61
+ /** True when submitted with zero comments — human signalled "looks good". */
62
+ approved: boolean;
63
+ comments: FeedbackComment[];
64
+ submittedAt?: string;
65
+ savedAt: string;
66
+ }
35
67
  export interface VisualBlock {
36
68
  questionId: string;
37
69
  content: string;
@@ -58,6 +90,45 @@ export interface TuiState {
58
90
  scrollOffset: number;
59
91
  persist?: () => void;
60
92
  }
93
+ /**
94
+ * Resolution contract returned by `ask`/`inbox`. On-disk `response.json` stays
95
+ * `{ responses, completedAt }`; `responsePath` points at it. `hl schema
96
+ * response` returns the JSON Schema this `schema` id names.
97
+ */
98
+ export interface ResolutionEnvelope {
99
+ /** 1 line/interaction "<title>: <option label>[ — <freetext>]"; deterministic, no LLM. */
100
+ summary: string;
101
+ /** Absolute path to response.json. */
102
+ responsePath: string;
103
+ schema: 'humanloop.response/v2';
104
+ /** Inline (small). */
105
+ responses: InteractionResponse[];
106
+ /** ISO timestamp. */
107
+ completedAt: string;
108
+ }
109
+ /**
110
+ * One pending interaction discovered by `scanInbox`. Read from the
111
+ * `deck.json` header only — never the full deck.
112
+ */
113
+ export interface InboxItem {
114
+ dir: string;
115
+ id: string;
116
+ title?: string;
117
+ subtitle?: string;
118
+ kind?: InteractionKind;
119
+ /** `deck.source.blockedSince` ?? `statSync(deck.json).mtime` (ISO). */
120
+ blockedSince: string;
121
+ source?: DeckSource;
122
+ }
123
+ /** Options for `display()` — the live-watch tmux pane surface. */
124
+ export interface DisplayOpts {
125
+ /** Pass `--watch` so the pane live-updates on edits. Default true. */
126
+ watch?: boolean;
127
+ /** `'auto'` (default) splits until the pane budget, then opens a new window. */
128
+ window?: 'auto' | 'split' | 'new';
129
+ /** Pane budget per window before `'auto'` opens a new window. Default 3. */
130
+ maxPanes?: number;
131
+ }
61
132
  export type GenerateVisual = (interaction: Interaction) => Promise<{
62
133
  ok: true;
63
134
  ansi: string;
@@ -1,5 +1,5 @@
1
1
  import { query } from '@r-cli/sdk';
2
- import { execSync } from 'child_process';
2
+ import { renderMarkdown } from '../render/termrender.js';
3
3
  const VISUAL_SYSTEM_PROMPT = `You're briefing a CTO-level engineer in the 30 seconds before they decide. They've been off this problem for days; they need a fast re-ground in what *already exists* — the files, data flow, or constraint they're deciding inside of — not a lecture on tradeoffs.
4
4
 
5
5
  # Length
@@ -68,31 +68,6 @@ async function callHaiku(prompt, systemPrompt) {
68
68
  return null;
69
69
  }
70
70
  }
71
- function renderWithTermrender(markdown, width) {
72
- // First attempt
73
- const result = tryTermrender(markdown, width);
74
- if (result !== null)
75
- return result;
76
- // Fallback: strip all directives and render as plain markdown
77
- const stripped = markdown.replace(/^:{3,}\w*.*$/gm, '').trim();
78
- const fallback = tryTermrender(stripped, width);
79
- return fallback ?? markdown;
80
- }
81
- function tryTermrender(markdown, width) {
82
- try {
83
- return execSync(`termrender -w ${width}`, {
84
- input: markdown,
85
- encoding: 'utf8',
86
- timeout: 5000,
87
- env: { ...process.env, TERMRENDER_COLOR: '1' },
88
- }).trimEnd();
89
- }
90
- catch (err) {
91
- const stderr = err.stderr || '';
92
- process.stderr.write(`[hl] termrender: ${stderr.split('\n')[0]}\n`);
93
- return null;
94
- }
95
- }
96
71
  // defaultGenerateVisual matches the GenerateVisual contract for use with
97
72
  // mountPanel. Width is read from process.stdout.columns so callers that
98
73
  // embed humanloop in a sub-region should supply their own closure that bakes
@@ -113,7 +88,7 @@ export async function defaultGenerateVisual(interaction, conversationContext) {
113
88
  .replace(/^```[\w]*\n?/gm, '')
114
89
  .replace(/^```\s*$/gm, '')
115
90
  .trim();
116
- const ansi = renderWithTermrender(markdown, width);
91
+ const ansi = renderMarkdown(markdown, width).join('\n');
117
92
  return { ok: true, ansi, markdown };
118
93
  }
119
94
  return { ok: false, error: 'haiku returned no output' };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/humanloop",
3
- "version": "0.1.4",
3
+ "version": "0.3.1",
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",
@@ -25,12 +25,14 @@
25
25
  "build": "tsc",
26
26
  "dev": "tsx src/cli.ts",
27
27
  "link": "npm link",
28
+ "postinstall": "node dist/scripts/install-renderer.js || true",
28
29
  "test": "tsx src/__tests__/mount-panel.test.ts"
29
30
  },
30
31
  "dependencies": {
31
32
  "@r-cli/sdk": "^1.3.0",
32
33
  "commander": "^13.0.0",
33
- "string-width": "^7.0.0"
34
+ "string-width": "^7.0.0",
35
+ "zod": "^4.3.6"
34
36
  },
35
37
  "devDependencies": {
36
38
  "@types/node": "^22.0.0",