@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,168 @@
1
+ import { TextAttributes } from "@opentui/core"
2
+ import { fileURLToPath } from "bun"
3
+ import { useTheme } from "../context/theme"
4
+ import { useDialog } from "../ui/dialog"
5
+ import { useSync } from "../context/sync"
6
+ import { For, Match, Switch, Show, createMemo } from "solid-js"
7
+
8
+ export type DialogStatusProps = {}
9
+
10
+ export function DialogStatus() {
11
+ const sync = useSync()
12
+ const { theme } = useTheme()
13
+ const dialog = useDialog()
14
+
15
+ const enabledFormatters = createMemo(() => sync.data.formatter.filter((f) => f.enabled))
16
+
17
+ const plugins = createMemo(() => {
18
+ const list = sync.data.config.plugin ?? []
19
+ const result = list.map((item) => {
20
+ const value = typeof item === "string" ? item : item[0]
21
+ if (value.startsWith("file://")) {
22
+ const path = fileURLToPath(value)
23
+ const parts = path.split("/")
24
+ const filename = parts.pop() || path
25
+ if (!filename.includes(".")) return { name: filename }
26
+ const basename = filename.split(".")[0]
27
+ if (basename === "index") {
28
+ const dirname = parts.pop()
29
+ const name = dirname || basename
30
+ return { name }
31
+ }
32
+ return { name: basename }
33
+ }
34
+ const index = value.lastIndexOf("@")
35
+ if (index <= 0) return { name: value, version: "latest" }
36
+ const name = value.substring(0, index)
37
+ const version = value.substring(index + 1)
38
+ return { name, version }
39
+ })
40
+ return result.toSorted((a, b) => a.name.localeCompare(b.name))
41
+ })
42
+
43
+ return (
44
+ <box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
45
+ <box flexDirection="row" justifyContent="space-between">
46
+ <text fg={theme.text} attributes={TextAttributes.BOLD}>
47
+ Status
48
+ </text>
49
+ <text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
50
+ esc
51
+ </text>
52
+ </box>
53
+ <Show when={Object.keys(sync.data.mcp).length > 0} fallback={<text fg={theme.text}>No MCP Servers</text>}>
54
+ <box>
55
+ <text fg={theme.text}>{Object.keys(sync.data.mcp).length} MCP Servers</text>
56
+ <For each={Object.entries(sync.data.mcp)}>
57
+ {([key, item]) => (
58
+ <box flexDirection="row" gap={1}>
59
+ <text
60
+ flexShrink={0}
61
+ style={{
62
+ fg: (
63
+ {
64
+ connected: theme.success,
65
+ failed: theme.error,
66
+ disabled: theme.textMuted,
67
+ needs_auth: theme.warning,
68
+ needs_client_registration: theme.error,
69
+ } as Record<string, typeof theme.success>
70
+ )[item.status],
71
+ }}
72
+ >
73
+
74
+ </text>
75
+ <text fg={theme.text} wrapMode="word">
76
+ <b>{key}</b>{" "}
77
+ <span style={{ fg: theme.textMuted }}>
78
+ <Switch fallback={item.status}>
79
+ <Match when={item.status === "connected"}>Connected</Match>
80
+ <Match when={item.status === "failed" && item}>{(val) => val().error}</Match>
81
+ <Match when={item.status === "disabled"}>Disabled in configuration</Match>
82
+ <Match when={(item.status as string) === "needs_auth"}>
83
+ Needs authentication (run: opencode mcp auth {key})
84
+ </Match>
85
+ <Match when={(item.status as string) === "needs_client_registration" && item}>
86
+ {(val) => (val() as { error: string }).error}
87
+ </Match>
88
+ </Switch>
89
+ </span>
90
+ </text>
91
+ </box>
92
+ )}
93
+ </For>
94
+ </box>
95
+ </Show>
96
+ {sync.data.lsp.length > 0 && (
97
+ <box>
98
+ <text fg={theme.text}>{sync.data.lsp.length} LSP Servers</text>
99
+ <For each={sync.data.lsp}>
100
+ {(item) => (
101
+ <box flexDirection="row" gap={1}>
102
+ <text
103
+ flexShrink={0}
104
+ style={{
105
+ fg: {
106
+ connected: theme.success,
107
+ error: theme.error,
108
+ }[item.status],
109
+ }}
110
+ >
111
+
112
+ </text>
113
+ <text fg={theme.text} wrapMode="word">
114
+ <b>{item.id}</b> <span style={{ fg: theme.textMuted }}>{item.root}</span>
115
+ </text>
116
+ </box>
117
+ )}
118
+ </For>
119
+ </box>
120
+ )}
121
+ <Show when={enabledFormatters().length > 0} fallback={<text fg={theme.text}>No Formatters</text>}>
122
+ <box>
123
+ <text fg={theme.text}>{enabledFormatters().length} Formatters</text>
124
+ <For each={enabledFormatters()}>
125
+ {(item) => (
126
+ <box flexDirection="row" gap={1}>
127
+ <text
128
+ flexShrink={0}
129
+ style={{
130
+ fg: theme.success,
131
+ }}
132
+ >
133
+
134
+ </text>
135
+ <text wrapMode="word" fg={theme.text}>
136
+ <b>{item.name}</b>
137
+ </text>
138
+ </box>
139
+ )}
140
+ </For>
141
+ </box>
142
+ </Show>
143
+ <Show when={plugins().length > 0} fallback={<text fg={theme.text}>No Plugins</text>}>
144
+ <box>
145
+ <text fg={theme.text}>{plugins().length} Plugins</text>
146
+ <For each={plugins()}>
147
+ {(item) => (
148
+ <box flexDirection="row" gap={1}>
149
+ <text
150
+ flexShrink={0}
151
+ style={{
152
+ fg: theme.success,
153
+ }}
154
+ >
155
+
156
+ </text>
157
+ <text wrapMode="word" fg={theme.text}>
158
+ <b>{item.name}</b>
159
+ {item.version && <span style={{ fg: theme.textMuted }}> @{item.version}</span>}
160
+ </text>
161
+ </box>
162
+ )}
163
+ </For>
164
+ </box>
165
+ </Show>
166
+ </box>
167
+ )
168
+ }
@@ -0,0 +1,47 @@
1
+ import { createMemo, createResource } from "solid-js"
2
+ import { DialogSelect } from "../ui/dialog-select"
3
+ import { useDialog } from "../ui/dialog"
4
+ import { useProject } from "../context/project"
5
+ import { useSDK } from "../context/sdk"
6
+ import { createStore } from "solid-js/store"
7
+
8
+ export function DialogTag(props: { onSelect?: (value: string) => void }) {
9
+ const sdk = useSDK()
10
+ const dialog = useDialog()
11
+ const project = useProject()
12
+
13
+ const [store] = createStore({
14
+ filter: "",
15
+ })
16
+
17
+ const [files] = createResource(
18
+ () => [store.filter],
19
+ async () => {
20
+ const result = await sdk.client.find.files({
21
+ query: store.filter,
22
+ workspace: project.workspace.current(),
23
+ })
24
+ if (result.error) return []
25
+ const sliced = (result.data ?? []).slice(0, 5)
26
+ return sliced
27
+ },
28
+ )
29
+
30
+ const options = createMemo(() =>
31
+ (files() ?? []).map((file) => ({
32
+ value: file,
33
+ title: file,
34
+ })),
35
+ )
36
+
37
+ return (
38
+ <DialogSelect
39
+ title="Autocomplete"
40
+ options={options()}
41
+ onSelect={(option) => {
42
+ props.onSelect?.(option.value)
43
+ dialog.clear()
44
+ }}
45
+ />
46
+ )
47
+ }
@@ -0,0 +1,50 @@
1
+ import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select"
2
+ import { useTheme } from "../context/theme"
3
+ import { useDialog } from "../ui/dialog"
4
+ import { onCleanup } from "solid-js"
5
+
6
+ export function DialogThemeList() {
7
+ const theme = useTheme()
8
+ const options = Object.keys(theme.all())
9
+ .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))
10
+ .map((value) => ({
11
+ title: value,
12
+ value: value,
13
+ }))
14
+ const dialog = useDialog()
15
+ let confirmed = false
16
+ let ref: DialogSelectRef<string>
17
+ const initial = theme.selected
18
+
19
+ onCleanup(() => {
20
+ if (!confirmed) theme.set(initial)
21
+ })
22
+
23
+ return (
24
+ <DialogSelect
25
+ title="Themes"
26
+ options={options}
27
+ current={initial}
28
+ onMove={(opt) => {
29
+ theme.set(opt.value)
30
+ }}
31
+ onSelect={(opt) => {
32
+ theme.set(opt.value)
33
+ confirmed = true
34
+ dialog.clear()
35
+ }}
36
+ ref={(r) => {
37
+ ref = r
38
+ }}
39
+ onFilter={(query) => {
40
+ if (query.length === 0) {
41
+ theme.set(initial)
42
+ return
43
+ }
44
+
45
+ const first = ref.filtered[0]
46
+ if (first) theme.set(first.value)
47
+ }}
48
+ />
49
+ )
50
+ }
@@ -0,0 +1,39 @@
1
+ import { createMemo } from "solid-js"
2
+ import { useLocal } from "../context/local"
3
+ import { DialogSelect } from "../ui/dialog-select"
4
+ import { useDialog } from "../ui/dialog"
5
+
6
+ export function DialogVariant() {
7
+ const local = useLocal()
8
+ const dialog = useDialog()
9
+
10
+ const options = createMemo(() => {
11
+ return [
12
+ {
13
+ value: "default",
14
+ title: "Default",
15
+ onSelect: () => {
16
+ dialog.clear()
17
+ local.model.variant.set(undefined)
18
+ },
19
+ },
20
+ ...local.model.variant.list().map((variant) => ({
21
+ value: variant,
22
+ title: variant,
23
+ onSelect: () => {
24
+ dialog.clear()
25
+ local.model.variant.set(variant)
26
+ },
27
+ })),
28
+ ]
29
+ })
30
+
31
+ return (
32
+ <DialogSelect<string>
33
+ options={options()}
34
+ title={"Select variant"}
35
+ current={local.model.variant.selected()}
36
+ flat={true}
37
+ />
38
+ )
39
+ }
@@ -0,0 +1,308 @@
1
+ import type { ExperimentalWorkspaceAdapterListResponse, Workspace } from "@neurocode-ai/sdk/v2"
2
+ import { useDialog } from "../ui/dialog"
3
+ import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
4
+ import { useSync } from "../context/sync"
5
+ import { useProject } from "../context/project"
6
+ import { useRoute } from "../context/route"
7
+ import { createMemo, createSignal, onMount } from "solid-js"
8
+ import { errorMessage } from "../util/error"
9
+ import { useSDK } from "../context/sdk"
10
+ import { useToast } from "../ui/toast"
11
+ import { DialogAlert } from "../ui/dialog-alert"
12
+ import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes"
13
+
14
+ type Adapter = ExperimentalWorkspaceAdapterListResponse[number]
15
+
16
+ export type WorkspaceSelection =
17
+ | {
18
+ type: "none"
19
+ }
20
+ | {
21
+ type: "new"
22
+ workspaceType: string
23
+ workspaceName: string
24
+ }
25
+ | {
26
+ type: "existing"
27
+ workspaceID: string
28
+ workspaceType: string
29
+ workspaceName: string
30
+ }
31
+
32
+ type WorkspaceSelectValue = WorkspaceSelection | { type: "existing-list" }
33
+ type ExistingWorkspaceSelectValue = { workspace: Workspace }
34
+
35
+ export function recentConnectedWorkspaces<WorkspaceInfo extends { id: string; timeUsed: number | string }>(input: {
36
+ workspaces: readonly WorkspaceInfo[]
37
+ status: (workspaceID: string) => string | undefined
38
+ limit?: number
39
+ omitWorkspaceID?: string
40
+ }) {
41
+ const allWorkspaces = input.workspaces.filter((workspace) => input.status(workspace.id) === "connected")
42
+ const workspaces = allWorkspaces.toSorted((a, b) => Number(b.timeUsed) - Number(a.timeUsed))
43
+ const recent = workspaces.slice(0, input.limit ?? 3)
44
+
45
+ return { recent, hasMore: recent.length < workspaces.length }
46
+ }
47
+
48
+ export function warpReminderText(dir: string) {
49
+ return `<system-reminder>The user has changed the current working directory to "${dir}". 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>`
50
+ }
51
+
52
+ async function loadWorkspaceAdapters(input: {
53
+ sdk: ReturnType<typeof useSDK>
54
+ sync: ReturnType<typeof useSync>
55
+ toast: ReturnType<typeof useToast>
56
+ }) {
57
+ const dir = input.sync.path.directory || input.sdk.directory
58
+ try {
59
+ const response = await input.sdk.client.experimental.workspace.adapter.list({ directory: dir })
60
+ if (response.error) throw response.error
61
+ return response.data
62
+ } catch (err) {
63
+ input.toast.show({
64
+ title: "Failed to load workspace adapters",
65
+ message: errorMessage(err),
66
+ variant: "error",
67
+ })
68
+ return undefined
69
+ }
70
+ }
71
+
72
+ export async function openWorkspaceSelect(input: {
73
+ dialog: ReturnType<typeof useDialog>
74
+ sdk: ReturnType<typeof useSDK>
75
+ sync: ReturnType<typeof useSync>
76
+ project: ReturnType<typeof useProject>
77
+ toast: ReturnType<typeof useToast>
78
+ onSelect: (selection: WorkspaceSelection) => Promise<void> | void
79
+ }) {
80
+ input.dialog.clear()
81
+ await input.sdk.client.experimental.workspace.syncList().catch(() => undefined)
82
+ await input.project.workspace.sync().catch(() => undefined)
83
+ const adapters = await loadWorkspaceAdapters(input)
84
+ if (!adapters) return
85
+ input.dialog.replace(() => <DialogWorkspaceSelect adapters={adapters} onSelect={input.onSelect} />)
86
+ }
87
+
88
+ export async function warpWorkspaceSession(input: {
89
+ dialog: ReturnType<typeof useDialog>
90
+ sdk: ReturnType<typeof useSDK>
91
+ sync: ReturnType<typeof useSync>
92
+ project: ReturnType<typeof useProject>
93
+ toast: ReturnType<typeof useToast>
94
+ sourceWorkspaceID?: string
95
+ workspaceID: string | null
96
+ sessionID: string
97
+ copyChanges: boolean
98
+ done?: () => void
99
+ }): Promise<boolean> {
100
+ let result
101
+ try {
102
+ result = await input.sdk.client.experimental.workspace.warp({
103
+ id: input.workspaceID,
104
+ sessionID: input.sessionID,
105
+ copyChanges: input.copyChanges,
106
+ })
107
+ } catch (err) {
108
+ input.toast.show({
109
+ title: "Failed to warp session",
110
+ message: errorMessage(err),
111
+ variant: "error",
112
+ })
113
+ return false
114
+ }
115
+ if (!result?.data) {
116
+ if (result?.error && "name" in result.error && result.error.name === "VcsApplyError") {
117
+ await DialogAlert.show(
118
+ input.dialog,
119
+ "Unable to Warp Session",
120
+ "Unable to apply file changes to this workspace. It has existing changes that conflict or is based off a different branch. Session has not been warped.",
121
+ )
122
+ return false
123
+ }
124
+
125
+ input.toast.show({
126
+ title: "Failed to warp session",
127
+ message: errorMessage(result?.error ?? "no response"),
128
+ variant: "error",
129
+ })
130
+ return false
131
+ }
132
+
133
+ input.project.workspace.set(input.workspaceID)
134
+
135
+ await input.sync.bootstrap({ fatal: false }).catch(() => undefined)
136
+
137
+ const dir = input.project.instance.directory() || input.sync.path.directory
138
+ if (dir) {
139
+ await input.sdk.client.session
140
+ .promptAsync({
141
+ sessionID: input.sessionID,
142
+ workspace: input.workspaceID ?? undefined,
143
+ noReply: true,
144
+ parts: [
145
+ {
146
+ type: "text",
147
+ text: warpReminderText(dir),
148
+ synthetic: true,
149
+ },
150
+ ],
151
+ })
152
+ .catch(() => undefined)
153
+ }
154
+
155
+ await Promise.all([input.project.workspace.sync(), input.sync.session.refresh()])
156
+
157
+ if (input.done) {
158
+ input.done()
159
+ return true
160
+ }
161
+ input.dialog.clear()
162
+ return true
163
+ }
164
+
165
+ export async function confirmWorkspaceFileChanges(input: {
166
+ dialog: ReturnType<typeof useDialog>
167
+ sdk: ReturnType<typeof useSDK>
168
+ sourceWorkspaceID?: string
169
+ }) {
170
+ const status = await input.sdk.client.vcs.status({ workspace: input.sourceWorkspaceID }).catch(() => undefined)
171
+ const fileChangeChoice = status?.data?.length
172
+ ? await DialogWorkspaceFileChanges.show(input.dialog, status.data)
173
+ : "no"
174
+ if (!fileChangeChoice) return
175
+ return fileChangeChoice === "yes"
176
+ }
177
+
178
+ export function DialogWorkspaceSelect(props: {
179
+ adapters?: Adapter[]
180
+ onSelect: (selection: WorkspaceSelection) => Promise<void> | void
181
+ }) {
182
+ const dialog = useDialog()
183
+ const project = useProject()
184
+ const route = useRoute()
185
+ const sync = useSync()
186
+ const sdk = useSDK()
187
+ const toast = useToast()
188
+ const [adapters, setAdapters] = createSignal<Adapter[] | undefined>(props.adapters)
189
+ const omittedWorkspaceID = createMemo(() => (route.data.type === "session" ? project.workspace.current() : undefined))
190
+
191
+ onMount(() => {
192
+ dialog.setSize("medium")
193
+ void (async () => {
194
+ if (adapters()) return
195
+ const res = await loadWorkspaceAdapters({ sdk, sync, toast })
196
+ if (!res) return
197
+ setAdapters(res)
198
+ })()
199
+ })
200
+
201
+ const options = createMemo<DialogSelectOption<WorkspaceSelectValue>[]>(() => {
202
+ const list = adapters()
203
+ if (!list) return []
204
+ const { recent, hasMore } = recentConnectedWorkspaces({
205
+ workspaces: project.workspace.list(),
206
+ status: project.workspace.status,
207
+ omitWorkspaceID: omittedWorkspaceID(),
208
+ })
209
+ return [
210
+ ...list.map((adapter) => ({
211
+ title: adapter.name,
212
+ value: { type: "new" as const, workspaceType: adapter.type, workspaceName: adapter.name },
213
+ description: adapter.description,
214
+ category: "New workspace",
215
+ })),
216
+ {
217
+ title: "None",
218
+ value: { type: "none" as const },
219
+ description: "Use the local project",
220
+ category: "Choose workspace",
221
+ },
222
+ ...recent.map((workspace: Workspace) => ({
223
+ title: workspace.name,
224
+ description: `(${workspace.type})`,
225
+ value: {
226
+ type: "existing" as const,
227
+ workspaceID: workspace.id,
228
+ workspaceType: workspace.type,
229
+ workspaceName: workspace.name,
230
+ },
231
+ category: "Choose workspace",
232
+ })),
233
+ ...(hasMore
234
+ ? [
235
+ {
236
+ title: "View all workspaces",
237
+ value: { type: "existing-list" as const },
238
+ description: "Choose from all workspaces",
239
+ category: "Choose workspace",
240
+ },
241
+ ]
242
+ : []),
243
+ ]
244
+ })
245
+
246
+ if (!adapters()) return null
247
+ return (
248
+ <DialogSelect<WorkspaceSelectValue>
249
+ title="Warp"
250
+ skipFilter={true}
251
+ renderFilter={false}
252
+ options={options()}
253
+ onSelect={(option) => {
254
+ if (!option.value) return
255
+ if (option.value.type === "none") {
256
+ void props.onSelect(option.value)
257
+ return
258
+ }
259
+ if (option.value.type === "new") {
260
+ void props.onSelect(option.value)
261
+ return
262
+ }
263
+ if (option.value.type === "existing") {
264
+ void props.onSelect(option.value)
265
+ return
266
+ }
267
+
268
+ dialog.replace(() => (
269
+ <DialogExistingWorkspaceSelect omitWorkspaceID={omittedWorkspaceID()} onSelect={props.onSelect} />
270
+ ))
271
+ }}
272
+ />
273
+ )
274
+ }
275
+
276
+ function DialogExistingWorkspaceSelect(props: {
277
+ omitWorkspaceID?: string
278
+ onSelect: (selection: WorkspaceSelection) => Promise<void> | void
279
+ }) {
280
+ const project = useProject()
281
+
282
+ const options = createMemo<DialogSelectOption<ExistingWorkspaceSelectValue>[]>(() =>
283
+ project.workspace
284
+ .list()
285
+ .filter((workspace) => project.workspace.status(workspace.id) === "connected")
286
+ .filter((workspace) => workspace.id !== props.omitWorkspaceID)
287
+ .map((workspace: Workspace) => ({
288
+ title: workspace.name,
289
+ description: `(${workspace.type})`,
290
+ value: { workspace },
291
+ })),
292
+ )
293
+
294
+ return (
295
+ <DialogSelect<ExistingWorkspaceSelectValue>
296
+ title="Existing Workspace"
297
+ options={options()}
298
+ onSelect={(option) => {
299
+ void props.onSelect({
300
+ type: "existing",
301
+ workspaceID: option.value.workspace.id,
302
+ workspaceType: option.value.workspace.type,
303
+ workspaceName: option.value.workspace.name,
304
+ })
305
+ }}
306
+ />
307
+ )
308
+ }