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/4] 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/4] 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] } }), From 23d90ba2e962d8224d86202914339121345ab783 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:00:49 +0200 Subject: [PATCH 3/4] feat(paths): a file or folder path on screen is a link when it exists (#99) Relative to the shell's folder or absolute. A click opens a file in its own app and a folder in Explorer; anything runnable is only shown in Explorer. Main resolves and checks every path itself. Menu: Open, Show in Explorer, Copy path. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- CLAUDE.md | 15 ++- core/main/ipc.ts | 21 ++++ core/main/termPathOpen.test.ts | 73 ++++++++++++ core/main/termPathOpen.ts | 109 ++++++++++++++++++ core/package.json | 2 +- core/preload/api.ts | 11 +- core/renderer/components/TerminalPanel.tsx | 88 +++++++++++++-- core/renderer/host.ts | 13 +++ core/renderer/lib/termLinkPaint.ts | 16 ++- core/renderer/lib/termPathLinks.test.ts | 14 +++ core/renderer/lib/termPathLinks.ts | 122 +++++++++++++++++++++ core/renderer/lib/termPaths.test.ts | 33 ++++++ core/renderer/lib/termPaths.ts | 78 +++++++++++++ core/shared/channels.ts | 4 +- package-lock.json | 4 +- package.json | 2 +- src/main/index.ts | 21 +++- src/renderer/src/App.tsx | 18 ++- tools/e2e/run.mjs | 114 +++++++++++++++++++ 19 files changed, 737 insertions(+), 21 deletions(-) create mode 100644 core/main/termPathOpen.test.ts create mode 100644 core/main/termPathOpen.ts create mode 100644 core/renderer/lib/termPathLinks.test.ts create mode 100644 core/renderer/lib/termPathLinks.ts create mode 100644 core/renderer/lib/termPaths.test.ts create mode 100644 core/renderer/lib/termPaths.ts diff --git a/CLAUDE.md b/CLAUDE.md index 0fe817d..82b6805 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -425,7 +425,8 @@ confirmation, Explorer verbs, the update chip and its window, command help (#12) Out, each a fresh owner decision and not a natural next step: split panes, several shells per tab, per-shell profiles, a tray icon, multiple windows, SSH management, app styles separate from the -terminal theme, anything that reads or shows files. +terminal theme, anything that reads or shows files. (OPENING a path in the app Windows gives it is in: +the owner's own call, #99.) ## Rules that must not regress (all measured in Prism, not assumed) @@ -624,6 +625,18 @@ terminal theme, anything that reads or shows files. 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 PATH ON SCREEN IS A LINK WHEN IT EXISTS** (#99; owner, 2026-09-29: "clickable links that would + open the file or folder", then "go ahead"). `lib/termPaths` finds what COULD be a path (relative or + absolute, sentence punctuation and `:12` taken off); main's `termPathOpen` answers which exist from + the shell's folder (`cwd` tracks the prompt's OSC 9;9), and ONLY those are painted and clickable + (`lib/termPathLinks`: batched, cached per folder, a "no" forgotten after 15 s). A left click opens + a file in its own app and a folder in Explorer; ANYTHING RUNNABLE (exe, scripts, shortcuts, + installers: `isRunnable`) is only SHOWN in Explorer, never run. Main never trusts the page with + a path: it gets the TEXT back and resolves and checks it again. No UNC paths (a share can stall a + stat). A prompt (`PS C:\x>`, `C:\x>`) is not a link. Menu: Open, Show in Explorer, Copy path. + Under `--e2e` main records (`__e2eOpenedPaths`) and opens nothing. A host without `paths` in its + main deps (Prism, until wired) paints no paths at all; `TermHostConfig.openPath` is where Prism + opens them inside Prism. The `pathLinks` e2e holds it. - **A file dropped on the terminal types its quoted path, and the terminal answers a right-click** (2026-09-19, #16). Both lived in Prism's `TermDock.tsx`, the split dock, and went with it when the dock was stripped, while the README, the spec and PR #3 went on listing the drop as shipped diff --git a/core/main/ipc.ts b/core/main/ipc.ts index 77c8645..3e10d5e 100644 --- a/core/main/ipc.ts +++ b/core/main/ipc.ts @@ -3,6 +3,7 @@ import { validResume } from './agentResume' import { pollAgentsNow, pollAgentsSoon, startAgentPoll } from './agentPoll' import { detectShells } from './shells' import { cdTerm, killTerm, prewarmShell, resizeTerm, spawnTerm, writeTerm } from './terminal' +import { openTermPath, pathKinds, PATHS_MAX, type PathOpeners } from './termPathOpen' /** * The main half of the terminal's bridge, for both hosts. @@ -57,6 +58,13 @@ export interface TermIpcDeps { /** May a running shell be moved to this folder (Prism's reroot)? A host with * no such feature answers false, and the channel does nothing. */ mayCd(path: string): boolean + /** + * How a path clicked in the terminal is opened (#99): the app Windows gives + * it, or Explorer with it selected. A host without them has no clickable + * paths at all: the kinds channel then answers null for everything, so + * nothing is painted that a click could not open. + */ + paths?: PathOpeners } /** Registers every terminal channel and starts the agent poll. Returns the @@ -153,5 +161,18 @@ export function registerTermIpc(deps: TermIpcDeps): () => void { if (typeof url === 'string' && /^https?:\/\//i.test(url)) deps.openExternal(url) }) + // PATHS (#99). The page asks which texts on screen name a file or a folder; + // a click hands the TEXT back and main resolves and checks it again. + ipcMain.handle(CH.pathKinds, async (_e: unknown, cwd: unknown, texts: unknown) => { + if (!deps.paths || typeof cwd !== 'string' || !Array.isArray(texts)) return [] + const list = texts.slice(0, PATHS_MAX).map((t) => (typeof t === 'string' ? t : '')) + return pathKinds(cwd, list) + }) + ipcMain.on(CH.openPath, (_e: unknown, cwd: unknown, text: unknown, mode: unknown) => { + const openers = deps.paths + if (!openers || typeof cwd !== 'string' || typeof text !== 'string') return + void openTermPath(cwd, text, mode === 'reveal' ? 'reveal' : 'open', openers) + }) + return startAgentPoll((id, has, kind) => send(CH.agent, id, has, kind)) } diff --git a/core/main/termPathOpen.test.ts b/core/main/termPathOpen.test.ts new file mode 100644 index 0000000..289a1d4 --- /dev/null +++ b/core/main/termPathOpen.test.ts @@ -0,0 +1,73 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { describe, expect, it, vi } from 'vitest' +import { isRunnable, openTermPath, pathKinds, resolveTermPath } from './termPathOpen' + +const tree = (): string => { + const root = mkdtempSync(join(tmpdir(), 'pt-paths-')) + mkdirSync(join(root, 'docs', 'sign-off'), { recursive: true }) + writeFileSync(join(root, 'docs', 'sign-off', 'rapport.pdf'), 'pdf') + writeFileSync(join(root, 'run.bat'), '@echo off') + return root +} + +describe('resolveTermPath', () => { + it('resolves against the shell folder, either separator', () => { + expect(resolveTermPath('C:\\work', 'docs/a.pdf')).toBe('C:\\work\\docs\\a.pdf') + expect(resolveTermPath('C:\\work', '..\\up.txt')).toBe('C:\\up.txt') + expect(resolveTermPath('C:\\work', 'D:/x/y.md')).toBe('D:\\x\\y.md') + expect(resolveTermPath('C:\\work', '\\rooted.txt')).toBe('C:\\rooted.txt') + expect(resolveTermPath('C:\\work', '~/n.txt', 'C:\\Users\\me')).toBe('C:\\Users\\me\\n.txt') + }) + it('refuses a network share, a NUL, and a relative path with no folder', () => { + expect(resolveTermPath('C:\\work', '\\\\server\\share\\a.txt')).toBeNull() + expect(resolveTermPath('C:\\work', '//server/share/a.txt')).toBeNull() + expect(resolveTermPath('C:\\work', 'a\0.txt')).toBeNull() + expect(resolveTermPath('', 'docs/a.pdf')).toBeNull() + expect(resolveTermPath('relative', 'docs/a.pdf')).toBeNull() + }) +}) + +describe('pathKinds', () => { + it('says what exists, a file or a folder, and null for the rest', async () => { + const root = tree() + const got = await pathKinds(root, ['docs/sign-off/rapport.pdf', 'docs/sign-off/', 'missing/x.txt', 'and/or']) + expect(got.map((g) => g?.kind ?? null)).toEqual(['file', 'dir', null, null]) + expect(got[0]?.abs).toBe(join(root, 'docs', 'sign-off', 'rapport.pdf')) + }) +}) + +describe('openTermPath', () => { + const openers = () => ({ openPath: vi.fn(), revealPath: vi.fn() }) + it('opens a file and a folder', async () => { + const root = tree() + const o = openers() + expect(await openTermPath(root, 'docs/sign-off/rapport.pdf', 'open', o)).toBe('opened') + expect(await openTermPath(root, 'docs', 'open', o)).toBe('opened') + expect(o.openPath).toHaveBeenCalledTimes(2) + expect(o.revealPath).not.toHaveBeenCalled() + }) + it('NEVER runs anything: a runnable file is shown in Explorer instead', async () => { + const root = tree() + const o = openers() + expect(await openTermPath(root, 'run.bat', 'open', o)).toBe('revealed') + expect(o.openPath).not.toHaveBeenCalled() + expect(o.revealPath).toHaveBeenCalledWith(join(root, 'run.bat')) + }) + it('reveals when asked, and does nothing for what does not exist', async () => { + const root = tree() + const o = openers() + expect(await openTermPath(root, 'docs/sign-off/rapport.pdf', 'reveal', o)).toBe('revealed') + expect(await openTermPath(root, 'missing.txt', 'open', o)).toBeNull() + expect(o.openPath).not.toHaveBeenCalled() + }) +}) + +describe('isRunnable', () => { + it('knows programs, scripts, shortcuts and installers, and not documents', () => { + for (const f of ['a.exe', 'b.BAT', 'c.ps1', 'd.lnk', 'e.msi', 'f.js', 'g.vbs', 'h.py', 'i.url', 'j.reg']) + expect(isRunnable(f)).toBe(true) + for (const f of ['a.pdf', 'b.md', 'c.png', 'd.txt', 'e.docx', 'f.ts', 'g.json', 'README']) expect(isRunnable(f)).toBe(false) + }) +}) diff --git a/core/main/termPathOpen.ts b/core/main/termPathOpen.ts new file mode 100644 index 0000000..43e9f76 --- /dev/null +++ b/core/main/termPathOpen.ts @@ -0,0 +1,109 @@ +import { stat } from 'fs/promises' +import { homedir } from 'os' +import { extname, isAbsolute, resolve, win32 } from 'path' + +/** + * A PATH CLICKED IN THE TERMINAL (#99; owner, 2026-09-29: "clickable links that + * would open the file or folder"). Main's half: the page says what text it saw + * and which folder the shell is in; main alone decides what that names, whether + * it exists, and what opening it means. The page is never trusted with a path + * to open: it hands over the TEXT again, and main resolves and checks it anew. + * + * What a click does. A folder opens in Explorer. A file opens in the app + * Windows gives it, EXCEPT anything Windows would RUN (an .exe, a script, a + * shortcut, an installer): that is shown selected in Explorer instead. Clicking + * text in a terminal must never start a program. + */ + +export type PathKind = 'file' | 'dir' + +export interface PathHit { + kind: PathKind + /** Resolved and normalised: what is opened. */ + abs: string +} + +/** Opened by Windows as a PROGRAM, or as something that runs one. */ +const RUNNABLE = new Set( + ( + 'exe com scr pif cpl msc msi msp msix msixbundle appx appxbundle appref-ms application gadget ' + + 'bat cmd ps1 psm1 psd1 ps1xml vbs vbe js jse wsf wsh ws wsc sct hta jar ' + + 'py pyw pyc pyz rb pl sh bash ahk au3 lnk url website reg inf ins isp scf shb shs ' + + 'library-ms settingcontent-ms search-ms diagcab xbap vsto' + ).split(/\s+/).filter(Boolean) +) + +export function isRunnable(file: string): boolean { + return RUNNABLE.has(extname(file).slice(1).toLowerCase()) +} + +/** The most a page may ask about in one go, and the longest text. */ +export const PATHS_MAX = 300 +const TEXT_MAX = 1024 + +/** + * What `text` names from inside `cwd`, or null when it names nothing this + * checks: a network path (a UNC share can stall a stat for many seconds, and + * the terminal asks about every path on screen), a relative path with no + * folder to be relative to, anything with a NUL in it. + */ +export function resolveTermPath(cwd: string, text: string, home = homedir()): string | null { + if (typeof text !== 'string' || !text || text.length > TEXT_MAX || text.includes('\0')) return null + let p = text + if (p === '~' || p.startsWith('~/') || p.startsWith('~\\')) p = home + p.slice(1) + if (/^[\\/]{2}/.test(p)) return null + if (isAbsolute(p) || win32.isAbsolute(p)) { + // "\foo" is absolute on Windows but names no drive: the shell's drive. + if (!/^[A-Za-z]:/.test(p)) { + if (!/^[A-Za-z]:[\\/]/.test(cwd)) return null + p = cwd.slice(0, 2) + p + } + return win32.normalize(p) + } + if (typeof cwd !== 'string' || !/^[A-Za-z]:[\\/]/.test(cwd) || cwd.includes('\0')) return null + return win32.normalize(resolve(cwd, p)) +} + +/** What each text names, in order: a file, a folder, or null. */ +export async function pathKinds(cwd: string, texts: string[]): Promise> { + const list = texts.slice(0, PATHS_MAX) + return Promise.all( + list.map(async (t) => { + const abs = resolveTermPath(cwd, t) + if (!abs) return null + try { + const s = await stat(abs) + if (s.isDirectory()) return { kind: 'dir' as const, abs } + if (s.isFile()) return { kind: 'file' as const, abs } + return null + } catch { + return null + } + }) + ) +} + +export interface PathOpeners { + /** The app Windows gives it (a folder: Explorer). */ + openPath(abs: string): void + /** Explorer, with it selected. */ + revealPath(abs: string): void +} + +/** Open or reveal what `text` names, after resolving and checking it here. + * Answers what was done, for a test to read. */ +export async function openTermPath( + cwd: string, + text: string, + mode: 'open' | 'reveal', + openers: PathOpeners +): Promise<'opened' | 'revealed' | null> { + const [hit] = await pathKinds(cwd, [text]) + if (!hit) return null + if (mode === 'reveal' || (hit.kind === 'file' && isRunnable(hit.abs))) { + openers.revealPath(hit.abs) + return 'revealed' + } + openers.openPath(hit.abs) + return 'opened' +} diff --git a/core/package.json b/core/package.json index f5bb836..00c28c6 100644 --- a/core/package.json +++ b/core/package.json @@ -1,6 +1,6 @@ { "name": "prism-term-core", - "version": "0.16.1", + "version": "0.17.0", "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/preload/api.ts b/core/preload/api.ts index fc45338..c4c6d50 100644 --- a/core/preload/api.ts +++ b/core/preload/api.ts @@ -66,6 +66,12 @@ export interface TermPreloadApi { writeClipboard(text: string): Promise /** The web-links addon's click-through: external URLs go to the OS browser. */ openExternal(url: string): void + /** Which of these texts name a file or a folder from `cwd` (#99). Main + * resolves and checks; null for anything else. */ + termPathKinds(cwd: string, texts: string[]): Promise> + /** Open, or show in Explorer, what `text` names from `cwd`. Main resolves + * and checks it again, and never runs a program. */ + termOpenPath(cwd: string, text: string, mode: 'open' | 'reveal'): void } export function createTermApi(ipc: IpcRendererLike): TermPreloadApi { @@ -93,6 +99,9 @@ export function createTermApi(ipc: IpcRendererLike): TermPreloadApi { writeClipboard: (text) => ipc.invoke(CH.clipboardWrite, text) as Promise, openExternal: (url) => { if (/^https?:/i.test(url)) ipc.send(CH.openExternal, url) - } + }, + termPathKinds: (cwd, texts) => + ipc.invoke(CH.pathKinds, cwd, texts) as Promise>, + termOpenPath: (cwd, text, mode) => ipc.send(CH.openPath, cwd, text, mode) } } diff --git a/core/renderer/components/TerminalPanel.tsx b/core/renderer/components/TerminalPanel.tsx index a898bf6..8ab2d7d 100644 --- a/core/renderer/components/TerminalPanel.tsx +++ b/core/renderer/components/TerminalPanel.tsx @@ -22,6 +22,7 @@ import { type SelectionGate } from '../lib/termSelectionEdit' import { attachLinkPaint, type LinkPainter } from '../lib/termLinkPaint' +import { knownPath, linkRanges, onPathsFound, pathCandidates, type PathHit } from '../lib/termPathLinks' import { cellFrom, cellText, type CellInfo } from '../lib/termCells' import { onTermLookChange, @@ -57,6 +58,9 @@ interface Session { unsub: Array<() => void> /** Ctrl+scroll zoom, this session only: never persisted, dies with it. */ fontOverride?: number + /** The folder the shell is in: where it started, then what its prompt + * last reported. A relative path on screen is relative to it (#99). */ + cwd(): string } const sessions = new Map() @@ -341,21 +345,54 @@ function deleteSelection(term: Terminal, id: string): boolean { * says copy"): the selection's text, and the link under the point (whole, even * when it wraps). Read-only; a host that has no session by that id gets nothing. */ -export function termContextAt(id: string, clientX: number, clientY: number): { selection: string; link: string | null } { +export interface TermPathAt extends PathHit { + /** As written on screen, which is what main resolves again on a click. */ + text: string +} + +/** The path, known to exist, that covers character `at` of `text`. */ +function pathAt(text: string, at: number, cwd: string): TermPathAt | null { + for (const c of pathCandidates(text)) { + if (at < c.start || at >= c.end) continue + const hit = knownPath(cwd, c.path) + return hit ? { ...hit, text: c.path } : null + } + return null +} + +/** + * OPEN A PATH FROM THE TERMINAL (#99): the host's own way first (Prism opens + * files inside Prism), else main's: the file's own app, a folder in Explorer, + * anything runnable only SHOWN in Explorer. + */ +export function openTermPath(id: string, target: TermPathAt, mode: 'open' | 'reveal'): void { const s = sessions.get(id) - if (!s) return { selection: '', link: null } + if (!s) return + if (termHost().openPath?.({ abs: target.abs, kind: target.kind, mode })) return + termApi().termOpenPath?.(s.cwd(), target.text, mode) +} + +export function termContextAt( + id: string, + clientX: number, + clientY: number +): { selection: string; link: string | null; path: TermPathAt | null } { + const s = sessions.get(id) + if (!s) return { selection: '', link: null, path: null } const term = s.term const selection = term.hasSelection() ? term.getSelection() : '' const screen = term.element?.querySelector('.xterm-screen') - if (!screen) return { selection, link: null } + if (!screen) return { selection, link: null, path: null } const r = screen.getBoundingClientRect() const col = Math.floor((clientX - r.left) / (r.width / term.cols)) const row = Math.floor((clientY - r.top) / (r.height / term.rows)) - if (col < 0 || col >= term.cols || row < 0 || row >= term.rows) return { selection, link: null } + if (col < 0 || col >= term.cols || row < 0 || row >= term.rows) return { selection, link: null, path: null } const y = term.buffer.active.viewportY + row const line = logicalLine(term, y) const target = (y - line.first) * term.cols + col - return { selection, link: linkAt(line.text, line.index[Math.min(target, line.index.length - 1)]) } + const at = line.index[Math.min(target, line.index.length - 1)] + const link = linkAt(line.text, at) + return { selection, link, path: link ? null : pathAt(line.text, at, s.cwd()) } } /** Refit a session and tell the pty its new geometry. */ @@ -586,7 +623,41 @@ function createSession(id: string, root: string, shellId: string | undefined): S term.loadAddon(new WebLinksAddon((_e, url) => termApi().openExternal(url))) // The addon makes a link clickable and underlines it under the pointer; this // is what makes it LOOK like a link the rest of the time. - const links = attachLinkPaint(term, currentLinkColor) + // The folder this shell is in (#99): where it started, then the prompt's + // own report below. Paths on screen are read against it. + let cwdNow = root + const links = attachLinkPaint(term, currentLinkColor, (text) => linkRanges(text, cwdNow)) + // A PATH IS A LINK TOO, when it exists (#99). The painter colours it; this + // makes it clickable, with the pointer and the underline of a link. A left + // click opens it; the right one is the menu's (Open, Show in Explorer, Copy + // path). + term.registerLinkProvider({ + provideLinks: (y, done) => { + if (!termApi().termPathKinds) return done(undefined) + const line = logicalLine(term, y - 1) + const out = [] + for (const c of pathCandidates(line.text)) { + const hit = knownPath(cwdNow, c.path) + if (!hit) continue + const from = line.index.indexOf(c.start) + const to = line.index.indexOf(c.end) - 1 + if (from < 0 || to < from) continue + const target: TermPathAt = { ...hit, text: c.path } + out.push({ + range: { + start: { x: (from % term.cols) + 1, y: line.first + Math.floor(from / term.cols) + 1 }, + end: { x: (to % term.cols) + 1, y: line.first + Math.floor(to / term.cols) + 1 } + }, + text: c.path, + decorations: { pointerCursor: true, underline: true }, + activate: (e: MouseEvent) => { + if (e.button === 0) openTermPath(id, target, 'open') + } + }) + } + done(out.length ? out : undefined) + } + }) const el = document.createElement('div') el.className = 'h-full w-full' @@ -614,6 +685,7 @@ function createSession(id: string, root: string, shellId: string | undefined): S term.parser.registerOscHandler(9, (data) => { const p = parseOsc9(data) if (p) { + cwdNow = p markPrompt(id) reportCwd(id, p) } @@ -666,6 +738,8 @@ function createSession(id: string, root: string, shellId: string | undefined): S let agentHere = Boolean(resume) const unsub = [ attachClickCaret(term, el, id), + // A path asked about turned out to exist: paint it now. + onPathsFound(() => links.repaint()), termApi().onTermAgent((forId, present) => { if (forId === id) agentHere = present }), @@ -764,7 +838,7 @@ function createSession(id: string, root: string, shellId: string | undefined): S return true }) - const session: Session = { term, fit, search, links, el, unsub } + const session: Session = { term, fit, search, links, el, unsub, cwd: () => cwdNow } sessions.set(id, session) // A session restored over a Claude conversation launches straight into it: diff --git a/core/renderer/host.ts b/core/renderer/host.ts index 7822c85..3f93ba1 100644 --- a/core/renderer/host.ts +++ b/core/renderer/host.ts @@ -49,6 +49,10 @@ export interface TermApi { * host that has none still builds, and `copyText` then reports a failure. */ writeClipboard?(text: string): Promise openExternal(url: string): void + /** Paths (#99). Optional so a host bridge without them still builds; the + * terminal then paints and opens no paths. */ + termPathKinds?(cwd: string, texts: string[]): Promise> + termOpenPath?(cwd: string, text: string, mode: 'open' | 'reveal'): void } export type AgentIndicator = 'off' | 'minimal' | 'full' @@ -146,6 +150,15 @@ export interface TermHostConfig { api: DictationApi canDictate(): boolean } + + /** + * A PATH CLICKED IN THE TERMINAL (#99), for a host that opens files itself. + * Prism has a viewer and an explorer of its own, so a click there should + * land in Prism (owner, 2026-09-29). Answer true when the host took it; + * false, or absent, and main opens it the Windows way (the app Windows + * gives a file, Explorer for a folder, never a program run). + */ + openPath?(target: { abs: string; kind: 'file' | 'dir'; mode: 'open' | 'reveal' }): boolean } let host: TermHostConfig | null = null diff --git a/core/renderer/lib/termLinkPaint.ts b/core/renderer/lib/termLinkPaint.ts index ad45dca..bd6888b 100644 --- a/core/renderer/lib/termLinkPaint.ts +++ b/core/renderer/lib/termLinkPaint.ts @@ -46,7 +46,13 @@ interface Cell { const DEBOUNCE_MS = 80 -export function attachLinkPaint(term: Terminal, color: () => string): LinkPainter { +export function attachLinkPaint( + term: Terminal, + color: () => string, + /** What wears the link colour in a line of text: the web links, and the + * host panel adds the paths that exist (#99). */ + find: (text: string) => Array<{ start: number; end: number }> = findLinks +): LinkPainter { let painted: Painted[] = [] /** Everything above this line is scrollback that has been painted. */ let finished: IMarker | undefined @@ -93,7 +99,7 @@ export function attachLinkPaint(term: Terminal, color: () => string): LinkPainte rows.push(line) quick += line.translateToString(r === last) } - if (!quick.includes('://')) return + if (!/[\\/.]/.test(quick)) return // no URL and no path can be here // Built cell by cell, because a wide character is one character and TWO // cells: an index into the string is not a column once a line holds one. let text = '' @@ -109,7 +115,7 @@ export function attachLinkPaint(term: Terminal, color: () => string): LinkPainte for (let k = 0; k < chars.length; k += 1) cells.push({ row: first + i, x, w }) } }) - for (const link of findLinks(text)) { + for (const link of find(text)) { let from = link.start while (from < link.end) { const row = cells[from].row @@ -124,8 +130,8 @@ 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 (!/[\\/.]/.test(text)) return + const links = find(text) if (!links.length) return // Where each text node starts in the row's text. const nodes: Array<{ node: Text; at: number }> = [] diff --git a/core/renderer/lib/termPathLinks.test.ts b/core/renderer/lib/termPathLinks.test.ts new file mode 100644 index 0000000..c3caa7d --- /dev/null +++ b/core/renderer/lib/termPathLinks.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { pathCandidates } from './termPathLinks' + +const paths = (text: string): string[] => pathCandidates(text).map((p) => p.path) + +describe('pathCandidates', () => { + it('leaves the prompt alone, PowerShell and cmd', () => { + expect(paths('PS C:\Users\me\work> ls docs/a.pdf')).toEqual(['docs/a.pdf']) + expect(paths('C:\Users\me\work>dir')).toEqual([]) + }) + it('leaves a path inside a web link to the link', () => { + expect(paths('see https://github.com/a/b/blob/main/README.md and README.md')).toEqual(['README.md']) + }) +}) diff --git a/core/renderer/lib/termPathLinks.ts b/core/renderer/lib/termPathLinks.ts new file mode 100644 index 0000000..b9685c7 --- /dev/null +++ b/core/renderer/lib/termPathLinks.ts @@ -0,0 +1,122 @@ +import { termApi } from '../host' +import { findLinks } from './termLinks' +import { findPaths, type PathCandidate } from './termPaths' + +/** + * WHICH PATHS ON SCREEN EXIST (#99). The painter and the click both ask + * `known`; a path nobody has asked about yet is queued and asked about in one + * batch, and when any turns out to exist, `onPathsFound` listeners paint + * again. So a path lights up a moment after it is printed, and only if it is + * real: nothing is painted that a click could not open. + * + * Per folder, since `docs/a.pdf` is a different file in every shell. A "no" is + * forgotten after a while, so a file written after its name was printed + * lights up the next time the screen is looked at; a "yes" stays. + */ +export interface PathHit { + kind: 'file' | 'dir' + abs: string +} + +const NO_TTL_MS = 15_000 +const MAX_ENTRIES = 5000 +const BATCH_MS = 40 + +const cache = new Map() +const queued = new Map>() +const inFlight = new Set() +let timer: ReturnType | undefined +const listeners = new Set<() => void>() + +const key = (cwd: string, path: string): string => `${cwd}\n${path}` + +/** A hit, null when it is known to name nothing, undefined when unknown. */ +export function knownPath(cwd: string, path: string): PathHit | null | undefined { + const e = cache.get(key(cwd, path)) + if (!e) return undefined + if (!e.hit && Date.now() - e.at > NO_TTL_MS) return undefined + return e.hit +} + +export function onPathsFound(fn: () => void): () => void { + listeners.add(fn) + return () => { + listeners.delete(fn) + } +} + +function flush(): void { + timer = undefined + const ask = termApi().termPathKinds + const batches = [...queued] + queued.clear() + if (!ask) return + for (const [cwd, set] of batches) { + const list = [...set] + list.forEach((p) => inFlight.add(key(cwd, p))) + void ask(cwd, list) + .then((hits) => { + if (cache.size > MAX_ENTRIES) cache.clear() + let found = false + const at = Date.now() + list.forEach((p, i) => { + const hit = hits?.[i] ?? null + cache.set(key(cwd, p), { hit, at }) + if (hit) found = true + }) + if (found) listeners.forEach((fn) => fn()) + }) + .catch(() => {}) + .finally(() => list.forEach((p) => inFlight.delete(key(cwd, p)))) + } +} + +function request(cwd: string, paths: string[]): void { + let set = queued.get(cwd) + for (const p of paths) { + if (inFlight.has(key(cwd, p))) continue + if (!set) queued.set(cwd, (set = new Set())) + set.add(p) + } + if (set && set.size && timer === undefined) timer = setTimeout(flush, BATCH_MS) +} + +/** + * The path candidates in `text` that are not inside a web link, and not a + * PROMPT: PowerShell's `PS C:\work>` and cmd's `C:\work>` name the folder the + * shell is in, it always exists, and a link on every prompt line is noise + * (MEASURED in the first screenshot of this). A path that runs straight into + * `>` is left alone. + */ +export function pathCandidates(text: string, links = findLinks(text)): PathCandidate[] { + return findPaths(text).filter( + (c) => text[c.end] !== '>' && !links.some((l) => c.start < l.end && l.start < c.end) + ) +} + +/** + * Everything in `text` that wears the link colour: the web links, and the + * paths known to exist from `cwd`. Unknown paths are asked about on the way. + */ +export function linkRanges(text: string, cwd: string | null): Array<{ start: number; end: number }> { + const links = findLinks(text) + if (!cwd || !termApi().termPathKinds || !/[\\/.]/.test(text)) return links + const out: Array<{ start: number; end: number }> = [...links] + const ask: string[] = [] + for (const c of pathCandidates(text, links)) { + const k = knownPath(cwd, c.path) + if (k) out.push(c) + else if (k === undefined) ask.push(c.path) + } + if (ask.length) request(cwd, ask) + return out.sort((a, b) => a.start - b.start) +} + +/** For tests: forget everything. */ +export function resetPathCache(): void { + cache.clear() + queued.clear() + inFlight.clear() + if (timer !== undefined) clearTimeout(timer) + timer = undefined +} diff --git a/core/renderer/lib/termPaths.test.ts b/core/renderer/lib/termPaths.test.ts new file mode 100644 index 0000000..18f68b8 --- /dev/null +++ b/core/renderer/lib/termPaths.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { findPaths } from './termPaths' + +const paths = (text: string): string[] => findPaths(text).map((p) => p.path) + +describe('findPaths', () => { + it('finds the paths in the owner\'s screenshot, without the sentence round them', () => { + const line = '- Rapport: docs/sign-off/rapport.pdf, 9 sider. PNG i docs/wireframes/png/. Kilde i docs/wireframes/kilde/, som' + expect(paths(line)).toEqual(['docs/sign-off/rapport.pdf', 'docs/wireframes/png/', 'docs/wireframes/kilde/']) + }) + it('says where each one sits in the text', () => { + const text = 'see (src/app.ts:12:3).' + const [p] = findPaths(text) + expect(text.slice(p.start, p.end)).toBe('src/app.ts') + }) + it('knows Windows paths, absolute and relative, and a bare file name', () => { + expect(paths('C:\\Users\\me\\notes.txt and docs\\sign-off\\rapport.pdf')).toEqual([ + 'C:\\Users\\me\\notes.txt', + 'docs\\sign-off\\rapport.pdf' + ]) + expect(paths('AGENTS.md points there')).toEqual(['AGENTS.md']) + expect(paths('../up/one.txt ./here.md ~/home.cfg')).toEqual(['../up/one.txt', './here.md', '~/home.cfg']) + }) + it('leaves a URL to the link finder', () => { + expect(paths('https://github.com/a/b/pull/11 and file:///C:/x')).toEqual([]) + }) + it('asks nothing about words, numbers, versions or flags', () => { + expect(paths('hello world 1.5 0.16.1 --force -rf 12:30 C: / ...')).toEqual([]) + }) + it('reads a path in quotes as the path', () => { + expect(paths('open "docs/a b.pdf" or \'x/y.md\'')).toEqual(['docs/a', 'b.pdf', 'x/y.md']) + }) +}) diff --git a/core/renderer/lib/termPaths.ts b/core/renderer/lib/termPaths.ts new file mode 100644 index 0000000..84a37d9 --- /dev/null +++ b/core/renderer/lib/termPaths.ts @@ -0,0 +1,78 @@ +/** + * TEXT THAT MAY BE A PATH (#99; owner, 2026-09-29: "would it be possible to + * show these as clickable links that would open the file or folder"). Pure: + * this only says where a path COULD be in a line of text. Whether one is there + * is main's to answer (`termPathKinds`), and only a path that exists is ever + * painted or clickable, so this errs on the side of asking: "and/or" is asked + * about once, answered no, and never lights up. + * + * A candidate is a run of text with no whitespace or quote in it, with the + * punctuation of the sentence around it taken off: the brackets it sits in, + * the full stop or comma after it, and a `:12` or `:12:3` line number. It must + * have a letter in it and either a separator (`docs/a.pdf`, `src\`, `C:\x`) or + * a file extension (`README.md`). A URL is not a path; `lib/termLinks` has it. + */ +export interface PathCandidate { + /** Where it sits in the text, end exclusive. */ + start: number + end: number + /** As written: relative to the shell's folder, or absolute. */ + path: string +} + +/** Broken on these: whitespace, quotes and the characters a Windows path + * can never hold. */ +const BREAK = /[\s"'`<>|*?]/ + +const LEAD = /^[([{]+/ +const TRAIL = /[.,;:!)\]}]+$/ +const LINE_NO = /:\d+(?::\d+)?$/ +const EXTENSION = /[A-Za-z0-9_-]\.[A-Za-z0-9]{1,8}$/ + +/** The longest text asked about: MAX_PATH, and a line is not a path past it. */ +const MAX = 260 + +export function findPaths(text: string): PathCandidate[] { + const out: PathCandidate[] = [] + let i = 0 + while (i < text.length) { + if (BREAK.test(text[i])) { + i += 1 + continue + } + let j = i + while (j < text.length && !BREAK.test(text[j])) j += 1 + const word = text.slice(i, j) + const hit = candidate(word) + if (hit) out.push({ start: i + hit.from, end: i + hit.from + hit.path.length, path: hit.path }) + i = j + } + return out +} + +function candidate(word: string): { from: number; path: string } | null { + if (word.includes('://')) return null + let from = 0 + let w = word + const lead = LEAD.exec(w) + if (lead) { + from = lead[0].length + w = w.slice(from) + } + // Sentence punctuation and a line number, in either order ("a.ts:12)." ). + for (let k = 0; k < 3; k += 1) { + const before = w + w = w.replace(TRAIL, '') + w = w.replace(LINE_NO, '') + if (w === before) break + } + // A folder is often written with its closing separator: keep it. + if (w.length < 2 || w.length > MAX) return null + if (!/[A-Za-z]/.test(w)) return null + if (w.startsWith('-')) return null // a command's flag, not a file + const separated = /[\\/]/.test(w) + if (!separated && !EXTENSION.test(w)) return null + // A bare drive ("C:") or a separator alone says nothing. + if (/^[A-Za-z]:[\\/]?$/.test(w) || /^[\\/.]+$/.test(w)) return null + return { from, path: w } +} diff --git a/core/shared/channels.ts b/core/shared/channels.ts index 1733c15..701dba4 100644 --- a/core/shared/channels.ts +++ b/core/shared/channels.ts @@ -17,7 +17,9 @@ export const CH = { exit: 'term:exit', clipboardRead: 'clipboard:read', clipboardWrite: 'clipboard:write', - openExternal: 'shell:open-external' + openExternal: 'shell:open-external', + pathKinds: 'term:path-kinds', + openPath: 'term:open-path' } as const /** Dictation's channels (#13). A table of its own: dictation is optional, and diff --git a/package-lock.json b/package-lock.json index 8ef7c0b..b67a17e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "prism-terminal", - "version": "0.19.1", + "version": "0.20.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "prism-terminal", - "version": "0.19.1", + "version": "0.20.0", "license": "MIT", "dependencies": { "@xterm/addon-fit": "^0.11.0", diff --git a/package.json b/package.json index d835644..2025d56 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "prism-terminal", "productName": "Prism Terminal", - "version": "0.19.1", + "version": "0.20.0", "description": "A tabbed Windows terminal for AI CLIs.", "main": "./out/main/index.js", "author": "Max", diff --git a/src/main/index.ts b/src/main/index.ts index eec077e..5e0efad 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -71,6 +71,24 @@ function openLink(url: string): void { void shell.openExternal(url) } +/** + * A PATH CLICKED IN THE TERMINAL (#99): the core has resolved it and checked it + * exists, and decided open or show. Under --e2e it is RECORDED, like a link: + * a test run never opens a file or an Explorer window on the owner's desktop. + */ +const e2eOpenedPaths: Array<{ how: 'open' | 'reveal'; abs: string }> = [] +if (E2E) Object.assign(globalThis, { __e2eOpenedPaths: e2eOpenedPaths }) +const pathOpeners = { + openPath: (abs: string): void => { + if (E2E) e2eOpenedPaths.push({ how: 'open', abs }) + else void shell.openPath(abs) + }, + revealPath: (abs: string): void => { + if (E2E) e2eOpenedPaths.push({ how: 'reveal', abs }) + else shell.showItemInFolder(abs) + } +} + // userData is `%APPDATA%\PrismTerminal`, whatever the product name's spacing // would have made it. The e2e (and anyone else) passing Chromium's own // --user-data-dir keeps the profile they asked for: Electron has already @@ -424,7 +442,8 @@ function wireIpc(): void { // Not while the strip is still being rebuilt from tabs.json. mayPrewarm: async (cwd) => !awaitingRestore && (await isDir(cwd)), // Prism's reroot. This app never moves a shell it did not start there. - mayCd: () => false + mayCd: () => false, + paths: pathOpeners }) // DICTATION (#13) is the core's too. What is this app's own: where ITS // installer put the CPU engine, the folder it shares with Prism, and the GPU diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 6c8ff33..100d745 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -5,7 +5,9 @@ import TerminalPanel, { ensureTermSession, focusTermSession, focusedTermFullScreen, - termContextAt + openTermPath, + termContextAt, + type TermPathAt } from '@core/renderer/components/TerminalPanel' import TermFind from '@core/renderer/components/TermFind' import { TabStrip } from './components/TabStrip' @@ -125,6 +127,8 @@ export default function App(): JSX.Element { /** What was selected when the menu opened, and the link under the point. */ selection: string link: string | null + /** A file or folder under the point that exists (#99). */ + path: TermPathAt | null } | null>(null) /** The command help popup (#12). The core's, the same in Prism; what is this * app's is the way in (F1, the ? in the title bar, the terminal's menu). */ @@ -612,6 +616,18 @@ export default function App(): JSX.Element { // the command help, which also raises the "Copied" badge once the // clipboard has it. Close tab left this menu the same day ("remove close // tab from the right click menu"); the tab's own menu still has it. + // A PATH (#99; owner, 2026-09-29): open it as a click would, show + // it in Explorer, or copy it whole and absolute. + ...(termMenu.path + ? [ + { + label: termMenu.path.kind === 'dir' ? 'Open folder' : 'Open', + onPick: () => openTermPath(termMenu.id, termMenu.path!, 'open') + }, + { label: 'Show in Explorer', onPick: () => openTermPath(termMenu.id, termMenu.path!, 'reveal') }, + { label: 'Copy path', onPick: () => void copyText(termMenu.path!.abs) } + ] + : []), ...(termMenu.link ? [{ label: 'Copy link', onPick: () => void copyText(termMenu.link!) }] : []), diff --git a/tools/e2e/run.mjs b/tools/e2e/run.mjs index 3303954..a2203d9 100644 --- a/tools/e2e/run.mjs +++ b/tools/e2e/run.mjs @@ -1009,6 +1009,120 @@ const scenarios = { }, /** A printed link is painted as one, in a colour that follows the theme. */ + // PATHS ARE LINKS WHEN THEY EXIST (#99; owner, 2026-09-29: "would it be + // possible to show these as clickable links that would open the file or + // folder"). Real files in the shell's folder, a sentence naming them the + // way Claude writes, and one that names nothing. Under --e2e main RECORDS + // what it would open (`__e2eOpenedPaths`); nothing opens on the desktop. + async pathLinks(ok) { + const w = world() + mkdirSync(join(w.alpha, 'docs', 'sign-off'), { recursive: true }) + mkdirSync(join(w.alpha, 'docs', 'wireframes', 'png'), { recursive: true }) + writeFileSync(join(w.alpha, 'docs', 'sign-off', 'rapport.pdf'), '%PDF-1.4') + writeFileSync(join(w.alpha, 'run.bat'), '@echo off') + const pdf = join(w.alpha, 'docs', 'sign-off', 'rapport.pdf') + const bat = join(w.alpha, 'run.bat') + const { app, page } = await launch(w, { args: [w.alpha] }) + const clip = () => app.evaluate(({ clipboard }) => clipboard.readText()) + const held = await clip() + const opened = () => app.evaluate(() => globalThis.__e2eOpenedPaths ?? []) + const INK = '121,167,216' // LINK_BLUE on the default theme + const inked = () => + page.evaluate((want) => { + const norm = (c) => (c.match(/\d+/g) ?? []).slice(0, 3).join(',') + return [...document.querySelectorAll('.xterm .xterm-rows > div')] + .map((row) => + [...row.querySelectorAll('span')] + .filter((sp) => norm(getComputedStyle(sp).color) === want) + .map((sp) => sp.textContent ?? '') + .join('') + ) + .join('\n') + }, INK) + const box = (needle, offset) => + page.evaluate( + ([n, off]) => { + const rows = [...document.querySelectorAll('.xterm-rows > div')] + for (let i = rows.length - 1; i >= 0; i -= 1) { + const at = rows[i].textContent.lastIndexOf(n) + if (at < 0) continue + const walker = document.createTreeWalker(rows[i], NodeFilter.SHOW_TEXT) + let left = at + off + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + if (left < node.textContent.length) { + const range = document.createRange() + range.setStart(node, left) + range.setEnd(node, left + 1) + const b = range.getBoundingClientRect() + return { x: b.left + b.width / 2, y: b.top + b.height / 2 } + } + left -= node.textContent.length + } + } + return null + }, + [needle, offset] + ) + // A click as a hand makes it: onto the text, a rest, then the press. + const clickOn = async (needle, button = 'left') => { + const at = await box(needle, 2) + await page.mouse.move(at.x + 30, at.y + 30) + await sleep(100) + await page.mouse.move(at.x, at.y) + await sleep(300) + await page.mouse.click(at.x, at.y, { button }) + } + const menu = () => + until(async () => { + const t = await page.locator('[role="menu"] [role="menuitem"]').allTextContents() + return t.length ? t : null + }, 4000) + try { + await typeLine(page, "cls; Write-Host 'Rapport: docs/sign-off/rapport.pdf, PNG i docs/wireframes/png/. Mangler: missing/file.txt og run.bat.'") + ok( + !!(await until(async () => { + const t = await inked() + return t.includes('docs/sign-off/rapport.pdf') && t.includes('docs/wireframes/png/') && t.includes('run.bat') ? t : null + }, 8000)), + 'a file, a folder and a script that exist wear the link colour' + ) + ok(!(await inked()).includes('missing/file.txt'), 'a path that names nothing does not') + await page.screenshot({ path: resolve(process.cwd(), '.e2e-shots/path-links.png') }).catch(() => {}) + await clickOn('docs/sign-off/rapport.pdf') + ok( + !!(await until(async () => (await opened()).some((o) => o.how === 'open' && o.abs === pdf), 4000)), + 'a click opens the file, resolved against the shell folder' + ) + const png = join(w.alpha, 'docs', 'wireframes', 'png') + await clickOn('docs/wireframes/png/') + ok(!!(await until(async () => (await opened()).some((o) => o.how === 'open' && o.abs === png), 4000)), 'a folder opens (in Explorer)') + await clickOn('run.bat') + ok(!!(await until(async () => (await opened()).some((o) => o.abs === bat), 4000)), 'the script was clicked') + ok( + !(await opened()).some((o) => o.how === 'open' && o.abs === bat), + 'and it is NEVER run: it is shown selected in Explorer instead' + ) + const before = (await opened()).length + await clickOn('docs/sign-off/rapport.pdf', 'right') + await sleep(300) + ok((await opened()).length === before, 'a right-click opens nothing') + const rows = await menu() + ok( + !!rows && rows[0] === 'Open' && rows[1] === 'Show in Explorer' && rows[2] === 'Copy path', + `the menu leads with Open, Show in Explorer, Copy path (${JSON.stringify(rows)})` + ) + await page.locator('[role="menu"] [role="menuitem"]', { hasText: 'Show in Explorer' }).click() + ok(!!(await until(async () => (await opened()).some((o) => o.how === 'reveal' && o.abs === pdf), 4000)), 'Show in Explorer shows the file') + await clickOn('docs/sign-off/rapport.pdf', 'right') + await menu() + await page.locator('[role="menu"] [role="menuitem"]', { hasText: 'Copy path' }).click() + ok((await until(async () => (await clip()) === pdf, 4000)) === true, 'Copy path copies it whole and absolute') + } finally { + await app.evaluate(({ clipboard }, text) => clipboard.writeText(text), held).catch(() => {}) + await closeApp(app) + } + }, + async links(ok) { const w = world() const { app, page } = await launch(w, { args: [w.alpha] }) From 7aab6d2e54cce6c8bfac57f519818af40eefc9b5 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:04:28 +0200 Subject: [PATCH 4/4] test(paths): real backslashes in the prompt cases (#99) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- core/renderer/lib/termPathLinks.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/core/renderer/lib/termPathLinks.test.ts b/core/renderer/lib/termPathLinks.test.ts index c3caa7d..17b7713 100644 --- a/core/renderer/lib/termPathLinks.test.ts +++ b/core/renderer/lib/termPathLinks.test.ts @@ -5,8 +5,10 @@ const paths = (text: string): string[] => pathCandidates(text).map((p) => p.path describe('pathCandidates', () => { it('leaves the prompt alone, PowerShell and cmd', () => { - expect(paths('PS C:\Users\me\work> ls docs/a.pdf')).toEqual(['docs/a.pdf']) - expect(paths('C:\Users\me\work>dir')).toEqual([]) + expect(paths('PS C:\\Users\\me\\work> ls docs/a.pdf')).toEqual(['docs/a.pdf']) + expect(paths('C:\\Users\\me\\work>dir')).toEqual([]) + // The same folder written in a sentence IS a path. + expect(paths('it is in C:\\Users\\me\\work now')).toEqual(['C:\\Users\\me\\work']) }) it('leaves a path inside a web link to the link', () => { expect(paths('see https://github.com/a/b/blob/main/README.md and README.md')).toEqual(['README.md'])