@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,790 @@
1
+ import {
2
+ InputRenderable,
3
+ RGBA,
4
+ ScrollBoxRenderable,
5
+ TextAttributes,
6
+ type KeyEvent,
7
+ type Renderable,
8
+ } from "@opentui/core"
9
+ import type { Binding } from "@opentui/keymap"
10
+ import { useTheme, selectedForeground } from "../context/theme"
11
+ import { entries, filter, flatMap, groupBy, pipe } from "remeda"
12
+ import { batch, createEffect, createMemo, createSignal, For, Show, type JSX, on, onCleanup } from "solid-js"
13
+ import { createStore } from "solid-js/store"
14
+ import { useTerminalDimensions } from "@opentui/solid"
15
+ import * as fuzzysort from "fuzzysort"
16
+ import { isDeepEqual } from "remeda"
17
+ import { useDialog, type DialogContext } from "./dialog"
18
+ import { Locale } from "../util/locale"
19
+ import { getScrollAcceleration } from "../util/scroll"
20
+ import { useTuiConfig } from "../config"
21
+ import { formatKeyBindings, useBindings, useKeymapSelector } from "../keymap"
22
+
23
+ export interface DialogSelectProps<T> {
24
+ title: string
25
+ titleView?: JSX.Element
26
+ placeholder?: string
27
+ footer?: JSX.Element
28
+ emptyView?: JSX.Element
29
+ options: DialogSelectOption<T>[]
30
+ flat?: boolean
31
+ ref?: (ref: DialogSelectRef<T>) => void
32
+ onMove?: (option: DialogSelectOption<T>) => void
33
+ onFilter?: (query: string) => void
34
+ onSelect?: (option: DialogSelectOption<T>) => void
35
+ skipFilter?: boolean
36
+ renderFilter?: boolean
37
+ locked?: boolean
38
+ preserveSelection?: boolean
39
+ actions?: {
40
+ command: string
41
+ title: string
42
+ side?: "left" | "right"
43
+ hidden?: boolean
44
+ disabled?: boolean | ((option: DialogSelectOption<T> | undefined) => boolean)
45
+ onTrigger: (option: DialogSelectOption<T>) => void
46
+ }[]
47
+ footerHints?: {
48
+ title: string
49
+ label: string
50
+ side?: "left" | "right"
51
+ }[]
52
+ bindings?: readonly Binding<Renderable, KeyEvent>[]
53
+ current?: T
54
+ }
55
+
56
+ export interface DialogSelectOption<T = any> {
57
+ title: string
58
+ titleView?: JSX.Element
59
+ value: T
60
+ description?: string
61
+ details?: string[]
62
+ footer?: JSX.Element | string
63
+ titleWidth?: number
64
+ truncateTitle?: boolean | "left"
65
+ category?: string
66
+ categoryView?: JSX.Element
67
+ disabled?: boolean
68
+ bg?: RGBA
69
+ gutter?: () => JSX.Element
70
+ margin?: JSX.Element
71
+ onSelect?: (ctx: DialogContext) => void
72
+ }
73
+
74
+ export type DialogSelectRef<T> = {
75
+ filter: string
76
+ filtered: DialogSelectOption<T>[]
77
+ moveTo(value: T): void
78
+ }
79
+
80
+ export function DialogSelect<T>(props: DialogSelectProps<T>) {
81
+ type Action = NonNullable<DialogSelectProps<T>["actions"]>[number]
82
+ type FooterHint = NonNullable<DialogSelectProps<T>["footerHints"]>[number]
83
+ type VisibleAction = (Action & { label: string }) | FooterHint
84
+
85
+ const dialog = useDialog()
86
+ const { theme } = useTheme()
87
+ const tuiConfig = useTuiConfig()
88
+ const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
89
+
90
+ const [store, setStore] = createStore({
91
+ selected: 0,
92
+ filter: "",
93
+ input: "keyboard" as "keyboard" | "mouse",
94
+ })
95
+ const [focusedAction, setFocusedAction] = createSignal<number>()
96
+ const actionFocused = createMemo(() => focusedAction() !== undefined)
97
+ let selection: { value: T; category?: string } | undefined
98
+ let resetSelection = false
99
+ let visibilityGeneration = 0
100
+
101
+ createEffect(
102
+ on(
103
+ () => props.current,
104
+ (current) => {
105
+ if (current) {
106
+ const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current))
107
+ if (currentIndex >= 0) {
108
+ setStore("selected", currentIndex)
109
+ selection = flat()[currentIndex]
110
+ }
111
+ }
112
+ },
113
+ ),
114
+ )
115
+
116
+ let input: InputRenderable
117
+
118
+ const actions = createMemo(() => props.actions ?? [])
119
+ const shownActions = createMemo(() => actions().filter((item) => !item.hidden))
120
+ const actionBindings = useKeymapSelector((keymap) =>
121
+ keymap.getCommandBindings({
122
+ visibility: "registered",
123
+ commands: shownActions().map((item) => item.command),
124
+ }),
125
+ )
126
+
127
+ const actionLabels = createMemo(() => {
128
+ const labels = new Map<string, string>()
129
+
130
+ for (const action of shownActions()) {
131
+ const label = formatKeyBindings(actionBindings().get(action.command), tuiConfig)
132
+ if (label) labels.set(action.command, label)
133
+ }
134
+
135
+ return labels
136
+ })
137
+ const visibleActions = createMemo(() => [
138
+ ...shownActions()
139
+ .map((item) => ({ ...item, label: actionLabels().get(item.command) ?? "" }))
140
+ .filter((item) => item.label),
141
+ ...(props.footerHints ?? []),
142
+ ])
143
+ const actionItems = createMemo(() =>
144
+ visibleActions()
145
+ .filter(isActionItem)
146
+ .filter((item) => !isActionDisabled(item)),
147
+ )
148
+
149
+ createEffect(() => {
150
+ const index = focusedAction()
151
+ if (index !== undefined && index >= actionItems().length) setFocusedAction(undefined)
152
+ })
153
+
154
+ const filtered = createMemo(() => {
155
+ if (props.skipFilter || props.renderFilter === false) return props.options.filter((x) => x.disabled !== true)
156
+ const needle = store.filter.toLowerCase()
157
+ const options = pipe(
158
+ props.options,
159
+ filter((x) => x.disabled !== true),
160
+ )
161
+ if (!needle) return options
162
+
163
+ // prioritize title matches (weight: 2) over category matches (weight: 1).
164
+ // users typically search by the item name, and not its category.
165
+ const result = fuzzysort
166
+ .go(needle, options, {
167
+ keys: ["title", "category"],
168
+ scoreFn: (r) => r[0].score * 2 + r[1].score,
169
+ })
170
+ .map((x) => x.obj)
171
+
172
+ return result
173
+ })
174
+
175
+ // When the filter changes due to how TUI works, the mousemove might still be triggered
176
+ // via a synthetic event as the layout moves underneath the cursor. This is a workaround to make sure the input mode remains keyboard
177
+ // that the mouseover event doesn't trigger when filtering.
178
+ createEffect(() => {
179
+ filtered()
180
+ setStore("input", "keyboard")
181
+ setFocusedAction(undefined)
182
+ })
183
+
184
+ const flatten = createMemo(() => props.flat && store.filter.length > 0)
185
+
186
+ const grouped = createMemo<[string, DialogSelectOption<T>[]][]>(() => {
187
+ if (flatten()) return [["", filtered()]]
188
+ const result = pipe(
189
+ filtered(),
190
+ groupBy((x) => x.category ?? ""),
191
+ // mapValues((x) => x.sort((a, b) => a.title.localeCompare(b.title))),
192
+ entries(),
193
+ )
194
+ return result
195
+ })
196
+
197
+ const flat = createMemo(() => {
198
+ return pipe(
199
+ grouped(),
200
+ flatMap(([_, options]) => options),
201
+ )
202
+ })
203
+
204
+ const rows = createMemo(() => {
205
+ const headers = grouped().reduce((acc, [category], i) => {
206
+ if (!category) return acc
207
+ return acc + (i > 0 ? 2 : 1)
208
+ }, 0)
209
+ return flat().reduce((acc, option) => acc + 1 + (option.details?.length ?? 0), headers)
210
+ })
211
+
212
+ const dimensions = useTerminalDimensions()
213
+ const height = createMemo(() => Math.min(rows(), Math.floor(dimensions().height / 2) - 6))
214
+
215
+ const selected = createMemo(() => flat()[store.selected])
216
+
217
+ createEffect(
218
+ on(
219
+ () => props.options,
220
+ () => {
221
+ if (!props.preserveSelection) return
222
+ if (resetSelection && store.filter.length > 0) {
223
+ const option = flat()[0]
224
+ if (!option) return
225
+ setStore("selected", 0)
226
+ selection = option
227
+ return
228
+ }
229
+ if (!selection) {
230
+ if (props.current !== undefined) {
231
+ const index = flat().findIndex((option) => isDeepEqual(option.value, props.current))
232
+ if (index >= 0) {
233
+ setStore("selected", index)
234
+ selection = flat()[index]
235
+ return
236
+ }
237
+ }
238
+ const option = selected()
239
+ if (!option) return
240
+ selection = option
241
+ return
242
+ }
243
+ const previous = selection
244
+ const index = flat().findIndex((option) => isDeepEqual(option.value, previous.value))
245
+ if (index >= 0) {
246
+ const option = flat()[index]
247
+ const moved = index !== store.selected || option.category !== previous.category
248
+ setStore("selected", index)
249
+ selection = option
250
+ if (!moved) return
251
+ const value = option.value
252
+ const generation = ++visibilityGeneration
253
+ requestAnimationFrame(() => {
254
+ requestAnimationFrame(() => {
255
+ if (generation !== visibilityGeneration) return
256
+ if (!props.preserveSelection || store.filter.length > 0) return
257
+ if (!isDeepEqual(selected()?.value, value)) return
258
+ scrollToSelection(false)
259
+ })
260
+ })
261
+ return
262
+ }
263
+ const next = Math.min(store.selected, flat().length - 1)
264
+ if (next < 0) return
265
+ setStore("selected", next)
266
+ selection = flat()[next]
267
+ },
268
+ ),
269
+ )
270
+ onCleanup(() => {
271
+ visibilityGeneration++
272
+ })
273
+
274
+ createEffect(
275
+ on([() => store.filter, () => props.current], ([filter, current]) => {
276
+ if (filter.length > 0) resetSelection = true
277
+ setTimeout(() => {
278
+ if (filter.length > 0) {
279
+ moveTo(0, true, false)
280
+ } else if (current) {
281
+ const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current))
282
+ if (currentIndex >= 0) {
283
+ moveTo(currentIndex, true)
284
+ }
285
+ }
286
+ }, 0)
287
+ }),
288
+ )
289
+
290
+ function move(direction: number) {
291
+ if (props.locked) return
292
+ if (flat().length === 0) return
293
+ let next = store.selected + direction
294
+ if (next < 0) next = flat().length - 1
295
+ if (next >= flat().length) next = 0
296
+ moveTo(next, true)
297
+ }
298
+
299
+ function moveTo(next: number, center = false, preserve = true) {
300
+ setFocusedAction(undefined)
301
+ setStore("selected", next)
302
+ const option = selected()
303
+ if (option) {
304
+ selection = option
305
+ resetSelection = !preserve
306
+ }
307
+ if (option) props.onMove?.(option)
308
+ scrollToSelection(center)
309
+ }
310
+
311
+ function scrollToSelection(center: boolean) {
312
+ if (!scroll) return
313
+ let remaining = store.selected
314
+ let index = 0
315
+ // Locate the row by position because a unique renderable ID cannot currently be ensured.
316
+ for (const [category, options] of grouped()) {
317
+ if (category) index++
318
+ if (remaining < options.length) {
319
+ index += remaining
320
+ break
321
+ }
322
+ index += options.length
323
+ remaining -= options.length
324
+ }
325
+ const target = scroll.getChildren()[index]
326
+ if (!target) return
327
+ const y = target.y - scroll.y
328
+ if (center) {
329
+ const centerOffset = Math.floor(scroll.height / 2)
330
+ scroll.scrollBy(y - centerOffset)
331
+ } else {
332
+ if (y >= scroll.height) {
333
+ scroll.scrollBy(y - scroll.height + 1)
334
+ }
335
+ if (y < 0) {
336
+ scroll.scrollBy(y)
337
+ if (isDeepEqual(flat()[0].value, selected()?.value)) {
338
+ scroll.scrollTo(0)
339
+ }
340
+ }
341
+ }
342
+ }
343
+
344
+ function submit() {
345
+ if (props.locked) return
346
+ setStore("input", "keyboard")
347
+ const index = focusedAction()
348
+ if (index !== undefined) {
349
+ triggerAction(actionItems()[index])
350
+ return
351
+ }
352
+ const option = selected()
353
+ if (!option) return
354
+ option.onSelect?.(dialog)
355
+ props.onSelect?.(option)
356
+ }
357
+
358
+ function moveAction(direction: 1 | -1) {
359
+ if (props.locked) return
360
+ const total = actionItems().length
361
+ if (total === 0) return
362
+ setFocusedAction((index) => {
363
+ if (index === undefined) return direction === 1 ? 0 : total - 1
364
+ const next = index + direction
365
+ return next < 0 || next >= total ? undefined : next
366
+ })
367
+ }
368
+
369
+ useBindings(() => {
370
+ const visible = shownActions()
371
+
372
+ return {
373
+ commands: [
374
+ {
375
+ name: "dialog.select.prev",
376
+ title: "Previous item",
377
+ category: "Dialog",
378
+ run() {
379
+ setStore("input", "keyboard")
380
+ move(-1)
381
+ },
382
+ },
383
+ {
384
+ name: "dialog.select.next",
385
+ title: "Next item",
386
+ category: "Dialog",
387
+ run() {
388
+ setStore("input", "keyboard")
389
+ move(1)
390
+ },
391
+ },
392
+ {
393
+ name: "dialog.select.page_up",
394
+ title: "Page up",
395
+ category: "Dialog",
396
+ run() {
397
+ setStore("input", "keyboard")
398
+ move(-10)
399
+ },
400
+ },
401
+ {
402
+ name: "dialog.select.page_down",
403
+ title: "Page down",
404
+ category: "Dialog",
405
+ run() {
406
+ setStore("input", "keyboard")
407
+ move(10)
408
+ },
409
+ },
410
+ {
411
+ name: "dialog.select.home",
412
+ title: "First item",
413
+ category: "Dialog",
414
+ run() {
415
+ if (props.locked) return
416
+ setStore("input", "keyboard")
417
+ moveTo(0)
418
+ },
419
+ },
420
+ {
421
+ name: "dialog.select.end",
422
+ title: "Last item",
423
+ category: "Dialog",
424
+ run() {
425
+ if (props.locked) return
426
+ setStore("input", "keyboard")
427
+ moveTo(flat().length - 1)
428
+ },
429
+ },
430
+ {
431
+ name: "dialog.select.submit",
432
+ title: "Select item",
433
+ category: "Dialog",
434
+ run: submit,
435
+ },
436
+ ...visible.map((item) => ({
437
+ name: item.command,
438
+ title: item.title,
439
+ category: "Dialog",
440
+ run() {
441
+ if (props.locked) return
442
+ if (isActionDisabled(item)) return
443
+ setStore("input", "keyboard")
444
+ const option = selected()
445
+ if (!option) return
446
+ item.onTrigger(option)
447
+ },
448
+ })),
449
+ ],
450
+ bindings: [
451
+ ...tuiConfig.keybinds.gather("dialog.select", [
452
+ "dialog.select.prev",
453
+ "dialog.select.next",
454
+ "dialog.select.page_up",
455
+ "dialog.select.page_down",
456
+ "dialog.select.home",
457
+ "dialog.select.end",
458
+ "dialog.select.submit",
459
+ ]),
460
+ ...visible.flatMap((item) => tuiConfig.keybinds.get(item.command)),
461
+ ...(visible.length
462
+ ? [
463
+ {
464
+ key: "tab",
465
+ desc: "Next dialog action",
466
+ group: "Dialog",
467
+ cmd: () => moveAction(1),
468
+ },
469
+ {
470
+ key: "shift+tab",
471
+ desc: "Previous dialog action",
472
+ group: "Dialog",
473
+ cmd: () => moveAction(-1),
474
+ },
475
+ ]
476
+ : []),
477
+ ...(props.bindings ?? []).filter((binding) => {
478
+ if (typeof binding.cmd !== "string") return true
479
+ return visible.some((item) => item.command === binding.cmd)
480
+ }),
481
+ ],
482
+ }
483
+ })
484
+
485
+ let scroll: ScrollBoxRenderable | undefined
486
+ const ref: DialogSelectRef<T> = {
487
+ get filter() {
488
+ return store.filter
489
+ },
490
+ get filtered() {
491
+ return filtered()
492
+ },
493
+ moveTo(value) {
494
+ const index = flat().findIndex((option) => isDeepEqual(option.value, value))
495
+ if (index >= 0) moveTo(index, true)
496
+ },
497
+ }
498
+ props.ref?.(ref)
499
+
500
+ const left = createMemo(() => visibleActions().filter((item) => item.side !== "right"))
501
+ const right = createMemo(() => visibleActions().filter((item) => item.side === "right"))
502
+
503
+ function triggerAction(item: VisibleAction | undefined) {
504
+ if (props.locked) return
505
+ if (!item || !isActionItem(item) || isActionDisabled(item)) return
506
+ setStore("input", "keyboard")
507
+ const option = selected()
508
+ if (!option) return
509
+ item.onTrigger(option)
510
+ }
511
+
512
+ function isActionItem(item: VisibleAction): item is Action & { label: string } {
513
+ return "onTrigger" in item
514
+ }
515
+
516
+ function isActionDisabled(item: Action) {
517
+ return typeof item.disabled === "function" ? item.disabled(selected()) : item.disabled
518
+ }
519
+
520
+ function isActionFocused(item: VisibleAction) {
521
+ if (props.locked) return false
522
+ if (!isActionItem(item)) return false
523
+ return actionItems().indexOf(item) === focusedAction()
524
+ }
525
+
526
+ function FooterAction(action: { item: VisibleAction }) {
527
+ if (!isActionItem(action.item))
528
+ return (
529
+ <text>
530
+ <span style={{ fg: theme.text }}>
531
+ <b>{action.item.title}</b>{" "}
532
+ </span>
533
+ <span style={{ fg: theme.textMuted }}>{action.item.label}</span>
534
+ </text>
535
+ )
536
+ const item = action.item
537
+ const active = createMemo(() => isActionFocused(item))
538
+ const disabled = createMemo(() => isActionDisabled(item))
539
+ const fg = selectedForeground(theme)
540
+ return (
541
+ <box
542
+ flexDirection="row"
543
+ backgroundColor={active() ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
544
+ onMouseUp={() => triggerAction(item)}
545
+ >
546
+ <text
547
+ fg={disabled() ? theme.textMuted : active() ? fg : theme.text}
548
+ attributes={active() ? TextAttributes.BOLD : undefined}
549
+ >
550
+ {item.title}
551
+ </text>
552
+ <text fg={disabled() ? theme.textMuted : active() ? fg : theme.textMuted}> {item.label}</text>
553
+ </box>
554
+ )
555
+ }
556
+
557
+ return (
558
+ <box gap={1} paddingBottom={1} flexGrow={1}>
559
+ <box paddingLeft={4} paddingRight={4}>
560
+ <box flexDirection="row" justifyContent="space-between">
561
+ {props.titleView ?? (
562
+ <text fg={theme.text} attributes={TextAttributes.BOLD}>
563
+ {props.title}
564
+ </text>
565
+ )}
566
+ <text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
567
+ esc
568
+ </text>
569
+ </box>
570
+ <Show when={props.renderFilter !== false}>
571
+ <box paddingTop={1}>
572
+ <input
573
+ onInput={(e) => {
574
+ if (props.locked) return
575
+ batch(() => {
576
+ setStore("filter", e)
577
+ props.onFilter?.(e)
578
+ })
579
+ }}
580
+ focusedBackgroundColor={theme.backgroundPanel}
581
+ cursorColor={theme.primary}
582
+ focusedTextColor={theme.textMuted}
583
+ ref={(r) => {
584
+ input = r
585
+ input.traits = { status: "FILTER" }
586
+ setTimeout(() => {
587
+ if (!input) return
588
+ if (input.isDestroyed) return
589
+ input.focus()
590
+ }, 1)
591
+ }}
592
+ placeholder={props.placeholder ?? "Search"}
593
+ placeholderColor={theme.textMuted}
594
+ />
595
+ </box>
596
+ </Show>
597
+ </box>
598
+ <box flexGrow={1} flexShrink={1}>
599
+ <Show
600
+ when={grouped().length > 0}
601
+ fallback={
602
+ props.emptyView ?? (
603
+ <box paddingLeft={4} paddingRight={4} paddingTop={1}>
604
+ <text fg={theme.textMuted}>No results found</text>
605
+ </box>
606
+ )
607
+ }
608
+ >
609
+ <scrollbox
610
+ paddingLeft={1}
611
+ paddingRight={1}
612
+ scrollbarOptions={{ visible: false }}
613
+ scrollAcceleration={scrollAcceleration()}
614
+ ref={(r: ScrollBoxRenderable) => (scroll = r)}
615
+ maxHeight={height()}
616
+ >
617
+ <For each={grouped()}>
618
+ {([category, options], index) => (
619
+ <>
620
+ <Show when={category}>
621
+ <box paddingTop={index() > 0 ? 1 : 0} paddingLeft={3}>
622
+ <Show
623
+ when={options[0]?.categoryView}
624
+ fallback={
625
+ <text fg={theme.accent} attributes={TextAttributes.BOLD}>
626
+ {category}
627
+ </text>
628
+ }
629
+ >
630
+ {options[0]?.categoryView}
631
+ </Show>
632
+ </box>
633
+ </Show>
634
+ <For each={options}>
635
+ {(option) => {
636
+ const active = createMemo(() => !props.locked && isDeepEqual(option.value, selected()?.value))
637
+ const current = createMemo(() => isDeepEqual(option.value, props.current))
638
+ return (
639
+ <box
640
+ flexDirection="column"
641
+ position="relative"
642
+ onMouseMove={() => {
643
+ if (props.locked) return
644
+ setStore("input", "mouse")
645
+ setFocusedAction(undefined)
646
+ }}
647
+ onMouseUp={() => {
648
+ if (props.locked) return
649
+ option.onSelect?.(dialog)
650
+ props.onSelect?.(option)
651
+ }}
652
+ onMouseOver={() => {
653
+ if (props.locked) return
654
+ if (store.input !== "mouse") return
655
+ const index = flat().findIndex((x) => isDeepEqual(x.value, option.value))
656
+ if (index === -1) return
657
+ moveTo(index)
658
+ }}
659
+ onMouseDown={() => {
660
+ if (props.locked) return
661
+ const index = flat().findIndex((x) => isDeepEqual(x.value, option.value))
662
+ if (index === -1) return
663
+ moveTo(index)
664
+ }}
665
+ >
666
+ <box
667
+ flexDirection="row"
668
+ paddingLeft={current() || option.gutter ? 1 : 3}
669
+ paddingRight={3}
670
+ gap={1}
671
+ backgroundColor={
672
+ active()
673
+ ? actionFocused()
674
+ ? theme.backgroundElement
675
+ : (option.bg ?? theme.primary)
676
+ : RGBA.fromInts(0, 0, 0, 0)
677
+ }
678
+ >
679
+ <Show when={!current() && option.margin}>
680
+ <box position="absolute" left={1} flexShrink={0}>
681
+ {option.margin}
682
+ </box>
683
+ </Show>
684
+ <Option
685
+ title={option.title}
686
+ titleView={option.titleView}
687
+ footer={flatten() ? (option.category ?? option.footer) : option.footer}
688
+ titleWidth={option.titleWidth}
689
+ truncateTitle={option.truncateTitle}
690
+ description={option.description !== category ? option.description : undefined}
691
+ active={active()}
692
+ current={current()}
693
+ muted={actionFocused()}
694
+ gutter={option.gutter}
695
+ />
696
+ </box>
697
+ <For each={option.details}>
698
+ {(detail) => (
699
+ <box paddingLeft={3} paddingRight={3}>
700
+ <text fg={theme.textMuted} wrapMode="none">
701
+ {Locale.truncateMiddle(detail, Math.max(1, Math.min(76, dimensions().width - 12)))}
702
+ </text>
703
+ </box>
704
+ )}
705
+ </For>
706
+ </box>
707
+ )
708
+ }}
709
+ </For>
710
+ </>
711
+ )}
712
+ </For>
713
+ </scrollbox>
714
+ </Show>
715
+ </box>
716
+ <Show when={props.footer || visibleActions().length} fallback={<box flexShrink={0} />}>
717
+ <box paddingRight={2} paddingLeft={4} flexDirection="row" justifyContent="space-between" flexShrink={0}>
718
+ <box flexDirection="row" gap={2}>
719
+ {props.footer}
720
+ <For each={left()}>{(item) => <FooterAction item={item} />}</For>
721
+ </box>
722
+ <box flexDirection="row" gap={2}>
723
+ <For each={right()}>{(item) => <FooterAction item={item} />}</For>
724
+ </box>
725
+ </box>
726
+ </Show>
727
+ </box>
728
+ )
729
+ }
730
+
731
+ function Option(props: {
732
+ title: string
733
+ titleView?: JSX.Element
734
+ description?: string
735
+ active?: boolean
736
+ current?: boolean
737
+ muted?: boolean
738
+ footer?: JSX.Element | string
739
+ titleWidth?: number
740
+ truncateTitle?: boolean | "left"
741
+ gutter?: () => JSX.Element
742
+ onMouseOver?: () => void
743
+ }) {
744
+ const { theme } = useTheme()
745
+ const fg = selectedForeground(theme)
746
+ const text = createMemo(() => {
747
+ if (props.active && !props.muted) return fg
748
+ if (props.muted && (props.active || props.current)) return theme.textMuted
749
+ if (props.current) return theme.primary
750
+ return theme.text
751
+ })
752
+
753
+ return (
754
+ <>
755
+ <Show when={props.current && !props.gutter}>
756
+ <text flexShrink={0} fg={text()} marginRight={0}>
757
+
758
+ </text>
759
+ </Show>
760
+ <Show when={props.gutter}>
761
+ <box flexShrink={0} marginRight={0}>
762
+ {props.gutter?.()}
763
+ </box>
764
+ </Show>
765
+ <text
766
+ flexGrow={1}
767
+ fg={text()}
768
+ attributes={props.active && !props.muted ? TextAttributes.BOLD : undefined}
769
+ overflow="hidden"
770
+ wrapMode="none"
771
+ paddingLeft={3}
772
+ >
773
+ {props.titleView ??
774
+ (props.truncateTitle === false
775
+ ? props.title
776
+ : props.truncateTitle === "left"
777
+ ? Locale.truncateLeft(props.title, props.titleWidth ?? 61)
778
+ : Locale.truncate(props.title, props.titleWidth ?? 61))}
779
+ <Show when={props.description}>
780
+ <span style={{ fg: props.active && !props.muted ? fg : theme.textMuted }}> {props.description}</span>
781
+ </Show>
782
+ </text>
783
+ <Show when={props.footer}>
784
+ <box flexShrink={0}>
785
+ <text fg={props.active && !props.muted ? fg : theme.textMuted}>{props.footer}</text>
786
+ </box>
787
+ </Show>
788
+ </>
789
+ )
790
+ }