terminal.tsx 4.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. import { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
  2. import { ComponentProps, 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 { usePrefersDark } from "@solid-primitives/media"
  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. export const Terminal = (props: TerminalProps) => {
  14. const sdk = useSDK()
  15. let container!: HTMLDivElement
  16. const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnectError"])
  17. let ws: WebSocket
  18. let term: Term
  19. let ghostty: Ghostty
  20. let serializeAddon: SerializeAddon
  21. let fitAddon: FitAddon
  22. let handleResize: () => void
  23. const prefersDark = usePrefersDark()
  24. onMount(async () => {
  25. ghostty = await Ghostty.load()
  26. ws = new WebSocket(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
  27. term = new Term({
  28. cursorBlink: true,
  29. fontSize: 14,
  30. fontFamily: "IBM Plex Mono, monospace",
  31. allowTransparency: true,
  32. theme: prefersDark()
  33. ? {
  34. background: "#191515",
  35. foreground: "#d4d4d4",
  36. cursor: "#d4d4d4",
  37. }
  38. : {
  39. background: "#fcfcfc",
  40. foreground: "#211e1e",
  41. cursor: "#211e1e",
  42. },
  43. scrollback: 10_000,
  44. ghostty,
  45. })
  46. term.attachCustomKeyEventHandler((event) => {
  47. // allow for ctrl-` to toggle terminal in parent
  48. if (event.ctrlKey && event.key.toLowerCase() === "`") {
  49. event.preventDefault()
  50. return true
  51. }
  52. return false
  53. })
  54. fitAddon = new FitAddon()
  55. serializeAddon = new SerializeAddon()
  56. term.loadAddon(serializeAddon)
  57. term.loadAddon(fitAddon)
  58. term.open(container)
  59. if (local.pty.buffer) {
  60. if (local.pty.rows && local.pty.cols) {
  61. term.resize(local.pty.cols, local.pty.rows)
  62. }
  63. term.reset()
  64. term.write(local.pty.buffer)
  65. if (local.pty.scrollY) {
  66. term.scrollToLine(local.pty.scrollY)
  67. }
  68. fitAddon.fit()
  69. }
  70. container.focus()
  71. fitAddon.observeResize()
  72. handleResize = () => fitAddon.fit()
  73. window.addEventListener("resize", handleResize)
  74. term.onResize(async (size) => {
  75. if (ws && ws.readyState === WebSocket.OPEN) {
  76. await sdk.client.pty.update({
  77. ptyID: local.pty.id,
  78. size: {
  79. cols: size.cols,
  80. rows: size.rows,
  81. },
  82. })
  83. }
  84. })
  85. term.onData((data) => {
  86. if (ws && ws.readyState === WebSocket.OPEN) {
  87. ws.send(data)
  88. }
  89. })
  90. term.onKey((key) => {
  91. if (key.key == "Enter") {
  92. props.onSubmit?.()
  93. }
  94. })
  95. // term.onScroll((ydisp) => {
  96. // console.log("Scroll position:", ydisp)
  97. // })
  98. ws.addEventListener("open", () => {
  99. console.log("WebSocket connected")
  100. sdk.client.pty.update({
  101. ptyID: local.pty.id,
  102. size: {
  103. cols: term.cols,
  104. rows: term.rows,
  105. },
  106. })
  107. })
  108. ws.addEventListener("message", (event) => {
  109. term.write(event.data)
  110. })
  111. ws.addEventListener("error", (error) => {
  112. console.error("WebSocket error:", error)
  113. props.onConnectError?.(error)
  114. })
  115. ws.addEventListener("close", () => {
  116. console.log("WebSocket disconnected")
  117. })
  118. })
  119. onCleanup(() => {
  120. if (handleResize) {
  121. window.removeEventListener("resize", handleResize)
  122. }
  123. if (serializeAddon && props.onCleanup) {
  124. const buffer = serializeAddon.serialize()
  125. props.onCleanup({
  126. ...local.pty,
  127. buffer,
  128. rows: term.rows,
  129. cols: term.cols,
  130. scrollY: term.getViewportY(),
  131. })
  132. }
  133. ws?.close()
  134. term?.dispose()
  135. })
  136. return (
  137. <div
  138. ref={container}
  139. data-component="terminal"
  140. data-prevent-autofocus
  141. classList={{
  142. ...(local.classList ?? {}),
  143. "size-full px-6 py-3 font-mono": true,
  144. [local.class ?? ""]: !!local.class,
  145. }}
  146. {...others}
  147. />
  148. )
  149. }