lonny-agent 0.1.2 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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,263 @@
1
+ import { getKeybindings } from '../keybindings.js'
2
+ import type { Component } from '../tui.js'
3
+ import { truncateToWidth, visibleWidth } from '../utils.js'
4
+
5
+ const DEFAULT_PRIMARY_COLUMN_WIDTH = 32
6
+ const PRIMARY_COLUMN_GAP = 2
7
+ const MIN_DESCRIPTION_WIDTH = 10
8
+
9
+ const normalizeToSingleLine = (text: string): string => text.replace(/[\r\n]+/g, ' ').trim()
10
+ const clamp = (value: number, min: number, max: number): number =>
11
+ Math.max(min, Math.min(value, max))
12
+
13
+ export interface SelectItem {
14
+ value: string
15
+ label: string
16
+ description?: string
17
+ }
18
+
19
+ export interface SelectListTheme {
20
+ selectedPrefix: (text: string) => string
21
+ selectedText: (text: string) => string
22
+ description: (text: string) => string
23
+ scrollInfo: (text: string) => string
24
+ noMatch: (text: string) => string
25
+ }
26
+
27
+ export interface SelectListTruncatePrimaryContext {
28
+ text: string
29
+ maxWidth: number
30
+ columnWidth: number
31
+ item: SelectItem
32
+ isSelected: boolean
33
+ }
34
+
35
+ export interface SelectListLayoutOptions {
36
+ minPrimaryColumnWidth?: number
37
+ maxPrimaryColumnWidth?: number
38
+ truncatePrimary?: (context: SelectListTruncatePrimaryContext) => string
39
+ }
40
+
41
+ export class SelectList implements Component {
42
+ private items: SelectItem[] = []
43
+ private filteredItems: SelectItem[] = []
44
+ private selectedIndex: number = 0
45
+ private maxVisible: number = 5
46
+ private theme: SelectListTheme
47
+ private layout: SelectListLayoutOptions
48
+
49
+ public onSelect?: (item: SelectItem) => void
50
+ public onCancel?: () => void
51
+ public onSelectionChange?: (item: SelectItem) => void
52
+
53
+ constructor(
54
+ items: SelectItem[],
55
+ maxVisible: number,
56
+ theme: SelectListTheme,
57
+ layout: SelectListLayoutOptions = {},
58
+ ) {
59
+ this.items = items
60
+ this.filteredItems = items
61
+ this.maxVisible = maxVisible
62
+ this.theme = theme
63
+ this.layout = layout
64
+ }
65
+
66
+ setFilter(filter: string): void {
67
+ this.filteredItems = this.items.filter(item =>
68
+ item.value.toLowerCase().startsWith(filter.toLowerCase()),
69
+ )
70
+ // Reset selection when filter changes
71
+ this.selectedIndex = 0
72
+ }
73
+
74
+ setSelectedIndex(index: number): void {
75
+ this.selectedIndex = Math.max(0, Math.min(index, this.filteredItems.length - 1))
76
+ }
77
+
78
+ invalidate(): void {
79
+ // No cached state to invalidate currently
80
+ }
81
+
82
+ render(width: number): string[] {
83
+ const lines: string[] = []
84
+
85
+ // If no items match filter, show message
86
+ if (this.filteredItems.length === 0) {
87
+ lines.push(this.theme.noMatch(' No matching commands'))
88
+ return lines
89
+ }
90
+
91
+ const primaryColumnWidth = this.getPrimaryColumnWidth()
92
+
93
+ // Calculate visible range with scrolling
94
+ const startIndex = Math.max(
95
+ 0,
96
+ Math.min(
97
+ this.selectedIndex - Math.floor(this.maxVisible / 2),
98
+ this.filteredItems.length - this.maxVisible,
99
+ ),
100
+ )
101
+ const endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length)
102
+
103
+ // Render visible items
104
+ for (let i = startIndex; i < endIndex; i++) {
105
+ const item = this.filteredItems[i]
106
+ if (!item) continue
107
+
108
+ const isSelected = i === this.selectedIndex
109
+ const descriptionSingleLine = item.description
110
+ ? normalizeToSingleLine(item.description)
111
+ : undefined
112
+ lines.push(
113
+ this.renderItem(item, isSelected, width, descriptionSingleLine, primaryColumnWidth),
114
+ )
115
+ }
116
+
117
+ // Add scroll indicators if needed
118
+ if (startIndex > 0 || endIndex < this.filteredItems.length) {
119
+ const scrollText = ` (${this.selectedIndex + 1}/${this.filteredItems.length})`
120
+ // Truncate if too long for terminal
121
+ lines.push(this.theme.scrollInfo(truncateToWidth(scrollText, width - 2, '')))
122
+ }
123
+
124
+ return lines
125
+ }
126
+
127
+ handleInput(keyData: string): void {
128
+ const kb = getKeybindings()
129
+ // Up arrow - wrap to bottom when at top
130
+ if (kb.matches(keyData, 'tui.select.up')) {
131
+ this.selectedIndex =
132
+ this.selectedIndex === 0 ? this.filteredItems.length - 1 : this.selectedIndex - 1
133
+ this.notifySelectionChange()
134
+ }
135
+ // Down arrow - wrap to top when at bottom
136
+ else if (kb.matches(keyData, 'tui.select.down')) {
137
+ this.selectedIndex =
138
+ this.selectedIndex === this.filteredItems.length - 1 ? 0 : this.selectedIndex + 1
139
+ this.notifySelectionChange()
140
+ }
141
+ // Enter
142
+ else if (kb.matches(keyData, 'tui.select.confirm')) {
143
+ const selectedItem = this.filteredItems[this.selectedIndex]
144
+ if (selectedItem && this.onSelect) {
145
+ this.onSelect(selectedItem)
146
+ }
147
+ }
148
+ // Escape or Ctrl+C
149
+ else if (kb.matches(keyData, 'tui.select.cancel')) {
150
+ if (this.onCancel) {
151
+ this.onCancel()
152
+ }
153
+ }
154
+ }
155
+
156
+ private renderItem(
157
+ item: SelectItem,
158
+ isSelected: boolean,
159
+ width: number,
160
+ descriptionSingleLine: string | undefined,
161
+ primaryColumnWidth: number,
162
+ ): string {
163
+ const prefix = isSelected ? '→ ' : ' '
164
+ const prefixWidth = visibleWidth(prefix)
165
+
166
+ if (descriptionSingleLine && width > 40) {
167
+ const effectivePrimaryColumnWidth = Math.max(
168
+ 1,
169
+ Math.min(primaryColumnWidth, width - prefixWidth - 4),
170
+ )
171
+ const maxPrimaryWidth = Math.max(1, effectivePrimaryColumnWidth - PRIMARY_COLUMN_GAP)
172
+ const truncatedValue = this.truncatePrimary(
173
+ item,
174
+ isSelected,
175
+ maxPrimaryWidth,
176
+ effectivePrimaryColumnWidth,
177
+ )
178
+ const truncatedValueWidth = visibleWidth(truncatedValue)
179
+ const spacing = ' '.repeat(Math.max(1, effectivePrimaryColumnWidth - truncatedValueWidth))
180
+ const descriptionStart = prefixWidth + truncatedValueWidth + spacing.length
181
+ const remainingWidth = width - descriptionStart - 2 // -2 for safety
182
+
183
+ if (remainingWidth > MIN_DESCRIPTION_WIDTH) {
184
+ const truncatedDesc = truncateToWidth(descriptionSingleLine, remainingWidth, '')
185
+ if (isSelected) {
186
+ return this.theme.selectedText(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`)
187
+ }
188
+
189
+ const descText = this.theme.description(spacing + truncatedDesc)
190
+ return prefix + truncatedValue + descText
191
+ }
192
+ }
193
+
194
+ const maxWidth = width - prefixWidth - 2
195
+ const truncatedValue = this.truncatePrimary(item, isSelected, maxWidth, maxWidth)
196
+ if (isSelected) {
197
+ return this.theme.selectedText(`${prefix}${truncatedValue}`)
198
+ }
199
+
200
+ return prefix + truncatedValue
201
+ }
202
+
203
+ private getPrimaryColumnWidth(): number {
204
+ const { min, max } = this.getPrimaryColumnBounds()
205
+ const widestPrimary = this.filteredItems.reduce((widest, item) => {
206
+ return Math.max(widest, visibleWidth(this.getDisplayValue(item)) + PRIMARY_COLUMN_GAP)
207
+ }, 0)
208
+
209
+ return clamp(widestPrimary, min, max)
210
+ }
211
+
212
+ private getPrimaryColumnBounds(): { min: number; max: number } {
213
+ const rawMin =
214
+ this.layout.minPrimaryColumnWidth ??
215
+ this.layout.maxPrimaryColumnWidth ??
216
+ DEFAULT_PRIMARY_COLUMN_WIDTH
217
+ const rawMax =
218
+ this.layout.maxPrimaryColumnWidth ??
219
+ this.layout.minPrimaryColumnWidth ??
220
+ DEFAULT_PRIMARY_COLUMN_WIDTH
221
+
222
+ return {
223
+ min: Math.max(1, Math.min(rawMin, rawMax)),
224
+ max: Math.max(1, Math.max(rawMin, rawMax)),
225
+ }
226
+ }
227
+
228
+ private truncatePrimary(
229
+ item: SelectItem,
230
+ isSelected: boolean,
231
+ maxWidth: number,
232
+ columnWidth: number,
233
+ ): string {
234
+ const displayValue = this.getDisplayValue(item)
235
+ const truncatedValue = this.layout.truncatePrimary
236
+ ? this.layout.truncatePrimary({
237
+ text: displayValue,
238
+ maxWidth,
239
+ columnWidth,
240
+ item,
241
+ isSelected,
242
+ })
243
+ : truncateToWidth(displayValue, maxWidth, '')
244
+
245
+ return truncateToWidth(truncatedValue, maxWidth, '')
246
+ }
247
+
248
+ private getDisplayValue(item: SelectItem): string {
249
+ return item.label || item.value
250
+ }
251
+
252
+ private notifySelectionChange(): void {
253
+ const selectedItem = this.filteredItems[this.selectedIndex]
254
+ if (selectedItem && this.onSelectionChange) {
255
+ this.onSelectionChange(selectedItem)
256
+ }
257
+ }
258
+
259
+ getSelectedItem(): SelectItem | null {
260
+ const item = this.filteredItems[this.selectedIndex]
261
+ return item || null
262
+ }
263
+ }
@@ -0,0 +1,264 @@
1
+ import { fuzzyFilter } from '../fuzzy.js'
2
+ import { getKeybindings } from '../keybindings.js'
3
+ import type { Component } from '../tui.js'
4
+ import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from '../utils.js'
5
+ import { Input } from './input.js'
6
+
7
+ export interface SettingItem {
8
+ /** Unique identifier for this setting */
9
+ id: string
10
+ /** Display label (left side) */
11
+ label: string
12
+ /** Optional description shown when selected */
13
+ description?: string
14
+ /** Current value to display (right side) */
15
+ currentValue: string
16
+ /** If provided, Enter/Space cycles through these values */
17
+ values?: string[]
18
+ /** If provided, Enter opens this submenu. Receives current value and done callback. */
19
+ submenu?: (currentValue: string, done: (selectedValue?: string) => void) => Component
20
+ }
21
+
22
+ export interface SettingsListTheme {
23
+ label: (text: string, selected: boolean) => string
24
+ value: (text: string, selected: boolean) => string
25
+ description: (text: string) => string
26
+ cursor: string
27
+ hint: (text: string) => string
28
+ }
29
+
30
+ export interface SettingsListOptions {
31
+ enableSearch?: boolean
32
+ }
33
+
34
+ export class SettingsList implements Component {
35
+ private items: SettingItem[]
36
+ private filteredItems: SettingItem[]
37
+ private theme: SettingsListTheme
38
+ private selectedIndex = 0
39
+ private maxVisible: number
40
+ private onChange: (id: string, newValue: string) => void
41
+ private onCancel: () => void
42
+ private searchInput?: Input
43
+ private searchEnabled: boolean
44
+
45
+ // Submenu state
46
+ private submenuComponent: Component | null = null
47
+ private submenuItemIndex: number | null = null
48
+
49
+ constructor(
50
+ items: SettingItem[],
51
+ maxVisible: number,
52
+ theme: SettingsListTheme,
53
+ onChange: (id: string, newValue: string) => void,
54
+ onCancel: () => void,
55
+ options: SettingsListOptions = {},
56
+ ) {
57
+ this.items = items
58
+ this.filteredItems = items
59
+ this.maxVisible = maxVisible
60
+ this.theme = theme
61
+ this.onChange = onChange
62
+ this.onCancel = onCancel
63
+ this.searchEnabled = options.enableSearch ?? false
64
+ if (this.searchEnabled) {
65
+ this.searchInput = new Input()
66
+ }
67
+ }
68
+
69
+ /** Update an item's currentValue */
70
+ updateValue(id: string, newValue: string): void {
71
+ const item = this.items.find(i => i.id === id)
72
+ if (item) {
73
+ item.currentValue = newValue
74
+ }
75
+ }
76
+
77
+ invalidate(): void {
78
+ this.submenuComponent?.invalidate?.()
79
+ }
80
+
81
+ render(width: number): string[] {
82
+ // If submenu is active, render it instead
83
+ if (this.submenuComponent) {
84
+ return this.submenuComponent.render(width)
85
+ }
86
+
87
+ return this.renderMainList(width)
88
+ }
89
+
90
+ private renderMainList(width: number): string[] {
91
+ const lines: string[] = []
92
+
93
+ if (this.searchEnabled && this.searchInput) {
94
+ lines.push(...this.searchInput.render(width))
95
+ lines.push('')
96
+ }
97
+
98
+ if (this.items.length === 0) {
99
+ lines.push(this.theme.hint(' No settings available'))
100
+ if (this.searchEnabled) {
101
+ this.addHintLine(lines, width)
102
+ }
103
+ return lines
104
+ }
105
+
106
+ const displayItems = this.searchEnabled ? this.filteredItems : this.items
107
+ if (displayItems.length === 0) {
108
+ lines.push(truncateToWidth(this.theme.hint(' No matching settings'), width))
109
+ this.addHintLine(lines, width)
110
+ return lines
111
+ }
112
+
113
+ // Calculate visible range with scrolling
114
+ const startIndex = Math.max(
115
+ 0,
116
+ Math.min(
117
+ this.selectedIndex - Math.floor(this.maxVisible / 2),
118
+ displayItems.length - this.maxVisible,
119
+ ),
120
+ )
121
+ const endIndex = Math.min(startIndex + this.maxVisible, displayItems.length)
122
+
123
+ // Calculate max label width for alignment
124
+ const maxLabelWidth = Math.min(
125
+ 30,
126
+ Math.max(...this.items.map(item => visibleWidth(item.label))),
127
+ )
128
+
129
+ // Render visible items
130
+ for (let i = startIndex; i < endIndex; i++) {
131
+ const item = displayItems[i]
132
+ if (!item) continue
133
+
134
+ const isSelected = i === this.selectedIndex
135
+ const prefix = isSelected ? this.theme.cursor : ' '
136
+ const prefixWidth = visibleWidth(prefix)
137
+
138
+ // Pad label to align values
139
+ const labelPadded =
140
+ item.label + ' '.repeat(Math.max(0, maxLabelWidth - visibleWidth(item.label)))
141
+ const labelText = this.theme.label(labelPadded, isSelected)
142
+
143
+ // Calculate space for value
144
+ const separator = ' '
145
+ const usedWidth = prefixWidth + maxLabelWidth + visibleWidth(separator)
146
+ const valueMaxWidth = width - usedWidth - 2
147
+
148
+ const valueText = this.theme.value(
149
+ truncateToWidth(item.currentValue, valueMaxWidth, ''),
150
+ isSelected,
151
+ )
152
+
153
+ lines.push(truncateToWidth(prefix + labelText + separator + valueText, width))
154
+ }
155
+
156
+ // Add scroll indicator if needed
157
+ if (startIndex > 0 || endIndex < displayItems.length) {
158
+ const scrollText = ` (${this.selectedIndex + 1}/${displayItems.length})`
159
+ lines.push(this.theme.hint(truncateToWidth(scrollText, width - 2, '')))
160
+ }
161
+
162
+ // Add description for selected item
163
+ const selectedItem = displayItems[this.selectedIndex]
164
+ if (selectedItem?.description) {
165
+ lines.push('')
166
+ const wrappedDesc = wrapTextWithAnsi(selectedItem.description, width - 4)
167
+ for (const line of wrappedDesc) {
168
+ lines.push(this.theme.description(` ${line}`))
169
+ }
170
+ }
171
+
172
+ // Add hint
173
+ this.addHintLine(lines, width)
174
+
175
+ return lines
176
+ }
177
+
178
+ handleInput(data: string): void {
179
+ // If submenu is active, delegate all input to it
180
+ // The submenu's onCancel (triggered by escape) will call done() which closes it
181
+ if (this.submenuComponent) {
182
+ this.submenuComponent.handleInput?.(data)
183
+ return
184
+ }
185
+
186
+ // Main list input handling
187
+ const kb = getKeybindings()
188
+ const displayItems = this.searchEnabled ? this.filteredItems : this.items
189
+ if (kb.matches(data, 'tui.select.up')) {
190
+ if (displayItems.length === 0) return
191
+ this.selectedIndex =
192
+ this.selectedIndex === 0 ? displayItems.length - 1 : this.selectedIndex - 1
193
+ } else if (kb.matches(data, 'tui.select.down')) {
194
+ if (displayItems.length === 0) return
195
+ this.selectedIndex =
196
+ this.selectedIndex === displayItems.length - 1 ? 0 : this.selectedIndex + 1
197
+ } else if (kb.matches(data, 'tui.select.confirm') || data === ' ') {
198
+ this.activateItem()
199
+ } else if (kb.matches(data, 'tui.select.cancel')) {
200
+ this.onCancel()
201
+ } else if (this.searchEnabled && this.searchInput) {
202
+ const sanitized = data.replace(/ /g, '')
203
+ if (!sanitized) {
204
+ return
205
+ }
206
+ this.searchInput.handleInput(sanitized)
207
+ this.applyFilter(this.searchInput.getValue())
208
+ }
209
+ }
210
+
211
+ private activateItem(): void {
212
+ const item = this.searchEnabled
213
+ ? this.filteredItems[this.selectedIndex]
214
+ : this.items[this.selectedIndex]
215
+ if (!item) return
216
+
217
+ if (item.submenu) {
218
+ // Open submenu, passing current value so it can pre-select correctly
219
+ this.submenuItemIndex = this.selectedIndex
220
+ this.submenuComponent = item.submenu(item.currentValue, (selectedValue?: string) => {
221
+ if (selectedValue !== undefined) {
222
+ item.currentValue = selectedValue
223
+ this.onChange(item.id, selectedValue)
224
+ }
225
+ this.closeSubmenu()
226
+ })
227
+ } else if (item.values && item.values.length > 0) {
228
+ // Cycle through values
229
+ const currentIndex = item.values.indexOf(item.currentValue)
230
+ const nextIndex = (currentIndex + 1) % item.values.length
231
+ const newValue = item.values[nextIndex]
232
+ item.currentValue = newValue
233
+ this.onChange(item.id, newValue)
234
+ }
235
+ }
236
+
237
+ private closeSubmenu(): void {
238
+ this.submenuComponent = null
239
+ // Restore selection to the item that opened the submenu
240
+ if (this.submenuItemIndex !== null) {
241
+ this.selectedIndex = this.submenuItemIndex
242
+ this.submenuItemIndex = null
243
+ }
244
+ }
245
+
246
+ private applyFilter(query: string): void {
247
+ this.filteredItems = fuzzyFilter(this.items, query, item => item.label)
248
+ this.selectedIndex = 0
249
+ }
250
+
251
+ private addHintLine(lines: string[], width: number): void {
252
+ lines.push('')
253
+ lines.push(
254
+ truncateToWidth(
255
+ this.theme.hint(
256
+ this.searchEnabled
257
+ ? ' Type to search · Enter/Space to change · Esc to cancel'
258
+ : ' Enter/Space to change · Esc to cancel',
259
+ ),
260
+ width,
261
+ ),
262
+ )
263
+ }
264
+ }
@@ -0,0 +1,28 @@
1
+ import type { Component } from '../tui.js'
2
+
3
+ /**
4
+ * Spacer component that renders empty lines
5
+ */
6
+ export class Spacer implements Component {
7
+ private lines: number
8
+
9
+ constructor(lines: number = 1) {
10
+ this.lines = lines
11
+ }
12
+
13
+ setLines(lines: number): void {
14
+ this.lines = lines
15
+ }
16
+
17
+ invalidate(): void {
18
+ // No cached state to invalidate currently
19
+ }
20
+
21
+ render(_width: number): string[] {
22
+ const result: string[] = []
23
+ for (let i = 0; i < this.lines; i++) {
24
+ result.push('')
25
+ }
26
+ return result
27
+ }
28
+ }
@@ -0,0 +1,113 @@
1
+ import type { Component } from '../tui.js'
2
+ import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from '../utils.js'
3
+
4
+ /**
5
+ * Text component - displays multi-line text with word wrapping
6
+ */
7
+ export class Text implements Component {
8
+ private text: string
9
+ private paddingX: number // Left/right padding
10
+ private paddingY: number // Top/bottom padding
11
+ private customBgFn?: (text: string) => string
12
+
13
+ // Cache for rendered output
14
+ private cachedText?: string
15
+ private cachedWidth?: number
16
+ private cachedLines?: string[]
17
+
18
+ constructor(
19
+ text: string = '',
20
+ paddingX: number = 1,
21
+ paddingY: number = 1,
22
+ customBgFn?: (text: string) => string,
23
+ ) {
24
+ this.text = text
25
+ this.paddingX = paddingX
26
+ this.paddingY = paddingY
27
+ this.customBgFn = customBgFn
28
+ }
29
+
30
+ setText(text: string): void {
31
+ this.text = text
32
+ this.cachedText = undefined
33
+ this.cachedWidth = undefined
34
+ this.cachedLines = undefined
35
+ }
36
+
37
+ setCustomBgFn(customBgFn?: (text: string) => string): void {
38
+ this.customBgFn = customBgFn
39
+ this.cachedText = undefined
40
+ this.cachedWidth = undefined
41
+ this.cachedLines = undefined
42
+ }
43
+
44
+ invalidate(): void {
45
+ this.cachedText = undefined
46
+ this.cachedWidth = undefined
47
+ this.cachedLines = undefined
48
+ }
49
+
50
+ render(width: number): string[] {
51
+ // Check cache
52
+ if (this.cachedLines && this.cachedText === this.text && this.cachedWidth === width) {
53
+ return this.cachedLines
54
+ }
55
+
56
+ // Don't render anything if there's no actual text
57
+ if (!this.text || this.text.trim() === '') {
58
+ const result: string[] = []
59
+ this.cachedText = this.text
60
+ this.cachedWidth = width
61
+ this.cachedLines = result
62
+ return result
63
+ }
64
+
65
+ // Replace tabs with 3 spaces
66
+ const normalizedText = this.text.replace(/\t/g, ' ')
67
+
68
+ // Calculate content width (subtract left/right margins)
69
+ const contentWidth = Math.max(1, width - this.paddingX * 2)
70
+
71
+ // Wrap text (this preserves ANSI codes but does NOT pad)
72
+ const wrappedLines = wrapTextWithAnsi(normalizedText, contentWidth)
73
+
74
+ // Add margins and background to each line
75
+ const leftMargin = ' '.repeat(this.paddingX)
76
+ const rightMargin = ' '.repeat(this.paddingX)
77
+ const contentLines: string[] = []
78
+
79
+ for (const line of wrappedLines) {
80
+ // Add margins
81
+ const lineWithMargins = leftMargin + line + rightMargin
82
+
83
+ // Apply background if specified (this also pads to full width)
84
+ if (this.customBgFn) {
85
+ contentLines.push(applyBackgroundToLine(lineWithMargins, width, this.customBgFn))
86
+ } else {
87
+ // No background - just pad to width with spaces
88
+ const visibleLen = visibleWidth(lineWithMargins)
89
+ const paddingNeeded = Math.max(0, width - visibleLen)
90
+ contentLines.push(lineWithMargins + ' '.repeat(paddingNeeded))
91
+ }
92
+ }
93
+
94
+ // Add top/bottom padding (empty lines)
95
+ const emptyLine = ' '.repeat(width)
96
+ const emptyLines: string[] = []
97
+ for (let i = 0; i < this.paddingY; i++) {
98
+ const line = this.customBgFn
99
+ ? applyBackgroundToLine(emptyLine, width, this.customBgFn)
100
+ : emptyLine
101
+ emptyLines.push(line)
102
+ }
103
+
104
+ const result = [...emptyLines, ...contentLines, ...emptyLines]
105
+
106
+ // Update cache
107
+ this.cachedText = this.text
108
+ this.cachedWidth = width
109
+ this.cachedLines = result
110
+
111
+ return result.length > 0 ? result : ['']
112
+ }
113
+ }