Skip to content
Closed
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
63 changes: 48 additions & 15 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,43 @@ every tab, terminal themes that colour the whole window. Electron + React 19 + T
Tailwind v4, node-pty + xterm. x64, Windows 10 1809+ / 11, per-user unsigned NSIS installer,
GitHub Releases.

## Where it came from
## This repo is the terminal of TWO apps

Lifted out of **Prism** (`../Prism`) at commit `4196c3a` (v0.50.3), 2026-09-18, owner decision.
The code was COPIED: Prism keeps its own terminal and is never edited from here. The two drift; a
fix worth having in both is ported by hand. Prism's CLAUDE.md holds the long history of WHY the
terminal behaves as it does (search it for the date in a copied comment).
**`core/` is the terminal, and Prism embeds it** (owner, 2026-09-19, #15: "this terminal app is just
an extraction of the main Prism app and should therefore be reflected in both apps... all should be
synced, unless it conflicts with one app, then you need to ask me", and "why can't this repo be the
core?"). Read [`core/README.md`](core/README.md) before touching anything under `core/`: it is the
contract. In one paragraph: what IS the terminal lives in `core/` once; what is an app's shell stays
in the app; every place the two apps legitimately differ is a DECLARED field of `TermHostConfig`
(`core/renderer/host.ts`), never a fork, and adding one is an owner decision; defaults are per host
so an update never silently changes what an existing user sees; the bridge to main is written once
(`core/shared/channels.ts`, `core/preload/api.ts`, `core/main/ipc.ts`). This app is one host
(`src/renderer/src/termHost.ts`); Prism is the other.

- **THE TERMINAL'S SETTINGS ARE THE CORE'S TOO** (owner, 2026-09-19: "they shouldn't be synced in
terms of personalization, but the setting names, types, how they function and so on should be the
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
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.
- **A terminal change goes in `core/`**, and is a change to Prism too: say so in the PR, and ask the
owner when it would conflict with how Prism works. App-shell changes (tabs, start screen, window)
stay in `src/`.
- **`core/` is lint-walled** (`eslint.config.js`): relative imports only (a consumer resolves
`@shared` against ITS OWN tree, MEASURED, silently), never `window.prism` (use `termApi()`), never
a host's `src/`, never `electron`, never `chromeTheme`. The app reaches the core through `@core`.
- **Carry the superset**: a capability only Prism uses (`cdTerm`, `decideFollow`, following the host
style) lives in `core/` anyway; deleting it here takes it from Prism.
- Prism consumes `core/` as a DEV dependency pinned to a `core-v*` tag of the `core-dist` branch
(`git subtree split --prefix=core`). Tags: `core-v*` for the core, `v*` for this app.

**History.** Made 2026-09-18 by COPYING Prism's terminal at Prism `4196c3a`, on the recommendation
"new repo, Prism untouched". That copy drifted within a day, which is what the core exists to end.
Prism's CLAUDE.md still holds the long history of WHY the terminal behaves as it does (search it for
the date in a copied comment).

Design spec and plan: `docs/superpowers/specs/2026-09-18-prism-terminal-design.md`,
`docs/superpowers/plans/2026-09-18-prism-terminal.md`. Owner decisions are marked `(owner)` there.
Expand Down Expand Up @@ -68,20 +99,22 @@ terminal theme, anything that reads or shows files.
build's Ctrl+Shift+T and its "ask" default). `newTabPrefs`: mode `folder` is the default and a
folder of `''` means the user's own, which main resolves (`homeDir()`); `ask` is the option.
Ctrl+T is therefore taken from whatever runs in the shell (Claude Code's task list), knowingly.
Close tab stays Ctrl+SHIFT+W: plain Ctrl+W is delete-word in every readline.
**Ctrl+W closes a tab, in BOTH apps** (owner, 2026-09-19, reversing the first build's
Ctrl+Shift+W, which still works). Known cost, accepted: the shell loses delete-word on that chord;
Ctrl+Backspace does the same job.
- **The window's edge is a faint hairline that follows the theme** (owner, same day;
`windowEdge.ts` + Prism's `dwmHelper.ts`). DWM's border is always one physical pixel, so it cannot
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).
- **Closing a TAB asks whenever its shell hosts an agent, working or idle** (2026-09-19, #8, owner:
"the prompt when you close a tab with an active agent didn't work, it just closed"). The first
build asked only mid-answer; an agent waiting at its own prompt is still a conversation the close
ends, and Prism's rule (ask while an agent is LIVE) is the one that was expected. The question
names the agent, and how long it has worked when it is working. Closing the WINDOW still asks
only while one is WORKING, on purpose: idle agents resume at the next launch, so nothing is lost.
A plain shell closes unasked, and Off means off. Proved by the e2e `closeAsk` scenario.
- **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
shell closes unasked; a tab whose shell HOSTS an agent asks, working or idle (an agent waiting at
its own prompt is still a conversation the close ends), and names it and how long it has worked;
closing the WINDOW is held only while one is mid-answer, since idle agents come back at the next
launch. It replaced this app's on/off switch and Prism's three modes. Proved by the e2e `closeAsk`.
- **The title bar has a settings cog and NO menu** (owner, same day: a menu of Settings + Quit was
cut to the cog). Nothing in the UI needs to quit the app any more; the X does.
- **The theme drives the chrome** through the real `--p-*` tokens (`lib/chromeTheme.ts`). Every ink is
Expand Down Expand Up @@ -133,8 +166,8 @@ terminal theme, anything that reads or shows files.

## Layout

`src/main` (index.ts is wiring only; terminal, shells, termPrompt, agentDetect, agentPoll,
agentResume, tabsStore, windowState, material, windowEdge, dwmHelper, verbSwitch, shellVerb,
`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
keys; components/; lib/ is pure and tested). One responsibility per file; aliases `@shared`,
Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,9 @@ and want to know, at a glance, which one has finished.
- **Made for the way AI CLIs are used.** An image on the clipboard pastes into Claude Code, copied
files paste as quoted paths, a file dropped on the window types its path, Shift+Enter is a
newline, and Ctrl+C over a selection copies instead of interrupting.
- **It asks before it interrupts.** Closing a tab or the window while an agent is mid-answer asks
first, and says which agent and how long it has been working. Off means off.
- **It asks before it interrupts.** Closing a tab that hosts an agent asks first, and says which
agent and how long it has been working; a plain shell just closes. Not a setting: it simply does
the sensible thing.

<div align="center">
<img src="assets/terminal-light.png" alt="The same window wearing a light theme: the whole chrome turns light with it" width="420">
Expand All @@ -68,15 +69,16 @@ lands on a start screen with the folders you were last in, each one press from a
| Key | Does |
|---|---|
| `Ctrl+T` | New tab |
| `Ctrl+Shift+W` | Close tab |
| `Ctrl+W` | Close tab |
| `Ctrl+Tab` / `Ctrl+Shift+Tab` | Next / previous tab |
| `Ctrl+1` to `Ctrl+9` | Jump to a tab |
| `Ctrl+Shift+F` | Find in the scrollback |
| `Ctrl+,` | Settings |
| `Ctrl+scroll` | Zoom this tab's text |
| `F11` | Fullscreen |

Everything else belongs to the shell: plain `Ctrl+W` is still delete-word, `Escape` is still vim's.
Everything else belongs to the shell: `Escape` is still vim's, and `Ctrl+Backspace` deletes a word
(`Ctrl+W` closes the tab, as it does in a browser).

## Shells

Expand Down
115 changes: 115 additions & 0 deletions core/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# prism-term-core

The terminal shared by **Prism Terminal** and **Prism**. One copy, two apps.

This folder is the terminal. Prism Terminal is an app built around it; Prism is a
media viewer that embeds it. Both compile this same TypeScript source, so a
terminal feature or fix is written once and reaches both.

## Why it exists

Prism Terminal began (2026-09-18) as a COPY of Prism's built-in terminal. A copy
drifts from the first commit: within a day, links highlighting, a painting fix,
a themed indicator and a paste rule each existed in only one of the two apps.
The owner's rule (2026-09-19, issue #15): *"this terminal app is just an
extraction of the main Prism app and should therefore be reflected in both
apps... all should be synced, unless it conflicts with one app, then you need to
ask me."* And: *"why can't this repo be the core?"* It can, and this is it.

## The contract

1. **What is the terminal lives here, once.** The pty and shells, the prompt
bootstrap, agent detection / poll / resume, the panel, find, link painting,
the agent indicator's rules and colours, the close question, the paste rule,
the theme and look stores, **and the terminal's SETTINGS UI** (`renderer/settings`):
the owner's rule is that the two apps' terminal settings are the same
settings, "the setting names, types, how they function", while the personal
VALUES stay per app (each has its own storage; nothing is shared).
2. **What is an app's shell stays in the app.** Prism: roots and the wall, the
sidebar, the split dock, several shells per tab, app styles. Prism Terminal:
folder tabs, the start screen, window chrome from the theme, the lifecycle.
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
reads as, whether there is a host style to follow, who paints the ground,
what an unpicked indicator colour resolves to, what "acrylic" means as a
terminal setting, which chords the app owns. If a difference is not on that page, it is a fork,
and a fork is what this exists to end. Adding a field is an owner decision.
4. **Defaults are per host on purpose.** An update must never silently change
what an existing user sees. Where the owner picks one value for both apps,
both hosts pass it and the difference disappears.
5. **The bridge to main is written once.** [`shared/channels.ts`](shared/channels.ts)
names every IPC channel; [`preload/api.ts`](preload/api.ts) is the preload
half and [`main/ipc.ts`](main/ipc.ts) the main half. A host passes in its own
`ipcRenderer` / `ipcMain` / clipboard; the core imports nothing from
`electron`.

## Rules for code in here (lint-enforced in Prism Terminal's `eslint.config.js`)

- **Relative imports only.** No `@shared` / `@renderer` / `@core` aliases: a
consumer's bundler resolves an alias against ITS OWN tree. Measured: the core
silently ran Prism's copy of a file, with no error anywhere.
- **Never `window.prism`.** Reach main through `termApi()`.
- **Never import from a host's `src/`**, never import `electron`, never import
Prism Terminal's `chromeTheme` (inside Prism it would overwrite the app's
styles).
- **Carry the superset.** If one app needs a capability the other does not
(`cdTerm`, `decideFollow`, following the host style), it lives here and the
other app simply does not call it. Deleting it here takes it from both.
- CSS: the core uses Tailwind utilities and the `--p-*` tokens both apps define,
plus the classes `.p-agent-run` and `.p-scroll` and the `.xterm` rules in each
app's `index.css`. A new token or class needed here must be added to BOTH.

## How each app consumes it

**Prism Terminal** (this repo): directly, through the app-side alias `@core`.
The app's tests, lint and the 14 e2e scenarios are the core's gate.

**Prism**: as a dev dependency pinned to a tag of the `core-dist` branch, which
is this folder on its own (`git subtree split --prefix=core`), so the package is
small, has no install scripts and carries no app files:

```jsonc
// Prism/package.json
"devDependencies": { "prism-term-core": "github:Maxaubert/PrismTerminal#core-v0.1.0" }
```

A DEV dependency on purpose: electron-vite bundles dev dependencies into the app
and externalises production ones, so the core is compiled in and nothing extra
ships. Prism's build needs three lines, all measured:

```ts
// electron.vite.config.ts, renderer: a linked checkout otherwise brings a second React
resolve: { dedupe: ['react', 'react-dom'] }, optimizeDeps: { exclude: ['prism-term-core'] }
```
```css
/* src/renderer/src/index.css: Tailwind generates nothing from a dependency until told to look */
@source '../../../node_modules/prism-term-core';
```

`react`, `node-pty` and `@xterm/*` must stay at compatible versions in both apps
so npm keeps ONE copy of each; the core is verified against Prism Terminal's
lockfile but ships against Prism's.

## Releasing the core

1. Land the change in Prism Terminal (PR, the app's full gate).
2. Bump `core/package.json`'s version. Publish: `git subtree split --prefix=core -b core-dist`,
tag `core-v<version>`, push the branch and the tag. Tags are `core-v*` for the
core and `v*` for the app, since this repo is both.
3. In Prism, and only after ASKING THE OWNER (2026-09-19: a core release is
never pulled into Prism unasked): bump the pin, run `npm run e2e:terminal`
(Prism's terminal gate: every scenario the terminal can break, required
green for any pin bump, because Prism has the larger footprint and so more
ways to break), then Prism's usual gate, install, PR. The core's lint and
unit tests run only here; Prism's gate on it is its compiler and its e2e.

## Status (2026-09-19)

Prism Terminal runs entirely on this core. Prism's adoption is written and
waiting on the owner's review as three stacked draft PRs (Prism #155, #156 and
the step-3 PR): identical files, then the panel / look stores / bridge, then the
settings, the indicator's rules and the close question. Until this branch merges
Prism pins a release candidate (`core-v0.1.0-rc.N`); the first real tag,
`core-v0.1.0`, is cut from `main` after the merge and Prism is repointed at it.
Issue #15 holds the decisions and what is left.
File renamed without changes.
File renamed without changes.
File renamed without changes.
2 changes: 1 addition & 1 deletion src/main/agentPoll.ts → core/main/agentPoll.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { execFile } from 'child_process'
import type { DetectedAgent } from '@shared/types'
import type { DetectedAgent } from '../shared/types'
import { parseProcLines, treeAgentKind } from './agentDetect'
import { livePids, ptyOutputTicks } from './terminal'

Expand Down
39 changes: 39 additions & 0 deletions core/main/agentResume.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { mkdirSync, mkdtempSync, utimesSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { describe, expect, it } from 'vitest'
import { CODEX_RESUME, claudeSessions, validResume } from './agentResume'

describe('validResume', () => {
it('accepts a session id and the codex marker, refuses a command', () => {
expect(validResume('3f2a9c1e-77aa-4c0d-9b1e-0a1b2c3d4e5f')).toBeTruthy()
expect(validResume(CODEX_RESUME)).toBe(CODEX_RESUME)
expect(validResume('x; rm -rf')).toBeUndefined()
})
it('refuses an id with a command riding behind it, and nothing at all', () => {
expect(validResume('3f2a9c1e-77aa-4c0d-9b1e-0a1b2c3d4e5f; calc')).toBeUndefined()
expect(validResume('')).toBeUndefined()
expect(validResume(undefined)).toBeUndefined()
})
})

describe('claudeSessions', () => {
it("reads claude's own store for the folder, newest first", () => {
const home = mkdtempSync(join(tmpdir(), 'pt-home-'))
// claude's encoding: every non-alphanumeric character becomes a dash.
const dir = join(home, '.claude', 'projects', 'C--Users-me-my-project')
mkdirSync(dir, { recursive: true })
const at = (name: string, secs: number): void => {
writeFileSync(join(dir, name), '')
utimesSync(join(dir, name), secs, secs)
}
at('old.jsonl', 1_000_000)
at('new.jsonl', 2_000_000)
at('notes.txt', 3_000_000)
expect(claudeSessions('C:\\Users\\me\\my project', home)).toEqual(['new', 'old'])
})
it('answers nothing for a folder claude never recorded', () => {
const home = mkdtempSync(join(tmpdir(), 'pt-home-'))
expect(claudeSessions('C:\\nowhere', home)).toEqual([])
})
})
43 changes: 0 additions & 43 deletions src/main/agentResume.ts → core/main/agentResume.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { readdirSync, statSync } from 'fs'
import { homedir } from 'os'
import { join } from 'path'
import type { Restored, RestoredTab, SavedTabs } from '@shared/types'

// What a tab that hosted an agent comes back to. Lifted out of Prism's index.ts
// so the rules can be tested without a window: main resolves the resume HERE,
Expand Down Expand Up @@ -48,45 +47,3 @@ export function claudeSessions(cwd: string, home: string = homedir()): string[]
export function validResume(r: string | undefined): string | undefined {
return r === CODEX_RESUME || (r && /^[0-9a-f][0-9a-f-]{6,62}[0-9a-f]$/i.test(r)) ? r : undefined
}

/**
* What a saved tab list comes back as. A folder that has gone is dropped
* without a word. Two claude tabs in one folder take the newest and the
* second-newest session, in order; no session on disk means no resume at all,
* never a bare --continue guessing.
*
* (Which conversation belonged to which tab is unknowable after the fact -
* newest-first in strip order is the honest guess.) Codex needs no lookup:
* `codex resume --last` continues the most recent session FOR THIS FOLDER (its
* picker filters by cwd), so the marker is enough and the shell starts in the
* tab's folder anyway.
*
* SAVED order, exactly, and the active tab stays the active tab: when tabs in
* front of it were dropped its index moves down with it, and when IT was the
* one dropped the first tab takes over.
*/
export function planRestore(
saved: SavedTabs,
exists: (p: string) => boolean,
sessions: (cwd: string) => string[]
): Restored {
const taken = new Map<string, number>()
const tabs: RestoredTab[] = []
let active = 0
saved.tabs.forEach((t, i) => {
if (!t.cwd || !exists(t.cwd)) return
if (i === saved.active) active = tabs.length
let resume: string | undefined
if (t.agent === 'codex') resume = CODEX_RESUME
else if (t.agent === 'claude') {
const key = t.cwd.toLowerCase()
const n = taken.get(key) ?? 0
// Not shape-checked here: term:spawn runs validResume on whatever comes
// back across the renderer, which is the check that cannot be skipped.
resume = sessions(t.cwd)[n]
if (resume) taken.set(key, n + 1)
}
tabs.push(resume ? { cwd: t.cwd, resume } : { cwd: t.cwd })
})
return { tabs, active: Math.min(active, Math.max(0, tabs.length - 1)) }
}
Loading
Loading