@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/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 +284 -152
- package/dist/tui/input.d.ts +2 -1
- package/dist/tui/input.js +123 -120
- package/dist/tui/render.d.ts +9 -5
- package/dist/tui/render.js +199 -149
- 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,14 +53,14 @@ 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();
|
|
34
65
|
}
|
|
35
66
|
else if (input === 'k' || key.upArrow) {
|
|
@@ -43,7 +74,7 @@ function handleOverview(input, key, state, render, exit) {
|
|
|
43
74
|
render();
|
|
44
75
|
}
|
|
45
76
|
else if (input === 'q') {
|
|
46
|
-
if (state.
|
|
77
|
+
if (state.responses.size >= state.interactions.length) {
|
|
47
78
|
exit();
|
|
48
79
|
}
|
|
49
80
|
else {
|
|
@@ -54,8 +85,7 @@ function handleOverview(input, key, state, render, exit) {
|
|
|
54
85
|
}
|
|
55
86
|
// ── Item Review ──────────────────────────────────────────────────────────────
|
|
56
87
|
function handleItemReview(input, key, state, render) {
|
|
57
|
-
const
|
|
58
|
-
// Navigation
|
|
88
|
+
const interaction = state.interactions[state.currentIndex];
|
|
59
89
|
if (input === 'n') {
|
|
60
90
|
advanceItem(state, 1);
|
|
61
91
|
render();
|
|
@@ -76,9 +106,22 @@ function handleItemReview(input, key, state, render) {
|
|
|
76
106
|
render();
|
|
77
107
|
return;
|
|
78
108
|
}
|
|
79
|
-
//
|
|
109
|
+
// Body scroll: u/d or Ctrl+D / Ctrl+U (half-page), Ctrl+E / Ctrl+Y (line).
|
|
110
|
+
// Plain u/d exists because tmux configs commonly bind C-d/C-u for pane scroll
|
|
111
|
+
// and intercept them before they reach the app. Render clamps state.scrollOffset,
|
|
112
|
+
// so over-scroll past the bottom is harmless.
|
|
113
|
+
if (input === 'd' || (key.ctrl && (input === 'd' || input === 'e'))) {
|
|
114
|
+
state.scrollOffset = (state.scrollOffset ?? 0) + (input === 'e' ? 1 : 10);
|
|
115
|
+
render();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (input === 'u' || (key.ctrl && (input === 'u' || input === 'y'))) {
|
|
119
|
+
state.scrollOffset = Math.max(0, (state.scrollOffset ?? 0) - (input === 'y' ? 1 : 10));
|
|
120
|
+
render();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
80
123
|
if (input === 'j' || key.downArrow) {
|
|
81
|
-
const max = actionCount(
|
|
124
|
+
const max = actionCount(interaction) - 1;
|
|
82
125
|
state.selectedAction = Math.min(state.selectedAction + 1, max);
|
|
83
126
|
render();
|
|
84
127
|
return;
|
|
@@ -88,82 +131,55 @@ function handleItemReview(input, key, state, render) {
|
|
|
88
131
|
render();
|
|
89
132
|
return;
|
|
90
133
|
}
|
|
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
|
-
}
|
|
134
|
+
handleInteractionAction(input, key, state, interaction, render);
|
|
101
135
|
}
|
|
102
|
-
function
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
136
|
+
function handleInteractionAction(input, key, state, interaction, render) {
|
|
137
|
+
const opts = interaction.options;
|
|
138
|
+
// Match by shortcut
|
|
139
|
+
const matched = opts.find((o) => o.shortcut === input);
|
|
140
|
+
if (matched !== undefined) {
|
|
141
|
+
submitOption(state, interaction, matched.id, undefined);
|
|
106
142
|
advanceItem(state, 1);
|
|
107
143
|
render();
|
|
144
|
+
return;
|
|
108
145
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
state.
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
advanceItem(state, 1);
|
|
146
|
+
// Comment mode: allowFreetext + options exist
|
|
147
|
+
// If the cursor is on an option row, pre-attach that option to the comment.
|
|
148
|
+
if (input === 'c' && interaction.allowFreetext && opts.length > 0) {
|
|
149
|
+
const preselected = state.selectedAction < opts.length
|
|
150
|
+
? opts[state.selectedAction].id
|
|
151
|
+
: undefined;
|
|
152
|
+
state.inputMode = preselected !== undefined
|
|
153
|
+
? { kind: 'comment', buffer: '', selectedOptionId: preselected }
|
|
154
|
+
: { kind: 'comment', buffer: '' };
|
|
119
155
|
render();
|
|
156
|
+
return;
|
|
120
157
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
158
|
+
// Freetext-only: 'r' or enter opens input mode
|
|
159
|
+
if (interaction.allowFreetext && opts.length === 0) {
|
|
160
|
+
if (input === 'r' || key.return) {
|
|
161
|
+
const existing = state.responses.get(interaction.id);
|
|
162
|
+
const prefill = existing !== undefined && existing.freetext !== undefined ? existing.freetext : '';
|
|
163
|
+
state.inputMode = { kind: 'freetext', buffer: prefill };
|
|
164
|
+
render();
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
126
167
|
}
|
|
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?.();
|
|
168
|
+
// Enter on selected option row
|
|
169
|
+
if (key.return && state.selectedAction < opts.length) {
|
|
170
|
+
const o = opts[state.selectedAction];
|
|
171
|
+
submitOption(state, interaction, o.id, undefined);
|
|
139
172
|
advanceItem(state, 1);
|
|
140
173
|
render();
|
|
141
174
|
return;
|
|
142
175
|
}
|
|
143
|
-
|
|
144
|
-
|
|
176
|
+
// Enter on the [c] row (allowFreetext + options exist)
|
|
177
|
+
if (key.return && state.selectedAction === opts.length
|
|
178
|
+
&& interaction.allowFreetext && opts.length > 0) {
|
|
179
|
+
state.inputMode = { kind: 'comment', buffer: '' };
|
|
145
180
|
render();
|
|
146
181
|
return;
|
|
147
182
|
}
|
|
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
183
|
}
|
|
168
184
|
// ── Input Mode ───────────────────────────────────────────────────────────────
|
|
169
185
|
function handleInputMode(input, key, state, render) {
|
|
@@ -173,68 +189,49 @@ function handleInputMode(input, key, state, render) {
|
|
|
173
189
|
render();
|
|
174
190
|
return;
|
|
175
191
|
}
|
|
192
|
+
// Tab cycles attached option in comment mode: (none) → opt1 → opt2 → ... → (none)
|
|
193
|
+
if (key.tab && mode.kind === 'comment') {
|
|
194
|
+
const interaction = state.interactions[state.currentIndex];
|
|
195
|
+
const opts = interaction.options;
|
|
196
|
+
if (opts.length > 0) {
|
|
197
|
+
const cur = mode.selectedOptionId;
|
|
198
|
+
const curIdx = cur === undefined ? -1 : opts.findIndex((o) => o.id === cur);
|
|
199
|
+
const nextIdx = curIdx + 1; // -1 → 0, last → length (which we map to none)
|
|
200
|
+
if (nextIdx >= opts.length) {
|
|
201
|
+
delete mode.selectedOptionId;
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
mode.selectedOptionId = opts[nextIdx].id;
|
|
205
|
+
}
|
|
206
|
+
render();
|
|
207
|
+
}
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
176
210
|
if (key.return) {
|
|
177
|
-
|
|
211
|
+
const interaction = state.interactions[state.currentIndex];
|
|
212
|
+
const attached = mode.kind === 'comment' ? mode.selectedOptionId : undefined;
|
|
213
|
+
submitOption(state, interaction, attached, mode.buffer);
|
|
178
214
|
state.inputMode = null;
|
|
179
215
|
advanceItem(state, 1);
|
|
180
216
|
render();
|
|
181
217
|
return;
|
|
182
218
|
}
|
|
183
219
|
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
220
|
const chars = [...mode.buffer];
|
|
187
221
|
chars.pop();
|
|
188
222
|
mode.buffer = chars.join('');
|
|
189
223
|
render();
|
|
190
224
|
return;
|
|
191
225
|
}
|
|
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
226
|
const cleaned = input
|
|
196
|
-
.replace(/\x1b\[20[01]~/g, '')
|
|
197
|
-
.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')
|
|
198
|
-
.replace(/[\x00-\x1F\x7F]/g, '');
|
|
227
|
+
.replace(/\x1b\[20[01]~/g, '')
|
|
228
|
+
.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')
|
|
229
|
+
.replace(/[\x00-\x1F\x7F]/g, '');
|
|
199
230
|
if (cleaned.length > 0) {
|
|
200
231
|
mode.buffer += cleaned;
|
|
201
232
|
render();
|
|
202
233
|
}
|
|
203
234
|
}
|
|
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
235
|
// ── Final ────────────────────────────────────────────────────────────────────
|
|
239
236
|
function handleFinal(input, key, state, render, exit) {
|
|
240
237
|
if (key.return) {
|
|
@@ -242,7 +239,7 @@ function handleFinal(input, key, state, render, exit) {
|
|
|
242
239
|
}
|
|
243
240
|
else if (input === 'p') {
|
|
244
241
|
state.phase = 'item-review';
|
|
245
|
-
state.currentIndex = state.
|
|
242
|
+
state.currentIndex = state.interactions.length - 1;
|
|
246
243
|
render();
|
|
247
244
|
}
|
|
248
245
|
}
|
|
@@ -251,18 +248,24 @@ function advanceItem(state, direction) {
|
|
|
251
248
|
const next = state.currentIndex + direction;
|
|
252
249
|
if (next < 0)
|
|
253
250
|
return;
|
|
254
|
-
if (next >= state.
|
|
251
|
+
if (next >= state.interactions.length) {
|
|
255
252
|
state.phase = 'final';
|
|
256
253
|
return;
|
|
257
254
|
}
|
|
258
255
|
state.currentIndex = next;
|
|
259
256
|
state.selectedAction = 0;
|
|
260
257
|
state.detailExpanded = false;
|
|
258
|
+
state.scrollOffset = 0;
|
|
261
259
|
}
|
|
262
|
-
function actionCount(
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
260
|
+
function actionCount(interaction) {
|
|
261
|
+
return interaction.options.length + (interaction.allowFreetext && interaction.options.length > 0 ? 1 : 0);
|
|
262
|
+
}
|
|
263
|
+
function submitOption(state, interaction, selectedOptionId, freetext) {
|
|
264
|
+
const response = { id: interaction.id };
|
|
265
|
+
if (selectedOptionId !== undefined)
|
|
266
|
+
response.selectedOptionId = selectedOptionId;
|
|
267
|
+
if (freetext !== undefined)
|
|
268
|
+
response.freetext = freetext;
|
|
269
|
+
state.responses.set(interaction.id, response);
|
|
270
|
+
state.persist?.();
|
|
268
271
|
}
|
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;
|