@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/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.answers.size >= state.questions.length) {
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.questions.length - 1);
63
+ state.currentIndex = Math.min(state.currentIndex + 1, state.interactions.length - 1);
33
64
  render();
65
+ return;
34
66
  }
35
- else if (input === 'k' || key.upArrow) {
67
+ if (input === 'k' || key.upArrow) {
36
68
  state.currentIndex = Math.max(state.currentIndex - 1, 0);
37
69
  render();
70
+ return;
38
71
  }
39
- else if (key.return) {
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
- else if (input === 'q') {
46
- if (state.answers.size >= state.questions.length) {
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 q = state.questions[state.currentIndex];
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
- // Action selection with j/k
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(q) - 1;
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
- // Type-specific actions
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 handleValidationAction(input, key, state, q, render) {
103
- if (input === '1' || (key.return && state.selectedAction === 0)) {
104
- state.answers.set(q.id, { id: q.id, type: 'validation', approved: true });
105
- state.persist?.();
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
- else if (input === '2' || (key.return && state.selectedAction === 1)) {
110
- state.inputMode = { kind: 'comment', buffer: '' };
111
- state.answers.set(q.id, { id: q.id, type: 'validation', approved: true, comment: '' });
112
- state.persist?.();
113
- render();
114
- }
115
- else if (input === '3' || (key.return && state.selectedAction === 2)) {
116
- state.answers.set(q.id, { id: q.id, type: 'validation', approved: false });
117
- state.persist?.();
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
- else if (input === '4' || (key.return && state.selectedAction === 3)) {
122
- state.inputMode = { kind: 'comment', buffer: '' };
123
- state.answers.set(q.id, { id: q.id, type: 'validation', approved: false, comment: '' });
124
- state.persist?.();
125
- render();
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
- function handleChoiceAction(input, key, state, q, render) {
129
- const numOptions = q.options.length;
130
- const digit = parseInt(input, 10);
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
- if (digit === numOptions + 1 || (key.return && state.selectedAction === numOptions)) {
144
- state.inputMode = { kind: 'custom-option', buffer: '' };
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
- commitInput(state);
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, '') // bracketed-paste start/end markers
197
- .replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '') // CSI sequences
198
- .replace(/[\x00-\x1F\x7F]/g, ''); // C0 controls and DEL
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.questions.length - 1;
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.questions.length) {
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(q) {
263
- switch (q.type) {
264
- case 'validation': return 4;
265
- case 'choice': return q.options.length + 1;
266
- case 'freetext': return 1;
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
  }
@@ -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 flush(lines: string[]): void;
4
- export declare function renderOverview(state: TuiState): string[];
5
- export declare function renderItemReview(state: TuiState): string[];
6
- export declare function renderFinal(state: TuiState): string[];
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;