lonny-agent 0.1.2 → 0.1.9

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 (216) hide show
  1. package/.kilo/plans/1780064105789-mighty-pixel.md +171 -0
  2. package/.lonny/fix-new-command-session-cleanup.md +65 -0
  3. package/.lonny/tui-autocomplete-above.md +62 -0
  4. package/dist/agent/__tests__/compaction.test.js +6 -4
  5. package/dist/agent/__tests__/compaction.test.js.map +1 -1
  6. package/dist/agent/llm.d.ts +1 -1
  7. package/dist/agent/llm.d.ts.map +1 -1
  8. package/dist/agent/prompt-builder.d.ts.map +1 -1
  9. package/dist/agent/prompt-builder.js +26 -8
  10. package/dist/agent/prompt-builder.js.map +1 -1
  11. package/dist/agent/providers/anthropic.d.ts +1 -1
  12. package/dist/agent/providers/anthropic.d.ts.map +1 -1
  13. package/dist/agent/providers/anthropic.js +2 -2
  14. package/dist/agent/providers/anthropic.js.map +1 -1
  15. package/dist/agent/providers/google.d.ts +1 -1
  16. package/dist/agent/providers/google.d.ts.map +1 -1
  17. package/dist/agent/providers/google.js +2 -1
  18. package/dist/agent/providers/google.js.map +1 -1
  19. package/dist/agent/providers/ollama.d.ts +1 -1
  20. package/dist/agent/providers/ollama.d.ts.map +1 -1
  21. package/dist/agent/providers/ollama.js +2 -1
  22. package/dist/agent/providers/ollama.js.map +1 -1
  23. package/dist/agent/providers/openai.d.ts +1 -1
  24. package/dist/agent/providers/openai.d.ts.map +1 -1
  25. package/dist/agent/providers/openai.js +2 -2
  26. package/dist/agent/providers/openai.js.map +1 -1
  27. package/dist/agent/session.d.ts +1 -0
  28. package/dist/agent/session.d.ts.map +1 -1
  29. package/dist/agent/session.js +8 -2
  30. package/dist/agent/session.js.map +1 -1
  31. package/dist/pi-tui/autocomplete.d.ts +54 -0
  32. package/dist/pi-tui/autocomplete.d.ts.map +1 -0
  33. package/dist/pi-tui/autocomplete.js +636 -0
  34. package/dist/pi-tui/autocomplete.js.map +1 -0
  35. package/dist/pi-tui/components/box.d.ts +22 -0
  36. package/dist/pi-tui/components/box.d.ts.map +1 -0
  37. package/dist/pi-tui/components/box.js +104 -0
  38. package/dist/pi-tui/components/box.js.map +1 -0
  39. package/dist/pi-tui/components/cancellable-loader.d.ts +22 -0
  40. package/dist/pi-tui/components/cancellable-loader.d.ts.map +1 -0
  41. package/dist/pi-tui/components/cancellable-loader.js +35 -0
  42. package/dist/pi-tui/components/cancellable-loader.js.map +1 -0
  43. package/dist/pi-tui/components/editor.d.ts +249 -0
  44. package/dist/pi-tui/components/editor.d.ts.map +1 -0
  45. package/dist/pi-tui/components/editor.js +1883 -0
  46. package/dist/pi-tui/components/editor.js.map +1 -0
  47. package/dist/pi-tui/components/image.d.ts +28 -0
  48. package/dist/pi-tui/components/image.d.ts.map +1 -0
  49. package/dist/pi-tui/components/image.js +90 -0
  50. package/dist/pi-tui/components/image.js.map +1 -0
  51. package/dist/pi-tui/components/input.d.ts +37 -0
  52. package/dist/pi-tui/components/input.d.ts.map +1 -0
  53. package/dist/pi-tui/components/input.js +382 -0
  54. package/dist/pi-tui/components/input.js.map +1 -0
  55. package/dist/pi-tui/components/loader.d.ts +31 -0
  56. package/dist/pi-tui/components/loader.d.ts.map +1 -0
  57. package/dist/pi-tui/components/loader.js +70 -0
  58. package/dist/pi-tui/components/loader.js.map +1 -0
  59. package/dist/pi-tui/components/markdown.d.ts +96 -0
  60. package/dist/pi-tui/components/markdown.d.ts.map +1 -0
  61. package/dist/pi-tui/components/markdown.js +647 -0
  62. package/dist/pi-tui/components/markdown.js.map +1 -0
  63. package/dist/pi-tui/components/select-list.d.ts +50 -0
  64. package/dist/pi-tui/components/select-list.d.ts.map +1 -0
  65. package/dist/pi-tui/components/select-list.js +167 -0
  66. package/dist/pi-tui/components/select-list.js.map +1 -0
  67. package/dist/pi-tui/components/settings-list.d.ts +50 -0
  68. package/dist/pi-tui/components/settings-list.d.ts.map +1 -0
  69. package/dist/pi-tui/components/settings-list.js +189 -0
  70. package/dist/pi-tui/components/settings-list.js.map +1 -0
  71. package/dist/pi-tui/components/spacer.d.ts +12 -0
  72. package/dist/pi-tui/components/spacer.d.ts.map +1 -0
  73. package/dist/pi-tui/components/spacer.js +23 -0
  74. package/dist/pi-tui/components/spacer.js.map +1 -0
  75. package/dist/pi-tui/components/text.d.ts +19 -0
  76. package/dist/pi-tui/components/text.d.ts.map +1 -0
  77. package/dist/pi-tui/components/text.js +91 -0
  78. package/dist/pi-tui/components/text.js.map +1 -0
  79. package/dist/pi-tui/components/truncated-text.d.ts +13 -0
  80. package/dist/pi-tui/components/truncated-text.d.ts.map +1 -0
  81. package/dist/pi-tui/components/truncated-text.js +51 -0
  82. package/dist/pi-tui/components/truncated-text.js.map +1 -0
  83. package/dist/pi-tui/editor-component.d.ts +39 -0
  84. package/dist/pi-tui/editor-component.d.ts.map +1 -0
  85. package/dist/pi-tui/editor-component.js +2 -0
  86. package/dist/pi-tui/editor-component.js.map +1 -0
  87. package/dist/pi-tui/fuzzy.d.ts +16 -0
  88. package/dist/pi-tui/fuzzy.d.ts.map +1 -0
  89. package/dist/pi-tui/fuzzy.js +110 -0
  90. package/dist/pi-tui/fuzzy.js.map +1 -0
  91. package/dist/pi-tui/index.d.ts +23 -0
  92. package/dist/pi-tui/index.d.ts.map +1 -0
  93. package/dist/pi-tui/index.js +32 -0
  94. package/dist/pi-tui/index.js.map +1 -0
  95. package/dist/pi-tui/keybindings.d.ts +193 -0
  96. package/dist/pi-tui/keybindings.d.ts.map +1 -0
  97. package/dist/pi-tui/keybindings.js +174 -0
  98. package/dist/pi-tui/keybindings.js.map +1 -0
  99. package/dist/pi-tui/keys.d.ts +184 -0
  100. package/dist/pi-tui/keys.d.ts.map +1 -0
  101. package/dist/pi-tui/keys.js +1182 -0
  102. package/dist/pi-tui/keys.js.map +1 -0
  103. package/dist/pi-tui/kill-ring.d.ts +28 -0
  104. package/dist/pi-tui/kill-ring.d.ts.map +1 -0
  105. package/dist/pi-tui/kill-ring.js +44 -0
  106. package/dist/pi-tui/kill-ring.js.map +1 -0
  107. package/dist/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node +0 -0
  108. package/dist/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node +0 -0
  109. package/dist/pi-tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node +0 -0
  110. package/dist/pi-tui/native/win32/prebuilds/win32-x64/win32-console-mode.node +0 -0
  111. package/dist/pi-tui/native-modifiers.d.ts +3 -0
  112. package/dist/pi-tui/native-modifiers.d.ts.map +1 -0
  113. package/dist/pi-tui/native-modifiers.js +53 -0
  114. package/dist/pi-tui/native-modifiers.js.map +1 -0
  115. package/dist/pi-tui/stdin-buffer.d.ts +50 -0
  116. package/dist/pi-tui/stdin-buffer.d.ts.map +1 -0
  117. package/dist/pi-tui/stdin-buffer.js +361 -0
  118. package/dist/pi-tui/stdin-buffer.js.map +1 -0
  119. package/dist/pi-tui/terminal-image.d.ts +90 -0
  120. package/dist/pi-tui/terminal-image.d.ts.map +1 -0
  121. package/dist/pi-tui/terminal-image.js +343 -0
  122. package/dist/pi-tui/terminal-image.js.map +1 -0
  123. package/dist/pi-tui/terminal.d.ts +113 -0
  124. package/dist/pi-tui/terminal.d.ts.map +1 -0
  125. package/dist/pi-tui/terminal.js +478 -0
  126. package/dist/pi-tui/terminal.js.map +1 -0
  127. package/dist/pi-tui/tui.d.ts +227 -0
  128. package/dist/pi-tui/tui.d.ts.map +1 -0
  129. package/dist/pi-tui/tui.js +1091 -0
  130. package/dist/pi-tui/tui.js.map +1 -0
  131. package/dist/pi-tui/undo-stack.d.ts +17 -0
  132. package/dist/pi-tui/undo-stack.d.ts.map +1 -0
  133. package/dist/pi-tui/undo-stack.js +25 -0
  134. package/dist/pi-tui/undo-stack.js.map +1 -0
  135. package/dist/pi-tui/utils.d.ts +84 -0
  136. package/dist/pi-tui/utils.d.ts.map +1 -0
  137. package/dist/pi-tui/utils.js +1024 -0
  138. package/dist/pi-tui/utils.js.map +1 -0
  139. package/dist/pi-tui/word-navigation.d.ts +25 -0
  140. package/dist/pi-tui/word-navigation.d.ts.map +1 -0
  141. package/dist/pi-tui/word-navigation.js +98 -0
  142. package/dist/pi-tui/word-navigation.js.map +1 -0
  143. package/dist/tools/__tests__/edit.test.js +85 -0
  144. package/dist/tools/__tests__/edit.test.js.map +1 -1
  145. package/dist/tools/edit.d.ts.map +1 -1
  146. package/dist/tools/edit.js +23 -1
  147. package/dist/tools/edit.js.map +1 -1
  148. package/dist/tui/components.d.ts +1 -1
  149. package/dist/tui/components.d.ts.map +1 -1
  150. package/dist/tui/components.js +1 -1
  151. package/dist/tui/components.js.map +1 -1
  152. package/dist/tui/editor-patch.d.ts +13 -0
  153. package/dist/tui/editor-patch.d.ts.map +1 -0
  154. package/dist/tui/editor-patch.js +41 -0
  155. package/dist/tui/editor-patch.js.map +1 -0
  156. package/dist/tui/index.d.ts.map +1 -1
  157. package/dist/tui/index.js +15 -3
  158. package/dist/tui/index.js.map +1 -1
  159. package/dist/web/index.d.ts.map +1 -1
  160. package/dist/web/index.js +9 -2
  161. package/dist/web/index.js.map +1 -1
  162. package/dist/web/public/app.js +65 -4
  163. package/dist/web/public/style.css +49 -0
  164. package/dist/web/session-bridge.d.ts +0 -4
  165. package/dist/web/session-bridge.d.ts.map +1 -1
  166. package/dist/web/session-bridge.js +16 -2
  167. package/dist/web/session-bridge.js.map +1 -1
  168. package/package.json +5 -3
  169. package/src/agent/__tests__/compaction.test.ts +6 -4
  170. package/src/agent/llm.ts +5 -1
  171. package/src/agent/prompt-builder.ts +27 -8
  172. package/src/agent/providers/anthropic.ts +15 -8
  173. package/src/agent/providers/google.ts +6 -1
  174. package/src/agent/providers/ollama.ts +6 -1
  175. package/src/agent/providers/openai.ts +21 -12
  176. package/src/agent/session.ts +12 -2
  177. package/src/pi-tui/autocomplete.ts +808 -0
  178. package/src/pi-tui/components/box.ts +137 -0
  179. package/src/pi-tui/components/cancellable-loader.ts +40 -0
  180. package/src/pi-tui/components/editor.ts +2321 -0
  181. package/src/pi-tui/components/image.ts +130 -0
  182. package/src/pi-tui/components/input.ts +456 -0
  183. package/src/pi-tui/components/loader.ts +93 -0
  184. package/src/pi-tui/components/markdown.ts +839 -0
  185. package/src/pi-tui/components/select-list.ts +263 -0
  186. package/src/pi-tui/components/settings-list.ts +264 -0
  187. package/src/pi-tui/components/spacer.ts +28 -0
  188. package/src/pi-tui/components/text.ts +113 -0
  189. package/src/pi-tui/components/truncated-text.ts +65 -0
  190. package/src/pi-tui/editor-component.ts +74 -0
  191. package/src/pi-tui/fuzzy.ts +137 -0
  192. package/src/pi-tui/index.ts +115 -0
  193. package/src/pi-tui/keybindings.ts +245 -0
  194. package/src/pi-tui/keys.ts +1440 -0
  195. package/src/pi-tui/kill-ring.ts +46 -0
  196. package/src/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node +0 -0
  197. package/src/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node +0 -0
  198. package/src/pi-tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node +0 -0
  199. package/src/pi-tui/native/win32/prebuilds/win32-x64/win32-console-mode.node +0 -0
  200. package/src/pi-tui/native-modifiers.ts +65 -0
  201. package/src/pi-tui/stdin-buffer.ts +434 -0
  202. package/src/pi-tui/terminal-image.ts +469 -0
  203. package/src/pi-tui/terminal.ts +594 -0
  204. package/src/pi-tui/tui.ts +1376 -0
  205. package/src/pi-tui/undo-stack.ts +28 -0
  206. package/src/pi-tui/utils.ts +1176 -0
  207. package/src/pi-tui/word-navigation.ts +127 -0
  208. package/src/tools/__tests__/edit.test.ts +91 -0
  209. package/src/tools/edit.ts +27 -1
  210. package/src/tui/components.ts +2 -2
  211. package/src/tui/index.ts +24 -10
  212. package/src/web/index.ts +9 -2
  213. package/src/web/public/app.js +65 -4
  214. package/src/web/public/style.css +49 -0
  215. package/src/web/session-bridge.ts +13 -2
  216. package/tsconfig.json +2 -2
@@ -0,0 +1,2321 @@
1
+ import type { AutocompleteProvider, AutocompleteSuggestions } from '../autocomplete.js'
2
+ import { getKeybindings } from '../keybindings.js'
3
+ import { decodePrintableKey, matchesKey } from '../keys.js'
4
+ import { KillRing } from '../kill-ring.js'
5
+ import { type Component, CURSOR_MARKER, type Focusable, type TUI } from '../tui.js'
6
+ import { UndoStack } from '../undo-stack.js'
7
+ import {
8
+ getGraphemeSegmenter,
9
+ getWordSegmenter,
10
+ isWhitespaceChar,
11
+ truncateToWidth,
12
+ visibleWidth,
13
+ } from '../utils.js'
14
+ import { findWordBackward, findWordForward } from '../word-navigation.js'
15
+ import { SelectList, type SelectListLayoutOptions, type SelectListTheme } from './select-list.js'
16
+
17
+ const graphemeSegmenter = getGraphemeSegmenter()
18
+ const wordSegmenter = getWordSegmenter()
19
+
20
+ /** Regex matching paste markers like `[paste #1 +123 lines]` or `[paste #2 1234 chars]`. */
21
+ const PASTE_MARKER_REGEX = /\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]/g
22
+
23
+ /** Non-global version for single-segment testing. */
24
+ const PASTE_MARKER_SINGLE = /^\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]$/
25
+
26
+ /** Check if a segment is a paste marker (i.e. was merged by segmentWithMarkers). */
27
+ function isPasteMarker(segment: string): boolean {
28
+ return segment.length >= 10 && PASTE_MARKER_SINGLE.test(segment)
29
+ }
30
+
31
+ /**
32
+ * A segmenter that wraps Intl.Segmenter and merges graphemes that fall
33
+ * within paste markers into single atomic segments. This makes cursor
34
+ * movement, deletion, word-wrap, etc. treat paste markers as single units.
35
+ *
36
+ * Only markers whose numeric ID exists in `validIds` are merged.
37
+ */
38
+ function segmentWithMarkers(
39
+ text: string,
40
+ baseSegmenter: Intl.Segmenter,
41
+ validIds: Set<number>,
42
+ ): Iterable<Intl.SegmentData> {
43
+ // Fast path: no paste markers in the text or no valid IDs.
44
+ if (validIds.size === 0 || !text.includes('[paste #')) {
45
+ return baseSegmenter.segment(text)
46
+ }
47
+
48
+ // Find all marker spans with valid IDs.
49
+ const markers: Array<{ start: number; end: number }> = []
50
+ for (const m of text.matchAll(PASTE_MARKER_REGEX)) {
51
+ const id = Number.parseInt(m[1]!, 10)
52
+ if (!validIds.has(id)) continue
53
+ markers.push({ start: m.index, end: m.index + m[0].length })
54
+ }
55
+ if (markers.length === 0) {
56
+ return baseSegmenter.segment(text)
57
+ }
58
+
59
+ // Build merged segment list.
60
+ const baseSegments = baseSegmenter.segment(text)
61
+ const result: Intl.SegmentData[] = []
62
+ let markerIdx = 0
63
+
64
+ for (const seg of baseSegments) {
65
+ // Skip past markers that are entirely before this segment.
66
+ while (markerIdx < markers.length && markers[markerIdx]!.end <= seg.index) {
67
+ markerIdx++
68
+ }
69
+
70
+ const marker = markerIdx < markers.length ? markers[markerIdx]! : null
71
+
72
+ if (marker && seg.index >= marker.start && seg.index < marker.end) {
73
+ // This segment falls inside a marker.
74
+ // If this is the first segment of the marker, emit a merged segment.
75
+ if (seg.index === marker.start) {
76
+ const markerText = text.slice(marker.start, marker.end)
77
+ result.push({
78
+ segment: markerText,
79
+ index: marker.start,
80
+ input: text,
81
+ })
82
+ }
83
+ // Otherwise skip (already merged into the first segment).
84
+ } else {
85
+ result.push(seg)
86
+ }
87
+ }
88
+
89
+ return result
90
+ }
91
+
92
+ /**
93
+ * Represents a chunk of text for word-wrap layout.
94
+ * Tracks both the text content and its position in the original line.
95
+ */
96
+ export interface TextChunk {
97
+ text: string
98
+ startIndex: number
99
+ endIndex: number
100
+ }
101
+
102
+ /**
103
+ * Split a line into word-wrapped chunks.
104
+ * Wraps at word boundaries when possible, falling back to character-level
105
+ * wrapping for words longer than the available width.
106
+ *
107
+ * @param line - The text line to wrap
108
+ * @param maxWidth - Maximum visible width per chunk
109
+ * @param preSegmented - Optional pre-segmented graphemes (e.g. with paste-marker awareness).
110
+ * When omitted the default Intl.Segmenter is used.
111
+ * @returns Array of chunks with text and position information
112
+ */
113
+ export function wordWrapLine(
114
+ line: string,
115
+ maxWidth: number,
116
+ preSegmented?: Intl.SegmentData[],
117
+ ): TextChunk[] {
118
+ if (!line || maxWidth <= 0) {
119
+ return [{ text: '', startIndex: 0, endIndex: 0 }]
120
+ }
121
+
122
+ const lineWidth = visibleWidth(line)
123
+ if (lineWidth <= maxWidth) {
124
+ return [{ text: line, startIndex: 0, endIndex: line.length }]
125
+ }
126
+
127
+ const chunks: TextChunk[] = []
128
+ const segments = preSegmented ?? [...graphemeSegmenter.segment(line)]
129
+
130
+ let currentWidth = 0
131
+ let chunkStart = 0
132
+
133
+ // Wrap opportunity: the position after the last whitespace before a non-whitespace
134
+ // grapheme, i.e. where a line break is allowed.
135
+ let wrapOppIndex = -1
136
+ let wrapOppWidth = 0
137
+
138
+ for (let i = 0; i < segments.length; i++) {
139
+ const seg = segments[i]!
140
+ const grapheme = seg.segment
141
+ const gWidth = visibleWidth(grapheme)
142
+ const charIndex = seg.index
143
+ const isWs = !isPasteMarker(grapheme) && isWhitespaceChar(grapheme)
144
+
145
+ // Overflow check before advancing.
146
+ if (currentWidth + gWidth > maxWidth) {
147
+ if (wrapOppIndex >= 0 && currentWidth - wrapOppWidth + gWidth <= maxWidth) {
148
+ // Backtrack to last wrap opportunity (the remaining content
149
+ // plus the current grapheme still fits within maxWidth).
150
+ chunks.push({
151
+ text: line.slice(chunkStart, wrapOppIndex),
152
+ startIndex: chunkStart,
153
+ endIndex: wrapOppIndex,
154
+ })
155
+ chunkStart = wrapOppIndex
156
+ currentWidth -= wrapOppWidth
157
+ } else if (chunkStart < charIndex) {
158
+ // No viable wrap opportunity: force-break at current position.
159
+ // This also handles the case where backtracking to a word
160
+ // boundary wouldn't help because the remaining content plus
161
+ // the current grapheme (e.g. a wide character) still exceeds
162
+ // maxWidth.
163
+ chunks.push({
164
+ text: line.slice(chunkStart, charIndex),
165
+ startIndex: chunkStart,
166
+ endIndex: charIndex,
167
+ })
168
+ chunkStart = charIndex
169
+ currentWidth = 0
170
+ }
171
+ wrapOppIndex = -1
172
+ }
173
+
174
+ if (gWidth > maxWidth) {
175
+ // Single atomic segment wider than maxWidth (e.g. paste marker
176
+ // in a narrow terminal). Re-wrap it at grapheme granularity.
177
+
178
+ // The segment remains logically atomic for cursor
179
+ // movement / editing — the split is purely visual for word-wrap layout.
180
+ const subChunks = wordWrapLine(grapheme, maxWidth)
181
+ for (let j = 0; j < subChunks.length - 1; j++) {
182
+ const sc = subChunks[j]!
183
+ chunks.push({
184
+ text: sc.text,
185
+ startIndex: charIndex + sc.startIndex,
186
+ endIndex: charIndex + sc.endIndex,
187
+ })
188
+ }
189
+ const last = subChunks[subChunks.length - 1]!
190
+ chunkStart = charIndex + last.startIndex
191
+ currentWidth = visibleWidth(last.text)
192
+ wrapOppIndex = -1
193
+ continue
194
+ }
195
+
196
+ // Advance.
197
+ currentWidth += gWidth
198
+
199
+ // Record wrap opportunity: whitespace followed by non-whitespace.
200
+ // Multiple spaces join (no break between them); the break point is
201
+ // after the last space before the next word.
202
+ const next = segments[i + 1]
203
+ if (isWs && next && (isPasteMarker(next.segment) || !isWhitespaceChar(next.segment))) {
204
+ wrapOppIndex = next.index
205
+ wrapOppWidth = currentWidth
206
+ }
207
+ }
208
+
209
+ // Push final chunk.
210
+ chunks.push({ text: line.slice(chunkStart), startIndex: chunkStart, endIndex: line.length })
211
+
212
+ return chunks
213
+ }
214
+
215
+ // Kitty CSI-u sequences for printable keys, including optional shifted/base codepoints.
216
+ interface EditorState {
217
+ lines: string[]
218
+ cursorLine: number
219
+ cursorCol: number
220
+ }
221
+
222
+ interface LayoutLine {
223
+ text: string
224
+ hasCursor: boolean
225
+ cursorPos?: number
226
+ }
227
+
228
+ export interface EditorTheme {
229
+ borderColor: (str: string) => string
230
+ selectList: SelectListTheme
231
+ }
232
+
233
+ export interface EditorOptions {
234
+ paddingX?: number
235
+ autocompleteMaxVisible?: number
236
+ }
237
+
238
+ const SLASH_COMMAND_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
239
+ minPrimaryColumnWidth: 12,
240
+ maxPrimaryColumnWidth: 32,
241
+ }
242
+
243
+ const ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS = 20
244
+
245
+ export class Editor implements Component, Focusable {
246
+ private state: EditorState = {
247
+ lines: [''],
248
+ cursorLine: 0,
249
+ cursorCol: 0,
250
+ }
251
+
252
+ /** Focusable interface - set by TUI when focus changes */
253
+ focused: boolean = false
254
+
255
+ protected tui: TUI
256
+ private theme: EditorTheme
257
+ private paddingX: number = 0
258
+
259
+ // Store last render width for cursor navigation
260
+ private lastWidth: number = 80
261
+
262
+ // Vertical scrolling support
263
+ private scrollOffset: number = 0
264
+
265
+ // Border color (can be changed dynamically)
266
+ public borderColor: (str: string) => string
267
+
268
+ // Autocomplete support
269
+ private autocompleteProvider?: AutocompleteProvider
270
+ private autocompleteList?: SelectList
271
+ private autocompleteState: 'regular' | 'force' | null = null
272
+ private autocompletePrefix: string = ''
273
+ private autocompleteMaxVisible: number = 5
274
+ private autocompleteAbort?: AbortController
275
+ private autocompleteDebounceTimer?: ReturnType<typeof setTimeout>
276
+ private autocompleteRequestTask: Promise<void> = Promise.resolve()
277
+ private autocompleteStartToken: number = 0
278
+ private autocompleteRequestId: number = 0
279
+
280
+ // Paste tracking for large pastes
281
+ private pastes: Map<number, string> = new Map()
282
+ private pasteCounter: number = 0
283
+
284
+ // Bracketed paste mode buffering
285
+ private pasteBuffer: string = ''
286
+ private isInPaste: boolean = false
287
+
288
+ // Prompt history for up/down navigation
289
+ private history: string[] = []
290
+ private historyIndex: number = -1 // -1 = not browsing, 0 = most recent, 1 = older, etc.
291
+
292
+ // Kill ring for Emacs-style kill/yank operations
293
+ private killRing = new KillRing()
294
+ private lastAction: 'kill' | 'yank' | 'type-word' | null = null
295
+
296
+ // Character jump mode
297
+ private jumpMode: 'forward' | 'backward' | null = null
298
+
299
+ // Preferred visual column for vertical cursor movement (sticky column)
300
+ private preferredVisualCol: number | null = null
301
+
302
+ // When the cursor is snapped to the start of an atomic segment, e.g. a
303
+ // paste marker, cursorCol no longer reflects where the cursor would have
304
+ // landed. This field stores the pre-snap cursorCol so that the next
305
+ // vertical move can resolve it to a visual column on whatever VL it belongs
306
+ // to.
307
+ private snappedFromCursorCol: number | null = null
308
+
309
+ // Undo support
310
+ private undoStack = new UndoStack<EditorState>()
311
+
312
+ public onSubmit?: (text: string) => void
313
+ public onChange?: (text: string) => void
314
+ public disableSubmit: boolean = false
315
+
316
+ constructor(tui: TUI, theme: EditorTheme, options: EditorOptions = {}) {
317
+ this.tui = tui
318
+ this.theme = theme
319
+ this.borderColor = theme.borderColor
320
+ const paddingX = options.paddingX ?? 0
321
+ this.paddingX = Number.isFinite(paddingX) ? Math.max(0, Math.floor(paddingX)) : 0
322
+ const maxVisible = options.autocompleteMaxVisible ?? 5
323
+ this.autocompleteMaxVisible = Number.isFinite(maxVisible)
324
+ ? Math.max(3, Math.min(20, Math.floor(maxVisible)))
325
+ : 5
326
+ }
327
+
328
+ /** Set of currently valid paste IDs, for marker-aware segmentation. */
329
+ private validPasteIds(): Set<number> {
330
+ return new Set(this.pastes.keys())
331
+ }
332
+
333
+ /** Segment text with paste-marker awareness, only merging markers with valid IDs. */
334
+ private segment(text: string, mode: 'word' | 'grapheme'): Iterable<Intl.SegmentData> {
335
+ return segmentWithMarkers(
336
+ text,
337
+ mode === 'word' ? wordSegmenter : graphemeSegmenter,
338
+ this.validPasteIds(),
339
+ )
340
+ }
341
+
342
+ getPaddingX(): number {
343
+ return this.paddingX
344
+ }
345
+
346
+ setPaddingX(padding: number): void {
347
+ const newPadding = Number.isFinite(padding) ? Math.max(0, Math.floor(padding)) : 0
348
+ if (this.paddingX !== newPadding) {
349
+ this.paddingX = newPadding
350
+ this.tui.requestRender()
351
+ }
352
+ }
353
+
354
+ getAutocompleteMaxVisible(): number {
355
+ return this.autocompleteMaxVisible
356
+ }
357
+
358
+ setAutocompleteMaxVisible(maxVisible: number): void {
359
+ const newMaxVisible = Number.isFinite(maxVisible)
360
+ ? Math.max(3, Math.min(20, Math.floor(maxVisible)))
361
+ : 5
362
+ if (this.autocompleteMaxVisible !== newMaxVisible) {
363
+ this.autocompleteMaxVisible = newMaxVisible
364
+ this.tui.requestRender()
365
+ }
366
+ }
367
+
368
+ setAutocompleteProvider(provider: AutocompleteProvider): void {
369
+ this.cancelAutocomplete()
370
+ this.autocompleteProvider = provider
371
+ }
372
+
373
+ /**
374
+ * Add a prompt to history for up/down arrow navigation.
375
+ * Called after successful submission.
376
+ */
377
+ addToHistory(text: string): void {
378
+ const trimmed = text.trim()
379
+ if (!trimmed) return
380
+ // Don't add consecutive duplicates
381
+ if (this.history.length > 0 && this.history[0] === trimmed) return
382
+ this.history.unshift(trimmed)
383
+ // Limit history size
384
+ if (this.history.length > 100) {
385
+ this.history.pop()
386
+ }
387
+ }
388
+
389
+ private isEditorEmpty(): boolean {
390
+ return this.state.lines.length === 1 && this.state.lines[0] === ''
391
+ }
392
+
393
+ private isOnFirstVisualLine(): boolean {
394
+ const visualLines = this.buildVisualLineMap(this.lastWidth)
395
+ const currentVisualLine = this.findCurrentVisualLine(visualLines)
396
+ return currentVisualLine === 0
397
+ }
398
+
399
+ private isOnLastVisualLine(): boolean {
400
+ const visualLines = this.buildVisualLineMap(this.lastWidth)
401
+ const currentVisualLine = this.findCurrentVisualLine(visualLines)
402
+ return currentVisualLine === visualLines.length - 1
403
+ }
404
+
405
+ private navigateHistory(direction: 1 | -1): void {
406
+ this.lastAction = null
407
+ if (this.history.length === 0) return
408
+
409
+ const newIndex = this.historyIndex - direction // Up(-1) increases index, Down(1) decreases
410
+ if (newIndex < -1 || newIndex >= this.history.length) return
411
+
412
+ // Capture state when first entering history browsing mode
413
+ if (this.historyIndex === -1 && newIndex >= 0) {
414
+ this.pushUndoSnapshot()
415
+ }
416
+
417
+ this.historyIndex = newIndex
418
+
419
+ if (this.historyIndex === -1) {
420
+ // Returned to "current" state - clear editor
421
+ this.setTextInternal('')
422
+ } else {
423
+ this.setTextInternal(this.history[this.historyIndex] || '')
424
+ }
425
+ }
426
+
427
+ /** Internal setText that doesn't reset history state - used by navigateHistory */
428
+ private setTextInternal(text: string): void {
429
+ const lines = text.split('\n')
430
+ this.state.lines = lines.length === 0 ? [''] : lines
431
+ this.state.cursorLine = this.state.lines.length - 1
432
+ this.setCursorCol(this.state.lines[this.state.cursorLine]?.length || 0)
433
+ // Reset scroll - render() will adjust to show cursor
434
+ this.scrollOffset = 0
435
+
436
+ if (this.onChange) {
437
+ this.onChange(this.getText())
438
+ }
439
+ }
440
+
441
+ invalidate(): void {
442
+ // No cached state to invalidate currently
443
+ }
444
+
445
+ render(width: number): string[] {
446
+ const maxPadding = Math.max(0, Math.floor((width - 1) / 2))
447
+ const paddingX = Math.min(this.paddingX, maxPadding)
448
+ const contentWidth = Math.max(1, width - paddingX * 2)
449
+
450
+ // Layout width: with padding the cursor can overflow into it,
451
+ // without padding we reserve 1 column for the cursor.
452
+ const layoutWidth = Math.max(1, contentWidth - (paddingX ? 0 : 1))
453
+
454
+ // Store for cursor navigation (must match wrapping width)
455
+ this.lastWidth = layoutWidth
456
+
457
+ const horizontal = this.borderColor('─')
458
+
459
+ // Layout the text
460
+ const layoutLines = this.layoutText(layoutWidth)
461
+
462
+ // Calculate max visible lines: 30% of terminal height, minimum 5 lines
463
+ const terminalRows = this.tui.terminal.rows
464
+ const maxVisibleLines = Math.max(5, Math.floor(terminalRows * 0.3))
465
+
466
+ // Find the cursor line index in layoutLines
467
+ let cursorLineIndex = layoutLines.findIndex(line => line.hasCursor)
468
+ if (cursorLineIndex === -1) cursorLineIndex = 0
469
+
470
+ // Adjust scroll offset to keep cursor visible
471
+ if (cursorLineIndex < this.scrollOffset) {
472
+ this.scrollOffset = cursorLineIndex
473
+ } else if (cursorLineIndex >= this.scrollOffset + maxVisibleLines) {
474
+ this.scrollOffset = cursorLineIndex - maxVisibleLines + 1
475
+ }
476
+
477
+ // Clamp scroll offset to valid range
478
+ const maxScrollOffset = Math.max(0, layoutLines.length - maxVisibleLines)
479
+ this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxScrollOffset))
480
+
481
+ // Get visible lines slice
482
+ const visibleLines = layoutLines.slice(this.scrollOffset, this.scrollOffset + maxVisibleLines)
483
+
484
+ const result: string[] = []
485
+ const leftPadding = ' '.repeat(paddingX)
486
+ const rightPadding = leftPadding
487
+
488
+ // Render top border (with scroll indicator if scrolled down)
489
+ if (this.scrollOffset > 0) {
490
+ const indicator = `─── ↑ ${this.scrollOffset} more `
491
+ const remaining = width - visibleWidth(indicator)
492
+ if (remaining >= 0) {
493
+ result.push(this.borderColor(indicator + '─'.repeat(remaining)))
494
+ } else {
495
+ result.push(this.borderColor(truncateToWidth(indicator, width)))
496
+ }
497
+ } else {
498
+ result.push(horizontal.repeat(width))
499
+ }
500
+
501
+ // Render each visible layout line
502
+ // Emit hardware cursor marker only when focused and not showing autocomplete
503
+ const emitCursorMarker = this.focused && !this.autocompleteState
504
+
505
+ for (const layoutLine of visibleLines) {
506
+ let displayText = layoutLine.text
507
+ let lineVisibleWidth = visibleWidth(layoutLine.text)
508
+ let cursorInPadding = false
509
+
510
+ // Add cursor if this line has it
511
+ if (layoutLine.hasCursor && layoutLine.cursorPos !== undefined) {
512
+ const before = displayText.slice(0, layoutLine.cursorPos)
513
+ const after = displayText.slice(layoutLine.cursorPos)
514
+
515
+ // Hardware cursor marker (zero-width, emitted before fake cursor for IME positioning)
516
+ const marker = emitCursorMarker ? CURSOR_MARKER : ''
517
+
518
+ if (after.length > 0) {
519
+ // Cursor is on a character (grapheme) - replace it with highlighted version
520
+ // Get the first grapheme from 'after'
521
+ const afterGraphemes = [...this.segment(after, 'grapheme')]
522
+ const firstGrapheme = afterGraphemes[0]?.segment || ''
523
+ const restAfter = after.slice(firstGrapheme.length)
524
+ const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`
525
+ displayText = before + marker + cursor + restAfter
526
+ // lineVisibleWidth stays the same - we're replacing, not adding
527
+ } else {
528
+ // Cursor is at the end - add highlighted space
529
+ const cursor = '\x1b[7m \x1b[0m'
530
+ displayText = before + marker + cursor
531
+ lineVisibleWidth = lineVisibleWidth + 1
532
+ // If cursor overflows content width into the padding, flag it
533
+ if (lineVisibleWidth > contentWidth && paddingX > 0) {
534
+ cursorInPadding = true
535
+ }
536
+ }
537
+ }
538
+
539
+ // Calculate padding based on actual visible width
540
+ const padding = ' '.repeat(Math.max(0, contentWidth - lineVisibleWidth))
541
+ const lineRightPadding = cursorInPadding ? rightPadding.slice(1) : rightPadding
542
+
543
+ // Render the line (no side borders, just horizontal lines above and below)
544
+ result.push(`${leftPadding}${displayText}${padding}${lineRightPadding}`)
545
+ }
546
+
547
+ // Render bottom border (with scroll indicator if more content below)
548
+ const linesBelow = layoutLines.length - (this.scrollOffset + visibleLines.length)
549
+ if (linesBelow > 0) {
550
+ const indicator = `─── ↓ ${linesBelow} more `
551
+ const remaining = width - visibleWidth(indicator)
552
+ result.push(this.borderColor(indicator + '─'.repeat(Math.max(0, remaining))))
553
+ } else {
554
+ result.push(horizontal.repeat(width))
555
+ }
556
+
557
+ // Autocomplete list rendered ABOVE the editor (matching web UI behavior)
558
+ if (this.autocompleteState && this.autocompleteList) {
559
+ const autocompleteResult = this.autocompleteList.render(contentWidth)
560
+ const acLines: string[] = []
561
+ for (const line of autocompleteResult) {
562
+ const lineWidth = visibleWidth(line)
563
+ const linePadding = ' '.repeat(Math.max(0, contentWidth - lineWidth))
564
+ acLines.push(`${leftPadding}${line}${linePadding}${rightPadding}`)
565
+ }
566
+ result.unshift(...acLines)
567
+ }
568
+
569
+ return result
570
+ }
571
+
572
+ handleInput(data: string): void {
573
+ const kb = getKeybindings()
574
+
575
+ // Handle character jump mode (awaiting next character to jump to)
576
+ if (this.jumpMode !== null) {
577
+ // Cancel if the hotkey is pressed again
578
+ if (
579
+ kb.matches(data, 'tui.editor.jumpForward') ||
580
+ kb.matches(data, 'tui.editor.jumpBackward')
581
+ ) {
582
+ this.jumpMode = null
583
+ return
584
+ }
585
+
586
+ const printable = decodePrintableKey(data) ?? (data.charCodeAt(0) >= 32 ? data : undefined)
587
+ if (printable !== undefined) {
588
+ // Printable character - perform the jump
589
+ const direction = this.jumpMode
590
+ this.jumpMode = null
591
+ this.jumpToChar(printable, direction)
592
+ return
593
+ }
594
+
595
+ // Control character - cancel and fall through to normal handling
596
+ this.jumpMode = null
597
+ }
598
+
599
+ // Handle bracketed paste mode
600
+ if (data.includes('\x1b[200~')) {
601
+ this.isInPaste = true
602
+ this.pasteBuffer = ''
603
+ data = data.replace('\x1b[200~', '')
604
+ }
605
+
606
+ if (this.isInPaste) {
607
+ this.pasteBuffer += data
608
+ const endIndex = this.pasteBuffer.indexOf('\x1b[201~')
609
+ if (endIndex !== -1) {
610
+ const pasteContent = this.pasteBuffer.substring(0, endIndex)
611
+ if (pasteContent.length > 0) {
612
+ this.handlePaste(pasteContent)
613
+ }
614
+ this.isInPaste = false
615
+ const remaining = this.pasteBuffer.substring(endIndex + 6)
616
+ this.pasteBuffer = ''
617
+ if (remaining.length > 0) {
618
+ this.handleInput(remaining)
619
+ }
620
+ return
621
+ }
622
+ return
623
+ }
624
+
625
+ // Ctrl+C - let parent handle (exit/clear)
626
+ if (kb.matches(data, 'tui.input.copy')) {
627
+ return
628
+ }
629
+
630
+ // Undo
631
+ if (kb.matches(data, 'tui.editor.undo')) {
632
+ this.undo()
633
+ return
634
+ }
635
+
636
+ // Handle autocomplete mode
637
+ if (this.autocompleteState && this.autocompleteList) {
638
+ if (kb.matches(data, 'tui.select.cancel')) {
639
+ this.cancelAutocomplete()
640
+ return
641
+ }
642
+
643
+ if (kb.matches(data, 'tui.select.up') || kb.matches(data, 'tui.select.down')) {
644
+ this.autocompleteList.handleInput(data)
645
+ return
646
+ }
647
+
648
+ if (kb.matches(data, 'tui.input.tab')) {
649
+ const selected = this.autocompleteList.getSelectedItem()
650
+ if (selected && this.autocompleteProvider) {
651
+ this.pushUndoSnapshot()
652
+ this.lastAction = null
653
+ const result = this.autocompleteProvider.applyCompletion(
654
+ this.state.lines,
655
+ this.state.cursorLine,
656
+ this.state.cursorCol,
657
+ selected,
658
+ this.autocompletePrefix,
659
+ )
660
+ this.state.lines = result.lines
661
+ this.state.cursorLine = result.cursorLine
662
+ this.setCursorCol(result.cursorCol)
663
+ this.cancelAutocomplete()
664
+ if (this.onChange) this.onChange(this.getText())
665
+ }
666
+ return
667
+ }
668
+
669
+ if (kb.matches(data, 'tui.select.confirm')) {
670
+ const selected = this.autocompleteList.getSelectedItem()
671
+ if (selected && this.autocompleteProvider) {
672
+ this.pushUndoSnapshot()
673
+ this.lastAction = null
674
+ const result = this.autocompleteProvider.applyCompletion(
675
+ this.state.lines,
676
+ this.state.cursorLine,
677
+ this.state.cursorCol,
678
+ selected,
679
+ this.autocompletePrefix,
680
+ )
681
+ this.state.lines = result.lines
682
+ this.state.cursorLine = result.cursorLine
683
+ this.setCursorCol(result.cursorCol)
684
+
685
+ if (this.autocompletePrefix.startsWith('/')) {
686
+ this.cancelAutocomplete()
687
+ // Fall through to submit
688
+ } else {
689
+ this.cancelAutocomplete()
690
+ if (this.onChange) this.onChange(this.getText())
691
+ return
692
+ }
693
+ }
694
+ }
695
+ }
696
+
697
+ // Tab - trigger completion
698
+ if (kb.matches(data, 'tui.input.tab') && !this.autocompleteState) {
699
+ this.handleTabCompletion()
700
+ return
701
+ }
702
+
703
+ // Deletion actions
704
+ if (kb.matches(data, 'tui.editor.deleteToLineEnd')) {
705
+ this.deleteToEndOfLine()
706
+ return
707
+ }
708
+ if (kb.matches(data, 'tui.editor.deleteToLineStart')) {
709
+ this.deleteToStartOfLine()
710
+ return
711
+ }
712
+ if (kb.matches(data, 'tui.editor.deleteWordBackward')) {
713
+ this.deleteWordBackwards()
714
+ return
715
+ }
716
+ if (kb.matches(data, 'tui.editor.deleteWordForward')) {
717
+ this.deleteWordForward()
718
+ return
719
+ }
720
+ if (kb.matches(data, 'tui.editor.deleteCharBackward') || matchesKey(data, 'shift+backspace')) {
721
+ this.handleBackspace()
722
+ return
723
+ }
724
+ if (kb.matches(data, 'tui.editor.deleteCharForward') || matchesKey(data, 'shift+delete')) {
725
+ this.handleForwardDelete()
726
+ return
727
+ }
728
+
729
+ // Kill ring actions
730
+ if (kb.matches(data, 'tui.editor.yank')) {
731
+ this.yank()
732
+ return
733
+ }
734
+ if (kb.matches(data, 'tui.editor.yankPop')) {
735
+ this.yankPop()
736
+ return
737
+ }
738
+
739
+ // Cursor movement actions
740
+ if (kb.matches(data, 'tui.editor.cursorLineStart')) {
741
+ this.moveToLineStart()
742
+ return
743
+ }
744
+ if (kb.matches(data, 'tui.editor.cursorLineEnd')) {
745
+ this.moveToLineEnd()
746
+ return
747
+ }
748
+ if (kb.matches(data, 'tui.editor.cursorWordLeft')) {
749
+ this.moveWordBackwards()
750
+ return
751
+ }
752
+ if (kb.matches(data, 'tui.editor.cursorWordRight')) {
753
+ this.moveWordForwards()
754
+ return
755
+ }
756
+
757
+ // New line
758
+ if (
759
+ kb.matches(data, 'tui.input.newLine') ||
760
+ (data.charCodeAt(0) === 10 && data.length > 1) ||
761
+ data === '\x1b\r' ||
762
+ data === '\x1b[13;2~' ||
763
+ (data.length > 1 && data.includes('\x1b') && data.includes('\r')) ||
764
+ (data === '\n' && data.length === 1)
765
+ ) {
766
+ if (this.shouldSubmitOnBackslashEnter(data, kb)) {
767
+ this.handleBackspace()
768
+ this.submitValue()
769
+ return
770
+ }
771
+ this.addNewLine()
772
+ return
773
+ }
774
+
775
+ // Submit (Enter)
776
+ if (kb.matches(data, 'tui.input.submit')) {
777
+ if (this.disableSubmit) return
778
+
779
+ // Workaround for terminals without Shift+Enter support:
780
+ // If char before cursor is \, delete it and insert newline instead of submitting.
781
+ const currentLine = this.state.lines[this.state.cursorLine] || ''
782
+ if (this.state.cursorCol > 0 && currentLine[this.state.cursorCol - 1] === '\\') {
783
+ this.handleBackspace()
784
+ this.addNewLine()
785
+ return
786
+ }
787
+
788
+ this.submitValue()
789
+ return
790
+ }
791
+
792
+ // Arrow key navigation (with history support)
793
+ if (kb.matches(data, 'tui.editor.cursorUp')) {
794
+ if (this.isEditorEmpty()) {
795
+ this.navigateHistory(-1)
796
+ } else if (this.historyIndex > -1 && this.isOnFirstVisualLine()) {
797
+ this.navigateHistory(-1)
798
+ } else if (this.isOnFirstVisualLine()) {
799
+ // Already at top - jump to start of line
800
+ this.moveToLineStart()
801
+ } else {
802
+ this.moveCursor(-1, 0)
803
+ }
804
+ return
805
+ }
806
+ if (kb.matches(data, 'tui.editor.cursorDown')) {
807
+ if (this.historyIndex > -1 && this.isOnLastVisualLine()) {
808
+ this.navigateHistory(1)
809
+ } else if (this.isOnLastVisualLine()) {
810
+ // Already at bottom - jump to end of line
811
+ this.moveToLineEnd()
812
+ } else {
813
+ this.moveCursor(1, 0)
814
+ }
815
+ return
816
+ }
817
+ if (kb.matches(data, 'tui.editor.cursorRight')) {
818
+ this.moveCursor(0, 1)
819
+ return
820
+ }
821
+ if (kb.matches(data, 'tui.editor.cursorLeft')) {
822
+ this.moveCursor(0, -1)
823
+ return
824
+ }
825
+
826
+ // Page up/down - scroll by page and move cursor
827
+ if (kb.matches(data, 'tui.editor.pageUp')) {
828
+ this.pageScroll(-1)
829
+ return
830
+ }
831
+ if (kb.matches(data, 'tui.editor.pageDown')) {
832
+ this.pageScroll(1)
833
+ return
834
+ }
835
+
836
+ // Character jump mode triggers
837
+ if (kb.matches(data, 'tui.editor.jumpForward')) {
838
+ this.jumpMode = 'forward'
839
+ return
840
+ }
841
+ if (kb.matches(data, 'tui.editor.jumpBackward')) {
842
+ this.jumpMode = 'backward'
843
+ return
844
+ }
845
+
846
+ // Shift+Space - insert regular space
847
+ if (matchesKey(data, 'shift+space')) {
848
+ this.insertCharacter(' ')
849
+ return
850
+ }
851
+
852
+ const printable = decodePrintableKey(data)
853
+ if (printable !== undefined) {
854
+ this.insertCharacter(printable)
855
+ return
856
+ }
857
+
858
+ // Regular characters
859
+ if (data.charCodeAt(0) >= 32) {
860
+ this.insertCharacter(data)
861
+ }
862
+ }
863
+
864
+ private layoutText(contentWidth: number): LayoutLine[] {
865
+ const layoutLines: LayoutLine[] = []
866
+
867
+ if (
868
+ this.state.lines.length === 0 ||
869
+ (this.state.lines.length === 1 && this.state.lines[0] === '')
870
+ ) {
871
+ // Empty editor
872
+ layoutLines.push({
873
+ text: '',
874
+ hasCursor: true,
875
+ cursorPos: 0,
876
+ })
877
+ return layoutLines
878
+ }
879
+
880
+ // Process each logical line
881
+ for (let i = 0; i < this.state.lines.length; i++) {
882
+ const line = this.state.lines[i] || ''
883
+ const isCurrentLine = i === this.state.cursorLine
884
+ const lineVisibleWidth = visibleWidth(line)
885
+
886
+ if (lineVisibleWidth <= contentWidth) {
887
+ // Line fits in one layout line
888
+ if (isCurrentLine) {
889
+ layoutLines.push({
890
+ text: line,
891
+ hasCursor: true,
892
+ cursorPos: this.state.cursorCol,
893
+ })
894
+ } else {
895
+ layoutLines.push({
896
+ text: line,
897
+ hasCursor: false,
898
+ })
899
+ }
900
+ } else {
901
+ // Line needs wrapping - use word-aware wrapping
902
+ const chunks = wordWrapLine(line, contentWidth, [...this.segment(line, 'grapheme')])
903
+
904
+ for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
905
+ const chunk = chunks[chunkIndex]
906
+ if (!chunk) continue
907
+
908
+ const cursorPos = this.state.cursorCol
909
+ const isLastChunk = chunkIndex === chunks.length - 1
910
+
911
+ // Determine if cursor is in this chunk
912
+ // For word-wrapped chunks, we need to handle the case where
913
+ // cursor might be in trimmed whitespace at end of chunk
914
+ let hasCursorInChunk = false
915
+ let adjustedCursorPos = 0
916
+
917
+ if (isCurrentLine) {
918
+ if (isLastChunk) {
919
+ // Last chunk: cursor belongs here if >= startIndex
920
+ hasCursorInChunk = cursorPos >= chunk.startIndex
921
+ adjustedCursorPos = cursorPos - chunk.startIndex
922
+ } else {
923
+ // Non-last chunk: cursor belongs here if in range [startIndex, endIndex)
924
+ // But we need to handle the visual position in the trimmed text
925
+ hasCursorInChunk = cursorPos >= chunk.startIndex && cursorPos < chunk.endIndex
926
+ if (hasCursorInChunk) {
927
+ adjustedCursorPos = cursorPos - chunk.startIndex
928
+ // Clamp to text length (in case cursor was in trimmed whitespace)
929
+ if (adjustedCursorPos > chunk.text.length) {
930
+ adjustedCursorPos = chunk.text.length
931
+ }
932
+ }
933
+ }
934
+ }
935
+
936
+ if (hasCursorInChunk) {
937
+ layoutLines.push({
938
+ text: chunk.text,
939
+ hasCursor: true,
940
+ cursorPos: adjustedCursorPos,
941
+ })
942
+ } else {
943
+ layoutLines.push({
944
+ text: chunk.text,
945
+ hasCursor: false,
946
+ })
947
+ }
948
+ }
949
+ }
950
+ }
951
+
952
+ return layoutLines
953
+ }
954
+
955
+ getText(): string {
956
+ return this.state.lines.join('\n')
957
+ }
958
+
959
+ private expandPasteMarkers(text: string): string {
960
+ let result = text
961
+ for (const [pasteId, pasteContent] of this.pastes) {
962
+ const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, 'g')
963
+ result = result.replace(markerRegex, () => pasteContent)
964
+ }
965
+ return result
966
+ }
967
+
968
+ /**
969
+ * Get text with paste markers expanded to their actual content.
970
+ * Use this when you need the full content (e.g., for external editor).
971
+ */
972
+ getExpandedText(): string {
973
+ return this.expandPasteMarkers(this.state.lines.join('\n'))
974
+ }
975
+
976
+ getLines(): string[] {
977
+ return [...this.state.lines]
978
+ }
979
+
980
+ getCursor(): { line: number; col: number } {
981
+ return { line: this.state.cursorLine, col: this.state.cursorCol }
982
+ }
983
+
984
+ setText(text: string): void {
985
+ this.cancelAutocomplete()
986
+ this.lastAction = null
987
+ this.historyIndex = -1 // Exit history browsing mode
988
+ const normalized = this.normalizeText(text)
989
+ // Push undo snapshot if content differs (makes programmatic changes undoable)
990
+ if (this.getText() !== normalized) {
991
+ this.pushUndoSnapshot()
992
+ }
993
+ this.setTextInternal(normalized)
994
+ }
995
+
996
+ /**
997
+ * Insert text at the current cursor position.
998
+ * Used for programmatic insertion (e.g., clipboard image markers).
999
+ * This is atomic for undo - single undo restores entire pre-insert state.
1000
+ */
1001
+ insertTextAtCursor(text: string): void {
1002
+ if (!text) return
1003
+ this.cancelAutocomplete()
1004
+ this.pushUndoSnapshot()
1005
+ this.lastAction = null
1006
+ this.historyIndex = -1
1007
+ this.insertTextAtCursorInternal(text)
1008
+ }
1009
+
1010
+ /**
1011
+ * Normalize text for editor storage:
1012
+ * - Normalize line endings (\r\n and \r -> \n)
1013
+ * - Expand tabs to 4 spaces
1014
+ */
1015
+ private normalizeText(text: string): string {
1016
+ return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').replace(/\t/g, ' ')
1017
+ }
1018
+
1019
+ /**
1020
+ * Internal text insertion at cursor. Handles single and multi-line text.
1021
+ * Does not push undo snapshots or trigger autocomplete - caller is responsible.
1022
+ * Normalizes line endings and calls onChange once at the end.
1023
+ */
1024
+ private insertTextAtCursorInternal(text: string): void {
1025
+ if (!text) return
1026
+
1027
+ // Normalize line endings and tabs
1028
+ const normalized = this.normalizeText(text)
1029
+ const insertedLines = normalized.split('\n')
1030
+
1031
+ const currentLine = this.state.lines[this.state.cursorLine] || ''
1032
+ const beforeCursor = currentLine.slice(0, this.state.cursorCol)
1033
+ const afterCursor = currentLine.slice(this.state.cursorCol)
1034
+
1035
+ if (insertedLines.length === 1) {
1036
+ // Single line - insert at cursor position
1037
+ this.state.lines[this.state.cursorLine] = beforeCursor + normalized + afterCursor
1038
+ this.setCursorCol(this.state.cursorCol + normalized.length)
1039
+ } else {
1040
+ // Multi-line insertion
1041
+ this.state.lines = [
1042
+ // All lines before current line
1043
+ ...this.state.lines.slice(0, this.state.cursorLine),
1044
+
1045
+ // The first inserted line merged with text before cursor
1046
+ beforeCursor + insertedLines[0],
1047
+
1048
+ // All middle inserted lines
1049
+ ...insertedLines.slice(1, -1),
1050
+
1051
+ // The last inserted line with text after cursor
1052
+ insertedLines[insertedLines.length - 1] + afterCursor,
1053
+
1054
+ // All lines after current line
1055
+ ...this.state.lines.slice(this.state.cursorLine + 1),
1056
+ ]
1057
+
1058
+ this.state.cursorLine += insertedLines.length - 1
1059
+ this.setCursorCol((insertedLines[insertedLines.length - 1] || '').length)
1060
+ }
1061
+
1062
+ if (this.onChange) {
1063
+ this.onChange(this.getText())
1064
+ }
1065
+ }
1066
+
1067
+ // All the editor methods from before...
1068
+ private insertCharacter(char: string, skipUndoCoalescing?: boolean): void {
1069
+ this.historyIndex = -1 // Exit history browsing mode
1070
+
1071
+ // Undo coalescing (fish-style):
1072
+ // - Consecutive word chars coalesce into one undo unit
1073
+ // - Space captures state before itself (so undo removes space+following word together)
1074
+ // - Each space is separately undoable
1075
+ // Skip coalescing when called from atomic operations (e.g., handlePaste)
1076
+ if (!skipUndoCoalescing) {
1077
+ if (isWhitespaceChar(char) || this.lastAction !== 'type-word') {
1078
+ this.pushUndoSnapshot()
1079
+ }
1080
+ this.lastAction = 'type-word'
1081
+ }
1082
+
1083
+ const line = this.state.lines[this.state.cursorLine] || ''
1084
+
1085
+ const before = line.slice(0, this.state.cursorCol)
1086
+ const after = line.slice(this.state.cursorCol)
1087
+
1088
+ this.state.lines[this.state.cursorLine] = before + char + after
1089
+ this.setCursorCol(this.state.cursorCol + char.length)
1090
+
1091
+ if (this.onChange) {
1092
+ this.onChange(this.getText())
1093
+ }
1094
+
1095
+ // Check if we should trigger or update autocomplete
1096
+ if (!this.autocompleteState) {
1097
+ // Auto-trigger for "/" at the start of a line (slash commands)
1098
+ if (char === '/' && this.isAtStartOfMessage()) {
1099
+ this.tryTriggerAutocomplete()
1100
+ }
1101
+ // Auto-trigger for symbol-based completion like @ or # at token boundaries
1102
+ else if (char === '@' || char === '#') {
1103
+ const currentLine = this.state.lines[this.state.cursorLine] || ''
1104
+ const textBeforeCursor = currentLine.slice(0, this.state.cursorCol)
1105
+ const charBeforeSymbol = textBeforeCursor[textBeforeCursor.length - 2]
1106
+ if (
1107
+ textBeforeCursor.length === 1 ||
1108
+ charBeforeSymbol === ' ' ||
1109
+ charBeforeSymbol === '\t'
1110
+ ) {
1111
+ this.tryTriggerAutocomplete()
1112
+ }
1113
+ }
1114
+ // Also auto-trigger when typing letters in a slash command or symbol completion context
1115
+ else if (/[a-zA-Z0-9.\-_]/.test(char)) {
1116
+ const currentLine = this.state.lines[this.state.cursorLine] || ''
1117
+ const textBeforeCursor = currentLine.slice(0, this.state.cursorCol)
1118
+ // Check if we're in a slash command (with or without space for arguments)
1119
+ if (this.isInSlashCommandContext(textBeforeCursor)) {
1120
+ this.tryTriggerAutocomplete()
1121
+ }
1122
+ // Check if we're in a symbol-based completion context like @ or #
1123
+ else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) {
1124
+ this.tryTriggerAutocomplete()
1125
+ }
1126
+ }
1127
+ } else {
1128
+ this.updateAutocomplete()
1129
+ }
1130
+ }
1131
+
1132
+ private handlePaste(pastedText: string): void {
1133
+ this.cancelAutocomplete()
1134
+ this.historyIndex = -1 // Exit history browsing mode
1135
+ this.lastAction = null
1136
+
1137
+ this.pushUndoSnapshot()
1138
+
1139
+ // Some terminals (e.g. tmux popups with extended-keys-format=csi-u) re-encode
1140
+ // control bytes inside bracketed paste as CSI-u Ctrl+<letter> sequences
1141
+ // (ESC [ <codepoint> ; 5 u). Decode those back to their literal byte so the
1142
+ // per-char filter below preserves newlines instead of stripping ESC and
1143
+ // leaking the printable tail (e.g. "[106;5u") into the editor.
1144
+ const decodedText = pastedText.replace(/\x1b\[(\d+);5u/g, (match, code) => {
1145
+ const cp = Number(code)
1146
+ if (cp >= 97 && cp <= 122) return String.fromCharCode(cp - 96)
1147
+ if (cp >= 65 && cp <= 90) return String.fromCharCode(cp - 64)
1148
+ return match
1149
+ })
1150
+
1151
+ // Clean the pasted text: normalize line endings, expand tabs
1152
+ const cleanText = this.normalizeText(decodedText)
1153
+
1154
+ // Filter out non-printable characters except newlines
1155
+ let filteredText = cleanText
1156
+ .split('')
1157
+ .filter(char => char === '\n' || char.charCodeAt(0) >= 32)
1158
+ .join('')
1159
+
1160
+ // If pasting a file path (starts with /, ~, or .) and the character before
1161
+ // the cursor is a word character, prepend a space for better readability
1162
+ if (/^[/~.]/.test(filteredText)) {
1163
+ const currentLine = this.state.lines[this.state.cursorLine] || ''
1164
+ const charBeforeCursor = this.state.cursorCol > 0 ? currentLine[this.state.cursorCol - 1] : ''
1165
+ if (charBeforeCursor && /\w/.test(charBeforeCursor)) {
1166
+ filteredText = ` ${filteredText}`
1167
+ }
1168
+ }
1169
+
1170
+ // Split into lines to check for large paste
1171
+ const pastedLines = filteredText.split('\n')
1172
+
1173
+ // Check if this is a large paste (> 10 lines or > 1000 characters)
1174
+ const totalChars = filteredText.length
1175
+ if (pastedLines.length > 10 || totalChars > 1000) {
1176
+ // Store the paste and insert a marker
1177
+ this.pasteCounter++
1178
+ const pasteId = this.pasteCounter
1179
+ this.pastes.set(pasteId, filteredText)
1180
+
1181
+ // Insert marker like "[paste #1 +123 lines]" or "[paste #1 1234 chars]"
1182
+ const marker =
1183
+ pastedLines.length > 10
1184
+ ? `[paste #${pasteId} +${pastedLines.length} lines]`
1185
+ : `[paste #${pasteId} ${totalChars} chars]`
1186
+ this.insertTextAtCursorInternal(marker)
1187
+ return
1188
+ }
1189
+
1190
+ if (pastedLines.length === 1) {
1191
+ // Single line - insert atomically (do not trigger autocomplete during paste)
1192
+ this.insertTextAtCursorInternal(filteredText)
1193
+ return
1194
+ }
1195
+
1196
+ // Multi-line paste - use direct state manipulation
1197
+ this.insertTextAtCursorInternal(filteredText)
1198
+ }
1199
+
1200
+ private addNewLine(): void {
1201
+ this.cancelAutocomplete()
1202
+ this.historyIndex = -1 // Exit history browsing mode
1203
+ this.lastAction = null
1204
+
1205
+ this.pushUndoSnapshot()
1206
+
1207
+ const currentLine = this.state.lines[this.state.cursorLine] || ''
1208
+
1209
+ const before = currentLine.slice(0, this.state.cursorCol)
1210
+ const after = currentLine.slice(this.state.cursorCol)
1211
+
1212
+ // Split current line
1213
+ this.state.lines[this.state.cursorLine] = before
1214
+ this.state.lines.splice(this.state.cursorLine + 1, 0, after)
1215
+
1216
+ // Move cursor to start of new line
1217
+ this.state.cursorLine++
1218
+ this.setCursorCol(0)
1219
+
1220
+ if (this.onChange) {
1221
+ this.onChange(this.getText())
1222
+ }
1223
+ }
1224
+
1225
+ private shouldSubmitOnBackslashEnter(
1226
+ data: string,
1227
+ kb: ReturnType<typeof getKeybindings>,
1228
+ ): boolean {
1229
+ if (this.disableSubmit) return false
1230
+ if (!matchesKey(data, 'enter')) return false
1231
+ const submitKeys = kb.getKeys('tui.input.submit')
1232
+ const hasShiftEnter = submitKeys.includes('shift+enter') || submitKeys.includes('shift+return')
1233
+ if (!hasShiftEnter) return false
1234
+
1235
+ const currentLine = this.state.lines[this.state.cursorLine] || ''
1236
+ return this.state.cursorCol > 0 && currentLine[this.state.cursorCol - 1] === '\\'
1237
+ }
1238
+
1239
+ private submitValue(): void {
1240
+ this.cancelAutocomplete()
1241
+ const result = this.expandPasteMarkers(this.state.lines.join('\n')).trim()
1242
+
1243
+ this.state = { lines: [''], cursorLine: 0, cursorCol: 0 }
1244
+ this.pastes.clear()
1245
+ this.pasteCounter = 0
1246
+ this.historyIndex = -1
1247
+ this.scrollOffset = 0
1248
+ this.undoStack.clear()
1249
+ this.lastAction = null
1250
+
1251
+ if (this.onChange) this.onChange('')
1252
+ if (this.onSubmit) this.onSubmit(result)
1253
+ }
1254
+
1255
+ private handleBackspace(): void {
1256
+ this.historyIndex = -1 // Exit history browsing mode
1257
+ this.lastAction = null
1258
+
1259
+ if (this.state.cursorCol > 0) {
1260
+ this.pushUndoSnapshot()
1261
+
1262
+ // Delete grapheme before cursor (handles emojis, combining characters, etc.)
1263
+ const line = this.state.lines[this.state.cursorLine] || ''
1264
+ const beforeCursor = line.slice(0, this.state.cursorCol)
1265
+
1266
+ // Find the last grapheme in the text before cursor
1267
+ const graphemes = [...this.segment(beforeCursor, 'grapheme')]
1268
+ const lastGrapheme = graphemes[graphemes.length - 1]
1269
+ const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1
1270
+
1271
+ const before = line.slice(0, this.state.cursorCol - graphemeLength)
1272
+ const after = line.slice(this.state.cursorCol)
1273
+
1274
+ this.state.lines[this.state.cursorLine] = before + after
1275
+ this.setCursorCol(this.state.cursorCol - graphemeLength)
1276
+ } else if (this.state.cursorLine > 0) {
1277
+ this.pushUndoSnapshot()
1278
+
1279
+ // Merge with previous line
1280
+ const currentLine = this.state.lines[this.state.cursorLine] || ''
1281
+ const previousLine = this.state.lines[this.state.cursorLine - 1] || ''
1282
+
1283
+ this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine
1284
+ this.state.lines.splice(this.state.cursorLine, 1)
1285
+
1286
+ this.state.cursorLine--
1287
+ this.setCursorCol(previousLine.length)
1288
+ }
1289
+
1290
+ if (this.onChange) {
1291
+ this.onChange(this.getText())
1292
+ }
1293
+
1294
+ // Update or re-trigger autocomplete after backspace
1295
+ if (this.autocompleteState) {
1296
+ this.updateAutocomplete()
1297
+ } else {
1298
+ // If autocomplete was cancelled (no matches), re-trigger if we're in a completable context
1299
+ const currentLine = this.state.lines[this.state.cursorLine] || ''
1300
+ const textBeforeCursor = currentLine.slice(0, this.state.cursorCol)
1301
+ // Slash command context
1302
+ if (this.isInSlashCommandContext(textBeforeCursor)) {
1303
+ this.tryTriggerAutocomplete()
1304
+ }
1305
+ // Symbol-based completion context like @ or #
1306
+ else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) {
1307
+ this.tryTriggerAutocomplete()
1308
+ }
1309
+ }
1310
+ }
1311
+
1312
+ /**
1313
+ * Set cursor column and clear preferredVisualCol.
1314
+ * Use this for all non-vertical cursor movements to reset sticky column behavior.
1315
+ */
1316
+ private setCursorCol(col: number): void {
1317
+ this.state.cursorCol = col
1318
+ this.preferredVisualCol = null
1319
+ this.snappedFromCursorCol = null
1320
+ }
1321
+
1322
+ /**
1323
+ * Move cursor to a target visual line, applying sticky column logic.
1324
+ * Shared by moveCursor() and pageScroll().
1325
+ */
1326
+ private moveToVisualLine(
1327
+ visualLines: Array<{ logicalLine: number; startCol: number; length: number }>,
1328
+ currentVisualLine: number,
1329
+ targetVisualLine: number,
1330
+ ): void {
1331
+ const currentVL = visualLines[currentVisualLine]
1332
+ const targetVL = visualLines[targetVisualLine]
1333
+ if (!(currentVL && targetVL)) return
1334
+
1335
+ // When the cursor was snapped to a segment start, resolve the pre-snap
1336
+ // position against the VL it belongs to. This gives the correct visual
1337
+ // column even after a resize reshuffles VLs.
1338
+ let currentVisualCol: number
1339
+ if (this.snappedFromCursorCol !== null) {
1340
+ const vlIndex = this.findVisualLineAt(
1341
+ visualLines,
1342
+ currentVL.logicalLine,
1343
+ this.snappedFromCursorCol,
1344
+ )
1345
+ currentVisualCol = this.snappedFromCursorCol - visualLines[vlIndex].startCol
1346
+ } else {
1347
+ currentVisualCol = this.state.cursorCol - currentVL.startCol
1348
+ }
1349
+
1350
+ // For non-last segments, clamp to length-1 to stay within the segment
1351
+ const isLastSourceSegment =
1352
+ currentVisualLine === visualLines.length - 1 ||
1353
+ visualLines[currentVisualLine + 1]?.logicalLine !== currentVL.logicalLine
1354
+ const sourceMaxVisualCol = isLastSourceSegment
1355
+ ? currentVL.length
1356
+ : Math.max(0, currentVL.length - 1)
1357
+
1358
+ const isLastTargetSegment =
1359
+ targetVisualLine === visualLines.length - 1 ||
1360
+ visualLines[targetVisualLine + 1]?.logicalLine !== targetVL.logicalLine
1361
+ const targetMaxVisualCol = isLastTargetSegment
1362
+ ? targetVL.length
1363
+ : Math.max(0, targetVL.length - 1)
1364
+
1365
+ const moveToVisualCol = this.computeVerticalMoveColumn(
1366
+ currentVisualCol,
1367
+ sourceMaxVisualCol,
1368
+ targetMaxVisualCol,
1369
+ )
1370
+
1371
+ // Set cursor position
1372
+ this.state.cursorLine = targetVL.logicalLine
1373
+ const targetCol = targetVL.startCol + moveToVisualCol
1374
+ const logicalLine = this.state.lines[targetVL.logicalLine] || ''
1375
+ this.state.cursorCol = Math.min(targetCol, logicalLine.length)
1376
+
1377
+ // Snap cursor to atomic segment boundary (e.g. paste markers)
1378
+ // so the cursor never lands in the middle of a multi-grapheme unit.
1379
+ // Single-grapheme segments don't need snapping.
1380
+ const segments = [...this.segment(logicalLine, 'grapheme')]
1381
+ for (const seg of segments) {
1382
+ if (seg.index > this.state.cursorCol) break
1383
+ if (seg.segment.length <= 1) continue
1384
+ if (this.state.cursorCol < seg.index + seg.segment.length) {
1385
+ const isContinuation = seg.index < targetVL.startCol
1386
+ const isMovingDown = targetVisualLine > currentVisualLine
1387
+
1388
+ if (isContinuation && isMovingDown) {
1389
+ // The segment started on a previous visual line, and we
1390
+ // already visited it on the way down. Skip all remaining
1391
+ // continuation VLs and land on the first VL past it.
1392
+ const segEnd = seg.index + seg.segment.length
1393
+ let next = targetVisualLine + 1
1394
+ while (
1395
+ next < visualLines.length &&
1396
+ visualLines[next].logicalLine === targetVL.logicalLine &&
1397
+ visualLines[next].startCol < segEnd
1398
+ ) {
1399
+ next++
1400
+ }
1401
+ if (next < visualLines.length) {
1402
+ this.moveToVisualLine(visualLines, currentVisualLine, next)
1403
+ return
1404
+ }
1405
+ }
1406
+
1407
+ // Snap to the start of the segment so it gets highlighted.
1408
+ // Store the pre-snap position so the next vertical move can
1409
+ // resolve it to the correct visual column.
1410
+ this.snappedFromCursorCol = this.state.cursorCol
1411
+ this.state.cursorCol = seg.index
1412
+ return
1413
+ }
1414
+ }
1415
+
1416
+ // No snap occurred – we moved out of the atomic segment.
1417
+ this.snappedFromCursorCol = null
1418
+ }
1419
+
1420
+ /**
1421
+ * Compute the target visual column for vertical cursor movement.
1422
+ * Implements the sticky column decision table:
1423
+ *
1424
+ * | P | S | T | U | Scenario | Set Preferred | Move To |
1425
+ * |---|---|---|---| ---------------------------------------------------- |---------------|-------------|
1426
+ * | 0 | * | 0 | - | Start nav, target fits | null | current |
1427
+ * | 0 | * | 1 | - | Start nav, target shorter | current | target end |
1428
+ * | 1 | 0 | 0 | 0 | Clamped, target fits preferred | null | preferred |
1429
+ * | 1 | 0 | 0 | 1 | Clamped, target longer but still can't fit preferred | keep | target end |
1430
+ * | 1 | 0 | 1 | - | Clamped, target even shorter | keep | target end |
1431
+ * | 1 | 1 | 0 | - | Rewrapped, target fits current | null | current |
1432
+ * | 1 | 1 | 1 | - | Rewrapped, target shorter than current | current | target end |
1433
+ *
1434
+ * Where:
1435
+ * - P = preferred col is set
1436
+ * - S = cursor in middle of source line (not clamped to end)
1437
+ * - T = target line shorter than current visual col
1438
+ * - U = target line shorter than preferred col
1439
+ */
1440
+ private computeVerticalMoveColumn(
1441
+ currentVisualCol: number,
1442
+ sourceMaxVisualCol: number,
1443
+ targetMaxVisualCol: number,
1444
+ ): number {
1445
+ const hasPreferred = this.preferredVisualCol !== null // P
1446
+ const cursorInMiddle = currentVisualCol < sourceMaxVisualCol // S
1447
+ const targetTooShort = targetMaxVisualCol < currentVisualCol // T
1448
+
1449
+ if (!hasPreferred || cursorInMiddle) {
1450
+ if (targetTooShort) {
1451
+ // Cases 2 and 7
1452
+ this.preferredVisualCol = currentVisualCol
1453
+ return targetMaxVisualCol
1454
+ }
1455
+
1456
+ // Cases 1 and 6
1457
+ this.preferredVisualCol = null
1458
+ return currentVisualCol
1459
+ }
1460
+
1461
+ const targetCantFitPreferred = targetMaxVisualCol < this.preferredVisualCol! // U
1462
+ if (targetTooShort || targetCantFitPreferred) {
1463
+ // Cases 4 and 5
1464
+ return targetMaxVisualCol
1465
+ }
1466
+
1467
+ // Case 3
1468
+ const result = this.preferredVisualCol!
1469
+ this.preferredVisualCol = null
1470
+ return result
1471
+ }
1472
+
1473
+ private moveToLineStart(): void {
1474
+ this.lastAction = null
1475
+ this.setCursorCol(0)
1476
+ }
1477
+
1478
+ private moveToLineEnd(): void {
1479
+ this.lastAction = null
1480
+ const currentLine = this.state.lines[this.state.cursorLine] || ''
1481
+ this.setCursorCol(currentLine.length)
1482
+ }
1483
+
1484
+ private deleteToStartOfLine(): void {
1485
+ this.historyIndex = -1 // Exit history browsing mode
1486
+
1487
+ const currentLine = this.state.lines[this.state.cursorLine] || ''
1488
+
1489
+ if (this.state.cursorCol > 0) {
1490
+ this.pushUndoSnapshot()
1491
+
1492
+ // Calculate text to be deleted and save to kill ring (backward deletion = prepend)
1493
+ const deletedText = currentLine.slice(0, this.state.cursorCol)
1494
+ this.killRing.push(deletedText, { prepend: true, accumulate: this.lastAction === 'kill' })
1495
+ this.lastAction = 'kill'
1496
+
1497
+ // Delete from start of line up to cursor
1498
+ this.state.lines[this.state.cursorLine] = currentLine.slice(this.state.cursorCol)
1499
+ this.setCursorCol(0)
1500
+ } else if (this.state.cursorLine > 0) {
1501
+ this.pushUndoSnapshot()
1502
+
1503
+ // At start of line - merge with previous line, treating newline as deleted text
1504
+ this.killRing.push('\n', { prepend: true, accumulate: this.lastAction === 'kill' })
1505
+ this.lastAction = 'kill'
1506
+
1507
+ const previousLine = this.state.lines[this.state.cursorLine - 1] || ''
1508
+ this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine
1509
+ this.state.lines.splice(this.state.cursorLine, 1)
1510
+ this.state.cursorLine--
1511
+ this.setCursorCol(previousLine.length)
1512
+ }
1513
+
1514
+ if (this.onChange) {
1515
+ this.onChange(this.getText())
1516
+ }
1517
+ }
1518
+
1519
+ private deleteToEndOfLine(): void {
1520
+ this.historyIndex = -1 // Exit history browsing mode
1521
+
1522
+ const currentLine = this.state.lines[this.state.cursorLine] || ''
1523
+
1524
+ if (this.state.cursorCol < currentLine.length) {
1525
+ this.pushUndoSnapshot()
1526
+
1527
+ // Calculate text to be deleted and save to kill ring (forward deletion = append)
1528
+ const deletedText = currentLine.slice(this.state.cursorCol)
1529
+ this.killRing.push(deletedText, { prepend: false, accumulate: this.lastAction === 'kill' })
1530
+ this.lastAction = 'kill'
1531
+
1532
+ // Delete from cursor to end of line
1533
+ this.state.lines[this.state.cursorLine] = currentLine.slice(0, this.state.cursorCol)
1534
+ } else if (this.state.cursorLine < this.state.lines.length - 1) {
1535
+ this.pushUndoSnapshot()
1536
+
1537
+ // At end of line - merge with next line, treating newline as deleted text
1538
+ this.killRing.push('\n', { prepend: false, accumulate: this.lastAction === 'kill' })
1539
+ this.lastAction = 'kill'
1540
+
1541
+ const nextLine = this.state.lines[this.state.cursorLine + 1] || ''
1542
+ this.state.lines[this.state.cursorLine] = currentLine + nextLine
1543
+ this.state.lines.splice(this.state.cursorLine + 1, 1)
1544
+ }
1545
+
1546
+ if (this.onChange) {
1547
+ this.onChange(this.getText())
1548
+ }
1549
+ }
1550
+
1551
+ private deleteWordBackwards(): void {
1552
+ this.historyIndex = -1 // Exit history browsing mode
1553
+
1554
+ const currentLine = this.state.lines[this.state.cursorLine] || ''
1555
+
1556
+ // If at start of line, behave like backspace at column 0 (merge with previous line)
1557
+ if (this.state.cursorCol === 0) {
1558
+ if (this.state.cursorLine > 0) {
1559
+ this.pushUndoSnapshot()
1560
+
1561
+ // Treat newline as deleted text (backward deletion = prepend)
1562
+ this.killRing.push('\n', { prepend: true, accumulate: this.lastAction === 'kill' })
1563
+ this.lastAction = 'kill'
1564
+
1565
+ const previousLine = this.state.lines[this.state.cursorLine - 1] || ''
1566
+ this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine
1567
+ this.state.lines.splice(this.state.cursorLine, 1)
1568
+ this.state.cursorLine--
1569
+ this.setCursorCol(previousLine.length)
1570
+ }
1571
+ } else {
1572
+ this.pushUndoSnapshot()
1573
+
1574
+ // Save lastAction before cursor movement (moveWordBackwards resets it)
1575
+ const wasKill = this.lastAction === 'kill'
1576
+
1577
+ const oldCursorCol = this.state.cursorCol
1578
+ this.moveWordBackwards()
1579
+ const deleteFrom = this.state.cursorCol
1580
+ this.setCursorCol(oldCursorCol)
1581
+
1582
+ const deletedText = currentLine.slice(deleteFrom, this.state.cursorCol)
1583
+ this.killRing.push(deletedText, { prepend: true, accumulate: wasKill })
1584
+ this.lastAction = 'kill'
1585
+
1586
+ this.state.lines[this.state.cursorLine] =
1587
+ currentLine.slice(0, deleteFrom) + currentLine.slice(this.state.cursorCol)
1588
+ this.setCursorCol(deleteFrom)
1589
+ }
1590
+
1591
+ if (this.onChange) {
1592
+ this.onChange(this.getText())
1593
+ }
1594
+ }
1595
+
1596
+ private deleteWordForward(): void {
1597
+ this.historyIndex = -1 // Exit history browsing mode
1598
+
1599
+ const currentLine = this.state.lines[this.state.cursorLine] || ''
1600
+
1601
+ // If at end of line, merge with next line (delete the newline)
1602
+ if (this.state.cursorCol >= currentLine.length) {
1603
+ if (this.state.cursorLine < this.state.lines.length - 1) {
1604
+ this.pushUndoSnapshot()
1605
+
1606
+ // Treat newline as deleted text (forward deletion = append)
1607
+ this.killRing.push('\n', { prepend: false, accumulate: this.lastAction === 'kill' })
1608
+ this.lastAction = 'kill'
1609
+
1610
+ const nextLine = this.state.lines[this.state.cursorLine + 1] || ''
1611
+ this.state.lines[this.state.cursorLine] = currentLine + nextLine
1612
+ this.state.lines.splice(this.state.cursorLine + 1, 1)
1613
+ }
1614
+ } else {
1615
+ this.pushUndoSnapshot()
1616
+
1617
+ // Save lastAction before cursor movement (moveWordForwards resets it)
1618
+ const wasKill = this.lastAction === 'kill'
1619
+
1620
+ const oldCursorCol = this.state.cursorCol
1621
+ this.moveWordForwards()
1622
+ const deleteTo = this.state.cursorCol
1623
+ this.setCursorCol(oldCursorCol)
1624
+
1625
+ const deletedText = currentLine.slice(this.state.cursorCol, deleteTo)
1626
+ this.killRing.push(deletedText, { prepend: false, accumulate: wasKill })
1627
+ this.lastAction = 'kill'
1628
+
1629
+ this.state.lines[this.state.cursorLine] =
1630
+ currentLine.slice(0, this.state.cursorCol) + currentLine.slice(deleteTo)
1631
+ }
1632
+
1633
+ if (this.onChange) {
1634
+ this.onChange(this.getText())
1635
+ }
1636
+ }
1637
+
1638
+ private handleForwardDelete(): void {
1639
+ this.historyIndex = -1 // Exit history browsing mode
1640
+ this.lastAction = null
1641
+
1642
+ const currentLine = this.state.lines[this.state.cursorLine] || ''
1643
+
1644
+ if (this.state.cursorCol < currentLine.length) {
1645
+ this.pushUndoSnapshot()
1646
+
1647
+ // Delete grapheme at cursor position (handles emojis, combining characters, etc.)
1648
+ const afterCursor = currentLine.slice(this.state.cursorCol)
1649
+
1650
+ // Find the first grapheme at cursor
1651
+ const graphemes = [...this.segment(afterCursor, 'grapheme')]
1652
+ const firstGrapheme = graphemes[0]
1653
+ const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1
1654
+
1655
+ const before = currentLine.slice(0, this.state.cursorCol)
1656
+ const after = currentLine.slice(this.state.cursorCol + graphemeLength)
1657
+ this.state.lines[this.state.cursorLine] = before + after
1658
+ } else if (this.state.cursorLine < this.state.lines.length - 1) {
1659
+ this.pushUndoSnapshot()
1660
+
1661
+ // At end of line - merge with next line
1662
+ const nextLine = this.state.lines[this.state.cursorLine + 1] || ''
1663
+ this.state.lines[this.state.cursorLine] = currentLine + nextLine
1664
+ this.state.lines.splice(this.state.cursorLine + 1, 1)
1665
+ }
1666
+
1667
+ if (this.onChange) {
1668
+ this.onChange(this.getText())
1669
+ }
1670
+
1671
+ // Update or re-trigger autocomplete after forward delete
1672
+ if (this.autocompleteState) {
1673
+ this.updateAutocomplete()
1674
+ } else {
1675
+ const currentLine = this.state.lines[this.state.cursorLine] || ''
1676
+ const textBeforeCursor = currentLine.slice(0, this.state.cursorCol)
1677
+ // Slash command context
1678
+ if (this.isInSlashCommandContext(textBeforeCursor)) {
1679
+ this.tryTriggerAutocomplete()
1680
+ }
1681
+ // Symbol-based completion context like @ or #
1682
+ else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) {
1683
+ this.tryTriggerAutocomplete()
1684
+ }
1685
+ }
1686
+ }
1687
+
1688
+ /**
1689
+ * Build a mapping from visual lines to logical positions.
1690
+ * Returns an array where each element represents a visual line with:
1691
+ * - logicalLine: index into this.state.lines
1692
+ * - startCol: starting column in the logical line
1693
+ * - length: length of this visual line segment
1694
+ */
1695
+ private buildVisualLineMap(
1696
+ width: number,
1697
+ ): Array<{ logicalLine: number; startCol: number; length: number }> {
1698
+ const visualLines: Array<{ logicalLine: number; startCol: number; length: number }> = []
1699
+
1700
+ for (let i = 0; i < this.state.lines.length; i++) {
1701
+ const line = this.state.lines[i] || ''
1702
+ const lineVisWidth = visibleWidth(line)
1703
+ if (line.length === 0) {
1704
+ // Empty line still takes one visual line
1705
+ visualLines.push({ logicalLine: i, startCol: 0, length: 0 })
1706
+ } else if (lineVisWidth <= width) {
1707
+ visualLines.push({ logicalLine: i, startCol: 0, length: line.length })
1708
+ } else {
1709
+ // Line needs wrapping - use word-aware wrapping
1710
+ const chunks = wordWrapLine(line, width, [...this.segment(line, 'grapheme')])
1711
+ for (const chunk of chunks) {
1712
+ visualLines.push({
1713
+ logicalLine: i,
1714
+ startCol: chunk.startIndex,
1715
+ length: chunk.endIndex - chunk.startIndex,
1716
+ })
1717
+ }
1718
+ }
1719
+ }
1720
+
1721
+ return visualLines
1722
+ }
1723
+
1724
+ /**
1725
+ * Find the visual line index that contains the given logical position.
1726
+ */
1727
+ private findVisualLineAt(
1728
+ visualLines: Array<{ logicalLine: number; startCol: number; length: number }>,
1729
+ line: number,
1730
+ col: number,
1731
+ ): number {
1732
+ for (let i = 0; i < visualLines.length; i++) {
1733
+ const vl = visualLines[i]
1734
+ if (!vl || vl.logicalLine !== line) continue
1735
+ const offset = col - vl.startCol
1736
+ // Cursor is in this segment if it's within range. For the last
1737
+ // segment of a logical line, cursor can be at length (end position)
1738
+ const isLastSegmentOfLine =
1739
+ i === visualLines.length - 1 || visualLines[i + 1]?.logicalLine !== vl.logicalLine
1740
+ if (offset >= 0 && (offset < vl.length || (isLastSegmentOfLine && offset === vl.length))) {
1741
+ return i
1742
+ }
1743
+ }
1744
+ return visualLines.length - 1
1745
+ }
1746
+
1747
+ /**
1748
+ * Find the visual line index for the current cursor position.
1749
+ */
1750
+ private findCurrentVisualLine(
1751
+ visualLines: Array<{ logicalLine: number; startCol: number; length: number }>,
1752
+ ): number {
1753
+ return this.findVisualLineAt(visualLines, this.state.cursorLine, this.state.cursorCol)
1754
+ }
1755
+
1756
+ private moveCursor(deltaLine: number, deltaCol: number): void {
1757
+ this.lastAction = null
1758
+ const visualLines = this.buildVisualLineMap(this.lastWidth)
1759
+ const currentVisualLine = this.findCurrentVisualLine(visualLines)
1760
+
1761
+ if (deltaLine !== 0) {
1762
+ const targetVisualLine = currentVisualLine + deltaLine
1763
+
1764
+ if (targetVisualLine >= 0 && targetVisualLine < visualLines.length) {
1765
+ this.moveToVisualLine(visualLines, currentVisualLine, targetVisualLine)
1766
+ }
1767
+ }
1768
+
1769
+ if (deltaCol !== 0) {
1770
+ const currentLine = this.state.lines[this.state.cursorLine] || ''
1771
+
1772
+ if (deltaCol > 0) {
1773
+ // Moving right - move by one grapheme (handles emojis, combining characters, etc.)
1774
+ if (this.state.cursorCol < currentLine.length) {
1775
+ const afterCursor = currentLine.slice(this.state.cursorCol)
1776
+ const graphemes = [...this.segment(afterCursor, 'grapheme')]
1777
+ const firstGrapheme = graphemes[0]
1778
+ this.setCursorCol(
1779
+ this.state.cursorCol + (firstGrapheme ? firstGrapheme.segment.length : 1),
1780
+ )
1781
+ } else if (this.state.cursorLine < this.state.lines.length - 1) {
1782
+ // Wrap to start of next logical line
1783
+ this.state.cursorLine++
1784
+ this.setCursorCol(0)
1785
+ } else {
1786
+ // At end of last line - can't move, but set preferredVisualCol for up/down navigation
1787
+ const currentVL = visualLines[currentVisualLine]
1788
+ if (currentVL) {
1789
+ this.preferredVisualCol = this.state.cursorCol - currentVL.startCol
1790
+ }
1791
+ }
1792
+ } else {
1793
+ // Moving left - move by one grapheme (handles emojis, combining characters, etc.)
1794
+ if (this.state.cursorCol > 0) {
1795
+ const beforeCursor = currentLine.slice(0, this.state.cursorCol)
1796
+ const graphemes = [...this.segment(beforeCursor, 'grapheme')]
1797
+ const lastGrapheme = graphemes[graphemes.length - 1]
1798
+ this.setCursorCol(this.state.cursorCol - (lastGrapheme ? lastGrapheme.segment.length : 1))
1799
+ } else if (this.state.cursorLine > 0) {
1800
+ // Wrap to end of previous logical line
1801
+ this.state.cursorLine--
1802
+ const prevLine = this.state.lines[this.state.cursorLine] || ''
1803
+ this.setCursorCol(prevLine.length)
1804
+ }
1805
+ }
1806
+ }
1807
+ }
1808
+
1809
+ /**
1810
+ * Scroll by a page (direction: -1 for up, 1 for down).
1811
+ * Moves cursor by the page size while keeping it in bounds.
1812
+ */
1813
+ private pageScroll(direction: -1 | 1): void {
1814
+ this.lastAction = null
1815
+ const terminalRows = this.tui.terminal.rows
1816
+ const pageSize = Math.max(5, Math.floor(terminalRows * 0.3))
1817
+
1818
+ const visualLines = this.buildVisualLineMap(this.lastWidth)
1819
+ const currentVisualLine = this.findCurrentVisualLine(visualLines)
1820
+ const targetVisualLine = Math.max(
1821
+ 0,
1822
+ Math.min(visualLines.length - 1, currentVisualLine + direction * pageSize),
1823
+ )
1824
+
1825
+ this.moveToVisualLine(visualLines, currentVisualLine, targetVisualLine)
1826
+ }
1827
+
1828
+ private moveWordBackwards(): void {
1829
+ this.lastAction = null
1830
+ const currentLine = this.state.lines[this.state.cursorLine] || ''
1831
+
1832
+ // If at start of line, move to end of previous line
1833
+ if (this.state.cursorCol === 0) {
1834
+ if (this.state.cursorLine > 0) {
1835
+ this.state.cursorLine--
1836
+ const prevLine = this.state.lines[this.state.cursorLine] || ''
1837
+ this.setCursorCol(prevLine.length)
1838
+ }
1839
+ return
1840
+ }
1841
+
1842
+ this.setCursorCol(
1843
+ findWordBackward(currentLine, this.state.cursorCol, {
1844
+ segment: text => this.segment(text, 'word'),
1845
+ isAtomicSegment: isPasteMarker,
1846
+ }),
1847
+ )
1848
+ }
1849
+
1850
+ /**
1851
+ * Yank (paste) the most recent kill ring entry at cursor position.
1852
+ */
1853
+ private yank(): void {
1854
+ if (this.killRing.length === 0) return
1855
+
1856
+ this.pushUndoSnapshot()
1857
+
1858
+ const text = this.killRing.peek()!
1859
+ this.insertYankedText(text)
1860
+
1861
+ this.lastAction = 'yank'
1862
+ }
1863
+
1864
+ /**
1865
+ * Cycle through kill ring (only works immediately after yank or yank-pop).
1866
+ * Replaces the last yanked text with the previous entry in the ring.
1867
+ */
1868
+ private yankPop(): void {
1869
+ // Only works if we just yanked and have more than one entry
1870
+ if (this.lastAction !== 'yank' || this.killRing.length <= 1) return
1871
+
1872
+ this.pushUndoSnapshot()
1873
+
1874
+ // Delete the previously yanked text (still at end of ring before rotation)
1875
+ this.deleteYankedText()
1876
+
1877
+ // Rotate the ring: move end to front
1878
+ this.killRing.rotate()
1879
+
1880
+ // Insert the new most recent entry (now at end after rotation)
1881
+ const text = this.killRing.peek()!
1882
+ this.insertYankedText(text)
1883
+
1884
+ this.lastAction = 'yank'
1885
+ }
1886
+
1887
+ /**
1888
+ * Insert text at cursor position (used by yank operations).
1889
+ */
1890
+ private insertYankedText(text: string): void {
1891
+ this.historyIndex = -1 // Exit history browsing mode
1892
+ const lines = text.split('\n')
1893
+
1894
+ if (lines.length === 1) {
1895
+ // Single line - insert at cursor
1896
+ const currentLine = this.state.lines[this.state.cursorLine] || ''
1897
+ const before = currentLine.slice(0, this.state.cursorCol)
1898
+ const after = currentLine.slice(this.state.cursorCol)
1899
+ this.state.lines[this.state.cursorLine] = before + text + after
1900
+ this.setCursorCol(this.state.cursorCol + text.length)
1901
+ } else {
1902
+ // Multi-line insert
1903
+ const currentLine = this.state.lines[this.state.cursorLine] || ''
1904
+ const before = currentLine.slice(0, this.state.cursorCol)
1905
+ const after = currentLine.slice(this.state.cursorCol)
1906
+
1907
+ // First line merges with text before cursor
1908
+ this.state.lines[this.state.cursorLine] = before + (lines[0] || '')
1909
+
1910
+ // Insert middle lines
1911
+ for (let i = 1; i < lines.length - 1; i++) {
1912
+ this.state.lines.splice(this.state.cursorLine + i, 0, lines[i] || '')
1913
+ }
1914
+
1915
+ // Last line merges with text after cursor
1916
+ const lastLineIndex = this.state.cursorLine + lines.length - 1
1917
+ this.state.lines.splice(lastLineIndex, 0, (lines[lines.length - 1] || '') + after)
1918
+
1919
+ // Update cursor position
1920
+ this.state.cursorLine = lastLineIndex
1921
+ this.setCursorCol((lines[lines.length - 1] || '').length)
1922
+ }
1923
+
1924
+ if (this.onChange) {
1925
+ this.onChange(this.getText())
1926
+ }
1927
+ }
1928
+
1929
+ /**
1930
+ * Delete the previously yanked text (used by yank-pop).
1931
+ * The yanked text is derived from killRing[end] since it hasn't been rotated yet.
1932
+ */
1933
+ private deleteYankedText(): void {
1934
+ const yankedText = this.killRing.peek()
1935
+ if (!yankedText) return
1936
+
1937
+ const yankLines = yankedText.split('\n')
1938
+
1939
+ if (yankLines.length === 1) {
1940
+ // Single line - delete backward from cursor
1941
+ const currentLine = this.state.lines[this.state.cursorLine] || ''
1942
+ const deleteLen = yankedText.length
1943
+ const before = currentLine.slice(0, this.state.cursorCol - deleteLen)
1944
+ const after = currentLine.slice(this.state.cursorCol)
1945
+ this.state.lines[this.state.cursorLine] = before + after
1946
+ this.setCursorCol(this.state.cursorCol - deleteLen)
1947
+ } else {
1948
+ // Multi-line delete - cursor is at end of last yanked line
1949
+ const startLine = this.state.cursorLine - (yankLines.length - 1)
1950
+ const startCol = (this.state.lines[startLine] || '').length - (yankLines[0] || '').length
1951
+
1952
+ // Get text after cursor on current line
1953
+ const afterCursor = (this.state.lines[this.state.cursorLine] || '').slice(
1954
+ this.state.cursorCol,
1955
+ )
1956
+
1957
+ // Get text before yank start position
1958
+ const beforeYank = (this.state.lines[startLine] || '').slice(0, startCol)
1959
+
1960
+ // Remove all lines from startLine to cursorLine and replace with merged line
1961
+ this.state.lines.splice(startLine, yankLines.length, beforeYank + afterCursor)
1962
+
1963
+ // Update cursor
1964
+ this.state.cursorLine = startLine
1965
+ this.setCursorCol(startCol)
1966
+ }
1967
+
1968
+ if (this.onChange) {
1969
+ this.onChange(this.getText())
1970
+ }
1971
+ }
1972
+
1973
+ private pushUndoSnapshot(): void {
1974
+ this.undoStack.push(this.state)
1975
+ }
1976
+
1977
+ private undo(): void {
1978
+ this.historyIndex = -1 // Exit history browsing mode
1979
+ const snapshot = this.undoStack.pop()
1980
+ if (!snapshot) return
1981
+ Object.assign(this.state, snapshot)
1982
+ this.lastAction = null
1983
+ this.preferredVisualCol = null
1984
+ if (this.onChange) {
1985
+ this.onChange(this.getText())
1986
+ }
1987
+ }
1988
+
1989
+ /**
1990
+ * Jump to the first occurrence of a character in the specified direction.
1991
+ * Multi-line search. Case-sensitive. Skips the current cursor position.
1992
+ */
1993
+ private jumpToChar(char: string, direction: 'forward' | 'backward'): void {
1994
+ this.lastAction = null
1995
+ const isForward = direction === 'forward'
1996
+ const lines = this.state.lines
1997
+
1998
+ const end = isForward ? lines.length : -1
1999
+ const step = isForward ? 1 : -1
2000
+
2001
+ for (let lineIdx = this.state.cursorLine; lineIdx !== end; lineIdx += step) {
2002
+ const line = lines[lineIdx] || ''
2003
+ const isCurrentLine = lineIdx === this.state.cursorLine
2004
+
2005
+ // Current line: start after/before cursor; other lines: search full line
2006
+ const searchFrom = isCurrentLine
2007
+ ? isForward
2008
+ ? this.state.cursorCol + 1
2009
+ : this.state.cursorCol - 1
2010
+ : undefined
2011
+
2012
+ const idx = isForward ? line.indexOf(char, searchFrom) : line.lastIndexOf(char, searchFrom)
2013
+
2014
+ if (idx !== -1) {
2015
+ this.state.cursorLine = lineIdx
2016
+ this.setCursorCol(idx)
2017
+ return
2018
+ }
2019
+ }
2020
+ // No match found - cursor stays in place
2021
+ }
2022
+
2023
+ private moveWordForwards(): void {
2024
+ this.lastAction = null
2025
+ const currentLine = this.state.lines[this.state.cursorLine] || ''
2026
+
2027
+ // If at end of line, move to start of next line
2028
+ if (this.state.cursorCol >= currentLine.length) {
2029
+ if (this.state.cursorLine < this.state.lines.length - 1) {
2030
+ this.state.cursorLine++
2031
+ this.setCursorCol(0)
2032
+ }
2033
+ return
2034
+ }
2035
+
2036
+ this.setCursorCol(
2037
+ findWordForward(currentLine, this.state.cursorCol, {
2038
+ segment: text => this.segment(text, 'word'),
2039
+ isAtomicSegment: isPasteMarker,
2040
+ }),
2041
+ )
2042
+ }
2043
+
2044
+ // Slash menu only allowed on the first line of the editor
2045
+ private isSlashMenuAllowed(): boolean {
2046
+ return this.state.cursorLine === 0
2047
+ }
2048
+
2049
+ // Helper method to check if cursor is at start of message (for slash command detection)
2050
+ private isAtStartOfMessage(): boolean {
2051
+ if (!this.isSlashMenuAllowed()) return false
2052
+ const currentLine = this.state.lines[this.state.cursorLine] || ''
2053
+ const beforeCursor = currentLine.slice(0, this.state.cursorCol)
2054
+ return beforeCursor.trim() === '' || beforeCursor.trim() === '/'
2055
+ }
2056
+
2057
+ private isInSlashCommandContext(textBeforeCursor: string): boolean {
2058
+ return this.isSlashMenuAllowed() && textBeforeCursor.trimStart().startsWith('/')
2059
+ }
2060
+
2061
+ // Autocomplete methods
2062
+ /**
2063
+ * Find the best autocomplete item index for the given prefix.
2064
+ * Returns -1 if no match is found.
2065
+ *
2066
+ * Match priority:
2067
+ * 1. Exact match (prefix === item.value) -> always selected
2068
+ * 2. Prefix match -> first item whose value starts with prefix
2069
+ * 3. No match -> -1 (keep default highlight)
2070
+ *
2071
+ * Matching is case-sensitive and checks item.value only.
2072
+ */
2073
+ private getBestAutocompleteMatchIndex(
2074
+ items: Array<{ value: string; label: string }>,
2075
+ prefix: string,
2076
+ ): number {
2077
+ if (!prefix) return -1
2078
+
2079
+ let firstPrefixIndex = -1
2080
+
2081
+ for (let i = 0; i < items.length; i++) {
2082
+ const value = items[i]!.value
2083
+ if (value === prefix) {
2084
+ return i // Exact match always wins
2085
+ }
2086
+ if (firstPrefixIndex === -1 && value.startsWith(prefix)) {
2087
+ firstPrefixIndex = i
2088
+ }
2089
+ }
2090
+
2091
+ return firstPrefixIndex
2092
+ }
2093
+
2094
+ private createAutocompleteList(
2095
+ prefix: string,
2096
+ items: Array<{ value: string; label: string; description?: string }>,
2097
+ ): SelectList {
2098
+ const layout = prefix.startsWith('/') ? SLASH_COMMAND_SELECT_LIST_LAYOUT : undefined
2099
+ return new SelectList(items, this.autocompleteMaxVisible, this.theme.selectList, layout)
2100
+ }
2101
+
2102
+ private tryTriggerAutocomplete(explicitTab: boolean = false): void {
2103
+ this.requestAutocomplete({ force: false, explicitTab })
2104
+ }
2105
+
2106
+ private handleTabCompletion(): void {
2107
+ if (!this.autocompleteProvider) return
2108
+
2109
+ const currentLine = this.state.lines[this.state.cursorLine] || ''
2110
+ const beforeCursor = currentLine.slice(0, this.state.cursorCol)
2111
+
2112
+ if (this.isInSlashCommandContext(beforeCursor) && !beforeCursor.trimStart().includes(' ')) {
2113
+ this.handleSlashCommandCompletion()
2114
+ } else {
2115
+ this.forceFileAutocomplete(true)
2116
+ }
2117
+ }
2118
+
2119
+ private handleSlashCommandCompletion(): void {
2120
+ this.requestAutocomplete({ force: false, explicitTab: true })
2121
+ }
2122
+
2123
+ private forceFileAutocomplete(explicitTab: boolean = false): void {
2124
+ this.requestAutocomplete({ force: true, explicitTab })
2125
+ }
2126
+
2127
+ private requestAutocomplete(options: { force: boolean; explicitTab: boolean }): void {
2128
+ if (!this.autocompleteProvider) return
2129
+
2130
+ if (options.force) {
2131
+ const shouldTrigger =
2132
+ !this.autocompleteProvider.shouldTriggerFileCompletion ||
2133
+ this.autocompleteProvider.shouldTriggerFileCompletion(
2134
+ this.state.lines,
2135
+ this.state.cursorLine,
2136
+ this.state.cursorCol,
2137
+ )
2138
+ if (!shouldTrigger) {
2139
+ return
2140
+ }
2141
+ }
2142
+
2143
+ this.cancelAutocompleteRequest()
2144
+ const startToken = ++this.autocompleteStartToken
2145
+
2146
+ const debounceMs = this.getAutocompleteDebounceMs(options)
2147
+ if (debounceMs > 0) {
2148
+ this.autocompleteDebounceTimer = setTimeout(() => {
2149
+ this.autocompleteDebounceTimer = undefined
2150
+ void this.startAutocompleteRequest(startToken, options)
2151
+ }, debounceMs)
2152
+ return
2153
+ }
2154
+
2155
+ void this.startAutocompleteRequest(startToken, options)
2156
+ }
2157
+
2158
+ private async startAutocompleteRequest(
2159
+ startToken: number,
2160
+ options: { force: boolean; explicitTab: boolean },
2161
+ ): Promise<void> {
2162
+ const previousTask = this.autocompleteRequestTask
2163
+ this.autocompleteRequestTask = (async () => {
2164
+ await previousTask
2165
+ if (startToken !== this.autocompleteStartToken || !this.autocompleteProvider) {
2166
+ return
2167
+ }
2168
+
2169
+ const controller = new AbortController()
2170
+ this.autocompleteAbort = controller
2171
+ const requestId = ++this.autocompleteRequestId
2172
+ const snapshotText = this.getText()
2173
+ const snapshotLine = this.state.cursorLine
2174
+ const snapshotCol = this.state.cursorCol
2175
+
2176
+ await this.runAutocompleteRequest(
2177
+ requestId,
2178
+ controller,
2179
+ snapshotText,
2180
+ snapshotLine,
2181
+ snapshotCol,
2182
+ options,
2183
+ )
2184
+ })()
2185
+ await this.autocompleteRequestTask
2186
+ }
2187
+
2188
+ private getAutocompleteDebounceMs(options: { force: boolean; explicitTab: boolean }): number {
2189
+ if (options.explicitTab || options.force) {
2190
+ return 0
2191
+ }
2192
+
2193
+ const currentLine = this.state.lines[this.state.cursorLine] || ''
2194
+ const textBeforeCursor = currentLine.slice(0, this.state.cursorCol)
2195
+ const isSymbolAutocompleteContext = /(?:^|[ \t])(?:@(?:"[^"]*|[^\s]*)|#[^\s]*)$/.test(
2196
+ textBeforeCursor,
2197
+ )
2198
+ return isSymbolAutocompleteContext ? ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS : 0
2199
+ }
2200
+
2201
+ private async runAutocompleteRequest(
2202
+ requestId: number,
2203
+ controller: AbortController,
2204
+ snapshotText: string,
2205
+ snapshotLine: number,
2206
+ snapshotCol: number,
2207
+ options: { force: boolean; explicitTab: boolean },
2208
+ ): Promise<void> {
2209
+ if (!this.autocompleteProvider) return
2210
+
2211
+ const suggestions = await this.autocompleteProvider.getSuggestions(
2212
+ this.state.lines,
2213
+ this.state.cursorLine,
2214
+ this.state.cursorCol,
2215
+ { signal: controller.signal, force: options.force },
2216
+ )
2217
+
2218
+ if (
2219
+ !this.isAutocompleteRequestCurrent(
2220
+ requestId,
2221
+ controller,
2222
+ snapshotText,
2223
+ snapshotLine,
2224
+ snapshotCol,
2225
+ )
2226
+ ) {
2227
+ return
2228
+ }
2229
+
2230
+ this.autocompleteAbort = undefined
2231
+
2232
+ if (!suggestions || !Array.isArray(suggestions.items) || suggestions.items.length === 0) {
2233
+ this.cancelAutocomplete()
2234
+ this.tui.requestRender()
2235
+ return
2236
+ }
2237
+
2238
+ if (options.force && options.explicitTab && suggestions.items.length === 1) {
2239
+ const item = suggestions.items[0]!
2240
+ this.pushUndoSnapshot()
2241
+ this.lastAction = null
2242
+ const result = this.autocompleteProvider.applyCompletion(
2243
+ this.state.lines,
2244
+ this.state.cursorLine,
2245
+ this.state.cursorCol,
2246
+ item,
2247
+ suggestions.prefix,
2248
+ )
2249
+ this.state.lines = result.lines
2250
+ this.state.cursorLine = result.cursorLine
2251
+ this.setCursorCol(result.cursorCol)
2252
+ if (this.onChange) this.onChange(this.getText())
2253
+ this.tui.requestRender()
2254
+ return
2255
+ }
2256
+
2257
+ this.applyAutocompleteSuggestions(suggestions, options.force ? 'force' : 'regular')
2258
+ this.tui.requestRender()
2259
+ }
2260
+
2261
+ private isAutocompleteRequestCurrent(
2262
+ requestId: number,
2263
+ controller: AbortController,
2264
+ snapshotText: string,
2265
+ snapshotLine: number,
2266
+ snapshotCol: number,
2267
+ ): boolean {
2268
+ return (
2269
+ !controller.signal.aborted &&
2270
+ requestId === this.autocompleteRequestId &&
2271
+ this.getText() === snapshotText &&
2272
+ this.state.cursorLine === snapshotLine &&
2273
+ this.state.cursorCol === snapshotCol
2274
+ )
2275
+ }
2276
+
2277
+ private applyAutocompleteSuggestions(
2278
+ suggestions: AutocompleteSuggestions,
2279
+ state: 'regular' | 'force',
2280
+ ): void {
2281
+ this.autocompletePrefix = suggestions.prefix
2282
+ this.autocompleteList = this.createAutocompleteList(suggestions.prefix, suggestions.items)
2283
+
2284
+ const bestMatchIndex = this.getBestAutocompleteMatchIndex(suggestions.items, suggestions.prefix)
2285
+ if (bestMatchIndex >= 0) {
2286
+ this.autocompleteList.setSelectedIndex(bestMatchIndex)
2287
+ }
2288
+
2289
+ this.autocompleteState = state
2290
+ }
2291
+
2292
+ private cancelAutocompleteRequest(): void {
2293
+ this.autocompleteStartToken += 1
2294
+ if (this.autocompleteDebounceTimer) {
2295
+ clearTimeout(this.autocompleteDebounceTimer)
2296
+ this.autocompleteDebounceTimer = undefined
2297
+ }
2298
+ this.autocompleteAbort?.abort()
2299
+ this.autocompleteAbort = undefined
2300
+ }
2301
+
2302
+ private clearAutocompleteUi(): void {
2303
+ this.autocompleteState = null
2304
+ this.autocompleteList = undefined
2305
+ this.autocompletePrefix = ''
2306
+ }
2307
+
2308
+ private cancelAutocomplete(): void {
2309
+ this.cancelAutocompleteRequest()
2310
+ this.clearAutocompleteUi()
2311
+ }
2312
+
2313
+ public isShowingAutocomplete(): boolean {
2314
+ return this.autocompleteState !== null
2315
+ }
2316
+
2317
+ private updateAutocomplete(): void {
2318
+ if (!this.autocompleteState || !this.autocompleteProvider) return
2319
+ this.requestAutocomplete({ force: this.autocompleteState === 'force', explicitTab: false })
2320
+ }
2321
+ }