@crouton-kit/humanloop 0.2.1 → 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/api.js +15 -7
- package/dist/cli.js +809 -385
- package/dist/inbox/deck-schema.d.ts +1 -0
- package/dist/inbox/deck-schema.js +1 -0
- package/dist/render/termrender.d.ts +2 -2
- package/dist/render/termrender.js +56 -21
- package/dist/render/version.d.ts +1 -1
- package/dist/render/version.js +1 -1
- package/dist/tui/input.js +67 -8
- package/dist/tui/render.js +32 -9
- package/dist/types.d.ts +6 -0
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
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,13 +32,13 @@ function binaryOk() {
|
|
|
32
32
|
if (!existsSync(VENV_BIN))
|
|
33
33
|
return false;
|
|
34
34
|
try {
|
|
35
|
-
|
|
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
|
-
|
|
41
|
-
return m?.[0] === TERMRENDER_VERSION;
|
|
41
|
+
return true;
|
|
42
42
|
}
|
|
43
43
|
catch {
|
|
44
44
|
return false;
|
|
@@ -91,7 +91,7 @@ export function ensureRenderer() {
|
|
|
91
91
|
}
|
|
92
92
|
rendererState = binaryOk() ? 'ready' : 'unavailable';
|
|
93
93
|
if (rendererState === 'unavailable') {
|
|
94
|
-
process.stderr.write('[hl] termrender install completed but
|
|
94
|
+
process.stderr.write('[hl] termrender install completed but health check failed; using plaintext fallback\n');
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
/** Cheap predicate — true when the pinned managed binary is present and correct. Does not install. */
|
|
@@ -172,12 +172,12 @@ export function renderMarkdown(md, width) {
|
|
|
172
172
|
ensureRenderer();
|
|
173
173
|
if (rendererState === 'ready') {
|
|
174
174
|
try {
|
|
175
|
-
const
|
|
176
|
-
|
|
175
|
+
const input = JSON.stringify({ source: md, width, color: true });
|
|
176
|
+
const out = execFileSync(VENV_BIN, ['doc', 'render'], {
|
|
177
|
+
input,
|
|
177
178
|
encoding: 'utf-8',
|
|
178
179
|
timeout: 5000,
|
|
179
180
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
180
|
-
env: { ...process.env, TERMRENDER_COLOR: '1' },
|
|
181
181
|
});
|
|
182
182
|
const lines = out.split('\n');
|
|
183
183
|
if (lines.length > 0 && lines[lines.length - 1] === '')
|
|
@@ -193,23 +193,42 @@ export function renderMarkdown(md, width) {
|
|
|
193
193
|
_bodyCache.set(key, fallback);
|
|
194
194
|
return fallback;
|
|
195
195
|
}
|
|
196
|
-
/** Validate markdown via `termrender
|
|
196
|
+
/** Validate markdown via `termrender doc check`. */
|
|
197
197
|
export function checkMarkdown(md) {
|
|
198
198
|
ensureRenderer();
|
|
199
199
|
// Renderer unavailable → don't block validation; the body just renders as
|
|
200
200
|
// plaintext later. Bricking deck validation here would be the wrong default.
|
|
201
201
|
if (rendererState !== 'ready')
|
|
202
202
|
return { ok: true };
|
|
203
|
-
const
|
|
204
|
-
|
|
203
|
+
const input = JSON.stringify({ source: md });
|
|
204
|
+
const result = spawnSync(VENV_BIN, ['doc', 'check'], {
|
|
205
|
+
input,
|
|
205
206
|
encoding: 'utf-8',
|
|
206
207
|
timeout: 5000,
|
|
207
208
|
});
|
|
208
209
|
if (result.error) {
|
|
209
|
-
return { ok: false, error: `termrender invocation failed: ${result.error.message}` };
|
|
210
|
+
return { ok: false, error: `termrender: invocation failed: ${result.error.message}` };
|
|
210
211
|
}
|
|
212
|
+
let parsed = null;
|
|
213
|
+
const rawStdout = typeof result.stdout === 'string' ? result.stdout : '';
|
|
214
|
+
if (rawStdout) {
|
|
215
|
+
try {
|
|
216
|
+
parsed = JSON.parse(rawStdout.trim());
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
// stdout not parseable — fall through to exit-code handling
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (parsed !== null) {
|
|
223
|
+
if (parsed.ok)
|
|
224
|
+
return { ok: true };
|
|
225
|
+
const first = Array.isArray(parsed.errors) ? parsed.errors[0] : undefined;
|
|
226
|
+
const msg = (first && typeof first.message === 'string' && first.message) ? first.message : 'invalid markdown';
|
|
227
|
+
return { ok: false, error: `termrender: ${msg}` };
|
|
228
|
+
}
|
|
229
|
+
// exit code 2 = invalid per contract; any non-zero is an error
|
|
211
230
|
if (result.status !== 0) {
|
|
212
|
-
return { ok: false, error: `termrender
|
|
231
|
+
return { ok: false, error: `termrender: doc check exited ${result.status}` };
|
|
213
232
|
}
|
|
214
233
|
return { ok: true };
|
|
215
234
|
}
|
|
@@ -222,15 +241,31 @@ export function displayInPane(path, opts = {}) {
|
|
|
222
241
|
ensureRenderer();
|
|
223
242
|
if (rendererState !== 'ready')
|
|
224
243
|
return {};
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
244
|
+
const input = JSON.stringify({
|
|
245
|
+
path,
|
|
246
|
+
watch: opts.watch !== false,
|
|
247
|
+
window: opts.newWindow ? 'new' : 'split',
|
|
248
|
+
});
|
|
249
|
+
const result = spawnSync(VENV_BIN, ['pane', 'open'], {
|
|
250
|
+
input,
|
|
251
|
+
encoding: 'utf-8',
|
|
252
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
253
|
+
});
|
|
232
254
|
if (result.error || result.status !== 0)
|
|
233
255
|
return {};
|
|
234
|
-
|
|
235
|
-
|
|
256
|
+
// `encoding: 'utf-8'` makes spawnSync return stdout as a string.
|
|
257
|
+
const rawStdout = result.stdout;
|
|
258
|
+
if (!rawStdout)
|
|
259
|
+
return {};
|
|
260
|
+
let parsed = null;
|
|
261
|
+
try {
|
|
262
|
+
parsed = JSON.parse(rawStdout.trim());
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
return {};
|
|
266
|
+
}
|
|
267
|
+
if (parsed && typeof parsed.pane_id === 'string' && parsed.pane_id) {
|
|
268
|
+
return { paneId: parsed.pane_id };
|
|
269
|
+
}
|
|
270
|
+
return {};
|
|
236
271
|
}
|
package/dist/render/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const TERMRENDER_VERSION = "1.0
|
|
1
|
+
export declare const TERMRENDER_VERSION = "2.1.0";
|
package/dist/render/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const TERMRENDER_VERSION = '1.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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
229
|
-
|
|
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
|
+
}
|
package/dist/tui/render.js
CHANGED
|
@@ -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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
|
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 {
|