terminal.tsx 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. import { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
  2. import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
  3. import { useSDK } from "@/context/sdk"
  4. import { SerializeAddon } from "@/addons/serialize"
  5. import { LocalPTY } from "@/context/terminal"
  6. import { resolveThemeVariant, useTheme } from "@opencode-ai/ui/theme"
  7. export interface TerminalProps extends ComponentProps<"div"> {
  8. pty: LocalPTY
  9. onSubmit?: () => void
  10. onCleanup?: (pty: LocalPTY) => void
  11. onConnectError?: (error: unknown) => void
  12. }
  13. type TerminalColors = {
  14. background: string
  15. foreground: string
  16. cursor: string
  17. }
  18. const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = {
  19. light: {
  20. background: "#fcfcfc",
  21. foreground: "#211e1e",
  22. cursor: "#211e1e",
  23. },
  24. dark: {
  25. background: "#191515",
  26. foreground: "#d4d4d4",
  27. cursor: "#d4d4d4",
  28. },
  29. }
  30. export const Terminal = (props: TerminalProps) => {
  31. const sdk = useSDK()
  32. const theme = useTheme()
  33. let container!: HTMLDivElement
  34. const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnectError"])
  35. let ws: WebSocket
  36. let term: Term
  37. let ghostty: Ghostty
  38. let serializeAddon: SerializeAddon
  39. let fitAddon: FitAddon
  40. let handleResize: () => void
  41. const getTerminalColors = (): TerminalColors => {
  42. const mode = theme.mode()
  43. const fallback = DEFAULT_TERMINAL_COLORS[mode]
  44. const currentTheme = theme.themes()[theme.themeId()]
  45. if (!currentTheme) return fallback
  46. const variant = mode === "dark" ? currentTheme.dark : currentTheme.light
  47. if (!variant?.seeds) return fallback
  48. const resolved = resolveThemeVariant(variant, mode === "dark")
  49. const text = resolved["text-base"] ?? fallback.foreground
  50. const background = resolved["background-stronger"] ?? fallback.background
  51. return {
  52. background,
  53. foreground: text,
  54. cursor: text,
  55. }
  56. }
  57. const [terminalColors, setTerminalColors] = createSignal<TerminalColors>(getTerminalColors())
  58. createEffect(() => {
  59. const colors = getTerminalColors()
  60. setTerminalColors(colors)
  61. if (!term) return
  62. const setOption = (term as unknown as { setOption?: (key: string, value: TerminalColors) => void }).setOption
  63. if (!setOption) return
  64. setOption("theme", colors)
  65. })
  66. const focusTerminal = () => term?.focus()
  67. const copySelection = () => {
  68. if (!term || !term.hasSelection()) return false
  69. const selection = term.getSelection()
  70. if (!selection) return false
  71. const clipboard = navigator.clipboard
  72. if (clipboard?.writeText) {
  73. clipboard.writeText(selection).catch(() => {})
  74. return true
  75. }
  76. if (!document.body) return false
  77. const textarea = document.createElement("textarea")
  78. textarea.value = selection
  79. textarea.setAttribute("readonly", "")
  80. textarea.style.position = "fixed"
  81. textarea.style.opacity = "0"
  82. document.body.appendChild(textarea)
  83. textarea.select()
  84. const copied = document.execCommand("copy")
  85. document.body.removeChild(textarea)
  86. return copied
  87. }
  88. const handlePointerDown = () => {
  89. const activeElement = document.activeElement
  90. if (activeElement instanceof HTMLElement && activeElement !== container) {
  91. activeElement.blur()
  92. }
  93. focusTerminal()
  94. }
  95. onMount(async () => {
  96. ghostty = await Ghostty.load()
  97. ws = new WebSocket(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
  98. term = new Term({
  99. cursorBlink: true,
  100. fontSize: 14,
  101. fontFamily: "IBM Plex Mono, monospace",
  102. allowTransparency: true,
  103. theme: terminalColors(),
  104. scrollback: 10_000,
  105. ghostty,
  106. })
  107. term.attachCustomKeyEventHandler((event) => {
  108. const key = event.key.toLowerCase()
  109. if (key === "c") {
  110. const macCopy = event.metaKey && !event.ctrlKey && !event.altKey
  111. const linuxCopy = event.ctrlKey && event.shiftKey && !event.metaKey
  112. if ((macCopy || linuxCopy) && copySelection()) {
  113. event.preventDefault()
  114. return true
  115. }
  116. }
  117. // allow for ctrl-` to toggle terminal in parent
  118. if (event.ctrlKey && key === "`") {
  119. event.preventDefault()
  120. return true
  121. }
  122. return false
  123. })
  124. fitAddon = new FitAddon()
  125. serializeAddon = new SerializeAddon()
  126. term.loadAddon(serializeAddon)
  127. term.loadAddon(fitAddon)
  128. term.open(container)
  129. container.addEventListener("pointerdown", handlePointerDown)
  130. focusTerminal()
  131. if (local.pty.buffer) {
  132. if (local.pty.rows && local.pty.cols) {
  133. term.resize(local.pty.cols, local.pty.rows)
  134. }
  135. term.reset()
  136. term.write(local.pty.buffer)
  137. if (local.pty.scrollY) {
  138. term.scrollToLine(local.pty.scrollY)
  139. }
  140. fitAddon.fit()
  141. }
  142. fitAddon.observeResize()
  143. handleResize = () => fitAddon.fit()
  144. window.addEventListener("resize", handleResize)
  145. term.onResize(async (size) => {
  146. if (ws && ws.readyState === WebSocket.OPEN) {
  147. await sdk.client.pty
  148. .update({
  149. ptyID: local.pty.id,
  150. size: {
  151. cols: size.cols,
  152. rows: size.rows,
  153. },
  154. })
  155. .catch(() => {})
  156. }
  157. })
  158. term.onData((data) => {
  159. if (ws && ws.readyState === WebSocket.OPEN) {
  160. ws.send(data)
  161. }
  162. })
  163. term.onKey((key) => {
  164. if (key.key == "Enter") {
  165. props.onSubmit?.()
  166. }
  167. })
  168. // term.onScroll((ydisp) => {
  169. // console.log("Scroll position:", ydisp)
  170. // })
  171. ws.addEventListener("open", () => {
  172. console.log("WebSocket connected")
  173. sdk.client.pty
  174. .update({
  175. ptyID: local.pty.id,
  176. size: {
  177. cols: term.cols,
  178. rows: term.rows,
  179. },
  180. })
  181. .catch(() => {})
  182. })
  183. ws.addEventListener("message", (event) => {
  184. term.write(event.data)
  185. })
  186. ws.addEventListener("error", (error) => {
  187. console.error("WebSocket error:", error)
  188. props.onConnectError?.(error)
  189. })
  190. ws.addEventListener("close", () => {
  191. console.log("WebSocket disconnected")
  192. })
  193. })
  194. onCleanup(() => {
  195. if (handleResize) {
  196. window.removeEventListener("resize", handleResize)
  197. }
  198. container.removeEventListener("pointerdown", handlePointerDown)
  199. if (serializeAddon && props.onCleanup) {
  200. const buffer = serializeAddon.serialize()
  201. props.onCleanup({
  202. ...local.pty,
  203. buffer,
  204. rows: term.rows,
  205. cols: term.cols,
  206. scrollY: term.getViewportY(),
  207. })
  208. }
  209. ws?.close()
  210. term?.dispose()
  211. })
  212. return (
  213. <div
  214. ref={container}
  215. data-component="terminal"
  216. data-prevent-autofocus
  217. style={{ "background-color": terminalColors().background }}
  218. classList={{
  219. ...(local.classList ?? {}),
  220. "size-full px-6 py-3 font-mono": true,
  221. [local.class ?? ""]: !!local.class,
  222. }}
  223. {...others}
  224. />
  225. )
  226. }