jaitus 0.3.4 → 0.3.5
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 +33 -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 +493 -103
- 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;
|
|
@@ -387,22 +390,54 @@ export function selectHint(env = process.env, platform = process.platform) {
|
|
|
387
390
|
return 'drag copies · shift drag: native';
|
|
388
391
|
}
|
|
389
392
|
|
|
390
|
-
/**
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
393
|
+
/**
|
|
394
|
+
* Register every URL on screen as a click target.
|
|
395
|
+
*
|
|
396
|
+
* A URL longer than the line is hard-cut across rows by wrapRow, so the match on the
|
|
397
|
+
* first row is only a prefix of it — clicking it opened a truncated address. wrapRow
|
|
398
|
+
* marks the rows it cut mid-word (`cut` on the last span); when a match runs to the end
|
|
399
|
+
* of such a row, the next row's leading token (after the hanging indent) is appended, and
|
|
400
|
+
* that piece is registered with the whole URL too.
|
|
401
|
+
*/
|
|
402
|
+
export function linkHitsFor(rows) {
|
|
403
|
+
const texts = rows.map((row) => rowText(row));
|
|
404
|
+
const cut = rows.map((row) => Boolean(row.length && row[row.length - 1].cut));
|
|
405
|
+
const out = [];
|
|
406
|
+
const claimed = new Set(); // "row:col" of continuation pieces, so they are not matched again
|
|
407
|
+
texts.forEach((text, index) => {
|
|
395
408
|
for (const match of text.matchAll(URL_IN_TEXT)) {
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
409
|
+
if (claimed.has(`${index}:${match.index}`)) continue;
|
|
410
|
+
let url = match[0];
|
|
411
|
+
const pieces = [{ row: index, x: match.index, len: match[0].length }];
|
|
412
|
+
let row = index;
|
|
413
|
+
let end = match.index + match[0].length;
|
|
414
|
+
while (cut[row] && end === texts[row].length && row + 1 < texts.length) {
|
|
415
|
+
const lead = /^(\s*)([^\s<>"'`]+)/.exec(texts[row + 1]);
|
|
416
|
+
if (!lead) break;
|
|
417
|
+
url += lead[2];
|
|
418
|
+
row += 1;
|
|
419
|
+
pieces.push({ row, x: lead[1].length, len: lead[2].length });
|
|
420
|
+
claimed.add(`${row}:${lead[1].length}`);
|
|
421
|
+
end = lead[0].length;
|
|
422
|
+
}
|
|
423
|
+
url = url.replace(/[.,;:)\]]+$/, '');
|
|
424
|
+
for (const piece of pieces) out.push({ ...piece, url });
|
|
404
425
|
}
|
|
405
426
|
});
|
|
427
|
+
return out;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function registerLinkHits(visible, screenTop, hits) {
|
|
431
|
+
if (!hits) return;
|
|
432
|
+
for (const piece of linkHitsFor(visible)) {
|
|
433
|
+
hits.add({
|
|
434
|
+
x1: 2 + piece.x,
|
|
435
|
+
x2: 2 + piece.x + piece.len - 1,
|
|
436
|
+
y1: screenTop + piece.row,
|
|
437
|
+
y2: screenTop + piece.row,
|
|
438
|
+
action: { type: 'link', url: piece.url },
|
|
439
|
+
});
|
|
440
|
+
}
|
|
406
441
|
}
|
|
407
442
|
|
|
408
443
|
function Transcript({ state, width, height, layout }) {
|
|
@@ -419,6 +454,10 @@ function Transcript({ state, width, height, layout }) {
|
|
|
419
454
|
// far more often than it names a path called `.`.
|
|
420
455
|
const URL_IN_TEXT = /https?:\/\/[^\s<>"'`]+/g;
|
|
421
456
|
|
|
457
|
+
// Replies to the startup probe (terminal.js), with or without the ESC Ink strips: OSC 11
|
|
458
|
+
// background colour, then CSI ? … c device attributes — alone or run together.
|
|
459
|
+
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;
|
|
460
|
+
|
|
422
461
|
/**
|
|
423
462
|
* Draw the drag selection.
|
|
424
463
|
*
|
|
@@ -503,17 +542,17 @@ function Composer({ state, width }) {
|
|
|
503
542
|
// and might have to delete.
|
|
504
543
|
const composed = [];
|
|
505
544
|
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.
|
|
545
|
+
for (const match of state.input.matchAll(/\[#\d+ pasted [^\]]*\]|\[Image #\d+\]/g)) {
|
|
546
|
+
if (match.index > at) composed.push({ t: state.input.slice(at, match.index), color: p.body });
|
|
508
547
|
composed.push({ t: match[0], color: p.purple, backgroundColor: p.raised });
|
|
509
548
|
at = match.index + match[0].length;
|
|
510
549
|
}
|
|
511
|
-
if (at < state.input.length) composed.push({ t: state.input.slice(at), color: p.
|
|
550
|
+
if (at < state.input.length) composed.push({ t: state.input.slice(at), color: p.body });
|
|
512
551
|
|
|
513
552
|
// The same lines the App measured the composer's height from (composerLayout): the
|
|
514
553
|
// frame height is fixed, so a composer one row taller than it was counted as pushes
|
|
515
554
|
// the whole screen up — that was the header vanishing after a multi-line paste.
|
|
516
|
-
const { lines, caretLine } = composerLayout(state, inner - 2, composed);
|
|
555
|
+
const { lines, caretLine, caretCol } = composerLayout(state, inner - 2, composed);
|
|
517
556
|
const placeholder = state.placeholder || 'Describe a change, ask a question, or / for commands';
|
|
518
557
|
|
|
519
558
|
const borderColor = state.busy ? p.brand : state.menu.length ? p.teal : p.line;
|
|
@@ -529,29 +568,46 @@ function Composer({ state, width }) {
|
|
|
529
568
|
h(Box, { flexDirection: 'row' },
|
|
530
569
|
h(Text, { color: promptColor, bold: true }, promptMark),
|
|
531
570
|
h(Box, { flexDirection: 'column', width: inner - 2 },
|
|
532
|
-
lines.map((row, index) =>
|
|
533
|
-
|
|
571
|
+
lines.map((row, index) => {
|
|
572
|
+
// The caret sits AT the cursor. It used to be drawn after the whole line, so
|
|
573
|
+
// moving left with the arrow keys showed no movement at all while typing went
|
|
574
|
+
// in somewhere in the middle.
|
|
575
|
+
const [left, right] = index === caretLine ? splitSpansAt(row, caretCol) : [row, []];
|
|
576
|
+
return h(Box, { key: index, flexDirection: 'row' },
|
|
577
|
+
h(Row, { spans: left, wrap: 'wrap' }),
|
|
534
578
|
// The caret is always present, including at an empty prompt. Hiding it there
|
|
535
579
|
// was meant to keep it off the placeholder, but the result is a prompt with
|
|
536
580
|
// no cursor at all — there is nothing on screen saying the app is waiting for
|
|
537
581
|
// typing, which is the one thing a prompt has to say. The placeholder sits
|
|
538
582
|
// AFTER the caret instead, so the caret is at the insertion point either way.
|
|
539
583
|
index === caretLine ? h(Caret, { color: p.brand, char: g('▏', '_') }) : null,
|
|
584
|
+
right.length ? h(Row, { spans: right, wrap: 'wrap' }) : null,
|
|
540
585
|
!state.input && index === 0
|
|
541
586
|
? h(Text, { color: p.faint, italic: true, wrap: 'truncate-end' }, placeholder)
|
|
542
587
|
: null,
|
|
543
|
-
|
|
588
|
+
);
|
|
589
|
+
}),
|
|
544
590
|
),
|
|
545
591
|
),
|
|
546
592
|
state.queued.length
|
|
547
593
|
? 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
594
|
: null,
|
|
549
|
-
state.
|
|
550
|
-
? h(Box, { marginTop: 1 }, h(Text, { color: p.purple
|
|
595
|
+
liveAttachments(state).length
|
|
596
|
+
? h(Box, { marginTop: 1 }, h(Text, { color: p.purple, wrap: 'truncate-end' },
|
|
597
|
+
`${liveAttachments(state).map((a) => `${g('⧉', '[img]')} #${a.id} ${a.name || 'image'}${a.bytes ? ` ${Math.max(1, Math.round(a.bytes / 1024))} KB` : ''}`).join(' ')}`,
|
|
598
|
+
h(Text, { color: p.faint }, ' · backspace over a chip removes it')))
|
|
551
599
|
: null,
|
|
552
600
|
);
|
|
553
601
|
}
|
|
554
602
|
|
|
603
|
+
/** Attachments whose chip is still in the input — the ones that will be sent. */
|
|
604
|
+
function liveAttachments(state) {
|
|
605
|
+
// Current attachments, and earlier ones a line recalled from history still names.
|
|
606
|
+
const all = new Map();
|
|
607
|
+
for (const a of [...(state.imageStore ? state.imageStore.values() : []), ...(state.attachments || [])]) if (a?.id) all.set(a.id, a);
|
|
608
|
+
return [...all.values()].filter((a) => state.input.includes(`[Image #${a.id}]`));
|
|
609
|
+
}
|
|
610
|
+
|
|
555
611
|
const COMPOSER_MAX_LINES = 8;
|
|
556
612
|
|
|
557
613
|
/** Spans split at every newline: one array of spans per line the user typed. */
|
|
@@ -584,27 +640,45 @@ function composerLayout(state, width, spans = null) {
|
|
|
584
640
|
const paragraphs = splitSpansAtNewlines(spans || [{ t: input }]);
|
|
585
641
|
const all = [];
|
|
586
642
|
let caret = 0;
|
|
643
|
+
let caretCol = 0;
|
|
587
644
|
let start = 0; // offset of this paragraph in `input`
|
|
588
645
|
const cursor = Math.max(0, Math.min(input.length, state.cursor ?? input.length));
|
|
589
646
|
for (const para of paragraphs) {
|
|
590
647
|
const text = para.map((s) => s.t).join('');
|
|
591
648
|
const rows = para.length ? wrapRow(para, width) : [[]];
|
|
592
649
|
if (cursor >= start && cursor <= start + text.length) {
|
|
593
|
-
let
|
|
650
|
+
let rowStart = start;
|
|
594
651
|
let line = rows.length - 1;
|
|
652
|
+
let col = 0;
|
|
595
653
|
for (let i = 0; i < rows.length; i++) {
|
|
596
|
-
|
|
597
|
-
if (cursor <=
|
|
598
|
-
|
|
654
|
+
const len = rows[i].map((s) => s.t).join('').length;
|
|
655
|
+
if (cursor <= rowStart + len || i === rows.length - 1) { line = i; col = Math.max(0, Math.min(len, cursor - rowStart)); break; }
|
|
656
|
+
rowStart += len + 1; // + the space the wrap consumed
|
|
599
657
|
}
|
|
600
658
|
caret = all.length + line;
|
|
659
|
+
caretCol = col;
|
|
601
660
|
}
|
|
602
661
|
all.push(...rows);
|
|
603
662
|
start += text.length + 1; // + the newline
|
|
604
663
|
}
|
|
605
|
-
if (all.length <= COMPOSER_MAX_LINES) return { lines: all, caretLine: caret, total: all.length };
|
|
664
|
+
if (all.length <= COMPOSER_MAX_LINES) return { lines: all, caretLine: caret, caretCol, total: all.length };
|
|
606
665
|
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 };
|
|
666
|
+
return { lines: all.slice(first, first + COMPOSER_MAX_LINES), caretLine: caret - first, caretCol, total: all.length };
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** A row of spans cut at character `n`: [before, after]. */
|
|
670
|
+
function splitSpansAt(row, n) {
|
|
671
|
+
const before = [];
|
|
672
|
+
const after = [];
|
|
673
|
+
let seen = 0;
|
|
674
|
+
for (const span of row || []) {
|
|
675
|
+
const t = String(span.t);
|
|
676
|
+
if (seen + t.length <= n) before.push(span);
|
|
677
|
+
else if (seen >= n) after.push(span);
|
|
678
|
+
else { before.push({ ...span, t: t.slice(0, n - seen) }); after.push({ ...span, t: t.slice(n - seen) }); }
|
|
679
|
+
seen += t.length;
|
|
680
|
+
}
|
|
681
|
+
return [before, after];
|
|
608
682
|
}
|
|
609
683
|
|
|
610
684
|
function StatusBar({ state, width }) {
|
|
@@ -686,9 +760,27 @@ function Dialog({ state, width, height, hits }) {
|
|
|
686
760
|
const dialog = state.dialog;
|
|
687
761
|
if (!dialog) return null;
|
|
688
762
|
const boxWidth = Math.min(Math.max(48, Math.floor(width * 0.78)), width - 4);
|
|
689
|
-
const previewRows = (dialog.preview || []).slice(0, 12);
|
|
690
763
|
const questionRows = wrapRow([{ t: plain(dialog.question) }], boxWidth - 6).length;
|
|
691
|
-
|
|
764
|
+
// What is being approved is WRAPPED, never cut at the box edge: a long command's tail
|
|
765
|
+
// (`… && curl x | sh`) was simply not shown, so it could be approved unseen. When it
|
|
766
|
+
// is taller than the screen allows, the start and the END are shown with the count of
|
|
767
|
+
// what is hidden between them.
|
|
768
|
+
const wrappedPreview = (dialog.preview || []).flatMap((line) => wrapRow(previewSpans(line, p), boxWidth - 6));
|
|
769
|
+
const roomForPreview = Math.max(4, height - (questionRows + dialog.options.length + 12));
|
|
770
|
+
let previewRows = wrappedPreview;
|
|
771
|
+
if (wrappedPreview.length > roomForPreview) {
|
|
772
|
+
const head = Math.floor((roomForPreview - 1) / 2);
|
|
773
|
+
const tail = roomForPreview - 1 - head;
|
|
774
|
+
const hidden = wrappedPreview.length - head - tail;
|
|
775
|
+
previewRows = [...wrappedPreview.slice(0, head), [{ t: `… ${hidden} more line${hidden === 1 ? '' : 's'} …`, color: p.yellow }], ...wrappedPreview.slice(-tail)];
|
|
776
|
+
}
|
|
777
|
+
// More options than fit: a window that follows the selection (a picker of 20 sessions
|
|
778
|
+
// or models no longer has to be cut to the 9 that have a number key).
|
|
779
|
+
const roomForOptions = Math.max(3, height - questionRows - previewRows.length - 12);
|
|
780
|
+
const optionCount = Math.min(dialog.options.length, roomForOptions);
|
|
781
|
+
const firstOption = Math.max(0, Math.min(dialog.index - Math.floor(optionCount / 2), dialog.options.length - optionCount));
|
|
782
|
+
const visibleOptions = dialog.options.slice(firstOption, firstOption + optionCount);
|
|
783
|
+
const rows = dialogHeight(questionRows, previewRows.length, optionCount);
|
|
692
784
|
const accent = dialog.tone === 'danger' ? p.red : p.brand;
|
|
693
785
|
|
|
694
786
|
const top = Math.max(0, Math.round((height - rows) / 2));
|
|
@@ -699,10 +791,10 @@ function Dialog({ state, width, height, hits }) {
|
|
|
699
791
|
// arithmetic mirrors dialogHeight() exactly — terminal rows are 1-based, hence the +1.
|
|
700
792
|
hits?.add({ x1: left + 1, x2: left + boxWidth, y1: top + 1, y2: top + rows, action: { type: 'swallow' } });
|
|
701
793
|
const optionsTop = top + 2 + 1 + 1 + questionRows + (previewRows.length ? 1 + previewRows.length : 0) + 1;
|
|
702
|
-
|
|
794
|
+
visibleOptions.forEach((option, index) => {
|
|
703
795
|
hits?.add({
|
|
704
796
|
x1: left + 1, x2: left + boxWidth, y1: optionsTop + index + 1, y2: optionsTop + index + 1,
|
|
705
|
-
action: { type: 'dialogOption', index },
|
|
797
|
+
action: { type: 'dialogOption', index: firstOption + index },
|
|
706
798
|
});
|
|
707
799
|
});
|
|
708
800
|
|
|
@@ -727,17 +819,18 @@ function Dialog({ state, width, height, hits }) {
|
|
|
727
819
|
.map((row, index) => h(Row, { key: index, spans: row }))),
|
|
728
820
|
previewRows.length
|
|
729
821
|
? h(Box, { marginTop: 1, flexDirection: 'column' },
|
|
730
|
-
previewRows.map((
|
|
731
|
-
key: index,
|
|
732
|
-
spans: truncateRow(previewSpans(line, p), boxWidth - 6),
|
|
733
|
-
})))
|
|
822
|
+
previewRows.map((row, index) => h(Row, { key: index, spans: row })))
|
|
734
823
|
: null,
|
|
735
824
|
h(Box, { marginTop: 1, flexDirection: 'column' },
|
|
736
|
-
|
|
825
|
+
visibleOptions.map((option, i) => {
|
|
826
|
+
const index = firstOption + i;
|
|
737
827
|
const selected = index === dialog.index;
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
828
|
+
const more = (i === 0 && firstOption > 0) ? ` ${g('↑', '^')}${firstOption} more`
|
|
829
|
+
: (i === visibleOptions.length - 1 && firstOption + optionCount < dialog.options.length) ? ` ${g('↓', 'v')}${dialog.options.length - firstOption - optionCount} more` : '';
|
|
830
|
+
return h(Box, { key: `${option.value}-${index}`, backgroundColor: selected ? p.raised : undefined, flexDirection: 'row' },
|
|
831
|
+
h(Text, { color: selected ? accent : p.muted, bold: selected, wrap: 'truncate-end' },
|
|
832
|
+
`${selected ? g(' ▸ ', ' > ') : ' '}${index < 9 ? `${index + 1}.` : ' '} ${plain(option.label)}`),
|
|
833
|
+
more ? h(Text, { color: p.faint }, more) : null);
|
|
741
834
|
})),
|
|
742
835
|
h(Box, { marginTop: 1 },
|
|
743
836
|
h(Text, { color: p.faint }, `${g('↑↓', 'up/dn')} choose · 1-9 jump · enter confirm · esc ${dialog.hideComment ? 'cancel' : 'deny'}`)),
|
|
@@ -779,7 +872,7 @@ function Panel({ state, width, height, hits }) {
|
|
|
779
872
|
const name = typeof option === 'string' ? option : option.name;
|
|
780
873
|
const summary = typeof option === 'string' ? '' : option.summary || '';
|
|
781
874
|
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))}`),
|
|
875
|
+
h(Text, { color: selected ? p.brand : p.muted, bold: selected, wrap: 'truncate-end' }, `${selected ? g(' ▸ ', ' > ') : ' '}${name.padEnd(Math.min(26, boxWidth - 10))}`),
|
|
783
876
|
summary ? h(Text, { color: p.faint, wrap: 'truncate-end' }, summary) : null,
|
|
784
877
|
);
|
|
785
878
|
});
|
|
@@ -788,12 +881,22 @@ function Panel({ state, width, height, hits }) {
|
|
|
788
881
|
bodyRows = 1;
|
|
789
882
|
}
|
|
790
883
|
} else {
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
884
|
+
// Scrollable. Only the last maxRows used to be drawn, with no way up — the top of a
|
|
885
|
+
// long /diff or /tasks was unreachable. `follow` (a live panel at its bottom) keeps
|
|
886
|
+
// showing the newest lines as they arrive.
|
|
887
|
+
const all = panel.rows?.length ? panel.rows : (panel.lines || []);
|
|
888
|
+
const total = all.length;
|
|
889
|
+
const maxStart = Math.max(0, total - maxRows);
|
|
890
|
+
panel._maxRows = maxRows;
|
|
891
|
+
panel._maxStart = maxStart;
|
|
892
|
+
const start = panel.follow ? maxStart : Math.max(0, Math.min(panel.scroll || 0, maxStart));
|
|
893
|
+
panel.scroll = start;
|
|
894
|
+
panel._range = total > maxRows ? `${start + 1}–${Math.min(total, start + maxRows)} of ${total}` : '';
|
|
895
|
+
const view = all.slice(start, start + maxRows);
|
|
896
|
+
bodyRows = Math.max(1, view.length);
|
|
897
|
+
body = panel.rows?.length
|
|
898
|
+
? [h(Rows, { key: 'rows', rows: view })]
|
|
899
|
+
: view.map((line, index) => h(Text, { key: index, color: p.text, wrap: 'truncate-end' }, plain(line)));
|
|
797
900
|
}
|
|
798
901
|
|
|
799
902
|
// border 2 + padY 2 + title 1 + gap 1 + body + gap 1 + hint 1
|
|
@@ -830,8 +933,8 @@ function Panel({ state, width, height, hits }) {
|
|
|
830
933
|
),
|
|
831
934
|
h(Box, { marginTop: 1, flexDirection: 'column' }, body),
|
|
832
935
|
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`)),
|
|
936
|
+
? `type to filter · ${g('↑↓', 'up/dn')} choose · ${panel.onPick ? 'enter pick' : 'enter run'} · esc close`
|
|
937
|
+
: `${panel._range ? `${panel._range} · ${g('↑↓', 'up/dn')} pgup/pgdn scroll · ` : ''}esc close`)),
|
|
835
938
|
),
|
|
836
939
|
),
|
|
837
940
|
);
|
|
@@ -883,13 +986,15 @@ function App({ controller }) {
|
|
|
883
986
|
+ (state.menu.length ? state.menu.length + 1 : 0)
|
|
884
987
|
+ Math.max(0, composerTextRows(state, width) - 1)
|
|
885
988
|
+ (state.queued.length ? 2 : 0)
|
|
886
|
-
+ (state.
|
|
989
|
+
+ (liveAttachments(state).length ? 2 : 0);
|
|
887
990
|
const workRows = state.busy || state.status ? 1 : 0;
|
|
888
991
|
// A single pinned line showing what was last asked, while the transcript is scrolled
|
|
889
992
|
// away from it. Scrolling back through a long answer and losing track of the question
|
|
890
993
|
// it was answering is the specific thing this fixes.
|
|
891
994
|
const stickyRows = state.viewingTranscript ? 1 : 0;
|
|
892
995
|
const transcriptHeight = Math.max(3, height - CHROME.header - CHROME.status - composerRows - workRows - CHROME.gapAboveComposer - stickyRows);
|
|
996
|
+
state._transcriptHeight = transcriptHeight;
|
|
997
|
+
state._width = width;
|
|
893
998
|
|
|
894
999
|
// The welcome stands until there is real conversation. Anything chrome-ish — a
|
|
895
1000
|
// notice, a stray pre-formatted print from an older code path — must not suppress it:
|
|
@@ -994,6 +1099,18 @@ function registerHeaderHits(state, width, hits) {
|
|
|
994
1099
|
|
|
995
1100
|
export function createTui({ slashCommands = [], fileProvider = null, meta = {}, onModeCycle = null, settings = null } = {}) {
|
|
996
1101
|
publishSettings(settings);
|
|
1102
|
+
// Settings changed during the session apply now, not at the next start.
|
|
1103
|
+
onSettingsChange((next) => {
|
|
1104
|
+
const mouseBefore = settings?.mouse !== false;
|
|
1105
|
+
settings = next || {};
|
|
1106
|
+
publishSettings(settings);
|
|
1107
|
+
const mouseNow = settings.mouse !== false && !process.env.JAITUS_DISABLE_MOUSE;
|
|
1108
|
+
if (state?.active && mouseBefore !== mouseNow) {
|
|
1109
|
+
if (mouseNow) state.mouse = enableMouse(process.stdout);
|
|
1110
|
+
else { disableMouse(process.stdout); state.mouse = false; }
|
|
1111
|
+
}
|
|
1112
|
+
if (state?.active) notifyNow();
|
|
1113
|
+
});
|
|
997
1114
|
|
|
998
1115
|
const commands = slashCommands.map((command) => (typeof command === 'string' ? { name: command, summary: '' } : command));
|
|
999
1116
|
|
|
@@ -1094,6 +1211,9 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1094
1211
|
}
|
|
1095
1212
|
|
|
1096
1213
|
function refreshMenu() {
|
|
1214
|
+
// Dismissed with Esc (or a recalled history line): stays closed until the text changes.
|
|
1215
|
+
if (state.menuDismissedFor !== undefined && state.menuDismissedFor === state.input) { state.menu = []; state.menuIndex = -1; return; }
|
|
1216
|
+
state.menuDismissedFor = undefined;
|
|
1097
1217
|
state.menu = state.dialog || state.panel ? [] : menuItemsFor(state.input, commands, fileProvider, state.cursor);
|
|
1098
1218
|
state.menuIndex = state.menu.length ? clamp(state.menuIndex < 0 ? 0 : state.menuIndex, 0, state.menu.length - 1) : -1;
|
|
1099
1219
|
}
|
|
@@ -1139,8 +1259,11 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1139
1259
|
return true;
|
|
1140
1260
|
}
|
|
1141
1261
|
|
|
1142
|
-
|
|
1143
|
-
|
|
1262
|
+
// A file keeps its `@`: without it the mention is plain text, so choosing a file
|
|
1263
|
+
// from the menu used to attach nothing at all. (Command names carry their own `/`.)
|
|
1264
|
+
const value = item.kind === 'file' ? `@${item.value}` : String(item.value);
|
|
1265
|
+
state.input = `${prefix}${value} ${suffix}`;
|
|
1266
|
+
state.cursor = prefix.length + value.length + 1;
|
|
1144
1267
|
refreshMenu();
|
|
1145
1268
|
notifyNow();
|
|
1146
1269
|
return true;
|
|
@@ -1158,6 +1281,9 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1158
1281
|
function handlePaste(text) {
|
|
1159
1282
|
const value = String(text ?? '').replace(/\r\n?/g, '\n');
|
|
1160
1283
|
if (!value) return;
|
|
1284
|
+
// Dragging image files onto the terminal pastes their paths: attach them instead.
|
|
1285
|
+
const images = imagePathsIn(value);
|
|
1286
|
+
if (images) { void attachImageFiles(images); return; }
|
|
1161
1287
|
const lines = value.split('\n').length;
|
|
1162
1288
|
|
|
1163
1289
|
if (lines <= 3 && value.length <= 240) {
|
|
@@ -1165,8 +1291,11 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1165
1291
|
return;
|
|
1166
1292
|
}
|
|
1167
1293
|
|
|
1168
|
-
|
|
1294
|
+
// Numbered for the whole session. `pastes.length + 1` reused a number after a chip was
|
|
1295
|
+
// deleted, so two chips could share it and both expand to the same content.
|
|
1296
|
+
const id = ++pasteSeq;
|
|
1169
1297
|
state.pastes.push({ id, text: value });
|
|
1298
|
+
remember(pasteStore, id, { id, text: value });
|
|
1170
1299
|
// The token has to survive being typed around, so it is bracketed and carries its
|
|
1171
1300
|
// own index; expansion matches on that rather than on position.
|
|
1172
1301
|
insert(`[#${id} pasted ${lines} line${lines === 1 ? '' : 's'}, ${value.length} chars]`);
|
|
@@ -1179,28 +1308,19 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1179
1308
|
* no text, an image on the clipboard becomes an attachment.
|
|
1180
1309
|
*/
|
|
1181
1310
|
let pasting = false;
|
|
1311
|
+
let returnToLiveTimer = null;
|
|
1182
1312
|
async function pasteFromClipboard({ imageOnly = false } = {}) {
|
|
1183
1313
|
if (pasting) return;
|
|
1184
1314
|
pasting = true;
|
|
1185
1315
|
try {
|
|
1186
1316
|
const text = imageOnly ? null : await (api._readClipboardText || readClipboardText)();
|
|
1187
1317
|
if (text) { handlePaste(text); notifyNow(); return; }
|
|
1188
|
-
const
|
|
1189
|
-
if (
|
|
1190
|
-
const fs = await import('node:fs');
|
|
1318
|
+
const img = await (api._readClipboardImage || readClipboardImage)();
|
|
1319
|
+
if (img?.bytes?.length) {
|
|
1191
1320
|
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();
|
|
1321
|
+
const file = path.join(os.tmpdir(), `jaitus-clip-${Date.now()}${img.ext || '.png'}`);
|
|
1322
|
+
fs.writeFileSync(file, img.bytes);
|
|
1323
|
+
await attachImageFiles([file], { name: 'clipboard image' });
|
|
1204
1324
|
return;
|
|
1205
1325
|
}
|
|
1206
1326
|
flash(imageOnly ? 'No image on the clipboard' : 'The clipboard is empty', 'info', 1600);
|
|
@@ -1218,20 +1338,83 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1218
1338
|
// so the content it stands for is silently orphaned and the literal text is what gets
|
|
1219
1339
|
// sent. Deleting it whole is both what you meant and the only way the placeholder and
|
|
1220
1340
|
// the thing it holds cannot come apart.
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
const
|
|
1341
|
+
// An attached image is a chip too — `[Image #2]` — so it is removed the same way: one
|
|
1342
|
+
// Backspace over it. There was no way at all to take an attachment back before.
|
|
1343
|
+
const CHIP = /\[#(\d+) pasted [^\]]*\]|\[Image #(\d+)\]/;
|
|
1344
|
+
const chipBefore = (text, cursor) => new RegExp(`(?:${CHIP.source})$`).exec(text.slice(0, cursor));
|
|
1345
|
+
const chipAfter = (text, cursor) => new RegExp(`^(?:${CHIP.source})`).exec(text.slice(cursor));
|
|
1224
1346
|
|
|
1225
1347
|
/** 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(
|
|
1348
|
+
function dropPaste(match) {
|
|
1349
|
+
if (match?.[1]) state.pastes = state.pastes.filter((entry) => entry.id !== Number(match[1]));
|
|
1350
|
+
if (match?.[2]) {
|
|
1351
|
+
const before = state.attachments.length;
|
|
1352
|
+
state.attachments = state.attachments.filter((a) => a.id !== Number(match[2]));
|
|
1353
|
+
if (state.attachments.length < before) flash(`Image #${match[2]} removed`, 'info', 1400);
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
// ── images ──
|
|
1358
|
+
let imageSeq = 0;
|
|
1359
|
+
let pasteSeq = 0;
|
|
1360
|
+
// What earlier chips stood for, so a message recalled from history is sent whole.
|
|
1361
|
+
// Bounded: images are the big part.
|
|
1362
|
+
const pasteStore = new Map();
|
|
1363
|
+
const imageStore = new Map();
|
|
1364
|
+
state.imageStore = imageStore;
|
|
1365
|
+
const remember = (store, id, value, max = store === imageStore ? 12 : 60) => {
|
|
1366
|
+
store.set(id, value);
|
|
1367
|
+
while (store.size > max) store.delete(store.keys().next().value);
|
|
1368
|
+
};
|
|
1369
|
+
/** Add an image: a chip at the cursor, the pixels kept aside until the message is sent. */
|
|
1370
|
+
function attachImage({ name, block }) {
|
|
1371
|
+
const id = ++imageSeq;
|
|
1372
|
+
const bytes = Math.round((String(block?.source?.data || '').length * 3) / 4);
|
|
1373
|
+
state.attachments.push({ id, name: name || `image ${id}`, block, bytes });
|
|
1374
|
+
remember(imageStore, id, { id, name: name || `image ${id}`, block, bytes });
|
|
1375
|
+
const before = state.input.slice(0, state.cursor);
|
|
1376
|
+
insert(`${before && !/\s$/.test(before) ? ' ' : ''}[Image #${id}] `);
|
|
1377
|
+
flash(`Image #${id} attached${bytes ? ` (${Math.max(1, Math.round(bytes / 1024))} KB)` : ''} — Backspace over it removes it`, 'info', 2400);
|
|
1378
|
+
notifyNow();
|
|
1379
|
+
return id;
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
/**
|
|
1383
|
+
* A paste that is nothing but paths to image files — what dragging a screenshot onto a
|
|
1384
|
+
* terminal window produces — attaches them instead of typing the paths. Quoted paths
|
|
1385
|
+
* (Windows, with spaces) and backslash-escaped spaces (macOS) are both understood.
|
|
1386
|
+
*/
|
|
1387
|
+
function imagePathsIn(text) {
|
|
1388
|
+
const raw = String(text || '').trim();
|
|
1389
|
+
if (!raw || raw.length > 4000) return null;
|
|
1390
|
+
const parts = [];
|
|
1391
|
+
const re = /"([^"]+)"|'([^']+)'|((?:\\ |[^\s"'])+)/g;
|
|
1392
|
+
for (const m of raw.matchAll(re)) parts.push((m[1] ?? m[2] ?? m[3]).replace(/\\ /g, ' '));
|
|
1393
|
+
if (!parts.length) return null;
|
|
1394
|
+
const files = parts.map((p) => p.replace(/^file:\/\/\/?/, '')).map((p) => {
|
|
1395
|
+
try { return /\.(png|jpe?g|gif|webp)$/i.test(p) && fs.statSync(p).isFile() ? p : null; } catch { return null; }
|
|
1396
|
+
});
|
|
1397
|
+
return files.every(Boolean) ? files : null;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
async function attachImageFiles(files, { name = null } = {}) {
|
|
1401
|
+
const { imageBlock } = await import('./agent/tools.js');
|
|
1402
|
+
for (const file of files) {
|
|
1403
|
+
const label = name || path.basename(file);
|
|
1404
|
+
// Too big as it is (a 4K screenshot): a smaller copy is made first.
|
|
1405
|
+
const ready = await shrinkImageFile(file).catch(() => null);
|
|
1406
|
+
const block = ready ? await imageBlock(ready).catch(() => null) : null;
|
|
1407
|
+
if (block) attachImage({ name: label, block });
|
|
1408
|
+
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);
|
|
1409
|
+
}
|
|
1228
1410
|
}
|
|
1229
1411
|
|
|
1230
1412
|
/** Put the real content back in place of every placeholder. */
|
|
1231
1413
|
function expandPastes(line) {
|
|
1232
|
-
if (!state.pastes.length) return line;
|
|
1233
1414
|
return line.replace(/\[#(\d+) pasted [^\]]*\]/g, (whole, id) => {
|
|
1234
|
-
|
|
1415
|
+
// The current pastes, then earlier ones: a line recalled from history with Up still
|
|
1416
|
+
// has its content — it used to send the literal placeholder text.
|
|
1417
|
+
const blob = state.pastes.find((entry) => entry.id === Number(id)) || pasteStore.get(Number(id));
|
|
1235
1418
|
return blob ? blob.text : whole;
|
|
1236
1419
|
});
|
|
1237
1420
|
}
|
|
@@ -1245,6 +1428,7 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1245
1428
|
// History keeps the TOKEN form. Pressing Up after sending a thousand-line paste
|
|
1246
1429
|
// should bring back the sentence you wrote, not the thousand lines.
|
|
1247
1430
|
if (state.history.at(-1) !== typed) state.history.push(typed);
|
|
1431
|
+
state.lastTyped = typed;
|
|
1248
1432
|
state.historyIndex = state.history.length;
|
|
1249
1433
|
state.viewingTranscript = false;
|
|
1250
1434
|
state.transcriptOffset = 0;
|
|
@@ -1260,6 +1444,9 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1260
1444
|
state.historyIndex = clamp(state.historyIndex + direction, 0, state.history.length);
|
|
1261
1445
|
state.input = state.historyIndex === state.history.length ? (state.draft || '') : state.history[state.historyIndex];
|
|
1262
1446
|
state.cursor = state.input.length;
|
|
1447
|
+
// A recalled `/review` must not open the command menu: Up then moved inside the menu
|
|
1448
|
+
// instead of on to the older prompt, and Enter ran whichever row was highlighted.
|
|
1449
|
+
state.menuDismissedFor = state.input;
|
|
1263
1450
|
refreshMenu();
|
|
1264
1451
|
notifyNow();
|
|
1265
1452
|
}
|
|
@@ -1408,7 +1595,7 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1408
1595
|
// Not "Ctrl+Shift+C": that is the TERMINAL's copy shortcut and it acts on the
|
|
1409
1596
|
// terminal's own selection, which does not exist while the app owns the mouse.
|
|
1410
1597
|
// Naming a shortcut that cannot work is worse than naming none.
|
|
1411
|
-
flash(`${text.length} characters selected ·
|
|
1598
|
+
flash(`${text.length} characters selected · Ctrl+C copies them`, 'info', 4000);
|
|
1412
1599
|
return;
|
|
1413
1600
|
}
|
|
1414
1601
|
const ok = await (state.onCopy ? state.onCopy(text) : Promise.resolve(false));
|
|
@@ -1429,6 +1616,11 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1429
1616
|
*/
|
|
1430
1617
|
function dispatchClick(region, event) {
|
|
1431
1618
|
if (!region) return;
|
|
1619
|
+
// While a dialog or panel is open, only its own controls respond. A click on the
|
|
1620
|
+
// header during a permission prompt opened the model picker OVER the prompt, which
|
|
1621
|
+
// was never answered — the session was stuck until the terminal was closed.
|
|
1622
|
+
const own = ['swallow', 'dialogOption', 'paletteRow', 'menuRow'];
|
|
1623
|
+
if ((state.dialog || state.panel) && !own.includes(region.action.type)) return;
|
|
1432
1624
|
switch (region.action.type) {
|
|
1433
1625
|
case 'swallow':
|
|
1434
1626
|
return;
|
|
@@ -1445,6 +1637,7 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1445
1637
|
const chosen = panel.options[panel.index];
|
|
1446
1638
|
const name = typeof chosen === 'string' ? chosen : chosen?.name;
|
|
1447
1639
|
state.panel = null;
|
|
1640
|
+
if (panel.onPick) { if (chosen) panel.onPick(chosen); return notifyNow(); }
|
|
1448
1641
|
refreshMenu();
|
|
1449
1642
|
if (!name) return notifyNow();
|
|
1450
1643
|
state.input = name;
|
|
@@ -1473,17 +1666,33 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1473
1666
|
return openPalette();
|
|
1474
1667
|
case 'transcript':
|
|
1475
1668
|
// A click in the body is how you get back to the live end after scrolling.
|
|
1669
|
+
// Deferred, and cancelled by a second click: a double-click to select a word
|
|
1670
|
+
// while scrolled back used to jump to the live end on the first click, so the
|
|
1671
|
+
// second one selected whatever had moved under the pointer.
|
|
1476
1672
|
if (state.viewingTranscript && event.button === 'left') {
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1673
|
+
clearTimeout(returnToLiveTimer);
|
|
1674
|
+
returnToLiveTimer = setTimeout(() => {
|
|
1675
|
+
state.viewingTranscript = false;
|
|
1676
|
+
state.transcriptOffset = 0;
|
|
1677
|
+
state.newMessages = 0;
|
|
1678
|
+
notifyNow();
|
|
1679
|
+
}, 350);
|
|
1481
1680
|
}
|
|
1482
1681
|
return;
|
|
1483
1682
|
default:
|
|
1484
1683
|
}
|
|
1485
1684
|
}
|
|
1486
1685
|
|
|
1686
|
+
/** Scroll the open panel by `delta` rows; at the bottom it follows new output again. */
|
|
1687
|
+
function scrollPanel(delta) {
|
|
1688
|
+
const panel = state.panel;
|
|
1689
|
+
if (!panel || panel.palette) return;
|
|
1690
|
+
const max = panel._maxStart ?? 0;
|
|
1691
|
+
panel.scroll = Math.max(0, Math.min(max, (panel.follow ? max : panel.scroll || 0) + delta));
|
|
1692
|
+
panel.follow = panel.scroll >= max && !!panel.live;
|
|
1693
|
+
notifyNow();
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1487
1696
|
function openPalette() {
|
|
1488
1697
|
state.panel = {
|
|
1489
1698
|
title: 'COMMAND PALETTE', palette: true, filter: '',
|
|
@@ -1510,13 +1719,24 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1510
1719
|
mouseFragment = { text: input, at: Date.now() };
|
|
1511
1720
|
return;
|
|
1512
1721
|
}
|
|
1722
|
+
// A terminal that answers the startup probe late (over SSH, under a multiplexer)
|
|
1723
|
+
// delivers the background-colour or device-attributes reply after the app has
|
|
1724
|
+
// started; Ink passes it through as typed text.
|
|
1725
|
+
if (TERMINAL_REPLY.test(input)) return;
|
|
1513
1726
|
if (isMouse(input)) {
|
|
1514
1727
|
mouseReports += 1;
|
|
1515
1728
|
lastMouseAt = Date.now();
|
|
1516
1729
|
const event = parseMouse(input);
|
|
1517
1730
|
if (!event) return;
|
|
1518
|
-
|
|
1519
|
-
|
|
1731
|
+
// The wheel scrolls what is in front: a panel, the palette's list, or nothing under
|
|
1732
|
+
// a dialog. It used to scroll the transcript hidden behind them.
|
|
1733
|
+
if (event.kind === 'wheelUp' || event.kind === 'wheelDown') {
|
|
1734
|
+
const up = event.kind === 'wheelUp';
|
|
1735
|
+
if (state.dialog) return undefined;
|
|
1736
|
+
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(); }
|
|
1737
|
+
if (state.panel) return scrollPanel(up ? -3 : 3);
|
|
1738
|
+
return scroll(up ? scrollStep() : -scrollStep());
|
|
1739
|
+
}
|
|
1520
1740
|
// Right-click pastes, as it does in a Windows console — the terminal cannot do it
|
|
1521
1741
|
// itself while the app has the mouse.
|
|
1522
1742
|
if (event.kind === 'down' && event.button === 'right') {
|
|
@@ -1525,6 +1745,7 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1525
1745
|
}
|
|
1526
1746
|
|
|
1527
1747
|
if (event.kind === 'down' && event.button === 'left') {
|
|
1748
|
+
clearTimeout(returnToLiveTimer); // a second click: this is a double-click, stay put
|
|
1528
1749
|
// A press starts a possible drag AND a possible click. Which it was is only
|
|
1529
1750
|
// known on release, so nothing is dispatched yet — dispatching here made every
|
|
1530
1751
|
// attempt to select text also press whatever was under the cursor.
|
|
@@ -1582,6 +1803,7 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1582
1803
|
const chosen = panel.options[panel.index];
|
|
1583
1804
|
const name = typeof chosen === 'string' ? chosen : chosen?.name;
|
|
1584
1805
|
state.panel = null;
|
|
1806
|
+
if (panel.onPick) { if (chosen) panel.onPick(chosen); return notifyNow(); }
|
|
1585
1807
|
refreshMenu();
|
|
1586
1808
|
if (!name) return notifyNow();
|
|
1587
1809
|
// Run it. Picking a command out of a palette and then being handed it to
|
|
@@ -1607,6 +1829,12 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1607
1829
|
return;
|
|
1608
1830
|
}
|
|
1609
1831
|
if (key.escape || key.return || key.enter) resolvePanel();
|
|
1832
|
+
else if (key.upArrow || key.up) scrollPanel(-1);
|
|
1833
|
+
else if (key.downArrow || key.down) scrollPanel(1);
|
|
1834
|
+
else if (key.pageUp || key.pageup) scrollPanel(-(panel._maxRows || 10));
|
|
1835
|
+
else if (key.pageDown || key.pagedown) scrollPanel(panel._maxRows || 10);
|
|
1836
|
+
else if (key.home) scrollPanel(-1e9);
|
|
1837
|
+
else if (key.end) scrollPanel(1e9);
|
|
1610
1838
|
return;
|
|
1611
1839
|
}
|
|
1612
1840
|
|
|
@@ -1614,18 +1842,30 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1614
1842
|
// Split here or the return becomes a literal character in the composer.
|
|
1615
1843
|
const newlineAt = String(input || '').search(/[\r\n]/);
|
|
1616
1844
|
if (newlineAt >= 0) {
|
|
1617
|
-
const
|
|
1618
|
-
const
|
|
1845
|
+
const text = String(input);
|
|
1846
|
+
const typed = text.slice(0, newlineAt);
|
|
1847
|
+
const sep = text[newlineAt];
|
|
1848
|
+
let rest = text.slice(newlineAt + 1);
|
|
1619
1849
|
if (typed) insert(typed);
|
|
1620
|
-
|
|
1621
|
-
// This path
|
|
1622
|
-
//
|
|
1623
|
-
//
|
|
1624
|
-
|
|
1625
|
-
|
|
1850
|
+
// Enter (\r) sends; a line feed (\n — Ctrl+J), Alt/Shift+Enter, or a `\` right
|
|
1851
|
+
// before the cursor is a NEW LINE. This path used to send on every one of them, so
|
|
1852
|
+
// no key in the README could add a line: each line went out as its own message.
|
|
1853
|
+
// A \r\n pair is a line break inside pasted text, never a send.
|
|
1854
|
+
let newline = key.shift || key.meta || sep === '\n';
|
|
1855
|
+
if (sep === '\r' && rest.startsWith('\n')) { newline = true; rest = rest.slice(1); }
|
|
1856
|
+
if (!newline && state.input[state.cursor - 1] === '\\') {
|
|
1857
|
+
state.input = state.input.slice(0, state.cursor - 1) + state.input.slice(state.cursor);
|
|
1858
|
+
state.cursor -= 1;
|
|
1859
|
+
newline = true;
|
|
1860
|
+
}
|
|
1861
|
+
if (newline) insert('\n');
|
|
1862
|
+
// Whether Enter arrives on its own or glued to the typed text depends on how the
|
|
1863
|
+
// PTY batched the keystrokes — so both paths make the same decision, including
|
|
1864
|
+
// taking the highlighted menu item (`/re` + Enter runs `/review`).
|
|
1626
1865
|
else if (state.menu.length) chooseMenu({ run: true });
|
|
1627
1866
|
else submit();
|
|
1628
|
-
|
|
1867
|
+
// The rest of the chunk goes through the same rules (it may hold more lines).
|
|
1868
|
+
if (rest) handleKey(rest, {}, exit);
|
|
1629
1869
|
return;
|
|
1630
1870
|
}
|
|
1631
1871
|
|
|
@@ -1640,6 +1880,16 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1640
1880
|
const ctrlKey = key.ctrl ? String(key.name || input || '').toLowerCase() : '';
|
|
1641
1881
|
|
|
1642
1882
|
if (ctrlKey === 'c') {
|
|
1883
|
+
// With text selected, Ctrl+C copies it (as the README says) — including mid-turn,
|
|
1884
|
+
// where it used to stop the turn instead. With copyOnSelect off it was the only way.
|
|
1885
|
+
const selected = state.selection ? selectedText() : '';
|
|
1886
|
+
if (selected.trim()) {
|
|
1887
|
+
void (state.onCopy ? state.onCopy(selected) : Promise.resolve(false)).then((ok) => {
|
|
1888
|
+
flash(ok ? `Copied ${selected.length} character${selected.length === 1 ? '' : 's'}` : 'Nothing could copy it — no clipboard tool found', ok ? 'ok' : 'warn', 1600);
|
|
1889
|
+
clearSelection();
|
|
1890
|
+
});
|
|
1891
|
+
return;
|
|
1892
|
+
}
|
|
1643
1893
|
if (state.busy) state.onEscape?.();
|
|
1644
1894
|
else if (state.input) clearInput();
|
|
1645
1895
|
else flash('Type /exit to leave', 'info', 1500);
|
|
@@ -1649,6 +1899,8 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1649
1899
|
if (ctrlKey === 'l') { api.clearTranscript(); return; }
|
|
1650
1900
|
if (ctrlKey === 'd') { api.toggleDebug(); return; }
|
|
1651
1901
|
if (ctrlKey === 'p') return openPalette();
|
|
1902
|
+
if (ctrlKey === 'r') return openHistorySearch();
|
|
1903
|
+
if (ctrlKey === 'o') return openTranscriptSearch();
|
|
1652
1904
|
// Ctrl+V reaches the app only in terminals that do not paste on it themselves (those
|
|
1653
1905
|
// send the text as a bracketed paste instead). Alt+V is for an image — a screenshot
|
|
1654
1906
|
// cannot be typed in, so no terminal pastes one.
|
|
@@ -1656,6 +1908,10 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1656
1908
|
if (key.meta && String(input).toLowerCase() === 'v') { void pasteFromClipboard({ imageOnly: true }); return; }
|
|
1657
1909
|
if (key.escape) {
|
|
1658
1910
|
if (state.busy) return state.onEscape?.();
|
|
1911
|
+
// Esc closes the suggestion menu first, as the README says. It went straight to the
|
|
1912
|
+
// rewind countdown, so a message ending in `@word` or `/word` could not be sent —
|
|
1913
|
+
// Enter kept choosing a suggestion.
|
|
1914
|
+
if (state.menu.length) { state.menu = []; state.menuIndex = -1; state.menuDismissedFor = state.input; return notifyNow(); }
|
|
1659
1915
|
if (state.viewingTranscript) { state.viewingTranscript = false; state.transcriptOffset = 0; state.newMessages = 0; return notifyNow(); }
|
|
1660
1916
|
const now = Date.now();
|
|
1661
1917
|
if (lastEscapeAt && now - lastEscapeAt < 1200) {
|
|
@@ -1683,9 +1939,18 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1683
1939
|
if ((key.upArrow || key.up) && state.menu.length) { state.menuIndex = clamp(state.menuIndex - 1, 0, state.menu.length - 1); return notifyNow(); }
|
|
1684
1940
|
if ((key.downArrow || key.down) && state.menu.length) { state.menuIndex = clamp(state.menuIndex + 1, 0, state.menu.length - 1); return notifyNow(); }
|
|
1685
1941
|
if (key.tab && state.menu.length) return chooseMenu();
|
|
1686
|
-
if (key.return || key.enter) {
|
|
1687
|
-
|
|
1688
|
-
|
|
1942
|
+
if (key.return || key.enter || input === '\n') {
|
|
1943
|
+
// A new line, not a send: Ctrl+J (a line feed, where Enter is a carriage return),
|
|
1944
|
+
// Alt+Enter, Shift+Enter where the terminal reports it, or a `\` right before the
|
|
1945
|
+
// cursor. Only Shift was checked, which almost no terminal reports — so every way
|
|
1946
|
+
// the README advertised sent the half-written message instead.
|
|
1947
|
+
if (key.shift || key.meta || input === '\n') return insert('\n');
|
|
1948
|
+
if (state.input[state.cursor - 1] === '\\') {
|
|
1949
|
+
state.input = state.input.slice(0, state.cursor - 1) + state.input.slice(state.cursor);
|
|
1950
|
+
state.cursor -= 1;
|
|
1951
|
+
return insert('\n');
|
|
1952
|
+
}
|
|
1953
|
+
if (state.menu.length) return chooseMenu({ run: true });
|
|
1689
1954
|
return submit();
|
|
1690
1955
|
}
|
|
1691
1956
|
|
|
@@ -1715,7 +1980,7 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1715
1980
|
// leave a broken token behind.
|
|
1716
1981
|
const chip = chipBefore(state.input, state.cursor);
|
|
1717
1982
|
const at = chip ? state.cursor - chip[0].length : wordLeft();
|
|
1718
|
-
if (chip) dropPaste(chip
|
|
1983
|
+
if (chip) dropPaste(chip);
|
|
1719
1984
|
state.input = state.input.slice(0, at) + state.input.slice(state.cursor);
|
|
1720
1985
|
state.cursor = at;
|
|
1721
1986
|
refreshMenu();
|
|
@@ -1723,9 +1988,12 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1723
1988
|
}
|
|
1724
1989
|
if (key.backspace) {
|
|
1725
1990
|
if (state.cursor === 0) return;
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1991
|
+
// A chip is inserted with a space after it; Backspace right after that space means
|
|
1992
|
+
// the chip, not the space — otherwise the first Backspace seemed to do nothing.
|
|
1993
|
+
const spaced = state.input[state.cursor - 1] === ' ' ? chipBefore(state.input, state.cursor - 1) : null;
|
|
1994
|
+
const chip = spaced || chipBefore(state.input, state.cursor);
|
|
1995
|
+
const eat = chip ? chip[0].length + (spaced ? 1 : 0) : 1;
|
|
1996
|
+
if (chip) dropPaste(chip);
|
|
1729
1997
|
state.input = state.input.slice(0, state.cursor - eat) + state.input.slice(state.cursor);
|
|
1730
1998
|
state.cursor -= eat;
|
|
1731
1999
|
refreshMenu();
|
|
@@ -1734,7 +2002,7 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1734
2002
|
if (key.delete) {
|
|
1735
2003
|
const chip = chipAfter(state.input, state.cursor);
|
|
1736
2004
|
const eat = chip ? chip[0].length : 1;
|
|
1737
|
-
if (chip) dropPaste(chip
|
|
2005
|
+
if (chip) dropPaste(chip);
|
|
1738
2006
|
state.input = state.input.slice(0, state.cursor) + state.input.slice(state.cursor + eat);
|
|
1739
2007
|
refreshMenu();
|
|
1740
2008
|
return notifyNow();
|
|
@@ -1743,12 +2011,93 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1743
2011
|
if (ctrlKey === 'k') { state.input = state.input.slice(0, state.cursor); refreshMenu(); return notifyNow(); }
|
|
1744
2012
|
if (key.upArrow || key.up) return historyMove(-1);
|
|
1745
2013
|
if (key.downArrow || key.down) return historyMove(1);
|
|
1746
|
-
if (input && !key.ctrl && !key.meta)
|
|
2014
|
+
if (input && !key.ctrl && !key.meta) {
|
|
2015
|
+
// Control characters are keys, never text. Two pressed quickly arrive as one chunk
|
|
2016
|
+
// ("\x05\x15" — Ctrl+E, Ctrl+U) that Ink hands over as input; it used to be
|
|
2017
|
+
// inserted as invisible characters, so the line no longer started with `/` and a
|
|
2018
|
+
// command went to the model as a message. Ctrl+_ \ ] ^ (0x1c–0x1f) did the same.
|
|
2019
|
+
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(input)) {
|
|
2020
|
+
let text = '';
|
|
2021
|
+
const flushText = () => { if (text) { insert(text); text = ''; } };
|
|
2022
|
+
for (const ch of input) {
|
|
2023
|
+
const code = ch.charCodeAt(0);
|
|
2024
|
+
if (code >= 1 && code <= 26 && code !== 9) { flushText(); handleKey(String.fromCharCode(96 + code), { ctrl: true }, exit); }
|
|
2025
|
+
else if (code === 0x7f || code === 8) { flushText(); handleKey('', { backspace: true }, exit); }
|
|
2026
|
+
else if (code < 32 && code !== 9) { /* 0x1c–0x1f and NUL: nothing to type */ }
|
|
2027
|
+
else text += ch;
|
|
2028
|
+
}
|
|
2029
|
+
flushText();
|
|
2030
|
+
return;
|
|
2031
|
+
}
|
|
2032
|
+
insert(input);
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
/**
|
|
2037
|
+
* A searchable list whose choice goes to `onPick` — Ctrl+R (prompt history) and
|
|
2038
|
+
* Ctrl+O (the conversation). Neither existed in this view; the README listed both.
|
|
2039
|
+
* Matching is plain "contains" (case-insensitive): these are sentences, not names.
|
|
2040
|
+
*/
|
|
2041
|
+
function openSearchPanel({ title, options, onPick, empty }) {
|
|
2042
|
+
if (!options.length) { flash(empty, 'info', 1600); return; }
|
|
2043
|
+
state.panel = { title, palette: true, contains: true, filter: '', all: options, options, index: 0, lines: [], onPick, resolve: () => {} };
|
|
2044
|
+
state.menu = [];
|
|
2045
|
+
notifyNow();
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
function openHistorySearch() {
|
|
2049
|
+
const seen = new Set();
|
|
2050
|
+
const options = [];
|
|
2051
|
+
for (const entry of [...state.history].reverse()) {
|
|
2052
|
+
if (!entry || seen.has(entry)) continue;
|
|
2053
|
+
seen.add(entry);
|
|
2054
|
+
options.push({ name: entry.replace(/\s+/g, ' ').slice(0, 110), value: entry });
|
|
2055
|
+
}
|
|
2056
|
+
openSearchPanel({
|
|
2057
|
+
title: 'SEARCH YOUR PROMPTS', options, empty: 'No prompt history yet',
|
|
2058
|
+
onPick: (o) => { state.input = o.value; state.cursor = o.value.length; state.menuDismissedFor = state.input; notifyNow(); },
|
|
2059
|
+
});
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
function openTranscriptSearch() {
|
|
2063
|
+
const inner = Math.max(24, (state._width || process.stdout.columns || 80) - 2);
|
|
2064
|
+
const rows = [];
|
|
2065
|
+
for (const entry of state.transcript) {
|
|
2066
|
+
const built = rowsFor(entry, inner);
|
|
2067
|
+
if (!built.length) continue;
|
|
2068
|
+
if (rows.length) rows.push([]);
|
|
2069
|
+
rows.push(...built);
|
|
2070
|
+
}
|
|
2071
|
+
const total = rows.length;
|
|
2072
|
+
const options = [];
|
|
2073
|
+
rows.forEach((row, i) => {
|
|
2074
|
+
const text = rowText(row).trim();
|
|
2075
|
+
if (text) options.push({ name: text.slice(0, 110), value: i });
|
|
2076
|
+
});
|
|
2077
|
+
openSearchPanel({
|
|
2078
|
+
title: 'SEARCH THE CONVERSATION', options: options.reverse(), empty: 'Nothing in the conversation yet',
|
|
2079
|
+
onPick: (o) => {
|
|
2080
|
+
// Scroll so the chosen line sits mid-screen (the offset counts rows up from the
|
|
2081
|
+
// bottom).
|
|
2082
|
+
const half = Math.floor((state._transcriptHeight || 20) / 2);
|
|
2083
|
+
const offset = Math.max(0, total - o.value - 1 - half);
|
|
2084
|
+
state.viewingTranscript = offset > 0;
|
|
2085
|
+
state.transcriptOffset = offset;
|
|
2086
|
+
flash(`Line ${o.value + 1} of ${total} — Ctrl+End returns to the end`, 'info', 2200);
|
|
2087
|
+
notifyNow();
|
|
2088
|
+
},
|
|
2089
|
+
});
|
|
1747
2090
|
}
|
|
1748
2091
|
|
|
1749
2092
|
function applyPaletteFilter(panel) {
|
|
1750
2093
|
const filter = String(panel.filter || '');
|
|
1751
2094
|
if (!filter) { panel.options = panel.all; panel.index = 0; return; }
|
|
2095
|
+
if (panel.contains) {
|
|
2096
|
+
const needle = filter.toLowerCase();
|
|
2097
|
+
panel.options = panel.all.filter((o) => String(o.name).toLowerCase().includes(needle));
|
|
2098
|
+
panel.index = 0;
|
|
2099
|
+
return;
|
|
2100
|
+
}
|
|
1752
2101
|
panel.options = panel.all
|
|
1753
2102
|
.map((command) => {
|
|
1754
2103
|
const name = typeof command === 'string' ? command : command.name;
|
|
@@ -2012,13 +2361,50 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
2012
2361
|
|
|
2013
2362
|
queue(line) { state.queued.push(line); notify(); },
|
|
2014
2363
|
takeQueued() { const queued = state.queued; state.queued = []; notify(); return queued; },
|
|
2364
|
+
/** Put text back into the composer (queued messages after an interrupt). */
|
|
2365
|
+
/** The line as typed (chips, not their contents) — what goes into the history file. */
|
|
2366
|
+
lastTyped() { const t = state.lastTyped; state.lastTyped = undefined; return t; },
|
|
2367
|
+
restoreInput(text) {
|
|
2368
|
+
const value = String(text || '');
|
|
2369
|
+
if (!value) return;
|
|
2370
|
+
state.input = state.input ? `${value}\n${state.input}` : value;
|
|
2371
|
+
state.cursor = state.input.length;
|
|
2372
|
+
refreshMenu();
|
|
2373
|
+
notifyNow();
|
|
2374
|
+
},
|
|
2015
2375
|
hasQueued() { return state.queued.length > 0; },
|
|
2016
2376
|
|
|
2017
|
-
|
|
2018
|
-
|
|
2377
|
+
/** Attach an image block (from /image, an @mention, a drop): returns its chip number. */
|
|
2378
|
+
addAttachment(attachment) { return attachImage(attachment); },
|
|
2379
|
+
/** Attach image files by path, shrinking any that are too big. */
|
|
2380
|
+
addImageFiles(files) { return attachImageFiles(files); },
|
|
2381
|
+
/**
|
|
2382
|
+
* The images to send with `line`: only those whose chip is still in it, in the order
|
|
2383
|
+
* the chips appear. A chip deleted, cleared with Ctrl+U or never sent is dropped —
|
|
2384
|
+
* the chip in the text is the one source of truth for what goes with the message.
|
|
2385
|
+
*/
|
|
2386
|
+
drainAttachments(line = '') {
|
|
2387
|
+
const order = [...String(line).matchAll(/\[Image #(\d+)\]/g)].map((m) => Number(m[1]));
|
|
2388
|
+
const blocks = order.map((id) => (state.attachments.find((a) => a.id === id) || imageStore.get(id))?.block).filter(Boolean);
|
|
2389
|
+
state.attachments = [];
|
|
2390
|
+
notify();
|
|
2391
|
+
return blocks;
|
|
2392
|
+
},
|
|
2019
2393
|
|
|
2020
2394
|
askDialog(question, options, preview = [], opts = {}) {
|
|
2021
2395
|
return new Promise((resolve) => {
|
|
2396
|
+
// One dialog at a time, and a replaced one is ANSWERED (as cancelled), never left
|
|
2397
|
+
// pending: whoever was awaiting it — a permission prompt, mid-turn — would
|
|
2398
|
+
// otherwise wait forever.
|
|
2399
|
+
if (state.dialog?.resolve) { const prev = state.dialog.resolve; state.dialog = null; prev(null); }
|
|
2400
|
+
// The turn it belongs to was stopped (Esc): close it, as cancelled.
|
|
2401
|
+
if (opts.signal) {
|
|
2402
|
+
if (opts.signal.aborted) { resolve(null); return; }
|
|
2403
|
+
opts.signal.addEventListener('abort', () => {
|
|
2404
|
+
if (state.dialog?.resolve === resolve) { state.dialog = null; notifyNow(); }
|
|
2405
|
+
resolve(null);
|
|
2406
|
+
}, { once: true });
|
|
2407
|
+
}
|
|
2022
2408
|
state.dialog = {
|
|
2023
2409
|
question: plain(question),
|
|
2024
2410
|
options: options.map((option) => (option && typeof option === 'object'
|
|
@@ -2039,6 +2425,7 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
2039
2425
|
|
|
2040
2426
|
showPanel(title, lines) {
|
|
2041
2427
|
return new Promise((resolve) => {
|
|
2428
|
+
if (state.panel?.resolve) { const prev = state.panel.resolve; state.panel = null; prev(null); }
|
|
2042
2429
|
state.panel = {
|
|
2043
2430
|
title: plain(title),
|
|
2044
2431
|
lines: (Array.isArray(lines) ? lines : String(lines).split('\n')).map((line) => plain(line)),
|
|
@@ -2051,6 +2438,9 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
2051
2438
|
|
|
2052
2439
|
updatePanel(lines) {
|
|
2053
2440
|
if (!state.panel) return;
|
|
2441
|
+
// A panel that is updated is live output: it follows the newest lines until the
|
|
2442
|
+
// user scrolls up, and again once they scroll back to the bottom.
|
|
2443
|
+
if (!state.panel.live) { state.panel.live = true; state.panel.follow = true; }
|
|
2054
2444
|
state.panel.lines = (Array.isArray(lines) ? lines : String(lines).split('\n')).map((line) => plain(line));
|
|
2055
2445
|
notify();
|
|
2056
2446
|
},
|