@crouton-kit/humanloop 0.3.21 → 0.3.23

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/app.js CHANGED
@@ -1,7 +1,10 @@
1
1
  import { readFileSync, existsSync, writeFileSync, renameSync, unlinkSync, statSync } from 'fs';
2
- import { dirname, resolve as resolvePath } from 'node:path';
2
+ import { dirname, resolve as resolvePath, join } from 'node:path';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { tmpdir } from 'node:os';
5
+ import { randomUUID } from 'node:crypto';
3
6
  import { setupTerminal, restoreTerminal, parseKeypress, getTerminalSize } from './terminal.js';
4
- import { diffFrame, renderOverview, renderItemReview, renderFinal } from './render.js';
7
+ import { diffFrame, renderOverview, renderItemReview, renderFinal, clampItemReviewScroll } from './render.js';
5
8
  import { handleKeypress, assignShortcuts } from './input.js';
6
9
  import { readConversation } from '../conversation/reader.js';
7
10
  import { defaultGenerateVisual } from '../visuals/generate.js';
@@ -113,12 +116,14 @@ function fireVisuals(internals, interactions) {
113
116
  internals.state.visuals.set(interaction.id, r.ok
114
117
  ? { questionId: interaction.id, content: r.ansi, status: 'ready' }
115
118
  : { questionId: interaction.id, content: '', status: 'error' });
119
+ internals.callbacks.onDirty?.();
116
120
  }).catch(() => {
117
121
  if (!internals.mounted)
118
122
  return;
119
123
  if (!internals.state.interactions.some((x) => x.id === interaction.id))
120
124
  return;
121
125
  internals.state.visuals.set(interaction.id, { questionId: interaction.id, content: '', status: 'error' });
126
+ internals.callbacks.onDirty?.();
122
127
  });
123
128
  }
124
129
  }
@@ -130,7 +135,7 @@ export function mountPanel(opts) {
130
135
  mounted: true,
131
136
  generateVisual: opts.generateVisual,
132
137
  progressPath: opts.progressPath,
133
- callbacks: { onProgress: opts.onProgress, onComplete: opts.onComplete, onExit: opts.onExit },
138
+ callbacks: { onProgress: opts.onProgress, onComplete: opts.onComplete, onExit: opts.onExit, onDirty: opts.onDirty },
134
139
  };
135
140
  assignShortcuts(internals.state.interactions);
136
141
  rebindPersist(internals);
@@ -168,6 +173,11 @@ export function mountPanel(opts) {
168
173
  internals.callbacks.onExit?.();
169
174
  }
170
175
  });
176
+ // Pre-render clamp (input layer): keep scrollOffset within the current
177
+ // body's bounds so u/d stay responsive. The renderer itself is pure.
178
+ if (internals.state.phase === 'item-review') {
179
+ clampItemReviewScroll(internals.state, internals.cols, internals.rows);
180
+ }
171
181
  },
172
182
  render() {
173
183
  if (!internals.mounted)
@@ -177,6 +187,10 @@ export function mountPanel(opts) {
177
187
  handleResize(cols, rows) {
178
188
  internals.cols = cols;
179
189
  internals.rows = rows;
190
+ // New dimensions change the scroll bounds — re-clamp before laying out.
191
+ if (internals.state.phase === 'item-review') {
192
+ clampItemReviewScroll(internals.state, cols, rows);
193
+ }
180
194
  return renderLines();
181
195
  },
182
196
  unmount() {
@@ -208,6 +222,17 @@ export function mountPanel(opts) {
208
222
  return true;
209
223
  return internals.state.phase === 'overview' && internals.state.inputMode === null;
210
224
  },
225
+ getInputBuffer() {
226
+ if (!internals.mounted || internals.state.inputMode === null)
227
+ return undefined;
228
+ return internals.state.inputMode.buffer;
229
+ },
230
+ setInputBuffer(text) {
231
+ if (!internals.mounted || internals.state.inputMode === null)
232
+ return;
233
+ internals.state.inputMode.buffer = text;
234
+ internals.state.inputMode.cursor = [...text].length;
235
+ },
211
236
  };
212
237
  }
213
238
  /**
@@ -254,14 +279,26 @@ export async function resolveInteractionDir(dir, deck, opts = {}) {
254
279
  let currentDeck = deck;
255
280
  let deckWatch = null;
256
281
  const flushHost = (lines) => {
257
- const { rows: currentRows } = getTerminalSize();
258
- const { writes, nextPrevFrame } = diffFrame(prevFrameLocal, lines, currentRows);
282
+ const { cols: currentCols, rows: currentRows } = getTerminalSize();
283
+ const { writes, nextPrevFrame } = diffFrame(prevFrameLocal, lines, currentRows, currentCols);
259
284
  process.stdout.write('\x1b[?2026h');
260
285
  for (const w of writes)
261
286
  process.stdout.write(w);
262
287
  process.stdout.write('\x1b[?2026l');
263
288
  prevFrameLocal = nextPrevFrame;
264
289
  };
290
+ // On resize the terminal reflows/scrolls existing content, so the diff
291
+ // model no longer matches the screen: re-layout at the new size, clear
292
+ // everything, and redraw from scratch.
293
+ const onResize = () => {
294
+ if (panel === null)
295
+ return;
296
+ const { cols: c, rows: r } = getTerminalSize();
297
+ const lines = panel.handleResize(c, r);
298
+ prevFrameLocal = [];
299
+ process.stdout.write('\x1b[2J\x1b[H');
300
+ flushHost(lines);
301
+ };
265
302
  const finalize = (responses) => {
266
303
  if (deckWatch !== null) {
267
304
  clearInterval(deckWatch);
@@ -269,10 +306,11 @@ export async function resolveInteractionDir(dir, deck, opts = {}) {
269
306
  }
270
307
  restoreTerminal();
271
308
  process.stdin.removeListener('data', onData);
309
+ process.stdout.removeListener('resize', onResize);
272
310
  panel?.unmount();
273
311
  const completedAt = new Date().toISOString();
274
312
  // Resolved supersedes in-progress: write response.json, drop progress.json.
275
- const rp = writeResponse(dir, responses, completedAt);
313
+ const rp = writeResponse(dir, responses, completedAt, currentDeck);
276
314
  clearProgress(dir);
277
315
  resolve({ responses, completedAt, responsePath: rp, deck: currentDeck });
278
316
  };
@@ -291,6 +329,12 @@ export async function resolveInteractionDir(dir, deck, opts = {}) {
291
329
  onExit: () => {
292
330
  finalize(lastResponses);
293
331
  },
332
+ // Async visual finished loading between keystrokes — repaint so the
333
+ // "loading context..." placeholder is replaced immediately.
334
+ onDirty: () => {
335
+ if (panel !== null)
336
+ flushHost(panel.render());
337
+ },
294
338
  });
295
339
  flushHost(panel.render());
296
340
  // ── Live deck reload ──────────────────────────────────────────────────
@@ -336,12 +380,90 @@ export async function resolveInteractionDir(dir, deck, opts = {}) {
336
380
  panel.loadDeck(nextDeck, { progressPath: progressPathFor(dir) });
337
381
  flushHost(panel.render());
338
382
  }, 500);
383
+ // $EDITOR escape hatch (ctrl+o): the host owns the stdin listener + raw
384
+ // mode, so it — not handleInputMode — is where the editor round-trip has
385
+ // to live. Detected here before delegating to the panel; only acts while
386
+ // an input-mode buffer exists (panel.getInputBuffer() undefined otherwise).
387
+ const runEditorEscapeHatch = (buffer) => {
388
+ if (panel === null)
389
+ return;
390
+ process.stdin.removeListener('data', onData);
391
+ process.stdout.removeListener('resize', onResize);
392
+ const tmpFile = join(tmpdir(), `hl-input-${randomUUID()}.txt`);
393
+ const editor = process.env.EDITOR || 'vi';
394
+ let next = buffer;
395
+ let errorMessage = null;
396
+ // Everything from suspending the TUI through the editor round-trip can
397
+ // throw (tmpfile write, spawn, readback) — a tmpdir write failure alone
398
+ // (e.g. /tmp full) used to escape with the listeners detached and the
399
+ // real terminal never restored. The try/finally below guarantees the
400
+ // finally branch — tmpfile cleanup, TUI restore, listener re-attach,
401
+ // resize-aware redraw — runs on every path, success or failure.
402
+ try {
403
+ restoreTerminal();
404
+ writeFileSync(tmpFile, buffer);
405
+ const result = spawnSync(editor, [tmpFile], { stdio: 'inherit' });
406
+ if (result.error) {
407
+ errorMessage = `$EDITOR ("${editor}") failed to launch: ${result.error.message}`;
408
+ }
409
+ else if (result.signal !== null) {
410
+ errorMessage = `$EDITOR ("${editor}") was killed by signal ${result.signal}`;
411
+ }
412
+ else if (result.status !== 0) {
413
+ errorMessage = `$EDITOR ("${editor}") exited with status ${result.status}`;
414
+ }
415
+ else {
416
+ let read = readFileSync(tmpFile, 'utf8');
417
+ // Editors conventionally append a trailing newline on save; strip
418
+ // exactly one so a round-trip that added nothing doesn't grow the
419
+ // buffer by a blank line.
420
+ if (read.endsWith('\n') && !buffer.endsWith('\n'))
421
+ read = read.slice(0, -1);
422
+ next = read;
423
+ }
424
+ }
425
+ catch (err) {
426
+ errorMessage = `$EDITOR round-trip failed: ${err instanceof Error ? err.message : String(err)}`;
427
+ }
428
+ finally {
429
+ try {
430
+ unlinkSync(tmpFile);
431
+ }
432
+ catch { /* best-effort cleanup — may never have been created */ }
433
+ setupTerminal();
434
+ process.stdin.on('data', onData);
435
+ process.stdout.on('resize', onResize);
436
+ // On any failure the original buffer is kept untouched. Re-sample the
437
+ // terminal size (it may have changed while the editor had it) so the
438
+ // post-editor redraw lays out for the CURRENT dimensions rather than
439
+ // the panel's stale cols/rows, same as the live resize listener does.
440
+ panel.setInputBuffer(errorMessage === null ? next : buffer);
441
+ const { cols: c, rows: r } = getTerminalSize();
442
+ const lines = panel.handleResize(c, r);
443
+ if (errorMessage !== null) {
444
+ while (lines.length < r)
445
+ lines.push('');
446
+ lines[r - 1] = ` ${errorMessage}`.slice(0, c);
447
+ }
448
+ prevFrameLocal = [];
449
+ process.stdout.write('\x1b[2J\x1b[H');
450
+ flushHost(lines);
451
+ }
452
+ };
339
453
  onData = (data) => {
340
454
  const { input: inp, key } = parseKeypress(data);
455
+ if (key.ctrl && inp === 'o') {
456
+ const buf = panel.getInputBuffer();
457
+ if (buf !== undefined) {
458
+ runEditorEscapeHatch(buf);
459
+ return;
460
+ }
461
+ }
341
462
  panel.handleKey(inp, key);
342
463
  flushHost(panel.render());
343
464
  };
344
465
  process.stdin.on('data', onData);
466
+ process.stdout.on('resize', onResize);
345
467
  });
346
468
  }
347
469
  // ── launchTui — file-path entry over the dir resolver (a kept public export
package/dist/tui/input.js CHANGED
@@ -211,7 +211,7 @@ function handleInteractionAction(input, key, state, interaction, render) {
211
211
  if (typeof prior === 'string')
212
212
  prefill = prior;
213
213
  }
214
- state.inputMode = { kind: 'comment', buffer: prefill, selectedOptionId: optId };
214
+ state.inputMode = { kind: 'comment', buffer: prefill, cursor: [...prefill].length, selectedOptionId: optId };
215
215
  }
216
216
  else {
217
217
  // On the [c] row in multi-select: pre-fill from existing overall freetext.
@@ -222,7 +222,7 @@ function handleInteractionAction(input, key, state, interaction, render) {
222
222
  prefill = existing.freetext;
223
223
  }
224
224
  }
225
- state.inputMode = { kind: 'comment', buffer: prefill };
225
+ state.inputMode = { kind: 'comment', buffer: prefill, cursor: [...prefill].length };
226
226
  }
227
227
  render();
228
228
  return;
@@ -232,7 +232,7 @@ function handleInteractionAction(input, key, state, interaction, render) {
232
232
  if (input === 'r' || key.return) {
233
233
  const existing = state.responses.get(interaction.id);
234
234
  const prefill = existing !== undefined && existing.freetext !== undefined ? existing.freetext : '';
235
- state.inputMode = { kind: 'freetext', buffer: prefill };
235
+ state.inputMode = { kind: 'freetext', buffer: prefill, cursor: [...prefill].length };
236
236
  render();
237
237
  return;
238
238
  }
@@ -264,7 +264,7 @@ function handleInteractionAction(input, key, state, interaction, render) {
264
264
  // Enter on the [c] row (allowFreetext + options exist)
265
265
  if (key.return && state.selectedAction === opts.length
266
266
  && interaction.allowFreetext && opts.length > 0) {
267
- state.inputMode = { kind: 'comment', buffer: '' };
267
+ state.inputMode = { kind: 'comment', buffer: '', cursor: 0 };
268
268
  render();
269
269
  return;
270
270
  }
@@ -321,43 +321,155 @@ function handleInputMode(input, key, state, render) {
321
321
  render();
322
322
  return;
323
323
  }
324
+ // Newline-insert chord (ctrl+j / alt+enter) — distinct from key.return
325
+ // (submit), handled above.
326
+ if (key.newline) {
327
+ insertAtCursor(mode, '\n');
328
+ render();
329
+ return;
330
+ }
331
+ // ── Cursor motion ──────────────────────────────────────────────────────
332
+ if (key.leftArrow) {
333
+ mode.cursor = Math.max(0, mode.cursor - 1);
334
+ render();
335
+ return;
336
+ }
337
+ if (key.rightArrow) {
338
+ mode.cursor = Math.min([...mode.buffer].length, mode.cursor + 1);
339
+ render();
340
+ return;
341
+ }
342
+ if (key.wordLeft) {
343
+ mode.cursor = wordLeftIndex(mode.buffer, mode.cursor);
344
+ render();
345
+ return;
346
+ }
347
+ if (key.wordRight) {
348
+ mode.cursor = wordRightIndex(mode.buffer, mode.cursor);
349
+ render();
350
+ return;
351
+ }
352
+ if (key.home || (key.ctrl && input === 'a')) {
353
+ mode.cursor = lineStart(mode.buffer, mode.cursor);
354
+ render();
355
+ return;
356
+ }
357
+ if (key.end || (key.ctrl && input === 'e')) {
358
+ mode.cursor = lineEnd(mode.buffer, mode.cursor);
359
+ render();
360
+ return;
361
+ }
362
+ // ── Edits ───────────────────────────────────────────────────────────────
324
363
  if (key.backspace && key.meta) {
325
- mode.buffer = deleteWordBack(mode.buffer);
364
+ deleteWordAtCursor(mode);
326
365
  render();
327
366
  return;
328
367
  }
329
- // Ctrl-U: delete to the start of the line (readline "unix-line-discard").
368
+ // Ctrl-U: delete from line start to cursor (readline "unix-line-discard").
330
369
  // iTerm2 maps Cmd+Backspace to a bare 0x15, which arrives here as ctrl+'u'.
331
- // The buffer is end-anchored (no mid-string cursor), so "to line start" is
332
- // the entire buffer.
333
370
  if (key.ctrl && input === 'u') {
334
- mode.buffer = '';
371
+ deleteToLineStart(mode);
372
+ render();
373
+ return;
374
+ }
375
+ if (key.del) {
376
+ deleteAtCursor(mode);
335
377
  render();
336
378
  return;
337
379
  }
338
380
  if (key.backspace) {
339
- const chars = [...mode.buffer];
340
- chars.pop();
341
- mode.buffer = chars.join('');
381
+ backspaceAtCursor(mode);
342
382
  render();
343
383
  return;
344
384
  }
385
+ // Typed input / paste. Bracketed-paste markers and other control chars are
386
+ // stripped, but \n (0x0A) is preserved so pasted newlines land as real
387
+ // newlines in the buffer instead of vanishing (CRLF is normalized to LF).
345
388
  const cleaned = input
346
389
  .replace(/\x1b\[20[01]~/g, '')
347
390
  .replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')
348
- .replace(/[\x00-\x1F\x7F]/g, '');
391
+ .replace(/\r\n?/g, '\n')
392
+ .replace(/[\x00-\x09\x0B-\x1F\x7F]/g, '');
349
393
  if (cleaned.length > 0) {
350
- mode.buffer += cleaned;
394
+ insertAtCursor(mode, cleaned);
351
395
  render();
352
396
  }
353
397
  }
354
- function deleteWordBack(buffer) {
398
+ function insertAtCursor(mode, text) {
399
+ const chars = [...mode.buffer];
400
+ const insert = [...text];
401
+ chars.splice(mode.cursor, 0, ...insert);
402
+ mode.buffer = chars.join('');
403
+ mode.cursor += insert.length;
404
+ }
405
+ function backspaceAtCursor(mode) {
406
+ if (mode.cursor <= 0)
407
+ return;
408
+ const chars = [...mode.buffer];
409
+ chars.splice(mode.cursor - 1, 1);
410
+ mode.buffer = chars.join('');
411
+ mode.cursor -= 1;
412
+ }
413
+ function deleteAtCursor(mode) {
414
+ const chars = [...mode.buffer];
415
+ if (mode.cursor >= chars.length)
416
+ return;
417
+ chars.splice(mode.cursor, 1);
418
+ mode.buffer = chars.join('');
419
+ }
420
+ function deleteWordAtCursor(mode) {
421
+ const start = wordLeftIndex(mode.buffer, mode.cursor);
422
+ const chars = [...mode.buffer];
423
+ chars.splice(start, mode.cursor - start);
424
+ mode.buffer = chars.join('');
425
+ mode.cursor = start;
426
+ }
427
+ function deleteToLineStart(mode) {
428
+ const start = lineStart(mode.buffer, mode.cursor);
429
+ const chars = [...mode.buffer];
430
+ chars.splice(start, mode.cursor - start);
431
+ mode.buffer = chars.join('');
432
+ mode.cursor = start;
433
+ }
434
+ /** Skip whitespace backward from `cursor`, then skip the non-whitespace word
435
+ * before it — lands on the word's start (readline alt-b / ctrl+left). */
436
+ function wordLeftIndex(buffer, cursor) {
437
+ const chars = [...buffer];
438
+ let i = Math.min(cursor, chars.length);
439
+ while (i > 0 && /\s/.test(chars[i - 1]))
440
+ i--;
441
+ while (i > 0 && !/\s/.test(chars[i - 1]))
442
+ i--;
443
+ return i;
444
+ }
445
+ /** Skip whitespace forward from `cursor`, then skip the non-whitespace word
446
+ * after it — lands just past the word's end (readline alt-f / ctrl+right). */
447
+ function wordRightIndex(buffer, cursor) {
448
+ const chars = [...buffer];
449
+ let i = Math.min(cursor, chars.length);
450
+ while (i < chars.length && /\s/.test(chars[i]))
451
+ i++;
452
+ while (i < chars.length && !/\s/.test(chars[i]))
453
+ i++;
454
+ return i;
455
+ }
456
+ /** Index just after the previous `\n` (or 0), i.e. the start of the current
457
+ * visual line — the multiline analog of ctrl+a/home. */
458
+ function lineStart(buffer, cursor) {
459
+ const chars = [...buffer];
460
+ let i = Math.min(cursor, chars.length);
461
+ while (i > 0 && chars[i - 1] !== '\n')
462
+ i--;
463
+ return i;
464
+ }
465
+ /** Index at the next `\n` (or end of buffer) — the multiline analog of
466
+ * ctrl+e/end. */
467
+ function lineEnd(buffer, cursor) {
355
468
  const chars = [...buffer];
356
- while (chars.length > 0 && /\s/.test(chars[chars.length - 1]))
357
- chars.pop();
358
- while (chars.length > 0 && !/\s/.test(chars[chars.length - 1]))
359
- chars.pop();
360
- return chars.join('');
469
+ let i = Math.min(cursor, chars.length);
470
+ while (i < chars.length && chars[i] !== '\n')
471
+ i++;
472
+ return i;
361
473
  }
362
474
  // ── Final ────────────────────────────────────────────────────────────────────
363
475
  function handleFinal(input, key, state, render, exit) {
@@ -1,10 +1,26 @@
1
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): {
2
+ export declare function diffFrame(prevFrame: string[], nextLines: string[], rows: number, cols?: number): {
4
3
  writes: string[];
5
4
  nextPrevFrame: string[];
6
5
  };
7
6
  export declare function renderOverview(state: TuiState, cols: number, rows: number): string[];
7
+ /**
8
+ * Render a (possibly multiline) input buffer with a visible cursor at
9
+ * `cursor` (a code-point index). A private-use sentinel is inserted at the
10
+ * cursor position, the result is hard-wrapped (so `\n` and width-wrapping
11
+ * behave exactly as they would without a cursor), then the sentinel is
12
+ * swapped for a reverse-video block on whichever wrapped line it landed on.
13
+ * `stringWidth('\uE000') === 1`, so it occupies exactly one column and never
14
+ * throws off the wrap math.
15
+ */
16
+ export declare function renderInputBuffer(buffer: string, cursor: number, maxWidth: number): string[];
17
+ /**
18
+ * Clamp state.scrollOffset to the current body's scroll bounds. The host runs
19
+ * this as a pre-render step (from the input path) so `renderItemReview` stays a
20
+ * pure function of state — the clamp keeps u/d scrolling responsive without the
21
+ * renderer mutating state mid-frame.
22
+ */
23
+ export declare function clampItemReviewScroll(state: TuiState, cols: number, rows: number): void;
8
24
  export declare function renderItemReview(state: TuiState, cols: number, rows: number): string[];
9
25
  export declare function renderFinal(state: TuiState, cols: number, rows: number): string[];
10
26
  export declare function responseSummary(r: InteractionResponse, interaction: Interaction): string;