diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 9e96d5dcbc..5102169b5c 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -1,7 +1,7 @@ import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" -import { Clipboard } from "@tui/util/clipboard" -import { Selection } from "@tui/util/selection" -import { Terminal } from "@tui/util/terminal" +import * as Clipboard from "@tui/util/clipboard" +import * as Selection from "@tui/util/selection" +import * as Terminal from "@tui/util/terminal" import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core" import { RouteProvider, useRoute } from "@tui/context/route" import { diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index c0e39e0e21..8e24ffb1b1 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -11,7 +11,7 @@ import { TextAttributes } from "@opentui/core" import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2" import { DialogModel } from "./dialog-model" import { useKeyboard } from "@opentui/solid" -import { Clipboard } from "@tui/util/clipboard" +import * as Clipboard from "@tui/util/clipboard" import { useToast } from "../ui/toast" import { isConsoleManagedProvider } from "@tui/util/provider-origin" diff --git a/packages/opencode/src/cli/cmd/tui/component/error-component.tsx b/packages/opencode/src/cli/cmd/tui/component/error-component.tsx index e8758b3d7f..38df35a04a 100644 --- a/packages/opencode/src/cli/cmd/tui/component/error-component.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/error-component.tsx @@ -1,6 +1,6 @@ import { TextAttributes } from "@opentui/core" import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" -import { Clipboard } from "@tui/util/clipboard" +import * as Clipboard from "@tui/util/clipboard" import { createSignal } from "solid-js" import { Installation } from "@/installation" import { win32FlushInputBuffer } from "../win32" diff --git a/packages/opencode/src/cli/cmd/tui/component/logo.tsx b/packages/opencode/src/cli/cmd/tui/component/logo.tsx index d41d36a6e1..e53974871a 100644 --- a/packages/opencode/src/cli/cmd/tui/component/logo.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/logo.tsx @@ -1,7 +1,7 @@ import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@opentui/core" import { For, createMemo, createSignal, onCleanup, type JSX } from "solid-js" import { useTheme, tint } from "@tui/context/theme" -import { Sound } from "@tui/util/sound" +import * as Sound from "@tui/util/sound" import { logo } from "@/cli/logo" // Shadow markers (rendered chars in parens): diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index b80c32243f..20003d8467 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -21,9 +21,9 @@ import { DialogStash } from "../dialog-stash" import { type AutocompleteRef, Autocomplete } from "./autocomplete" import { useCommandDialog } from "../dialog-command" import { useRenderer, type JSX } from "@opentui/solid" -import { Editor } from "@tui/util/editor" +import * as Editor from "@tui/util/editor" import { useExit } from "../../context/exit" -import { Clipboard } from "../../util/clipboard" +import * as Clipboard from "../../util/clipboard" import type { AssistantMessage, FilePart, UserMessage } from "@opencode-ai/sdk/v2" import { TuiEvent } from "../../event" import { iife } from "@/util/iife" diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx index 835ac8f5d5..412b4d87eb 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx @@ -3,7 +3,7 @@ import { useSync } from "@tui/context/sync" import { DialogSelect } from "@tui/ui/dialog-select" import { useSDK } from "@tui/context/sdk" import { useRoute } from "@tui/context/route" -import { Clipboard } from "@tui/util/clipboard" +import * as Clipboard from "@tui/util/clipboard" import type { PromptInfo } from "@tui/component/prompt/history" import { strip } from "@tui/component/prompt/part" diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 2ea936c898..75098b6083 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -66,10 +66,10 @@ import { SubagentFooter } from "./subagent-footer.tsx" import { Flag } from "@/flag/flag" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import parsers from "../../../../../../parsers-config.ts" -import { Clipboard } from "../../util/clipboard" +import * as Clipboard from "../../util/clipboard" import { Toast, useToast } from "../../ui/toast" import { useKV } from "../../context/kv.tsx" -import { Editor } from "../../util/editor" +import * as Editor from "../../util/editor" import stripAnsi from "strip-ansi" import { usePromptRef } from "../../context/prompt" import { useExit } from "../../context/exit" diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx index 11c43fe24c..29eb6fd4cb 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx @@ -5,7 +5,7 @@ import { MouseButton, Renderable, RGBA } from "@opentui/core" import { createStore } from "solid-js/store" import { useToast } from "./toast" import { Flag } from "@/flag/flag" -import { Selection } from "@tui/util/selection" +import * as Selection from "@tui/util/selection" export function Dialog( props: ParentProps<{ diff --git a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts index a67eb04f69..6968b07eb4 100644 --- a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts +++ b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts @@ -22,171 +22,169 @@ function writeOsc52(text: string): void { process.stdout.write(sequence) } -export namespace Clipboard { - export interface Content { - data: string - mime: string - } +export interface Content { + data: string + mime: string +} - // Checks clipboard for images first, then falls back to text. - // - // On Windows prompt/ can call this from multiple paste signals because - // terminals surface image paste differently: - // 1. A forwarded Ctrl+V keypress - // 2. An empty bracketed-paste hint for image-only clipboard in Windows - // Terminal <1.25 - // 3. A kitty Ctrl+V key-release fallback for Windows Terminal 1.25+ - export async function read(): Promise { - const os = platform() +// Checks clipboard for images first, then falls back to text. +// +// On Windows prompt/ can call this from multiple paste signals because +// terminals surface image paste differently: +// 1. A forwarded Ctrl+V keypress +// 2. An empty bracketed-paste hint for image-only clipboard in Windows +// Terminal <1.25 +// 3. A kitty Ctrl+V key-release fallback for Windows Terminal 1.25+ +export async function read(): Promise { + const os = platform() - if (os === "darwin") { - const tmpfile = path.join(tmpdir(), "opencode-clipboard.png") - try { - await Process.run( - [ - "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", - ], - { nothrow: true }, - ) - const buffer = await Filesystem.readBytes(tmpfile) - return { data: buffer.toString("base64"), mime: "image/png" } - } catch { - } finally { - await fs.rm(tmpfile, { force: true }).catch(() => {}) - } - } - - // Windows/WSL: probe clipboard for images via PowerShell. - // Bracketed paste can't carry image data so we read it directly. - if (os === "win32" || release().includes("WSL")) { - const script = - "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()) }" - const base64 = await Process.text(["powershell.exe", "-NonInteractive", "-NoProfile", "-command", script], { - nothrow: true, - }) - if (base64.text) { - const imageBuffer = Buffer.from(base64.text.trim(), "base64") - if (imageBuffer.length > 0) { - return { data: imageBuffer.toString("base64"), mime: "image/png" } - } - } - } - - if (os === "linux") { - const wayland = await Process.run(["wl-paste", "-t", "image/png"], { nothrow: true }) - if (wayland.stdout.byteLength > 0) { - return { data: Buffer.from(wayland.stdout).toString("base64"), mime: "image/png" } - } - const x11 = await Process.run(["xclip", "-selection", "clipboard", "-t", "image/png", "-o"], { - nothrow: true, - }) - if (x11.stdout.byteLength > 0) { - return { data: Buffer.from(x11.stdout).toString("base64"), mime: "image/png" } - } - } - - const text = await clipboardy.read().catch(() => {}) - if (text) { - return { data: text, mime: "text/plain" } + if (os === "darwin") { + const tmpfile = path.join(tmpdir(), "opencode-clipboard.png") + try { + await Process.run( + [ + "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", + ], + { nothrow: true }, + ) + const buffer = await Filesystem.readBytes(tmpfile) + return { data: buffer.toString("base64"), mime: "image/png" } + } catch { + } finally { + await fs.rm(tmpfile, { force: true }).catch(() => {}) } } - const getCopyMethod = lazy(() => { - const os = platform() - - if (os === "darwin" && which("osascript")) { - console.log("clipboard: using osascript") - return async (text: string) => { - const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"') - await Process.run(["osascript", "-e", `set the clipboard to "${escaped}"`], { nothrow: true }) + // Windows/WSL: probe clipboard for images via PowerShell. + // Bracketed paste can't carry image data so we read it directly. + if (os === "win32" || release().includes("WSL")) { + const script = + "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()) }" + const base64 = await Process.text(["powershell.exe", "-NonInteractive", "-NoProfile", "-command", script], { + nothrow: true, + }) + if (base64.text) { + const imageBuffer = Buffer.from(base64.text.trim(), "base64") + if (imageBuffer.length > 0) { + return { data: imageBuffer.toString("base64"), mime: "image/png" } } } + } - if (os === "linux") { - if (process.env["WAYLAND_DISPLAY"] && which("wl-copy")) { - console.log("clipboard: using wl-copy") - return async (text: string) => { - const proc = Process.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" }) - if (!proc.stdin) return - proc.stdin.write(text) - proc.stdin.end() - await proc.exited.catch(() => {}) - } - } - if (which("xclip")) { - console.log("clipboard: using xclip") - return async (text: string) => { - const proc = Process.spawn(["xclip", "-selection", "clipboard"], { - stdin: "pipe", - stdout: "ignore", - stderr: "ignore", - }) - if (!proc.stdin) return - proc.stdin.write(text) - proc.stdin.end() - await proc.exited.catch(() => {}) - } - } - if (which("xsel")) { - console.log("clipboard: using xsel") - return async (text: string) => { - const proc = Process.spawn(["xsel", "--clipboard", "--input"], { - stdin: "pipe", - stdout: "ignore", - stderr: "ignore", - }) - if (!proc.stdin) return - proc.stdin.write(text) - proc.stdin.end() - await proc.exited.catch(() => {}) - } - } + if (os === "linux") { + const wayland = await Process.run(["wl-paste", "-t", "image/png"], { nothrow: true }) + if (wayland.stdout.byteLength > 0) { + return { data: Buffer.from(wayland.stdout).toString("base64"), mime: "image/png" } } + const x11 = await Process.run(["xclip", "-selection", "clipboard", "-t", "image/png", "-o"], { + nothrow: true, + }) + if (x11.stdout.byteLength > 0) { + return { data: Buffer.from(x11.stdout).toString("base64"), mime: "image/png" } + } + } - if (os === "win32") { - console.log("clipboard: using powershell") + const text = await clipboardy.read().catch(() => {}) + if (text) { + return { data: text, mime: "text/plain" } + } +} + +const getCopyMethod = lazy(() => { + const os = platform() + + if (os === "darwin" && which("osascript")) { + console.log("clipboard: using osascript") + return async (text: string) => { + const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + await Process.run(["osascript", "-e", `set the clipboard to "${escaped}"`], { nothrow: true }) + } + } + + if (os === "linux") { + if (process.env["WAYLAND_DISPLAY"] && which("wl-copy")) { + console.log("clipboard: using wl-copy") return async (text: string) => { - // Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.) - const proc = Process.spawn( - [ - "powershell.exe", - "-NonInteractive", - "-NoProfile", - "-Command", - "[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())", - ], - { - stdin: "pipe", - stdout: "ignore", - stderr: "ignore", - }, - ) - + const proc = Process.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" }) if (!proc.stdin) return proc.stdin.write(text) proc.stdin.end() await proc.exited.catch(() => {}) } } - - console.log("clipboard: no native support") - return async (text: string) => { - await clipboardy.write(text).catch(() => {}) + if (which("xclip")) { + console.log("clipboard: using xclip") + return async (text: string) => { + const proc = Process.spawn(["xclip", "-selection", "clipboard"], { + stdin: "pipe", + stdout: "ignore", + stderr: "ignore", + }) + if (!proc.stdin) return + proc.stdin.write(text) + proc.stdin.end() + await proc.exited.catch(() => {}) + } + } + if (which("xsel")) { + console.log("clipboard: using xsel") + return async (text: string) => { + const proc = Process.spawn(["xsel", "--clipboard", "--input"], { + stdin: "pipe", + stdout: "ignore", + stderr: "ignore", + }) + if (!proc.stdin) return + proc.stdin.write(text) + proc.stdin.end() + await proc.exited.catch(() => {}) + } } - }) - - export async function copy(text: string): Promise { - writeOsc52(text) - await getCopyMethod()(text) } + + if (os === "win32") { + console.log("clipboard: using powershell") + return async (text: string) => { + // Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.) + const proc = Process.spawn( + [ + "powershell.exe", + "-NonInteractive", + "-NoProfile", + "-Command", + "[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())", + ], + { + stdin: "pipe", + stdout: "ignore", + stderr: "ignore", + }, + ) + + if (!proc.stdin) return + proc.stdin.write(text) + proc.stdin.end() + await proc.exited.catch(() => {}) + } + } + + console.log("clipboard: no native support") + return async (text: string) => { + await clipboardy.write(text).catch(() => {}) + } +}) + +export async function copy(text: string): Promise { + writeOsc52(text) + await getCopyMethod()(text) } diff --git a/packages/opencode/src/cli/cmd/tui/util/editor.ts b/packages/opencode/src/cli/cmd/tui/util/editor.ts index 540cf6f497..26e595dfbc 100644 --- a/packages/opencode/src/cli/cmd/tui/util/editor.ts +++ b/packages/opencode/src/cli/cmd/tui/util/editor.ts @@ -6,32 +6,30 @@ import { CliRenderer } from "@opentui/core" import { Filesystem } from "@/util" import { Process } from "@/util" -export namespace Editor { - export async function open(opts: { value: string; renderer: CliRenderer }): Promise { - const editor = process.env["VISUAL"] || process.env["EDITOR"] - if (!editor) return +export async function open(opts: { value: string; renderer: CliRenderer }): Promise { + const editor = process.env["VISUAL"] || process.env["EDITOR"] + if (!editor) return - const filepath = join(tmpdir(), `${Date.now()}.md`) - await using _ = defer(async () => rm(filepath, { force: true })) + const filepath = join(tmpdir(), `${Date.now()}.md`) + await using _ = defer(async () => rm(filepath, { force: true })) - await Filesystem.write(filepath, opts.value) - opts.renderer.suspend() + await Filesystem.write(filepath, opts.value) + opts.renderer.suspend() + opts.renderer.currentRenderBuffer.clear() + try { + const parts = editor.split(" ") + const proc = Process.spawn([...parts, filepath], { + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + shell: process.platform === "win32", + }) + await proc.exited + const content = await Filesystem.readText(filepath) + return content || undefined + } finally { opts.renderer.currentRenderBuffer.clear() - try { - const parts = editor.split(" ") - const proc = Process.spawn([...parts, filepath], { - stdin: "inherit", - stdout: "inherit", - stderr: "inherit", - shell: process.platform === "win32", - }) - await proc.exited - const content = await Filesystem.readText(filepath) - return content || undefined - } finally { - opts.renderer.currentRenderBuffer.clear() - opts.renderer.resume() - opts.renderer.requestRender() - } + opts.renderer.resume() + opts.renderer.requestRender() } } diff --git a/packages/opencode/src/cli/cmd/tui/util/index.ts b/packages/opencode/src/cli/cmd/tui/util/index.ts new file mode 100644 index 0000000000..a0bdbc3c28 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/util/index.ts @@ -0,0 +1,5 @@ +export * as Editor from "./editor" +export * as Selection from "./selection" +export * as Sound from "./sound" +export * as Terminal from "./terminal" +export * as Clipboard from "./clipboard" diff --git a/packages/opencode/src/cli/cmd/tui/util/selection.ts b/packages/opencode/src/cli/cmd/tui/util/selection.ts index 1230852dcc..d677972ee8 100644 --- a/packages/opencode/src/cli/cmd/tui/util/selection.ts +++ b/packages/opencode/src/cli/cmd/tui/util/selection.ts @@ -1,4 +1,4 @@ -import { Clipboard } from "./clipboard" +import * as Clipboard from "./clipboard" type Toast = { show: (input: { message: string; variant: "info" | "success" | "warning" | "error" }) => void @@ -10,16 +10,14 @@ type Renderer = { clearSelection: () => void } -export namespace Selection { - export function copy(renderer: Renderer, toast: Toast): boolean { - const text = renderer.getSelection()?.getSelectedText() - if (!text) return false +export function copy(renderer: Renderer, toast: Toast): boolean { + const text = renderer.getSelection()?.getSelectedText() + if (!text) return false - Clipboard.copy(text) - .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) - .catch(toast.error) + Clipboard.copy(text) + .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) + .catch(toast.error) - renderer.clearSelection() - return true - } + renderer.clearSelection() + return true } diff --git a/packages/opencode/src/cli/cmd/tui/util/sound.ts b/packages/opencode/src/cli/cmd/tui/util/sound.ts index 1be35eecbf..e0a15c1a70 100644 --- a/packages/opencode/src/cli/cmd/tui/util/sound.ts +++ b/packages/opencode/src/cli/cmd/tui/util/sound.ts @@ -43,114 +43,112 @@ function args(kind: Kind, file: string, volume: number) { return [kind, "-c", `(New-Object Media.SoundPlayer '${file.replace(/'/g, "''")}').PlaySync()`] } -export namespace Sound { - let item: Player | null | undefined - let kind: Kind | null | undefined - let proc: Process.Child | undefined - let tail: ReturnType | undefined - let cache: Promise<{ hum: string; pulse: string[] }> | undefined - let seq = 0 - let shot = 0 +let item: Player | null | undefined +let kind: Kind | null | undefined +let proc: Process.Child | undefined +let tail: ReturnType | undefined +let cache: Promise<{ hum: string; pulse: string[] }> | undefined +let seq = 0 +let shot = 0 - function load() { - if (item !== undefined) return item - try { - item = new Player({ volume: 0.35 }) - } catch { - item = null - } - return item - } - - async function file(path: string) { - mkdirSync(DIR, { recursive: true }) - const next = join(DIR, basename(path)) - const out = Bun.file(next) - if (await out.exists()) return next - await Bun.write(out, Bun.file(path)) - return next - } - - function asset() { - cache ??= Promise.all([file(HUM), Promise.all(FILE.map(file))]).then(([hum, pulse]) => ({ hum, pulse })) - return cache - } - - function pick() { - if (kind !== undefined) return kind - kind = LIST.find((item) => which(item)) ?? null - return kind - } - - function run(file: string, volume: number) { - const kind = pick() - if (!kind) return - return Process.spawn(args(kind, file, volume), { - stdin: "ignore", - stdout: "ignore", - stderr: "ignore", - }) - } - - function clear() { - if (!tail) return - clearTimeout(tail) - tail = undefined - } - - function play(file: string, volume: number) { - const item = load() - if (!item) return run(file, volume)?.exited - return item.play(file, { volume }).catch(() => run(file, volume)?.exited) - } - - export function start() { - stop() - const id = ++seq - void asset().then(({ hum }) => { - if (id !== seq) return - const next = run(hum, 0.24) - if (!next) return - proc = next - void next.exited.then( - () => { - if (id !== seq) return - if (proc === next) proc = undefined - }, - () => { - if (id !== seq) return - if (proc === next) proc = undefined - }, - ) - }) - } - - export function stop(delay = 0) { - seq++ - clear() - if (!proc) return - const next = proc - if (delay <= 0) { - proc = undefined - void Process.stop(next).catch(() => undefined) - return - } - tail = setTimeout(() => { - tail = undefined - if (proc === next) proc = undefined - void Process.stop(next).catch(() => undefined) - }, delay) - } - - export function pulse(scale = 1) { - stop(140) - const index = shot++ % FILE.length - void asset() - .then(({ pulse }) => play(pulse[index], 0.26 + 0.14 * scale)) - .catch(() => undefined) - } - - export function dispose() { - stop() +function load() { + if (item !== undefined) return item + try { + item = new Player({ volume: 0.35 }) + } catch { + item = null } + return item +} + +async function file(path: string) { + mkdirSync(DIR, { recursive: true }) + const next = join(DIR, basename(path)) + const out = Bun.file(next) + if (await out.exists()) return next + await Bun.write(out, Bun.file(path)) + return next +} + +function asset() { + cache ??= Promise.all([file(HUM), Promise.all(FILE.map(file))]).then(([hum, pulse]) => ({ hum, pulse })) + return cache +} + +function pick() { + if (kind !== undefined) return kind + kind = LIST.find((item) => which(item)) ?? null + return kind +} + +function run(file: string, volume: number) { + const kind = pick() + if (!kind) return + return Process.spawn(args(kind, file, volume), { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }) +} + +function clear() { + if (!tail) return + clearTimeout(tail) + tail = undefined +} + +function play(file: string, volume: number) { + const item = load() + if (!item) return run(file, volume)?.exited + return item.play(file, { volume }).catch(() => run(file, volume)?.exited) +} + +export function start() { + stop() + const id = ++seq + void asset().then(({ hum }) => { + if (id !== seq) return + const next = run(hum, 0.24) + if (!next) return + proc = next + void next.exited.then( + () => { + if (id !== seq) return + if (proc === next) proc = undefined + }, + () => { + if (id !== seq) return + if (proc === next) proc = undefined + }, + ) + }) +} + +export function stop(delay = 0) { + seq++ + clear() + if (!proc) return + const next = proc + if (delay <= 0) { + proc = undefined + void Process.stop(next).catch(() => undefined) + return + } + tail = setTimeout(() => { + tail = undefined + if (proc === next) proc = undefined + void Process.stop(next).catch(() => undefined) + }, delay) +} + +export function pulse(scale = 1) { + stop(140) + const index = shot++ % FILE.length + void asset() + .then(({ pulse }) => play(pulse[index], 0.26 + 0.14 * scale)) + .catch(() => undefined) +} + +export function dispose() { + stop() } diff --git a/packages/opencode/src/cli/cmd/tui/util/terminal.ts b/packages/opencode/src/cli/cmd/tui/util/terminal.ts index 97b51fb4c5..46cf4635a7 100644 --- a/packages/opencode/src/cli/cmd/tui/util/terminal.ts +++ b/packages/opencode/src/cli/cmd/tui/util/terminal.ts @@ -1,137 +1,135 @@ import { RGBA } from "@opentui/core" -export namespace Terminal { - export type Colors = Awaited> +export type Colors = Awaited> - function parse(color: string): RGBA | null { - if (color.startsWith("rgb:")) { - const parts = color.substring(4).split("/") - return RGBA.fromInts(parseInt(parts[0], 16) >> 8, parseInt(parts[1], 16) >> 8, parseInt(parts[2], 16) >> 8, 255) - } - if (color.startsWith("#")) { - return RGBA.fromHex(color) - } - if (color.startsWith("rgb(")) { - const parts = color.substring(4, color.length - 1).split(",") - return RGBA.fromInts(parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2]), 255) - } - return null +function parse(color: string): RGBA | null { + if (color.startsWith("rgb:")) { + const parts = color.substring(4).split("/") + return RGBA.fromInts(parseInt(parts[0], 16) >> 8, parseInt(parts[1], 16) >> 8, parseInt(parts[2], 16) >> 8, 255) } - - function mode(bg: RGBA | null): "dark" | "light" { - if (!bg) return "dark" - const luminance = (0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b) / 255 - return luminance > 0.5 ? "light" : "dark" + if (color.startsWith("#")) { + return RGBA.fromHex(color) } + if (color.startsWith("rgb(")) { + const parts = color.substring(4, color.length - 1).split(",") + return RGBA.fromInts(parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2]), 255) + } + return null +} - /** - * Query terminal colors including background, foreground, and palette (0-15). - * Uses OSC escape sequences to retrieve actual terminal color values. - * - * Note: OSC 4 (palette) queries may not work through tmux as responses are filtered. - * OSC 10/11 (foreground/background) typically work in most environments. - * - * Returns an object with background, foreground, and colors array. - * Any query that fails will be null/empty. - */ - export async function colors(): Promise<{ - background: RGBA | null - foreground: RGBA | null - colors: RGBA[] - }> { - if (!process.stdin.isTTY) return { background: null, foreground: null, colors: [] } +function mode(bg: RGBA | null): "dark" | "light" { + if (!bg) return "dark" + const luminance = (0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b) / 255 + return luminance > 0.5 ? "light" : "dark" +} - return new Promise((resolve) => { - let background: RGBA | null = null - let foreground: RGBA | null = null - const paletteColors: RGBA[] = [] - let timeout: NodeJS.Timeout +/** + * Query terminal colors including background, foreground, and palette (0-15). + * Uses OSC escape sequences to retrieve actual terminal color values. + * + * Note: OSC 4 (palette) queries may not work through tmux as responses are filtered. + * OSC 10/11 (foreground/background) typically work in most environments. + * + * Returns an object with background, foreground, and colors array. + * Any query that fails will be null/empty. + */ +export async function colors(): Promise<{ + background: RGBA | null + foreground: RGBA | null + colors: RGBA[] +}> { + if (!process.stdin.isTTY) return { background: null, foreground: null, colors: [] } - const cleanup = () => { - process.stdin.setRawMode(false) - process.stdin.removeListener("data", handler) - clearTimeout(timeout) + return new Promise((resolve) => { + let background: RGBA | null = null + let foreground: RGBA | null = null + const paletteColors: RGBA[] = [] + let timeout: NodeJS.Timeout + + const cleanup = () => { + process.stdin.setRawMode(false) + process.stdin.removeListener("data", handler) + clearTimeout(timeout) + } + + const handler = (data: Buffer) => { + const str = data.toString() + + // Match OSC 11 (background color) + const bgMatch = str.match(/\x1b]11;([^\x07\x1b]+)/) + if (bgMatch) { + background = parse(bgMatch[1]) } - const handler = (data: Buffer) => { - const str = data.toString() - - // Match OSC 11 (background color) - const bgMatch = str.match(/\x1b]11;([^\x07\x1b]+)/) - if (bgMatch) { - background = parse(bgMatch[1]) - } - - // Match OSC 10 (foreground color) - const fgMatch = str.match(/\x1b]10;([^\x07\x1b]+)/) - if (fgMatch) { - foreground = parse(fgMatch[1]) - } - - // Match OSC 4 (palette colors) - const paletteMatches = str.matchAll(/\x1b]4;(\d+);([^\x07\x1b]+)/g) - for (const match of paletteMatches) { - const index = parseInt(match[1]) - const color = parse(match[2]) - if (color) paletteColors[index] = color - } - - // Return immediately if we have all 16 palette colors - if (paletteColors.filter((c) => c !== undefined).length === 16) { - cleanup() - resolve({ background, foreground, colors: paletteColors }) - } + // Match OSC 10 (foreground color) + const fgMatch = str.match(/\x1b]10;([^\x07\x1b]+)/) + if (fgMatch) { + foreground = parse(fgMatch[1]) } - process.stdin.setRawMode(true) - process.stdin.on("data", handler) - - // Query background (OSC 11) - process.stdout.write("\x1b]11;?\x07") - // Query foreground (OSC 10) - process.stdout.write("\x1b]10;?\x07") - // Query palette colors 0-15 (OSC 4) - for (let i = 0; i < 16; i++) { - process.stdout.write(`\x1b]4;${i};?\x07`) + // Match OSC 4 (palette colors) + const paletteMatches = str.matchAll(/\x1b]4;(\d+);([^\x07\x1b]+)/g) + for (const match of paletteMatches) { + const index = parseInt(match[1]) + const color = parse(match[2]) + if (color) paletteColors[index] = color } - timeout = setTimeout(() => { + // Return immediately if we have all 16 palette colors + if (paletteColors.filter((c) => c !== undefined).length === 16) { cleanup() resolve({ background, foreground, colors: paletteColors }) - }, 1000) - }) - } - - // Keep startup mode detection separate from `colors()`: the TUI boot path only - // needs OSC 11 and should resolve on the first background response instead of - // waiting on the full palette query used by system theme generation. - export async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { - if (!process.stdin.isTTY) return "dark" - - return new Promise((resolve) => { - let timeout: NodeJS.Timeout - - const cleanup = () => { - process.stdin.setRawMode(false) - process.stdin.removeListener("data", handler) - clearTimeout(timeout) } + } - const handler = (data: Buffer) => { - const match = data.toString().match(/\x1b]11;([^\x07\x1b]+)/) - if (!match) return - cleanup() - resolve(mode(parse(match[1]))) - } + process.stdin.setRawMode(true) + process.stdin.on("data", handler) - process.stdin.setRawMode(true) - process.stdin.on("data", handler) - process.stdout.write("\x1b]11;?\x07") + // Query background (OSC 11) + process.stdout.write("\x1b]11;?\x07") + // Query foreground (OSC 10) + process.stdout.write("\x1b]10;?\x07") + // Query palette colors 0-15 (OSC 4) + for (let i = 0; i < 16; i++) { + process.stdout.write(`\x1b]4;${i};?\x07`) + } - timeout = setTimeout(() => { - cleanup() - resolve("dark") - }, 1000) - }) - } + timeout = setTimeout(() => { + cleanup() + resolve({ background, foreground, colors: paletteColors }) + }, 1000) + }) +} + +// Keep startup mode detection separate from `colors()`: the TUI boot path only +// needs OSC 11 and should resolve on the first background response instead of +// waiting on the full palette query used by system theme generation. +export async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { + if (!process.stdin.isTTY) return "dark" + + return new Promise((resolve) => { + let timeout: NodeJS.Timeout + + const cleanup = () => { + process.stdin.setRawMode(false) + process.stdin.removeListener("data", handler) + clearTimeout(timeout) + } + + const handler = (data: Buffer) => { + const match = data.toString().match(/\x1b]11;([^\x07\x1b]+)/) + if (!match) return + cleanup() + resolve(mode(parse(match[1]))) + } + + process.stdin.setRawMode(true) + process.stdin.on("data", handler) + process.stdout.write("\x1b]11;?\x07") + + timeout = setTimeout(() => { + cleanup() + resolve("dark") + }, 1000) + }) }