@crouton-kit/humanloop 0.3.22 → 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/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,18 +1,26 @@
1
1
  import type { TuiState, Interaction, InteractionResponse } from '../types.js';
2
- export declare function sanitize(text: string): string;
3
- /**
4
- * ANSI-aware clip: truncate a line's *visible* width to `maxWidth`, passing
5
- * escape sequences through untouched. Every line written to the terminal must
6
- * fit within the columns, or it physically wraps onto the next row — which
7
- * breaks diffFrame's one-logical-line-per-row model and strands spillover text
8
- * on rows the differ believes are empty (so it never erases them).
9
- */
10
- export declare function clipLine(line: string, maxWidth: number): string;
11
2
  export declare function diffFrame(prevFrame: string[], nextLines: string[], rows: number, cols?: number): {
12
3
  writes: string[];
13
4
  nextPrevFrame: string[];
14
5
  };
15
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;
16
24
  export declare function renderItemReview(state: TuiState, cols: number, rows: number): string[];
17
25
  export declare function renderFinal(state: TuiState, cols: number, rows: number): string[];
18
26
  export declare function responseSummary(r: InteractionResponse, interaction: Interaction): string;
@@ -1,184 +1,6 @@
1
- import stringWidth from 'string-width';
2
1
  import { renderMarkdown } from '../render/termrender.js';
3
- // ── ANSI helpers ─────────────────────────────────────────────────────────────
4
- const ESC = '\x1b[';
5
- const RESET = `${ESC}0m`;
6
- const BOLD = `${ESC}1m`;
7
- const DIM = `${ESC}2m`;
8
- const ITALIC = `${ESC}3m`;
9
- const GREEN = `${ESC}32m`;
10
- const YELLOW = `${ESC}33m`;
11
- const CYAN = `${ESC}36m`;
12
- const CONTROL_CHARS_RE = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b[@-_]|[\x00-\x08\x0B\x0E-\x1F\x7F-\x9F]/g;
13
- export function sanitize(text) {
14
- if (typeof text !== 'string')
15
- return '';
16
- return text.replace(CONTROL_CHARS_RE, '');
17
- }
18
- function singleLine(text) {
19
- return sanitize(text).replace(/\s+/g, ' ').trim();
20
- }
21
- function truncate(text, maxWidth) {
22
- if (maxWidth < 1)
23
- return '';
24
- if (stringWidth(text) <= maxWidth)
25
- return text;
26
- const chars = [...text];
27
- let w = 0;
28
- let out = '';
29
- for (const ch of chars) {
30
- const cw = stringWidth(ch);
31
- if (w + cw + 1 > maxWidth)
32
- break;
33
- out += ch;
34
- w += cw;
35
- }
36
- return out + '…';
37
- }
38
- function padRight(text, width) {
39
- const w = stringWidth(text);
40
- if (w >= width)
41
- return text;
42
- return text + ' '.repeat(width - w);
43
- }
44
- function hline(width, char = '─') {
45
- if (width < 1)
46
- return '';
47
- return char.repeat(width);
48
- }
49
- function wrap(text, maxWidth) {
50
- if (maxWidth < 1)
51
- return [text];
52
- const out = [];
53
- const paragraphs = text.split('\n');
54
- for (let p = 0; p < paragraphs.length; p++) {
55
- const para = paragraphs[p];
56
- if (para === '') {
57
- out.push('');
58
- continue;
59
- }
60
- const words = para.split(/[ \t]+/).filter(Boolean);
61
- let current = '';
62
- for (let word of words) {
63
- while (stringWidth(word) > maxWidth) {
64
- if (current) {
65
- out.push(current);
66
- current = '';
67
- }
68
- const piece = sliceByWidth(word, maxWidth);
69
- out.push(piece);
70
- word = word.slice(piece.length);
71
- }
72
- const candidate = current ? `${current} ${word}` : word;
73
- if (stringWidth(candidate) <= maxWidth) {
74
- current = candidate;
75
- }
76
- else {
77
- if (current)
78
- out.push(current);
79
- current = word;
80
- }
81
- }
82
- if (current)
83
- out.push(current);
84
- }
85
- return out.length > 0 ? out : [''];
86
- }
87
- function sliceByWidth(s, maxWidth) {
88
- let w = 0;
89
- let out = '';
90
- for (const ch of s) {
91
- const cw = stringWidth(ch);
92
- if (w + cw > maxWidth)
93
- break;
94
- out += ch;
95
- w += cw;
96
- }
97
- if (out === '' && s.length > 0)
98
- out = [...s][0];
99
- return out;
100
- }
101
- function hardWrap(text, maxWidth) {
102
- if (maxWidth < 1)
103
- return [text];
104
- const segments = text.split('\n');
105
- const out = [];
106
- for (const seg of segments) {
107
- if (seg.length === 0) {
108
- out.push('');
109
- continue;
110
- }
111
- let current = '';
112
- let currentW = 0;
113
- for (const ch of [...seg]) {
114
- const cw = stringWidth(ch);
115
- if (currentW + cw > maxWidth) {
116
- out.push(current);
117
- current = ch;
118
- currentW = cw;
119
- }
120
- else {
121
- current += ch;
122
- currentW += cw;
123
- }
124
- }
125
- out.push(current);
126
- }
127
- return out;
128
- }
129
- // ── Horizontal centering ─────────────────────────────────────────────────────
130
- /**
131
- * Pad each non-empty line with leading spaces to horizontally center the
132
- * `contentWidth`-wide block within `cols`. Wide terminals (dashboard, full
133
- * screen) get visual breathing room; narrow panes (split tmux pane next to a
134
- * spawning agent) skip centering because there's nothing to center.
135
- *
136
- * Empty lines stay empty so frame diffing can keep them as cheap no-ops.
137
- */
138
- function centerHorizontal(lines, cols, contentWidth) {
139
- const extraPad = Math.max(0, Math.floor((cols - contentWidth) / 2));
140
- if (extraPad === 0)
141
- return lines;
142
- const pad = ' '.repeat(extraPad);
143
- return lines.map((line) => (line === '' ? '' : pad + line));
144
- }
2
+ import { ESC, RESET, BOLD, DIM, ITALIC, GREEN, YELLOW, CYAN, REVERSE, sanitize, singleLine, truncate, hline, wrap, hardWrap, centerHorizontal, clipLine, } from './ansi.js';
145
3
  // ── Frame buffer ─────────────────────────────────────────────────────────────
146
- /**
147
- * ANSI-aware clip: truncate a line's *visible* width to `maxWidth`, passing
148
- * escape sequences through untouched. Every line written to the terminal must
149
- * fit within the columns, or it physically wraps onto the next row — which
150
- * breaks diffFrame's one-logical-line-per-row model and strands spillover text
151
- * on rows the differ believes are empty (so it never erases them).
152
- */
153
- export function clipLine(line, maxWidth) {
154
- if (maxWidth < 1)
155
- return '';
156
- if (stringWidth(line) <= maxWidth)
157
- return line; // string-width ignores ANSI
158
- let out = '';
159
- let w = 0;
160
- let i = 0;
161
- let sawAnsi = false;
162
- while (i < line.length) {
163
- if (line[i] === '\x1b') {
164
- const m = /^\x1b\[[0-9;?]*[a-zA-Z]|^\x1b[@-_]/.exec(line.slice(i));
165
- if (m !== null) {
166
- out += m[0];
167
- i += m[0].length;
168
- sawAnsi = true;
169
- continue;
170
- }
171
- }
172
- const ch = String.fromCodePoint(line.codePointAt(i));
173
- const cw = stringWidth(ch);
174
- if (w + cw > maxWidth)
175
- break;
176
- out += ch;
177
- w += cw;
178
- i += ch.length;
179
- }
180
- return sawAnsi ? out + RESET : out;
181
- }
182
4
  export function diffFrame(prevFrame, nextLines, rows, cols) {
183
5
  const clipped = cols !== undefined
184
6
  ? nextLines.map((l) => clipLine(l, cols))
@@ -260,7 +82,29 @@ export function renderOverview(state, cols, rows) {
260
82
  const centered = centerHorizontal(lines.slice(0, rows), cols, Math.min(cols, 60) + 2);
261
83
  return centered;
262
84
  }
263
- export function renderItemReview(state, cols, rows) {
85
+ /**
86
+ * Render a (possibly multiline) input buffer with a visible cursor at
87
+ * `cursor` (a code-point index). A private-use sentinel is inserted at the
88
+ * cursor position, the result is hard-wrapped (so `\n` and width-wrapping
89
+ * behave exactly as they would without a cursor), then the sentinel is
90
+ * swapped for a reverse-video block on whichever wrapped line it landed on.
91
+ * `stringWidth('\uE000') === 1`, so it occupies exactly one column and never
92
+ * throws off the wrap math.
93
+ */
94
+ export function renderInputBuffer(buffer, cursor, maxWidth) {
95
+ const chars = [...buffer];
96
+ const at = Math.max(0, Math.min(cursor, chars.length));
97
+ const withCaret = [...chars.slice(0, at), '\uE000', ...chars.slice(at)].join('');
98
+ const wrapped = hardWrap(withCaret, maxWidth);
99
+ return wrapped.map((line) => (line.includes('\uE000') ? line.replace('\uE000', `${REVERSE} ${RESET}`) : line));
100
+ }
101
+ /**
102
+ * Build the item-review frame parts (pre/body/post) and derive the body's
103
+ * scroll bounds. Pure: reads state, mutates nothing. Both `renderItemReview`
104
+ * and the pre-render `clampItemReviewScroll` go through this, so the layout
105
+ * math has a single source of truth.
106
+ */
107
+ function buildItemReviewLayout(state, cols, rows) {
264
108
  const interaction = state.interactions[state.currentIndex];
265
109
  const visual = state.visuals.get(interaction.id);
266
110
  const response = state.responses.get(interaction.id);
@@ -348,17 +192,15 @@ export function renderItemReview(state, cols, rows) {
348
192
  for (const labelLine of wrap(`${singleLine(label)}:`, maxW)) {
349
193
  postLines.push(` ${YELLOW}${labelLine}${RESET}`);
350
194
  }
351
- const bufLines = hardWrap(state.inputMode.buffer, maxW - 1);
352
- for (let i = 0; i < bufLines.length; i++) {
353
- const isLast = i === bufLines.length - 1;
354
- postLines.push(` ${bufLines[i]}${isLast ? '█' : ''}`);
355
- }
195
+ const bufLines = renderInputBuffer(state.inputMode.buffer, state.inputMode.cursor, maxW - 1);
196
+ for (const line of bufLines)
197
+ postLines.push(` ${line}`);
356
198
  if (attachedLine !== undefined) {
357
199
  postLines.push('');
358
200
  postLines.push(attachedLine);
359
201
  }
360
202
  postLines.push('');
361
- postLines.push(` ${DIM}enter${RESET} submit ${DIM}esc${RESET} cancel`);
203
+ postLines.push(` ${DIM}enter${RESET} submit ${DIM}^J/⌥⏎${RESET} newline ${DIM}^O${RESET} editor ${DIM}esc${RESET} cancel`);
362
204
  }
363
205
  else {
364
206
  postLines.push(...renderActions(interaction, state.selectedAction, maxW, response));
@@ -371,14 +213,31 @@ export function renderItemReview(state, cols, rows) {
371
213
  postLines.push(` ${YELLOW}${hintLine}${RESET}`);
372
214
  }
373
215
  }
374
- // Window the body
216
+ // Derive the scroll window bounds. This builder never writes back into state
217
+ // — the host clamps state.scrollOffset via clampItemReviewScroll before render.
375
218
  const reservedRows = preLines.length + postLines.length + 1; // +1 for footer
376
219
  const bodyHeight = Math.max(1, rows - reservedRows);
377
- const overflows = bodyLines.length > bodyHeight;
378
- let scroll = state.scrollOffset || 0;
379
220
  const maxScroll = Math.max(0, bodyLines.length - bodyHeight);
380
- scroll = Math.max(0, Math.min(scroll, maxScroll));
381
- state.scrollOffset = scroll;
221
+ return {
222
+ interaction, preLines, bodyLines, postLines,
223
+ maxW, bodyHeight, maxScroll, overflows: bodyLines.length > bodyHeight,
224
+ };
225
+ }
226
+ /**
227
+ * Clamp state.scrollOffset to the current body's scroll bounds. The host runs
228
+ * this as a pre-render step (from the input path) so `renderItemReview` stays a
229
+ * pure function of state — the clamp keeps u/d scrolling responsive without the
230
+ * renderer mutating state mid-frame.
231
+ */
232
+ export function clampItemReviewScroll(state, cols, rows) {
233
+ const { maxScroll } = buildItemReviewLayout(state, cols, rows);
234
+ state.scrollOffset = Math.max(0, Math.min(state.scrollOffset || 0, maxScroll));
235
+ }
236
+ export function renderItemReview(state, cols, rows) {
237
+ const { interaction, preLines, bodyLines, postLines, maxW, bodyHeight, maxScroll, overflows } = buildItemReviewLayout(state, cols, rows);
238
+ // Read-only clamp: renderer never mutates state (clampItemReviewScroll, run
239
+ // pre-render, keeps state.scrollOffset itself in bounds).
240
+ const scroll = Math.max(0, Math.min(state.scrollOffset || 0, maxScroll));
382
241
  let visibleBody;
383
242
  if (overflows) {
384
243
  visibleBody = bodyLines.slice(scroll, scroll + bodyHeight);
@@ -1,7 +1,18 @@
1
1
  export interface Key {
2
2
  upArrow: boolean;
3
3
  downArrow: boolean;
4
+ leftArrow: boolean;
5
+ rightArrow: boolean;
6
+ /** Word-wise cursor motion (ctrl/alt + arrow, or readline alt-b / alt-f). */
7
+ wordLeft: boolean;
8
+ wordRight: boolean;
9
+ home: boolean;
10
+ end: boolean;
11
+ /** Forward delete (the Del key / \x1b[3~). */
12
+ del: boolean;
4
13
  return: boolean;
14
+ /** Newline-insert chord (ctrl+j / alt+enter) — distinct from return=submit. */
15
+ newline: boolean;
5
16
  escape: boolean;
6
17
  ctrl: boolean;
7
18
  meta: boolean;
@@ -2,7 +2,15 @@ function emptyKey() {
2
2
  return {
3
3
  upArrow: false,
4
4
  downArrow: false,
5
+ leftArrow: false,
6
+ rightArrow: false,
7
+ wordLeft: false,
8
+ wordRight: false,
9
+ home: false,
10
+ end: false,
11
+ del: false,
5
12
  return: false,
13
+ newline: false,
6
14
  escape: false,
7
15
  ctrl: false,
8
16
  meta: false,
@@ -21,10 +29,54 @@ export function parseKeypress(data) {
21
29
  key.downArrow = true;
22
30
  return { input: '', key };
23
31
  }
24
- if (str === '\r' || str === '\n') {
32
+ if (str === '\x1b[C') {
33
+ key.rightArrow = true;
34
+ return { input: '', key };
35
+ }
36
+ if (str === '\x1b[D') {
37
+ key.leftArrow = true;
38
+ return { input: '', key };
39
+ }
40
+ // Word-wise motion: ctrl/alt + left/right (xterm modifier encodings) and the
41
+ // readline alt-b / alt-f bindings. Must precede the bare-ESC checks below.
42
+ if (str === '\x1b[1;5C' || str === '\x1b[1;3C' || str === '\x1bf') {
43
+ key.wordRight = true;
44
+ return { input: '', key };
45
+ }
46
+ if (str === '\x1b[1;5D' || str === '\x1b[1;3D' || str === '\x1bb') {
47
+ key.wordLeft = true;
48
+ return { input: '', key };
49
+ }
50
+ if (str === '\x1b[H' || str === '\x1b[1~') {
51
+ key.home = true;
52
+ return { input: '', key };
53
+ }
54
+ if (str === '\x1b[F' || str === '\x1b[4~') {
55
+ key.end = true;
56
+ return { input: '', key };
57
+ }
58
+ if (str === '\x1b[3~') {
59
+ key.del = true;
60
+ return { input: '', key };
61
+ }
62
+ // Alt+Enter inserts a newline in freetext (distinct from Enter=submit). Must
63
+ // precede the bare-ESC and meta-backspace checks so the two-byte sequence
64
+ // isn't swallowed as a lone escape.
65
+ if (str === '\x1b\r' || str === '\x1b\n') {
66
+ key.newline = true;
67
+ return { input: '', key };
68
+ }
69
+ // Enter (submit) is CR; ctrl+j (LF) inserts a newline. Splitting them lets
70
+ // freetext use ctrl+j for a hard newline while Enter still submits. In a raw
71
+ // TTY the Enter key sends CR, so this split is safe.
72
+ if (str === '\r') {
25
73
  key.return = true;
26
74
  return { input: '', key };
27
75
  }
76
+ if (str === '\n') {
77
+ key.newline = true;
78
+ return { input: '', key };
79
+ }
28
80
  // Alt+Backspace: terminals send ESC followed by DEL/BS. Must precede the
29
81
  // bare-ESC check so the two-byte sequence isn't swallowed as plain escape.
30
82
  // iTerm2 (and many readline configs) instead map Option/Alt+Backspace to a
package/dist/types.d.ts CHANGED
@@ -102,10 +102,12 @@ export type Phase = 'overview' | 'item-review' | 'final';
102
102
  export type InputMode = null | {
103
103
  kind: 'comment';
104
104
  buffer: string;
105
+ cursor: number;
105
106
  selectedOptionId?: string;
106
107
  } | {
107
108
  kind: 'freetext';
108
109
  buffer: string;
110
+ cursor: number;
109
111
  };
110
112
  export interface TuiState {
111
113
  phase: Phase;
@@ -181,6 +183,11 @@ export interface MountedPanelOpts {
181
183
  onProgress?: (responses: InteractionResponse[]) => void;
182
184
  onComplete?: (responses: InteractionResponse[]) => void;
183
185
  onExit?: () => void;
186
+ /** Fired when panel state changes outside a keypress — currently when an
187
+ * async visual finishes loading. Hosts wire this to a repaint so
188
+ * "loading context..." is replaced the moment context arrives, rather than
189
+ * sticking until the next keystroke. Optional / backward-compatible. */
190
+ onDirty?: () => void;
184
191
  }
185
192
  export interface MountedPanel {
186
193
  handleKey(input: string, key: Key): void;
@@ -197,4 +204,15 @@ export interface MountedPanel {
197
204
  * whether Esc should step back inside the deck (false) or tear it down (true).
198
205
  */
199
206
  atDeckTop(): boolean;
207
+ /**
208
+ * Live comment/freetext buffer, or undefined when not in input mode. Lets a
209
+ * host implement the $EDITOR escape hatch (ctrl+o) without reaching into
210
+ * panel-internal state: read the buffer here before spawning the editor.
211
+ */
212
+ getInputBuffer(): string | undefined;
213
+ /**
214
+ * Replace the input-mode buffer (e.g. after an $EDITOR round-trip) and move
215
+ * the cursor to the end. No-op when not in input mode.
216
+ */
217
+ setInputBuffer(text: string): void;
200
218
  }