From 16368956dd4ddee4a573ceb8a257f4a5748a993b Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sat, 19 Sep 2026 10:10:20 +0200 Subject: [PATCH 1/8] refactor(core): the terminal moves into core/, behind a declared host seam (#15) Everything that IS the terminal now lives in core/ and is what Prism will consume: pty, shells, prompt bootstrap, agent detection, poll and resume, the panel, find, links, the indicator rules, the theme and look stores. The bridge to main is written once for both hosts (core/preload/api, core/main/ipc, one channel table). Where the two apps legitimately differ, the core asks its host (core/renderer/host): per-host defaults, whether there is a host style to follow, who paints the ground, which chords the app owns. The core carries Prism's fuller versions where this app had deleted a capability (cdTerm, decideFollow, cdCommand, follow-the-host-style) and Prism's newer termPaste, so Prism loses nothing by adopting it. Relative imports only inside core/, and it never reads window.prism. No behaviour change here: 360 unit tests and all 13 e2e scenarios pass. Co-Authored-By: Claude Fable 5.1 --- {src => core}/main/agentDetect.test.ts | 0 {src => core}/main/agentDetect.ts | 0 {src => core}/main/agentPoll.test.ts | 0 {src => core}/main/agentPoll.ts | 2 +- core/main/agentResume.test.ts | 39 +++++ {src => core}/main/agentResume.ts | 43 ------ core/main/ipc.ts | 136 ++++++++++++++++++ {src => core}/main/shells.test.ts | 0 {src => core}/main/shells.ts | 2 +- {src => core}/main/termPrompt.test.ts | 0 {src => core}/main/termPrompt.ts | 0 {src => core}/main/terminal.test.ts | 0 {src => core}/main/terminal.ts | 26 +++- core/package.json | 36 +++++ core/preload/api.ts | 86 +++++++++++ .../renderer}/components/TermFind.tsx | 0 .../renderer}/components/TerminalPanel.tsx | 99 ++++++------- core/renderer/host.ts | 126 ++++++++++++++++ .../renderer}/lib/agentClock.test.ts | 0 .../src => core/renderer}/lib/agentClock.ts | 0 .../renderer}/lib/agentTitle.test.ts | 0 .../src => core/renderer}/lib/agentTitle.ts | 0 .../renderer}/lib/recentRoots.pins.test.ts | 0 .../renderer}/lib/recentRoots.test.ts | 0 .../src => core/renderer}/lib/recentRoots.ts | 0 .../renderer}/lib/termActivity.born.test.ts | 0 .../renderer}/lib/termActivity.test.ts | 0 .../src => core/renderer}/lib/termActivity.ts | 0 .../renderer}/lib/termAnsi.test.ts | 0 .../src => core/renderer}/lib/termAnsi.ts | 0 .../src => core/renderer}/lib/termBus.ts | 0 .../renderer}/lib/termLinkPaint.ts | 0 .../renderer}/lib/termLinks.test.ts | 0 .../src => core/renderer}/lib/termLinks.ts | 0 .../renderer}/lib/termLook.test.ts | 0 .../src => core/renderer}/lib/termLook.ts | 49 +++++-- .../renderer}/lib/termPaste.test.ts | 6 + .../src => core/renderer}/lib/termPaste.ts | 6 +- .../renderer}/lib/termPrefs.test.ts | 0 .../src => core/renderer}/lib/termPrefs.ts | 0 .../renderer}/lib/termTheme.legible.test.ts | 0 .../renderer}/lib/termTheme.test.ts | 0 .../src => core/renderer}/lib/termTheme.ts | 55 ++++++- .../renderer}/lib/useAgentIndicator.ts | 7 +- core/shared/channels.ts | 19 +++ core/shared/termCwd.test.ts | 46 ++++++ core/shared/termCwd.ts | 57 ++++++++ core/shared/types.ts | 18 +++ electron.vite.config.ts | 7 +- src/main/index.ts | 88 +++--------- ...gentResume.test.ts => planRestore.test.ts} | 40 +----- src/main/planRestore.ts | 44 ++++++ src/preload/index.ts | 38 +---- src/renderer/src/App.tsx | 22 +-- src/renderer/src/components/EmptyState.tsx | 2 +- src/renderer/src/components/Settings.tsx | 24 ++-- src/renderer/src/components/TabStrip.tsx | 6 +- src/renderer/src/lib/agentColors.test.ts | 4 +- src/renderer/src/lib/agentColors.ts | 6 +- src/renderer/src/lib/chromeTheme.test.ts | 4 +- src/renderer/src/lib/chromeTheme.ts | 4 +- src/renderer/src/main.tsx | 4 + src/renderer/src/termHost.ts | 40 ++++++ src/shared/termCwd.test.ts | 17 --- src/shared/termCwd.ts | 29 ---- src/shared/types.ts | 12 +- tsconfig.node.json | 22 ++- tsconfig.web.json | 22 ++- vitest.config.ts | 5 +- 69 files changed, 937 insertions(+), 361 deletions(-) rename {src => core}/main/agentDetect.test.ts (100%) rename {src => core}/main/agentDetect.ts (100%) rename {src => core}/main/agentPoll.test.ts (100%) rename {src => core}/main/agentPoll.ts (99%) create mode 100644 core/main/agentResume.test.ts rename {src => core}/main/agentResume.ts (53%) create mode 100644 core/main/ipc.ts rename {src => core}/main/shells.test.ts (100%) rename {src => core}/main/shells.ts (98%) rename {src => core}/main/termPrompt.test.ts (100%) rename {src => core}/main/termPrompt.ts (100%) rename {src => core}/main/terminal.test.ts (100%) rename {src => core}/main/terminal.ts (92%) create mode 100644 core/package.json create mode 100644 core/preload/api.ts rename {src/renderer/src => core/renderer}/components/TermFind.tsx (100%) rename {src/renderer/src => core/renderer}/components/TerminalPanel.tsx (88%) create mode 100644 core/renderer/host.ts rename {src/renderer/src => core/renderer}/lib/agentClock.test.ts (100%) rename {src/renderer/src => core/renderer}/lib/agentClock.ts (100%) rename {src/renderer/src => core/renderer}/lib/agentTitle.test.ts (100%) rename {src/renderer/src => core/renderer}/lib/agentTitle.ts (100%) rename {src/renderer/src => core/renderer}/lib/recentRoots.pins.test.ts (100%) rename {src/renderer/src => core/renderer}/lib/recentRoots.test.ts (100%) rename {src/renderer/src => core/renderer}/lib/recentRoots.ts (100%) rename {src/renderer/src => core/renderer}/lib/termActivity.born.test.ts (100%) rename {src/renderer/src => core/renderer}/lib/termActivity.test.ts (100%) rename {src/renderer/src => core/renderer}/lib/termActivity.ts (100%) rename {src/renderer/src => core/renderer}/lib/termAnsi.test.ts (100%) rename {src/renderer/src => core/renderer}/lib/termAnsi.ts (100%) rename {src/renderer/src => core/renderer}/lib/termBus.ts (100%) rename {src/renderer/src => core/renderer}/lib/termLinkPaint.ts (100%) rename {src/renderer/src => core/renderer}/lib/termLinks.test.ts (100%) rename {src/renderer/src => core/renderer}/lib/termLinks.ts (100%) rename {src/renderer/src => core/renderer}/lib/termLook.test.ts (100%) rename {src/renderer/src => core/renderer}/lib/termLook.ts (89%) rename {src/renderer/src => core/renderer}/lib/termPaste.test.ts (86%) rename {src/renderer/src => core/renderer}/lib/termPaste.ts (90%) rename {src/renderer/src => core/renderer}/lib/termPrefs.test.ts (100%) rename {src/renderer/src => core/renderer}/lib/termPrefs.ts (100%) rename {src/renderer/src => core/renderer}/lib/termTheme.legible.test.ts (100%) rename {src/renderer/src => core/renderer}/lib/termTheme.test.ts (100%) rename {src/renderer/src => core/renderer}/lib/termTheme.ts (88%) rename {src/renderer/src => core/renderer}/lib/useAgentIndicator.ts (97%) create mode 100644 core/shared/channels.ts create mode 100644 core/shared/termCwd.test.ts create mode 100644 core/shared/termCwd.ts create mode 100644 core/shared/types.ts rename src/main/{agentResume.test.ts => planRestore.test.ts} (59%) create mode 100644 src/main/planRestore.ts create mode 100644 src/renderer/src/termHost.ts delete mode 100644 src/shared/termCwd.test.ts delete mode 100644 src/shared/termCwd.ts diff --git a/src/main/agentDetect.test.ts b/core/main/agentDetect.test.ts similarity index 100% rename from src/main/agentDetect.test.ts rename to core/main/agentDetect.test.ts diff --git a/src/main/agentDetect.ts b/core/main/agentDetect.ts similarity index 100% rename from src/main/agentDetect.ts rename to core/main/agentDetect.ts diff --git a/src/main/agentPoll.test.ts b/core/main/agentPoll.test.ts similarity index 100% rename from src/main/agentPoll.test.ts rename to core/main/agentPoll.test.ts diff --git a/src/main/agentPoll.ts b/core/main/agentPoll.ts similarity index 99% rename from src/main/agentPoll.ts rename to core/main/agentPoll.ts index 998867f..be98871 100644 --- a/src/main/agentPoll.ts +++ b/core/main/agentPoll.ts @@ -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' diff --git a/core/main/agentResume.test.ts b/core/main/agentResume.test.ts new file mode 100644 index 0000000..15a3d7b --- /dev/null +++ b/core/main/agentResume.test.ts @@ -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([]) + }) +}) diff --git a/src/main/agentResume.ts b/core/main/agentResume.ts similarity index 53% rename from src/main/agentResume.ts rename to core/main/agentResume.ts index 8f68336..4000365 100644 --- a/src/main/agentResume.ts +++ b/core/main/agentResume.ts @@ -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, @@ -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() - 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)) } -} diff --git a/core/main/ipc.ts b/core/main/ipc.ts new file mode 100644 index 0000000..e71fc30 --- /dev/null +++ b/core/main/ipc.ts @@ -0,0 +1,136 @@ +import { CH } from '../shared/channels' +import { validResume } from './agentResume' +import { pollAgentsSoon, startAgentPoll } from './agentPoll' +import { detectShells } from './shells' +import { cdTerm, killTerm, prewarmShell, resizeTerm, spawnTerm, writeTerm } from './terminal' + +/** + * The main half of the terminal's bridge, for both hosts. + * + * Everything that is the same in Prism and Prism Terminal is registered here: + * the channels, the argument checks, the resume shape check, the agent poll. + * What differs is WHERE A SHELL MAY START, and that is the host's to say: + * Prism has a wall (a shell starts inside a folder a tab has open, or not at + * all), Prism Terminal has none (a folder that has gone falls back to the + * user's own). So the host hands in three small answers and keeps its policy. + * + * Like the rest of core/main this imports nothing from `electron`: the host + * passes `ipcMain`, its clipboard and its opener, so the core never decides + * how a host loads Electron and stays testable without it. + */ + +/* The slice of Electron's `ipcMain` this needs. Its listener types are + * written against Electron's own event classes, which the core does not + * import, so the arguments are typed at each handler instead. */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +interface IpcMainLike { + handle(channel: string, listener: (event: any, ...args: any[]) => unknown): unknown + on(channel: string, listener: (event: any, ...args: any[]) => void): unknown +} +/* eslint-enable @typescript-eslint/no-explicit-any */ + +interface ClipboardLike { + availableFormats(): string[] + readBuffer(format: string): Buffer + readText(): string +} + +export interface TermIpcDeps { + ipcMain: IpcMainLike + /** To the window that holds the terminals; a no-op while there is none. */ + send(channel: string, ...args: unknown[]): void + clipboard: ClipboardLike + /** Already restricted to http(s) by the caller of this function's contract: + * this module checks the scheme again before calling it. */ + openExternal(url: string): void + /** + * The folder a shell asked to start in, as the host allows it: the folder + * itself, another folder to use instead, or null to refuse the spawn. + */ + spawnDir(cwd: string): Promise + /** May a shell be warmed in this folder ahead of the click? */ + mayPrewarm(cwd: string): Promise + /** 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 +} + +/** Registers every terminal channel and starts the agent poll. Returns the + * poll's stop function. */ +export function registerTermIpc(deps: TermIpcDeps): () => void { + const { ipcMain, send } = deps + const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined) + + ipcMain.handle(CH.shells, () => detectShells()) + + ipcMain.handle(CH.spawn, async (_e: unknown, id: unknown, cwd: unknown, shellId: unknown, resume: unknown) => { + if (typeof id !== 'string' || !id || typeof cwd !== 'string') return false + const dir = await deps.spawnDir(cwd) + if (!dir) return false + // The resume id came from main's own scan of the agent's session files, + // but it crossed the renderer on the way back: shape-check it again before + // it goes anywhere near a command line. + const ok = await spawnTerm(id, dir, str(shellId), send, validResume(str(resume))) + // Warm the agent-poll pipeline now: the first process query is the slow + // one, and running it while the user is still typing their first command + // means the mark can appear on the poll that actually matters. + if (ok) pollAgentsSoon() + return ok + }) + + ipcMain.on(CH.input, (_e: unknown, id: unknown, data: unknown) => { + if (typeof id === 'string' && typeof data === 'string') writeTerm(id, data) + }) + + ipcMain.on(CH.resize, (_e: unknown, id: unknown, cols: unknown, rows: unknown) => { + if (typeof id === 'string' && typeof cols === 'number' && typeof rows === 'number') + resizeTerm(id, cols, rows) + }) + + ipcMain.on(CH.kill, (_e: unknown, id: unknown) => { + if (typeof id === 'string') killTerm(id) + }) + + ipcMain.on(CH.prewarm, (_e: unknown, cwd: unknown, shellId: unknown) => { + if (typeof cwd !== 'string') return + void deps.mayPrewarm(cwd).then((ok) => { + if (ok) void prewarmShell(cwd, str(shellId)) + }) + }) + + // The host rerooted and its shell should follow. Main composes the line; + // the renderer decided whether now was a safe moment to write it. + ipcMain.on(CH.cd, (_e: unknown, id: unknown, path: unknown) => { + if (typeof id === 'string' && typeof path === 'string' && deps.mayCd(path)) cdTerm(id, path) + }) + + // Synchronous, because the key handler that asks has to be. Copied FILES + // arrive as a FileNameW buffer: a NUL-separated UTF-16 list. + ipcMain.on(CH.clipboardRead, (e: { returnValue: unknown }) => { + try { + const formats = deps.clipboard.availableFormats() + const files = formats.includes('FileNameW') + ? deps.clipboard + .readBuffer('FileNameW') + .toString('ucs2') + .replace(/\0+$/, '') + .split('\0') + .filter(Boolean) + : [] + e.returnValue = { + image: formats.some((f) => f.startsWith('image/')), + text: deps.clipboard.readText(), + files + } + } catch { + e.returnValue = { image: false, text: '', files: [] } + } + }) + + // 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) + }) + + return startAgentPoll((id, has, kind) => send(CH.agent, id, has, kind)) +} diff --git a/src/main/shells.test.ts b/core/main/shells.test.ts similarity index 100% rename from src/main/shells.test.ts rename to core/main/shells.test.ts diff --git a/src/main/shells.ts b/core/main/shells.ts similarity index 98% rename from src/main/shells.ts rename to core/main/shells.ts index 955acb1..de43068 100644 --- a/src/main/shells.ts +++ b/core/main/shells.ts @@ -1,5 +1,5 @@ import { execFile } from 'child_process' -import type { ShellDef } from '@shared/types' +import type { ShellDef } from '../shared/types' import { PS_FILE_STYLE, PS_PROMPT_HOOK } from './termPrompt' // The shells this machine actually has. Prism never execs a renderer-supplied diff --git a/src/main/termPrompt.test.ts b/core/main/termPrompt.test.ts similarity index 100% rename from src/main/termPrompt.test.ts rename to core/main/termPrompt.test.ts diff --git a/src/main/termPrompt.ts b/core/main/termPrompt.ts similarity index 100% rename from src/main/termPrompt.ts rename to core/main/termPrompt.ts diff --git a/src/main/terminal.test.ts b/core/main/terminal.test.ts similarity index 100% rename from src/main/terminal.test.ts rename to core/main/terminal.test.ts diff --git a/src/main/terminal.ts b/core/main/terminal.ts similarity index 92% rename from src/main/terminal.ts rename to core/main/terminal.ts index 2e13217..f5dee6f 100644 --- a/src/main/terminal.ts +++ b/core/main/terminal.ts @@ -1,4 +1,5 @@ import type { IPty } from 'node-pty' +import { cdCommand } from '../shared/termCwd' import { detectShells, shellById } from './shells' import { cmdPrompt } from './termPrompt' @@ -62,6 +63,8 @@ interface Session { /** The onData/onExit subscriptions, disposed BEFORE the pty is killed so no * callback can fire into a session that is being torn down. */ subs: Array<{ dispose(): void }> + /** Which shell this is, for the one line Prism may write into it (#99). */ + defId: string } /** @@ -136,6 +139,23 @@ export function ptyEnv(from: NodeJS.ProcessEnv, shellId?: string): Record() /** @@ -224,7 +244,7 @@ type Send = (channel: string, ...args: unknown[]) => void /** * Spawn a shell for `id`, cwd at `root`. Refuses a live id (the renderer asked * twice; the first shell wins). node-pty is imported here rather than at module - * top so the window's launch path never touches the native module. + * top so the resident window's launch path never touches the native module. */ /** * A restored Claude session rides the shell's OWN startup command, so nothing @@ -277,7 +297,7 @@ export async function spawnTerm( send('term:exit', id) }) ] - sessions.set(id, { pty: w.pty, batcher, subs }) + sessions.set(id, { pty: w.pty, batcher, subs, defId: w.defId }) const want = desiredSize.get(id) if (want) resizeTerm(id, want.cols, want.rows) return true @@ -305,7 +325,7 @@ export async function spawnTerm( send('term:exit', id) }) ] - sessions.set(id, { pty: p, batcher, subs }) + sessions.set(id, { pty: p, batcher, subs, defId: def.id }) return true } catch { return false // shell missing or ConPTY refused; the renderer shows the line diff --git a/core/package.json b/core/package.json new file mode 100644 index 0000000..ea13600 --- /dev/null +++ b/core/package.json @@ -0,0 +1,36 @@ +{ + "name": "prism-term-core", + "version": "0.1.0", + "description": "The terminal shared by Prism Terminal and Prism: pty, shells, agent detection and indicator, themes, links, the panel. TypeScript source, compiled by the host.", + "license": "MIT", + "private": true, + "sideEffects": [ + "./renderer/components/TerminalPanel.tsx", + "**/*.css" + ], + "exports": { + "./main/*": "./main/*.ts", + "./preload/*": "./preload/*.ts", + "./shared/*": "./shared/*.ts", + "./renderer/host": "./renderer/host.ts", + "./renderer/lib/*": "./renderer/lib/*.ts", + "./renderer/components/*": "./renderer/components/*.tsx" + }, + "files": [ + "main", + "preload", + "shared", + "renderer", + "!**/*.test.ts", + "README.md" + ], + "peerDependencies": { + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-search": "^0.16.0", + "@xterm/addon-unicode11": "^0.9.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/xterm": "^6.0.0", + "node-pty": "^1.1.0", + "react": "^19.2.7" + } +} diff --git a/core/preload/api.ts b/core/preload/api.ts new file mode 100644 index 0000000..cea3106 --- /dev/null +++ b/core/preload/api.ts @@ -0,0 +1,86 @@ +import type { DetectedAgent, ShellDef } from '../shared/types' +import { CH } from '../shared/channels' + +/** + * The preload half of the terminal's bridge, for both hosts. + * + * A host's preload calls this with its own `ipcRenderer` and spreads the + * result into whatever it exposes, so the member names, the signatures and the + * channel names exist in one place. It takes `ipcRenderer` as an argument and + * imports nothing from `electron` itself: a sandboxed preload is bundled, and + * the core must not decide how a host loads Electron. + */ + +/** The slice of Electron's `ipcRenderer` this needs. */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +export interface IpcRendererLike { + invoke(channel: string, ...args: any[]): Promise + send(channel: string, ...args: any[]): void + sendSync(channel: string, ...args: any[]): any + on(channel: string, listener: (event: any, ...args: any[]) => void): unknown + removeListener(channel: string, listener: (event: any, ...args: any[]) => void): unknown +} +/* eslint-enable @typescript-eslint/no-explicit-any */ + +export interface ClipboardRead { + image: boolean + text: string + files: string[] +} + +export interface TermPreloadApi { + /** The shells main detected; the only things term:spawn will ever launch. */ + termShells(): Promise + /** `resume` is what the host's restore handed back for this shell, untouched. */ + termSpawn(id: string, cwd: string, shellId?: string, resume?: string): Promise + termInput(id: string, data: string): void + termResize(id: string, cols: number, rows: number): void + termKill(id: string): void + /** Start a shell in `cwd` ahead of the click. Best-effort. */ + termPrewarm(cwd: string, shellId?: string): void + /** Move a shell to a folder it should follow (Prism's #99); main writes the line. */ + termCd(id: string, path: string): void + onTermData(cb: (id: string, data: string) => void): () => void + /** An AI CLI (Claude Code, codex...) appeared or left a session's shell. */ + onTermAgent(cb: (id: string, has: boolean, kind: DetectedAgent | null) => void): () => void + /** The shell ended by itself (`exit`); a shell main was told to kill says nothing. */ + onTermExit(cb: (id: string) => void): () => void + /** + * What the clipboard holds RIGHT NOW, for the terminal's paste rule. An + * image forwards the ^V key (a clipboard-aware TUI like Claude Code reads + * the image itself); text becomes a bracketed paste; copied files paste as + * quoted paths. The decision itself is pure and lives in lib/termPaste. + * Synchronous, as the key handler that calls it has to be; main does the + * reading, since a sandboxed preload has no clipboard module. + */ + readClipboard(): ClipboardRead + /** The web-links addon's click-through: external URLs go to the OS browser. */ + openExternal(url: string): void +} + +export function createTermApi(ipc: IpcRendererLike): TermPreloadApi { + const on = (channel: string, cb: (...args: A) => void): (() => void) => { + const listener = (_e: unknown, ...args: unknown[]): void => cb(...(args as A)) + ipc.on(channel, listener) + return () => { + ipc.removeListener(channel, listener) + } + } + return { + termShells: () => ipc.invoke(CH.shells) as Promise, + termSpawn: (id, cwd, shellId, resume) => + ipc.invoke(CH.spawn, id, cwd, shellId, resume) as Promise, + termInput: (id, data) => ipc.send(CH.input, id, data), + termResize: (id, cols, rows) => ipc.send(CH.resize, id, cols, rows), + termKill: (id) => ipc.send(CH.kill, id), + termPrewarm: (cwd, shellId) => ipc.send(CH.prewarm, cwd, shellId), + termCd: (id, path) => ipc.send(CH.cd, id, path), + onTermData: (cb) => on(CH.data, cb), + onTermAgent: (cb) => on(CH.agent, cb), + onTermExit: (cb) => on(CH.exit, cb), + readClipboard: () => ipc.sendSync(CH.clipboardRead) as ClipboardRead, + openExternal: (url) => { + if (/^https?:/i.test(url)) ipc.send(CH.openExternal, url) + } + } +} diff --git a/src/renderer/src/components/TermFind.tsx b/core/renderer/components/TermFind.tsx similarity index 100% rename from src/renderer/src/components/TermFind.tsx rename to core/renderer/components/TermFind.tsx diff --git a/src/renderer/src/components/TerminalPanel.tsx b/core/renderer/components/TerminalPanel.tsx similarity index 88% rename from src/renderer/src/components/TerminalPanel.tsx rename to core/renderer/components/TerminalPanel.tsx index 4fef1f6..7e4a3f9 100644 --- a/src/renderer/src/components/TerminalPanel.tsx +++ b/core/renderer/components/TerminalPanel.tsx @@ -6,14 +6,16 @@ import { WebLinksAddon } from '@xterm/addon-web-links' import { SearchAddon } from '@xterm/addon-search' import { decidePaste } from '../lib/termPaste' import { registerPaste, reportCwd, reportTitle } from '../lib/termBus' -import { parseOsc9 } from '@shared/termCwd' -import { resolveTermTheme } from '../lib/termTheme' +import { parseOsc9 } from '../../shared/termCwd' +import { resolveTermTheme, watchTermTheme } from '../lib/termTheme' +import { followsHostStyle, paintsGround, termApi, termHost } from '../host' import { normalizeColor } from '../lib/termAnsi' import { linkColor } from '../lib/termLinks' import { attachLinkPaint, type LinkPainter } from '../lib/termLinkPaint' import { onTermLookChange, termBaseFontPx, + termAcrylic, termFontStack, termThemeId } from '../lib/termLook' @@ -48,8 +50,16 @@ interface Session { const sessions = new Map() +/** What a link wears on the theme in force: blue, moved as far as this + * ground (a preset's, or one the user picked) needs for it to read. */ +function currentLinkColor(): string { + const theme = resolveTermTheme(termThemeId()) + return linkColor(theme.background, theme.foreground) +} + /** - * The theme as painted: the theme's colours on a TRANSPARENT canvas. + * The theme as painted. Where the PANEL paints the ground (`paintsGround`, + * the host's word), it is the theme's colours on a TRANSPARENT canvas: * * THE PANEL PAINTS THE GROUND, NOT XTERM (2026-09-19, owner screenshot: a grey * bar along the bottom of a black terminal). xterm sizes itself in whole rows, @@ -67,15 +77,14 @@ const sessions = new Map() * defaults to the background; on a clear background that would be a hole, so * it is named. */ -/** What a link wears on the theme in force: blue, moved as far as this - * ground (a preset's, or one the user picked) needs for it to read. */ -function currentLinkColor(): string { - const theme = resolveTermTheme(termThemeId()) - return linkColor(theme.background, theme.foreground) -} - -function currentTermTheme(): ReturnType & { cursorAccent: string } { +function currentTermTheme(): ReturnType & { cursorAccent?: string } { const theme = resolveTermTheme(termThemeId()) + if (!paintsGround()) { + // The HOST paints behind the panel (Prism's dock does), so the canvas + // carries the ground as it always did there: clear only when the terminal + // is following an acrylic style, so the window's material shows through. + return termThemeId() === 'style' && termAcrylic() ? { ...theme, background: '#00000000' } : theme + } // Flattened first: a custom theme may hold #rgb or an rgba(). const solid = normalizeColor(theme.background, '#0b0b0f') return { ...theme, background: '#00000000', cursorAccent: solid } @@ -150,7 +159,7 @@ function refitSession(id: string, s: Session): void { if (!s.el.clientWidth || !s.el.clientHeight) return suppressActivity(id) fitKeepingCursorLine(s.term, s.fit) - window.prism.termResize(id, s.term.cols, s.term.rows) + termApi().termResize(id, s.term.cols, s.term.rows) } // One restyler for every running shell, driven by the Terminal settings @@ -172,6 +181,13 @@ function applyLook(): void { } } onTermLookChange(applyLook) +// A host with styles of its own repaints :root when the style changes, and a +// terminal following it has to follow that too. Armed lazily: the host has +// not been configured yet when this module is first evaluated. +let styleWatch: (() => void) | null = null +function watchHostStyle(): void { + if (!styleWatch && followsHostStyle()) styleWatch = watchTermTheme(() => applyLook()) +} /** Ctrl+scroll: zoom THIS session's text, unpersisted. */ function zoomSession(id: string, delta: number): void { @@ -292,6 +308,7 @@ export function onTermFindResults( } function createSession(id: string, root: string, shellId: string | undefined): Session { + watchHostStyle() const term = new Terminal({ cursorBlink: true, scrollback: 10000, @@ -322,7 +339,7 @@ function createSession(id: string, root: string, shellId: string | undefined): S // a terminal that didn't bother. term.loadAddon(new Unicode11Addon()) term.unicode.activeVersion = '11' - term.loadAddon(new WebLinksAddon((_e, url) => window.prism.openExternal(url))) + 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) @@ -341,7 +358,7 @@ function createSession(id: string, root: string, shellId: string | undefined): S term.onKey(() => markTouched(id)) term.onData((d) => { if (looksTyped(d)) markTouched(id) - window.prism.termInput(id, d) + termApi().termInput(id, d) }) // The prompt's own report of where the shell is (#99): OSC 9;9, the // Windows Terminal convention, printed by the hook main put in the @@ -389,10 +406,10 @@ function createSession(id: string, root: string, shellId: string | undefined): S * growing a second, wrong copy of it. */ const pasteHere = (): void => { - const decision = decidePaste(window.prism.readClipboard()) + const decision = decidePaste(termApi().readClipboard()) if (decision.kind === 'key') { markTouched(id) - window.prism.termInput(id, '') + termApi().termInput(id, '') } else if (decision.kind === 'text') { markTouched(id) // a paste is typing; bracketed, it starts with ESC term.paste(decision.data) @@ -400,7 +417,7 @@ function createSession(id: string, root: string, shellId: string | undefined): S } const unsub = [ - window.prism.onTermData((forId, data) => { + termApi().onTermData((forId, data) => { if (forId === id) { stopSpin() term.write(data) @@ -430,34 +447,19 @@ function createSession(id: string, root: string, shellId: string | undefined): S term.clearSelection() return false } - // Find in the scrollback. Explicitly, and BEFORE the block below, whose - // regex is case-insensitive and ignores shift: widening it to include f - // would cost the shell plain Ctrl+F as well. - if ((e.key === 'f' || e.key === 'F') && e.ctrlKey && e.shiftKey && !e.altKey) return false - // The app's tab-management keys work over a focused shell (no shell uses - // them): returning false keeps xterm from also feeding bytes to the pty. - // App's window listener does the actual work. Ctrl+, is Settings. - if ( - e.ctrlKey && - !e.altKey && - (e.key === 'Tab' || /^[1-9]$/.test(e.key) || e.key === ',') - ) { - return false - } - // New tab is Ctrl+T (owner, 2026-09-18), with or without shift, as it is - // in Prism and in every browser. That takes the chord from whatever runs - // in the shell (Claude Code uses Ctrl+T for its task list), which is the - // owner's trade. Close tab stays Ctrl+SHIFT+W: plain Ctrl+W deletes a word - // in every readline, and yielding it would swallow a key people type all - // day. The shift is tested for the same reason it is for find above. - if (e.ctrlKey && !e.altKey && (e.key === 't' || e.key === 'T')) return false - if (e.ctrlKey && e.shiftKey && !e.altKey && (e.key === 'w' || e.key === 'W')) return false + // THE HOST'S CHORDS (core/renderer/host). Which keys belong to the app + // even over a focused shell is the one thing the two apps disagree about + // (Prism closes a tab on Ctrl+W and toggles the panel on Ctrl+`; Prism + // Terminal leaves Ctrl+W to the shell as delete-word), so the host says. + // Returning false keeps xterm from ALSO feeding the bytes to the pty: left + // to xterm, Ctrl+` became a NUL, which counted as the user typing. + if (termHost().ownsKey(e)) return false if (e.key === 'Enter' && e.shiftKey) { // Newline-without-submit, the continuation form Claude Code accepts // everywhere. This is what /terminal-setup exists to configure; here it // simply works. markTouched(id) // input like any other: its repaint is echo, not work - window.prism.termInput(id, '\\\r') + termApi().termInput(id, '\\\r') return false } if ((e.key === 'v' || e.key === 'V') && e.ctrlKey && !e.shiftKey) { @@ -466,7 +468,7 @@ function createSession(id: string, root: string, shellId: string | undefined): S } if ((e.key === 'v' || e.key === 'V') && e.ctrlKey && e.shiftKey) { // The escape hatch: plain text paste even when an image rides along. - const clip = window.prism.readClipboard() + const clip = termApi().readClipboard() if (clip.text) { markTouched(id) term.paste(clip.text) @@ -482,14 +484,14 @@ function createSession(id: string, root: string, shellId: string | undefined): S // A session restored over a Claude conversation launches straight into it: // the resume id rides the SPAWN (main builds it into the shell's startup // command), so nothing is ever visibly typed. - void window.prism.termSpawn(id, root, shellId, resume ?? undefined).then((ok) => { + void termApi().termSpawn(id, root, shellId, resume ?? undefined).then((ok) => { if (!ok && sessions.has(id)) { term.write('\x1b[31mCould not start the shell.\x1b[0m\r\n') return } // The spawn is done: re-assert the real size once. The first fit can race // a slow spawn, and a static window will never resize on its own. - if (sessions.has(id)) window.prism.termResize(id, term.cols, term.rows) + if (sessions.has(id)) termApi().termResize(id, term.cols, term.rows) }) return session } @@ -523,7 +525,7 @@ export default function TerminalPanel({ // The redraw this resize provokes is us, not the shell working. suppressActivity(sessionId) fitKeepingCursorLine(s.term, s.fit) - window.prism.termResize(sessionId, s.term.cols, s.term.rows) + termApi().termResize(sessionId, s.term.cols, s.term.rows) } refit() const ro = new ResizeObserver(refit) @@ -551,14 +553,15 @@ export default function TerminalPanel({ } }, [sessionId, root, shellId]) - // The panel paints the ground and the canvas over it is clear (see - // currentTermTheme): the 4px frame, the rows and the strip under the last - // row are one surface in one coat. + // Where the panel paints the ground, the 4px frame, the rows and the strip + // under the last row are one surface in one coat (see currentTermTheme). + // Where the host paints behind it, the panel adds nothing: a second + // translucent coat is a visibly darker panel than the rest of the window. return (
) } diff --git a/core/renderer/host.ts b/core/renderer/host.ts new file mode 100644 index 0000000..fa0ecb7 --- /dev/null +++ b/core/renderer/host.ts @@ -0,0 +1,126 @@ +import type { DetectedAgent, ShellDef } from '../shared/types' + +/** + * THE HOST SEAM (2026-09-19, #15). + * + * This core is the terminal of TWO apps: Prism Terminal, where it is the whole + * product, and Prism, where it is a panel inside a media viewer. The owner's + * rule is that the two terminals are the same ("all should be synced, unless + * it conflicts with one app, then you need to ask me"). So everything that IS + * the terminal lives in this core, once, and every place the two apps + * legitimately differ is DECLARED here as a setting the host supplies. A + * difference that is not on this page is a fork, and a fork is what this + * exists to end. + * + * Nothing in the core reads `window.prism`, imports from a host's `src/`, or + * uses a path alias (`@shared`, `@renderer`): a consumer's bundler resolves an + * alias against ITS OWN tree, MEASURED, silently, so the core would run the + * host's copy of a file with no error anywhere. Relative imports only, and + * the bridge to main is handed in, never reached for. + */ + +/** What the clipboard holds right now, for the paste rule. */ +export interface ClipboardRead { + image: boolean + text: string + files: string[] +} + +/** The bridge to the main process that the terminal needs. A host's preload + * builds it with `createTermApi` (core/preload/api), so the channel names and + * signatures exist in one place. */ +export interface TermApi { + termShells(): Promise + termSpawn(id: string, cwd: string, shellId?: string, resume?: string): Promise + termInput(id: string, data: string): void + termResize(id: string, cols: number, rows: number): void + termKill(id: string): void + termPrewarm(cwd: string, shellId?: string): void + onTermData(cb: (id: string, data: string) => void): () => void + onTermAgent(cb: (id: string, has: boolean, kind: DetectedAgent | null) => void): () => void + onTermExit(cb: (id: string) => void): () => void + readClipboard(): ClipboardRead + openExternal(url: string): void +} + +export type AgentIndicator = 'off' | 'minimal' | 'full' + +export interface TermHostConfig { + api: TermApi + + /** + * What a setting reads as when the user has never touched it. Per host on + * purpose: an update must not silently change what an existing user sees, + * and the two apps shipped different answers. Where the owner picks ONE + * value for both, both hosts pass it and the difference is gone. + */ + defaults: { + /** A preset id, or 'style' where the host has styles to follow. */ + theme: string + acrylic: boolean + indicator: AgentIndicator + /** A hex, or '' for "follow the theme" (see `accent`). */ + agentColor: string + agentDoneColor: string + } + + /** + * Does the terminal wear the HOST's style? Prism has app styles and publishes + * them as `--p-bg` / `--p-text` / `--p-accent-hi` on :root, so its terminal + * can follow them (theme id 'style') and restyle when they change. Prism + * Terminal has no styles of its own: there the terminal theme drives the + * window, the other way round, so 'style' does not exist. + */ + followsHostStyle: boolean + + /** + * Does the panel paint the ground? True: the panel box paints `--p-bg` and + * xterm's canvas is clear, so the strip under the last row (xterm sizes + * itself in whole rows) is the theme's and every pixel gets one coat. False: + * the host paints behind the panel itself (Prism's dock does), and the + * canvas carries the ground as it always did there. + */ + paintsGround: boolean + + /** + * A chord the HOST owns even while a shell has the keyboard. xterm must not + * also feed it to the pty: left to xterm, Ctrl+` became a NUL byte, which + * counted as the user typing. Everything the host does not claim is the + * shell's, so claim little: plain Ctrl+W is delete-word in every readline. + */ + ownsKey(e: KeyboardEvent): boolean +} + +let host: TermHostConfig | null = null + +/** Called once by the host, before anything in the core renders. */ +export function configureTermCore(config: TermHostConfig): void { + host = config +} + +export function termHost(): TermHostConfig { + if (!host) throw new Error('prism-term-core: configureTermCore() has not been called by the host') + return host +} + +/** The bridge to main. */ +export const termApi = (): TermApi => termHost().api + +/** What an unset setting reads as when NO host has spoken: a unit test, or a + * store read at import time. Prism Terminal's own answers. */ +const NEUTRAL: TermHostConfig['defaults'] = { + theme: 'prism', + acrylic: false, + indicator: 'minimal', + agentColor: '', + agentDoneColor: '' +} + +export const hostDefaults = (): TermHostConfig['defaults'] => host?.defaults ?? NEUTRAL +export const followsHostStyle = (): boolean => host?.followsHostStyle ?? false +export const paintsGround = (): boolean => host?.paintsGround ?? true + +/** For tests, and for a host that tears down. */ +export function resetTermCore(): void { + host = null +} diff --git a/src/renderer/src/lib/agentClock.test.ts b/core/renderer/lib/agentClock.test.ts similarity index 100% rename from src/renderer/src/lib/agentClock.test.ts rename to core/renderer/lib/agentClock.test.ts diff --git a/src/renderer/src/lib/agentClock.ts b/core/renderer/lib/agentClock.ts similarity index 100% rename from src/renderer/src/lib/agentClock.ts rename to core/renderer/lib/agentClock.ts diff --git a/src/renderer/src/lib/agentTitle.test.ts b/core/renderer/lib/agentTitle.test.ts similarity index 100% rename from src/renderer/src/lib/agentTitle.test.ts rename to core/renderer/lib/agentTitle.test.ts diff --git a/src/renderer/src/lib/agentTitle.ts b/core/renderer/lib/agentTitle.ts similarity index 100% rename from src/renderer/src/lib/agentTitle.ts rename to core/renderer/lib/agentTitle.ts diff --git a/src/renderer/src/lib/recentRoots.pins.test.ts b/core/renderer/lib/recentRoots.pins.test.ts similarity index 100% rename from src/renderer/src/lib/recentRoots.pins.test.ts rename to core/renderer/lib/recentRoots.pins.test.ts diff --git a/src/renderer/src/lib/recentRoots.test.ts b/core/renderer/lib/recentRoots.test.ts similarity index 100% rename from src/renderer/src/lib/recentRoots.test.ts rename to core/renderer/lib/recentRoots.test.ts diff --git a/src/renderer/src/lib/recentRoots.ts b/core/renderer/lib/recentRoots.ts similarity index 100% rename from src/renderer/src/lib/recentRoots.ts rename to core/renderer/lib/recentRoots.ts diff --git a/src/renderer/src/lib/termActivity.born.test.ts b/core/renderer/lib/termActivity.born.test.ts similarity index 100% rename from src/renderer/src/lib/termActivity.born.test.ts rename to core/renderer/lib/termActivity.born.test.ts diff --git a/src/renderer/src/lib/termActivity.test.ts b/core/renderer/lib/termActivity.test.ts similarity index 100% rename from src/renderer/src/lib/termActivity.test.ts rename to core/renderer/lib/termActivity.test.ts diff --git a/src/renderer/src/lib/termActivity.ts b/core/renderer/lib/termActivity.ts similarity index 100% rename from src/renderer/src/lib/termActivity.ts rename to core/renderer/lib/termActivity.ts diff --git a/src/renderer/src/lib/termAnsi.test.ts b/core/renderer/lib/termAnsi.test.ts similarity index 100% rename from src/renderer/src/lib/termAnsi.test.ts rename to core/renderer/lib/termAnsi.test.ts diff --git a/src/renderer/src/lib/termAnsi.ts b/core/renderer/lib/termAnsi.ts similarity index 100% rename from src/renderer/src/lib/termAnsi.ts rename to core/renderer/lib/termAnsi.ts diff --git a/src/renderer/src/lib/termBus.ts b/core/renderer/lib/termBus.ts similarity index 100% rename from src/renderer/src/lib/termBus.ts rename to core/renderer/lib/termBus.ts diff --git a/src/renderer/src/lib/termLinkPaint.ts b/core/renderer/lib/termLinkPaint.ts similarity index 100% rename from src/renderer/src/lib/termLinkPaint.ts rename to core/renderer/lib/termLinkPaint.ts diff --git a/src/renderer/src/lib/termLinks.test.ts b/core/renderer/lib/termLinks.test.ts similarity index 100% rename from src/renderer/src/lib/termLinks.test.ts rename to core/renderer/lib/termLinks.test.ts diff --git a/src/renderer/src/lib/termLinks.ts b/core/renderer/lib/termLinks.ts similarity index 100% rename from src/renderer/src/lib/termLinks.ts rename to core/renderer/lib/termLinks.ts diff --git a/src/renderer/src/lib/termLook.test.ts b/core/renderer/lib/termLook.test.ts similarity index 100% rename from src/renderer/src/lib/termLook.test.ts rename to core/renderer/lib/termLook.test.ts diff --git a/src/renderer/src/lib/termLook.ts b/core/renderer/lib/termLook.ts similarity index 89% rename from src/renderer/src/lib/termLook.ts rename to core/renderer/lib/termLook.ts index b6749b0..b215df5 100644 --- a/src/renderer/src/lib/termLook.ts +++ b/core/renderer/lib/termLook.ts @@ -1,4 +1,7 @@ import { useSyncExternalStore } from 'react' +import { followsHostStyle, hostDefaults, type AgentIndicator } from '../host' + +export type { AgentIndicator } // The terminal's persisted look: which theme it wears and its base font size. // Same tiny-store shape as tabPrefs. Per-SESSION font zoom (Ctrl+scroll) is @@ -18,7 +21,11 @@ const notify = (): void => listeners.forEach((l) => l()) * stored 'style' reads as 'prism' rather than as a theme nothing can resolve. */ export function termThemeId(): string { const v = localStorage.getItem(THEME_KEY) - return !v || v === 'style' ? 'prism' : v + // Never touched: the HOST's default ('style' in Prism, a preset here). A + // stored 'style' where the host has no style to follow reads as that + // default too, rather than as a theme nothing can resolve. + if (!v || (v === 'style' && !followsHostStyle())) return hostDefaults().theme + return v } export function setTermThemeId(id: string): void { @@ -85,7 +92,9 @@ const OPACITY_KEY = 'prism.term.opacity' * the follow-style terminal alone and was on by default; here it works with * any theme and is OFF until asked for. */ export function termAcrylic(): boolean { - return localStorage.getItem(ACRYLIC_KEY) === '1' + const v = localStorage.getItem(ACRYLIC_KEY) + // Never touched: the host's default (on in Prism, off in Prism Terminal). + return v === null ? hostDefaults().acrylic : v === '1' } export function setTermAcrylic(on: boolean): void { localStorage.setItem(ACRYLIC_KEY, on ? '1' : '0') @@ -105,7 +114,6 @@ export function setTermOpacity(pct: number): void { const AGENT_IND_KEY = 'prism.term.agentIndicator' -export type AgentIndicator = 'off' | 'minimal' | 'full' /** How a working agent shows on its tab: not at all, a line under the tab, * or the whole tab turning. MINIMAL is the default (owner, 2026-09-18; it was @@ -114,7 +122,7 @@ export type AgentIndicator = 'off' | 'minimal' | 'full' * paints. */ export function agentIndicator(): AgentIndicator { const v = localStorage.getItem(AGENT_IND_KEY) - return v === 'full' || v === 'off' ? v : 'minimal' + return v === 'full' || v === 'minimal' || v === 'off' ? v : hostDefaults().indicator } export function setAgentIndicator(v: AgentIndicator): void { @@ -135,7 +143,7 @@ const HEX = /^#[0-9a-f]{6}$/i */ export function agentColorChoice(): string { const v = localStorage.getItem(AGENT_COLOR_KEY) - return v && HEX.test(v) ? v : '' + return v && HEX.test(v) ? v : hostDefaults().agentColor } /** A hex picks a colour; '' gives the choice back to the theme. */ @@ -149,7 +157,7 @@ export function setAgentColor(hex: string): void { * a BACKGROUND tab wears it until the tab is visited. */ export function agentDoneColorChoice(): string { const v = localStorage.getItem(AGENT_DONE_KEY) - return v && HEX.test(v) ? v : '' + return v && HEX.test(v) ? v : hostDefaults().agentDoneColor } export function setAgentDoneColor(hex: string): void { @@ -178,15 +186,26 @@ export interface CustomTermTheme { /** What every non-colour terminal setting is out of the box. Picking any * theme returns to these; deviating from them is what "Save changes" saves. */ -export const TERM_EXTRA_DEFAULTS = { - font: 'cascadia', - fontPct: 100, - indicator: 'minimal' as AgentIndicator, - // '' = the theme's own (see agentColorChoice). - indicatorColor: '', - doneColor: '', - acrylic: false, - opacity: 100 +export function termExtraDefaults(): { + font: string + fontPct: number + indicator: AgentIndicator + indicatorColor: string + doneColor: string + acrylic: boolean + opacity: number +} { + const d = hostDefaults() + return { + font: 'cascadia', + fontPct: 100, + indicator: d.indicator, + // '' = the theme's own (see agentColorChoice). + indicatorColor: d.agentColor, + doneColor: d.agentDoneColor, + acrylic: d.acrylic, + opacity: 100 + } } /** Selecting a theme overwrites the terminal settings with their defaults: diff --git a/src/renderer/src/lib/termPaste.test.ts b/core/renderer/lib/termPaste.test.ts similarity index 86% rename from src/renderer/src/lib/termPaste.test.ts rename to core/renderer/lib/termPaste.test.ts index 6174ca0..e6ef2a9 100644 --- a/src/renderer/src/lib/termPaste.test.ts +++ b/core/renderer/lib/termPaste.test.ts @@ -19,6 +19,12 @@ describe('decidePaste', () => { data: '"C:\\a b\\s.png"' }) }) + it('an image copied as a file pastes its path despite the document bitmap', () => { + expect(decidePaste({ image: true, text: '', files: ['C:\\a b\\s.png'] })).toEqual({ + kind: 'text', + data: '"C:\\a b\\s.png"' + }) + }) it('copied files beat their own text form (Explorer sets both)', () => { expect(decidePaste({ image: false, text: 's.png', files: ['C:\\x\\s.png'] })).toEqual({ kind: 'text', diff --git a/src/renderer/src/lib/termPaste.ts b/core/renderer/lib/termPaste.ts similarity index 90% rename from src/renderer/src/lib/termPaste.ts rename to core/renderer/lib/termPaste.ts index e818ab4..51342b6 100644 --- a/src/renderer/src/lib/termPaste.ts +++ b/core/renderer/lib/termPaste.ts @@ -23,12 +23,12 @@ export function quotePaths(paths: string[]): string { } export function decidePaste(clip: ClipboardState): PasteDecision { + // A copied image file also offers pixels to document apps. In a terminal, + // its file representation remains the useful one. + if (clip.files.length) return { kind: 'text', data: quotePaths(clip.files) } // The image wins over any text riding along (Word copies both): a screenshot // is why the user pressed Ctrl+V, and Ctrl+Shift+V is the text escape hatch. if (clip.image) return { kind: 'key' } - // Copied files beat their own text form (Explorer sets both): the path is - // the useful half, and it also covers images copied as files. - if (clip.files.length) return { kind: 'text', data: quotePaths(clip.files) } if (clip.text) return { kind: 'text', data: clip.text } return { kind: 'none' } } diff --git a/src/renderer/src/lib/termPrefs.test.ts b/core/renderer/lib/termPrefs.test.ts similarity index 100% rename from src/renderer/src/lib/termPrefs.test.ts rename to core/renderer/lib/termPrefs.test.ts diff --git a/src/renderer/src/lib/termPrefs.ts b/core/renderer/lib/termPrefs.ts similarity index 100% rename from src/renderer/src/lib/termPrefs.ts rename to core/renderer/lib/termPrefs.ts diff --git a/src/renderer/src/lib/termTheme.legible.test.ts b/core/renderer/lib/termTheme.legible.test.ts similarity index 100% rename from src/renderer/src/lib/termTheme.legible.test.ts rename to core/renderer/lib/termTheme.legible.test.ts diff --git a/src/renderer/src/lib/termTheme.test.ts b/core/renderer/lib/termTheme.test.ts similarity index 100% rename from src/renderer/src/lib/termTheme.test.ts rename to core/renderer/lib/termTheme.test.ts diff --git a/src/renderer/src/lib/termTheme.ts b/core/renderer/lib/termTheme.ts similarity index 88% rename from src/renderer/src/lib/termTheme.ts rename to core/renderer/lib/termTheme.ts index dc04239..2feba32 100644 --- a/src/renderer/src/lib/termTheme.ts +++ b/core/renderer/lib/termTheme.ts @@ -1,5 +1,6 @@ -import { deriveAnsi, legiblePalette, type Ansi16 } from './termAnsi' +import { deriveAnsi, legiblePalette, normalizeColor, type Ansi16 } from './termAnsi' import { customTermTheme } from './termLook' +import { followsHostStyle, hostDefaults } from '../host' // The terminal owns its colours. Inside Prism it could also WEAR THE APP STYLE // (read --p-bg / --p-text / --p-accent-hi off :root); Prism Terminal has no app @@ -13,6 +14,54 @@ export interface TermTheme extends Partial { selectionBackground: string } +/** + * FOLLOWING THE HOST'S STYLE (theme id 'style'). Only where the host HAS styles + * (Prism): it publishes its surfaces as CSS custom properties on :root, so the + * terminal reads those instead of owning colours, and a style switch restyles + * running shells. Prism Terminal has no styles to follow; there the terminal + * theme drives the window, and `followsHostStyle` is false. + */ + +/** Pure: style surfaces in, xterm theme out. Fallbacks are the default dark, + * for the moment before the style has painted. */ +export function buildTermTheme(bg: string, text: string, accent: string, flatBg?: string): TermTheme { + const b = bg.trim() || '#0b0b0f' + const t = text.trim() || '#d7dae1' + const a = accent.trim() || '#5b5bd6' + // The ANSI sixteen are DERIVED from the base, not assumed: edit a style's + // background toward red and red text adapts instead of vanishing into it. + // The maths run against the FLAT surface: an acrylic style publishes an + // rgba() background, which is fine to PAINT but not to measure against - + // unparsed it turned every hue pure black. + return { + background: b, + foreground: t, + cursor: a, + selectionBackground: `${a}55`, + ...deriveAnsi(normalizeColor(flatBg?.trim() || b, '#101215'), t) + } +} + +/** What the host's style says, right now. */ +export function readTermTheme(): TermTheme { + const cs = getComputedStyle(document.documentElement) + return buildTermTheme( + cs.getPropertyValue('--p-bg'), + cs.getPropertyValue('--p-text'), + cs.getPropertyValue('--p-accent-hi'), + // The flat twin of --p-bg: guaranteed hex, exists exactly because + // "the contrast maths read it, and neither wants an rgba". + cs.getPropertyValue('--p-side-flat') + ) +} + +/** Call `cb` whenever the host's style repaints :root. Returns the stop function. */ +export function watchTermTheme(cb: (t: TermTheme) => void): () => void { + const mo = new MutationObserver(() => cb(readTermTheme())) + mo.observe(document.documentElement, { attributes: true, attributeFilter: ['style', 'class'] }) + return () => mo.disconnect() +} + /** * Preset terminal themes, whole palettes each. Presets rather than variants * of the app styles on purpose: a style's material (acrylic, mica, gradients) @@ -328,6 +377,8 @@ export function presetAccent(themeId: string): string | undefined { } export function resolveTermTheme(themeId: string): TermTheme { + // The host's own style, where it has one to follow. + if (themeId === 'style' && followsHostStyle()) return readTermTheme() if (themeId === 'custom') { const c = customTermTheme() if (c) @@ -342,6 +393,8 @@ export function resolveTermTheme(themeId: string): TermTheme { // TERM_PRESETS[0] is 'prism'; the find cannot miss, the fallback is for the type. const p = TERM_PRESETS.find((x) => x.id === themeId) ?? + // An id nothing answers to: the host's own default, where that is a preset. + TERM_PRESETS.find((x) => x.id === hostDefaults().theme) ?? TERM_PRESETS.find((x) => x.id === DEFAULT_TERM_THEME) ?? TERM_PRESETS[0] return { diff --git a/src/renderer/src/lib/useAgentIndicator.ts b/core/renderer/lib/useAgentIndicator.ts similarity index 97% rename from src/renderer/src/lib/useAgentIndicator.ts rename to core/renderer/lib/useAgentIndicator.ts index 095e81d..43fe70c 100644 --- a/src/renderer/src/lib/useAgentIndicator.ts +++ b/core/renderer/lib/useAgentIndicator.ts @@ -1,9 +1,10 @@ import { useEffect, useRef, useState, type RefObject } from 'react' -import type { DetectedAgent } from '@shared/types' +import type { DetectedAgent } from '../../shared/types' import { activitySuppressed, inputEcho, markBorn, startupOutput } from './termActivity' import { forgetAgentTitle, readAgentTitle } from './agentTitle' import { noteWorking } from './agentClock' import { onTitle } from './termBus' +import { termApi } from '../host' /** * Which tabs host an agent, which are mid-answer, and which finished while @@ -53,7 +54,7 @@ export function useAgentIndicator(activeId: string | null): AgentIndicator { useEffect( () => - window.prism.onTermAgent((id, present, kind) => { + termApi().onTermAgent((id, present, kind) => { if (present && kind) agentKinds.current.set(id, kind) else if (!present) { agentKinds.current.delete(id) @@ -90,7 +91,7 @@ export function useAgentIndicator(activeId: string | null): AgentIndicator { useEffect( () => - window.prism.onTermData((id) => { + termApi().onTermData((id) => { // A session whose agent SAYS what it is doing (through the title, // see onTitle below) is never scored from its output: the agent's // own word is exact, and its repaints would only second-guess it. diff --git a/core/shared/channels.ts b/core/shared/channels.ts new file mode 100644 index 0000000..b86aa7a --- /dev/null +++ b/core/shared/channels.ts @@ -0,0 +1,19 @@ +/** + * The terminal's IPC channel names, ONCE. The preload half (`core/preload/api`) + * and the main half (`core/main/ipc`) both read this table, so a channel cannot + * be renamed on one side of the bridge and not the other, in either app. + */ +export const CH = { + shells: 'term:shells', + spawn: 'term:spawn', + input: 'term:input', + resize: 'term:resize', + kill: 'term:kill', + prewarm: 'term:prewarm', + cd: 'term:cd', + data: 'term:data', + agent: 'term:agent', + exit: 'term:exit', + clipboardRead: 'clipboard:read', + openExternal: 'shell:open-external' +} as const diff --git a/core/shared/termCwd.test.ts b/core/shared/termCwd.test.ts new file mode 100644 index 0000000..6a90b1d --- /dev/null +++ b/core/shared/termCwd.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { cdCommand, decideFollow, parseOsc9 } from './termCwd' + +describe('parseOsc9', () => { + it('reads the Windows Terminal cwd report, quoted or not', () => { + expect(parseOsc9('9;C:\\Users\\me\\proj')).toBe('C:\\Users\\me\\proj') + expect(parseOsc9('9;"C:\\Users\\me\\proj"')).toBe('C:\\Users\\me\\proj') + expect(parseOsc9('9;\\\\srv\\share\\x')).toBe('\\\\srv\\share\\x') + }) + + it('refuses anything that is not a report of a Windows folder', () => { + expect(parseOsc9('4;100;50')).toBeNull() // ConEmu progress, same OSC number + expect(parseOsc9('9;')).toBeNull() + expect(parseOsc9('9;Cert:\\LocalMachine')).toBeNull() + expect(parseOsc9('9;/home/me')).toBeNull() + }) +}) + +describe('decideFollow', () => { + const root = 'C:\\work\\Prism' + it('tells the root, a folder inside it and a folder past the wall apart', () => { + expect(decideFollow(root, 'c:\\WORK\\prism')).toBe('same') + expect(decideFollow(root, 'C:\\work\\Prism\\')).toBe('same') + expect(decideFollow(root, 'C:\\work\\Prism\\src\\main')).toBe('inside') + expect(decideFollow(root, 'C:\\work\\Prism2')).toBe('outside') // a same-prefixed sibling + expect(decideFollow(root, 'C:\\work')).toBe('outside') + expect(decideFollow(root, 'D:\\')).toBe('outside') + }) +}) + +describe('cdCommand', () => { + it('writes Set-Location for the PowerShells, quoting the one character that needs it', () => { + expect(cdCommand('pwsh', "C:\\it's\\here")).toBe("Set-Location -LiteralPath 'C:\\it''s\\here'\r") + expect(cdCommand('powershell', 'C:\\a b')).toBe("Set-Location -LiteralPath 'C:\\a b'\r") + }) + + it('writes cd /d for cmd, so a drive change works', () => { + expect(cdCommand('cmd', 'D:\\a b')).toBe('cd /d "D:\\a b"\r') + }) + + it('writes nothing for shells it does not know how to move', () => { + expect(cdCommand('wsl-Ubuntu', 'C:\\x')).toBeNull() + expect(cdCommand(undefined, 'C:\\x')).toBeNull() + expect(cdCommand('pwsh', '')).toBeNull() + }) +}) diff --git a/core/shared/termCwd.ts b/core/shared/termCwd.ts new file mode 100644 index 0000000..d7c61d9 --- /dev/null +++ b/core/shared/termCwd.ts @@ -0,0 +1,57 @@ +/** + * The terminal and the sidebar in step (2026-09-04, #99). Pure, shared by + * main (which composes the one command it writes) and the renderer (which + * reads the shell's reports and decides what the tree does with them). + * + * The shell tells Prism where it is the way it tells Windows Terminal: its + * PROMPT prints `ESC ] 9 ; 9 ; ESC \` (OSC 9;9). That is the only + * source: pwsh's process cwd does not follow Set-Location, so asking the + * process is wrong on Windows, and a report from the prompt is by definition + * current only at an idle prompt - which is exactly when it is safe to act on. + */ + +/** A drive path (`C:\x`) or a UNC path (`\\srv\share`), nothing else. */ +const ABS = /^(?:[A-Za-z]:[\\/]|\\\\[^\\/])/ + +/** + * The path out of an OSC 9 payload as xterm hands it over: everything after + * the `9;` selector, so a Windows Terminal report arrives as `9;C:\path`. + * Null for any other OSC 9 (ConEmu's progress bar rides the same number). + */ +export function parseOsc9(data: string): string | null { + if (!data.startsWith('9;')) return null + let p = data.slice(2).trim() + // pwsh's prompt may quote the path; a folder cannot contain a quote. + if (p.length > 1 && p.startsWith('"') && p.endsWith('"')) p = p.slice(1, -1) + return ABS.test(p) ? p : null +} + +const norm = (p: string): string => p.replace(/[\\/]+/g, '\\').replace(/\\$/, '').toLowerCase() + +/** + * Where a cwd stands against the tab's root. `same` is the root itself; + * `inside` is a folder the tree can show; `outside` is past the wall, where + * only a reroot shows anything true. + */ +export function decideFollow(root: string, cwd: string): 'same' | 'inside' | 'outside' { + const r = norm(root) + const c = norm(cwd) + if (!r || !c) return 'outside' + if (r === c) return 'same' + return c.startsWith(r + '\\') ? 'inside' : 'outside' +} + +/** + * The change-directory line for a shell, ending in Enter, or null for a shell + * Prism does not write into (WSL, bash: they report nothing either). Written + * ONLY at an idle prompt with nothing typed (the renderer's guard). pwsh takes + * the literal path, single-quoted with quotes doubled; cmd needs `/d` to cross + * drives, and a Windows path cannot hold a double quote, so none is escaped. + */ +export function cdCommand(shellId: string | undefined, path: string): string | null { + if (!path) return null + if (shellId === 'pwsh' || shellId === 'powershell') + return `Set-Location -LiteralPath '${path.replace(/'/g, "''")}'\r` + if (shellId === 'cmd') return `cd /d "${path}"\r` + return null +} diff --git a/core/shared/types.ts b/core/shared/types.ts new file mode 100644 index 0000000..4df4a89 --- /dev/null +++ b/core/shared/types.ts @@ -0,0 +1,18 @@ +// The types the terminal itself speaks. Owned by the core, so a host (Prism, +// Prism Terminal) imports them from here and the two can never disagree about +// what a shell or an agent is. + +/** A shell main detected on this machine; the only things term:spawn launches. */ +export interface ShellDef { + id: string + name: string + exe: string + args: string[] +} + +/** The agents whose conversation can be resumed at the next launch. */ +export type AgentKind = 'claude' | 'codex' + +/** What the process poll can find in a shell. 'other' agents (aider, gemini) + * light the indicator but have nothing to come back to. */ +export type DetectedAgent = AgentKind | 'other' diff --git a/electron.vite.config.ts b/electron.vite.config.ts index fed1ad4..682ca59 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -6,12 +6,12 @@ import tailwindcss from '@tailwindcss/vite' export default defineConfig({ main: { plugins: [externalizeDepsPlugin()], - resolve: { alias: { '@shared': resolve('src/shared') } }, + resolve: { alias: { '@shared': resolve('src/shared'), '@core': resolve('core') } }, build: { rollupOptions: { input: { index: resolve(__dirname, 'src/main/index.ts') } } } }, preload: { plugins: [externalizeDepsPlugin()], - resolve: { alias: { '@shared': resolve('src/shared') } }, + resolve: { alias: { '@shared': resolve('src/shared'), '@core': resolve('core') } }, build: { rollupOptions: { input: { index: resolve(__dirname, 'src/preload/index.ts') } } } }, renderer: { @@ -19,7 +19,8 @@ export default defineConfig({ resolve: { alias: { '@renderer': resolve('src/renderer/src'), - '@shared': resolve('src/shared') + '@shared': resolve('src/shared'), + '@core': resolve('core') } }, plugins: [react(), tailwindcss()], diff --git a/src/main/index.ts b/src/main/index.ts index 550d105..e4c5271 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -4,15 +4,16 @@ import { stat } from 'fs/promises' import { homedir } from 'os' import { dirname, join } from 'path' import type { Restored, SavedTabs, UpdateInfo } from '@shared/types' -import { startAgentPoll, pollAgentsSoon } from './agentPoll' -import { claudeSessions, planRestore, validResume } from './agentResume' +import { claudeSessions } from '@core/main/agentResume' +import { registerTermIpc } from '@core/main/ipc' +import { planRestore } from './planRestore' import { foldersFromArgv } from './argv' import { acrylicOk, createMaterial } from './material' import { stopDwmHelper, warmDwmHelper } from './dwmHelper' import { createWindowEdge } from './windowEdge' -import { detectShells } from './shells' +import { detectShells } from '@core/main/shells' import { createTabsStore } from './tabsStore' -import { killAll, killTerm, prewarmShell, resizeTerm, spawnTerm, writeTerm } from './terminal' +import { killAll } from '@core/main/terminal' import { installUpdate, watchForUpdates } from './update' import { createVerbSwitch } from './verbSwitch' import { readWindowState, watchWindowState } from './windowState' @@ -335,70 +336,21 @@ function wireIpc(): void { // spawn: the shell is one main detected (spawnTerm's own), the folder exists // (else the user's own folder, not a refusal: a shell somewhere beats none), // and the resume is a session id's shape. - ipcMain.handle('term:shells', () => detectShells()) - ipcMain.handle( - 'term:spawn', - async (_e, id: string, cwd: string, shellId?: string, resume?: string) => { - if (typeof id !== 'string' || !id) return false - const dir = (await isDir(cwd)) ? cwd : homedir() - const ok = await spawnTerm( - id, - dir, - typeof shellId === 'string' ? shellId : undefined, - send, - validResume(typeof resume === 'string' ? resume : undefined) - ) - // Warm the agent-poll pipeline now: the first CIM query is the slow one. - if (ok) pollAgentsSoon() - return ok - } - ) - ipcMain.on('term:input', (_e, id: string, d: string) => { - if (typeof id === 'string' && typeof d === 'string') writeTerm(id, d) - }) - ipcMain.on('term:resize', (_e, id: string, c: number, r: number) => resizeTerm(id, c, r)) - ipcMain.on('term:kill', (_e, id: string) => killTerm(id)) - // The renderer says which folder the next tab will open in (the fixed-folder - // new-tab mode); main starts its shell ahead of the click. Best-effort. - ipcMain.on('term:prewarm', (_e, cwd: string, shellId?: string) => { - void isDir(cwd).then((ok) => { - if (ok && !awaitingRestore) - void prewarmShell(cwd, typeof shellId === 'string' ? shellId : undefined) - }) - }) - startAgentPoll((id, has, kind) => send('term:agent', id, has, kind)) - - /** - * What the clipboard holds RIGHT NOW, for the terminal's paste rule. An - * image forwards the ^V key (a clipboard-aware TUI like Claude Code reads - * the image itself); text becomes a bracketed paste; copied files paste as - * quoted paths. SYNCHRONOUS on purpose: the rule runs inside a key handler - * that has to answer xterm before the event is gone, and the read is a few - * microseconds. It lives in main because a sandboxed preload has no clipboard. - */ - ipcMain.on('clipboard:read', (e) => { - try { - const formats = clipboard.availableFormats() - const files = formats.includes('FileNameW') - ? clipboard - .readBuffer('FileNameW') - .toString('ucs2') - .replace(/\0+$/, '') - .split('\0') - .filter(Boolean) - : [] - e.returnValue = { - image: formats.some((f) => f.startsWith('image/')), - text: clipboard.readText(), - files - } - } catch { - e.returnValue = { image: false, text: '', files: [] } - } - }) - // The terminal's clickable links. http(s) only, checked on both sides. - ipcMain.on('shell:open-external', (_e, url: string) => { - if (typeof url === 'string' && /^https?:\/\//i.test(url)) void shell.openExternal(url) + // THE TERMINAL'S BRIDGE IS THE CORE'S (core/main/ipc): the channels, the + // argument checks, the resume shape check, the clipboard read, the link + // opener and the agent poll are registered there, once, for this app and + // for Prism. What is this app's own is where a shell may start. + registerTermIpc({ + ipcMain, + send, + clipboard, + openExternal: (url) => void shell.openExternal(url), + // No wall here: a folder that has gone falls back to the user's own. + spawnDir: async (cwd) => ((await isDir(cwd)) ? cwd : homedir()), + // 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 }) // A tab's "Show in File Explorer": a folder that exists, and nothing else. ipcMain.on('shell:show-folder', (_e, p: string) => { diff --git a/src/main/agentResume.test.ts b/src/main/planRestore.test.ts similarity index 59% rename from src/main/agentResume.test.ts rename to src/main/planRestore.test.ts index 12513e2..a3bc8c2 100644 --- a/src/main/agentResume.test.ts +++ b/src/main/planRestore.test.ts @@ -1,8 +1,6 @@ -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, planRestore, validResume } from './agentResume' +import { CODEX_RESUME } from '@core/main/agentResume' +import { planRestore } from './planRestore' describe('planRestore', () => { const all = (): boolean => true @@ -65,37 +63,3 @@ describe('planRestore', () => { ).toBe(CODEX_RESUME) }) }) - -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([]) - }) -}) diff --git a/src/main/planRestore.ts b/src/main/planRestore.ts new file mode 100644 index 0000000..4e69040 --- /dev/null +++ b/src/main/planRestore.ts @@ -0,0 +1,44 @@ +import type { Restored, RestoredTab, SavedTabs } from '@shared/types' +import { CODEX_RESUME } from '@core/main/agentResume' + +/** + * 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() + 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)) } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index affb949..a160729 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,5 +1,6 @@ import { contextBridge, ipcRenderer, webUtils } from 'electron' -import type { DetectedAgent, Restored, SavedTabs, ShellDef, UpdateInfo } from '@shared/types' +import { createTermApi } from '@core/preload/api' +import type { Restored, SavedTabs, UpdateInfo } from '@shared/types' // The renderer's whole view of the machine. SANDBOXED: nothing here touches // node, and the only electron modules used are ones a sandboxed preload is @@ -16,38 +17,9 @@ const api = { /* ----- the terminal ----- */ /** The shells main detected; the only things term:spawn will ever launch. */ - termShells: (): Promise => ipcRenderer.invoke('term:shells'), - /** `cwd` that is not an existing folder spawns in the user's own folder. - * `resume` is what restoreTabs handed back for this tab, untouched. */ - termSpawn: (id: string, cwd: string, shellId?: string, resume?: string): Promise => - ipcRenderer.invoke('term:spawn', id, cwd, shellId, resume), - termInput: (id: string, data: string): void => ipcRenderer.send('term:input', id, data), - termResize: (id: string, cols: number, rows: number): void => - ipcRenderer.send('term:resize', id, cols, rows), - termKill: (id: string): void => ipcRenderer.send('term:kill', id), - /** Start a shell in `cwd` ahead of the click. Best-effort. */ - termPrewarm: (cwd: string, shellId?: string): void => - ipcRenderer.send('term:prewarm', cwd, shellId), - onTermData: (cb: (id: string, data: string) => void): (() => void) => on('term:data', cb), - /** An AI CLI (Claude Code, codex...) appeared or left a session's shell. */ - onTermAgent: (cb: (id: string, has: boolean, kind: DetectedAgent | null) => void): (() => void) => - on('term:agent', cb), - /** The shell ended by itself (`exit`); a shell main was told to kill says nothing. */ - onTermExit: (cb: (id: string) => void): (() => void) => on('term:exit', cb), - /** - * What the clipboard holds RIGHT NOW, for the terminal's paste rule. An - * image forwards the ^V key (a clipboard-aware TUI like Claude Code reads - * the image itself); text becomes a bracketed paste; copied files paste as - * quoted paths. The decision itself is pure and lives in lib/termPaste. - * Synchronous, as the key handler that calls it has to be; main does the - * reading, since a sandboxed preload has no clipboard module. - */ - readClipboard: (): { image: boolean; text: string; files: string[] } => - ipcRenderer.sendSync('clipboard:read') as { image: boolean; text: string; files: string[] }, - /** The web-links addon's click-through: external URLs go to the OS browser. */ - openExternal: (url: string): void => { - if (/^https?:/i.test(url)) ipcRenderer.send('shell:open-external', url) - }, + // THE TERMINAL'S BRIDGE IS THE CORE'S (core/preload/api): the member names, + // their signatures and the channel names exist once, for this app and Prism. + ...createTermApi(ipcRenderer), /** The real path of a File from a drop (the sandbox hides `File.path`). */ getDroppedPath: (file: File): string => webUtils.getPathForFile(file), /** A dropped path as the folder a tab would open in: a folder is itself, a diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 751567e..4001984 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -4,8 +4,8 @@ import TerminalPanel, { disposeTermSession, ensureTermSession, focusTermSession -} from './components/TerminalPanel' -import TermFind from './components/TermFind' +} from '@core/renderer/components/TerminalPanel' +import TermFind from '@core/renderer/components/TermFind' import { TabStrip } from './components/TabStrip' import TitleBar from './components/TitleBar' import EmptyState from './components/EmptyState' @@ -23,18 +23,18 @@ import { tabLabels, type TabState } from './lib/tabs' -import { useAgentIndicator } from './lib/useAgentIndicator' -import { humanFor, workingFor } from './lib/agentClock' -import { forgetSession, markResume, markTouched } from './lib/termActivity' -import { onCwd, pasteInto } from './lib/termBus' -import { quotePaths } from './lib/termPaste' +import { useAgentIndicator } from '@core/renderer/lib/useAgentIndicator' +import { humanFor, workingFor } from '@core/renderer/lib/agentClock' +import { forgetSession, markResume, markTouched } from '@core/renderer/lib/termActivity' +import { onCwd, pasteInto } from '@core/renderer/lib/termBus' +import { quotePaths } from '@core/renderer/lib/termPaste' import { ContextMenu } from './components/ContextMenu' -import { rememberRoot } from './lib/recentRoots' -import { savedShellId } from './lib/termPrefs' +import { rememberRoot } from '@core/renderer/lib/recentRoots' +import { savedShellId } from '@core/renderer/lib/termPrefs' import { newTabFolder, newTabMode } from './lib/newTabPrefs' import { confirmClose } from './lib/closePrefs' -import { onTermLookChange, termAcrylic, termOpacity, termThemeId } from './lib/termLook' -import { presetAccent, resolveTermTheme } from './lib/termTheme' +import { onTermLookChange, termAcrylic, termOpacity, termThemeId } from '@core/renderer/lib/termLook' +import { presetAccent, resolveTermTheme } from '@core/renderer/lib/termTheme' import { applyChrome, chromeTokens } from './lib/chromeTheme' const Settings = lazy(() => import('./components/Settings')) diff --git a/src/renderer/src/components/EmptyState.tsx b/src/renderer/src/components/EmptyState.tsx index dcdc035..678b395 100644 --- a/src/renderer/src/components/EmptyState.tsx +++ b/src/renderer/src/components/EmptyState.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, type JSX, type ReactNode } from 'react' -import { pinnedRoots, plusMenuList, recentRoots } from '../lib/recentRoots' +import { pinnedRoots, plusMenuList, recentRoots } from '@core/renderer/lib/recentRoots' /** * The start screen: the window with no tab open (owner, 2026-09-18, with diff --git a/src/renderer/src/components/Settings.tsx b/src/renderer/src/components/Settings.tsx index 2093ece..8f9718f 100644 --- a/src/renderer/src/components/Settings.tsx +++ b/src/renderer/src/components/Settings.tsx @@ -1,11 +1,11 @@ import { useEffect, useMemo, useRef, useState, type JSX, type ReactNode } from 'react' -import { savedShellId, saveShellId } from '../lib/termPrefs' +import { savedShellId, saveShellId } from '@core/renderer/lib/termPrefs' import { setConfirmClose, useConfirmClose } from '../lib/closePrefs' import { setNewTabMode, useNewTabFolder, useNewTabMode } from '../lib/newTabPrefs' import { FONT_PCTS, TERM_FONTS, - TERM_EXTRA_DEFAULTS, + termExtraDefaults, agentIndicator, applyCustomExtras, resetTermExtras, @@ -30,10 +30,10 @@ import { useTermThemeId, type AgentIndicator, type CustomTermTheme -} from '../lib/termLook' -import { resolveTermTheme, TERM_PRESETS } from '../lib/termTheme' +} from '@core/renderer/lib/termLook' +import { resolveTermTheme, TERM_PRESETS } from '@core/renderer/lib/termTheme' import { useAgentColors } from '../lib/agentColors' -import { luminance, normalizeColor } from '../lib/termAnsi' +import { luminance, normalizeColor } from '@core/renderer/lib/termAnsi' // Settings WRITES STORES and nothing else. The window's chrome and its acrylic // material follow the terminal theme, and App is the one listening to @@ -585,7 +585,7 @@ function pickPreset(id: string): void { const volume = agentIndicator() setTermThemeId(id) resetTermExtras() - if (volume !== TERM_EXTRA_DEFAULTS.indicator) setAgentIndicator(volume) + if (volume !== termExtraDefaults().indicator) setAgentIndicator(volume) } function AppearanceTab(): JSX.Element { @@ -644,12 +644,12 @@ function AppearanceTab(): JSX.Element { // is by JSON and key order is part of that. const src = themeId === 'custom' && custom ? custom : null const baseline = { - font: src?.font ?? TERM_EXTRA_DEFAULTS.font, - fontPct: src?.fontPct ?? TERM_EXTRA_DEFAULTS.fontPct, - indicatorColor: src?.indicatorColor ?? TERM_EXTRA_DEFAULTS.indicatorColor, - doneColor: src?.doneColor ?? TERM_EXTRA_DEFAULTS.doneColor, - acrylic: src?.acrylic ?? TERM_EXTRA_DEFAULTS.acrylic, - opacity: src?.opacity ?? TERM_EXTRA_DEFAULTS.opacity + font: src?.font ?? termExtraDefaults().font, + fontPct: src?.fontPct ?? termExtraDefaults().fontPct, + indicatorColor: src?.indicatorColor ?? termExtraDefaults().indicatorColor, + doneColor: src?.doneColor ?? termExtraDefaults().doneColor, + acrylic: src?.acrylic ?? termExtraDefaults().acrylic, + opacity: src?.opacity ?? termExtraDefaults().opacity } const termDirty = JSON.stringify(extras) !== JSON.stringify(baseline) const saveTermSetup = (): void => { diff --git a/src/renderer/src/components/TabStrip.tsx b/src/renderer/src/components/TabStrip.tsx index e8065b0..733db1a 100644 --- a/src/renderer/src/components/TabStrip.tsx +++ b/src/renderer/src/components/TabStrip.tsx @@ -1,9 +1,9 @@ import { useEffect, useRef, useState, type JSX, type MouseEvent, type PointerEvent } from 'react' import { tabLabels, type Tab } from '../lib/tabs' -import { useAgentIndicator } from '../lib/termLook' +import { useAgentIndicator } from '@core/renderer/lib/termLook' import { useAgentColors } from '../lib/agentColors' -import { contrastRatio } from '../lib/termAnsi' -import { pinnedRoots, plusMenuList, recentLabels, recentRoots, togglePin } from '../lib/recentRoots' +import { contrastRatio } from '@core/renderer/lib/termAnsi' +import { pinnedRoots, plusMenuList, recentLabels, recentRoots, togglePin } from '@core/renderer/lib/recentRoots' import { ContextMenu } from './ContextMenu' /** diff --git a/src/renderer/src/lib/agentColors.test.ts b/src/renderer/src/lib/agentColors.test.ts index cbdea6c..297fff6 100644 --- a/src/renderer/src/lib/agentColors.test.ts +++ b/src/renderer/src/lib/agentColors.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest' import { themeAgentColors } from './agentColors' import { chromeTokens } from './chromeTheme' -import { contrastRatio } from './termAnsi' -import { TERM_PRESETS, presetAccent, resolveTermTheme } from './termTheme' +import { contrastRatio } from '@core/renderer/lib/termAnsi' +import { TERM_PRESETS, presetAccent, resolveTermTheme } from '@core/renderer/lib/termTheme' describe('themeAgentColors', () => { it('working IS the chrome accent of the theme', () => { diff --git a/src/renderer/src/lib/agentColors.ts b/src/renderer/src/lib/agentColors.ts index dcc1289..d1b01cb 100644 --- a/src/renderer/src/lib/agentColors.ts +++ b/src/renderer/src/lib/agentColors.ts @@ -1,13 +1,13 @@ import { useMemo } from 'react' import { chromeTokens } from './chromeTheme' -import { contrastRatio, ensureContrast, normalizeColor } from './termAnsi' -import { presetAccent, resolveTermTheme } from './termTheme' +import { contrastRatio, ensureContrast, normalizeColor } from '@core/renderer/lib/termAnsi' +import { presetAccent, resolveTermTheme } from '@core/renderer/lib/termTheme' import { useAgentColorChoice, useAgentDoneColorChoice, useCustomTermTheme, useTermThemeId -} from './termLook' +} from '@core/renderer/lib/termLook' /** * What the agent indicators wear when the user has not picked a colour: the diff --git a/src/renderer/src/lib/chromeTheme.test.ts b/src/renderer/src/lib/chromeTheme.test.ts index b69cae6..5e98e71 100644 --- a/src/renderer/src/lib/chromeTheme.test.ts +++ b/src/renderer/src/lib/chromeTheme.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { CHROME_COLOUR_TOKENS, chromeTokens } from './chromeTheme' -import { TERM_PRESETS, resolveTermTheme, type TermTheme } from './termTheme' -import { contrastRatio } from './termAnsi' +import { TERM_PRESETS, resolveTermTheme, type TermTheme } from '@core/renderer/lib/termTheme' +import { contrastRatio } from '@core/renderer/lib/termAnsi' describe('chromeTokens', () => { it.each(TERM_PRESETS.map((p) => p.id))( diff --git a/src/renderer/src/lib/chromeTheme.ts b/src/renderer/src/lib/chromeTheme.ts index 7d0f645..b13c40a 100644 --- a/src/renderer/src/lib/chromeTheme.ts +++ b/src/renderer/src/lib/chromeTheme.ts @@ -1,5 +1,5 @@ -import type { TermTheme } from './termTheme' -import { contrastRatio, ensureContrast, luminance, mixHex, normalizeColor } from './termAnsi' +import type { TermTheme } from '@core/renderer/lib/termTheme' +import { contrastRatio, ensureContrast, luminance, mixHex, normalizeColor } from '@core/renderer/lib/termAnsi' // The window wears the terminal's theme. Every chrome colour is derived from // the theme's background, foreground and blue, and whatever falls short of a diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 023913e..d3edfbd 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -1,8 +1,12 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import App from './App' +import { configurePrismTerminalHost } from './termHost' import './index.css' +// Before anything renders: the core asks its host who it is. +configurePrismTerminalHost() + createRoot(document.getElementById('root')!).render( diff --git a/src/renderer/src/termHost.ts b/src/renderer/src/termHost.ts new file mode 100644 index 0000000..295f629 --- /dev/null +++ b/src/renderer/src/termHost.ts @@ -0,0 +1,40 @@ +import { configureTermCore } from '@core/renderer/host' + +/** + * PRISM TERMINAL AS A HOST OF THE TERMINAL CORE (#15). + * + * Everything that is the terminal lives in `core/` and is shared with Prism. + * This file is the whole of what this app says differently, and each line is an + * owner decision recorded in CLAUDE.md: the terminal theme drives the window + * (so there is no host style to follow), the panel paints the ground, the + * indicator is quiet and wears the theme by default, and the app claims only + * the chords a shell never uses. + */ +export function configurePrismTerminalHost(): void { + configureTermCore({ + api: window.prism, + defaults: { + theme: 'prism', + acrylic: false, + indicator: 'minimal', + // '' = follow the theme (lib/agentColors resolves what that is here). + agentColor: '', + agentDoneColor: '' + }, + followsHostStyle: false, + paintsGround: true, + ownsKey: (e) => { + if (!e.ctrlKey || e.altKey) return false + const k = e.key.toLowerCase() + // Next / previous tab, jump to a tab, Settings. + if (e.key === 'Tab' || /^[1-9]$/.test(e.key) || e.key === ',') return true + // New tab is Ctrl+T (owner, 2026-09-18), with or without shift, which + // takes the chord from whatever runs in the shell (Claude Code's task + // list): the owner's trade. + if (k === 't') return true + // Close tab and find carry SHIFT, and the shift is tested: plain Ctrl+W + // deletes a word in every readline and plain Ctrl+F is the shell's. + return e.shiftKey && (k === 'w' || k === 'f') + } + }) +} diff --git a/src/shared/termCwd.test.ts b/src/shared/termCwd.test.ts deleted file mode 100644 index 5eaceea..0000000 --- a/src/shared/termCwd.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { parseOsc9 } from './termCwd' - -describe('parseOsc9', () => { - it('reads the Windows Terminal cwd report, quoted or not', () => { - expect(parseOsc9('9;C:\\Users\\me\\proj')).toBe('C:\\Users\\me\\proj') - expect(parseOsc9('9;"C:\\Users\\me\\proj"')).toBe('C:\\Users\\me\\proj') - expect(parseOsc9('9;\\\\srv\\share\\x')).toBe('\\\\srv\\share\\x') - }) - - it('refuses anything that is not a report of a Windows folder', () => { - expect(parseOsc9('4;100;50')).toBeNull() // ConEmu progress, same OSC number - expect(parseOsc9('9;')).toBeNull() - expect(parseOsc9('9;Cert:\\LocalMachine')).toBeNull() - expect(parseOsc9('9;/home/me')).toBeNull() - }) -}) diff --git a/src/shared/termCwd.ts b/src/shared/termCwd.ts deleted file mode 100644 index 4bc9cd8..0000000 --- a/src/shared/termCwd.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Where the shell is (Prism 2026-09-04, #99). Pure. In Prism this file also - * held the sidebar's half, the inside/outside-root decision and the - * Set-Location line main wrote on a reroot; Prism Terminal has no tree and no - * root to follow, so only the READING survives: a tab's title and its saved - * cwd come from the shell's own report. - * - * The shell tells Prism where it is the way it tells Windows Terminal: its - * PROMPT prints `ESC ] 9 ; 9 ; ESC \` (OSC 9;9). That is the only - * source: pwsh's process cwd does not follow Set-Location, so asking the - * process is wrong on Windows, and a report from the prompt is by definition - * current only at an idle prompt - which is exactly when it is safe to act on. - */ - -/** A drive path (`C:\x`) or a UNC path (`\\srv\share`), nothing else. */ -const ABS = /^(?:[A-Za-z]:[\\/]|\\\\[^\\/])/ - -/** - * The path out of an OSC 9 payload as xterm hands it over: everything after - * the `9;` selector, so a Windows Terminal report arrives as `9;C:\path`. - * Null for any other OSC 9 (ConEmu's progress bar rides the same number). - */ -export function parseOsc9(data: string): string | null { - if (!data.startsWith('9;')) return null - let p = data.slice(2).trim() - // pwsh's prompt may quote the path; a folder cannot contain a quote. - if (p.length > 1 && p.startsWith('"') && p.endsWith('"')) p = p.slice(1, -1) - return ABS.test(p) ? p : null -} diff --git a/src/shared/types.ts b/src/shared/types.ts index ef6a972..3b0a321 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,18 +1,12 @@ -/** A shell main detected on this machine; the only things term:spawn launches. */ -export interface ShellDef { - id: string - name: string - exe: string - args: string[] -} +// The terminal's own types come from the core; the rest here is this app's. +export type { AgentKind, DetectedAgent, ShellDef } from '@core/shared/types' +import type { AgentKind } from '@core/shared/types' /** The two agents whose sessions can be resumed, each by its own flag: * `claude --resume ` and `codex resume --last`. */ -export type AgentKind = 'claude' | 'codex' /** What the agent poll can find under a shell. 'other' (aider, gemini) still * lights the tab, it just has nothing to come back to, so it is never saved. */ -export type DetectedAgent = AgentKind | 'other' /** A tab as tabs.json holds it: the folder its shell was in, and the agent it * hosted at the last report, when that agent is one that can be resumed. */ diff --git a/tsconfig.node.json b/tsconfig.node.json index 67fa7f5..9b69062 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -5,8 +5,12 @@ "module": "ESNext", "moduleResolution": "Bundler", "target": "ES2022", - "lib": ["ES2023"], - "types": ["node"], + "lib": [ + "ES2023" + ], + "types": [ + "node" + ], "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, @@ -14,7 +18,14 @@ "esModuleInterop": true, "resolveJsonModule": true, "baseUrl": ".", - "paths": { "@shared/*": ["src/shared/*"] } + "paths": { + "@shared/*": [ + "src/shared/*" + ], + "@core/*": [ + "core/*" + ] + } }, "include": [ "src/main/**/*", @@ -22,6 +33,9 @@ "src/shared/**/*", "test/**/*", "electron.vite.config.ts", - "vitest.config.ts" + "vitest.config.ts", + "core/main/**/*", + "core/preload/**/*", + "core/shared/**/*" ] } diff --git a/tsconfig.web.json b/tsconfig.web.json index bbf0732..8ee654d 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -5,7 +5,11 @@ "module": "ESNext", "moduleResolution": "Bundler", "target": "ES2022", - "lib": ["ES2023", "DOM", "DOM.Iterable"], + "lib": [ + "ES2023", + "DOM", + "DOM.Iterable" + ], "jsx": "react-jsx", "strict": true, "noUnusedLocals": true, @@ -14,14 +18,24 @@ "esModuleInterop": true, "baseUrl": ".", "paths": { - "@renderer/*": ["src/renderer/src/*"], - "@shared/*": ["src/shared/*"] + "@renderer/*": [ + "src/renderer/src/*" + ], + "@shared/*": [ + "src/shared/*" + ], + "@core/*": [ + "core/*" + ] } }, "include": [ "src/renderer/src/**/*", "src/renderer/src/**/*.tsx", "src/preload/index.d.ts", - "src/shared/**/*" + "src/shared/**/*", + "core/renderer/**/*", + "core/shared/**/*", + "core/preload/**/*" ] } diff --git a/vitest.config.ts b/vitest.config.ts index 45f44eb..e022c15 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,11 +7,12 @@ export default defineConfig({ resolve: { alias: { '@shared': resolve(__dirname, 'src/shared'), - '@renderer': resolve(__dirname, 'src/renderer/src') + '@renderer': resolve(__dirname, 'src/renderer/src'), + '@core': resolve(__dirname, 'core') } }, test: { - include: ['src/**/*.test.ts'], + include: ['src/**/*.test.ts', 'core/**/*.test.ts'], environment: 'node', // The renderer's stores read localStorage at import time; the setup gives // them one so their pure logic can be tested without a browser. From 2e9ef22e257e370f7a03dfe174f6e032200263b0 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sat, 19 Sep 2026 10:12:55 +0200 Subject: [PATCH 2/8] chore(core): the lint wall, the host seam's tests, and the sync contract (#15) core/ may not use a path alias, import a host's src/, import electron, read window.prism or pull in the window chrome; the rule is proven to bite. The seam tests model both hosts and prove an untouched setting reads as ITS host says, so adopting the core changes nothing a Prism user sees. core/README.md is the contract, and CLAUDE.md's "copied, port by hand" paragraph, which the owner has reversed, is replaced with it. Co-Authored-By: Claude Fable 5.1 --- CLAUDE.md | 36 ++++++++++--- core/README.md | 103 +++++++++++++++++++++++++++++++++++++ core/renderer/host.test.ts | 78 ++++++++++++++++++++++++++++ eslint.config.js | 28 ++++++++++ 4 files changed, 238 insertions(+), 7 deletions(-) create mode 100644 core/README.md create mode 100644 core/renderer/host.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 9666767..7622d58 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,12 +5,34 @@ 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. + +- **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. @@ -133,8 +155,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`, diff --git a/core/README.md b/core/README.md new file mode 100644 index 0000000..661597f --- /dev/null +++ b/core/README.md @@ -0,0 +1,103 @@ +# 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, the paste rule, the theme and look stores. +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, + 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 13 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`, 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: bump the pin, run Prism's own gate (typecheck, unit, its terminal + e2e), 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 in progress and +deliberately incremental: see issue #15 for what has moved, what is waiting on +an owner answer, and the questions themselves. diff --git a/core/renderer/host.test.ts b/core/renderer/host.test.ts new file mode 100644 index 0000000..5dc3869 --- /dev/null +++ b/core/renderer/host.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { configureTermCore, resetTermCore, termHost, type TermApi, type TermHostConfig } from './host' +import { agentColorChoice, agentIndicator, termAcrylic, termExtraDefaults, termThemeId } from './lib/termLook' +import { CH } from '../shared/channels' + +// The seam is what lets ONE terminal serve two apps. These tests are the two +// apps: each host's defaults, read through the same stores. + +const api = {} as TermApi + +/** Prism Terminal: the theme drives the window, quiet by default. */ +const PRISM_TERMINAL: TermHostConfig = { + api, + defaults: { theme: 'prism', acrylic: false, indicator: 'minimal', agentColor: '', agentDoneColor: '' }, + followsHostStyle: false, + paintsGround: true, + ownsKey: () => false +} + +/** Prism, as it ships today: the terminal wears the app style. */ +const PRISM: TermHostConfig = { + api, + defaults: { theme: 'style', acrylic: true, indicator: 'full', agentColor: '#f97316', agentDoneColor: '#22c55e' }, + followsHostStyle: true, + paintsGround: false, + ownsKey: () => false +} + +describe('the host seam', () => { + beforeEach(() => localStorage.clear()) + afterEach(() => resetTermCore()) + + it('refuses to be used before a host has spoken', () => { + expect(() => termHost()).toThrow(/configureTermCore/) + }) + + it('an untouched setting reads as ITS HOST says, so adopting the core changes nothing a user sees', () => { + configureTermCore(PRISM) + expect([termThemeId(), termAcrylic(), agentIndicator(), agentColorChoice()]).toEqual(['style', true, 'full', '#f97316']) + configureTermCore(PRISM_TERMINAL) + expect([termThemeId(), termAcrylic(), agentIndicator(), agentColorChoice()]).toEqual(['prism', false, 'minimal', '']) + }) + + it('a setting the user DID touch wins over either host', () => { + localStorage.setItem('prism.term.theme', 'dracula') + localStorage.setItem('prism.term.acrylic', '0') + localStorage.setItem('prism.term.agentIndicator', 'off') + for (const host of [PRISM, PRISM_TERMINAL]) { + configureTermCore(host) + expect([termThemeId(), termAcrylic(), agentIndicator()]).toEqual(['dracula', false, 'off']) + } + }) + + it("'style' is a theme only where the host has a style to follow", () => { + localStorage.setItem('prism.term.theme', 'style') + configureTermCore(PRISM) + expect(termThemeId()).toBe('style') + // A save carried over from Prism must not leave a theme nothing can resolve. + configureTermCore(PRISM_TERMINAL) + expect(termThemeId()).toBe('prism') + }) + + it('"what a theme pick resets to" follows the host too', () => { + configureTermCore(PRISM) + expect(termExtraDefaults()).toMatchObject({ indicator: 'full', acrylic: true, indicatorColor: '#f97316' }) + configureTermCore(PRISM_TERMINAL) + expect(termExtraDefaults()).toMatchObject({ indicator: 'minimal', acrylic: false, indicatorColor: '' }) + }) +}) + +describe('the channel table', () => { + it('names every channel once, and no two alike', () => { + const names = Object.values(CH) + expect(new Set(names).size).toBe(names.length) + // main's terminal.ts sends these two by their literal names. + expect([CH.data, CH.exit]).toEqual(['term:data', 'term:exit']) + }) +}) diff --git a/eslint.config.js b/eslint.config.js index 91553f3..142d517 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -35,6 +35,34 @@ export default tseslint.config( 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }] } }, + { + // THE CORE STAYS EMBEDDABLE (#15). core/ is compiled by TWO hosts, this app + // and Prism, so it may not reach for anything only one of them has. A path + // alias is resolved against the CONSUMER's tree (measured: silently, with + // no error, the core ran the host's copy of a file), a host's src/ does + // not exist in the other app, `electron` is the host's to load, and the + // bridge to main is handed in through core/renderer/host, never taken off + // a global. The window chrome is this app's own idea and would overwrite + // Prism's styles if the core ever pulled it in. + files: ['core/**/*.{ts,tsx}'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { group: ['@shared/*', '@renderer/*', '@core/*'], message: 'No path aliases inside core/: a consumer resolves them against its own tree. Use a relative import.' }, + { group: ['**/src/*', '**/src/**'], message: 'core/ may not import from a host app.' }, + { group: ['**/chromeTheme', '**/chromeTheme.*'], message: "chromeTheme is Prism Terminal's window chrome; inside Prism it would overwrite the app's styles." } + ], + paths: [{ name: 'electron', message: "core/ never imports electron: the host passes what it needs (ipcMain, ipcRenderer, clipboard)." }] + } + ], + 'no-restricted-properties': [ + 'error', + { object: 'window', property: 'prism', message: 'core/ reaches main through termApi() (core/renderer/host), never through a global.' } + ] + } + }, { // tools/ runs on Node, drives a browser through Playwright, and injects the // odd snippet into a page: it needs both sets of globals and none of the From 22de1171753e411637978f6381b6967dd4c105f1 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sat, 19 Sep 2026 10:52:55 +0200 Subject: [PATCH 3/8] refactor(host): the host speaks from a module imported first (#15) Imports are evaluated in order and before main.tsx's own body, so anything in App's module graph that reads a terminal setting at import time has to find a host already configured. Found while wiring Prism, whose theme.ts paints at import time; the same shape here keeps the two hosts alike. Co-Authored-By: Claude Fable 5.1 --- src/renderer/src/main.tsx | 7 ++--- src/renderer/src/termHost.ts | 59 +++++++++++++++++++----------------- 2 files changed, 34 insertions(+), 32 deletions(-) diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index d3edfbd..b8e4bbc 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -1,12 +1,11 @@ +// The terminal core asks its host who it is; this runs before App's module +// graph is evaluated (see termHost.ts). +import './termHost' import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import App from './App' -import { configurePrismTerminalHost } from './termHost' import './index.css' -// Before anything renders: the core asks its host who it is. -configurePrismTerminalHost() - createRoot(document.getElementById('root')!).render( diff --git a/src/renderer/src/termHost.ts b/src/renderer/src/termHost.ts index 295f629..50b3082 100644 --- a/src/renderer/src/termHost.ts +++ b/src/renderer/src/termHost.ts @@ -9,32 +9,35 @@ import { configureTermCore } from '@core/renderer/host' * (so there is no host style to follow), the panel paints the ground, the * indicator is quiet and wears the theme by default, and the app claims only * the chords a shell never uses. + * + * A SIDE-EFFECT MODULE, imported FIRST by main.tsx: imports are evaluated in + * order and before main.tsx's own body, so anything in App's module graph that + * reads a terminal setting at import time must find a host already there. + * (Learned in Prism, whose theme.ts paints at import time.) */ -export function configurePrismTerminalHost(): void { - configureTermCore({ - api: window.prism, - defaults: { - theme: 'prism', - acrylic: false, - indicator: 'minimal', - // '' = follow the theme (lib/agentColors resolves what that is here). - agentColor: '', - agentDoneColor: '' - }, - followsHostStyle: false, - paintsGround: true, - ownsKey: (e) => { - if (!e.ctrlKey || e.altKey) return false - const k = e.key.toLowerCase() - // Next / previous tab, jump to a tab, Settings. - if (e.key === 'Tab' || /^[1-9]$/.test(e.key) || e.key === ',') return true - // New tab is Ctrl+T (owner, 2026-09-18), with or without shift, which - // takes the chord from whatever runs in the shell (Claude Code's task - // list): the owner's trade. - if (k === 't') return true - // Close tab and find carry SHIFT, and the shift is tested: plain Ctrl+W - // deletes a word in every readline and plain Ctrl+F is the shell's. - return e.shiftKey && (k === 'w' || k === 'f') - } - }) -} +configureTermCore({ + api: window.prism, + defaults: { + theme: 'prism', + acrylic: false, + indicator: 'minimal', + // '' = follow the theme (lib/agentColors resolves what that is here). + agentColor: '', + agentDoneColor: '' + }, + followsHostStyle: false, + paintsGround: true, + ownsKey: (e) => { + if (!e.ctrlKey || e.altKey) return false + const k = e.key.toLowerCase() + // Next / previous tab, jump to a tab, Settings. + if (e.key === 'Tab' || /^[1-9]$/.test(e.key) || e.key === ',') return true + // New tab is Ctrl+T (owner, 2026-09-18), with or without shift, which + // takes the chord from whatever runs in the shell (Claude Code's task + // list): the owner's trade. + if (k === 't') return true + // Close tab and find carry SHIFT, and the shift is tested: plain Ctrl+W + // deletes a word in every readline and plain Ctrl+F is the shell's. + return e.shiftKey && (k === 'w' || k === 'f') + } +}) From 4ec0a0681c0801e9ef542385af2b329a3b4b1d2e Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sat, 19 Sep 2026 13:39:53 +0200 Subject: [PATCH 4/8] feat(core): the terminal's settings, indicator colours and close question move into the core (#15) Owner, 2026-09-19: the two apps' terminal settings are the same settings ("the setting names, types, how they function"), only the personal values differ. So the settings UI is shared code now: core/renderer/settings holds the field primitives, the appearance section and the shell and indicator rows, and this app's Settings is just its page plus its own rows (1107 lines to about 220). A list of every option by id is held to the sections by a unit test and to the running app by the e2e. Two new declared host differences: what an unpicked indicator colour resolves to, and what "acrylic" means as a terminal setting (the window's own material here; in Prism the style owns it, so no opacity slider there). Also by owner decision, for both apps: the close question is one rule and no longer a setting (a plain shell closes unasked, a tab hosting an agent asks), and Ctrl+W closes a tab. Co-Authored-By: Claude Fable 5.1 --- CLAUDE.md | 27 +- README.md | 10 +- core/README.md | 9 +- core/renderer/host.test.ts | 4 + core/renderer/host.ts | 19 + core/renderer/lib/agentClose.test.ts | 18 + core/renderer/lib/agentClose.ts | 41 + core/renderer/lib/agentColors.ts | 29 + core/renderer/settings/TerminalAppearance.tsx | 572 +++++++++++ core/renderer/settings/TerminalBehaviour.tsx | 69 ++ core/renderer/settings/fields.tsx | 325 ++++++ core/renderer/settings/options.test.ts | 37 + core/renderer/settings/options.ts | 44 + src/renderer/src/App.tsx | 35 +- src/renderer/src/components/Settings.tsx | 921 +----------------- src/renderer/src/components/TabStrip.tsx | 2 +- src/renderer/src/lib/agentColors.ts | 22 - src/renderer/src/lib/closePrefs.test.ts | 17 - src/renderer/src/lib/closePrefs.ts | 26 - src/renderer/src/termHost.ts | 21 +- tools/e2e/run.mjs | 38 + 21 files changed, 1275 insertions(+), 1011 deletions(-) create mode 100644 core/renderer/lib/agentClose.test.ts create mode 100644 core/renderer/lib/agentClose.ts create mode 100644 core/renderer/lib/agentColors.ts create mode 100644 core/renderer/settings/TerminalAppearance.tsx create mode 100644 core/renderer/settings/TerminalBehaviour.tsx create mode 100644 core/renderer/settings/fields.tsx create mode 100644 core/renderer/settings/options.test.ts create mode 100644 core/renderer/settings/options.ts delete mode 100644 src/renderer/src/lib/closePrefs.test.ts delete mode 100644 src/renderer/src/lib/closePrefs.ts diff --git a/CLAUDE.md b/CLAUDE.md index 7622d58..1ceb632 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,15 @@ so an update never silently changes what an existing user sees; the bridge to ma (`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/`. @@ -90,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 diff --git a/README.md b/README.md index 760735c..44b2382 100644 --- a/README.md +++ b/README.md @@ -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.
The same window wearing a light theme: the whole chrome turns light with it @@ -68,7 +69,7 @@ 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 | @@ -76,7 +77,8 @@ lands on a start screen with the folders you were last in, each one press from a | `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 diff --git a/core/README.md b/core/README.md index 661597f..7a781e4 100644 --- a/core/README.md +++ b/core/README.md @@ -20,7 +20,11 @@ ask me."* And: *"why can't this repo be the core?"* It can, and this is it. 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, the paste rule, the theme and look stores. + 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. @@ -28,7 +32,8 @@ ask me."* And: *"why can't this repo be the core?"* It can, and this is it. 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, - which chords the app owns. If a difference is not on that page, it is a fork, + 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, diff --git a/core/renderer/host.test.ts b/core/renderer/host.test.ts index 5dc3869..0b8ee59 100644 --- a/core/renderer/host.test.ts +++ b/core/renderer/host.test.ts @@ -14,6 +14,8 @@ const PRISM_TERMINAL: TermHostConfig = { defaults: { theme: 'prism', acrylic: false, indicator: 'minimal', agentColor: '', agentDoneColor: '' }, followsHostStyle: false, paintsGround: true, + themedAgentColors: () => ({ working: '#5b5bd6', finished: '#22c55e' }), + acrylic: { kind: 'window', supported: async () => true }, ownsKey: () => false } @@ -23,6 +25,8 @@ const PRISM: TermHostConfig = { defaults: { theme: 'style', acrylic: true, indicator: 'full', agentColor: '#f97316', agentDoneColor: '#22c55e' }, followsHostStyle: true, paintsGround: false, + themedAgentColors: () => ({ working: '#3f5570', finished: '#22c55e' }), + acrylic: { kind: 'style' }, ownsKey: () => false } diff --git a/core/renderer/host.ts b/core/renderer/host.ts index fa0ecb7..274ac03 100644 --- a/core/renderer/host.ts +++ b/core/renderer/host.ts @@ -82,6 +82,25 @@ export interface TermHostConfig { */ paintsGround: boolean + /** + * What an agent-indicator colour is when the user has NOT picked one: the + * host's own accent for "working", and a green that reads on its ground for + * "finished". The tab strip is the host's chrome, so the accent is the + * host's to name: Prism Terminal derives its chrome from the terminal theme, + * Prism takes it from the app style. + */ + themedAgentColors(themeId: string): { working: string; finished: string } + + /** + * What "Acrylic" means as a TERMINAL setting. 'window': the terminal setting + * switches the window's own material on, for any theme, with an opacity + * slider (Prism Terminal, where nothing else owns the window). 'style': the + * window material belongs to the host's STYLE, and the row only decides + * whether the terminal lets it show through; no slider, since two alphas + * over one sheet of glass would fight (Prism; owner, 2026-09-19). + */ + acrylic: { kind: 'window'; supported(): Promise } | { kind: 'style' } + /** * A chord the HOST owns even while a shell has the keyboard. xterm must not * also feed it to the pty: left to xterm, Ctrl+` became a NUL byte, which diff --git a/core/renderer/lib/agentClose.test.ts b/core/renderer/lib/agentClose.test.ts new file mode 100644 index 0000000..b6c5ec9 --- /dev/null +++ b/core/renderer/lib/agentClose.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { asksBeforeClosingTab, closeQuestionTitle, holdsWindowClose } from './agentClose' + +describe('the close question', () => { + it('never asks about a plain shell, and asks whenever a tab hosts an agent', () => { + expect(asksBeforeClosingTab(false)).toBe(false) + expect(asksBeforeClosingTab(true)).toBe(true) + }) + it('holds the window only while an agent is mid-answer', () => { + expect(holdsWindowClose(0)).toBe(false) + expect(holdsWindowClose(2)).toBe(true) + }) + it('says what it is about to interrupt', () => { + expect(closeQuestionTitle('tab', null)).toMatch(/end the agent/) + expect(closeQuestionTitle('tab', 90_000)).toMatch(/Stop the agent/) + expect(closeQuestionTitle('window', 90_000)).toMatch(/window/) + }) +}) diff --git a/core/renderer/lib/agentClose.ts b/core/renderer/lib/agentClose.ts new file mode 100644 index 0000000..1e22b2a --- /dev/null +++ b/core/renderer/lib/agentClose.ts @@ -0,0 +1,41 @@ +import type { DetectedAgent } from '../../shared/types' + +/** + * THE CLOSE QUESTION, one rule for both apps and NOT a setting (owner, + * 2026-09-19: "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"). + * + * It replaced two different settings: Prism's three modes (always / agent / + * never) and Prism Terminal's on/off switch. A plain shell has nothing to + * lose, so closing it never asks. 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. Closing the WINDOW is held only while an agent is mid-answer, + * because both apps bring idle agents back at the next launch. + * + * A host's OTHER reasons to ask (Prism's unsaved text) are its own and are + * untouched by this. + */ + +/** Closing this tab: ask first? */ +export function asksBeforeClosingTab(agentPresent: boolean): boolean { + return agentPresent +} + +/** Closing the window: hold it and ask? */ +export function holdsWindowClose(agentsWorking: number): boolean { + return agentsWorking > 0 +} + +export const AGENT_NAMES: Record = { + claude: 'Claude', + codex: 'Codex', + other: 'The agent' +} + +/** What the question says. `forMs` is how long the agent has been mid-answer, + * null when it is only present, waiting at its own prompt. */ +export function closeQuestionTitle(target: 'tab' | 'window', forMs: number | null): string { + if (target === 'window') return 'Stop the agent and close the window?' + return forMs === null ? 'Close the tab and end the agent?' : 'Stop the agent and close?' +} diff --git a/core/renderer/lib/agentColors.ts b/core/renderer/lib/agentColors.ts new file mode 100644 index 0000000..70eacd8 --- /dev/null +++ b/core/renderer/lib/agentColors.ts @@ -0,0 +1,29 @@ +import { useMemo } from 'react' +import { termHost } from '../host' +import { + useAgentColorChoice, + useAgentDoneColorChoice, + useCustomTermTheme, + useTermThemeId +} from './termLook' + +/** + * The agent indicator's colours IN FORCE: the user's pick where there is one, + * else what the HOST says its theme gives (owner, 2026-09-18 and -19: the + * indicator wears the accent, in both apps). `termLook` only remembers whether + * the user has an opinion ('' = follow); the host names the accent + * (`themedAgentColors`), because the tab strip is the host's chrome. + */ +export function useAgentColors(): { working: string; finished: string } { + const themeId = useTermThemeId() + // A custom theme edited in place keeps its id; its palette is the dependency. + const custom = useCustomTermTheme() + const working = useAgentColorChoice() + const finished = useAgentDoneColorChoice() + return useMemo(() => { + const themed = termHost().themedAgentColors(themeId) + return { working: working || themed.working, finished: finished || themed.finished } + // `custom` is read by the host's resolver, not named in the body. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [themeId, custom, working, finished]) +} diff --git a/core/renderer/settings/TerminalAppearance.tsx b/core/renderer/settings/TerminalAppearance.tsx new file mode 100644 index 0000000..debb4b2 --- /dev/null +++ b/core/renderer/settings/TerminalAppearance.tsx @@ -0,0 +1,572 @@ +import { useEffect, useMemo, useRef, useState, type JSX } from 'react' +import { followsHostStyle, termHost } from '../host' +import { + FONT_PCTS, + TERM_FONTS, + termExtraDefaults, + agentIndicator, + applyCustomExtras, + resetTermExtras, + saveCustomTermTheme, + setAgentColor, + setAgentDoneColor, + setAgentIndicator, + setTermAcrylic, + setTermFontId, + setTermFontPct, + setTermOpacity, + setTermThemeId, + termThemeId, + useAgentColorChoice, + useAgentDoneColorChoice, + useCustomTermTheme, + useTermAcrylic, + useTermFontId, + useTermFontPct, + useTermOpacity, + useTermThemeId, + type CustomTermTheme +} from '../lib/termLook' +import { resolveTermTheme, watchTermTheme, TERM_PRESETS } from '../lib/termTheme' +import { useAgentColors } from '../lib/agentColors' +import { luminance, normalizeColor } from '../lib/termAnsi' +import { HexSwatch, Pref, ROWS, ROW_BUTTON, SaveButton, Select, Switch, ThemeHead } from './fields' + +// THE TERMINAL'S LOOK, as one settings section for both hosts (#15): the theme +// wall and its editor, font, size, acrylic, and the two agent indicator +// colours. It WRITES STORES and nothing else; whoever hosts it listens to +// onTermLookChange and repaints what it owns. Each app composes its own page +// round this; the rows, their names, types and behaviour are the same code. + +/* ---------- appearance ---------- */ + +/** A theme as a miniature terminal: prompt, coloured ls output, cursor. The + * point is the SYNTAX colours - a swatch row says nothing about how a real + * session will read. Every card paints itself with its OWN palette, never the + * chrome's tokens: the chrome is whichever theme is selected, and a wall of + * cards wearing that would be one theme drawn forty times. */ +function TermThemeCard({ + id, + name, + on, + bg, + fg, + cursor, + ansi, + onPick, + onEdit +}: { + id: string + name: string + on: boolean + bg: string + fg: string + cursor: string + ansi: { green: string; yellow: string; blue: string; cyan: string; red: string } + onPick: () => void + /** Rendered as a pencil on the SELECTED card only. */ + onEdit?: () => void +}): JSX.Element { + return ( + + ) +} + +const ANSI_KEYS = [ + 'black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white', + 'brightBlack', 'brightRed', 'brightGreen', 'brightYellow', 'brightBlue', 'brightMagenta', 'brightCyan', 'brightWhite' +] as const + +/** The five colours a card's miniature session reads, with the fallbacks a + * custom palette missing one of them gets. */ +const cardAnsi = ( + a: Record +): { green: string; yellow: string; blue: string; cyan: string; red: string } => ({ + green: a.green ?? '#8cc265', + yellow: a.yellow ?? '#d1a54b', + blue: a.blue ?? '#4aa5f0', + cyan: a.cyan ?? '#42b3c2', + red: a.red ?? '#e05561' +}) + +/** The Tabby-style editor: every colour of a theme, individually, with the + * live preview beside them. Save lands in the single Custom slot. */ +function TermThemeEditor({ + seed, + onSave, + onCancel +}: { + seed: CustomTermTheme + onSave: (t: CustomTermTheme) => void + onCancel: () => void +}): JSX.Element { + const [draft, setDraft] = useState(seed) + const set = (k: string, v: string): void => + setDraft((d) => + k === 'bg' || k === 'fg' || k === 'cursor' + ? { ...d, [k]: v } + : { ...d, ansi: { ...d.ansi, [k]: v } } + ) + const well = (label: string, key: string, value: string): JSX.Element => ( + + ) + return ( + // A popup, not an inline section: below the card grid the editor sat out + // of view. data-owns-escape keeps App's window Escape away; the backdrop + // and Escape both cancel. +
{ + if (e.target === e.currentTarget) onCancel() + }} + onKeyDown={(e) => { + if (e.key === 'Escape') { + e.stopPropagation() + onCancel() + } + }} + role="dialog" + aria-label="Edit terminal colours" + > +
+
Edit colours
+
+
+ {well('Background', 'bg', draft.bg)} + {well('Foreground', 'fg', draft.fg)} + {well('Cursor', 'cursor', draft.cursor)} + {ANSI_KEYS.map((k) => well(k, k, draft.ansi[k] ?? '#888888'))} +
+ {}} + /> +
+
+ + +
+
+
+ ) +} + +/** The selected theme's palette as the editor and the Custom slot hold it. + * Normalised: a theme may publish rgba() or #rrggbbaa, and a colour input + * handed either silently renders black. */ +function paletteOf(id: string): Pick { + const t = resolveTermTheme(id) + const ansi: Record = {} + for (const k of ANSI_KEYS) { + const v = t[k] + if (typeof v === 'string') ansi[k] = v + } + return { + bg: normalizeColor(t.background, '#0b0b0f'), + fg: normalizeColor(t.foreground, '#e7e7ee'), + cursor: normalizeColor(t.cursor, '#5b5bd6'), + ansi + } +} + +/** Picking a theme returns the LOOK to its defaults: the theme is the whole + * setup. The indicator's volume is carried across, because it is a General + * setting here (how loudly a tab speaks, not what the terminal looks like) + * and a behaviour that reset itself on a theme click would be a setting that + * does not hold. */ +function pickPreset(id: string): void { + const volume = agentIndicator() + setTermThemeId(id) + resetTermExtras() + if (volume !== termExtraDefaults().indicator) setAgentIndicator(volume) +} + +export function TerminalAppearanceSettings(): JSX.Element { + const themeId = useTermThemeId() + const fontPct = useTermFontPct() + const fontId = useTermFontId() + const acrylicOn = useTermAcrylic() + const opacity = useTermOpacity() + // The CHOICES ('' = follow the theme) are what is saved and compared; the + // colours in force are what the swatches show. + const agentCol = useAgentColorChoice() + const doneCol = useAgentDoneColorChoice() + const inForce = useAgentColors() + const custom = useCustomTermTheme() + // What the HOST's style looks like right now, for its card; re-read when the + // style repaints :root. Only a host WITH styles has one (Prism). + const [styleTheme, setStyleTheme] = useState(() => resolveTermTheme(followsHostStyle() ? 'style' : termThemeId())) + useEffect(() => (followsHostStyle() ? watchTermTheme(setStyleTheme) : undefined), []) + // The material is Windows 11's; null while main has not answered yet, which + // reads as supported so the row does not flash disabled on every open. + const [acrylicOk, setAcrylicOk] = useState(null) + // WHAT ACRYLIC MEANS is the host's (core/renderer/host): here the terminal + // setting switches the window's own material on ('window'), in Prism the + // material belongs to the app style and the row only lets it show through + // the terminal ('style'), with no slider. + const acrylic = termHost().acrylic + const windowAcrylic = acrylic.kind === 'window' + useEffect(() => { + if (acrylic.kind !== 'window') return + let live = true + void acrylic.supported().then((ok) => { + if (live) setAcrylicOk(ok) + }) + return () => { + live = false + } + }, [acrylic]) + const noAcrylic = windowAcrylic && acrylicOk === false + // Presets ordered by brightness, the themes nearest your own look leading: + // on a light theme the wall runs light to dark, on a dark one dark to light. + // The direction is MEASURED off the theme worn when the page opened, and + // read once: the chrome follows the theme now, so re-sorting on every pick + // would shuffle the wall under the pointer that just clicked a card. + const [lightFirst] = useState( + () => luminance(normalizeColor(resolveTermTheme(termThemeId()).background, '#000000')) > 0.4 + ) + const sortedPresets = useMemo(() => { + const lum = (bg: string): number => luminance(normalizeColor(bg, '#000000')) + return [...TERM_PRESETS].sort((a, b) => (lightFirst ? lum(b.bg) - lum(a.bg) : lum(a.bg) - lum(b.bg))) + }, [lightFirst]) + // "Save changes": the WHOLE look - palette of the selected theme, font, + // size, agent colours, acrylic and its opacity - lands in the Custom slot, + // reselectable after any theme switch. The indicator's volume is not part + // of it (see pickPreset). + const extras = { + font: fontId, + fontPct, + indicatorColor: agentCol, + doneColor: doneCol, + acrylic: acrylicOn, + opacity + } + // Dirty = the SETTINGS deviate from the selected theme's stock: any theme + // arrives with the defaults, a Custom arrives with what it saved. Comparing + // whole palettes kept the button lit forever - the palette IS the selection. + // Built field by field in the same order as `extras`, since the comparison + // is by JSON and key order is part of that. + const src = themeId === 'custom' && custom ? custom : null + const baseline = { + font: src?.font ?? termExtraDefaults().font, + fontPct: src?.fontPct ?? termExtraDefaults().fontPct, + indicatorColor: src?.indicatorColor ?? termExtraDefaults().indicatorColor, + doneColor: src?.doneColor ?? termExtraDefaults().doneColor, + acrylic: src?.acrylic ?? termExtraDefaults().acrylic, + opacity: src?.opacity ?? termExtraDefaults().opacity + } + const termDirty = JSON.stringify(extras) !== JSON.stringify(baseline) + const saveTermSetup = (): void => { + saveCustomTermTheme({ ...paletteOf(termThemeId()), ...extras }) + setTermThemeId('custom') + } + // The editor popup, seeded from the SELECTED theme. Presets never change - + // editing always lands in the Custom slot. + const [editing, setEditing] = useState(null) + // Measured at click time, so the expand can ANIMATE: max-height can't + // tween to 'none', only to a number, and the content's real height is the + // honest one. 268px is the two collapsed rows. + const [wallHeight, setWallHeight] = useState(268) + const allThemes = wallHeight !== 268 + const themeWall = useRef(null) + const toggleWall = (): void => + setWallHeight(allThemes ? 268 : (themeWall.current?.scrollHeight ?? 2400)) + return ( +
+
+ + } + /> +
+
+ {followsHostStyle() && ( + )} + onPick={() => pickPreset('style')} + onEdit={() => setEditing(paletteOf('style'))} + /> + )} + {custom && ( + { + setTermThemeId('custom') + // The saved setup is more than the palette: font, agent + // colours, acrylic come back with it when the save captured them. + applyCustomExtras(custom) + }} + onEdit={() => setEditing(paletteOf('custom'))} + /> + )} + {sortedPresets.map((p) => { + const t = resolveTermTheme(p.id) + return ( + pickPreset(p.id)} + onEdit={() => setEditing(paletteOf(p.id))} + /> + ) + })} +
+
+
+ +
+ {editing && ( + { + saveCustomTermTheme(t) + setTermThemeId('custom') + setEditing(null) + }} + onCancel={() => setEditing(null)} + /> + )} +
+ + setTermFontPct(Number(v))} + options={FONT_PCTS.map((p) => ({ id: String(p), name: `${p}%` }))} + /> + + {/* The material does not exist before Windows 11, so there the row says + why instead of offering a switch that would do nothing. */} + + + + {windowAcrylic && ( + +
+ + {opacity}% + + setTermOpacity(Number(e.target.value))} + className="h-1.5 w-[180px] cursor-pointer appearance-none rounded-full bg-[var(--p-track)] disabled:cursor-default" + style={{ accentColor: 'var(--p-accent)' }} + /> +
+
+ )} + +
+ {agentCol && ( + + )} + +
+
+ +
+ {doneCol && ( + + )} + +
+
+
+ ) +} diff --git a/core/renderer/settings/TerminalBehaviour.tsx b/core/renderer/settings/TerminalBehaviour.tsx new file mode 100644 index 0000000..3c32492 --- /dev/null +++ b/core/renderer/settings/TerminalBehaviour.tsx @@ -0,0 +1,69 @@ +import { useEffect, useState, type JSX } from 'react' +import { termApi } from '../host' +import { savedShellId, saveShellId } from '../lib/termPrefs' +import { setAgentIndicator, useAgentIndicator, type AgentIndicator } from '../lib/termLook' +import { Pref, Segmented, Select } from './fields' + +// THE TERMINAL'S BEHAVIOUR ROWS, for both hosts (#15): which shell a new +// terminal launches, and how loudly a working agent shows on its tab. Rows +// rather than a page, because where they sit is each app's own layout: Prism +// Terminal puts them under General, Prism under its Terminal tab. + +/** Which shell new terminals launch, from the ones main detected. */ +export function ShellSetting(): JSX.Element | null { + // Fetched when the row first shows. A saved id may name a shell that no + // longer exists; the select then shows the real default, which is also what + // a new terminal would actually launch. + const [shells, setShells] = useState>([]) + const [choice, setChoice] = useState(() => savedShellId() ?? '') + useEffect(() => { + let live = true + void termApi() + .termShells() + .then((list) => { + if (live) setShells(list.map((s) => ({ id: s.id, name: s.name }))) + }) + return () => { + live = false + } + }, []) + if (!shells.length) return null + const value = shells.some((s) => s.id === choice) ? choice : (shells[0]?.id ?? '') + return ( + + setDraft(e.target.value)} + onBlur={() => commit(text)} + onKeyDown={(e) => { + if (e.key === 'Enter') commit(text) + else if (e.key === 'Escape') setDraft(null) + }} + spellCheck={false} + aria-label={`${label} hex value`} + className="w-[64px] rounded-[var(--p-radius-sm)] border border-[color:var(--p-line)] bg-[var(--p-control)] px-1 py-0.5 text-center font-mono text-[10.5px] uppercase text-[var(--p-text)] focus-visible:border-[var(--p-accent-hi)] focus-visible:outline-none" + /> + + + ) +} + +/** Two or three exclusive choices as one control rather than a row of buttons. */ +export function Segmented({ + value, + onChange, + options +}: { + value: T + onChange: (v: T) => void + options: Array<{ id: T; name: string }> +}): JSX.Element { + return ( +
+ {options.map((o) => { + const on = o.id === value + return ( + + ) + })} +
+ ) +} + +/** The one save button: accent while there is something to save, quietly grey + * when there is not. */ +export function SaveButton({ dirty, onClick, title }: { dirty: boolean; onClick: () => void; title: string }): JSX.Element { + return ( + + ) +} + +/** The section head: title, one line under it, and the save button in the + * top-right corner. */ +export function ThemeHead({ sub, save }: { sub: string; save: ReactNode }): JSX.Element { + return ( +
+
+
Theme
+

{sub}

+
+ {save} +
+ ) +} + +// Both pages are plain lists: one row per setting, name + one line of +// explanation on the left, its control on the right, hairline between. No +// panels: the pages are short enough that grouping them would be ceremony. + +// A list of preference rows, hairline above and below each one. Full width: the +// control sits at the right edge of the page, where the eye already is. +export const ROWS = 'border-t border-[color:var(--p-line)]' + +/** One setting: copy on the left, control on the right. The hint is one line - + * it truncates rather than wraps, because a setting that needs a paragraph + * needs a better name instead. `off` dims the whole row: a control that + * cannot be used should not look like one that can. */ +export function Pref({ + id, + label, + hint, + off, + children +}: { + id: string + label: string + hint?: string + off?: boolean + children: ReactNode +}): JSX.Element { + return ( +
+
+ + {hint && ( +

+ {hint} +

+ )} +
+
{children}
+
+ ) +} + +/** A styled dropdown, not a native select: the popup wears the app's own menu + * look (flat panel, hover token, a check on the active row), and each option + * can carry a style - which is how the font picker previews its faces. */ +export function Select({ + id, + value, + onChange, + options +}: { + id: string + value: string + onChange: (v: string) => void + options: Array<{ id: string; name: string; style?: React.CSSProperties }> +}): JSX.Element { + const [open, setOpen] = useState(false) + const box = useRef(null) + useEffect(() => { + if (!open) return + const close = (): void => setOpen(false) + const onKey = (e: KeyboardEvent): void => { + if (e.key === 'Escape') { + e.stopPropagation() + close() + } + } + const onDown = (e: PointerEvent): void => { + if (!box.current?.contains(e.target as Node)) close() + } + window.addEventListener('keydown', onKey, true) + window.addEventListener('pointerdown', onDown, true) + window.addEventListener('blur', close) + return () => { + window.removeEventListener('keydown', onKey, true) + window.removeEventListener('pointerdown', onDown, true) + window.removeEventListener('blur', close) + } + }, [open]) + const cur = options.find((o) => o.id === value) + return ( +
+ + {open && ( +
+ {options.map((o) => { + const on = o.id === value + return ( + + ) + })} +
+ )} +
+ ) +} + +/** The quiet accent-outlined button a row uses for a verb of its own. */ +export const ROW_BUTTON = + 'h-8 rounded-lg border border-[color:var(--p-accent)]/45 bg-[var(--p-accent)]/10 px-3 text-[12px] font-semibold text-[var(--p-accent-hi)] transition-colors hover:border-[color:var(--p-accent)] hover:bg-[var(--p-accent)]/18 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--p-accent)]/45' + diff --git a/core/renderer/settings/options.test.ts b/core/renderer/settings/options.test.ts new file mode 100644 index 0000000..f0771e4 --- /dev/null +++ b/core/renderer/settings/options.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { readdirSync, readFileSync } from 'fs' +import { join } from 'path' +import { TERMINAL_OPTIONS, terminalOptionIds } from './options' + +// The list and the sections must say the same thing: the list is what each +// host's parity check compares its Settings page against, so a row added to a +// section and not to the list (or the other way round) would let the two apps +// drift while every test stayed green. + +const sections = readdirSync(__dirname) + .filter((f) => f.endsWith('.tsx')) + .map((f) => readFileSync(join(__dirname, f), 'utf8')) + .join('\n') + +/** Every row id a shared section renders: ` m[1] ?? m[2]) +) + +describe('the terminal options list', () => { + it('names every row the shared sections render, and nothing they do not', () => { + expect([...rendered].sort()).toEqual(TERMINAL_OPTIONS.map((o) => o.id).sort()) + }) + + it('has one storage key per option, all under prism.term', () => { + const keys = TERMINAL_OPTIONS.map((o) => o.key) + expect(new Set(keys).size).toBe(keys.length) + for (const k of keys) expect(k.startsWith('prism.term.')).toBe(true) + }) + + it('drops only the opacity slider where the host style owns the window material', () => { + const all = terminalOptionIds({ windowAcrylic: true }) + const prism = terminalOptionIds({ windowAcrylic: false }) + expect(all.filter((id) => !prism.includes(id))).toEqual(['term-opacity']) + }) +}) diff --git a/core/renderer/settings/options.ts b/core/renderer/settings/options.ts new file mode 100644 index 0000000..9fa2a6c --- /dev/null +++ b/core/renderer/settings/options.ts @@ -0,0 +1,44 @@ +/** + * EVERY TERMINAL OPTION, BY ID (#15). The owner's rule: the two apps' terminal + * settings are the same settings ("the setting names, types, how they + * function"); only the personal values differ. The shared sections render + * these rows as `data-pref=""`, and each app has a PARITY TEST asserting + * that what it shows is this list, so an option can never exist in one app and + * not the other without a test going red. + * + * `hosts` names where a row legitimately does not appear, and why. + */ +export interface TerminalOption { + id: string + label: string + type: 'choice' | 'switch' | 'range' | 'colour' | 'theme' + /** The localStorage key behind it. Same key in both apps, separate stores. */ + key: string + /** Absent = every host. */ + onlyWhere?: 'the terminal owns the window acrylic' +} + +export const TERMINAL_OPTIONS: readonly TerminalOption[] = [ + { id: 'term-theme', label: 'Theme', type: 'theme', key: 'prism.term.theme' }, + { id: 'term-font-family', label: 'Font', type: 'choice', key: 'prism.term.font' }, + { id: 'term-font', label: 'Font size', type: 'choice', key: 'prism.term.fontPct' }, + { id: 'term-acrylic', label: 'Acrylic background', type: 'switch', key: 'prism.term.acrylic' }, + { + id: 'term-opacity', + label: 'Opacity', + type: 'range', + key: 'prism.term.opacity', + // In Prism the window material belongs to the app style; a second alpha + // over the same glass would fight it (owner, 2026-09-19). + onlyWhere: 'the terminal owns the window acrylic' + }, + { id: 'agent-color', label: 'Agent working indicator', type: 'colour', key: 'prism.term.agentColor' }, + { id: 'agent-done-color', label: 'Agent finished indicator', type: 'colour', key: 'prism.term.agentDoneColor' }, + { id: 'term-shell', label: 'Shell', type: 'choice', key: 'prism.term.shell' }, + { id: 'agent-indicator', label: 'Agent indicator', type: 'choice', key: 'prism.term.agentIndicator' } +] + +/** The option ids a host should be showing. */ +export function terminalOptionIds(host: { windowAcrylic: boolean }): string[] { + return TERMINAL_OPTIONS.filter((o) => !o.onlyWhere || host.windowAcrylic).map((o) => o.id) +} diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 4001984..d3998e7 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -32,19 +32,18 @@ import { ContextMenu } from './components/ContextMenu' import { rememberRoot } from '@core/renderer/lib/recentRoots' import { savedShellId } from '@core/renderer/lib/termPrefs' import { newTabFolder, newTabMode } from './lib/newTabPrefs' -import { confirmClose } from './lib/closePrefs' +import { + AGENT_NAMES, + asksBeforeClosingTab, + closeQuestionTitle, + holdsWindowClose +} from '@core/renderer/lib/agentClose' import { onTermLookChange, termAcrylic, termOpacity, termThemeId } from '@core/renderer/lib/termLook' import { presetAccent, resolveTermTheme } from '@core/renderer/lib/termTheme' import { applyChrome, chromeTokens } from './lib/chromeTheme' const Settings = lazy(() => import('./components/Settings')) -const AGENT_NAMES: Record = { - claude: 'Claude', - codex: 'Codex', - other: 'The agent' -} - /** What a close question is about to interrupt. */ interface Ask { /** A tab id, or 'window' for the whole window. */ @@ -178,9 +177,10 @@ export default function App(): JSX.Element { // work, it just closed"). The first build asked only mid-answer, and an // agent waiting at its own prompt is still a conversation the close // ends: Prism's rule, which is the one that was expected. A plain shell - // has nothing to lose and closes unasked. 'Off' means off: a - // confirmation that appears anyway is a setting that lies. - if (tab && tab.kind !== 'settings' && confirmClose() && agents.has(id)) { + // has nothing to lose and closes unasked. NOT a setting any more + // (owner, same day): the rule is core/renderer/lib/agentClose, the same + // in Prism. + if (tab && tab.kind !== 'settings' && asksBeforeClosingTab(agents.has(id))) { const label = tabLabels(st.tabs)[st.tabs.indexOf(tab)] setAsk({ target: id, @@ -241,7 +241,7 @@ export default function App(): JSX.Element { }, [state, activeId, agentIds, agentKinds]) useEffect(() => { - window.prism.setAgentBusy(confirmClose() && workingIds.size > 0) + window.prism.setAgentBusy(holdsWindowClose(workingIds.size)) }, [workingIds]) // Whatever a tab interaction did to DOM focus, the shell in front gets the @@ -280,7 +280,10 @@ export default function App(): JSX.Element { // Windows Terminal leaves in the hand. hit() void newTab() - } else if (e.shiftKey && k === 'w') { + } else if (k === 'w') { + // Ctrl+W closes a tab in BOTH apps (owner, 2026-09-19), with or without + // shift. Known cost, accepted: the shell loses delete-word on that + // chord; Ctrl+Backspace does the same job. hit() const id = live.current.state.activeId if (id) requestClose(id) @@ -404,13 +407,7 @@ export default function App(): JSX.Element { {ask && ( void - label: string - disabled?: boolean -}): JSX.Element { - return ( - - ) -} - -/** - * A colour well: type a hex, or open the system picker. - * - * The hex is a field rather than a readout - a colour you already know is - * quicker typed than hunted for in a picker, and it is how a colour arrives - * from anywhere else. It is held as text while you edit and only applied when - * it parses, so half-typed values don't repaint the app on every keystroke. - */ -/** "#abc", "abc", "#aabbcc" → "#aabbcc"; anything else → null. */ -function parseHexInput(raw: string): string | null { - const hex = '#' + raw.trim().replace(/^#/, '') - const full = /^#[0-9a-f]{3}$/i.test(hex) - ? '#' + - hex - .slice(1) - .split('') - .map((c) => c + c) - .join('') - : hex - return /^#[0-9a-f]{6}$/i.test(full) ? full.toLowerCase() : null -} - -/** The compact colour control: a hex field and a swatch. Every place a colour - * is chosen carries the field - a picker without one strands anyone pasting - * a code from elsewhere. */ -function HexSwatch({ - label, - value, - onChange -}: { - label: string - value: string - onChange: (v: string) => void -}): JSX.Element { - // While you are typing the field holds the draft; the rest of the time it is - // simply the colour. No effect syncing the two, which is a render loop - // waiting to happen. - const [draft, setDraft] = useState(null) - const text = draft ?? value - const commit = (raw: string): void => { - setDraft(null) // either it took, or the field goes back to the colour - const full = parseHexInput(raw) - if (full) onChange(full) - } - return ( - - setDraft(e.target.value)} - onBlur={() => commit(text)} - onKeyDown={(e) => { - if (e.key === 'Enter') commit(text) - else if (e.key === 'Escape') setDraft(null) - }} - spellCheck={false} - aria-label={`${label} hex value`} - className="w-[64px] rounded-[var(--p-radius-sm)] border border-[color:var(--p-line)] bg-[var(--p-control)] px-1 py-0.5 text-center font-mono text-[10.5px] uppercase text-[var(--p-text)] focus-visible:border-[var(--p-accent-hi)] focus-visible:outline-none" - /> - - - ) -} - -/** Two or three exclusive choices as one control rather than a row of buttons. */ -function Segmented({ - value, - onChange, - options -}: { - value: T - onChange: (v: T) => void - options: Array<{ id: T; name: string }> -}): JSX.Element { - return ( -
- {options.map((o) => { - const on = o.id === value - return ( - - ) - })} -
- ) -} - -/** The one save button: accent while there is something to save, quietly grey - * when there is not. */ -function SaveButton({ dirty, onClick, title }: { dirty: boolean; onClick: () => void; title: string }): JSX.Element { - return ( - - ) -} - -/** The section head: title, one line under it, and the save button in the - * top-right corner. */ -function ThemeHead({ sub, save }: { sub: string; save: ReactNode }): JSX.Element { - return ( -
-
-
Theme
-

{sub}

-
- {save} -
- ) -} - -// Both pages are plain lists: one row per setting, name + one line of -// explanation on the left, its control on the right, hairline between. No -// panels: the pages are short enough that grouping them would be ceremony. - -// A list of preference rows, hairline above and below each one. Full width: the -// control sits at the right edge of the page, where the eye already is. -const ROWS = 'border-t border-[color:var(--p-line)]' - -/** One setting: copy on the left, control on the right. The hint is one line - - * it truncates rather than wraps, because a setting that needs a paragraph - * needs a better name instead. `off` dims the whole row: a control that - * cannot be used should not look like one that can. */ -function Pref({ - id, - label, - hint, - off, - children -}: { - id: string - label: string - hint?: string - off?: boolean - children: ReactNode -}): JSX.Element { - return ( -
-
- - {hint && ( -

- {hint} -

- )} -
-
{children}
-
- ) -} - -/** A styled dropdown, not a native select: the popup wears the app's own menu - * look (flat panel, hover token, a check on the active row), and each option - * can carry a style - which is how the font picker previews its faces. */ -function Select({ - id, - value, - onChange, - options -}: { - id: string - value: string - onChange: (v: string) => void - options: Array<{ id: string; name: string; style?: React.CSSProperties }> -}): JSX.Element { - const [open, setOpen] = useState(false) - const box = useRef(null) - useEffect(() => { - if (!open) return - const close = (): void => setOpen(false) - const onKey = (e: KeyboardEvent): void => { - if (e.key === 'Escape') { - e.stopPropagation() - close() - } - } - const onDown = (e: PointerEvent): void => { - if (!box.current?.contains(e.target as Node)) close() - } - window.addEventListener('keydown', onKey, true) - window.addEventListener('pointerdown', onDown, true) - window.addEventListener('blur', close) - return () => { - window.removeEventListener('keydown', onKey, true) - window.removeEventListener('pointerdown', onDown, true) - window.removeEventListener('blur', close) - } - }, [open]) - const cur = options.find((o) => o.id === value) - return ( -
- - {open && ( -
- {options.map((o) => { - const on = o.id === value - return ( - - ) - })} -
- )} -
- ) -} - -/** The quiet accent-outlined button a row uses for a verb of its own. */ -const ROW_BUTTON = - 'h-8 rounded-lg border border-[color:var(--p-accent)]/45 bg-[var(--p-accent)]/10 px-3 text-[12px] font-semibold text-[var(--p-accent-hi)] transition-colors hover:border-[color:var(--p-accent)] hover:bg-[var(--p-accent)]/18 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--p-accent)]/45' - -/* ---------- appearance ---------- */ - -/** A theme as a miniature terminal: prompt, coloured ls output, cursor. The - * point is the SYNTAX colours - a swatch row says nothing about how a real - * session will read. Every card paints itself with its OWN palette, never the - * chrome's tokens: the chrome is whichever theme is selected, and a wall of - * cards wearing that would be one theme drawn forty times. */ -function TermThemeCard({ - id, - name, - on, - bg, - fg, - cursor, - ansi, - onPick, - onEdit -}: { - id: string - name: string - on: boolean - bg: string - fg: string - cursor: string - ansi: { green: string; yellow: string; blue: string; cyan: string; red: string } - onPick: () => void - /** Rendered as a pencil on the SELECTED card only. */ - onEdit?: () => void -}): JSX.Element { - return ( - - ) -} - -const ANSI_KEYS = [ - 'black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white', - 'brightBlack', 'brightRed', 'brightGreen', 'brightYellow', 'brightBlue', 'brightMagenta', 'brightCyan', 'brightWhite' -] as const - -/** The five colours a card's miniature session reads, with the fallbacks a - * custom palette missing one of them gets. */ -const cardAnsi = ( - a: Record -): { green: string; yellow: string; blue: string; cyan: string; red: string } => ({ - green: a.green ?? '#8cc265', - yellow: a.yellow ?? '#d1a54b', - blue: a.blue ?? '#4aa5f0', - cyan: a.cyan ?? '#42b3c2', - red: a.red ?? '#e05561' -}) - -/** The Tabby-style editor: every colour of a theme, individually, with the - * live preview beside them. Save lands in the single Custom slot. */ -function TermThemeEditor({ - seed, - onSave, - onCancel -}: { - seed: CustomTermTheme - onSave: (t: CustomTermTheme) => void - onCancel: () => void -}): JSX.Element { - const [draft, setDraft] = useState(seed) - const set = (k: string, v: string): void => - setDraft((d) => - k === 'bg' || k === 'fg' || k === 'cursor' - ? { ...d, [k]: v } - : { ...d, ansi: { ...d.ansi, [k]: v } } - ) - const well = (label: string, key: string, value: string): JSX.Element => ( - - ) - return ( - // A popup, not an inline section: below the card grid the editor sat out - // of view. data-owns-escape keeps App's window Escape away; the backdrop - // and Escape both cancel. -
{ - if (e.target === e.currentTarget) onCancel() - }} - onKeyDown={(e) => { - if (e.key === 'Escape') { - e.stopPropagation() - onCancel() - } - }} - role="dialog" - aria-label="Edit terminal colours" - > -
-
Edit colours
-
-
- {well('Background', 'bg', draft.bg)} - {well('Foreground', 'fg', draft.fg)} - {well('Cursor', 'cursor', draft.cursor)} - {ANSI_KEYS.map((k) => well(k, k, draft.ansi[k] ?? '#888888'))} -
- {}} - /> -
-
- - -
-
-
- ) -} - -/** The selected theme's palette as the editor and the Custom slot hold it. - * Normalised: a theme may publish rgba() or #rrggbbaa, and a colour input - * handed either silently renders black. */ -function paletteOf(id: string): Pick { - const t = resolveTermTheme(id) - const ansi: Record = {} - for (const k of ANSI_KEYS) { - const v = t[k] - if (typeof v === 'string') ansi[k] = v - } - return { - bg: normalizeColor(t.background, '#0b0b0f'), - fg: normalizeColor(t.foreground, '#e7e7ee'), - cursor: normalizeColor(t.cursor, '#5b5bd6'), - ansi - } -} +// onTermLookChange to repaint them. -/** Picking a theme returns the LOOK to its defaults: the theme is the whole - * setup. The indicator's volume is carried across, because it is a General - * setting here (how loudly a tab speaks, not what the terminal looks like) - * and a behaviour that reset itself on a theme click would be a setting that - * does not hold. */ -function pickPreset(id: string): void { - const volume = agentIndicator() - setTermThemeId(id) - resetTermExtras() - if (volume !== termExtraDefaults().indicator) setAgentIndicator(volume) -} - -function AppearanceTab(): JSX.Element { - const themeId = useTermThemeId() - const fontPct = useTermFontPct() - const fontId = useTermFontId() - const acrylicOn = useTermAcrylic() - const opacity = useTermOpacity() - // The CHOICES ('' = follow the theme) are what is saved and compared; the - // colours in force are what the swatches show. - const agentCol = useAgentColorChoice() - const doneCol = useAgentDoneColorChoice() - const inForce = useAgentColors() - const custom = useCustomTermTheme() - // The material is Windows 11's; null while main has not answered yet, which - // reads as supported so the row does not flash disabled on every open. - const [acrylicOk, setAcrylicOk] = useState(null) - useEffect(() => { - let live = true - void window.prism.acrylicSupported().then((ok) => { - if (live) setAcrylicOk(ok) - }) - return () => { - live = false - } - }, []) - const noAcrylic = acrylicOk === false - // Presets ordered by brightness, the themes nearest your own look leading: - // on a light theme the wall runs light to dark, on a dark one dark to light. - // The direction is MEASURED off the theme worn when the page opened, and - // read once: the chrome follows the theme now, so re-sorting on every pick - // would shuffle the wall under the pointer that just clicked a card. - const [lightFirst] = useState( - () => luminance(normalizeColor(resolveTermTheme(termThemeId()).background, '#000000')) > 0.4 - ) - const sortedPresets = useMemo(() => { - const lum = (bg: string): number => luminance(normalizeColor(bg, '#000000')) - return [...TERM_PRESETS].sort((a, b) => (lightFirst ? lum(b.bg) - lum(a.bg) : lum(a.bg) - lum(b.bg))) - }, [lightFirst]) - // "Save changes": the WHOLE look - palette of the selected theme, font, - // size, agent colours, acrylic and its opacity - lands in the Custom slot, - // reselectable after any theme switch. The indicator's volume is not part - // of it (see pickPreset). - const extras = { - font: fontId, - fontPct, - indicatorColor: agentCol, - doneColor: doneCol, - acrylic: acrylicOn, - opacity - } - // Dirty = the SETTINGS deviate from the selected theme's stock: any theme - // arrives with the defaults, a Custom arrives with what it saved. Comparing - // whole palettes kept the button lit forever - the palette IS the selection. - // Built field by field in the same order as `extras`, since the comparison - // is by JSON and key order is part of that. - const src = themeId === 'custom' && custom ? custom : null - const baseline = { - font: src?.font ?? termExtraDefaults().font, - fontPct: src?.fontPct ?? termExtraDefaults().fontPct, - indicatorColor: src?.indicatorColor ?? termExtraDefaults().indicatorColor, - doneColor: src?.doneColor ?? termExtraDefaults().doneColor, - acrylic: src?.acrylic ?? termExtraDefaults().acrylic, - opacity: src?.opacity ?? termExtraDefaults().opacity - } - const termDirty = JSON.stringify(extras) !== JSON.stringify(baseline) - const saveTermSetup = (): void => { - saveCustomTermTheme({ ...paletteOf(termThemeId()), ...extras }) - setTermThemeId('custom') - } - // The editor popup, seeded from the SELECTED theme. Presets never change - - // editing always lands in the Custom slot. - const [editing, setEditing] = useState(null) - // Measured at click time, so the expand can ANIMATE: max-height can't - // tween to 'none', only to a number, and the content's real height is the - // honest one. 268px is the two collapsed rows. - const [wallHeight, setWallHeight] = useState(268) - const allThemes = wallHeight !== 268 - const themeWall = useRef(null) - const toggleWall = (): void => - setWallHeight(allThemes ? 268 : (themeWall.current?.scrollHeight ?? 2400)) - return ( -
-
- - } - /> -
-
- {custom && ( - { - setTermThemeId('custom') - // The saved setup is more than the palette: font, agent - // colours, acrylic come back with it when the save captured them. - applyCustomExtras(custom) - }} - onEdit={() => setEditing(paletteOf('custom'))} - /> - )} - {sortedPresets.map((p) => { - const t = resolveTermTheme(p.id) - return ( - pickPreset(p.id)} - onEdit={() => setEditing(paletteOf(p.id))} - /> - ) - })} -
-
-
- -
- {editing && ( - { - saveCustomTermTheme(t) - setTermThemeId('custom') - setEditing(null) - }} - onCancel={() => setEditing(null)} - /> - )} -
- - setTermFontPct(Number(v))} - options={FONT_PCTS.map((p) => ({ id: String(p), name: `${p}%` }))} - /> - - {/* The material does not exist before Windows 11, so there the row says - why instead of offering a switch that would do nothing. */} - - - - -
- - {opacity}% - - setTermOpacity(Number(e.target.value))} - className="h-1.5 w-[180px] cursor-pointer appearance-none rounded-full bg-[var(--p-track)] disabled:cursor-default" - style={{ accentColor: 'var(--p-accent)' }} - /> -
-
- -
- {agentCol && ( - - )} - -
-
- -
- {doneCol && ( - - )} - -
-
-
- ) -} /* ---------- general ---------- */ function GeneralTab(): JSX.Element { - // The shells main detected, fetched when the page first shows. `saved` may - // name one that no longer exists; the select then shows the real default, - // which is also what a new terminal would actually launch. - const [shells, setShells] = useState>([]) - const [shellChoice, setShellChoice] = useState(() => savedShellId() ?? '') const [version, setVersion] = useState('') // Explorer's context-menu verb lives in the registry, not in a settings // file: the switch reports what Windows actually has. @@ -875,9 +26,6 @@ function GeneralTab(): JSX.Element { const [verbBusy, setVerbBusy] = useState(true) useEffect(() => { let live = true - void window.prism.termShells().then((list) => { - if (live) setShells(list.map((sh) => ({ id: sh.id, name: sh.name }))) - }) void window.prism.appVersion().then((v) => { if (live) setVersion(v) }) @@ -899,10 +47,6 @@ function GeneralTab(): JSX.Element { setVerbBusy(false) }) } - const shellValue = shells.some((sh) => sh.id === shellChoice) ? shellChoice : (shells[0]?.id ?? '') - - const agentInd = useAgentIndicator() - const askClose = useConfirmClose() const tabMode = useNewTabMode() const tabFolder = useNewTabFolder() const chooseFolder = (): void => { @@ -948,41 +92,8 @@ function GeneralTab(): JSX.Element { />
- {shells.length > 0 && ( - -