@neurocode-ai/tui 1.18.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 (241) hide show
  1. package/bunfig.toml +4 -0
  2. package/package.json +72 -0
  3. package/src/app.tsx +1134 -0
  4. package/src/attention.ts +260 -0
  5. package/src/audio.d.ts +9 -0
  6. package/src/audio.ts +53 -0
  7. package/src/clipboard.ts +124 -0
  8. package/src/component/bg-pulse-render.ts +436 -0
  9. package/src/component/bg-pulse.tsx +99 -0
  10. package/src/component/command-palette.tsx +79 -0
  11. package/src/component/dialog-agent.tsx +31 -0
  12. package/src/component/dialog-console-org.tsx +135 -0
  13. package/src/component/dialog-debug.tsx +90 -0
  14. package/src/component/dialog-mcp.tsx +85 -0
  15. package/src/component/dialog-model.tsx +197 -0
  16. package/src/component/dialog-move-session.tsx +353 -0
  17. package/src/component/dialog-provider.tsx +469 -0
  18. package/src/component/dialog-retry-action.tsx +160 -0
  19. package/src/component/dialog-session-delete-failed.tsx +99 -0
  20. package/src/component/dialog-session-list.tsx +364 -0
  21. package/src/component/dialog-session-rename.tsx +31 -0
  22. package/src/component/dialog-skill.tsx +70 -0
  23. package/src/component/dialog-stash.tsx +87 -0
  24. package/src/component/dialog-status.tsx +168 -0
  25. package/src/component/dialog-tag.tsx +47 -0
  26. package/src/component/dialog-theme-list.tsx +50 -0
  27. package/src/component/dialog-variant.tsx +39 -0
  28. package/src/component/dialog-workspace-create.tsx +308 -0
  29. package/src/component/dialog-workspace-file-changes.tsx +144 -0
  30. package/src/component/dialog-workspace-list.tsx +112 -0
  31. package/src/component/dialog-workspace-unavailable.tsx +69 -0
  32. package/src/component/error-component.tsx +240 -0
  33. package/src/component/logo.tsx +61 -0
  34. package/src/component/plugin-route-missing.tsx +14 -0
  35. package/src/component/prompt/autocomplete.tsx +781 -0
  36. package/src/component/prompt/cwd.ts +0 -0
  37. package/src/component/prompt/frecency.tsx +1 -0
  38. package/src/component/prompt/history.tsx +1 -0
  39. package/src/component/prompt/index.tsx +1713 -0
  40. package/src/component/prompt/local-attachment.ts +48 -0
  41. package/src/component/prompt/move.tsx +205 -0
  42. package/src/component/prompt/stash.tsx +1 -0
  43. package/src/component/prompt/workspace.tsx +137 -0
  44. package/src/component/register-spinner.ts +6 -0
  45. package/src/component/spinner.tsx +26 -0
  46. package/src/component/startup-loading.tsx +63 -0
  47. package/src/component/todo-item.tsx +32 -0
  48. package/src/component/use-connected.tsx +12 -0
  49. package/src/component/workspace-label.tsx +19 -0
  50. package/src/config/index.tsx +129 -0
  51. package/src/config/keybind.ts +471 -0
  52. package/src/context/args.tsx +16 -0
  53. package/src/context/clipboard.tsx +18 -0
  54. package/src/context/data.tsx +569 -0
  55. package/src/context/directory.ts +17 -0
  56. package/src/context/editor.ts +408 -0
  57. package/src/context/epilogue.tsx +6 -0
  58. package/src/context/event.ts +36 -0
  59. package/src/context/exit.tsx +8 -0
  60. package/src/context/helper.tsx +26 -0
  61. package/src/context/kv.tsx +66 -0
  62. package/src/context/local.tsx +542 -0
  63. package/src/context/location.tsx +14 -0
  64. package/src/context/path-format.tsx +24 -0
  65. package/src/context/permission.tsx +26 -0
  66. package/src/context/project.tsx +115 -0
  67. package/src/context/prompt.tsx +18 -0
  68. package/src/context/route.tsx +60 -0
  69. package/src/context/runtime.tsx +62 -0
  70. package/src/context/sdk.tsx +151 -0
  71. package/src/context/sync.tsx +666 -0
  72. package/src/context/theme.tsx +332 -0
  73. package/src/context/thinking.ts +67 -0
  74. package/src/editor-zed.ts +286 -0
  75. package/src/editor.ts +101 -0
  76. package/src/feature-plugins/builtins.ts +36 -0
  77. package/src/feature-plugins/home/footer.tsx +100 -0
  78. package/src/feature-plugins/home/tips-view.tsx +287 -0
  79. package/src/feature-plugins/home/tips.tsx +59 -0
  80. package/src/feature-plugins/sidebar/context.tsx +65 -0
  81. package/src/feature-plugins/sidebar/files.tsx +70 -0
  82. package/src/feature-plugins/sidebar/footer.tsx +98 -0
  83. package/src/feature-plugins/sidebar/lsp.tsx +65 -0
  84. package/src/feature-plugins/sidebar/mcp.tsx +97 -0
  85. package/src/feature-plugins/sidebar/todo.tsx +49 -0
  86. package/src/feature-plugins/system/diff-viewer-file-tree-utils.ts +232 -0
  87. package/src/feature-plugins/system/diff-viewer-file-tree.tsx +162 -0
  88. package/src/feature-plugins/system/diff-viewer-ui.tsx +103 -0
  89. package/src/feature-plugins/system/diff-viewer.tsx +1077 -0
  90. package/src/feature-plugins/system/notifications.ts +94 -0
  91. package/src/feature-plugins/system/plugins.tsx +269 -0
  92. package/src/feature-plugins/system/which-key.tsx +608 -0
  93. package/src/index.tsx +1 -0
  94. package/src/keymap.tsx +290 -0
  95. package/src/logo.ts +11 -0
  96. package/src/parsers-config.ts +386 -0
  97. package/src/plugin/adapters.tsx +355 -0
  98. package/src/plugin/api.ts +52 -0
  99. package/src/plugin/command-shim.ts +109 -0
  100. package/src/plugin/runtime.tsx +81 -0
  101. package/src/plugin/slots.tsx +65 -0
  102. package/src/prompt/display.ts +48 -0
  103. package/src/prompt/frecency.tsx +80 -0
  104. package/src/prompt/history.tsx +111 -0
  105. package/src/prompt/part.ts +29 -0
  106. package/src/prompt/stash.tsx +89 -0
  107. package/src/prompt/traits.ts +29 -0
  108. package/src/routes/home/session-destination.tsx +41 -0
  109. package/src/routes/home.tsx +95 -0
  110. package/src/routes/session/dialog-fork-from-timeline.tsx +76 -0
  111. package/src/routes/session/dialog-message.tsx +109 -0
  112. package/src/routes/session/dialog-subagent.tsx +26 -0
  113. package/src/routes/session/dialog-timeline.tsx +47 -0
  114. package/src/routes/session/footer.tsx +91 -0
  115. package/src/routes/session/index.tsx +2710 -0
  116. package/src/routes/session/permission.tsx +718 -0
  117. package/src/routes/session/question.tsx +514 -0
  118. package/src/routes/session/sidebar.tsx +103 -0
  119. package/src/routes/session/subagent-footer.tsx +132 -0
  120. package/src/runtime.tsx +9 -0
  121. package/src/terminal-win32.ts +130 -0
  122. package/src/theme/assets/aura.json +69 -0
  123. package/src/theme/assets/ayu.json +80 -0
  124. package/src/theme/assets/carbonfox.json +248 -0
  125. package/src/theme/assets/catppuccin-frappe.json +230 -0
  126. package/src/theme/assets/catppuccin-macchiato.json +230 -0
  127. package/src/theme/assets/catppuccin.json +112 -0
  128. package/src/theme/assets/cobalt2.json +225 -0
  129. package/src/theme/assets/cursor.json +249 -0
  130. package/src/theme/assets/dracula.json +219 -0
  131. package/src/theme/assets/everforest.json +241 -0
  132. package/src/theme/assets/flexoki.json +237 -0
  133. package/src/theme/assets/github.json +233 -0
  134. package/src/theme/assets/gruvbox.json +242 -0
  135. package/src/theme/assets/kanagawa.json +77 -0
  136. package/src/theme/assets/lucent-orng.json +234 -0
  137. package/src/theme/assets/material.json +235 -0
  138. package/src/theme/assets/matrix.json +77 -0
  139. package/src/theme/assets/mercury.json +252 -0
  140. package/src/theme/assets/monokai.json +221 -0
  141. package/src/theme/assets/nightowl.json +221 -0
  142. package/src/theme/assets/nord.json +223 -0
  143. package/src/theme/assets/one-dark.json +84 -0
  144. package/src/theme/assets/opencode.json +245 -0
  145. package/src/theme/assets/orng.json +249 -0
  146. package/src/theme/assets/osaka-jade.json +93 -0
  147. package/src/theme/assets/palenight.json +222 -0
  148. package/src/theme/assets/rosepine.json +234 -0
  149. package/src/theme/assets/solarized.json +223 -0
  150. package/src/theme/assets/synthwave84.json +226 -0
  151. package/src/theme/assets/tokyonight.json +243 -0
  152. package/src/theme/assets/vercel.json +245 -0
  153. package/src/theme/assets/vesper.json +218 -0
  154. package/src/theme/assets/zenburn.json +223 -0
  155. package/src/theme/index.ts +1089 -0
  156. package/src/ui/border.ts +21 -0
  157. package/src/ui/dialog-alert.tsx +66 -0
  158. package/src/ui/dialog-confirm.tsx +108 -0
  159. package/src/ui/dialog-export-options.tsx +217 -0
  160. package/src/ui/dialog-help.tsx +40 -0
  161. package/src/ui/dialog-prompt.tsx +126 -0
  162. package/src/ui/dialog-select.tsx +790 -0
  163. package/src/ui/dialog.tsx +231 -0
  164. package/src/ui/link.tsx +34 -0
  165. package/src/ui/spinner.ts +368 -0
  166. package/src/ui/toast.tsx +102 -0
  167. package/src/util/collapse-tool-output.ts +19 -0
  168. package/src/util/error.ts +182 -0
  169. package/src/util/filetype.ts +130 -0
  170. package/src/util/format.ts +20 -0
  171. package/src/util/layout.ts +25 -0
  172. package/src/util/locale.ts +86 -0
  173. package/src/util/model.ts +28 -0
  174. package/src/util/path.ts +12 -0
  175. package/src/util/persistence.ts +33 -0
  176. package/src/util/presentation.ts +38 -0
  177. package/src/util/provider-origin.ts +7 -0
  178. package/src/util/record.ts +3 -0
  179. package/src/util/renderer.ts +7 -0
  180. package/src/util/revert-diff.ts +18 -0
  181. package/src/util/scroll.ts +27 -0
  182. package/src/util/selection.ts +79 -0
  183. package/src/util/session.ts +3 -0
  184. package/src/util/signal.ts +51 -0
  185. package/src/util/system.ts +20 -0
  186. package/src/util/tool-display.ts +13 -0
  187. package/src/util/transcript.ts +112 -0
  188. package/sst-env.d.ts +10 -0
  189. package/test/app-lifecycle.test.tsx +128 -0
  190. package/test/cli/cmd/tui/dialog-workspace-create.test.ts +28 -0
  191. package/test/cli/cmd/tui/model-options.test.ts +32 -0
  192. package/test/cli/cmd/tui/notifications.test.ts +267 -0
  193. package/test/cli/cmd/tui/provider-options.test.ts +41 -0
  194. package/test/cli/cmd/tui/sync-fixture.tsx +70 -0
  195. package/test/cli/cmd/tui/sync-live-hydration.test.tsx +262 -0
  196. package/test/cli/cmd/tui/sync-undefined-messages.test.tsx +43 -0
  197. package/test/cli/cmd/tui/sync.test.tsx +65 -0
  198. package/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap +92 -0
  199. package/test/cli/tui/data.test.tsx +486 -0
  200. package/test/cli/tui/dialog-prompt.test.tsx +147 -0
  201. package/test/cli/tui/diff-viewer-file-tree.test.tsx +200 -0
  202. package/test/cli/tui/diff-viewer.test.tsx +268 -0
  203. package/test/cli/tui/inline-tool-wrap-snapshot.test.tsx +351 -0
  204. package/test/cli/tui/prompt-submit-race.test.ts +98 -0
  205. package/test/cli/tui/thinking.test.ts +36 -0
  206. package/test/cli/tui/use-event.test.tsx +148 -0
  207. package/test/clipboard.test.ts +19 -0
  208. package/test/component/dialog-session-list.test.ts +46 -0
  209. package/test/config.test.tsx +127 -0
  210. package/test/context/local.test.ts +22 -0
  211. package/test/editor.test.ts +32 -0
  212. package/test/feature-plugins/diff-viewer-file-tree-utils.test.ts +323 -0
  213. package/test/fixture/fixture.ts +13 -0
  214. package/test/fixture/tui-environment.tsx +32 -0
  215. package/test/fixture/tui-plugin.ts +36 -0
  216. package/test/fixture/tui-runtime.ts +12 -0
  217. package/test/fixture/tui-sdk.ts +109 -0
  218. package/test/index.test.tsx +6 -0
  219. package/test/keymap.test.tsx +141 -0
  220. package/test/plugin/runtime.test.ts +50 -0
  221. package/test/plugin/slots.test.tsx +38 -0
  222. package/test/prompt/display.test.ts +33 -0
  223. package/test/prompt/history.test.ts +39 -0
  224. package/test/prompt/jsonl.test.ts +24 -0
  225. package/test/prompt/local-attachment.test.ts +43 -0
  226. package/test/prompt/part.test.ts +53 -0
  227. package/test/prompt/persistence.test.ts +23 -0
  228. package/test/prompt/traits.test.ts +25 -0
  229. package/test/runtime.test.tsx +37 -0
  230. package/test/theme.test.ts +81 -0
  231. package/test/util/error.test.ts +49 -0
  232. package/test/util/filetype.test.ts +16 -0
  233. package/test/util/format.test.ts +59 -0
  234. package/test/util/model.test.ts +9 -0
  235. package/test/util/presentation.test.ts +8 -0
  236. package/test/util/renderer.test.ts +30 -0
  237. package/test/util/revert-diff.test.ts +35 -0
  238. package/test/util/session.test.ts +10 -0
  239. package/test/util/tool-display.test.ts +40 -0
  240. package/test/util/transcript.test.ts +421 -0
  241. package/tsconfig.json +10 -0
@@ -0,0 +1,781 @@
1
+ import type { BoxRenderable, TextareaRenderable, ScrollBoxRenderable } from "@opentui/core"
2
+ import { pathToFileURL } from "bun"
3
+ import fuzzysort from "fuzzysort"
4
+ import path from "path"
5
+ import { firstBy } from "remeda"
6
+ import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Show, createSignal } from "solid-js"
7
+ import { createStore } from "solid-js/store"
8
+ import { useEditorContext } from "../../context/editor"
9
+ import { useProject } from "../../context/project"
10
+ import { useSDK } from "../../context/sdk"
11
+ import { useSync } from "../../context/sync"
12
+ import { useData } from "../../context/data"
13
+ import { getScrollAcceleration } from "../../util/scroll"
14
+ import { useTuiPaths } from "../../context/runtime"
15
+ import { useTuiConfig } from "../../config"
16
+ import { useLocation } from "../../context/location"
17
+ import { useTheme, selectedForeground } from "../../context/theme"
18
+ import { SplitBorder } from "../../ui/border"
19
+ import { useTerminalDimensions } from "@opentui/solid"
20
+ import { Locale } from "../../util/locale"
21
+ import type { PromptInfo } from "../../prompt/history"
22
+ import { useFrecency } from "../../prompt/frecency"
23
+ import { useBindings, useCommandSlashes, useNeurocodeModeStack } from "../../keymap"
24
+ import { displayCharAt, mentionTriggerIndex } from "../../prompt/display"
25
+ import type { FileSystemEntry } from "@neurocode-ai/sdk/v2"
26
+
27
+ function removeLineRange(input: string) {
28
+ const hashIndex = input.lastIndexOf("#")
29
+ return hashIndex !== -1 ? input.substring(0, hashIndex) : input
30
+ }
31
+
32
+ function extractLineRange(input: string) {
33
+ const hashIndex = input.lastIndexOf("#")
34
+ if (hashIndex === -1) {
35
+ return { baseQuery: input }
36
+ }
37
+
38
+ const baseName = input.substring(0, hashIndex)
39
+ const linePart = input.substring(hashIndex + 1)
40
+ const lineMatch = linePart.match(/^(\d+)(?:-(\d*))?$/)
41
+
42
+ if (!lineMatch) {
43
+ return { baseQuery: baseName }
44
+ }
45
+
46
+ const startLine = Number(lineMatch[1])
47
+ const endLine = lineMatch[2] && startLine < Number(lineMatch[2]) ? Number(lineMatch[2]) : undefined
48
+
49
+ return {
50
+ lineRange: {
51
+ baseName,
52
+ startLine,
53
+ endLine,
54
+ },
55
+ baseQuery: baseName,
56
+ }
57
+ }
58
+
59
+ export type AutocompleteRef = {
60
+ onInput: (value: string) => void
61
+ visible: false | "@" | "/"
62
+ }
63
+
64
+ export type AutocompleteOption = {
65
+ display: string
66
+ value?: string
67
+ aliases?: string[]
68
+ disabled?: boolean
69
+ description?: string
70
+ isDirectory?: boolean
71
+ onSelect?: () => void
72
+ path?: string
73
+ }
74
+
75
+ export function Autocomplete(props: {
76
+ value: string
77
+ sessionID?: string
78
+ setPrompt: (input: (prompt: PromptInfo) => void) => void
79
+ setExtmark: (partIndex: number, extmarkId: number) => void
80
+ anchor: () => BoxRenderable
81
+ input: () => TextareaRenderable
82
+ ref: (ref: AutocompleteRef) => void
83
+ fileStyleId: number
84
+ agentStyleId: number
85
+ promptPartTypeId: () => number
86
+ }) {
87
+ const editor = useEditorContext()
88
+ const sdk = useSDK()
89
+ const sync = useSync()
90
+ const data = useData()
91
+ const project = useProject()
92
+ const slashes = useCommandSlashes()
93
+ const modeStack = useNeurocodeModeStack()
94
+ const { theme } = useTheme()
95
+ const dimensions = useTerminalDimensions()
96
+ const frecency = useFrecency()
97
+ const tuiConfig = useTuiConfig()
98
+ const paths = useTuiPaths()
99
+ const location = useLocation()
100
+ const [store, setStore] = createStore({
101
+ index: 0,
102
+ selected: 0,
103
+ visible: false as AutocompleteRef["visible"],
104
+ input: "keyboard" as "keyboard" | "mouse",
105
+ })
106
+
107
+ const [positionTick, setPositionTick] = createSignal(0)
108
+
109
+ createEffect(() => {
110
+ if (!store.visible) return
111
+ const popMode = modeStack.push("autocomplete")
112
+ onCleanup(popMode)
113
+ })
114
+
115
+ createEffect(() => {
116
+ if (store.visible) {
117
+ let lastPos = { x: 0, y: 0, width: 0 }
118
+ const interval = setInterval(() => {
119
+ const anchor = props.anchor()
120
+ if (anchor.x !== lastPos.x || anchor.y !== lastPos.y || anchor.width !== lastPos.width) {
121
+ lastPos = { x: anchor.x, y: anchor.y, width: anchor.width }
122
+ setPositionTick((t) => t + 1)
123
+ }
124
+ }, 50)
125
+
126
+ onCleanup(() => clearInterval(interval))
127
+ }
128
+ })
129
+
130
+ const position = createMemo(() => {
131
+ if (!store.visible) return { x: 0, y: 0, width: 0 }
132
+ dimensions()
133
+ positionTick()
134
+ const anchor = props.anchor()
135
+ const parent = anchor.parent
136
+ const parentX = parent?.x ?? 0
137
+ const parentY = parent?.y ?? 0
138
+
139
+ return {
140
+ x: anchor.x - parentX,
141
+ y: anchor.y - parentY,
142
+ width: anchor.width,
143
+ }
144
+ })
145
+
146
+ const filter = createMemo(() => {
147
+ if (!store.visible) return
148
+ // Track props.value to make memo reactive to text changes
149
+ props.value // <- there surely is a better way to do this, like making .input() reactive
150
+
151
+ return props.input().getTextRange(store.index + 1, props.input().cursorOffset)
152
+ })
153
+
154
+ // filter() reads reactive props.value plus non-reactive cursor/text state.
155
+ // On keypress those can be briefly out of sync, so filter() may return an empty/partial string.
156
+ // Copy it into search in an effect because effects run after reactive updates have been rendered and painted
157
+ // so the input has settled and all consumers read the same stable value.
158
+ const [search, setSearch] = createSignal("")
159
+ createEffect(() => {
160
+ const next = filter()
161
+ setSearch(next ? next : "")
162
+ })
163
+
164
+ // When the filter changes due to how TUI works, the mousemove might still be triggered
165
+ // via a synthetic event as the layout moves underneath the cursor. This is a workaround to make sure the input mode remains keyboard so
166
+ // that the mouseover event doesn't trigger when filtering.
167
+ createEffect(() => {
168
+ filter()
169
+ setStore("input", "keyboard")
170
+ })
171
+
172
+ function insertPart(text: string, part: PromptInfo["parts"][number]) {
173
+ const input = props.input()
174
+ const currentCursorOffset = input.cursorOffset
175
+
176
+ const charAfterCursor = displayCharAt(props.value, currentCursorOffset)
177
+ const needsSpace = charAfterCursor !== " "
178
+ const append = "@" + text + (needsSpace ? " " : "")
179
+
180
+ input.cursorOffset = store.index
181
+ const startCursor = input.logicalCursor
182
+ input.cursorOffset = currentCursorOffset
183
+ const endCursor = input.logicalCursor
184
+
185
+ input.deleteRange(startCursor.row, startCursor.col, endCursor.row, endCursor.col)
186
+ input.insertText(append)
187
+
188
+ const virtualText = "@" + text
189
+ const extmarkStart = store.index
190
+ const extmarkEnd = extmarkStart + Bun.stringWidth(virtualText)
191
+
192
+ const styleId = part.type === "file" ? props.fileStyleId : part.type === "agent" ? props.agentStyleId : undefined
193
+
194
+ const extmarkId = input.extmarks.create({
195
+ start: extmarkStart,
196
+ end: extmarkEnd,
197
+ virtual: true,
198
+ styleId,
199
+ typeId: props.promptPartTypeId(),
200
+ })
201
+
202
+ props.setPrompt((draft) => {
203
+ if (part.type === "file") {
204
+ const existingIndex = draft.parts.findIndex((p) => p.type === "file" && "url" in p && p.url === part.url)
205
+ if (existingIndex !== -1) {
206
+ const existing = draft.parts[existingIndex]
207
+ if (
208
+ part.source?.text &&
209
+ existing &&
210
+ "source" in existing &&
211
+ existing.source &&
212
+ "text" in existing.source &&
213
+ existing.source.text
214
+ ) {
215
+ existing.source.text.start = extmarkStart
216
+ existing.source.text.end = extmarkEnd
217
+ existing.source.text.value = virtualText
218
+ }
219
+ return
220
+ }
221
+ }
222
+
223
+ if (part.type === "file" && part.source?.text) {
224
+ part.source.text.start = extmarkStart
225
+ part.source.text.end = extmarkEnd
226
+ part.source.text.value = virtualText
227
+ } else if (part.type === "agent" && part.source) {
228
+ part.source.start = extmarkStart
229
+ part.source.end = extmarkEnd
230
+ part.source.value = virtualText
231
+ }
232
+ const partIndex = draft.parts.length
233
+ draft.parts.push(part)
234
+ props.setExtmark(partIndex, extmarkId)
235
+ })
236
+
237
+ if (part.type === "file" && part.source && part.source.type === "file") {
238
+ frecency.updateFrecency(part.source.path)
239
+ }
240
+ }
241
+
242
+ function createFilePart(
243
+ item: FileSystemEntry,
244
+ filePath: string,
245
+ lineRange?: { startLine: number; endLine?: number },
246
+ ) {
247
+ const urlObj = pathToFileURL(filePath)
248
+ const filename =
249
+ lineRange && item.type !== "directory"
250
+ ? `${item.path}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}`
251
+ : item.path
252
+
253
+ if (lineRange && item.type !== "directory") {
254
+ urlObj.searchParams.set("start", String(lineRange.startLine))
255
+ if (lineRange.endLine !== undefined) {
256
+ urlObj.searchParams.set("end", String(lineRange.endLine))
257
+ }
258
+ }
259
+
260
+ return {
261
+ filename,
262
+ part: {
263
+ type: "file" as const,
264
+ mime: item.type === "directory" ? "application/x-directory" : "text/plain",
265
+ filename,
266
+ url: urlObj.href,
267
+ source: {
268
+ type: "file" as const,
269
+ text: {
270
+ start: 0,
271
+ end: 0,
272
+ value: "",
273
+ },
274
+ path: item.path,
275
+ },
276
+ },
277
+ }
278
+ }
279
+
280
+ const references = createMemo(() => data.location.reference.list() ?? [])
281
+
282
+ const referenceMatch = createMemo(() => {
283
+ if (!store.visible || store.visible === "/") return
284
+ const { baseQuery } = extractLineRange(search())
285
+ const slash = baseQuery.indexOf("/")
286
+ const alias = slash === -1 ? baseQuery : baseQuery.slice(0, slash)
287
+ return references().find((item) => !item.hidden && item.name === alias)
288
+ })
289
+
290
+ function normalizeMentionPath(filePath: string) {
291
+ const baseDir = location()?.directory || sync.path.directory || paths.cwd
292
+ const absolute = path.resolve(filePath)
293
+ const relative = path.relative(baseDir, absolute)
294
+
295
+ if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) {
296
+ return relative.split(path.sep).join("/")
297
+ }
298
+
299
+ return absolute.split(path.sep).join("/")
300
+ }
301
+
302
+ function insertFileMention(input: { filePath: string; lineStart: number; lineEnd: number }) {
303
+ const item = normalizeMentionPath(input.filePath)
304
+ const lineRange = {
305
+ startLine: input.lineStart,
306
+ endLine: input.lineEnd > input.lineStart ? input.lineEnd : undefined,
307
+ }
308
+ const { filename, part } = createFilePart({ path: item, type: "file" }, input.filePath, lineRange)
309
+ const index = store.visible === "@" ? store.index : props.input().cursorOffset
310
+
311
+ setStore("visible", false)
312
+ setStore("index", index)
313
+ insertPart(filename, part)
314
+ }
315
+
316
+ const [files] = createResource(
317
+ () => ({ query: search(), location: location() }),
318
+ async (input) => {
319
+ if (!store.visible || store.visible === "/") return []
320
+ if (referenceMatch()) return []
321
+ const { lineRange, baseQuery } = extractLineRange(input.query ?? "")
322
+
323
+ // Get files from SDK
324
+ const result = await sdk.client.v2.fs.find({
325
+ query: baseQuery,
326
+ limit: "20",
327
+ location: {
328
+ directory: input.location?.directory,
329
+ workspace: input.location?.workspaceID ?? project.workspace.current(),
330
+ },
331
+ })
332
+
333
+ const options: AutocompleteOption[] = []
334
+
335
+ // Add file options. Trust the order returned by fff (frecency, fuzzy
336
+ // score, filename bonus, etc. are already factored in).
337
+ if (!result.error && result.data) {
338
+ const width = props.anchor().width - 4
339
+ options.push(
340
+ ...result.data.data.map((item): AutocompleteOption => {
341
+ const { filename, part } = createFilePart(
342
+ item,
343
+ path.join(result.data.location.directory, item.path),
344
+ lineRange,
345
+ )
346
+ return {
347
+ display: Locale.truncateMiddle(filename, width),
348
+ value: filename,
349
+ isDirectory: item.type === "directory",
350
+ path: item.path,
351
+ onSelect: () => {
352
+ insertPart(filename, part)
353
+ },
354
+ }
355
+ }),
356
+ )
357
+ }
358
+
359
+ return options
360
+ },
361
+ {
362
+ initialValue: [],
363
+ },
364
+ )
365
+
366
+ const mcpResources = createMemo(() => {
367
+ if (!store.visible || store.visible === "/") return []
368
+
369
+ const options: AutocompleteOption[] = []
370
+ const width = props.anchor().width - 4
371
+
372
+ for (const res of Object.values(sync.data.mcp_resource)) {
373
+ options.push({
374
+ display: Locale.truncateMiddle(res.name, width),
375
+ // Match the name only; matching the URI caused unrelated fuzzy hits.
376
+ value: res.name,
377
+ description: res.description,
378
+ onSelect: () => {
379
+ insertPart(res.name, {
380
+ type: "file",
381
+ mime: res.mimeType ?? "text/plain",
382
+ filename: res.name,
383
+ url: res.uri,
384
+ source: {
385
+ type: "resource",
386
+ text: {
387
+ start: 0,
388
+ end: 0,
389
+ value: "",
390
+ },
391
+ clientName: res.client,
392
+ uri: res.uri,
393
+ },
394
+ })
395
+ },
396
+ })
397
+ }
398
+
399
+ return options
400
+ })
401
+
402
+ const agents = createMemo(() => {
403
+ return sync.data.agent
404
+ .filter((agent) => !agent.hidden && agent.mode !== "primary")
405
+ .map(
406
+ (agent): AutocompleteOption => ({
407
+ display: "@" + agent.name,
408
+ onSelect: () => {
409
+ insertPart(agent.name, {
410
+ type: "agent",
411
+ name: agent.name,
412
+ source: {
413
+ start: 0,
414
+ end: 0,
415
+ value: "",
416
+ },
417
+ })
418
+ },
419
+ }),
420
+ )
421
+ })
422
+
423
+ const referenceAliases = createMemo(() =>
424
+ references()
425
+ .filter((reference) => !reference.hidden)
426
+ .map(
427
+ (reference): AutocompleteOption => ({
428
+ display: "@" + reference.name,
429
+ description: ` ${reference.source.type === "git" ? reference.source.repository : reference.source.path}`,
430
+ onSelect: () => {
431
+ insertPart(reference.name, {
432
+ type: "file",
433
+ mime: "application/x-directory",
434
+ filename: reference.name,
435
+ url: pathToFileURL(reference.path).href,
436
+ source: {
437
+ type: "file",
438
+ text: { start: 0, end: 0, value: "" },
439
+ path: reference.name,
440
+ },
441
+ })
442
+ },
443
+ }),
444
+ ),
445
+ )
446
+
447
+ const commands = createMemo((): AutocompleteOption[] => {
448
+ const results: AutocompleteOption[] = [...slashes()]
449
+
450
+ for (const serverCommand of sync.data.command) {
451
+ if (serverCommand.source === "skill") continue
452
+ const label = serverCommand.source === "mcp" ? ":mcp" : ""
453
+ results.push({
454
+ display: "/" + serverCommand.name + label,
455
+ description: serverCommand.description,
456
+ onSelect: () => {
457
+ const newText = "/" + serverCommand.name + " "
458
+ const cursor = props.input().logicalCursor
459
+ props.input().deleteRange(0, 0, cursor.row, cursor.col)
460
+ props.input().insertText(newText)
461
+ props.input().cursorOffset = Bun.stringWidth(newText)
462
+ },
463
+ })
464
+ }
465
+
466
+ results.sort((a, b) => a.display.localeCompare(b.display))
467
+
468
+ const max = firstBy(results, [(x) => x.display.length, "desc"])?.display.length
469
+ if (!max) return results
470
+ return results.map((item) => ({
471
+ ...item,
472
+ display: item.display.padEnd(max + 2),
473
+ }))
474
+ })
475
+
476
+ const options = createMemo((prev: AutocompleteOption[] | undefined) => {
477
+ const filesValue = files()
478
+ const referenceMatchValue = referenceMatch()
479
+ const agentsValue = agents()
480
+ const referenceAliasesValue = referenceAliases()
481
+ const commandsValue = commands()
482
+ const searchValue = search()
483
+
484
+ if (store.visible === "@" && referenceMatchValue) {
485
+ return referenceAliasesValue.filter((item) => item.display === `@${referenceMatchValue.name}`)
486
+ }
487
+
488
+ // Files come from fff already fuzzy ranked and filtered
489
+ // it shouldn't be additionally sorted by fuzzysort as it will loose the results
490
+ const fileOptions: AutocompleteOption[] = store.visible === "@" ? filesValue || [] : []
491
+ const nonFileOptions: AutocompleteOption[] =
492
+ store.visible === "@" ? [...referenceAliasesValue, ...agentsValue, ...mcpResources()] : [...commandsValue]
493
+
494
+ if (!searchValue) {
495
+ return [...nonFileOptions, ...fileOptions]
496
+ }
497
+
498
+ if (files.loading && prev && prev.length > 0) {
499
+ return prev
500
+ }
501
+
502
+ const fuzziedNonFiles = fuzzysort
503
+ .go(removeLineRange(searchValue), nonFileOptions, {
504
+ keys: [
505
+ (obj) => removeLineRange((obj.value ?? obj.display).trimEnd()),
506
+ // Match description for slash commands only; for "@" it surfaced unrelated items.
507
+ ...(store.visible === "/" ? ["description" as const] : []),
508
+ (obj) => obj.aliases?.join(" ") ?? "",
509
+ ],
510
+ threshold: store.visible === "@" ? 0.5 : 0,
511
+ limit: 10,
512
+ scoreFn: (objResults) => {
513
+ const displayResult = objResults[0]
514
+ let score = objResults.score
515
+ if (displayResult && displayResult.target.startsWith(store.visible + searchValue)) {
516
+ score *= 2
517
+ }
518
+ const frecencyScore = objResults.obj.path ? frecency.getFrecency(objResults.obj.path) : 0
519
+ return score * (1 + frecencyScore)
520
+ },
521
+ })
522
+ .map((arr) => arr.obj)
523
+
524
+ return [...fuzziedNonFiles, ...fileOptions].slice(0, 10)
525
+ })
526
+
527
+ createEffect(() => {
528
+ filter()
529
+ setStore("selected", 0)
530
+ })
531
+
532
+ function move(direction: -1 | 1) {
533
+ if (!store.visible) return
534
+ if (!options().length) return
535
+ let next = store.selected + direction
536
+ if (next < 0) next = options().length - 1
537
+ if (next >= options().length) next = 0
538
+ moveTo(next)
539
+ }
540
+
541
+ function moveTo(next: number) {
542
+ setStore("selected", next)
543
+ if (!scroll) return
544
+ const viewportHeight = Math.min(height(), options().length)
545
+ const scrollBottom = scroll.scrollTop + viewportHeight
546
+ if (next < scroll.scrollTop) {
547
+ scroll.scrollBy(next - scroll.scrollTop)
548
+ } else if (next + 1 > scrollBottom) {
549
+ scroll.scrollBy(next + 1 - scrollBottom)
550
+ }
551
+ }
552
+
553
+ function select() {
554
+ const selected = options()[store.selected]
555
+ if (!selected) return
556
+ hide()
557
+ selected.onSelect?.()
558
+ }
559
+
560
+ function expandDirectory() {
561
+ const selected = options()[store.selected]
562
+ if (!selected) return
563
+
564
+ const input = props.input()
565
+ const currentCursorOffset = input.cursorOffset
566
+
567
+ const displayText = (selected.value ?? selected.display).trimEnd()
568
+ const path = displayText.startsWith("@") ? displayText.slice(1) : displayText
569
+
570
+ input.cursorOffset = store.index
571
+ const startCursor = input.logicalCursor
572
+ input.cursorOffset = currentCursorOffset
573
+ const endCursor = input.logicalCursor
574
+
575
+ input.deleteRange(startCursor.row, startCursor.col, endCursor.row, endCursor.col)
576
+ input.insertText("@" + path + "/")
577
+
578
+ setStore("selected", 0)
579
+ }
580
+
581
+ useBindings(() => ({
582
+ target: props.input,
583
+ enabled: () => Boolean(store.visible),
584
+ commands: [
585
+ {
586
+ name: "prompt.autocomplete.prev",
587
+ title: "Previous autocomplete item",
588
+ category: "Autocomplete",
589
+ run() {
590
+ setStore("input", "keyboard")
591
+ move(-1)
592
+ },
593
+ },
594
+ {
595
+ name: "prompt.autocomplete.next",
596
+ title: "Next autocomplete item",
597
+ category: "Autocomplete",
598
+ run() {
599
+ setStore("input", "keyboard")
600
+ move(1)
601
+ },
602
+ },
603
+ {
604
+ name: "prompt.autocomplete.hide",
605
+ title: "Hide autocomplete",
606
+ category: "Autocomplete",
607
+ run() {
608
+ hide()
609
+ },
610
+ },
611
+ {
612
+ name: "prompt.autocomplete.select",
613
+ title: "Select autocomplete item",
614
+ category: "Autocomplete",
615
+ run() {
616
+ select()
617
+ },
618
+ },
619
+ {
620
+ name: "prompt.autocomplete.complete",
621
+ title: "Complete autocomplete item",
622
+ category: "Autocomplete",
623
+ run() {
624
+ const selected = options()[store.selected]
625
+ if (selected?.isDirectory) {
626
+ expandDirectory()
627
+ return
628
+ }
629
+
630
+ select()
631
+ },
632
+ },
633
+ ],
634
+ bindings: tuiConfig.keybinds.gather("prompt.autocomplete", [
635
+ "prompt.autocomplete.prev",
636
+ "prompt.autocomplete.next",
637
+ "prompt.autocomplete.hide",
638
+ "prompt.autocomplete.select",
639
+ "prompt.autocomplete.complete",
640
+ ]),
641
+ }))
642
+
643
+ function show(mode: "@" | "/") {
644
+ setStore({
645
+ visible: mode,
646
+ index: props.input().cursorOffset,
647
+ })
648
+ }
649
+
650
+ function hide() {
651
+ const text = props.input().plainText
652
+ if (store.visible === "/" && !text.endsWith(" ") && text.startsWith("/")) {
653
+ const cursor = props.input().logicalCursor
654
+ props.input().deleteRange(0, 0, cursor.row, cursor.col)
655
+ // Sync the prompt store immediately since onContentChange is async
656
+ props.setPrompt((draft) => {
657
+ draft.input = props.input().plainText
658
+ })
659
+ }
660
+ setStore("visible", false)
661
+ }
662
+
663
+ onMount(() => {
664
+ const unsubscribeMention = editor.onMention((mention) => {
665
+ insertFileMention(mention)
666
+ })
667
+
668
+ onCleanup(() => {
669
+ unsubscribeMention()
670
+ })
671
+
672
+ props.ref({
673
+ get visible() {
674
+ return store.visible
675
+ },
676
+ onInput(value) {
677
+ if (store.visible) {
678
+ if (
679
+ // Typed text before the trigger
680
+ props.input().cursorOffset <= store.index ||
681
+ // There is a space between the trigger and the cursor
682
+ props.input().getTextRange(store.index, props.input().cursorOffset).match(/\s/) ||
683
+ // "/<command>" is not the sole content
684
+ (store.visible === "/" && value.match(/^\S+\s+\S+\s*$/))
685
+ ) {
686
+ hide()
687
+ }
688
+ return
689
+ }
690
+
691
+ // Check if autocomplete should reopen (e.g., after backspace deleted a space)
692
+ const offset = props.input().cursorOffset
693
+ if (offset === 0) return
694
+
695
+ // Check for "/" at position 0 - reopen slash commands
696
+ if (value.startsWith("/") && !value.slice(0, offset).match(/\s/)) {
697
+ show("/")
698
+ setStore("index", 0)
699
+ return
700
+ }
701
+
702
+ // Check for "@" trigger - find the nearest "@" before cursor with no whitespace between
703
+ const idx = mentionTriggerIndex(value, offset)
704
+ if (idx !== undefined) {
705
+ show("@")
706
+ setStore("index", idx)
707
+ }
708
+ },
709
+ })
710
+ })
711
+
712
+ const height = createMemo(() => {
713
+ const count = options().length || 1
714
+ if (!store.visible) return Math.min(10, count)
715
+ positionTick()
716
+ return Math.min(10, count, Math.max(1, props.anchor().y))
717
+ })
718
+
719
+ let scroll: ScrollBoxRenderable
720
+ const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
721
+
722
+ return (
723
+ <box
724
+ visible={store.visible !== false}
725
+ position="absolute"
726
+ top={position().y - height()}
727
+ left={position().x}
728
+ width={position().width}
729
+ zIndex={100}
730
+ {...SplitBorder}
731
+ borderColor={theme.border}
732
+ >
733
+ <scrollbox
734
+ ref={(r: ScrollBoxRenderable) => (scroll = r)}
735
+ backgroundColor={theme.backgroundMenu}
736
+ height={height()}
737
+ scrollbarOptions={{ visible: false }}
738
+ scrollAcceleration={scrollAcceleration()}
739
+ >
740
+ <Index
741
+ each={options()}
742
+ fallback={
743
+ <box paddingLeft={1} paddingRight={1}>
744
+ <text fg={theme.textMuted}>No matching items</text>
745
+ </box>
746
+ }
747
+ >
748
+ {(option, index) => (
749
+ <box
750
+ paddingLeft={1}
751
+ paddingRight={1}
752
+ backgroundColor={index === store.selected ? theme.primary : undefined}
753
+ flexDirection="row"
754
+ onMouseMove={() => {
755
+ setStore("input", "mouse")
756
+ }}
757
+ onMouseOver={() => {
758
+ if (store.input !== "mouse") return
759
+ moveTo(index)
760
+ }}
761
+ onMouseDown={() => {
762
+ setStore("input", "mouse")
763
+ moveTo(index)
764
+ }}
765
+ onMouseUp={() => select()}
766
+ >
767
+ <text fg={index === store.selected ? selectedForeground(theme) : theme.text} flexShrink={0}>
768
+ {option().display}
769
+ </text>
770
+ <Show when={option().description}>
771
+ <text fg={index === store.selected ? selectedForeground(theme) : theme.textMuted} wrapMode="none">
772
+ {" " + option().description?.trimStart()}
773
+ </text>
774
+ </Show>
775
+ </box>
776
+ )}
777
+ </Index>
778
+ </scrollbox>
779
+ </box>
780
+ )
781
+ }