@crouton-kit/humanloop 0.1.2 → 0.1.4

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/tui/app.js CHANGED
@@ -1,201 +1,336 @@
1
1
  import { readFileSync, existsSync, writeFileSync, renameSync, unlinkSync } from 'fs';
2
2
  import { setupTerminal, restoreTerminal, parseKeypress, getTerminalSize } from './terminal.js';
3
- import { flush, renderOverview, renderItemReview, renderFinal } from './render.js';
4
- import { handleKeypress } from './input.js';
3
+ import { diffFrame, renderOverview, renderItemReview, renderFinal } from './render.js';
4
+ import { handleKeypress, assignShortcuts } from './input.js';
5
5
  import { readConversation } from '../conversation/reader.js';
6
- import { generateVisuals } from '../visuals/generate.js';
7
- // Validate the parsed JSON before opening the terminal so bad agent input
8
- // fails with a clear error instead of crashing inside the TUI.
6
+ import { defaultGenerateVisual } from '../visuals/generate.js';
9
7
  export function validateInput(parsed) {
10
8
  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
11
- throw new Error('Decisions file must be a JSON object with a `questions` array');
9
+ throw new Error('Deck file must be a JSON object with an `interactions` array');
12
10
  }
13
11
  const obj = parsed;
14
- if (!Array.isArray(obj.questions)) {
15
- throw new Error('`questions` must be an array');
12
+ if (!Array.isArray(obj.interactions)) {
13
+ throw new Error('`interactions` must be an array');
16
14
  }
17
- if (obj.questions.length === 0) {
18
- throw new Error('No questions in decisions file');
15
+ if (obj.interactions.length === 0) {
16
+ throw new Error('No interactions in deck file');
19
17
  }
20
18
  if (obj.title !== undefined && typeof obj.title !== 'string') {
21
19
  throw new Error('`title` must be a string when present');
22
20
  }
23
21
  const seen = new Set();
24
22
  const validated = [];
25
- for (let i = 0; i < obj.questions.length; i++) {
26
- const q = obj.questions[i];
27
- const where = `questions[${i}]`;
28
- if (typeof q !== 'object' || q === null || Array.isArray(q)) {
23
+ for (let i = 0; i < obj.interactions.length; i++) {
24
+ const raw = obj.interactions[i];
25
+ const where = `interactions[${i}]`;
26
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
29
27
  throw new Error(`${where} must be an object`);
30
28
  }
31
- if (typeof q.id !== 'string' || q.id === '') {
29
+ if (typeof raw.id !== 'string' || raw.id === '') {
32
30
  throw new Error(`${where}.id must be a non-empty string`);
33
31
  }
34
- if (seen.has(q.id)) {
35
- throw new Error(`Duplicate question id: ${JSON.stringify(q.id)}`);
36
- }
37
- seen.add(q.id);
38
- if (q.type === 'validation') {
39
- if (typeof q.statement !== 'string')
40
- throw new Error(`${where}.statement must be a string`);
41
- if (typeof q.rationale !== 'string')
42
- throw new Error(`${where}.rationale must be a string`);
43
- validated.push({ id: q.id, type: 'validation', statement: q.statement, rationale: q.rationale });
44
- }
45
- else if (q.type === 'choice') {
46
- if (typeof q.question !== 'string')
47
- throw new Error(`${where}.question must be a string`);
48
- if (typeof q.rationale !== 'string')
49
- throw new Error(`${where}.rationale must be a string`);
50
- if (!Array.isArray(q.options))
51
- throw new Error(`${where}.options must be an array`);
52
- if (q.options.length < 2)
53
- throw new Error(`${where}.options must have at least 2 items (got ${q.options.length})`);
54
- const opts = [];
55
- for (let j = 0; j < q.options.length; j++) {
56
- if (typeof q.options[j] !== 'string')
57
- throw new Error(`${where}.options[${j}] must be a string`);
58
- opts.push(q.options[j]);
32
+ if (seen.has(raw.id)) {
33
+ throw new Error(`Duplicate interaction id: ${JSON.stringify(raw.id)}`);
34
+ }
35
+ seen.add(raw.id);
36
+ if (typeof raw.title !== 'string' || raw.title === '') {
37
+ throw new Error(`${where}.title must be a non-empty string`);
38
+ }
39
+ if (!Array.isArray(raw.options)) {
40
+ throw new Error(`${where}.options must be an array`);
41
+ }
42
+ const opts = [];
43
+ for (let j = 0; j < raw.options.length; j++) {
44
+ const o = raw.options[j];
45
+ const owhere = `${where}.options[${j}]`;
46
+ if (typeof o !== 'object' || o === null || Array.isArray(o)) {
47
+ throw new Error(`${owhere} must be an object`);
48
+ }
49
+ if (typeof o.id !== 'string' || o.id === '') {
50
+ throw new Error(`${owhere}.id must be a non-empty string`);
59
51
  }
60
- validated.push({ id: q.id, type: 'choice', question: q.question, rationale: q.rationale, options: opts });
52
+ if (typeof o.label !== 'string') {
53
+ throw new Error(`${owhere}.label must be a string`);
54
+ }
55
+ const opt = { id: o.id, label: o.label };
56
+ if (o.description !== undefined) {
57
+ if (typeof o.description !== 'string')
58
+ throw new Error(`${owhere}.description must be a string`);
59
+ opt.description = o.description;
60
+ }
61
+ if (o.shortcut !== undefined) {
62
+ if (typeof o.shortcut !== 'string')
63
+ throw new Error(`${owhere}.shortcut must be a string`);
64
+ opt.shortcut = o.shortcut;
65
+ }
66
+ opts.push(opt);
67
+ }
68
+ const interaction = { id: raw.id, title: raw.title, options: opts };
69
+ if (raw.subtitle !== undefined) {
70
+ if (typeof raw.subtitle !== 'string')
71
+ throw new Error(`${where}.subtitle must be a string`);
72
+ interaction.subtitle = raw.subtitle;
61
73
  }
62
- else if (q.type === 'freetext') {
63
- if (typeof q.question !== 'string')
64
- throw new Error(`${where}.question must be a string`);
65
- if (typeof q.rationale !== 'string')
66
- throw new Error(`${where}.rationale must be a string`);
67
- validated.push({ id: q.id, type: 'freetext', question: q.question, rationale: q.rationale });
74
+ if (raw.body !== undefined) {
75
+ if (typeof raw.body !== 'string')
76
+ throw new Error(`${where}.body must be a string`);
77
+ interaction.body = raw.body;
68
78
  }
69
- else {
70
- throw new Error(`${where}.type must be "validation" | "choice" | "freetext" (got ${JSON.stringify(q.type)})`);
79
+ if (raw.bodyPath !== undefined) {
80
+ if (typeof raw.bodyPath !== 'string')
81
+ throw new Error(`${where}.bodyPath must be a string`);
82
+ interaction.bodyPath = raw.bodyPath;
71
83
  }
84
+ if (raw.freetextLabel !== undefined) {
85
+ if (typeof raw.freetextLabel !== 'string')
86
+ throw new Error(`${where}.freetextLabel must be a string`);
87
+ interaction.freetextLabel = raw.freetextLabel;
88
+ }
89
+ if (raw.allowFreetext !== undefined) {
90
+ if (typeof raw.allowFreetext !== 'boolean')
91
+ throw new Error(`${where}.allowFreetext must be a boolean`);
92
+ interaction.allowFreetext = raw.allowFreetext;
93
+ }
94
+ if (raw.kind !== undefined) {
95
+ interaction.kind = raw.kind;
96
+ }
97
+ validated.push(interaction);
72
98
  }
73
- return { title: obj.title, questions: validated };
99
+ const deck = { interactions: validated };
100
+ if (obj.title !== undefined)
101
+ deck.title = obj.title;
102
+ return deck;
74
103
  }
75
- export async function launchTui(decisionsPath, sessionId) {
76
- if (!existsSync(decisionsPath)) {
77
- throw new Error(`Decisions file not found: ${decisionsPath}`);
78
- }
79
- const raw = readFileSync(decisionsPath, 'utf8');
80
- const parsed = JSON.parse(raw);
81
- const input = validateInput(parsed);
82
- const state = {
83
- phase: 'overview',
104
+ // ── Internal helpers ──────────────────────────────────────────────────────────
105
+ function buildInitialState(deck) {
106
+ // Single-question decks skip the overview list — there's nothing to overview,
107
+ // and overview hides the option hotkeys so users press 'y' and nothing happens.
108
+ const initialPhase = deck.interactions.length === 1 ? 'item-review' : 'overview';
109
+ return {
110
+ phase: initialPhase,
84
111
  currentIndex: 0,
85
- questions: input.questions,
86
- answers: new Map(),
112
+ interactions: deck.interactions,
113
+ responses: new Map(),
87
114
  visuals: new Map(),
88
115
  inputMode: null,
89
116
  selectedAction: 0,
90
117
  detailExpanded: false,
91
118
  scrollOffset: 0,
92
119
  };
93
- const progressPath = `${decisionsPath}.progress.json`;
94
- state.persist = () => {
95
- const answers = [];
96
- for (const q of input.questions) {
97
- const a = state.answers.get(q.id);
98
- if (a)
99
- answers.push(a);
100
- }
101
- const payload = {
102
- partial: true,
103
- answers,
104
- savedAt: new Date().toISOString(),
105
- };
106
- try {
107
- const tmp = `${progressPath}.tmp`;
108
- writeFileSync(tmp, JSON.stringify(payload, null, 2));
109
- renameSync(tmp, progressPath);
110
- }
111
- catch {
112
- // best-effort — do not crash the TUI if the directory isn't writable
120
+ }
121
+ function collectResponses(state) {
122
+ const out = [];
123
+ for (const interaction of state.interactions) {
124
+ const r = state.responses.get(interaction.id);
125
+ if (r !== undefined)
126
+ out.push(r);
127
+ }
128
+ return out;
129
+ }
130
+ function tryResume(state, progressPath, interactions) {
131
+ try {
132
+ const prior = JSON.parse(readFileSync(progressPath, 'utf8'));
133
+ if (!Array.isArray(prior.responses))
134
+ return;
135
+ const validIds = new Set(interactions.map((i) => i.id));
136
+ for (const r of prior.responses) {
137
+ if (validIds.has(r.id))
138
+ state.responses.set(r.id, r);
113
139
  }
140
+ const firstUnanswered = interactions.findIndex((i) => !state.responses.has(i.id));
141
+ state.currentIndex = firstUnanswered >= 0 ? firstUnanswered : 0;
142
+ }
143
+ catch {
144
+ // corrupt or missing progress file — start fresh
145
+ }
146
+ }
147
+ function atomicWriteProgress(progressPath, responses) {
148
+ const payload = JSON.stringify({ partial: true, responses, savedAt: new Date().toISOString() }, null, 2);
149
+ const tmp = `${progressPath}.tmp`;
150
+ try {
151
+ writeFileSync(tmp, payload);
152
+ renameSync(tmp, progressPath);
153
+ }
154
+ catch {
155
+ // best-effort
156
+ }
157
+ }
158
+ function rebindPersist(internals) {
159
+ internals.state.persist = () => {
160
+ const responses = collectResponses(internals.state);
161
+ if (internals.progressPath !== undefined)
162
+ atomicWriteProgress(internals.progressPath, responses);
163
+ internals.callbacks.onProgress?.(responses);
114
164
  };
115
- if (existsSync(progressPath)) {
116
- try {
117
- const prior = JSON.parse(readFileSync(progressPath, 'utf8'));
118
- const validIds = new Set(input.questions.map((q) => q.id));
119
- for (const a of prior.answers ?? []) {
120
- if (validIds.has(a.id))
121
- state.answers.set(a.id, a);
122
- }
123
- const firstUnanswered = input.questions.findIndex((q) => !state.answers.has(q.id));
124
- state.currentIndex = firstUnanswered >= 0 ? firstUnanswered : 0;
125
- }
126
- catch {
127
- // corrupt progress file — ignore and start fresh
128
- }
165
+ }
166
+ function fireVisuals(internals, interactions) {
167
+ if (internals.generateVisual === undefined)
168
+ return;
169
+ const gen = internals.generateVisual;
170
+ for (const interaction of interactions) {
171
+ internals.state.visuals.set(interaction.id, { questionId: interaction.id, content: '', status: 'loading' });
172
+ gen(interaction).then((r) => {
173
+ if (!internals.mounted)
174
+ return;
175
+ if (!internals.state.interactions.some((x) => x.id === interaction.id))
176
+ return;
177
+ internals.state.visuals.set(interaction.id, r.ok
178
+ ? { questionId: interaction.id, content: r.ansi, status: 'ready' }
179
+ : { questionId: interaction.id, content: '', status: 'error' });
180
+ }).catch(() => {
181
+ if (!internals.mounted)
182
+ return;
183
+ if (!internals.state.interactions.some((x) => x.id === interaction.id))
184
+ return;
185
+ internals.state.visuals.set(interaction.id, { questionId: interaction.id, content: '', status: 'error' });
186
+ });
129
187
  }
130
- // Initialize visuals — 'loading' if we'll generate them, skip otherwise
131
- if (sessionId) {
132
- for (const q of input.questions) {
133
- state.visuals.set(q.id, { questionId: q.id, content: '', status: 'loading' });
134
- }
188
+ }
189
+ export function mountPanel(opts) {
190
+ const internals = {
191
+ state: buildInitialState(opts.deck),
192
+ cols: opts.cols,
193
+ rows: opts.rows,
194
+ mounted: true,
195
+ generateVisual: opts.generateVisual,
196
+ progressPath: opts.progressPath,
197
+ callbacks: { onProgress: opts.onProgress, onComplete: opts.onComplete, onExit: opts.onExit },
198
+ };
199
+ assignShortcuts(internals.state.interactions);
200
+ rebindPersist(internals);
201
+ if (internals.progressPath !== undefined) {
202
+ tryResume(internals.state, internals.progressPath, opts.deck.interactions);
135
203
  }
136
- setupTerminal();
137
- const render = () => {
138
- let lines;
139
- switch (state.phase) {
140
- case 'overview':
141
- lines = renderOverview(state);
142
- break;
143
- case 'item-review':
144
- lines = renderItemReview(state);
145
- break;
146
- case 'final':
147
- lines = renderFinal(state);
148
- break;
149
- }
150
- flush(lines);
204
+ fireVisuals(internals, opts.deck.interactions);
205
+ const renderLines = () => {
206
+ switch (internals.state.phase) {
207
+ case 'overview': return renderOverview(internals.state, internals.cols, internals.rows);
208
+ case 'item-review': return renderItemReview(internals.state, internals.cols, internals.rows);
209
+ case 'final': return renderFinal(internals.state, internals.cols, internals.rows);
210
+ }
151
211
  };
152
- // Initial render
153
- render();
154
- // Fan out haiku visual generation in background
155
- if (sessionId) {
156
- try {
157
- const conversation = readConversation(sessionId);
158
- if (conversation.length > 0) {
159
- const { cols } = getTerminalSize();
160
- const visualWidth = Math.max(40, Math.min(cols - 4, 76));
161
- generateVisuals(input.questions, conversation, (qId, block) => {
162
- state.visuals.set(qId, block);
163
- render();
164
- }, visualWidth).catch((err) => {
165
- process.stderr.write(`Visual generation failed: ${err}\n`);
166
- });
212
+ return {
213
+ handleKey(input, key) {
214
+ if (!internals.mounted)
215
+ return;
216
+ const onAutoComplete = () => {
217
+ const responses = collectResponses(internals.state);
218
+ if (internals.progressPath !== undefined) {
219
+ try {
220
+ unlinkSync(internals.progressPath);
221
+ }
222
+ catch { /* ignore */ }
223
+ }
224
+ internals.callbacks.onComplete?.(responses);
225
+ };
226
+ handleKeypress(input, key, internals.state, () => { }, () => {
227
+ const responses = collectResponses(internals.state);
228
+ if (responses.length >= internals.state.interactions.length) {
229
+ onAutoComplete();
230
+ }
231
+ else {
232
+ internals.callbacks.onExit?.();
233
+ }
234
+ });
235
+ },
236
+ render() {
237
+ if (!internals.mounted)
238
+ return [];
239
+ return renderLines();
240
+ },
241
+ handleResize(cols, rows) {
242
+ internals.cols = cols;
243
+ internals.rows = rows;
244
+ return renderLines();
245
+ },
246
+ unmount() {
247
+ internals.mounted = false;
248
+ internals.state.visuals.clear();
249
+ internals.state.persist = undefined;
250
+ },
251
+ loadDeck(deck, loadOpts) {
252
+ if (!internals.mounted)
253
+ return;
254
+ internals.state = buildInitialState(deck);
255
+ if (loadOpts !== undefined && loadOpts.progressPath !== undefined) {
256
+ internals.progressPath = loadOpts.progressPath;
167
257
  }
168
- }
169
- catch (err) {
170
- for (const q of input.questions) {
171
- state.visuals.set(q.id, { questionId: q.id, content: '', status: 'error' });
258
+ assignShortcuts(internals.state.interactions);
259
+ rebindPersist(internals);
260
+ if (internals.progressPath !== undefined) {
261
+ tryResume(internals.state, internals.progressPath, deck.interactions);
172
262
  }
263
+ fireVisuals(internals, deck.interactions);
264
+ },
265
+ canAcceptHostKeys() {
266
+ if (!internals.mounted)
267
+ return false;
268
+ return internals.state.inputMode === null;
269
+ },
270
+ };
271
+ }
272
+ // ── launchTui shim ────────────────────────────────────────────────────────────
273
+ export async function launchTui(decisionsPath, sessionId) {
274
+ if (!existsSync(decisionsPath)) {
275
+ throw new Error(`Decisions file not found: ${decisionsPath}`);
276
+ }
277
+ const raw = readFileSync(decisionsPath, 'utf8');
278
+ const deck = validateInput(JSON.parse(raw));
279
+ let conversationContext = '';
280
+ if (sessionId !== undefined) {
281
+ try {
282
+ const conv = readConversation(sessionId);
283
+ conversationContext = conv.map((m) => `${m.role}: ${m.content}`).join('\n\n');
284
+ }
285
+ catch {
286
+ // empty context — proceed without visuals context
173
287
  }
174
288
  }
289
+ setupTerminal();
290
+ const { cols, rows } = getTerminalSize();
175
291
  return new Promise((resolve) => {
176
- const exit = () => {
292
+ let panel = null;
293
+ let prevFrameLocal = [];
294
+ let lastResponses = [];
295
+ let onData;
296
+ const flushHost = (lines) => {
297
+ const { rows: currentRows } = getTerminalSize();
298
+ const { writes, nextPrevFrame } = diffFrame(prevFrameLocal, lines, currentRows);
299
+ process.stdout.write('\x1b[?2026h');
300
+ for (const w of writes)
301
+ process.stdout.write(w);
302
+ process.stdout.write('\x1b[?2026l');
303
+ prevFrameLocal = nextPrevFrame;
304
+ };
305
+ const onComplete = (responses) => {
177
306
  restoreTerminal();
178
307
  process.stdin.removeListener('data', onData);
179
- const answers = [];
180
- for (const q of input.questions) {
181
- const a = state.answers.get(q.id);
182
- if (a)
183
- answers.push(a);
184
- }
185
- if (answers.length >= input.questions.length) {
186
- try {
187
- unlinkSync(progressPath);
188
- }
189
- catch { /* ignore */ }
190
- }
191
- resolve({
192
- answers,
193
- completedAt: new Date().toISOString(),
194
- });
308
+ panel?.unmount();
309
+ resolve({ responses, completedAt: new Date().toISOString() });
195
310
  };
196
- const onData = (data) => {
311
+ panel = mountPanel({
312
+ deck,
313
+ progressPath: `${decisionsPath}.progress.json`,
314
+ cols,
315
+ rows,
316
+ generateVisual: sessionId !== undefined
317
+ ? (interaction) => defaultGenerateVisual(interaction, conversationContext)
318
+ : undefined,
319
+ onProgress: (responses) => {
320
+ lastResponses = responses;
321
+ if (panel !== null)
322
+ flushHost(panel.render());
323
+ },
324
+ onComplete,
325
+ onExit: () => {
326
+ onComplete(lastResponses);
327
+ },
328
+ });
329
+ flushHost(panel.render());
330
+ onData = (data) => {
197
331
  const { input: inp, key } = parseKeypress(data);
198
- handleKeypress(inp, key, state, render, exit);
332
+ panel.handleKey(inp, key);
333
+ flushHost(panel.render());
199
334
  };
200
335
  process.stdin.on('data', onData);
201
336
  });
@@ -1,5 +1,6 @@
1
- import type { TuiState } from '../types.js';
1
+ import type { TuiState, Interaction } from '../types.js';
2
2
  import type { Key } from './terminal.js';
3
3
  export type RenderFn = () => void;
4
4
  export type ExitFn = () => void;
5
+ export declare function assignShortcuts(interactions: Interaction[]): void;
5
6
  export declare function handleKeypress(input: string, key: Key, state: TuiState, render: RenderFn, exit: ExitFn): void;