Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 25 additions & 40 deletions ui-tui/packages/hermes-ink/src/ink/components/Link.tsx
Original file line number Diff line number Diff line change
@@ -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=
52 changes: 52 additions & 0 deletions ui-tui/packages/hermes-ink/src/ink/hyperlinkHover.ts
Original file line number Diff line number Diff line change
@@ -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
}
104 changes: 102 additions & 2 deletions ui-tui/packages/hermes-ink/src/ink/ink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Comment thread
OutThisLife marked this conversation as resolved.

// 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -769,6 +806,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.
Expand Down Expand Up @@ -1182,6 +1239,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 {
Expand Down Expand Up @@ -1770,6 +1837,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()
}
Comment thread
OutThisLife marked this conversation as resolved.
}
dispatchKeyboardEvent(parsedKey: ParsedKey): void {
const target = this.focusManager.activeElement ?? this.rootNode
Expand Down Expand Up @@ -1814,8 +1909,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
Comment thread
OutThisLife marked this conversation as resolved.

Expand Down
22 changes: 20 additions & 2 deletions ui-tui/packages/hermes-ink/src/ink/root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand Down
13 changes: 12 additions & 1 deletion ui-tui/src/entry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}
})
Loading
Loading