reigncode-app 1.3.2

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 (300) hide show
  1. package/AGENTS.md +30 -0
  2. package/Dockerfile +21 -0
  3. package/README.md +51 -0
  4. package/bunfig.toml +3 -0
  5. package/create-effect-simplification-spec.md +515 -0
  6. package/e2e/AGENTS.md +226 -0
  7. package/e2e/actions.ts +1018 -0
  8. package/e2e/app/home.spec.ts +24 -0
  9. package/e2e/app/navigation.spec.ts +10 -0
  10. package/e2e/app/palette.spec.ts +20 -0
  11. package/e2e/app/server-default.spec.ts +58 -0
  12. package/e2e/app/session.spec.ts +16 -0
  13. package/e2e/app/titlebar-history.spec.ts +120 -0
  14. package/e2e/commands/input-focus.spec.ts +15 -0
  15. package/e2e/commands/panels.spec.ts +33 -0
  16. package/e2e/commands/tab-close.spec.ts +32 -0
  17. package/e2e/files/file-open.spec.ts +31 -0
  18. package/e2e/files/file-tree.spec.ts +56 -0
  19. package/e2e/files/file-viewer.spec.ts +156 -0
  20. package/e2e/fixtures.ts +154 -0
  21. package/e2e/models/model-picker.spec.ts +48 -0
  22. package/e2e/models/models-visibility.spec.ts +61 -0
  23. package/e2e/projects/project-edit.spec.ts +43 -0
  24. package/e2e/projects/projects-close.spec.ts +54 -0
  25. package/e2e/projects/projects-switch.spec.ts +116 -0
  26. package/e2e/projects/workspace-new-session.spec.ts +94 -0
  27. package/e2e/projects/workspaces.spec.ts +375 -0
  28. package/e2e/prompt/context.spec.ts +95 -0
  29. package/e2e/prompt/prompt-async.spec.ts +76 -0
  30. package/e2e/prompt/prompt-drop-file-uri.spec.ts +22 -0
  31. package/e2e/prompt/prompt-drop-file.spec.ts +30 -0
  32. package/e2e/prompt/prompt-history.spec.ts +184 -0
  33. package/e2e/prompt/prompt-mention.spec.ts +26 -0
  34. package/e2e/prompt/prompt-multiline.spec.ts +24 -0
  35. package/e2e/prompt/prompt-shell.spec.ts +62 -0
  36. package/e2e/prompt/prompt-slash-open.spec.ts +22 -0
  37. package/e2e/prompt/prompt-slash-share.spec.ts +64 -0
  38. package/e2e/prompt/prompt-slash-terminal.spec.ts +18 -0
  39. package/e2e/prompt/prompt.spec.ts +55 -0
  40. package/e2e/selectors.ts +75 -0
  41. package/e2e/session/session-child-navigation.spec.ts +37 -0
  42. package/e2e/session/session-composer-dock.spec.ts +530 -0
  43. package/e2e/session/session-model-persistence.spec.ts +359 -0
  44. package/e2e/session/session-review.spec.ts +426 -0
  45. package/e2e/session/session-undo-redo.spec.ts +233 -0
  46. package/e2e/session/session.spec.ts +174 -0
  47. package/e2e/settings/settings-keybinds.spec.ts +389 -0
  48. package/e2e/settings/settings-models.spec.ts +122 -0
  49. package/e2e/settings/settings-providers.spec.ts +136 -0
  50. package/e2e/settings/settings.spec.ts +519 -0
  51. package/e2e/sidebar/sidebar-popover-actions.spec.ts +118 -0
  52. package/e2e/sidebar/sidebar-session-links.spec.ts +30 -0
  53. package/e2e/sidebar/sidebar.spec.ts +40 -0
  54. package/e2e/status/status-popover.spec.ts +94 -0
  55. package/e2e/terminal/terminal-init.spec.ts +28 -0
  56. package/e2e/terminal/terminal-reconnect.spec.ts +46 -0
  57. package/e2e/terminal/terminal-tabs.spec.ts +168 -0
  58. package/e2e/terminal/terminal.spec.ts +18 -0
  59. package/e2e/thinking-level.spec.ts +25 -0
  60. package/e2e/tsconfig.json +9 -0
  61. package/e2e/utils.ts +63 -0
  62. package/happydom.ts +75 -0
  63. package/index.html +23 -0
  64. package/package.json +77 -0
  65. package/playwright.config.ts +45 -0
  66. package/public/_headers +17 -0
  67. package/public/oc-theme-preload.js +35 -0
  68. package/script/e2e-local.ts +180 -0
  69. package/src/addons/serialize.test.ts +319 -0
  70. package/src/addons/serialize.ts +634 -0
  71. package/src/app.tsx +308 -0
  72. package/src/components/debug-bar.tsx +443 -0
  73. package/src/components/dialog-connect-provider.tsx +617 -0
  74. package/src/components/dialog-custom-provider-form.ts +158 -0
  75. package/src/components/dialog-custom-provider.test.ts +80 -0
  76. package/src/components/dialog-custom-provider.tsx +329 -0
  77. package/src/components/dialog-edit-project.tsx +255 -0
  78. package/src/components/dialog-fork.tsx +108 -0
  79. package/src/components/dialog-manage-models.tsx +101 -0
  80. package/src/components/dialog-release-notes.tsx +144 -0
  81. package/src/components/dialog-select-directory.tsx +392 -0
  82. package/src/components/dialog-select-file.tsx +466 -0
  83. package/src/components/dialog-select-mcp.tsx +107 -0
  84. package/src/components/dialog-select-model-unpaid.tsx +137 -0
  85. package/src/components/dialog-select-model.tsx +220 -0
  86. package/src/components/dialog-select-provider.tsx +86 -0
  87. package/src/components/dialog-select-server.tsx +649 -0
  88. package/src/components/dialog-settings.tsx +73 -0
  89. package/src/components/file-tree.test.ts +78 -0
  90. package/src/components/file-tree.tsx +507 -0
  91. package/src/components/link.tsx +26 -0
  92. package/src/components/model-tooltip.tsx +91 -0
  93. package/src/components/prompt-input/attachments.test.ts +44 -0
  94. package/src/components/prompt-input/attachments.ts +201 -0
  95. package/src/components/prompt-input/build-request-parts.test.ts +312 -0
  96. package/src/components/prompt-input/build-request-parts.ts +175 -0
  97. package/src/components/prompt-input/context-items.tsx +88 -0
  98. package/src/components/prompt-input/drag-overlay.tsx +25 -0
  99. package/src/components/prompt-input/editor-dom.test.ts +99 -0
  100. package/src/components/prompt-input/editor-dom.ts +148 -0
  101. package/src/components/prompt-input/files.ts +66 -0
  102. package/src/components/prompt-input/history.test.ts +153 -0
  103. package/src/components/prompt-input/history.ts +256 -0
  104. package/src/components/prompt-input/image-attachments.tsx +58 -0
  105. package/src/components/prompt-input/paste.ts +24 -0
  106. package/src/components/prompt-input/placeholder.test.ts +48 -0
  107. package/src/components/prompt-input/placeholder.ts +15 -0
  108. package/src/components/prompt-input/slash-popover.tsx +141 -0
  109. package/src/components/prompt-input/submit.test.ts +346 -0
  110. package/src/components/prompt-input/submit.ts +579 -0
  111. package/src/components/prompt-input.tsx +1595 -0
  112. package/src/components/server/server-row.tsx +130 -0
  113. package/src/components/session/index.ts +5 -0
  114. package/src/components/session/session-context-breakdown.test.ts +61 -0
  115. package/src/components/session/session-context-breakdown.ts +132 -0
  116. package/src/components/session/session-context-format.ts +20 -0
  117. package/src/components/session/session-context-metrics.test.ts +101 -0
  118. package/src/components/session/session-context-metrics.ts +82 -0
  119. package/src/components/session/session-context-tab.tsx +339 -0
  120. package/src/components/session/session-header.tsx +486 -0
  121. package/src/components/session/session-new-view.tsx +91 -0
  122. package/src/components/session/session-sortable-tab.tsx +70 -0
  123. package/src/components/session/session-sortable-terminal-tab.tsx +193 -0
  124. package/src/components/session-context-usage.tsx +122 -0
  125. package/src/components/settings-general.tsx +585 -0
  126. package/src/components/settings-keybinds.tsx +453 -0
  127. package/src/components/settings-list.tsx +5 -0
  128. package/src/components/settings-models.tsx +137 -0
  129. package/src/components/settings-providers.tsx +251 -0
  130. package/src/components/status-popover.tsx +419 -0
  131. package/src/components/terminal.tsx +653 -0
  132. package/src/components/titlebar-history.test.ts +63 -0
  133. package/src/components/titlebar-history.ts +57 -0
  134. package/src/components/titlebar.tsx +312 -0
  135. package/src/constants/file-picker.ts +89 -0
  136. package/src/context/command-keybind.test.ts +69 -0
  137. package/src/context/command.test.ts +25 -0
  138. package/src/context/command.tsx +437 -0
  139. package/src/context/comments.test.ts +186 -0
  140. package/src/context/comments.tsx +243 -0
  141. package/src/context/file/content-cache.ts +88 -0
  142. package/src/context/file/path.test.ts +360 -0
  143. package/src/context/file/path.ts +151 -0
  144. package/src/context/file/tree-store.ts +170 -0
  145. package/src/context/file/types.ts +41 -0
  146. package/src/context/file/view-cache.ts +146 -0
  147. package/src/context/file/watcher.test.ts +149 -0
  148. package/src/context/file/watcher.ts +53 -0
  149. package/src/context/file-content-eviction-accounting.test.ts +65 -0
  150. package/src/context/file.tsx +280 -0
  151. package/src/context/global-sdk.tsx +232 -0
  152. package/src/context/global-sync/bootstrap.ts +206 -0
  153. package/src/context/global-sync/child-store.test.ts +38 -0
  154. package/src/context/global-sync/child-store.ts +281 -0
  155. package/src/context/global-sync/event-reducer.test.ts +552 -0
  156. package/src/context/global-sync/event-reducer.ts +359 -0
  157. package/src/context/global-sync/eviction.ts +28 -0
  158. package/src/context/global-sync/queue.ts +83 -0
  159. package/src/context/global-sync/session-cache.test.ts +102 -0
  160. package/src/context/global-sync/session-cache.ts +62 -0
  161. package/src/context/global-sync/session-load.ts +25 -0
  162. package/src/context/global-sync/session-prefetch.test.ts +96 -0
  163. package/src/context/global-sync/session-prefetch.ts +100 -0
  164. package/src/context/global-sync/session-trim.test.ts +59 -0
  165. package/src/context/global-sync/session-trim.ts +56 -0
  166. package/src/context/global-sync/types.ts +133 -0
  167. package/src/context/global-sync/utils.ts +25 -0
  168. package/src/context/global-sync.test.ts +122 -0
  169. package/src/context/global-sync.tsx +408 -0
  170. package/src/context/highlights.tsx +233 -0
  171. package/src/context/language.tsx +248 -0
  172. package/src/context/layout-scroll.test.ts +64 -0
  173. package/src/context/layout-scroll.ts +126 -0
  174. package/src/context/layout.test.ts +69 -0
  175. package/src/context/layout.tsx +937 -0
  176. package/src/context/local.tsx +422 -0
  177. package/src/context/model-variant.test.ts +86 -0
  178. package/src/context/model-variant.ts +52 -0
  179. package/src/context/models.tsx +163 -0
  180. package/src/context/notification.tsx +373 -0
  181. package/src/context/permission-auto-respond.test.ts +102 -0
  182. package/src/context/permission-auto-respond.ts +51 -0
  183. package/src/context/permission.tsx +277 -0
  184. package/src/context/platform.tsx +99 -0
  185. package/src/context/prompt.tsx +297 -0
  186. package/src/context/sdk.tsx +49 -0
  187. package/src/context/server.tsx +295 -0
  188. package/src/context/settings.tsx +241 -0
  189. package/src/context/sync-optimistic.test.ts +123 -0
  190. package/src/context/sync.tsx +618 -0
  191. package/src/context/terminal-title.ts +51 -0
  192. package/src/context/terminal.test.ts +82 -0
  193. package/src/context/terminal.tsx +437 -0
  194. package/src/entry.tsx +144 -0
  195. package/src/env.d.ts +18 -0
  196. package/src/hooks/use-providers.ts +44 -0
  197. package/src/i18n/ar.ts +855 -0
  198. package/src/i18n/br.ts +867 -0
  199. package/src/i18n/bs.ts +943 -0
  200. package/src/i18n/da.ts +937 -0
  201. package/src/i18n/de.ts +879 -0
  202. package/src/i18n/en.ts +948 -0
  203. package/src/i18n/es.ts +950 -0
  204. package/src/i18n/fr.ts +878 -0
  205. package/src/i18n/ja.ts +861 -0
  206. package/src/i18n/ko.ts +860 -0
  207. package/src/i18n/no.ts +944 -0
  208. package/src/i18n/parity.test.ts +32 -0
  209. package/src/i18n/pl.ts +865 -0
  210. package/src/i18n/ru.ts +946 -0
  211. package/src/i18n/th.ts +933 -0
  212. package/src/i18n/tr.ts +952 -0
  213. package/src/i18n/zh.ts +930 -0
  214. package/src/i18n/zht.ts +925 -0
  215. package/src/index.css +29 -0
  216. package/src/index.ts +6 -0
  217. package/src/pages/directory-layout.tsx +88 -0
  218. package/src/pages/error.tsx +327 -0
  219. package/src/pages/home.tsx +131 -0
  220. package/src/pages/layout/deep-links.ts +50 -0
  221. package/src/pages/layout/helpers.test.ts +211 -0
  222. package/src/pages/layout/helpers.ts +98 -0
  223. package/src/pages/layout/inline-editor.tsx +126 -0
  224. package/src/pages/layout/sidebar-items.tsx +437 -0
  225. package/src/pages/layout/sidebar-project.tsx +384 -0
  226. package/src/pages/layout/sidebar-shell.tsx +125 -0
  227. package/src/pages/layout/sidebar-workspace.tsx +504 -0
  228. package/src/pages/layout.tsx +2509 -0
  229. package/src/pages/session/composer/index.ts +2 -0
  230. package/src/pages/session/composer/session-composer-region.tsx +255 -0
  231. package/src/pages/session/composer/session-composer-state.test.ts +128 -0
  232. package/src/pages/session/composer/session-composer-state.ts +249 -0
  233. package/src/pages/session/composer/session-followup-dock.tsx +109 -0
  234. package/src/pages/session/composer/session-permission-dock.tsx +74 -0
  235. package/src/pages/session/composer/session-question-dock.tsx +449 -0
  236. package/src/pages/session/composer/session-request-tree.ts +52 -0
  237. package/src/pages/session/composer/session-revert-dock.tsx +99 -0
  238. package/src/pages/session/composer/session-todo-dock.tsx +330 -0
  239. package/src/pages/session/file-tab-scroll.test.ts +40 -0
  240. package/src/pages/session/file-tab-scroll.ts +67 -0
  241. package/src/pages/session/file-tabs.tsx +456 -0
  242. package/src/pages/session/handoff.ts +36 -0
  243. package/src/pages/session/helpers.test.ts +181 -0
  244. package/src/pages/session/helpers.ts +198 -0
  245. package/src/pages/session/message-gesture.test.ts +62 -0
  246. package/src/pages/session/message-gesture.ts +21 -0
  247. package/src/pages/session/message-id-from-hash.ts +6 -0
  248. package/src/pages/session/message-timeline.tsx +1013 -0
  249. package/src/pages/session/review-tab.tsx +170 -0
  250. package/src/pages/session/session-layout.ts +20 -0
  251. package/src/pages/session/session-model-helpers.test.ts +51 -0
  252. package/src/pages/session/session-model-helpers.ts +16 -0
  253. package/src/pages/session/session-side-panel.tsx +453 -0
  254. package/src/pages/session/terminal-label.ts +16 -0
  255. package/src/pages/session/terminal-panel.test.ts +25 -0
  256. package/src/pages/session/terminal-panel.tsx +326 -0
  257. package/src/pages/session/use-session-commands.tsx +495 -0
  258. package/src/pages/session/use-session-hash-scroll.test.ts +16 -0
  259. package/src/pages/session/use-session-hash-scroll.ts +197 -0
  260. package/src/pages/session.tsx +1841 -0
  261. package/src/sst-env.d.ts +12 -0
  262. package/src/testing/model-selection.ts +80 -0
  263. package/src/testing/prompt.ts +56 -0
  264. package/src/testing/session-composer.ts +84 -0
  265. package/src/testing/terminal.ts +118 -0
  266. package/src/theme-preload.test.ts +46 -0
  267. package/src/utils/agent.ts +23 -0
  268. package/src/utils/aim.ts +138 -0
  269. package/src/utils/base64.ts +10 -0
  270. package/src/utils/comment-note.ts +88 -0
  271. package/src/utils/id.ts +99 -0
  272. package/src/utils/notification-click.test.ts +27 -0
  273. package/src/utils/notification-click.ts +13 -0
  274. package/src/utils/persist.test.ts +115 -0
  275. package/src/utils/persist.ts +476 -0
  276. package/src/utils/prompt.test.ts +44 -0
  277. package/src/utils/prompt.ts +203 -0
  278. package/src/utils/runtime-adapters.test.ts +62 -0
  279. package/src/utils/runtime-adapters.ts +39 -0
  280. package/src/utils/same.ts +6 -0
  281. package/src/utils/scoped-cache.test.ts +69 -0
  282. package/src/utils/scoped-cache.ts +104 -0
  283. package/src/utils/server-errors.test.ts +131 -0
  284. package/src/utils/server-errors.ts +80 -0
  285. package/src/utils/server-health.test.ts +123 -0
  286. package/src/utils/server-health.ts +91 -0
  287. package/src/utils/server.ts +22 -0
  288. package/src/utils/solid-dnd.tsx +49 -0
  289. package/src/utils/sound.ts +117 -0
  290. package/src/utils/terminal-writer.test.ts +64 -0
  291. package/src/utils/terminal-writer.ts +65 -0
  292. package/src/utils/time.ts +22 -0
  293. package/src/utils/uuid.test.ts +78 -0
  294. package/src/utils/uuid.ts +12 -0
  295. package/src/utils/worktree.test.ts +46 -0
  296. package/src/utils/worktree.ts +73 -0
  297. package/sst-env.d.ts +10 -0
  298. package/tsconfig.json +26 -0
  299. package/vite.config.ts +15 -0
  300. package/vite.js +26 -0
@@ -0,0 +1,653 @@
1
+ import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@reign-labs/ui/theme"
2
+ import { showToast } from "@reign-labs/ui/toast"
3
+ import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
4
+ import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js"
5
+ import { SerializeAddon } from "@/addons/serialize"
6
+ import { matchKeybind, parseKeybind } from "@/context/command"
7
+ import { useLanguage } from "@/context/language"
8
+ import { usePlatform } from "@/context/platform"
9
+ import { useSDK } from "@/context/sdk"
10
+ import { useServer } from "@/context/server"
11
+ import { monoFontFamily, useSettings } from "@/context/settings"
12
+ import type { LocalPTY } from "@/context/terminal"
13
+ import { terminalAttr, terminalProbe } from "@/testing/terminal"
14
+ import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
15
+ import { terminalWriter } from "@/utils/terminal-writer"
16
+
17
+ const TOGGLE_TERMINAL_ID = "terminal.toggle"
18
+ const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
19
+ export interface TerminalProps extends ComponentProps<"div"> {
20
+ pty: LocalPTY
21
+ autoFocus?: boolean
22
+ onSubmit?: () => void
23
+ onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void
24
+ onConnect?: () => void
25
+ onConnectError?: (error: unknown) => void
26
+ }
27
+
28
+ let shared: Promise<{ mod: typeof import("ghostty-web"); ghostty: Ghostty }> | undefined
29
+
30
+ const loadGhostty = () => {
31
+ if (shared) return shared
32
+ shared = import("ghostty-web")
33
+ .then(async (mod) => ({ mod, ghostty: await mod.Ghostty.load() }))
34
+ .catch((err) => {
35
+ shared = undefined
36
+ throw err
37
+ })
38
+ return shared
39
+ }
40
+
41
+ type TerminalColors = {
42
+ background: string
43
+ foreground: string
44
+ cursor: string
45
+ selectionBackground: string
46
+ }
47
+
48
+ const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = {
49
+ light: {
50
+ background: "#fcfcfc",
51
+ foreground: "#211e1e",
52
+ cursor: "#211e1e",
53
+ selectionBackground: withAlpha("#211e1e", 0.2),
54
+ },
55
+ dark: {
56
+ background: "#191515",
57
+ foreground: "#d4d4d4",
58
+ cursor: "#d4d4d4",
59
+ selectionBackground: withAlpha("#d4d4d4", 0.25),
60
+ },
61
+ }
62
+
63
+ const debugTerminal = (...values: unknown[]) => {
64
+ if (!import.meta.env.DEV) return
65
+ console.debug("[terminal]", ...values)
66
+ }
67
+
68
+ const errorName = (err: unknown) => {
69
+ if (!err || typeof err !== "object") return
70
+ if (!("name" in err)) return
71
+ const errorName = err.name
72
+ return typeof errorName === "string" ? errorName : undefined
73
+ }
74
+
75
+ const useTerminalUiBindings = (input: {
76
+ container: HTMLDivElement
77
+ term: Term
78
+ cleanups: VoidFunction[]
79
+ handlePointerDown: () => void
80
+ handleLinkClick: (event: MouseEvent) => void
81
+ }) => {
82
+ const handleCopy = (event: ClipboardEvent) => {
83
+ const selection = input.term.getSelection()
84
+ if (!selection) return
85
+
86
+ const clipboard = event.clipboardData
87
+ if (!clipboard) return
88
+
89
+ event.preventDefault()
90
+ clipboard.setData("text/plain", selection)
91
+ }
92
+
93
+ const handlePaste = (event: ClipboardEvent) => {
94
+ const clipboard = event.clipboardData
95
+ const text = clipboard?.getData("text/plain") ?? clipboard?.getData("text") ?? ""
96
+ if (!text) return
97
+
98
+ event.preventDefault()
99
+ event.stopPropagation()
100
+ input.term.paste(text)
101
+ }
102
+
103
+ const handleTextareaFocus = () => {
104
+ input.term.options.cursorBlink = true
105
+ }
106
+ const handleTextareaBlur = () => {
107
+ input.term.options.cursorBlink = false
108
+ }
109
+
110
+ input.container.addEventListener("copy", handleCopy, true)
111
+ input.cleanups.push(() => input.container.removeEventListener("copy", handleCopy, true))
112
+
113
+ input.container.addEventListener("paste", handlePaste, true)
114
+ input.cleanups.push(() => input.container.removeEventListener("paste", handlePaste, true))
115
+
116
+ input.container.addEventListener("pointerdown", input.handlePointerDown)
117
+ input.cleanups.push(() => input.container.removeEventListener("pointerdown", input.handlePointerDown))
118
+
119
+ input.container.addEventListener("click", input.handleLinkClick, {
120
+ capture: true,
121
+ })
122
+ input.cleanups.push(() =>
123
+ input.container.removeEventListener("click", input.handleLinkClick, {
124
+ capture: true,
125
+ }),
126
+ )
127
+
128
+ input.term.textarea?.addEventListener("focus", handleTextareaFocus)
129
+ input.term.textarea?.addEventListener("blur", handleTextareaBlur)
130
+ input.cleanups.push(() => input.term.textarea?.removeEventListener("focus", handleTextareaFocus))
131
+ input.cleanups.push(() => input.term.textarea?.removeEventListener("blur", handleTextareaBlur))
132
+ }
133
+
134
+ const persistTerminal = (input: {
135
+ term: Term | undefined
136
+ addon: SerializeAddon | undefined
137
+ cursor: number
138
+ id: string
139
+ onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void
140
+ }) => {
141
+ if (!input.addon || !input.onCleanup || !input.term) return
142
+ const buffer = (() => {
143
+ try {
144
+ return input.addon.serialize()
145
+ } catch {
146
+ debugTerminal("failed to serialize terminal buffer")
147
+ return ""
148
+ }
149
+ })()
150
+
151
+ input.onCleanup({
152
+ id: input.id,
153
+ buffer,
154
+ cursor: input.cursor,
155
+ rows: input.term.rows,
156
+ cols: input.term.cols,
157
+ scrollY: input.term.getViewportY(),
158
+ })
159
+ }
160
+
161
+ export const Terminal = (props: TerminalProps) => {
162
+ const platform = usePlatform()
163
+ const sdk = useSDK()
164
+ const settings = useSettings()
165
+ const theme = useTheme()
166
+ const language = useLanguage()
167
+ const server = useServer()
168
+ const directory = sdk.directory
169
+ const client = sdk.client
170
+ const url = sdk.url
171
+ const auth = server.current?.http
172
+ const username = auth?.username ?? "opencode"
173
+ const password = auth?.password ?? ""
174
+ let container!: HTMLDivElement
175
+ const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
176
+ const id = local.pty.id
177
+ const probe = terminalProbe(id)
178
+ const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
179
+ const restoreSize =
180
+ restore &&
181
+ typeof local.pty.cols === "number" &&
182
+ Number.isSafeInteger(local.pty.cols) &&
183
+ local.pty.cols > 0 &&
184
+ typeof local.pty.rows === "number" &&
185
+ Number.isSafeInteger(local.pty.rows) &&
186
+ local.pty.rows > 0
187
+ ? { cols: local.pty.cols, rows: local.pty.rows }
188
+ : undefined
189
+ const scrollY = typeof local.pty.scrollY === "number" ? local.pty.scrollY : undefined
190
+ let ws: WebSocket | undefined
191
+ let term: Term | undefined
192
+ let ghostty: Ghostty
193
+ let serializeAddon: SerializeAddon
194
+ let fitAddon: FitAddon
195
+ let handleResize: () => void
196
+ let fitFrame: number | undefined
197
+ let sizeTimer: ReturnType<typeof setTimeout> | undefined
198
+ let pendingSize: { cols: number; rows: number } | undefined
199
+ let lastSize: { cols: number; rows: number } | undefined
200
+ let disposed = false
201
+ const cleanups: VoidFunction[] = []
202
+ const start =
203
+ typeof local.pty.cursor === "number" && Number.isSafeInteger(local.pty.cursor) ? local.pty.cursor : undefined
204
+ let cursor = start ?? 0
205
+ let seek = start !== undefined ? start : restore ? -1 : 0
206
+ let output: ReturnType<typeof terminalWriter> | undefined
207
+ let drop: VoidFunction | undefined
208
+ let reconn: ReturnType<typeof setTimeout> | undefined
209
+ let tries = 0
210
+
211
+ const cleanup = () => {
212
+ if (!cleanups.length) return
213
+ const fns = cleanups.splice(0).reverse()
214
+ for (const fn of fns) {
215
+ try {
216
+ fn()
217
+ } catch (err) {
218
+ debugTerminal("cleanup failed", err)
219
+ }
220
+ }
221
+ }
222
+
223
+ const pushSize = (cols: number, rows: number) => {
224
+ return client.pty
225
+ .update({
226
+ ptyID: id,
227
+ size: { cols, rows },
228
+ })
229
+ .catch((err) => {
230
+ debugTerminal("failed to sync terminal size", err)
231
+ })
232
+ }
233
+
234
+ const getTerminalColors = (): TerminalColors => {
235
+ const mode = theme.mode() === "dark" ? "dark" : "light"
236
+ const fallback = DEFAULT_TERMINAL_COLORS[mode]
237
+ const currentTheme = theme.themes()[theme.themeId()]
238
+ if (!currentTheme) return fallback
239
+ const variant = mode === "dark" ? currentTheme.dark : currentTheme.light
240
+ if (!variant?.seeds && !variant?.palette) return fallback
241
+ const resolved = resolveThemeVariant(variant, mode === "dark")
242
+ const text = resolved["text-stronger"] ?? fallback.foreground
243
+ const background = resolved["background-stronger"] ?? fallback.background
244
+ const alpha = mode === "dark" ? 0.25 : 0.2
245
+ const base = text.startsWith("#") ? (text as HexColor) : (fallback.foreground as HexColor)
246
+ const selectionBackground = withAlpha(base, alpha)
247
+ return {
248
+ background,
249
+ foreground: text,
250
+ cursor: text,
251
+ selectionBackground,
252
+ }
253
+ }
254
+
255
+ const terminalColors = createMemo(getTerminalColors)
256
+
257
+ const scheduleFit = () => {
258
+ if (disposed) return
259
+ if (!fitAddon) return
260
+ if (fitFrame !== undefined) return
261
+
262
+ fitFrame = requestAnimationFrame(() => {
263
+ fitFrame = undefined
264
+ if (disposed) return
265
+ fitAddon.fit()
266
+ })
267
+ }
268
+
269
+ const scheduleSize = (cols: number, rows: number) => {
270
+ if (disposed) return
271
+ if (lastSize?.cols === cols && lastSize?.rows === rows) return
272
+
273
+ pendingSize = { cols, rows }
274
+
275
+ if (!lastSize) {
276
+ lastSize = pendingSize
277
+ void pushSize(cols, rows)
278
+ return
279
+ }
280
+
281
+ if (sizeTimer !== undefined) return
282
+ sizeTimer = setTimeout(() => {
283
+ sizeTimer = undefined
284
+ const next = pendingSize
285
+ if (!next) return
286
+ pendingSize = undefined
287
+ if (disposed) return
288
+ if (lastSize?.cols === next.cols && lastSize?.rows === next.rows) return
289
+ lastSize = next
290
+ void pushSize(next.cols, next.rows)
291
+ }, 100)
292
+ }
293
+
294
+ createEffect(() => {
295
+ const colors = terminalColors()
296
+ if (!term) return
297
+ setOptionIfSupported(term, "theme", colors)
298
+ })
299
+
300
+ createEffect(() => {
301
+ const font = monoFontFamily(settings.appearance.font())
302
+ if (!term) return
303
+ setOptionIfSupported(term, "fontFamily", font)
304
+ scheduleFit()
305
+ })
306
+
307
+ let zoom = platform.webviewZoom?.()
308
+ createEffect(() => {
309
+ const next = platform.webviewZoom?.()
310
+ if (next === undefined) return
311
+ if (next === zoom) return
312
+ zoom = next
313
+ scheduleFit()
314
+ })
315
+
316
+ const focusTerminal = () => {
317
+ const t = term
318
+ if (!t) return
319
+ t.focus()
320
+ t.textarea?.focus()
321
+ setTimeout(() => t.textarea?.focus(), 0)
322
+ }
323
+ const handlePointerDown = () => {
324
+ const activeElement = document.activeElement
325
+ if (activeElement instanceof HTMLElement && activeElement !== container && !container.contains(activeElement)) {
326
+ activeElement.blur()
327
+ }
328
+ focusTerminal()
329
+ }
330
+
331
+ const handleLinkClick = (event: MouseEvent) => {
332
+ if (!event.shiftKey && !event.ctrlKey && !event.metaKey) return
333
+ if (event.altKey) return
334
+ if (event.button !== 0) return
335
+
336
+ const t = term
337
+ if (!t) return
338
+
339
+ const text = getHoveredLinkText(t)
340
+ if (!text) return
341
+
342
+ event.preventDefault()
343
+ event.stopImmediatePropagation()
344
+ platform.openLink(text)
345
+ }
346
+
347
+ onMount(() => {
348
+ probe.init()
349
+ cleanups.push(() => probe.drop())
350
+
351
+ const run = async () => {
352
+ const loaded = await loadGhostty()
353
+ if (disposed) return
354
+
355
+ const mod = loaded.mod
356
+ const g = loaded.ghostty
357
+
358
+ const t = new mod.Terminal({
359
+ cursorBlink: true,
360
+ cursorStyle: "bar",
361
+ cols: restoreSize?.cols,
362
+ rows: restoreSize?.rows,
363
+ fontSize: 14,
364
+ fontFamily: monoFontFamily(settings.appearance.font()),
365
+ allowTransparency: false,
366
+ convertEol: false,
367
+ theme: terminalColors(),
368
+ scrollback: 10_000,
369
+ ghostty: g,
370
+ })
371
+ cleanups.push(() => t.dispose())
372
+ if (disposed) {
373
+ cleanup()
374
+ return
375
+ }
376
+ ghostty = g
377
+ term = t
378
+ output = terminalWriter((data, done) =>
379
+ t.write(data, () => {
380
+ probe.render(data)
381
+ probe.settle()
382
+ done?.()
383
+ }),
384
+ )
385
+
386
+ t.attachCustomKeyEventHandler((event) => {
387
+ const key = event.key.toLowerCase()
388
+
389
+ if (event.ctrlKey && event.shiftKey && !event.metaKey && key === "c") {
390
+ document.execCommand("copy")
391
+ return true
392
+ }
393
+
394
+ // allow for toggle terminal keybinds in parent
395
+ const config = settings.keybinds.get(TOGGLE_TERMINAL_ID) ?? DEFAULT_TOGGLE_TERMINAL_KEYBIND
396
+ const keybinds = parseKeybind(config)
397
+
398
+ return matchKeybind(keybinds, event)
399
+ })
400
+
401
+ const fit = new mod.FitAddon()
402
+ const serializer = new SerializeAddon()
403
+ cleanups.push(() => disposeIfDisposable(fit))
404
+ t.loadAddon(serializer)
405
+ t.loadAddon(fit)
406
+ fitAddon = fit
407
+ serializeAddon = serializer
408
+
409
+ t.open(container)
410
+ useTerminalUiBindings({
411
+ container,
412
+ term: t,
413
+ cleanups,
414
+ handlePointerDown,
415
+ handleLinkClick,
416
+ })
417
+
418
+ if (local.autoFocus !== false) focusTerminal()
419
+
420
+ if (typeof document !== "undefined" && document.fonts) {
421
+ document.fonts.ready.then(scheduleFit)
422
+ }
423
+
424
+ const onResize = t.onResize((size) => {
425
+ scheduleSize(size.cols, size.rows)
426
+ })
427
+ cleanups.push(() => disposeIfDisposable(onResize))
428
+ const onData = t.onData((data) => {
429
+ if (ws?.readyState === WebSocket.OPEN) ws.send(data)
430
+ })
431
+ cleanups.push(() => disposeIfDisposable(onData))
432
+ const onKey = t.onKey((key) => {
433
+ if (key.key == "Enter") {
434
+ props.onSubmit?.()
435
+ }
436
+ })
437
+ cleanups.push(() => disposeIfDisposable(onKey))
438
+
439
+ const startResize = () => {
440
+ fit.observeResize()
441
+ handleResize = scheduleFit
442
+ window.addEventListener("resize", handleResize)
443
+ cleanups.push(() => window.removeEventListener("resize", handleResize))
444
+ }
445
+
446
+ const write = (data: string) =>
447
+ new Promise<void>((resolve) => {
448
+ if (!output) {
449
+ resolve()
450
+ return
451
+ }
452
+ output.push(data)
453
+ output.flush(resolve)
454
+ })
455
+
456
+ if (restore && restoreSize) {
457
+ await write(restore)
458
+ fit.fit()
459
+ scheduleSize(t.cols, t.rows)
460
+ if (scrollY !== undefined) t.scrollToLine(scrollY)
461
+ startResize()
462
+ } else {
463
+ fit.fit()
464
+ scheduleSize(t.cols, t.rows)
465
+ if (restore) {
466
+ await write(restore)
467
+ if (scrollY !== undefined) t.scrollToLine(scrollY)
468
+ }
469
+ startResize()
470
+ }
471
+
472
+ const once = { value: false }
473
+ const decoder = new TextDecoder()
474
+
475
+ const fail = (err: unknown) => {
476
+ if (disposed) return
477
+ if (once.value) return
478
+ once.value = true
479
+ local.onConnectError?.(err)
480
+ }
481
+
482
+ const gone = () =>
483
+ client.pty
484
+ .get({ ptyID: id })
485
+ .then(() => false)
486
+ .catch((err) => {
487
+ if (errorName(err) === "NotFoundError") return true
488
+ debugTerminal("failed to inspect terminal session", err)
489
+ return false
490
+ })
491
+
492
+ const retry = (err: unknown) => {
493
+ if (disposed) return
494
+ if (reconn !== undefined) return
495
+
496
+ const ms = Math.min(250 * 2 ** Math.min(tries, 4), 4_000)
497
+ reconn = setTimeout(async () => {
498
+ reconn = undefined
499
+ if (disposed) return
500
+ if (await gone()) {
501
+ if (disposed) return
502
+ fail(err)
503
+ return
504
+ }
505
+ if (disposed) return
506
+ tries += 1
507
+ open()
508
+ }, ms)
509
+ }
510
+
511
+ const open = () => {
512
+ if (disposed) return
513
+ drop?.()
514
+
515
+ const next = new URL(url + `/pty/${id}/connect`)
516
+ next.searchParams.set("directory", directory)
517
+ next.searchParams.set("cursor", String(seek))
518
+ next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
519
+ next.username = username
520
+ next.password = password
521
+
522
+ const socket = new WebSocket(next)
523
+ socket.binaryType = "arraybuffer"
524
+ ws = socket
525
+
526
+ const handleOpen = () => {
527
+ if (disposed) return
528
+ tries = 0
529
+ probe.connect()
530
+ local.onConnect?.()
531
+ scheduleSize(t.cols, t.rows)
532
+ }
533
+
534
+ const handleMessage = (event: MessageEvent) => {
535
+ if (disposed) return
536
+ if (event.data instanceof ArrayBuffer) {
537
+ const bytes = new Uint8Array(event.data)
538
+ if (bytes[0] !== 0) return
539
+ const json = decoder.decode(bytes.subarray(1))
540
+ try {
541
+ const meta = JSON.parse(json) as { cursor?: unknown }
542
+ const next = meta?.cursor
543
+ if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) {
544
+ cursor = next
545
+ seek = next
546
+ }
547
+ } catch (err) {
548
+ debugTerminal("invalid websocket control frame", err)
549
+ }
550
+ return
551
+ }
552
+
553
+ const data = typeof event.data === "string" ? event.data : ""
554
+ if (!data) return
555
+ output?.push(data)
556
+ cursor += data.length
557
+ seek = cursor
558
+ }
559
+
560
+ const handleError = (error: Event) => {
561
+ if (disposed) return
562
+ debugTerminal("websocket error", error)
563
+ }
564
+
565
+ const stop = () => {
566
+ socket.removeEventListener("open", handleOpen)
567
+ socket.removeEventListener("message", handleMessage)
568
+ socket.removeEventListener("error", handleError)
569
+ socket.removeEventListener("close", handleClose)
570
+ if (ws === socket) ws = undefined
571
+ if (drop === stop) drop = undefined
572
+ if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close(1000)
573
+ }
574
+
575
+ const handleClose = (event: CloseEvent) => {
576
+ if (ws === socket) ws = undefined
577
+ if (drop === stop) drop = undefined
578
+ socket.removeEventListener("open", handleOpen)
579
+ socket.removeEventListener("message", handleMessage)
580
+ socket.removeEventListener("error", handleError)
581
+ socket.removeEventListener("close", handleClose)
582
+ if (disposed) return
583
+ if (event.code === 1000) return
584
+ retry(new Error(language.t("terminal.connectionLost.abnormalClose", { code: event.code })))
585
+ }
586
+
587
+ drop = stop
588
+ socket.addEventListener("open", handleOpen)
589
+ socket.addEventListener("message", handleMessage)
590
+ socket.addEventListener("error", handleError)
591
+ socket.addEventListener("close", handleClose)
592
+ }
593
+
594
+ probe.control({
595
+ disconnect: () => {
596
+ if (!ws) return
597
+ ws.close(4_000, "e2e")
598
+ },
599
+ })
600
+
601
+ open()
602
+ }
603
+
604
+ void run().catch((err) => {
605
+ if (disposed) return
606
+ showToast({
607
+ variant: "error",
608
+ title: language.t("terminal.connectionLost.title"),
609
+ description: err instanceof Error ? err.message : language.t("terminal.connectionLost.description"),
610
+ })
611
+ local.onConnectError?.(err)
612
+ })
613
+ })
614
+
615
+ onCleanup(() => {
616
+ disposed = true
617
+ if (fitFrame !== undefined) cancelAnimationFrame(fitFrame)
618
+ if (sizeTimer !== undefined) clearTimeout(sizeTimer)
619
+ if (reconn !== undefined) clearTimeout(reconn)
620
+ drop?.()
621
+ if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close(1000)
622
+
623
+ const finalize = () => {
624
+ persistTerminal({ term, addon: serializeAddon, cursor, id, onCleanup: props.onCleanup })
625
+ cleanup()
626
+ }
627
+
628
+ if (!output) {
629
+ finalize()
630
+ return
631
+ }
632
+
633
+ output.flush(finalize)
634
+ })
635
+
636
+ return (
637
+ <div
638
+ ref={container}
639
+ data-component="terminal"
640
+ {...{ [terminalAttr]: id }}
641
+ data-prevent-autofocus
642
+ tabIndex={-1}
643
+ style={{ "background-color": terminalColors().background }}
644
+ classList={{
645
+ ...(local.classList ?? {}),
646
+ "select-text": true,
647
+ "size-full px-6 py-3 font-mono relative overflow-hidden": true,
648
+ [local.class ?? ""]: !!local.class,
649
+ }}
650
+ {...others}
651
+ />
652
+ )
653
+ }
@@ -0,0 +1,63 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { applyPath, backPath, forwardPath, type TitlebarHistory } from "./titlebar-history"
3
+
4
+ function history(): TitlebarHistory {
5
+ return { stack: [], index: 0, action: undefined }
6
+ }
7
+
8
+ describe("titlebar history", () => {
9
+ test("append and trim keeps max bounded", () => {
10
+ let state = history()
11
+ state = applyPath(state, "/", 3)
12
+ state = applyPath(state, "/a", 3)
13
+ state = applyPath(state, "/b", 3)
14
+ state = applyPath(state, "/c", 3)
15
+
16
+ expect(state.stack).toEqual(["/a", "/b", "/c"])
17
+ expect(state.stack.length).toBe(3)
18
+ expect(state.index).toBe(2)
19
+ })
20
+
21
+ test("back and forward indexes stay correct after trimming", () => {
22
+ let state = history()
23
+ state = applyPath(state, "/", 3)
24
+ state = applyPath(state, "/a", 3)
25
+ state = applyPath(state, "/b", 3)
26
+ state = applyPath(state, "/c", 3)
27
+
28
+ expect(state.stack).toEqual(["/a", "/b", "/c"])
29
+ expect(state.index).toBe(2)
30
+
31
+ const back = backPath(state)
32
+ expect(back?.to).toBe("/b")
33
+ expect(back?.state.index).toBe(1)
34
+
35
+ const afterBack = applyPath(back!.state, back!.to, 3)
36
+ expect(afterBack.stack).toEqual(["/a", "/b", "/c"])
37
+ expect(afterBack.index).toBe(1)
38
+
39
+ const forward = forwardPath(afterBack)
40
+ expect(forward?.to).toBe("/c")
41
+ expect(forward?.state.index).toBe(2)
42
+
43
+ const afterForward = applyPath(forward!.state, forward!.to, 3)
44
+ expect(afterForward.stack).toEqual(["/a", "/b", "/c"])
45
+ expect(afterForward.index).toBe(2)
46
+ })
47
+
48
+ test("action-driven navigation does not push duplicate history entries", () => {
49
+ const state: TitlebarHistory = {
50
+ stack: ["/", "/a", "/b"],
51
+ index: 2,
52
+ action: undefined,
53
+ }
54
+
55
+ const back = backPath(state)
56
+ expect(back?.to).toBe("/a")
57
+
58
+ const next = applyPath(back!.state, back!.to, 10)
59
+ expect(next.stack).toEqual(["/", "/a", "/b"])
60
+ expect(next.index).toBe(1)
61
+ expect(next.action).toBeUndefined()
62
+ })
63
+ })