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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ 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 Prompt: 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. |
| **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. |
Expand Down
9 changes: 8 additions & 1 deletion core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ is `settings/sections/DiagnosticsPage.tsx` (props only), its rows
**THE TAB MARKS (#143, 2026-10-10)** are the sixth: how a tab says its agent
is working, finished, asking or failed. `renderer/lib/tabMark.ts`
(`resolveTabMark`, the one rule: which mark, in which colour role, with which
motion, for Off, Minimal, Ring and Full on a flat or a Prompt tab),
motion, for Off, Minimal, Ring and Full on a flat or a Powerline tab),
`markColours.ts` (the icon's rainbow, `floorMark` to 3:1 on every ground),
`nameInk.ts` (Full's name: the theme's text unless under 2:1 on the fill),
`markPalette.ts` (every colour a strip's marks wear, pure),
Expand All @@ -221,6 +221,13 @@ host's own filled tab, and no rainbow row, so a host that has not adopted the
marks sees no change. That row's list is `settings/markOptions.ts`, NOT
`TERMINAL_OPTIONS`; `coreSettingsIndex({ marks: true })` indexes it. Its
switch is `agentRainbow()` (`prism.term.agentRainbow`, on; no theme resets it).
Since #154 Full's working fill on a flat tab carries Minimal's run on top
(`overlay`, in the name's ink; a Powerline fill has no overlay) and finished is
the rainbow across the whole tab (`rainbowInk` picks its name's ink).

**THE ON SWITCH'S KNOB (#154)** is `--p-switch-knob` where the host defines it
(`lib/switchKnob.ts`: the theme's black or white, flipped only under 2:1 on
the track), else `--p-on-accent` as before. Prism has not defined it.

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

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.29.0",
"version": "0.30.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
30 changes: 20 additions & 10 deletions core/renderer/components/TabMark.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@ import { TabMark } from './TabMark'
const draw = (
indicator: 'minimal' | 'ring' | 'full',
state: MarkState,
extra: { active?: boolean; foot?: string; ink?: string } = {}
extra: { active?: boolean; overlay?: string; ink?: string } = {}
): string =>
renderToStaticMarkup(
createElement(TabMark, {
mark: resolveTabMark({ indicator, tabStyle: 'flat', state, active: !!extra.active, rainbow: true }),
state,
background: '#123456',
foot: extra.foot,
overlay: extra.overlay,
ink: extra.ink
})
)
Expand Down Expand Up @@ -50,19 +50,29 @@ describe('the flat tab mark (#143)', () => {
})

it('fills a Full tab not in front, the name ink a CSS variable, not per frame', () => {
const html = draw('full', 'working', { ink: '#0b0b0b' })
expect(html).toContain('data-mark="fill"')
expect(html).toContain('--mark-ink:#0b0b0b')
expect(html).toContain('p-mark-ride')
const q = draw('full', 'question', { ink: '#eeeeee' })
expect(q).toContain('data-mark="fill"')
expect(q).toContain('p-mark-breathe')
expect(q).toContain('--mark-ink:#eeeeee')
})

it('puts the rainbow along a finished badge\'s foot', () => {
const html = draw('full', 'done', { foot: 'linear-gradient(90deg, red, blue, red)' })
expect(html).toContain('data-mark-foot')
expect(html).toContain('p-mark-flow-x')
// Owner, 2026-10-10: on a working fill the Minimal bar is the NAME'S ink
// (black on Volt's yellow), never a faded shade of the working colour.
it('runs Minimal\'s bar over a working fill, in the colour it is given', () => {
const html = draw('full', 'working', { ink: '#0b0b0b', overlay: '#0b0b0b' })
expect(html).toContain('data-mark="fill"')
expect(html).toContain('data-mark-overlay="run"')
expect(html).toContain('p-agent-run')
expect(html).toContain('background:#0b0b0b')
expect(html).not.toContain('p-mark-ride')
})

it('flows the rainbow over a whole finished fill, no badge, no foot', () => {
const html = draw('full', 'done')
expect(html).toContain('data-mark="fill"')
expect(html).toContain('data-rainbow=""')
expect(html).toContain('p-mark-flow-fill')
expect(html).not.toContain('data-mark-foot')
})

it('draws nothing for a Full tab in front beyond Minimal\'s mark', () => {
Expand Down
38 changes: 21 additions & 17 deletions core/renderer/components/TabMark.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ import { motionClass } from './markClasses'
* - `line`: a 3 px line along the foot (finished, question, failed).
* - `ring`: the spinner, an inline element the strip puts beside the name.
* - `fill`: the whole tab, solid, under the name (Full, a tab not in front);
* with `foot` the rainbow along its foot (the finished badge), with a `ride`
* motion the bar riding along it in the name's ink.
* a rainbow finish flows across all of it. With an `overlay` of `run`,
* Minimal's running bar on top in the `overlay` colour (the name's ink,
* owner, 2026-10-10). A Prompt fill has none: its edge is the host's.
* - `edge`: a Prompt segment's arrow edge, which the host's Prompt strip draws
* itself; nothing here.
*
Expand All @@ -22,20 +23,21 @@ export function TabMark({
mark,
state,
background,
foot,
overlay,
ink
}: {
mark: Mark
state: MarkState | null
/** The mark's colour, or the rainbow's gradient. */
background: string
/** The rainbow along a finished fill's foot. */
foot?: string
/** The colour of the working mark drawn on a working fill (`markPalette`'s
* `overlay.run`). */
overlay?: string
/** The name's ink on a fill, chosen once per state colour (`nameInk`). */
ink?: string
}): JSX.Element | null {
const attention = state && state !== 'working' ? state : undefined
const rainbow = mark.colour === 'rainbow' || mark.colour === 'badge' ? '' : undefined
const rainbow = mark.colour === 'rainbow' ? '' : undefined
const common = {
'data-mark': mark.place,
'data-mark-motion': mark.motion ?? undefined,
Expand Down Expand Up @@ -70,18 +72,20 @@ export function TabMark({
return (
<span
{...common}
className={`pointer-events-none absolute inset-0 z-0 ${mark.motion === 'breathe' ? motionClass('breathe') : ''}`}
style={{ background, ...(ink ? { ['--mark-ink' as string]: ink } : {}) }}
className={`pointer-events-none absolute inset-0 z-0 ${motionClass(mark.motion, 'fill')}`}
// The `background` shorthand resets the class's size, so the flowing
// rainbow's tile (228 px, two tabs) is said again here.
style={{
background,
...(mark.motion === 'flow' ? { backgroundSize: '228px 100%' } : {}),
...(ink ? { ['--mark-ink' as string]: ink } : {})
}}
>
{foot && (
<span
data-mark-foot
className={`absolute inset-x-0 bottom-0 h-[3px] ${motionClass(mark.motion, 'x')}`}
style={{ background: foot, backgroundSize: '114px 100%' }}
/>
)}
{mark.motion === 'ride' && (
<span data-mark-ride className="p-mark-ride absolute inset-x-1.5 bottom-[3px] h-0.5 rounded-sm" />
{/* Minimal's own run, on top of the fill: the run case's markup. */}
{mark.overlay === 'run' && overlay && (
<span data-mark-overlay="run" className="absolute inset-x-0 bottom-0 h-[3px] overflow-hidden">
<span className="p-agent-run absolute inset-y-0 w-[42%] rounded-full" style={{ background: overlay }} />
</span>
)}
</span>
)
Expand Down
17 changes: 7 additions & 10 deletions core/renderer/components/markClasses.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,21 @@
import type { MarkMotion } from '../lib/tabMark'

/** The class in `styles/marks.css` that moves each motion. `flow` has one per
* axis (a line flows along x, a Prompt edge down y); `still` moves nothing. */
* axis (a line flows along x, a Prompt edge down y) and one for a whole Full
* fill; `still` moves nothing. */
export const MOTION_CLASS: Record<MarkMotion, string> = {
run: 'p-agent-run',
grow: 'p-mark-grow',
breathe: 'p-mark-breathe',
flow: 'p-mark-flow-x p-mark-flow-y',
ride: 'p-mark-ride',
flow: 'p-mark-flow-x p-mark-flow-y p-mark-flow-fill',
spin: 'p-mark-spin',
still: ''
}

/** The class for a motion on a mark running along `axis`. */
export function motionClass(motion: MarkMotion | null, axis: 'x' | 'y' = 'x'): string {
/** The class for a motion on a mark running along `axis`, or over a whole
* Full fill. */
export function motionClass(motion: MarkMotion | null, axis: 'x' | 'y' | 'fill' = 'x'): string {
if (!motion) return ''
if (motion === 'flow') return axis === 'x' ? 'p-mark-flow-x' : 'p-mark-flow-y'
if (motion === 'flow') return axis === 'x' ? 'p-mark-flow-x' : axis === 'y' ? 'p-mark-flow-y' : 'p-mark-flow-fill'
return MOTION_CLASS[motion]
}

/** The icon's dark ground (#143, the mockup's `#383c44`): Full's finished fill,
* with the rainbow along its foot. */
export const MARK_BADGE = '#383c44'
66 changes: 0 additions & 66 deletions core/renderer/lib/__snapshots__/termTheme.snapshot.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -305,28 +305,6 @@ exports[`resolveTermTheme, opaque, as it was > every preset 1`] = `
"white": "#a89984",
"yellow": "#d79921",
},
"high-contrast": {
"background": "#000000",
"black": "#646464",
"blue": "#7aa8ff",
"brightBlack": "#a0a0a0",
"brightBlue": "#a6c4ff",
"brightCyan": "#99ffff",
"brightGreen": "#8cff8c",
"brightMagenta": "#ffa6ff",
"brightRed": "#ff9494",
"brightWhite": "#ffffff",
"brightYellow": "#ffff99",
"cursor": "#ffff00",
"cyan": "#5cffff",
"foreground": "#ffffff",
"green": "#5cff5c",
"magenta": "#ff7aff",
"red": "#ff6b6b",
"selectionBackground": "#ffff0055",
"white": "#ffffff",
"yellow": "#ffff5c",
},
"horizon": {
"background": "#1c1e26",
"black": "#717175",
Expand Down Expand Up @@ -1504,50 +1482,6 @@ exports[`resolveTermTheme, opaque, as it was > every preset and the Custom on a
"white": "#8f826f",
"yellow": "#b6821c",
},
"high-contrast@#203040": {
"background": "#203040",
"black": "#7b7b7b",
"blue": "#7aa8ff",
"brightBlack": "#a0a0a0",
"brightBlue": "#a6c4ff",
"brightCyan": "#99ffff",
"brightGreen": "#8cff8c",
"brightMagenta": "#ffa6ff",
"brightRed": "#ff9494",
"brightWhite": "#ffffff",
"brightYellow": "#ffff99",
"cursor": "#ffff00",
"cyan": "#5cffff",
"foreground": "#ffffff",
"green": "#5cff5c",
"magenta": "#ff7aff",
"red": "#ff6b6b",
"selectionBackground": "#ffff0055",
"white": "#ffffff",
"yellow": "#ffff5c",
},
"high-contrast@#f4f1ea": {
"background": "#f4f1ea",
"black": "#646464",
"blue": "#5f84c7",
"brightBlack": "#878787",
"brightBlue": "#6e82a8",
"brightCyan": "#568f8f",
"brightGreen": "#4e8f4e",
"brightMagenta": "#a86ea8",
"brightRed": "#c77373",
"brightWhite": "#848484",
"brightYellow": "#84844f",
"cursor": "#8f8f00",
"cyan": "#348f8f",
"foreground": "#666666",
"green": "#389b38",
"magenta": "#c75fc7",
"red": "#d85a5a",
"selectionBackground": "#8f8f0055",
"white": "#848484",
"yellow": "#8f8f34",
},
"horizon@#203040": {
"background": "#203040",
"black": "#7c7c80",
Expand Down
6 changes: 5 additions & 1 deletion core/renderer/lib/markColours.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { ICON_RAINBOW, floorMark, opaqueOver, rainbowGradient, rainbowOn } from './markColours'
import { ICON_RAINBOW, floorMark, opaqueOver, rainbowFillX, rainbowGradient, rainbowOn } from './markColours'
import { contrastRatio, mixHex } from './termAnsi'
import { rgbToHsl, parseColour } from './colour'

Expand Down Expand Up @@ -51,6 +51,10 @@ describe('the rainbow of the app icon (#143)', () => {
expect(rainbowGradient(['#111111', '#222222'], 'y')).toBe('linear-gradient(180deg, #111111, #222222, #111111)')
})

it('lays the icon\'s seven, raw, across a whole fill as a looping gradient', () => {
expect(rainbowFillX()).toBe(`linear-gradient(90deg, ${[...ICON_RAINBOW, ICON_RAINBOW[0]].join(', ')})`)
})

it('makes a see-through colour opaque over the solid ground, for Full', () => {
expect(opaqueOver('#d8ff2680', '#050706')).toMatch(/^#[0-9a-f]{6}$/)
expect(opaqueOver('#d8ff2680', '#050706')).toBe(mixHex('#050706', '#d8ff26', 0x80 / 255))
Expand Down
8 changes: 8 additions & 0 deletions core/renderer/lib/markColours.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ export function rainbowGradient(cols: readonly string[], axis: 'x' | 'y'): strin
return `linear-gradient(${axis === 'x' ? '90deg' : '180deg'}, ${[...cols, cols[0]].join(', ')})`
}

/** The icon's seven, RAW, across a whole Full fill as a looping gradient
* (2026-10-10 rework, choice 1A: "the solid flowing rainbow across the whole
* finished tab"). Not floored: a fill is the colour itself, and the name's ink
* is what is held to the rule (`rainbowInk`). */
export function rainbowFillX(): string {
return rainbowGradient(ICON_RAINBOW, 'x')
}

/** A see-through colour as the opaque one it shows over the solid ground: Full
* fills a tab SOLID (owner, 2026-10-10), so a half-alpha working colour (#112)
* is laid on the window's own ground first. */
Expand Down
51 changes: 38 additions & 13 deletions core/renderer/lib/markPalette.test.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,64 @@
import { describe, expect, it } from 'vitest'
import { MARK_BADGE } from '../components/markClasses'
import { ICON_RAINBOW } from './markColours'
import { markPalette } from './markPalette'
import { contrastRatio, mixHex } from './termAnsi'

// Volt as the r4 mockup computed it.
const VOLT = { working: '#d8ff26', finished: '#22c55e', question: '#3b82f6', failed: '#ff3b5c' }
const GROUND = '#050706'
const TEXT = '#eef2e6'
const SEGS = [GROUND, mixHex(GROUND, TEXT, 0.04), mixHex(GROUND, TEXT, 0.11)]
// Paper, the light row of the 2026-10-10 full-style mockup.
const PAPER = { working: '#3f8ccb', finished: '#1b9a49', question: '#3b82f6', failed: '#e05561' }
const PAPER_GROUND = '#f6f4ee'
const PAPER_TEXT = '#2a2620'

const volt = (rainbow = true) => markPalette({ colours: VOLT, grounds: [GROUND], solidGround: GROUND, text: TEXT, rainbow })
const paper = (rainbow = true) =>
markPalette({ colours: PAPER, grounds: [PAPER_GROUND], solidGround: PAPER_GROUND, text: PAPER_TEXT, rainbow })

describe('the marks palette (#143)', () => {
it('leaves Volt\'s bright marks as they are, and floors every line to 3:1 on every ground', () => {
const p = markPalette({ colours: VOLT, grounds: SEGS, solidGround: GROUND, text: TEXT, rainbow: true })
it('leaves Volt\'s bright marks as they are, and floors every line to 3:1 on the ground', () => {
const p = volt()
expect(p.line.working).toBe('#d8ff26')
for (const c of Object.values(p.line)) for (const g of SEGS) expect(contrastRatio(c, g)).toBeGreaterThanOrEqual(3)
for (const c of Object.values(p.line)) expect(contrastRatio(c, GROUND)).toBeGreaterThanOrEqual(3)
})

it('names Volt\'s Full inks by the owner\'s rule: working and the rainbow flip', () => {
expect(volt().ink).toEqual({ working: '#0b0b0b', done: '#0b0b0b', question: TEXT, failed: TEXT })
// With the rainbow off, finished is the green fill, where the text holds.
expect(volt(false).ink.done).toBe(TEXT)
})

it('names Volt\'s Full inks by the owner\'s rule: only working flips', () => {
const p = markPalette({ colours: VOLT, grounds: SEGS, solidGround: GROUND, text: TEXT, rainbow: true })
expect(p.ink).toEqual({ working: '#0b0b0b', done: TEXT, question: TEXT, failed: TEXT })
it('keeps Paper\'s text on every fill, the rainbow included', () => {
expect(paper().ink).toEqual({ working: PAPER_TEXT, done: PAPER_TEXT, question: PAPER_TEXT, failed: PAPER_TEXT })
})

it('fills finished with the dark badge only while the rainbow is on', () => {
expect(markPalette({ colours: VOLT, grounds: SEGS, solidGround: GROUND, text: TEXT, rainbow: true }).fill.done).toBe(MARK_BADGE)
expect(markPalette({ colours: VOLT, grounds: SEGS, solidGround: GROUND, text: TEXT, rainbow: false }).fill.done).toBe('#22c55e')
it('fills finished with the icon\'s seven, raw, while the rainbow is on', () => {
const on = volt().fill.done
expect(on.startsWith('linear-gradient(90deg, ')).toBe(true)
for (const c of ICON_RAINBOW) expect(on).toContain(c)
expect(volt(false).fill.done).toBe('#22c55e')
})

it('fills SOLID: a half-alpha working colour is laid on the ground first', () => {
const p = markPalette({ colours: { ...VOLT, working: '#d8ff2680' }, grounds: SEGS, solidGround: GROUND, text: TEXT, rainbow: true })
const p = markPalette({ colours: { ...VOLT, working: '#d8ff2680' }, grounds: [GROUND], solidGround: GROUND, text: TEXT, rainbow: true })
expect(p.fill.working).toMatch(/^#[0-9a-f]{6}$/)
expect(p.fill.working).toBe(mixHex(GROUND, '#d8ff26', 0x80 / 255))
})

// THE WORKING MARK ON THE WORKING FILL (owner, 2026-10-10: "if the tab is
// yellow ... the working bar that is on that tab, like the minimal bar,
// should be black and not like a faded arc yellow"). Classic's run is the
// NAME'S ink; Prompt's edge sits in the ground-coloured gap, where black
// would vanish on a dark theme, so it keeps the shade (variant 2A).
it('draws Classic\'s run on a working fill in the name\'s ink', () => {
expect(volt().overlay.run).toBe('#0b0b0b')
expect(volt().overlay.run).toBe(volt().ink.working)
expect(paper().overlay.run).toBe(PAPER_TEXT)
})

it('loops both rainbow gradients', () => {
const p = markPalette({ colours: VOLT, grounds: SEGS, solidGround: GROUND, text: TEXT, rainbow: true })
const p = volt()
expect(p.rainbowX.startsWith('linear-gradient(90deg, #12cee5')).toBe(true)
expect(p.rainbowY.startsWith('linear-gradient(180deg, #12cee5')).toBe(true)
expect(p.rainbowX.endsWith('#12cee5)')).toBe(true)
Expand Down
Loading
Loading