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/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
- /** 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);
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
- 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
- });
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.text });
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.text });
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) => h(Box, { key: index, flexDirection: 'row' },
533
- h(Row, { spans: row, wrap: 'wrap' }),
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.attachments.length
550
- ? h(Box, { marginTop: 1 }, h(Text, { color: p.purple }, state.attachments.map((a) => `${g('⧉', '[img]')} ${a.name || 'image'}`).join(' ')))
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 seen = start;
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
- seen += rows[i].map((s) => s.t).join('').length;
597
- if (cursor <= seen) { line = i; break; }
598
- seen += 1; // the space the wrap consumed
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
- const rows = dialogHeight(questionRows, previewRows.length, dialog.options.length);
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
- dialog.options.forEach((option, index) => {
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((line, index) => h(Row, {
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
- dialog.options.map((option, index) => {
825
+ visibleOptions.map((option, i) => {
826
+ const index = firstOption + i;
737
827
  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)}`));
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
- 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)));
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.attachments.length ? 2 : 0);
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
- state.input = `${prefix}${item.value} ${suffix}`;
1143
- state.cursor = prefix.length + String(item.value).length + 1;
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
- const id = state.pastes.length + 1;
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 png = await (api._readClipboardImage || readClipboardImage)();
1189
- if (png?.length) {
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 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();
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
- 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));
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(id) {
1227
- state.pastes = state.pastes.filter((entry) => entry.id !== Number(id));
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
- const blob = state.pastes.find((entry) => entry.id === Number(id));
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 · jaitus config copyOnSelect on — to copy these automatically`, 'info', 4000);
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
- state.viewingTranscript = false;
1478
- state.transcriptOffset = 0;
1479
- state.newMessages = 0;
1480
- notifyNow();
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
- if (event.kind === 'wheelUp') return scroll(scrollStep());
1519
- if (event.kind === 'wheelDown') return scroll(-scrollStep());
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 typed = String(input).slice(0, newlineAt);
1618
- const rest = String(input).slice(newlineAt + 1);
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
- 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.
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
- if (rest) insert(rest);
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
- if (!key.shift && state.menu.length) return chooseMenu({ run: true });
1688
- if (key.shift) return insert('\n');
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[1]);
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
- const chip = chipBefore(state.input, state.cursor);
1727
- const eat = chip ? chip[0].length : 1;
1728
- if (chip) dropPaste(chip[1]);
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[1]);
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) insert(input);
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
- addAttachment(attachment) { state.attachments.push(attachment); notify(); },
2018
- drainAttachments() { const list = state.attachments; state.attachments = []; notify(); return list.map((a) => a.block).filter(Boolean); },
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
  },