@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/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.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();
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.answers.size >= state.questions.length) {
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 q = state.questions[state.currentIndex];
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
- // Action selection with j/k
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(q) - 1;
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
- // 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
- }
134
+ handleInteractionAction(input, key, state, interaction, render);
101
135
  }
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?.();
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
- 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);
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
- 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();
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
- 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?.();
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
- if (digit === numOptions + 1 || (key.return && state.selectedAction === numOptions)) {
144
- state.inputMode = { kind: 'custom-option', buffer: '' };
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,57 +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
- commitInput(state);
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
- mode.buffer = mode.buffer.slice(0, -1);
220
+ const chars = [...mode.buffer];
221
+ chars.pop();
222
+ mode.buffer = chars.join('');
185
223
  render();
186
224
  return;
187
225
  }
188
- if (input.length === 1 && input.charCodeAt(0) >= 32) {
189
- mode.buffer += input;
226
+ const cleaned = input
227
+ .replace(/\x1b\[20[01]~/g, '')
228
+ .replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')
229
+ .replace(/[\x00-\x1F\x7F]/g, '');
230
+ if (cleaned.length > 0) {
231
+ mode.buffer += cleaned;
190
232
  render();
191
233
  }
192
234
  }
193
- function commitInput(state) {
194
- const q = state.questions[state.currentIndex];
195
- const mode = state.inputMode;
196
- if (mode.kind === 'comment') {
197
- const existing = state.answers.get(q.id);
198
- const approved = existing ? existing.approved : false;
199
- state.answers.set(q.id, {
200
- id: q.id,
201
- type: 'validation',
202
- approved,
203
- comment: mode.buffer || undefined,
204
- });
205
- }
206
- else if (mode.kind === 'custom-option') {
207
- if (mode.buffer) {
208
- state.answers.set(q.id, {
209
- id: q.id,
210
- type: 'choice',
211
- selected: mode.buffer,
212
- isCustom: true,
213
- });
214
- }
215
- }
216
- else if (mode.kind === 'freetext') {
217
- if (mode.buffer) {
218
- state.answers.set(q.id, {
219
- id: q.id,
220
- type: 'freetext',
221
- response: mode.buffer,
222
- });
223
- }
224
- }
225
- state.persist?.();
226
- }
227
235
  // ── Final ────────────────────────────────────────────────────────────────────
228
236
  function handleFinal(input, key, state, render, exit) {
229
237
  if (key.return) {
@@ -231,7 +239,7 @@ function handleFinal(input, key, state, render, exit) {
231
239
  }
232
240
  else if (input === 'p') {
233
241
  state.phase = 'item-review';
234
- state.currentIndex = state.questions.length - 1;
242
+ state.currentIndex = state.interactions.length - 1;
235
243
  render();
236
244
  }
237
245
  }
@@ -240,18 +248,24 @@ function advanceItem(state, direction) {
240
248
  const next = state.currentIndex + direction;
241
249
  if (next < 0)
242
250
  return;
243
- if (next >= state.questions.length) {
251
+ if (next >= state.interactions.length) {
244
252
  state.phase = 'final';
245
253
  return;
246
254
  }
247
255
  state.currentIndex = next;
248
256
  state.selectedAction = 0;
249
257
  state.detailExpanded = false;
258
+ state.scrollOffset = 0;
250
259
  }
251
- function actionCount(q) {
252
- switch (q.type) {
253
- case 'validation': return 4;
254
- case 'choice': return q.options.length + 1;
255
- case 'freetext': return 1;
256
- }
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?.();
257
271
  }
@@ -1,5 +1,10 @@
1
- import type { TuiState } from '../types.js';
2
- export declare function flush(lines: string[]): void;
3
- export declare function renderOverview(state: TuiState): string[];
4
- export declare function renderItemReview(state: TuiState): string[];
5
- export declare function renderFinal(state: TuiState): string[];
1
+ import type { TuiState, Interaction, InteractionResponse } from '../types.js';
2
+ export declare function sanitize(text: string): 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;