feat(tui): add Node runtime adapters

This commit is contained in:
Simon Klee
2026-07-10 09:35:45 +02:00
parent 12a294c5d0
commit 87a488e3ce
20 changed files with 351 additions and 150 deletions
+4
View File
@@ -1022,10 +1022,12 @@
"diff": "catalog:",
"effect": "catalog:",
"fuzzysort": "catalog:",
"get-east-asian-width": "catalog:",
"open": "10.1.2",
"opentui-spinner": "catalog:",
"remeda": "catalog:",
"solid-js": "catalog:",
"string-width": "catalog:",
"strip-ansi": "7.1.2",
"uqr": "0.1.3",
},
@@ -1221,6 +1223,7 @@
"drizzle-orm": "1.0.0-rc.2",
"effect": "4.0.0-beta.83",
"fuzzysort": "3.1.0",
"get-east-asian-width": "1.6.0",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
"luxon": "3.6.1",
@@ -1235,6 +1238,7 @@
"solid-js": "1.9.10",
"solid-list": "0.3.0",
"sst": "4.13.1",
"string-width": "7.2.0",
"tailwindcss": "4.1.11",
"typescript": "5.8.2",
"ulid": "3.0.1",
+2
View File
@@ -75,6 +75,7 @@
"hono": "4.10.7",
"hono-openapi": "1.1.2",
"fuzzysort": "3.1.0",
"get-east-asian-width": "1.6.0",
"luxon": "3.6.1",
"marked": "17.0.6",
"marked-shiki": "1.2.1",
@@ -89,6 +90,7 @@
"sst": "4.13.1",
"shiki": "4.2.0",
"solid-list": "0.3.0",
"string-width": "7.2.0",
"tailwindcss": "4.1.11",
"vite": "7.1.4",
"@solidjs/meta": "0.29.4",
+25
View File
@@ -43,6 +43,7 @@
"./util/persistence": "./src/util/persistence.ts",
"./util/record": "./src/util/record.ts",
"./util/session": "./src/util/session.ts",
"./util/string-width": "./src/util/string-width.ts",
"./logo": "./src/logo.ts",
"./ui/dialog": "./src/ui/dialog.tsx",
"./ui/spinner": "./src/ui/spinner.ts",
@@ -50,6 +51,28 @@
"./component/spinner": "./src/component/spinner.tsx",
"./component/register-spinner": "./src/component/register-spinner.ts"
},
"imports": {
"#attention-sounds": {
"bun": "./src/attention-sounds.bun.ts",
"node": "./src/attention-sounds.node.ts",
"default": "./src/attention-sounds.bun.ts"
},
"#terminal-win32": {
"bun": "./src/terminal-win32.bun.ts",
"node": "./src/terminal-win32.node.ts",
"default": "./src/terminal-win32.bun.ts"
},
"#string-width": {
"bun": "./src/util/string-width.bun.ts",
"node": "./src/util/string-width.node.ts",
"default": "./src/util/string-width.bun.ts"
},
"#zed-sqlite": {
"bun": "./src/editor-zed-sqlite.bun.ts",
"node": "./src/editor-zed-sqlite.node.ts",
"default": "./src/editor-zed-sqlite.bun.ts"
}
},
"dependencies": {
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
@@ -64,10 +87,12 @@
"diff": "catalog:",
"effect": "catalog:",
"fuzzysort": "catalog:",
"get-east-asian-width": "catalog:",
"open": "10.1.2",
"opentui-spinner": "catalog:",
"remeda": "catalog:",
"solid-js": "catalog:",
"string-width": "catalog:",
"strip-ansi": "7.1.2",
"uqr": "0.1.3"
},
+8
View File
@@ -0,0 +1,8 @@
/// <reference path="./audio.d.ts" />
import defaultSoundPath from "@opencode-ai/ui/audio/bip-bop-01.mp3" with { type: "file" }
import questionSoundPath from "@opencode-ai/ui/audio/bip-bop-03.mp3" with { type: "file" }
import permissionSoundPath from "@opencode-ai/ui/audio/staplebops-06.mp3" with { type: "file" }
import errorSoundPath from "@opencode-ai/ui/audio/nope-03.mp3" with { type: "file" }
import subagentDoneSoundPath from "@opencode-ai/ui/audio/yup-01.mp3" with { type: "file" }
export { defaultSoundPath, questionSoundPath, permissionSoundPath, errorSoundPath, subagentDoneSoundPath }
+16
View File
@@ -0,0 +1,16 @@
import { createRequire } from "node:module"
import path from "node:path"
const require = createRequire(import.meta.url)
const resolve = (name: string) => {
const key = `@opencode-ai/ui/audio/${name}`
return process.env.OPENCODE_NODE_ASSETS_DIR
? path.join(process.env.OPENCODE_NODE_ASSETS_DIR, key)
: require.resolve(key)
}
export const defaultSoundPath = resolve("bip-bop-01.mp3")
export const questionSoundPath = resolve("bip-bop-03.mp3")
export const permissionSoundPath = resolve("staplebops-06.mp3")
export const errorSoundPath = resolve("nope-03.mp3")
export const subagentDoneSoundPath = resolve("yup-01.mp3")
+8 -7
View File
@@ -14,12 +14,13 @@ import { AttentionSoundName, type Config } from "./config"
import { Schema } from "effect"
import stripAnsi from "strip-ansi"
import * as TuiAudio from "./audio"
import defaultSoundPath from "@opencode-ai/ui/audio/bip-bop-01.mp3" with { type: "file" }
import questionSoundPath from "@opencode-ai/ui/audio/bip-bop-03.mp3" with { type: "file" }
import permissionSoundPath from "@opencode-ai/ui/audio/staplebops-06.mp3" with { type: "file" }
import errorSoundPath from "@opencode-ai/ui/audio/nope-03.mp3" with { type: "file" }
import doneSoundPath from "@opencode-ai/ui/audio/bip-bop-01.mp3" with { type: "file" }
import subagentDoneSoundPath from "@opencode-ai/ui/audio/yup-01.mp3" with { type: "file" }
import {
defaultSoundPath,
questionSoundPath,
permissionSoundPath,
errorSoundPath,
subagentDoneSoundPath,
} from "#attention-sounds"
type FocusState = "unknown" | "focused" | "blurred"
@@ -51,7 +52,7 @@ const BUILTIN_PACK: RegisteredSoundPack = {
question: questionSoundPath,
permission: permissionSoundPath,
error: errorSoundPath,
done: doneSoundPath,
done: defaultSoundPath,
subagent_done: subagentDoneSoundPath,
},
}
@@ -1,5 +1,5 @@
import type { BoxRenderable, TextareaRenderable, ScrollBoxRenderable } from "@opentui/core"
import { pathToFileURL } from "bun"
import { pathToFileURL } from "node:url"
import fuzzysort from "fuzzysort"
import path from "path"
import { firstBy } from "remeda"
@@ -22,6 +22,7 @@ import { useBindings } from "../../keymap"
import { Keymap } from "../../context/keymap"
import { displayCharAt, mentionTriggerIndex } from "../../prompt/display"
import type { FileSystemEntry } from "@opencode-ai/client"
import { stringWidth } from "../../util/string-width"
function removeLineRange(input: string) {
const hashIndex = input.lastIndexOf("#")
@@ -189,7 +190,7 @@ export function Autocomplete(props: {
const virtualText = "@" + text
const extmarkStart = store.index
const extmarkEnd = extmarkStart + Bun.stringWidth(virtualText)
const extmarkEnd = extmarkStart + stringWidth(virtualText)
const styleId = part.type === "file" ? props.fileStyleId : props.agentStyleId
@@ -432,7 +433,7 @@ export function Autocomplete(props: {
const cursor = props.input().logicalCursor
props.input().deleteRange(0, 0, cursor.row, cursor.col)
props.input().insertText(newText)
props.input().cursorOffset = Bun.stringWidth(newText)
props.input().cursorOffset = stringWidth(newText)
}
const commands = createMemo((): AutocompleteOption[] => {
+2 -1
View File
@@ -28,6 +28,7 @@ import { editorSelectionKey, useEditorContext, type EditorSelection } from "../.
import { normalizePromptContent, openEditor } from "../../editor"
import { useExit } from "../../context/exit"
import { promptOffsetWidth } from "../../prompt/display"
import { stringWidth } from "../../util/string-width"
import { createStore, produce, unwrap } from "solid-js/store"
import { emptyPrompt, usePromptHistory, type PromptInfo, type PromptPartRef } from "../../prompt/history"
import { computePromptTraits } from "../../prompt/traits"
@@ -530,7 +531,7 @@ export function Prompt(props: PromptProps) {
pasted: [],
})
restoreExtmarksFromPrompt(store.prompt)
input.cursorOffset = Bun.stringWidth(normalized)
input.cursorOffset = stringWidth(normalized)
},
},
{
@@ -0,0 +1 @@
export { Database } from "bun:sqlite"
@@ -0,0 +1,21 @@
import { DatabaseSync, type SQLInputValue } from "node:sqlite"
export class Database {
readonly #database: DatabaseSync
constructor(file: string, options?: { readonly?: boolean }) {
this.#database = new DatabaseSync(file, { readOnly: options?.readonly })
}
query(sql: string) {
const statement = this.#database.prepare(sql)
return {
all: (parameters?: Record<string, SQLInputValue>) => (parameters ? statement.all(parameters) : statement.all()),
get: (parameters?: Record<string, SQLInputValue>) => (parameters ? statement.get(parameters) : statement.get()),
}
}
close() {
this.#database.close()
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { Database } from "bun:sqlite"
import { Database } from "#zed-sqlite"
import { statSync } from "node:fs"
import { readFile as readFileAsync } from "node:fs/promises"
import os from "node:os"
+13
View File
@@ -0,0 +1,13 @@
declare module "node:ffi" {
type Signature = {
readonly arguments?: readonly string[]
readonly return?: string
}
type ForeignFunction = (...args: ReadonlyArray<unknown>) => number | bigint
export function dlopen(
path: string,
definitions: Readonly<Record<string, Signature>>,
): { readonly functions: Readonly<Record<string, ForeignFunction>> }
}
+4 -2
View File
@@ -1,10 +1,12 @@
import { stringWidth } from "../util/string-width"
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" })
export function promptOffsetWidth(value: string) {
let width = 0
for (const part of graphemes.segment(value)) {
// Textarea offsets count newlines as one position; Bun.stringWidth counts them as zero.
width += part.segment === "\n" ? 1 : Bun.stringWidth(part.segment)
// Textarea offsets count newlines as one position; terminal width counts them as zero.
width += part.segment === "\n" ? 1 : stringWidth(part.segment)
}
return width
}
+130
View File
@@ -0,0 +1,130 @@
import { dlopen, ptr } from "bun:ffi"
import type { ReadStream } from "node:tty"
const STD_INPUT_HANDLE = -10
const ENABLE_PROCESSED_INPUT = 0x0001
const kernel = () =>
dlopen("kernel32.dll", {
GetStdHandle: { args: ["i32"], returns: "ptr" },
GetConsoleMode: { args: ["ptr", "ptr"], returns: "i32" },
SetConsoleMode: { args: ["ptr", "u32"], returns: "i32" },
FlushConsoleInputBuffer: { args: ["ptr"], returns: "i32" },
})
let k32: ReturnType<typeof kernel> | undefined
function load() {
if (process.platform !== "win32") return false
try {
k32 ??= kernel()
return true
} catch {
return false
}
}
/**
* Clear ENABLE_PROCESSED_INPUT on the console stdin handle.
*/
export function win32DisableProcessedInput() {
if (process.platform !== "win32") return
if (!process.stdin.isTTY) return
if (!load()) return
const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE)
const buf = new Uint32Array(1)
if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return
const mode = buf[0]!
if ((mode & ENABLE_PROCESSED_INPUT) === 0) return
k32!.symbols.SetConsoleMode(handle, mode & ~ENABLE_PROCESSED_INPUT)
}
/**
* Discard any queued console input (mouse events, key presses, etc.).
*/
export function win32FlushInputBuffer() {
if (process.platform !== "win32") return
if (!process.stdin.isTTY) return
if (!load()) return
const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE)
k32!.symbols.FlushConsoleInputBuffer(handle)
}
let unhook: (() => void) | undefined
/**
* Keep ENABLE_PROCESSED_INPUT disabled.
*
* On Windows, Ctrl+C becomes a CTRL_C_EVENT (instead of stdin input) when
* ENABLE_PROCESSED_INPUT is set. Various runtimes can re-apply console modes
* (sometimes on a later tick), and the flag is console-global, not per-process.
*
* We combine:
* - A `setRawMode(...)` hook to re-clear after known raw-mode toggles.
* - A low-frequency poll as a backstop for native/external mode changes.
*/
export function win32InstallCtrlCGuard() {
if (process.platform !== "win32") return
if (!process.stdin.isTTY) return
if (!load()) return
if (unhook) return unhook
const stdin = process.stdin as ReadStream
const original = stdin.setRawMode
const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE)
const buf = new Uint32Array(1)
if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return
const initial = buf[0]!
const enforce = () => {
if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return
const mode = buf[0]!
if ((mode & ENABLE_PROCESSED_INPUT) === 0) return
k32!.symbols.SetConsoleMode(handle, mode & ~ENABLE_PROCESSED_INPUT)
}
// Some runtimes can re-apply console modes on the next tick; enforce twice.
const later = () => {
enforce()
setImmediate(enforce)
}
let wrapped: ReadStream["setRawMode"] | undefined
if (typeof original === "function") {
wrapped = (mode: boolean) => {
const result = original.call(stdin, mode)
later()
return result
}
stdin.setRawMode = wrapped
}
// Ensure it's cleared immediately too (covers any earlier mode changes).
later()
const interval = setInterval(enforce, 100)
interval.unref()
let done = false
unhook = () => {
if (done) return
done = true
clearInterval(interval)
if (wrapped && stdin.setRawMode === wrapped) {
stdin.setRawMode = original
}
k32!.symbols.SetConsoleMode(handle, initial)
unhook = undefined
}
return unhook
}
+77
View File
@@ -0,0 +1,77 @@
import { dlopen } from "node:ffi"
import type { ReadStream } from "node:tty"
const STD_INPUT_HANDLE = -10
const ENABLE_PROCESSED_INPUT = 0x0001
const kernel = () =>
dlopen("kernel32.dll", {
GetStdHandle: { arguments: ["i32"], return: "pointer" },
GetConsoleMode: { arguments: ["pointer", "pointer"], return: "i32" },
SetConsoleMode: { arguments: ["pointer", "u32"], return: "i32" },
FlushConsoleInputBuffer: { arguments: ["pointer"], return: "i32" },
}).functions
let k32: ReturnType<typeof kernel> | undefined
function load() {
if (process.platform !== "win32") return false
try {
k32 ??= kernel()
return true
} catch {
return false
}
}
export function win32DisableProcessedInput() {
if (process.platform !== "win32" || !process.stdin.isTTY || !load()) return
const handle = k32!.GetStdHandle(STD_INPUT_HANDLE)
const buffer = new Uint32Array(1)
if (k32!.GetConsoleMode(handle, buffer) === 0) return
const mode = buffer[0]!
if ((mode & ENABLE_PROCESSED_INPUT) === 0) return
k32!.SetConsoleMode(handle, mode & ~ENABLE_PROCESSED_INPUT)
}
export function win32FlushInputBuffer() {
if (process.platform !== "win32" || !process.stdin.isTTY || !load()) return
k32!.FlushConsoleInputBuffer(k32!.GetStdHandle(STD_INPUT_HANDLE))
}
let unhook: (() => void) | undefined
export function win32InstallCtrlCGuard() {
if (process.platform !== "win32" || !process.stdin.isTTY || !load() || unhook) return unhook
const stdin = process.stdin as ReadStream
const original = stdin.setRawMode
const handle = k32!.GetStdHandle(STD_INPUT_HANDLE)
const buffer = new Uint32Array(1)
if (k32!.GetConsoleMode(handle, buffer) === 0) return
const initial = buffer[0]!
const enforce = () => {
if (k32!.GetConsoleMode(handle, buffer) === 0) return
const mode = buffer[0]!
if ((mode & ENABLE_PROCESSED_INPUT) !== 0) k32!.SetConsoleMode(handle, mode & ~ENABLE_PROCESSED_INPUT)
}
const later = () => {
enforce()
setImmediate(enforce)
}
const wrapped: ReadStream["setRawMode"] = (mode) => {
const result = original.call(stdin, mode)
later()
return result
}
stdin.setRawMode = wrapped
later()
const interval = setInterval(enforce, 100)
interval.unref()
unhook = () => {
clearInterval(interval)
if (stdin.setRawMode === wrapped) stdin.setRawMode = original
k32!.SetConsoleMode(handle, initial)
unhook = undefined
}
return unhook
}
+1 -130
View File
@@ -1,130 +1 @@
import { dlopen, ptr } from "bun:ffi"
import type { ReadStream } from "node:tty"
const STD_INPUT_HANDLE = -10
const ENABLE_PROCESSED_INPUT = 0x0001
const kernel = () =>
dlopen("kernel32.dll", {
GetStdHandle: { args: ["i32"], returns: "ptr" },
GetConsoleMode: { args: ["ptr", "ptr"], returns: "i32" },
SetConsoleMode: { args: ["ptr", "u32"], returns: "i32" },
FlushConsoleInputBuffer: { args: ["ptr"], returns: "i32" },
})
let k32: ReturnType<typeof kernel> | undefined
function load() {
if (process.platform !== "win32") return false
try {
k32 ??= kernel()
return true
} catch {
return false
}
}
/**
* Clear ENABLE_PROCESSED_INPUT on the console stdin handle.
*/
export function win32DisableProcessedInput() {
if (process.platform !== "win32") return
if (!process.stdin.isTTY) return
if (!load()) return
const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE)
const buf = new Uint32Array(1)
if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return
const mode = buf[0]!
if ((mode & ENABLE_PROCESSED_INPUT) === 0) return
k32!.symbols.SetConsoleMode(handle, mode & ~ENABLE_PROCESSED_INPUT)
}
/**
* Discard any queued console input (mouse events, key presses, etc.).
*/
export function win32FlushInputBuffer() {
if (process.platform !== "win32") return
if (!process.stdin.isTTY) return
if (!load()) return
const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE)
k32!.symbols.FlushConsoleInputBuffer(handle)
}
let unhook: (() => void) | undefined
/**
* Keep ENABLE_PROCESSED_INPUT disabled.
*
* On Windows, Ctrl+C becomes a CTRL_C_EVENT (instead of stdin input) when
* ENABLE_PROCESSED_INPUT is set. Various runtimes can re-apply console modes
* (sometimes on a later tick), and the flag is console-global, not per-process.
*
* We combine:
* - A `setRawMode(...)` hook to re-clear after known raw-mode toggles.
* - A low-frequency poll as a backstop for native/external mode changes.
*/
export function win32InstallCtrlCGuard() {
if (process.platform !== "win32") return
if (!process.stdin.isTTY) return
if (!load()) return
if (unhook) return unhook
const stdin = process.stdin as ReadStream
const original = stdin.setRawMode
const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE)
const buf = new Uint32Array(1)
if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return
const initial = buf[0]!
const enforce = () => {
if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return
const mode = buf[0]!
if ((mode & ENABLE_PROCESSED_INPUT) === 0) return
k32!.symbols.SetConsoleMode(handle, mode & ~ENABLE_PROCESSED_INPUT)
}
// Some runtimes can re-apply console modes on the next tick; enforce twice.
const later = () => {
enforce()
setImmediate(enforce)
}
let wrapped: ReadStream["setRawMode"] | undefined
if (typeof original === "function") {
wrapped = (mode: boolean) => {
const result = original.call(stdin, mode)
later()
return result
}
stdin.setRawMode = wrapped
}
// Ensure it's cleared immediately too (covers any earlier mode changes).
later()
const interval = setInterval(enforce, 100)
interval.unref()
let done = false
unhook = () => {
if (done) return
done = true
clearInterval(interval)
if (wrapped && stdin.setRawMode === wrapped) {
stdin.setRawMode = original
}
k32!.symbols.SetConsoleMode(handle, initial)
unhook = undefined
}
return unhook
}
export { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "#terminal-win32"
+6 -6
View File
@@ -1,17 +1,17 @@
import path from "path"
import { appendFile, mkdir, rename, rm } from "fs/promises"
import { appendFile, mkdir, readFile, rename, rm, writeFile } from "fs/promises"
export function readText(filePath: string) {
return Bun.file(filePath).text()
return readFile(filePath, "utf8")
}
export function readJson<T>(filePath: string) {
return Bun.file(filePath).json() as Promise<T>
export async function readJson<T>(filePath: string) {
return JSON.parse(await readFile(filePath, "utf8")) as T
}
export async function writeText(filePath: string, content: string) {
await mkdir(path.dirname(filePath), { recursive: true })
await Bun.write(filePath, content)
await writeFile(filePath, content)
}
export async function appendText(filePath: string, content: string) {
@@ -22,7 +22,7 @@ export async function appendText(filePath: string, content: string) {
export async function writeJsonAtomic(filePath: string, value: unknown) {
await mkdir(path.dirname(filePath), { recursive: true })
const temporary = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`
await Bun.write(temporary, JSON.stringify(value)).catch(async (error) => {
await writeFile(temporary, JSON.stringify(value)).catch(async (error) => {
await rm(temporary, { force: true }).catch(() => undefined)
throw error
})
@@ -0,0 +1 @@
export const stringWidth = Bun.stringWidth
@@ -0,0 +1,26 @@
import measure from "string-width"
import stripAnsi from "strip-ansi"
import { eastAsianWidth } from "get-east-asian-width"
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" })
const textEmoji = /^\p{Emoji}\p{Mark}*$/u
const emojiPresentation = /^\p{Emoji_Presentation}/u
export function stringWidth(value: string) {
return Array.from(graphemes.segment(stripAnsi(value))).reduce((total, part) => {
const width = measure(part.segment)
const codePoint = part.segment.codePointAt(0)
if (
width !== 2 ||
codePoint === undefined ||
eastAsianWidth(codePoint) === 2 ||
!textEmoji.test(part.segment) ||
emojiPresentation.test(part.segment) ||
part.segment.includes("\uFE0F") ||
part.segment.includes("\u20E3") ||
part.segment.includes("\u200D")
)
return total + width
return total + 1
}, 0)
}
+1
View File
@@ -0,0 +1 @@
export { stringWidth } from "#string-width"