@crouton-kit/humanloop 0.1.2 → 0.1.3

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,333 @@
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 = {
104
+ // ── Internal helpers ──────────────────────────────────────────────────────────
105
+ function buildInitialState(deck) {
106
+ return {
83
107
  phase: 'overview',
84
108
  currentIndex: 0,
85
- questions: input.questions,
86
- answers: new Map(),
109
+ interactions: deck.interactions,
110
+ responses: new Map(),
87
111
  visuals: new Map(),
88
112
  inputMode: null,
89
113
  selectedAction: 0,
90
114
  detailExpanded: false,
91
115
  scrollOffset: 0,
92
116
  };
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
117
+ }
118
+ function collectResponses(state) {
119
+ const out = [];
120
+ for (const interaction of state.interactions) {
121
+ const r = state.responses.get(interaction.id);
122
+ if (r !== undefined)
123
+ out.push(r);
124
+ }
125
+ return out;
126
+ }
127
+ function tryResume(state, progressPath, interactions) {
128
+ try {
129
+ const prior = JSON.parse(readFileSync(progressPath, 'utf8'));
130
+ if (!Array.isArray(prior.responses))
131
+ return;
132
+ const validIds = new Set(interactions.map((i) => i.id));
133
+ for (const r of prior.responses) {
134
+ if (validIds.has(r.id))
135
+ state.responses.set(r.id, r);
113
136
  }
137
+ const firstUnanswered = interactions.findIndex((i) => !state.responses.has(i.id));
138
+ state.currentIndex = firstUnanswered >= 0 ? firstUnanswered : 0;
139
+ }
140
+ catch {
141
+ // corrupt or missing progress file — start fresh
142
+ }
143
+ }
144
+ function atomicWriteProgress(progressPath, responses) {
145
+ const payload = JSON.stringify({ partial: true, responses, savedAt: new Date().toISOString() }, null, 2);
146
+ const tmp = `${progressPath}.tmp`;
147
+ try {
148
+ writeFileSync(tmp, payload);
149
+ renameSync(tmp, progressPath);
150
+ }
151
+ catch {
152
+ // best-effort
153
+ }
154
+ }
155
+ function rebindPersist(internals) {
156
+ internals.state.persist = () => {
157
+ const responses = collectResponses(internals.state);
158
+ if (internals.progressPath !== undefined)
159
+ atomicWriteProgress(internals.progressPath, responses);
160
+ internals.callbacks.onProgress?.(responses);
114
161
  };
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
- }
162
+ }
163
+ function fireVisuals(internals, interactions) {
164
+ if (internals.generateVisual === undefined)
165
+ return;
166
+ const gen = internals.generateVisual;
167
+ for (const interaction of interactions) {
168
+ internals.state.visuals.set(interaction.id, { questionId: interaction.id, content: '', status: 'loading' });
169
+ gen(interaction).then((r) => {
170
+ if (!internals.mounted)
171
+ return;
172
+ if (!internals.state.interactions.some((x) => x.id === interaction.id))
173
+ return;
174
+ internals.state.visuals.set(interaction.id, r.ok
175
+ ? { questionId: interaction.id, content: r.ansi, status: 'ready' }
176
+ : { questionId: interaction.id, content: '', status: 'error' });
177
+ }).catch(() => {
178
+ if (!internals.mounted)
179
+ return;
180
+ if (!internals.state.interactions.some((x) => x.id === interaction.id))
181
+ return;
182
+ internals.state.visuals.set(interaction.id, { questionId: interaction.id, content: '', status: 'error' });
183
+ });
129
184
  }
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
- }
185
+ }
186
+ export function mountPanel(opts) {
187
+ const internals = {
188
+ state: buildInitialState(opts.deck),
189
+ cols: opts.cols,
190
+ rows: opts.rows,
191
+ mounted: true,
192
+ generateVisual: opts.generateVisual,
193
+ progressPath: opts.progressPath,
194
+ callbacks: { onProgress: opts.onProgress, onComplete: opts.onComplete, onExit: opts.onExit },
195
+ };
196
+ assignShortcuts(internals.state.interactions);
197
+ rebindPersist(internals);
198
+ if (internals.progressPath !== undefined) {
199
+ tryResume(internals.state, internals.progressPath, opts.deck.interactions);
135
200
  }
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);
201
+ fireVisuals(internals, opts.deck.interactions);
202
+ const renderLines = () => {
203
+ switch (internals.state.phase) {
204
+ case 'overview': return renderOverview(internals.state, internals.cols, internals.rows);
205
+ case 'item-review': return renderItemReview(internals.state, internals.cols, internals.rows);
206
+ case 'final': return renderFinal(internals.state, internals.cols, internals.rows);
207
+ }
151
208
  };
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
- });
209
+ return {
210
+ handleKey(input, key) {
211
+ if (!internals.mounted)
212
+ return;
213
+ const onAutoComplete = () => {
214
+ const responses = collectResponses(internals.state);
215
+ if (internals.progressPath !== undefined) {
216
+ try {
217
+ unlinkSync(internals.progressPath);
218
+ }
219
+ catch { /* ignore */ }
220
+ }
221
+ internals.callbacks.onComplete?.(responses);
222
+ };
223
+ handleKeypress(input, key, internals.state, () => { }, () => {
224
+ const responses = collectResponses(internals.state);
225
+ if (responses.length >= internals.state.interactions.length) {
226
+ onAutoComplete();
227
+ }
228
+ else {
229
+ internals.callbacks.onExit?.();
230
+ }
231
+ });
232
+ },
233
+ render() {
234
+ if (!internals.mounted)
235
+ return [];
236
+ return renderLines();
237
+ },
238
+ handleResize(cols, rows) {
239
+ internals.cols = cols;
240
+ internals.rows = rows;
241
+ return renderLines();
242
+ },
243
+ unmount() {
244
+ internals.mounted = false;
245
+ internals.state.visuals.clear();
246
+ internals.state.persist = undefined;
247
+ },
248
+ loadDeck(deck, loadOpts) {
249
+ if (!internals.mounted)
250
+ return;
251
+ internals.state = buildInitialState(deck);
252
+ if (loadOpts !== undefined && loadOpts.progressPath !== undefined) {
253
+ internals.progressPath = loadOpts.progressPath;
167
254
  }
168
- }
169
- catch (err) {
170
- for (const q of input.questions) {
171
- state.visuals.set(q.id, { questionId: q.id, content: '', status: 'error' });
255
+ assignShortcuts(internals.state.interactions);
256
+ rebindPersist(internals);
257
+ if (internals.progressPath !== undefined) {
258
+ tryResume(internals.state, internals.progressPath, deck.interactions);
172
259
  }
260
+ fireVisuals(internals, deck.interactions);
261
+ },
262
+ canAcceptHostKeys() {
263
+ if (!internals.mounted)
264
+ return false;
265
+ return internals.state.inputMode === null;
266
+ },
267
+ };
268
+ }
269
+ // ── launchTui shim ────────────────────────────────────────────────────────────
270
+ export async function launchTui(decisionsPath, sessionId) {
271
+ if (!existsSync(decisionsPath)) {
272
+ throw new Error(`Decisions file not found: ${decisionsPath}`);
273
+ }
274
+ const raw = readFileSync(decisionsPath, 'utf8');
275
+ const deck = validateInput(JSON.parse(raw));
276
+ let conversationContext = '';
277
+ if (sessionId !== undefined) {
278
+ try {
279
+ const conv = readConversation(sessionId);
280
+ conversationContext = conv.map((m) => `${m.role}: ${m.content}`).join('\n\n');
281
+ }
282
+ catch {
283
+ // empty context — proceed without visuals context
173
284
  }
174
285
  }
286
+ setupTerminal();
287
+ const { cols, rows } = getTerminalSize();
175
288
  return new Promise((resolve) => {
176
- const exit = () => {
289
+ let panel = null;
290
+ let prevFrameLocal = [];
291
+ let lastResponses = [];
292
+ let onData;
293
+ const flushHost = (lines) => {
294
+ const { rows: currentRows } = getTerminalSize();
295
+ const { writes, nextPrevFrame } = diffFrame(prevFrameLocal, lines, currentRows);
296
+ process.stdout.write('\x1b[?2026h');
297
+ for (const w of writes)
298
+ process.stdout.write(w);
299
+ process.stdout.write('\x1b[?2026l');
300
+ prevFrameLocal = nextPrevFrame;
301
+ };
302
+ const onComplete = (responses) => {
177
303
  restoreTerminal();
178
304
  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
- });
305
+ panel?.unmount();
306
+ resolve({ responses, completedAt: new Date().toISOString() });
195
307
  };
196
- const onData = (data) => {
308
+ panel = mountPanel({
309
+ deck,
310
+ progressPath: `${decisionsPath}.progress.json`,
311
+ cols,
312
+ rows,
313
+ generateVisual: sessionId !== undefined
314
+ ? (interaction) => defaultGenerateVisual(interaction, conversationContext)
315
+ : undefined,
316
+ onProgress: (responses) => {
317
+ lastResponses = responses;
318
+ if (panel !== null)
319
+ flushHost(panel.render());
320
+ },
321
+ onComplete,
322
+ onExit: () => {
323
+ onComplete(lastResponses);
324
+ },
325
+ });
326
+ flushHost(panel.render());
327
+ onData = (data) => {
197
328
  const { input: inp, key } = parseKeypress(data);
198
- handleKeypress(inp, key, state, render, exit);
329
+ panel.handleKey(inp, key);
330
+ flushHost(panel.render());
199
331
  };
200
332
  process.stdin.on('data', onData);
201
333
  });
@@ -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;