@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,48 @@
1
+ import { readFile } from "node:fs/promises"
2
+ import path from "node:path"
3
+
4
+ export type LocalFiles = Readonly<{
5
+ readText(path: string): Promise<string>
6
+ readBytes(path: string): Promise<Uint8Array>
7
+ mime(path: string): Promise<string>
8
+ }>
9
+
10
+ export type LocalAttachment =
11
+ | Readonly<{ type: "text"; mime: "image/svg+xml"; content: string }>
12
+ | Readonly<{ type: "binary"; mime: string; content: Uint8Array }>
13
+
14
+ export function readLocalAttachment(file: string) {
15
+ return readLocalAttachmentWith(
16
+ {
17
+ readText: (value) => readFile(value, "utf8"),
18
+ readBytes: (value) => readFile(value),
19
+ mime: async (value) => mimeTypes[path.extname(value).toLowerCase()] ?? "application/octet-stream",
20
+ },
21
+ file,
22
+ )
23
+ }
24
+
25
+ const mimeTypes: Record<string, string> = {
26
+ ".avif": "image/avif",
27
+ ".gif": "image/gif",
28
+ ".jpeg": "image/jpeg",
29
+ ".jpg": "image/jpeg",
30
+ ".pdf": "application/pdf",
31
+ ".png": "image/png",
32
+ ".svg": "image/svg+xml",
33
+ ".webp": "image/webp",
34
+ }
35
+
36
+ export async function readLocalAttachmentWith(files: LocalFiles, path: string): Promise<LocalAttachment | undefined> {
37
+ const mime = await files.mime(path).catch(() => undefined)
38
+ if (!mime) return
39
+ if (mime === "image/svg+xml") {
40
+ const content = await files.readText(path).catch(() => undefined)
41
+ if (!content) return
42
+ return { type: "text", mime, content }
43
+ }
44
+ if (!mime.startsWith("image/") && mime !== "application/pdf") return
45
+ const content = await files.readBytes(path).catch(() => undefined)
46
+ if (!content) return
47
+ return { type: "binary", mime, content }
48
+ }
@@ -0,0 +1,205 @@
1
+ import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
2
+ import path from "path"
3
+ import { useTuiPaths } from "../../context/runtime"
4
+ import { errorMessage } from "../../util/error"
5
+ import { useDialog } from "../../ui/dialog"
6
+ import { useSDK } from "../../context/sdk"
7
+ import { useSync } from "../../context/sync"
8
+ import { useToast } from "../../ui/toast"
9
+ import { DialogMoveSession, type MoveSessionSelection } from "../dialog-move-session"
10
+ import { DialogWorkspaceFileChanges } from "../dialog-workspace-file-changes"
11
+ import { useHomeSessionDestination } from "../../routes/home/session-destination"
12
+ import { useProject } from "../../context/project"
13
+
14
+ function moveReminderText(directory: string) {
15
+ return `<system-reminder>The user has changed the current working directory to "${directory}". This is still the same project but at a possibly new location; take this into account when working with any files from now on.</system-reminder>`
16
+ }
17
+
18
+ export function usePromptMove(input: { projectID: () => string | undefined; sessionID: () => string | undefined }) {
19
+ const dialog = useDialog()
20
+ const sdk = useSDK()
21
+ const sync = useSync()
22
+ const toast = useToast()
23
+ const homeDestination = useHomeSessionDestination()
24
+ const project = useProject()
25
+ const paths = useTuiPaths()
26
+ const [creating, setCreating] = createSignal(false)
27
+ const [creatingDots, setCreatingDots] = createSignal(3)
28
+ const [progress, setProgress] = createSignal<string>()
29
+
30
+ async function create(context?: string) {
31
+ const projectID = input.projectID()
32
+ if (!projectID) return
33
+ setCreating(true)
34
+ setProgress("Creating copy")
35
+ try {
36
+ const generated = await sdk.client.experimental.projectCopy.generateName(
37
+ { projectID, context },
38
+ { throwOnError: true },
39
+ )
40
+ const result = await sdk.client.v2.projectCopy.create(
41
+ {
42
+ projectID,
43
+ location: { directory: sdk.directory },
44
+ strategy: "git_worktree",
45
+ directory: path.join(paths.worktree, projectID.slice(0, 6)),
46
+ name: generated.data.name,
47
+ },
48
+ { throwOnError: true },
49
+ )
50
+ const directory = result.data?.directory
51
+ if (!directory) throw new Error("No project copy directory returned")
52
+
53
+ // Call a location-based route to make sure it's bootstrapped
54
+ // before moving on
55
+ await sdk.client.path.get({ directory }, { throwOnError: true })
56
+
57
+ setProgress("Creating session")
58
+ return directory
59
+ } catch (err) {
60
+ homeDestination?.clear()
61
+ setProgress(undefined)
62
+ setCreating(false)
63
+ toast.show({ title: "Creating workspace failed", message: errorMessage(err), variant: "error" })
64
+ return
65
+ }
66
+ }
67
+
68
+ function open() {
69
+ const projectID = input.projectID()
70
+ if (!projectID) return
71
+ const sessionID = input.sessionID()
72
+ const session = sessionID ? sync.session.get(sessionID) : undefined
73
+ dialog.replace(() => (
74
+ <DialogMoveSession
75
+ projectID={projectID}
76
+ current={
77
+ homeDestination?.destination() ??
78
+ (session
79
+ ? {
80
+ type: "directory",
81
+ directory: session.directory,
82
+ subdirectory: !!session.path,
83
+ }
84
+ : {
85
+ type: "directory",
86
+ directory: project.instance.directory(),
87
+ subdirectory: project.instance.directory() !== project.instance.path().worktree,
88
+ })
89
+ }
90
+ onCurrentChange={(selection) => homeDestination?.setDestination(selection)}
91
+ onSelect={(selection) => {
92
+ const sessionID = input.sessionID()
93
+ if (!sessionID) {
94
+ homeDestination?.setDestination(selection)
95
+ dialog.clear()
96
+ return
97
+ }
98
+ void moveExistingSession(sessionID, selection)
99
+ }}
100
+ />
101
+ ))
102
+ }
103
+
104
+ function sessionContext(sessionID: string) {
105
+ const session = sync.session.get(sessionID)
106
+ const messages = (sync.data.message[sessionID] ?? [])
107
+ .slice(-6)
108
+ .map((message) =>
109
+ [
110
+ message.role + ":",
111
+ ...(sync.data.part[message.id] ?? []).flatMap((part) => (part.type === "text" ? [part.text] : [])),
112
+ ].join(" "),
113
+ )
114
+ return [session?.title, ...messages].filter(Boolean).join("\n") || undefined
115
+ }
116
+
117
+ async function moveExistingSession(sessionID: string, selection: MoveSessionSelection) {
118
+ const session = sync.session.get(sessionID)
119
+ const status = await sdk.client.vcs.status({ directory: session?.directory }).catch(() => undefined)
120
+ const choice = status?.data?.length ? await DialogWorkspaceFileChanges.show(dialog, status.data) : "no"
121
+ if (!choice) return
122
+ dialog.clear()
123
+ const directory = selection.type === "new" ? await create(sessionContext(sessionID)) : selection.directory
124
+ if (!directory) {
125
+ setProgress(undefined)
126
+ dialog.clear()
127
+ return
128
+ }
129
+ setProgress("Moving session")
130
+ try {
131
+ await sdk.client.experimental.controlPlane.moveSession(
132
+ {
133
+ sessionID,
134
+ destination: { directory },
135
+ moveChanges: choice === "yes",
136
+ },
137
+ { throwOnError: true },
138
+ )
139
+ await sdk.client.session
140
+ .promptAsync({
141
+ sessionID,
142
+ directory,
143
+ noReply: true,
144
+ parts: [
145
+ {
146
+ type: "text",
147
+ text: moveReminderText(directory),
148
+ synthetic: true,
149
+ },
150
+ ],
151
+ })
152
+ .catch(() => undefined)
153
+ dialog.clear()
154
+ } catch (error) {
155
+ toast.error(error)
156
+ dialog.clear()
157
+ } finally {
158
+ setProgress(undefined)
159
+ setCreating(false)
160
+ }
161
+ }
162
+
163
+ const pending = createMemo(() => Boolean(homeDestination?.destination()))
164
+ const pendingNew = createMemo(() => homeDestination?.destination()?.type === "new")
165
+
166
+ async function getDirectory(context?: string) {
167
+ const value = homeDestination?.destination()
168
+ if (!value) return
169
+ if (value.type === "directory") {
170
+ return value.directory
171
+ }
172
+ return await create(context)
173
+ }
174
+
175
+ function startSubmit() {
176
+ if (progress()) setProgress("Submitting prompt")
177
+ }
178
+
179
+ function finishSubmit() {
180
+ homeDestination?.clear()
181
+ setProgress(undefined)
182
+ setCreating(false)
183
+ }
184
+
185
+ createEffect(() => {
186
+ if (!creating()) {
187
+ setCreatingDots(3)
188
+ return
189
+ }
190
+ const timer = setInterval(() => setCreatingDots((dots) => (dots % 3) + 1), 1000)
191
+ onCleanup(() => clearInterval(timer))
192
+ })
193
+
194
+ return {
195
+ creating,
196
+ creatingDots,
197
+ finishSubmit,
198
+ getDirectory,
199
+ open,
200
+ pending,
201
+ pendingNew,
202
+ progress,
203
+ startSubmit,
204
+ }
205
+ }
@@ -0,0 +1 @@
1
+ export * from "../../prompt/stash"
@@ -0,0 +1,137 @@
1
+ import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
2
+ import { useDialog } from "../../ui/dialog"
3
+ import { useSDK } from "../../context/sdk"
4
+ import { useProject } from "../../context/project"
5
+ import { useSync } from "../../context/sync"
6
+ import { useToast } from "../../ui/toast"
7
+ import { errorMessage } from "../../util/error"
8
+ import {
9
+ confirmWorkspaceFileChanges,
10
+ openWorkspaceSelect,
11
+ warpWorkspaceSession,
12
+ type WorkspaceSelection,
13
+ } from "../dialog-workspace-create"
14
+ import type { WorkspaceStatus } from "../workspace-label"
15
+
16
+ export function usePromptWorkspace(sessionID?: string) {
17
+ const dialog = useDialog()
18
+ const sdk = useSDK()
19
+ const project = useProject()
20
+ const sync = useSync()
21
+ const toast = useToast()
22
+ const [selection, setSelection] = createSignal<WorkspaceSelection>()
23
+ const [creating, setCreating] = createSignal(false)
24
+ const [creatingDots, setCreatingDots] = createSignal(3)
25
+ const [notice, setNotice] = createSignal<string>()
26
+
27
+ async function create(selection: Extract<WorkspaceSelection, { type: "new" }>) {
28
+ setCreating(true)
29
+ let result
30
+ try {
31
+ result = await sdk.client.experimental.workspace.create({ type: selection.workspaceType, branch: null })
32
+ } catch (err) {
33
+ setSelection(undefined)
34
+ setCreating(false)
35
+ toast.show({ title: "Creating workspace failed", message: errorMessage(err), variant: "error" })
36
+ return
37
+ }
38
+ if (result.error || !result.data) {
39
+ setSelection(undefined)
40
+ setCreating(false)
41
+ toast.show({
42
+ title: "Creating workspace failed",
43
+ message: errorMessage(result.error ?? "no response"),
44
+ variant: "error",
45
+ })
46
+ return
47
+ }
48
+
49
+ await project.workspace.sync()
50
+ const workspace = result.data
51
+ setSelection({
52
+ type: "existing",
53
+ workspaceID: workspace.id,
54
+ workspaceType: workspace.type,
55
+ workspaceName: workspace.name,
56
+ })
57
+ setCreating(false)
58
+ return workspace
59
+ }
60
+
61
+ async function warp(selection: WorkspaceSelection) {
62
+ if (!sessionID) {
63
+ setSelection(selection)
64
+ dialog.clear()
65
+ if (selection.type === "new") void create(selection)
66
+ return
67
+ }
68
+ const sourceWorkspaceID = project.workspace.current()
69
+ const copyChanges = await confirmWorkspaceFileChanges({ dialog, sdk, sourceWorkspaceID })
70
+ if (copyChanges === undefined) return
71
+ setSelection(selection)
72
+ dialog.clear()
73
+
74
+ const workspace =
75
+ selection.type === "none"
76
+ ? { id: null, name: "local project" }
77
+ : selection.type === "existing"
78
+ ? { id: selection.workspaceID, name: selection.workspaceName }
79
+ : await create(selection)
80
+ if (!workspace) return
81
+
82
+ const warped = await warpWorkspaceSession({
83
+ dialog,
84
+ sdk,
85
+ sync,
86
+ project,
87
+ toast,
88
+ sourceWorkspaceID,
89
+ workspaceID: workspace.id,
90
+ sessionID,
91
+ copyChanges,
92
+ })
93
+ if (warped) showNotice(workspace.name)
94
+ }
95
+
96
+ function showNotice(name: string) {
97
+ setNotice(`Warped to ${name}`)
98
+ setTimeout(() => setNotice(undefined), 4000)
99
+ }
100
+
101
+ function clearNotice() {
102
+ setNotice(undefined)
103
+ }
104
+
105
+ function open() {
106
+ void openWorkspaceSelect({ dialog, sdk, sync, project, toast, onSelect: warp })
107
+ }
108
+
109
+ createEffect(() => {
110
+ if (!creating()) {
111
+ setCreatingDots(3)
112
+ return
113
+ }
114
+ const timer = setInterval(() => setCreatingDots((dots) => (dots % 3) + 1), 1000)
115
+ onCleanup(() => clearInterval(timer))
116
+ })
117
+
118
+ const label = createMemo<
119
+ | { type: "new"; workspaceType: string }
120
+ | { type: "existing"; workspaceType: string; workspaceName: string; status?: WorkspaceStatus }
121
+ | undefined
122
+ >(() => {
123
+ const selected = selection()
124
+ if (!selected) return
125
+ if (selected.type === "none") return
126
+ if (sessionID && !creating()) return
127
+ if (selected.type === "new") return { type: "new", workspaceType: selected.workspaceType }
128
+ return {
129
+ type: "existing",
130
+ workspaceType: selected.workspaceType,
131
+ workspaceName: selected.workspaceName,
132
+ status: selected.type === "existing" ? "connected" : undefined,
133
+ }
134
+ })
135
+
136
+ return { selection, creating, creatingDots, notice, label, open, warp, clearNotice }
137
+ }
@@ -0,0 +1,6 @@
1
+ import { getComponentCatalogue } from "@opentui/solid/components"
2
+ import { registerSpinner } from "opentui-spinner/solid"
3
+
4
+ export function registerNeurocodeSpinner() {
5
+ if (!getComponentCatalogue().spinner) registerSpinner()
6
+ }
@@ -0,0 +1,26 @@
1
+ import { Show } from "solid-js"
2
+ import { useTheme } from "../context/theme"
3
+ import { useKV } from "../context/kv"
4
+ import type { JSX } from "@opentui/solid"
5
+ import type { RGBA } from "@opentui/core"
6
+ import { registerNeurocodeSpinner } from "./register-spinner"
7
+
8
+ registerNeurocodeSpinner()
9
+
10
+ export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
11
+
12
+ export function Spinner(props: { children?: JSX.Element; color?: RGBA }) {
13
+ const { theme } = useTheme()
14
+ const kv = useKV()
15
+ const color = () => props.color ?? theme.textMuted
16
+ return (
17
+ <Show when={kv.get("animations_enabled", true)} fallback={<text fg={color()}>⋯ {props.children}</text>}>
18
+ <box flexDirection="row" gap={1}>
19
+ <spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
20
+ <Show when={props.children}>
21
+ <text fg={color()}>{props.children}</text>
22
+ </Show>
23
+ </box>
24
+ </Show>
25
+ )
26
+ }
@@ -0,0 +1,63 @@
1
+ import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"
2
+ import { useTheme } from "../context/theme"
3
+ import { Spinner } from "./spinner"
4
+
5
+ export function StartupLoading(props: { ready: () => boolean }) {
6
+ const theme = useTheme().theme
7
+ const [show, setShow] = createSignal(false)
8
+ const text = createMemo(() => (props.ready() ? "Finishing startup..." : "Loading plugins..."))
9
+ let wait: NodeJS.Timeout | undefined
10
+ let hold: NodeJS.Timeout | undefined
11
+ let stamp = 0
12
+
13
+ createEffect(() => {
14
+ if (props.ready()) {
15
+ if (wait) {
16
+ clearTimeout(wait)
17
+ wait = undefined
18
+ }
19
+ if (!show()) return
20
+ if (hold) return
21
+
22
+ const left = 3000 - (Date.now() - stamp)
23
+ if (left <= 0) {
24
+ setShow(false)
25
+ return
26
+ }
27
+
28
+ hold = setTimeout(() => {
29
+ hold = undefined
30
+ setShow(false)
31
+ }, left).unref()
32
+ return
33
+ }
34
+
35
+ if (hold) {
36
+ clearTimeout(hold)
37
+ hold = undefined
38
+ }
39
+ if (show()) return
40
+ if (wait) return
41
+
42
+ wait = setTimeout(() => {
43
+ wait = undefined
44
+ stamp = Date.now()
45
+ setShow(true)
46
+ }, 500).unref()
47
+ })
48
+
49
+ onCleanup(() => {
50
+ if (wait) clearTimeout(wait)
51
+ if (hold) clearTimeout(hold)
52
+ })
53
+
54
+ return (
55
+ <Show when={show()}>
56
+ <box position="absolute" zIndex={5000} left={0} right={0} bottom={1} justifyContent="center" alignItems="center">
57
+ <box backgroundColor={theme.backgroundPanel} paddingLeft={1} paddingRight={1}>
58
+ <Spinner color={theme.textMuted}>{text()}</Spinner>
59
+ </box>
60
+ </box>
61
+ </Show>
62
+ )
63
+ }
@@ -0,0 +1,32 @@
1
+ import { useTheme } from "../context/theme"
2
+
3
+ export interface TodoItemProps {
4
+ status: string
5
+ content: string
6
+ }
7
+
8
+ export function TodoItem(props: TodoItemProps) {
9
+ const { theme } = useTheme()
10
+
11
+ return (
12
+ <box flexDirection="row" gap={0}>
13
+ <text
14
+ flexShrink={0}
15
+ style={{
16
+ fg: props.status === "in_progress" ? theme.warning : theme.textMuted,
17
+ }}
18
+ >
19
+ [{props.status === "completed" ? "✓" : props.status === "in_progress" ? "•" : " "}]{" "}
20
+ </text>
21
+ <text
22
+ flexGrow={1}
23
+ wrapMode="word"
24
+ style={{
25
+ fg: props.status === "in_progress" ? theme.warning : theme.textMuted,
26
+ }}
27
+ >
28
+ {props.content}
29
+ </text>
30
+ </box>
31
+ )
32
+ }
@@ -0,0 +1,12 @@
1
+ import { createMemo } from "solid-js"
2
+ import { useSync } from "../context/sync"
3
+
4
+ export function useConnected() {
5
+ const sync = useSync()
6
+ return createMemo(() =>
7
+ sync.data.provider.some(
8
+ (provider) =>
9
+ provider.id !== "opencode" || Object.values(provider.models).some((model) => model.cost?.input !== 0),
10
+ ),
11
+ )
12
+ }
@@ -0,0 +1,19 @@
1
+ import { useTheme } from "../context/theme"
2
+
3
+ export type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
4
+
5
+ export function WorkspaceLabel(props: { type: string; name: string; status?: WorkspaceStatus; icon?: boolean }) {
6
+ const { theme } = useTheme()
7
+ const color = () => {
8
+ if (props.status === "connected") return theme.success
9
+ if (props.status === "error") return theme.error
10
+ return theme.textMuted
11
+ }
12
+
13
+ return (
14
+ <>
15
+ {props.icon ? <span style={{ fg: color() }}>● </span> : undefined}
16
+ <span style={{ fg: theme.text }}>{props.name}</span> <span style={{ fg: theme.textMuted }}>({props.type})</span>
17
+ </>
18
+ )
19
+ }
@@ -0,0 +1,129 @@
1
+ export * as TuiConfig from "."
2
+
3
+ import { createBindingLookup } from "@opentui/keymap/extras"
4
+ import { Schema } from "effect"
5
+ import { createContext, type JSX, useContext } from "solid-js"
6
+ import { TuiKeybind } from "./keybind"
7
+
8
+ export const AttentionSoundName = Schema.Literals([
9
+ "default",
10
+ "question",
11
+ "permission",
12
+ "error",
13
+ "done",
14
+ "subagent_done",
15
+ ])
16
+ export type AttentionSoundName = Schema.Schema.Type<typeof AttentionSoundName>
17
+
18
+ export const PluginOptions = Schema.Record(Schema.String, Schema.Unknown)
19
+ export const PluginSpec = Schema.Union([Schema.String, Schema.mutable(Schema.Tuple([Schema.String, PluginOptions]))])
20
+
21
+ export const LeaderTimeoutDefault = 2000
22
+ export const LeaderTimeout = Schema.Int.check(Schema.isGreaterThan(0)).annotate({
23
+ description: "Leader key timeout in milliseconds",
24
+ })
25
+
26
+ export const ScrollSpeed = Schema.Number.check(Schema.isGreaterThanOrEqualTo(0.001))
27
+ export const ScrollAcceleration = Schema.Struct({
28
+ enabled: Schema.Boolean.annotate({ description: "Enable scroll acceleration" }),
29
+ }).annotate({ description: "Scroll acceleration settings" })
30
+ export const DiffStyle = Schema.Literals(["auto", "stacked"]).annotate({
31
+ description: "Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column",
32
+ })
33
+
34
+ export const AttentionSounds = Schema.Record(AttentionSoundName, Schema.optionalKey(Schema.String))
35
+ export type AttentionSoundPaths = Schema.Schema.Type<typeof AttentionSounds>
36
+ export const Attention = Schema.Struct({
37
+ enabled: Schema.optional(Schema.Boolean),
38
+ notifications: Schema.optional(Schema.Boolean),
39
+ sound: Schema.optional(Schema.Boolean),
40
+ volume: Schema.optional(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(1))),
41
+ sound_pack: Schema.optional(Schema.String),
42
+ sounds: Schema.optional(AttentionSounds),
43
+ }).annotate({ description: "Attention notification and sound settings" })
44
+
45
+ const PromptSize = Schema.Int.check(Schema.isGreaterThan(0))
46
+ export const Prompt = Schema.Struct({
47
+ max_height: Schema.optional(PromptSize).annotate({ description: "Prompt textarea max height" }),
48
+ max_width: Schema.optional(Schema.Union([PromptSize, Schema.Literal("auto")])).annotate({
49
+ description: "Home prompt max width: a positive integer for a fixed cap, or 'auto' to scale with terminal width",
50
+ }),
51
+ }).annotate({ description: "Prompt size settings" })
52
+
53
+ export const Info = Schema.Struct({
54
+ $schema: Schema.optional(Schema.String),
55
+ theme: Schema.optional(Schema.String),
56
+ keybinds: Schema.optional(TuiKeybind.KeybindOverrides),
57
+ plugin: Schema.optional(Schema.Array(PluginSpec)),
58
+ plugin_enabled: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
59
+ leader_timeout: Schema.optional(LeaderTimeout),
60
+ attention: Schema.optional(Attention),
61
+ prompt: Schema.optional(Prompt),
62
+ scroll_speed: Schema.optional(ScrollSpeed).annotate({ description: "TUI scroll speed" }),
63
+ scroll_acceleration: Schema.optional(ScrollAcceleration),
64
+ diff_style: Schema.optional(DiffStyle),
65
+ mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable or disable mouse capture (default: true)" }),
66
+ })
67
+ export type Info = Schema.Schema.Type<typeof Info>
68
+
69
+ export type Resolved = Omit<Info, "attention" | "keybinds" | "leader_timeout" | "mouse"> & {
70
+ attention: {
71
+ enabled: boolean
72
+ notifications: boolean
73
+ sound: boolean
74
+ volume: number
75
+ sound_pack: string
76
+ sounds: AttentionSoundPaths
77
+ }
78
+ keybinds: TuiKeybind.BindingLookupView
79
+ leader_timeout: number
80
+ mouse: boolean
81
+ }
82
+
83
+ export const ResolveOptions = Schema.Struct({
84
+ terminalSuspend: Schema.Boolean,
85
+ })
86
+ export type ResolveOptions = Schema.Schema.Type<typeof ResolveOptions>
87
+
88
+ export function resolve(input: Info, options: ResolveOptions): Resolved {
89
+ const keybinds: TuiKeybind.KeybindOverrides = { ...input.keybinds }
90
+ if (!options.terminalSuspend) {
91
+ keybinds.terminal_suspend = "none"
92
+ if (keybinds.input_undo === undefined) {
93
+ const inputUndo = TuiKeybind.defaultValue("input_undo")
94
+ keybinds.input_undo = ["ctrl+z", ...(typeof inputUndo === "string" ? inputUndo.split(",") : [])]
95
+ .filter((value, index, values) => values.indexOf(value) === index)
96
+ .join(",")
97
+ }
98
+ }
99
+
100
+ return {
101
+ ...input,
102
+ attention: {
103
+ enabled: input.attention?.enabled ?? false,
104
+ notifications: input.attention?.notifications ?? true,
105
+ sound: input.attention?.sound ?? true,
106
+ volume: input.attention?.volume ?? 0.4,
107
+ sound_pack: input.attention?.sound_pack ?? "opencode.default",
108
+ sounds: input.attention?.sounds ?? {},
109
+ },
110
+ keybinds: createBindingLookup(TuiKeybind.toBindingConfig(TuiKeybind.parse(keybinds)), {
111
+ commandMap: TuiKeybind.CommandMap,
112
+ bindingDefaults: TuiKeybind.bindingDefaults(),
113
+ }),
114
+ leader_timeout: input.leader_timeout ?? LeaderTimeoutDefault,
115
+ mouse: input.mouse ?? true,
116
+ }
117
+ }
118
+
119
+ const ConfigContext = createContext<Resolved>()
120
+
121
+ export function TuiConfigProvider(props: { config: Resolved; children: JSX.Element }) {
122
+ return <ConfigContext.Provider value={props.config}>{props.children}</ConfigContext.Provider>
123
+ }
124
+
125
+ export function useTuiConfig() {
126
+ const value = useContext(ConfigContext)
127
+ if (!value) throw new Error("TuiConfigProvider is missing")
128
+ return value
129
+ }