Merge main into bb/gui.

Resolve merge conflicts while preserving bb/gui dashboard paths and STT provider support.
This commit is contained in:
Brooklyn Nicholson
2026-05-15 15:33:28 -05:00
415 changed files with 38391 additions and 20402 deletions
@@ -0,0 +1,136 @@
import { describe, expect, it } from 'vitest'
import { canFastAppendShape, canFastBackspaceShape } from '../components/textInput.js'
// The fast-echo path bypasses Ink and writes characters directly to stdout
// for the common case of typing plain English at the end of the line. These
// tests pin the shape preconditions that make that bypass safe.
//
// Regression intent: any non-ASCII text — Vietnamese precomposed letters
// (one grapheme, `text.length === 1`, `stringWidth === 1`, but produced
// via IME composition across multiple keystrokes), combining marks
// (zero width), CJK (double width), emoji (variable width), or anything
// that could be produced by an in-flight IME composition — must NOT
// take the bypass. Closes:
// - "TUI is experiencing font errors when using Unicode to type Vietnamese"
// - #5221 TUI input box renders incorrectly for CJK / East-Asian wide
// - #7443 CLI TUI renders and deletes Chinese characters incorrectly
// - #17602 / #17603 Chinese text scattering / ghosting
describe('canFastAppendShape', () => {
const COLS = 40
it('accepts plain ASCII appended at end of single-line input', () => {
expect(canFastAppendShape('hello', 5, 'x', COLS, 5)).toBe(true)
expect(canFastAppendShape('hello', 5, ' world', COLS, 5)).toBe(true)
})
it('rejects when cursor is not at end of line', () => {
expect(canFastAppendShape('hello', 3, 'x', COLS, 5)).toBe(false)
})
it('rejects when current is empty (placeholder render path needed)', () => {
expect(canFastAppendShape('', 0, 'x', COLS, 0)).toBe(false)
})
it('rejects when current contains a newline (multi-line layout)', () => {
expect(canFastAppendShape('hi\nthere', 8, 'x', COLS, 5)).toBe(false)
})
it('rejects when appending would hit the wrap column', () => {
// Reaching cols on append must trigger a wrap, which the bypass
// cannot draw. Stay strictly below cols.
expect(canFastAppendShape('hello', 5, 'x', 6, 5)).toBe(false)
})
// -- Regression coverage: Vietnamese / combining marks / IME --
it('rejects Vietnamese precomposed letter ề (U+1EC1) — IME composition path', () => {
// 'ề' is one grapheme, length 1, width 1, but Vietnamese Telex/IME
// produces it via a multi-key composition. Fast-echo would commit the
// intermediate state to stdout and desync once the final commit
// arrives.
expect(canFastAppendShape('hello', 5, 'ề', COLS, 5)).toBe(false)
})
it('rejects Vietnamese tone marks ă, ơ, ư (Latin-Extended-A/B)', () => {
for (const ch of ['ă', 'ắ', 'ơ', 'ờ', 'ư', 'ự']) {
expect(canFastAppendShape('hello', 5, ch, COLS, 5)).toBe(false)
}
})
it('rejects NFD combining marks (U+0300 grave, U+0301 acute, U+0302 circumflex)', () => {
// Decomposed Vietnamese: 'e' + combining circumflex + combining grave
// = 'ề'. Each combining mark is zero-width but length 1; without the
// ASCII guard the second/third keypress would be fast-echoed and
// desync the cell column.
expect(canFastAppendShape('hello', 5, '\u0300', COLS, 5)).toBe(false)
expect(canFastAppendShape('hello', 5, '\u0301', COLS, 5)).toBe(false)
expect(canFastAppendShape('hello', 5, '\u0302', COLS, 5)).toBe(false)
})
it('rejects CJK (East-Asian wide) characters', () => {
expect(canFastAppendShape('hello', 5, '你', COLS, 5)).toBe(false)
expect(canFastAppendShape('hello', 5, '日本', COLS, 5)).toBe(false)
})
it('rejects emoji', () => {
expect(canFastAppendShape('hello', 5, '🙂', COLS, 5)).toBe(false)
})
it('rejects ANSI-bearing or control text', () => {
expect(canFastAppendShape('hello', 5, '\x1b[31m', COLS, 5)).toBe(false)
expect(canFastAppendShape('hello', 5, '\t', COLS, 5)).toBe(false)
expect(canFastAppendShape('hello', 5, '\x7f', COLS, 5)).toBe(false)
})
it('rejects NBSP and Latin-1 letters that would change the line shape', () => {
expect(canFastAppendShape('hello', 5, '\u00a0', COLS, 5)).toBe(false)
expect(canFastAppendShape('hello', 5, 'é', COLS, 5)).toBe(false)
expect(canFastAppendShape('hello', 5, 'ñ', COLS, 5)).toBe(false)
})
})
describe('canFastBackspaceShape', () => {
it('accepts deleting the last ASCII char', () => {
expect(canFastBackspaceShape('hello', 5)).toBe(true)
})
it('rejects when cursor is not at end', () => {
expect(canFastBackspaceShape('hello', 3)).toBe(false)
})
it('rejects when there is nothing to delete', () => {
expect(canFastBackspaceShape('', 0)).toBe(false)
expect(canFastBackspaceShape('hello', 0)).toBe(false)
})
it('rejects when value contains a newline', () => {
expect(canFastBackspaceShape('hi\nthere', 8)).toBe(false)
})
it('rejects deleting Vietnamese precomposed letter ề', () => {
// The "\b \b" shortcut clears one terminal cell; that's fine for a
// 1-cell ASCII char but if the previous grapheme is a Vietnamese
// letter that the IME may still be holding open, we want Ink to
// re-render so composition state stays consistent.
expect(canFastBackspaceShape('helloề', 'helloề'.length)).toBe(false)
})
it('rejects deleting a CJK character (2 cells)', () => {
expect(canFastBackspaceShape('hi你', 'hi你'.length)).toBe(false)
})
it('rejects deleting a NFD-composed grapheme with combining marks', () => {
// 'e' + U+0302 (circumflex) + U+0300 (grave) — final grapheme is one
// cluster but the previous-grapheme slice is multi-codepoint. Width
// is 1 but the bypass would be unsafe because the rendered cell
// already contained the combined glyph.
const s = 'hello' + 'e\u0302\u0300'
expect(canFastBackspaceShape(s, s.length)).toBe(false)
})
it('rejects deleting an emoji', () => {
expect(canFastBackspaceShape('hi🙂', 'hi🙂'.length)).toBe(false)
})
})
+82 -19
View File
@@ -179,6 +179,84 @@ export function lineNav(s: string, p: number, dir: -1 | 1): null | number {
export { offsetFromPosition }
const ASCII_PRINTABLE_RE = /^[\x20-\x7e]+$/
/**
* Pure shape-only precondition for the fast-echo append path.
*
* The fast-echo path bypasses Ink's renderer and writes text directly to
* stdout, so the stored value, the rendered terminal cells, and the cursor
* column must all stay in sync without any layout work. We only allow it
* when the inserted text is pure printable ASCII so that:
*
* - `text.length` matches the number of grapheme clusters (no combining
* marks, no surrogate pairs, no precomposed CJK / Latin-Extended
* letters that an IME might still be holding open as a composition),
* - terminal width is exactly 1 cell per character (no East-Asian wide,
* no zero-width, no ambiguous-width fonts),
* - input methods (Vietnamese Telex, IME, dead-keys) cannot leak
* intermediate composition bytes through the bypass before the final
* commit arrives — those always go through the normal Ink render path
* and stay layout-accurate (closes #5221, #7443, #17602/#17603).
*
* We deliberately do NOT just check `stringWidth(text) === text.length`:
* Vietnamese precomposed letters like "ề" (U+1EC1) report width 1 and
* length 1 but are still produced by IME compositions and must not be
* fast-echoed.
*/
export function canFastAppendShape(
current: string,
cursor: number,
text: string,
columns: number,
currentLineWidth: number
): boolean {
if (cursor !== current.length) {
return false
}
if (current.length === 0) {
return false
}
if (current.includes('\n')) {
return false
}
if (!ASCII_PRINTABLE_RE.test(text)) {
return false
}
return currentLineWidth + text.length < Math.max(1, columns)
}
/**
* Pure shape-only precondition for the fast-echo backspace path.
*
* Same reasoning as canFastAppendShape — only allow the direct
* "\b \b" stdout shortcut when the deleted grapheme is pure printable
* ASCII. Anything else (combining marks, IME compositions, wide chars,
* tabs, ANSI fragments) goes through the normal render path so Ink can
* recompute cell widths.
*/
export function canFastBackspaceShape(current: string, cursor: number): boolean {
if (cursor !== current.length) {
return false
}
if (cursor <= 0) {
return false
}
if (current.includes('\n')) {
return false
}
const removed = current.slice(prevPos(current, cursor), cursor)
return ASCII_PRINTABLE_RE.test(removed)
}
function renderWithCursor(value: string, cursor: number) {
const pos = Math.max(0, Math.min(cursor, value.length))
@@ -444,26 +522,11 @@ export function TextInput({
const canFastEchoBase = () => focus && termFocus && !selected && !mask && !!stdout?.isTTY
const canFastAppend = (current: string, cursor: number, text: string) => {
const sw = stringWidth(text)
const canFastAppend = (current: string, cursor: number, text: string) =>
canFastEchoBase() && canFastAppendShape(current, cursor, text, columns, lineWidthRef.current)
return (
canFastEchoBase() &&
cursor === current.length &&
current.length > 0 &&
!current.includes('\n') &&
sw === text.length &&
lineWidthRef.current + sw < Math.max(1, columns)
)
}
const canFastBackspace = (current: string, cursor: number) => {
if (!canFastEchoBase() || cursor !== current.length || cursor <= 0 || current.includes('\n')) {
return false
}
return stringWidth(current.slice(prevPos(current, cursor), cursor)) === 1
}
const canFastBackspace = (current: string, cursor: number) =>
canFastEchoBase() && canFastBackspaceShape(current, cursor)
const commit = (
next: string,
+12 -1
View File
@@ -9,6 +9,7 @@ import { GatewayClient } from './gatewayClient.js'
import { setupGracefulExit } from './lib/gracefulExit.js'
import { formatBytes, type HeapDumpResult, performHeapDump } from './lib/memory.js'
import { type MemorySnapshot, startMemoryMonitor } from './lib/memoryMonitor.js'
import { openExternalUrl } from './lib/openExternalUrl.js'
import { resetTerminalModes } from './lib/terminalModes.js'
if (!process.stdin.isTTY) {
@@ -85,4 +86,14 @@ const onFrame =
}
: undefined
ink.render(<App gw={gw} />, { exitOnCtrlC: false, onFrame })
ink.render(<App gw={gw} />, {
exitOnCtrlC: false,
onFrame,
// Open URLs in the user's default browser when a link cell is clicked.
// The TUI's mouse tracking captures click events before Terminal.app's
// own URL detection can fire, so without this hook clicks on `<Link>`
// do nothing in any terminal where mouseTracking is on.
onHyperlinkClick: url => {
openExternalUrl(url)
}
})
+217
View File
@@ -0,0 +1,217 @@
import type { ChildProcess, spawn as SpawnFn } from 'node:child_process'
import { EventEmitter } from 'node:events'
import { describe, expect, it, vi } from 'vitest'
import { openCommand, openExternalUrl, parseSafeUrl } from './openExternalUrl.js'
type SpawnLike = typeof SpawnFn
describe('parseSafeUrl', () => {
it('accepts http and https URLs', () => {
expect(parseSafeUrl('https://example.com')?.href).toBe('https://example.com/')
expect(parseSafeUrl('http://example.com/path?q=1')?.href).toBe('http://example.com/path?q=1')
})
it('rejects file: URLs (would let a hostile model trigger arbitrary local handlers)', () => {
expect(parseSafeUrl('file:///etc/passwd')).toBeNull()
})
it('rejects javascript:, data:, and vbscript: URLs', () => {
expect(parseSafeUrl('javascript:alert(1)')).toBeNull()
expect(parseSafeUrl('data:text/html,<script>alert(1)</script>')).toBeNull()
expect(parseSafeUrl('vbscript:msgbox')).toBeNull()
})
it('rejects mailto:, ftp:, and other non-web protocols', () => {
expect(parseSafeUrl('mailto:test@example.com')).toBeNull()
expect(parseSafeUrl('ftp://example.com')).toBeNull()
expect(parseSafeUrl('ssh://example.com')).toBeNull()
})
it('rejects unparseable strings', () => {
expect(parseSafeUrl('not a url')).toBeNull()
expect(parseSafeUrl('')).toBeNull()
})
it('rejects non-string inputs defensively', () => {
expect(parseSafeUrl(undefined as unknown as string)).toBeNull()
expect(parseSafeUrl(null as unknown as string)).toBeNull()
expect(parseSafeUrl(123 as unknown as string)).toBeNull()
})
})
describe('openCommand', () => {
it('returns macOS open(1) on darwin', () => {
expect(openCommand('darwin')).toEqual({ command: 'open', args: [] })
})
it('routes through explorer.exe on win32 — not cmd.exe — so URLs with & | ^ < > stay safe', () => {
// win32 must not route through cmd.exe — see comment in openCommand.
// Test pins the contract that we use explorer.exe (non-shell) so URLs
// with `&`/`|`/`^`/`<`/`>` aren't reparsed by cmd's tokenizer.
const cmd = openCommand('win32')
expect(cmd?.command).toBe('explorer.exe')
expect(cmd?.args).toEqual([])
})
it('falls back to xdg-open on linux/bsd', () => {
expect(openCommand('linux')).toEqual({ command: 'xdg-open', args: [] })
expect(openCommand('freebsd')).toEqual({ command: 'xdg-open', args: [] })
expect(openCommand('openbsd')).toEqual({ command: 'xdg-open', args: [] })
})
it('returns null for unknown platforms (aix, sunos, cygwin, etc.)', () => {
// Avoid optimistically dispatching xdg-open on platforms where it
// probably isn't installed — the caller's `if (!command) return false`
// path surfaces "no opener" honestly instead.
expect(openCommand('aix')).toBeNull()
expect(openCommand('sunos')).toBeNull()
expect(openCommand('cygwin')).toBeNull()
expect(openCommand('haiku')).toBeNull()
expect(openCommand('')).toBeNull()
})
})
describe('openExternalUrl on unsupported platforms', () => {
it('returns false without spawning when the platform has no known opener', () => {
const spawn = vi.fn() as unknown as SpawnLike
expect(openExternalUrl('https://example.com/', { spawn, platform: () => 'aix' })).toBe(false)
expect(spawn).not.toHaveBeenCalled()
})
})
describe('openExternalUrl', () => {
// Tracks the most recent fake child so tests can inspect its 'error'
// handlers and emit on it. Use a loose EventEmitter alias rather than
// ChildProcess — the latter's `unref` signature is strictly `() => void`
// and doesn't accept `vi.fn()` without a generic.
type FakeChild = EventEmitter & { unref: () => void }
function mockSpawn(): {
spawn: SpawnLike
calls: Array<{ command: string; args: readonly string[] }>
lastChild: () => FakeChild | undefined
} {
const calls: Array<{ command: string; args: readonly string[] }> = []
let lastChild: FakeChild | undefined
const spawn = vi.fn((command: string, args: readonly string[]) => {
calls.push({ command, args })
// Use a real EventEmitter so .once('error', cb) wires up correctly
// and we can synthesize async failures by emitting 'error' from the
// test. The cast is the same one Node uses internally — ChildProcess
// extends EventEmitter.
const child = new EventEmitter() as FakeChild
child.unref = () => {}
lastChild = child
return child as unknown as ChildProcess
}) as unknown as SpawnLike
return { spawn, calls, lastChild: () => lastChild }
}
it('opens a normal https URL via the platform command', () => {
const { spawn, calls } = mockSpawn()
expect(openExternalUrl('https://example.com/foo', { spawn, platform: () => 'darwin' })).toBe(true)
expect(calls).toHaveLength(1)
expect(calls[0]!.command).toBe('open')
expect(calls[0]!.args).toEqual(['https://example.com/foo'])
})
it('uses xdg-open on linux', () => {
const { spawn, calls } = mockSpawn()
openExternalUrl('https://example.com/', { spawn, platform: () => 'linux' })
expect(calls[0]!.command).toBe('xdg-open')
})
it('refuses to open file: URLs and does not spawn', () => {
const { spawn, calls } = mockSpawn()
expect(openExternalUrl('file:///etc/passwd', { spawn, platform: () => 'darwin' })).toBe(false)
expect(calls).toHaveLength(0)
})
it('refuses to open javascript: URLs and does not spawn', () => {
const { spawn, calls } = mockSpawn()
expect(openExternalUrl('javascript:alert(1)', { spawn, platform: () => 'darwin' })).toBe(false)
expect(calls).toHaveLength(0)
})
it('passes URLs containing shell metacharacters as plain args (no shell interpolation)', () => {
const { spawn, calls } = mockSpawn()
// A URL with `; & ` plus URL-encoded backticks. spawn(..., args) without
// shell:true means the OS receives these as a single argv element.
const hostile = 'https://example.com/path%3Bevil%20%26%20rm%20-rf'
openExternalUrl(hostile, { spawn, platform: () => 'darwin' })
expect(calls).toHaveLength(1)
expect(calls[0]!.args[calls[0]!.args.length - 1]).toBe(hostile)
})
it('on win32, a URL with & | ^ < > is forwarded as a single argv element via explorer.exe', () => {
const { spawn, calls } = mockSpawn()
// Plain http URL with & in query (very common, e.g. analytics params)
// plus other cmd metacharacters that would split or reinterpret the
// command if win32 routed through cmd.exe /c start. Note that the URL
// parser percent-encodes `<` and `>` (which is fine — encoded forms
// can't be reinterpreted by any shell), but `&`, `|`, `^` survive
// and would tokenize cmd.exe if we ever regressed back to it.
const meta = 'https://example.com/q?a=1&b=2|c^d<e>f'
expect(openExternalUrl(meta, { spawn, platform: () => 'win32' })).toBe(true)
expect(calls).toHaveLength(1)
expect(calls[0]!.command).toBe('explorer.exe')
// The URL must arrive as exactly one argv element — not split on &/|/^/etc.
const forwarded = calls[0]!.args[0]!
expect(calls[0]!.args).toHaveLength(1)
expect(forwarded).toContain('a=1&b=2')
expect(forwarded).toContain('|c^d')
})
it('on win32, common http URLs with & query params are forwarded intact', () => {
const { spawn, calls } = mockSpawn()
const url = 'https://example.com/search?q=foo&page=2&utm_source=hermes'
openExternalUrl(url, { spawn, platform: () => 'win32' })
expect(calls[0]!.args).toEqual([url])
})
it('returns false on synchronous spawn failure', () => {
const spawn = vi.fn(() => {
throw new Error('ENOENT')
}) as unknown as SpawnLike
expect(openExternalUrl('https://example.com/', { spawn, platform: () => 'linux' })).toBe(false)
})
it('does not crash the host when the spawned process emits an async error', () => {
// Real-world case: `xdg-open` / `explorer.exe` missing on PATH. spawn()
// returns a ChildProcess synchronously, then emits 'error' once the
// exec actually fails. Without a registered 'error' listener, Node
// re-throws the event as an uncaught exception → TUI dies. We attach
// a no-op listener inside openExternalUrl; this test pins that contract.
const { spawn, lastChild } = mockSpawn()
expect(openExternalUrl('https://example.com/', { spawn, platform: () => 'linux' })).toBe(true)
const child = lastChild()
expect(child).toBeDefined()
// Must have a listener registered BEFORE we emit, or EventEmitter will
// throw synchronously here (which is exactly the crash we're preventing).
expect(child!.listenerCount('error')).toBeGreaterThan(0)
// Emit and assert it doesn't throw. If the listener weren't attached,
// this would throw 'Unhandled error' and fail the test.
expect(() => child!.emit('error', new Error('ENOENT: xdg-open not found'))).not.toThrow()
})
})
+158
View File
@@ -0,0 +1,158 @@
import { spawn, type SpawnOptions } from 'node:child_process'
import { platform } from 'node:os'
/**
* Opens an external URL in the user's default browser/handler.
*
* Wired into the Ink instance via `onHyperlinkClick` in entry.tsx, so any
* mouse click on a `<Link>` cell (or a row containing a plain-text URL the
* renderer detected) goes here. Mouse tracking inside the TUI prevents
* Terminal.app's native Cmd+click from firing — the click is captured
* before the terminal application sees it — so we have to handle the open
* ourselves.
*
* Safety:
* - http(s) only. Anything else (`file:`, `data:`, `javascript:`, etc.) is
* rejected — a hostile model could otherwise emit `<Link url="file:///">`
* and trick a click into running an arbitrary local handler.
* - Hostname is parsed via `URL`; only well-formed URLs are forwarded.
* - Spawned via `child_process.spawn` with arg array (no shell), so a URL
* containing shell metacharacters (`;`, `&`, backticks) cannot be
* interpreted as a command.
*
* Returns `true` if the spawn was attempted, `false` if the open could
* not proceed — covers (a) URL rejected by `parseSafeUrl` (non-http(s),
* malformed, etc.), (b) no known opener for the current platform
* (`openCommand` returned null), or (c) `spawn()` threw synchronously
* before the child was created. Async failures after spawn (`'error'`
* event because the binary couldn't exec) still return `true` because
* the spawn was attempted — the no-op error listener absorbs the event
* so the TUI doesn't crash, and the user just doesn't see their browser
* pop.
*/
export function openExternalUrl(rawUrl: string, dependencies: OpenDependencies = {}): boolean {
const url = parseSafeUrl(rawUrl)
if (!url) {
return false
}
const spawnFn = dependencies.spawn ?? spawn
const platformId = dependencies.platform?.() ?? platform()
const command = openCommand(platformId)
if (!command) {
return false
}
try {
const child = spawnFn(command.command, [...command.args, url.toString()], {
// Detach so closing the TUI later doesn't kill the browser process,
// and ignore stdio so we don't leak FDs into our raw-mode terminal.
// Without `ignore` here, Chrome's stderr can land in the alt screen.
detached: true,
stdio: 'ignore'
} satisfies SpawnOptions)
// Async failure path: spawn returns a ChildProcess synchronously even
// when the binary is missing (ENOENT on `xdg-open` / `explorer.exe`),
// unreachable (EACCES), or otherwise unusable — the failure surfaces
// later as an 'error' event. Without a handler, an unhandled 'error'
// on an EventEmitter crashes Node, which would tear down the whole
// TUI. Attach a no-op listener BEFORE unref() so the event has a
// consumer; we already returned `true` synchronously, so the user
// just won't see their browser open — same as if the URL had been
// rejected upstream.
child.once('error', () => {
// Intentional no-op. The TUI keeps running; user gets no browser
// pop, which is the failure mode we promised in the doc comment.
})
child.unref()
return true
} catch {
// spawn can also throw synchronously on argv-validation failures
// (e.g. NUL in the path). Treat it as a no-op rather than crashing.
return false
}
}
export type OpenDependencies = {
spawn?: typeof spawn
platform?: () => string
}
/**
* Validate and normalize a URL for opening externally.
* Exported for testing.
*/
export function parseSafeUrl(value: string): null | URL {
if (!value || typeof value !== 'string') {
return null
}
let parsed: URL
try {
parsed = new URL(value)
} catch {
return null
}
// http(s) only — opening file://, data:, javascript:, vbscript:, etc.
// would let a malicious model run a local handler with attacker-controlled
// input on a single click.
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return null
}
// Reject empty or all-whitespace hostnames defensively. URL parsing
// accepts URLs like 'http:///foo' on some Node versions; we don't want
// to forward those to `open`.
if (!parsed.hostname.trim()) {
return null
}
return parsed
}
type OpenCommand = { command: string; args: readonly string[] }
/**
* Per-platform open command. We deliberately avoid `cmd.exe /c start` on
* Windows even though it's the canonical example, because `start` is a cmd
* builtin: the URL string is reparsed by cmd's command-line tokenizer and
* characters like `&`, `|`, `^`, `<`, `>` either break the command or get
* interpreted as additional commands. That undermines the protocol
* allowlist's safety story and also breaks plain http(s) URLs with `&` in
* query strings. `explorer.exe <url>` is the safe, non-shell alternative —
* it invokes the registered protocol handler for http(s) without going
* through cmd. Linux/BSD use `xdg-open` directly with no shell wrapping.
*
* Returns null for platforms where we don't know a safe opener (e.g. `aix`,
* `sunos`, `cygwin`). The caller's `if (!command) return false` path then
* surfaces "no opener" instead of optimistically trying `xdg-open` on a
* platform that probably doesn't have it.
*/
export function openCommand(platformId: string): OpenCommand | null {
if (platformId === 'darwin') {
return { command: 'open', args: [] }
}
if (platformId === 'win32') {
return { command: 'explorer.exe', args: [] }
}
// Linux + the BSD family ship xdg-open via xdg-utils. Everything else
// (aix, sunos, cygwin, haiku, etc.) returns null so openExternalUrl's
// command-not-found fallback fires honestly.
const XDG_OPEN_PLATFORMS = new Set(['linux', 'freebsd', 'openbsd', 'netbsd', 'dragonfly'])
if (XDG_OPEN_PLATFORMS.has(platformId)) {
return { command: 'xdg-open', args: [] }
}
return null
}
+1
View File
@@ -66,6 +66,7 @@ declare module '@hermes/ink' {
readonly exitOnCtrlC?: boolean
readonly patchConsole?: boolean
readonly onFrame?: (event: FrameEvent) => void
readonly onHyperlinkClick?: (url: string) => void
}
export type Instance = {