From 997472d3e41a9457901290f54d2c542d4413448e Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:22:56 +0200 Subject: [PATCH 1/2] fix(links): paint links on the alternate screen too, where Claude's fullscreen view draws (#97) xterm has no markers, so no decorations, on the alternate screen. Each row drawn there has its link text wrapped in a span of the link colour. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- CLAUDE.md | 6 ++- core/package.json | 2 +- core/renderer/lib/termLinkPaint.ts | 63 ++++++++++++++++++++++++++++-- package-lock.json | 4 +- package.json | 2 +- tools/e2e/run.mjs | 16 ++++++++ 6 files changed, 85 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6d412a8..0fe817d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -617,7 +617,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** diff --git a/core/package.json b/core/package.json index 5bb48d6..f5bb836 100644 --- a/core/package.json +++ b/core/package.json @@ -1,6 +1,6 @@ { "name": "prism-term-core", - "version": "0.16.0", + "version": "0.16.1", "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, diff --git a/core/renderer/lib/termLinkPaint.ts b/core/renderer/lib/termLinkPaint.ts index c5c7665..ad45dca 100644 --- a/core/renderer/lib/termLinkPaint.ts +++ b/core/renderer/lib/termLinkPaint.ts @@ -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. */ @@ -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 @@ -146,6 +200,8 @@ 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[] = [ @@ -153,7 +209,8 @@ export function attachLinkPaint(term: Terminal, color: () => string): LinkPainte // 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 { diff --git a/package-lock.json b/package-lock.json index a446d5e..8ef7c0b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "prism-terminal", - "version": "0.19.0", + "version": "0.19.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "prism-terminal", - "version": "0.19.0", + "version": "0.19.1", "license": "MIT", "dependencies": { "@xterm/addon-fit": "^0.11.0", diff --git a/package.json b/package.json index 84ff80c..d835644 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "prism-terminal", "productName": "Prism Terminal", - "version": "0.19.0", + "version": "0.19.1", "description": "A tabbed Windows terminal for AI CLIs.", "main": "./out/main/index.js", "author": "Max", diff --git a/tools/e2e/run.mjs b/tools/e2e/run.mjs index 2e7f86d..ef81531 100644 --- a/tools/e2e/run.mjs +++ b/tools/e2e/run.mjs @@ -1041,6 +1041,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) From c77ef75e1c00c5e41c26b8d19e7c6fd99e52f01a Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:24:35 +0200 Subject: [PATCH 2/2] style(links): a calmer link blue, #79a7d8 (#97) The owner found #4ea1ff too saturated. Same hue and lightness, half the saturation. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- core/renderer/lib/termLinks.ts | 6 ++++-- tools/e2e/run.mjs | 5 +++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/core/renderer/lib/termLinks.ts b/core/renderer/lib/termLinks.ts index 5ccda40..f395863 100644 --- a/core/renderer/lib/termLinks.ts +++ b/core/renderer/lib/termLinks.ts @@ -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 diff --git a/tools/e2e/run.mjs b/tools/e2e/run.mjs index ef81531..3303954 100644 --- a/tools/e2e/run.mjs +++ b/tools/e2e/run.mjs @@ -1026,11 +1026,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. @@ -1100,7 +1101,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] } }),