@crouton-kit/humanloop 0.1.0

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.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,211 @@
1
+ #!/usr/bin/env node
2
+ import { Command, Option } from 'commander';
3
+ import { writeFileSync } from 'fs';
4
+ import { launchTui } from './tui/app.js';
5
+ import { dispatchToTmuxPane } from './tui/tmux.js';
6
+ import { findRecentSessionId } from './conversation/reader.js';
7
+ const program = new Command();
8
+ program
9
+ .name('hl')
10
+ .description('Human-in-the-loop decision TUI.\n' +
11
+ '\n' +
12
+ 'Use this when you (the agent) need the human to validate decisions, choose\n' +
13
+ 'between options, or provide freetext input before you continue. The tool\n' +
14
+ 'blocks until the human finishes review and returns their answers as JSON.\n' +
15
+ '\n' +
16
+ 'Workflow:\n' +
17
+ ' 1. Write a decisions file matching `hl schema` (a JSON list of questions).\n' +
18
+ ' 2. Run `hl create <file>`; it blocks and prints DecisionsOutput JSON to stdout.\n' +
19
+ ' 3. Parse the JSON; each answer\'s `type` mirrors the question\'s `type`.\n' +
20
+ '\n' +
21
+ 'Question types: validation (approve/reject a statement), choice (pick an\n' +
22
+ 'option or enter custom), freetext (open-ended response).')
23
+ .version('0.1.0')
24
+ .addHelpText('after', '\nExamples:\n' +
25
+ ' hl schema # print the input JSON schema\n' +
26
+ ' hl create decisions.json # open TUI, block, print answers JSON\n' +
27
+ ' hl create decisions.json --output answers.json # write result to file\n' +
28
+ ' hl create decisions.json --no-tmux # run in current pane even inside tmux\n');
29
+ program
30
+ .command('create')
31
+ .description('Open the decisions TUI on <file> and block until the human finishes review.\n' +
32
+ 'Prints DecisionsOutput JSON to stdout (or to --output / --write-to).')
33
+ .argument('<file>', 'Path to decisions JSON file (see `hl schema` for format)')
34
+ .option('--session-id <id>', 'Claude session ID; enables per-question visual context from conversation history. Defaults to the most recent session in cwd.')
35
+ .option('--no-visuals', 'Skip visual context generation (faster, no haiku calls)')
36
+ .option('--output <path>', 'Write result JSON to <path> instead of stdout')
37
+ .option('--no-tmux', 'Do not auto-dispatch the TUI to a new tmux pane even when $TMUX is set')
38
+ .addOption(new Option('--write-to <path>', 'internal: tmux child mode').hideHelp())
39
+ .addHelpText('after', '\n' +
40
+ 'INPUT FORMAT\n' +
41
+ ' JSON file with a `questions` array. Each question has an `id`, a `type`\n' +
42
+ ' ("validation" | "choice" | "freetext"), and `rationale`. Run `hl schema`\n' +
43
+ ' for the full schema. Example:\n' +
44
+ ' {\n' +
45
+ ' "questions": [\n' +
46
+ ' {"id": "q1", "type": "validation",\n' +
47
+ ' "statement": "We should use Postgres over SQLite",\n' +
48
+ ' "rationale": "Need concurrent writes from multiple services"},\n' +
49
+ ' {"id": "q2", "type": "choice",\n' +
50
+ ' "question": "Which migration tool?",\n' +
51
+ ' "rationale": "Need repeatable schema changes",\n' +
52
+ ' "options": ["Prisma", "Drizzle", "raw SQL"]},\n' +
53
+ ' {"id": "q3", "type": "freetext",\n' +
54
+ ' "question": "What should the retry policy be?",\n' +
55
+ ' "rationale": "Affects reliability budget"}\n' +
56
+ ' ]\n' +
57
+ ' }\n' +
58
+ '\n' +
59
+ 'OUTPUT FORMAT (stdout on success, JSON)\n' +
60
+ ' {\n' +
61
+ ' "answers": [ ... ], # same order as input questions\n' +
62
+ ' "completedAt": "2026-04-20T15:23:00.000Z"\n' +
63
+ ' }\n' +
64
+ '\n' +
65
+ ' Answer shape by `type`:\n' +
66
+ ' validation: { id, type: "validation", approved: boolean, comment?: string }\n' +
67
+ ' choice: { id, type: "choice", selected: string, isCustom: boolean, comment?: string }\n' +
68
+ ' freetext: { id, type: "freetext", response: string }\n' +
69
+ '\n' +
70
+ ' The human can skip questions. `answers` may have FEWER entries than input\n' +
71
+ ' questions — look up by `id`, do not assume index alignment.\n' +
72
+ '\n' +
73
+ 'BEHAVIOR\n' +
74
+ ' tmux When $TMUX is set, the TUI auto-splits into a new pane to the\n' +
75
+ ' right (-d keeps focus on the caller). Disable with --no-tmux.\n' +
76
+ ' progress Answers are persisted atomically to <file>.progress.json after\n' +
77
+ ' every change. If the process is killed, the next run resumes\n' +
78
+ ' from where the human left off. The file is removed on full\n' +
79
+ ' completion; partial-answer files are preserved.\n' +
80
+ ' visuals With --session-id (or auto-detected) haiku generates a short\n' +
81
+ ' ANSI context block per question from recent conversation turns.\n' +
82
+ '\n' +
83
+ 'EXIT CODES\n' +
84
+ ' 0 success — result JSON emitted\n' +
85
+ ' 1 error — message on stderr (file missing, invalid JSON, empty\n' +
86
+ ' questions, no TTY, etc.)\n' +
87
+ '\n' +
88
+ 'EXAMPLES\n' +
89
+ ' # Typical agent flow:\n' +
90
+ ' cat > /tmp/d.json <<EOF\n' +
91
+ ' {"questions":[{"id":"x","type":"validation",\n' +
92
+ ' "statement":"...","rationale":"..."}]}\n' +
93
+ ' EOF\n' +
94
+ ' hl create /tmp/d.json > /tmp/answers.json\n' +
95
+ ' jq \'.answers[] | select(.id=="x")\' /tmp/answers.json\n')
96
+ .action(async (file, opts) => {
97
+ const sessionId = opts.visuals
98
+ ? (opts.sessionId || findRecentSessionId(process.cwd()) || findRecentSessionId() || undefined)
99
+ : undefined;
100
+ const emit = (result) => {
101
+ const json = JSON.stringify(result, null, 2) + '\n';
102
+ if (opts.writeTo) {
103
+ writeFileSync(opts.writeTo, json);
104
+ }
105
+ else if (opts.output) {
106
+ writeFileSync(opts.output, json);
107
+ }
108
+ else {
109
+ process.stdout.write(json);
110
+ }
111
+ };
112
+ try {
113
+ if (process.env.TMUX && opts.tmux && !opts.writeTo) {
114
+ try {
115
+ const result = await dispatchToTmuxPane(file, { sessionId, visuals: opts.visuals });
116
+ emit(result);
117
+ process.exit(0);
118
+ }
119
+ catch (err) {
120
+ process.stderr.write(`tmux dispatch failed, running locally: ${err instanceof Error ? err.message : String(err)}\n`);
121
+ }
122
+ }
123
+ const result = await launchTui(file, sessionId);
124
+ emit(result);
125
+ process.exit(0);
126
+ }
127
+ catch (err) {
128
+ const msg = err instanceof Error ? err.message : String(err);
129
+ process.stderr.write(`ERROR: ${msg}\n`);
130
+ if (msg.includes('not found')) {
131
+ process.stderr.write('\nFix: pass a path to an existing decisions JSON file.\nSee format: hl schema\n');
132
+ }
133
+ else if (msg.includes('No questions')) {
134
+ process.stderr.write('\nFix: the file must contain a non-empty `questions` array.\nSee format: hl schema\n');
135
+ }
136
+ else if (msg.includes('TTY')) {
137
+ process.stderr.write('\nFix: hl needs an interactive terminal. If the caller captures stdin,\nrun inside tmux so hl can auto-dispatch the TUI to a new pane, or pipe\nstdin from /dev/tty.\n');
138
+ }
139
+ else if (msg.includes('JSON')) {
140
+ process.stderr.write('\nFix: the decisions file must be valid JSON matching `hl schema`.\n');
141
+ }
142
+ process.exit(1);
143
+ }
144
+ });
145
+ program
146
+ .command('schema')
147
+ .description('Print the decisions-input JSON schema to stdout')
148
+ .addHelpText('after', '\n' +
149
+ 'Use this to learn the exact input format for `hl create`.\n' +
150
+ 'The schema documents the three question types and their required fields.\n' +
151
+ '\n' +
152
+ 'Example:\n' +
153
+ ' hl schema > decisions.schema.json # save for reference\n' +
154
+ ' hl schema | jq # pretty-print\n')
155
+ .action(() => {
156
+ const schema = {
157
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
158
+ description: 'Input schema for hl create',
159
+ type: 'object',
160
+ properties: {
161
+ title: {
162
+ type: 'string',
163
+ description: 'Optional title shown in the TUI header',
164
+ },
165
+ questions: {
166
+ type: 'array',
167
+ items: {
168
+ oneOf: [
169
+ {
170
+ type: 'object',
171
+ description: 'Validation — a statement for the user to approve or reject (with optional comment)',
172
+ required: ['id', 'type', 'statement', 'rationale'],
173
+ properties: {
174
+ id: { type: 'string' },
175
+ type: { const: 'validation' },
176
+ statement: { type: 'string', description: 'A statement to validate, not a question' },
177
+ rationale: { type: 'string', description: 'Why this decision was made' },
178
+ },
179
+ },
180
+ {
181
+ type: 'object',
182
+ description: 'Choice — pick from options or provide a custom answer',
183
+ required: ['id', 'type', 'question', 'rationale', 'options'],
184
+ properties: {
185
+ id: { type: 'string' },
186
+ type: { const: 'choice' },
187
+ question: { type: 'string' },
188
+ rationale: { type: 'string' },
189
+ options: { type: 'array', items: { type: 'string' }, minItems: 2 },
190
+ },
191
+ },
192
+ {
193
+ type: 'object',
194
+ description: 'Freetext — open-ended response',
195
+ required: ['id', 'type', 'question', 'rationale'],
196
+ properties: {
197
+ id: { type: 'string' },
198
+ type: { const: 'freetext' },
199
+ question: { type: 'string' },
200
+ rationale: { type: 'string' },
201
+ },
202
+ },
203
+ ],
204
+ },
205
+ },
206
+ },
207
+ required: ['questions'],
208
+ };
209
+ process.stdout.write(JSON.stringify(schema, null, 2) + '\n');
210
+ });
211
+ program.parse();
@@ -0,0 +1,6 @@
1
+ export interface ConversationMessage {
2
+ role: 'user' | 'assistant';
3
+ content: string;
4
+ }
5
+ export declare function readConversation(sessionId: string): ConversationMessage[];
6
+ export declare function findRecentSessionId(cwd?: string): string | null;
@@ -0,0 +1,58 @@
1
+ import { execSync } from 'child_process';
2
+ import { homedir } from 'os';
3
+ import { join } from 'path';
4
+ import { existsSync } from 'fs';
5
+ const CLAUDE_DB_PATH = join(homedir(), '.claude', '__store.db');
6
+ export function readConversation(sessionId) {
7
+ if (!existsSync(CLAUDE_DB_PATH)) {
8
+ throw new Error(`Claude database not found at ${CLAUDE_DB_PATH}`);
9
+ }
10
+ const query = `
11
+ SELECT bm.message_type,
12
+ COALESCE(um.message, am.message) AS content
13
+ FROM base_messages bm
14
+ LEFT JOIN user_messages um ON bm.uuid = um.uuid
15
+ LEFT JOIN assistant_messages am ON bm.uuid = am.uuid
16
+ WHERE bm.session_id = '${sessionId.replace(/'/g, "''")}'
17
+ ORDER BY bm.timestamp ASC;
18
+ `;
19
+ const raw = execSync(`sqlite3 -json "${CLAUDE_DB_PATH}" "${query.replace(/"/g, '\\"')}"`, {
20
+ encoding: 'utf8',
21
+ maxBuffer: 50 * 1024 * 1024,
22
+ });
23
+ if (!raw.trim())
24
+ return [];
25
+ const rows = JSON.parse(raw);
26
+ const messages = [];
27
+ for (const row of rows) {
28
+ if (!row.content)
29
+ continue;
30
+ if (row.message_type === 'user' || row.message_type === 'assistant') {
31
+ messages.push({
32
+ role: row.message_type,
33
+ content: row.content,
34
+ });
35
+ }
36
+ }
37
+ return messages;
38
+ }
39
+ export function findRecentSessionId(cwd) {
40
+ if (!existsSync(CLAUDE_DB_PATH))
41
+ return null;
42
+ const whereClause = cwd
43
+ ? `WHERE cwd = '${cwd.replace(/'/g, "''")}'`
44
+ : '';
45
+ const query = `SELECT DISTINCT session_id FROM base_messages ${whereClause} ORDER BY timestamp DESC LIMIT 1;`;
46
+ try {
47
+ const raw = execSync(`sqlite3 -json "${CLAUDE_DB_PATH}" "${query.replace(/"/g, '\\"')}"`, {
48
+ encoding: 'utf8',
49
+ });
50
+ if (!raw.trim())
51
+ return null;
52
+ const rows = JSON.parse(raw);
53
+ return rows[0]?.session_id ?? null;
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ }
@@ -0,0 +1,2 @@
1
+ import type { DecisionsOutput } from '../types.js';
2
+ export declare function launchTui(decisionsPath: string, sessionId?: string): Promise<DecisionsOutput>;
@@ -0,0 +1,136 @@
1
+ import { readFileSync, existsSync, writeFileSync, renameSync, unlinkSync } from 'fs';
2
+ import { setupTerminal, restoreTerminal, parseKeypress, getTerminalSize } from './terminal.js';
3
+ import { flush, renderOverview, renderItemReview, renderFinal } from './render.js';
4
+ import { handleKeypress } from './input.js';
5
+ import { readConversation } from '../conversation/reader.js';
6
+ import { generateVisuals } from '../visuals/generate.js';
7
+ export async function launchTui(decisionsPath, sessionId) {
8
+ if (!existsSync(decisionsPath)) {
9
+ throw new Error(`Decisions file not found: ${decisionsPath}`);
10
+ }
11
+ const raw = readFileSync(decisionsPath, 'utf8');
12
+ const input = JSON.parse(raw);
13
+ if (!input.questions || input.questions.length === 0) {
14
+ throw new Error('No questions in decisions file');
15
+ }
16
+ const state = {
17
+ phase: 'overview',
18
+ currentIndex: 0,
19
+ questions: input.questions,
20
+ answers: new Map(),
21
+ visuals: new Map(),
22
+ inputMode: null,
23
+ selectedAction: 0,
24
+ detailExpanded: false,
25
+ scrollOffset: 0,
26
+ };
27
+ const progressPath = `${decisionsPath}.progress.json`;
28
+ state.persist = () => {
29
+ const answers = [];
30
+ for (const q of input.questions) {
31
+ const a = state.answers.get(q.id);
32
+ if (a)
33
+ answers.push(a);
34
+ }
35
+ const payload = {
36
+ partial: true,
37
+ answers,
38
+ savedAt: new Date().toISOString(),
39
+ };
40
+ try {
41
+ const tmp = `${progressPath}.tmp`;
42
+ writeFileSync(tmp, JSON.stringify(payload, null, 2));
43
+ renameSync(tmp, progressPath);
44
+ }
45
+ catch {
46
+ // best-effort — do not crash the TUI if the directory isn't writable
47
+ }
48
+ };
49
+ if (existsSync(progressPath)) {
50
+ try {
51
+ const prior = JSON.parse(readFileSync(progressPath, 'utf8'));
52
+ const validIds = new Set(input.questions.map((q) => q.id));
53
+ for (const a of prior.answers ?? []) {
54
+ if (validIds.has(a.id))
55
+ state.answers.set(a.id, a);
56
+ }
57
+ const firstUnanswered = input.questions.findIndex((q) => !state.answers.has(q.id));
58
+ state.currentIndex = firstUnanswered >= 0 ? firstUnanswered : 0;
59
+ }
60
+ catch {
61
+ // corrupt progress file — ignore and start fresh
62
+ }
63
+ }
64
+ // Initialize visuals — 'loading' if we'll generate them, skip otherwise
65
+ if (sessionId) {
66
+ for (const q of input.questions) {
67
+ state.visuals.set(q.id, { questionId: q.id, content: '', status: 'loading' });
68
+ }
69
+ }
70
+ setupTerminal();
71
+ const render = () => {
72
+ let lines;
73
+ switch (state.phase) {
74
+ case 'overview':
75
+ lines = renderOverview(state);
76
+ break;
77
+ case 'item-review':
78
+ lines = renderItemReview(state);
79
+ break;
80
+ case 'final':
81
+ lines = renderFinal(state);
82
+ break;
83
+ }
84
+ flush(lines);
85
+ };
86
+ // Initial render
87
+ render();
88
+ // Fan out haiku visual generation in background
89
+ if (sessionId) {
90
+ try {
91
+ const conversation = readConversation(sessionId);
92
+ if (conversation.length > 0) {
93
+ const { cols } = getTerminalSize();
94
+ const visualWidth = Math.max(40, Math.min(cols - 4, 76));
95
+ generateVisuals(input.questions, conversation, (qId, block) => {
96
+ state.visuals.set(qId, block);
97
+ render();
98
+ }, visualWidth).catch((err) => {
99
+ process.stderr.write(`Visual generation failed: ${err}\n`);
100
+ });
101
+ }
102
+ }
103
+ catch (err) {
104
+ for (const q of input.questions) {
105
+ state.visuals.set(q.id, { questionId: q.id, content: '', status: 'error' });
106
+ }
107
+ }
108
+ }
109
+ return new Promise((resolve) => {
110
+ const exit = () => {
111
+ restoreTerminal();
112
+ process.stdin.removeListener('data', onData);
113
+ const answers = [];
114
+ for (const q of input.questions) {
115
+ const a = state.answers.get(q.id);
116
+ if (a)
117
+ answers.push(a);
118
+ }
119
+ if (answers.length >= input.questions.length) {
120
+ try {
121
+ unlinkSync(progressPath);
122
+ }
123
+ catch { /* ignore */ }
124
+ }
125
+ resolve({
126
+ answers,
127
+ completedAt: new Date().toISOString(),
128
+ });
129
+ };
130
+ const onData = (data) => {
131
+ const { input: inp, key } = parseKeypress(data);
132
+ handleKeypress(inp, key, state, render, exit);
133
+ };
134
+ process.stdin.on('data', onData);
135
+ });
136
+ }
@@ -0,0 +1,5 @@
1
+ import type { TuiState } from '../types.js';
2
+ import type { Key } from './terminal.js';
3
+ export type RenderFn = () => void;
4
+ export type ExitFn = () => void;
5
+ export declare function handleKeypress(input: string, key: Key, state: TuiState, render: RenderFn, exit: ExitFn): void;
@@ -0,0 +1,257 @@
1
+ export function handleKeypress(input, key, state, render, exit) {
2
+ if (key.ctrl && input === 'c') {
3
+ exit();
4
+ return;
5
+ }
6
+ if (state.inputMode) {
7
+ handleInputMode(input, key, state, render);
8
+ checkAutoExit(state, exit);
9
+ return;
10
+ }
11
+ switch (state.phase) {
12
+ case 'overview':
13
+ handleOverview(input, key, state, render, exit);
14
+ break;
15
+ case 'item-review':
16
+ handleItemReview(input, key, state, render);
17
+ checkAutoExit(state, exit);
18
+ break;
19
+ case 'final':
20
+ handleFinal(input, key, state, render, exit);
21
+ break;
22
+ }
23
+ }
24
+ function checkAutoExit(state, exit) {
25
+ if (state.phase === 'final' && state.answers.size >= state.questions.length) {
26
+ exit();
27
+ }
28
+ }
29
+ // ── Overview ─────────────────────────────────────────────────────────────────
30
+ function handleOverview(input, key, state, render, exit) {
31
+ if (input === 'j' || key.downArrow) {
32
+ state.currentIndex = Math.min(state.currentIndex + 1, state.questions.length - 1);
33
+ render();
34
+ }
35
+ else if (input === 'k' || key.upArrow) {
36
+ state.currentIndex = Math.max(state.currentIndex - 1, 0);
37
+ render();
38
+ }
39
+ else if (key.return) {
40
+ state.phase = 'item-review';
41
+ state.selectedAction = 0;
42
+ state.detailExpanded = false;
43
+ render();
44
+ }
45
+ else if (input === 'q') {
46
+ if (state.answers.size >= state.questions.length) {
47
+ exit();
48
+ }
49
+ else {
50
+ state.phase = 'final';
51
+ render();
52
+ }
53
+ }
54
+ }
55
+ // ── Item Review ──────────────────────────────────────────────────────────────
56
+ function handleItemReview(input, key, state, render) {
57
+ const q = state.questions[state.currentIndex];
58
+ // Navigation
59
+ if (input === 'n') {
60
+ advanceItem(state, 1);
61
+ render();
62
+ return;
63
+ }
64
+ if (input === 'p') {
65
+ advanceItem(state, -1);
66
+ render();
67
+ return;
68
+ }
69
+ if (input === 'q') {
70
+ state.phase = 'overview';
71
+ render();
72
+ return;
73
+ }
74
+ if (input === ' ') {
75
+ state.detailExpanded = !state.detailExpanded;
76
+ render();
77
+ return;
78
+ }
79
+ // Action selection with j/k
80
+ if (input === 'j' || key.downArrow) {
81
+ const max = actionCount(q) - 1;
82
+ state.selectedAction = Math.min(state.selectedAction + 1, max);
83
+ render();
84
+ return;
85
+ }
86
+ if (input === 'k' || key.upArrow) {
87
+ state.selectedAction = Math.max(state.selectedAction - 1, 0);
88
+ render();
89
+ return;
90
+ }
91
+ // Type-specific actions
92
+ if (q.type === 'validation') {
93
+ handleValidationAction(input, key, state, q, render);
94
+ }
95
+ else if (q.type === 'choice') {
96
+ handleChoiceAction(input, key, state, q, render);
97
+ }
98
+ else {
99
+ handleFreetextAction(input, key, state, render);
100
+ }
101
+ }
102
+ function handleValidationAction(input, key, state, q, render) {
103
+ if (input === '1' || (key.return && state.selectedAction === 0)) {
104
+ state.answers.set(q.id, { id: q.id, type: 'validation', approved: true });
105
+ state.persist?.();
106
+ advanceItem(state, 1);
107
+ render();
108
+ }
109
+ else if (input === '2' || (key.return && state.selectedAction === 1)) {
110
+ state.inputMode = { kind: 'comment', buffer: '' };
111
+ state.answers.set(q.id, { id: q.id, type: 'validation', approved: true, comment: '' });
112
+ state.persist?.();
113
+ render();
114
+ }
115
+ else if (input === '3' || (key.return && state.selectedAction === 2)) {
116
+ state.answers.set(q.id, { id: q.id, type: 'validation', approved: false });
117
+ state.persist?.();
118
+ advanceItem(state, 1);
119
+ render();
120
+ }
121
+ else if (input === '4' || (key.return && state.selectedAction === 3)) {
122
+ state.inputMode = { kind: 'comment', buffer: '' };
123
+ state.answers.set(q.id, { id: q.id, type: 'validation', approved: false, comment: '' });
124
+ state.persist?.();
125
+ render();
126
+ }
127
+ }
128
+ function handleChoiceAction(input, key, state, q, render) {
129
+ const numOptions = q.options.length;
130
+ const digit = parseInt(input, 10);
131
+ if (digit >= 1 && digit <= numOptions) {
132
+ state.answers.set(q.id, {
133
+ id: q.id,
134
+ type: 'choice',
135
+ selected: q.options[digit - 1],
136
+ isCustom: false,
137
+ });
138
+ state.persist?.();
139
+ advanceItem(state, 1);
140
+ render();
141
+ return;
142
+ }
143
+ if (digit === numOptions + 1 || (key.return && state.selectedAction === numOptions)) {
144
+ state.inputMode = { kind: 'custom-option', buffer: '' };
145
+ render();
146
+ return;
147
+ }
148
+ if (key.return && state.selectedAction < numOptions) {
149
+ state.answers.set(q.id, {
150
+ id: q.id,
151
+ type: 'choice',
152
+ selected: q.options[state.selectedAction],
153
+ isCustom: false,
154
+ });
155
+ state.persist?.();
156
+ advanceItem(state, 1);
157
+ render();
158
+ }
159
+ }
160
+ function handleFreetextAction(input, key, state, render) {
161
+ if (input === 'r' || key.return) {
162
+ const existing = state.answers.get(state.questions[state.currentIndex].id);
163
+ const prefill = existing?.type === 'freetext' ? existing.response : '';
164
+ state.inputMode = { kind: 'freetext', buffer: prefill };
165
+ render();
166
+ }
167
+ }
168
+ // ── Input Mode ───────────────────────────────────────────────────────────────
169
+ function handleInputMode(input, key, state, render) {
170
+ const mode = state.inputMode;
171
+ if (key.escape) {
172
+ state.inputMode = null;
173
+ render();
174
+ return;
175
+ }
176
+ if (key.return) {
177
+ commitInput(state);
178
+ state.inputMode = null;
179
+ advanceItem(state, 1);
180
+ render();
181
+ return;
182
+ }
183
+ if (key.backspace) {
184
+ mode.buffer = mode.buffer.slice(0, -1);
185
+ render();
186
+ return;
187
+ }
188
+ if (input.length === 1 && input.charCodeAt(0) >= 32) {
189
+ mode.buffer += input;
190
+ render();
191
+ }
192
+ }
193
+ function commitInput(state) {
194
+ const q = state.questions[state.currentIndex];
195
+ const mode = state.inputMode;
196
+ if (mode.kind === 'comment') {
197
+ const existing = state.answers.get(q.id);
198
+ const approved = existing ? existing.approved : false;
199
+ state.answers.set(q.id, {
200
+ id: q.id,
201
+ type: 'validation',
202
+ approved,
203
+ comment: mode.buffer || undefined,
204
+ });
205
+ }
206
+ else if (mode.kind === 'custom-option') {
207
+ if (mode.buffer) {
208
+ state.answers.set(q.id, {
209
+ id: q.id,
210
+ type: 'choice',
211
+ selected: mode.buffer,
212
+ isCustom: true,
213
+ });
214
+ }
215
+ }
216
+ else if (mode.kind === 'freetext') {
217
+ if (mode.buffer) {
218
+ state.answers.set(q.id, {
219
+ id: q.id,
220
+ type: 'freetext',
221
+ response: mode.buffer,
222
+ });
223
+ }
224
+ }
225
+ state.persist?.();
226
+ }
227
+ // ── Final ────────────────────────────────────────────────────────────────────
228
+ function handleFinal(input, key, state, render, exit) {
229
+ if (key.return) {
230
+ exit();
231
+ }
232
+ else if (input === 'p') {
233
+ state.phase = 'item-review';
234
+ state.currentIndex = state.questions.length - 1;
235
+ render();
236
+ }
237
+ }
238
+ // ── Helpers ──────────────────────────────────────────────────────────────────
239
+ function advanceItem(state, direction) {
240
+ const next = state.currentIndex + direction;
241
+ if (next < 0)
242
+ return;
243
+ if (next >= state.questions.length) {
244
+ state.phase = 'final';
245
+ return;
246
+ }
247
+ state.currentIndex = next;
248
+ state.selectedAction = 0;
249
+ state.detailExpanded = false;
250
+ }
251
+ function actionCount(q) {
252
+ switch (q.type) {
253
+ case 'validation': return 4;
254
+ case 'choice': return q.options.length + 1;
255
+ case 'freetext': return 1;
256
+ }
257
+ }