@fleetagent/pi-tui 0.1.8 → 0.2.0

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 (109) hide show
  1. package/README.md +47 -7
  2. package/dist/autocomplete.d.ts +17 -23
  3. package/dist/autocomplete.d.ts.map +1 -1
  4. package/dist/autocomplete.js +129 -195
  5. package/dist/autocomplete.js.map +1 -1
  6. package/dist/components/alt-screen-flash.d.ts +13 -0
  7. package/dist/components/alt-screen-flash.d.ts.map +1 -0
  8. package/dist/components/alt-screen-flash.js +37 -0
  9. package/dist/components/alt-screen-flash.js.map +1 -0
  10. package/dist/components/editor.d.ts +47 -4
  11. package/dist/components/editor.d.ts.map +1 -1
  12. package/dist/components/editor.js +615 -585
  13. package/dist/components/editor.js.map +1 -1
  14. package/dist/components/h-stack.d.ts +7 -0
  15. package/dist/components/h-stack.d.ts.map +1 -0
  16. package/dist/components/h-stack.js +43 -0
  17. package/dist/components/h-stack.js.map +1 -0
  18. package/dist/components/image.d.ts +4 -0
  19. package/dist/components/image.d.ts.map +1 -1
  20. package/dist/components/image.js +38 -51
  21. package/dist/components/image.js.map +1 -1
  22. package/dist/components/input.d.ts +9 -0
  23. package/dist/components/input.d.ts.map +1 -1
  24. package/dist/components/input.js +121 -147
  25. package/dist/components/input.js.map +1 -1
  26. package/dist/components/markdown.d.ts +34 -0
  27. package/dist/components/markdown.d.ts.map +1 -1
  28. package/dist/components/markdown.js +491 -429
  29. package/dist/components/markdown.js.map +1 -1
  30. package/dist/components/scroll-view.d.ts +53 -0
  31. package/dist/components/scroll-view.d.ts.map +1 -0
  32. package/dist/components/scroll-view.js +173 -0
  33. package/dist/components/scroll-view.js.map +1 -0
  34. package/dist/components/select-list.d.ts.map +1 -1
  35. package/dist/components/select-list.js.map +1 -1
  36. package/dist/components/settings-list.d.ts +2 -0
  37. package/dist/components/settings-list.d.ts.map +1 -1
  38. package/dist/components/settings-list.js +31 -29
  39. package/dist/components/settings-list.js.map +1 -1
  40. package/dist/components/stack.d.ts +32 -0
  41. package/dist/components/stack.d.ts.map +1 -0
  42. package/dist/components/stack.js +122 -0
  43. package/dist/components/stack.js.map +1 -0
  44. package/dist/components/v-stack.d.ts +8 -0
  45. package/dist/components/v-stack.d.ts.map +1 -0
  46. package/dist/components/v-stack.js +27 -0
  47. package/dist/components/v-stack.js.map +1 -0
  48. package/dist/fuzzy.d.ts.map +1 -1
  49. package/dist/fuzzy.js +37 -46
  50. package/dist/fuzzy.js.map +1 -1
  51. package/dist/index.d.ts +10 -3
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +11 -2
  54. package/dist/index.js.map +1 -1
  55. package/dist/keybindings.d.ts +54 -4
  56. package/dist/keybindings.d.ts.map +1 -1
  57. package/dist/keybindings.js +39 -4
  58. package/dist/keybindings.js.map +1 -1
  59. package/dist/keys.d.ts.map +1 -1
  60. package/dist/keys.js +359 -479
  61. package/dist/keys.js.map +1 -1
  62. package/dist/kill-ring.d.ts +6 -4
  63. package/dist/kill-ring.d.ts.map +1 -1
  64. package/dist/kill-ring.js +0 -7
  65. package/dist/kill-ring.js.map +1 -1
  66. package/dist/latex.d.ts +10 -0
  67. package/dist/latex.d.ts.map +1 -0
  68. package/dist/latex.js +1406 -0
  69. package/dist/latex.js.map +1 -0
  70. package/dist/layout-node.d.ts +43 -0
  71. package/dist/layout-node.d.ts.map +1 -0
  72. package/dist/layout-node.js +6 -0
  73. package/dist/layout-node.js.map +1 -0
  74. package/dist/layout.d.ts +40 -0
  75. package/dist/layout.d.ts.map +1 -0
  76. package/dist/layout.js +386 -0
  77. package/dist/layout.js.map +1 -0
  78. package/dist/stdin-buffer.d.ts +8 -0
  79. package/dist/stdin-buffer.d.ts.map +1 -1
  80. package/dist/stdin-buffer.js +116 -159
  81. package/dist/stdin-buffer.js.map +1 -1
  82. package/dist/terminal-image.d.ts +41 -19
  83. package/dist/terminal-image.d.ts.map +1 -1
  84. package/dist/terminal-image.js +171 -32
  85. package/dist/terminal-image.js.map +1 -1
  86. package/dist/terminal.d.ts +3 -0
  87. package/dist/terminal.d.ts.map +1 -1
  88. package/dist/terminal.js +27 -23
  89. package/dist/terminal.js.map +1 -1
  90. package/dist/tui-alt-screen.d.ts +114 -0
  91. package/dist/tui-alt-screen.d.ts.map +1 -0
  92. package/dist/tui-alt-screen.js +950 -0
  93. package/dist/tui-alt-screen.js.map +1 -0
  94. package/dist/tui-main-screen.d.ts +52 -0
  95. package/dist/tui-main-screen.d.ts.map +1 -0
  96. package/dist/tui-main-screen.js +511 -0
  97. package/dist/tui-main-screen.js.map +1 -0
  98. package/dist/tui.d.ts +112 -44
  99. package/dist/tui.d.ts.map +1 -1
  100. package/dist/tui.js +489 -699
  101. package/dist/tui.js.map +1 -1
  102. package/dist/utils.d.ts +29 -17
  103. package/dist/utils.d.ts.map +1 -1
  104. package/dist/utils.js +605 -528
  105. package/dist/utils.js.map +1 -1
  106. package/dist/word-navigation.d.ts.map +1 -1
  107. package/dist/word-navigation.js +37 -39
  108. package/dist/word-navigation.js.map +1 -1
  109. package/package.json +1 -1
@@ -3,7 +3,7 @@ import { decodePrintableKey, matchesKey } from "../keys.js";
3
3
  import { KillRing } from "../kill-ring.js";
4
4
  import { CURSOR_MARKER } from "../tui.js";
5
5
  import { UndoStack } from "../undo-stack.js";
6
- import { getGraphemeSegmenter, getWordSegmenter, isWhitespaceChar, truncateToWidth, visibleWidth } from "../utils.js";
6
+ import { getGraphemeSegmenter, getWordSegmenter, isWhitespaceChar, sliceByColumn, visibleWidth } from "../utils.js";
7
7
  import { findWordBackward, findWordForward } from "../word-navigation.js";
8
8
  import { SelectList } from "./select-list.js";
9
9
  const graphemeSegmenter = getGraphemeSegmenter();
@@ -23,51 +23,93 @@ function isPasteMarker(segment) {
23
23
  *
24
24
  * Only markers whose numeric ID exists in `validIds` are merged.
25
25
  */
26
- function segmentWithMarkers(text, baseSegmenter, validIds) {
27
- // Fast path: no paste markers in the text or no valid IDs.
28
- if (validIds.size === 0 || !text.includes("[paste #")) {
29
- return baseSegmenter.segment(text);
30
- }
31
- // Find all marker spans with valid IDs.
26
+ function findPasteMarkerSpans(text, validIds) {
32
27
  const markers = [];
33
- for (const m of text.matchAll(PASTE_MARKER_REGEX)) {
34
- const id = Number.parseInt(m[1], 10);
28
+ for (const match of text.matchAll(PASTE_MARKER_REGEX)) {
29
+ const id = Number.parseInt(match[1], 10);
35
30
  if (!validIds.has(id))
36
31
  continue;
37
- markers.push({ start: m.index, end: m.index + m[0].length });
38
- }
39
- if (markers.length === 0) {
40
- return baseSegmenter.segment(text);
32
+ markers.push({ start: match.index, end: match.index + match[0].length });
41
33
  }
42
- // Build merged segment list.
43
- const baseSegments = baseSegmenter.segment(text);
34
+ return markers;
35
+ }
36
+ function mergePasteMarkerSegments(text, baseSegments, markers) {
44
37
  const result = [];
45
- let markerIdx = 0;
46
- for (const seg of baseSegments) {
47
- // Skip past markers that are entirely before this segment.
48
- while (markerIdx < markers.length && markers[markerIdx].end <= seg.index) {
49
- markerIdx++;
50
- }
51
- const marker = markerIdx < markers.length ? markers[markerIdx] : null;
52
- if (marker && seg.index >= marker.start && seg.index < marker.end) {
53
- // This segment falls inside a marker.
54
- // If this is the first segment of the marker, emit a merged segment.
55
- if (seg.index === marker.start) {
56
- const markerText = text.slice(marker.start, marker.end);
38
+ let markerIndex = 0;
39
+ for (const segment of baseSegments) {
40
+ while (markerIndex < markers.length && markers[markerIndex].end <= segment.index)
41
+ markerIndex++;
42
+ const marker = markerIndex < markers.length ? markers[markerIndex] : undefined;
43
+ if (marker && segment.index >= marker.start && segment.index < marker.end) {
44
+ if (segment.index === marker.start) {
57
45
  result.push({
58
- segment: markerText,
46
+ segment: text.slice(marker.start, marker.end),
59
47
  index: marker.start,
60
48
  input: text,
61
49
  });
62
50
  }
63
- // Otherwise skip (already merged into the first segment).
64
51
  }
65
52
  else {
66
- result.push(seg);
53
+ result.push(segment);
67
54
  }
68
55
  }
69
56
  return result;
70
57
  }
58
+ function segmentWithMarkers(text, baseSegmenter, validIds) {
59
+ if (validIds.size === 0 || !text.includes("[paste #"))
60
+ return baseSegmenter.segment(text);
61
+ const markers = findPasteMarkerSpans(text, validIds);
62
+ if (markers.length === 0)
63
+ return baseSegmenter.segment(text);
64
+ return mergePasteMarkerSegments(text, baseSegmenter.segment(text), markers);
65
+ }
66
+ function resolveWrapOverflow(input) {
67
+ if (input.wrapOpportunityIndex >= 0 &&
68
+ input.currentWidth - input.wrapOpportunityWidth + input.graphemeWidth <= input.maxWidth) {
69
+ return {
70
+ chunk: {
71
+ text: input.line.slice(input.chunkStart, input.wrapOpportunityIndex),
72
+ startIndex: input.chunkStart,
73
+ endIndex: input.wrapOpportunityIndex,
74
+ },
75
+ chunkStart: input.wrapOpportunityIndex,
76
+ currentWidth: input.currentWidth - input.wrapOpportunityWidth,
77
+ };
78
+ }
79
+ if (input.chunkStart < input.charIndex) {
80
+ return {
81
+ chunk: {
82
+ text: input.line.slice(input.chunkStart, input.charIndex),
83
+ startIndex: input.chunkStart,
84
+ endIndex: input.charIndex,
85
+ },
86
+ chunkStart: input.charIndex,
87
+ currentWidth: 0,
88
+ };
89
+ }
90
+ return { chunkStart: input.chunkStart, currentWidth: input.currentWidth };
91
+ }
92
+ function splitOversizedSegment(grapheme, maxWidth, charIndex) {
93
+ const subChunks = wordWrapLine(grapheme, maxWidth);
94
+ const completedChunks = subChunks.slice(0, -1).map((chunk) => ({
95
+ text: chunk.text,
96
+ startIndex: charIndex + chunk.startIndex,
97
+ endIndex: charIndex + chunk.endIndex,
98
+ }));
99
+ const last = subChunks[subChunks.length - 1];
100
+ return {
101
+ completedChunks,
102
+ chunkStart: charIndex + last.startIndex,
103
+ currentWidth: visibleWidth(last.text),
104
+ };
105
+ }
106
+ function findWrapOpportunityAfterWhitespace(currentIsWhitespace, next) {
107
+ if (!currentIsWhitespace || !next)
108
+ return undefined;
109
+ if (!isPasteMarker(next.segment) && isWhitespaceChar(next.segment))
110
+ return undefined;
111
+ return next.index;
112
+ }
71
113
  /**
72
114
  * Split a line into word-wrapped chunks.
73
115
  * Wraps at word boundaries when possible, falling back to character-level
@@ -101,40 +143,30 @@ export function wordWrapLine(line, maxWidth, preSegmented) {
101
143
  const gWidth = visibleWidth(grapheme);
102
144
  const charIndex = seg.index;
103
145
  const isWs = !isPasteMarker(grapheme) && isWhitespaceChar(grapheme);
104
- // Overflow check before advancing.
146
+ // Resolve overflow before advancing to the current grapheme.
105
147
  if (currentWidth + gWidth > maxWidth) {
106
- if (wrapOppIndex >= 0 && currentWidth - wrapOppWidth + gWidth <= maxWidth) {
107
- // Backtrack to last wrap opportunity (the remaining content
108
- // plus the current grapheme still fits within maxWidth).
109
- chunks.push({ text: line.slice(chunkStart, wrapOppIndex), startIndex: chunkStart, endIndex: wrapOppIndex });
110
- chunkStart = wrapOppIndex;
111
- currentWidth -= wrapOppWidth;
112
- }
113
- else if (chunkStart < charIndex) {
114
- // No viable wrap opportunity: force-break at current position.
115
- // This also handles the case where backtracking to a word
116
- // boundary wouldn't help because the remaining content plus
117
- // the current grapheme (e.g. a wide character) still exceeds
118
- // maxWidth.
119
- chunks.push({ text: line.slice(chunkStart, charIndex), startIndex: chunkStart, endIndex: charIndex });
120
- chunkStart = charIndex;
121
- currentWidth = 0;
122
- }
148
+ const resolution = resolveWrapOverflow({
149
+ line,
150
+ maxWidth,
151
+ graphemeWidth: gWidth,
152
+ charIndex,
153
+ chunkStart,
154
+ currentWidth,
155
+ wrapOpportunityIndex: wrapOppIndex,
156
+ wrapOpportunityWidth: wrapOppWidth,
157
+ });
158
+ if (resolution.chunk)
159
+ chunks.push(resolution.chunk);
160
+ chunkStart = resolution.chunkStart;
161
+ currentWidth = resolution.currentWidth;
123
162
  wrapOppIndex = -1;
124
163
  }
125
164
  if (gWidth > maxWidth) {
126
- // Single atomic segment wider than maxWidth (e.g. paste marker
127
- // in a narrow terminal). Re-wrap it at grapheme granularity.
128
- // The segment remains logically atomic for cursor
129
- // movement / editing — the split is purely visual for word-wrap layout.
130
- const subChunks = wordWrapLine(grapheme, maxWidth);
131
- for (let j = 0; j < subChunks.length - 1; j++) {
132
- const sc = subChunks[j];
133
- chunks.push({ text: sc.text, startIndex: charIndex + sc.startIndex, endIndex: charIndex + sc.endIndex });
134
- }
135
- const last = subChunks[subChunks.length - 1];
136
- chunkStart = charIndex + last.startIndex;
137
- currentWidth = visibleWidth(last.text);
165
+ // Keep the segment logically atomic for editing while splitting its visual layout.
166
+ const wrapped = splitOversizedSegment(grapheme, maxWidth, charIndex);
167
+ chunks.push(...wrapped.completedChunks);
168
+ chunkStart = wrapped.chunkStart;
169
+ currentWidth = wrapped.currentWidth;
138
170
  wrapOppIndex = -1;
139
171
  continue;
140
172
  }
@@ -144,8 +176,9 @@ export function wordWrapLine(line, maxWidth, preSegmented) {
144
176
  // Multiple spaces join (no break between them); the break point is
145
177
  // after the last space before the next word.
146
178
  const next = segments[i + 1];
147
- if (isWs && next && (isPasteMarker(next.segment) || !isWhitespaceChar(next.segment))) {
148
- wrapOppIndex = next.index;
179
+ const nextWrapOpportunity = findWrapOpportunityAfterWhitespace(isWs, next);
180
+ if (nextWrapOpportunity !== undefined) {
181
+ wrapOppIndex = nextWrapOpportunity;
149
182
  wrapOppWidth = currentWidth;
150
183
  }
151
184
  }
@@ -158,6 +191,16 @@ const SLASH_COMMAND_SELECT_LIST_LAYOUT = {
158
191
  maxPrimaryColumnWidth: 32,
159
192
  };
160
193
  const ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS = 20;
194
+ function createScrollBorder(direction, hiddenLineCount, width) {
195
+ const availableWidth = Math.max(0, width);
196
+ const indicator = `─── ${direction} ${hiddenLineCount} more `;
197
+ const remaining = availableWidth - visibleWidth(indicator);
198
+ if (remaining >= 0)
199
+ return indicator + "─".repeat(remaining);
200
+ const ellipsis = "...".slice(0, availableWidth);
201
+ const indicatorWidth = availableWidth - visibleWidth(ellipsis);
202
+ return sliceByColumn(indicator, 0, indicatorWidth, true) + ellipsis;
203
+ }
161
204
  export class Editor {
162
205
  state = {
163
206
  lines: [""],
@@ -319,452 +362,411 @@ export class Editor {
319
362
  invalidate() {
320
363
  // No cached state to invalidate currently
321
364
  }
322
- render(width) {
365
+ getRenderGeometry(width) {
323
366
  const maxPadding = Math.max(0, Math.floor((width - 1) / 2));
324
367
  const paddingX = Math.min(this.paddingX, maxPadding);
325
368
  const contentWidth = Math.max(1, width - paddingX * 2);
326
- // Layout width: with padding the cursor can overflow into it,
327
- // without padding we reserve 1 column for the cursor.
328
369
  const layoutWidth = Math.max(1, contentWidth - (paddingX ? 0 : 1));
329
- // Store for cursor navigation (must match wrapping width)
330
- this.lastWidth = layoutWidth;
331
- const horizontal = this.borderColor("─");
332
- // Layout the text
333
- const layoutLines = this.layoutText(layoutWidth);
334
- // Calculate max visible lines: 30% of terminal height, minimum 5 lines
335
- const terminalRows = this.tui.terminal.rows;
336
- const maxVisibleLines = Math.max(5, Math.floor(terminalRows * 0.3));
337
- // Find the cursor line index in layoutLines
338
- let cursorLineIndex = layoutLines.findIndex((line) => line.hasCursor);
339
- if (cursorLineIndex === -1)
340
- cursorLineIndex = 0;
341
- // Adjust scroll offset to keep cursor visible
370
+ const leftPadding = " ".repeat(paddingX);
371
+ return {
372
+ width,
373
+ paddingX,
374
+ contentWidth,
375
+ layoutWidth,
376
+ horizontal: this.borderColor("─"),
377
+ leftPadding,
378
+ rightPadding: leftPadding,
379
+ };
380
+ }
381
+ getVisibleLayoutLines(layoutLines) {
382
+ const maxVisibleLines = Math.max(5, Math.floor(this.tui.terminal.rows * 0.3));
383
+ const foundCursorLineIndex = layoutLines.findIndex((line) => line.hasCursor);
384
+ const cursorLineIndex = foundCursorLineIndex === -1 ? 0 : foundCursorLineIndex;
342
385
  if (cursorLineIndex < this.scrollOffset) {
343
386
  this.scrollOffset = cursorLineIndex;
344
387
  }
345
388
  else if (cursorLineIndex >= this.scrollOffset + maxVisibleLines) {
346
389
  this.scrollOffset = cursorLineIndex - maxVisibleLines + 1;
347
390
  }
348
- // Clamp scroll offset to valid range
349
391
  const maxScrollOffset = Math.max(0, layoutLines.length - maxVisibleLines);
350
392
  this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxScrollOffset));
351
- // Get visible lines slice
352
- const visibleLines = layoutLines.slice(this.scrollOffset, this.scrollOffset + maxVisibleLines);
353
- const result = [];
354
- const leftPadding = " ".repeat(paddingX);
355
- const rightPadding = leftPadding;
356
- // Render top border (with scroll indicator if scrolled down)
357
- if (this.scrollOffset > 0) {
358
- const indicator = `─── ↑ ${this.scrollOffset} more `;
359
- const remaining = width - visibleWidth(indicator);
360
- if (remaining >= 0) {
361
- result.push(this.borderColor(indicator + "─".repeat(remaining)));
362
- }
363
- else {
364
- result.push(this.borderColor(truncateToWidth(indicator, width)));
365
- }
366
- }
367
- else {
368
- result.push(horizontal.repeat(width));
393
+ return layoutLines.slice(this.scrollOffset, this.scrollOffset + maxVisibleLines);
394
+ }
395
+ renderLayoutLineCursor(layoutLine, geometry, emitCursorMarker) {
396
+ const lineWidth = visibleWidth(layoutLine.text);
397
+ if (!layoutLine.hasCursor || layoutLine.cursorPos === undefined) {
398
+ return { displayText: layoutLine.text, visibleWidth: lineWidth, cursorInPadding: false };
399
+ }
400
+ const before = layoutLine.text.slice(0, layoutLine.cursorPos);
401
+ const after = layoutLine.text.slice(layoutLine.cursorPos);
402
+ const marker = emitCursorMarker ? CURSOR_MARKER : "";
403
+ if (after.length > 0) {
404
+ const firstGrapheme = [...this.segment(after, "grapheme")][0]?.segment || "";
405
+ const restAfter = after.slice(firstGrapheme.length);
406
+ return {
407
+ displayText: `${before}${marker}\x1b[7m${firstGrapheme}\x1b[0m${restAfter}`,
408
+ visibleWidth: lineWidth,
409
+ cursorInPadding: false,
410
+ };
411
+ }
412
+ const widthWithCursor = lineWidth + 1;
413
+ return {
414
+ displayText: `${before}${marker}\x1b[7m \x1b[0m`,
415
+ visibleWidth: widthWithCursor,
416
+ cursorInPadding: widthWithCursor > geometry.contentWidth && geometry.paddingX > 0,
417
+ };
418
+ }
419
+ renderVisibleLayoutLine(layoutLine, geometry, emitCursorMarker) {
420
+ const rendered = this.renderLayoutLineCursor(layoutLine, geometry, emitCursorMarker);
421
+ const padding = " ".repeat(Math.max(0, geometry.contentWidth - rendered.visibleWidth));
422
+ const rightPadding = rendered.cursorInPadding ? geometry.rightPadding.slice(1) : geometry.rightPadding;
423
+ return `${geometry.leftPadding}${rendered.displayText}${padding}${rightPadding}`;
424
+ }
425
+ renderTopEditorBorder(geometry) {
426
+ if (this.scrollOffset === 0)
427
+ return geometry.horizontal.repeat(geometry.width);
428
+ return this.borderColor(createScrollBorder("↑", this.scrollOffset, geometry.width));
429
+ }
430
+ renderBottomEditorBorder(layoutLineCount, visibleLineCount, geometry) {
431
+ const linesBelow = layoutLineCount - (this.scrollOffset + visibleLineCount);
432
+ if (linesBelow <= 0)
433
+ return geometry.horizontal.repeat(geometry.width);
434
+ return this.borderColor(createScrollBorder("↓", linesBelow, geometry.width));
435
+ }
436
+ appendAutocompleteLines(result, geometry) {
437
+ if (!this.autocompleteState || !this.autocompleteList)
438
+ return;
439
+ for (const line of this.autocompleteList.render(geometry.contentWidth)) {
440
+ const lineWidth = visibleWidth(line);
441
+ const linePadding = " ".repeat(Math.max(0, geometry.contentWidth - lineWidth));
442
+ result.push(`${geometry.leftPadding}${line}${linePadding}${geometry.rightPadding}`);
369
443
  }
370
- // Render each visible layout line
371
- // Emit hardware cursor marker only when focused and not showing autocomplete
444
+ }
445
+ render(width) {
446
+ const geometry = this.getRenderGeometry(width);
447
+ this.lastWidth = geometry.layoutWidth;
448
+ const layoutLines = this.layoutText(geometry.layoutWidth);
449
+ const visibleLines = this.getVisibleLayoutLines(layoutLines);
450
+ const result = [this.renderTopEditorBorder(geometry)];
372
451
  const emitCursorMarker = this.focused && !this.autocompleteState;
373
452
  for (const layoutLine of visibleLines) {
374
- let displayText = layoutLine.text;
375
- let lineVisibleWidth = visibleWidth(layoutLine.text);
376
- let cursorInPadding = false;
377
- // Add cursor if this line has it
378
- if (layoutLine.hasCursor && layoutLine.cursorPos !== undefined) {
379
- const before = displayText.slice(0, layoutLine.cursorPos);
380
- const after = displayText.slice(layoutLine.cursorPos);
381
- // Hardware cursor marker (zero-width, emitted before fake cursor for IME positioning)
382
- const marker = emitCursorMarker ? CURSOR_MARKER : "";
383
- if (after.length > 0) {
384
- // Cursor is on a character (grapheme) - replace it with highlighted version
385
- // Get the first grapheme from 'after'
386
- const afterGraphemes = [...this.segment(after, "grapheme")];
387
- const firstGrapheme = afterGraphemes[0]?.segment || "";
388
- const restAfter = after.slice(firstGrapheme.length);
389
- const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`;
390
- displayText = before + marker + cursor + restAfter;
391
- // lineVisibleWidth stays the same - we're replacing, not adding
392
- }
393
- else {
394
- // Cursor is at the end - add highlighted space
395
- const cursor = "\x1b[7m \x1b[0m";
396
- displayText = before + marker + cursor;
397
- lineVisibleWidth = lineVisibleWidth + 1;
398
- // If cursor overflows content width into the padding, flag it
399
- if (lineVisibleWidth > contentWidth && paddingX > 0) {
400
- cursorInPadding = true;
401
- }
402
- }
403
- }
404
- // Calculate padding based on actual visible width
405
- const padding = " ".repeat(Math.max(0, contentWidth - lineVisibleWidth));
406
- const lineRightPadding = cursorInPadding ? rightPadding.slice(1) : rightPadding;
407
- // Render the line (no side borders, just horizontal lines above and below)
408
- result.push(`${leftPadding}${displayText}${padding}${lineRightPadding}`);
409
- }
410
- // Render bottom border (with scroll indicator if more content below)
411
- const linesBelow = layoutLines.length - (this.scrollOffset + visibleLines.length);
412
- if (linesBelow > 0) {
413
- const indicator = `─── ↓ ${linesBelow} more `;
414
- const remaining = width - visibleWidth(indicator);
415
- result.push(this.borderColor(indicator + "─".repeat(Math.max(0, remaining))));
416
- }
417
- else {
418
- result.push(horizontal.repeat(width));
419
- }
420
- // Add autocomplete list if active
421
- if (this.autocompleteState && this.autocompleteList) {
422
- const autocompleteResult = this.autocompleteList.render(contentWidth);
423
- for (const line of autocompleteResult) {
424
- const lineWidth = visibleWidth(line);
425
- const linePadding = " ".repeat(Math.max(0, contentWidth - lineWidth));
426
- result.push(`${leftPadding}${line}${linePadding}${rightPadding}`);
427
- }
453
+ result.push(this.renderVisibleLayoutLine(layoutLine, geometry, emitCursorMarker));
428
454
  }
455
+ result.push(this.renderBottomEditorBorder(layoutLines.length, visibleLines.length, geometry));
456
+ this.appendAutocompleteLines(result, geometry);
429
457
  return result;
430
458
  }
431
- handleInput(data) {
432
- const kb = getKeybindings();
433
- // Handle character jump mode (awaiting next character to jump to)
434
- if (this.jumpMode !== null) {
435
- // Cancel if the hotkey is pressed again
436
- if (kb.matches(data, "tui.editor.jumpForward") || kb.matches(data, "tui.editor.jumpBackward")) {
437
- this.jumpMode = null;
438
- return;
439
- }
440
- const printable = decodePrintableKey(data) ?? (data.charCodeAt(0) >= 32 ? data : undefined);
441
- if (printable !== undefined) {
442
- // Printable character - perform the jump
443
- const direction = this.jumpMode;
444
- this.jumpMode = null;
445
- this.jumpToChar(printable, direction);
446
- return;
447
- }
448
- // Control character - cancel and fall through to normal handling
459
+ handleJumpModeInput(data, keybindings) {
460
+ if (this.jumpMode === null)
461
+ return false;
462
+ if (keybindings.matches(data, "tui.editor.jumpForward") || keybindings.matches(data, "tui.editor.jumpBackward")) {
449
463
  this.jumpMode = null;
464
+ return true;
450
465
  }
451
- // Handle bracketed paste mode
452
- if (data.includes("\x1b[200~")) {
466
+ const printable = decodePrintableKey(data) ?? (data.charCodeAt(0) >= 32 ? data : undefined);
467
+ if (printable !== undefined) {
468
+ const direction = this.jumpMode;
469
+ this.jumpMode = null;
470
+ this.jumpToChar(printable, direction);
471
+ return true;
472
+ }
473
+ this.jumpMode = null;
474
+ return false;
475
+ }
476
+ handleBracketedPasteInput(data) {
477
+ let remainingData = data;
478
+ if (remainingData.includes("\x1b[200~")) {
453
479
  this.isInPaste = true;
454
480
  this.pasteBuffer = "";
455
- data = data.replace("\x1b[200~", "");
456
- }
457
- if (this.isInPaste) {
458
- this.pasteBuffer += data;
459
- const endIndex = this.pasteBuffer.indexOf("\x1b[201~");
460
- if (endIndex !== -1) {
461
- const pasteContent = this.pasteBuffer.substring(0, endIndex);
462
- if (pasteContent.length > 0) {
463
- this.handlePaste(pasteContent);
464
- }
465
- this.isInPaste = false;
466
- const remaining = this.pasteBuffer.substring(endIndex + 6);
467
- this.pasteBuffer = "";
468
- if (remaining.length > 0) {
469
- this.handleInput(remaining);
470
- }
471
- return;
472
- }
473
- return;
474
- }
475
- // Ctrl+C - let parent handle (exit/clear)
476
- if (kb.matches(data, "tui.input.copy")) {
477
- return;
481
+ remainingData = remainingData.replace("\x1b[200~", "");
482
+ }
483
+ if (!this.isInPaste)
484
+ return { data: remainingData, consumed: false };
485
+ this.pasteBuffer += remainingData;
486
+ const endIndex = this.pasteBuffer.indexOf("\x1b[201~");
487
+ if (endIndex === -1)
488
+ return { data: remainingData, consumed: true };
489
+ const pasteContent = this.pasteBuffer.substring(0, endIndex);
490
+ if (pasteContent.length > 0)
491
+ this.handlePaste(pasteContent);
492
+ this.isInPaste = false;
493
+ const trailingInput = this.pasteBuffer.substring(endIndex + 6);
494
+ this.pasteBuffer = "";
495
+ if (trailingInput.length > 0)
496
+ this.handleInput(trailingInput);
497
+ return { data: remainingData, consumed: true };
498
+ }
499
+ applySelectedAutocomplete() {
500
+ const selected = this.autocompleteList?.getSelectedItem();
501
+ if (!selected || !this.autocompleteProvider)
502
+ return false;
503
+ this.pushUndoSnapshot();
504
+ this.lastAction = null;
505
+ const result = this.autocompleteProvider.applyCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol, selected, this.autocompletePrefix);
506
+ this.state.lines = result.lines;
507
+ this.state.cursorLine = result.cursorLine;
508
+ this.setCursorCol(result.cursorCol);
509
+ return true;
510
+ }
511
+ handleAutocompleteInput(data, keybindings) {
512
+ if (!this.autocompleteState || !this.autocompleteList)
513
+ return false;
514
+ if (keybindings.matches(data, "tui.select.cancel")) {
515
+ this.cancelAutocomplete();
516
+ return true;
478
517
  }
479
- // Undo
480
- if (kb.matches(data, "tui.editor.undo")) {
481
- this.undo();
482
- return;
518
+ if (keybindings.matches(data, "tui.select.up") || keybindings.matches(data, "tui.select.down")) {
519
+ this.autocompleteList.handleInput(data);
520
+ return true;
483
521
  }
484
- // Handle autocomplete mode
485
- if (this.autocompleteState && this.autocompleteList) {
486
- if (kb.matches(data, "tui.select.cancel")) {
522
+ if (keybindings.matches(data, "tui.input.tab")) {
523
+ if (this.applySelectedAutocomplete()) {
487
524
  this.cancelAutocomplete();
488
- return;
489
- }
490
- if (kb.matches(data, "tui.select.up") || kb.matches(data, "tui.select.down")) {
491
- this.autocompleteList.handleInput(data);
492
- return;
493
- }
494
- if (kb.matches(data, "tui.input.tab")) {
495
- const selected = this.autocompleteList.getSelectedItem();
496
- if (selected && this.autocompleteProvider) {
497
- this.pushUndoSnapshot();
498
- this.lastAction = null;
499
- const result = this.autocompleteProvider.applyCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol, selected, this.autocompletePrefix);
500
- this.state.lines = result.lines;
501
- this.state.cursorLine = result.cursorLine;
502
- this.setCursorCol(result.cursorCol);
503
- this.cancelAutocomplete();
504
- if (this.onChange)
505
- this.onChange(this.getText());
506
- }
507
- return;
508
- }
509
- if (kb.matches(data, "tui.select.confirm")) {
510
- const selected = this.autocompleteList.getSelectedItem();
511
- if (selected && this.autocompleteProvider) {
512
- this.pushUndoSnapshot();
513
- this.lastAction = null;
514
- const result = this.autocompleteProvider.applyCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol, selected, this.autocompletePrefix);
515
- this.state.lines = result.lines;
516
- this.state.cursorLine = result.cursorLine;
517
- this.setCursorCol(result.cursorCol);
518
- if (this.autocompletePrefix.startsWith("/")) {
519
- this.cancelAutocomplete();
520
- // Fall through to submit
521
- }
522
- else {
523
- this.cancelAutocomplete();
524
- if (this.onChange)
525
- this.onChange(this.getText());
526
- return;
527
- }
528
- }
525
+ this.onChange?.(this.getText());
529
526
  }
527
+ return true;
530
528
  }
531
- // Tab - trigger completion
532
- if (kb.matches(data, "tui.input.tab") && !this.autocompleteState) {
533
- this.handleTabCompletion();
534
- return;
535
- }
536
- // Deletion actions
537
- if (kb.matches(data, "tui.editor.deleteToLineEnd")) {
529
+ if (!keybindings.matches(data, "tui.select.confirm") || !this.applySelectedAutocomplete())
530
+ return false;
531
+ const submitCompletion = this.autocompletePrefix.startsWith("/");
532
+ this.cancelAutocomplete();
533
+ if (submitCompletion)
534
+ return false;
535
+ this.onChange?.(this.getText());
536
+ return true;
537
+ }
538
+ handleGlobalEditorInput(data, keybindings) {
539
+ if (keybindings.matches(data, "tui.input.copy"))
540
+ return true;
541
+ if (!keybindings.matches(data, "tui.editor.undo"))
542
+ return false;
543
+ this.undo();
544
+ return true;
545
+ }
546
+ handleTabInput(data, keybindings) {
547
+ if (!keybindings.matches(data, "tui.input.tab") || this.autocompleteState)
548
+ return false;
549
+ this.handleTabCompletion();
550
+ return true;
551
+ }
552
+ handleDeletionInput(data, keybindings) {
553
+ if (keybindings.matches(data, "tui.editor.deleteToLineEnd"))
538
554
  this.deleteToEndOfLine();
539
- return;
540
- }
541
- if (kb.matches(data, "tui.editor.deleteToLineStart")) {
555
+ else if (keybindings.matches(data, "tui.editor.deleteToLineStart"))
542
556
  this.deleteToStartOfLine();
543
- return;
544
- }
545
- if (kb.matches(data, "tui.editor.deleteWordBackward")) {
557
+ else if (keybindings.matches(data, "tui.editor.deleteWordBackward"))
546
558
  this.deleteWordBackwards();
547
- return;
548
- }
549
- if (kb.matches(data, "tui.editor.deleteWordForward")) {
559
+ else if (keybindings.matches(data, "tui.editor.deleteWordForward"))
550
560
  this.deleteWordForward();
551
- return;
552
- }
553
- if (kb.matches(data, "tui.editor.deleteCharBackward") || matchesKey(data, "shift+backspace")) {
561
+ else if (keybindings.matches(data, "tui.editor.deleteCharBackward") || matchesKey(data, "shift+backspace"))
554
562
  this.handleBackspace();
555
- return;
556
- }
557
- if (kb.matches(data, "tui.editor.deleteCharForward") || matchesKey(data, "shift+delete")) {
563
+ else if (keybindings.matches(data, "tui.editor.deleteCharForward") || matchesKey(data, "shift+delete"))
558
564
  this.handleForwardDelete();
559
- return;
560
- }
561
- // Kill ring actions
562
- if (kb.matches(data, "tui.editor.yank")) {
565
+ else
566
+ return false;
567
+ return true;
568
+ }
569
+ handleKillRingInput(data, keybindings) {
570
+ if (keybindings.matches(data, "tui.editor.yank"))
563
571
  this.yank();
564
- return;
565
- }
566
- if (kb.matches(data, "tui.editor.yankPop")) {
572
+ else if (keybindings.matches(data, "tui.editor.yankPop"))
567
573
  this.yankPop();
568
- return;
569
- }
570
- // Cursor movement actions
571
- if (kb.matches(data, "tui.editor.cursorLineStart")) {
574
+ else
575
+ return false;
576
+ return true;
577
+ }
578
+ handleHistoryInput(data, keybindings) {
579
+ let direction;
580
+ if (keybindings.matches(data, "tui.editor.historyPrevious"))
581
+ direction = -1;
582
+ else if (keybindings.matches(data, "tui.editor.historyNext"))
583
+ direction = 1;
584
+ else
585
+ return false;
586
+ this.cancelAutocomplete();
587
+ this.navigateHistory(direction);
588
+ return true;
589
+ }
590
+ handleLineAndWordMovement(data, keybindings) {
591
+ if (keybindings.matches(data, "tui.editor.cursorLineStart"))
572
592
  this.moveToLineStart();
573
- return;
574
- }
575
- if (kb.matches(data, "tui.editor.cursorLineEnd")) {
593
+ else if (keybindings.matches(data, "tui.editor.cursorLineEnd"))
576
594
  this.moveToLineEnd();
577
- return;
578
- }
579
- if (kb.matches(data, "tui.editor.cursorWordLeft")) {
595
+ else if (keybindings.matches(data, "tui.editor.cursorWordLeft"))
580
596
  this.moveWordBackwards();
581
- return;
582
- }
583
- if (kb.matches(data, "tui.editor.cursorWordRight")) {
597
+ else if (keybindings.matches(data, "tui.editor.cursorWordRight"))
584
598
  this.moveWordForwards();
585
- return;
586
- }
587
- // New line
588
- if (kb.matches(data, "tui.input.newLine") ||
599
+ else
600
+ return false;
601
+ return true;
602
+ }
603
+ isLineBreakInput(data, keybindings) {
604
+ return (keybindings.matches(data, "tui.input.newLine") ||
589
605
  (data.charCodeAt(0) === 10 && data.length > 1) ||
590
606
  data === "\x1b\r" ||
591
607
  data === "\x1b[13;2~" ||
592
608
  (data.length > 1 && data.includes("\x1b") && data.includes("\r")) ||
593
- (data === "\n" && data.length === 1)) {
594
- if (this.shouldSubmitOnBackslashEnter(data, kb)) {
595
- this.handleBackspace();
596
- this.submitValue();
597
- return;
598
- }
609
+ data === "\n");
610
+ }
611
+ handleLineBreakInput(data, keybindings) {
612
+ if (!this.isLineBreakInput(data, keybindings))
613
+ return false;
614
+ if (this.shouldSubmitOnBackslashEnter(data, keybindings)) {
615
+ this.handleBackspace();
616
+ this.submitValue();
617
+ }
618
+ else {
599
619
  this.addNewLine();
600
- return;
601
620
  }
602
- // Submit (Enter)
603
- if (kb.matches(data, "tui.input.submit")) {
604
- if (this.disableSubmit)
605
- return;
606
- // Workaround for terminals without Shift+Enter support:
607
- // If char before cursor is \, delete it and insert newline instead of submitting.
608
- const currentLine = this.state.lines[this.state.cursorLine] || "";
609
- if (this.state.cursorCol > 0 && currentLine[this.state.cursorCol - 1] === "\\") {
610
- this.handleBackspace();
611
- this.addNewLine();
612
- return;
613
- }
621
+ return true;
622
+ }
623
+ handleSubmitInput(data, keybindings) {
624
+ if (!keybindings.matches(data, "tui.input.submit"))
625
+ return false;
626
+ if (this.disableSubmit)
627
+ return true;
628
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
629
+ if (this.state.cursorCol > 0 && currentLine[this.state.cursorCol - 1] === "\\") {
630
+ this.handleBackspace();
631
+ this.addNewLine();
632
+ }
633
+ else {
614
634
  this.submitValue();
615
- return;
616
635
  }
617
- // Arrow key navigation (with history support)
618
- if (kb.matches(data, "tui.editor.cursorUp")) {
619
- if (this.isEditorEmpty()) {
620
- this.navigateHistory(-1);
621
- }
622
- else if (this.historyIndex > -1 && this.isOnFirstVisualLine()) {
636
+ return true;
637
+ }
638
+ handleVerticalNavigation(data, keybindings) {
639
+ if (keybindings.matches(data, "tui.editor.cursorUp")) {
640
+ if (this.isEditorEmpty() || (this.historyIndex > -1 && this.isOnFirstVisualLine()))
623
641
  this.navigateHistory(-1);
624
- }
625
- else if (this.isOnFirstVisualLine()) {
626
- // Already at top - jump to start of line
642
+ else if (this.isOnFirstVisualLine())
627
643
  this.moveToLineStart();
628
- }
629
- else {
644
+ else
630
645
  this.moveCursor(-1, 0);
631
- }
632
- return;
633
- }
634
- if (kb.matches(data, "tui.editor.cursorDown")) {
635
- if (this.historyIndex > -1 && this.isOnLastVisualLine()) {
636
- this.navigateHistory(1);
637
- }
638
- else if (this.isOnLastVisualLine()) {
639
- // Already at bottom - jump to end of line
640
- this.moveToLineEnd();
641
- }
642
- else {
643
- this.moveCursor(1, 0);
644
- }
645
- return;
646
+ return true;
646
647
  }
647
- if (kb.matches(data, "tui.editor.cursorRight")) {
648
+ if (!keybindings.matches(data, "tui.editor.cursorDown"))
649
+ return false;
650
+ if (this.historyIndex > -1 && this.isOnLastVisualLine())
651
+ this.navigateHistory(1);
652
+ else if (this.isOnLastVisualLine())
653
+ this.moveToLineEnd();
654
+ else
655
+ this.moveCursor(1, 0);
656
+ return true;
657
+ }
658
+ handleHorizontalNavigation(data, keybindings) {
659
+ if (keybindings.matches(data, "tui.editor.cursorRight"))
648
660
  this.moveCursor(0, 1);
649
- return;
650
- }
651
- if (kb.matches(data, "tui.editor.cursorLeft")) {
661
+ else if (keybindings.matches(data, "tui.editor.cursorLeft"))
652
662
  this.moveCursor(0, -1);
653
- return;
654
- }
655
- // Page up/down - scroll by page and move cursor
656
- if (kb.matches(data, "tui.editor.pageUp")) {
663
+ else
664
+ return false;
665
+ return true;
666
+ }
667
+ handlePageAndJumpInput(data, keybindings) {
668
+ if (keybindings.matches(data, "tui.editor.pageUp"))
657
669
  this.pageScroll(-1);
658
- return;
659
- }
660
- if (kb.matches(data, "tui.editor.pageDown")) {
670
+ else if (keybindings.matches(data, "tui.editor.pageDown"))
661
671
  this.pageScroll(1);
662
- return;
663
- }
664
- // Character jump mode triggers
665
- if (kb.matches(data, "tui.editor.jumpForward")) {
672
+ else if (keybindings.matches(data, "tui.editor.jumpForward"))
666
673
  this.jumpMode = "forward";
667
- return;
668
- }
669
- if (kb.matches(data, "tui.editor.jumpBackward")) {
674
+ else if (keybindings.matches(data, "tui.editor.jumpBackward"))
670
675
  this.jumpMode = "backward";
671
- return;
672
- }
673
- // Shift+Space - insert regular space
676
+ else
677
+ return false;
678
+ return true;
679
+ }
680
+ handlePrintableInput(data) {
674
681
  if (matchesKey(data, "shift+space")) {
675
682
  this.insertCharacter(" ");
676
683
  return;
677
684
  }
678
685
  const printable = decodePrintableKey(data);
679
- if (printable !== undefined) {
686
+ if (printable !== undefined)
680
687
  this.insertCharacter(printable);
681
- return;
682
- }
683
- // Regular characters
684
- if (data.charCodeAt(0) >= 32) {
688
+ else if (data.charCodeAt(0) >= 32)
685
689
  this.insertCharacter(data);
690
+ }
691
+ handleInput(data) {
692
+ const keybindings = getKeybindings();
693
+ if (this.handleJumpModeInput(data, keybindings))
694
+ return;
695
+ const paste = this.handleBracketedPasteInput(data);
696
+ if (paste.consumed)
697
+ return;
698
+ data = paste.data;
699
+ if (this.handleGlobalEditorInput(data, keybindings))
700
+ return;
701
+ if (this.handleAutocompleteInput(data, keybindings))
702
+ return;
703
+ if (this.handleTabInput(data, keybindings))
704
+ return;
705
+ if (this.handleDeletionInput(data, keybindings))
706
+ return;
707
+ if (this.handleKillRingInput(data, keybindings))
708
+ return;
709
+ if (this.handleHistoryInput(data, keybindings))
710
+ return;
711
+ if (this.handleLineAndWordMovement(data, keybindings))
712
+ return;
713
+ if (this.handleLineBreakInput(data, keybindings))
714
+ return;
715
+ if (this.handleSubmitInput(data, keybindings))
716
+ return;
717
+ if (this.handleVerticalNavigation(data, keybindings))
718
+ return;
719
+ if (this.handleHorizontalNavigation(data, keybindings))
720
+ return;
721
+ if (this.handlePageAndJumpInput(data, keybindings))
722
+ return;
723
+ this.handlePrintableInput(data);
724
+ }
725
+ getWrappedChunkCursorPosition(chunk, isLastChunk, isCurrentLine) {
726
+ if (!isCurrentLine)
727
+ return undefined;
728
+ const cursorPosition = this.state.cursorCol;
729
+ if (isLastChunk) {
730
+ return cursorPosition >= chunk.startIndex ? cursorPosition - chunk.startIndex : undefined;
686
731
  }
732
+ if (cursorPosition < chunk.startIndex || cursorPosition >= chunk.endIndex)
733
+ return undefined;
734
+ return Math.min(cursorPosition - chunk.startIndex, chunk.text.length);
687
735
  }
688
- layoutText(contentWidth) {
736
+ layoutWrappedLine(line, contentWidth, isCurrentLine) {
737
+ const chunks = wordWrapLine(line, contentWidth, [...this.segment(line, "grapheme")]);
689
738
  const layoutLines = [];
690
- if (this.state.lines.length === 0 || (this.state.lines.length === 1 && this.state.lines[0] === "")) {
691
- // Empty editor
739
+ for (let index = 0; index < chunks.length; index++) {
740
+ const chunk = chunks[index];
741
+ if (!chunk)
742
+ continue;
743
+ const cursorPos = this.getWrappedChunkCursorPosition(chunk, index === chunks.length - 1, isCurrentLine);
692
744
  layoutLines.push({
693
- text: "",
694
- hasCursor: true,
695
- cursorPos: 0,
745
+ text: chunk.text,
746
+ hasCursor: cursorPos !== undefined,
747
+ ...(cursorPos === undefined ? {} : { cursorPos }),
696
748
  });
697
- return layoutLines;
698
749
  }
699
- // Process each logical line
700
- for (let i = 0; i < this.state.lines.length; i++) {
701
- const line = this.state.lines[i] || "";
702
- const isCurrentLine = i === this.state.cursorLine;
703
- const lineVisibleWidth = visibleWidth(line);
704
- if (lineVisibleWidth <= contentWidth) {
705
- // Line fits in one layout line
706
- if (isCurrentLine) {
707
- layoutLines.push({
708
- text: line,
709
- hasCursor: true,
710
- cursorPos: this.state.cursorCol,
711
- });
712
- }
713
- else {
714
- layoutLines.push({
715
- text: line,
716
- hasCursor: false,
717
- });
718
- }
719
- }
720
- else {
721
- // Line needs wrapping - use word-aware wrapping
722
- const chunks = wordWrapLine(line, contentWidth, [...this.segment(line, "grapheme")]);
723
- for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
724
- const chunk = chunks[chunkIndex];
725
- if (!chunk)
726
- continue;
727
- const cursorPos = this.state.cursorCol;
728
- const isLastChunk = chunkIndex === chunks.length - 1;
729
- // Determine if cursor is in this chunk
730
- // For word-wrapped chunks, we need to handle the case where
731
- // cursor might be in trimmed whitespace at end of chunk
732
- let hasCursorInChunk = false;
733
- let adjustedCursorPos = 0;
734
- if (isCurrentLine) {
735
- if (isLastChunk) {
736
- // Last chunk: cursor belongs here if >= startIndex
737
- hasCursorInChunk = cursorPos >= chunk.startIndex;
738
- adjustedCursorPos = cursorPos - chunk.startIndex;
739
- }
740
- else {
741
- // Non-last chunk: cursor belongs here if in range [startIndex, endIndex)
742
- // But we need to handle the visual position in the trimmed text
743
- hasCursorInChunk = cursorPos >= chunk.startIndex && cursorPos < chunk.endIndex;
744
- if (hasCursorInChunk) {
745
- adjustedCursorPos = cursorPos - chunk.startIndex;
746
- // Clamp to text length (in case cursor was in trimmed whitespace)
747
- if (adjustedCursorPos > chunk.text.length) {
748
- adjustedCursorPos = chunk.text.length;
749
- }
750
- }
751
- }
752
- }
753
- if (hasCursorInChunk) {
754
- layoutLines.push({
755
- text: chunk.text,
756
- hasCursor: true,
757
- cursorPos: adjustedCursorPos,
758
- });
759
- }
760
- else {
761
- layoutLines.push({
762
- text: chunk.text,
763
- hasCursor: false,
764
- });
765
- }
766
- }
767
- }
750
+ return layoutLines;
751
+ }
752
+ layoutLogicalLine(line, contentWidth, isCurrentLine) {
753
+ if (visibleWidth(line) > contentWidth)
754
+ return this.layoutWrappedLine(line, contentWidth, isCurrentLine);
755
+ return [
756
+ {
757
+ text: line,
758
+ hasCursor: isCurrentLine,
759
+ ...(isCurrentLine ? { cursorPos: this.state.cursorCol } : {}),
760
+ },
761
+ ];
762
+ }
763
+ layoutText(contentWidth) {
764
+ if (this.state.lines.length === 0 || (this.state.lines.length === 1 && this.state.lines[0] === "")) {
765
+ return [{ text: "", hasCursor: true, cursorPos: 0 }];
766
+ }
767
+ const layoutLines = [];
768
+ for (let index = 0; index < this.state.lines.length; index++) {
769
+ layoutLines.push(...this.layoutLogicalLine(this.state.lines[index] || "", contentWidth, index === this.state.cursorLine));
768
770
  }
769
771
  return layoutLines;
770
772
  }
@@ -801,6 +803,8 @@ export class Editor {
801
803
  if (this.getText() !== normalized) {
802
804
  this.pushUndoSnapshot();
803
805
  }
806
+ this.pastes.clear();
807
+ this.pasteCounter = 0;
804
808
  this.setTextInternal(normalized);
805
809
  }
806
810
  /**
@@ -865,6 +869,30 @@ export class Editor {
865
869
  this.onChange(this.getText());
866
870
  }
867
871
  }
872
+ refreshAutocompleteAfterCharacterInsertion(char) {
873
+ if (this.autocompleteState) {
874
+ this.updateAutocomplete();
875
+ return;
876
+ }
877
+ if (char === "/" && this.isAtStartOfMessage()) {
878
+ this.tryTriggerAutocomplete();
879
+ return;
880
+ }
881
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
882
+ const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
883
+ if (char === "@" || char === "#") {
884
+ const charBeforeSymbol = textBeforeCursor[textBeforeCursor.length - 2];
885
+ if (textBeforeCursor.length === 1 || charBeforeSymbol === " " || charBeforeSymbol === "\t") {
886
+ this.tryTriggerAutocomplete();
887
+ }
888
+ return;
889
+ }
890
+ if (!/[a-zA-Z0-9.\-_]/.test(char))
891
+ return;
892
+ if (this.isInSlashCommandContext(textBeforeCursor) || textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) {
893
+ this.tryTriggerAutocomplete();
894
+ }
895
+ }
868
896
  // All the editor methods from before...
869
897
  insertCharacter(char, skipUndoCoalescing) {
870
898
  this.historyIndex = -1; // Exit history browsing mode
@@ -887,38 +915,7 @@ export class Editor {
887
915
  if (this.onChange) {
888
916
  this.onChange(this.getText());
889
917
  }
890
- // Check if we should trigger or update autocomplete
891
- if (!this.autocompleteState) {
892
- // Auto-trigger for "/" at the start of a line (slash commands)
893
- if (char === "/" && this.isAtStartOfMessage()) {
894
- this.tryTriggerAutocomplete();
895
- }
896
- // Auto-trigger for symbol-based completion like @ or # at token boundaries
897
- else if (char === "@" || char === "#") {
898
- const currentLine = this.state.lines[this.state.cursorLine] || "";
899
- const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
900
- const charBeforeSymbol = textBeforeCursor[textBeforeCursor.length - 2];
901
- if (textBeforeCursor.length === 1 || charBeforeSymbol === " " || charBeforeSymbol === "\t") {
902
- this.tryTriggerAutocomplete();
903
- }
904
- }
905
- // Also auto-trigger when typing letters in a slash command or symbol completion context
906
- else if (/[a-zA-Z0-9.\-_]/.test(char)) {
907
- const currentLine = this.state.lines[this.state.cursorLine] || "";
908
- const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
909
- // Check if we're in a slash command (with or without space for arguments)
910
- if (this.isInSlashCommandContext(textBeforeCursor)) {
911
- this.tryTriggerAutocomplete();
912
- }
913
- // Check if we're in a symbol-based completion context like @ or #
914
- else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) {
915
- this.tryTriggerAutocomplete();
916
- }
917
- }
918
- }
919
- else {
920
- this.updateAutocomplete();
921
- }
918
+ this.refreshAutocompleteAfterCharacterInsertion(char);
922
919
  }
923
920
  handlePaste(pastedText) {
924
921
  this.cancelAutocomplete();
@@ -1023,22 +1020,53 @@ export class Editor {
1023
1020
  if (this.onSubmit)
1024
1021
  this.onSubmit(result);
1025
1022
  }
1023
+ deleteGraphemeBeforeCursor() {
1024
+ const line = this.state.lines[this.state.cursorLine] || "";
1025
+ const beforeCursor = line.slice(0, this.state.cursorCol);
1026
+ const graphemes = [...this.segment(beforeCursor, "grapheme")];
1027
+ const lastGrapheme = graphemes[graphemes.length - 1];
1028
+ const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;
1029
+ const pastedSegment = lastGrapheme ? PASTE_MARKER_SINGLE.exec(lastGrapheme.segment) : null;
1030
+ if (!pastedSegment) {
1031
+ const before = line.slice(0, this.state.cursorCol - graphemeLength);
1032
+ const after = line.slice(this.state.cursorCol);
1033
+ this.state.lines[this.state.cursorLine] = before + after;
1034
+ this.setCursorCol(this.state.cursorCol - graphemeLength);
1035
+ return;
1036
+ }
1037
+ const targetId = Number(pastedSegment[1]);
1038
+ const markerStart = this.state.cursorCol - graphemeLength;
1039
+ const beforeMarker = line.slice(0, markerStart);
1040
+ const afterMarker = line.slice(this.state.cursorCol);
1041
+ this.pastes.delete(targetId);
1042
+ this.pasteCounter--;
1043
+ // Shift registry entries down in ascending id order, independent
1044
+ // of marker order in the text ([paste #3] becomes [paste #2] when
1045
+ // [paste #1] is removed).
1046
+ const higherIds = [...this.pastes.keys()].filter((id) => id > targetId).sort((a, b) => a - b);
1047
+ for (const id of higherIds) {
1048
+ this.pastes.set(id - 1, this.pastes.get(id));
1049
+ this.pastes.delete(id);
1050
+ }
1051
+ const renumberMarkers = (text) => text.replace(PASTE_MARKER_REGEX, (fullMatch, idGroup, suffixGroup) => {
1052
+ const id = Number(idGroup);
1053
+ if (id <= targetId)
1054
+ return fullMatch;
1055
+ return `[paste #${id - 1}${suffixGroup}]`;
1056
+ });
1057
+ // Delete the target before renumbering. Renumber the text on each side
1058
+ // separately so an earlier #10 -> #9 change cannot stale cursorCol.
1059
+ const before = renumberMarkers(beforeMarker);
1060
+ const after = renumberMarkers(afterMarker);
1061
+ this.state.lines = this.state.lines.map((line, index) => index === this.state.cursorLine ? before + after : renumberMarkers(line));
1062
+ this.setCursorCol(before.length);
1063
+ }
1026
1064
  handleBackspace() {
1027
1065
  this.historyIndex = -1; // Exit history browsing mode
1028
1066
  this.lastAction = null;
1029
1067
  if (this.state.cursorCol > 0) {
1030
1068
  this.pushUndoSnapshot();
1031
- // Delete grapheme before cursor (handles emojis, combining characters, etc.)
1032
- const line = this.state.lines[this.state.cursorLine] || "";
1033
- const beforeCursor = line.slice(0, this.state.cursorCol);
1034
- // Find the last grapheme in the text before cursor
1035
- const graphemes = [...this.segment(beforeCursor, "grapheme")];
1036
- const lastGrapheme = graphemes[graphemes.length - 1];
1037
- const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;
1038
- const before = line.slice(0, this.state.cursorCol - graphemeLength);
1039
- const after = line.slice(this.state.cursorCol);
1040
- this.state.lines[this.state.cursorLine] = before + after;
1041
- this.setCursorCol(this.state.cursorCol - graphemeLength);
1069
+ this.deleteGraphemeBeforeCursor();
1042
1070
  }
1043
1071
  else if (this.state.cursorLine > 0) {
1044
1072
  this.pushUndoSnapshot();
@@ -1084,73 +1112,70 @@ export class Editor {
1084
1112
  * Move cursor to a target visual line, applying sticky column logic.
1085
1113
  * Shared by moveCursor() and pageScroll().
1086
1114
  */
1115
+ resolveCurrentVisualColumn(visualLines, currentVL) {
1116
+ if (this.snappedFromCursorCol === null)
1117
+ return this.state.cursorCol - currentVL.startCol;
1118
+ const visualLineIndex = this.findVisualLineAt(visualLines, currentVL.logicalLine, this.snappedFromCursorCol);
1119
+ return this.snappedFromCursorCol - visualLines[visualLineIndex].startCol;
1120
+ }
1121
+ getVisualSegmentMaxColumn(visualLines, visualLineIndex, visualLine) {
1122
+ const isLastSegment = visualLineIndex === visualLines.length - 1 ||
1123
+ visualLines[visualLineIndex + 1]?.logicalLine !== visualLine.logicalLine;
1124
+ return isLastSegment ? visualLine.length : Math.max(0, visualLine.length - 1);
1125
+ }
1126
+ findAtomicSegmentAtCursor(logicalLine) {
1127
+ for (const segment of this.segment(logicalLine, "grapheme")) {
1128
+ if (segment.index > this.state.cursorCol)
1129
+ return undefined;
1130
+ if (segment.segment.length > 1 && this.state.cursorCol < segment.index + segment.segment.length)
1131
+ return segment;
1132
+ }
1133
+ return undefined;
1134
+ }
1135
+ findVisualLineAfterSegment(visualLines, targetVisualLine, segmentEnd) {
1136
+ const target = visualLines[targetVisualLine];
1137
+ if (!target)
1138
+ return undefined;
1139
+ let next = targetVisualLine + 1;
1140
+ while (next < visualLines.length &&
1141
+ visualLines[next].logicalLine === target.logicalLine &&
1142
+ visualLines[next].startCol < segmentEnd) {
1143
+ next++;
1144
+ }
1145
+ return next < visualLines.length ? next : undefined;
1146
+ }
1147
+ snapCursorToAtomicSegment(visualLines, currentVisualLine, targetVisualLine, logicalLine) {
1148
+ const segment = this.findAtomicSegmentAtCursor(logicalLine);
1149
+ if (!segment) {
1150
+ this.snappedFromCursorCol = null;
1151
+ return;
1152
+ }
1153
+ const target = visualLines[targetVisualLine];
1154
+ const isContinuation = target ? segment.index < target.startCol : false;
1155
+ if (isContinuation && targetVisualLine > currentVisualLine) {
1156
+ const next = this.findVisualLineAfterSegment(visualLines, targetVisualLine, segment.index + segment.segment.length);
1157
+ if (next !== undefined) {
1158
+ this.moveToVisualLine(visualLines, currentVisualLine, next);
1159
+ return;
1160
+ }
1161
+ }
1162
+ this.snappedFromCursorCol = this.state.cursorCol;
1163
+ this.state.cursorCol = segment.index;
1164
+ }
1087
1165
  moveToVisualLine(visualLines, currentVisualLine, targetVisualLine) {
1088
1166
  const currentVL = visualLines[currentVisualLine];
1089
1167
  const targetVL = visualLines[targetVisualLine];
1090
1168
  if (!(currentVL && targetVL))
1091
1169
  return;
1092
- // When the cursor was snapped to a segment start, resolve the pre-snap
1093
- // position against the VL it belongs to. This gives the correct visual
1094
- // column even after a resize reshuffles VLs.
1095
- let currentVisualCol;
1096
- if (this.snappedFromCursorCol !== null) {
1097
- const vlIndex = this.findVisualLineAt(visualLines, currentVL.logicalLine, this.snappedFromCursorCol);
1098
- currentVisualCol = this.snappedFromCursorCol - visualLines[vlIndex].startCol;
1099
- }
1100
- else {
1101
- currentVisualCol = this.state.cursorCol - currentVL.startCol;
1102
- }
1103
- // For non-last segments, clamp to length-1 to stay within the segment
1104
- const isLastSourceSegment = currentVisualLine === visualLines.length - 1 ||
1105
- visualLines[currentVisualLine + 1]?.logicalLine !== currentVL.logicalLine;
1106
- const sourceMaxVisualCol = isLastSourceSegment ? currentVL.length : Math.max(0, currentVL.length - 1);
1107
- const isLastTargetSegment = targetVisualLine === visualLines.length - 1 ||
1108
- visualLines[targetVisualLine + 1]?.logicalLine !== targetVL.logicalLine;
1109
- const targetMaxVisualCol = isLastTargetSegment ? targetVL.length : Math.max(0, targetVL.length - 1);
1170
+ const currentVisualCol = this.resolveCurrentVisualColumn(visualLines, currentVL);
1171
+ const sourceMaxVisualCol = this.getVisualSegmentMaxColumn(visualLines, currentVisualLine, currentVL);
1172
+ const targetMaxVisualCol = this.getVisualSegmentMaxColumn(visualLines, targetVisualLine, targetVL);
1110
1173
  const moveToVisualCol = this.computeVerticalMoveColumn(currentVisualCol, sourceMaxVisualCol, targetMaxVisualCol);
1111
- // Set cursor position
1112
1174
  this.state.cursorLine = targetVL.logicalLine;
1113
1175
  const targetCol = targetVL.startCol + moveToVisualCol;
1114
1176
  const logicalLine = this.state.lines[targetVL.logicalLine] || "";
1115
1177
  this.state.cursorCol = Math.min(targetCol, logicalLine.length);
1116
- // Snap cursor to atomic segment boundary (e.g. paste markers)
1117
- // so the cursor never lands in the middle of a multi-grapheme unit.
1118
- // Single-grapheme segments don't need snapping.
1119
- const segments = [...this.segment(logicalLine, "grapheme")];
1120
- for (const seg of segments) {
1121
- if (seg.index > this.state.cursorCol)
1122
- break;
1123
- if (seg.segment.length <= 1)
1124
- continue;
1125
- if (this.state.cursorCol < seg.index + seg.segment.length) {
1126
- const isContinuation = seg.index < targetVL.startCol;
1127
- const isMovingDown = targetVisualLine > currentVisualLine;
1128
- if (isContinuation && isMovingDown) {
1129
- // The segment started on a previous visual line, and we
1130
- // already visited it on the way down. Skip all remaining
1131
- // continuation VLs and land on the first VL past it.
1132
- const segEnd = seg.index + seg.segment.length;
1133
- let next = targetVisualLine + 1;
1134
- while (next < visualLines.length &&
1135
- visualLines[next].logicalLine === targetVL.logicalLine &&
1136
- visualLines[next].startCol < segEnd) {
1137
- next++;
1138
- }
1139
- if (next < visualLines.length) {
1140
- this.moveToVisualLine(visualLines, currentVisualLine, next);
1141
- return;
1142
- }
1143
- }
1144
- // Snap to the start of the segment so it gets highlighted.
1145
- // Store the pre-snap position so the next vertical move can
1146
- // resolve it to the correct visual column.
1147
- this.snappedFromCursorCol = this.state.cursorCol;
1148
- this.state.cursorCol = seg.index;
1149
- return;
1150
- }
1151
- }
1152
- // No snap occurred – we moved out of the atomic segment.
1153
- this.snappedFromCursorCol = null;
1178
+ this.snapCursorToAtomicSegment(visualLines, currentVisualLine, targetVisualLine, logicalLine);
1154
1179
  }
1155
1180
  /**
1156
1181
  * Compute the target visual column for vertical cursor movement.
@@ -1427,55 +1452,58 @@ export class Editor {
1427
1452
  findCurrentVisualLine(visualLines) {
1428
1453
  return this.findVisualLineAt(visualLines, this.state.cursorLine, this.state.cursorCol);
1429
1454
  }
1455
+ moveCursorVertically(visualLines, currentVisualLine, deltaLine) {
1456
+ if (deltaLine === 0)
1457
+ return;
1458
+ const targetVisualLine = currentVisualLine + deltaLine;
1459
+ if (targetVisualLine >= 0 && targetVisualLine < visualLines.length) {
1460
+ this.moveToVisualLine(visualLines, currentVisualLine, targetVisualLine);
1461
+ }
1462
+ }
1463
+ moveCursorRight(visualLines, currentVisualLine) {
1464
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
1465
+ if (this.state.cursorCol < currentLine.length) {
1466
+ const afterCursor = currentLine.slice(this.state.cursorCol);
1467
+ const firstGrapheme = [...this.segment(afterCursor, "grapheme")][0];
1468
+ this.setCursorCol(this.state.cursorCol + (firstGrapheme ? firstGrapheme.segment.length : 1));
1469
+ return;
1470
+ }
1471
+ if (this.state.cursorLine < this.state.lines.length - 1) {
1472
+ this.state.cursorLine++;
1473
+ this.setCursorCol(0);
1474
+ return;
1475
+ }
1476
+ const currentVL = visualLines[currentVisualLine];
1477
+ if (currentVL)
1478
+ this.preferredVisualCol = this.state.cursorCol - currentVL.startCol;
1479
+ }
1480
+ moveCursorLeft() {
1481
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
1482
+ if (this.state.cursorCol > 0) {
1483
+ const beforeCursor = currentLine.slice(0, this.state.cursorCol);
1484
+ const graphemes = [...this.segment(beforeCursor, "grapheme")];
1485
+ const lastGrapheme = graphemes[graphemes.length - 1];
1486
+ this.setCursorCol(this.state.cursorCol - (lastGrapheme ? lastGrapheme.segment.length : 1));
1487
+ return;
1488
+ }
1489
+ if (this.state.cursorLine > 0) {
1490
+ this.state.cursorLine--;
1491
+ const previousLine = this.state.lines[this.state.cursorLine] || "";
1492
+ this.setCursorCol(previousLine.length);
1493
+ }
1494
+ }
1495
+ moveCursorHorizontally(visualLines, currentVisualLine, deltaCol) {
1496
+ if (deltaCol > 0)
1497
+ this.moveCursorRight(visualLines, currentVisualLine);
1498
+ else if (deltaCol < 0)
1499
+ this.moveCursorLeft();
1500
+ }
1430
1501
  moveCursor(deltaLine, deltaCol) {
1431
1502
  this.lastAction = null;
1432
1503
  const visualLines = this.buildVisualLineMap(this.lastWidth);
1433
1504
  const currentVisualLine = this.findCurrentVisualLine(visualLines);
1434
- if (deltaLine !== 0) {
1435
- const targetVisualLine = currentVisualLine + deltaLine;
1436
- if (targetVisualLine >= 0 && targetVisualLine < visualLines.length) {
1437
- this.moveToVisualLine(visualLines, currentVisualLine, targetVisualLine);
1438
- }
1439
- }
1440
- if (deltaCol !== 0) {
1441
- const currentLine = this.state.lines[this.state.cursorLine] || "";
1442
- if (deltaCol > 0) {
1443
- // Moving right - move by one grapheme (handles emojis, combining characters, etc.)
1444
- if (this.state.cursorCol < currentLine.length) {
1445
- const afterCursor = currentLine.slice(this.state.cursorCol);
1446
- const graphemes = [...this.segment(afterCursor, "grapheme")];
1447
- const firstGrapheme = graphemes[0];
1448
- this.setCursorCol(this.state.cursorCol + (firstGrapheme ? firstGrapheme.segment.length : 1));
1449
- }
1450
- else if (this.state.cursorLine < this.state.lines.length - 1) {
1451
- // Wrap to start of next logical line
1452
- this.state.cursorLine++;
1453
- this.setCursorCol(0);
1454
- }
1455
- else {
1456
- // At end of last line - can't move, but set preferredVisualCol for up/down navigation
1457
- const currentVL = visualLines[currentVisualLine];
1458
- if (currentVL) {
1459
- this.preferredVisualCol = this.state.cursorCol - currentVL.startCol;
1460
- }
1461
- }
1462
- }
1463
- else {
1464
- // Moving left - move by one grapheme (handles emojis, combining characters, etc.)
1465
- if (this.state.cursorCol > 0) {
1466
- const beforeCursor = currentLine.slice(0, this.state.cursorCol);
1467
- const graphemes = [...this.segment(beforeCursor, "grapheme")];
1468
- const lastGrapheme = graphemes[graphemes.length - 1];
1469
- this.setCursorCol(this.state.cursorCol - (lastGrapheme ? lastGrapheme.segment.length : 1));
1470
- }
1471
- else if (this.state.cursorLine > 0) {
1472
- // Wrap to end of previous logical line
1473
- this.state.cursorLine--;
1474
- const prevLine = this.state.lines[this.state.cursorLine] || "";
1475
- this.setCursorCol(prevLine.length);
1476
- }
1477
- }
1478
- }
1505
+ this.moveCursorVertically(visualLines, currentVisualLine, deltaLine);
1506
+ this.moveCursorHorizontally(visualLines, currentVisualLine, deltaCol);
1479
1507
  }
1480
1508
  /**
1481
1509
  * Scroll by a page (direction: -1 for up, 1 for down).
@@ -1609,14 +1637,16 @@ export class Editor {
1609
1637
  }
1610
1638
  }
1611
1639
  pushUndoSnapshot() {
1612
- this.undoStack.push(this.state);
1640
+ this.undoStack.push({ state: this.state, pastes: this.pastes, pasteCounter: this.pasteCounter });
1613
1641
  }
1614
1642
  undo() {
1615
1643
  this.historyIndex = -1; // Exit history browsing mode
1616
1644
  const snapshot = this.undoStack.pop();
1617
1645
  if (!snapshot)
1618
1646
  return;
1619
- Object.assign(this.state, snapshot);
1647
+ Object.assign(this.state, snapshot.state);
1648
+ this.pastes = snapshot.pastes;
1649
+ this.pasteCounter = snapshot.pasteCounter;
1620
1650
  this.lastAction = null;
1621
1651
  this.preferredVisualCol = null;
1622
1652
  if (this.onChange) {