feat(tui): add Node runtime adapters
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 }
|
||||
@@ -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")
|
||||
@@ -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[] => {
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
Vendored
+13
@@ -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>> }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 +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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { stringWidth } from "#string-width"
|
||||
Reference in New Issue
Block a user