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
15 changes: 14 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,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)

Expand Down Expand Up @@ -667,6 +668,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
Expand Down
21 changes: 21 additions & 0 deletions core/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))
}
73 changes: 73 additions & 0 deletions core/main/termPathOpen.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
109 changes: 109 additions & 0 deletions core/main/termPathOpen.ts
Original file line number Diff line number Diff line change
@@ -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<Array<PathHit | null>> {
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'
}
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.2",
"version": "0.19.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,
Expand Down
11 changes: 10 additions & 1 deletion core/preload/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ export interface TermPreloadApi {
writeClipboard(text: string): Promise<boolean>
/** 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<Array<{ kind: 'file' | 'dir'; abs: string } | null>>
/** 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 {
Expand Down Expand Up @@ -93,6 +99,9 @@ export function createTermApi(ipc: IpcRendererLike): TermPreloadApi {
writeClipboard: (text) => ipc.invoke(CH.clipboardWrite, text) as Promise<boolean>,
openExternal: (url) => {
if (/^https?:/i.test(url)) ipc.send(CH.openExternal, url)
}
},
termPathKinds: (cwd, texts) =>
ipc.invoke(CH.pathKinds, cwd, texts) as Promise<Array<{ kind: 'file' | 'dir'; abs: string } | null>>,
termOpenPath: (cwd, text, mode) => ipc.send(CH.openPath, cwd, text, mode)
}
}
Loading
Loading