@crouton-kit/humanloop 0.2.1 → 0.3.2

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.
@@ -25,6 +25,7 @@ export declare const deckSchema: z.ZodObject<{
25
25
  description: z.ZodOptional<z.ZodString>;
26
26
  shortcut: z.ZodOptional<z.ZodString>;
27
27
  }, z.core.$strip>>;
28
+ multiSelect: z.ZodOptional<z.ZodBoolean>;
28
29
  allowFreetext: z.ZodOptional<z.ZodBoolean>;
29
30
  freetextLabel: z.ZodOptional<z.ZodString>;
30
31
  kind: z.ZodOptional<z.ZodEnum<{
@@ -17,6 +17,7 @@ const interactionSchema = z.object({
17
17
  body: z.string().optional(),
18
18
  bodyPath: z.string().optional(),
19
19
  options: z.array(interactionOptionSchema),
20
+ multiSelect: z.boolean().optional(),
20
21
  allowFreetext: z.boolean().optional(),
21
22
  freetextLabel: z.string().optional(),
22
23
  kind: z.enum(['notify', 'validation', 'decision', 'context', 'error']).optional(),
@@ -13,7 +13,7 @@ export declare function ensureRenderer(): void;
13
13
  export declare function isRendererReady(): boolean;
14
14
  /** Render markdown to terminal lines via the pinned binary; plaintext fallback. */
15
15
  export declare function renderMarkdown(md: string, width: number): string[];
16
- /** Validate markdown via `termrender --check` (exit 0 ok / non-zero error). */
16
+ /** Validate markdown via `termrender doc check`. */
17
17
  export declare function checkMarkdown(md: string): {
18
18
  ok: true;
19
19
  } | {
@@ -21,7 +21,7 @@ export declare function checkMarkdown(md: string): {
21
21
  error: string;
22
22
  };
23
23
  export interface DisplayInPaneOpts {
24
- /** Pass `--watch` so the pane live-updates on file edits. Default true. */
24
+ /** Pass watch so the pane live-updates on file edits. Default true. */
25
25
  watch?: boolean;
26
26
  /** Open in a new tmux window instead of splitting the current one. */
27
27
  newWindow?: boolean;
@@ -32,18 +32,34 @@ function binaryOk() {
32
32
  if (!existsSync(VENV_BIN))
33
33
  return false;
34
34
  try {
35
- const out = execFileSync(VENV_BIN, ['--version'], {
35
+ // v2 contract: no --version flag; use -h (exit 0) as a liveness check.
36
+ execFileSync(VENV_BIN, ['-h'], {
36
37
  encoding: 'utf8',
37
38
  stdio: ['ignore', 'pipe', 'pipe'],
38
39
  timeout: 5000,
39
40
  });
40
- const m = out.match(/\d+\.\d+\.\d+/);
41
- return m?.[0] === TERMRENDER_VERSION;
41
+ return true;
42
42
  }
43
43
  catch {
44
44
  return false;
45
45
  }
46
46
  }
47
+ // Returns the termrender version installed in the managed venv (via
48
+ // importlib.metadata), or null if the venv is missing/broken or termrender is
49
+ // not installed. Used by ensureRenderer() to detect drift from the pin and
50
+ // trigger a reinstall — otherwise a venv provisioned at an older pin sticks
51
+ // forever (binaryOk passes for any working binary, regardless of version).
52
+ function installedVersion() {
53
+ if (!existsSync(VENV_PYTHON))
54
+ return null;
55
+ try {
56
+ const out = execFileSync(VENV_PYTHON, ['-c', 'import importlib.metadata as m; print(m.version("termrender"))'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000 });
57
+ return out.trim() || null;
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ }
47
63
  function uvAvailable() {
48
64
  try {
49
65
  execFileSync('uv', ['--version'], { stdio: 'pipe', timeout: 5000 });
@@ -70,7 +86,7 @@ export function ensureRenderer() {
70
86
  rendererState = 'unavailable';
71
87
  return;
72
88
  }
73
- if (binaryOk()) {
89
+ if (binaryOk() && installedVersion() === TERMRENDER_VERSION) {
74
90
  rendererState = 'ready';
75
91
  return;
76
92
  }
@@ -81,7 +97,12 @@ export function ensureRenderer() {
81
97
  return;
82
98
  }
83
99
  try {
84
- execFileSync('uv', ['venv', VENV_DIR], { stdio: 'pipe', timeout: 60000 });
100
+ // Skip venv creation on drift (venv exists with wrong termrender version)
101
+ // — `uv pip install` into the existing venv replaces it in place. Only
102
+ // create when the venv directory is genuinely absent.
103
+ if (!existsSync(VENV_DIR)) {
104
+ execFileSync('uv', ['venv', VENV_DIR], { stdio: 'pipe', timeout: 60000 });
105
+ }
85
106
  execFileSync('uv', ['pip', 'install', '--python', VENV_PYTHON, `termrender==${TERMRENDER_VERSION}`], { stdio: 'pipe', timeout: 120000 });
86
107
  }
87
108
  catch (err) {
@@ -89,9 +110,9 @@ export function ensureRenderer() {
89
110
  rendererState = 'unavailable';
90
111
  return;
91
112
  }
92
- rendererState = binaryOk() ? 'ready' : 'unavailable';
113
+ rendererState = (binaryOk() && installedVersion() === TERMRENDER_VERSION) ? 'ready' : 'unavailable';
93
114
  if (rendererState === 'unavailable') {
94
- process.stderr.write('[hl] termrender install completed but version check failed; using plaintext fallback\n');
115
+ process.stderr.write('[hl] termrender install completed but health check failed; using plaintext fallback\n');
95
116
  }
96
117
  }
97
118
  /** Cheap predicate — true when the pinned managed binary is present and correct. Does not install. */
@@ -172,12 +193,12 @@ export function renderMarkdown(md, width) {
172
193
  ensureRenderer();
173
194
  if (rendererState === 'ready') {
174
195
  try {
175
- const out = execFileSync(VENV_BIN, ['--width', String(width)], {
176
- input: md,
196
+ const input = JSON.stringify({ source: md, width, color: true });
197
+ const out = execFileSync(VENV_BIN, ['doc', 'render'], {
198
+ input,
177
199
  encoding: 'utf-8',
178
200
  timeout: 5000,
179
201
  stdio: ['pipe', 'pipe', 'pipe'],
180
- env: { ...process.env, TERMRENDER_COLOR: '1' },
181
202
  });
182
203
  const lines = out.split('\n');
183
204
  if (lines.length > 0 && lines[lines.length - 1] === '')
@@ -193,23 +214,42 @@ export function renderMarkdown(md, width) {
193
214
  _bodyCache.set(key, fallback);
194
215
  return fallback;
195
216
  }
196
- /** Validate markdown via `termrender --check` (exit 0 ok / non-zero error). */
217
+ /** Validate markdown via `termrender doc check`. */
197
218
  export function checkMarkdown(md) {
198
219
  ensureRenderer();
199
220
  // Renderer unavailable → don't block validation; the body just renders as
200
221
  // plaintext later. Bricking deck validation here would be the wrong default.
201
222
  if (rendererState !== 'ready')
202
223
  return { ok: true };
203
- const result = spawnSync(VENV_BIN, ['--check'], {
204
- input: md,
224
+ const input = JSON.stringify({ source: md });
225
+ const result = spawnSync(VENV_BIN, ['doc', 'check'], {
226
+ input,
205
227
  encoding: 'utf-8',
206
228
  timeout: 5000,
207
229
  });
208
230
  if (result.error) {
209
- return { ok: false, error: `termrender invocation failed: ${result.error.message}` };
231
+ return { ok: false, error: `termrender: invocation failed: ${result.error.message}` };
232
+ }
233
+ let parsed = null;
234
+ const rawStdout = typeof result.stdout === 'string' ? result.stdout : '';
235
+ if (rawStdout) {
236
+ try {
237
+ parsed = JSON.parse(rawStdout.trim());
238
+ }
239
+ catch {
240
+ // stdout not parseable — fall through to exit-code handling
241
+ }
210
242
  }
243
+ if (parsed !== null) {
244
+ if (parsed.ok)
245
+ return { ok: true };
246
+ const first = Array.isArray(parsed.errors) ? parsed.errors[0] : undefined;
247
+ const msg = (first && typeof first.message === 'string' && first.message) ? first.message : 'invalid markdown';
248
+ return { ok: false, error: `termrender: ${msg}` };
249
+ }
250
+ // exit code 2 = invalid per contract; any non-zero is an error
211
251
  if (result.status !== 0) {
212
- return { ok: false, error: `termrender --check exited ${result.status}: ${(result.stderr || '').toString().trim()}` };
252
+ return { ok: false, error: `termrender: doc check exited ${result.status}` };
213
253
  }
214
254
  return { ok: true };
215
255
  }
@@ -222,15 +262,31 @@ export function displayInPane(path, opts = {}) {
222
262
  ensureRenderer();
223
263
  if (rendererState !== 'ready')
224
264
  return {};
225
- const args = ['--tmux'];
226
- if (opts.watch !== false)
227
- args.push('--watch');
228
- if (opts.newWindow)
229
- args.push('--tmux-new-window');
230
- args.push(path);
231
- const result = spawnSync(VENV_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'] });
265
+ const input = JSON.stringify({
266
+ path,
267
+ watch: opts.watch !== false,
268
+ window: opts.newWindow ? 'new' : 'split',
269
+ });
270
+ const result = spawnSync(VENV_BIN, ['pane', 'open'], {
271
+ input,
272
+ encoding: 'utf-8',
273
+ stdio: ['pipe', 'pipe', 'pipe'],
274
+ });
232
275
  if (result.error || result.status !== 0)
233
276
  return {};
234
- const paneId = (result.stdout?.toString() || '').trim();
235
- return paneId ? { paneId } : {};
277
+ // `encoding: 'utf-8'` makes spawnSync return stdout as a string.
278
+ const rawStdout = result.stdout;
279
+ if (!rawStdout)
280
+ return {};
281
+ let parsed = null;
282
+ try {
283
+ parsed = JSON.parse(rawStdout.trim());
284
+ }
285
+ catch {
286
+ return {};
287
+ }
288
+ if (parsed && typeof parsed.pane_id === 'string' && parsed.pane_id) {
289
+ return { paneId: parsed.pane_id };
290
+ }
291
+ return {};
236
292
  }
@@ -1 +1 @@
1
- export declare const TERMRENDER_VERSION = "1.0.0";
1
+ export declare const TERMRENDER_VERSION = "2.1.0";
@@ -1 +1 @@
1
- export const TERMRENDER_VERSION = '1.0.0';
1
+ export const TERMRENDER_VERSION = '2.1.0';
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
+ }
@@ -276,9 +276,10 @@ export function renderItemReview(state, cols, rows) {
276
276
  const label = interaction.freetextLabel !== undefined
277
277
  ? interaction.freetextLabel
278
278
  : state.inputMode.kind === 'comment' ? 'Comment' : 'Response';
279
- // 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.
280
281
  let attachedLine;
281
- if (state.inputMode.kind === 'comment') {
282
+ if (state.inputMode.kind === 'comment' && !interaction.multiSelect) {
282
283
  const attachedId = state.inputMode.selectedOptionId;
283
284
  const opts = interaction.options;
284
285
  if (opts.length > 0) {
@@ -330,11 +331,18 @@ export function renderItemReview(state, cols, rows) {
330
331
  visibleBody = bodyLines;
331
332
  }
332
333
  // Footer hint — mention scroll keys when body overflows
333
- const footerParts = [
334
- `${DIM}n/p${RESET} prev/next`,
335
- `${DIM}space${RESET} expand`,
336
- `${DIM}q${RESET} overview`,
337
- ];
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
+ ];
338
346
  if (overflows)
339
347
  footerParts.unshift(`${DIM}u/d${RESET} scroll`);
340
348
  const footer = ` ${footerParts.join(' ')}`;
@@ -356,7 +364,9 @@ function renderActions(interaction, selectedAction, maxW, existing) {
356
364
  const opts = interaction.options;
357
365
  // Prefix on first row: " X [s] " — 2 + 1 (cursor) + 1 + 3 ([s]) + 1 = 8 visible cols.
358
366
  // Continuation rows align under the label so each option reads as a block.
359
- const prefixWidth = 8;
367
+ const multi = interaction.multiSelect === true;
368
+ const checked = new Set(existing?.selectedOptionIds ?? []);
369
+ const prefixWidth = multi ? 12 : 8;
360
370
  const indent = ' '.repeat(prefixWidth);
361
371
  const contentMax = Math.max(20, maxW - prefixWidth);
362
372
  for (let i = 0; i < opts.length; i++) {
@@ -364,9 +374,12 @@ function renderActions(interaction, selectedAction, maxW, existing) {
364
374
  const cursor = i === selectedAction ? `${CYAN}▸${RESET}` : ' ';
365
375
  const sc = o.shortcut ?? ' ';
366
376
  const keyBadge = `${DIM}[${sc}]${RESET}`;
377
+ const box = multi
378
+ ? (checked.has(o.id) ? `${GREEN}[x]${RESET}` : `${DIM}[ ]${RESET}`) + ' '
379
+ : '';
367
380
  const labelLines = wrap(sanitize(o.label), contentMax);
368
381
  for (let j = 0; j < labelLines.length; j++) {
369
- const prefix = j === 0 ? ` ${cursor} ${keyBadge} ` : indent;
382
+ const prefix = j === 0 ? ` ${cursor} ${box}${keyBadge} ` : indent;
370
383
  lines.push(`${prefix}${labelLines[j]}`);
371
384
  }
372
385
  if (o.description) {
@@ -433,6 +446,16 @@ export function renderFinal(state, cols, rows) {
433
446
  return centerHorizontal(lines.slice(0, rows), cols, maxW + 2);
434
447
  }
435
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
+ }
436
459
  const opt = r.selectedOptionId
437
460
  ? interaction.options.find((o) => o.id === r.selectedOptionId)
438
461
  : undefined;
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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/humanloop",
3
- "version": "0.2.1",
3
+ "version": "0.3.2",
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",