Merge main into bb/gui.
Resolve merge conflicts while preserving bb/gui dashboard paths and STT provider support.
This commit is contained in:
@@ -1,53 +1,38 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import React from 'react'
|
||||
import { c as _c } from 'react/compiler-runtime'
|
||||
|
||||
import { supportsHyperlinks } from '../supports-hyperlinks.js'
|
||||
|
||||
import Text from './Text.js'
|
||||
export type Props = {
|
||||
readonly children?: ReactNode
|
||||
readonly url: string
|
||||
// Kept for backwards-compat: prior versions rendered `fallback` instead of
|
||||
// the linked content on terminals where supportsHyperlinks() was false. We
|
||||
// now always emit the hyperlink metadata so the in-process click/hover
|
||||
// dispatcher can act on it regardless of the terminal's own OSC 8 support
|
||||
// (see comment in the function body), so `fallback` is no longer wired up.
|
||||
// Leaving the prop on the interface keeps existing call sites compiling.
|
||||
readonly fallback?: ReactNode
|
||||
}
|
||||
|
||||
export default function Link(t0: Props) {
|
||||
const $ = _c(5)
|
||||
|
||||
const { children, url, fallback } = t0
|
||||
|
||||
export default function Link({ children, url }: Props): React.ReactNode {
|
||||
// Always emit <ink-link>: the renderer stores `hyperlink` per cell in the
|
||||
// screen buffer, which the click dispatcher (Ink.getHyperlinkAt →
|
||||
// onHyperlinkClick) reads on mouseup to open URLs externally. Gating this
|
||||
// on supportsHyperlinks() broke clicks in Apple Terminal / any terminal
|
||||
// not on the OSC 8 allowlist — the cell's hyperlink field stayed empty,
|
||||
// so the click pipeline had nothing to open.
|
||||
//
|
||||
// The OSC 8 escape itself is emitted unconditionally by the renderer
|
||||
// (wrapWithOsc8Link in render-node-to-output.ts, oscLink in log-update.ts).
|
||||
// Terminals that don't understand OSC 8 silently strip it — including
|
||||
// Apple Terminal, which is why hover/click affordance has to come from
|
||||
// the in-process overlay (applyHyperlinkHoverHighlight) and not from the
|
||||
// terminal's own link rendering.
|
||||
const content = children ?? url
|
||||
|
||||
if (supportsHyperlinks()) {
|
||||
let t1
|
||||
|
||||
if ($[0] !== content || $[1] !== url) {
|
||||
t1 = (
|
||||
<Text>
|
||||
<ink-link href={url}>{content}</ink-link>
|
||||
</Text>
|
||||
)
|
||||
$[0] = content
|
||||
$[1] = url
|
||||
$[2] = t1
|
||||
} else {
|
||||
t1 = $[2]
|
||||
}
|
||||
|
||||
return t1
|
||||
}
|
||||
|
||||
const t1 = fallback ?? content
|
||||
let t2
|
||||
|
||||
if ($[3] !== t1) {
|
||||
t2 = <Text>{t1}</Text>
|
||||
$[3] = t1
|
||||
$[4] = t2
|
||||
} else {
|
||||
t2 = $[4]
|
||||
}
|
||||
|
||||
return t2
|
||||
return (
|
||||
<Text>
|
||||
<ink-link href={url}>{content}</ink-link>
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdE5vZGUiLCJSZWFjdCIsInN1cHBvcnRzSHlwZXJsaW5rcyIsIlRleHQiLCJQcm9wcyIsImNoaWxkcmVuIiwidXJsIiwiZmFsbGJhY2siLCJMaW5rIiwidDAiLCIkIiwiX2MiLCJjb250ZW50IiwidDEiLCJ0MiJdLCJzb3VyY2VzIjpbIkxpbmsudHN4Il0sInNvdXJjZXNDb250ZW50IjpbImltcG9ydCB0eXBlIHsgUmVhY3ROb2RlIH0gZnJvbSAncmVhY3QnXG5pbXBvcnQgUmVhY3QgZnJvbSAncmVhY3QnXG5pbXBvcnQgeyBzdXBwb3J0c0h5cGVybGlua3MgfSBmcm9tICcuLi9zdXBwb3J0cy1oeXBlcmxpbmtzLmpzJ1xuaW1wb3J0IFRleHQgZnJvbSAnLi9UZXh0LmpzJ1xuXG5leHBvcnQgdHlwZSBQcm9wcyA9IHtcbiAgcmVhZG9ubHkgY2hpbGRyZW4/OiBSZWFjdE5vZGVcbiAgcmVhZG9ubHkgdXJsOiBzdHJpbmdcbiAgcmVhZG9ubHkgZmFsbGJhY2s/OiBSZWFjdE5vZGVcbn1cblxuZXhwb3J0IGRlZmF1bHQgZnVuY3Rpb24gTGluayh7XG4gIGNoaWxkcmVuLFxuICB1cmwsXG4gIGZhbGxiYWNrLFxufTogUHJvcHMpOiBSZWFjdC5SZWFjdE5vZGUge1xuICAvLyBVc2UgY2hpbGRyZW4gaWYgcHJvdmlkZWQsIG90aGVyd2lzZSBkaXNwbGF5IHRoZSBVUkxcbiAgY29uc3QgY29udGVudCA9IGNoaWxkcmVuID8/IHVybFxuXG4gIGlmIChzdXBwb3J0c0h5cGVybGlua3MoKSkge1xuICAgIC8vIFdyYXAgaW4gVGV4dCB0byBlbnN1cmUgd2UncmUgaW4gYSB0ZXh0IGNvbnRleHRcbiAgICAvLyAoaW5rLWxpbmsgaXMgYSB0ZXh0IGVsZW1lbnQgbGlrZSBpbmstdGV4dClcbiAgICByZXR1cm4gKFxuICAgICAgPFRleHQ+XG4gICAgICAgIDxpbmstbGluayBocmVmPXt1cmx9Pntjb250ZW50fTwvaW5rLWxpbms+XG4gICAgICA8L1RleHQ+XG4gICAgKVxuICB9XG5cbiAgcmV0dXJuIDxUZXh0PntmYWxsYmFjayA/PyBjb250ZW50fTwvVGV4dD5cbn1cbiJdLCJtYXBwaW5ncyI6IjtBQUFBLGNBQWNBLFNBQVMsUUFBUSxPQUFPO0FBQ3RDLE9BQU9DLEtBQUssTUFBTSxPQUFPO0FBQ3pCLFNBQVNDLGtCQUFrQixRQUFRLDJCQUEyQjtBQUM5RCxPQUFPQyxJQUFJLE1BQU0sV0FBVztBQUU1QixPQUFPLEtBQUtDLEtBQUssR0FBRztFQUNsQixTQUFTQyxRQUFRLENBQUMsRUFBRUwsU0FBUztFQUM3QixTQUFTTSxHQUFHLEVBQUUsTUFBTTtFQUNwQixTQUFTQyxRQUFRLENBQUMsRUFBRVAsU0FBUztBQUMvQixDQUFDO0FBRUQsZUFBZSxTQUFBUSxLQUFBQyxFQUFBO0VBQUEsTUFBQUMsQ0FBQSxHQUFBQyxFQUFBO0VBQWM7SUFBQU4sUUFBQTtJQUFBQyxHQUFBO0lBQUFDO0VBQUEsSUFBQUUsRUFJckI7RUFFTixNQUFBRyxPQUFBLEdBQWdCUCxRQUFlLElBQWZDLEdBQWU7RUFFL0IsSUFBSUosa0JBQWtCLENBQUMsQ0FBQztJQUFBLElBQUFXLEVBQUE7SUFBQSxJQUFBSCxDQUFBLFFBQUFFLE9BQUEsSUFBQUYsQ0FBQSxRQUFBSixHQUFBO01BSXBCTyxFQUFBLElBQUMsSUFBSSxDQUNILFNBQXlDLENBQXpCUCxJQUFHLENBQUhBLElBQUUsQ0FBQyxDQUFHTSxRQUFNLENBQUUsRUFBOUIsUUFBeUMsQ0FDM0MsRUFGQyxJQUFJLENBRUU7TUFBQUYsQ0FBQSxNQUFBRSxPQUFBO01BQUFGLENBQUEsTUFBQUosR0FBQTtNQUFBSSxDQUFBLE1BQUFHLEVBQUE7SUFBQTtNQUFBQSxFQUFBLEdBQUFILENBQUE7SUFBQTtJQUFBLE9BRlBHLEVBRU87RUFBQTtFQUlHLE1BQUFBLEVBQUEsR0FBQU4sUUFBbUIsSUFBbkJLLE9BQW1CO0VBQUEsSUFBQUUsRUFBQTtFQUFBLElBQUFKLENBQUEsUUFBQUcsRUFBQTtJQUExQkMsRUFBQSxJQUFDLElBQUksQ0FBRSxDQUFBRCxFQUFrQixDQUFFLEVBQTFCLElBQUksQ0FBNkI7SUFBQUgsQ0FBQSxNQUFBRyxFQUFBO0lBQUFILENBQUEsTUFBQUksRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQUosQ0FBQTtFQUFBO0VBQUEsT0FBbENJLEVBQWtDO0FBQUEiLCJpZ25vcmVMaXN0IjpbXX0=
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { cellAtIndex, CellWidth, type Screen, setCellStyleId, type StylePool } from './screen.js'
|
||||
|
||||
/**
|
||||
* Highlight every cell whose OSC 8 hyperlink matches `hoveredUrl` by inverting
|
||||
* its style. This is the cursor-hover affordance for clickable links: terminal
|
||||
* applications can't change the system mouse cursor, so we light up the link
|
||||
* itself when the pointer is over it. Same overlay machinery as
|
||||
* applySearchHighlight — post-layout, pure SGR, picked up by the diff.
|
||||
*
|
||||
* Returns true if any cell was highlighted. The caller decides whether to
|
||||
* promote that into a full-frame damage request — for hover specifically,
|
||||
* full damage is only useful on enter/leave/change transitions (so the
|
||||
* previous frame's inverted cells get re-emitted), not on every steady-state
|
||||
* frame the pointer sits on the link.
|
||||
*/
|
||||
export function applyHyperlinkHoverHighlight(
|
||||
screen: Screen,
|
||||
hoveredUrl: string | undefined,
|
||||
stylePool: StylePool
|
||||
): boolean {
|
||||
if (!hoveredUrl) {
|
||||
return false
|
||||
}
|
||||
|
||||
const w = screen.width
|
||||
const height = screen.height
|
||||
let applied = false
|
||||
|
||||
for (let row = 0; row < height; row++) {
|
||||
const rowOff = row * w
|
||||
|
||||
for (let col = 0; col < w; col++) {
|
||||
const cell = cellAtIndex(screen, rowOff + col)
|
||||
|
||||
// Skip SpacerTail — the head cell at col-1 owns the hyperlink, and
|
||||
// setCellStyleId on the tail would split the styling of a wide-char
|
||||
// glyph mid-cell. The head's restyle covers both halves.
|
||||
if (cell.width === CellWidth.SpacerTail) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (cell.hyperlink !== hoveredUrl) {
|
||||
continue
|
||||
}
|
||||
|
||||
applied = true
|
||||
setCellStyleId(screen, col, row, stylePool.withInverse(cell.styleId))
|
||||
}
|
||||
}
|
||||
|
||||
return applied
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { EventEmitter } from 'events'
|
||||
import React from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import Text from './components/Text.js'
|
||||
import Ink from './ink.js'
|
||||
import { CURSOR_HOME, ERASE_SCREEN } from './termio/csi.js'
|
||||
|
||||
class FakeTty extends EventEmitter {
|
||||
chunks: string[] = []
|
||||
columns = 20
|
||||
rows = 5
|
||||
isTTY = true
|
||||
|
||||
write(chunk: string | Uint8Array, cb?: (err?: Error | null) => void): boolean {
|
||||
this.chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'))
|
||||
cb?.()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const tick = () => new Promise<void>(resolve => queueMicrotask(resolve))
|
||||
|
||||
describe('Ink resize healing', () => {
|
||||
it('heals same-dimension alt-screen resize events with an erase before repaint', async () => {
|
||||
const stdout = new FakeTty()
|
||||
const stdin = new FakeTty()
|
||||
const stderr = new FakeTty()
|
||||
const ink = new Ink({
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false,
|
||||
stderr: stderr as unknown as NodeJS.WriteStream,
|
||||
stdin: stdin as unknown as NodeJS.ReadStream,
|
||||
stdout: stdout as unknown as NodeJS.WriteStream
|
||||
})
|
||||
|
||||
ink.setAltScreenActive(true)
|
||||
ink.render(React.createElement(Text, null, 'hello'))
|
||||
ink.onRender()
|
||||
stdout.chunks = []
|
||||
|
||||
stdout.emit('resize')
|
||||
ink.onRender()
|
||||
await tick()
|
||||
|
||||
expect(stdout.chunks.join('')).toContain(ERASE_SCREEN + CURSOR_HOME)
|
||||
|
||||
ink.unmount()
|
||||
})
|
||||
})
|
||||
@@ -24,6 +24,7 @@ import { KeyboardEvent } from './events/keyboard-event.js'
|
||||
import { FocusManager } from './focus.js'
|
||||
import { emptyFrame, type Frame, type FrameEvent } from './frame.js'
|
||||
import { dispatchClick, dispatchHover, dispatchMouse } from './hit-test.js'
|
||||
import { applyHyperlinkHoverHighlight } from './hyperlinkHover.js'
|
||||
import instances from './instances.js'
|
||||
import { LogUpdate } from './log-update.js'
|
||||
import { nodeCache } from './node-cache.js'
|
||||
@@ -150,6 +151,21 @@ export type Options = {
|
||||
patchConsole: boolean
|
||||
waitUntilExit?: () => Promise<void>
|
||||
onFrame?: (event: FrameEvent) => void
|
||||
/**
|
||||
* Called when a click lands on a cell with an OSC 8 hyperlink (or a
|
||||
* plain-text URL detected by findPlainTextUrlAt). The host is responsible
|
||||
* for opening the URL — `child_process.spawn` with an argv array (NOT
|
||||
* shell-mode) to the platform's native opener: `open` on macOS,
|
||||
* `xdg-open` on Linux/BSD, `explorer.exe` on Windows. Avoid
|
||||
* `cmd.exe /c start` — `start` is a cmd builtin that reparses the URL
|
||||
* through cmd's tokenizer (`&` / `|` / `^` / `<` / `>` get split or
|
||||
* reinterpreted), which both breaks plain URLs with `&` in query
|
||||
* strings and undermines any caller-side protocol allowlist. Without
|
||||
* this wired up, links rendered by `<Link>` look underlined but do
|
||||
* nothing on click in any terminal where mouse tracking is on
|
||||
* (Cmd+click is consumed by the TUI, not Terminal.app).
|
||||
*/
|
||||
onHyperlinkClick?: (url: string) => void
|
||||
}
|
||||
export default class Ink {
|
||||
private readonly log: LogUpdate
|
||||
@@ -232,6 +248,19 @@ export default class Ink {
|
||||
// so App.tsx's handleMouseEvent is stateless — dispatchHover diffs
|
||||
// against this set and mutates it in place.
|
||||
private readonly hoveredNodes = new Set<dom.DOMElement>()
|
||||
|
||||
// The OSC 8 hyperlink URL under the pointer, or undefined when the cursor
|
||||
// isn't on a link. Updated from dispatchHover; consumed by the render-pass
|
||||
// overlay (applyHyperlinkHoverHighlight) to invert link cells under the
|
||||
// pointer. This is the closest the TUI can get to the desktop's
|
||||
// cursor-changes-on-hover affordance — terminals don't expose cursor
|
||||
// shape control to applications.
|
||||
private hoveredHyperlink: string | undefined = undefined
|
||||
|
||||
// Last value of hoveredHyperlink that we actually painted. Compared in
|
||||
// onRender so we can scope full-screen damage to enter/leave/change
|
||||
// transitions, not every steady-state hover frame.
|
||||
private lastRenderedHoveredHyperlink: string | undefined = undefined
|
||||
// Set by <AlternateScreen> via setAltScreenActive(). Controls the
|
||||
// renderer's cursor.y clamping (keeps cursor in-viewport to avoid
|
||||
// LF-induced scroll when screen.height === terminalRows) and gates
|
||||
@@ -287,6 +316,14 @@ export default class Ink {
|
||||
this.restoreStderr = this.patchStderr()
|
||||
}
|
||||
|
||||
// Host-supplied hyperlink-open callback. The mouse-event pipeline
|
||||
// (App.tsx → onOpenHyperlink → Ink.openHyperlink → onHyperlinkClick)
|
||||
// is fully wired internally; without this assignment the optional
|
||||
// chain in openHyperlink() bails silently and clicks on URLs do
|
||||
// nothing. The field stays writable so tests / debug overlays can
|
||||
// still rebind it after construction.
|
||||
this.onHyperlinkClick = options.onHyperlinkClick
|
||||
|
||||
this.terminal = {
|
||||
stdout: options.stdout,
|
||||
stderr: options.stderr
|
||||
@@ -447,17 +484,22 @@ export default class Ink {
|
||||
private handleResize = () => {
|
||||
const cols = this.options.stdout.columns || 80
|
||||
const rows = this.options.stdout.rows || 24
|
||||
const dimsChanged = cols !== this.terminalColumns || rows !== this.terminalRows
|
||||
|
||||
// Terminals often emit 2+ resize events for one user action (window
|
||||
// settling). Same-dimension events are no-ops; skip to avoid redundant
|
||||
// frame resets and renders.
|
||||
if (cols === this.terminalColumns && rows === this.terminalRows) {
|
||||
// Terminals often emit 2+ resize events for one user action
|
||||
// (window settling). Same-dimension events are usually no-ops,
|
||||
// but in alt-screen mode a same-dimension resize can signal a
|
||||
// terminal host reflow or buffer restore that leaves stale glyphs
|
||||
// on the physical screen — treat it as a repaint signal.
|
||||
if (!dimsChanged && !(this.altScreenActive && !this.isPaused && this.options.stdout.isTTY)) {
|
||||
return
|
||||
}
|
||||
|
||||
this.terminalColumns = cols
|
||||
this.terminalRows = rows
|
||||
this.altScreenParkPatch = makeAltScreenParkPatch(this.terminalRows)
|
||||
if (dimsChanged) {
|
||||
this.terminalColumns = cols
|
||||
this.terminalRows = rows
|
||||
this.altScreenParkPatch = makeAltScreenParkPatch(this.terminalRows)
|
||||
}
|
||||
|
||||
// Pending throttled/drain work captured stale dims — cancel so
|
||||
// the upcoming microtask owns the next frame.
|
||||
@@ -484,26 +526,7 @@ export default class Ink {
|
||||
// doesn't exit alt-screen. Do NOT write ERASE_SCREEN: render() below
|
||||
// can take ~80ms; erasing first leaves the screen blank that whole time.
|
||||
if (this.altScreenActive && !this.isPaused && this.options.stdout.isTTY) {
|
||||
if (this.altScreenMouseTracking) {
|
||||
this.options.stdout.write(ENABLE_MOUSE_TRACKING)
|
||||
}
|
||||
|
||||
this.resetFramesForAltScreen()
|
||||
this.needsEraseBeforePaint = true
|
||||
|
||||
// One last repaint after the resize burst settles closes any host-side
|
||||
// reflow drift the normal diff path can't see.
|
||||
this.resizeSettleTimer = setTimeout(() => {
|
||||
this.resizeSettleTimer = null
|
||||
|
||||
if (!this.canAltScreenRepaint()) {
|
||||
return
|
||||
}
|
||||
|
||||
this.resetFramesForAltScreen()
|
||||
this.needsEraseBeforePaint = true
|
||||
this.render(this.currentNode!)
|
||||
}, 160)
|
||||
this.prepareAltScreenResizeRepaint()
|
||||
}
|
||||
|
||||
// Already queued: later events in this burst updated dims/alt-screen
|
||||
@@ -536,6 +559,36 @@ export default class Ink {
|
||||
)
|
||||
}
|
||||
|
||||
private prepareAltScreenResizeRepaint(): void {
|
||||
// Clear any pending settle timer from a previous resize burst so
|
||||
// rapid events don't stack redundant delayed repaints. (handleResize
|
||||
// also clears this, but the defensive clear keeps the method safe
|
||||
// if it's ever called from other code paths.)
|
||||
if (this.resizeSettleTimer !== null) {
|
||||
clearTimeout(this.resizeSettleTimer)
|
||||
this.resizeSettleTimer = null
|
||||
}
|
||||
|
||||
if (this.altScreenMouseTracking) {
|
||||
this.options.stdout.write(ENABLE_MOUSE_TRACKING)
|
||||
}
|
||||
|
||||
this.resetFramesForAltScreen()
|
||||
this.needsEraseBeforePaint = true
|
||||
|
||||
this.resizeSettleTimer = setTimeout(() => {
|
||||
this.resizeSettleTimer = null
|
||||
|
||||
if (!this.canAltScreenRepaint()) {
|
||||
return
|
||||
}
|
||||
|
||||
this.resetFramesForAltScreen()
|
||||
this.needsEraseBeforePaint = true
|
||||
this.render(this.currentNode!)
|
||||
}, 160)
|
||||
}
|
||||
|
||||
resolveExitPromise: () => void = () => {}
|
||||
rejectExitPromise: (reason?: Error) => void = () => {}
|
||||
unsubscribeExit: () => void = () => {}
|
||||
@@ -769,6 +822,26 @@ export default class Ink {
|
||||
// Position-highlight (below) overlays CURRENT (yellow) on top.
|
||||
hlActive = applySearchHighlight(frame.screen, this.searchHighlightQuery, this.stylePool)
|
||||
|
||||
// Hyperlink hover overlay: inverts every cell of the link currently
|
||||
// under the pointer. Cheap-ish (linear scan of the visible buffer),
|
||||
// only fires when hoveredHyperlink is set.
|
||||
//
|
||||
// hlActive controls full-screen damage (used by selection/search to
|
||||
// make sure the previous frame's inverted cells get re-diffed when
|
||||
// the highlight set changes). For hover, the *transition* is what
|
||||
// needs the full-damage hammer — enter / leave / change-to-other-link.
|
||||
// During steady-state hover the painted cells don't change and the
|
||||
// ordinary per-cell diff handles the no-op. Folding the steady-state
|
||||
// case into hlActive would burn full-screen diffs every frame while
|
||||
// the pointer just sits on the link.
|
||||
const hoverApplied = applyHyperlinkHoverHighlight(frame.screen, this.hoveredHyperlink, this.stylePool)
|
||||
const hoverTransition = this.hoveredHyperlink !== this.lastRenderedHoveredHyperlink
|
||||
this.lastRenderedHoveredHyperlink = this.hoveredHyperlink
|
||||
|
||||
if (hoverApplied && hoverTransition) {
|
||||
hlActive = true
|
||||
}
|
||||
|
||||
// Position-based CURRENT: write yellow at positions[currentIdx] +
|
||||
// rowOffset. No scanning — positions came from a prior scan when
|
||||
// the message first mounted. Message-relative + rowOffset = screen.
|
||||
@@ -862,8 +935,9 @@ export default class Ink {
|
||||
const optimized = optimize(diff)
|
||||
const optimizeMs = performance.now() - tOptimize
|
||||
const hasDiff = optimized.length > 0
|
||||
const needsAltScreenErase = this.altScreenActive && this.needsEraseBeforePaint
|
||||
|
||||
if (this.altScreenActive && hasDiff) {
|
||||
if (this.altScreenActive && (hasDiff || needsAltScreenErase)) {
|
||||
// Prepend CSI H to anchor the physical cursor to (0,0) so
|
||||
// log-update's relative moves compute from a known spot (self-healing
|
||||
// against out-of-band cursor drift, see the ALT_SCREEN_ANCHOR_CURSOR
|
||||
@@ -883,7 +957,7 @@ export default class Ink {
|
||||
// resize, so it gets CSI 3J in this one recovery path. When BSU/ESU is
|
||||
// supported, the clear+paint lands atomically; otherwise the final state
|
||||
// is still healed even if the repaint is visible.
|
||||
if (this.needsEraseBeforePaint) {
|
||||
if (needsAltScreenErase) {
|
||||
this.needsEraseBeforePaint = false
|
||||
optimized.unshift(needsAltScreenResizeScrollbackClear() ? DEEP_ERASE_THEN_HOME_PATCH : ERASE_THEN_HOME_PATCH)
|
||||
} else {
|
||||
@@ -1005,7 +1079,7 @@ export default class Ink {
|
||||
this.lastDrainMs = 0
|
||||
|
||||
// Only track drain on TTY. Piped/non-TTY stdout bypasses flow control.
|
||||
const trackDrain = this.options.stdout.isTTY && hasDiff
|
||||
const trackDrain = this.options.stdout.isTTY && optimized.length > 0
|
||||
const drainStart = trackDrain ? tWrite : 0
|
||||
|
||||
if (trackDrain) {
|
||||
@@ -1182,6 +1256,16 @@ export default class Ink {
|
||||
this.altScreenActive = active
|
||||
this.altScreenMouseTracking = active && mouseTracking
|
||||
|
||||
// Hover state is alt-screen-scoped: dispatchHover is gated on
|
||||
// altScreenActive, so once we leave the alt screen there's no path to
|
||||
// clear it on our own. Without this reset, remounting <AlternateScreen>
|
||||
// would render a phantom hover highlight from the previous session
|
||||
// until the next mouse-move event arrived. Clear both the live value
|
||||
// and the last-rendered tracker so the next onRender sees no transition
|
||||
// and no overlay.
|
||||
this.hoveredHyperlink = undefined
|
||||
this.lastRenderedHoveredHyperlink = undefined
|
||||
|
||||
if (active) {
|
||||
this.resetFramesForAltScreen()
|
||||
} else {
|
||||
@@ -1770,6 +1854,34 @@ export default class Ink {
|
||||
}
|
||||
|
||||
dispatchHover(this.rootNode, col, row, this.hoveredNodes)
|
||||
|
||||
// Hover affordance for hyperlinks: read the cell at the pointer, store
|
||||
// its URL (or clear when the pointer leaves a link), and request a
|
||||
// repaint when the value changes. The render-pass overlay paints the
|
||||
// highlight; we just track which URL is "hot".
|
||||
//
|
||||
// IMPORTANT: bypass getHyperlinkAt() here — its plain-text URL fallback
|
||||
// (findPlainTextUrlAt) would return URLs for cells whose `cell.hyperlink`
|
||||
// is undefined, which the overlay (applyHyperlinkHoverHighlight)
|
||||
// wouldn't match. That'd burn re-renders without ever producing an
|
||||
// affordance. Read the OSC 8 hyperlink directly off the cell so the
|
||||
// hover state is a 1:1 fit for what the overlay can paint. The
|
||||
// plain-text URL fallback still works for clicks; hover is a strictly
|
||||
// weaker signal and OK to skip on plain-text URLs.
|
||||
const screen = this.frontFrame.screen
|
||||
const cell = cellAt(screen, col, row)
|
||||
let next = cell?.hyperlink
|
||||
|
||||
// SpacerTail (second half of a wide-char / emoji glyph) stores the
|
||||
// hyperlink on the head cell at col-1. Same logic as getHyperlinkAt.
|
||||
if (!next && cell?.width === CellWidth.SpacerTail && col > 0) {
|
||||
next = cellAt(screen, col - 1, row)?.hyperlink
|
||||
}
|
||||
|
||||
if (next !== this.hoveredHyperlink) {
|
||||
this.hoveredHyperlink = next
|
||||
this.scheduleRender()
|
||||
}
|
||||
}
|
||||
dispatchKeyboardEvent(parsedKey: ParsedKey): void {
|
||||
const target = this.focusManager.activeElement ?? this.rootNode
|
||||
@@ -1814,8 +1926,13 @@ export default class Ink {
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional callback fired when clicking an OSC 8 hyperlink in fullscreen
|
||||
* mode. Set by FullscreenLayout via useLayoutEffect.
|
||||
* Optional callback fired when clicking a cell that has an associated URL
|
||||
* in fullscreen mode. `url` may be either an OSC 8 hyperlink (from a
|
||||
* `<Link>` render or external OSC 8 escape that landed in the buffer) or
|
||||
* a plain-text URL detected on the clicked row by findPlainTextUrlAt
|
||||
* (App.tsx routes both into the same callback). Set from the host via
|
||||
* the `onHyperlinkClick` Render/Ink option, or directly on the instance
|
||||
* for late-bound test scenarios.
|
||||
*/
|
||||
onHyperlinkClick: ((url: string) => void) | undefined
|
||||
|
||||
|
||||
@@ -44,6 +44,22 @@ export type RenderOptions = {
|
||||
* Called after each frame render with timing and flicker information.
|
||||
*/
|
||||
onFrame?: (event: FrameEvent) => void
|
||||
|
||||
/**
|
||||
* Called when a click lands on a cell with an OSC 8 hyperlink (or a
|
||||
* plain-text URL the renderer detects on the same row). The host owns
|
||||
* the actual open — `child_process.spawn` with an argv array (NOT
|
||||
* shell-mode) to the platform's native opener: `open` on macOS,
|
||||
* `xdg-open` on Linux/BSD, `explorer.exe` on Windows. Avoid
|
||||
* `cmd.exe /c start` — `start` is a cmd builtin that reparses the URL
|
||||
* through cmd's tokenizer (`&` / `|` / `^` / `<` / `>` get split or
|
||||
* reinterpreted as command syntax), which both breaks plain URLs with
|
||||
* `&` in query strings and undermines any protocol allowlist on the
|
||||
* caller side. Hermes wires this in `entry.tsx`; library users who
|
||||
* don't pass it will see clickable underline styling but no action on
|
||||
* click in any terminal where mouse tracking is on.
|
||||
*/
|
||||
onHyperlinkClick?: (url: string) => void
|
||||
}
|
||||
|
||||
export type Instance = {
|
||||
@@ -138,7 +154,8 @@ export async function createRoot({
|
||||
stderr = process.stderr,
|
||||
exitOnCtrlC = true,
|
||||
patchConsole = true,
|
||||
onFrame
|
||||
onFrame,
|
||||
onHyperlinkClick
|
||||
}: RenderOptions = {}): Promise<Root> {
|
||||
// See wrappedRender — preserve microtask boundary from the old WASM await.
|
||||
await Promise.resolve()
|
||||
@@ -149,7 +166,8 @@ export async function createRoot({
|
||||
stderr,
|
||||
exitOnCtrlC,
|
||||
patchConsole,
|
||||
onFrame
|
||||
onFrame,
|
||||
onHyperlinkClick
|
||||
})
|
||||
|
||||
// Register in the instances map so that code that looks up the Ink
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Vendored
+1
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user