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/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
- /** Register every URL on screen as a click target. */
391
- function registerLinkHits(visible, screenTop, hits) {
392
- if (!hits) return;
393
- visible.forEach((row, index) => {
394
- const text = rowText(row);
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
- const url = match[0].replace(/[.,;:)\]]+$/, '');
397
- hits.add({
398
- x1: 2 + match.index,
399
- x2: 2 + match.index + url.length - 1,
400
- y1: screenTop + index,
401
- y2: screenTop + index,
402
- action: { type: 'link', url },
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.text });
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.text });
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) => h(Box, { key: index, flexDirection: 'row' },
533
- h(Row, { spans: row, wrap: 'wrap' }),
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.attachments.length
550
- ? h(Box, { marginTop: 1 }, h(Text, { color: p.purple }, state.attachments.map((a) => `${g('⧉', '[img]')} ${a.name || 'image'}`).join(' ')))
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 seen = start;
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
- seen += rows[i].map((s) => s.t).join('').length;
597
- if (cursor <= seen) { line = i; break; }
598
- seen += 1; // the space the wrap consumed
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
- ['ctrl+l clear', p.faint],
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
- const rows = dialogHeight(questionRows, previewRows.length, dialog.options.length);
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
- dialog.options.forEach((option, index) => {
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((line, index) => h(Row, {
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
- dialog.options.map((option, index) => {
837
+ visibleOptions.map((option, i) => {
838
+ const index = firstOption + i;
737
839
  const selected = index === dialog.index;
738
- return h(Box, { key: `${option.value}-${index}`, backgroundColor: selected ? p.raised : undefined },
739
- h(Text, { color: selected ? accent : p.muted, bold: selected },
740
- `${selected ? g(' ▸ ', ' > ') : ' '}${index + 1}. ${plain(option.label)}`));
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
- const rows = (panel.rows || []).slice(-maxRows);
792
- const lines = (panel.lines || []).slice(-maxRows);
793
- bodyRows = Math.max(1, rows.length || lines.length);
794
- body = rows.length
795
- ? [h(Rows, { key: 'rows', rows })]
796
- : lines.map((line, index) => h(Text, { key: index, color: p.text, wrap: 'truncate-end' }, plain(line)));
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.attachments.length ? 2 : 0);
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
- state.input = `${prefix}${item.value} ${suffix}`;
1143
- state.cursor = prefix.length + String(item.value).length + 1;
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
- if (!value) return;
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
- const id = state.pastes.length + 1;
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 png = await (api._readClipboardImage || readClipboardImage)();
1189
- if (png?.length) {
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 path = await import('node:path');
1193
- const file = path.join(os.tmpdir(), `jaitus-clip-${Date.now()}.png`);
1194
- fs.writeFileSync(file, png);
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' : 'The clipboard is empty', 'info', 1600);
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
- const CHIP = /\[#(\d+) pasted [^\]]*\]/;
1222
- const chipBefore = (text, cursor) => new RegExp(`${CHIP.source}$`).exec(text.slice(0, cursor));
1223
- const chipAfter = (text, cursor) => new RegExp(`^${CHIP.source}`).exec(text.slice(cursor));
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(id) {
1227
- state.pastes = state.pastes.filter((entry) => entry.id !== Number(id));
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
- const blob = state.pastes.find((entry) => entry.id === Number(id));
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 · jaitus config copyOnSelect on — to copy these automatically`, 'info', 4000);
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
- state.viewingTranscript = false;
1478
- state.transcriptOffset = 0;
1479
- state.newMessages = 0;
1480
- notifyNow();
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
- if (event.kind === 'wheelUp') return scroll(scrollStep());
1519
- if (event.kind === 'wheelDown') return scroll(-scrollStep());
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 typed = String(input).slice(0, newlineAt);
1618
- const rest = String(input).slice(newlineAt + 1);
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
- if (key.shift) insert('\n');
1621
- // This path went straight to submit(), which is why `/re` + Enter sometimes ran
1622
- // `/re` instead of the highlighted `/review`. Whether Enter arrives on its own or
1623
- // glued to the typed text is a matter of how fast the keystrokes were batched by
1624
- // the PTY — so the two paths have to make the same decision, or the shortcut
1625
- // works or does not work depending on typing speed.
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
- if (rest) insert(rest);
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
- if (!key.shift && state.menu.length) return chooseMenu({ run: true });
1688
- if (key.shift) return insert('\n');
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[1]);
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
- const chip = chipBefore(state.input, state.cursor);
1727
- const eat = chip ? chip[0].length : 1;
1728
- if (chip) dropPaste(chip[1]);
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[1]);
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) insert(input);
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
- addAttachment(attachment) { state.attachments.push(attachment); notify(); },
2018
- drainAttachments() { const list = state.attachments; state.attachments = []; notify(); return list.map((a) => a.block).filter(Boolean); },
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
  },