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
46 changes: 40 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ so an update never silently changes what an existing user sees; the bridge to ma
same"). `core/renderer/settings`: the field primitives, `TerminalAppearanceSettings` (theme wall and
editor, font, size, acrylic, the two indicator colours) and the rows `ShellSetting` /
`AgentIndicatorSetting`. Each app composes its OWN page round them (`components/Settings.tsx` here
is only the page plus this app's rows: new tabs, Explorer menu, version). Values are per app (own
is only the page plus this app's rows: new tabs, Explorer menu, window edges, version). Values are per app (own
userData), never shared. `settings/options.ts` lists every terminal option by id; a unit test holds
the list and the sections together, and each app's e2e (`options`) asserts its page shows that
list and no terminal-looking row of its own. A row outside the list is a fork.
Expand Down Expand Up @@ -180,7 +180,32 @@ terminal theme, anything that reads or shows files.
be thinner; what reads as thickness is contrast, so it is drawn a small step off the theme's own
ground, and removed when maximized or fullscreen. Chromium rewrites the DWM attributes when the
backdrop changes, so it is re-applied, debounced, after every material or ground change. Off
under `--e2e` (the helper is a PowerShell that compiles a P/Invoke per launch).
under `--e2e` (the helper is a PowerShell that compiles a P/Invoke per launch). How BIG the step
is now follows the Edges setting below (2026-09-19, #27): the hairline is the default and is the
step it always was.
- **EDGES ARE A SETTING, AND THE DEFAULT IS THE WINDOW AS IT WAS** (owner, 2026-09-19, #27: "add
the option to specify the edges that you have in the Terminal app, like we have in the main app,
where you can choose like Hairline, Faint, or like Solid edges, or even No edges"). Settings >
Appearance > Edges (`window-edges`, localStorage `prism.window.edges`, store `lib/edgesPrefs.ts`).
The control runs WEAKEST TO STRONGEST (None, Faint, Hairline, Solid), the order of Prism's own
Edges row: a segmented scale reads as one only in order, and the order the owner said the words
in was a sentence, not a layout. THIS APP'S row, not the core's: the edges are the window's chrome, which in Prism belongs to the
app style and has an Edges row of its own there, so it is in the `options` e2e's closed list of
this app's rows and its key is not `prism.term.*`. EVERY edge in the window reads one of two
tokens (`--p-divider`, the chrome's line; `--p-line`, the list's), core's components included, so
the choice is applied ONCE, where `chromeTokens` derives those two, and no component knows the
setting exists. The numbers are in `src/shared/windowEdges.ts` (shared because main reads them
too): hairline is 7/10% and 9/12% (dark/light), EXACTLY what was hard-coded before, so nobody's
window changed; faint (2.2/3.5%) and solid (16/18%) are Prism's `faint` and `strong`, the owner's
word for the latter being Solid; none is alpha 00, still a colour, so a border keeps its width
and nothing shifts (the e2e compares the layout across all four). Unlike Prism, `--p-line`
follows too: the owner asked for every edge, and "no edges" with ruled settings rows is half a
setting. The DWM border round the window follows as well (`window:edges` tells main, which
validates it; `edgeFor` scales its step, and none is DWMWA_COLOR_NONE, what a maximized window
already gets). That half is unit-tested only: the DWM helper is off under `--e2e`, so the e2e
asserts what main HEARD, and how the border looks is on the hands-on list. The `edges` e2e
measures real edges (a tab separator, the title bar's rule, the settings rail, a settings row),
WAITING for each to arrive, since the strip's border colour transitions over 550ms.
- **THE CLOSE QUESTION IS ONE RULE, NOT A SETTING** (owner, 2026-09-19, #15: "remove the setting but
just have it on smart mode by default, so it won't ask if you're in a normal shell but if you're
working with an agent it will ask"). `core/renderer/lib/agentClose.ts`, the same in Prism: a plain
Expand Down Expand Up @@ -229,9 +254,18 @@ terminal theme, anything that reads or shows files.
- **Opacity is a number read defensively** (`termOpacity`): `Number(null)` is 0, and never-set must
read as opaque.
- **Explorer verbs**: HKCU, `reg.exe` with argv only, on `Directory` and `Directory\Background`, no
`*`. On by default but APPLIED ONCE (marker file), never in dev and never under `--e2e`, and the
Settings switch reports what the REGISTRY says. `shellVerbParity.test.ts` asserts the uninstaller
deletes every key the app writes.
`*`. On by default, put back at launch unless somebody said no (the off-marker is the one fact
stored), never in dev and never under `--e2e`, and the Settings switch reports what the REGISTRY
says. `shellVerbParity.test.ts` asserts the uninstaller deletes every key the app writes.
**BOTH ENTRIES READ "Open terminal here" AND NEITHER NAMES THE APP** (owner, 2026-09-19, #27:
"have it say Open Terminal here and don't have any of them mention Prism, you can see that by
the logo"; they were "Open in Prism Terminal" and "Open Prism Terminal here"). An install that
already had them is RELABELLED by `reconcile` at launch, under the narrowest rule that does it:
no "no" on record, the key present AND pointing at this exe (each key judged on its own), its
label readable and different, and then only the label value is written. It never turns on an
entry somebody turned off (that case does not even query), and `allowed` still gates it like
every other write. The label is read by matching `REG_SZ`, never the value's name, because
reg.exe prints "(Default)" in the language of the Windows it runs on.
- **Renderer is sandboxed** (`sandbox: true`, context isolation on). The preload reaches the
clipboard through main for that reason. `will-navigate` and window-open allow http(s) only.
- The preload global is `window.prism` and localStorage keys are `prism.term.*`, kept from Prism so
Expand All @@ -242,7 +276,7 @@ terminal theme, anything that reads or shows files.
`core/` (the terminal, shared with Prism: see above). `src/main` (index.ts is wiring only;
planRestore, tabsStore, windowState, material, windowEdge, dwmHelper, verbSwitch, shellVerb,
update, argv),
`src/preload`, `src/shared` (termCwd, types), `src/renderer/src` (App.tsx owns the tab list and the
`src/preload`, `src/shared` (termCwd, types, windowEdges), `src/renderer/src` (App.tsx owns the tab list and the
keys; components/; lib/ is pure and tested). One responsibility per file; aliases `@shared`,
`@renderer`. No new runtime dependency without a reason; today there are eight.

Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,11 @@ and want to know, at a glance, which one has finished.
- **The theme is the window.** About forty terminal themes, a custom-theme editor, fifteen fonts.
The title bar, tabs, menus and settings take their colours from the terminal theme you pick, and
every colour is checked against a contrast floor, so no theme can make an unreadable window.
Acrylic works with any of them, with an opacity slider.
- **Open a tab where you are.** "Open in Prism Terminal" on a folder and "Open Prism Terminal here"
on empty space, in Explorer's right-click menu. If the app is running, the folder arrives as a new
tab in its window.
Acrylic works with any of them, with an opacity slider. The lines between the parts of the window,
and the border round it, are yours to set: hairline, faint, solid, or none.
- **Open a tab where you are.** "Open terminal here", with the app's icon beside it, on a folder and
on the empty space inside one, in Explorer's right-click menu. If the app is running, the folder
arrives as a new tab in its window.
- **New tabs your way.** The + opens in your user folder, or in one folder you choose, or asks each
time. Right-click the + for pinned and recent folders.
- **Links look like links.** A URL printed in the terminal is highlighted and underlined all the
Expand Down
2 changes: 2 additions & 0 deletions docs/superpowers/specs/2026-09-18-prism-terminal-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ argument and a visible window: just raise it.

**Explorer verbs** (owner): classic HKCU verbs via `reg.exe` (argv only), on `Directory`
("Open in Prism Terminal", `%1`) and `Directory\Background` ("Open Prism Terminal here", `%V`).
(Labels superseded 2026-09-19, #27, owner: both entries read "Open terminal here", since the icon
already names the app; an install that has the old text is relabelled at launch.)
NO verb under `*`. Key name `PrismTerminal`, distinct from Prism's. On by default, applied once
(marker file in userData), switchable in Settings, reports what the REGISTRY says, never in dev
or under `--e2e`. The NSIS uninstall macro deletes both keys, and a parity test asserts every key
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.2.0",
"version": "0.3.0",
"description": "A tabbed Windows terminal for AI CLIs.",
"main": "./out/main/index.js",
"author": "Max",
Expand Down
20 changes: 18 additions & 2 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { foldersFromArgv } from './argv'
import { acrylicOk, createMaterial } from './material'
import { stopDwmHelper, warmDwmHelper } from './dwmHelper'
import { createWindowEdge } from './windowEdge'
import { DEFAULT_WINDOW_EDGES, validWindowEdges, type WindowEdges } from '@shared/windowEdges'
import { detectShells } from '@core/main/shells'
import { createTabsStore } from './tabsStore'
import { killAll } from '@core/main/terminal'
Expand Down Expand Up @@ -76,14 +77,20 @@ Menu.setApplicationMenu(null)
let mainWindow: BrowserWindow | null = null
// Acrylic and the window's solid ground; the rules are material.ts's.
const material = createMaterial(() => mainWindow)
// The faint hairline round a floating window; the rules are windowEdge.ts's.
// How strongly the user wants edges drawn (#27). The choice lives in the page's
// localStorage, so main is TOLD it (`window:edges`, at launch and on a change)
// and holds the default, the border as it always was, until it has been.
let windowEdges: WindowEdges = DEFAULT_WINDOW_EDGES
// The line round a floating window, a hairline unless the setting above says
// otherwise; the rules are windowEdge.ts's.
// Not under --e2e: a parked window has no edge anyone sees, and the helper is a
// PowerShell that compiles a P/Invoke, once per launch, thirty launches a run.
const edge = E2E
? { apply: (): void => {} }
: createWindowEdge(
() => mainWindow,
() => material.bg()
() => material.bg(),
() => windowEdges
)

/** Every event to the renderer goes through here: a pty can outlive the
Expand Down Expand Up @@ -510,6 +517,15 @@ function wireIpc(): void {
material.setBg(hex)
edge.apply() // the edge is a step off the ground, so it follows the theme
})
// The edges setting (#27). Validated, never trusted: anything that is not one
// of the four words is the default. Not persisted here: the page says it at
// every launch, as it says the ground.
ipcMain.on('window:edges', (_e, edges: unknown) => {
windowEdges = validWindowEdges(edges)
edge.apply()
})
// The DWM border itself is off under --e2e, so the suite asks what main HEARD.
if (E2E) ipcMain.handle('e2e:window-edges', () => windowEdges)
}

// Single instance: a second launch (the verb, a shortcut, a command line)
Expand Down
83 changes: 76 additions & 7 deletions src/main/shellVerb.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
import { describe, expect, it } from 'vitest'
import { addArgs, pointsAt, queryArgs, removeArgs, shouldWriteVerb, verbKeys, verbSpec } from './shellVerb'
import {
VERB_LABEL,
addArgs,
labelArgs,
labelQueryArgs,
pointsAt,
queryArgs,
readLabel,
removeArgs,
shouldWriteVerb,
verbKeys,
verbSpec
} from './shellVerb'

const EXE = 'C:\\Users\\Admin\\AppData\\Local\\Programs\\Prism Terminal\\PrismTerminal.exe'

Expand Down Expand Up @@ -28,13 +40,19 @@ describe('the Explorer verb', () => {
// %1 is empty on a background click: the verb would launch Prism with no
// path at all, which is exactly the "nothing happens" this fixes.
const bg = verbKeys().find((k) => k.includes('Background'))!
expect(verbSpec(bg)).toEqual({ label: 'Open Prism Terminal here', arg: '%V' })
expect(verbSpec(verbKeys()[0])).toEqual({ label: 'Open in Prism Terminal', arg: '%1' })
expect(verbSpec(bg)).toEqual({ label: 'Open terminal here', arg: '%V' })
expect(verbSpec(verbKeys()[0])).toEqual({ label: 'Open terminal here', arg: '%1' })
})

it('says where you land, on the verb that lands you somewhere else', () => {
const flat = addArgs(EXE).map((a) => a.join(' ')).join('\n')
expect(flat).toContain('Open Prism Terminal here')
it('says what it does and never names the app, on both entries', () => {
// Owner, 2026-09-19 (#27): "have it say Open Terminal here and don't have
// any of them mention Prism, you can see that by the logo".
expect(VERB_LABEL).toBe('Open terminal here')
const labels = addArgs(EXE)
.filter((a) => a.includes('/ve') && !a[1].endsWith('\\command'))
.map((a) => a[a.length - 2])
expect(labels).toEqual(['Open terminal here', 'Open terminal here'])
for (const l of labels) expect(l.toLowerCase()).not.toContain('prism')
})

it('quotes the path inside the command, so a folder with spaces survives', () => {
Expand All @@ -44,7 +62,7 @@ describe('the Explorer verb', () => {

it('names the menu item and gives it the app icon', () => {
const flat = addArgs(EXE).map((a) => a.join(' ')).join('\n')
expect(flat).toContain('Open in Prism Terminal')
expect(flat).toContain('Open terminal here')
expect(flat).toContain(`${EXE},0`)
})

Expand Down Expand Up @@ -99,6 +117,57 @@ HKEY_CURRENT_USER\\Software\\Classes\\Directory\\shell\\PrismTerminal\\command
})
})

describe('reading and rewriting the label', () => {
const DIR = verbKeys()[0]
// What reg.exe printed on this machine, 2026-09-19, CRLF and all.
const english =
'\r\nHKEY_CURRENT_USER\\Software\\Classes\\Directory\\shell\\PrismTerminal\r\n' +
' (Default) REG_SZ Open in Prism Terminal\r\n\r\n'

it('reads the label reg.exe printed, without its line ending', () => {
expect(readLabel(english)).toBe('Open in Prism Terminal')
})

it('does not depend on the word (Default), which Windows translates', () => {
expect(readLabel(english.replace('(Default)', '(Standard)'))).toBe('Open in Prism Terminal')
})

it('reads the new label back as itself, so a relabelled entry is not relabelled again', () => {
expect(readLabel(english.replace('Open in Prism Terminal', VERB_LABEL))).toBe(VERB_LABEL)
})

it('answers null when there is nothing to read, which is not the same as stale', () => {
expect(readLabel('')).toBeNull()
expect(
readLabel('ERROR: The system was unable to find the specified registry key or value.')
).toBeNull()
})

it('asks for the key itself, not its command, and rewrites only the default value', () => {
expect(labelQueryArgs(DIR)).toEqual(['query', DIR, '/ve'])
expect(labelArgs(DIR)).toEqual([
'add',
DIR,
'/ve',
'/t',
'REG_SZ',
'/d',
'Open terminal here',
'/f'
])
// Never the Icon and never the command: a relabel touches the text alone.
expect(labelArgs(DIR)).not.toContain('Icon')
expect(labelArgs(DIR)[1].endsWith('\\command')).toBe(false)
})

it('can ask about either key, and asks about the folder verb by default', () => {
expect(queryArgs(verbKeys()[1])[1]).toContain(
'\\Directory\\Background\\shell\\PrismTerminal\\command'
)
expect(queryArgs()[1]).toContain('\\Directory\\shell\\PrismTerminal\\command')
})
})

// The uninstaller parity (every key written here is one the NSIS macro deletes)
// lives in shellVerbParity.test.ts, beside the installer it reads.

Expand Down
Loading
Loading