@crouton-kit/humanloop 0.3.4 → 0.3.6
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/cli.js +76 -0
- package/dist/inbox/deck-schema.d.ts +12 -0
- package/dist/inbox/deck-schema.js +7 -0
- package/dist/render/termrender.js +16 -17
- package/dist/render/version.d.ts +1 -1
- package/dist/render/version.js +1 -1
- package/dist/tui/app.js +26 -2
- package/dist/tui/input.js +32 -4
- package/dist/tui/render.js +18 -2
- package/dist/types.d.ts +22 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -9,6 +9,7 @@ import { launchReview } from './editor/review.js';
|
|
|
9
9
|
import { validateDeck } from './inbox/deck-schema.js';
|
|
10
10
|
import { ask, inbox } from './api.js';
|
|
11
11
|
import { display } from './surfaces/display.js';
|
|
12
|
+
import { renderMarkdown, checkMarkdown } from './render/termrender.js';
|
|
12
13
|
import { scanInbox } from './inbox/scan.js';
|
|
13
14
|
import { deckPath, atomicWriteJson, readJson, responsePath, } from './inbox/convention.js';
|
|
14
15
|
// ── Version ───────────────────────────────────────────────────────────────────
|
|
@@ -297,6 +298,7 @@ program
|
|
|
297
298
|
' deck — structured set of interactions (questions) for the human\n' +
|
|
298
299
|
' review — freeform markdown document review with anchored comments\n' +
|
|
299
300
|
' view — passive live render of a file in a tmux pane\n' +
|
|
301
|
+
' doc — render or validate directive-flavored markdown to stdout\n' +
|
|
300
302
|
' inbox — list/resolve all pending interactions across root dirs\n' +
|
|
301
303
|
' job — a running or completed kickoff (deck ask / review / inbox)\n' +
|
|
302
304
|
' schema — JSON Schema for deck, resolution, or feedback payloads\n' +
|
|
@@ -305,6 +307,7 @@ program
|
|
|
305
307
|
' hl deck — write questions, get answers | use when: material decisions\n' +
|
|
306
308
|
' hl review — markdown doc review | use when: doc feedback needed\n' +
|
|
307
309
|
' hl view — live render in pane | use when: displaying a file\n' +
|
|
310
|
+
' hl doc — render/validate to stdout | use when: piping rendered markdown\n' +
|
|
308
311
|
' hl inbox — browse pending interactions | use when: clearing a backlog\n' +
|
|
309
312
|
' hl job — inspect/wait/cancel running jobs | use when: polling job output\n' +
|
|
310
313
|
' hl schema — print JSON Schemas | use when: validating inputs\n' +
|
|
@@ -626,6 +629,79 @@ viewCmd
|
|
|
626
629
|
}
|
|
627
630
|
process.exit(0);
|
|
628
631
|
});
|
|
632
|
+
// ── doc ───────────────────────────────────────────────────────────────────────
|
|
633
|
+
const docCmd = program.command('doc').description('Render or validate directive-flavored markdown to stdout.\n' +
|
|
634
|
+
'\n' +
|
|
635
|
+
'Children:\n' +
|
|
636
|
+
' hl doc check — validate directive syntax, no output | use when: preflighting before write\n' +
|
|
637
|
+
' hl doc render — render markdown to ANSI/plain stdout | use when: piping rendered text to a file or consumer\n' +
|
|
638
|
+
'\n' +
|
|
639
|
+
'These wrap the pinned termrender binary that humanloop manages. Consumers\n' +
|
|
640
|
+
'should never call `termrender` directly — go through hl/SDK so there is one\n' +
|
|
641
|
+
'org-wide caller.\n');
|
|
642
|
+
docCmd
|
|
643
|
+
.command('check')
|
|
644
|
+
.description('Validate directive-flavored markdown without rendering.\n' +
|
|
645
|
+
'\n' +
|
|
646
|
+
'stdin { source?: string, path?: string } exactly one required\n' +
|
|
647
|
+
'stdout { ok: bool, error?: string }\n' +
|
|
648
|
+
'exit 0 always (validation failures are not process errors)\n')
|
|
649
|
+
.helpOption('-h, --help', 'Show help')
|
|
650
|
+
.action(() => {
|
|
651
|
+
const input = parseStdinJson();
|
|
652
|
+
const src = resolveDocSource(input);
|
|
653
|
+
const res = checkMarkdown(src);
|
|
654
|
+
process.stdout.write(JSON.stringify(res) + '\n');
|
|
655
|
+
process.exit(0);
|
|
656
|
+
});
|
|
657
|
+
docCmd
|
|
658
|
+
.command('render')
|
|
659
|
+
.description('Render directive-flavored markdown to ANSI or plain text on stdout.\n' +
|
|
660
|
+
'\n' +
|
|
661
|
+
'stdin { source?: string, path?: string, width?: int=process.stdout.columns||100, color?: bool=true }\n' +
|
|
662
|
+
'stdout the rendered text (raw bytes; not JSON)\n' +
|
|
663
|
+
'exit 0 on success, non-zero on bad input\n' +
|
|
664
|
+
'\n' +
|
|
665
|
+
'When color=false, ANSI escape sequences are stripped from the output.\n' +
|
|
666
|
+
'Use this for feeding rendered content to other agents that need plain\n' +
|
|
667
|
+
'text without color codes.\n')
|
|
668
|
+
.helpOption('-h, --help', 'Show help')
|
|
669
|
+
.action(() => {
|
|
670
|
+
const input = parseStdinJson();
|
|
671
|
+
const src = resolveDocSource(input);
|
|
672
|
+
const width = typeof input.width === 'number' && input.width > 0
|
|
673
|
+
? input.width
|
|
674
|
+
: (process.stdout.columns || 100);
|
|
675
|
+
const lines = renderMarkdown(src, width);
|
|
676
|
+
let out = lines.join('\n');
|
|
677
|
+
if (input.color === false) {
|
|
678
|
+
// Strip ANSI escape sequences for plain-text consumers.
|
|
679
|
+
// eslint-disable-next-line no-control-regex
|
|
680
|
+
out = out.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
|
|
681
|
+
}
|
|
682
|
+
process.stdout.write(out);
|
|
683
|
+
if (!out.endsWith('\n'))
|
|
684
|
+
process.stdout.write('\n');
|
|
685
|
+
process.exit(0);
|
|
686
|
+
});
|
|
687
|
+
function resolveDocSource(input) {
|
|
688
|
+
const hasSource = typeof input.source === 'string' && input.source.length > 0;
|
|
689
|
+
const hasPath = typeof input.path === 'string' && input.path.length > 0;
|
|
690
|
+
if (hasSource === hasPath) {
|
|
691
|
+
emitError({
|
|
692
|
+
error: 'bad_input',
|
|
693
|
+
message: 'provide exactly one of {source, path}',
|
|
694
|
+
next: 'stdin like {"source": "..."} or {"path": "/abs/file.md"}',
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
if (hasSource)
|
|
698
|
+
return input.source;
|
|
699
|
+
const abs = resolve(input.path);
|
|
700
|
+
if (!existsSync(abs)) {
|
|
701
|
+
emitError({ error: 'file_not_found', message: `path not found: ${abs}`, next: 'check the path' });
|
|
702
|
+
}
|
|
703
|
+
return readFileSync(abs, 'utf-8');
|
|
704
|
+
}
|
|
629
705
|
// ── inbox ─────────────────────────────────────────────────────────────────────
|
|
630
706
|
const inboxCmd = program.command('inbox').description('Browse and resolve pending interactions across root dirs.');
|
|
631
707
|
inboxCmd
|
|
@@ -6,6 +6,12 @@ export declare const interactionOptionSchema: z.ZodObject<{
|
|
|
6
6
|
description: z.ZodOptional<z.ZodString>;
|
|
7
7
|
shortcut: z.ZodOptional<z.ZodString>;
|
|
8
8
|
}, z.core.$strip>;
|
|
9
|
+
export declare const preAnswerSchema: z.ZodObject<{
|
|
10
|
+
selectedOptionId: z.ZodOptional<z.ZodString>;
|
|
11
|
+
selectedOptionIds: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
12
|
+
freetext: z.ZodOptional<z.ZodString>;
|
|
13
|
+
label: z.ZodOptional<z.ZodString>;
|
|
14
|
+
}, z.core.$strip>;
|
|
9
15
|
export declare const deckSchema: z.ZodObject<{
|
|
10
16
|
title: z.ZodOptional<z.ZodString>;
|
|
11
17
|
source: z.ZodOptional<z.ZodObject<{
|
|
@@ -35,6 +41,12 @@ export declare const deckSchema: z.ZodObject<{
|
|
|
35
41
|
context: "context";
|
|
36
42
|
error: "error";
|
|
37
43
|
}>>;
|
|
44
|
+
preAnswered: z.ZodOptional<z.ZodObject<{
|
|
45
|
+
selectedOptionId: z.ZodOptional<z.ZodString>;
|
|
46
|
+
selectedOptionIds: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
47
|
+
freetext: z.ZodOptional<z.ZodString>;
|
|
48
|
+
label: z.ZodOptional<z.ZodString>;
|
|
49
|
+
}, z.core.$strip>>;
|
|
38
50
|
}, z.core.$strip>>;
|
|
39
51
|
}, z.core.$strip>;
|
|
40
52
|
export declare function inlineBodyPath(deckPath: string, bodyPath: string): string;
|
|
@@ -10,6 +10,12 @@ export const interactionOptionSchema = z.object({
|
|
|
10
10
|
description: z.string().optional(),
|
|
11
11
|
shortcut: z.string().optional(),
|
|
12
12
|
});
|
|
13
|
+
export const preAnswerSchema = z.object({
|
|
14
|
+
selectedOptionId: z.string().optional(),
|
|
15
|
+
selectedOptionIds: z.array(z.string()).optional(),
|
|
16
|
+
freetext: z.string().optional(),
|
|
17
|
+
label: z.string().optional(),
|
|
18
|
+
});
|
|
13
19
|
const interactionSchema = z.object({
|
|
14
20
|
id: z.string().regex(/^[A-Za-z0-9_-]+$/, { error: 'interaction id must match /^[A-Za-z0-9_-]+$/' }).min(1).max(64),
|
|
15
21
|
title: z.string().min(1, { error: 'title must be non-empty' }),
|
|
@@ -21,6 +27,7 @@ const interactionSchema = z.object({
|
|
|
21
27
|
allowFreetext: z.boolean().optional(),
|
|
22
28
|
freetextLabel: z.string().optional(),
|
|
23
29
|
kind: z.enum(['notify', 'validation', 'decision', 'context', 'error']).optional(),
|
|
30
|
+
preAnswered: preAnswerSchema.optional(),
|
|
24
31
|
});
|
|
25
32
|
const deckSourceSchema = z.object({
|
|
26
33
|
sessionName: z.string().optional(),
|
|
@@ -97,11 +97,14 @@ export function ensureRenderer() {
|
|
|
97
97
|
return;
|
|
98
98
|
}
|
|
99
99
|
try {
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
|
|
100
|
+
// (Re)create the venv whenever the interpreter is missing — covers both
|
|
101
|
+
// "directory absent" and "directory present but bin/python stripped"
|
|
102
|
+
// (seen in the wild when pnpm rebuilds/dedupes node_modules or uv rotates
|
|
103
|
+
// its managed Python store). `--clear` makes uv wipe any partial state
|
|
104
|
+
// rather than refusing on the existing dir. If the interpreter is intact,
|
|
105
|
+
// skip straight to `uv pip install` so version drift reuses the venv.
|
|
106
|
+
if (!existsSync(VENV_PYTHON)) {
|
|
107
|
+
execFileSync('uv', ['venv', '--clear', VENV_DIR], { stdio: 'pipe', timeout: 60000 });
|
|
105
108
|
}
|
|
106
109
|
execFileSync('uv', ['pip', 'install', '--python', VENV_PYTHON, `termrender==${TERMRENDER_VERSION}`], { stdio: 'pipe', timeout: 120000 });
|
|
107
110
|
}
|
|
@@ -193,9 +196,8 @@ export function renderMarkdown(md, width) {
|
|
|
193
196
|
ensureRenderer();
|
|
194
197
|
if (rendererState === 'ready') {
|
|
195
198
|
try {
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
input,
|
|
199
|
+
const out = execFileSync(VENV_BIN, ['doc', 'render', '--width', String(width), '--color', 'on'], {
|
|
200
|
+
input: md,
|
|
199
201
|
encoding: 'utf-8',
|
|
200
202
|
timeout: 5000,
|
|
201
203
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
@@ -221,9 +223,8 @@ export function checkMarkdown(md) {
|
|
|
221
223
|
// plaintext later. Bricking deck validation here would be the wrong default.
|
|
222
224
|
if (rendererState !== 'ready')
|
|
223
225
|
return { ok: true };
|
|
224
|
-
const input = JSON.stringify({ source: md });
|
|
225
226
|
const result = spawnSync(VENV_BIN, ['doc', 'check'], {
|
|
226
|
-
input,
|
|
227
|
+
input: md,
|
|
227
228
|
encoding: 'utf-8',
|
|
228
229
|
timeout: 5000,
|
|
229
230
|
});
|
|
@@ -262,13 +263,11 @@ export function displayInPane(path, opts = {}) {
|
|
|
262
263
|
ensureRenderer();
|
|
263
264
|
if (rendererState !== 'ready')
|
|
264
265
|
return {};
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
const result = spawnSync(VENV_BIN, ['pane', 'open'], {
|
|
271
|
-
input,
|
|
266
|
+
const args = ['pane', 'open', path];
|
|
267
|
+
if (opts.watch !== false)
|
|
268
|
+
args.push('--watch');
|
|
269
|
+
args.push('--window', opts.newWindow ? 'new' : 'split');
|
|
270
|
+
const result = spawnSync(VENV_BIN, args, {
|
|
272
271
|
encoding: 'utf-8',
|
|
273
272
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
274
273
|
});
|
package/dist/render/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const TERMRENDER_VERSION = "
|
|
1
|
+
export declare const TERMRENDER_VERSION = "3.0.0";
|
package/dist/render/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const TERMRENDER_VERSION = '
|
|
1
|
+
export const TERMRENDER_VERSION = '3.0.0';
|
package/dist/tui/app.js
CHANGED
|
@@ -18,12 +18,36 @@ function buildInitialState(deck) {
|
|
|
18
18
|
// Single-question decks skip the overview list — there's nothing to overview,
|
|
19
19
|
// and overview hides the option hotkeys so users press 'y' and nothing happens.
|
|
20
20
|
const initialPhase = deck.interactions.length === 1 ? 'item-review' : 'overview';
|
|
21
|
+
const responses = new Map();
|
|
22
|
+
const preAnsweredIds = new Set();
|
|
23
|
+
// Seed responses + preAnsweredIds from any `preAnswered` field. The seeded
|
|
24
|
+
// response counts as answered for navigation/auto-advance, but is rendered
|
|
25
|
+
// distinctly so the human knows it carried over. `tryResume` runs after and
|
|
26
|
+
// takes priority — mid-deck progress should not be overwritten by defaults.
|
|
27
|
+
for (const interaction of deck.interactions) {
|
|
28
|
+
const pa = interaction.preAnswered;
|
|
29
|
+
if (pa === undefined)
|
|
30
|
+
continue;
|
|
31
|
+
const response = { id: interaction.id };
|
|
32
|
+
if (pa.selectedOptionId !== undefined)
|
|
33
|
+
response.selectedOptionId = pa.selectedOptionId;
|
|
34
|
+
if (pa.selectedOptionIds !== undefined)
|
|
35
|
+
response.selectedOptionIds = [...pa.selectedOptionIds];
|
|
36
|
+
if (pa.freetext !== undefined)
|
|
37
|
+
response.freetext = pa.freetext;
|
|
38
|
+
responses.set(interaction.id, response);
|
|
39
|
+
preAnsweredIds.add(interaction.id);
|
|
40
|
+
}
|
|
41
|
+
// Start cursor on the first unanswered interaction — humans land where they
|
|
42
|
+
// need to act. If every interaction is pre-answered, fall back to index 0.
|
|
43
|
+
const firstUnanswered = deck.interactions.findIndex((i) => !responses.has(i.id));
|
|
21
44
|
return {
|
|
22
45
|
phase: initialPhase,
|
|
23
|
-
currentIndex: 0,
|
|
46
|
+
currentIndex: firstUnanswered >= 0 ? firstUnanswered : 0,
|
|
24
47
|
interactions: deck.interactions,
|
|
25
|
-
responses
|
|
48
|
+
responses,
|
|
26
49
|
visuals: new Map(),
|
|
50
|
+
preAnsweredIds,
|
|
27
51
|
inputMode: null,
|
|
28
52
|
selectedAction: 0,
|
|
29
53
|
detailExpanded: false,
|
package/dist/tui/input.js
CHANGED
|
@@ -173,7 +173,7 @@ function handleInteractionAction(input, key, state, interaction, render) {
|
|
|
173
173
|
return;
|
|
174
174
|
}
|
|
175
175
|
submitOption(state, interaction, matched.id, undefined);
|
|
176
|
-
|
|
176
|
+
advanceToNextUnanswered(state);
|
|
177
177
|
render();
|
|
178
178
|
return;
|
|
179
179
|
}
|
|
@@ -204,13 +204,13 @@ function handleInteractionAction(input, key, state, interaction, render) {
|
|
|
204
204
|
if (key.return && state.selectedAction < opts.length) {
|
|
205
205
|
if (interaction.multiSelect) {
|
|
206
206
|
commitMulti(state, interaction);
|
|
207
|
-
|
|
207
|
+
advanceToNextUnanswered(state);
|
|
208
208
|
render();
|
|
209
209
|
return;
|
|
210
210
|
}
|
|
211
211
|
const o = opts[state.selectedAction];
|
|
212
212
|
submitOption(state, interaction, o.id, undefined);
|
|
213
|
-
|
|
213
|
+
advanceToNextUnanswered(state);
|
|
214
214
|
render();
|
|
215
215
|
return;
|
|
216
216
|
}
|
|
@@ -258,7 +258,7 @@ function handleInputMode(input, key, state, render) {
|
|
|
258
258
|
submitOption(state, interaction, attached, mode.buffer);
|
|
259
259
|
}
|
|
260
260
|
state.inputMode = null;
|
|
261
|
-
|
|
261
|
+
advanceToNextUnanswered(state);
|
|
262
262
|
render();
|
|
263
263
|
return;
|
|
264
264
|
}
|
|
@@ -320,6 +320,27 @@ function advanceItem(state, direction) {
|
|
|
320
320
|
state.detailExpanded = false;
|
|
321
321
|
state.scrollOffset = 0;
|
|
322
322
|
}
|
|
323
|
+
/**
|
|
324
|
+
* Move to the next interaction WITHOUT a response, falling through to the
|
|
325
|
+
* final phase if every following interaction is already answered (whether
|
|
326
|
+
* user-answered or `preAnswered`-seeded). Used by all post-submit advance
|
|
327
|
+
* sites so the human flies through pre-approved items by hitting Enter; raw
|
|
328
|
+
* `n`/`p` still step one at a time via `advanceItem`.
|
|
329
|
+
*/
|
|
330
|
+
function advanceToNextUnanswered(state) {
|
|
331
|
+
let next = state.currentIndex + 1;
|
|
332
|
+
while (next < state.interactions.length && state.responses.has(state.interactions[next].id)) {
|
|
333
|
+
next++;
|
|
334
|
+
}
|
|
335
|
+
if (next >= state.interactions.length) {
|
|
336
|
+
state.phase = 'final';
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
state.currentIndex = next;
|
|
340
|
+
state.selectedAction = 0;
|
|
341
|
+
state.detailExpanded = false;
|
|
342
|
+
state.scrollOffset = 0;
|
|
343
|
+
}
|
|
323
344
|
function actionCount(interaction) {
|
|
324
345
|
return interaction.options.length + (interaction.allowFreetext && interaction.options.length > 0 ? 1 : 0);
|
|
325
346
|
}
|
|
@@ -330,6 +351,9 @@ function submitOption(state, interaction, selectedOptionId, freetext) {
|
|
|
330
351
|
if (freetext !== undefined)
|
|
331
352
|
response.freetext = freetext;
|
|
332
353
|
state.responses.set(interaction.id, response);
|
|
354
|
+
// Explicit user submission overrides any preAnswered seed — flip the icon
|
|
355
|
+
// from "previously answered" to user-answered.
|
|
356
|
+
state.preAnsweredIds.delete(interaction.id);
|
|
333
357
|
state.persist?.();
|
|
334
358
|
}
|
|
335
359
|
// ── Multi-select ─────────────────────────────────────────────────────────────
|
|
@@ -346,6 +370,8 @@ function toggleMulti(state, interaction, optionId) {
|
|
|
346
370
|
if (existing?.freetext !== undefined)
|
|
347
371
|
response.freetext = existing.freetext;
|
|
348
372
|
state.responses.set(interaction.id, response);
|
|
373
|
+
// User edited the checked set — no longer a passive carry-over.
|
|
374
|
+
state.preAnsweredIds.delete(interaction.id);
|
|
349
375
|
state.persist?.();
|
|
350
376
|
}
|
|
351
377
|
/** Ensure a (possibly empty) response exists so the interaction counts as
|
|
@@ -360,5 +386,7 @@ function commitMulti(state, interaction, freetext) {
|
|
|
360
386
|
if (ft !== undefined)
|
|
361
387
|
response.freetext = ft;
|
|
362
388
|
state.responses.set(interaction.id, response);
|
|
389
|
+
// Explicit confirm overrides any preAnswered seed.
|
|
390
|
+
state.preAnsweredIds.delete(interaction.id);
|
|
363
391
|
state.persist?.();
|
|
364
392
|
}
|
package/dist/tui/render.js
CHANGED
|
@@ -166,7 +166,10 @@ export function renderOverview(state, cols, rows) {
|
|
|
166
166
|
for (let i = 0; i < state.interactions.length; i++) {
|
|
167
167
|
const interaction = state.interactions[i];
|
|
168
168
|
const response = state.responses.get(interaction.id);
|
|
169
|
-
const
|
|
169
|
+
const preAnswered = state.preAnsweredIds.has(interaction.id);
|
|
170
|
+
const icon = response
|
|
171
|
+
? (preAnswered ? `${DIM}◆${RESET}` : `${GREEN}✓${RESET}`)
|
|
172
|
+
: `${DIM}○${RESET}`;
|
|
170
173
|
const label = singleLine(interaction.title);
|
|
171
174
|
const cursor = i === state.currentIndex ? `${CYAN}▸${RESET} ` : ' ';
|
|
172
175
|
const labelMax = Math.max(10, cols - 16);
|
|
@@ -238,6 +241,16 @@ export function renderItemReview(state, cols, rows) {
|
|
|
238
241
|
preLines.push(` ${DIM}${line}${RESET}`);
|
|
239
242
|
}
|
|
240
243
|
}
|
|
244
|
+
// "Previously answered" marker — shown only while the seed is intact (no user
|
|
245
|
+
// override yet). The label comes from preAnswered.label so callers can be
|
|
246
|
+
// domain-specific ("Previously approved", "Carried over from prior pass").
|
|
247
|
+
if (state.preAnsweredIds.has(interaction.id)) {
|
|
248
|
+
const customLabel = interaction.preAnswered !== undefined ? interaction.preAnswered.label : undefined;
|
|
249
|
+
const label = typeof customLabel === 'string' && customLabel.length > 0
|
|
250
|
+
? customLabel
|
|
251
|
+
: 'Previously answered';
|
|
252
|
+
preLines.push(` ${DIM}${ITALIC}◆ ${sanitize(label)} — press n/p to review, or any option to override${RESET}`);
|
|
253
|
+
}
|
|
241
254
|
// Body: rendered question body + expanded visual block (scrollable)
|
|
242
255
|
const bodyLines = [];
|
|
243
256
|
if (interaction.body) {
|
|
@@ -425,7 +438,10 @@ export function renderFinal(state, cols, rows) {
|
|
|
425
438
|
const questionRows = [];
|
|
426
439
|
for (const interaction of state.interactions) {
|
|
427
440
|
const response = state.responses.get(interaction.id);
|
|
428
|
-
const
|
|
441
|
+
const preAnswered = state.preAnsweredIds.has(interaction.id);
|
|
442
|
+
const icon = response
|
|
443
|
+
? (preAnswered ? `${DIM}◆${RESET}` : `${GREEN}✓${RESET}`)
|
|
444
|
+
: `${YELLOW}○${RESET}`;
|
|
429
445
|
const label = singleLine(interaction.title);
|
|
430
446
|
questionRows.push(` ${icon} ${truncate(label, Math.max(10, maxW - 4))}`);
|
|
431
447
|
if (response) {
|
package/dist/types.d.ts
CHANGED
|
@@ -6,6 +6,23 @@ export interface InteractionOption {
|
|
|
6
6
|
description?: string;
|
|
7
7
|
shortcut?: string;
|
|
8
8
|
}
|
|
9
|
+
/**
|
|
10
|
+
* Seed an interaction with an answer the caller already has on hand — e.g. a
|
|
11
|
+
* prior approval the human shouldn't have to re-confirm. When present, the
|
|
12
|
+
* panel: (a) populates `responses[id]` from these fields at mount, (b) renders
|
|
13
|
+
* a distinct "previously answered" marker in overview/final and labels it in
|
|
14
|
+
* item-review, and (c) skips past the interaction on post-submit auto-advance.
|
|
15
|
+
* The human can still navigate to it with `n`/`p` and override the seeded
|
|
16
|
+
* answer — once they do, it renders as user-answered.
|
|
17
|
+
*/
|
|
18
|
+
export interface InteractionPreAnswer {
|
|
19
|
+
selectedOptionId?: string;
|
|
20
|
+
selectedOptionIds?: string[];
|
|
21
|
+
freetext?: string;
|
|
22
|
+
/** One-line marker shown in the answered chrome (e.g. "Previously approved").
|
|
23
|
+
* Defaults to "Previously answered" when omitted. */
|
|
24
|
+
label?: string;
|
|
25
|
+
}
|
|
9
26
|
export interface Interaction {
|
|
10
27
|
id: string;
|
|
11
28
|
title: string;
|
|
@@ -19,6 +36,7 @@ export interface Interaction {
|
|
|
19
36
|
allowFreetext?: boolean;
|
|
20
37
|
freetextLabel?: string;
|
|
21
38
|
kind?: InteractionKind;
|
|
39
|
+
preAnswered?: InteractionPreAnswer;
|
|
22
40
|
}
|
|
23
41
|
export interface InteractionResponse {
|
|
24
42
|
id: string;
|
|
@@ -84,6 +102,10 @@ export interface TuiState {
|
|
|
84
102
|
interactions: Interaction[];
|
|
85
103
|
responses: Map<string, InteractionResponse>;
|
|
86
104
|
visuals: Map<string, VisualBlock>;
|
|
105
|
+
/** Ids of interactions whose response was seeded from `Interaction.preAnswered`
|
|
106
|
+
* and which the human has not yet overridden. Drives the distinct
|
|
107
|
+
* "previously answered" rendering and the skip-on-advance behavior. */
|
|
108
|
+
preAnsweredIds: Set<string>;
|
|
87
109
|
inputMode: InputMode;
|
|
88
110
|
selectedAction: number;
|
|
89
111
|
detailExpanded: boolean;
|