clipboard.ts 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  1. import { $ } from "bun"
  2. import type { CliRenderer } from "@opentui/core"
  3. import { platform, release } from "os"
  4. import clipboardy from "clipboardy"
  5. import { lazy } from "../../../../util/lazy.js"
  6. import { tmpdir } from "os"
  7. import path from "path"
  8. const rendererRef = { current: undefined as CliRenderer | undefined }
  9. export namespace Clipboard {
  10. export interface Content {
  11. data: string
  12. mime: string
  13. }
  14. export function setRenderer(renderer: CliRenderer | undefined): void {
  15. rendererRef.current = renderer
  16. }
  17. export async function read(): Promise<Content | undefined> {
  18. const os = platform()
  19. if (os === "darwin") {
  20. const tmpfile = path.join(tmpdir(), "opencode-clipboard.png")
  21. try {
  22. await $`osascript -e 'set imageData to the clipboard as "PNGf"' -e 'set fileRef to open for access POSIX file "${tmpfile}" with write permission' -e 'set eof fileRef to 0' -e 'write imageData to fileRef' -e 'close access fileRef'`
  23. .nothrow()
  24. .quiet()
  25. const file = Bun.file(tmpfile)
  26. const buffer = await file.arrayBuffer()
  27. return { data: Buffer.from(buffer).toString("base64"), mime: "image/png" }
  28. } catch {
  29. } finally {
  30. await $`rm -f "${tmpfile}"`.nothrow().quiet()
  31. }
  32. }
  33. if (os === "win32" || release().includes("WSL")) {
  34. const script =
  35. "Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"
  36. const base64 = await $`powershell.exe -NonInteractive -NoProfile -command "${script}"`.nothrow().text()
  37. if (base64) {
  38. const imageBuffer = Buffer.from(base64.trim(), "base64")
  39. if (imageBuffer.length > 0) {
  40. return { data: imageBuffer.toString("base64"), mime: "image/png" }
  41. }
  42. }
  43. }
  44. if (os === "linux") {
  45. const wayland = await $`wl-paste -t image/png`.nothrow().arrayBuffer()
  46. if (wayland && wayland.byteLength > 0) {
  47. return { data: Buffer.from(wayland).toString("base64"), mime: "image/png" }
  48. }
  49. const x11 = await $`xclip -selection clipboard -t image/png -o`.nothrow().arrayBuffer()
  50. if (x11 && x11.byteLength > 0) {
  51. return { data: Buffer.from(x11).toString("base64"), mime: "image/png" }
  52. }
  53. }
  54. const text = await clipboardy.read().catch(() => {})
  55. if (text) {
  56. return { data: text, mime: "text/plain" }
  57. }
  58. }
  59. const getCopyMethod = lazy(() => {
  60. const os = platform()
  61. if (os === "darwin" && Bun.which("osascript")) {
  62. console.log("clipboard: using osascript")
  63. return async (text: string) => {
  64. const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
  65. await $`osascript -e 'set the clipboard to "${escaped}"'`.nothrow().quiet()
  66. }
  67. }
  68. if (os === "linux") {
  69. if (process.env["WAYLAND_DISPLAY"] && Bun.which("wl-copy")) {
  70. console.log("clipboard: using wl-copy")
  71. return async (text: string) => {
  72. const proc = Bun.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
  73. proc.stdin.write(text)
  74. proc.stdin.end()
  75. await proc.exited.catch(() => {})
  76. }
  77. }
  78. if (Bun.which("xclip")) {
  79. console.log("clipboard: using xclip")
  80. return async (text: string) => {
  81. const proc = Bun.spawn(["xclip", "-selection", "clipboard"], {
  82. stdin: "pipe",
  83. stdout: "ignore",
  84. stderr: "ignore",
  85. })
  86. proc.stdin.write(text)
  87. proc.stdin.end()
  88. await proc.exited.catch(() => {})
  89. }
  90. }
  91. if (Bun.which("xsel")) {
  92. console.log("clipboard: using xsel")
  93. return async (text: string) => {
  94. const proc = Bun.spawn(["xsel", "--clipboard", "--input"], {
  95. stdin: "pipe",
  96. stdout: "ignore",
  97. stderr: "ignore",
  98. })
  99. proc.stdin.write(text)
  100. proc.stdin.end()
  101. await proc.exited.catch(() => {})
  102. }
  103. }
  104. }
  105. if (os === "win32") {
  106. console.log("clipboard: using powershell")
  107. return async (text: string) => {
  108. // Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.)
  109. const proc = Bun.spawn(
  110. [
  111. "powershell.exe",
  112. "-NonInteractive",
  113. "-NoProfile",
  114. "-Command",
  115. "[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())",
  116. ],
  117. {
  118. stdin: "pipe",
  119. stdout: "ignore",
  120. stderr: "ignore",
  121. },
  122. )
  123. proc.stdin.write(text)
  124. proc.stdin.end()
  125. await proc.exited.catch(() => {})
  126. }
  127. }
  128. console.log("clipboard: no native support")
  129. return async (text: string) => {
  130. await clipboardy.write(text).catch(() => {})
  131. }
  132. })
  133. export async function copy(text: string): Promise<void> {
  134. const renderer = rendererRef.current
  135. if (renderer) {
  136. // Try OSC52 but don't early return - always fall back to native method
  137. // OSC52 may report success but not actually work in all terminals
  138. renderer.copyToClipboardOSC52(text)
  139. }
  140. await getCopyMethod()(text)
  141. }
  142. }