Skip to content

Commit 4f5a6e6

Browse files
abernierclaude
andcommitted
feat: click a page to read it, and a link in one to open it
The terminal has a pointer and Ink does not know it, so the reader asks for SGR mouse reporting itself: the terminal then sends a CSI sequence per click and per wheel notch, and Ink hands each one to `useInput` whole. Reading the pointer is parsing a string, and the panes' geometry is what turns a row back into the page that was clicked. The wheel answers to the pointer rather than to the focus: whatever sits under it is what moves. Links carry their target now, resolved against the page they were written on, so a click on one opens it. They are also OSC 8 hyperlinks, which costs a sequence Ink passes through untouched and no width at all: a terminal that speaks it underlines the label and opens the URL on its own, and one that does not prints the label alone, as before. In exchange the terminal stops selecting text on its own -- that needs the usual modifier held down while the reader is up. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F9YQGrxNQLRHRLarpzJXLm
1 parent 343037e commit 4f5a6e6

11 files changed

Lines changed: 352 additions & 32 deletions

.changeset/cli-browse.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Read the published pmndrs documentation from the terminal: `npx @pmndrs/docs`.
66

77
Two new commands, both reading each library's published `llms-full.txt` — one GET per library, cached for an hour under `~/.cache/pmndrs-docs`, `--refresh` to fetch again:
88

9-
- `browse [target]`, the default command, is a reader: pages on the left, the page on the right, `b` folds the sidebar away, `/` searches every library at once, `o` opens the page in a browser. A target lands straight where it points — `drei`, `drei/performances/instances`, or a query.
9+
- `browse [target]`, the default command, is a reader: pages on the left, the page on the right, `b` folds the sidebar away, `/` searches every library at once, `o` opens the page in a browser. A target lands straight where it points — `drei`, `drei/performances/instances`, or a query. The mouse works too: click a page to read it, click a link in one to open it, and the wheel turns whichever pane it sits over — and links are terminal hyperlinks, so a terminal that speaks OSC 8 opens them on its own.
1010
- `search <query>` is the same search with no screen: one result per line on stdout, in the `{lib} {path} - {title}` shape the MCP server publishes its index in, so a pipe or an agent can read it. `--in` narrows to a library, or to the matching lines of a single page.
1111

1212
Outside a terminal `browse` writes the page it was pointed at to stdout, rather than opening anything.

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ hands the focus over, `esc` or `⇥` hands it back, and the lit border says wher
2121
changes library, `b` folds the sidebar away, `/` searches every library at once, `o` opens the
2222
page in a browser, `q` quits.
2323

24+
The mouse works too: click a page to read it, click a link in one to open it, and the wheel
25+
turns whichever pane it sits over. Links are terminal hyperlinks as well, so a terminal that
26+
speaks OSC 8 opens them on its own. Selecting text needs the usual modifier held down (⌥ in
27+
iTerm2, ⇧ elsewhere) while the reader is asking for the pointer.
28+
2429
`search` is the half a pipe or an agent can use: results read `{lib} {path} - {title}`, the
2530
shape the [MCP server](https://docs.pmnd.rs) publishes its index in, and nothing found exits 1.
2631
`--in drei` narrows to one library, `--in drei/performances/instances` to the matching lines of

src/cli/browse.lines.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// The styled-line model, as Ink. `toAnsi` is the same lines for a pager.
22

33
import { Text } from 'ink'
4-
import type { Line } from './browse.markdown'
4+
import { hyperlink, type Line } from './browse.markdown'
55

66
export function Lines({ lines }: { lines: Line[] }) {
77
return (
@@ -20,7 +20,7 @@ export function Lines({ lines }: { lines: Line[] }) {
2020
italic={span.italic}
2121
underline={span.underline}
2222
>
23-
{span.text}
23+
{span.href ? hyperlink(span.text, span.href) : span.text}
2424
</Text>
2525
))}
2626
</Text>

src/cli/browse.list.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,18 @@ export interface Row {
88
hint?: string
99
}
1010

11+
/**
12+
* The first row a list of `length` shows, given where its cursor is.
13+
*
14+
* The window is the whole of the list's scrolling: what it shows follows the cursor rather
15+
* than a scroll of its own. A pointer needs it too -- it is what turns a screen row back into
16+
* the row of the list that was clicked.
17+
*/
18+
export function listWindowStart(cursor: number, length: number, height: number) {
19+
const window = Math.max(1, height)
20+
return Math.max(0, Math.min(cursor - Math.floor(window / 2), length - window))
21+
}
22+
1123
/** A list of `height` rows that keeps the cursor in view, centred when it can be. */
1224
export function List({
1325
rows,
@@ -21,7 +33,7 @@ export function List({
2133
focused?: boolean
2234
}) {
2335
const window = Math.max(1, height)
24-
const start = Math.max(0, Math.min(cursor - Math.floor(window / 2), rows.length - window))
36+
const start = listWindowStart(cursor, rows.length, height)
2537

2638
return (
2739
<Box flexDirection="column" height={window} flexShrink={0}>

src/cli/browse.markdown.test.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
import { expect, test } from 'vitest'
2-
import { plain, renderMarkdown, toAnsi, type Line } from './browse.markdown'
2+
import { plain, renderMarkdown, spanAt, toAnsi, type Line } from './browse.markdown'
33

44
const WIDTH = 80
55

6+
const ESC = String.fromCharCode(27)
7+
const BEL = String.fromCharCode(7)
8+
const HYPERLINK = new RegExp(`${ESC}\\]8;;[^${BEL}]*${BEL}`, 'g')
9+
const SGR = new RegExp(`${ESC}\\[[\\d;]*m`, 'g')
10+
611
test('renders a heading in its own colour, over a rule', () => {
712
const lines = renderMarkdown('## Getting started', WIDTH)
813
const heading = lines.find((line) => line[0]?.text === 'Getting started')
@@ -42,6 +47,56 @@ test('renders a link as its underlined label, without the URL', () => {
4247
expect(lines.flat().find((span) => span.underline)?.text).toBe('the docs')
4348
})
4449

50+
test('a link keeps its target, resolved against the page it was written on', () => {
51+
const base = 'https://pmndrs.github.io/drei/performances/instances'
52+
const lines = renderMarkdown(
53+
'see [the docs](/getting-started) and [three](https://threejs.org)',
54+
80,
55+
base,
56+
)
57+
const links = lines.flat().filter((span) => span.href)
58+
59+
expect(links.map((span) => span.href)).toEqual([
60+
'https://pmndrs.github.io/getting-started',
61+
'https://threejs.org/',
62+
])
63+
})
64+
65+
test('with no page to resolve against, only an absolute link keeps its target', () => {
66+
const lines = renderMarkdown('[near](/getting-started) and [far](https://threejs.org)', WIDTH)
67+
const spans = lines.flat()
68+
69+
expect(plain(lines)).toEqual(['near and far'])
70+
expect(spans.find((span) => span.text === 'near')?.href).toBeUndefined()
71+
expect(spans.find((span) => span.text === 'far')?.href).toBe('https://threejs.org')
72+
})
73+
74+
test('two links side by side stay two links', () => {
75+
const lines = renderMarkdown('[one](https://a.example) [two](https://b.example)', WIDTH)
76+
77+
expect(lines.flat().filter((span) => span.href).length).toBe(2)
78+
})
79+
80+
test('a linked span is a terminal hyperlink, printing the label alone', () => {
81+
const lines = renderMarkdown('see [the docs](https://docs.pmnd.rs) for more', WIDTH)
82+
const ansi = toAnsi(lines)
83+
84+
// ESC ] 8 ; ; URL BEL label ESC ] 8 ; ; BEL -- the URL travels inside the sequence, so what
85+
// the terminal shows is the label, and the label is what a click follows
86+
expect(ansi).toContain(`${ESC}]8;;https://docs.pmnd.rs${BEL}the docs${ESC}]8;;${BEL}`)
87+
expect(ansi.replace(HYPERLINK, '').replace(SGR, '')).toBe('see the docs for more')
88+
})
89+
90+
test('spanAt finds what sits at a column, and nothing past the end of the line', () => {
91+
const [line] = renderMarkdown('see [the docs](https://docs.pmnd.rs) here', WIDTH)
92+
93+
expect(spanAt(line, 0)?.text).toBe('see ')
94+
expect(spanAt(line, 5)?.href).toBe('https://docs.pmnd.rs')
95+
expect(spanAt(line, 11)?.href).toBe('https://docs.pmnd.rs')
96+
expect(spanAt(line, 12)?.href).toBeUndefined()
97+
expect(spanAt(line, 999)).toBeUndefined()
98+
})
99+
45100
test('renders a blockquote behind a gutter', () => {
46101
const lines = renderMarkdown('> mind the gap', WIDTH)
47102

src/cli/browse.markdown.ts

Lines changed: 70 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99

1010
export interface Span {
1111
text: string
12+
/** Where the span points, absolute. A link, and nothing else, has one. */
13+
href?: string
1214
fg?: string
1315
bold?: boolean
1416
dim?: boolean
@@ -35,7 +37,23 @@ const BADGE_ROW = /^\s*(\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)\s*)+$/
3537
/** Inline `code`, [label](href), **bold** and _emphasis_, in one left-to-right pass. */
3638
const INLINE_TOKEN = /(`[^`]+`)|(\[([^\]]*)\]\(([^)]+)\))|(\*\*[^*]+\*\*)|(_[^_]+_)/g
3739

38-
function inline(text: string): Span[] {
40+
/**
41+
* A link's target as a browser can follow it.
42+
*
43+
* Docs link to each other with a path (`/getting-started/introduction`), which only means
44+
* something next to the page it was written on -- so a link is worth keeping only when there
45+
* is a page to resolve it against.
46+
*/
47+
function resolve(href: string, base: string | undefined): string | undefined {
48+
if (!base) return /^[a-z][\w+.-]*:/i.test(href) ? href : undefined
49+
try {
50+
return new URL(href, base).href
51+
} catch {
52+
return undefined
53+
}
54+
}
55+
56+
function inline(text: string, base?: string): Span[] {
3957
const spans: Span[] = []
4058
let last = 0
4159

@@ -44,9 +62,15 @@ function inline(text: string): Span[] {
4462
if (at > last) spans.push({ text: text.slice(last, at) })
4563

4664
if (match[1]) spans.push({ text: match[1].slice(1, -1), fg: theme.code })
47-
// A link keeps its label, or its href when it has no label. The URL itself is not
48-
// clickable in a pager, and spelling it out costs more width than it is worth.
49-
else if (match[2]) spans.push({ text: match[3] || match[4], fg: theme.link, underline: true })
65+
// A link keeps its label, or its href when it has no label: spelling the URL out next to
66+
// the label costs more width than it is worth, and the href travels with the span anyway
67+
else if (match[2])
68+
spans.push({
69+
text: match[3] || match[4],
70+
href: resolve(match[4], base),
71+
fg: theme.link,
72+
underline: true,
73+
})
5074
else if (match[5]) spans.push({ text: match[5].slice(2, -2), bold: true })
5175
else if (match[6]) spans.push({ text: match[6].slice(1, -1), italic: true })
5276

@@ -58,6 +82,7 @@ function inline(text: string): Span[] {
5882
}
5983

6084
const sameStyle = (a: Span, b: Span) =>
85+
a.href === b.href &&
6186
a.fg === b.fg &&
6287
!!a.bold === !!b.bold &&
6388
!!a.dim === !!b.dim &&
@@ -135,7 +160,7 @@ function demoMarker(folder: string | undefined): Line {
135160
return line
136161
}
137162

138-
function renderBlocks(source: string[], width: number): Line[] {
163+
function renderBlocks(source: string[], width: number, base?: string): Line[] {
139164
const out: Line[] = []
140165
let i = 0
141166

@@ -197,7 +222,7 @@ function renderBlocks(source: string[], width: number): Line[] {
197222

198223
const bullet = line.match(/^(\s*)[-*]\s+(.*)$/)
199224
if (bullet) {
200-
out.push(...wrap(inline(bullet[2]), width, `${bullet[1]} • `))
225+
out.push(...wrap(inline(bullet[2], base), width, `${bullet[1]} • `))
201226
i++
202227
continue
203228
}
@@ -216,7 +241,7 @@ function renderBlocks(source: string[], width: number): Line[] {
216241
continue
217242
}
218243

219-
out.push(...wrap(inline(line), width))
244+
out.push(...wrap(inline(line, base), width))
220245
i++
221246
}
222247

@@ -226,10 +251,14 @@ function renderBlocks(source: string[], width: number): Line[] {
226251
/**
227252
* Renders one markdown body to styled lines, wrapped to `width` columns.
228253
*
254+
* `base` is the URL the body was published at, and it is what turns the relative links a page
255+
* makes to its neighbours into links something can follow. Without it, only the absolute ones
256+
* survive as links.
257+
*
229258
* Never throws: any input renders, and anything malformed degrades to the plain text it was
230259
* written as. A reader is not allowed to die on a page.
231260
*/
232-
export function renderMarkdown(body: string, width: number): Line[] {
261+
export function renderMarkdown(body: string, width: number, base?: string): Line[] {
233262
const source = String(body ?? '')
234263
.replace(/\r\n?/g, '\n')
235264
// Pages come out of the corpus behind their own `URL:`/`Description:` header.
@@ -241,15 +270,45 @@ export function renderMarkdown(body: string, width: number): Line[] {
241270
const columns = Number.isFinite(width) && width >= 1 ? Math.floor(width) : 1
242271

243272
try {
244-
return renderBlocks(source, columns)
273+
return renderBlocks(source, columns, base)
245274
} catch {
246275
return source.map((line) => [{ text: line }])
247276
}
248277
}
249278

279+
/**
280+
* The span covering `column`, counting from the start of the line, or nothing past its end.
281+
*
282+
* This is how a pointer lands on a word: the reader knows where a line starts on screen, and
283+
* the line knows what sits at each of its columns.
284+
*/
285+
export function spanAt(line: Line, column: number): Span | undefined {
286+
let at = 0
287+
for (const span of line) {
288+
at += span.text.length
289+
if (column < at) return span
290+
}
291+
return undefined
292+
}
293+
250294
const RESET = '\x1b[0m'
251295
const HEX = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i
252296

297+
const OSC = '\x1b]8;;'
298+
const BEL = '\x07'
299+
300+
/**
301+
* Marks `text` as a link to `href`, the way OSC 8 does.
302+
*
303+
* The terminal is what makes it clickable, so this costs no width, no key and no code: a
304+
* terminal that speaks OSC 8 underlines the label and opens the URL on a click, and one that
305+
* does not prints the label alone. Ink passes the sequence through untouched, and measures
306+
* the text as if it were not there.
307+
*/
308+
export function hyperlink(text: string, href: string): string {
309+
return `${OSC}${href}${BEL}${text}${OSC}${BEL}`
310+
}
311+
253312
function ansi(span: Span): string {
254313
let codes = ''
255314

@@ -263,7 +322,8 @@ function ansi(span: Span): string {
263322
if (span.italic) codes += '\x1b[3m'
264323
if (span.underline) codes += '\x1b[4m'
265324

266-
return codes ? codes + span.text + RESET : span.text
325+
const text = span.href ? hyperlink(span.text, span.href) : span.text
326+
return codes ? codes + text + RESET : text
267327
}
268328

269329
/** Serializes lines to one ANSI string, for a pager or a plain `stdout` write. */

src/cli/browse.mouse.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { expect, test } from 'vitest'
2+
import { parseMouse } from './browse.mouse'
3+
4+
/** What Ink hands over: the sequence the terminal sent, minus its leading escape. */
5+
const event = (button: number, column: number, row: number, final = 'M') =>
6+
`[<${button};${column};${row}${final}`
7+
8+
test('reads a left click, in coordinates counting from zero', () => {
9+
expect(parseMouse(event(0, 12, 7))).toEqual({ kind: 'click', column: 11, row: 6 })
10+
})
11+
12+
test('reads the wheel, both ways', () => {
13+
expect(parseMouse(event(64, 1, 1))?.kind).toBe('wheel-up')
14+
expect(parseMouse(event(65, 1, 1))?.kind).toBe('wheel-down')
15+
})
16+
17+
test('a click counts once: the release that ends it is not a second one', () => {
18+
expect(parseMouse(event(0, 12, 7, 'm'))).toBeUndefined()
19+
})
20+
21+
test('a modifier held down changes nothing about the click', () => {
22+
// shift (4), meta (8) and ctrl (16) ride in the same number as the button
23+
expect(parseMouse(event(0 + 4 + 16, 12, 7))).toEqual({ kind: 'click', column: 11, row: 6 })
24+
})
25+
26+
test('leaves alone what it has no answer for', () => {
27+
expect(parseMouse(event(2, 12, 7))).toBeUndefined() // right button
28+
expect(parseMouse(event(66, 12, 7))).toBeUndefined() // sideways wheel
29+
expect(parseMouse(event(32, 12, 7))).toBeUndefined() // a drag
30+
expect(parseMouse('j')).toBeUndefined()
31+
expect(parseMouse('[A')).toBeUndefined()
32+
})
33+
34+
test('reads a column past the 223 an older encoding could report', () => {
35+
expect(parseMouse(event(0, 400, 300))).toEqual({ kind: 'click', column: 399, row: 299 })
36+
})

src/cli/browse.mouse.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// The terminal has a pointer; Ink does not know it. Asking for SGR mouse reporting makes the
2+
// terminal send one CSI sequence per click and per wheel notch, and Ink hands each of them to
3+
// `useInput` whole, with its ESC already stripped -- so reading the pointer is reading a
4+
// string, and the reader needs no second listener on stdin.
5+
6+
/** Turns click and wheel reporting on. SGR (1006) is what lifts the 223-column limit. */
7+
export const MOUSE_ON = '\x1b[?1000h\x1b[?1006h'
8+
9+
/** And off again -- left set, the terminal keeps reporting into whatever runs next. */
10+
export const MOUSE_OFF = '\x1b[?1006l\x1b[?1000l'
11+
12+
export interface Mouse {
13+
kind: 'click' | 'wheel-up' | 'wheel-down'
14+
/** Both 0-based, from the top-left corner of the terminal, as Ink lays it out. */
15+
column: number
16+
row: number
17+
}
18+
19+
/** `[<button;column;rowM` — a press, or `m`, its release. Columns and rows count from 1. */
20+
const SGR_MOUSE = /^\[<(\d+);(\d+);(\d+)([Mm])$/
21+
22+
/** Modifiers ride in bits 2-4 of the button, and say nothing about where the pointer is. */
23+
const BUTTON = 0b1000011
24+
25+
/** Bit 5 marks a move rather than a press -- a drag has nothing here to drag. */
26+
const MOTION = 0b100000
27+
28+
/**
29+
* The pointer event `input` carries, or nothing when it carries none.
30+
*
31+
* Only what the reader acts on comes back: the press of the left button, and the wheel. A
32+
* release repeats the press that it ends, and a drag would move a cursor with nothing to drag.
33+
*/
34+
export function parseMouse(input: string): Mouse | undefined {
35+
const match = input.match(SGR_MOUSE)
36+
if (!match) return undefined
37+
38+
// A wheel notch reports as a press with no release, so acting on presses alone still counts
39+
// every notch -- and counts a click once rather than twice.
40+
if (match[4] !== 'M') return undefined
41+
42+
if (Number(match[1]) & MOTION) return undefined
43+
44+
const button = Number(match[1]) & BUTTON
45+
const kind =
46+
button === 64 ? 'wheel-up' : button === 65 ? 'wheel-down' : button === 0 ? 'click' : undefined
47+
if (!kind) return undefined
48+
49+
return { kind, column: Number(match[2]) - 1, row: Number(match[3]) - 1 }
50+
}

0 commit comments

Comments
 (0)