@agentprojectcontext/apx 1.15.6 → 1.16.0

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