@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/cli.js +81 -91
- package/dist/index.d.ts +5 -0
- package/dist/index.js +2 -0
- package/dist/tui/app.d.ts +7 -3
- package/dist/tui/app.js +288 -153
- package/dist/tui/input.d.ts +2 -1
- package/dist/tui/input.js +142 -123
- package/dist/tui/render.d.ts +9 -5
- package/dist/tui/render.js +238 -153
- package/dist/tui/tmux.d.ts +6 -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/input.js
CHANGED
|
@@ -1,3 +1,34 @@
|
|
|
1
|
+
const RESERVED = new Set(['c', 'r', 'n', 'p', 'q', 'j', 'k', 'u', 'd', ' ']);
|
|
2
|
+
export function assignShortcuts(interactions) {
|
|
3
|
+
for (const it of interactions) {
|
|
4
|
+
const used = new Set(it.options.map((o) => o.shortcut).filter((s) => s !== undefined));
|
|
5
|
+
for (const opt of it.options) {
|
|
6
|
+
if (opt.shortcut !== undefined)
|
|
7
|
+
continue;
|
|
8
|
+
const letters = [...opt.label.toLowerCase()].filter((c) => /[a-z]/.test(c));
|
|
9
|
+
let chosen;
|
|
10
|
+
for (const letter of letters) {
|
|
11
|
+
if (!used.has(letter) && !RESERVED.has(letter)) {
|
|
12
|
+
chosen = letter;
|
|
13
|
+
break;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
if (chosen === undefined) {
|
|
17
|
+
for (let d = 1; d <= 9; d++) {
|
|
18
|
+
const s = String(d);
|
|
19
|
+
if (!used.has(s)) {
|
|
20
|
+
chosen = s;
|
|
21
|
+
break;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (chosen !== undefined) {
|
|
26
|
+
opt.shortcut = chosen;
|
|
27
|
+
used.add(chosen);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
1
32
|
export function handleKeypress(input, key, state, render, exit) {
|
|
2
33
|
if (key.ctrl && input === 'c') {
|
|
3
34
|
exit();
|
|
@@ -22,40 +53,55 @@ export function handleKeypress(input, key, state, render, exit) {
|
|
|
22
53
|
}
|
|
23
54
|
}
|
|
24
55
|
function checkAutoExit(state, exit) {
|
|
25
|
-
if (state.phase === 'final' && state.
|
|
56
|
+
if (state.phase === 'final' && state.responses.size >= state.interactions.length) {
|
|
26
57
|
exit();
|
|
27
58
|
}
|
|
28
59
|
}
|
|
29
60
|
// ── Overview ─────────────────────────────────────────────────────────────────
|
|
30
61
|
function handleOverview(input, key, state, render, exit) {
|
|
31
62
|
if (input === 'j' || key.downArrow) {
|
|
32
|
-
state.currentIndex = Math.min(state.currentIndex + 1, state.
|
|
63
|
+
state.currentIndex = Math.min(state.currentIndex + 1, state.interactions.length - 1);
|
|
33
64
|
render();
|
|
65
|
+
return;
|
|
34
66
|
}
|
|
35
|
-
|
|
67
|
+
if (input === 'k' || key.upArrow) {
|
|
36
68
|
state.currentIndex = Math.max(state.currentIndex - 1, 0);
|
|
37
69
|
render();
|
|
70
|
+
return;
|
|
38
71
|
}
|
|
39
|
-
|
|
72
|
+
if (key.return || input === ' ') {
|
|
40
73
|
state.phase = 'item-review';
|
|
41
74
|
state.selectedAction = 0;
|
|
42
75
|
state.detailExpanded = false;
|
|
43
76
|
render();
|
|
77
|
+
return;
|
|
44
78
|
}
|
|
45
|
-
|
|
46
|
-
if (state.
|
|
79
|
+
if (input === 'q') {
|
|
80
|
+
if (state.responses.size >= state.interactions.length) {
|
|
47
81
|
exit();
|
|
48
82
|
}
|
|
49
83
|
else {
|
|
50
84
|
state.phase = 'final';
|
|
51
85
|
render();
|
|
52
86
|
}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
// Quick-answer: option shortcut for the focused interaction. Lets users
|
|
90
|
+
// answer from the overview list without pressing Enter first.
|
|
91
|
+
const interaction = state.interactions[state.currentIndex];
|
|
92
|
+
if (interaction !== undefined) {
|
|
93
|
+
const matched = interaction.options.find((o) => o.shortcut === input);
|
|
94
|
+
if (matched !== undefined) {
|
|
95
|
+
submitOption(state, interaction, matched.id, undefined);
|
|
96
|
+
// Don't auto-advance the cursor — users may want to re-answer the same
|
|
97
|
+
// question. The response icon flips ✓ and they can j/k away when ready.
|
|
98
|
+
render();
|
|
99
|
+
}
|
|
53
100
|
}
|
|
54
101
|
}
|
|
55
102
|
// ── Item Review ──────────────────────────────────────────────────────────────
|
|
56
103
|
function handleItemReview(input, key, state, render) {
|
|
57
|
-
const
|
|
58
|
-
// Navigation
|
|
104
|
+
const interaction = state.interactions[state.currentIndex];
|
|
59
105
|
if (input === 'n') {
|
|
60
106
|
advanceItem(state, 1);
|
|
61
107
|
render();
|
|
@@ -76,9 +122,22 @@ function handleItemReview(input, key, state, render) {
|
|
|
76
122
|
render();
|
|
77
123
|
return;
|
|
78
124
|
}
|
|
79
|
-
//
|
|
125
|
+
// Body scroll: u/d or Ctrl+D / Ctrl+U (half-page), Ctrl+E / Ctrl+Y (line).
|
|
126
|
+
// Plain u/d exists because tmux configs commonly bind C-d/C-u for pane scroll
|
|
127
|
+
// and intercept them before they reach the app. Render clamps state.scrollOffset,
|
|
128
|
+
// so over-scroll past the bottom is harmless.
|
|
129
|
+
if (input === 'd' || (key.ctrl && (input === 'd' || input === 'e'))) {
|
|
130
|
+
state.scrollOffset = (state.scrollOffset ?? 0) + (input === 'e' ? 1 : 10);
|
|
131
|
+
render();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (input === 'u' || (key.ctrl && (input === 'u' || input === 'y'))) {
|
|
135
|
+
state.scrollOffset = Math.max(0, (state.scrollOffset ?? 0) - (input === 'y' ? 1 : 10));
|
|
136
|
+
render();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
80
139
|
if (input === 'j' || key.downArrow) {
|
|
81
|
-
const max = actionCount(
|
|
140
|
+
const max = actionCount(interaction) - 1;
|
|
82
141
|
state.selectedAction = Math.min(state.selectedAction + 1, max);
|
|
83
142
|
render();
|
|
84
143
|
return;
|
|
@@ -88,82 +147,55 @@ function handleItemReview(input, key, state, render) {
|
|
|
88
147
|
render();
|
|
89
148
|
return;
|
|
90
149
|
}
|
|
91
|
-
|
|
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
|
-
}
|
|
150
|
+
handleInteractionAction(input, key, state, interaction, render);
|
|
101
151
|
}
|
|
102
|
-
function
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
152
|
+
function handleInteractionAction(input, key, state, interaction, render) {
|
|
153
|
+
const opts = interaction.options;
|
|
154
|
+
// Match by shortcut
|
|
155
|
+
const matched = opts.find((o) => o.shortcut === input);
|
|
156
|
+
if (matched !== undefined) {
|
|
157
|
+
submitOption(state, interaction, matched.id, undefined);
|
|
106
158
|
advanceItem(state, 1);
|
|
107
159
|
render();
|
|
160
|
+
return;
|
|
108
161
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
state.
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
advanceItem(state, 1);
|
|
162
|
+
// Comment mode: allowFreetext + options exist
|
|
163
|
+
// If the cursor is on an option row, pre-attach that option to the comment.
|
|
164
|
+
if (input === 'c' && interaction.allowFreetext && opts.length > 0) {
|
|
165
|
+
const preselected = state.selectedAction < opts.length
|
|
166
|
+
? opts[state.selectedAction].id
|
|
167
|
+
: undefined;
|
|
168
|
+
state.inputMode = preselected !== undefined
|
|
169
|
+
? { kind: 'comment', buffer: '', selectedOptionId: preselected }
|
|
170
|
+
: { kind: 'comment', buffer: '' };
|
|
119
171
|
render();
|
|
172
|
+
return;
|
|
120
173
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
174
|
+
// Freetext-only: 'r' or enter opens input mode
|
|
175
|
+
if (interaction.allowFreetext && opts.length === 0) {
|
|
176
|
+
if (input === 'r' || key.return) {
|
|
177
|
+
const existing = state.responses.get(interaction.id);
|
|
178
|
+
const prefill = existing !== undefined && existing.freetext !== undefined ? existing.freetext : '';
|
|
179
|
+
state.inputMode = { kind: 'freetext', buffer: prefill };
|
|
180
|
+
render();
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
126
183
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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?.();
|
|
184
|
+
// Enter on selected option row
|
|
185
|
+
if (key.return && state.selectedAction < opts.length) {
|
|
186
|
+
const o = opts[state.selectedAction];
|
|
187
|
+
submitOption(state, interaction, o.id, undefined);
|
|
139
188
|
advanceItem(state, 1);
|
|
140
189
|
render();
|
|
141
190
|
return;
|
|
142
191
|
}
|
|
143
|
-
|
|
144
|
-
|
|
192
|
+
// Enter on the [c] row (allowFreetext + options exist)
|
|
193
|
+
if (key.return && state.selectedAction === opts.length
|
|
194
|
+
&& interaction.allowFreetext && opts.length > 0) {
|
|
195
|
+
state.inputMode = { kind: 'comment', buffer: '' };
|
|
145
196
|
render();
|
|
146
197
|
return;
|
|
147
198
|
}
|
|
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
199
|
}
|
|
168
200
|
// ── Input Mode ───────────────────────────────────────────────────────────────
|
|
169
201
|
function handleInputMode(input, key, state, render) {
|
|
@@ -173,68 +205,49 @@ function handleInputMode(input, key, state, render) {
|
|
|
173
205
|
render();
|
|
174
206
|
return;
|
|
175
207
|
}
|
|
208
|
+
// Tab cycles attached option in comment mode: (none) → opt1 → opt2 → ... → (none)
|
|
209
|
+
if (key.tab && mode.kind === 'comment') {
|
|
210
|
+
const interaction = state.interactions[state.currentIndex];
|
|
211
|
+
const opts = interaction.options;
|
|
212
|
+
if (opts.length > 0) {
|
|
213
|
+
const cur = mode.selectedOptionId;
|
|
214
|
+
const curIdx = cur === undefined ? -1 : opts.findIndex((o) => o.id === cur);
|
|
215
|
+
const nextIdx = curIdx + 1; // -1 → 0, last → length (which we map to none)
|
|
216
|
+
if (nextIdx >= opts.length) {
|
|
217
|
+
delete mode.selectedOptionId;
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
mode.selectedOptionId = opts[nextIdx].id;
|
|
221
|
+
}
|
|
222
|
+
render();
|
|
223
|
+
}
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
176
226
|
if (key.return) {
|
|
177
|
-
|
|
227
|
+
const interaction = state.interactions[state.currentIndex];
|
|
228
|
+
const attached = mode.kind === 'comment' ? mode.selectedOptionId : undefined;
|
|
229
|
+
submitOption(state, interaction, attached, mode.buffer);
|
|
178
230
|
state.inputMode = null;
|
|
179
231
|
advanceItem(state, 1);
|
|
180
232
|
render();
|
|
181
233
|
return;
|
|
182
234
|
}
|
|
183
235
|
if (key.backspace) {
|
|
184
|
-
// Drop the last *codepoint*, not the last UTF-16 code unit, so backspace
|
|
185
|
-
// on an emoji removes the whole glyph instead of leaving a lone surrogate.
|
|
186
236
|
const chars = [...mode.buffer];
|
|
187
237
|
chars.pop();
|
|
188
238
|
mode.buffer = chars.join('');
|
|
189
239
|
render();
|
|
190
240
|
return;
|
|
191
241
|
}
|
|
192
|
-
// Accept any printable input — including pasted multi-char chunks and
|
|
193
|
-
// multi-byte UTF-8 (emoji / CJK). Strip control bytes (ESC sequences,
|
|
194
|
-
// bracketed-paste markers, BEL, BS, CR) so they can't corrupt the TUI.
|
|
195
242
|
const cleaned = input
|
|
196
|
-
.replace(/\x1b\[20[01]~/g, '')
|
|
197
|
-
.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')
|
|
198
|
-
.replace(/[\x00-\x1F\x7F]/g, '');
|
|
243
|
+
.replace(/\x1b\[20[01]~/g, '')
|
|
244
|
+
.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')
|
|
245
|
+
.replace(/[\x00-\x1F\x7F]/g, '');
|
|
199
246
|
if (cleaned.length > 0) {
|
|
200
247
|
mode.buffer += cleaned;
|
|
201
248
|
render();
|
|
202
249
|
}
|
|
203
250
|
}
|
|
204
|
-
function commitInput(state) {
|
|
205
|
-
const q = state.questions[state.currentIndex];
|
|
206
|
-
const mode = state.inputMode;
|
|
207
|
-
if (mode.kind === 'comment') {
|
|
208
|
-
const existing = state.answers.get(q.id);
|
|
209
|
-
const approved = existing ? existing.approved : false;
|
|
210
|
-
state.answers.set(q.id, {
|
|
211
|
-
id: q.id,
|
|
212
|
-
type: 'validation',
|
|
213
|
-
approved,
|
|
214
|
-
comment: mode.buffer || undefined,
|
|
215
|
-
});
|
|
216
|
-
}
|
|
217
|
-
else if (mode.kind === 'custom-option') {
|
|
218
|
-
if (mode.buffer) {
|
|
219
|
-
state.answers.set(q.id, {
|
|
220
|
-
id: q.id,
|
|
221
|
-
type: 'choice',
|
|
222
|
-
selected: mode.buffer,
|
|
223
|
-
isCustom: true,
|
|
224
|
-
});
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
else if (mode.kind === 'freetext') {
|
|
228
|
-
if (mode.buffer) {
|
|
229
|
-
state.answers.set(q.id, {
|
|
230
|
-
id: q.id,
|
|
231
|
-
type: 'freetext',
|
|
232
|
-
response: mode.buffer,
|
|
233
|
-
});
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
state.persist?.();
|
|
237
|
-
}
|
|
238
251
|
// ── Final ────────────────────────────────────────────────────────────────────
|
|
239
252
|
function handleFinal(input, key, state, render, exit) {
|
|
240
253
|
if (key.return) {
|
|
@@ -242,7 +255,7 @@ function handleFinal(input, key, state, render, exit) {
|
|
|
242
255
|
}
|
|
243
256
|
else if (input === 'p') {
|
|
244
257
|
state.phase = 'item-review';
|
|
245
|
-
state.currentIndex = state.
|
|
258
|
+
state.currentIndex = state.interactions.length - 1;
|
|
246
259
|
render();
|
|
247
260
|
}
|
|
248
261
|
}
|
|
@@ -251,18 +264,24 @@ function advanceItem(state, direction) {
|
|
|
251
264
|
const next = state.currentIndex + direction;
|
|
252
265
|
if (next < 0)
|
|
253
266
|
return;
|
|
254
|
-
if (next >= state.
|
|
267
|
+
if (next >= state.interactions.length) {
|
|
255
268
|
state.phase = 'final';
|
|
256
269
|
return;
|
|
257
270
|
}
|
|
258
271
|
state.currentIndex = next;
|
|
259
272
|
state.selectedAction = 0;
|
|
260
273
|
state.detailExpanded = false;
|
|
274
|
+
state.scrollOffset = 0;
|
|
261
275
|
}
|
|
262
|
-
function actionCount(
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
276
|
+
function actionCount(interaction) {
|
|
277
|
+
return interaction.options.length + (interaction.allowFreetext && interaction.options.length > 0 ? 1 : 0);
|
|
278
|
+
}
|
|
279
|
+
function submitOption(state, interaction, selectedOptionId, freetext) {
|
|
280
|
+
const response = { id: interaction.id };
|
|
281
|
+
if (selectedOptionId !== undefined)
|
|
282
|
+
response.selectedOptionId = selectedOptionId;
|
|
283
|
+
if (freetext !== undefined)
|
|
284
|
+
response.freetext = freetext;
|
|
285
|
+
state.responses.set(interaction.id, response);
|
|
286
|
+
state.persist?.();
|
|
268
287
|
}
|
package/dist/tui/render.d.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
import type { TuiState } from '../types.js';
|
|
1
|
+
import type { TuiState, Interaction, InteractionResponse } from '../types.js';
|
|
2
2
|
export declare function sanitize(text: string): string;
|
|
3
|
-
export declare function
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
export declare function diffFrame(prevFrame: string[], nextLines: string[], rows: number): {
|
|
4
|
+
writes: string[];
|
|
5
|
+
nextPrevFrame: string[];
|
|
6
|
+
};
|
|
7
|
+
export declare function renderOverview(state: TuiState, cols: number, rows: number): string[];
|
|
8
|
+
export declare function renderItemReview(state: TuiState, cols: number, rows: number): string[];
|
|
9
|
+
export declare function renderFinal(state: TuiState, cols: number, rows: number): string[];
|
|
10
|
+
export declare function responseSummary(r: InteractionResponse, interaction: Interaction): string;
|