jaitus 0.3.13 → 0.3.14

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
@@ -293,6 +293,13 @@ function Hero({ state, width, height }) {
293
293
  room(3) ? h(Box, { marginTop: 1 },
294
294
  h(Text, { color: mix(p.bg, p.dim, lineIn) },
295
295
  `a coding agent in your terminal ${g('·', '-')} v${state.meta.version || ''}`)) : null,
296
+ // Start-up notices (a service outage or maintenance, a newer version) are transcript
297
+ // entries, which the welcome hides until the first message; without this they were
298
+ // printed but never seen. The newest two, one line each.
299
+ room(5) ? h(Box, { marginTop: 1, flexDirection: 'column' },
300
+ state.transcript.filter((e) => e.kind === 'notice' && (e.data?.tone === 'warn' || e.data?.tone === 'info')).slice(-2)
301
+ .map((e, index) => h(Text, { key: index, color: e.data.tone === 'warn' ? p.yellow : p.muted, wrap: 'truncate-end' },
302
+ `${e.data.tone === 'warn' ? '!' : g('›', '>')} ${String(e.data.text ?? '').replace(/\x1b\[[0-9;]*m/g, '')}`))) : null,
296
303
  room(8) ? h(Box, { marginTop: 1, flexDirection: 'column' },
297
304
  tips.map(([marker, title, note], index) => {
298
305
  const on = Math.max(0, Math.min(1, tipsIn * 3 - index));
@@ -326,19 +333,25 @@ function layoutTranscript(state, width, height, top) {
326
333
  // Where each prompt starts, so the sticky header can name the one whose answer is
327
334
  // currently on screen rather than always the newest.
328
335
  const prompts = [];
336
+ // The entry each row comes from (the live, streaming entry included) and where each
337
+ // entry starts — what keeps a scrolled-back view pinned to the line it shows.
338
+ const rowEntry = [];
339
+ const entryStart = new Map();
329
340
  for (const entry of state.transcript) {
330
341
  const built = rowsFor(entry, inner);
331
342
  if (!built.length) continue;
332
- if (rows.length) { rows.push([]); owners.push(null); }
343
+ if (rows.length) { rows.push([]); owners.push(null); rowEntry.push(null); }
333
344
  if (entry.kind === 'user') prompts.push({ at: rows.length, text: String(entry.data.text || '') });
334
- built.forEach((row, i) => { rows.push(row); owners.push({ entry, head: i === 0 }); });
345
+ entryStart.set(entry.id, rows.length);
346
+ built.forEach((row, i) => { rows.push(row); owners.push({ entry, head: i === 0 }); rowEntry.push(entry.id); });
335
347
  }
336
348
  if (state.live && state.live.text) {
337
349
  const built = rowsFor(state.live.entry, inner);
338
350
  if (built.length) {
339
- if (rows.length) { rows.push([]); owners.push(null); }
351
+ if (rows.length) { rows.push([]); owners.push(null); rowEntry.push(null); }
352
+ entryStart.set(state.live.entry.id, rows.length);
340
353
  rows.push(...built);
341
- for (let i = 0; i < built.length; i++) owners.push(null);
354
+ for (let i = 0; i < built.length; i++) { owners.push(null); rowEntry.push(state.live.entry.id); }
342
355
  }
343
356
  }
344
357
 
@@ -349,6 +362,17 @@ function layoutTranscript(state, width, height, top) {
349
362
  // not stuck, it was somewhere off the top of a list that had already ended.
350
363
  const maxOffset = Math.max(0, rows.length - height);
351
364
  state.maxScroll = maxOffset;
365
+ // Scrolled back, the view stays on the line it shows while the answer streams in below.
366
+ // The offset counts rows up from the BOTTOM, so every new line of a streaming answer
367
+ // used to slide the view down a line: whatever you had scrolled up to read crept away
368
+ // a few lines a second, and scrolling felt like fighting the screen. Instead the view
369
+ // is pinned to the entry (and row within it) at its top; the offset is re-derived from
370
+ // that each frame. A scroll by the user, a width change or returning to live drops the pin.
371
+ const pin = state._scrollPin;
372
+ if (state.viewingTranscript && pin && pin.width === width && entryStart.has(pin.entry)) {
373
+ const wantFirst = entryStart.get(pin.entry) + pin.row;
374
+ state.transcriptOffset = rows.length - height - wantFirst;
375
+ }
352
376
  const offset = state.viewingTranscript ? clamp(state.transcriptOffset, 0, maxOffset) : 0;
353
377
  // Snap the stored offset to what will actually be drawn: this is the only place that
354
378
  // knows how many rows exist at this width, so it is the authority, and writing the
@@ -358,6 +382,13 @@ function layoutTranscript(state, width, height, top) {
358
382
 
359
383
  const end = rows.length - offset;
360
384
  const first = Math.max(0, end - height);
385
+ // Pin to the first visible row that belongs to an entry.
386
+ state._scrollPin = null;
387
+ if (state.viewingTranscript && offset > 0) {
388
+ for (let i = first; i < end; i++) {
389
+ if (rowEntry[i] != null) { state._scrollPin = { entry: rowEntry[i], row: i - entryStart.get(rowEntry[i]) - (i - first), width }; break; }
390
+ }
391
+ }
361
392
  const visible = rows.slice(first, end);
362
393
  const visibleOwners = owners.slice(first, end);
363
394
  // Bottom-anchored: a short conversation sits at the composer, not floating at the top
@@ -610,7 +641,16 @@ function Composer({ state, width }) {
610
641
  // The same lines the App measured the composer's height from (composerLayout): the
611
642
  // frame height is fixed, so a composer one row taller than it was counted as pushes
612
643
  // the whole screen up — that was the header vanishing after a multi-line paste.
613
- const { lines, caretLine, caretCol } = composerLayout(state, inner - 2, composed);
644
+ const layout = composerLayout(state, inner - 2, composed);
645
+ const { caretLine, caretCol } = layout;
646
+ // The selected part of the input, drawn inverted like a selection in the transcript.
647
+ const sel = inputSelection(state);
648
+ const lines = !sel ? layout.lines : layout.lines.map((row, i) => {
649
+ const start = layout.starts[i];
650
+ const from = Math.max(0, sel[0] - start);
651
+ const to = Math.min(layout.lens[i], sel[1] - start);
652
+ return to > from ? inverseRange(row, from, to) : row;
653
+ });
614
654
  const placeholder = state.placeholder || 'Describe a change, ask a question, or / for commands';
615
655
 
616
656
  const borderColor = state.busy ? p.brand : state.menu.length ? p.teal : p.line;
@@ -695,10 +735,17 @@ function splitSpansAtNewlines(spans) {
695
735
  */
696
736
  function composerLayout(state, width, spans = null) {
697
737
  const input = state.input || '';
698
- if (!input) return { lines: [[]], caretLine: 0, total: 1 };
738
+ if (!input) return { lines: [[]], caretLine: 0, caretCol: 0, total: 1, starts: [0], lens: [0] };
699
739
  const paragraphs = splitSpansAtNewlines(spans || [{ t: input }]);
700
740
  const all = [];
701
- let caret = 0;
741
+ // Where each drawn line starts in `input`, and how long it is. Found by locating each
742
+ // line's own text in its paragraph rather than assumed: a wrap at a space drops the
743
+ // space but a hard break inside a long word drops nothing, so "+1 per wrap" put the
744
+ // caret one character off on every line after a hard break — and a click or a mouse
745
+ // selection needs the exact offset of every cell.
746
+ const starts = [];
747
+ const lens = [];
748
+ let caret = -1;
702
749
  let caretCol = 0;
703
750
  let start = 0; // offset of this paragraph in `input`
704
751
  const cursor = Math.max(0, Math.min(input.length, state.cursor ?? input.length));
@@ -707,24 +754,45 @@ function composerLayout(state, width, spans = null) {
707
754
  // One column is kept spare for the cursor to sit on after a line's last character,
708
755
  // so a full line never grows a cell when the cursor reaches its end.
709
756
  const rows = para.length ? wrapRow(para, Math.max(4, width - 1)) : [[]];
710
- if (cursor >= start && cursor <= start + text.length) {
711
- let rowStart = start;
712
- let line = rows.length - 1;
713
- let col = 0;
714
- for (let i = 0; i < rows.length; i++) {
715
- const len = rows[i].map((s) => s.t).join('').length;
716
- if (cursor <= rowStart + len || i === rows.length - 1) { line = i; col = Math.max(0, Math.min(len, cursor - rowStart)); break; }
717
- rowStart += len + 1; // + the space the wrap consumed
757
+ let from = 0;
758
+ rows.forEach((row, i) => {
759
+ const rowText = row.map((s) => s.t).join('');
760
+ const found = rowText ? text.indexOf(rowText, from) : from;
761
+ const at = found >= 0 ? found : from;
762
+ const len = rowText.length;
763
+ starts.push(start + at);
764
+ lens.push(len);
765
+ // The caret's line: the first line whose span reaches the cursor (a cursor at a
766
+ // wrap point sits in the spare cell at the end of the line, as it always has).
767
+ const lineStart = start + at;
768
+ if (caret < 0 && cursor >= lineStart && cursor <= lineStart + len) {
769
+ caret = all.length + i;
770
+ caretCol = cursor - lineStart;
718
771
  }
719
- caret = all.length + line;
720
- caretCol = col;
772
+ from = at + len;
773
+ });
774
+ // A cursor sitting on a space a wrap consumed: the end of the line before it.
775
+ if (caret < 0 && cursor >= start && cursor <= start + text.length) {
776
+ caret = all.length + rows.length - 1;
777
+ caretCol = lens[lens.length - 1];
721
778
  }
722
779
  all.push(...rows);
723
780
  start += text.length + 1; // + the newline
724
781
  }
725
- if (all.length <= COMPOSER_MAX_LINES) return { lines: all, caretLine: caret, caretCol, total: all.length };
782
+ if (caret < 0) { caret = all.length - 1; caretCol = lens[lens.length - 1]; }
783
+ if (all.length <= COMPOSER_MAX_LINES) return { lines: all, caretLine: caret, caretCol, total: all.length, starts, lens };
726
784
  const first = Math.max(0, Math.min(caret - COMPOSER_MAX_LINES + 1, all.length - COMPOSER_MAX_LINES));
727
- return { lines: all.slice(first, first + COMPOSER_MAX_LINES), caretLine: caret - first, caretCol, total: all.length };
785
+ return {
786
+ lines: all.slice(first, first + COMPOSER_MAX_LINES), caretLine: caret - first, caretCol, total: all.length,
787
+ starts: starts.slice(first, first + COMPOSER_MAX_LINES), lens: lens.slice(first, first + COMPOSER_MAX_LINES),
788
+ };
789
+ }
790
+
791
+ /** The selected part of the input as [from, to), or null — the anchor to the cursor. */
792
+ function inputSelection(state) {
793
+ const a = state.inputAnchor;
794
+ if (a == null || a === state.cursor) return null;
795
+ return a < state.cursor ? [a, state.cursor] : [state.cursor, a];
728
796
  }
729
797
 
730
798
  /** A row of spans cut at character `n`: [before, after]. */
@@ -1051,6 +1119,17 @@ function App({ controller }) {
1051
1119
  const state = controller.state;
1052
1120
  // One empty frame after a resize — see onResize.
1053
1121
  if (state.blankFrame) return h(Box, { height: 1 });
1122
+ // Smaller than the layout can fit: say so instead of drawing a frame wider or taller
1123
+ // than the window, which the terminal wraps and scrolls into a heap of half-lines with
1124
+ // the header pushed off the top. Everything comes back as soon as it is bigger.
1125
+ const realCols = size?.columns || process.stdout.columns || 80;
1126
+ const realRows = size?.rows || process.stdout.rows || 24;
1127
+ if (realCols < 30 || realRows < 11) {
1128
+ controller.hits.reset();
1129
+ return h(Box, { width: realCols, height: Math.max(1, realRows - 1), flexDirection: 'column', justifyContent: 'center' },
1130
+ h(Text, { color: p.dim, wrap: 'truncate-end' }, realCols < 22 ? 'Too small' : 'Window too small for Jaitus'),
1131
+ realRows > 2 ? h(Text, { color: p.faint, wrap: 'truncate-end' }, 'Make it at least 30 × 11') : null);
1132
+ }
1054
1133
  const composerRows = CHROME.composerFrame
1055
1134
  + (state.menu.length ? state.menu.length + 1 : 0)
1056
1135
  + Math.max(0, composerTextRows(state, width) - 1)
@@ -1088,6 +1167,17 @@ function App({ controller }) {
1088
1167
  registerLinkHits(layout.visible, layout.screenTop, hits);
1089
1168
  registerHeaderHits(state, width, hits);
1090
1169
  hits.add({ x1: 1, x2: width, y1: height, y2: height, action: { type: 'status' } });
1170
+ // The composer's text, cell by cell, so a click places the cursor and a drag selects —
1171
+ // the transcript was the only selectable text, so nothing typed could be selected or
1172
+ // copied back out. Its border sits right above the status bar; the text starts after
1173
+ // the menu (when open), two columns of border and padding and the two-column prompt.
1174
+ {
1175
+ const top = height - CHROME.status - composerRows + 1; // the border row
1176
+ const textTop = top + 1 + (state.menu.length ? state.menu.length + 1 : 0);
1177
+ const geo = composerLayout(state, Math.max(16, width - 6) - 2);
1178
+ state._composer = { textTop, textLeft: 6, starts: geo.starts, lens: geo.lens };
1179
+ hits.add({ x1: 2, x2: width - 1, y1: textTop, y2: textTop + geo.lines.length - 1, action: { type: 'composer' } });
1180
+ }
1091
1181
  if (state.menu.length) {
1092
1182
  const menuTop = height - CHROME.status - composerRows + 1; // inside the composer border
1093
1183
  state.menu.forEach((_item, index) => {
@@ -1215,6 +1305,19 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
1215
1305
  const n = Number(settings?.scrollSpeed);
1216
1306
  return Math.max(1, Math.round((Number.isFinite(n) && n > 0 ? n : 1) * 3));
1217
1307
  };
1308
+ // Wheel acceleration: a slow notch moves one step, a fast spin moves further — up to
1309
+ // five steps a notch. At a flat 3 rows a notch, getting to the top of a long session
1310
+ // took hundreds of notches. `wheelAccel off` keeps every notch the same (the setting
1311
+ // existed and was listed, but nothing read it).
1312
+ let wheelAt = 0;
1313
+ let wheelStreak = 0;
1314
+ const wheelRows = () => {
1315
+ const now = Date.now();
1316
+ wheelStreak = now - wheelAt < 80 ? Math.min(wheelStreak + 1, 12) : 0;
1317
+ wheelAt = now;
1318
+ const accel = settings?.wheelAccel === false ? 1 : 1 + Math.floor(wheelStreak / 3);
1319
+ return scrollStep() * accel;
1320
+ };
1218
1321
 
1219
1322
  /**
1220
1323
  * Coalesce notifications to one per animation frame.
@@ -1289,6 +1392,8 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
1289
1392
  }
1290
1393
 
1291
1394
  function insert(text) {
1395
+ // Typing over a selection replaces it, as in every text field.
1396
+ deleteInputSelection();
1292
1397
  state.input = state.input.slice(0, state.cursor) + text + state.input.slice(state.cursor);
1293
1398
  state.cursor += text.length;
1294
1399
  refreshMenu();
@@ -1298,6 +1403,7 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
1298
1403
  function clearInput() {
1299
1404
  state.input = '';
1300
1405
  state.cursor = 0;
1406
+ state.inputAnchor = null;
1301
1407
  state.menu = [];
1302
1408
  state.menuIndex = -1;
1303
1409
  notifyNow();
@@ -1513,6 +1619,7 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
1513
1619
  function historyMove(direction) {
1514
1620
  if (!state.history.length) return;
1515
1621
  if (state.historyIndex === state.history.length) state.draft = state.input;
1622
+ state.inputAnchor = null;
1516
1623
  state.historyIndex = clamp(state.historyIndex + direction, 0, state.history.length);
1517
1624
  state.input = state.historyIndex === state.history.length ? (state.draft || '') : state.history[state.historyIndex];
1518
1625
  state.cursor = state.input.length;
@@ -1591,11 +1698,85 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
1591
1698
  return;
1592
1699
  }
1593
1700
  state.transcriptOffset = next;
1701
+ state._scrollPin = null; // the user moved: the next frame pins anew
1594
1702
  state.viewingTranscript = next > 0;
1595
1703
  if (!next) state.newMessages = 0;
1596
1704
  notifyNow();
1597
1705
  }
1598
1706
 
1707
+ // ── selecting text in the composer ──
1708
+ //
1709
+ // The composer is a text field, so it selects like one: drag with the mouse (copied on
1710
+ // release, like the transcript), click to place the cursor, double-click a word,
1711
+ // triple-click everything; Shift with the arrows, Home and End from the keyboard;
1712
+ // Ctrl+C copies, Ctrl+X cuts, and typing, Backspace or Delete replace the selection.
1713
+ // Before this nothing typed could be selected or copied back out of the box at all.
1714
+ let composerPress = false;
1715
+
1716
+ /** The input offset under a screen cell, clamped into the composer's lines. */
1717
+ function inputOffsetAt(point, { through = false } = {}) {
1718
+ const geo = state._composer;
1719
+ if (!geo?.starts?.length) return state.cursor;
1720
+ const n = geo.starts.length;
1721
+ if (point.y < geo.textTop) return geo.starts[0];
1722
+ if (point.y > geo.textTop + n - 1) return geo.starts[n - 1] + geo.lens[n - 1];
1723
+ const i = point.y - geo.textTop;
1724
+ // `through`: a drag to the right covers the character under the pointer too, as a
1725
+ // terminal's own selection does — otherwise the last letter was always left out.
1726
+ return geo.starts[i] + clamp(point.x - geo.textLeft + (through ? 1 : 0), 0, geo.lens[i]);
1727
+ }
1728
+
1729
+ // A paste or image chip is one object: a selection that cuts through one takes all of it.
1730
+ function snapToChips(from, to) {
1731
+ for (const m of state.input.matchAll(/\[#\d+ pasted [^\]]*\]|\[Image #\d+\]/g)) {
1732
+ const a = m.index;
1733
+ const b = a + m[0].length;
1734
+ if (from > a && from < b) from = a;
1735
+ if (to > a && to < b) to = b;
1736
+ }
1737
+ return [from, to];
1738
+ }
1739
+
1740
+ function selectedInputText() {
1741
+ const sel = inputSelection(state);
1742
+ return sel ? state.input.slice(...snapToChips(sel[0], sel[1])) : '';
1743
+ }
1744
+
1745
+ /** Remove the selected part of the input. True when there was one. */
1746
+ function deleteInputSelection() {
1747
+ const sel = inputSelection(state);
1748
+ state.inputAnchor = null;
1749
+ if (!sel) return false;
1750
+ const [from, to] = snapToChips(sel[0], sel[1]);
1751
+ for (const m of state.input.slice(from, to).matchAll(/\[#(\d+) pasted [^\]]*\]|\[Image #(\d+)\]/g)) dropPaste(m);
1752
+ state.input = state.input.slice(0, from) + state.input.slice(to);
1753
+ state.cursor = from;
1754
+ refreshMenu();
1755
+ return true;
1756
+ }
1757
+
1758
+ async function copyInputSelection({ cut = false } = {}) {
1759
+ const text = selectedInputText();
1760
+ if (!text) return false;
1761
+ const ok = await (state.onCopy ? state.onCopy(text) : Promise.resolve(false));
1762
+ if (ok && cut) deleteInputSelection();
1763
+ flash(ok ? `${cut ? 'Cut' : 'Copied'} ${text.length} character${text.length === 1 ? '' : 's'}` : 'Nothing could copy it — no clipboard tool found', ok ? 'ok' : 'warn', 1600);
1764
+ notifyNow();
1765
+ return ok;
1766
+ }
1767
+
1768
+ /**
1769
+ * Move the cursor to `to`. With Shift the selection grows from where it started;
1770
+ * without it any selection goes.
1771
+ */
1772
+ function moveCursor(to, shift) {
1773
+ if (shift) { if (state.inputAnchor == null) state.inputAnchor = state.cursor; }
1774
+ else state.inputAnchor = null;
1775
+ state.cursor = clamp(to, 0, state.input.length);
1776
+ if (state.inputAnchor === state.cursor) state.inputAnchor = null;
1777
+ return notifyNow();
1778
+ }
1779
+
1599
1780
  /** Every screen cell between two points, as text. */
1600
1781
  function selectedText() {
1601
1782
  const sel = state.selection;
@@ -1827,7 +2008,8 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
1827
2008
  if (state.dialog) return undefined;
1828
2009
  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(); }
1829
2010
  if (state.panel) return scrollPanel(up ? -3 : 3);
1830
- return scroll(up ? scrollStep() : -scrollStep());
2011
+ const rows = wheelRows();
2012
+ return scroll(up ? rows : -rows);
1831
2013
  }
1832
2014
  // Right-click pastes, as it does in a Windows console — the terminal cannot do it
1833
2015
  // itself while the app has the mouse.
@@ -1836,8 +2018,43 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
1836
2018
  return;
1837
2019
  }
1838
2020
 
2021
+ if (event.kind === 'down' && event.button === 'left' && !state.dialog && !state.panel && hits.at(event.x, event.y)?.action.type === 'composer') {
2022
+ clearSelection();
2023
+ composerPress = true;
2024
+ state.cursor = inputOffsetAt(event);
2025
+ state.inputAnchor = state.cursor;
2026
+ return notifyNow();
2027
+ }
2028
+ if (event.kind === 'drag' && composerPress) {
2029
+ const plain = inputOffsetAt(event);
2030
+ state.cursor = plain >= (state.inputAnchor ?? plain) ? inputOffsetAt(event, { through: true }) : plain;
2031
+ return notifyNow();
2032
+ }
2033
+ if (event.kind === 'up' && composerPress) {
2034
+ composerPress = false;
2035
+ const now = Date.now();
2036
+ const again = lastClick && now - lastClick.at < 450 && lastClick.y === event.y && Math.abs(lastClick.x - event.x) <= 1;
2037
+ clickCount = again ? clickCount + 1 : 1;
2038
+ lastClick = { x: event.x, y: event.y, at: now };
2039
+ if (clickCount >= 3) { state.inputAnchor = 0; state.cursor = state.input.length; }
2040
+ else if (clickCount === 2) {
2041
+ // A word is what you would want to paste: a path, a URL, an identifier.
2042
+ const inWord = (ch) => ch && !/[\s"'`()[\]{}<>,;]/.test(ch);
2043
+ let a = state.cursor; let b = state.cursor;
2044
+ while (inWord(state.input[a - 1])) a -= 1;
2045
+ while (inWord(state.input[b])) b += 1;
2046
+ if (b > a) { state.inputAnchor = a; state.cursor = b; }
2047
+ }
2048
+ if (!inputSelection(state)) { state.inputAnchor = null; return notifyNow(); }
2049
+ notifyNow();
2050
+ // Copied on release, like a selection in the transcript (unless copyOnSelect is off).
2051
+ if (settings?.copyOnSelect !== false) void copyInputSelection();
2052
+ else flash(`${selectedInputText().length} characters selected · Ctrl+C copies them`, 'info', 3000);
2053
+ return;
2054
+ }
1839
2055
  if (event.kind === 'down' && event.button === 'left') {
1840
2056
  clearTimeout(returnToLiveTimer); // a second click: this is a double-click, stay put
2057
+ if (state.inputAnchor != null) { state.inputAnchor = null; notifyNow(); }
1841
2058
  // A press starts a possible drag AND a possible click. Which it was is only
1842
2059
  // known on release, so nothing is dispatched yet — dispatching here made every
1843
2060
  // attempt to select text also press whatever was under the cursor.
@@ -1981,6 +2198,8 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
1981
2198
  // readline keypress objects) can drive the same handler.
1982
2199
  const ctrlKey = key.ctrl ? String(key.name || input || '').toLowerCase() : '';
1983
2200
 
2201
+ if (ctrlKey === 'c' && inputSelection(state)) { void copyInputSelection(); return; }
2202
+ if (ctrlKey === 'x' && inputSelection(state)) { void copyInputSelection({ cut: true }); return; }
1984
2203
  if (ctrlKey === 'c') {
1985
2204
  // With text selected, Ctrl+C copies it (as the README says) — including mid-turn,
1986
2205
  // where it used to stop the turn instead. With copyOnSelect off it was the only way.
@@ -2009,6 +2228,7 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
2009
2228
  if (ctrlKey === 'v') { void pasteFromClipboard(); return; }
2010
2229
  if (key.meta && String(input).toLowerCase() === 'v') { void pasteFromClipboard({ imageOnly: true }); return; }
2011
2230
  if (key.escape) {
2231
+ if (inputSelection(state)) { state.inputAnchor = null; return notifyNow(); }
2012
2232
  if (state.busy) return state.onEscape?.();
2013
2233
  // Esc closes the suggestion menu first, as the README says. It went straight to the
2014
2234
  // rewind countdown, so a message ending in `@word` or `/word` could not be sent —
@@ -2058,25 +2278,25 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
2058
2278
 
2059
2279
  // ── line editing ──
2060
2280
  if (key.leftArrow || key.left) {
2061
- if (key.ctrl || key.meta) state.cursor = wordLeft();
2062
- else {
2063
- // Step over a chip, not into it. A cursor sitting inside `[#1 pasted 120 …]`
2064
- // can only do damage: anything typed there breaks the token.
2065
- const chip = chipBefore(state.input, state.cursor);
2066
- state.cursor = Math.max(0, state.cursor - (chip ? chip[0].length : 1));
2067
- }
2068
- return notifyNow();
2281
+ const sel = inputSelection(state);
2282
+ // Left with a selection and no Shift lands at its start, as in any text field.
2283
+ if (sel && !key.shift) return moveCursor(sel[0], false);
2284
+ if (key.ctrl || key.meta) return moveCursor(wordLeft(), key.shift);
2285
+ // Step over a chip, not into it. A cursor sitting inside `[#1 pasted 120 …]`
2286
+ // can only do damage: anything typed there breaks the token.
2287
+ const chip = chipBefore(state.input, state.cursor);
2288
+ return moveCursor(state.cursor - (chip ? chip[0].length : 1), key.shift);
2069
2289
  }
2070
2290
  if (key.rightArrow || key.right) {
2071
- if (key.ctrl || key.meta) state.cursor = wordRight();
2072
- else {
2073
- const chip = chipAfter(state.input, state.cursor);
2074
- state.cursor = Math.min(state.input.length, state.cursor + (chip ? chip[0].length : 1));
2075
- }
2076
- return notifyNow();
2291
+ const sel = inputSelection(state);
2292
+ if (sel && !key.shift) return moveCursor(sel[1], false);
2293
+ if (key.ctrl || key.meta) return moveCursor(wordRight(), key.shift);
2294
+ const chip = chipAfter(state.input, state.cursor);
2295
+ return moveCursor(state.cursor + (chip ? chip[0].length : 1), key.shift);
2077
2296
  }
2078
- if (key.home || (ctrlKey === 'a')) { state.cursor = 0; return notifyNow(); }
2079
- if (key.end || (ctrlKey === 'e')) { state.cursor = state.input.length; return notifyNow(); }
2297
+ if (key.home || (ctrlKey === 'a')) return moveCursor(0, key.shift);
2298
+ if (key.end || (ctrlKey === 'e')) return moveCursor(state.input.length, key.shift);
2299
+ if (['w', 'u', 'k'].includes(ctrlKey)) state.inputAnchor = null;
2080
2300
  if (ctrlKey === 'w') {
2081
2301
  // A chip is one word. Deleting "the last word" of `…, 969 chars]` would otherwise
2082
2302
  // leave a broken token behind.
@@ -2088,6 +2308,7 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
2088
2308
  refreshMenu();
2089
2309
  return notifyNow();
2090
2310
  }
2311
+ if ((key.backspace || key.delete) && deleteInputSelection()) return notifyNow();
2091
2312
  if (key.backspace) {
2092
2313
  if (state.cursor === 0) return;
2093
2314
  // A chip is inserted with a space after it; Backspace right after that space means
@@ -2185,6 +2406,7 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
2185
2406
  const offset = Math.max(0, total - o.value - 1 - half);
2186
2407
  state.viewingTranscript = offset > 0;
2187
2408
  state.transcriptOffset = offset;
2409
+ state._scrollPin = null;
2188
2410
  flash(`Line ${o.value + 1} of ${total} — Ctrl+End returns to the end`, 'info', 2200);
2189
2411
  notifyNow();
2190
2412
  },