@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 +9 -3
- package/dist/cli.js +5 -0
- package/dist/editor/review.d.ts +17 -0
- package/dist/editor/review.js +45 -8
- package/dist/tui/input.js +89 -12
- package/dist/tui/render.js +32 -9
- package/dist/types.d.ts +5 -1
- package/package.json +1 -1
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
|
|
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) =>
|
|
32
|
-
|
|
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
|
},
|
package/dist/editor/review.d.ts
CHANGED
|
@@ -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
|
package/dist/editor/review.js
CHANGED
|
@@ -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 (
|
|
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 = {
|
|
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
|
-
|
|
389
|
-
`HL_SOURCE=${shellQuote(absFile)}`,
|
|
418
|
+
...envPairs,
|
|
390
419
|
shellQuote(bin),
|
|
391
420
|
...editorArgs.map(shellQuote),
|
|
392
421
|
].join(' ');
|
|
393
422
|
try {
|
|
394
|
-
|
|
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:
|
|
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.
|
|
181
|
-
//
|
|
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
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
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
|
|
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: [...
|
|
426
|
+
selectedOptionIds: [...priorIds],
|
|
384
427
|
};
|
|
385
|
-
|
|
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
|
+
}
|
package/dist/tui/render.js
CHANGED
|
@@ -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
|
|
293
|
-
//
|
|
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'
|
|
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
|
-
|
|
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
|
|
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) =>
|
|
470
|
-
|
|
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;
|