diff --git a/bun.lock b/bun.lock
index 571f91a3cd..a61606885c 100644
--- a/bun.lock
+++ b/bun.lock
@@ -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",
diff --git a/package.json b/package.json
index ab4731e1b3..20dce22672 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/packages/tui/package.json b/packages/tui/package.json
index c0c361d556..5165869b14 100644
--- a/packages/tui/package.json
+++ b/packages/tui/package.json
@@ -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"
},
diff --git a/packages/tui/src/attention-sounds.bun.ts b/packages/tui/src/attention-sounds.bun.ts
new file mode 100644
index 0000000000..09aa5fe82a
--- /dev/null
+++ b/packages/tui/src/attention-sounds.bun.ts
@@ -0,0 +1,8 @@
+///
+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 }
diff --git a/packages/tui/src/attention-sounds.node.ts b/packages/tui/src/attention-sounds.node.ts
new file mode 100644
index 0000000000..5169eabfa5
--- /dev/null
+++ b/packages/tui/src/attention-sounds.node.ts
@@ -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")
diff --git a/packages/tui/src/attention.ts b/packages/tui/src/attention.ts
index e210a745d6..7eac2e0646 100644
--- a/packages/tui/src/attention.ts
+++ b/packages/tui/src/attention.ts
@@ -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,
},
}
diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx
index 545f8099d6..65fb78f0f9 100644
--- a/packages/tui/src/component/prompt/autocomplete.tsx
+++ b/packages/tui/src/component/prompt/autocomplete.tsx
@@ -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[] => {
diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx
index fb0a6e2fca..4bd160fc12 100644
--- a/packages/tui/src/component/prompt/index.tsx
+++ b/packages/tui/src/component/prompt/index.tsx
@@ -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)
},
},
{
diff --git a/packages/tui/src/editor-zed-sqlite.bun.ts b/packages/tui/src/editor-zed-sqlite.bun.ts
new file mode 100644
index 0000000000..5a2e8ee121
--- /dev/null
+++ b/packages/tui/src/editor-zed-sqlite.bun.ts
@@ -0,0 +1 @@
+export { Database } from "bun:sqlite"
diff --git a/packages/tui/src/editor-zed-sqlite.node.ts b/packages/tui/src/editor-zed-sqlite.node.ts
new file mode 100644
index 0000000000..f8ae7a0682
--- /dev/null
+++ b/packages/tui/src/editor-zed-sqlite.node.ts
@@ -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) => (parameters ? statement.all(parameters) : statement.all()),
+ get: (parameters?: Record) => (parameters ? statement.get(parameters) : statement.get()),
+ }
+ }
+
+ close() {
+ this.#database.close()
+ }
+}
diff --git a/packages/tui/src/editor-zed.ts b/packages/tui/src/editor-zed.ts
index 8ef53ab443..6553978046 100644
--- a/packages/tui/src/editor-zed.ts
+++ b/packages/tui/src/editor-zed.ts
@@ -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"
diff --git a/packages/tui/src/node-ffi.d.ts b/packages/tui/src/node-ffi.d.ts
new file mode 100644
index 0000000000..297cd3dadf
--- /dev/null
+++ b/packages/tui/src/node-ffi.d.ts
@@ -0,0 +1,13 @@
+declare module "node:ffi" {
+ type Signature = {
+ readonly arguments?: readonly string[]
+ readonly return?: string
+ }
+
+ type ForeignFunction = (...args: ReadonlyArray) => number | bigint
+
+ export function dlopen(
+ path: string,
+ definitions: Readonly>,
+ ): { readonly functions: Readonly> }
+}
diff --git a/packages/tui/src/prompt/display.ts b/packages/tui/src/prompt/display.ts
index 4c22942ea8..94bbc7e15f 100644
--- a/packages/tui/src/prompt/display.ts
+++ b/packages/tui/src/prompt/display.ts
@@ -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
}
diff --git a/packages/tui/src/terminal-win32.bun.ts b/packages/tui/src/terminal-win32.bun.ts
new file mode 100644
index 0000000000..1aaa80aecd
--- /dev/null
+++ b/packages/tui/src/terminal-win32.bun.ts
@@ -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 | 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
+}
diff --git a/packages/tui/src/terminal-win32.node.ts b/packages/tui/src/terminal-win32.node.ts
new file mode 100644
index 0000000000..11f2f9c993
--- /dev/null
+++ b/packages/tui/src/terminal-win32.node.ts
@@ -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 | 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
+}
diff --git a/packages/tui/src/terminal-win32.ts b/packages/tui/src/terminal-win32.ts
index 1aaa80aecd..e58c0e6148 100644
--- a/packages/tui/src/terminal-win32.ts
+++ b/packages/tui/src/terminal-win32.ts
@@ -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 | 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"
diff --git a/packages/tui/src/util/persistence.ts b/packages/tui/src/util/persistence.ts
index 1f7c283ca1..d013628218 100644
--- a/packages/tui/src/util/persistence.ts
+++ b/packages/tui/src/util/persistence.ts
@@ -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(filePath: string) {
- return Bun.file(filePath).json() as Promise
+export async function readJson(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
})
diff --git a/packages/tui/src/util/string-width.bun.ts b/packages/tui/src/util/string-width.bun.ts
new file mode 100644
index 0000000000..6e3df4cda8
--- /dev/null
+++ b/packages/tui/src/util/string-width.bun.ts
@@ -0,0 +1 @@
+export const stringWidth = Bun.stringWidth
diff --git a/packages/tui/src/util/string-width.node.ts b/packages/tui/src/util/string-width.node.ts
new file mode 100644
index 0000000000..10d7299d18
--- /dev/null
+++ b/packages/tui/src/util/string-width.node.ts
@@ -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)
+}
diff --git a/packages/tui/src/util/string-width.ts b/packages/tui/src/util/string-width.ts
new file mode 100644
index 0000000000..09c1ffd392
--- /dev/null
+++ b/packages/tui/src/util/string-width.ts
@@ -0,0 +1 @@
+export { stringWidth } from "#string-width"