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"