Forráskód Böngészése

feat: unwrap cli-tui namespaces to flat exports + barrel (#22759)

Kit Langton 17 órája
szülő
commit
f6cc228684

+ 3 - 3
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 {

+ 1 - 1
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"
 

+ 1 - 1
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"

+ 1 - 1
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):

+ 2 - 2
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"

+ 1 - 1
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"
 

+ 2 - 2
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"

+ 1 - 1
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<{

+ 136 - 138
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<Content | undefined> {
-    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<Content | undefined> {
+  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(() => {})
-      }
+  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" }
-        }
+  // 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" }
-      }
+  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 text = await clipboardy.read().catch(() => {})
-    if (text) {
-      return { data: text, mime: "text/plain" }
+    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 getCopyMethod = lazy(() => {
-    const os = platform()
+  const text = await clipboardy.read().catch(() => {})
+  if (text) {
+    return { data: text, mime: "text/plain" }
+  }
+}
 
-    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 })
-      }
+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) => {
-          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 (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("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 (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 (os === "win32") {
-      console.log("clipboard: using powershell")
+    if (which("xsel")) {
+      console.log("clipboard: using xsel")
       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(["xsel", "--clipboard", "--input"], {
+          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")
+  if (os === "win32") {
+    console.log("clipboard: using powershell")
     return async (text: string) => {
-      await clipboardy.write(text).catch(() => {})
+      // 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(() => {})
     }
-  })
+  }
 
-  export async function copy(text: string): Promise<void> {
-    writeOsc52(text)
-    await getCopyMethod()(text)
+  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)
 }

+ 22 - 24
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<string | undefined> {
-    const editor = process.env["VISUAL"] || process.env["EDITOR"]
-    if (!editor) return
+export async function open(opts: { value: string; renderer: CliRenderer }): Promise<string | undefined> {
+  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()
   }
 }

+ 5 - 0
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"

+ 9 - 11
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
 }

+ 96 - 98
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<typeof setTimeout> | 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
+let item: Player | null | undefined
+let kind: Kind | null | undefined
+let proc: Process.Child | undefined
+let tail: ReturnType<typeof setTimeout> | 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
-  }
+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 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 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 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 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)
-  }
+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 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 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 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()
-  }
+export function dispose() {
+  stop()
 }

+ 114 - 116
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<ReturnType<typeof colors>>
+export type Colors = Awaited<ReturnType<typeof colors>>
 
-  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: [] }
-
-    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)
-      }
+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"
+}
+
+/**
+ * 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: [] }
+
+  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()
 
-      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 11 (background color)
+      const bgMatch = str.match(/\x1b]11;([^\x07\x1b]+)/)
+      if (bgMatch) {
+        background = parse(bgMatch[1])
       }
 
-      process.stdin.setRawMode(true)
-      process.stdin.on("data", handler)
+      // Match OSC 10 (foreground color)
+      const fgMatch = str.match(/\x1b]10;([^\x07\x1b]+)/)
+      if (fgMatch) {
+        foreground = parse(fgMatch[1])
+      }
 
-      // 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"
+    process.stdin.setRawMode(true)
+    process.stdin.on("data", handler)
 
-    return new Promise((resolve) => {
-      let timeout: NodeJS.Timeout
+    // 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`)
+    }
 
-      const cleanup = () => {
-        process.stdin.setRawMode(false)
-        process.stdin.removeListener("data", handler)
-        clearTimeout(timeout)
-      }
+    timeout = setTimeout(() => {
+      cleanup()
+      resolve({ background, foreground, colors: paletteColors })
+    }, 1000)
+  })
+}
 
-      const handler = (data: Buffer) => {
-        const match = data.toString().match(/\x1b]11;([^\x07\x1b]+)/)
-        if (!match) return
-        cleanup()
-        resolve(mode(parse(match[1])))
-      }
+// 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"
 
-      process.stdin.setRawMode(true)
-      process.stdin.on("data", handler)
-      process.stdout.write("\x1b]11;?\x07")
+  return new Promise((resolve) => {
+    let timeout: NodeJS.Timeout
 
-      timeout = setTimeout(() => {
-        cleanup()
-        resolve("dark")
-      }, 1000)
-    })
-  }
+    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)
+  })
 }