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,130 @@
1
+ import {
2
+ allocateImageId,
3
+ getCapabilities,
4
+ getCellDimensions,
5
+ getImageDimensions,
6
+ type ImageDimensions,
7
+ imageFallback,
8
+ renderImage,
9
+ } from '../terminal-image.js'
10
+ import type { Component } from '../tui.js'
11
+
12
+ export interface ImageTheme {
13
+ fallbackColor: (str: string) => string
14
+ }
15
+
16
+ export interface ImageOptions {
17
+ maxWidthCells?: number
18
+ maxHeightCells?: number
19
+ filename?: string
20
+ /** Kitty image ID. If provided, reuses this ID (for animations/updates). */
21
+ imageId?: number
22
+ }
23
+
24
+ export class Image implements Component {
25
+ private base64Data: string
26
+ private mimeType: string
27
+ private dimensions: ImageDimensions
28
+ private theme: ImageTheme
29
+ private options: ImageOptions
30
+ private imageId?: number
31
+
32
+ private cachedLines?: string[]
33
+ private cachedWidth?: number
34
+
35
+ constructor(
36
+ base64Data: string,
37
+ mimeType: string,
38
+ theme: ImageTheme,
39
+ options: ImageOptions = {},
40
+ dimensions?: ImageDimensions,
41
+ ) {
42
+ this.base64Data = base64Data
43
+ this.mimeType = mimeType
44
+ this.theme = theme
45
+ this.options = options
46
+ this.dimensions = dimensions ||
47
+ getImageDimensions(base64Data, mimeType) || { widthPx: 800, heightPx: 600 }
48
+ this.imageId = options.imageId
49
+ }
50
+
51
+ /** Get the Kitty image ID used by this image (if any). */
52
+ getImageId(): number | undefined {
53
+ return this.imageId
54
+ }
55
+
56
+ invalidate(): void {
57
+ this.cachedLines = undefined
58
+ this.cachedWidth = undefined
59
+ }
60
+
61
+ render(width: number): string[] {
62
+ if (this.cachedLines && this.cachedWidth === width) {
63
+ return this.cachedLines
64
+ }
65
+
66
+ const maxWidth = Math.max(1, Math.min(width - 2, this.options.maxWidthCells ?? 60))
67
+ const cellDimensions = getCellDimensions()
68
+ const defaultMaxHeight = Math.max(
69
+ 1,
70
+ Math.ceil((maxWidth * cellDimensions.widthPx) / cellDimensions.heightPx),
71
+ )
72
+ const maxHeight = this.options.maxHeightCells ?? defaultMaxHeight
73
+
74
+ const caps = getCapabilities()
75
+ let lines: string[]
76
+
77
+ if (caps.images) {
78
+ if (caps.images === 'kitty' && this.imageId === undefined) {
79
+ this.imageId = allocateImageId()
80
+ }
81
+ const result = renderImage(this.base64Data, this.dimensions, {
82
+ maxWidthCells: maxWidth,
83
+ maxHeightCells: maxHeight,
84
+ imageId: this.imageId,
85
+ moveCursor: false,
86
+ })
87
+
88
+ if (result) {
89
+ // Store the image ID for later cleanup
90
+ if (result.imageId) {
91
+ this.imageId = result.imageId
92
+ }
93
+
94
+ if (caps.images === 'kitty') {
95
+ // For Kitty: C=1 prevents cursor movement.
96
+ // Don't need the cursor movement.
97
+ lines = [result.sequence]
98
+
99
+ // Return `rows` lines so TUI accounts for image height.
100
+ for (let i = 0; i < result.rows - 1; i++) {
101
+ lines.push('')
102
+ }
103
+ } else {
104
+ // Return `rows` lines so TUI accounts for image height.
105
+ // First (rows-1) lines are empty and cleared before the image is drawn.
106
+ // Last line: move cursor back up, draw the image, then move back down
107
+ // so TUI cursor accounting stays inside the scroll area.
108
+ lines = []
109
+ for (let i = 0; i < result.rows - 1; i++) {
110
+ lines.push('')
111
+ }
112
+ const rowOffset = result.rows - 1
113
+ const moveUp = rowOffset > 0 ? `\x1b[${rowOffset}A` : ''
114
+ lines.push(moveUp + result.sequence)
115
+ }
116
+ } else {
117
+ const fallback = imageFallback(this.mimeType, this.dimensions, this.options.filename)
118
+ lines = [this.theme.fallbackColor(fallback)]
119
+ }
120
+ } else {
121
+ const fallback = imageFallback(this.mimeType, this.dimensions, this.options.filename)
122
+ lines = [this.theme.fallbackColor(fallback)]
123
+ }
124
+
125
+ this.cachedLines = lines
126
+ this.cachedWidth = width
127
+
128
+ return lines
129
+ }
130
+ }
@@ -0,0 +1,456 @@
1
+ import { getKeybindings } from '../keybindings.js'
2
+ import { decodeKittyPrintable } from '../keys.js'
3
+ import { KillRing } from '../kill-ring.js'
4
+ import { type Component, CURSOR_MARKER, type Focusable } from '../tui.js'
5
+ import { UndoStack } from '../undo-stack.js'
6
+ import { getGraphemeSegmenter, isWhitespaceChar, sliceByColumn, visibleWidth } from '../utils.js'
7
+ import { findWordBackward, findWordForward } from '../word-navigation.js'
8
+
9
+ const segmenter = getGraphemeSegmenter()
10
+
11
+ interface InputState {
12
+ value: string
13
+ cursor: number
14
+ }
15
+
16
+ /**
17
+ * Input component - single-line text input with horizontal scrolling
18
+ */
19
+ export class Input implements Component, Focusable {
20
+ private value: string = ''
21
+ private cursor: number = 0 // Cursor position in the value
22
+ public onSubmit?: (value: string) => void
23
+ public onEscape?: () => void
24
+
25
+ /** Focusable interface - set by TUI when focus changes */
26
+ focused: boolean = false
27
+
28
+ // Bracketed paste mode buffering
29
+ private pasteBuffer: string = ''
30
+ private isInPaste: boolean = false
31
+
32
+ // Kill ring for Emacs-style kill/yank operations
33
+ private killRing = new KillRing()
34
+ private lastAction: 'kill' | 'yank' | 'type-word' | null = null
35
+
36
+ // Undo support
37
+ private undoStack = new UndoStack<InputState>()
38
+
39
+ getValue(): string {
40
+ return this.value
41
+ }
42
+
43
+ setValue(value: string): void {
44
+ this.value = value
45
+ this.cursor = Math.min(this.cursor, value.length)
46
+ }
47
+
48
+ handleInput(data: string): void {
49
+ // Handle bracketed paste mode
50
+ // Start of paste: \x1b[200~
51
+ // End of paste: \x1b[201~
52
+
53
+ // Check if we're starting a bracketed paste
54
+ if (data.includes('\x1b[200~')) {
55
+ this.isInPaste = true
56
+ this.pasteBuffer = ''
57
+ data = data.replace('\x1b[200~', '')
58
+ }
59
+
60
+ // If we're in a paste, buffer the data
61
+ if (this.isInPaste) {
62
+ // Check if this chunk contains the end marker
63
+ this.pasteBuffer += data
64
+
65
+ const endIndex = this.pasteBuffer.indexOf('\x1b[201~')
66
+ if (endIndex !== -1) {
67
+ // Extract the pasted content
68
+ const pasteContent = this.pasteBuffer.substring(0, endIndex)
69
+
70
+ // Process the complete paste
71
+ this.handlePaste(pasteContent)
72
+
73
+ // Reset paste state
74
+ this.isInPaste = false
75
+
76
+ // Handle any remaining input after the paste marker
77
+ const remaining = this.pasteBuffer.substring(endIndex + 6) // 6 = length of \x1b[201~
78
+ this.pasteBuffer = ''
79
+ if (remaining) {
80
+ this.handleInput(remaining)
81
+ }
82
+ }
83
+ return
84
+ }
85
+
86
+ const kb = getKeybindings()
87
+
88
+ // Escape/Cancel
89
+ if (kb.matches(data, 'tui.select.cancel')) {
90
+ if (this.onEscape) this.onEscape()
91
+ return
92
+ }
93
+
94
+ // Undo
95
+ if (kb.matches(data, 'tui.editor.undo')) {
96
+ this.undo()
97
+ return
98
+ }
99
+
100
+ // Submit
101
+ if (kb.matches(data, 'tui.input.submit') || data === '\n') {
102
+ if (this.onSubmit) this.onSubmit(this.value)
103
+ return
104
+ }
105
+
106
+ // Deletion
107
+ if (kb.matches(data, 'tui.editor.deleteCharBackward')) {
108
+ this.handleBackspace()
109
+ return
110
+ }
111
+
112
+ if (kb.matches(data, 'tui.editor.deleteCharForward')) {
113
+ this.handleForwardDelete()
114
+ return
115
+ }
116
+
117
+ if (kb.matches(data, 'tui.editor.deleteWordBackward')) {
118
+ this.deleteWordBackwards()
119
+ return
120
+ }
121
+
122
+ if (kb.matches(data, 'tui.editor.deleteWordForward')) {
123
+ this.deleteWordForward()
124
+ return
125
+ }
126
+
127
+ if (kb.matches(data, 'tui.editor.deleteToLineStart')) {
128
+ this.deleteToLineStart()
129
+ return
130
+ }
131
+
132
+ if (kb.matches(data, 'tui.editor.deleteToLineEnd')) {
133
+ this.deleteToLineEnd()
134
+ return
135
+ }
136
+
137
+ // Kill ring actions
138
+ if (kb.matches(data, 'tui.editor.yank')) {
139
+ this.yank()
140
+ return
141
+ }
142
+ if (kb.matches(data, 'tui.editor.yankPop')) {
143
+ this.yankPop()
144
+ return
145
+ }
146
+
147
+ // Cursor movement
148
+ if (kb.matches(data, 'tui.editor.cursorLeft')) {
149
+ this.lastAction = null
150
+ if (this.cursor > 0) {
151
+ const beforeCursor = this.value.slice(0, this.cursor)
152
+ const graphemes = [...segmenter.segment(beforeCursor)]
153
+ const lastGrapheme = graphemes[graphemes.length - 1]
154
+ this.cursor -= lastGrapheme ? lastGrapheme.segment.length : 1
155
+ }
156
+ return
157
+ }
158
+
159
+ if (kb.matches(data, 'tui.editor.cursorRight')) {
160
+ this.lastAction = null
161
+ if (this.cursor < this.value.length) {
162
+ const afterCursor = this.value.slice(this.cursor)
163
+ const graphemes = [...segmenter.segment(afterCursor)]
164
+ const firstGrapheme = graphemes[0]
165
+ this.cursor += firstGrapheme ? firstGrapheme.segment.length : 1
166
+ }
167
+ return
168
+ }
169
+
170
+ if (kb.matches(data, 'tui.editor.cursorLineStart')) {
171
+ this.lastAction = null
172
+ this.cursor = 0
173
+ return
174
+ }
175
+
176
+ if (kb.matches(data, 'tui.editor.cursorLineEnd')) {
177
+ this.lastAction = null
178
+ this.cursor = this.value.length
179
+ return
180
+ }
181
+
182
+ if (kb.matches(data, 'tui.editor.cursorWordLeft')) {
183
+ this.moveWordBackwards()
184
+ return
185
+ }
186
+
187
+ if (kb.matches(data, 'tui.editor.cursorWordRight')) {
188
+ this.moveWordForwards()
189
+ return
190
+ }
191
+
192
+ // Kitty CSI-u printable character (e.g. \x1b[97u for 'a').
193
+ // Terminals with Kitty protocol flag 1 (disambiguate) send CSI-u for all keys,
194
+ // including plain printable characters. Decode before the control-char check
195
+ // since CSI-u sequences contain \x1b which would be rejected.
196
+ const kittyPrintable = decodeKittyPrintable(data)
197
+ if (kittyPrintable !== undefined) {
198
+ this.insertCharacter(kittyPrintable)
199
+ return
200
+ }
201
+
202
+ // Regular character input - accept printable characters including Unicode,
203
+ // but reject control characters (C0: 0x00-0x1F, DEL: 0x7F, C1: 0x80-0x9F)
204
+ const hasControlChars = [...data].some(ch => {
205
+ const code = ch.charCodeAt(0)
206
+ return code < 32 || code === 0x7f || (code >= 0x80 && code <= 0x9f)
207
+ })
208
+ if (!hasControlChars) {
209
+ this.insertCharacter(data)
210
+ }
211
+ }
212
+
213
+ private insertCharacter(char: string): void {
214
+ // Undo coalescing: consecutive word chars coalesce into one undo unit
215
+ if (isWhitespaceChar(char) || this.lastAction !== 'type-word') {
216
+ this.pushUndo()
217
+ }
218
+ this.lastAction = 'type-word'
219
+
220
+ this.value = this.value.slice(0, this.cursor) + char + this.value.slice(this.cursor)
221
+ this.cursor += char.length
222
+ }
223
+
224
+ private handleBackspace(): void {
225
+ this.lastAction = null
226
+ if (this.cursor > 0) {
227
+ this.pushUndo()
228
+ const beforeCursor = this.value.slice(0, this.cursor)
229
+ const graphemes = [...segmenter.segment(beforeCursor)]
230
+ const lastGrapheme = graphemes[graphemes.length - 1]
231
+ const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1
232
+ this.value = this.value.slice(0, this.cursor - graphemeLength) + this.value.slice(this.cursor)
233
+ this.cursor -= graphemeLength
234
+ }
235
+ }
236
+
237
+ private handleForwardDelete(): void {
238
+ this.lastAction = null
239
+ if (this.cursor < this.value.length) {
240
+ this.pushUndo()
241
+ const afterCursor = this.value.slice(this.cursor)
242
+ const graphemes = [...segmenter.segment(afterCursor)]
243
+ const firstGrapheme = graphemes[0]
244
+ const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1
245
+ this.value = this.value.slice(0, this.cursor) + this.value.slice(this.cursor + graphemeLength)
246
+ }
247
+ }
248
+
249
+ private deleteToLineStart(): void {
250
+ if (this.cursor === 0) return
251
+ this.pushUndo()
252
+ const deletedText = this.value.slice(0, this.cursor)
253
+ this.killRing.push(deletedText, { prepend: true, accumulate: this.lastAction === 'kill' })
254
+ this.lastAction = 'kill'
255
+ this.value = this.value.slice(this.cursor)
256
+ this.cursor = 0
257
+ }
258
+
259
+ private deleteToLineEnd(): void {
260
+ if (this.cursor >= this.value.length) return
261
+ this.pushUndo()
262
+ const deletedText = this.value.slice(this.cursor)
263
+ this.killRing.push(deletedText, { prepend: false, accumulate: this.lastAction === 'kill' })
264
+ this.lastAction = 'kill'
265
+ this.value = this.value.slice(0, this.cursor)
266
+ }
267
+
268
+ private deleteWordBackwards(): void {
269
+ if (this.cursor === 0) return
270
+
271
+ // Save lastAction before cursor movement (moveWordBackwards resets it)
272
+ const wasKill = this.lastAction === 'kill'
273
+
274
+ this.pushUndo()
275
+
276
+ const oldCursor = this.cursor
277
+ this.moveWordBackwards()
278
+ const deleteFrom = this.cursor
279
+ this.cursor = oldCursor
280
+
281
+ const deletedText = this.value.slice(deleteFrom, this.cursor)
282
+ this.killRing.push(deletedText, { prepend: true, accumulate: wasKill })
283
+ this.lastAction = 'kill'
284
+
285
+ this.value = this.value.slice(0, deleteFrom) + this.value.slice(this.cursor)
286
+ this.cursor = deleteFrom
287
+ }
288
+
289
+ private deleteWordForward(): void {
290
+ if (this.cursor >= this.value.length) return
291
+
292
+ // Save lastAction before cursor movement (moveWordForwards resets it)
293
+ const wasKill = this.lastAction === 'kill'
294
+
295
+ this.pushUndo()
296
+
297
+ const oldCursor = this.cursor
298
+ this.moveWordForwards()
299
+ const deleteTo = this.cursor
300
+ this.cursor = oldCursor
301
+
302
+ const deletedText = this.value.slice(this.cursor, deleteTo)
303
+ this.killRing.push(deletedText, { prepend: false, accumulate: wasKill })
304
+ this.lastAction = 'kill'
305
+
306
+ this.value = this.value.slice(0, this.cursor) + this.value.slice(deleteTo)
307
+ }
308
+
309
+ private yank(): void {
310
+ const text = this.killRing.peek()
311
+ if (!text) return
312
+
313
+ this.pushUndo()
314
+
315
+ this.value = this.value.slice(0, this.cursor) + text + this.value.slice(this.cursor)
316
+ this.cursor += text.length
317
+ this.lastAction = 'yank'
318
+ }
319
+
320
+ private yankPop(): void {
321
+ if (this.lastAction !== 'yank' || this.killRing.length <= 1) return
322
+
323
+ this.pushUndo()
324
+
325
+ // Delete the previously yanked text (still at end of ring before rotation)
326
+ const prevText = this.killRing.peek() || ''
327
+ this.value = this.value.slice(0, this.cursor - prevText.length) + this.value.slice(this.cursor)
328
+ this.cursor -= prevText.length
329
+
330
+ // Rotate and insert new entry
331
+ this.killRing.rotate()
332
+ const text = this.killRing.peek() || ''
333
+ this.value = this.value.slice(0, this.cursor) + text + this.value.slice(this.cursor)
334
+ this.cursor += text.length
335
+ this.lastAction = 'yank'
336
+ }
337
+
338
+ private pushUndo(): void {
339
+ this.undoStack.push({ value: this.value, cursor: this.cursor })
340
+ }
341
+
342
+ private undo(): void {
343
+ const snapshot = this.undoStack.pop()
344
+ if (!snapshot) return
345
+ this.value = snapshot.value
346
+ this.cursor = snapshot.cursor
347
+ this.lastAction = null
348
+ }
349
+
350
+ private moveWordBackwards(): void {
351
+ if (this.cursor === 0) return
352
+ this.lastAction = null
353
+ this.cursor = findWordBackward(this.value, this.cursor)
354
+ }
355
+
356
+ private moveWordForwards(): void {
357
+ if (this.cursor >= this.value.length) return
358
+ this.lastAction = null
359
+ this.cursor = findWordForward(this.value, this.cursor)
360
+ }
361
+
362
+ private handlePaste(pastedText: string): void {
363
+ this.lastAction = null
364
+ this.pushUndo()
365
+
366
+ // Clean the pasted text - remove newlines and carriage returns
367
+ const cleanText = pastedText
368
+ .replace(/\r\n/g, '')
369
+ .replace(/\r/g, '')
370
+ .replace(/\n/g, '')
371
+ .replace(/\t/g, ' ')
372
+
373
+ // Insert at cursor position
374
+ this.value = this.value.slice(0, this.cursor) + cleanText + this.value.slice(this.cursor)
375
+ this.cursor += cleanText.length
376
+ }
377
+
378
+ invalidate(): void {
379
+ // No cached state to invalidate currently
380
+ }
381
+
382
+ render(width: number): string[] {
383
+ // Calculate visible window
384
+ const prompt = '> '
385
+ const availableWidth = width - prompt.length
386
+
387
+ if (availableWidth <= 0) {
388
+ return [prompt]
389
+ }
390
+
391
+ let visibleText = ''
392
+ let cursorDisplay = this.cursor
393
+ const totalWidth = visibleWidth(this.value)
394
+
395
+ if (totalWidth < availableWidth) {
396
+ // Everything fits (leave room for cursor at end)
397
+ visibleText = this.value
398
+ } else {
399
+ // Need horizontal scrolling
400
+ // Reserve one column for cursor if it's at the end
401
+ const scrollWidth = this.cursor === this.value.length ? availableWidth - 1 : availableWidth
402
+ const cursorCol = visibleWidth(this.value.slice(0, this.cursor))
403
+
404
+ if (scrollWidth > 0) {
405
+ const halfWidth = Math.floor(scrollWidth / 2)
406
+ let startCol = 0
407
+
408
+ if (cursorCol < halfWidth) {
409
+ // Cursor near start
410
+ startCol = 0
411
+ } else if (cursorCol > totalWidth - halfWidth) {
412
+ // Cursor near end
413
+ startCol = Math.max(0, totalWidth - scrollWidth)
414
+ } else {
415
+ // Cursor in middle
416
+ startCol = Math.max(0, cursorCol - halfWidth)
417
+ }
418
+
419
+ visibleText = sliceByColumn(this.value, startCol, scrollWidth, true)
420
+ const beforeCursor = sliceByColumn(
421
+ this.value,
422
+ startCol,
423
+ Math.max(0, cursorCol - startCol),
424
+ true,
425
+ )
426
+ cursorDisplay = beforeCursor.length
427
+ } else {
428
+ visibleText = ''
429
+ cursorDisplay = 0
430
+ }
431
+ }
432
+
433
+ // Build line with fake cursor
434
+ // Insert cursor character at cursor position
435
+ const graphemes = [...segmenter.segment(visibleText.slice(cursorDisplay))]
436
+ const cursorGrapheme = graphemes[0]
437
+
438
+ const beforeCursor = visibleText.slice(0, cursorDisplay)
439
+ const atCursor = cursorGrapheme?.segment ?? ' ' // Character at cursor, or space if at end
440
+ const afterCursor = visibleText.slice(cursorDisplay + atCursor.length)
441
+
442
+ // Hardware cursor marker (zero-width, emitted before fake cursor for IME positioning)
443
+ const marker = this.focused ? CURSOR_MARKER : ''
444
+
445
+ // Use inverse video to show cursor
446
+ const cursorChar = `\x1b[7m${atCursor}\x1b[27m` // ESC[7m = reverse video, ESC[27m = normal
447
+ const textWithCursor = beforeCursor + marker + cursorChar + afterCursor
448
+
449
+ // Calculate visual width
450
+ const visualLength = visibleWidth(textWithCursor)
451
+ const padding = ' '.repeat(Math.max(0, availableWidth - visualLength))
452
+ const line = prompt + textWithCursor + padding
453
+
454
+ return [line]
455
+ }
456
+ }
@@ -0,0 +1,93 @@
1
+ import type { TUI } from '../tui.js'
2
+ import { Text } from './text.js'
3
+
4
+ export interface LoaderIndicatorOptions {
5
+ /** Animation frames. Use an empty array to hide the indicator. */
6
+ frames?: string[]
7
+ /** Frame interval in milliseconds for animated indicators. */
8
+ intervalMs?: number
9
+ }
10
+
11
+ const DEFAULT_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
12
+ const DEFAULT_INTERVAL_MS = 80
13
+
14
+ /**
15
+ * Loader component that updates with an optional spinning animation.
16
+ */
17
+ export class Loader extends Text {
18
+ private frames = [...DEFAULT_FRAMES]
19
+ private intervalMs = DEFAULT_INTERVAL_MS
20
+ private currentFrame = 0
21
+ private intervalId: NodeJS.Timeout | null = null
22
+ private ui: TUI | null = null
23
+ private renderIndicatorVerbatim = false
24
+ private spinnerColorFn: (str: string) => string
25
+ private messageColorFn: (str: string) => string
26
+ private message: string = 'Loading...'
27
+
28
+ constructor(
29
+ ui: TUI,
30
+ spinnerColorFn: (str: string) => string,
31
+ messageColorFn: (str: string) => string,
32
+ message: string = 'Loading...',
33
+ indicator?: LoaderIndicatorOptions,
34
+ ) {
35
+ super('', 1, 0)
36
+ this.ui = ui
37
+ this.spinnerColorFn = spinnerColorFn
38
+ this.messageColorFn = messageColorFn
39
+ this.message = message
40
+ this.setIndicator(indicator)
41
+ }
42
+
43
+ render(width: number): string[] {
44
+ return ['', ...super.render(width)]
45
+ }
46
+
47
+ start(): void {
48
+ this.updateDisplay()
49
+ this.restartAnimation()
50
+ }
51
+
52
+ stop(): void {
53
+ if (this.intervalId) {
54
+ clearInterval(this.intervalId)
55
+ this.intervalId = null
56
+ }
57
+ }
58
+
59
+ setMessage(message: string): void {
60
+ this.message = message
61
+ this.updateDisplay()
62
+ }
63
+
64
+ setIndicator(indicator?: LoaderIndicatorOptions): void {
65
+ this.renderIndicatorVerbatim = indicator !== undefined
66
+ this.frames = indicator?.frames !== undefined ? [...indicator.frames] : [...DEFAULT_FRAMES]
67
+ this.intervalMs =
68
+ indicator?.intervalMs && indicator.intervalMs > 0 ? indicator.intervalMs : DEFAULT_INTERVAL_MS
69
+ this.currentFrame = 0
70
+ this.start()
71
+ }
72
+
73
+ private restartAnimation(): void {
74
+ this.stop()
75
+ if (this.frames.length <= 1) {
76
+ return
77
+ }
78
+ this.intervalId = setInterval(() => {
79
+ this.currentFrame = (this.currentFrame + 1) % this.frames.length
80
+ this.updateDisplay()
81
+ }, this.intervalMs)
82
+ }
83
+
84
+ private updateDisplay(): void {
85
+ const frame = this.frames[this.currentFrame] ?? ''
86
+ const renderedFrame = this.renderIndicatorVerbatim ? frame : this.spinnerColorFn(frame)
87
+ const indicator = frame.length > 0 ? `${renderedFrame} ` : ''
88
+ this.setText(`${indicator}${this.messageColorFn(this.message)}`)
89
+ if (this.ui) {
90
+ this.ui.requestRender()
91
+ }
92
+ }
93
+ }