@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/api.js +1 -42
- package/dist/cli.js +9 -1
- package/dist/inbox/convention.d.ts +1 -1
- package/dist/inbox/convention.js +9 -2
- package/dist/inbox/tui.js +3 -32
- package/dist/summary.d.ts +9 -0
- package/dist/summary.js +45 -0
- package/dist/tui/ansi.d.ts +43 -0
- package/dist/tui/ansi.js +210 -0
- package/dist/tui/app.js +112 -4
- package/dist/tui/input.js +132 -20
- package/dist/tui/render.d.ts +17 -9
- package/dist/tui/render.js +50 -191
- package/dist/tui/terminal.d.ts +11 -0
- package/dist/tui/terminal.js +53 -1
- package/dist/types.d.ts +18 -0
- package/dist/visuals/generate.js +25 -41
- package/package.json +1 -1
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
|
-
|
|
364
|
+
deleteWordAtCursor(mode);
|
|
326
365
|
render();
|
|
327
366
|
return;
|
|
328
367
|
}
|
|
329
|
-
// Ctrl-U: delete
|
|
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
|
|
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
|
-
|
|
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(
|
|
391
|
+
.replace(/\r\n?/g, '\n')
|
|
392
|
+
.replace(/[\x00-\x09\x0B-\x1F\x7F]/g, '');
|
|
349
393
|
if (cleaned.length > 0) {
|
|
350
|
-
mode
|
|
394
|
+
insertAtCursor(mode, cleaned);
|
|
351
395
|
render();
|
|
352
396
|
}
|
|
353
397
|
}
|
|
354
|
-
function
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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) {
|
package/dist/tui/render.d.ts
CHANGED
|
@@ -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;
|
package/dist/tui/render.js
CHANGED
|
@@ -1,184 +1,6 @@
|
|
|
1
|
-
import stringWidth from 'string-width';
|
|
2
1
|
import { renderMarkdown } from '../render/termrender.js';
|
|
3
|
-
|
|
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
|
-
|
|
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 =
|
|
352
|
-
for (
|
|
353
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
381
|
-
|
|
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);
|
package/dist/tui/terminal.d.ts
CHANGED
|
@@ -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;
|
package/dist/tui/terminal.js
CHANGED
|
@@ -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 === '\
|
|
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
|
}
|