@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,332 @@
1
+ import { CliRenderEvents, SyntaxStyle, type TerminalColors } from "@opentui/core"
2
+ import { useRenderer } from "@opentui/solid"
3
+ import {
4
+ DEFAULT_THEMES,
5
+ addTheme,
6
+ allThemes,
7
+ generateSubtleSyntax,
8
+ generateSyntax,
9
+ generateSystem,
10
+ hasTheme,
11
+ isTheme,
12
+ resolveTheme,
13
+ selectedForeground,
14
+ setCustomThemes,
15
+ setSystemTheme,
16
+ subscribeThemes,
17
+ terminalMode,
18
+ tint,
19
+ upsertTheme,
20
+ type ThemeJson,
21
+ } from "../theme"
22
+ import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
23
+ import { createStore, produce } from "solid-js/store"
24
+ import { createSimpleContext } from "./helper"
25
+ import { useKV } from "./kv"
26
+ import { useTuiConfig } from "../config"
27
+ import { Global } from "@neurocode-ai/core/global"
28
+ import { Glob } from "@neurocode-ai/core/util/glob"
29
+ import { readFile } from "node:fs/promises"
30
+ import path from "node:path"
31
+
32
+ export type ThemeSource = Readonly<{
33
+ discover(): Promise<Record<string, unknown>>
34
+ subscribeRefresh?(refresh: () => void): () => void
35
+ }>
36
+
37
+ const themeSource: ThemeSource = {
38
+ async discover() {
39
+ const directories = [Global.Path.config]
40
+ for (let current = process.cwd(); ; current = path.dirname(current)) {
41
+ directories.push(path.join(current, ".opencode"))
42
+ if (path.dirname(current) === current) break
43
+ }
44
+ return discoverThemes(directories)
45
+ },
46
+ subscribeRefresh(refresh) {
47
+ process.on("SIGUSR2", refresh)
48
+ return () => process.off("SIGUSR2", refresh)
49
+ },
50
+ }
51
+
52
+ export async function discoverThemes(directories: string[]) {
53
+ const result: Record<string, unknown> = {}
54
+ for (const directory of directories) {
55
+ const files = await Glob.scan("themes/*.json", { cwd: directory, absolute: true, dot: true, symlink: true })
56
+ for (const file of files) {
57
+ result[path.basename(file, ".json")] = JSON.parse(await readFile(file, "utf8")) as unknown
58
+ }
59
+ }
60
+ return result
61
+ }
62
+
63
+ export {
64
+ DEFAULT_THEMES,
65
+ addTheme,
66
+ allThemes,
67
+ generateSubtleSyntax,
68
+ generateSyntax,
69
+ generateSystem,
70
+ hasTheme,
71
+ isTheme,
72
+ resolveTheme,
73
+ selectedForeground,
74
+ terminalMode,
75
+ tint,
76
+ upsertTheme,
77
+ type Theme,
78
+ type ThemeJson,
79
+ type SyntaxStyleOverrides,
80
+ } from "../theme"
81
+
82
+ const THEME_REFRESH_DELAYS = [250, 1000] as const
83
+
84
+ type State = {
85
+ themes: Record<string, ThemeJson>
86
+ mode: "dark" | "light"
87
+ lock: "dark" | "light" | undefined
88
+ active: string
89
+ ready: boolean
90
+ }
91
+
92
+ const [store, setStore] = createStore<State>({
93
+ themes: allThemes(),
94
+ mode: "dark",
95
+ lock: undefined,
96
+ active: "opencode",
97
+ ready: false,
98
+ })
99
+
100
+ subscribeThemes((themes) => setStore("themes", themes))
101
+
102
+ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
103
+ name: "Theme",
104
+ init: (props: { mode: "dark" | "light"; source?: ThemeSource }) => {
105
+ const renderer = useRenderer()
106
+ const config = useTuiConfig()
107
+ const kv = useKV()
108
+ const themes = props.source ?? themeSource
109
+ const pick = (value: unknown) => {
110
+ if (value === "dark" || value === "light") return value
111
+ return
112
+ }
113
+
114
+ setStore(
115
+ produce((draft) => {
116
+ const lock = pick(kv.get("theme_mode_lock"))
117
+ const mode = lock ?? pick(renderer.themeMode) ?? props.mode
118
+ if (!lock && pick(kv.get("theme_mode")) !== undefined) kv.set("theme_mode", undefined)
119
+ draft.mode = mode
120
+ draft.lock = lock
121
+ const active = config.theme ?? kv.get("theme", "opencode")
122
+ draft.active = typeof active === "string" ? active : "opencode"
123
+ draft.ready = false
124
+ }),
125
+ )
126
+
127
+ createEffect(() => {
128
+ const theme = config.theme
129
+ if (theme) setStore("active", theme)
130
+ })
131
+
132
+ function syncCustomThemes() {
133
+ return themes
134
+ .discover()
135
+ .then((themes) => {
136
+ setCustomThemes(
137
+ Object.entries(themes).reduce<Record<string, ThemeJson>>((result, [name, theme]) => {
138
+ if (isTheme(theme)) result[name] = theme
139
+ return result
140
+ }, {}),
141
+ )
142
+ })
143
+ .catch(() => setStore("active", "opencode"))
144
+ }
145
+
146
+ onMount(() => {
147
+ void Promise.allSettled([resolveSystemTheme(store.mode), syncCustomThemes()]).finally(() => {
148
+ setStore("ready", true)
149
+ })
150
+ })
151
+
152
+ let systemThemeSignature: string | undefined
153
+ let systemThemeMode: "dark" | "light" | undefined
154
+ let hasResolvedSystemTheme = false
155
+ function resolveSystemTheme(mode: "dark" | "light" = store.mode) {
156
+ return renderer
157
+ .getPalette({ size: 16 })
158
+ .then((colors: TerminalColors) => {
159
+ if (!colors.palette[0]) {
160
+ if (hasResolvedSystemTheme) return
161
+ setSystemTheme(undefined)
162
+ if (store.active === "system") setStore("active", "opencode")
163
+ return
164
+ }
165
+ const next = store.lock ?? terminalMode(colors) ?? mode
166
+ if (store.mode !== next) setStore("mode", next)
167
+ const signature = JSON.stringify(colors)
168
+ hasResolvedSystemTheme = true
169
+ if (store.themes.system && systemThemeSignature === signature && systemThemeMode === next) return
170
+ systemThemeSignature = signature
171
+ systemThemeMode = next
172
+ setSystemTheme(generateSystem(colors, next))
173
+ })
174
+ .catch(() => {
175
+ if (hasResolvedSystemTheme) return
176
+ setSystemTheme(undefined)
177
+ if (store.active === "system") setStore("active", "opencode")
178
+ })
179
+ }
180
+
181
+ let systemRefreshRunning = false
182
+ let systemRefreshQueued = false
183
+ let systemRefreshMode = store.mode
184
+ function refreshSystemTheme(mode: "dark" | "light" = store.mode) {
185
+ systemRefreshMode = mode
186
+ if (systemRefreshRunning) {
187
+ systemRefreshQueued = true
188
+ return
189
+ }
190
+
191
+ systemRefreshRunning = true
192
+ const retry = renderer.paletteDetectionStatus === "detecting"
193
+ renderer.clearPaletteCache()
194
+ void resolveSystemTheme(mode).finally(() => {
195
+ systemRefreshRunning = false
196
+ if (!retry && !systemRefreshQueued) return
197
+ systemRefreshQueued = false
198
+ refreshSystemTheme(systemRefreshMode)
199
+ })
200
+ }
201
+
202
+ function apply(mode: "dark" | "light") {
203
+ if (store.lock !== undefined) kv.set("theme_mode", mode)
204
+ if (store.mode === mode) return
205
+ setStore("mode", mode)
206
+ refreshSystemTheme(mode)
207
+ }
208
+
209
+ function pin(mode: "dark" | "light" = store.mode) {
210
+ setStore("lock", mode)
211
+ kv.set("theme_mode_lock", mode)
212
+ apply(mode)
213
+ }
214
+
215
+ function free() {
216
+ setStore("lock", undefined)
217
+ kv.set("theme_mode_lock", undefined)
218
+ kv.set("theme_mode", undefined)
219
+ refreshSystemTheme(renderer.themeMode ?? store.mode)
220
+ }
221
+
222
+ const handle = (mode: "dark" | "light") => {
223
+ if (store.lock) return
224
+ apply(mode)
225
+ }
226
+ renderer.on(CliRenderEvents.THEME_MODE, handle)
227
+
228
+ const handleThemeNotification = (sequence: string) => {
229
+ if (sequence !== "\x1b[?997;1n" && sequence !== "\x1b[?997;2n") return false
230
+ queueMicrotask(() => refreshSystemTheme())
231
+ return false
232
+ }
233
+ renderer.prependInputHandler(handleThemeNotification)
234
+
235
+ let themeRefreshTimeouts: ReturnType<typeof setTimeout>[] = []
236
+ const refresh = () => {
237
+ for (const timeout of themeRefreshTimeouts) clearTimeout(timeout)
238
+ themeRefreshTimeouts = THEME_REFRESH_DELAYS.map((delay) =>
239
+ setTimeout(() => {
240
+ refreshSystemTheme()
241
+ if (delay === THEME_REFRESH_DELAYS[THEME_REFRESH_DELAYS.length - 1]) void syncCustomThemes()
242
+ }, delay),
243
+ )
244
+ }
245
+ let unsubscribeRefresh: (() => void) | undefined
246
+ unsubscribeRefresh = themes.subscribeRefresh?.(refresh)
247
+
248
+ onCleanup(() => {
249
+ renderer.off(CliRenderEvents.THEME_MODE, handle)
250
+ renderer.removeInputHandler(handleThemeNotification)
251
+ unsubscribeRefresh?.()
252
+ for (const timeout of themeRefreshTimeouts) clearTimeout(timeout)
253
+ themeRefreshTimeouts.length = 0
254
+ })
255
+
256
+ const values = createMemo(() => {
257
+ const active = store.themes[store.active]
258
+ if (active) return resolveTheme(active, store.mode)
259
+
260
+ const saved = kv.get("theme")
261
+ if (typeof saved === "string") {
262
+ const theme = store.themes[saved]
263
+ if (theme) return resolveTheme(theme, store.mode)
264
+ }
265
+
266
+ return resolveTheme(store.themes.opencode, store.mode)
267
+ })
268
+
269
+ createEffect(() => renderer.setBackgroundColor(values().background))
270
+
271
+ const syntax = createSyntaxStyleMemo(() => generateSyntax(values()))
272
+ const subtleSyntax = createSyntaxStyleMemo(() => generateSubtleSyntax(values()))
273
+
274
+ return {
275
+ theme: new Proxy(values(), {
276
+ get(_target, prop) {
277
+ // @ts-expect-error Properties are forwarded to the current reactive value.
278
+ return values()[prop]
279
+ },
280
+ }),
281
+ get selected() {
282
+ return store.active
283
+ },
284
+ all: allThemes,
285
+ has: hasTheme,
286
+ syntax,
287
+ subtleSyntax,
288
+ mode: () => store.mode,
289
+ locked: () => store.lock !== undefined,
290
+ lock: () => pin(store.mode),
291
+ unlock: free,
292
+ setMode: pin,
293
+ set(theme: string) {
294
+ if (!hasTheme(theme)) return false
295
+ setStore("active", theme)
296
+ kv.set("theme", theme)
297
+ return true
298
+ },
299
+ get ready() {
300
+ return store.ready
301
+ },
302
+ }
303
+ },
304
+ })
305
+
306
+ export function createSyntaxStyleMemo(factory: () => SyntaxStyle) {
307
+ const renderer = useRenderer()
308
+ const retained = new Set<SyntaxStyle>()
309
+ let current: SyntaxStyle | undefined
310
+
311
+ const release = (style: SyntaxStyle) => {
312
+ retained.add(style)
313
+ void renderer
314
+ .idle()
315
+ .catch(() => {})
316
+ .finally(() => {
317
+ if (!retained.delete(style)) return
318
+ style.destroy()
319
+ })
320
+ }
321
+
322
+ onCleanup(() => {
323
+ if (current) release(current)
324
+ })
325
+
326
+ return createMemo(() => {
327
+ const previous = current
328
+ current = factory()
329
+ if (previous) release(previous)
330
+ return current
331
+ })
332
+ }
@@ -0,0 +1,67 @@
1
+ import { createMemo, type Setter } from "solid-js"
2
+ import { useKV } from "./kv"
3
+
4
+ export type ThinkingMode = "show" | "hide"
5
+
6
+ const MODES: readonly ThinkingMode[] = ["show", "hide"] as const
7
+
8
+ // OpenAI's Responses API surfaces reasoning summaries that start with a bolded
9
+ // title block: "**Inspecting PR workflow**\n\n<body>". Treat that first block,
10
+ // or a complete title still awaiting its body while streaming, as disclosure
11
+ // metadata so the TUI can style its header independently from the markdown body.
12
+ export function reasoningSummary(text: string) {
13
+ const content = text.trim()
14
+ const match = content.match(/^\*\*([^*\n]+)\*\*(?:\r?\n\r?\n|$)/)
15
+ if (!match) return { title: null, body: content }
16
+ return { title: match[1].trim(), body: content.slice(match[0].length).trimEnd() }
17
+ }
18
+
19
+ export function isThinkingMode(value: unknown): value is ThinkingMode {
20
+ return typeof value === "string" && (MODES as readonly string[]).includes(value)
21
+ }
22
+
23
+ // Cycle order matches the slash command: show → hide → show.
24
+ export function nextThinkingMode(current: ThinkingMode): ThinkingMode {
25
+ const idx = MODES.indexOf(current)
26
+ return MODES[(idx + 1) % MODES.length] ?? "show"
27
+ }
28
+
29
+ export function useThinkingMode() {
30
+ const kv = useKV()
31
+ // Capture pre-state before `kv.signal` seeds a default, so we can detect
32
+ // first-time users with a legacy `thinking_visibility` boolean and migrate.
33
+ // The KVProvider only renders children once kv.ready, so reads here are safe.
34
+ const hadStored = kv.get("thinking_mode") !== undefined
35
+ const legacy = kv.get("thinking_visibility")
36
+ const [stored, setStored] = kv.signal<ThinkingMode>("thinking_mode", "hide")
37
+
38
+ // The kv signal exposes its setter typed as `Setter<T>` which carries Solid's
39
+ // overload set; passing an updater fn through a property access loses the
40
+ // bivariance trick the existing `setX((prev) => ...)` callsites rely on.
41
+ // Wrap it in a sane shape so consumers can just call `set(next)` or pass
42
+ // an updater.
43
+ const set = (next: ThinkingMode | ((prev: ThinkingMode) => ThinkingMode)) => {
44
+ if (typeof next === "function") setStored(next as Setter<ThinkingMode>)
45
+ else setStored(() => next)
46
+ }
47
+
48
+ // Preserve previous experience for users who had explicitly toggled the
49
+ // legacy `thinking_visibility` boolean. First-time users (no legacy key)
50
+ // get the new "hide" default (collapsed thinking).
51
+ if (!hadStored) {
52
+ if (legacy === true) set("show")
53
+ else if (legacy === false) set("hide")
54
+ }
55
+
56
+ if ((stored() as string) === "minimal") set("hide")
57
+
58
+ const mode = createMemo<ThinkingMode>(() => {
59
+ const value = stored()
60
+ return isThinkingMode(value) ? value : "hide"
61
+ })
62
+
63
+ return {
64
+ mode,
65
+ set,
66
+ }
67
+ }
@@ -0,0 +1,286 @@
1
+ import { Database } from "bun:sqlite"
2
+ import { statSync } from "node:fs"
3
+ import { readFile as readFileAsync } from "node:fs/promises"
4
+ import os from "node:os"
5
+ import path from "node:path"
6
+ import { Option, Schema } from "effect"
7
+ import type { EditorSelection } from "./context/editor"
8
+
9
+ const ZedEditorRowSchema = Schema.Struct({
10
+ item_kind: Schema.String,
11
+ editor_id: Schema.NullOr(Schema.Number),
12
+ workspace_id: Schema.Number,
13
+ workspace_paths: Schema.NullOr(Schema.String),
14
+ timestamp: Schema.String,
15
+ buffer_path: Schema.NullOr(Schema.String),
16
+ })
17
+
18
+ const ZedSelectionRowSchema = Schema.Struct({
19
+ selection_start: Schema.NullOr(Schema.Number),
20
+ selection_end: Schema.NullOr(Schema.Number),
21
+ })
22
+
23
+ const ZedEditorContentsSchema = Schema.Struct({
24
+ contents: Schema.NullOr(Schema.String),
25
+ })
26
+
27
+ const decodeZedEditorRow = Schema.decodeUnknownOption(ZedEditorRowSchema)
28
+ const decodeZedSelectionRow = Schema.decodeUnknownOption(ZedSelectionRowSchema)
29
+ const decodeZedEditorContents = Schema.decodeUnknownOption(ZedEditorContentsSchema)
30
+
31
+ const utf8 = new TextEncoder()
32
+
33
+ type ZedEditorRow = Schema.Schema.Type<typeof ZedEditorRowSchema>
34
+ type ZedActiveEditorRow = ZedEditorRow & { item_kind: "Editor"; editor_id: number }
35
+
36
+ export type ZedSelectionResult =
37
+ | { type: "selection"; selection: EditorSelection }
38
+ | { type: "empty" }
39
+ | { type: "unavailable" }
40
+
41
+ export async function resolveZedSelection(dbPath: string, cwd = process.cwd()): Promise<ZedSelectionResult> {
42
+ const active = queryZedActiveEditor(dbPath, cwd)
43
+ if (active.type !== "row") return active
44
+
45
+ const row = active.row
46
+ if (!row.buffer_path) return { type: "empty" }
47
+
48
+ const selections = queryZedEditorSelections(dbPath, row)
49
+ if (selections.type !== "selections") return selections
50
+ const byteRanges = selections.selections
51
+ .flatMap((selection) => {
52
+ if (selection.selection_start == null || selection.selection_end == null) return []
53
+ return [
54
+ {
55
+ start: Math.min(selection.selection_start, selection.selection_end),
56
+ end: Math.max(selection.selection_start, selection.selection_end),
57
+ },
58
+ ]
59
+ })
60
+ .sort((left, right) => left.start - right.start || left.end - right.end)
61
+ if (byteRanges.length === 0) return { type: "unavailable" }
62
+
63
+ const contents = queryZedEditorContents(dbPath, row)
64
+ const text =
65
+ contents.type === "contents" && contents.contents != null
66
+ ? contents.contents
67
+ : await readFileAsync(row.buffer_path, "utf8").catch(() => undefined)
68
+ if (text == null) return { type: "unavailable" }
69
+
70
+ const ranges = byteRanges.map((range) => {
71
+ const startOffset = utf8ByteOffsetToStringIndex(text, range.start)
72
+ const endOffset = utf8ByteOffsetToStringIndex(text, range.end)
73
+ return {
74
+ text: text.slice(startOffset, endOffset),
75
+ selection: offsetsToSelection(text, startOffset, endOffset),
76
+ }
77
+ })
78
+
79
+ return {
80
+ type: "selection",
81
+ selection: {
82
+ filePath: row.buffer_path,
83
+ source: "zed",
84
+ ranges,
85
+ },
86
+ }
87
+ }
88
+
89
+ function queryZedActiveEditor(dbPath: string, cwd: string) {
90
+ let db: Database | undefined
91
+ try {
92
+ db = new Database(dbPath, { readonly: true })
93
+ const raw = db
94
+ .query(
95
+ `select
96
+ i.kind as item_kind,
97
+ e.item_id as editor_id,
98
+ i.workspace_id as workspace_id,
99
+ w.paths as workspace_paths,
100
+ w.timestamp as timestamp,
101
+ e.buffer_path as buffer_path
102
+ from items i
103
+ join panes p on p.pane_id = i.pane_id and p.workspace_id = i.workspace_id
104
+ join workspaces w on w.workspace_id = i.workspace_id
105
+ left join editors e on e.item_id = i.item_id and e.workspace_id = i.workspace_id
106
+ where i.active = 1 and p.active = 1
107
+ order by w.timestamp desc`,
108
+ )
109
+ .all()
110
+
111
+ const rows = raw.flatMap((row) => {
112
+ const parsed = decodeZedEditorRow(row)
113
+ return Option.isSome(parsed) ? [parsed.value] : []
114
+ })
115
+
116
+ if (raw.length > 0 && rows.length === 0) return { type: "unavailable" as const }
117
+
118
+ const row = rows
119
+ .map((row) => ({ row, score: scoreZedWorkspace(row.workspace_paths, cwd) }))
120
+ .filter((entry) => entry.score > 0)
121
+ .sort((left, right) => right.score - left.score || right.row.timestamp.localeCompare(left.row.timestamp))[0]?.row
122
+ if (!row) return { type: "empty" as const }
123
+ if (row.item_kind !== "Editor") return { type: "unavailable" as const }
124
+ if (!isZedActiveEditorRow(row)) return { type: "empty" as const }
125
+ return { type: "row" as const, row }
126
+ } catch {
127
+ return { type: "unavailable" as const }
128
+ } finally {
129
+ db?.close()
130
+ }
131
+ }
132
+
133
+ function queryZedEditorSelections(dbPath: string, row: ZedActiveEditorRow) {
134
+ let db: Database | undefined
135
+ try {
136
+ db = new Database(dbPath, { readonly: true })
137
+ const raw = db
138
+ .query(
139
+ `select
140
+ start as selection_start,
141
+ end as selection_end
142
+ from editor_selections
143
+ where editor_id = $editorID and workspace_id = $workspaceID`,
144
+ )
145
+ .all({ $editorID: row.editor_id, $workspaceID: row.workspace_id })
146
+
147
+ const selections = raw.flatMap((selection) => {
148
+ const parsed = decodeZedSelectionRow(selection)
149
+ return Option.isSome(parsed) ? [parsed.value] : []
150
+ })
151
+
152
+ if (raw.length > 0 && selections.length === 0) return { type: "unavailable" as const }
153
+ return { type: "selections" as const, selections }
154
+ } catch {
155
+ return { type: "unavailable" as const }
156
+ } finally {
157
+ db?.close()
158
+ }
159
+ }
160
+
161
+ function queryZedEditorContents(dbPath: string, row: ZedActiveEditorRow) {
162
+ let db: Database | undefined
163
+ try {
164
+ db = new Database(dbPath, { readonly: true })
165
+ const parsed = decodeZedEditorContents(
166
+ db
167
+ .query(
168
+ `select contents
169
+ from editors
170
+ where item_id = $editorID and workspace_id = $workspaceID`,
171
+ )
172
+ .get({ $editorID: row.editor_id, $workspaceID: row.workspace_id }),
173
+ )
174
+ if (Option.isNone(parsed)) return { type: "unavailable" as const }
175
+ return { type: "contents" as const, contents: parsed.value.contents }
176
+ } catch {
177
+ return { type: "unavailable" as const }
178
+ } finally {
179
+ db?.close()
180
+ }
181
+ }
182
+
183
+ function isZedActiveEditorRow(row: ZedEditorRow): row is ZedActiveEditorRow {
184
+ return row.item_kind === "Editor" && row.editor_id != null
185
+ }
186
+
187
+ export function resolveZedDbPath() {
188
+ const candidates = [
189
+ process.env.NEUROCODE_ZED_DB,
190
+ path.join(os.homedir(), "Library", "Application Support", "Zed", "db", "0-stable", "db.sqlite"),
191
+ path.join(os.homedir(), ".local", "share", "zed", "db", "0-stable", "db.sqlite"),
192
+ ].filter((item): item is string => Boolean(item))
193
+
194
+ return candidates.find((item) => isFile(item))
195
+ }
196
+
197
+ export function isZedTerminal() {
198
+ return process.env.ZED_TERM === "true" || process.env.TERM_PROGRAM?.toLowerCase() === "zed"
199
+ }
200
+
201
+ function isFile(item: string) {
202
+ try {
203
+ return statSync(item).isFile()
204
+ } catch {
205
+ return false
206
+ }
207
+ }
208
+
209
+ function scoreZedWorkspace(workspacePaths: string | null, cwd: string) {
210
+ return zedWorkspacePaths(workspacePaths).reduce((score, item) => {
211
+ if (pathContains(item, cwd)) return Math.max(score, path.resolve(item).length)
212
+ return score
213
+ }, 0)
214
+ }
215
+
216
+ function zedWorkspacePaths(value: string | null) {
217
+ if (!value) return []
218
+ const parsed = parseJson(value)
219
+ if (Array.isArray(parsed)) return parsed.filter((item): item is string => typeof item === "string")
220
+ return value.split(/\r?\n/).filter(Boolean)
221
+ }
222
+
223
+ export function offsetToPosition(text: string, offset: number) {
224
+ const stringOffset = utf8ByteOffsetToStringIndex(text, offset)
225
+ return offsetsToSelection(text, stringOffset, stringOffset).start
226
+ }
227
+
228
+ function utf8ByteOffsetToStringIndex(text: string, byteOffset: number) {
229
+ if (byteOffset <= 0) return 0
230
+
231
+ let bytes = 0
232
+ for (let index = 0; index < text.length; ) {
233
+ const codePoint = text.codePointAt(index)
234
+ if (codePoint === undefined) return text.length
235
+
236
+ const nextIndex = index + (codePoint > 0xffff ? 2 : 1)
237
+ bytes += utf8.encode(text.slice(index, nextIndex)).length
238
+ if (bytes >= byteOffset) return nextIndex
239
+ index = nextIndex
240
+ }
241
+
242
+ return text.length
243
+ }
244
+
245
+ function offsetsToSelection(text: string, startOffset: number, endOffset: number) {
246
+ const start = Math.max(0, Math.min(startOffset, text.length))
247
+ const end = Math.max(0, Math.min(endOffset, text.length))
248
+ let line = 1
249
+ let lineStart = 0
250
+ let startPosition = position(line, lineStart, start)
251
+ let endPosition = position(line, lineStart, end)
252
+
253
+ for (let index = 0; index <= end; index++) {
254
+ if (index === start) startPosition = position(line, lineStart, index)
255
+ if (index === end) {
256
+ endPosition = position(line, lineStart, index)
257
+ break
258
+ }
259
+ if (text[index] === "\n") {
260
+ line += 1
261
+ lineStart = index + 1
262
+ }
263
+ }
264
+
265
+ return { start: startPosition, end: endPosition }
266
+ }
267
+
268
+ function position(line: number, lineStart: number, offset: number) {
269
+ return {
270
+ line,
271
+ character: offset - lineStart + 1,
272
+ }
273
+ }
274
+
275
+ function pathContains(parent: string, child: string) {
276
+ const relative = path.relative(path.resolve(parent), path.resolve(child))
277
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
278
+ }
279
+
280
+ function parseJson(value: string) {
281
+ try {
282
+ return JSON.parse(value) as unknown
283
+ } catch {
284
+ return
285
+ }
286
+ }