@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,469 @@
1
+ import { createMemo, createSignal, onMount, Show } from "solid-js"
2
+ import { useSync } from "../context/sync"
3
+ import { map, pipe, sortBy } from "remeda"
4
+ import { DialogSelect } from "../ui/dialog-select"
5
+ import { useDialog } from "../ui/dialog"
6
+ import { useSDK } from "../context/sdk"
7
+ import { DialogPrompt } from "../ui/dialog-prompt"
8
+ import { Link } from "../ui/link"
9
+ import { useTheme } from "../context/theme"
10
+ import { TextAttributes } from "@opentui/core"
11
+ import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@neurocode-ai/sdk/v2"
12
+ import { DialogModel } from "./dialog-model"
13
+ import { useToast } from "../ui/toast"
14
+ import { isConsoleManagedProvider } from "../util/provider-origin"
15
+ import { useConnected } from "./use-connected"
16
+ import { useBindings } from "../keymap"
17
+ import { useClipboard } from "../context/clipboard"
18
+
19
+ const PROVIDER_PRIORITY: Record<string, number> = {
20
+ opencode: 0,
21
+ "opencode-go": 1,
22
+ openai: 2,
23
+ "github-copilot": 3,
24
+ anthropic: 4,
25
+ google: 5,
26
+ }
27
+
28
+ const CUSTOM_PROVIDER_OPTION_VALUE = "__NEUROCODE_custom_provider__"
29
+ const CUSTOM_PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
30
+
31
+ type ProviderOptionBase = {
32
+ title: string
33
+ value: string
34
+ description?: string
35
+ category: string
36
+ }
37
+
38
+ type ProviderOption =
39
+ | (ProviderOptionBase & {
40
+ type: "provider"
41
+ providerID: string
42
+ })
43
+ | (ProviderOptionBase & {
44
+ type: "custom"
45
+ })
46
+
47
+ export function providerOptions(list: { id: string; name: string }[]): ProviderOption[] {
48
+ return [
49
+ ...pipe(
50
+ list,
51
+ sortBy(
52
+ (x) => PROVIDER_PRIORITY[x.id] ?? 99,
53
+ (x) => x.name.toLowerCase(),
54
+ (x) => x.id,
55
+ ),
56
+ map((provider) => ({
57
+ type: "provider" as const,
58
+ title: provider.name,
59
+ value: provider.id,
60
+ providerID: provider.id,
61
+ description: {
62
+ opencode: "(Recommended)",
63
+ anthropic: "(API key)",
64
+ openai: "(ChatGPT Plus/Pro or API key)",
65
+ "opencode-go": "Low cost subscription for everyone",
66
+ }[provider.id],
67
+ category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Providers",
68
+ })),
69
+ ),
70
+ {
71
+ type: "custom",
72
+ title: "Other",
73
+ value: CUSTOM_PROVIDER_OPTION_VALUE,
74
+ description: "Custom provider",
75
+ category: "Providers",
76
+ },
77
+ ]
78
+ }
79
+
80
+ export function normalizeCustomProviderID(value: string) {
81
+ const providerID = value.trim().replace(/^@ai-sdk\//, "")
82
+ if (!CUSTOM_PROVIDER_ID.test(providerID)) return
83
+ return providerID
84
+ }
85
+
86
+ export function createDialogProviderOptions() {
87
+ const sync = useSync()
88
+ const dialog = useDialog()
89
+ const sdk = useSDK()
90
+ const toast = useToast()
91
+ const { theme } = useTheme()
92
+ const onboarded = useConnected()
93
+
94
+ async function promptCustomProviderID(): Promise<string | undefined> {
95
+ const value = await DialogPrompt.show(dialog, "Other", {
96
+ placeholder: "Provider id",
97
+ description: () => (
98
+ <text fg={theme.textMuted}>
99
+ This only stores a credential. Configure the provider in opencode.json to use it.
100
+ </text>
101
+ ),
102
+ })
103
+ if (value === null) return
104
+
105
+ const providerID = normalizeCustomProviderID(value)
106
+ if (providerID) return providerID
107
+
108
+ toast.show({
109
+ variant: "error",
110
+ message:
111
+ "Provider ids must start with a lowercase letter or number and only use lowercase letters, numbers, hyphens, and underscores",
112
+ })
113
+ return promptCustomProviderID()
114
+ }
115
+
116
+ const options = createMemo(() => {
117
+ return pipe(
118
+ providerOptions(sync.data.provider_next.all),
119
+ map((provider) => {
120
+ if (provider.type === "custom") {
121
+ return {
122
+ title: provider.title,
123
+ value: provider.value,
124
+ description: provider.description,
125
+ category: provider.category,
126
+ async onSelect() {
127
+ const providerID = await promptCustomProviderID()
128
+ if (!providerID) return
129
+ return dialog.replace(() => <ApiMethod providerID={providerID} title="API key" custom />)
130
+ },
131
+ }
132
+ }
133
+
134
+ const providerID = provider.providerID
135
+ const consoleManaged = isConsoleManagedProvider(sync.data.console_state.consoleManagedProviders, providerID)
136
+ const connected = sync.data.provider_next.connected.includes(providerID)
137
+
138
+ return {
139
+ title: provider.title,
140
+ value: provider.value,
141
+ description: provider.description,
142
+ footer: consoleManaged ? sync.data.console_state.activeOrgName : undefined,
143
+ category: provider.category,
144
+ gutter: connected && onboarded() ? () => <text fg={theme.success}>✓</text> : undefined,
145
+ async onSelect() {
146
+ if (consoleManaged) return
147
+
148
+ const methods = sync.data.provider_auth[providerID] ?? [
149
+ {
150
+ type: "api",
151
+ label: "API key",
152
+ },
153
+ ]
154
+ let index: number | null = 0
155
+ if (methods.length > 1) {
156
+ index = await new Promise<number | null>((resolve) => {
157
+ dialog.replace(
158
+ () => (
159
+ <DialogSelect
160
+ title="Select auth method"
161
+ options={methods.map((x, index) => ({
162
+ title: x.label,
163
+ value: index,
164
+ }))}
165
+ onSelect={(option) => resolve(option.value)}
166
+ />
167
+ ),
168
+ () => resolve(null),
169
+ )
170
+ })
171
+ }
172
+ if (index == null) return
173
+ const method = methods[index]
174
+ if (method.type === "oauth") {
175
+ let inputs: Record<string, string> | undefined
176
+ if (method.prompts?.length) {
177
+ const value = await PromptsMethod({
178
+ dialog,
179
+ prompts: method.prompts,
180
+ })
181
+ if (!value) return
182
+ inputs = value
183
+ }
184
+
185
+ const result = await sdk.client.provider.oauth.authorize({
186
+ providerID,
187
+ method: index,
188
+ inputs,
189
+ })
190
+ if (result.error) {
191
+ toast.show({
192
+ variant: "error",
193
+ message: JSON.stringify(result.error),
194
+ })
195
+ dialog.clear()
196
+ return
197
+ }
198
+ if (result.data?.method === "code") {
199
+ dialog.replace(() => (
200
+ <CodeMethod providerID={providerID} title={method.label} index={index} authorization={result.data!} />
201
+ ))
202
+ }
203
+ if (result.data?.method === "auto") {
204
+ dialog.replace(() => (
205
+ <AutoMethod providerID={providerID} title={method.label} index={index} authorization={result.data!} />
206
+ ))
207
+ }
208
+ }
209
+ if (method.type === "api") {
210
+ let metadata: Record<string, string> | undefined
211
+ if (method.prompts?.length) {
212
+ const value = await PromptsMethod({ dialog, prompts: method.prompts })
213
+ if (!value) return
214
+ metadata = value
215
+ }
216
+ return dialog.replace(() => (
217
+ <ApiMethod providerID={providerID} title={method.label} metadata={metadata} />
218
+ ))
219
+ }
220
+ },
221
+ }
222
+ }),
223
+ )
224
+ })
225
+ return options
226
+ }
227
+
228
+ export function DialogProvider() {
229
+ const options = createDialogProviderOptions()
230
+ return <DialogSelect title="Connect a provider" options={options()} />
231
+ }
232
+
233
+ interface AutoMethodProps {
234
+ index: number
235
+ providerID: string
236
+ title: string
237
+ authorization: ProviderAuthAuthorization
238
+ }
239
+ function AutoMethod(props: AutoMethodProps) {
240
+ const { theme } = useTheme()
241
+ const sdk = useSDK()
242
+ const dialog = useDialog()
243
+ const sync = useSync()
244
+ const toast = useToast()
245
+ const clipboard = useClipboard()
246
+
247
+ useBindings(() => ({
248
+ bindings: [
249
+ {
250
+ key: "c",
251
+ desc: "Copy provider code",
252
+ group: "Dialog",
253
+ cmd: () => {
254
+ const code =
255
+ props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.authorization.url
256
+ clipboard
257
+ .write?.(code)
258
+ .then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
259
+ .catch(toast.error)
260
+ },
261
+ },
262
+ ],
263
+ }))
264
+
265
+ onMount(async () => {
266
+ const result = await sdk.client.provider.oauth.callback({
267
+ providerID: props.providerID,
268
+ method: props.index,
269
+ })
270
+ if (result.error) {
271
+ toast.show({
272
+ variant: "error",
273
+ message:
274
+ "name" in result.error && result.error.name === "ProviderAuthOauthCallbackFailed"
275
+ ? "OAuth authorization failed. Try /connect again."
276
+ : JSON.stringify(result.error),
277
+ })
278
+ dialog.clear()
279
+ return
280
+ }
281
+ await sdk.client.instance.dispose()
282
+ await sync.bootstrap()
283
+ dialog.replace(() => <DialogModel providerID={props.providerID} />)
284
+ })
285
+
286
+ return (
287
+ <box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
288
+ <box flexDirection="row" justifyContent="space-between">
289
+ <text attributes={TextAttributes.BOLD} fg={theme.text}>
290
+ {props.title}
291
+ </text>
292
+ <text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
293
+ esc
294
+ </text>
295
+ </box>
296
+ <box gap={1}>
297
+ <Link href={props.authorization.url} fg={theme.primary} />
298
+ <text fg={theme.textMuted}>{props.authorization.instructions}</text>
299
+ </box>
300
+ <text fg={theme.textMuted}>Waiting for authorization...</text>
301
+ <text fg={theme.text}>
302
+ c <span style={{ fg: theme.textMuted }}>copy</span>
303
+ </text>
304
+ </box>
305
+ )
306
+ }
307
+
308
+ interface CodeMethodProps {
309
+ index: number
310
+ title: string
311
+ providerID: string
312
+ authorization: ProviderAuthAuthorization
313
+ }
314
+ function CodeMethod(props: CodeMethodProps) {
315
+ const { theme } = useTheme()
316
+ const sdk = useSDK()
317
+ const sync = useSync()
318
+ const dialog = useDialog()
319
+ const [error, setError] = createSignal(false)
320
+
321
+ return (
322
+ <DialogPrompt
323
+ title={props.title}
324
+ placeholder="Authorization code"
325
+ onConfirm={async (value) => {
326
+ const { error } = await sdk.client.provider.oauth.callback({
327
+ providerID: props.providerID,
328
+ method: props.index,
329
+ code: value,
330
+ })
331
+ if (!error) {
332
+ await sdk.client.instance.dispose()
333
+ await sync.bootstrap()
334
+ dialog.replace(() => <DialogModel providerID={props.providerID} />)
335
+ return
336
+ }
337
+ setError(true)
338
+ }}
339
+ description={() => (
340
+ <box gap={1}>
341
+ <text fg={theme.textMuted}>{props.authorization.instructions}</text>
342
+ <Link href={props.authorization.url} fg={theme.primary} />
343
+ <Show when={error()}>
344
+ <text fg={theme.error}>Invalid code</text>
345
+ </Show>
346
+ </box>
347
+ )}
348
+ />
349
+ )
350
+ }
351
+
352
+ interface ApiMethodProps {
353
+ providerID: string
354
+ title: string
355
+ metadata?: Record<string, string>
356
+ custom?: boolean
357
+ }
358
+ function ApiMethod(props: ApiMethodProps) {
359
+ const dialog = useDialog()
360
+ const sdk = useSDK()
361
+ const sync = useSync()
362
+ const toast = useToast()
363
+ const { theme } = useTheme()
364
+
365
+ return (
366
+ <DialogPrompt
367
+ title={props.title}
368
+ placeholder="API key"
369
+ description={() =>
370
+ ({
371
+ opencode: (
372
+ <box gap={1}>
373
+ <text fg={theme.textMuted}>
374
+ NeuroCode Zen gives you access to all the best coding models at the cheapest prices with a single API
375
+ key.
376
+ </text>
377
+ <text fg={theme.text}>
378
+ Go to <span style={{ fg: theme.primary }}>https://opencode.ai/zen</span> to get a key
379
+ </text>
380
+ </box>
381
+ ),
382
+ "opencode-go": (
383
+ <box gap={1}>
384
+ <text fg={theme.textMuted}>
385
+ NeuroCode Go is a $10 per month subscription that provides reliable access to popular open coding models
386
+ with generous usage limits.
387
+ </text>
388
+ <text fg={theme.text}>
389
+ Go to <span style={{ fg: theme.primary }}>https://opencode.ai/go</span> and enable NeuroCode Go
390
+ </text>
391
+ </box>
392
+ ),
393
+ })[props.providerID] ?? undefined
394
+ }
395
+ onConfirm={async (value) => {
396
+ if (!value) return
397
+ await sdk.client.auth.set({
398
+ providerID: props.providerID,
399
+ auth: {
400
+ type: "api",
401
+ key: value,
402
+ ...(props.metadata ? { metadata: props.metadata } : {}),
403
+ },
404
+ })
405
+ await sdk.client.instance.dispose()
406
+ await sync.bootstrap()
407
+ if (props.custom && !sync.data.provider_next.all.some((provider) => provider.id === props.providerID)) {
408
+ toast.show({
409
+ variant: "info",
410
+ message: `Saved credential for ${props.providerID}. Configure it in opencode.json to use it.`,
411
+ })
412
+ dialog.clear()
413
+ return
414
+ }
415
+ dialog.replace(() => <DialogModel providerID={props.providerID} />)
416
+ }}
417
+ />
418
+ )
419
+ }
420
+
421
+ interface PromptsMethodProps {
422
+ dialog: ReturnType<typeof useDialog>
423
+ prompts: NonNullable<ProviderAuthMethod["prompts"]>[number][]
424
+ }
425
+ async function PromptsMethod(props: PromptsMethodProps) {
426
+ const inputs: Record<string, string> = {}
427
+ for (const prompt of props.prompts) {
428
+ if (prompt.when) {
429
+ const value = inputs[prompt.when.key]
430
+ if (value === undefined) continue
431
+ const matches = prompt.when.op === "eq" ? value === prompt.when.value : value !== prompt.when.value
432
+ if (!matches) continue
433
+ }
434
+
435
+ if (prompt.type === "select") {
436
+ const value = await new Promise<string | null>((resolve) => {
437
+ props.dialog.replace(
438
+ () => (
439
+ <DialogSelect
440
+ title={prompt.message}
441
+ options={prompt.options.map((x) => ({
442
+ title: x.label,
443
+ value: x.value,
444
+ description: x.hint,
445
+ }))}
446
+ onSelect={(option) => resolve(option.value)}
447
+ />
448
+ ),
449
+ () => resolve(null),
450
+ )
451
+ })
452
+ if (value === null) return null
453
+ inputs[prompt.key] = value
454
+ continue
455
+ }
456
+
457
+ const value = await new Promise<string | null>((resolve) => {
458
+ props.dialog.replace(
459
+ () => (
460
+ <DialogPrompt title={prompt.message} placeholder={prompt.placeholder} onConfirm={(value) => resolve(value)} />
461
+ ),
462
+ () => resolve(null),
463
+ )
464
+ })
465
+ if (value === null) return null
466
+ inputs[prompt.key] = value
467
+ }
468
+ return inputs
469
+ }
@@ -0,0 +1,160 @@
1
+ import { RGBA, TextAttributes } from "@opentui/core"
2
+ import open from "open"
3
+ import { createSignal } from "solid-js"
4
+ import { selectedForeground, useTheme } from "../context/theme"
5
+ import { useDialog, type DialogContext } from "../ui/dialog"
6
+ import { Link } from "../ui/link"
7
+ import { BgPulse } from "./bg-pulse"
8
+ import { useBindings } from "../keymap"
9
+
10
+ const GO_URL = "https://opencode.ai/go"
11
+ const PAD_X = 3
12
+ const PAD_TOP_OUTER = 1
13
+ const FOREGROUND_ALPHA = 186
14
+
15
+ export type DialogRetryActionProps = {
16
+ title: string
17
+ message: string
18
+ label: string
19
+ link?: string
20
+ onClose?: (dontShowAgain?: boolean) => void
21
+ }
22
+
23
+ function runAction(props: DialogRetryActionProps, dialog: ReturnType<typeof useDialog>) {
24
+ if (props.link) open(props.link).catch(() => {})
25
+ props.onClose?.()
26
+ dialog.clear()
27
+ }
28
+
29
+ function dismiss(props: DialogRetryActionProps, dialog: ReturnType<typeof useDialog>) {
30
+ props.onClose?.(true)
31
+ dialog.clear()
32
+ }
33
+
34
+ function panelOverlay(color: RGBA) {
35
+ const [r, g, b] = color.toInts()
36
+ return RGBA.fromInts(r, g, b, FOREGROUND_ALPHA)
37
+ }
38
+
39
+ export function DialogRetryAction(props: DialogRetryActionProps) {
40
+ const dialog = useDialog()
41
+ const { theme } = useTheme()
42
+ const fg = selectedForeground(theme)
43
+ const showGoTreatment = () => props.link === GO_URL
44
+ const textBg = () => (showGoTreatment() ? panelOverlay(theme.backgroundPanel) : undefined)
45
+ const [selected, setSelected] = createSignal<"dismiss" | "action">("action")
46
+
47
+ useBindings(() => ({
48
+ bindings: [
49
+ {
50
+ key: "left",
51
+ desc: "Previous retry option",
52
+ group: "Dialog",
53
+ cmd: () => setSelected((value) => (value === "action" ? "dismiss" : "action")),
54
+ },
55
+ {
56
+ key: "right",
57
+ desc: "Next retry option",
58
+ group: "Dialog",
59
+ cmd: () => setSelected((value) => (value === "action" ? "dismiss" : "action")),
60
+ },
61
+ {
62
+ key: "tab",
63
+ desc: "Next retry option",
64
+ group: "Dialog",
65
+ cmd: () => setSelected((value) => (value === "action" ? "dismiss" : "action")),
66
+ },
67
+ {
68
+ key: "return",
69
+ desc: "Confirm retry option",
70
+ group: "Dialog",
71
+ cmd: () => {
72
+ if (selected() === "action") runAction(props, dialog)
73
+ else dismiss(props, dialog)
74
+ },
75
+ },
76
+ ],
77
+ }))
78
+
79
+ return (
80
+ <box>
81
+ {showGoTreatment() ? (
82
+ <box position="absolute" top={-PAD_TOP_OUTER} left={0} right={0} bottom={0} zIndex={0}>
83
+ <BgPulse />
84
+ </box>
85
+ ) : null}
86
+ <box zIndex={1} paddingLeft={PAD_X} paddingRight={PAD_X} paddingBottom={1} gap={1}>
87
+ <box flexDirection="row" justifyContent="space-between">
88
+ <text attributes={TextAttributes.BOLD} fg={theme.text} bg={textBg()}>
89
+ {props.title}
90
+ </text>
91
+ <text fg={theme.textMuted} bg={textBg()} onMouseUp={() => dialog.clear()}>
92
+ esc
93
+ </text>
94
+ </box>
95
+ <box gap={0}>
96
+ <text fg={theme.textMuted} bg={textBg()}>
97
+ {props.message}
98
+ </text>
99
+ </box>
100
+ {props.link ? (
101
+ showGoTreatment() ? (
102
+ <box alignItems="center" justifyContent="flex-end" height={7} paddingBottom={1}>
103
+ <Link href={props.link} fg={theme.primary} bg={textBg()} wrapMode="none" />
104
+ </box>
105
+ ) : (
106
+ <box width="100%" flexDirection="row" justifyContent="center" paddingBottom={1}>
107
+ <Link href={props.link} fg={theme.primary} wrapMode="none" />
108
+ </box>
109
+ )
110
+ ) : (
111
+ <box paddingBottom={1} />
112
+ )}
113
+ <box flexDirection="row" justifyContent="space-between">
114
+ <box
115
+ paddingLeft={2}
116
+ paddingRight={2}
117
+ backgroundColor={selected() === "dismiss" ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
118
+ onMouseOver={() => setSelected("dismiss")}
119
+ onMouseUp={() => dismiss(props, dialog)}
120
+ >
121
+ <text
122
+ fg={selected() === "dismiss" ? fg : theme.textMuted}
123
+ bg={selected() === "dismiss" ? undefined : textBg()}
124
+ attributes={selected() === "dismiss" ? TextAttributes.BOLD : undefined}
125
+ >
126
+ don't show again
127
+ </text>
128
+ </box>
129
+ <box
130
+ paddingLeft={2}
131
+ paddingRight={2}
132
+ backgroundColor={selected() === "action" ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
133
+ onMouseOver={() => setSelected("action")}
134
+ onMouseUp={() => runAction(props, dialog)}
135
+ >
136
+ <text
137
+ fg={selected() === "action" ? fg : theme.text}
138
+ bg={selected() === "action" ? undefined : textBg()}
139
+ attributes={selected() === "action" ? TextAttributes.BOLD : undefined}
140
+ >
141
+ {props.label}
142
+ </text>
143
+ </box>
144
+ </box>
145
+ </box>
146
+ </box>
147
+ )
148
+ }
149
+
150
+ DialogRetryAction.show = (
151
+ dialog: DialogContext,
152
+ props: Pick<DialogRetryActionProps, "title" | "message" | "label" | "link">,
153
+ ) => {
154
+ return new Promise<boolean>((resolve) => {
155
+ dialog.replace(
156
+ () => <DialogRetryAction {...props} onClose={(dontShow) => resolve(dontShow ?? false)} />,
157
+ () => resolve(false),
158
+ )
159
+ })
160
+ }