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
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -660,7 +660,11 @@ terminal theme, anything that reads or shows files.
links are painted once and ride a marker; the LIVE screen is redrawn in place by TUIs, so every
pass throws away what it painted from the last finished line down and paints that again (a
decoration left on a rewritten row is a blue smear over words that were never a link). Rebuilt
on a resize (reflow) and on a theme change; not on the alternate screen. Columns are counted in
on a resize (reflow) and on a theme change. **THE ALTERNATE SCREEN TOO** (#97; owner, 2026-09-28:
a link in Claude Code's fullscreen view "is not blue ... it seems to know it's a link"): markers,
so decorations, do not exist there, so each row xterm DRAWS there (`onRender`) has its link text
wrapped in a span of the link colour; xterm replaces a row's contents when it draws it, so a
rewritten row starts clean. Per row, since a TUI places its own text. Columns are counted in
CELLS, since a wide character is one character and two cells. xterm splits a row into spans as
it likes, so the e2e finds a link's span by POSITION, never by its text.
- **A file dropped on the terminal types its quoted path, and the terminal answers a right-click**
Expand Down
2 changes: 1 addition & 1 deletion core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "prism-term-core",
"version": "0.18.1",
"version": "0.18.2",
"description": "What Prism Terminal and Prism share: the terminal (pty, shells, agent detection and indicator, themes, links, the panel, dictation) and the update chip with its window. TypeScript source, compiled by the host.",
"license": "MIT",
"private": true,
Expand Down
63 changes: 60 additions & 3 deletions core/renderer/lib/termLinkPaint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,15 @@ import { findLinks } from './termLinks'
* last finished line down and paints that part again. A marker remembers where
* "finished" was, which survives the buffer scrolling and trimming under it.
*
* Not on the alternate screen: vim and less own every cell there, markers do
* not exist in it, and what is in it is not a log of printed output.
* THE ALTERNATE SCREEN IS INKED AS IT IS DRAWN (owner, 2026-09-28: a link in
* Claude Code's fullscreen view "is not blue ... it seems to know it's a link
* since i can click it"). Markers do not exist there, so neither do
* decorations. Instead, each time xterm draws rows there (`onRender`), the
* link text in those rows' elements is wrapped in a span of the link colour.
* xterm's DOM renderer REPLACES a row's contents when it draws it, so a row a
* TUI rewrites starts clean and is inked again only if it still holds a link:
* no smear to clean up. Per row: a program that owns the screen places its own
* text, and a link it breaks over two rows is two pieces of text there.
*/
export interface LinkPainter extends IDisposable {
/** The colour changed (a theme, a custom ground): paint everything again. */
Expand Down Expand Up @@ -114,6 +121,53 @@ export function attachLinkPaint(term: Terminal, color: () => string): LinkPainte
}
}

/** Ink the links in one drawn row of the alternate screen. */
const inkRow = (row: Element, ink: string): void => {
const text = row.textContent ?? ''
if (!text.includes('://')) return
const links = findLinks(text)
if (!links.length) return
// Where each text node starts in the row's text.
const nodes: Array<{ node: Text; at: number }> = []
const walk = document.createTreeWalker(row, NodeFilter.SHOW_TEXT)
let at = 0
for (let n = walk.nextNode(); n; n = walk.nextNode()) {
nodes.push({ node: n as Text, at })
at += n.textContent?.length ?? 0
}
const pieces: Array<{ node: Text; from: number; to: number }> = []
for (const link of links)
for (const { node, at: start } of nodes) {
const from = Math.max(link.start, start)
const to = Math.min(link.end, start + (node.textContent?.length ?? 0))
if (from < to) pieces.push({ node, from: from - start, to: to - start })
}
// Last first: wrapping splits a text node, and the part BEFORE the split
// stays the node the earlier pieces point into.
for (const { node, from, to } of pieces.reverse()) {
const range = document.createRange()
range.setStart(node, from)
range.setEnd(node, to)
const span = document.createElement('span')
span.dataset.linkInk = ''
span.style.color = ink
span.style.textDecoration = 'underline'
span.style.textDecorationColor = `${ink}8c`
range.surroundContents(span)
}
}

const inkDrawn = (start: number, end: number): void => {
if (dead || term.buffer.active.type !== 'alternate') return
const rows = term.element?.querySelector('.xterm-rows')?.children
if (!rows) return
const ink = color()
for (let r = start; r <= end; r += 1) {
const row = rows[r]
if (row) inkRow(row, ink)
}
}

const scan = (): void => {
timer = undefined
if (dead) return
Expand Down Expand Up @@ -146,14 +200,17 @@ export function attachLinkPaint(term: Terminal, color: () => string): LinkPainte
finished?.dispose()
finished = undefined
soon()
// The alternate screen is inked as it is drawn: draw it all again.
if (term.buffer.active.type === 'alternate') term.refresh(0, term.rows - 1)
}

const subs: IDisposable[] = [
term.onWriteParsed(soon),
// A resize reflows every wrapped line: nothing painted is where it was.
term.onResize(startOver),
// Into the alternate screen and back out of it.
term.buffer.onBufferChange(startOver)
term.buffer.onBufferChange(startOver),
term.onRender(({ start, end }) => inkDrawn(start, end))
]

return {
Expand Down
6 changes: 4 additions & 2 deletions core/renderer/lib/termLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import { contrastRatio, ensureContrast, mixHex, normalizeColor } from './termAns
*/

/** The link blue. It is what a link is on the dark grounds most themes have,
* and the starting point on every other. */
export const LINK_BLUE = '#4ea1ff'
* and the starting point on every other. A calm blue, not a vivid one
* (owner, 2026-09-28: "make the link colours less blue ... too saturated"):
* #4ea1ff at half its saturation, the same hue and lightness. */
export const LINK_BLUE = '#79a7d8'

/** Readable as TEXT, not merely visible: a link is something you read. */
const FLOOR = 4.5
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "prism-terminal",
"productName": "Prism Terminal",
"version": "0.23.0",
"version": "0.23.1",
"description": "A tabbed Windows terminal for AI CLIs.",
"main": "./out/main/index.js",
"author": "Max",
Expand Down
21 changes: 19 additions & 2 deletions tools/e2e/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1205,11 +1205,12 @@ const scenarios = {
)
.filter((t) => t.length)
}, rgb)
const BLUE = '78,161,255' // LINK_BLUE, which reads as it is on the default theme
const BLUE = '121,167,216' // LINK_BLUE, which reads as it is on the default theme
const url = 'https://go.microsoft.com/fwlink/?LinkID=108518'
// Write-Host, so the OUTPUT row holds the sentence exactly as typed here.
await typeLine(page, `cls; Write-Host 'online at ${url}. Then more.'`)
ok(await until(async () => (await inked(BLUE)).includes(url), 8000), 'a printed link wears the link blue')
await page.screenshot({ path: resolve(process.cwd(), '.e2e-shots/links.png') }).catch(() => {})
const runs = await inked(BLUE)
ok(runs.every((t) => !t.endsWith('.')), `and the sentence's full stop is not part of it (${JSON.stringify(runs)})`)
// A link longer than the window is wide wraps; every row of it is the link.
Expand All @@ -1220,6 +1221,22 @@ const scenarios = {
'a link that wraps over rows is painted on every one of them'
)
ok((await inked(BLUE)).length >= 2, 'and it really did wrap')
// ON THE ALTERNATE SCREEN TOO (owner, 2026-09-28: a link in Claude Code's
// fullscreen view "is not blue ... it seems to know it's a link since i can
// click it"). Markers, so decorations, do not exist there; the rows are
// inked as xterm draws them. And a row a TUI rewrites loses the colour.
const alt = 'https://example.org/alt/path?x=1'
await typeLine(page, `Write-Host -NoNewline "$([char]27)[?1049h$([char]27)[H"; Write-Host 'open ${alt} here'`)
ok(await until(async () => (await inked(BLUE)).includes(alt), 8000), 'on the alternate screen a printed link wears the link blue')
await typeLine(page, `Write-Host -NoNewline "$([char]27)[H$([char]27)[2Kplain words where the link was"`)
ok(
await until(async () => !(await inked(BLUE)).some((t) => t.includes('plain') || t.includes('words')), 8000),
'and a row rewritten in place keeps no link colour'
)
// Out again. Typed as it is: the prompt now sits at the top of the
// alternate screen, not at the end of its rows, which typeLine waits for.
await page.keyboard.type(`Write-Host -NoNewline "$([char]27)[?1049l"`)
await page.keyboard.press('Enter')
// A light theme: the same blue would be unreadable, so it moves.
await typeLine(page, `cls; Write-Host 'see ${url}'`)
await until(async () => (await inked(BLUE)).includes(url), 8000)
Expand Down Expand Up @@ -1263,7 +1280,7 @@ const scenarios = {
// two where the row is repainted and the link's decoration is not back yet:
// the span then wears the plain text ink, which reads fine and is not a
// link colour at all (MEASURED: 62,62,62 accepted two runs in three).
return ink.join(',') === '78,161,255' || ratio < 4.5 || !(ink[2] > ink[0])
return ink.join(',') === '121,167,216' || ratio < 4.5 || !(ink[2] > ink[0])
? null
: { rgb: ink.join(','), ratio, blue: ink[2] > ink[0] }
}),
Expand Down
Loading