node v2 cli support (#36309)
This commit is contained in:
parent
27e1692848
commit
a1b274e6f8
75 changed files with 1502 additions and 367 deletions
8
packages/tui/src/attention-sounds.bun.ts
Normal file
8
packages/tui/src/attention-sounds.bun.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/// <reference path="./audio.d.ts" />
|
||||
import defaultSoundPath from "@opencode-ai/ui/audio/bip-bop-01.mp3" with { type: "file" }
|
||||
import questionSoundPath from "@opencode-ai/ui/audio/bip-bop-03.mp3" with { type: "file" }
|
||||
import permissionSoundPath from "@opencode-ai/ui/audio/staplebops-06.mp3" with { type: "file" }
|
||||
import errorSoundPath from "@opencode-ai/ui/audio/nope-03.mp3" with { type: "file" }
|
||||
import subagentDoneSoundPath from "@opencode-ai/ui/audio/yup-01.mp3" with { type: "file" }
|
||||
|
||||
export { defaultSoundPath, questionSoundPath, permissionSoundPath, errorSoundPath, subagentDoneSoundPath }
|
||||
16
packages/tui/src/attention-sounds.node.ts
Normal file
16
packages/tui/src/attention-sounds.node.ts
Normal 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")
|
||||
|
|
@ -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"
|
||||
|
|
@ -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[] => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
1
packages/tui/src/editor-zed-sqlite.bun.ts
Normal file
1
packages/tui/src/editor-zed-sqlite.bun.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { Database } from "bun:sqlite"
|
||||
21
packages/tui/src/editor-zed-sqlite.node.ts
Normal file
21
packages/tui/src/editor-zed-sqlite.node.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
13
packages/tui/src/node-ffi.d.ts
vendored
Normal 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>> }
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
130
packages/tui/src/terminal-win32.bun.ts
Normal file
130
packages/tui/src/terminal-win32.bun.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { dlopen, ptr } from "bun:ffi"
|
||||
import type { ReadStream } from "node:tty"
|
||||
|
||||
const STD_INPUT_HANDLE = -10
|
||||
const ENABLE_PROCESSED_INPUT = 0x0001
|
||||
|
||||
const kernel = () =>
|
||||
dlopen("kernel32.dll", {
|
||||
GetStdHandle: { args: ["i32"], returns: "ptr" },
|
||||
GetConsoleMode: { args: ["ptr", "ptr"], returns: "i32" },
|
||||
SetConsoleMode: { args: ["ptr", "u32"], returns: "i32" },
|
||||
FlushConsoleInputBuffer: { args: ["ptr"], returns: "i32" },
|
||||
})
|
||||
|
||||
let k32: ReturnType<typeof kernel> | undefined
|
||||
|
||||
function load() {
|
||||
if (process.platform !== "win32") return false
|
||||
try {
|
||||
k32 ??= kernel()
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear ENABLE_PROCESSED_INPUT on the console stdin handle.
|
||||
*/
|
||||
export function win32DisableProcessedInput() {
|
||||
if (process.platform !== "win32") return
|
||||
if (!process.stdin.isTTY) return
|
||||
if (!load()) return
|
||||
|
||||
const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE)
|
||||
const buf = new Uint32Array(1)
|
||||
if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return
|
||||
|
||||
const mode = buf[0]!
|
||||
if ((mode & ENABLE_PROCESSED_INPUT) === 0) return
|
||||
k32!.symbols.SetConsoleMode(handle, mode & ~ENABLE_PROCESSED_INPUT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard any queued console input (mouse events, key presses, etc.).
|
||||
*/
|
||||
export function win32FlushInputBuffer() {
|
||||
if (process.platform !== "win32") return
|
||||
if (!process.stdin.isTTY) return
|
||||
if (!load()) return
|
||||
|
||||
const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE)
|
||||
k32!.symbols.FlushConsoleInputBuffer(handle)
|
||||
}
|
||||
|
||||
let unhook: (() => void) | undefined
|
||||
|
||||
/**
|
||||
* Keep ENABLE_PROCESSED_INPUT disabled.
|
||||
*
|
||||
* On Windows, Ctrl+C becomes a CTRL_C_EVENT (instead of stdin input) when
|
||||
* ENABLE_PROCESSED_INPUT is set. Various runtimes can re-apply console modes
|
||||
* (sometimes on a later tick), and the flag is console-global, not per-process.
|
||||
*
|
||||
* We combine:
|
||||
* - A `setRawMode(...)` hook to re-clear after known raw-mode toggles.
|
||||
* - A low-frequency poll as a backstop for native/external mode changes.
|
||||
*/
|
||||
export function win32InstallCtrlCGuard() {
|
||||
if (process.platform !== "win32") return
|
||||
if (!process.stdin.isTTY) return
|
||||
if (!load()) return
|
||||
if (unhook) return unhook
|
||||
|
||||
const stdin = process.stdin as ReadStream
|
||||
const original = stdin.setRawMode
|
||||
|
||||
const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE)
|
||||
const buf = new Uint32Array(1)
|
||||
|
||||
if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return
|
||||
const initial = buf[0]!
|
||||
|
||||
const enforce = () => {
|
||||
if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return
|
||||
const mode = buf[0]!
|
||||
if ((mode & ENABLE_PROCESSED_INPUT) === 0) return
|
||||
k32!.symbols.SetConsoleMode(handle, mode & ~ENABLE_PROCESSED_INPUT)
|
||||
}
|
||||
|
||||
// Some runtimes can re-apply console modes on the next tick; enforce twice.
|
||||
const later = () => {
|
||||
enforce()
|
||||
setImmediate(enforce)
|
||||
}
|
||||
|
||||
let wrapped: ReadStream["setRawMode"] | undefined
|
||||
|
||||
if (typeof original === "function") {
|
||||
wrapped = (mode: boolean) => {
|
||||
const result = original.call(stdin, mode)
|
||||
later()
|
||||
return result
|
||||
}
|
||||
|
||||
stdin.setRawMode = wrapped
|
||||
}
|
||||
|
||||
// Ensure it's cleared immediately too (covers any earlier mode changes).
|
||||
later()
|
||||
|
||||
const interval = setInterval(enforce, 100)
|
||||
interval.unref()
|
||||
|
||||
let done = false
|
||||
unhook = () => {
|
||||
if (done) return
|
||||
done = true
|
||||
|
||||
clearInterval(interval)
|
||||
if (wrapped && stdin.setRawMode === wrapped) {
|
||||
stdin.setRawMode = original
|
||||
}
|
||||
|
||||
k32!.symbols.SetConsoleMode(handle, initial)
|
||||
unhook = undefined
|
||||
}
|
||||
|
||||
return unhook
|
||||
}
|
||||
77
packages/tui/src/terminal-win32.node.ts
Normal file
77
packages/tui/src/terminal-win32.node.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { dlopen } from "node:ffi"
|
||||
import type { ReadStream } from "node:tty"
|
||||
|
||||
const STD_INPUT_HANDLE = -10
|
||||
const ENABLE_PROCESSED_INPUT = 0x0001
|
||||
|
||||
const kernel = () =>
|
||||
dlopen("kernel32.dll", {
|
||||
GetStdHandle: { arguments: ["i32"], return: "pointer" },
|
||||
GetConsoleMode: { arguments: ["pointer", "pointer"], return: "i32" },
|
||||
SetConsoleMode: { arguments: ["pointer", "u32"], return: "i32" },
|
||||
FlushConsoleInputBuffer: { arguments: ["pointer"], return: "i32" },
|
||||
}).functions
|
||||
|
||||
let k32: ReturnType<typeof kernel> | undefined
|
||||
|
||||
function load() {
|
||||
if (process.platform !== "win32") return false
|
||||
try {
|
||||
k32 ??= kernel()
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function win32DisableProcessedInput() {
|
||||
if (process.platform !== "win32" || !process.stdin.isTTY || !load()) return
|
||||
const handle = k32!.GetStdHandle(STD_INPUT_HANDLE)
|
||||
const buffer = new Uint32Array(1)
|
||||
if (k32!.GetConsoleMode(handle, buffer) === 0) return
|
||||
const mode = buffer[0]!
|
||||
if ((mode & ENABLE_PROCESSED_INPUT) === 0) return
|
||||
k32!.SetConsoleMode(handle, mode & ~ENABLE_PROCESSED_INPUT)
|
||||
}
|
||||
|
||||
export function win32FlushInputBuffer() {
|
||||
if (process.platform !== "win32" || !process.stdin.isTTY || !load()) return
|
||||
k32!.FlushConsoleInputBuffer(k32!.GetStdHandle(STD_INPUT_HANDLE))
|
||||
}
|
||||
|
||||
let unhook: (() => void) | undefined
|
||||
|
||||
export function win32InstallCtrlCGuard() {
|
||||
if (process.platform !== "win32" || !process.stdin.isTTY || !load() || unhook) return unhook
|
||||
const stdin = process.stdin as ReadStream
|
||||
const original = stdin.setRawMode
|
||||
const handle = k32!.GetStdHandle(STD_INPUT_HANDLE)
|
||||
const buffer = new Uint32Array(1)
|
||||
if (k32!.GetConsoleMode(handle, buffer) === 0) return
|
||||
const initial = buffer[0]!
|
||||
const enforce = () => {
|
||||
if (k32!.GetConsoleMode(handle, buffer) === 0) return
|
||||
const mode = buffer[0]!
|
||||
if ((mode & ENABLE_PROCESSED_INPUT) !== 0) k32!.SetConsoleMode(handle, mode & ~ENABLE_PROCESSED_INPUT)
|
||||
}
|
||||
const later = () => {
|
||||
enforce()
|
||||
setImmediate(enforce)
|
||||
}
|
||||
const wrapped: ReadStream["setRawMode"] = (mode) => {
|
||||
const result = original.call(stdin, mode)
|
||||
later()
|
||||
return result
|
||||
}
|
||||
stdin.setRawMode = wrapped
|
||||
later()
|
||||
const interval = setInterval(enforce, 100)
|
||||
interval.unref()
|
||||
unhook = () => {
|
||||
clearInterval(interval)
|
||||
if (stdin.setRawMode === wrapped) stdin.setRawMode = original
|
||||
k32!.SetConsoleMode(handle, initial)
|
||||
unhook = undefined
|
||||
}
|
||||
return unhook
|
||||
}
|
||||
|
|
@ -1,130 +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,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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
1
packages/tui/src/util/string-width.bun.ts
Normal file
1
packages/tui/src/util/string-width.bun.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const stringWidth = Bun.stringWidth
|
||||
26
packages/tui/src/util/string-width.node.ts
Normal file
26
packages/tui/src/util/string-width.node.ts
Normal 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)
|
||||
}
|
||||
1
packages/tui/src/util/string-width.ts
Normal file
1
packages/tui/src/util/string-width.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { stringWidth } from "#string-width"
|
||||
Loading…
Add table
Add a link
Reference in a new issue