@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,135 @@
1
+ import { createResource, createMemo, createSignal } from "solid-js"
2
+ import { TextAttributes } from "@opentui/core"
3
+ import { DialogSelect } from "../ui/dialog-select"
4
+ import { useSDK } from "../context/sdk"
5
+ import { useDialog } from "../ui/dialog"
6
+ import { useToast } from "../ui/toast"
7
+ import { useTheme } from "../context/theme"
8
+ import { errorMessage } from "../util/error"
9
+ import type { ExperimentalConsoleListOrgsResponse } from "@neurocode-ai/sdk/v2"
10
+
11
+ type OrgOption = ExperimentalConsoleListOrgsResponse["orgs"][number]
12
+
13
+ const accountHost = (url: string) => {
14
+ try {
15
+ return new URL(url).host
16
+ } catch {
17
+ return url
18
+ }
19
+ }
20
+
21
+ const accountLabel = (item: Pick<OrgOption, "accountEmail" | "accountUrl">) =>
22
+ `${item.accountEmail} ${accountHost(item.accountUrl)}`
23
+
24
+ export function DialogConsoleOrg() {
25
+ const sdk = useSDK()
26
+ const dialog = useDialog()
27
+ const toast = useToast()
28
+ const { theme } = useTheme()
29
+
30
+ const [loadError, setLoadError] = createSignal<unknown>()
31
+
32
+ const [orgs] = createResource(() =>
33
+ sdk.client.experimental.console
34
+ .listOrgs({}, { throwOnError: true })
35
+ .then((result) => result.data?.orgs ?? [])
36
+ // Catch so the rejected resource never reaches the memos below: reading
37
+ // orgs() in an errored state re-throws and tears down the dialog.
38
+ .catch((error) => {
39
+ setLoadError(error)
40
+ return undefined
41
+ }),
42
+ )
43
+
44
+ const showError = createMemo(() => Boolean(loadError()))
45
+
46
+ const current = createMemo(() => orgs()?.find((item) => item.active))
47
+
48
+ const options = createMemo(() => {
49
+ if (showError()) return []
50
+ const listed = orgs()
51
+ if (listed === undefined) {
52
+ return [
53
+ {
54
+ title: "Loading orgs...",
55
+ value: "loading",
56
+ onSelect: () => {},
57
+ },
58
+ ]
59
+ }
60
+
61
+ if (listed.length === 0) {
62
+ return [
63
+ {
64
+ title: "No orgs found",
65
+ value: "empty",
66
+ onSelect: () => {},
67
+ },
68
+ ]
69
+ }
70
+
71
+ return listed
72
+ .toSorted((a, b) => {
73
+ const activeAccountA = a.active ? 0 : 1
74
+ const activeAccountB = b.active ? 0 : 1
75
+ if (activeAccountA !== activeAccountB) return activeAccountA - activeAccountB
76
+
77
+ const accountCompare = accountLabel(a).localeCompare(accountLabel(b))
78
+ if (accountCompare !== 0) return accountCompare
79
+
80
+ return a.orgName.localeCompare(b.orgName)
81
+ })
82
+ .map((item) => ({
83
+ title: item.orgName,
84
+ value: item,
85
+ category: accountLabel(item),
86
+ categoryView: (
87
+ <box flexDirection="row" gap={2}>
88
+ <text fg={theme.accent}>{item.accountEmail}</text>
89
+ <text fg={theme.textMuted}>{accountHost(item.accountUrl)}</text>
90
+ </box>
91
+ ),
92
+ onSelect: async () => {
93
+ if (item.active) {
94
+ dialog.clear()
95
+ return
96
+ }
97
+
98
+ await sdk.client.experimental.console.switchOrg(
99
+ {
100
+ accountID: item.accountID,
101
+ orgID: item.orgID,
102
+ },
103
+ { throwOnError: true },
104
+ )
105
+
106
+ await sdk.client.instance.dispose()
107
+ toast.show({
108
+ message: `Switched to ${item.orgName}`,
109
+ variant: "info",
110
+ })
111
+ dialog.clear()
112
+ },
113
+ }))
114
+ })
115
+
116
+ return (
117
+ <DialogSelect<string | OrgOption>
118
+ title="Switch org"
119
+ options={options()}
120
+ current={current()}
121
+ renderFilter={!showError()}
122
+ locked={showError()}
123
+ emptyView={
124
+ showError() ? (
125
+ <box paddingLeft={4} paddingRight={4}>
126
+ <text fg={theme.error} attributes={TextAttributes.BOLD}>
127
+ Could not load orgs
128
+ </text>
129
+ <text fg={theme.textMuted}>{errorMessage(loadError())}</text>
130
+ </box>
131
+ ) : undefined
132
+ }
133
+ />
134
+ )
135
+ }
@@ -0,0 +1,90 @@
1
+ import { TextAttributes } from "@opentui/core"
2
+ import { createMemo, createSignal, For } from "solid-js"
3
+ import { InstallationChannel, InstallationVersion } from "@neurocode-ai/core/installation/version"
4
+ import { useTheme } from "../context/theme"
5
+ import { useDialog } from "../ui/dialog"
6
+ import { useRoute } from "../context/route"
7
+ import { useLocal } from "../context/local"
8
+ import { useClipboard } from "../context/clipboard"
9
+ import { useToast } from "../ui/toast"
10
+ import { useBindings } from "../keymap"
11
+ import { describeOS, describeTerminal } from "../util/system"
12
+
13
+ export function DialogDebug() {
14
+ const { theme } = useTheme()
15
+ const dialog = useDialog()
16
+ const route = useRoute()
17
+ const local = useLocal()
18
+ const clipboard = useClipboard()
19
+ const toast = useToast()
20
+ const [copied, setCopied] = createSignal(false)
21
+
22
+ dialog.setSize("large")
23
+
24
+ const entries = createMemo(() => {
25
+ const model = local.model.current()
26
+ return [
27
+ { label: "Version", value: `${InstallationVersion} (${InstallationChannel})` },
28
+ { label: "Date", value: new Date().toISOString() },
29
+ { label: "OS", value: describeOS() },
30
+ { label: "Terminal", value: describeTerminal() },
31
+ { label: "Session ID", value: route.data.type === "session" ? route.data.sessionID : "n/a" },
32
+ { label: "Model", value: model ? `${model.providerID}/${model.modelID}` : "n/a" },
33
+ ]
34
+ })
35
+
36
+ const copy = () => {
37
+ const text = entries()
38
+ .map((entry) => `${entry.label}: ${entry.value}`)
39
+ .join("\n")
40
+ void clipboard
41
+ .write?.(text)
42
+ .then(() => {
43
+ setCopied(true)
44
+ toast.show({ message: "Debug info copied to clipboard", variant: "info" })
45
+ })
46
+ .catch(toast.error)
47
+ }
48
+
49
+ useBindings(() => ({
50
+ bindings: [{ key: "return", desc: "Copy debug info", group: "Dialog", cmd: copy }],
51
+ }))
52
+
53
+ return (
54
+ <box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
55
+ <box flexDirection="row" justifyContent="space-between">
56
+ <text fg={theme.text} attributes={TextAttributes.BOLD}>
57
+ Debug
58
+ </text>
59
+ <text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
60
+ esc
61
+ </text>
62
+ </box>
63
+ {/* No click-to-copy here: releasing a mouse selection must trigger the
64
+ global copy-on-select so users can copy a single value, e.g. the session id. */}
65
+ <box>
66
+ <For each={entries()}>
67
+ {(entry) => (
68
+ <box flexDirection="row" gap={1}>
69
+ <text flexShrink={0} fg={theme.textMuted}>
70
+ {entry.label.padEnd(10)}
71
+ </text>
72
+ <text fg={theme.text} wrapMode="word">
73
+ {entry.value}
74
+ </text>
75
+ </box>
76
+ )}
77
+ </For>
78
+ </box>
79
+ <box flexDirection="row" justifyContent="space-between">
80
+ <text fg={theme.textMuted}>Share this when reporting an issue.</text>
81
+ <text onMouseUp={copy}>
82
+ <span style={{ fg: copied() ? theme.success : theme.text }}>
83
+ <b>{copied() ? "✓ copied" : "copy"}</b>{" "}
84
+ </span>
85
+ <span style={{ fg: theme.textMuted }}>enter</span>
86
+ </text>
87
+ </box>
88
+ </box>
89
+ )
90
+ }
@@ -0,0 +1,85 @@
1
+ import { createMemo, createSignal } from "solid-js"
2
+ import { useLocal } from "../context/local"
3
+ import { useSync } from "../context/sync"
4
+ import { map, pipe, entries, sortBy } from "remeda"
5
+ import { DialogSelect, type DialogSelectRef, type DialogSelectOption } from "../ui/dialog-select"
6
+ import { useTheme } from "../context/theme"
7
+ import { TextAttributes } from "@opentui/core"
8
+ import { useSDK } from "../context/sdk"
9
+
10
+ function Status(props: { enabled: boolean; loading: boolean }) {
11
+ const { theme } = useTheme()
12
+ if (props.loading) {
13
+ return <span style={{ fg: theme.textMuted }}>⋯ Loading</span>
14
+ }
15
+ if (props.enabled) {
16
+ return <span style={{ fg: theme.success, attributes: TextAttributes.BOLD }}>✓ Enabled</span>
17
+ }
18
+ return <span style={{ fg: theme.textMuted }}>○ Disabled</span>
19
+ }
20
+
21
+ export function DialogMcp() {
22
+ const local = useLocal()
23
+ const sync = useSync()
24
+ const sdk = useSDK()
25
+ const [, setRef] = createSignal<DialogSelectRef<unknown>>()
26
+ const [loading, setLoading] = createSignal<string | null>(null)
27
+
28
+ const options = createMemo(() => {
29
+ // Track sync data and loading state to trigger re-render when they change
30
+ const mcpData = sync.data.mcp
31
+ const loadingMcp = loading()
32
+
33
+ return pipe(
34
+ mcpData ?? {},
35
+ entries(),
36
+ sortBy(([name]) => name),
37
+ map(([name, status]) => ({
38
+ value: name,
39
+ title: name,
40
+ description: status.status === "failed" ? "failed" : status.status,
41
+ footer: <Status enabled={local.mcp.isEnabled(name)} loading={loadingMcp === name} />,
42
+ category: undefined,
43
+ })),
44
+ )
45
+ })
46
+
47
+ const actions = createMemo(() => [
48
+ {
49
+ command: "dialog.mcp.toggle",
50
+ title: "toggle",
51
+ onTrigger: async (option: DialogSelectOption<string>) => {
52
+ // Prevent toggling while an operation is already in progress
53
+ if (loading() !== null) return
54
+
55
+ setLoading(option.value)
56
+ try {
57
+ await local.mcp.toggle(option.value)
58
+ // Refresh MCP status from server
59
+ const status = await sdk.client.mcp.status()
60
+ if (status.data) {
61
+ sync.set("mcp", status.data)
62
+ } else {
63
+ console.error("Failed to refresh MCP status: no data returned")
64
+ }
65
+ } catch (error) {
66
+ console.error("Failed to toggle MCP:", error)
67
+ } finally {
68
+ setLoading(null)
69
+ }
70
+ },
71
+ },
72
+ ])
73
+
74
+ return (
75
+ <DialogSelect
76
+ ref={setRef}
77
+ title="MCPs"
78
+ options={options()}
79
+ actions={actions()}
80
+ onSelect={(_option) => {
81
+ // Don't close on select, only on escape
82
+ }}
83
+ />
84
+ )
85
+ }
@@ -0,0 +1,197 @@
1
+ import { createMemo, createSignal } from "solid-js"
2
+ import { useLocal } from "../context/local"
3
+ import { map, pipe, flatMap, entries, filter, sortBy, take } from "remeda"
4
+ import { DialogSelect } from "../ui/dialog-select"
5
+ import { useDialog } from "../ui/dialog"
6
+ import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
7
+ import { DialogVariant } from "./dialog-variant"
8
+ import * as fuzzysort from "fuzzysort"
9
+ import { useConnected } from "./use-connected"
10
+ import { useSync } from "../context/sync"
11
+
12
+ export function DialogModel(props: { providerID?: string }) {
13
+ const local = useLocal()
14
+ const sync = useSync()
15
+ const dialog = useDialog()
16
+ const [query, setQuery] = createSignal("")
17
+
18
+ const connected = useConnected()
19
+ const providers = createDialogProviderOptions()
20
+
21
+ const showExtra = createMemo(() => connected() && !props.providerID)
22
+
23
+ const options = createMemo(() => {
24
+ const needle = query().trim()
25
+ const showSections = showExtra() && needle.length === 0
26
+ const favorites = connected() ? local.model.favorite() : []
27
+ const recents = local.model.recent()
28
+
29
+ function toOptions(items: typeof favorites, category: string) {
30
+ if (!showSections) return []
31
+ return items.flatMap((item) => {
32
+ const provider = sync.data.provider.find((provider) => provider.id === item.providerID)
33
+ if (!provider) return []
34
+ const model = provider.models[item.modelID]
35
+ if (!model) return []
36
+ return [
37
+ {
38
+ key: item,
39
+ value: { providerID: provider.id, modelID: model.id },
40
+ title: model.name ?? item.modelID,
41
+ description: provider.name,
42
+ category,
43
+ disabled: provider.id === "opencode" && model.id.includes("-nano"),
44
+ footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
45
+ onSelect: () => {
46
+ onSelect(provider.id, model.id)
47
+ },
48
+ },
49
+ ]
50
+ })
51
+ }
52
+
53
+ const favoriteOptions = toOptions(favorites, "Favorites")
54
+ const recentOptions = toOptions(
55
+ recents.filter(
56
+ (item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID),
57
+ ),
58
+ "Recent",
59
+ )
60
+
61
+ const providerOptions = pipe(
62
+ sync.data.provider,
63
+ sortBy(
64
+ (provider) => provider.id !== "opencode",
65
+ (provider) => provider.name,
66
+ ),
67
+ flatMap((provider) =>
68
+ pipe(
69
+ provider.models,
70
+ entries(),
71
+ filter(([_, info]) => info.status !== "deprecated"),
72
+ filter(([_, info]) => (props.providerID ? info.providerID === props.providerID : true)),
73
+ map(([model, info]) => ({
74
+ value: { providerID: provider.id, modelID: model },
75
+ title: info.name ?? model,
76
+ releaseDate: info.release_date,
77
+ description: favorites.some((item) => item.providerID === provider.id && item.modelID === model)
78
+ ? "(Favorite)"
79
+ : undefined,
80
+ category: connected() ? provider.name : undefined,
81
+ disabled: provider.id === "opencode" && model.includes("-nano"),
82
+ footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
83
+ onSelect() {
84
+ onSelect(provider.id, model)
85
+ },
86
+ })),
87
+ filter((option) => {
88
+ if (!showSections) return true
89
+ if (
90
+ favorites.some(
91
+ (item) => item.providerID === option.value.providerID && item.modelID === option.value.modelID,
92
+ )
93
+ )
94
+ return false
95
+ if (
96
+ recents.some(
97
+ (item) => item.providerID === option.value.providerID && item.modelID === option.value.modelID,
98
+ )
99
+ )
100
+ return false
101
+ return true
102
+ }),
103
+ (options) => sortModelOptions(options, props.providerID !== undefined),
104
+ ),
105
+ ),
106
+ )
107
+
108
+ const popularProviders = !connected()
109
+ ? pipe(
110
+ providers(),
111
+ map((option) => ({
112
+ ...option,
113
+ category: "Popular providers",
114
+ })),
115
+ take(6),
116
+ )
117
+ : []
118
+
119
+ if (needle) {
120
+ return [
121
+ ...sortModelOptions(
122
+ fuzzysort.go(needle, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj),
123
+ false,
124
+ ),
125
+ ...fuzzysort.go(needle, popularProviders, { keys: ["title"] }).map((x) => x.obj),
126
+ ]
127
+ }
128
+
129
+ return [...favoriteOptions, ...recentOptions, ...providerOptions, ...popularProviders]
130
+ })
131
+
132
+ const provider = createMemo(() =>
133
+ props.providerID ? sync.data.provider.find((item) => item.id === props.providerID) : null,
134
+ )
135
+
136
+ const title = createMemo(() => {
137
+ const value = provider()
138
+ if (!value) return "Select model"
139
+ return value.name
140
+ })
141
+
142
+ function onSelect(providerID: string, modelID: string) {
143
+ local.model.set({ providerID, modelID }, { recent: true })
144
+ const list = local.model.variant.list()
145
+ const cur = local.model.variant.selected()
146
+ if (cur === "default" || (cur && list.includes(cur))) {
147
+ dialog.clear()
148
+ return
149
+ }
150
+ if (list.length > 0) {
151
+ dialog.replace(() => <DialogVariant />)
152
+ return
153
+ }
154
+ dialog.clear()
155
+ }
156
+
157
+ return (
158
+ <DialogSelect<ReturnType<typeof options>[number]["value"]>
159
+ options={options()}
160
+ actions={[
161
+ {
162
+ command: "model.dialog.provider",
163
+ title: connected() ? "Connect provider" : "View all providers",
164
+ onTrigger() {
165
+ dialog.replace(() => <DialogProvider />)
166
+ },
167
+ },
168
+ {
169
+ command: "model.dialog.favorite",
170
+ title: "Favorite",
171
+ hidden: !connected(),
172
+ onTrigger: (option) => {
173
+ local.model.toggleFavorite(option.value as { providerID: string; modelID: string })
174
+ },
175
+ },
176
+ ]}
177
+ onFilter={setQuery}
178
+ flat={true}
179
+ skipFilter={true}
180
+ title={title()}
181
+ current={local.model.current()}
182
+ />
183
+ )
184
+ }
185
+
186
+ export function sortModelOptions<T extends { footer?: string; releaseDate: string | number; title: string }>(
187
+ options: T[],
188
+ newestFirst: boolean,
189
+ ) {
190
+ if (newestFirst) return sortBy(options, [(option) => option.releaseDate, "desc"], (option) => option.title)
191
+ return sortBy(
192
+ options,
193
+ (option) => option.footer !== "Free",
194
+ [(option) => option.releaseDate, "desc"],
195
+ (option) => option.title,
196
+ )
197
+ }