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/package.json +2 -2
- package/src/agent/loop.js +9 -1
- package/src/agent/tools.js +11 -5
- package/src/api.js +174 -174
- package/src/commands/update.js +75 -49
- package/src/index.js +7 -3
- package/src/serviceNotice.js +151 -0
- package/src/sessions.js +8 -0
- package/src/tui-ink.js +258 -36
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
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
|
-
|
|
720
|
-
|
|
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 (
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
return
|
|
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
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
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'))
|
|
2079
|
-
if (key.end || (ctrlKey === 'e'))
|
|
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
|
},
|