jaitus 0.3.4 → 0.3.6
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/README.md +35 -37
- package/bin/jaitus.js +8 -0
- package/package.json +2 -2
- package/src/agent/loop.js +186 -28
- package/src/agent/permissions.js +50 -17
- package/src/agent/rules.js +85 -14
- package/src/agent/tools.js +266 -50
- package/src/background.js +34 -7
- package/src/commands/commit.js +63 -46
- package/src/commands/doctor.js +1 -1
- package/src/commands/explain.js +5 -5
- package/src/commands/logout.js +2 -0
- package/src/commands/research.js +5 -6
- package/src/commands/review.js +8 -11
- package/src/commands/test.js +18 -14
- package/src/config.js +12 -1
- package/src/errors.js +4 -0
- package/src/index.js +567 -118
- package/src/mcp.js +209 -72
- package/src/project.js +0 -0
- package/src/session.js +18 -0
- package/src/sessions.js +20 -2
- package/src/terminal.js +46 -11
- package/src/tui/ansi.js +3 -1
- package/src/tui/clipboard.js +100 -7
- package/src/tui/entries.js +5 -5
- package/src/tui/highlight.js +4 -4
- package/src/tui/markdown.js +1 -1
- package/src/tui/theme.js +8 -0
- package/src/tui-ink.js +510 -106
- package/src/tui.js +4 -0
- package/src/ui.js +5 -1
package/src/tui-ink.js
CHANGED
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
// Reduced motion (`/animations off`, `JAITUS_NO_ANIMATION=1`) is a first-class mode:
|
|
22
22
|
// every animated widget has a still frame that reads correctly on its own.
|
|
23
23
|
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
24
26
|
import React from 'react';
|
|
25
27
|
import { Box, Spacer, Text, render, useAnimation, useApp, useCursor, useInput, usePaste, useWindowSize } from 'ink';
|
|
26
28
|
import { palette, resetPalette, mix, ramp } from './tui/theme.js';
|
|
@@ -30,7 +32,8 @@ import { Spinner, Shimmer, GradientText, FlowingRule, Meter, Elapsed, Caret, Row
|
|
|
30
32
|
import { wordRows, wordSize } from './tui/wordmark.js';
|
|
31
33
|
import { enableMouse, disableMouse, parseMouse, isMouse, isMouseFragment, createHitMap } from './tui/mouse.js';
|
|
32
34
|
import { openUrl } from './open.js';
|
|
33
|
-
import { readClipboardText, readClipboardImage } from './tui/clipboard.js';
|
|
35
|
+
import { readClipboardText, readClipboardImage, shrinkImageFile } from './tui/clipboard.js';
|
|
36
|
+
import { onSettingsChange } from './config.js';
|
|
34
37
|
import { MASCOT_ROWS, startupTip } from './brand.js';
|
|
35
38
|
|
|
36
39
|
const h = React.createElement;
|
|
@@ -370,6 +373,15 @@ function layoutTranscript(state, width, height, top) {
|
|
|
370
373
|
return { visible, pad, screenTop };
|
|
371
374
|
}
|
|
372
375
|
|
|
376
|
+
/**
|
|
377
|
+
* The key that pastes an image here. Windows Terminal and VS Code keep Ctrl+V for their
|
|
378
|
+
* own text paste, so on Windows it is Alt+V; on macOS Cmd+V is the terminal's and Ctrl+V
|
|
379
|
+
* reaches the app (Option+V types "√" unless Option is set to act as Meta).
|
|
380
|
+
*/
|
|
381
|
+
export function imagePasteHint(platform = process.platform) {
|
|
382
|
+
return platform === 'win32' ? 'alt+v paste image' : 'ctrl+v paste image';
|
|
383
|
+
}
|
|
384
|
+
|
|
373
385
|
/**
|
|
374
386
|
* How to select text, in words that are true for THIS terminal.
|
|
375
387
|
*
|
|
@@ -387,22 +399,54 @@ export function selectHint(env = process.env, platform = process.platform) {
|
|
|
387
399
|
return 'drag copies · shift drag: native';
|
|
388
400
|
}
|
|
389
401
|
|
|
390
|
-
/**
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
402
|
+
/**
|
|
403
|
+
* Register every URL on screen as a click target.
|
|
404
|
+
*
|
|
405
|
+
* A URL longer than the line is hard-cut across rows by wrapRow, so the match on the
|
|
406
|
+
* first row is only a prefix of it — clicking it opened a truncated address. wrapRow
|
|
407
|
+
* marks the rows it cut mid-word (`cut` on the last span); when a match runs to the end
|
|
408
|
+
* of such a row, the next row's leading token (after the hanging indent) is appended, and
|
|
409
|
+
* that piece is registered with the whole URL too.
|
|
410
|
+
*/
|
|
411
|
+
export function linkHitsFor(rows) {
|
|
412
|
+
const texts = rows.map((row) => rowText(row));
|
|
413
|
+
const cut = rows.map((row) => Boolean(row.length && row[row.length - 1].cut));
|
|
414
|
+
const out = [];
|
|
415
|
+
const claimed = new Set(); // "row:col" of continuation pieces, so they are not matched again
|
|
416
|
+
texts.forEach((text, index) => {
|
|
395
417
|
for (const match of text.matchAll(URL_IN_TEXT)) {
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
418
|
+
if (claimed.has(`${index}:${match.index}`)) continue;
|
|
419
|
+
let url = match[0];
|
|
420
|
+
const pieces = [{ row: index, x: match.index, len: match[0].length }];
|
|
421
|
+
let row = index;
|
|
422
|
+
let end = match.index + match[0].length;
|
|
423
|
+
while (cut[row] && end === texts[row].length && row + 1 < texts.length) {
|
|
424
|
+
const lead = /^(\s*)([^\s<>"'`]+)/.exec(texts[row + 1]);
|
|
425
|
+
if (!lead) break;
|
|
426
|
+
url += lead[2];
|
|
427
|
+
row += 1;
|
|
428
|
+
pieces.push({ row, x: lead[1].length, len: lead[2].length });
|
|
429
|
+
claimed.add(`${row}:${lead[1].length}`);
|
|
430
|
+
end = lead[0].length;
|
|
431
|
+
}
|
|
432
|
+
url = url.replace(/[.,;:)\]]+$/, '');
|
|
433
|
+
for (const piece of pieces) out.push({ ...piece, url });
|
|
404
434
|
}
|
|
405
435
|
});
|
|
436
|
+
return out;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function registerLinkHits(visible, screenTop, hits) {
|
|
440
|
+
if (!hits) return;
|
|
441
|
+
for (const piece of linkHitsFor(visible)) {
|
|
442
|
+
hits.add({
|
|
443
|
+
x1: 2 + piece.x,
|
|
444
|
+
x2: 2 + piece.x + piece.len - 1,
|
|
445
|
+
y1: screenTop + piece.row,
|
|
446
|
+
y2: screenTop + piece.row,
|
|
447
|
+
action: { type: 'link', url: piece.url },
|
|
448
|
+
});
|
|
449
|
+
}
|
|
406
450
|
}
|
|
407
451
|
|
|
408
452
|
function Transcript({ state, width, height, layout }) {
|
|
@@ -419,6 +463,10 @@ function Transcript({ state, width, height, layout }) {
|
|
|
419
463
|
// far more often than it names a path called `.`.
|
|
420
464
|
const URL_IN_TEXT = /https?:\/\/[^\s<>"'`]+/g;
|
|
421
465
|
|
|
466
|
+
// Replies to the startup probe (terminal.js), with or without the ESC Ink strips: OSC 11
|
|
467
|
+
// background colour, then CSI ? … c device attributes — alone or run together.
|
|
468
|
+
export const TERMINAL_REPLY = /^(?=[\x1b\][])(?:\x1b?\]11;rgb:[0-9a-f]{1,4}\/[0-9a-f]{1,4}\/[0-9a-f]{1,4}(?:\x07|\x1b\\)?)?(?:\x1b?\[\?[0-9;]*c)?$/i;
|
|
469
|
+
|
|
422
470
|
/**
|
|
423
471
|
* Draw the drag selection.
|
|
424
472
|
*
|
|
@@ -503,17 +551,17 @@ function Composer({ state, width }) {
|
|
|
503
551
|
// and might have to delete.
|
|
504
552
|
const composed = [];
|
|
505
553
|
let at = 0;
|
|
506
|
-
for (const match of state.input.matchAll(/\[#\d+ pasted [^\]]*\]/g)) {
|
|
507
|
-
if (match.index > at) composed.push({ t: state.input.slice(at, match.index), color: p.
|
|
554
|
+
for (const match of state.input.matchAll(/\[#\d+ pasted [^\]]*\]|\[Image #\d+\]/g)) {
|
|
555
|
+
if (match.index > at) composed.push({ t: state.input.slice(at, match.index), color: p.body });
|
|
508
556
|
composed.push({ t: match[0], color: p.purple, backgroundColor: p.raised });
|
|
509
557
|
at = match.index + match[0].length;
|
|
510
558
|
}
|
|
511
|
-
if (at < state.input.length) composed.push({ t: state.input.slice(at), color: p.
|
|
559
|
+
if (at < state.input.length) composed.push({ t: state.input.slice(at), color: p.body });
|
|
512
560
|
|
|
513
561
|
// The same lines the App measured the composer's height from (composerLayout): the
|
|
514
562
|
// frame height is fixed, so a composer one row taller than it was counted as pushes
|
|
515
563
|
// the whole screen up — that was the header vanishing after a multi-line paste.
|
|
516
|
-
const { lines, caretLine } = composerLayout(state, inner - 2, composed);
|
|
564
|
+
const { lines, caretLine, caretCol } = composerLayout(state, inner - 2, composed);
|
|
517
565
|
const placeholder = state.placeholder || 'Describe a change, ask a question, or / for commands';
|
|
518
566
|
|
|
519
567
|
const borderColor = state.busy ? p.brand : state.menu.length ? p.teal : p.line;
|
|
@@ -529,29 +577,46 @@ function Composer({ state, width }) {
|
|
|
529
577
|
h(Box, { flexDirection: 'row' },
|
|
530
578
|
h(Text, { color: promptColor, bold: true }, promptMark),
|
|
531
579
|
h(Box, { flexDirection: 'column', width: inner - 2 },
|
|
532
|
-
lines.map((row, index) =>
|
|
533
|
-
|
|
580
|
+
lines.map((row, index) => {
|
|
581
|
+
// The caret sits AT the cursor. It used to be drawn after the whole line, so
|
|
582
|
+
// moving left with the arrow keys showed no movement at all while typing went
|
|
583
|
+
// in somewhere in the middle.
|
|
584
|
+
const [left, right] = index === caretLine ? splitSpansAt(row, caretCol) : [row, []];
|
|
585
|
+
return h(Box, { key: index, flexDirection: 'row' },
|
|
586
|
+
h(Row, { spans: left, wrap: 'wrap' }),
|
|
534
587
|
// The caret is always present, including at an empty prompt. Hiding it there
|
|
535
588
|
// was meant to keep it off the placeholder, but the result is a prompt with
|
|
536
589
|
// no cursor at all — there is nothing on screen saying the app is waiting for
|
|
537
590
|
// typing, which is the one thing a prompt has to say. The placeholder sits
|
|
538
591
|
// AFTER the caret instead, so the caret is at the insertion point either way.
|
|
539
592
|
index === caretLine ? h(Caret, { color: p.brand, char: g('▏', '_') }) : null,
|
|
593
|
+
right.length ? h(Row, { spans: right, wrap: 'wrap' }) : null,
|
|
540
594
|
!state.input && index === 0
|
|
541
595
|
? h(Text, { color: p.faint, italic: true, wrap: 'truncate-end' }, placeholder)
|
|
542
596
|
: null,
|
|
543
|
-
|
|
597
|
+
);
|
|
598
|
+
}),
|
|
544
599
|
),
|
|
545
600
|
),
|
|
546
601
|
state.queued.length
|
|
547
602
|
? h(Box, { marginTop: 1 }, h(Text, { color: p.purple }, `${g('⏎', '>')} ${state.queued.length} message${state.queued.length === 1 ? '' : 's'} queued — they send when this turn finishes`))
|
|
548
603
|
: null,
|
|
549
|
-
state.
|
|
550
|
-
? h(Box, { marginTop: 1 }, h(Text, { color: p.purple
|
|
604
|
+
liveAttachments(state).length
|
|
605
|
+
? h(Box, { marginTop: 1 }, h(Text, { color: p.purple, wrap: 'truncate-end' },
|
|
606
|
+
`${liveAttachments(state).map((a) => `${g('⧉', '[img]')} #${a.id} ${a.name || 'image'}${a.bytes ? ` ${Math.max(1, Math.round(a.bytes / 1024))} KB` : ''}`).join(' ')}`,
|
|
607
|
+
h(Text, { color: p.faint }, ' · backspace over a chip removes it')))
|
|
551
608
|
: null,
|
|
552
609
|
);
|
|
553
610
|
}
|
|
554
611
|
|
|
612
|
+
/** Attachments whose chip is still in the input — the ones that will be sent. */
|
|
613
|
+
function liveAttachments(state) {
|
|
614
|
+
// Current attachments, and earlier ones a line recalled from history still names.
|
|
615
|
+
const all = new Map();
|
|
616
|
+
for (const a of [...(state.imageStore ? state.imageStore.values() : []), ...(state.attachments || [])]) if (a?.id) all.set(a.id, a);
|
|
617
|
+
return [...all.values()].filter((a) => state.input.includes(`[Image #${a.id}]`));
|
|
618
|
+
}
|
|
619
|
+
|
|
555
620
|
const COMPOSER_MAX_LINES = 8;
|
|
556
621
|
|
|
557
622
|
/** Spans split at every newline: one array of spans per line the user typed. */
|
|
@@ -584,27 +649,45 @@ function composerLayout(state, width, spans = null) {
|
|
|
584
649
|
const paragraphs = splitSpansAtNewlines(spans || [{ t: input }]);
|
|
585
650
|
const all = [];
|
|
586
651
|
let caret = 0;
|
|
652
|
+
let caretCol = 0;
|
|
587
653
|
let start = 0; // offset of this paragraph in `input`
|
|
588
654
|
const cursor = Math.max(0, Math.min(input.length, state.cursor ?? input.length));
|
|
589
655
|
for (const para of paragraphs) {
|
|
590
656
|
const text = para.map((s) => s.t).join('');
|
|
591
657
|
const rows = para.length ? wrapRow(para, width) : [[]];
|
|
592
658
|
if (cursor >= start && cursor <= start + text.length) {
|
|
593
|
-
let
|
|
659
|
+
let rowStart = start;
|
|
594
660
|
let line = rows.length - 1;
|
|
661
|
+
let col = 0;
|
|
595
662
|
for (let i = 0; i < rows.length; i++) {
|
|
596
|
-
|
|
597
|
-
if (cursor <=
|
|
598
|
-
|
|
663
|
+
const len = rows[i].map((s) => s.t).join('').length;
|
|
664
|
+
if (cursor <= rowStart + len || i === rows.length - 1) { line = i; col = Math.max(0, Math.min(len, cursor - rowStart)); break; }
|
|
665
|
+
rowStart += len + 1; // + the space the wrap consumed
|
|
599
666
|
}
|
|
600
667
|
caret = all.length + line;
|
|
668
|
+
caretCol = col;
|
|
601
669
|
}
|
|
602
670
|
all.push(...rows);
|
|
603
671
|
start += text.length + 1; // + the newline
|
|
604
672
|
}
|
|
605
|
-
if (all.length <= COMPOSER_MAX_LINES) return { lines: all, caretLine: caret, total: all.length };
|
|
673
|
+
if (all.length <= COMPOSER_MAX_LINES) return { lines: all, caretLine: caret, caretCol, total: all.length };
|
|
606
674
|
const first = Math.max(0, Math.min(caret - COMPOSER_MAX_LINES + 1, all.length - COMPOSER_MAX_LINES));
|
|
607
|
-
return { lines: all.slice(first, first + COMPOSER_MAX_LINES), caretLine: caret - first, total: all.length };
|
|
675
|
+
return { lines: all.slice(first, first + COMPOSER_MAX_LINES), caretLine: caret - first, caretCol, total: all.length };
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** A row of spans cut at character `n`: [before, after]. */
|
|
679
|
+
function splitSpansAt(row, n) {
|
|
680
|
+
const before = [];
|
|
681
|
+
const after = [];
|
|
682
|
+
let seen = 0;
|
|
683
|
+
for (const span of row || []) {
|
|
684
|
+
const t = String(span.t);
|
|
685
|
+
if (seen + t.length <= n) before.push(span);
|
|
686
|
+
else if (seen >= n) after.push(span);
|
|
687
|
+
else { before.push({ ...span, t: t.slice(0, n - seen) }); after.push({ ...span, t: t.slice(n - seen) }); }
|
|
688
|
+
seen += t.length;
|
|
689
|
+
}
|
|
690
|
+
return [before, after];
|
|
608
691
|
}
|
|
609
692
|
|
|
610
693
|
function StatusBar({ state, width }) {
|
|
@@ -630,7 +713,10 @@ function StatusBar({ state, width }) {
|
|
|
630
713
|
: [
|
|
631
714
|
['ctrl+p palette', p.faint],
|
|
632
715
|
['shift+tab mode', p.faint],
|
|
633
|
-
|
|
716
|
+
// How to paste a screenshot was written only in the README, and the key people
|
|
717
|
+
// try first (Ctrl+V in Windows Terminal, Cmd+V on a Mac) belongs to the terminal,
|
|
718
|
+
// which has no text to paste and sends the app nothing.
|
|
719
|
+
[imagePasteHint(), p.faint],
|
|
634
720
|
['esc esc rewind', p.faint],
|
|
635
721
|
// Mouse reporting takes the terminal's own text selection away, so the way to
|
|
636
722
|
// get it back has to be on screen — otherwise the first thing a new user
|
|
@@ -686,9 +772,27 @@ function Dialog({ state, width, height, hits }) {
|
|
|
686
772
|
const dialog = state.dialog;
|
|
687
773
|
if (!dialog) return null;
|
|
688
774
|
const boxWidth = Math.min(Math.max(48, Math.floor(width * 0.78)), width - 4);
|
|
689
|
-
const previewRows = (dialog.preview || []).slice(0, 12);
|
|
690
775
|
const questionRows = wrapRow([{ t: plain(dialog.question) }], boxWidth - 6).length;
|
|
691
|
-
|
|
776
|
+
// What is being approved is WRAPPED, never cut at the box edge: a long command's tail
|
|
777
|
+
// (`… && curl x | sh`) was simply not shown, so it could be approved unseen. When it
|
|
778
|
+
// is taller than the screen allows, the start and the END are shown with the count of
|
|
779
|
+
// what is hidden between them.
|
|
780
|
+
const wrappedPreview = (dialog.preview || []).flatMap((line) => wrapRow(previewSpans(line, p), boxWidth - 6));
|
|
781
|
+
const roomForPreview = Math.max(4, height - (questionRows + dialog.options.length + 12));
|
|
782
|
+
let previewRows = wrappedPreview;
|
|
783
|
+
if (wrappedPreview.length > roomForPreview) {
|
|
784
|
+
const head = Math.floor((roomForPreview - 1) / 2);
|
|
785
|
+
const tail = roomForPreview - 1 - head;
|
|
786
|
+
const hidden = wrappedPreview.length - head - tail;
|
|
787
|
+
previewRows = [...wrappedPreview.slice(0, head), [{ t: `… ${hidden} more line${hidden === 1 ? '' : 's'} …`, color: p.yellow }], ...wrappedPreview.slice(-tail)];
|
|
788
|
+
}
|
|
789
|
+
// More options than fit: a window that follows the selection (a picker of 20 sessions
|
|
790
|
+
// or models no longer has to be cut to the 9 that have a number key).
|
|
791
|
+
const roomForOptions = Math.max(3, height - questionRows - previewRows.length - 12);
|
|
792
|
+
const optionCount = Math.min(dialog.options.length, roomForOptions);
|
|
793
|
+
const firstOption = Math.max(0, Math.min(dialog.index - Math.floor(optionCount / 2), dialog.options.length - optionCount));
|
|
794
|
+
const visibleOptions = dialog.options.slice(firstOption, firstOption + optionCount);
|
|
795
|
+
const rows = dialogHeight(questionRows, previewRows.length, optionCount);
|
|
692
796
|
const accent = dialog.tone === 'danger' ? p.red : p.brand;
|
|
693
797
|
|
|
694
798
|
const top = Math.max(0, Math.round((height - rows) / 2));
|
|
@@ -699,10 +803,10 @@ function Dialog({ state, width, height, hits }) {
|
|
|
699
803
|
// arithmetic mirrors dialogHeight() exactly — terminal rows are 1-based, hence the +1.
|
|
700
804
|
hits?.add({ x1: left + 1, x2: left + boxWidth, y1: top + 1, y2: top + rows, action: { type: 'swallow' } });
|
|
701
805
|
const optionsTop = top + 2 + 1 + 1 + questionRows + (previewRows.length ? 1 + previewRows.length : 0) + 1;
|
|
702
|
-
|
|
806
|
+
visibleOptions.forEach((option, index) => {
|
|
703
807
|
hits?.add({
|
|
704
808
|
x1: left + 1, x2: left + boxWidth, y1: optionsTop + index + 1, y2: optionsTop + index + 1,
|
|
705
|
-
action: { type: 'dialogOption', index },
|
|
809
|
+
action: { type: 'dialogOption', index: firstOption + index },
|
|
706
810
|
});
|
|
707
811
|
});
|
|
708
812
|
|
|
@@ -727,17 +831,18 @@ function Dialog({ state, width, height, hits }) {
|
|
|
727
831
|
.map((row, index) => h(Row, { key: index, spans: row }))),
|
|
728
832
|
previewRows.length
|
|
729
833
|
? h(Box, { marginTop: 1, flexDirection: 'column' },
|
|
730
|
-
previewRows.map((
|
|
731
|
-
key: index,
|
|
732
|
-
spans: truncateRow(previewSpans(line, p), boxWidth - 6),
|
|
733
|
-
})))
|
|
834
|
+
previewRows.map((row, index) => h(Row, { key: index, spans: row })))
|
|
734
835
|
: null,
|
|
735
836
|
h(Box, { marginTop: 1, flexDirection: 'column' },
|
|
736
|
-
|
|
837
|
+
visibleOptions.map((option, i) => {
|
|
838
|
+
const index = firstOption + i;
|
|
737
839
|
const selected = index === dialog.index;
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
840
|
+
const more = (i === 0 && firstOption > 0) ? ` ${g('↑', '^')}${firstOption} more`
|
|
841
|
+
: (i === visibleOptions.length - 1 && firstOption + optionCount < dialog.options.length) ? ` ${g('↓', 'v')}${dialog.options.length - firstOption - optionCount} more` : '';
|
|
842
|
+
return h(Box, { key: `${option.value}-${index}`, backgroundColor: selected ? p.raised : undefined, flexDirection: 'row' },
|
|
843
|
+
h(Text, { color: selected ? accent : p.muted, bold: selected, wrap: 'truncate-end' },
|
|
844
|
+
`${selected ? g(' ▸ ', ' > ') : ' '}${index < 9 ? `${index + 1}.` : ' '} ${plain(option.label)}`),
|
|
845
|
+
more ? h(Text, { color: p.faint }, more) : null);
|
|
741
846
|
})),
|
|
742
847
|
h(Box, { marginTop: 1 },
|
|
743
848
|
h(Text, { color: p.faint }, `${g('↑↓', 'up/dn')} choose · 1-9 jump · enter confirm · esc ${dialog.hideComment ? 'cancel' : 'deny'}`)),
|
|
@@ -779,7 +884,7 @@ function Panel({ state, width, height, hits }) {
|
|
|
779
884
|
const name = typeof option === 'string' ? option : option.name;
|
|
780
885
|
const summary = typeof option === 'string' ? '' : option.summary || '';
|
|
781
886
|
return h(Box, { key: `${name}-${index}`, backgroundColor: selected ? p.raised : undefined, flexDirection: 'row' },
|
|
782
|
-
h(Text, { color: selected ? p.brand : p.muted, bold: selected }, `${selected ? g(' ▸ ', ' > ') : ' '}${name.padEnd(Math.min(26, boxWidth - 10))}`),
|
|
887
|
+
h(Text, { color: selected ? p.brand : p.muted, bold: selected, wrap: 'truncate-end' }, `${selected ? g(' ▸ ', ' > ') : ' '}${name.padEnd(Math.min(26, boxWidth - 10))}`),
|
|
783
888
|
summary ? h(Text, { color: p.faint, wrap: 'truncate-end' }, summary) : null,
|
|
784
889
|
);
|
|
785
890
|
});
|
|
@@ -788,12 +893,22 @@ function Panel({ state, width, height, hits }) {
|
|
|
788
893
|
bodyRows = 1;
|
|
789
894
|
}
|
|
790
895
|
} else {
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
896
|
+
// Scrollable. Only the last maxRows used to be drawn, with no way up — the top of a
|
|
897
|
+
// long /diff or /tasks was unreachable. `follow` (a live panel at its bottom) keeps
|
|
898
|
+
// showing the newest lines as they arrive.
|
|
899
|
+
const all = panel.rows?.length ? panel.rows : (panel.lines || []);
|
|
900
|
+
const total = all.length;
|
|
901
|
+
const maxStart = Math.max(0, total - maxRows);
|
|
902
|
+
panel._maxRows = maxRows;
|
|
903
|
+
panel._maxStart = maxStart;
|
|
904
|
+
const start = panel.follow ? maxStart : Math.max(0, Math.min(panel.scroll || 0, maxStart));
|
|
905
|
+
panel.scroll = start;
|
|
906
|
+
panel._range = total > maxRows ? `${start + 1}–${Math.min(total, start + maxRows)} of ${total}` : '';
|
|
907
|
+
const view = all.slice(start, start + maxRows);
|
|
908
|
+
bodyRows = Math.max(1, view.length);
|
|
909
|
+
body = panel.rows?.length
|
|
910
|
+
? [h(Rows, { key: 'rows', rows: view })]
|
|
911
|
+
: view.map((line, index) => h(Text, { key: index, color: p.text, wrap: 'truncate-end' }, plain(line)));
|
|
797
912
|
}
|
|
798
913
|
|
|
799
914
|
// border 2 + padY 2 + title 1 + gap 1 + body + gap 1 + hint 1
|
|
@@ -830,8 +945,8 @@ function Panel({ state, width, height, hits }) {
|
|
|
830
945
|
),
|
|
831
946
|
h(Box, { marginTop: 1, flexDirection: 'column' }, body),
|
|
832
947
|
h(Box, { marginTop: 1 }, h(Text, { color: p.faint }, panel.palette
|
|
833
|
-
? `type to filter · ${g('↑↓', 'up/dn')} choose · enter run · esc close`
|
|
834
|
-
: `${g('↑↓', 'up/dn')} scroll · esc close`)),
|
|
948
|
+
? `type to filter · ${g('↑↓', 'up/dn')} choose · ${panel.onPick ? 'enter pick' : 'enter run'} · esc close`
|
|
949
|
+
: `${panel._range ? `${panel._range} · ${g('↑↓', 'up/dn')} pgup/pgdn scroll · ` : ''}esc close`)),
|
|
835
950
|
),
|
|
836
951
|
),
|
|
837
952
|
);
|
|
@@ -883,13 +998,15 @@ function App({ controller }) {
|
|
|
883
998
|
+ (state.menu.length ? state.menu.length + 1 : 0)
|
|
884
999
|
+ Math.max(0, composerTextRows(state, width) - 1)
|
|
885
1000
|
+ (state.queued.length ? 2 : 0)
|
|
886
|
-
+ (state.
|
|
1001
|
+
+ (liveAttachments(state).length ? 2 : 0);
|
|
887
1002
|
const workRows = state.busy || state.status ? 1 : 0;
|
|
888
1003
|
// A single pinned line showing what was last asked, while the transcript is scrolled
|
|
889
1004
|
// away from it. Scrolling back through a long answer and losing track of the question
|
|
890
1005
|
// it was answering is the specific thing this fixes.
|
|
891
1006
|
const stickyRows = state.viewingTranscript ? 1 : 0;
|
|
892
1007
|
const transcriptHeight = Math.max(3, height - CHROME.header - CHROME.status - composerRows - workRows - CHROME.gapAboveComposer - stickyRows);
|
|
1008
|
+
state._transcriptHeight = transcriptHeight;
|
|
1009
|
+
state._width = width;
|
|
893
1010
|
|
|
894
1011
|
// The welcome stands until there is real conversation. Anything chrome-ish — a
|
|
895
1012
|
// notice, a stray pre-formatted print from an older code path — must not suppress it:
|
|
@@ -994,6 +1111,18 @@ function registerHeaderHits(state, width, hits) {
|
|
|
994
1111
|
|
|
995
1112
|
export function createTui({ slashCommands = [], fileProvider = null, meta = {}, onModeCycle = null, settings = null } = {}) {
|
|
996
1113
|
publishSettings(settings);
|
|
1114
|
+
// Settings changed during the session apply now, not at the next start.
|
|
1115
|
+
onSettingsChange((next) => {
|
|
1116
|
+
const mouseBefore = settings?.mouse !== false;
|
|
1117
|
+
settings = next || {};
|
|
1118
|
+
publishSettings(settings);
|
|
1119
|
+
const mouseNow = settings.mouse !== false && !process.env.JAITUS_DISABLE_MOUSE;
|
|
1120
|
+
if (state?.active && mouseBefore !== mouseNow) {
|
|
1121
|
+
if (mouseNow) state.mouse = enableMouse(process.stdout);
|
|
1122
|
+
else { disableMouse(process.stdout); state.mouse = false; }
|
|
1123
|
+
}
|
|
1124
|
+
if (state?.active) notifyNow();
|
|
1125
|
+
});
|
|
997
1126
|
|
|
998
1127
|
const commands = slashCommands.map((command) => (typeof command === 'string' ? { name: command, summary: '' } : command));
|
|
999
1128
|
|
|
@@ -1094,6 +1223,9 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1094
1223
|
}
|
|
1095
1224
|
|
|
1096
1225
|
function refreshMenu() {
|
|
1226
|
+
// Dismissed with Esc (or a recalled history line): stays closed until the text changes.
|
|
1227
|
+
if (state.menuDismissedFor !== undefined && state.menuDismissedFor === state.input) { state.menu = []; state.menuIndex = -1; return; }
|
|
1228
|
+
state.menuDismissedFor = undefined;
|
|
1097
1229
|
state.menu = state.dialog || state.panel ? [] : menuItemsFor(state.input, commands, fileProvider, state.cursor);
|
|
1098
1230
|
state.menuIndex = state.menu.length ? clamp(state.menuIndex < 0 ? 0 : state.menuIndex, 0, state.menu.length - 1) : -1;
|
|
1099
1231
|
}
|
|
@@ -1139,8 +1271,11 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1139
1271
|
return true;
|
|
1140
1272
|
}
|
|
1141
1273
|
|
|
1142
|
-
|
|
1143
|
-
|
|
1274
|
+
// A file keeps its `@`: without it the mention is plain text, so choosing a file
|
|
1275
|
+
// from the menu used to attach nothing at all. (Command names carry their own `/`.)
|
|
1276
|
+
const value = item.kind === 'file' ? `@${item.value}` : String(item.value);
|
|
1277
|
+
state.input = `${prefix}${value} ${suffix}`;
|
|
1278
|
+
state.cursor = prefix.length + value.length + 1;
|
|
1144
1279
|
refreshMenu();
|
|
1145
1280
|
notifyNow();
|
|
1146
1281
|
return true;
|
|
@@ -1157,7 +1292,12 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1157
1292
|
*/
|
|
1158
1293
|
function handlePaste(text) {
|
|
1159
1294
|
const value = String(text ?? '').replace(/\r\n?/g, '\n');
|
|
1160
|
-
|
|
1295
|
+
// An empty paste is what a terminal sends for its paste key when the clipboard holds
|
|
1296
|
+
// only an image — it has no text to give. The image is right there: fetch it.
|
|
1297
|
+
if (!value) { void pasteFromClipboard({ imageOnly: true }); return; }
|
|
1298
|
+
// Dragging image files onto the terminal pastes their paths: attach them instead.
|
|
1299
|
+
const images = imagePathsIn(value);
|
|
1300
|
+
if (images) { void attachImageFiles(images); return; }
|
|
1161
1301
|
const lines = value.split('\n').length;
|
|
1162
1302
|
|
|
1163
1303
|
if (lines <= 3 && value.length <= 240) {
|
|
@@ -1165,8 +1305,11 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1165
1305
|
return;
|
|
1166
1306
|
}
|
|
1167
1307
|
|
|
1168
|
-
|
|
1308
|
+
// Numbered for the whole session. `pastes.length + 1` reused a number after a chip was
|
|
1309
|
+
// deleted, so two chips could share it and both expand to the same content.
|
|
1310
|
+
const id = ++pasteSeq;
|
|
1169
1311
|
state.pastes.push({ id, text: value });
|
|
1312
|
+
remember(pasteStore, id, { id, text: value });
|
|
1170
1313
|
// The token has to survive being typed around, so it is bracketed and carries its
|
|
1171
1314
|
// own index; expansion matches on that rather than on position.
|
|
1172
1315
|
insert(`[#${id} pasted ${lines} line${lines === 1 ? '' : 's'}, ${value.length} chars]`);
|
|
@@ -1179,31 +1322,22 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1179
1322
|
* no text, an image on the clipboard becomes an attachment.
|
|
1180
1323
|
*/
|
|
1181
1324
|
let pasting = false;
|
|
1325
|
+
let returnToLiveTimer = null;
|
|
1182
1326
|
async function pasteFromClipboard({ imageOnly = false } = {}) {
|
|
1183
1327
|
if (pasting) return;
|
|
1184
1328
|
pasting = true;
|
|
1185
1329
|
try {
|
|
1186
1330
|
const text = imageOnly ? null : await (api._readClipboardText || readClipboardText)();
|
|
1187
1331
|
if (text) { handlePaste(text); notifyNow(); return; }
|
|
1188
|
-
const
|
|
1189
|
-
if (
|
|
1190
|
-
const fs = await import('node:fs');
|
|
1332
|
+
const img = await (api._readClipboardImage || readClipboardImage)();
|
|
1333
|
+
if (img?.bytes?.length) {
|
|
1191
1334
|
const os = await import('node:os');
|
|
1192
|
-
const
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
const { imageBlock } = await import('./agent/tools.js');
|
|
1196
|
-
const block = await imageBlock(file).catch(() => null);
|
|
1197
|
-
if (block) {
|
|
1198
|
-
state.attachments.push({ name: `image ${state.attachments.length + 1}`, block });
|
|
1199
|
-
flash('Image attached — it is sent with your next message', 'info', 2000);
|
|
1200
|
-
} else {
|
|
1201
|
-
flash('That image could not be read (png, jpg, gif or webp, up to about 3 MB)', 'warn', 2500);
|
|
1202
|
-
}
|
|
1203
|
-
notifyNow();
|
|
1335
|
+
const file = path.join(os.tmpdir(), `jaitus-clip-${Date.now()}${img.ext || '.png'}`);
|
|
1336
|
+
fs.writeFileSync(file, img.bytes);
|
|
1337
|
+
await attachImageFiles([file], { name: 'clipboard image' });
|
|
1204
1338
|
return;
|
|
1205
1339
|
}
|
|
1206
|
-
flash(imageOnly ? 'No image on the clipboard' :
|
|
1340
|
+
flash(imageOnly ? 'No image on the clipboard — copy a screenshot first' : `The clipboard is empty · ${imagePasteHint()}`, 'info', 1800);
|
|
1207
1341
|
} catch {
|
|
1208
1342
|
flash('Could not read the clipboard', 'warn', 2000);
|
|
1209
1343
|
} finally {
|
|
@@ -1218,20 +1352,83 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1218
1352
|
// so the content it stands for is silently orphaned and the literal text is what gets
|
|
1219
1353
|
// sent. Deleting it whole is both what you meant and the only way the placeholder and
|
|
1220
1354
|
// the thing it holds cannot come apart.
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
const
|
|
1355
|
+
// An attached image is a chip too — `[Image #2]` — so it is removed the same way: one
|
|
1356
|
+
// Backspace over it. There was no way at all to take an attachment back before.
|
|
1357
|
+
const CHIP = /\[#(\d+) pasted [^\]]*\]|\[Image #(\d+)\]/;
|
|
1358
|
+
const chipBefore = (text, cursor) => new RegExp(`(?:${CHIP.source})$`).exec(text.slice(0, cursor));
|
|
1359
|
+
const chipAfter = (text, cursor) => new RegExp(`^(?:${CHIP.source})`).exec(text.slice(cursor));
|
|
1224
1360
|
|
|
1225
1361
|
/** Forget the content a chip stood for, once the chip is gone. */
|
|
1226
|
-
function dropPaste(
|
|
1227
|
-
state.pastes = state.pastes.filter((entry) => entry.id !== Number(
|
|
1362
|
+
function dropPaste(match) {
|
|
1363
|
+
if (match?.[1]) state.pastes = state.pastes.filter((entry) => entry.id !== Number(match[1]));
|
|
1364
|
+
if (match?.[2]) {
|
|
1365
|
+
const before = state.attachments.length;
|
|
1366
|
+
state.attachments = state.attachments.filter((a) => a.id !== Number(match[2]));
|
|
1367
|
+
if (state.attachments.length < before) flash(`Image #${match[2]} removed`, 'info', 1400);
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
// ── images ──
|
|
1372
|
+
let imageSeq = 0;
|
|
1373
|
+
let pasteSeq = 0;
|
|
1374
|
+
// What earlier chips stood for, so a message recalled from history is sent whole.
|
|
1375
|
+
// Bounded: images are the big part.
|
|
1376
|
+
const pasteStore = new Map();
|
|
1377
|
+
const imageStore = new Map();
|
|
1378
|
+
state.imageStore = imageStore;
|
|
1379
|
+
const remember = (store, id, value, max = store === imageStore ? 12 : 60) => {
|
|
1380
|
+
store.set(id, value);
|
|
1381
|
+
while (store.size > max) store.delete(store.keys().next().value);
|
|
1382
|
+
};
|
|
1383
|
+
/** Add an image: a chip at the cursor, the pixels kept aside until the message is sent. */
|
|
1384
|
+
function attachImage({ name, block }) {
|
|
1385
|
+
const id = ++imageSeq;
|
|
1386
|
+
const bytes = Math.round((String(block?.source?.data || '').length * 3) / 4);
|
|
1387
|
+
state.attachments.push({ id, name: name || `image ${id}`, block, bytes });
|
|
1388
|
+
remember(imageStore, id, { id, name: name || `image ${id}`, block, bytes });
|
|
1389
|
+
const before = state.input.slice(0, state.cursor);
|
|
1390
|
+
insert(`${before && !/\s$/.test(before) ? ' ' : ''}[Image #${id}] `);
|
|
1391
|
+
flash(`Image #${id} attached${bytes ? ` (${Math.max(1, Math.round(bytes / 1024))} KB)` : ''} — Backspace over it removes it`, 'info', 2400);
|
|
1392
|
+
notifyNow();
|
|
1393
|
+
return id;
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
/**
|
|
1397
|
+
* A paste that is nothing but paths to image files — what dragging a screenshot onto a
|
|
1398
|
+
* terminal window produces — attaches them instead of typing the paths. Quoted paths
|
|
1399
|
+
* (Windows, with spaces) and backslash-escaped spaces (macOS) are both understood.
|
|
1400
|
+
*/
|
|
1401
|
+
function imagePathsIn(text) {
|
|
1402
|
+
const raw = String(text || '').trim();
|
|
1403
|
+
if (!raw || raw.length > 4000) return null;
|
|
1404
|
+
const parts = [];
|
|
1405
|
+
const re = /"([^"]+)"|'([^']+)'|((?:\\ |[^\s"'])+)/g;
|
|
1406
|
+
for (const m of raw.matchAll(re)) parts.push((m[1] ?? m[2] ?? m[3]).replace(/\\ /g, ' '));
|
|
1407
|
+
if (!parts.length) return null;
|
|
1408
|
+
const files = parts.map((p) => p.replace(/^file:\/\/\/?/, '')).map((p) => {
|
|
1409
|
+
try { return /\.(png|jpe?g|gif|webp)$/i.test(p) && fs.statSync(p).isFile() ? p : null; } catch { return null; }
|
|
1410
|
+
});
|
|
1411
|
+
return files.every(Boolean) ? files : null;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
async function attachImageFiles(files, { name = null } = {}) {
|
|
1415
|
+
const { imageBlock } = await import('./agent/tools.js');
|
|
1416
|
+
for (const file of files) {
|
|
1417
|
+
const label = name || path.basename(file);
|
|
1418
|
+
// Too big as it is (a 4K screenshot): a smaller copy is made first.
|
|
1419
|
+
const ready = await shrinkImageFile(file).catch(() => null);
|
|
1420
|
+
const block = ready ? await imageBlock(ready).catch(() => null) : null;
|
|
1421
|
+
if (block) attachImage({ name: label, block });
|
|
1422
|
+
else flash(`${label} could not be attached — it must be png, jpg, gif or webp, and it could not be made smaller than 3.5 MB`, 'warn', 3200);
|
|
1423
|
+
}
|
|
1228
1424
|
}
|
|
1229
1425
|
|
|
1230
1426
|
/** Put the real content back in place of every placeholder. */
|
|
1231
1427
|
function expandPastes(line) {
|
|
1232
|
-
if (!state.pastes.length) return line;
|
|
1233
1428
|
return line.replace(/\[#(\d+) pasted [^\]]*\]/g, (whole, id) => {
|
|
1234
|
-
|
|
1429
|
+
// The current pastes, then earlier ones: a line recalled from history with Up still
|
|
1430
|
+
// has its content — it used to send the literal placeholder text.
|
|
1431
|
+
const blob = state.pastes.find((entry) => entry.id === Number(id)) || pasteStore.get(Number(id));
|
|
1235
1432
|
return blob ? blob.text : whole;
|
|
1236
1433
|
});
|
|
1237
1434
|
}
|
|
@@ -1245,6 +1442,7 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1245
1442
|
// History keeps the TOKEN form. Pressing Up after sending a thousand-line paste
|
|
1246
1443
|
// should bring back the sentence you wrote, not the thousand lines.
|
|
1247
1444
|
if (state.history.at(-1) !== typed) state.history.push(typed);
|
|
1445
|
+
state.lastTyped = typed;
|
|
1248
1446
|
state.historyIndex = state.history.length;
|
|
1249
1447
|
state.viewingTranscript = false;
|
|
1250
1448
|
state.transcriptOffset = 0;
|
|
@@ -1260,6 +1458,9 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1260
1458
|
state.historyIndex = clamp(state.historyIndex + direction, 0, state.history.length);
|
|
1261
1459
|
state.input = state.historyIndex === state.history.length ? (state.draft || '') : state.history[state.historyIndex];
|
|
1262
1460
|
state.cursor = state.input.length;
|
|
1461
|
+
// A recalled `/review` must not open the command menu: Up then moved inside the menu
|
|
1462
|
+
// instead of on to the older prompt, and Enter ran whichever row was highlighted.
|
|
1463
|
+
state.menuDismissedFor = state.input;
|
|
1263
1464
|
refreshMenu();
|
|
1264
1465
|
notifyNow();
|
|
1265
1466
|
}
|
|
@@ -1408,7 +1609,7 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1408
1609
|
// Not "Ctrl+Shift+C": that is the TERMINAL's copy shortcut and it acts on the
|
|
1409
1610
|
// terminal's own selection, which does not exist while the app owns the mouse.
|
|
1410
1611
|
// Naming a shortcut that cannot work is worse than naming none.
|
|
1411
|
-
flash(`${text.length} characters selected ·
|
|
1612
|
+
flash(`${text.length} characters selected · Ctrl+C copies them`, 'info', 4000);
|
|
1412
1613
|
return;
|
|
1413
1614
|
}
|
|
1414
1615
|
const ok = await (state.onCopy ? state.onCopy(text) : Promise.resolve(false));
|
|
@@ -1429,6 +1630,11 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1429
1630
|
*/
|
|
1430
1631
|
function dispatchClick(region, event) {
|
|
1431
1632
|
if (!region) return;
|
|
1633
|
+
// While a dialog or panel is open, only its own controls respond. A click on the
|
|
1634
|
+
// header during a permission prompt opened the model picker OVER the prompt, which
|
|
1635
|
+
// was never answered — the session was stuck until the terminal was closed.
|
|
1636
|
+
const own = ['swallow', 'dialogOption', 'paletteRow', 'menuRow'];
|
|
1637
|
+
if ((state.dialog || state.panel) && !own.includes(region.action.type)) return;
|
|
1432
1638
|
switch (region.action.type) {
|
|
1433
1639
|
case 'swallow':
|
|
1434
1640
|
return;
|
|
@@ -1445,6 +1651,7 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1445
1651
|
const chosen = panel.options[panel.index];
|
|
1446
1652
|
const name = typeof chosen === 'string' ? chosen : chosen?.name;
|
|
1447
1653
|
state.panel = null;
|
|
1654
|
+
if (panel.onPick) { if (chosen) panel.onPick(chosen); return notifyNow(); }
|
|
1448
1655
|
refreshMenu();
|
|
1449
1656
|
if (!name) return notifyNow();
|
|
1450
1657
|
state.input = name;
|
|
@@ -1473,17 +1680,33 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1473
1680
|
return openPalette();
|
|
1474
1681
|
case 'transcript':
|
|
1475
1682
|
// A click in the body is how you get back to the live end after scrolling.
|
|
1683
|
+
// Deferred, and cancelled by a second click: a double-click to select a word
|
|
1684
|
+
// while scrolled back used to jump to the live end on the first click, so the
|
|
1685
|
+
// second one selected whatever had moved under the pointer.
|
|
1476
1686
|
if (state.viewingTranscript && event.button === 'left') {
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1687
|
+
clearTimeout(returnToLiveTimer);
|
|
1688
|
+
returnToLiveTimer = setTimeout(() => {
|
|
1689
|
+
state.viewingTranscript = false;
|
|
1690
|
+
state.transcriptOffset = 0;
|
|
1691
|
+
state.newMessages = 0;
|
|
1692
|
+
notifyNow();
|
|
1693
|
+
}, 350);
|
|
1481
1694
|
}
|
|
1482
1695
|
return;
|
|
1483
1696
|
default:
|
|
1484
1697
|
}
|
|
1485
1698
|
}
|
|
1486
1699
|
|
|
1700
|
+
/** Scroll the open panel by `delta` rows; at the bottom it follows new output again. */
|
|
1701
|
+
function scrollPanel(delta) {
|
|
1702
|
+
const panel = state.panel;
|
|
1703
|
+
if (!panel || panel.palette) return;
|
|
1704
|
+
const max = panel._maxStart ?? 0;
|
|
1705
|
+
panel.scroll = Math.max(0, Math.min(max, (panel.follow ? max : panel.scroll || 0) + delta));
|
|
1706
|
+
panel.follow = panel.scroll >= max && !!panel.live;
|
|
1707
|
+
notifyNow();
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1487
1710
|
function openPalette() {
|
|
1488
1711
|
state.panel = {
|
|
1489
1712
|
title: 'COMMAND PALETTE', palette: true, filter: '',
|
|
@@ -1510,13 +1733,24 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1510
1733
|
mouseFragment = { text: input, at: Date.now() };
|
|
1511
1734
|
return;
|
|
1512
1735
|
}
|
|
1736
|
+
// A terminal that answers the startup probe late (over SSH, under a multiplexer)
|
|
1737
|
+
// delivers the background-colour or device-attributes reply after the app has
|
|
1738
|
+
// started; Ink passes it through as typed text.
|
|
1739
|
+
if (TERMINAL_REPLY.test(input)) return;
|
|
1513
1740
|
if (isMouse(input)) {
|
|
1514
1741
|
mouseReports += 1;
|
|
1515
1742
|
lastMouseAt = Date.now();
|
|
1516
1743
|
const event = parseMouse(input);
|
|
1517
1744
|
if (!event) return;
|
|
1518
|
-
|
|
1519
|
-
|
|
1745
|
+
// The wheel scrolls what is in front: a panel, the palette's list, or nothing under
|
|
1746
|
+
// a dialog. It used to scroll the transcript hidden behind them.
|
|
1747
|
+
if (event.kind === 'wheelUp' || event.kind === 'wheelDown') {
|
|
1748
|
+
const up = event.kind === 'wheelUp';
|
|
1749
|
+
if (state.dialog) return undefined;
|
|
1750
|
+
if (state.panel?.palette) { state.panel.index = clamp(state.panel.index + (up ? -1 : 1), 0, Math.max(0, state.panel.options.length - 1)); return notifyNow(); }
|
|
1751
|
+
if (state.panel) return scrollPanel(up ? -3 : 3);
|
|
1752
|
+
return scroll(up ? scrollStep() : -scrollStep());
|
|
1753
|
+
}
|
|
1520
1754
|
// Right-click pastes, as it does in a Windows console — the terminal cannot do it
|
|
1521
1755
|
// itself while the app has the mouse.
|
|
1522
1756
|
if (event.kind === 'down' && event.button === 'right') {
|
|
@@ -1525,6 +1759,7 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1525
1759
|
}
|
|
1526
1760
|
|
|
1527
1761
|
if (event.kind === 'down' && event.button === 'left') {
|
|
1762
|
+
clearTimeout(returnToLiveTimer); // a second click: this is a double-click, stay put
|
|
1528
1763
|
// A press starts a possible drag AND a possible click. Which it was is only
|
|
1529
1764
|
// known on release, so nothing is dispatched yet — dispatching here made every
|
|
1530
1765
|
// attempt to select text also press whatever was under the cursor.
|
|
@@ -1582,6 +1817,7 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1582
1817
|
const chosen = panel.options[panel.index];
|
|
1583
1818
|
const name = typeof chosen === 'string' ? chosen : chosen?.name;
|
|
1584
1819
|
state.panel = null;
|
|
1820
|
+
if (panel.onPick) { if (chosen) panel.onPick(chosen); return notifyNow(); }
|
|
1585
1821
|
refreshMenu();
|
|
1586
1822
|
if (!name) return notifyNow();
|
|
1587
1823
|
// Run it. Picking a command out of a palette and then being handed it to
|
|
@@ -1607,6 +1843,12 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1607
1843
|
return;
|
|
1608
1844
|
}
|
|
1609
1845
|
if (key.escape || key.return || key.enter) resolvePanel();
|
|
1846
|
+
else if (key.upArrow || key.up) scrollPanel(-1);
|
|
1847
|
+
else if (key.downArrow || key.down) scrollPanel(1);
|
|
1848
|
+
else if (key.pageUp || key.pageup) scrollPanel(-(panel._maxRows || 10));
|
|
1849
|
+
else if (key.pageDown || key.pagedown) scrollPanel(panel._maxRows || 10);
|
|
1850
|
+
else if (key.home) scrollPanel(-1e9);
|
|
1851
|
+
else if (key.end) scrollPanel(1e9);
|
|
1610
1852
|
return;
|
|
1611
1853
|
}
|
|
1612
1854
|
|
|
@@ -1614,18 +1856,30 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1614
1856
|
// Split here or the return becomes a literal character in the composer.
|
|
1615
1857
|
const newlineAt = String(input || '').search(/[\r\n]/);
|
|
1616
1858
|
if (newlineAt >= 0) {
|
|
1617
|
-
const
|
|
1618
|
-
const
|
|
1859
|
+
const text = String(input);
|
|
1860
|
+
const typed = text.slice(0, newlineAt);
|
|
1861
|
+
const sep = text[newlineAt];
|
|
1862
|
+
let rest = text.slice(newlineAt + 1);
|
|
1619
1863
|
if (typed) insert(typed);
|
|
1620
|
-
|
|
1621
|
-
// This path
|
|
1622
|
-
//
|
|
1623
|
-
//
|
|
1624
|
-
|
|
1625
|
-
|
|
1864
|
+
// Enter (\r) sends; a line feed (\n — Ctrl+J), Alt/Shift+Enter, or a `\` right
|
|
1865
|
+
// before the cursor is a NEW LINE. This path used to send on every one of them, so
|
|
1866
|
+
// no key in the README could add a line: each line went out as its own message.
|
|
1867
|
+
// A \r\n pair is a line break inside pasted text, never a send.
|
|
1868
|
+
let newline = key.shift || key.meta || sep === '\n';
|
|
1869
|
+
if (sep === '\r' && rest.startsWith('\n')) { newline = true; rest = rest.slice(1); }
|
|
1870
|
+
if (!newline && state.input[state.cursor - 1] === '\\') {
|
|
1871
|
+
state.input = state.input.slice(0, state.cursor - 1) + state.input.slice(state.cursor);
|
|
1872
|
+
state.cursor -= 1;
|
|
1873
|
+
newline = true;
|
|
1874
|
+
}
|
|
1875
|
+
if (newline) insert('\n');
|
|
1876
|
+
// Whether Enter arrives on its own or glued to the typed text depends on how the
|
|
1877
|
+
// PTY batched the keystrokes — so both paths make the same decision, including
|
|
1878
|
+
// taking the highlighted menu item (`/re` + Enter runs `/review`).
|
|
1626
1879
|
else if (state.menu.length) chooseMenu({ run: true });
|
|
1627
1880
|
else submit();
|
|
1628
|
-
|
|
1881
|
+
// The rest of the chunk goes through the same rules (it may hold more lines).
|
|
1882
|
+
if (rest) handleKey(rest, {}, exit);
|
|
1629
1883
|
return;
|
|
1630
1884
|
}
|
|
1631
1885
|
|
|
@@ -1640,6 +1894,16 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1640
1894
|
const ctrlKey = key.ctrl ? String(key.name || input || '').toLowerCase() : '';
|
|
1641
1895
|
|
|
1642
1896
|
if (ctrlKey === 'c') {
|
|
1897
|
+
// With text selected, Ctrl+C copies it (as the README says) — including mid-turn,
|
|
1898
|
+
// where it used to stop the turn instead. With copyOnSelect off it was the only way.
|
|
1899
|
+
const selected = state.selection ? selectedText() : '';
|
|
1900
|
+
if (selected.trim()) {
|
|
1901
|
+
void (state.onCopy ? state.onCopy(selected) : Promise.resolve(false)).then((ok) => {
|
|
1902
|
+
flash(ok ? `Copied ${selected.length} character${selected.length === 1 ? '' : 's'}` : 'Nothing could copy it — no clipboard tool found', ok ? 'ok' : 'warn', 1600);
|
|
1903
|
+
clearSelection();
|
|
1904
|
+
});
|
|
1905
|
+
return;
|
|
1906
|
+
}
|
|
1643
1907
|
if (state.busy) state.onEscape?.();
|
|
1644
1908
|
else if (state.input) clearInput();
|
|
1645
1909
|
else flash('Type /exit to leave', 'info', 1500);
|
|
@@ -1649,6 +1913,8 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1649
1913
|
if (ctrlKey === 'l') { api.clearTranscript(); return; }
|
|
1650
1914
|
if (ctrlKey === 'd') { api.toggleDebug(); return; }
|
|
1651
1915
|
if (ctrlKey === 'p') return openPalette();
|
|
1916
|
+
if (ctrlKey === 'r') return openHistorySearch();
|
|
1917
|
+
if (ctrlKey === 'o') return openTranscriptSearch();
|
|
1652
1918
|
// Ctrl+V reaches the app only in terminals that do not paste on it themselves (those
|
|
1653
1919
|
// send the text as a bracketed paste instead). Alt+V is for an image — a screenshot
|
|
1654
1920
|
// cannot be typed in, so no terminal pastes one.
|
|
@@ -1656,6 +1922,10 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1656
1922
|
if (key.meta && String(input).toLowerCase() === 'v') { void pasteFromClipboard({ imageOnly: true }); return; }
|
|
1657
1923
|
if (key.escape) {
|
|
1658
1924
|
if (state.busy) return state.onEscape?.();
|
|
1925
|
+
// Esc closes the suggestion menu first, as the README says. It went straight to the
|
|
1926
|
+
// rewind countdown, so a message ending in `@word` or `/word` could not be sent —
|
|
1927
|
+
// Enter kept choosing a suggestion.
|
|
1928
|
+
if (state.menu.length) { state.menu = []; state.menuIndex = -1; state.menuDismissedFor = state.input; return notifyNow(); }
|
|
1659
1929
|
if (state.viewingTranscript) { state.viewingTranscript = false; state.transcriptOffset = 0; state.newMessages = 0; return notifyNow(); }
|
|
1660
1930
|
const now = Date.now();
|
|
1661
1931
|
if (lastEscapeAt && now - lastEscapeAt < 1200) {
|
|
@@ -1683,9 +1953,18 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1683
1953
|
if ((key.upArrow || key.up) && state.menu.length) { state.menuIndex = clamp(state.menuIndex - 1, 0, state.menu.length - 1); return notifyNow(); }
|
|
1684
1954
|
if ((key.downArrow || key.down) && state.menu.length) { state.menuIndex = clamp(state.menuIndex + 1, 0, state.menu.length - 1); return notifyNow(); }
|
|
1685
1955
|
if (key.tab && state.menu.length) return chooseMenu();
|
|
1686
|
-
if (key.return || key.enter) {
|
|
1687
|
-
|
|
1688
|
-
|
|
1956
|
+
if (key.return || key.enter || input === '\n') {
|
|
1957
|
+
// A new line, not a send: Ctrl+J (a line feed, where Enter is a carriage return),
|
|
1958
|
+
// Alt+Enter, Shift+Enter where the terminal reports it, or a `\` right before the
|
|
1959
|
+
// cursor. Only Shift was checked, which almost no terminal reports — so every way
|
|
1960
|
+
// the README advertised sent the half-written message instead.
|
|
1961
|
+
if (key.shift || key.meta || input === '\n') return insert('\n');
|
|
1962
|
+
if (state.input[state.cursor - 1] === '\\') {
|
|
1963
|
+
state.input = state.input.slice(0, state.cursor - 1) + state.input.slice(state.cursor);
|
|
1964
|
+
state.cursor -= 1;
|
|
1965
|
+
return insert('\n');
|
|
1966
|
+
}
|
|
1967
|
+
if (state.menu.length) return chooseMenu({ run: true });
|
|
1689
1968
|
return submit();
|
|
1690
1969
|
}
|
|
1691
1970
|
|
|
@@ -1715,7 +1994,7 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1715
1994
|
// leave a broken token behind.
|
|
1716
1995
|
const chip = chipBefore(state.input, state.cursor);
|
|
1717
1996
|
const at = chip ? state.cursor - chip[0].length : wordLeft();
|
|
1718
|
-
if (chip) dropPaste(chip
|
|
1997
|
+
if (chip) dropPaste(chip);
|
|
1719
1998
|
state.input = state.input.slice(0, at) + state.input.slice(state.cursor);
|
|
1720
1999
|
state.cursor = at;
|
|
1721
2000
|
refreshMenu();
|
|
@@ -1723,9 +2002,12 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1723
2002
|
}
|
|
1724
2003
|
if (key.backspace) {
|
|
1725
2004
|
if (state.cursor === 0) return;
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
2005
|
+
// A chip is inserted with a space after it; Backspace right after that space means
|
|
2006
|
+
// the chip, not the space — otherwise the first Backspace seemed to do nothing.
|
|
2007
|
+
const spaced = state.input[state.cursor - 1] === ' ' ? chipBefore(state.input, state.cursor - 1) : null;
|
|
2008
|
+
const chip = spaced || chipBefore(state.input, state.cursor);
|
|
2009
|
+
const eat = chip ? chip[0].length + (spaced ? 1 : 0) : 1;
|
|
2010
|
+
if (chip) dropPaste(chip);
|
|
1729
2011
|
state.input = state.input.slice(0, state.cursor - eat) + state.input.slice(state.cursor);
|
|
1730
2012
|
state.cursor -= eat;
|
|
1731
2013
|
refreshMenu();
|
|
@@ -1734,7 +2016,7 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1734
2016
|
if (key.delete) {
|
|
1735
2017
|
const chip = chipAfter(state.input, state.cursor);
|
|
1736
2018
|
const eat = chip ? chip[0].length : 1;
|
|
1737
|
-
if (chip) dropPaste(chip
|
|
2019
|
+
if (chip) dropPaste(chip);
|
|
1738
2020
|
state.input = state.input.slice(0, state.cursor) + state.input.slice(state.cursor + eat);
|
|
1739
2021
|
refreshMenu();
|
|
1740
2022
|
return notifyNow();
|
|
@@ -1743,12 +2025,93 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1743
2025
|
if (ctrlKey === 'k') { state.input = state.input.slice(0, state.cursor); refreshMenu(); return notifyNow(); }
|
|
1744
2026
|
if (key.upArrow || key.up) return historyMove(-1);
|
|
1745
2027
|
if (key.downArrow || key.down) return historyMove(1);
|
|
1746
|
-
if (input && !key.ctrl && !key.meta)
|
|
2028
|
+
if (input && !key.ctrl && !key.meta) {
|
|
2029
|
+
// Control characters are keys, never text. Two pressed quickly arrive as one chunk
|
|
2030
|
+
// ("\x05\x15" — Ctrl+E, Ctrl+U) that Ink hands over as input; it used to be
|
|
2031
|
+
// inserted as invisible characters, so the line no longer started with `/` and a
|
|
2032
|
+
// command went to the model as a message. Ctrl+_ \ ] ^ (0x1c–0x1f) did the same.
|
|
2033
|
+
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(input)) {
|
|
2034
|
+
let text = '';
|
|
2035
|
+
const flushText = () => { if (text) { insert(text); text = ''; } };
|
|
2036
|
+
for (const ch of input) {
|
|
2037
|
+
const code = ch.charCodeAt(0);
|
|
2038
|
+
if (code >= 1 && code <= 26 && code !== 9) { flushText(); handleKey(String.fromCharCode(96 + code), { ctrl: true }, exit); }
|
|
2039
|
+
else if (code === 0x7f || code === 8) { flushText(); handleKey('', { backspace: true }, exit); }
|
|
2040
|
+
else if (code < 32 && code !== 9) { /* 0x1c–0x1f and NUL: nothing to type */ }
|
|
2041
|
+
else text += ch;
|
|
2042
|
+
}
|
|
2043
|
+
flushText();
|
|
2044
|
+
return;
|
|
2045
|
+
}
|
|
2046
|
+
insert(input);
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
/**
|
|
2051
|
+
* A searchable list whose choice goes to `onPick` — Ctrl+R (prompt history) and
|
|
2052
|
+
* Ctrl+O (the conversation). Neither existed in this view; the README listed both.
|
|
2053
|
+
* Matching is plain "contains" (case-insensitive): these are sentences, not names.
|
|
2054
|
+
*/
|
|
2055
|
+
function openSearchPanel({ title, options, onPick, empty }) {
|
|
2056
|
+
if (!options.length) { flash(empty, 'info', 1600); return; }
|
|
2057
|
+
state.panel = { title, palette: true, contains: true, filter: '', all: options, options, index: 0, lines: [], onPick, resolve: () => {} };
|
|
2058
|
+
state.menu = [];
|
|
2059
|
+
notifyNow();
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
function openHistorySearch() {
|
|
2063
|
+
const seen = new Set();
|
|
2064
|
+
const options = [];
|
|
2065
|
+
for (const entry of [...state.history].reverse()) {
|
|
2066
|
+
if (!entry || seen.has(entry)) continue;
|
|
2067
|
+
seen.add(entry);
|
|
2068
|
+
options.push({ name: entry.replace(/\s+/g, ' ').slice(0, 110), value: entry });
|
|
2069
|
+
}
|
|
2070
|
+
openSearchPanel({
|
|
2071
|
+
title: 'SEARCH YOUR PROMPTS', options, empty: 'No prompt history yet',
|
|
2072
|
+
onPick: (o) => { state.input = o.value; state.cursor = o.value.length; state.menuDismissedFor = state.input; notifyNow(); },
|
|
2073
|
+
});
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
function openTranscriptSearch() {
|
|
2077
|
+
const inner = Math.max(24, (state._width || process.stdout.columns || 80) - 2);
|
|
2078
|
+
const rows = [];
|
|
2079
|
+
for (const entry of state.transcript) {
|
|
2080
|
+
const built = rowsFor(entry, inner);
|
|
2081
|
+
if (!built.length) continue;
|
|
2082
|
+
if (rows.length) rows.push([]);
|
|
2083
|
+
rows.push(...built);
|
|
2084
|
+
}
|
|
2085
|
+
const total = rows.length;
|
|
2086
|
+
const options = [];
|
|
2087
|
+
rows.forEach((row, i) => {
|
|
2088
|
+
const text = rowText(row).trim();
|
|
2089
|
+
if (text) options.push({ name: text.slice(0, 110), value: i });
|
|
2090
|
+
});
|
|
2091
|
+
openSearchPanel({
|
|
2092
|
+
title: 'SEARCH THE CONVERSATION', options: options.reverse(), empty: 'Nothing in the conversation yet',
|
|
2093
|
+
onPick: (o) => {
|
|
2094
|
+
// Scroll so the chosen line sits mid-screen (the offset counts rows up from the
|
|
2095
|
+
// bottom).
|
|
2096
|
+
const half = Math.floor((state._transcriptHeight || 20) / 2);
|
|
2097
|
+
const offset = Math.max(0, total - o.value - 1 - half);
|
|
2098
|
+
state.viewingTranscript = offset > 0;
|
|
2099
|
+
state.transcriptOffset = offset;
|
|
2100
|
+
flash(`Line ${o.value + 1} of ${total} — Ctrl+End returns to the end`, 'info', 2200);
|
|
2101
|
+
notifyNow();
|
|
2102
|
+
},
|
|
2103
|
+
});
|
|
1747
2104
|
}
|
|
1748
2105
|
|
|
1749
2106
|
function applyPaletteFilter(panel) {
|
|
1750
2107
|
const filter = String(panel.filter || '');
|
|
1751
2108
|
if (!filter) { panel.options = panel.all; panel.index = 0; return; }
|
|
2109
|
+
if (panel.contains) {
|
|
2110
|
+
const needle = filter.toLowerCase();
|
|
2111
|
+
panel.options = panel.all.filter((o) => String(o.name).toLowerCase().includes(needle));
|
|
2112
|
+
panel.index = 0;
|
|
2113
|
+
return;
|
|
2114
|
+
}
|
|
1752
2115
|
panel.options = panel.all
|
|
1753
2116
|
.map((command) => {
|
|
1754
2117
|
const name = typeof command === 'string' ? command : command.name;
|
|
@@ -2012,13 +2375,50 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
2012
2375
|
|
|
2013
2376
|
queue(line) { state.queued.push(line); notify(); },
|
|
2014
2377
|
takeQueued() { const queued = state.queued; state.queued = []; notify(); return queued; },
|
|
2378
|
+
/** Put text back into the composer (queued messages after an interrupt). */
|
|
2379
|
+
/** The line as typed (chips, not their contents) — what goes into the history file. */
|
|
2380
|
+
lastTyped() { const t = state.lastTyped; state.lastTyped = undefined; return t; },
|
|
2381
|
+
restoreInput(text) {
|
|
2382
|
+
const value = String(text || '');
|
|
2383
|
+
if (!value) return;
|
|
2384
|
+
state.input = state.input ? `${value}\n${state.input}` : value;
|
|
2385
|
+
state.cursor = state.input.length;
|
|
2386
|
+
refreshMenu();
|
|
2387
|
+
notifyNow();
|
|
2388
|
+
},
|
|
2015
2389
|
hasQueued() { return state.queued.length > 0; },
|
|
2016
2390
|
|
|
2017
|
-
|
|
2018
|
-
|
|
2391
|
+
/** Attach an image block (from /image, an @mention, a drop): returns its chip number. */
|
|
2392
|
+
addAttachment(attachment) { return attachImage(attachment); },
|
|
2393
|
+
/** Attach image files by path, shrinking any that are too big. */
|
|
2394
|
+
addImageFiles(files) { return attachImageFiles(files); },
|
|
2395
|
+
/**
|
|
2396
|
+
* The images to send with `line`: only those whose chip is still in it, in the order
|
|
2397
|
+
* the chips appear. A chip deleted, cleared with Ctrl+U or never sent is dropped —
|
|
2398
|
+
* the chip in the text is the one source of truth for what goes with the message.
|
|
2399
|
+
*/
|
|
2400
|
+
drainAttachments(line = '') {
|
|
2401
|
+
const order = [...String(line).matchAll(/\[Image #(\d+)\]/g)].map((m) => Number(m[1]));
|
|
2402
|
+
const blocks = order.map((id) => (state.attachments.find((a) => a.id === id) || imageStore.get(id))?.block).filter(Boolean);
|
|
2403
|
+
state.attachments = [];
|
|
2404
|
+
notify();
|
|
2405
|
+
return blocks;
|
|
2406
|
+
},
|
|
2019
2407
|
|
|
2020
2408
|
askDialog(question, options, preview = [], opts = {}) {
|
|
2021
2409
|
return new Promise((resolve) => {
|
|
2410
|
+
// One dialog at a time, and a replaced one is ANSWERED (as cancelled), never left
|
|
2411
|
+
// pending: whoever was awaiting it — a permission prompt, mid-turn — would
|
|
2412
|
+
// otherwise wait forever.
|
|
2413
|
+
if (state.dialog?.resolve) { const prev = state.dialog.resolve; state.dialog = null; prev(null); }
|
|
2414
|
+
// The turn it belongs to was stopped (Esc): close it, as cancelled.
|
|
2415
|
+
if (opts.signal) {
|
|
2416
|
+
if (opts.signal.aborted) { resolve(null); return; }
|
|
2417
|
+
opts.signal.addEventListener('abort', () => {
|
|
2418
|
+
if (state.dialog?.resolve === resolve) { state.dialog = null; notifyNow(); }
|
|
2419
|
+
resolve(null);
|
|
2420
|
+
}, { once: true });
|
|
2421
|
+
}
|
|
2022
2422
|
state.dialog = {
|
|
2023
2423
|
question: plain(question),
|
|
2024
2424
|
options: options.map((option) => (option && typeof option === 'object'
|
|
@@ -2039,6 +2439,7 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
2039
2439
|
|
|
2040
2440
|
showPanel(title, lines) {
|
|
2041
2441
|
return new Promise((resolve) => {
|
|
2442
|
+
if (state.panel?.resolve) { const prev = state.panel.resolve; state.panel = null; prev(null); }
|
|
2042
2443
|
state.panel = {
|
|
2043
2444
|
title: plain(title),
|
|
2044
2445
|
lines: (Array.isArray(lines) ? lines : String(lines).split('\n')).map((line) => plain(line)),
|
|
@@ -2051,6 +2452,9 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
2051
2452
|
|
|
2052
2453
|
updatePanel(lines) {
|
|
2053
2454
|
if (!state.panel) return;
|
|
2455
|
+
// A panel that is updated is live output: it follows the newest lines until the
|
|
2456
|
+
// user scrolls up, and again once they scroll back to the bottom.
|
|
2457
|
+
if (!state.panel.live) { state.panel.live = true; state.panel.follow = true; }
|
|
2054
2458
|
state.panel.lines = (Array.isArray(lines) ? lines : String(lines).split('\n')).map((line) => plain(line));
|
|
2055
2459
|
notify();
|
|
2056
2460
|
},
|