feat: unwrap cli-tui namespaces to flat exports + barrel (#22759)
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||||
import { Clipboard } from "@tui/util/clipboard"
|
import * as Clipboard from "@tui/util/clipboard"
|
||||||
import { Selection } from "@tui/util/selection"
|
import * as Selection from "@tui/util/selection"
|
||||||
import { Terminal } from "@tui/util/terminal"
|
import * as Terminal from "@tui/util/terminal"
|
||||||
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
|
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
|
||||||
import { RouteProvider, useRoute } from "@tui/context/route"
|
import { RouteProvider, useRoute } from "@tui/context/route"
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { TextAttributes } from "@opentui/core"
|
|||||||
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2"
|
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2"
|
||||||
import { DialogModel } from "./dialog-model"
|
import { DialogModel } from "./dialog-model"
|
||||||
import { useKeyboard } from "@opentui/solid"
|
import { useKeyboard } from "@opentui/solid"
|
||||||
import { Clipboard } from "@tui/util/clipboard"
|
import * as Clipboard from "@tui/util/clipboard"
|
||||||
import { useToast } from "../ui/toast"
|
import { useToast } from "../ui/toast"
|
||||||
import { isConsoleManagedProvider } from "@tui/util/provider-origin"
|
import { isConsoleManagedProvider } from "@tui/util/provider-origin"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { TextAttributes } from "@opentui/core"
|
import { TextAttributes } from "@opentui/core"
|
||||||
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
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 { createSignal } from "solid-js"
|
||||||
import { Installation } from "@/installation"
|
import { Installation } from "@/installation"
|
||||||
import { win32FlushInputBuffer } from "../win32"
|
import { win32FlushInputBuffer } from "../win32"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@opentui/core"
|
import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@opentui/core"
|
||||||
import { For, createMemo, createSignal, onCleanup, type JSX } from "solid-js"
|
import { For, createMemo, createSignal, onCleanup, type JSX } from "solid-js"
|
||||||
import { useTheme, tint } from "@tui/context/theme"
|
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"
|
import { logo } from "@/cli/logo"
|
||||||
|
|
||||||
// Shadow markers (rendered chars in parens):
|
// Shadow markers (rendered chars in parens):
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ import { DialogStash } from "../dialog-stash"
|
|||||||
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
|
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
|
||||||
import { useCommandDialog } from "../dialog-command"
|
import { useCommandDialog } from "../dialog-command"
|
||||||
import { useRenderer, type JSX } from "@opentui/solid"
|
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 { 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 type { AssistantMessage, FilePart, UserMessage } from "@opencode-ai/sdk/v2"
|
||||||
import { TuiEvent } from "../../event"
|
import { TuiEvent } from "../../event"
|
||||||
import { iife } from "@/util/iife"
|
import { iife } from "@/util/iife"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useSync } from "@tui/context/sync"
|
|||||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||||
import { useSDK } from "@tui/context/sdk"
|
import { useSDK } from "@tui/context/sdk"
|
||||||
import { useRoute } from "@tui/context/route"
|
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 type { PromptInfo } from "@tui/component/prompt/history"
|
||||||
import { strip } from "@tui/component/prompt/part"
|
import { strip } from "@tui/component/prompt/part"
|
||||||
|
|
||||||
|
|||||||
@@ -66,10 +66,10 @@ import { SubagentFooter } from "./subagent-footer.tsx"
|
|||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
|
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
|
||||||
import parsers from "../../../../../../parsers-config.ts"
|
import parsers from "../../../../../../parsers-config.ts"
|
||||||
import { Clipboard } from "../../util/clipboard"
|
import * as Clipboard from "../../util/clipboard"
|
||||||
import { Toast, useToast } from "../../ui/toast"
|
import { Toast, useToast } from "../../ui/toast"
|
||||||
import { useKV } from "../../context/kv.tsx"
|
import { useKV } from "../../context/kv.tsx"
|
||||||
import { Editor } from "../../util/editor"
|
import * as Editor from "../../util/editor"
|
||||||
import stripAnsi from "strip-ansi"
|
import stripAnsi from "strip-ansi"
|
||||||
import { usePromptRef } from "../../context/prompt"
|
import { usePromptRef } from "../../context/prompt"
|
||||||
import { useExit } from "../../context/exit"
|
import { useExit } from "../../context/exit"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { MouseButton, Renderable, RGBA } from "@opentui/core"
|
|||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useToast } from "./toast"
|
import { useToast } from "./toast"
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { Selection } from "@tui/util/selection"
|
import * as Selection from "@tui/util/selection"
|
||||||
|
|
||||||
export function Dialog(
|
export function Dialog(
|
||||||
props: ParentProps<{
|
props: ParentProps<{
|
||||||
|
|||||||
@@ -22,171 +22,169 @@ function writeOsc52(text: string): void {
|
|||||||
process.stdout.write(sequence)
|
process.stdout.write(sequence)
|
||||||
}
|
}
|
||||||
|
|
||||||
export namespace Clipboard {
|
export interface Content {
|
||||||
export interface Content {
|
data: string
|
||||||
data: string
|
mime: string
|
||||||
mime: string
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Checks clipboard for images first, then falls back to text.
|
// Checks clipboard for images first, then falls back to text.
|
||||||
//
|
//
|
||||||
// On Windows prompt/ can call this from multiple paste signals because
|
// On Windows prompt/ can call this from multiple paste signals because
|
||||||
// terminals surface image paste differently:
|
// terminals surface image paste differently:
|
||||||
// 1. A forwarded Ctrl+V keypress
|
// 1. A forwarded Ctrl+V keypress
|
||||||
// 2. An empty bracketed-paste hint for image-only clipboard in Windows
|
// 2. An empty bracketed-paste hint for image-only clipboard in Windows
|
||||||
// Terminal <1.25
|
// Terminal <1.25
|
||||||
// 3. A kitty Ctrl+V key-release fallback for Windows Terminal 1.25+
|
// 3. A kitty Ctrl+V key-release fallback for Windows Terminal 1.25+
|
||||||
export async function read(): Promise<Content | undefined> {
|
export async function read(): Promise<Content | undefined> {
|
||||||
const os = platform()
|
const os = platform()
|
||||||
|
|
||||||
if (os === "darwin") {
|
if (os === "darwin") {
|
||||||
const tmpfile = path.join(tmpdir(), "opencode-clipboard.png")
|
const tmpfile = path.join(tmpdir(), "opencode-clipboard.png")
|
||||||
try {
|
try {
|
||||||
await Process.run(
|
await Process.run(
|
||||||
[
|
[
|
||||||
"osascript",
|
"osascript",
|
||||||
"-e",
|
"-e",
|
||||||
'set imageData to the clipboard as "PNGf"',
|
'set imageData to the clipboard as "PNGf"',
|
||||||
"-e",
|
"-e",
|
||||||
`set fileRef to open for access POSIX file "${tmpfile}" with write permission`,
|
`set fileRef to open for access POSIX file "${tmpfile}" with write permission`,
|
||||||
"-e",
|
"-e",
|
||||||
"set eof fileRef to 0",
|
"set eof fileRef to 0",
|
||||||
"-e",
|
"-e",
|
||||||
"write imageData to fileRef",
|
"write imageData to fileRef",
|
||||||
"-e",
|
"-e",
|
||||||
"close access fileRef",
|
"close access fileRef",
|
||||||
],
|
],
|
||||||
{ nothrow: true },
|
{ nothrow: true },
|
||||||
)
|
)
|
||||||
const buffer = await Filesystem.readBytes(tmpfile)
|
const buffer = await Filesystem.readBytes(tmpfile)
|
||||||
return { data: buffer.toString("base64"), mime: "image/png" }
|
return { data: buffer.toString("base64"), mime: "image/png" }
|
||||||
} catch {
|
} catch {
|
||||||
} finally {
|
} finally {
|
||||||
await fs.rm(tmpfile, { force: true }).catch(() => {})
|
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" }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCopyMethod = lazy(() => {
|
// Windows/WSL: probe clipboard for images via PowerShell.
|
||||||
const os = platform()
|
// Bracketed paste can't carry image data so we read it directly.
|
||||||
|
if (os === "win32" || release().includes("WSL")) {
|
||||||
if (os === "darwin" && which("osascript")) {
|
const script =
|
||||||
console.log("clipboard: using osascript")
|
"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()) }"
|
||||||
return async (text: string) => {
|
const base64 = await Process.text(["powershell.exe", "-NonInteractive", "-NoProfile", "-command", script], {
|
||||||
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
|
nothrow: true,
|
||||||
await Process.run(["osascript", "-e", `set the clipboard to "${escaped}"`], { 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 (os === "linux") {
|
||||||
if (process.env["WAYLAND_DISPLAY"] && which("wl-copy")) {
|
const wayland = await Process.run(["wl-paste", "-t", "image/png"], { nothrow: true })
|
||||||
console.log("clipboard: using wl-copy")
|
if (wayland.stdout.byteLength > 0) {
|
||||||
return async (text: string) => {
|
return { data: Buffer.from(wayland.stdout).toString("base64"), mime: "image/png" }
|
||||||
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(() => {})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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") {
|
const text = await clipboardy.read().catch(() => {})
|
||||||
console.log("clipboard: using powershell")
|
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) => {
|
return async (text: string) => {
|
||||||
// Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.)
|
const proc = Process.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
|
||||||
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
|
if (!proc.stdin) return
|
||||||
proc.stdin.write(text)
|
proc.stdin.write(text)
|
||||||
proc.stdin.end()
|
proc.stdin.end()
|
||||||
await proc.exited.catch(() => {})
|
await proc.exited.catch(() => {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (which("xclip")) {
|
||||||
console.log("clipboard: no native support")
|
console.log("clipboard: using xclip")
|
||||||
return async (text: string) => {
|
return async (text: string) => {
|
||||||
await clipboardy.write(text).catch(() => {})
|
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<void> {
|
|
||||||
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<void> {
|
||||||
|
writeOsc52(text)
|
||||||
|
await getCopyMethod()(text)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,32 +6,30 @@ import { CliRenderer } from "@opentui/core"
|
|||||||
import { Filesystem } from "@/util"
|
import { Filesystem } from "@/util"
|
||||||
import { Process } from "@/util"
|
import { Process } from "@/util"
|
||||||
|
|
||||||
export namespace Editor {
|
export async function open(opts: { value: string; renderer: CliRenderer }): Promise<string | undefined> {
|
||||||
export async function open(opts: { value: string; renderer: CliRenderer }): Promise<string | undefined> {
|
const editor = process.env["VISUAL"] || process.env["EDITOR"]
|
||||||
const editor = process.env["VISUAL"] || process.env["EDITOR"]
|
if (!editor) return
|
||||||
if (!editor) return
|
|
||||||
|
|
||||||
const filepath = join(tmpdir(), `${Date.now()}.md`)
|
const filepath = join(tmpdir(), `${Date.now()}.md`)
|
||||||
await using _ = defer(async () => rm(filepath, { force: true }))
|
await using _ = defer(async () => rm(filepath, { force: true }))
|
||||||
|
|
||||||
await Filesystem.write(filepath, opts.value)
|
await Filesystem.write(filepath, opts.value)
|
||||||
opts.renderer.suspend()
|
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()
|
opts.renderer.currentRenderBuffer.clear()
|
||||||
try {
|
opts.renderer.resume()
|
||||||
const parts = editor.split(" ")
|
opts.renderer.requestRender()
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Clipboard } from "./clipboard"
|
import * as Clipboard from "./clipboard"
|
||||||
|
|
||||||
type Toast = {
|
type Toast = {
|
||||||
show: (input: { message: string; variant: "info" | "success" | "warning" | "error" }) => void
|
show: (input: { message: string; variant: "info" | "success" | "warning" | "error" }) => void
|
||||||
@@ -10,16 +10,14 @@ type Renderer = {
|
|||||||
clearSelection: () => void
|
clearSelection: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export namespace Selection {
|
export function copy(renderer: Renderer, toast: Toast): boolean {
|
||||||
export function copy(renderer: Renderer, toast: Toast): boolean {
|
const text = renderer.getSelection()?.getSelectedText()
|
||||||
const text = renderer.getSelection()?.getSelectedText()
|
if (!text) return false
|
||||||
if (!text) return false
|
|
||||||
|
|
||||||
Clipboard.copy(text)
|
Clipboard.copy(text)
|
||||||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||||
.catch(toast.error)
|
.catch(toast.error)
|
||||||
|
|
||||||
renderer.clearSelection()
|
renderer.clearSelection()
|
||||||
return true
|
return true
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,114 +43,112 @@ function args(kind: Kind, file: string, volume: number) {
|
|||||||
return [kind, "-c", `(New-Object Media.SoundPlayer '${file.replace(/'/g, "''")}').PlaySync()`]
|
return [kind, "-c", `(New-Object Media.SoundPlayer '${file.replace(/'/g, "''")}').PlaySync()`]
|
||||||
}
|
}
|
||||||
|
|
||||||
export namespace Sound {
|
let item: Player | null | undefined
|
||||||
let item: Player | null | undefined
|
let kind: Kind | null | undefined
|
||||||
let kind: Kind | null | undefined
|
let proc: Process.Child | undefined
|
||||||
let proc: Process.Child | undefined
|
let tail: ReturnType<typeof setTimeout> | undefined
|
||||||
let tail: ReturnType<typeof setTimeout> | undefined
|
let cache: Promise<{ hum: string; pulse: string[] }> | undefined
|
||||||
let cache: Promise<{ hum: string; pulse: string[] }> | undefined
|
let seq = 0
|
||||||
let seq = 0
|
let shot = 0
|
||||||
let shot = 0
|
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
if (item !== undefined) return item
|
if (item !== undefined) return item
|
||||||
try {
|
try {
|
||||||
item = new Player({ volume: 0.35 })
|
item = new Player({ volume: 0.35 })
|
||||||
} catch {
|
} catch {
|
||||||
item = null
|
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()
|
|
||||||
}
|
}
|
||||||
|
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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,137 +1,135 @@
|
|||||||
import { RGBA } from "@opentui/core"
|
import { RGBA } from "@opentui/core"
|
||||||
|
|
||||||
export namespace Terminal {
|
export type Colors = Awaited<ReturnType<typeof colors>>
|
||||||
export type Colors = Awaited<ReturnType<typeof colors>>
|
|
||||||
|
|
||||||
function parse(color: string): RGBA | null {
|
function parse(color: string): RGBA | null {
|
||||||
if (color.startsWith("rgb:")) {
|
if (color.startsWith("rgb:")) {
|
||||||
const parts = color.substring(4).split("/")
|
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)
|
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
|
|
||||||
}
|
}
|
||||||
|
if (color.startsWith("#")) {
|
||||||
function mode(bg: RGBA | null): "dark" | "light" {
|
return RGBA.fromHex(color)
|
||||||
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("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 mode(bg: RGBA | null): "dark" | "light" {
|
||||||
* Query terminal colors including background, foreground, and palette (0-15).
|
if (!bg) return "dark"
|
||||||
* Uses OSC escape sequences to retrieve actual terminal color values.
|
const luminance = (0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b) / 255
|
||||||
*
|
return luminance > 0.5 ? "light" : "dark"
|
||||||
* 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: [] }
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
/**
|
||||||
let background: RGBA | null = null
|
* Query terminal colors including background, foreground, and palette (0-15).
|
||||||
let foreground: RGBA | null = null
|
* Uses OSC escape sequences to retrieve actual terminal color values.
|
||||||
const paletteColors: RGBA[] = []
|
*
|
||||||
let timeout: NodeJS.Timeout
|
* 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 = () => {
|
return new Promise((resolve) => {
|
||||||
process.stdin.setRawMode(false)
|
let background: RGBA | null = null
|
||||||
process.stdin.removeListener("data", handler)
|
let foreground: RGBA | null = null
|
||||||
clearTimeout(timeout)
|
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) => {
|
// Match OSC 10 (foreground color)
|
||||||
const str = data.toString()
|
const fgMatch = str.match(/\x1b]10;([^\x07\x1b]+)/)
|
||||||
|
if (fgMatch) {
|
||||||
// Match OSC 11 (background color)
|
foreground = parse(fgMatch[1])
|
||||||
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 })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
process.stdin.setRawMode(true)
|
// Match OSC 4 (palette colors)
|
||||||
process.stdin.on("data", handler)
|
const paletteMatches = str.matchAll(/\x1b]4;(\d+);([^\x07\x1b]+)/g)
|
||||||
|
for (const match of paletteMatches) {
|
||||||
// Query background (OSC 11)
|
const index = parseInt(match[1])
|
||||||
process.stdout.write("\x1b]11;?\x07")
|
const color = parse(match[2])
|
||||||
// Query foreground (OSC 10)
|
if (color) paletteColors[index] = color
|
||||||
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(() => {
|
// Return immediately if we have all 16 palette colors
|
||||||
|
if (paletteColors.filter((c) => c !== undefined).length === 16) {
|
||||||
cleanup()
|
cleanup()
|
||||||
resolve({ background, foreground, colors: paletteColors })
|
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) => {
|
process.stdin.setRawMode(true)
|
||||||
const match = data.toString().match(/\x1b]11;([^\x07\x1b]+)/)
|
process.stdin.on("data", handler)
|
||||||
if (!match) return
|
|
||||||
cleanup()
|
|
||||||
resolve(mode(parse(match[1])))
|
|
||||||
}
|
|
||||||
|
|
||||||
process.stdin.setRawMode(true)
|
// Query background (OSC 11)
|
||||||
process.stdin.on("data", handler)
|
process.stdout.write("\x1b]11;?\x07")
|
||||||
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(() => {
|
timeout = setTimeout(() => {
|
||||||
cleanup()
|
cleanup()
|
||||||
resolve("dark")
|
resolve({ background, foreground, colors: paletteColors })
|
||||||
}, 1000)
|
}, 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)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user