@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/cli.js +82 -89
- package/dist/index.d.ts +5 -0
- package/dist/index.js +2 -0
- package/dist/tui/app.d.ts +7 -2
- package/dist/tui/app.js +303 -105
- package/dist/tui/input.d.ts +2 -1
- package/dist/tui/input.js +129 -115
- package/dist/tui/render.d.ts +10 -5
- package/dist/tui/render.js +329 -157
- package/dist/tui/terminal.js +5 -0
- package/dist/tui/tmux.d.ts +6 -2
- package/dist/tui/tmux.js +20 -2
- package/dist/types.d.ts +57 -45
- package/dist/types.js +1 -1
- package/dist/visuals/generate.d.ts +9 -4
- package/dist/visuals/generate.js +30 -39
- package/package.json +14 -2
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 {
|
|
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 {
|
|
7
|
-
export
|
|
8
|
-
if (
|
|
9
|
-
throw new Error(
|
|
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
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
case '
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
329
|
+
panel.handleKey(inp, key);
|
|
330
|
+
flushHost(panel.render());
|
|
133
331
|
};
|
|
134
332
|
process.stdin.on('data', onData);
|
|
135
333
|
});
|
package/dist/tui/input.d.ts
CHANGED
|
@@ -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;
|