editor.ts 3.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101
  1. import type { CliRenderer } from "@opentui/core"
  2. import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
  3. import { readFile, rm, writeFile } from "node:fs/promises"
  4. import os from "node:os"
  5. import path from "node:path"
  6. import { spawn } from "node:child_process"
  7. import type { Stream } from "node:stream"
  8. import { resolveZedDbPath, resolveZedSelection } from "./editor-zed"
  9. type EditorStdio = "inherit" | "pipe" | "ignore" | number | Stream
  10. export function normalizePromptContent(content: string) {
  11. if (content.endsWith("\r\n")) {
  12. const body = content.slice(0, -2)
  13. return !body.includes("\n") && !body.includes("\r") ? body : content
  14. }
  15. if (content.endsWith("\n")) {
  16. const body = content.slice(0, -1)
  17. return !body.includes("\n") && !body.includes("\r") ? body : content
  18. }
  19. return content
  20. }
  21. export async function openEditor(input: { value: string; renderer: CliRenderer; cwd?: string; stdin?: EditorStdio }) {
  22. const editor = process.env.VISUAL || process.env.EDITOR
  23. if (!editor) return
  24. const file = path.join(os.tmpdir(), `${Date.now()}.md`)
  25. await writeFile(file, input.value)
  26. input.renderer.suspend()
  27. input.renderer.currentRenderBuffer.clear()
  28. try {
  29. await new Promise<void>((resolve, reject) => {
  30. const parts = editor.split(" ")
  31. const child = spawn(parts[0]!, [...parts.slice(1), file], {
  32. cwd: input.cwd && existsSync(input.cwd) ? input.cwd : process.cwd(),
  33. stdio: [input.stdin ?? "inherit", "inherit", "inherit"],
  34. shell: process.platform === "win32",
  35. })
  36. child.on("error", reject)
  37. child.on("exit", (code, signal) => {
  38. if (code === 0) return resolve()
  39. reject(new Error(`Editor exited with ${signal ? `signal ${signal}` : `code ${code}`}`))
  40. })
  41. })
  42. return (await readFile(file, "utf8")) || undefined
  43. } finally {
  44. await rm(file, { force: true }).catch(() => {})
  45. input.renderer.currentRenderBuffer.clear()
  46. input.renderer.resume()
  47. input.renderer.requestRender()
  48. }
  49. }
  50. export function discoverEditorConnection(directory: string) {
  51. const root = path.join(os.homedir(), ".claude", "ide")
  52. const contains = (parent: string) => {
  53. const resolved = path.resolve(parent)
  54. const relative = path.relative(resolved, path.resolve(directory))
  55. return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) ? resolved.length : 0
  56. }
  57. try {
  58. return readdirSync(root)
  59. .filter((entry) => entry.endsWith(".lock"))
  60. .flatMap((entry) => {
  61. const file = path.join(root, entry)
  62. const port = Number.parseInt(path.basename(file, ".lock"), 10)
  63. if (!Number.isInteger(port) || port <= 0 || port > 65535) return []
  64. try {
  65. const value = JSON.parse(readFileSync(file, "utf8")) as Record<string, unknown>
  66. if (value.transport !== undefined && value.transport !== "ws") return []
  67. const folders = Array.isArray(value.workspaceFolders)
  68. ? value.workspaceFolders.filter((item): item is string => typeof item === "string")
  69. : []
  70. const score = Math.max(0, ...folders.map(contains))
  71. if (!score) return []
  72. return [
  73. {
  74. url: `ws://127.0.0.1:${port}`,
  75. authToken: typeof value.authToken === "string" ? value.authToken : undefined,
  76. source: `lock:${port}`,
  77. score,
  78. mtime: statSync(file).mtimeMs,
  79. },
  80. ]
  81. } catch {
  82. return []
  83. }
  84. })
  85. .sort((left, right) => right.score - left.score || right.mtime - left.mtime)
  86. .map(({ url, authToken, source }) => ({ url, authToken, source }))[0]
  87. } catch {
  88. return undefined
  89. }
  90. }
  91. export const editorIntegration = {
  92. connection: discoverEditorConnection,
  93. selection: (directory: string) => resolveZedSelection(resolveZedDbPath() ?? "", directory),
  94. }