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
53 changes: 52 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,57 @@ so an update never silently changes what an existing user sees; the bridge to ma
MEASURED in the e2e with the fix taken out. App now puts the update window away whenever a
question (`ask`) appears; `updateGuard` holds it. The window's Tab trap also leaves Ctrl+Tab
alone, which used to switch tabs AND move the focus inside the window in one press.
- **COMMAND HELP IS A POPUP THAT SHOWS AND COPIES, AND NOTHING ELSE** (#12; owner, 2026-09-19: "an
easy to use panel where you can find shell commands... searchable... metadata on each command so a
natural-language search finds it... optional in settings", and 2026-09-20: "a pop up with copy
icons for easy copying"). Built ONCE in `core/` for both apps: the catalogue and its search in
`shared/help/`, `renderer/lib/helpPrefs.ts`, `renderer/components/HelpPanel.tsx` (props only),
`renderer/settings/Help.tsx` + `helpOptions.ts`, and `writeClipboard` on the bridge. This app's
part is the way in: `F1`, the ? in the title bar, "Command help" in the terminal's right-click
menu, the row in Settings > General. The rules that must not regress:
- **IT NEVER INSERTS AND NEVER RUNS** (owner, 2026-09-19: "picking a command in the help panel
does NOT insert it into the shell"). The component is handed the clipboard and nothing else: no
session id, no `termInput`, no bridge. It is NOT a fifth exception to "the app never types into
your shell", and adding Run or Insert is a fresh owner decision. The `helpPanel` e2e reads the
terminal's text before the popup is touched and after every search, copy and Enter in it.
- **COPY IS EXACT.** What goes on the clipboard is the text on screen, placeholders and all: a
command with FOLDER in it fails loudly when pasted unedited, and a panel that guessed would
fail quietly. It goes through main (`clipboard:write`, text only, refused past 4000 characters,
never trimmed) because `navigator.clipboard` refuses when the document has no focus. The e2e
reads the clipboard back in main and compares it character for character, then restores what
the clipboard held. A "command" that is a KEY (`Ctrl+C`, `Esc`; `isKeyPress`) is drawn as key
caps with no copy button.
- **CURATED AND OFFLINE.** A few hundred hand-written entries, task first, with keywords in the
words of somebody who does not know the command; the search (`shared/help/search.ts`) is pure:
stop words, a light stemmer, a small synonym table, prefix and one-typo matching, no model, no
network, no dependency. The popup and its catalogue are a LAZY chunk (about 290 kB), loaded at
the first open; what the app needs before then is in `shared/help/shells.ts`.
- **`catalogue.test.ts` IS THE GATE FOR CONTENT.** To add an entry: put it in the file of its
shell (`powershell.ts`, `cmd.ts`, `bash.ts`, or `git.ts` / `agents.ts` / `packages.ts` for what
reads the same everywhere), RUN the command first where that is safe, and run `npm test`. The
test holds: unique ids with the shell's prefix, a short task with no full stop, 6 to 16
lower-case keywords, every placeholder declared and used (UPPER_SNAKE, never angle brackets,
which a shell reads as redirection), no em-dash, and a `danger` line on EVERYTHING that matches
a destructive pattern (Remove-Item, rm, del, rmdir /s, taskkill, kill, git reset --hard, git
clean, a forced push, Set-Content, a single `>` over a file, a download over a file...). It
also asks about twenty real questions per shell of the
REAL catalogue and names the first answer each must get: a search can be right and the
catalogue still lack the keyword.
- **ON BY DEFAULT, AND OFF MEANS OFF.** It is a discoverability feature for exactly the people who
would never find a switch to turn it on; the owner asked only that it be optional. Off: no
button, no menu row, and `F1` is the shell's again (`ownsKey` and App's handler both read
`helpEnabled()`). Known cost of F1 while on, accepted: PSReadLine's own F1 (help for the command
under the cursor) and F1 inside a full-screen program under WSL do not arrive.
- **ONE LAYER, ONE THING IN IT.** The popup is the same layer as the update window and a close
question. It does not open over either, and App puts it away while RENDERING when one appears
(Ctrl+W and Alt+F4 still work over it), so a question is never underneath it. It also LEAVES
WHEN WHAT IS IN FRONT CHANGES (review, 2026-09-20): Ctrl+T, Ctrl+Tab and Ctrl+Shift+F work over
it, a terminal takes the focus as it attaches, and the popup left up sat over a focused shell
with the next "search" typed into it; `helpPanel` holds Ctrl+T and find. Escape returns
the keyboard to the shell that had it; Tab stays inside (a PLAIN Tab only, Ctrl+Tab is the
app's); the list draws a page at a time.
- Its option list is `helpOptions.ts`, NOT a row in `TERMINAL_OPTIONS`: Prism's gate reads that
file as text, and a row there would fail Prism's parity check until Prism wires the popup.
- **A PAGE THAT WORKS IS NOT A PAGE THAT LOOKS RIGHT** (#20, 2026-09-19). Moving the settings into
`core/` dropped every Tailwind class used only there (`core/` is outside the scanned root; the fix
is the `@source` line at the top of `index.css`, do not remove it). All 14 e2e scenarios passed over
Expand Down Expand Up @@ -181,7 +232,7 @@ Design spec and plan: `docs/superpowers/specs/2026-09-18-prism-terminal-design.m
In: tabs (a tab is ONE shell and its folder, nothing else), the agent indicator, themes + custom
theme + fonts + acrylic, tab restore with agent resume, the + with ask / fixed-folder modes and its
pinned + recent list, the start screen, find in scrollback, the paste and drop rules, close
confirmation, Explorer verbs, the update chip and its window, single instance.
confirmation, Explorer verbs, the update chip and its window, command help (#12), single instance.

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
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ and want to know, at a glance, which one has finished.
model in Settings (Base runs well on any CPU; with an NVIDIA card one click adds the GPU engine and
the large models answer in under a second), a language or auto-detect, and optionally pause your
music while you talk. Off until you switch it on: nothing listens and nothing downloads before that.
- **Command help, one key away.** Press `F1` (or the ? in the title bar) and describe what you want
in your own words: "find big files", "what is using port 3000", "undo last commit". A few hundred
everyday commands for PowerShell, Command Prompt and Bash, plus Git, Claude Code, Codex, winget,
npm and pip, each with a plain explanation, its variations, what the placeholders mean and a
warning on anything that deletes or overwrites. Every command has a copy button. It opens on the
shell of the tab in front, works offline (the list ships with the app, no model and no network),
and it never types or runs anything: you copy, you paste, you press Enter. Optional: Settings >
General > Command help.
- **Tabs that come back.** Close the app and reopen it: every tab returns in the folder its shell
was in, and a tab that hosted Claude or Codex resumes that conversation (`claude --resume <id>`,
`codex resume --last`).
Expand Down Expand Up @@ -92,6 +100,7 @@ lands on a start screen with the folders you were last in, each one press from a
| `Ctrl+,` | Settings |
| `Ctrl+scroll` | Zoom this tab's text |
| `F11` | Fullscreen |
| `F1` | Command help (when switched on; off, the key is the shell's again) |
| hold `Right Alt` | Dictate (when switched on; rebindable, or press-to-toggle) |

Everything else belongs to the shell: `Escape` is still vim's, and `Ctrl+Backspace` deletes a word
Expand Down
23 changes: 23 additions & 0 deletions core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,29 @@ ask me."* And: *"why can't this repo be the core?"* It can, and this is it.
and printed as text nodes; never HTML, never rendered markdown, never an
anchor. **The chip never changes width**, in any phase: every label is laid
out in one cell and only the current one is visible.
**COMMAND HELP (#12)** is the second thing in here that is not terminal, under
the same bar: `shared/help/` (the catalogue, six files of curated entries; its
pure offline search; `catalogue.test.ts`, the gate for content),
`renderer/lib/helpPrefs.ts`, `renderer/components/HelpPanel.tsx` and
`renderer/settings/Help.tsx` + `helpOptions.ts`. A host wires it with:
`helpEnabled()` / `useHelpEnabled()` to decide whether its way in exists at
all (on by default; off means no button and no key); ONE `<HelpPanel>` at its
root, lazy-loaded since it brings the catalogue with it, handed `shell`
(`shellOfShellId` of the shell in front, from the light `shared/help/shells.ts`),
`monoFont` (`termFontStack()`), `onCopy` (the bridge's `writeClipboard`, which
goes through main because `navigator.clipboard` refuses an unfocused
document), `onPickShell` and `onClose`; its key in `ownsKey`, tested against
`helpEnabled()`, so xterm yields it exactly when the host takes it (Prism
Terminal uses a bare F1); and `<HelpSetting opensWith="...">` on its settings
page, which says what that host's way in is. As with the update window, a
host that raises a question of its own while the popup is up must PUT IT
AWAY first and must not open it over one: they are the same layer. The same
goes for a chord that puts something ELSE in front while it is up (another
tab, a new tab, a find bar): a terminal takes the focus as it attaches, and a
popup left over a focused shell has its questions typed into that shell. Two rules
travel with it. **It never inserts and never runs**: the component has no
session id and no bridge, only `onCopy`, and a host must not give it more.
**Copy is exact**: the text on screen, placeholders included, never trimmed.
3. **A difference between the apps is DECLARED, never forked.** Every place the
two legitimately differ is a field of `TermHostConfig` in
[`renderer/host.ts`](renderer/host.ts): the default each untouched setting
Expand Down
50 changes: 50 additions & 0 deletions core/main/ipc.clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { CH } from '../shared/channels'
import { CLIPBOARD_WRITE_MAX, registerTermIpc } from './ipc'

// THE ONE WRITE THE PAGE MAY MAKE TO THE CLIPBOARD (#12, the help panel's copy
// buttons). It is text, it is bounded, and an oversized write is REFUSED, not
// trimmed: a trimmed command is a different command.

function wire(): { write: (text: unknown) => unknown; written: string[]; stop: () => void } {
const handlers = new Map<string, (e: unknown, ...args: unknown[]) => unknown>()
const written: string[] = []
const stop = registerTermIpc({
ipcMain: {
handle: (channel, listener) => handlers.set(channel, listener),
on: () => undefined
},
send: () => undefined,
clipboard: {
availableFormats: () => [],
readBuffer: () => Buffer.alloc(0),
readText: () => '',
writeText: (text) => {
written.push(text)
}
},
openExternal: () => undefined,
spawnDir: async () => null,
mayPrewarm: async () => false,
mayCd: () => false
})
return { write: (text) => handlers.get(CH.clipboardWrite)?.({}, text), written, stop }
}

describe('clipboard:write', () => {
it('writes the exact text it was handed', () => {
const w = wire()
const command = "Get-ChildItem -Recurse -File | Sort-Object Length -Descending | Select-Object -First 20"
expect(w.write(command)).toBe(true)
expect(w.written).toEqual([command])
w.stop()
})

it('refuses what is not text, what is empty and what is too long, and writes none of it', () => {
const w = wire()
for (const bad of [undefined, null, 7, {}, ['x'], '', 'x'.repeat(CLIPBOARD_WRITE_MAX + 1)]) expect(w.write(bad)).toBe(false)
expect(w.written).toEqual([])
expect(w.write('x'.repeat(CLIPBOARD_WRITE_MAX))).toBe(true)
w.stop()
})
})
18 changes: 18 additions & 0 deletions core/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,12 @@ interface ClipboardLike {
availableFormats(): string[]
readBuffer(format: string): Buffer
readText(): string
writeText(text: string): void
}

/** The most text the page may put on the clipboard in one write. */
export const CLIPBOARD_WRITE_MAX = 4000

export interface TermIpcDeps {
ipcMain: IpcMainLike
/** To the window that holds the terminals; a no-op while there is none. */
Expand Down Expand Up @@ -127,6 +131,20 @@ export function registerTermIpc(deps: TermIpcDeps): () => void {
}
})

// The help panel's copy buttons (#12). Text only, and bounded: the longest
// command in the catalogue is a few hundred characters, so anything near the
// cap is not a command, and it is REFUSED rather than trimmed, since a
// trimmed command is a different command.
ipcMain.handle(CH.clipboardWrite, (_e: unknown, text: unknown) => {
if (typeof text !== 'string' || text.length === 0 || text.length > CLIPBOARD_WRITE_MAX) return false
try {
deps.clipboard.writeText(text)
return true
} catch {
return false
}
})

// A link clicked in the terminal. The page is never trusted with a scheme.
ipcMain.on(CH.openExternal, (_e: unknown, url: unknown) => {
if (typeof url === 'string' && /^https?:\/\//i.test(url)) deps.openExternal(url)
Expand Down
2 changes: 1 addition & 1 deletion core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "prism-term-core",
"version": "0.3.0",
"version": "0.4.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
9 changes: 9 additions & 0 deletions core/preload/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ export interface TermPreloadApi {
* reading, since a sandboxed preload has no clipboard module.
*/
readClipboard(): ClipboardRead
/**
* Put TEXT on the clipboard (#12, the help panel's copy buttons). Through
* main rather than `navigator.clipboard`: that one refuses unless the
* document has the focus ("Document is not focused"), and a copy button
* that works on some presses and not others is worse than none. Text only,
* and main caps its length. Answers whether it was written.
*/
writeClipboard(text: string): Promise<boolean>
/** The web-links addon's click-through: external URLs go to the OS browser. */
openExternal(url: string): void
}
Expand All @@ -79,6 +87,7 @@ export function createTermApi(ipc: IpcRendererLike): TermPreloadApi {
onTermAgent: (cb) => on(CH.agent, cb),
onTermExit: (cb) => on(CH.exit, cb),
readClipboard: () => ipc.sendSync(CH.clipboardRead) as ClipboardRead,
writeClipboard: (text) => ipc.invoke(CH.clipboardWrite, text) as Promise<boolean>,
openExternal: (url) => {
if (/^https?:/i.test(url)) ipc.send(CH.openExternal, url)
}
Expand Down
Loading
Loading