@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 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
- summary: buildSummary(deck, responses),
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: new Map(),
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
- if (input === 'q') {
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
- advanceItem(state, 1);
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
- advanceItem(state, 1);
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
- advanceItem(state, 1);
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
- advanceItem(state, 1);
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
  }
@@ -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 icon = response ? `${GREEN}✓${RESET}` : `${DIM}○${RESET}`;
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 icon = response ? `${GREEN}✓${RESET}` : `${YELLOW}○${RESET}`;
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) {
@@ -4,6 +4,7 @@ export interface Key {
4
4
  return: boolean;
5
5
  escape: boolean;
6
6
  ctrl: boolean;
7
+ meta: boolean;
7
8
  tab: boolean;
8
9
  backspace: boolean;
9
10
  }
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/humanloop",
3
- "version": "0.3.2",
3
+ "version": "0.3.5",
4
4
  "description": "Human-in-the-loop decision TUI — agents write questions, humans answer them",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",