Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,11 @@ session comes back when you reopen the app.
| | |
|---|---|
| **Agent indicator on every tab** | A tab lights the moment Claude Code or Codex starts working and shows when the answer has landed, a question is waiting, or a turn failed. Pick Minimal, Ring or Full; a finished tab wears the app icon's rainbow, flowing. |
| **Two tab styles** | Classic flat tabs, or Powerline: chevron segments whose arrow edge is the agent's mark. |
| **Two tab styles** | Classic flat tabs, or Powerline: chevron segments whose arrow edge is the agent's mark. `Ctrl+Tab` goes along the strip or back to the tab you used last. |
| **Sessions that come back** | Close the app and reopen it: every tab returns in its folder, and agent conversations resume on their own. |
| **Local dictation** | Hold `Right Alt` and speak. [whisper.cpp](https://github.com/ggml-org/whisper.cpp) runs on your PC, offline, and never presses Enter. |
| **Command help** | Press `F1`, describe a task in plain words and copy the command. Works offline. |
| **Themes that dress the window** | About forty themes, a theme editor, fifteen fonts and acrylic. The whole window follows the theme. |
| **Themes that dress the window** | About forty themes, a theme editor, fifteen fonts and a see-through window. The whole window follows the theme. |
| **Made for AI CLIs** | Images paste into Claude Code, dropped files type their path, and `Shift+Enter` is a newline. |
| **Asks before it interrupts** | Closing a tab with a running agent asks first. A plain shell just closes. |
| **Open terminal here** | Right-click any folder in Explorer to open it in a new tab. |
Expand All @@ -52,7 +52,7 @@ Works with PowerShell 7, Windows PowerShell, Command Prompt and any installed WS
| Key | Action |
|---|---|
| `Ctrl+T` / `Ctrl+W` | New tab / close tab |
| `Ctrl+Tab` / `Ctrl+Shift+Tab` | Next / previous tab |
| `Ctrl+Tab` / `Ctrl+Shift+Tab` | Next / previous tab, in strip order or most recently used (Settings > Appearance > Tab switching) |
| `Ctrl+1` to `Ctrl+9` | Jump to a tab |
| `Ctrl+F` | Find in the scrollback (`Ctrl+Shift+F` too; in vim or less, `Ctrl+F` stays page down) |
| `Ctrl+,` | Settings |
Expand Down
11 changes: 11 additions & 0 deletions core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,17 @@ there is no Opacity row and no `termOpacity`/`setTermOpacity`: where
`acrylic.kind` is 'window' the theme Background's alpha is the window's
see-through (`termGroundAlpha`, `withGroundAlpha`, `legacyTermOpacity` for a
host's one-time migration); `TERMINAL_OPTIONS` is the same list in every host.
Since 0.31.0 (#156) the switch alone makes such a window see-through: an
opaque ground in force under it paints Prism's default levels
(`seeThroughAlpha`, 0xb9 dark / 0xd1 light, `lib/seeThrough.ts`) through
`paintsAlpha`, the one rule `termGroundAlpha` and Save changes' dirty check
share; `termAcrylicInForce` / `useTermAcrylicInForce` is the switch with High
Contrast held solid (`seeThroughBlocked`), which is what a host paints by; the
row is named by `acrylicLabel()` ("See-through window" there, the list's label
in Prism) and drawn right under the wall, before `afterTheme`.
Since 0.31.0 (#157) the wall orders the presets after its leading cards with
`orderTermThemes` (`lib/themeOrder.ts`): neutral grounds first, then coloured,
each black to white by OKLab L; neutral is chroma below `NEUTRAL_CHROMA`.
Spec:
`docs/superpowers/specs/2026-10-03-colour-picker-alpha-design.md`.

Expand Down
2 changes: 1 addition & 1 deletion core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "prism-term-core",
"version": "0.30.0",
"version": "0.31.0",
"description": "What Prism Terminal and Prism share: the terminal (pty, shells, agent detection and indicator, themes, links, the panel, dictation) and the update chip with its window. TypeScript source, compiled by the host.",
"license": "MIT",
"private": true,
Expand Down
3 changes: 2 additions & 1 deletion core/renderer/components/ThemeSwitchAsk.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useEffect, useRef, type JSX } from 'react'
import { ROW_BUTTON } from '../settings/fields'
import { acrylicWord } from '../settings/sections/opts'

/**
* UNSAVED CHANGES ASK BEFORE A THEME SWITCH (owner, 2026-09-23: "if you have
Expand Down Expand Up @@ -64,7 +65,7 @@ export default function ThemeSwitchAsk({
Save your changes first?
</h2>
<p id="theme-switch-ask-body" className="mt-1.5 text-[12.5px] leading-relaxed text-[var(--p-dim)]">
Switching theme puts the agent colours and acrylic back to the new theme&apos;s own.
Switching theme puts the agent colours and {acrylicWord()} back to the new theme&apos;s own.
Your changes are not saved yet.
</p>
<div className="mt-5 flex justify-end gap-2">
Expand Down
73 changes: 72 additions & 1 deletion core/renderer/host.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { configureTermCore, resetTermCore, termHost, type TermApi, type TermHostConfig } from './host'
import { agentColorChoice, agentIndicator, saveCustomTermTheme, setTermThemeId, termAcrylic, termExtraDefaults, termGroundAlpha, termThemeId, withGroundAlpha } from './lib/termLook'
import { agentColorChoice, agentIndicator, paintsAlpha, saveCustomTermTheme, setTermAcrylic, setTermThemeId, termAcrylic, termAcrylicInForce, termExtraDefaults, termGroundAlpha, termThemeId, withGroundAlpha } from './lib/termLook'
import { CH } from '../shared/channels'

// The seam is what lets ONE terminal serve two apps. These tests are the two
Expand Down Expand Up @@ -117,3 +117,74 @@ describe('the ground alpha a saved setup carries', () => {
expect(withGroundAlpha(palette)).toEqual(palette)
})
})

// #156: the switch alone makes the window see-through where the terminal owns
// the window acrylic, as Prism's "See-through window" does. An opaque ground
// in force paints Prism's default, measured light or dark; a ground that
// carries its own alpha keeps it; High Contrast stays solid.
describe('the see-through window (#156)', () => {
afterEach(() => resetTermCore())
beforeEach(() => localStorage.clear())

it('switch on over a preset: the default, by the ground light or dark', () => {
configureTermCore(PRISM_TERMINAL)
setTermThemeId('dracula')
expect(termGroundAlpha()).toBe(1)
setTermAcrylic(true)
expect(termGroundAlpha()).toBe(0xb9 / 255)
setTermThemeId('paper')
setTermAcrylic(true)
expect(termGroundAlpha()).toBe(0xd1 / 255)
})
it('an opaque picked Background is measured, not the theme under it', () => {
let picked: string | null = '#ffffff'
configureTermCore({ ...PRISM_TERMINAL, terminalGround: () => picked })
setTermThemeId('dracula')
setTermAcrylic(true)
expect(termGroundAlpha()).toBe(0xd1 / 255)
// A picked alpha wins over the default: the Alpha slider still tunes it.
picked = '#ffffff99'
expect(termGroundAlpha()).toBe(0x99 / 255)
})
it('an opaque Custom paints the default; one with an alpha keeps it', () => {
configureTermCore(PRISM_TERMINAL)
saveCustomTermTheme({ bg: '#111111', fg: '#eeeeee', cursor: '#ff0000', ansi: {} })
setTermThemeId('custom')
setTermAcrylic(true)
expect(termGroundAlpha()).toBe(0xb9 / 255)
saveCustomTermTheme({ bg: '#11111180', fg: '#eeeeee', cursor: '#ff0000', ansi: {} })
expect(termGroundAlpha()).toBe(0x80 / 255)
})
it('High Contrast stays solid, and the stored choice comes back after it', () => {
configureTermCore(PRISM_TERMINAL)
setTermThemeId('high-contrast')
setTermAcrylic(true)
expect(termAcrylic()).toBe(true)
expect(termAcrylicInForce()).toBe(false)
expect(termGroundAlpha()).toBe(1)
setTermThemeId('dracula')
expect(termAcrylicInForce()).toBe(true)
})
it('Save as Custom carries the default, so a saved setup stays see-through', () => {
configureTermCore(PRISM_TERMINAL)
setTermThemeId('dracula')
setTermAcrylic(true)
expect(withGroundAlpha({ bg: '#1e1f29' }).bg).toBe('#1e1f29b9')
})
it('in Prism, where the style owns the glass, nothing changes', () => {
configureTermCore(PRISM)
setTermThemeId('dracula')
setTermAcrylic(true)
expect(termGroundAlpha()).toBe(1)
setTermThemeId('high-contrast')
expect(termAcrylicInForce()).toBe(true)
})
it('paintsAlpha: the one rule the window and the dirty check share', () => {
expect(paintsAlpha(1, '#000000', false)).toBe(1)
expect(paintsAlpha(1, '#000000', true)).toBe(0xb9 / 255)
expect(paintsAlpha(1, '#ffffff', true)).toBe(0xd1 / 255)
expect(paintsAlpha(0x80 / 255, '#ffffff', true)).toBe(0x80 / 255)
// The 30% floor, on or off.
expect(paintsAlpha(0x05 / 255, '#000000', true)).toBe(0x4d / 255)
})
})
46 changes: 46 additions & 0 deletions core/renderer/lib/__snapshots__/themeOrder.test.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`orderTermThemes (#157) > holds the whole order 1`] = `
[
"pitch",
"high-contrast",
"volt",
"prism",
"campbell",
"pt-default",
"graphite",
"monokai",
"gruvbox-dark",
"mist",
"catppuccin-latte",
"paper",
"phosphor",
"amber",
"garnet",
"plum",
"moss",
"rosewood",
"tokyonight",
"horizon",
"dracula",
"catppuccin-mocha",
"kanagawa",
"ocean",
"umber",
"solarized-dark",
"synthwave",
"marine",
"cobalt",
"nord",
"retro",
"cinder",
"fawn",
"sage",
"lavender",
"peach",
"mint",
"blossom",
"sky",
"butter",
]
`;
28 changes: 28 additions & 0 deletions core/renderer/lib/seeThrough.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { SEE_THROUGH_ALPHA, SEE_THROUGH_MAX, seeThroughAlpha } from './seeThrough'

describe('the default see-through (#156)', () => {
it("is Prism's two levels as they paint: 0xb9 on dark, 0xd1 on light", () => {
expect(Math.round(SEE_THROUGH_ALPHA.dark * 255)).toBe(0xb9)
expect(Math.round(SEE_THROUGH_ALPHA.light * 255)).toBe(0xd1)
expect(seeThroughAlpha('#0b0b0f')).toBe(SEE_THROUGH_ALPHA.dark)
expect(seeThroughAlpha('#ffffff')).toBe(SEE_THROUGH_ALPHA.light)
})
it('light is measured: relative luminance above 0.4', () => {
// #aaaaaa is 0.402, #a9a9a9 is 0.397: either side of the edge.
expect(seeThroughAlpha('#aaaaaa')).toBe(SEE_THROUGH_ALPHA.light)
expect(seeThroughAlpha('#a9a9a9')).toBe(SEE_THROUGH_ALPHA.dark)
})
it('a see-through colour reads by its own opaque colour, not its composite', () => {
expect(seeThroughAlpha('#ffffff20')).toBe(SEE_THROUGH_ALPHA.light)
expect(seeThroughAlpha('#00000020')).toBe(SEE_THROUGH_ALPHA.dark)
})
it('an unparseable ground reads as dark', () => {
expect(seeThroughAlpha('soup')).toBe(SEE_THROUGH_ALPHA.dark)
})
it("the cap sits below opaque and above both defaults, so Alpha and the switch agree", () => {
expect(Math.round(SEE_THROUGH_MAX * 255)).toBe(0xf2)
expect(SEE_THROUGH_MAX).toBeGreaterThan(SEE_THROUGH_ALPHA.light)
expect(SEE_THROUGH_MAX).toBeLessThan(1)
})
})
34 changes: 34 additions & 0 deletions core/renderer/lib/seeThrough.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { opaque } from './colour'
import { luminance } from './termAnsi'

/**
* THE SEE-THROUGH WINDOW'S DEFAULT (#156; owner, 2026-10-10: "add support for
* the see through window setting ... its in prism in style settings i want it
* here too"). Where the terminal owns the window acrylic (Prism Terminal), the
* switch alone makes the window see-through, as Prism's does: an OPAQUE ground
* under it paints at Prism's own levels, as they paint.
*
* Prism's `SEE_THROUGH_LEVEL` is 70 on a dark style and 49 on a light one
* (the levels Glacier and Orchid paint). A level paints
* `glass = 0.85 - level / 100 * 0.55`, alpha `1 - (1 - glass * 0.75) ^ 3`:
* level 70 is 0.724, the byte 0xb9; level 49 is 0.820, the byte 0xd1. Stored
* as those bytes so the window and the Alpha field name the same value.
*/
export const SEE_THROUGH_ALPHA = { dark: 0xb9 / 255, light: 0xd1 / 255 } as const

/**
* The highest Alpha a ground may carry while the switch is on: 95%, the byte
* 0xf2. Opaque is the switch's OFF (Prism's rule: the switch and the alpha
* always agree), and an Alpha of 100 under the switch would read back as the
* default and make the slider jump.
*/
export const SEE_THROUGH_MAX = 0xf2 / 255

/**
* The default see-through for a ground: light or dark MEASURED from its own
* opaque colour (relative luminance above 0.4, the test chromeTheme uses for
* `data-mode`), never read off a theme's name. Unparseable reads as dark.
*/
export function seeThroughAlpha(ground: string): number {
return luminance(opaque(ground)) > 0.4 ? SEE_THROUGH_ALPHA.light : SEE_THROUGH_ALPHA.dark
}
80 changes: 77 additions & 3 deletions core/renderer/lib/termLook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ import {
type AgentIndicator
} from '../host'
import { liveThemeId } from './termThemeRetired'
import { alphaOf, parseColour, toStored, withAlpha } from './colour'
import { alphaOf, opaque, parseColour, toStored, withAlpha } from './colour'
import { seeThroughAlpha } from './seeThrough'
// A CYCLE, and a safe one: termTheme imports this module too, and neither reads
// the other at module load (only inside functions, at call time), so either may
// be evaluated first.
import { resolveTermTheme } from './termTheme'

export type { AgentIndicator }

Expand Down Expand Up @@ -419,13 +424,62 @@ function stored(c: unknown): string | null {
* they write, but a stored or hand-edited `#12121205` must not paint an all
* but invisible window. The floor is the byte 30% gives (0x4d), so every
* value a picker or the migration writes reads unchanged.
*
* And there, with the see-through switch in force, an OPAQUE ground paints
* the default see-through (#156, `paintsAlpha`): a preset is no longer 1.
*/
export function termGroundAlpha(): number {
const a = rawGroundAlpha()
return hostOwnsWindowAcrylic() ? Math.max(GROUND_ALPHA_MIN, a) : a
if (!hostOwnsWindowAcrylic()) return a
// The ground is only looked up when the default can apply: resolving a
// theme on every read of an alpha that needs none would be waste.
const inForce = termAcrylicInForce()
return paintsAlpha(a, inForce && isOpaque(a) ? groundInForce() : '#000000', inForce)
}
/** The lowest window see-through: Opacity 30's byte, round(0.3 * 255). */
export const GROUND_ALPHA_MIN = 0x4d / 255

/**
* THE ONE RULE for what a ground paints where the terminal owns the window
* acrylic (#156), shared by the window (`termGroundAlpha`) and Save changes'
* dirty check (`useTermSetup`), so the two can never disagree about a saved
* setup. With the switch on, an OPAQUE ground paints the default see-through
* (Prism's levels, by the ground light or dark): on means see-through by
* itself, as Prism's switch does, where before #156 a preset stayed solid and
* the switch looked broken. A ground that carries an alpha keeps it, floored
* at 30% as the Opacity slider was.
*/
export function paintsAlpha(raw: number, ground: string, acrylicOn: boolean): number {
if (acrylicOn && isOpaque(raw)) return seeThroughAlpha(ground)
return Math.max(GROUND_ALPHA_MIN, raw)
}
/** Opaque BY THE BYTE: an alpha that stores as ff is opaque. */
const isOpaque = (a: number): boolean => Math.round(a * 255) >= 255

/** The ground in force, opaque, in the order the window paints it: the
* host's picked one, else the theme's own (a Custom's `bg` included). */
function groundInForce(): string {
return opaque(hostGround() ?? resolveTermTheme(termThemeId()).background)
}

/**
* Whether the window is see-through RIGHT NOW: the switch, except that High
* Contrast stays solid where the terminal owns the window acrylic (#156,
* Prism's rule: its contrast is measured on a solid ground, and glass would
* put an unmeasurable desktop under the text; this owner works zoomed in and
* relies on it). A theme pick resets the switch with the rest of the setup
* (`resetTermExtras`, "the theme is the whole setup"), so a stored on under
* High Contrast only comes from before #156; Save changes judges and saves
* this, not the stored value (`termSetupState`). In Prism the style owns the
* glass and the switch is read as it is.
*/
export function termAcrylicInForce(): boolean {
return termAcrylic() && !seeThroughBlocked()
}
/** The theme in force keeps the window solid whatever the switch says. */
export function seeThroughBlocked(): boolean {
return hostOwnsWindowAcrylic() && termThemeId() === 'high-contrast'
}
function rawGroundAlpha(): number {
const picked = hostGround()
if (picked) return alphaOf(picked)
Expand All @@ -442,10 +496,23 @@ function rawGroundAlpha(): number {
* the colour editor opens on, so a see-through window stays see-through
* through a save (the Opacity slider was carried in the setup the same way).
* Where the host's style owns the glass (Prism), the palette as it is.
*
* It is the alpha the WINDOW paints (`termGroundAlpha`), the default measured
* from the picked Background where one is in force: what the user saw is what
* is saved, and what Save changes' dirty check reads back. Measuring from the
* palette's own ground instead (review of #156) saved a dark Custom under an
* opaque white pick at b9 while the window and the check stayed at d1, so
* Save changes stayed lit after every save.
*
* Without a pick the ground is the PALETTE's own, not the active theme's:
* every preset card has an edit button, and a light preset edited under a
* dark active theme opened, and saved, at the dark level (review of the fix
* above). For the active theme the two are the same ground.
*/
export function withGroundAlpha<T extends { bg: string }>(palette: T): T {
if (!hostOwnsWindowAcrylic()) return palette
return { ...palette, bg: withAlpha(palette.bg, termGroundAlpha()) }
const ground = opaque(hostGround() ?? palette.bg)
return { ...palette, bg: withAlpha(palette.bg, paintsAlpha(rawGroundAlpha(), ground, termAcrylicInForce())) }
}

export function saveCustomTermTheme(theme: CustomTermTheme): void {
Expand Down Expand Up @@ -474,6 +541,13 @@ export function useTermFontId(): string {
export function useTermAcrylic(): boolean {
return useSyncExternalStore(sub, termAcrylic)
}
/** The see-through in force (High Contrast stays solid, #156). */
export function useTermAcrylicInForce(): boolean {
return useSyncExternalStore(sub, termAcrylicInForce)
}
export function useSeeThroughBlocked(): boolean {
return useSyncExternalStore(sub, seeThroughBlocked)
}
/** The ground's alpha, re-read when the look OR the host's window colours
* change (a picked background is the host's, not this store's). */
export function useTermGroundAlpha(): number {
Expand Down
Loading
Loading