@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,144 @@
1
+ import { TextAttributes } from "@opentui/core"
2
+ import { useKeyboard } from "@opentui/solid"
3
+ import type { VcsFileStatus } from "@neurocode-ai/sdk/v2"
4
+ import { createMemo, For } from "solid-js"
5
+ import { createStore } from "solid-js/store"
6
+ import { Locale } from "../util/locale"
7
+ import { useTheme } from "../context/theme"
8
+ import { useTuiConfig } from "../config"
9
+ import { useDialog, type DialogContext } from "../ui/dialog"
10
+ import { getScrollAcceleration } from "../util/scroll"
11
+
12
+ const options = ["no", "yes"] as const
13
+
14
+ export type WorkspaceFileChangesChoice = (typeof options)[number]
15
+
16
+ function statusLabel(status: VcsFileStatus["status"]) {
17
+ if (status === "added") return "A"
18
+ if (status === "deleted") return "D"
19
+ return "M"
20
+ }
21
+
22
+ function changeCountWidth(file: VcsFileStatus) {
23
+ // The "plus 2" is for spaces
24
+ return `${file.additions ? `+${file.additions}` : ""}${file.deletions ? ` -${file.deletions}` : ""}`.length + 2
25
+ }
26
+
27
+ export function DialogWorkspaceFileChanges(props: {
28
+ files: VcsFileStatus[]
29
+ onSelect: (choice: WorkspaceFileChangesChoice) => void
30
+ title?: string
31
+ message?: string
32
+ }) {
33
+ const dialog = useDialog()
34
+ const { theme } = useTheme()
35
+ const tuiConfig = useTuiConfig()
36
+ const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
37
+ const [store, setStore] = createStore({ active: "yes" as WorkspaceFileChangesChoice })
38
+ const height = createMemo(() => Math.min(props.files.length, 8))
39
+ const fileNameWidth = createMemo(() => 48 - Math.max(Math.max(7, ...props.files.map(changeCountWidth)) - 7, 0))
40
+
41
+ function confirm() {
42
+ props.onSelect(store.active)
43
+ dialog.clear()
44
+ }
45
+
46
+ useKeyboard((evt) => {
47
+ if (evt.name === "return") {
48
+ evt.preventDefault()
49
+ evt.stopPropagation()
50
+ confirm()
51
+ return
52
+ }
53
+ if (evt.name === "left") {
54
+ evt.preventDefault()
55
+ evt.stopPropagation()
56
+ const index = options.indexOf(store.active)
57
+ setStore("active", options[Math.max(index - 1, 0)])
58
+ return
59
+ }
60
+ if (evt.name === "right") {
61
+ evt.preventDefault()
62
+ evt.stopPropagation()
63
+ const index = options.indexOf(store.active)
64
+ setStore("active", options[Math.min(index + 1, options.length - 1)])
65
+ }
66
+ })
67
+
68
+ return (
69
+ <box gap={1}>
70
+ <box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
71
+ <text attributes={TextAttributes.BOLD} fg={theme.text}>
72
+ {props.title ?? "File Changes Found"}
73
+ </text>
74
+ <text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
75
+ esc
76
+ </text>
77
+ </box>
78
+ <box paddingLeft={2} paddingRight={2}>
79
+ <text fg={theme.textMuted} wrapMode="word">
80
+ {props.message ?? "Do you want to move these changes with the session?"}
81
+ </text>
82
+ </box>
83
+ <scrollbox
84
+ height={height()}
85
+ backgroundColor={theme.backgroundElement}
86
+ scrollbarOptions={{ visible: false }}
87
+ scrollAcceleration={scrollAcceleration()}
88
+ >
89
+ <For each={props.files}>
90
+ {(item) => (
91
+ <box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
92
+ <box flexDirection="row" minWidth={0} flexShrink={1}>
93
+ <box width={2} flexShrink={0}>
94
+ <text fg={theme.textMuted}>{statusLabel(item.status)}</text>
95
+ </box>
96
+ <text fg={theme.textMuted} wrapMode="none">
97
+ {Locale.truncateLeft(item.file, fileNameWidth())}
98
+ </text>
99
+ </box>
100
+ <box flexDirection="row" gap={1} minWidth={7} flexShrink={0} justifyContent="flex-end">
101
+ <text>
102
+ {" "}
103
+ {item.additions ? <span style={{ fg: theme.diffAdded }}>+{item.additions}</span> : null}
104
+ {item.deletions ? <span style={{ fg: theme.diffRemoved }}> -{item.deletions}</span> : null}
105
+ </text>
106
+ </box>
107
+ </box>
108
+ )}
109
+ </For>
110
+ </scrollbox>
111
+ <box flexDirection="row" justifyContent="flex-end" paddingLeft={2} paddingRight={2} paddingBottom={1}>
112
+ <For each={options}>
113
+ {(item) => (
114
+ <box
115
+ paddingLeft={2}
116
+ paddingRight={2}
117
+ backgroundColor={item === store.active ? theme.primary : undefined}
118
+ onMouseUp={() => {
119
+ setStore("active", item)
120
+ props.onSelect(item)
121
+ dialog.clear()
122
+ }}
123
+ >
124
+ <text fg={item === store.active ? theme.selectedListItemText : theme.textMuted}>{item}</text>
125
+ </box>
126
+ )}
127
+ </For>
128
+ </box>
129
+ </box>
130
+ )
131
+ }
132
+
133
+ DialogWorkspaceFileChanges.show = (
134
+ dialog: DialogContext,
135
+ files: VcsFileStatus[],
136
+ options?: { title?: string; message?: string },
137
+ ) => {
138
+ return new Promise<WorkspaceFileChangesChoice | undefined>((resolve) => {
139
+ dialog.replace(
140
+ () => <DialogWorkspaceFileChanges files={files} onSelect={resolve} {...options} />,
141
+ () => resolve(undefined),
142
+ )
143
+ })
144
+ }
@@ -0,0 +1,112 @@
1
+ import type { Workspace } from "@neurocode-ai/sdk/v2"
2
+ import { useDialog } from "../ui/dialog"
3
+ import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
4
+ import { useProject } from "../context/project"
5
+ import { useRoute } from "../context/route"
6
+ import { useSync } from "../context/sync"
7
+ import { useTheme } from "../context/theme"
8
+ import { createMemo, createSignal, onMount } from "solid-js"
9
+ import { createStore } from "solid-js/store"
10
+ import { errorMessage } from "../util/error"
11
+ import { useSDK } from "../context/sdk"
12
+ import { useToast } from "../ui/toast"
13
+
14
+ type WorkspaceOption = { workspace: Workspace }
15
+
16
+ export function DialogWorkspaceList() {
17
+ const dialog = useDialog()
18
+ const route = useRoute()
19
+ const sync = useSync()
20
+ const sdk = useSDK()
21
+ const toast = useToast()
22
+ const project = useProject()
23
+ const { theme } = useTheme()
24
+ const [deleting, setDeleting] = createSignal<string>()
25
+ const [removing, setRemoving] = createSignal<string>()
26
+ const [expanded, setExpanded] = createStore<Record<string, boolean>>({})
27
+
28
+ const current = createMemo(() => {
29
+ if (route.data.type === "session") return sync.session.get(route.data.sessionID)?.workspaceID
30
+ return project.workspace.current()
31
+ })
32
+
33
+ const options = createMemo<DialogSelectOption<WorkspaceOption>[]>(() =>
34
+ project.workspace
35
+ .list()
36
+ .toSorted((a, b) => a.name.localeCompare(b.name))
37
+ .map((workspace) => {
38
+ const status = project.workspace.status(workspace.id)
39
+ return {
40
+ title:
41
+ removing() === workspace.id
42
+ ? "Deleting..."
43
+ : deleting() === workspace.id
44
+ ? `Delete ${workspace.name}? Press delete again`
45
+ : workspace.name,
46
+ value: { workspace },
47
+ footer: workspace.type,
48
+ details: expanded[workspace.id] && workspace.directory ? [workspace.directory] : undefined,
49
+ gutter: () => <text fg={status === "connected" ? theme.success : theme.error}>●</text>,
50
+ }
51
+ }),
52
+ )
53
+
54
+ function showDetails(workspace: Workspace) {
55
+ setExpanded(workspace.id, (open) => !open)
56
+ }
57
+
58
+ async function remove(workspace: Workspace) {
59
+ if (removing()) return
60
+ if (deleting() !== workspace.id) {
61
+ setDeleting(workspace.id)
62
+ return
63
+ }
64
+
65
+ setDeleting(undefined)
66
+ setRemoving(workspace.id)
67
+ const result = await sdk.client.experimental.workspace.remove({ id: workspace.id }).catch((err) => ({
68
+ error: err,
69
+ }))
70
+ if (result?.error) {
71
+ setRemoving(undefined)
72
+ toast.show({
73
+ variant: "error",
74
+ title: "Failed to delete workspace",
75
+ message: errorMessage(result.error),
76
+ })
77
+ return
78
+ }
79
+
80
+ if (current() === workspace.id) {
81
+ project.workspace.set(undefined)
82
+ route.navigate({ type: "home" })
83
+ }
84
+ await project.workspace.sync()
85
+ await sync.bootstrap({ fatal: false }).catch(() => undefined)
86
+ setRemoving(undefined)
87
+ }
88
+
89
+ onMount(() => {
90
+ dialog.setSize("large")
91
+ void sdk.client.experimental.workspace.syncList().catch(() => undefined)
92
+ void project.workspace.sync()
93
+ })
94
+
95
+ return (
96
+ <DialogSelect
97
+ title="Workspaces"
98
+ options={options()}
99
+ onMove={(option) => {
100
+ setDeleting(undefined)
101
+ }}
102
+ onSelect={(option) => showDetails(option.value.workspace)}
103
+ actions={[
104
+ {
105
+ command: "session.delete",
106
+ title: "delete",
107
+ onTrigger: (option) => void remove(option.value.workspace),
108
+ },
109
+ ]}
110
+ />
111
+ )
112
+ }
@@ -0,0 +1,69 @@
1
+ import { TextAttributes } from "@opentui/core"
2
+ import { createStore } from "solid-js/store"
3
+ import { For } from "solid-js"
4
+ import { useTheme } from "../context/theme"
5
+ import { useDialog } from "../ui/dialog"
6
+ import { useBindings } from "../keymap"
7
+
8
+ export function DialogWorkspaceUnavailable(props: { onRestore?: () => boolean | void | Promise<boolean | void> }) {
9
+ const dialog = useDialog()
10
+ const { theme } = useTheme()
11
+ const [store, setStore] = createStore({
12
+ active: "restore" as "cancel" | "restore",
13
+ })
14
+
15
+ const options = ["cancel", "restore"] as const
16
+
17
+ async function confirm() {
18
+ if (store.active === "cancel") {
19
+ dialog.clear()
20
+ return
21
+ }
22
+ const result = await props.onRestore?.()
23
+ if (result === false) return
24
+ }
25
+
26
+ useBindings(() => ({
27
+ bindings: [
28
+ { key: "return", desc: "Confirm workspace option", group: "Dialog", cmd: () => void confirm() },
29
+ { key: "left", desc: "Cancel workspace restore", group: "Dialog", cmd: () => setStore("active", "cancel") },
30
+ { key: "right", desc: "Restore workspace", group: "Dialog", cmd: () => setStore("active", "restore") },
31
+ ],
32
+ }))
33
+
34
+ return (
35
+ <box paddingLeft={2} paddingRight={2} gap={1}>
36
+ <box flexDirection="row" justifyContent="space-between">
37
+ <text attributes={TextAttributes.BOLD} fg={theme.text}>
38
+ Workspace Unavailable
39
+ </text>
40
+ <text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
41
+ esc
42
+ </text>
43
+ </box>
44
+ <text fg={theme.textMuted} wrapMode="word">
45
+ This session is attached to a workspace that is no longer available.
46
+ </text>
47
+ <text fg={theme.textMuted} wrapMode="word">
48
+ Would you like to restore this session into a new workspace?
49
+ </text>
50
+ <box flexDirection="row" justifyContent="flex-end" paddingBottom={1} gap={1}>
51
+ <For each={options}>
52
+ {(item) => (
53
+ <box
54
+ paddingLeft={2}
55
+ paddingRight={2}
56
+ backgroundColor={item === store.active ? theme.primary : undefined}
57
+ onMouseUp={() => {
58
+ setStore("active", item)
59
+ void confirm()
60
+ }}
61
+ >
62
+ <text fg={item === store.active ? theme.selectedListItemText : theme.textMuted}>{item}</text>
63
+ </box>
64
+ )}
65
+ </For>
66
+ </box>
67
+ </box>
68
+ )
69
+ }
@@ -0,0 +1,240 @@
1
+ import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
2
+ import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
3
+ import { createSignal, For, Show } from "solid-js"
4
+ import { getScrollAcceleration } from "../util/scroll"
5
+ import { useClipboard } from "../context/clipboard"
6
+ import { InstallationVersion } from "@neurocode-ai/core/installation/version"
7
+ import { useExit } from "../context/exit"
8
+ import { describeOS, describeTerminal } from "../util/system"
9
+
10
+ export function ErrorComponent(props: { error: Error; reset: () => void; mode?: "dark" | "light" }) {
11
+ const term = useTerminalDimensions()
12
+ const exit = useExit()
13
+ const clipboard = useClipboard()
14
+ const [copied, setCopied] = createSignal(false)
15
+
16
+ // Safe fallback palette per mode (mirrors theme/assets/opencode.json) since the
17
+ // theme context may be the thing that crashed.
18
+ const isLight = props.mode === "light"
19
+ const colors = isLight
20
+ ? {
21
+ bg: "#ffffff",
22
+ element: "#f5f5f5",
23
+ borderSubtle: "#d4d4d4",
24
+ text: "#1a1a1a",
25
+ muted: "#8a8a8a",
26
+ primary: "#3b7dd8",
27
+ onPrimary: "#ffffff",
28
+ error: "#d1383d",
29
+ success: "#3d9a57",
30
+ }
31
+ : {
32
+ bg: "#0a0a0a",
33
+ element: "#1e1e1e",
34
+ borderSubtle: "#3c3c3c",
35
+ text: "#eeeeee",
36
+ muted: "#808080",
37
+ primary: "#fab283",
38
+ onPrimary: "#0a0a0a",
39
+ error: "#e06c75",
40
+ success: "#7fd88f",
41
+ }
42
+
43
+ const message = props.error.message || "An unknown error occurred."
44
+ const stack = props.error.stack || "No stack trace available."
45
+ const issueURL = buildIssueURL(message, stack)
46
+
47
+ const copyReport = () => {
48
+ void clipboard.write?.(issueURL.toString()).then(() => setCopied(true))
49
+ }
50
+
51
+ const actions = [
52
+ { key: "c", label: () => (copied() ? "✓ Copied" : "Copy report"), copy: true, onUse: copyReport },
53
+ { key: "r", label: () => "Restart", onUse: props.reset },
54
+ { key: "q", label: () => "Quit", onUse: () => exit() },
55
+ ]
56
+ const [selected, setSelected] = createSignal(0)
57
+ const move = (delta: number) => setSelected((prev) => (prev + delta + actions.length) % actions.length)
58
+ let scroll: ScrollBoxRenderable | undefined
59
+
60
+ useKeyboard((evt) => {
61
+ if (evt.ctrl && evt.name === "c") return exit()
62
+ if (evt.name === "return") {
63
+ evt.preventDefault()
64
+ evt.stopPropagation()
65
+ return actions[selected()].onUse()
66
+ }
67
+ if (evt.name === "left") {
68
+ evt.preventDefault()
69
+ evt.stopPropagation()
70
+ return move(-1)
71
+ }
72
+ if (evt.name === "right") {
73
+ evt.preventDefault()
74
+ evt.stopPropagation()
75
+ return move(1)
76
+ }
77
+ if (evt.name === "tab") {
78
+ evt.preventDefault()
79
+ evt.stopPropagation()
80
+ return move(evt.shift ? -1 : 1)
81
+ }
82
+ // Vertical keys scroll the stack trace; buttons navigate horizontally.
83
+ if (evt.name === "up") return scroll?.scrollBy(-1)
84
+ if (evt.name === "down") return scroll?.scrollBy(1)
85
+ if (evt.name === "pageup" && scroll) return scroll.scrollBy(-scroll.height)
86
+ if (evt.name === "pagedown" && scroll) return scroll.scrollBy(scroll.height)
87
+ if (evt.name === "home" && scroll) return scroll.scrollTo(0)
88
+ if (evt.name === "end" && scroll) return scroll.scrollTo(scroll.scrollHeight)
89
+ if (evt.name === "q") return exit()
90
+ if (evt.name === "c") return copyReport()
91
+ if (evt.name === "r") return props.reset()
92
+ })
93
+
94
+ // Responsive thresholds.
95
+ const contentWidth = () => Math.min(84, Math.max(24, term().width - 4))
96
+ const showSubtext = () => term().height >= 18
97
+ const showFooter = () => term().height >= 20
98
+
99
+ return (
100
+ <box
101
+ width={term().width}
102
+ height={term().height}
103
+ backgroundColor={colors.bg}
104
+ flexDirection="column"
105
+ alignItems="center"
106
+ >
107
+ <box width={contentWidth()} flexGrow={1} flexDirection="column" paddingTop={1} paddingBottom={1} gap={1}>
108
+ {/* Headline */}
109
+ <box flexDirection="column" alignItems="center" flexShrink={0}>
110
+ <text attributes={TextAttributes.BOLD} fg={colors.text}>
111
+ opencode crashed
112
+ </text>
113
+ <Show when={showSubtext()}>
114
+ <text fg={colors.muted}>An unexpected error stopped the session.</text>
115
+ </Show>
116
+ </box>
117
+
118
+ {/* Error message panel */}
119
+ <box
120
+ flexShrink={0}
121
+ border
122
+ borderStyle="rounded"
123
+ borderColor={colors.error}
124
+ title=" Error "
125
+ titleColor={colors.error}
126
+ paddingLeft={2}
127
+ paddingRight={2}
128
+ >
129
+ <text fg={colors.text}>{message}</text>
130
+ </box>
131
+
132
+ {/* Actions */}
133
+ <box flexDirection="row" flexWrap="wrap" justifyContent="center" gap={2} rowGap={1} flexShrink={0}>
134
+ <For each={actions}>
135
+ {(action, index) => {
136
+ const isSelected = () => selected() === index()
137
+ const isCopied = () => action.copy && copied()
138
+ return (
139
+ <box flexDirection="column" alignItems="center" flexShrink={0}>
140
+ <box
141
+ onMouseDown={() => setSelected(index())}
142
+ onMouseUp={() => action.onUse()}
143
+ backgroundColor={isCopied() ? colors.success : isSelected() ? colors.primary : colors.element}
144
+ minWidth={15}
145
+ alignItems="center"
146
+ paddingLeft={2}
147
+ paddingRight={2}
148
+ >
149
+ <text
150
+ attributes={TextAttributes.BOLD}
151
+ fg={isCopied() || isSelected() ? colors.onPrimary : colors.text}
152
+ >
153
+ {action.label()}
154
+ </text>
155
+ </box>
156
+ <text fg={isSelected() ? colors.primary : colors.muted}>{action.key}</text>
157
+ </box>
158
+ )
159
+ }}
160
+ </For>
161
+ </box>
162
+
163
+ {/* Stack trace */}
164
+ <box
165
+ flexGrow={1}
166
+ flexBasis={0}
167
+ minHeight={3}
168
+ border
169
+ borderStyle="rounded"
170
+ borderColor={colors.borderSubtle}
171
+ title=" Stack trace "
172
+ titleColor={colors.muted}
173
+ bottomTitle=" ↑↓ scroll "
174
+ bottomTitleAlignment="right"
175
+ paddingLeft={1}
176
+ paddingRight={1}
177
+ >
178
+ <scrollbox
179
+ ref={(element: ScrollBoxRenderable) => (scroll = element)}
180
+ flexGrow={1}
181
+ scrollAcceleration={getScrollAcceleration()}
182
+ >
183
+ <text fg={colors.muted}>{stack}</text>
184
+ </scrollbox>
185
+ </box>
186
+
187
+ {/* Footer */}
188
+ <Show when={showFooter()}>
189
+ <box flexDirection="column" alignItems="center" flexShrink={0}>
190
+ <text fg={colors.muted}>
191
+ {copied()
192
+ ? "Report copied — paste it into a new GitHub issue."
193
+ : "Copy the report and open a GitHub issue to help us fix this."}
194
+ </text>
195
+ <text fg={colors.muted}>opencode {InstallationVersion}</text>
196
+ </box>
197
+ </Show>
198
+ </box>
199
+ </box>
200
+ )
201
+ }
202
+
203
+ function buildIssueURL(message: string, stack: string) {
204
+ // Field keys match the ids in .github/ISSUE_TEMPLATE/bug-report.yml so the issue
205
+ // form opens pre-filled. Populating os/terminal/reproduce keeps the report past
206
+ // the contributing-guidelines compliance check, which pushes for system info.
207
+ const url = new URL("https://github.com/anomalyco/opencode/issues/new?template=bug-report.yml")
208
+ url.searchParams.set("title", `TUI crash: ${message}`)
209
+ url.searchParams.set("opencode-version", InstallationVersion)
210
+ url.searchParams.set("os", describeOS())
211
+ url.searchParams.set("terminal", describeTerminal())
212
+ url.searchParams.set(
213
+ "reproduce",
214
+ "Reported automatically from the opencode crash screen. If you can, describe what you were doing when it crashed.",
215
+ )
216
+
217
+ // Budget the stack against the fully URL-encoded length (not the raw length) so
218
+ // the final link stays under GitHub's practical limit; flag truncation so a
219
+ // clipped trace is obvious. searchParams.set handles encoding without throwing,
220
+ // so measuring url.toString() is both correct and safe on any input.
221
+ const MAX_URL_LENGTH = 6000
222
+ const marker = "\n... (truncated)"
223
+ const head = `The opencode TUI crashed with an unexpected error.\n\n**Error:** ${message}\n\n**Stack trace:**\n`
224
+ const setBody = (body: string) => url.searchParams.set("description", head + "```\n" + body + "\n```")
225
+
226
+ setBody(stack)
227
+ if (url.toString().length <= MAX_URL_LENGTH) return url
228
+
229
+ // Largest raw stack prefix whose encoded URL (with the marker) still fits.
230
+ let lo = 0
231
+ let hi = stack.length
232
+ while (lo < hi) {
233
+ const mid = Math.ceil((lo + hi) / 2)
234
+ setBody(stack.slice(0, mid) + marker)
235
+ if (url.toString().length <= MAX_URL_LENGTH) lo = mid
236
+ else hi = mid - 1
237
+ }
238
+ setBody(stack.slice(0, lo) + marker)
239
+ return url
240
+ }
@@ -0,0 +1,61 @@
1
+ import { RGBA, TextAttributes } from "@opentui/core"
2
+ import { For, type JSX } from "solid-js"
3
+ import { tint, useTheme } from "../context/theme"
4
+ import { logo } from "../logo"
5
+
6
+ export function Logo() {
7
+ const { theme } = useTheme()
8
+
9
+ const renderLine = (line: string, fg: RGBA, bold: boolean): JSX.Element[] => {
10
+ const shadow = tint(theme.background, fg, 0.25)
11
+ const attrs = bold ? TextAttributes.BOLD : undefined
12
+ return Array.from(line).map((char) => {
13
+ if (char === "_") {
14
+ return (
15
+ <text fg={fg} bg={shadow} attributes={attrs} selectable={false}>
16
+ {" "}
17
+ </text>
18
+ )
19
+ }
20
+ if (char === "^") {
21
+ return (
22
+ <text fg={fg} bg={shadow} attributes={attrs} selectable={false}>
23
+
24
+ </text>
25
+ )
26
+ }
27
+ if (char === "~") {
28
+ return (
29
+ <text fg={shadow} attributes={attrs} selectable={false}>
30
+
31
+ </text>
32
+ )
33
+ }
34
+ if (char === ",") {
35
+ return (
36
+ <text fg={shadow} attributes={attrs} selectable={false}>
37
+
38
+ </text>
39
+ )
40
+ }
41
+ return (
42
+ <text fg={fg} attributes={attrs} selectable={false}>
43
+ {char}
44
+ </text>
45
+ )
46
+ })
47
+ }
48
+
49
+ return (
50
+ <box>
51
+ <For each={logo.left}>
52
+ {(line, index) => (
53
+ <box flexDirection="row" gap={1}>
54
+ <box flexDirection="row">{renderLine(line, theme.textMuted, false)}</box>
55
+ <box flexDirection="row">{renderLine(logo.right[index()], theme.text, true)}</box>
56
+ </box>
57
+ )}
58
+ </For>
59
+ </box>
60
+ )
61
+ }
@@ -0,0 +1,14 @@
1
+ import { useTheme } from "../context/theme"
2
+
3
+ export function PluginRouteMissing(props: { id: string; onHome: () => void }) {
4
+ const { theme } = useTheme()
5
+
6
+ return (
7
+ <box width="100%" height="100%" alignItems="center" justifyContent="center" flexDirection="column" gap={1}>
8
+ <text fg={theme.warning}>Unknown plugin route: {props.id}</text>
9
+ <box onMouseUp={props.onHome} backgroundColor={theme.backgroundElement} paddingLeft={1} paddingRight={1}>
10
+ <text fg={theme.text}>go home</text>
11
+ </box>
12
+ </box>
13
+ )
14
+ }