node v2 cli support (#36309)

This commit is contained in:
Simon Klee 2026-07-17 14:45:06 +02:00 committed by GitHub
commit a1b274e6f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
75 changed files with 1502 additions and 367 deletions

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 }

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")

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,
},
}

View file

@ -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"
@ -21,6 +21,7 @@ import { useFrecency } from "../../prompt/frecency"
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("#")
@ -188,7 +189,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
@ -431,7 +432,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[] => {

View file

@ -26,6 +26,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"
@ -529,7 +530,7 @@ export function Prompt(props: PromptProps) {
pasted: [],
})
restoreExtmarksFromPrompt(store.prompt)
input.cursorOffset = Bun.stringWidth(normalized)
input.cursorOffset = stringWidth(normalized)
},
},
{

View file

@ -0,0 +1 @@
export { Database } from "bun:sqlite"

View file

@ -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()
}
}

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"

View file

@ -6,6 +6,7 @@ import { useTuiPaths } from "../../context/runtime"
import { useTheme } from "../../context/theme"
import { abbreviateHome } from "../../runtime"
import { FilePath } from "../../ui/file-path"
import { stringWidth } from "../../util/string-width"
function Directory(props: { context: Plugin.Context; maxWidth: number }) {
const { themeV2 } = useTheme()
@ -56,7 +57,7 @@ function View(props: { context: Plugin.Context }) {
const list = props.context.data.location.mcp.server.list(props.context.location) ?? []
if (list.length === 0) return 0
const count = list.filter((item) => item.status.status === "connected").length
return Bun.stringWidth(`${count} MCP /status`) + 2
return stringWidth(`${count} MCP /status`) + 2
})
return (
@ -72,7 +73,7 @@ function View(props: { context: Plugin.Context }) {
>
<Directory
context={props.context}
maxWidth={Math.max(2, dimensions().width - 8 - Bun.stringWidth(InstallationVersion) - mcpWidth())}
maxWidth={Math.max(2, dimensions().width - 8 - stringWidth(InstallationVersion) - mcpWidth())}
/>
<Mcp context={props.context} />
<box flexGrow={1} />

13
packages/tui/src/node-ffi.d.ts vendored Normal file
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>> }
}

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
}

View file

@ -75,6 +75,7 @@ import { createSessionRows, messageBoundaryIDs, resolvePart, type PartRef, type
import { switchLabel } from "../../util/model"
import { findMessageBoundary, messageNavigationSlack } from "./message-navigation"
import type { ComponentTheme } from "../../theme/v2/component"
import { stringWidth } from "../../util/string-width"
addDefaultParsers(parsers.parsers)
@ -1404,8 +1405,7 @@ function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
return state() ?? "finished"
}
const heading = () => `${state() === "completed" ? "↳" : "!"} ${actor()} ${status()}`
const suffix = () =>
Locale.truncateWidth(` · ${description()}`, Math.max(0, ctx.width - 3 - Bun.stringWidth(heading())))
const suffix = () => Locale.truncateWidth(` · ${description()}`, Math.max(0, ctx.width - 3 - stringWidth(heading())))
const color = () => {
if (state() === "error") return themeV2.text.feedback.error()
if (state() === "cancelled") return themeV2.text.feedback.warning()
@ -1561,8 +1561,8 @@ function RevertMessage(props: {
2,
ctx.width -
5 -
(file.additions > 0 ? Bun.stringWidth(`+${file.additions}`) + 1 : 0) -
(file.deletions > 0 ? Bun.stringWidth(`-${file.deletions}`) + 1 : 0),
(file.additions > 0 ? stringWidth(`+${file.additions}`) + 1 : 0) -
(file.deletions > 0 ? stringWidth(`-${file.deletions}`) + 1 : 0),
)}
fg={themeV2.text()}
/>
@ -2383,7 +2383,7 @@ function BlockTool(props: {
</Show>
<FilePath
value={path().value}
maxWidth={Math.max(2, ctx.width - 4 - Bun.stringWidth(path().label) - (props.spinner ? 2 : 0))}
maxWidth={Math.max(2, ctx.width - 4 - stringWidth(path().label) - (props.spinner ? 2 : 0))}
fg={permission() ? themeV2.text.feedback.warning() : themeV2.text.subdued()}
/>
</box>

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
}

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
}

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"

View file

@ -1,5 +1,6 @@
import type { RGBA } from "@opentui/core"
import { createMemo } from "solid-js"
import { stringWidth } from "../util/string-width"
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" })
@ -29,7 +30,7 @@ export function FilePath(props: FilePathProps) {
export function truncateFilePath(value: string, maxWidth: number) {
if (maxWidth <= 0) return ""
if (Bun.stringWidth(value) <= maxWidth) return value
if (stringWidth(value) <= maxWidth) return value
const drive = value.match(/^([A-Za-z]:)([\\/])/)
const unc = value.match(/^(\\\\|\/\/)([^\\/]+)[\\/]([^\\/]+)(?:[\\/]|$)/)
@ -46,22 +47,22 @@ export function truncateFilePath(value: string, maxWidth: number) {
const segments = source.split(windows ? /[\\/]/ : separator).filter(Boolean)
const basename = segments.at(-1) ?? value
if (segments.length < 2) {
const rootWidth = Bun.stringWidth(root)
const rootWidth = stringWidth(root)
if (rootWidth >= maxWidth) return takeStart(root, maxWidth)
return root + truncateBasename(basename, maxWidth - rootWidth)
}
const prefix = `${root}${separator}`
const basenameWidth = maxWidth - Bun.stringWidth(prefix)
const basenameWidth = maxWidth - stringWidth(prefix)
if (basenameWidth <= 0) return takeStart(prefix, maxWidth)
const compact = truncateBasename(basename, basenameWidth)
if (compact !== basename) return prefix + compact
const selected = [basename]
const separatorWidth = Bun.stringWidth(separator)
let width = Bun.stringWidth(prefix + basename)
const separatorWidth = stringWidth(separator)
let width = stringWidth(prefix + basename)
for (let index = segments.length - 2; index >= 0; index--) {
const next = Bun.stringWidth(segments[index]!) + separatorWidth
const next = stringWidth(segments[index]!) + separatorWidth
if (width + next > maxWidth) break
selected.unshift(segments[index]!)
width += next
@ -70,12 +71,12 @@ export function truncateFilePath(value: string, maxWidth: number) {
}
function truncateBasename(value: string, maxWidth: number) {
if (Bun.stringWidth(value) <= maxWidth) return value
if (stringWidth(value) <= maxWidth) return value
if (maxWidth <= 1) return takeStart("…", maxWidth)
const dot = value.lastIndexOf(".")
const extension = dot > 0 ? value.slice(dot) : ""
const extensionWidth = Bun.stringWidth(extension)
const extensionWidth = stringWidth(extension)
if (extensionWidth >= maxWidth) return "…" + takeEnd(extension, maxWidth - 1)
const stem = extension ? value.slice(0, dot) : value
@ -96,7 +97,7 @@ function take(value: string, maxWidth: number, reverse: boolean) {
const selected: string[] = []
let width = 0
for (const segment of segments) {
const next = Bun.stringWidth(segment)
const next = stringWidth(segment)
if (width + next > maxWidth) break
selected.push(segment)
width += next

View file

@ -1,3 +1,5 @@
import { stringWidth } from "./string-width"
export function titlecase(str: string) {
return str.replace(/\b\w/g, (c) => c.toUpperCase())
}
@ -67,13 +69,13 @@ const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme
export function truncateWidth(str: string, width: number): string {
if (width <= 0) return ""
if (Bun.stringWidth(str) <= width) return str
if (stringWidth(str) <= width) return str
if (width === 1) return "…"
const result: string[] = []
let used = 0
for (const item of graphemeSegmenter.segment(str)) {
const next = Bun.stringWidth(item.segment)
const next = stringWidth(item.segment)
if (used + next > width - 1) break
result.push(item.segment)
used += next

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
})

View file

@ -0,0 +1 @@
export const stringWidth = Bun.stringWidth

View file

@ -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)
}

View file

@ -0,0 +1 @@
export { stringWidth } from "#string-width"