@prometheus-ai/tui 0.5.3 → 0.5.8

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.
Files changed (55) hide show
  1. package/dist/types/autocomplete.d.ts +3 -1
  2. package/dist/types/components/box.d.ts +1 -1
  3. package/dist/types/components/editor.d.ts +35 -2
  4. package/dist/types/components/image.d.ts +22 -3
  5. package/dist/types/components/input.d.ts +6 -1
  6. package/dist/types/components/loader.d.ts +9 -2
  7. package/dist/types/components/markdown.d.ts +3 -1
  8. package/dist/types/components/scroll-view.d.ts +23 -1
  9. package/dist/types/components/select-list.d.ts +19 -1
  10. package/dist/types/components/settings-list.d.ts +87 -7
  11. package/dist/types/components/spacer.d.ts +1 -1
  12. package/dist/types/components/tab-bar.d.ts +37 -4
  13. package/dist/types/components/text.d.ts +2 -2
  14. package/dist/types/components/truncated-text.d.ts +1 -1
  15. package/dist/types/fuzzy.d.ts +10 -1
  16. package/dist/types/index.d.ts +1 -0
  17. package/dist/types/keybindings.d.ts +5 -3
  18. package/dist/types/keys.d.ts +1 -1
  19. package/dist/types/kill-ring.d.ts +0 -7
  20. package/dist/types/kitty-graphics.d.ts +16 -31
  21. package/dist/types/loop-watchdog.d.ts +39 -0
  22. package/dist/types/mouse.d.ts +41 -0
  23. package/dist/types/stdin-buffer.d.ts +17 -0
  24. package/dist/types/terminal-capabilities.d.ts +74 -18
  25. package/dist/types/terminal.d.ts +34 -36
  26. package/dist/types/tui.d.ts +191 -79
  27. package/dist/types/utils.d.ts +5 -2
  28. package/package.json +4 -4
  29. package/src/autocomplete.ts +79 -65
  30. package/src/components/box.ts +43 -63
  31. package/src/components/editor.ts +471 -136
  32. package/src/components/image.ts +85 -9
  33. package/src/components/input.ts +12 -3
  34. package/src/components/loader.ts +35 -21
  35. package/src/components/markdown.ts +174 -53
  36. package/src/components/scroll-view.ts +63 -2
  37. package/src/components/select-list.ts +233 -38
  38. package/src/components/settings-list.ts +626 -64
  39. package/src/components/spacer.ts +9 -5
  40. package/src/components/tab-bar.ts +153 -28
  41. package/src/components/text.ts +6 -2
  42. package/src/components/truncated-text.ts +10 -2
  43. package/src/fuzzy.ts +214 -59
  44. package/src/index.ts +3 -1
  45. package/src/keybindings.ts +72 -14
  46. package/src/keys.ts +1 -1
  47. package/src/kill-ring.ts +5 -0
  48. package/src/kitty-graphics.ts +2 -101
  49. package/src/loop-watchdog.ts +106 -0
  50. package/src/mouse.ts +55 -0
  51. package/src/stdin-buffer.ts +291 -81
  52. package/src/terminal-capabilities.ts +206 -168
  53. package/src/terminal.ts +367 -110
  54. package/src/tui.ts +2102 -1729
  55. package/src/utils.ts +92 -60
@@ -27,6 +27,7 @@ const SLASH_COMMAND_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
27
27
  minPrimaryColumnWidth: 12,
28
28
  maxPrimaryColumnWidth: 32,
29
29
  overflowSearch: false,
30
+ wrapDescription: true,
30
31
  };
31
32
 
32
33
  function sanitizeLoadedText(text: string): string {
@@ -138,8 +139,12 @@ function wordWrapLine(line: string, maxWidth: number): TextChunk[] {
138
139
  for (const token of tokens) {
139
140
  const tokenWidth = visibleWidth(token.text);
140
141
 
141
- // Skip leading whitespace at line start
142
+ // Skip leading whitespace at line start. Keep the skipped run mapped onto the
143
+ // preceding chunk (when one exists) so every cursor position resolves to a
144
+ // layout line instead of falling through to the buffer's last visual line.
142
145
  if (atLineStart && token.isWhitespace) {
146
+ const prev = chunks[chunks.length - 1];
147
+ if (prev) prev.endIndex = token.endIndex;
143
148
  chunkStartIndex = token.endIndex;
144
149
  continue;
145
150
  }
@@ -240,10 +245,19 @@ function wordWrapLine(line: string, maxWidth: number): TextChunk[] {
240
245
  startIndex: chunkStartIndex,
241
246
  endIndex: chunkStartIndex + currentChunk.length,
242
247
  });
248
+ } else {
249
+ // All-whitespace chunk collapsed away: keep its span mapped on the
250
+ // previous chunk so cursor positions inside it stay addressable.
251
+ const prev = chunks[chunks.length - 1];
252
+ if (prev) prev.endIndex = chunkStartIndex + currentChunk.length;
243
253
  }
244
254
  // Start new line - skip leading whitespace
245
255
  atLineStart = true;
246
256
  if (token.isWhitespace) {
257
+ // Extend the preceding chunk over the whitespace run skipped at the wrap
258
+ // point; otherwise cursor positions inside it map to no layout line.
259
+ const prev = chunks[chunks.length - 1];
260
+ if (prev) prev.endIndex = token.endIndex;
247
261
  currentChunk = "";
248
262
  currentWidth = 0;
249
263
  chunkStartIndex = token.endIndex;
@@ -272,8 +286,47 @@ function wordWrapLine(line: string, maxWidth: number): TextChunk[] {
272
286
  return chunks.length > 0 ? chunks : [{ text: "", startIndex: 0, endIndex: 0 }];
273
287
  }
274
288
 
289
+ /** Visual cell column of code-unit `offset` within `text`, counted by grapheme walk. */
290
+ function visualColAtOffset(text: string, offset: number): number {
291
+ if (offset <= 0) return 0;
292
+ let col = 0;
293
+ for (const seg of segmenter.segment(text)) {
294
+ if (seg.index >= offset) break;
295
+ col += visibleWidth(seg.segment);
296
+ }
297
+ return col;
298
+ }
299
+
300
+ /** Code-unit offset of visual cell `col` within `text`, snapped to a grapheme
301
+ * boundary so the result never splits a surrogate pair or cluster. */
302
+ function offsetAtVisualCol(text: string, col: number): number {
303
+ if (col <= 0) return 0;
304
+ let current = 0;
305
+ for (const seg of segmenter.segment(text)) {
306
+ const width = visibleWidth(seg.segment);
307
+ if (current + width > col) return seg.index;
308
+ current += width;
309
+ }
310
+ return text.length;
311
+ }
312
+
313
+ /** Highest visual column the cursor may occupy on a wrap segment: the full width
314
+ * on a logical line's last segment, otherwise just before the final grapheme
315
+ * (the segment end is the next segment's start). */
316
+ function maxSegmentVisualCol(text: string, isLastSegment: boolean): number {
317
+ let total = 0;
318
+ let lastWidth = 0;
319
+ for (const seg of segmenter.segment(text)) {
320
+ lastWidth = visibleWidth(seg.segment);
321
+ total += lastWidth;
322
+ }
323
+ return isLastSegment ? total : Math.max(0, total - lastWidth);
324
+ }
325
+
275
326
  const DEFAULT_PAGE_SCROLL_LINES = 10;
276
327
 
328
+ const MAX_UNDO_STACK = 100;
329
+
277
330
  interface EditorState {
278
331
  lines: string[];
279
332
  cursorLine: number;
@@ -338,13 +391,18 @@ export class Editor implements Component, Focusable {
338
391
 
339
392
  // Store last layout width for cursor navigation
340
393
  #lastLayoutWidth: number = 80;
394
+ // Word-wrap result cache shared by #layoutText, #buildVisualLineMap, and key
395
+ // handlers within a frame. Line text is a sound key (strings are immutable);
396
+ // cleared on width change and size-bounded so stale lines don't accumulate.
397
+ #wrapCache = new Map<string, TextChunk[]>();
398
+ #wrapCacheWidth = -1;
341
399
  #paddingXOverride: number | undefined;
342
400
  #maxHeight?: number;
343
401
  #scrollOffset: number = 0;
344
402
 
345
403
  // Emacs-style kill ring
346
404
  #killRing = new KillRing();
347
- #lastAction: "kill" | "yank" | null = null;
405
+ #lastAction: "kill" | "yank" | "type-word" | null = null;
348
406
 
349
407
  // Character jump mode
350
408
  #jumpMode: "forward" | "backward" | null = null;
@@ -368,6 +426,15 @@ export class Editor implements Component, Focusable {
368
426
  #pastes: Map<number, string> = new Map();
369
427
  #pasteCounter: number = 0;
370
428
 
429
+ /** Optional pattern matching atomic placeholder tokens (e.g. `[Image #1, 800x600]` or
430
+ * `[Paste #2, +30 lines]`) that the editor treats as indivisible: a backspace or forward-delete
431
+ * landing on any character of a token removes the whole token instead of corrupting it into
432
+ * stray text. MUST be a global regex; the editor recompiles a private copy so its `lastIndex`
433
+ * is never shared with the caller. */
434
+ atomicTokenPattern: RegExp | undefined;
435
+ #atomicTokenSource: string | undefined;
436
+ #atomicTokenRe: RegExp | undefined;
437
+
371
438
  // Bracketed paste mode buffering
372
439
  #pasteHandler = new BracketedPasteHandler();
373
440
 
@@ -383,9 +450,16 @@ export class Editor implements Component, Focusable {
383
450
  // Debounce timer for autocomplete updates
384
451
  #autocompleteTimeout?: NodeJS.Timeout;
385
452
 
386
- onSubmit?: (text: string) => void;
453
+ onSubmit?: (text: string) => void | Promise<void>;
387
454
  onAltEnter?: (text: string) => void;
388
455
  onChange?: (text: string) => void;
456
+ /** Called for a "marker-sized" paste — the point where the editor would otherwise collapse it
457
+ * into a `[Paste #N]` token (> 10 lines or > 1000 characters). Return `true` to intercept:
458
+ * the editor inserts nothing and records no undo state, leaving insertion to the host (e.g. a
459
+ * "wrap in a code block / XML / attach as file" menu for very large pastes), which re-inserts
460
+ * via {@link insertPaste} or {@link insertText}. Return `false` (or leave unset) for the
461
+ * default collapse-to-marker behavior. `lineCount` is the sanitized paste's line count. */
462
+ onLargePaste?: (text: string, lineCount: number) => boolean;
389
463
  onAutocompleteCancel?: () => void;
390
464
  disableSubmit: boolean = false;
391
465
 
@@ -593,10 +667,20 @@ export class Editor implements Component, Focusable {
593
667
  }
594
668
 
595
669
  /** Apply the optional input decorator to a plain (ANSI-free) text segment.
596
- * Decoration only adds zero-width SGR codes, so visible width is unchanged. */
670
+ * Decoration only adds zero-width SGR codes, so visible width is unchanged.
671
+ * Splits around CURSOR_MARKER so each user-text segment is decorated in
672
+ * isolation: the marker begins with ESC, and a keyword regex that pins
673
+ * the right boundary with `(?!\S)` would otherwise reject an otherwise-
674
+ * valid match at the cursor seam (e.g. `ultrathink` immediately followed
675
+ * by the marker stops glowing until a trailing character is typed). */
597
676
  #decorate(text: string): string {
598
677
  const decorate = this.decorateText;
599
- return decorate !== undefined && text.length > 0 ? decorate(text) : text;
678
+ if (decorate === undefined || text.length === 0) return text;
679
+ const idx = text.indexOf(CURSOR_MARKER);
680
+ if (idx === -1) return decorate(text);
681
+ const before = text.slice(0, idx);
682
+ const after = text.slice(idx + CURSOR_MARKER.length);
683
+ return (before.length > 0 ? decorate(before) : "") + CURSOR_MARKER + (after.length > 0 ? decorate(after) : "");
600
684
  }
601
685
 
602
686
  #getStyledInputCursor(): { text: string; width: number } {
@@ -691,7 +775,7 @@ export class Editor implements Component, Focusable {
691
775
  this.#scrollOffset = Math.min(this.#scrollOffset, maxOffset);
692
776
  }
693
777
 
694
- render(width: number): string[] {
778
+ render(width: number): readonly string[] {
695
779
  const paddingX = this.#getEditorPaddingX();
696
780
  const borderVisible = this.#borderVisible;
697
781
  const promptGutter = this.#getPromptGutter(width, paddingX);
@@ -808,9 +892,10 @@ export class Editor implements Component, Focusable {
808
892
  const before = displayText.slice(0, layoutLine.cursorPos);
809
893
  const after = displayText.slice(layoutLine.cursorPos);
810
894
  if (after.length === 0 && inlineHint) {
811
- const hintText = hintStyle(truncateToWidth(inlineHint, Math.max(0, lineContentWidth - displayWidth)));
895
+ const availWidth = Math.max(0, lineContentWidth - displayWidth);
896
+ const hintText = hintStyle(truncateToWidth(inlineHint, availWidth));
812
897
  displayText = before + marker + hintText;
813
- displayWidth += visibleWidth(inlineHint);
898
+ displayWidth += Math.min(visibleWidth(inlineHint), availWidth);
814
899
  } else if (after.length === 0 && !borderVisible && displayWidth >= lineContentWidth) {
815
900
  displayText = this.#renderTerminalCursorMarker(before, marker, lineContentWidth);
816
901
  } else {
@@ -879,9 +964,9 @@ export class Editor implements Component, Focusable {
879
964
  }
880
965
  }
881
966
 
882
- // No cursor on this line, or a branch that left the user text intact: decorate the
883
- // whole line. CURSOR_MARKER and cursor glyphs begin with ESC, so word boundaries
884
- // around a decorated keyword stay intact when matched against the assembled line.
967
+ // No cursor on this line, or a branch that left the user text intact: decorate
968
+ // the whole line. `#decorate` splits around CURSOR_MARKER so a keyword glued to
969
+ // the cursor still satisfies its right-boundary lookahead.
885
970
  if (!decorated) {
886
971
  displayText = this.#decorate(displayText);
887
972
  }
@@ -1109,12 +1194,18 @@ export class Editor implements Component, Focusable {
1109
1194
  else if (matchesKey(data, "ctrl+w")) {
1110
1195
  this.#deleteWordBackwards();
1111
1196
  }
1112
- // Option/Alt+Backspace - Delete word backwards
1113
- else if (matchesKey(data, "alt+backspace")) {
1197
+ // Option/Alt+Backspace - Delete word backwards.
1198
+ // Ghostty on macOS reports Option+Backspace as super+alt (kitty mod 11) — see #2064.
1199
+ else if (matchesKey(data, "alt+backspace") || matchesKey(data, "super+alt+backspace")) {
1114
1200
  this.#deleteWordBackwards();
1115
1201
  }
1116
- // Option/Alt+D - Delete word forwards
1117
- else if (matchesKey(data, "alt+d") || matchesKey(data, "alt+delete")) {
1202
+ // Option/Alt+D and Option+Delete - Delete word forwards. Same Ghostty quirk applies.
1203
+ else if (
1204
+ matchesKey(data, "alt+d") ||
1205
+ matchesKey(data, "alt+delete") ||
1206
+ matchesKey(data, "super+alt+d") ||
1207
+ matchesKey(data, "super+alt+delete")
1208
+ ) {
1118
1209
  this.#deleteWordForwards();
1119
1210
  }
1120
1211
  // Ctrl+Y - Yank from kill ring
@@ -1288,6 +1379,22 @@ export class Editor implements Component, Focusable {
1288
1379
  }
1289
1380
  }
1290
1381
 
1382
+ #wrapLine(line: string, width: number): TextChunk[] {
1383
+ if (width !== this.#wrapCacheWidth) {
1384
+ this.#wrapCache.clear();
1385
+ this.#wrapCacheWidth = width;
1386
+ }
1387
+ let chunks = this.#wrapCache.get(line);
1388
+ if (chunks === undefined) {
1389
+ if (this.#wrapCache.size >= 256) {
1390
+ this.#wrapCache.clear();
1391
+ }
1392
+ chunks = wordWrapLine(line, width);
1393
+ this.#wrapCache.set(line, chunks);
1394
+ }
1395
+ return chunks;
1396
+ }
1397
+
1291
1398
  #layoutText(contentWidth: number): LayoutLine[] {
1292
1399
  const layoutLines: LayoutLine[] = [];
1293
1400
 
@@ -1323,7 +1430,7 @@ export class Editor implements Component, Focusable {
1323
1430
  }
1324
1431
  } else {
1325
1432
  // Line needs wrapping - use word-aware wrapping
1326
- const chunks = wordWrapLine(line, contentWidth);
1433
+ const chunks = this.#wrapLine(line, contentWidth);
1327
1434
 
1328
1435
  for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
1329
1436
  const chunk = chunks[chunkIndex];
@@ -1339,21 +1446,19 @@ export class Editor implements Component, Focusable {
1339
1446
  let adjustedCursorPos = 0;
1340
1447
 
1341
1448
  if (isCurrentLine) {
1449
+ // The first chunk owns any leading whitespace the wrapper skipped,
1450
+ // so a cursor inside it still maps to a layout line.
1451
+ const chunkStart = chunkIndex === 0 ? 0 : chunk.startIndex;
1342
1452
  if (isLastChunk) {
1343
1453
  // Last chunk: cursor belongs here if >= startIndex
1344
- hasCursorInChunk = cursorPos >= chunk.startIndex;
1345
- adjustedCursorPos = cursorPos - chunk.startIndex;
1454
+ hasCursorInChunk = cursorPos >= chunkStart;
1346
1455
  } else {
1347
1456
  // Non-last chunk: cursor belongs here if in range [startIndex, endIndex)
1348
- // But we need to handle the visual position in the trimmed text
1349
- hasCursorInChunk = cursorPos >= chunk.startIndex && cursorPos < chunk.endIndex;
1350
- if (hasCursorInChunk) {
1351
- adjustedCursorPos = cursorPos - chunk.startIndex;
1352
- // Clamp to text length (in case cursor was in trimmed whitespace)
1353
- if (adjustedCursorPos > chunk.text.length) {
1354
- adjustedCursorPos = chunk.text.length;
1355
- }
1356
- }
1457
+ hasCursorInChunk = cursorPos >= chunkStart && cursorPos < chunk.endIndex;
1458
+ }
1459
+ if (hasCursorInChunk) {
1460
+ // Clamp into the displayed text (cursor may sit in trimmed/skipped whitespace)
1461
+ adjustedCursorPos = Math.max(0, Math.min(cursorPos - chunk.startIndex, chunk.text.length));
1357
1462
  }
1358
1463
  }
1359
1464
 
@@ -1383,7 +1488,7 @@ export class Editor implements Component, Focusable {
1383
1488
  #expandPasteMarkers(text: string): string {
1384
1489
  let result = text;
1385
1490
  for (const [pasteId, pasteContent] of this.#pastes) {
1386
- const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g");
1491
+ const markerRegex = new RegExp(`\\[Paste #${pasteId}(?:, (?:\\+\\d+ lines|\\d+ chars))?\\]`, "g");
1387
1492
  result = result.replace(markerRegex, () => pasteContent);
1388
1493
  }
1389
1494
  return result;
@@ -1495,11 +1600,112 @@ export class Editor implements Component, Focusable {
1495
1600
  this.#insertTextAtCursor(text);
1496
1601
  }
1497
1602
 
1498
- // All the editor methods from before...
1499
- #insertCharacter(char: string): void {
1603
+ /** Delete up to `count` characters immediately before the cursor on the current line.
1604
+ * Used to "track back" the auto-repeat spaces that the space-hold push-to-talk gesture
1605
+ * optimistically inserts before it recognizes the hold. Capped at the cursor column so it
1606
+ * never crosses a line boundary or under-runs the line. */
1607
+ deleteBeforeCursor(count: number): void {
1608
+ const removable = Math.min(count, this.#state.cursorCol);
1609
+ if (removable <= 0) return;
1500
1610
  this.#exitHistoryForEditing();
1611
+ this.#recordUndoState();
1612
+ const line = this.#state.lines[this.#state.cursorLine] ?? "";
1613
+ this.#state.lines[this.#state.cursorLine] =
1614
+ line.slice(0, this.#state.cursorCol - removable) + line.slice(this.#state.cursorCol);
1615
+ this.#setCursorCol(this.#state.cursorCol - removable);
1616
+ this.#lastAction = null;
1617
+ if (this.onChange) {
1618
+ this.onChange(this.getText());
1619
+ }
1620
+ }
1621
+
1622
+ /** Code units of the current volatile speech-to-text preview (see {@link setVolatileText}). */
1623
+ #volatileTextLen = 0;
1624
+
1625
+ /** Show or replace a volatile speech-to-text preview at the cursor. The text is
1626
+ * inserted with undo suspended so a long live dictation never floods the undo
1627
+ * stack; finalize it with {@link commitVolatileText} or drop it with
1628
+ * {@link clearVolatileText}. Newlines are allowed. */
1629
+ setVolatileText(text: string): void {
1630
+ this.#exitHistoryForEditing();
1631
+ this.#withUndoSuspended(() => {
1632
+ this.#deleteCharsBeforeCursor(this.#volatileTextLen);
1633
+ if (text) this.#insertTextAtCursor(text);
1634
+ });
1635
+ this.#volatileTextLen = text.length;
1636
+ if (!text && this.onChange) this.onChange(this.getText());
1637
+ }
1638
+
1639
+ /** Remove the current volatile preview without committing it. */
1640
+ clearVolatileText(): void {
1641
+ if (this.#volatileTextLen === 0) return;
1642
+ this.#withUndoSuspended(() => this.#deleteCharsBeforeCursor(this.#volatileTextLen));
1643
+ this.#volatileTextLen = 0;
1644
+ if (this.onChange) this.onChange(this.getText());
1645
+ }
1646
+
1647
+ /** Drop any volatile preview, then insert `text` as a single undoable edit. */
1648
+ commitVolatileText(text: string): void {
1649
+ this.#exitHistoryForEditing();
1650
+ this.#withUndoSuspended(() => this.#deleteCharsBeforeCursor(this.#volatileTextLen));
1651
+ this.#volatileTextLen = 0;
1652
+ if (text) this.#insertTextAtCursor(text);
1653
+ else if (this.onChange) this.onChange(this.getText());
1654
+ }
1655
+
1656
+ /** Delete `count` UTF-16 code units immediately before the cursor, crossing line
1657
+ * boundaries (each consumed newline counts as one). Undo is the caller's concern. */
1658
+ #deleteCharsBeforeCursor(count: number): void {
1659
+ let remaining = count;
1660
+ while (remaining > 0) {
1661
+ if (this.#state.cursorCol > 0) {
1662
+ const removable = Math.min(remaining, this.#state.cursorCol);
1663
+ const line = this.#state.lines[this.#state.cursorLine] ?? "";
1664
+ this.#state.lines[this.#state.cursorLine] =
1665
+ line.slice(0, this.#state.cursorCol - removable) + line.slice(this.#state.cursorCol);
1666
+ this.#setCursorCol(this.#state.cursorCol - removable);
1667
+ remaining -= removable;
1668
+ } else if (this.#state.cursorLine > 0) {
1669
+ const prev = this.#state.lines[this.#state.cursorLine - 1] ?? "";
1670
+ const cur = this.#state.lines[this.#state.cursorLine] ?? "";
1671
+ this.#state.lines[this.#state.cursorLine - 1] = prev + cur;
1672
+ this.#state.lines.splice(this.#state.cursorLine, 1);
1673
+ this.#state.cursorLine -= 1;
1674
+ this.#setCursorCol(prev.length);
1675
+ remaining -= 1;
1676
+ } else {
1677
+ break;
1678
+ }
1679
+ }
1680
+ }
1681
+
1682
+ /** Apply terminal paste semantics to text from non-bracketed paste transports. */
1683
+ pasteText(text: string): void {
1684
+ this.#handlePaste(text);
1685
+ }
1686
+
1687
+ /** Insert `content` as a collapsed `[Paste #N]` marker (stored for expansion on submit via
1688
+ * {@link getExpandedText}). Hosts that intercept large pastes through {@link onLargePaste} use
1689
+ * this to re-insert a (possibly transformed) paste without re-triggering the interception hook. */
1690
+ insertPaste(content: string): void {
1691
+ this.#historyIndex = -1;
1501
1692
  this.#resetKillSequence();
1502
1693
  this.#recordUndoState();
1694
+ this.#withUndoSuspended(() => {
1695
+ this.#storePasteMarker(content, content.split("\n").length);
1696
+ });
1697
+ }
1698
+
1699
+ // All the editor methods from before...
1700
+ #insertCharacter(char: string): void {
1701
+ this.#exitHistoryForEditing();
1702
+ // Undo coalescing: consecutive word typing collapses into one undo unit
1703
+ // (mirrors Input); any other action resets the run via #lastAction.
1704
+ const isWordChunk = [...segmenter.segment(char)].every(seg => getWordNavKind(seg.segment) !== "whitespace");
1705
+ if (!isWordChunk || this.#lastAction !== "type-word") {
1706
+ this.#recordUndoState();
1707
+ }
1708
+ this.#lastAction = isWordChunk ? "type-word" : null;
1503
1709
 
1504
1710
  const line = this.#state.lines[this.#state.cursorLine] || "";
1505
1711
 
@@ -1587,76 +1793,47 @@ export class Editor implements Component, Focusable {
1587
1793
  }
1588
1794
 
1589
1795
  #handlePaste(pastedText: string): void {
1590
- this.#historyIndex = -1; // Exit history browsing mode
1591
- this.#resetKillSequence();
1592
- this.#recordUndoState();
1593
-
1594
- this.#withUndoSuspended(() => {
1595
- // Some terminals (e.g. tmux popups with extended-keys-format=csi-u) re-encode
1596
- // control bytes inside bracketed paste as CSI-u Ctrl+<letter> sequences
1597
- // (ESC [ <codepoint> ; 5 u). Decode those back to their literal byte so the
1598
- // per-char filter below preserves newlines instead of stripping ESC and
1599
- // leaking the printable tail (e.g. "[106;5u") into the editor.
1600
- const decodedText = pastedText.replace(/\x1b\[(\d+);5u/g, (match, code) => {
1601
- const cp = Number(code);
1602
- if (cp >= 97 && cp <= 122) return String.fromCharCode(cp - 96);
1603
- if (cp >= 65 && cp <= 90) return String.fromCharCode(cp - 64);
1604
- return match;
1605
- });
1796
+ let filteredText = this.#sanitizePastedText(pastedText);
1606
1797
 
1607
- // Clean the pasted text. NFC-normalize so macOS Finder drag-drops of
1608
- // Korean filenames (which arrive as NFD: e.g. `ᄒ`+`ᅪ` instead of `화`)
1609
- // land in the buffer as the same precomposed syllables a terminal
1610
- // renders without this, cursor column accounting drifts by
1611
- // `(NFD cells NFC cells)` and the visible glyph desyncs from the
1612
- // hardware cursor. Matches the `Input` component's prior fix; this
1613
- // is the same fix on the real Prometheus prompt component (`Editor`).
1614
- const cleanText = decodedText.replace(/\r\n?/g, "\n").normalize("NFC");
1615
-
1616
- // Convert tabs to spaces (4 spaces per tab)
1617
- const tabExpandedText = cleanText.replace(/\t/g, " ");
1618
-
1619
- // Filter out non-printable characters except newlines
1620
- let filteredText = tabExpandedText
1621
- .split("")
1622
- .filter(char => char === "\n" || char.charCodeAt(0) >= 32)
1623
- .join("");
1624
-
1625
- // If pasting a file path (starts with /, ~, or .) and the character before
1626
- // the cursor is a word character, prepend a space for better readability
1627
- if (/^[/~.]/.test(filteredText)) {
1628
- const currentLine = this.#state.lines[this.#state.cursorLine] || "";
1629
- const charBeforeCursor = this.#state.cursorCol > 0 ? currentLine[this.#state.cursorCol - 1] : "";
1630
- if (charBeforeCursor && /\w/.test(charBeforeCursor)) {
1631
- filteredText = ` ${filteredText}`;
1632
- }
1798
+ // If pasting a file path (starts with /, ~, or .) and the character before
1799
+ // the cursor is a word character, prepend a space for better readability.
1800
+ if (/^[/~.]/.test(filteredText)) {
1801
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
1802
+ const charBeforeCursor = this.#state.cursorCol > 0 ? currentLine[this.#state.cursorCol - 1] : "";
1803
+ if (charBeforeCursor && /\w/.test(charBeforeCursor)) {
1804
+ filteredText = ` ${filteredText}`;
1633
1805
  }
1806
+ }
1634
1807
 
1635
- // Split into lines
1636
- const pastedLines = filteredText.split("\n");
1808
+ const pastedLines = filteredText.split("\n");
1809
+ const totalChars = filteredText.length;
1810
+ // "Marker-sized": large enough to collapse into a `[Paste #N]` token (> 10 lines or
1811
+ // > 1000 characters) instead of flooding the buffer.
1812
+ const isMarkerSized = pastedLines.length > 10 || totalChars > 1000;
1637
1813
 
1638
- // Check if this is a large paste (> 10 lines or > 1000 characters)
1639
- const totalChars = filteredText.length;
1640
- if (pastedLines.length > 10 || totalChars > 1000) {
1641
- // Store the paste and insert a marker
1642
- this.#pasteCounter++;
1643
- const pasteId = this.#pasteCounter;
1644
- this.#pastes.set(pasteId, filteredText);
1814
+ // Let the host intercept marker-sized pastes (e.g. the large-paste menu). When it takes
1815
+ // over, the editor inserts nothing and records no undo state — the host re-inserts via
1816
+ // `insertPaste`/`insertText` once the user chooses.
1817
+ if (isMarkerSized && this.onLargePaste?.(filteredText, pastedLines.length)) {
1818
+ return;
1819
+ }
1645
1820
 
1646
- // Insert marker like "[paste #1 +123 lines]" or "[paste #1 1234 chars]"
1647
- const marker =
1648
- pastedLines.length > 10
1649
- ? `[paste #${pasteId} +${pastedLines.length} lines]`
1650
- : `[paste #${pasteId} ${totalChars} chars]`;
1651
- this.#insertTextAtCursor(marker);
1821
+ this.#historyIndex = -1; // Exit history browsing mode
1822
+ this.#resetKillSequence();
1823
+ this.#recordUndoState();
1652
1824
 
1825
+ this.#withUndoSuspended(() => {
1826
+ if (isMarkerSized) {
1827
+ this.#storePasteMarker(filteredText, pastedLines.length);
1653
1828
  return;
1654
1829
  }
1655
1830
 
1656
1831
  if (pastedLines.length === 1) {
1657
- // Single line - insert character by character to trigger autocomplete
1658
- for (const char of filteredText) {
1659
- this.#insertCharacter(char);
1832
+ // Single line - insert in one operation (per-char replay is O(paste × buffer)),
1833
+ // then evaluate autocomplete triggers once at the final cursor position.
1834
+ if (filteredText) {
1835
+ this.#insertTextAtCursor(filteredText);
1836
+ this.#retriggerAutocompleteAtCursor();
1660
1837
  }
1661
1838
  return;
1662
1839
  }
@@ -1666,6 +1843,70 @@ export class Editor implements Component, Focusable {
1666
1843
  });
1667
1844
  }
1668
1845
 
1846
+ /** Normalize raw pasted text: decode tmux CSI-u re-encoded control bytes, normalize CRLF and
1847
+ * NFC (macOS NFD filename drag-drops), expand tabs, and strip control characters except newline. */
1848
+ #sanitizePastedText(pastedText: string): string {
1849
+ // Some terminals (e.g. tmux popups with extended-keys-format=csi-u) re-encode
1850
+ // control bytes inside bracketed paste as CSI-u Ctrl+<letter> sequences
1851
+ // (ESC [ <codepoint> ; 5 u). Decode those back to their literal byte so the
1852
+ // per-char filter below preserves newlines instead of stripping ESC and
1853
+ // leaking the printable tail (e.g. "[106;5u") into the editor.
1854
+ const decodedText = pastedText.replace(/\x1b\[(\d+);5u/g, (match, code) => {
1855
+ const cp = Number(code);
1856
+ if (cp >= 97 && cp <= 122) return String.fromCharCode(cp - 96);
1857
+ if (cp >= 65 && cp <= 90) return String.fromCharCode(cp - 64);
1858
+ return match;
1859
+ });
1860
+
1861
+ // Clean the pasted text. NFC-normalize so macOS Finder drag-drops of
1862
+ // Korean filenames (which arrive as NFD: e.g. `ᄒ`+`ᅪ` instead of `화`)
1863
+ // land in the buffer as the same precomposed syllables a terminal
1864
+ // renders — without this, cursor column accounting drifts by
1865
+ // `(NFD cells − NFC cells)` and the visible glyph desyncs from the
1866
+ // hardware cursor.
1867
+ const cleanText = decodedText.replace(/\r\n?/g, "\n").normalize("NFC");
1868
+
1869
+ // Convert tabs to spaces (4 spaces per tab).
1870
+ const tabExpandedText = cleanText.replace(/\t/g, " ");
1871
+
1872
+ // Strip control characters except newline (tabs already expanded above, CRs already
1873
+ // normalized). Single regex pass instead of split/filter/join to avoid allocating a
1874
+ // per-code-unit array for large pastes.
1875
+ return tabExpandedText.replace(/[\x00-\x09\x0B-\x1F]/g, "");
1876
+ }
1877
+
1878
+ /** Store `content` in the paste buffer and insert a collapsed `[Paste #N]` marker that expands
1879
+ * back to `content` on submit. `lineCount` is the content's line count. */
1880
+ #storePasteMarker(content: string, lineCount: number): void {
1881
+ this.#pasteCounter++;
1882
+ const pasteId = this.#pasteCounter;
1883
+ this.#pastes.set(pasteId, content);
1884
+
1885
+ // Insert marker like "[Paste #1, +123 lines]" or "[Paste #1, 1234 chars]".
1886
+ const marker =
1887
+ lineCount > 10 ? `[Paste #${pasteId}, +${lineCount} lines]` : `[Paste #${pasteId}, ${content.length} chars]`;
1888
+ this.#insertTextAtCursor(marker);
1889
+ }
1890
+
1891
+ /** Re-evaluate autocomplete triggers for the text ending at the cursor (used after bulk edits). */
1892
+ #retriggerAutocompleteAtCursor(): void {
1893
+ if (this.#autocompleteState) {
1894
+ this.#debouncedUpdateAutocomplete();
1895
+ return;
1896
+ }
1897
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
1898
+ const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1899
+ if (this.#isInSubmittedSlashCommandContext()) {
1900
+ this.#tryTriggerAutocomplete();
1901
+ } else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
1902
+ this.#tryTriggerAutocomplete();
1903
+ } else if (textBeforeCursor.match(/#[^\s#]*$/)) {
1904
+ this.#tryTriggerAutocomplete();
1905
+ } else if (this.#textTriggersUrlAutocomplete(textBeforeCursor)) {
1906
+ this.#tryTriggerAutocomplete();
1907
+ }
1908
+ }
1909
+
1669
1910
  #addNewLine(): void {
1670
1911
  this.#historyIndex = -1; // Exit history browsing mode
1671
1912
  this.#resetKillSequence();
@@ -1716,26 +1957,88 @@ export class Editor implements Component, Focusable {
1716
1957
  if (this.onSubmit) this.onSubmit(result);
1717
1958
  }
1718
1959
 
1960
+ /** Resolve the compiled, global copy of `atomicTokenPattern`, rebuilt only when the source changes. */
1961
+ #getAtomicTokenRe(): RegExp | undefined {
1962
+ const pattern = this.atomicTokenPattern;
1963
+ if (pattern === undefined) {
1964
+ this.#atomicTokenSource = undefined;
1965
+ this.#atomicTokenRe = undefined;
1966
+ return undefined;
1967
+ }
1968
+ if (pattern.source !== this.#atomicTokenSource) {
1969
+ this.#atomicTokenSource = pattern.source;
1970
+ this.#atomicTokenRe = new RegExp(
1971
+ pattern.source,
1972
+ pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`,
1973
+ );
1974
+ }
1975
+ return this.#atomicTokenRe;
1976
+ }
1977
+
1978
+ /** Find an atomic token on `line` whose span contains column `col` (`start <= col < end`). */
1979
+ #atomicTokenAt(line: string, col: number): { start: number; end: number } | undefined {
1980
+ const re = this.#getAtomicTokenRe();
1981
+ if (re === undefined) return undefined;
1982
+ re.lastIndex = 0;
1983
+ for (;;) {
1984
+ const match = re.exec(line);
1985
+ if (match === null) break;
1986
+ if (match[0].length === 0) {
1987
+ re.lastIndex = match.index + 1;
1988
+ continue;
1989
+ }
1990
+ const start = match.index;
1991
+ const end = start + match[0].length;
1992
+ if (col < start) break;
1993
+ if (col < end) return { start, end };
1994
+ }
1995
+ return undefined;
1996
+ }
1997
+
1998
+ /** Expand the half-open range [start, end) so it never cuts through an atomic
1999
+ * placeholder token: a boundary landing inside a token pulls the whole token in. */
2000
+ #expandRangeOverAtomicTokens(line: string, start: number, end: number): { start: number; end: number } {
2001
+ const startToken = this.#atomicTokenAt(line, start);
2002
+ if (startToken !== undefined && startToken.start < start) {
2003
+ start = startToken.start;
2004
+ }
2005
+ if (end > start) {
2006
+ const endToken = this.#atomicTokenAt(line, end - 1);
2007
+ if (endToken !== undefined && endToken.end > end) {
2008
+ end = endToken.end;
2009
+ }
2010
+ }
2011
+ return { start, end };
2012
+ }
2013
+
1719
2014
  #handleBackspace(): void {
1720
2015
  this.#historyIndex = -1; // Exit history browsing mode
1721
2016
  this.#resetKillSequence();
1722
2017
  this.#recordUndoState();
1723
2018
 
1724
2019
  if (this.#state.cursorCol > 0) {
1725
- // Delete grapheme before cursor (handles emojis, combining characters, etc.)
1726
2020
  const line = this.#state.lines[this.#state.cursorLine] || "";
1727
- const beforeCursor = line.slice(0, this.#state.cursorCol);
2021
+ // An atomic placeholder token (image/paste marker) deletes as a unit, so a single
2022
+ // backspace never leaves a half-eaten `[Paste #1, +30 lines` behind as stray text.
2023
+ const token = this.#atomicTokenAt(line, this.#state.cursorCol - 1);
2024
+ if (token !== undefined) {
2025
+ this.#state.lines[this.#state.cursorLine] = line.slice(0, token.start) + line.slice(token.end);
2026
+ this.#setCursorCol(token.start);
2027
+ } else {
2028
+ // Delete grapheme before cursor (handles emojis, combining characters, etc.)
2029
+ const beforeCursor = line.slice(0, this.#state.cursorCol);
1728
2030
 
1729
- // Find the last grapheme in the text before cursor
1730
- const graphemes = [...segmenter.segment(beforeCursor)];
1731
- const lastGrapheme = graphemes[graphemes.length - 1];
1732
- const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;
2031
+ // Find the last grapheme in the text before cursor
2032
+ const graphemes = [...segmenter.segment(beforeCursor)];
2033
+ const lastGrapheme = graphemes[graphemes.length - 1];
2034
+ const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;
1733
2035
 
1734
- const before = line.slice(0, this.#state.cursorCol - graphemeLength);
1735
- const after = line.slice(this.#state.cursorCol);
2036
+ const before = line.slice(0, this.#state.cursorCol - graphemeLength);
2037
+ const after = line.slice(this.#state.cursorCol);
1736
2038
 
1737
- this.#state.lines[this.#state.cursorLine] = before + after;
1738
- this.#setCursorCol(this.#state.cursorCol - graphemeLength);
2039
+ this.#state.lines[this.#state.cursorLine] = before + after;
2040
+ this.#setCursorCol(this.#state.cursorCol - graphemeLength);
2041
+ }
1739
2042
  } else if (this.#state.cursorLine > 0) {
1740
2043
  // Merge with previous line
1741
2044
  const currentLine = this.#state.lines[this.#state.cursorLine] || "";
@@ -1800,18 +2103,24 @@ export class Editor implements Component, Focusable {
1800
2103
  const targetVL = visualLines[targetVisualLine];
1801
2104
 
1802
2105
  if (currentVL && targetVL) {
1803
- const currentVisualCol = this.#state.cursorCol - currentVL.startCol;
2106
+ // Work in visual cells (grapheme-walked), not UTF-16 code units: code-unit
2107
+ // columns land mid-surrogate on emoji and drift on wide CJK glyphs.
2108
+ const sourceLine = this.#state.lines[currentVL.logicalLine] || "";
2109
+ const sourceText = sourceLine.slice(currentVL.startCol, currentVL.startCol + currentVL.length);
2110
+ const currentVisualCol = visualColAtOffset(sourceText, this.#state.cursorCol - currentVL.startCol);
1804
2111
 
1805
- // For non-last segments, clamp to length-1 to stay within the segment
2112
+ // For non-last segments, clamp before the segment end to stay within the segment
1806
2113
  const isLastSourceSegment =
1807
2114
  currentVisualLine === visualLines.length - 1 ||
1808
2115
  visualLines[currentVisualLine + 1]?.logicalLine !== currentVL.logicalLine;
1809
- const sourceMaxVisualCol = isLastSourceSegment ? currentVL.length : Math.max(0, currentVL.length - 1);
2116
+ const sourceMaxVisualCol = maxSegmentVisualCol(sourceText, isLastSourceSegment);
1810
2117
 
1811
2118
  const isLastTargetSegment =
1812
2119
  targetVisualLine === visualLines.length - 1 ||
1813
2120
  visualLines[targetVisualLine + 1]?.logicalLine !== targetVL.logicalLine;
1814
- const targetMaxVisualCol = isLastTargetSegment ? targetVL.length : Math.max(0, targetVL.length - 1);
2121
+ const targetLine = this.#state.lines[targetVL.logicalLine] || "";
2122
+ const targetText = targetLine.slice(targetVL.startCol, targetVL.startCol + targetVL.length);
2123
+ const targetMaxVisualCol = maxSegmentVisualCol(targetText, isLastTargetSegment);
1815
2124
 
1816
2125
  const moveToVisualCol = this.#computeVerticalMoveColumn(
1817
2126
  currentVisualCol,
@@ -1819,11 +2128,10 @@ export class Editor implements Component, Focusable {
1819
2128
  targetMaxVisualCol,
1820
2129
  );
1821
2130
 
1822
- // Set cursor position
2131
+ // Set cursor position, snapping to a grapheme boundary in the target text
1823
2132
  this.#state.cursorLine = targetVL.logicalLine;
1824
- const targetCol = targetVL.startCol + moveToVisualCol;
1825
- const logicalLine = this.#state.lines[targetVL.logicalLine] || "";
1826
- this.#state.cursorCol = Math.min(targetCol, logicalLine.length);
2133
+ const targetCol = targetVL.startCol + offsetAtVisualCol(targetText, moveToVisualCol);
2134
+ this.#state.cursorCol = Math.min(targetCol, targetLine.length);
1827
2135
  }
1828
2136
  }
1829
2137
 
@@ -1900,6 +2208,9 @@ export class Editor implements Component, Focusable {
1900
2208
  #recordUndoState(): void {
1901
2209
  if (this.#suspendUndo) return;
1902
2210
  this.#undoStack.push(structuredClone(this.#state));
2211
+ if (this.#undoStack.length > MAX_UNDO_STACK) {
2212
+ this.#undoStack.shift();
2213
+ }
1903
2214
  }
1904
2215
 
1905
2216
  #applyUndo(): void {
@@ -2089,9 +2400,11 @@ export class Editor implements Component, Focusable {
2089
2400
  let deletedText = "";
2090
2401
 
2091
2402
  if (this.#state.cursorCol > 0) {
2092
- // Delete from start of line up to cursor
2093
- deletedText = currentLine.slice(0, this.#state.cursorCol);
2094
- this.#state.lines[this.#state.cursorLine] = currentLine.slice(this.#state.cursorCol);
2403
+ // Delete from start of line up to cursor, extending over any atomic token
2404
+ // the boundary would otherwise cut in half.
2405
+ const { end } = this.#expandRangeOverAtomicTokens(currentLine, 0, this.#state.cursorCol);
2406
+ deletedText = currentLine.slice(0, end);
2407
+ this.#state.lines[this.#state.cursorLine] = currentLine.slice(end);
2095
2408
  this.#setCursorCol(0);
2096
2409
  } else if (this.#state.cursorLine > 0) {
2097
2410
  // At start of line - merge with previous line
@@ -2118,9 +2431,14 @@ export class Editor implements Component, Focusable {
2118
2431
  let deletedText = "";
2119
2432
 
2120
2433
  if (this.#state.cursorCol < currentLine.length) {
2121
- // Delete from cursor to end of line
2122
- deletedText = currentLine.slice(this.#state.cursorCol);
2123
- this.#state.lines[this.#state.cursorLine] = currentLine.slice(0, this.#state.cursorCol);
2434
+ // Delete from cursor to end of line, extending backwards over an atomic
2435
+ // token the cursor sits inside so no half-eaten marker text remains.
2436
+ const { start } = this.#expandRangeOverAtomicTokens(currentLine, this.#state.cursorCol, currentLine.length);
2437
+ deletedText = currentLine.slice(start);
2438
+ this.#state.lines[this.#state.cursorLine] = currentLine.slice(0, start);
2439
+ if (start < this.#state.cursorCol) {
2440
+ this.#setCursorCol(start);
2441
+ }
2124
2442
  } else if (this.#state.cursorLine < this.#state.lines.length - 1) {
2125
2443
  // At end of line - merge with next line
2126
2444
  const nextLine = this.#state.lines[this.#state.cursorLine + 1] || "";
@@ -2155,13 +2473,13 @@ export class Editor implements Component, Focusable {
2155
2473
  } else {
2156
2474
  const oldCursorCol = this.#state.cursorCol;
2157
2475
  this.#moveWordBackwards();
2158
- const deleteFrom = this.#state.cursorCol;
2159
- this.#setCursorCol(oldCursorCol);
2476
+ // Extend the range over any atomic token it intersects so a word delete
2477
+ // never leaves half-eaten marker text behind.
2478
+ const range = this.#expandRangeOverAtomicTokens(currentLine, this.#state.cursorCol, oldCursorCol);
2160
2479
 
2161
- const deletedText = currentLine.slice(deleteFrom, oldCursorCol);
2162
- this.#state.lines[this.#state.cursorLine] =
2163
- currentLine.slice(0, deleteFrom) + currentLine.slice(this.#state.cursorCol);
2164
- this.#setCursorCol(deleteFrom);
2480
+ const deletedText = currentLine.slice(range.start, range.end);
2481
+ this.#state.lines[this.#state.cursorLine] = currentLine.slice(0, range.start) + currentLine.slice(range.end);
2482
+ this.#setCursorCol(range.start);
2165
2483
  this.#recordKill(deletedText, "backward");
2166
2484
  }
2167
2485
 
@@ -2186,11 +2504,13 @@ export class Editor implements Component, Focusable {
2186
2504
  } else {
2187
2505
  const oldCursorCol = this.#state.cursorCol;
2188
2506
  this.#moveWordForwards();
2189
- const deleteTo = this.#state.cursorCol;
2190
- this.#setCursorCol(oldCursorCol);
2507
+ // Extend the range over any atomic token it intersects so a word delete
2508
+ // never leaves half-eaten marker text behind.
2509
+ const range = this.#expandRangeOverAtomicTokens(currentLine, oldCursorCol, this.#state.cursorCol);
2191
2510
 
2192
- const deletedText = currentLine.slice(oldCursorCol, deleteTo);
2193
- this.#state.lines[this.#state.cursorLine] = currentLine.slice(0, oldCursorCol) + currentLine.slice(deleteTo);
2511
+ const deletedText = currentLine.slice(range.start, range.end);
2512
+ this.#state.lines[this.#state.cursorLine] = currentLine.slice(0, range.start) + currentLine.slice(range.end);
2513
+ this.#setCursorCol(range.start);
2194
2514
  this.#recordKill(deletedText, "forward");
2195
2515
  }
2196
2516
 
@@ -2207,17 +2527,25 @@ export class Editor implements Component, Focusable {
2207
2527
  const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2208
2528
 
2209
2529
  if (this.#state.cursorCol < currentLine.length) {
2210
- // Delete grapheme at cursor position (handles emojis, combining characters, etc.)
2211
- const afterCursor = currentLine.slice(this.#state.cursorCol);
2530
+ // An atomic placeholder token (image/paste marker) deletes as a unit.
2531
+ const token = this.#atomicTokenAt(currentLine, this.#state.cursorCol);
2532
+ if (token !== undefined) {
2533
+ this.#state.lines[this.#state.cursorLine] =
2534
+ currentLine.slice(0, token.start) + currentLine.slice(token.end);
2535
+ this.#setCursorCol(token.start);
2536
+ } else {
2537
+ // Delete grapheme at cursor position (handles emojis, combining characters, etc.)
2538
+ const afterCursor = currentLine.slice(this.#state.cursorCol);
2212
2539
 
2213
- // Find the first grapheme at cursor
2214
- const graphemes = [...segmenter.segment(afterCursor)];
2215
- const firstGrapheme = graphemes[0];
2216
- const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
2540
+ // Find the first grapheme at cursor
2541
+ const graphemes = [...segmenter.segment(afterCursor)];
2542
+ const firstGrapheme = graphemes[0];
2543
+ const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
2217
2544
 
2218
- const before = currentLine.slice(0, this.#state.cursorCol);
2219
- const after = currentLine.slice(this.#state.cursorCol + graphemeLength);
2220
- this.#state.lines[this.#state.cursorLine] = before + after;
2545
+ const before = currentLine.slice(0, this.#state.cursorCol);
2546
+ const after = currentLine.slice(this.#state.cursorCol + graphemeLength);
2547
+ this.#state.lines[this.#state.cursorLine] = before + after;
2548
+ }
2221
2549
  } else if (this.#state.cursorLine < this.#state.lines.length - 1) {
2222
2550
  // At end of line - merge with next line
2223
2551
  const nextLine = this.#state.lines[this.#state.cursorLine + 1] || "";
@@ -2274,7 +2602,7 @@ export class Editor implements Component, Focusable {
2274
2602
  visualLines.push({ logicalLine: i, startCol: 0, length: line.length });
2275
2603
  } else {
2276
2604
  // Line needs wrapping - use word-aware wrapping
2277
- const chunks = wordWrapLine(line, width);
2605
+ const chunks = this.#wrapLine(line, width);
2278
2606
  for (const chunk of chunks) {
2279
2607
  visualLines.push({
2280
2608
  logicalLine: i,
@@ -2299,9 +2627,15 @@ export class Editor implements Component, Focusable {
2299
2627
  const colInSegment = this.#state.cursorCol - vl.startCol;
2300
2628
  // Cursor is in this segment if it's within range
2301
2629
  // For the last segment of a logical line, cursor can be at length (end position)
2630
+ // The first segment also owns any leading whitespace the wrapper skipped
2631
+ // (its startCol can be > 0), so a negative colInSegment maps there.
2302
2632
  const isLastSegmentOfLine =
2303
2633
  i === visualLines.length - 1 || visualLines[i + 1]?.logicalLine !== vl.logicalLine;
2304
- if (colInSegment >= 0 && (colInSegment < vl.length || (isLastSegmentOfLine && colInSegment <= vl.length))) {
2634
+ const isFirstSegmentOfLine = i === 0 || visualLines[i - 1]?.logicalLine !== vl.logicalLine;
2635
+ if (
2636
+ (colInSegment >= 0 || isFirstSegmentOfLine) &&
2637
+ (colInSegment < vl.length || (isLastSegmentOfLine && colInSegment <= vl.length))
2638
+ ) {
2305
2639
  return i;
2306
2640
  }
2307
2641
  }
@@ -2341,7 +2675,8 @@ export class Editor implements Component, Focusable {
2341
2675
  // At end of last line - can't move, but set preferredVisualCol for up/down navigation
2342
2676
  const currentVL = visualLines[currentVisualLine];
2343
2677
  if (currentVL) {
2344
- this.#preferredVisualCol = this.#state.cursorCol - currentVL.startCol;
2678
+ const segmentText = currentLine.slice(currentVL.startCol, currentVL.startCol + currentVL.length);
2679
+ this.#preferredVisualCol = visualColAtOffset(segmentText, this.#state.cursorCol - currentVL.startCol);
2345
2680
  }
2346
2681
  }
2347
2682
  } else {