@crouton-kit/humanloop 0.1.0 → 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,135 +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
- export async function launchTui(decisionsPath, sessionId) {
8
- if (!existsSync(decisionsPath)) {
9
- throw new Error(`Decisions file not found: ${decisionsPath}`);
6
+ import { defaultGenerateVisual } from '../visuals/generate.js';
7
+ export function validateInput(parsed) {
8
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
9
+ throw new Error('Deck file must be a JSON object with an `interactions` array');
10
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');
11
+ const obj = parsed;
12
+ if (!Array.isArray(obj.interactions)) {
13
+ throw new Error('`interactions` must be an array');
14
+ }
15
+ if (obj.interactions.length === 0) {
16
+ throw new Error('No interactions in deck file');
17
+ }
18
+ if (obj.title !== undefined && typeof obj.title !== 'string') {
19
+ throw new Error('`title` must be a string when present');
20
+ }
21
+ const seen = new Set();
22
+ const validated = [];
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)) {
27
+ throw new Error(`${where} must be an object`);
28
+ }
29
+ if (typeof raw.id !== 'string' || raw.id === '') {
30
+ throw new Error(`${where}.id must be a non-empty string`);
31
+ }
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`);
51
+ }
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;
73
+ }
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;
78
+ }
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;
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);
15
98
  }
16
- const state = {
99
+ const deck = { interactions: validated };
100
+ if (obj.title !== undefined)
101
+ deck.title = obj.title;
102
+ return deck;
103
+ }
104
+ // ── Internal helpers ──────────────────────────────────────────────────────────
105
+ function buildInitialState(deck) {
106
+ return {
17
107
  phase: 'overview',
18
108
  currentIndex: 0,
19
- questions: input.questions,
20
- answers: new Map(),
109
+ interactions: deck.interactions,
110
+ responses: new Map(),
21
111
  visuals: new Map(),
22
112
  inputMode: null,
23
113
  selectedAction: 0,
24
114
  detailExpanded: false,
25
115
  scrollOffset: 0,
26
116
  };
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
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);
47
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);
48
161
  };
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
- }
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
+ });
63
184
  }
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
- }
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);
69
200
  }
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);
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
+ }
85
208
  };
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
- });
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;
101
254
  }
102
- }
103
- catch (err) {
104
- for (const q of input.questions) {
105
- 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);
106
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
107
284
  }
108
285
  }
286
+ setupTerminal();
287
+ const { cols, rows } = getTerminalSize();
109
288
  return new Promise((resolve) => {
110
- 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) => {
111
303
  restoreTerminal();
112
304
  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
- });
305
+ panel?.unmount();
306
+ resolve({ responses, completedAt: new Date().toISOString() });
129
307
  };
130
- 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) => {
131
328
  const { input: inp, key } = parseKeypress(data);
132
- handleKeypress(inp, key, state, render, exit);
329
+ panel.handleKey(inp, key);
330
+ flushHost(panel.render());
133
331
  };
134
332
  process.stdin.on('data', onData);
135
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;