@crouton-kit/humanloop 0.3.2 → 0.3.5
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 +4 -2
- package/dist/cli.js +159 -3
- package/dist/inbox/deck-schema.d.ts +12 -0
- package/dist/inbox/deck-schema.js +7 -0
- package/dist/tui/app.d.ts +7 -0
- package/dist/tui/app.js +93 -5
- package/dist/tui/input.js +51 -5
- package/dist/tui/render.js +18 -2
- package/dist/tui/terminal.d.ts +1 -0
- package/dist/tui/terminal.js +8 -0
- package/dist/types.d.ts +28 -0
- package/package.json +1 -1
package/dist/api.js
CHANGED
|
@@ -55,13 +55,15 @@ export async function ask(deck, opts = {}) {
|
|
|
55
55
|
const dir = opts.dir ?? managedDir();
|
|
56
56
|
mkdirSync(dir, { recursive: true });
|
|
57
57
|
atomicWriteJson(deckPath(dir), deck);
|
|
58
|
-
const { responses, completedAt, responsePath } = await resolveInteractionDir(dir, deck, {
|
|
58
|
+
const { responses, completedAt, responsePath, deck: answeredDeck } = await resolveInteractionDir(dir, deck, {
|
|
59
59
|
sessionId: opts.sessionId,
|
|
60
60
|
cols: opts.cols,
|
|
61
61
|
rows: opts.rows,
|
|
62
62
|
});
|
|
63
63
|
return {
|
|
64
|
-
|
|
64
|
+
// `answeredDeck` === `deck` unless an agent ran `hl deck update`
|
|
65
|
+
// mid-flight; the summary must describe the questions actually answered.
|
|
66
|
+
summary: buildSummary(answeredDeck, responses),
|
|
65
67
|
responsePath,
|
|
66
68
|
schema: RESPONSE_SCHEMA_ID,
|
|
67
69
|
responses,
|
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' +
|
|
@@ -313,7 +316,17 @@ program
|
|
|
313
316
|
.helpOption('-h, --help', 'Show help')
|
|
314
317
|
.addHelpCommand(false);
|
|
315
318
|
// ── deck ──────────────────────────────────────────────────────────────────────
|
|
316
|
-
const deckCmd = program.command('deck').description('Write questions, get answers from the human
|
|
319
|
+
const deckCmd = program.command('deck').description('Write questions, get answers from the human.\n' +
|
|
320
|
+
'\n' +
|
|
321
|
+
'Children:\n' +
|
|
322
|
+
' hl deck ask — spawn the decisions TUI, return a job handle | use when: posing material decisions\n' +
|
|
323
|
+
' hl deck update — replace the deck of a LIVE ask job in place | use when: the questions changed after ask\n' +
|
|
324
|
+
' hl deck validate — preflight a deck object, no side effects | use when: checking a deck before ask\n' +
|
|
325
|
+
'\n' +
|
|
326
|
+
'A `deck update` rewrites the live job\'s deck.json; the TUI pane the\n' +
|
|
327
|
+
'human is looking at reloads it automatically within ~1s (answers whose\n' +
|
|
328
|
+
'interaction ids still exist are kept). Read this leaf\'s -h before calling\n' +
|
|
329
|
+
'it — it mutates a session a human is actively in.');
|
|
317
330
|
deckCmd
|
|
318
331
|
.command('ask')
|
|
319
332
|
.description('Kickoff: spawn the decisions TUI and return immediately.\n' +
|
|
@@ -324,7 +337,9 @@ deckCmd
|
|
|
324
337
|
'\n' +
|
|
325
338
|
'Effects: writes <dir>/deck.json, <dir>/progress.json (live),\n' +
|
|
326
339
|
' <dir>/response.json (on finish), <dir>/job.log (JSONL).\n' +
|
|
327
|
-
' Spawns TUI detached in a tmux pane when tmux=true and $TMUX set.\n'
|
|
340
|
+
' Spawns TUI detached in a tmux pane when tmux=true and $TMUX set.\n' +
|
|
341
|
+
' While the job is live the TUI watches <dir>/deck.json: a later\n' +
|
|
342
|
+
' `hl deck update` rewrites it and the pane reloads automatically.\n')
|
|
328
343
|
.helpOption('-h, --help', 'Show help')
|
|
329
344
|
.action(async () => {
|
|
330
345
|
const input = parseStdinJson();
|
|
@@ -407,7 +422,7 @@ deckCmd
|
|
|
407
422
|
process.stdout.write(JSON.stringify({
|
|
408
423
|
job_id: jobId,
|
|
409
424
|
dir,
|
|
410
|
-
follow_up: `Call hl job result with stdin {"job_id":"${jobId}","wait":true} to block until the human finishes.`,
|
|
425
|
+
follow_up: `Call hl job result with stdin {"job_id":"${jobId}","wait":true} to block until the human finishes. If the questions change before they answer, pipe {"job_id":"${jobId}","deck":{...}} to hl deck update — the pane reloads automatically.`,
|
|
411
426
|
}) + '\n');
|
|
412
427
|
process.exit(0);
|
|
413
428
|
}
|
|
@@ -435,6 +450,74 @@ deckCmd
|
|
|
435
450
|
});
|
|
436
451
|
}
|
|
437
452
|
});
|
|
453
|
+
deckCmd
|
|
454
|
+
.command('update')
|
|
455
|
+
.description('Replace the deck of a LIVE ask job; the human\'s TUI pane reloads.\n' +
|
|
456
|
+
'\n' +
|
|
457
|
+
'stdin { job_id: string (required), deck: object (required) }\n' +
|
|
458
|
+
'stdout { ok: true, job_id: string, interactions: int, follow_up: string }\n' +
|
|
459
|
+
'\n' +
|
|
460
|
+
'The TUI watches deck.json and reloads within ~1s of this write. Answers\n' +
|
|
461
|
+
'whose interaction id still exists in the new deck are preserved; new or\n' +
|
|
462
|
+
'id-changed interactions appear unanswered. In-flight unsubmitted input\n' +
|
|
463
|
+
'(a comment being typed) is discarded on reload.\n' +
|
|
464
|
+
'\n' +
|
|
465
|
+
'Errors: job_not_found (no such job_id) | job_not_live (already\n' +
|
|
466
|
+
'done/failed/canceled — nothing to reload) | deck_invalid (deck rejected;\n' +
|
|
467
|
+
'the old deck stays in place, run hl deck validate first).\n' +
|
|
468
|
+
'\n' +
|
|
469
|
+
'Effects: atomically rewrites <dir>/deck.json; appends a deck_updated\n' +
|
|
470
|
+
'event to <dir>/job.log. No effect on response.json/progress.json.\n')
|
|
471
|
+
.helpOption('-h, --help', 'Show help')
|
|
472
|
+
.action(() => {
|
|
473
|
+
const input = parseStdinJson();
|
|
474
|
+
if (!input.job_id || typeof input.job_id !== 'string') {
|
|
475
|
+
emitError({ error: 'bad_input', message: 'job_id is required', field: 'job_id', next: 'Provide: {"job_id": "<id>", "deck": {...}}' });
|
|
476
|
+
}
|
|
477
|
+
if (!input.deck || typeof input.deck !== 'object') {
|
|
478
|
+
emitError({ error: 'bad_input', message: 'deck is required and must be an object', field: 'deck', next: "Run: echo '{\"kind\":\"deck\"}' | hl schema show" });
|
|
479
|
+
}
|
|
480
|
+
const dir = resolveJobDir(input.job_id);
|
|
481
|
+
if (!existsSync(dir) || !existsSync(deckPath(dir))) {
|
|
482
|
+
emitError({ error: 'job_not_found', message: `Job not found: ${input.job_id}`, next: 'Check the job_id returned by hl deck ask.' });
|
|
483
|
+
}
|
|
484
|
+
const state = detectJobState(dir);
|
|
485
|
+
if (state !== 'live') {
|
|
486
|
+
emitError({
|
|
487
|
+
error: 'job_not_live',
|
|
488
|
+
message: `Job is ${state}; its deck can no longer be reloaded.`,
|
|
489
|
+
received: state,
|
|
490
|
+
next: 'The human already finished. Start a fresh deck with hl deck ask.',
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
let deck;
|
|
494
|
+
try {
|
|
495
|
+
const _v = {};
|
|
496
|
+
void _v;
|
|
497
|
+
deck = validateDeck(input.deck);
|
|
498
|
+
}
|
|
499
|
+
catch (validationErr) {
|
|
500
|
+
emitError({
|
|
501
|
+
error: 'deck_invalid',
|
|
502
|
+
message: `deck validation failed: ${validationErr instanceof Error ? validationErr.message : String(validationErr)}`,
|
|
503
|
+
received: input.deck,
|
|
504
|
+
next: "The live deck is unchanged. Fix the deck, then: echo '{\"deck\":{...}}' | hl deck validate",
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
atomicWriteJson(deckPath(dir), deck);
|
|
508
|
+
appendJobLog(dir, {
|
|
509
|
+
level: 'info', event: 'deck_updated',
|
|
510
|
+
message: `deck replaced (${deck.interactions.length} interaction(s)); pane reloads on next watch tick`,
|
|
511
|
+
data: { jobId: basename(dir), interactions: deck.interactions.length },
|
|
512
|
+
});
|
|
513
|
+
process.stdout.write(JSON.stringify({
|
|
514
|
+
ok: true,
|
|
515
|
+
job_id: basename(dir),
|
|
516
|
+
interactions: deck.interactions.length,
|
|
517
|
+
follow_up: `The pane reloads within ~1s. Still resolve with hl job result {"job_id":"${basename(dir)}","wait":true}.`,
|
|
518
|
+
}) + '\n');
|
|
519
|
+
process.exit(0);
|
|
520
|
+
});
|
|
438
521
|
deckCmd
|
|
439
522
|
.command('validate')
|
|
440
523
|
.description('Preflight deck validation — no side effects.\n' +
|
|
@@ -546,6 +629,79 @@ viewCmd
|
|
|
546
629
|
}
|
|
547
630
|
process.exit(0);
|
|
548
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
|
+
}
|
|
549
705
|
// ── inbox ─────────────────────────────────────────────────────────────────────
|
|
550
706
|
const inboxCmd = program.command('inbox').description('Browse and resolve pending interactions across root dirs.');
|
|
551
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(),
|
package/dist/tui/app.d.ts
CHANGED
|
@@ -18,11 +18,18 @@ export interface ResolveDirOpts {
|
|
|
18
18
|
* with skips) write `<dir>/response.json` atomically and drop the progress
|
|
19
19
|
* file. A hard process kill leaves `progress.json` for a later resume —
|
|
20
20
|
* `tryResume` (unchanged logic) reads the new dir-derived path.
|
|
21
|
+
*
|
|
22
|
+
* While the panel is mounted, `<dir>/deck.json` is polled for changes (an
|
|
23
|
+
* agent calling `hl deck update`). On a valid rewrite the panel is reloaded
|
|
24
|
+
* in place via `loadDeck`, so the human's pane reflects the new questions
|
|
25
|
+
* without a respawn; answers for surviving interaction ids are kept. The
|
|
26
|
+
* returned `deck` is the one actually answered (post-reload).
|
|
21
27
|
*/
|
|
22
28
|
export declare function resolveInteractionDir(dir: string, deck: Deck, opts?: ResolveDirOpts): Promise<{
|
|
23
29
|
responses: InteractionResponse[];
|
|
24
30
|
completedAt: string;
|
|
25
31
|
responsePath: string;
|
|
32
|
+
deck: Deck;
|
|
26
33
|
}>;
|
|
27
34
|
export declare function launchTui(decisionsPath: string, sessionId?: string): Promise<{
|
|
28
35
|
responses: InteractionResponse[];
|
package/dist/tui/app.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync, existsSync, writeFileSync, renameSync, unlinkSync } from 'fs';
|
|
1
|
+
import { readFileSync, existsSync, writeFileSync, renameSync, unlinkSync, statSync } from 'fs';
|
|
2
2
|
import { dirname, resolve as resolvePath } from 'node:path';
|
|
3
3
|
import { setupTerminal, restoreTerminal, parseKeypress, getTerminalSize } from './terminal.js';
|
|
4
4
|
import { diffFrame, renderOverview, renderItemReview, renderFinal } from './render.js';
|
|
@@ -6,7 +6,7 @@ import { handleKeypress, assignShortcuts } from './input.js';
|
|
|
6
6
|
import { readConversation } from '../conversation/reader.js';
|
|
7
7
|
import { defaultGenerateVisual } from '../visuals/generate.js';
|
|
8
8
|
import { validateDeck } from '../inbox/deck-schema.js';
|
|
9
|
-
import { progressPath as progressPathFor, writeResponse, clearProgress } from '../inbox/convention.js';
|
|
9
|
+
import { progressPath as progressPathFor, deckPath as deckPathFor, writeResponse, clearProgress } from '../inbox/convention.js';
|
|
10
10
|
/** Validate an arbitrary parsed value as a Deck. Delegates to the canonical
|
|
11
11
|
* Zod validator in `inbox/deck-schema.ts` (the single source of truth shared
|
|
12
12
|
* with sisyphus). Kept exported for back-compat. */
|
|
@@ -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,
|
|
@@ -179,6 +203,11 @@ export function mountPanel(opts) {
|
|
|
179
203
|
return false;
|
|
180
204
|
return internals.state.inputMode === null;
|
|
181
205
|
},
|
|
206
|
+
atDeckTop() {
|
|
207
|
+
if (!internals.mounted)
|
|
208
|
+
return true;
|
|
209
|
+
return internals.state.phase === 'overview' && internals.state.inputMode === null;
|
|
210
|
+
},
|
|
182
211
|
};
|
|
183
212
|
}
|
|
184
213
|
/**
|
|
@@ -187,6 +216,12 @@ export function mountPanel(opts) {
|
|
|
187
216
|
* with skips) write `<dir>/response.json` atomically and drop the progress
|
|
188
217
|
* file. A hard process kill leaves `progress.json` for a later resume —
|
|
189
218
|
* `tryResume` (unchanged logic) reads the new dir-derived path.
|
|
219
|
+
*
|
|
220
|
+
* While the panel is mounted, `<dir>/deck.json` is polled for changes (an
|
|
221
|
+
* agent calling `hl deck update`). On a valid rewrite the panel is reloaded
|
|
222
|
+
* in place via `loadDeck`, so the human's pane reflects the new questions
|
|
223
|
+
* without a respawn; answers for surviving interaction ids are kept. The
|
|
224
|
+
* returned `deck` is the one actually answered (post-reload).
|
|
190
225
|
*/
|
|
191
226
|
export async function resolveInteractionDir(dir, deck, opts = {}) {
|
|
192
227
|
let conversationContext = '';
|
|
@@ -212,6 +247,12 @@ export async function resolveInteractionDir(dir, deck, opts = {}) {
|
|
|
212
247
|
let prevFrameLocal = [];
|
|
213
248
|
let lastResponses = [];
|
|
214
249
|
let onData;
|
|
250
|
+
// The deck the human is actually answering. An agent may replace it
|
|
251
|
+
// mid-flight via `hl deck update` (atomic deck.json rewrite); the poller
|
|
252
|
+
// below reloads the panel in place and tracks the live deck here so the
|
|
253
|
+
// returned envelope/summary describes what was answered, not the kickoff.
|
|
254
|
+
let currentDeck = deck;
|
|
255
|
+
let deckWatch = null;
|
|
215
256
|
const flushHost = (lines) => {
|
|
216
257
|
const { rows: currentRows } = getTerminalSize();
|
|
217
258
|
const { writes, nextPrevFrame } = diffFrame(prevFrameLocal, lines, currentRows);
|
|
@@ -222,6 +263,10 @@ export async function resolveInteractionDir(dir, deck, opts = {}) {
|
|
|
222
263
|
prevFrameLocal = nextPrevFrame;
|
|
223
264
|
};
|
|
224
265
|
const finalize = (responses) => {
|
|
266
|
+
if (deckWatch !== null) {
|
|
267
|
+
clearInterval(deckWatch);
|
|
268
|
+
deckWatch = null;
|
|
269
|
+
}
|
|
225
270
|
restoreTerminal();
|
|
226
271
|
process.stdin.removeListener('data', onData);
|
|
227
272
|
panel?.unmount();
|
|
@@ -229,7 +274,7 @@ export async function resolveInteractionDir(dir, deck, opts = {}) {
|
|
|
229
274
|
// Resolved supersedes in-progress: write response.json, drop progress.json.
|
|
230
275
|
const rp = writeResponse(dir, responses, completedAt);
|
|
231
276
|
clearProgress(dir);
|
|
232
|
-
resolve({ responses, completedAt, responsePath: rp });
|
|
277
|
+
resolve({ responses, completedAt, responsePath: rp, deck: currentDeck });
|
|
233
278
|
};
|
|
234
279
|
panel = mountPanel({
|
|
235
280
|
deck,
|
|
@@ -248,6 +293,49 @@ export async function resolveInteractionDir(dir, deck, opts = {}) {
|
|
|
248
293
|
},
|
|
249
294
|
});
|
|
250
295
|
flushHost(panel.render());
|
|
296
|
+
// ── Live deck reload ──────────────────────────────────────────────────
|
|
297
|
+
// Poll deck.json mtime (cheap stat; full read only on change). atomicWrite
|
|
298
|
+
// does write-tmp + rename, so stat/read always see a whole file — no
|
|
299
|
+
// fs.watch rename flakiness. The TUI never writes deck.json, so there is
|
|
300
|
+
// no feedback loop. A structurally identical rewrite is ignored so a
|
|
301
|
+
// no-op touch never disrupts the human mid-answer.
|
|
302
|
+
const deckFile = deckPathFor(dir);
|
|
303
|
+
const deckMtime = () => {
|
|
304
|
+
try {
|
|
305
|
+
return statSync(deckFile).mtimeMs;
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
return 0;
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
let lastDeckMtime = deckMtime();
|
|
312
|
+
let lastDeckJson = JSON.stringify(currentDeck);
|
|
313
|
+
deckWatch = setInterval(() => {
|
|
314
|
+
if (panel === null)
|
|
315
|
+
return;
|
|
316
|
+
const m = deckMtime();
|
|
317
|
+
if (m === 0 || m === lastDeckMtime)
|
|
318
|
+
return;
|
|
319
|
+
lastDeckMtime = m;
|
|
320
|
+
let nextDeck;
|
|
321
|
+
try {
|
|
322
|
+
const parsed = JSON.parse(readFileSync(deckFile, 'utf8'));
|
|
323
|
+
nextDeck = validateDeck(parsed);
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
// Mid-rename, invalid, or rejected by schema: keep the live deck,
|
|
327
|
+
// retry on the next tick. `hl deck update` validates before writing,
|
|
328
|
+
// so a persistently bad file is an out-of-band edit, not our concern.
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
const nextJson = JSON.stringify(nextDeck);
|
|
332
|
+
if (nextJson === lastDeckJson)
|
|
333
|
+
return; // touch / identical content
|
|
334
|
+
lastDeckJson = nextJson;
|
|
335
|
+
currentDeck = nextDeck;
|
|
336
|
+
panel.loadDeck(nextDeck, { progressPath: progressPathFor(dir) });
|
|
337
|
+
flushHost(panel.render());
|
|
338
|
+
}, 500);
|
|
251
339
|
onData = (data) => {
|
|
252
340
|
const { input: inp, key } = parseKeypress(data);
|
|
253
341
|
panel.handleKey(inp, key);
|
package/dist/tui/input.js
CHANGED
|
@@ -117,7 +117,8 @@ function handleItemReview(input, key, state, render) {
|
|
|
117
117
|
render();
|
|
118
118
|
return;
|
|
119
119
|
}
|
|
120
|
-
|
|
120
|
+
// q / Esc step back to the deck overview (one level up from a card).
|
|
121
|
+
if (input === 'q' || key.escape) {
|
|
121
122
|
state.phase = 'overview';
|
|
122
123
|
render();
|
|
123
124
|
return;
|
|
@@ -172,7 +173,7 @@ function handleInteractionAction(input, key, state, interaction, render) {
|
|
|
172
173
|
return;
|
|
173
174
|
}
|
|
174
175
|
submitOption(state, interaction, matched.id, undefined);
|
|
175
|
-
|
|
176
|
+
advanceToNextUnanswered(state);
|
|
176
177
|
render();
|
|
177
178
|
return;
|
|
178
179
|
}
|
|
@@ -203,13 +204,13 @@ function handleInteractionAction(input, key, state, interaction, render) {
|
|
|
203
204
|
if (key.return && state.selectedAction < opts.length) {
|
|
204
205
|
if (interaction.multiSelect) {
|
|
205
206
|
commitMulti(state, interaction);
|
|
206
|
-
|
|
207
|
+
advanceToNextUnanswered(state);
|
|
207
208
|
render();
|
|
208
209
|
return;
|
|
209
210
|
}
|
|
210
211
|
const o = opts[state.selectedAction];
|
|
211
212
|
submitOption(state, interaction, o.id, undefined);
|
|
212
|
-
|
|
213
|
+
advanceToNextUnanswered(state);
|
|
213
214
|
render();
|
|
214
215
|
return;
|
|
215
216
|
}
|
|
@@ -257,7 +258,12 @@ function handleInputMode(input, key, state, render) {
|
|
|
257
258
|
submitOption(state, interaction, attached, mode.buffer);
|
|
258
259
|
}
|
|
259
260
|
state.inputMode = null;
|
|
260
|
-
|
|
261
|
+
advanceToNextUnanswered(state);
|
|
262
|
+
render();
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (key.backspace && key.meta) {
|
|
266
|
+
mode.buffer = deleteWordBack(mode.buffer);
|
|
261
267
|
render();
|
|
262
268
|
return;
|
|
263
269
|
}
|
|
@@ -277,11 +283,23 @@ function handleInputMode(input, key, state, render) {
|
|
|
277
283
|
render();
|
|
278
284
|
}
|
|
279
285
|
}
|
|
286
|
+
function deleteWordBack(buffer) {
|
|
287
|
+
const chars = [...buffer];
|
|
288
|
+
while (chars.length > 0 && /\s/.test(chars[chars.length - 1]))
|
|
289
|
+
chars.pop();
|
|
290
|
+
while (chars.length > 0 && !/\s/.test(chars[chars.length - 1]))
|
|
291
|
+
chars.pop();
|
|
292
|
+
return chars.join('');
|
|
293
|
+
}
|
|
280
294
|
// ── Final ────────────────────────────────────────────────────────────────────
|
|
281
295
|
function handleFinal(input, key, state, render, exit) {
|
|
282
296
|
if (key.return) {
|
|
283
297
|
exit();
|
|
284
298
|
}
|
|
299
|
+
else if (key.escape) {
|
|
300
|
+
state.phase = 'overview';
|
|
301
|
+
render();
|
|
302
|
+
}
|
|
285
303
|
else if (input === 'p') {
|
|
286
304
|
state.phase = 'item-review';
|
|
287
305
|
state.currentIndex = state.interactions.length - 1;
|
|
@@ -302,6 +320,27 @@ function advanceItem(state, direction) {
|
|
|
302
320
|
state.detailExpanded = false;
|
|
303
321
|
state.scrollOffset = 0;
|
|
304
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
|
+
}
|
|
305
344
|
function actionCount(interaction) {
|
|
306
345
|
return interaction.options.length + (interaction.allowFreetext && interaction.options.length > 0 ? 1 : 0);
|
|
307
346
|
}
|
|
@@ -312,6 +351,9 @@ function submitOption(state, interaction, selectedOptionId, freetext) {
|
|
|
312
351
|
if (freetext !== undefined)
|
|
313
352
|
response.freetext = freetext;
|
|
314
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);
|
|
315
357
|
state.persist?.();
|
|
316
358
|
}
|
|
317
359
|
// ── Multi-select ─────────────────────────────────────────────────────────────
|
|
@@ -328,6 +370,8 @@ function toggleMulti(state, interaction, optionId) {
|
|
|
328
370
|
if (existing?.freetext !== undefined)
|
|
329
371
|
response.freetext = existing.freetext;
|
|
330
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);
|
|
331
375
|
state.persist?.();
|
|
332
376
|
}
|
|
333
377
|
/** Ensure a (possibly empty) response exists so the interaction counts as
|
|
@@ -342,5 +386,7 @@ function commitMulti(state, interaction, freetext) {
|
|
|
342
386
|
if (ft !== undefined)
|
|
343
387
|
response.freetext = ft;
|
|
344
388
|
state.responses.set(interaction.id, response);
|
|
389
|
+
// Explicit confirm overrides any preAnswered seed.
|
|
390
|
+
state.preAnsweredIds.delete(interaction.id);
|
|
345
391
|
state.persist?.();
|
|
346
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/tui/terminal.d.ts
CHANGED
package/dist/tui/terminal.js
CHANGED
|
@@ -5,6 +5,7 @@ function emptyKey() {
|
|
|
5
5
|
return: false,
|
|
6
6
|
escape: false,
|
|
7
7
|
ctrl: false,
|
|
8
|
+
meta: false,
|
|
8
9
|
tab: false,
|
|
9
10
|
backspace: false,
|
|
10
11
|
};
|
|
@@ -24,6 +25,13 @@ export function parseKeypress(data) {
|
|
|
24
25
|
key.return = true;
|
|
25
26
|
return { input: '', key };
|
|
26
27
|
}
|
|
28
|
+
// Alt+Backspace: terminals send ESC followed by DEL/BS. Must precede the
|
|
29
|
+
// bare-ESC check so the two-byte sequence isn't swallowed as plain escape.
|
|
30
|
+
if (str === '\x1b\x7f' || str === '\x1b\b') {
|
|
31
|
+
key.meta = true;
|
|
32
|
+
key.backspace = true;
|
|
33
|
+
return { input: '', key };
|
|
34
|
+
}
|
|
27
35
|
if (str === '\x1b') {
|
|
28
36
|
key.escape = true;
|
|
29
37
|
return { input: '', key };
|
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;
|
|
@@ -156,4 +178,10 @@ export interface MountedPanel {
|
|
|
156
178
|
progressPath?: string;
|
|
157
179
|
}): void;
|
|
158
180
|
canAcceptHostKeys(): boolean;
|
|
181
|
+
/**
|
|
182
|
+
* True when the deck is at its top level: overview phase with no active
|
|
183
|
+
* comment/freetext input. A host that owns mount/unmount uses this to decide
|
|
184
|
+
* whether Esc should step back inside the deck (false) or tear it down (true).
|
|
185
|
+
*/
|
|
186
|
+
atDeckTop(): boolean;
|
|
159
187
|
}
|