@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.
- package/bunfig.toml +4 -0
- package/package.json +72 -0
- package/src/app.tsx +1134 -0
- package/src/attention.ts +260 -0
- package/src/audio.d.ts +9 -0
- package/src/audio.ts +53 -0
- package/src/clipboard.ts +124 -0
- package/src/component/bg-pulse-render.ts +436 -0
- package/src/component/bg-pulse.tsx +99 -0
- package/src/component/command-palette.tsx +79 -0
- package/src/component/dialog-agent.tsx +31 -0
- package/src/component/dialog-console-org.tsx +135 -0
- package/src/component/dialog-debug.tsx +90 -0
- package/src/component/dialog-mcp.tsx +85 -0
- package/src/component/dialog-model.tsx +197 -0
- package/src/component/dialog-move-session.tsx +353 -0
- package/src/component/dialog-provider.tsx +469 -0
- package/src/component/dialog-retry-action.tsx +160 -0
- package/src/component/dialog-session-delete-failed.tsx +99 -0
- package/src/component/dialog-session-list.tsx +364 -0
- package/src/component/dialog-session-rename.tsx +31 -0
- package/src/component/dialog-skill.tsx +70 -0
- package/src/component/dialog-stash.tsx +87 -0
- package/src/component/dialog-status.tsx +168 -0
- package/src/component/dialog-tag.tsx +47 -0
- package/src/component/dialog-theme-list.tsx +50 -0
- package/src/component/dialog-variant.tsx +39 -0
- package/src/component/dialog-workspace-create.tsx +308 -0
- package/src/component/dialog-workspace-file-changes.tsx +144 -0
- package/src/component/dialog-workspace-list.tsx +112 -0
- package/src/component/dialog-workspace-unavailable.tsx +69 -0
- package/src/component/error-component.tsx +240 -0
- package/src/component/logo.tsx +61 -0
- package/src/component/plugin-route-missing.tsx +14 -0
- package/src/component/prompt/autocomplete.tsx +781 -0
- package/src/component/prompt/cwd.ts +0 -0
- package/src/component/prompt/frecency.tsx +1 -0
- package/src/component/prompt/history.tsx +1 -0
- package/src/component/prompt/index.tsx +1713 -0
- package/src/component/prompt/local-attachment.ts +48 -0
- package/src/component/prompt/move.tsx +205 -0
- package/src/component/prompt/stash.tsx +1 -0
- package/src/component/prompt/workspace.tsx +137 -0
- package/src/component/register-spinner.ts +6 -0
- package/src/component/spinner.tsx +26 -0
- package/src/component/startup-loading.tsx +63 -0
- package/src/component/todo-item.tsx +32 -0
- package/src/component/use-connected.tsx +12 -0
- package/src/component/workspace-label.tsx +19 -0
- package/src/config/index.tsx +129 -0
- package/src/config/keybind.ts +471 -0
- package/src/context/args.tsx +16 -0
- package/src/context/clipboard.tsx +18 -0
- package/src/context/data.tsx +569 -0
- package/src/context/directory.ts +17 -0
- package/src/context/editor.ts +408 -0
- package/src/context/epilogue.tsx +6 -0
- package/src/context/event.ts +36 -0
- package/src/context/exit.tsx +8 -0
- package/src/context/helper.tsx +26 -0
- package/src/context/kv.tsx +66 -0
- package/src/context/local.tsx +542 -0
- package/src/context/location.tsx +14 -0
- package/src/context/path-format.tsx +24 -0
- package/src/context/permission.tsx +26 -0
- package/src/context/project.tsx +115 -0
- package/src/context/prompt.tsx +18 -0
- package/src/context/route.tsx +60 -0
- package/src/context/runtime.tsx +62 -0
- package/src/context/sdk.tsx +151 -0
- package/src/context/sync.tsx +666 -0
- package/src/context/theme.tsx +332 -0
- package/src/context/thinking.ts +67 -0
- package/src/editor-zed.ts +286 -0
- package/src/editor.ts +101 -0
- package/src/feature-plugins/builtins.ts +36 -0
- package/src/feature-plugins/home/footer.tsx +100 -0
- package/src/feature-plugins/home/tips-view.tsx +287 -0
- package/src/feature-plugins/home/tips.tsx +59 -0
- package/src/feature-plugins/sidebar/context.tsx +65 -0
- package/src/feature-plugins/sidebar/files.tsx +70 -0
- package/src/feature-plugins/sidebar/footer.tsx +98 -0
- package/src/feature-plugins/sidebar/lsp.tsx +65 -0
- package/src/feature-plugins/sidebar/mcp.tsx +97 -0
- package/src/feature-plugins/sidebar/todo.tsx +49 -0
- package/src/feature-plugins/system/diff-viewer-file-tree-utils.ts +232 -0
- package/src/feature-plugins/system/diff-viewer-file-tree.tsx +162 -0
- package/src/feature-plugins/system/diff-viewer-ui.tsx +103 -0
- package/src/feature-plugins/system/diff-viewer.tsx +1077 -0
- package/src/feature-plugins/system/notifications.ts +94 -0
- package/src/feature-plugins/system/plugins.tsx +269 -0
- package/src/feature-plugins/system/which-key.tsx +608 -0
- package/src/index.tsx +1 -0
- package/src/keymap.tsx +290 -0
- package/src/logo.ts +11 -0
- package/src/parsers-config.ts +386 -0
- package/src/plugin/adapters.tsx +355 -0
- package/src/plugin/api.ts +52 -0
- package/src/plugin/command-shim.ts +109 -0
- package/src/plugin/runtime.tsx +81 -0
- package/src/plugin/slots.tsx +65 -0
- package/src/prompt/display.ts +48 -0
- package/src/prompt/frecency.tsx +80 -0
- package/src/prompt/history.tsx +111 -0
- package/src/prompt/part.ts +29 -0
- package/src/prompt/stash.tsx +89 -0
- package/src/prompt/traits.ts +29 -0
- package/src/routes/home/session-destination.tsx +41 -0
- package/src/routes/home.tsx +95 -0
- package/src/routes/session/dialog-fork-from-timeline.tsx +76 -0
- package/src/routes/session/dialog-message.tsx +109 -0
- package/src/routes/session/dialog-subagent.tsx +26 -0
- package/src/routes/session/dialog-timeline.tsx +47 -0
- package/src/routes/session/footer.tsx +91 -0
- package/src/routes/session/index.tsx +2710 -0
- package/src/routes/session/permission.tsx +718 -0
- package/src/routes/session/question.tsx +514 -0
- package/src/routes/session/sidebar.tsx +103 -0
- package/src/routes/session/subagent-footer.tsx +132 -0
- package/src/runtime.tsx +9 -0
- package/src/terminal-win32.ts +130 -0
- package/src/theme/assets/aura.json +69 -0
- package/src/theme/assets/ayu.json +80 -0
- package/src/theme/assets/carbonfox.json +248 -0
- package/src/theme/assets/catppuccin-frappe.json +230 -0
- package/src/theme/assets/catppuccin-macchiato.json +230 -0
- package/src/theme/assets/catppuccin.json +112 -0
- package/src/theme/assets/cobalt2.json +225 -0
- package/src/theme/assets/cursor.json +249 -0
- package/src/theme/assets/dracula.json +219 -0
- package/src/theme/assets/everforest.json +241 -0
- package/src/theme/assets/flexoki.json +237 -0
- package/src/theme/assets/github.json +233 -0
- package/src/theme/assets/gruvbox.json +242 -0
- package/src/theme/assets/kanagawa.json +77 -0
- package/src/theme/assets/lucent-orng.json +234 -0
- package/src/theme/assets/material.json +235 -0
- package/src/theme/assets/matrix.json +77 -0
- package/src/theme/assets/mercury.json +252 -0
- package/src/theme/assets/monokai.json +221 -0
- package/src/theme/assets/nightowl.json +221 -0
- package/src/theme/assets/nord.json +223 -0
- package/src/theme/assets/one-dark.json +84 -0
- package/src/theme/assets/opencode.json +245 -0
- package/src/theme/assets/orng.json +249 -0
- package/src/theme/assets/osaka-jade.json +93 -0
- package/src/theme/assets/palenight.json +222 -0
- package/src/theme/assets/rosepine.json +234 -0
- package/src/theme/assets/solarized.json +223 -0
- package/src/theme/assets/synthwave84.json +226 -0
- package/src/theme/assets/tokyonight.json +243 -0
- package/src/theme/assets/vercel.json +245 -0
- package/src/theme/assets/vesper.json +218 -0
- package/src/theme/assets/zenburn.json +223 -0
- package/src/theme/index.ts +1089 -0
- package/src/ui/border.ts +21 -0
- package/src/ui/dialog-alert.tsx +66 -0
- package/src/ui/dialog-confirm.tsx +108 -0
- package/src/ui/dialog-export-options.tsx +217 -0
- package/src/ui/dialog-help.tsx +40 -0
- package/src/ui/dialog-prompt.tsx +126 -0
- package/src/ui/dialog-select.tsx +790 -0
- package/src/ui/dialog.tsx +231 -0
- package/src/ui/link.tsx +34 -0
- package/src/ui/spinner.ts +368 -0
- package/src/ui/toast.tsx +102 -0
- package/src/util/collapse-tool-output.ts +19 -0
- package/src/util/error.ts +182 -0
- package/src/util/filetype.ts +130 -0
- package/src/util/format.ts +20 -0
- package/src/util/layout.ts +25 -0
- package/src/util/locale.ts +86 -0
- package/src/util/model.ts +28 -0
- package/src/util/path.ts +12 -0
- package/src/util/persistence.ts +33 -0
- package/src/util/presentation.ts +38 -0
- package/src/util/provider-origin.ts +7 -0
- package/src/util/record.ts +3 -0
- package/src/util/renderer.ts +7 -0
- package/src/util/revert-diff.ts +18 -0
- package/src/util/scroll.ts +27 -0
- package/src/util/selection.ts +79 -0
- package/src/util/session.ts +3 -0
- package/src/util/signal.ts +51 -0
- package/src/util/system.ts +20 -0
- package/src/util/tool-display.ts +13 -0
- package/src/util/transcript.ts +112 -0
- package/sst-env.d.ts +10 -0
- package/test/app-lifecycle.test.tsx +128 -0
- package/test/cli/cmd/tui/dialog-workspace-create.test.ts +28 -0
- package/test/cli/cmd/tui/model-options.test.ts +32 -0
- package/test/cli/cmd/tui/notifications.test.ts +267 -0
- package/test/cli/cmd/tui/provider-options.test.ts +41 -0
- package/test/cli/cmd/tui/sync-fixture.tsx +70 -0
- package/test/cli/cmd/tui/sync-live-hydration.test.tsx +262 -0
- package/test/cli/cmd/tui/sync-undefined-messages.test.tsx +43 -0
- package/test/cli/cmd/tui/sync.test.tsx +65 -0
- package/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap +92 -0
- package/test/cli/tui/data.test.tsx +486 -0
- package/test/cli/tui/dialog-prompt.test.tsx +147 -0
- package/test/cli/tui/diff-viewer-file-tree.test.tsx +200 -0
- package/test/cli/tui/diff-viewer.test.tsx +268 -0
- package/test/cli/tui/inline-tool-wrap-snapshot.test.tsx +351 -0
- package/test/cli/tui/prompt-submit-race.test.ts +98 -0
- package/test/cli/tui/thinking.test.ts +36 -0
- package/test/cli/tui/use-event.test.tsx +148 -0
- package/test/clipboard.test.ts +19 -0
- package/test/component/dialog-session-list.test.ts +46 -0
- package/test/config.test.tsx +127 -0
- package/test/context/local.test.ts +22 -0
- package/test/editor.test.ts +32 -0
- package/test/feature-plugins/diff-viewer-file-tree-utils.test.ts +323 -0
- package/test/fixture/fixture.ts +13 -0
- package/test/fixture/tui-environment.tsx +32 -0
- package/test/fixture/tui-plugin.ts +36 -0
- package/test/fixture/tui-runtime.ts +12 -0
- package/test/fixture/tui-sdk.ts +109 -0
- package/test/index.test.tsx +6 -0
- package/test/keymap.test.tsx +141 -0
- package/test/plugin/runtime.test.ts +50 -0
- package/test/plugin/slots.test.tsx +38 -0
- package/test/prompt/display.test.ts +33 -0
- package/test/prompt/history.test.ts +39 -0
- package/test/prompt/jsonl.test.ts +24 -0
- package/test/prompt/local-attachment.test.ts +43 -0
- package/test/prompt/part.test.ts +53 -0
- package/test/prompt/persistence.test.ts +23 -0
- package/test/prompt/traits.test.ts +25 -0
- package/test/runtime.test.tsx +37 -0
- package/test/theme.test.ts +81 -0
- package/test/util/error.test.ts +49 -0
- package/test/util/filetype.test.ts +16 -0
- package/test/util/format.test.ts +59 -0
- package/test/util/model.test.ts +9 -0
- package/test/util/presentation.test.ts +8 -0
- package/test/util/renderer.test.ts +30 -0
- package/test/util/revert-diff.test.ts +35 -0
- package/test/util/session.test.ts +10 -0
- package/test/util/tool-display.test.ts +40 -0
- package/test/util/transcript.test.ts +421 -0
- 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
|
+
}
|