@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/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 +13 -35
- 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 +128 -6
- package/dist/tui/input.js +132 -20
- package/dist/tui/render.d.ts +18 -2
- package/dist/tui/render.js +75 -163
- 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/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
|
-
|
|
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,10 +1,26 @@
|
|
|
1
1
|
import type { TuiState, Interaction, InteractionResponse } from '../types.js';
|
|
2
|
-
export declare function
|
|
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;
|