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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ 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. |
| **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. |
| **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
29 changes: 26 additions & 3 deletions core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,8 @@ ask me."* And: *"why can't this repo be the core?"* It can, and this is it.
[`renderer/host.ts`](renderer/host.ts): the default each untouched setting
reads as, whether there is a host style to follow, who paints the ground,
what an unpicked indicator colour resolves to, what "acrylic" means as a
terminal setting, which chords the app owns. If a difference is not on that page, it is a fork,
terminal setting, which chords the app owns, which tab marks its strip draws
(`tabMarks`, #143). 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,
Expand Down Expand Up @@ -201,6 +202,26 @@ is `settings/sections/DiagnosticsPage.tsx` (props only), its rows
`diagnosticsOptions.ts`. Schema and how to read it: Prism Terminal's
`docs/diagnostics.md`.

**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),
`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),
`components/TabMark.tsx` (a flat tab's run, line, ring and fill, props only)
and `styles/marks.css`, the motion, which a host IMPORTS
(`prism-term-core/renderer/styles/marks.css`; without it the marks stand still).
**`tabMarks`** on `TermHostConfig` declares what the host's strip draws: the
indicator choices (`hostIndicators()`; a stored value outside them reads as the
host's default, `readIndicator`) and whether it draws the rainbow
(`hostRainbow()`, which also decides whether `AgentMarksSection` draws the
"Rainbow finished mark" row). Absent: Off, Minimal and Full, Full being the
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).

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

- **Relative imports only.** No `@shared` / `@renderer` / `@core` aliases: a
Expand All @@ -218,8 +239,10 @@ is `settings/sections/DiagnosticsPage.tsx` (props only), its rows
the three preload members it needs (`UpdateBridge`), so the same components
drop into a host whose terminal is not even mounted.
- 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.
plus the class `.p-scroll` and the `.xterm` rules in each app's `index.css`. A
new token or class needed here must be added to BOTH. The marks' classes
(`.p-agent-run`, `.p-mark-*`) live in the core's own `styles/marks.css` since
#143, which each host imports.

## How each app consumes it

Expand Down
4 changes: 3 additions & 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.27.1",
"version": "0.28.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 All @@ -18,6 +18,8 @@
"./renderer/settings/options": "./renderer/settings/options.ts",
"./renderer/settings/*": "./renderer/settings/*.tsx",
"./renderer/settings/dictationOptions": "./renderer/settings/dictationOptions.ts",
"./renderer/settings/markOptions": "./renderer/settings/markOptions.ts",
"./renderer/styles/*": "./renderer/styles/*",
"./tools/*": "./tools/*",
"./claude-plugin/*": "./claude-plugin/*"
},
Expand Down
71 changes: 71 additions & 0 deletions core/renderer/components/TabMark.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { resolveTabMark, type MarkState } from '../lib/tabMark'
import { TabMark } from './TabMark'

const draw = (
indicator: 'minimal' | 'ring' | 'full',
state: MarkState,
extra: { active?: boolean; foot?: string; ink?: string } = {}
): string =>
renderToStaticMarkup(
createElement(TabMark, {
mark: resolveTabMark({ indicator, tabStyle: 'flat', state, active: !!extra.active, rainbow: true }),
state,
background: '#123456',
foot: extra.foot,
ink: extra.ink
})
)

describe('the flat tab mark (#143)', () => {
it('draws the running bar while working, hidden from the reader', () => {
const html = draw('minimal', 'working')
expect(html).toContain('data-mark="run"')
expect(html).toContain('p-agent-run')
expect(html).toContain('aria-hidden="true"')
expect(html).not.toContain('data-attention')
})

it('draws a 3 px line for a question, breathing, with its attention kept for the e2e', () => {
const html = draw('minimal', 'question')
expect(html).toContain('data-mark="line"')
expect(html).toContain('data-attention="question"')
expect(html).toContain('p-mark-breathe')
expect(html).toContain('h-[3px]')
})

it('flows the rainbow on a finished line and says so', () => {
const html = draw('minimal', 'done')
expect(html).toContain('data-rainbow=""')
expect(html).toContain('p-mark-flow-x')
expect(html).toContain('data-attention="done"')
})

it('spins a ring for Ring while working', () => {
const html = draw('ring', 'working')
expect(html).toContain('data-mark="ring"')
expect(html).toContain('p-mark-spin')
})

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('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')
})

it('draws nothing for a Full tab in front beyond Minimal\'s mark', () => {
expect(draw('full', 'working', { active: true })).toContain('data-mark="run"')
})
})
91 changes: 91 additions & 0 deletions core/renderer/components/TabMark.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import type { JSX } from 'react'
import type { MarkState, TabMark as Mark } from '../lib/tabMark'
import { motionClass } from './markClasses'

/**
* A TAB'S AGENT MARK, on a flat tab (#143). Props only: the strip asks
* `resolveTabMark` WHAT to draw and resolves its colour; this draws it.
*
* - `run`: the working bar running along the foot.
* - `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.
* - `edge`: a Prompt segment's arrow edge, which the host's Prompt strip draws
* itself; nothing here.
*
* Every element is `aria-hidden` and lets the pointer through; the tab's own
* attributes say its state to tests and to nobody else.
*/
export function TabMark({
mark,
state,
background,
foot,
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 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 common = {
'data-mark': mark.place,
'data-mark-motion': mark.motion ?? undefined,
'data-attention': attention,
'data-rainbow': rainbow,
'aria-hidden': true
}
switch (mark.place) {
case 'run':
return (
<span {...common} className="pointer-events-none absolute inset-x-0 bottom-0 z-[2] h-[3px] overflow-hidden">
<span className="p-agent-run absolute inset-y-0 w-[42%] rounded-full" style={{ background }} />
</span>
)
case 'line':
return (
<span
{...common}
className={`pointer-events-none absolute inset-x-0 bottom-0 z-[2] h-[3px] ${motionClass(mark.motion, 'x')}`}
style={{ background, ...(mark.motion === 'flow' ? { backgroundSize: '114px 100%' } : {}) }}
/>
)
case 'ring':
return (
<span
{...common}
className="p-mark-spin pointer-events-none relative z-[2] -mr-0.5 inline-block h-3 w-3 shrink-0 rounded-full border-2 border-[color-mix(in_srgb,var(--p-text)_24%,transparent)]"
style={{ borderTopColor: background, ['--mark-c' as string]: background }}
/>
)
case 'fill':
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 } : {}) }}
>
{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" />
)}
</span>
)
default:
return null
}
}
24 changes: 24 additions & 0 deletions core/renderer/components/markClasses.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
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. */
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',
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 {
if (!motion) return ''
if (motion === 'flow') return axis === 'x' ? 'p-mark-flow-x' : 'p-mark-flow-y'
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'
24 changes: 23 additions & 1 deletion core/renderer/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,14 @@ export interface TermApi {
termOpenPath?(cwd: string, text: string, mode: 'open' | 'reveal'): void
}

export type AgentIndicator = 'off' | 'minimal' | 'full'
/** How a tab marks its agent (#143 added `ring`). What a host's strip can
* DRAW is its `tabMarks.indicators`; a stored value outside it reads as the
* host's default (`readIndicator`). */
export type AgentIndicator = 'off' | 'minimal' | 'ring' | 'full'

/** The choices a host that has not declared `tabMarks` draws: its own Off,
* Minimal and Full (Prism, whose Full is its own filled tab). */
export const LEGACY_INDICATORS: readonly AgentIndicator[] = ['off', 'minimal', 'full']

export interface TermHostConfig {
api: TermApi
Expand Down Expand Up @@ -162,6 +169,16 @@ export interface TermHostConfig {
* gives a file, Explorer for a folder, never a program run).
*/
openPath?(target: { abs: string; kind: 'file' | 'dir'; mode: 'open' | 'reveal' }): boolean

/**
* WHAT THE HOST'S TAB STRIP DRAWS (#143; owner decision, 2026-10-10, named
* in its PR). `indicators`: the choices the Agents page offers and the store
* accepts, in order; `rainbow`: the strip draws the rainbow finish, so the
* "Rainbow finished mark" row is offered. Absent: Off, Minimal and Full,
* where Full is the host's own filled tab, and no rainbow row (Prism, until
* it adopts the core's marks), so an update changes nothing it draws.
*/
tabMarks?: { indicators: readonly AgentIndicator[]; rainbow: boolean }
}

let host: TermHostConfig | null = null
Expand Down Expand Up @@ -207,6 +224,11 @@ export const hostOwnsWindowAcrylic = (): boolean => host?.acrylic.kind === 'wind
/** Hear the host's window colours change (`onChromeChange`); a no-op where the
* host has none, or none has spoken. */
export const onHostChromeChange = (cb: () => void): (() => void) => host?.onChromeChange?.(cb) ?? (() => {})
/** The indicator choices the host's strip draws (`tabMarks`), the legacy three
* where it has not declared them or no host has spoken. */
export const hostIndicators = (): readonly AgentIndicator[] => host?.tabMarks?.indicators ?? LEGACY_INDICATORS
/** Whether the host's strip draws the rainbow finish (`tabMarks.rainbow`). */
export const hostRainbow = (): boolean => host?.tabMarks?.rainbow ?? false

/** For tests, and for a host that tears down. */
export function resetTermCore(): void {
Expand Down
59 changes: 59 additions & 0 deletions core/renderer/lib/markColours.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest'
import { ICON_RAINBOW, floorMark, opaqueOver, rainbowGradient, rainbowOn } from './markColours'
import { contrastRatio, mixHex } from './termAnsi'
import { rgbToHsl, parseColour } from './colour'

const minOn = (c: string, grounds: string[]): number => Math.min(...grounds.map((g) => contrastRatio(c, g)))
const hue = (c: string): number => rgbToHsl(parseColour(c)!).h

// Volt and Paper as the mockups computed them: the strip, and the two Prompt
// segments (the text mixed into the strip at 4% and 11%).
const VOLT = ['#050706', mixHex('#050706', '#eef2e6', 0.04), mixHex('#050706', '#eef2e6', 0.11)]
const PAPER = ['#f6f4ee', mixHex('#f6f4ee', '#2a2620', 0.04), mixHex('#f6f4ee', '#2a2620', 0.11)]

describe('the rainbow of the app icon (#143)', () => {
it('is the seven colours of build/icon-source.png, in order', () => {
expect(ICON_RAINBOW).toEqual(['#12cee5', '#2179fa', '#945af5', '#e84fd2', '#fb7f6c', '#f0a934', '#e8d021'])
})

it('leaves every colour alone where it already clears 3:1 (Volt)', () => {
for (const c of ICON_RAINBOW) expect(floorMark(c, VOLT, 3)).toBe(c)
expect(Math.min(...rainbowOn(VOLT).map((c) => minOn(c, VOLT)))).toBeGreaterThan(3.8)
})

it('darkens what falls short on Paper until it clears 3:1 on every ground, keeping its hue', () => {
for (const c of ICON_RAINBOW) {
const m = floorMark(c, PAPER, 3)
expect(minOn(m, PAPER), `${c} -> ${m}`).toBeGreaterThanOrEqual(3)
const dh = Math.abs(hue(m) - hue(c))
expect(Math.min(dh, 360 - dh), `${c} -> ${m}`).toBeLessThanOrEqual(2)
}
// The mockup's own table: the closest is just over the floor, not far past it.
expect(Math.min(...rainbowOn(PAPER).map((c) => minOn(c, PAPER)))).toBeLessThan(3.2)
})

it('LIGHTENS a short colour on a dark mid grey, never darkens it', () => {
const cinder = ['#383c44']
const m = floorMark('#2179fa', cinder, 3)
expect(minOn(m, cinder)).toBeGreaterThanOrEqual(3)
expect(parseColour(m)!.r).toBeGreaterThan(parseColour('#2179fa')!.r)
})

it('hands back the nearest it got when the floor cannot be reached, and never throws', () => {
const grey = ['#777777']
expect(() => floorMark('#7a7a7a', grey, 21)).not.toThrow()
const m = floorMark('#7a7a7a', grey, 21)
expect(minOn(m, grey)).toBeGreaterThan(minOn('#7a7a7a', grey))
})

it('loops its gradient, along x or down y', () => {
expect(rainbowGradient(['#111111', '#222222'], 'x')).toBe('linear-gradient(90deg, #111111, #222222, #111111)')
expect(rainbowGradient(['#111111', '#222222'], 'y')).toBe('linear-gradient(180deg, #111111, #222222, #111111)')
})

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))
expect(opaqueOver('#d8ff26', '#050706')).toBe('#d8ff26')
})
})
Loading
Loading