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
12 changes: 10 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,18 @@ terminal theme, anything that reads or shows files.
- **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
moved to a contrast floor, and light/dark is MEASURED from the ground, never read off a name. No
container behind `TerminalPanel` may paint `--p-bg`: on acrylic that is a second translucent coat.
moved to a contrast floor, and light/dark is MEASURED from the ground, never read off a name.
The `:root` fallbacks in `index.css` are `chromeTokens` output for the `prism` preset; recompute
them if either changes.
- **The terminal panel paints the ground; xterm's canvas is CLEAR** (2026-09-19, #6, owner
screenshot: a grey bar under a black terminal). xterm sizes itself in whole rows (MEASURED: 604px
in a 611px box), so the strip under the last row is never its to paint; with the ground on the
canvas and a transparent box round it, that strip showed the native window background. So
`TerminalPanel`'s box is `bg-[var(--p-bg)]` and `currentTermTheme()` hands xterm
`background: #00000000` plus a named `cursorAccent` (it defaults to the background, which would
make the character under a block cursor a hole). Exactly ONE coat per pixel, which also matters
on acrylic: two translucent coats are a visibly darker panel, so App's own container behind the
panel stays unpainted. The e2e `theme` scenario measures the strip, and fails on the old code.
- **Opacity is a number read defensively** (`termOpacity`): `Number(null)` is 0, and never-set must
read as opaque.
- **Explorer verbs**: HKCU, `reg.exe` with argv only, on `Directory` and `Directory\Background`, no
Expand Down
54 changes: 28 additions & 26 deletions src/renderer/src/components/TerminalPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,10 @@
import { parseOsc9 } from '@shared/termCwd'
import { resolveTermTheme } from '../lib/termTheme'
import { normalizeColor } from '../lib/termAnsi'
import { alphaHex } from '../lib/chromeTheme'
import {
onTermLookChange,
termAcrylic,
termBaseFontPx,
termFontStack,
termOpacity,
termThemeId
} from '../lib/termLook'
import {
Expand Down Expand Up @@ -47,21 +44,30 @@

const sessions = new Map<string, Session>()

/** The theme as painted. Acrylic works with ANY theme here: the theme's own
* background is kept and only its alpha changes (#rrggbbaa at termOpacity()),
* so the window's material shows through a ground that is still the theme's
* colour. It is the same colour at the same alpha the chrome paints (--p-bg,
* lib/chromeTheme), which is what makes the window one sheet. NOTHING behind
* the canvas may paint that colour a second time: two translucent coats are a
* visibly darker panel than the rest of the window. */
function currentTermTheme(): ReturnType<typeof resolveTermTheme> {
const theme = { ...resolveTermTheme(termThemeId()) }
if (termAcrylic() && termOpacity() < 100) {
// Flattened first: a custom theme may hold #rgb or an rgba(), and an alpha
// pair appended to either is not a colour.
theme.background = normalizeColor(theme.background, '#0b0b0f') + alphaHex(termOpacity())
}
return theme
/**
* The theme as painted: 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,
* MEASURED at 604px in a 611px box, so a few pixels under the last row are
* never xterm's to paint. While the canvas carried the ground and the box
* around it was transparent, that strip showed the native window background
* instead of the theme. So the ground is `--p-bg` on the panel (the chrome's
* own colour, at the window's alpha when it is acrylic, see lib/chromeTheme)
* and the canvas over it is clear: every pixel of the panel, rows, frame and
* leftover strip alike, gets exactly ONE coat. One and not two is the other
* half of it: two translucent coats are a visibly darker panel than the rest
* of an acrylic window.
*
* The block cursor draws the character under it in `cursorAccent`, which
* defaults to the background; on a clear background that would be a hole, so
* it is named.
*/
function currentTermTheme(): ReturnType<typeof resolveTermTheme> & { cursorAccent: string } {
const theme = resolveTermTheme(termThemeId())
// Flattened first: a custom theme may hold #rgb or an rgba().
const solid = normalizeColor(theme.background, '#0b0b0f')
return { ...theme, background: '#00000000', cursorAccent: solid }
}

/**
Expand Down Expand Up @@ -169,7 +175,7 @@

/** Wipe a session's screen and scrollback, back to a bare prompt. The shell
* itself is untouched: same process, same cwd, same history. */
export function clearTermSession(id: string): void {

Check warning on line 178 in src/renderer/src/components/TerminalPanel.tsx

View workflow job for this annotation

GitHub Actions / check

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
sessions.get(id)?.term.clear()
}

Expand All @@ -177,20 +183,20 @@
* Claude session must resume now, not when the tab is first visited. The
* xterm lives against its detached element (the same way hidden tabs keep
* theirs) and the panel simply attaches it later, scrollback intact. */
export function ensureTermSession(id: string, root: string, shellId: string | undefined): void {

Check warning on line 186 in src/renderer/src/components/TerminalPanel.tsx

View workflow job for this annotation

GitHub Actions / check

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
if (!sessions.has(id)) createSession(id, root, shellId)
}

/** Give a live session the keyboard back. Used after a tab interaction (a
* click, a reorder drag) stole focus from a shell the user never left: the
* next keystroke would otherwise go to the strip instead of Claude Code. */
export function focusTermSession(id: string): void {

Check warning on line 193 in src/renderer/src/components/TerminalPanel.tsx

View workflow job for this annotation

GitHub Actions / check

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
sessions.get(id)?.term.focus()
}

/** Kill a session's renderer half: the xterm instance and its element. Main's
* pty half is killed separately (term:kill) or already exited. */
export function disposeTermSession(id: string): void {

Check warning on line 199 in src/renderer/src/components/TerminalPanel.tsx

View workflow job for this annotation

GitHub Actions / check

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
const s = sessions.get(id)
if (!s) return
sessions.delete(id)
Expand Down Expand Up @@ -243,7 +249,7 @@
* character at a time. Enter and the arrows pass false, which is what makes
* them step.
*/
export function findInTerm(id: string, query: string, dir: 1 | -1, incremental = false): boolean {

Check warning on line 252 in src/renderer/src/components/TerminalPanel.tsx

View workflow job for this annotation

GitHub Actions / check

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
const s = sessions.get(id)
if (!s) return false
if (!query) {
Expand All @@ -256,13 +262,13 @@
}

/** Drop the highlights: the bar closed, or the query emptied. */
export function clearTermFind(id: string): void {

Check warning on line 265 in src/renderer/src/components/TerminalPanel.tsx

View workflow job for this annotation

GitHub Actions / check

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
sessions.get(id)?.search.clearDecorations()
}

/** Hear the running count. `resultIndex` is -1 past xterm's match threshold,
* which the bar reports as "many" rather than as "none". */
export function onTermFindResults(

Check warning on line 271 in src/renderer/src/components/TerminalPanel.tsx

View workflow job for this annotation

GitHub Actions / check

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
id: string,
cb: (r: { index: number; count: number }) => void
): () => void {
Expand Down Expand Up @@ -529,18 +535,14 @@
}
}, [sessionId, root, shellId])

// The 4px frame round the canvas is a BORDER in --p-bg, not padding over a
// background. The canvas paints the theme's ground itself, at the window's
// alpha when the window is acrylic, so a background here would be a second
// translucent coat under it and a visibly darker panel than the rest of the
// window (Prism's acrylic terminal made that obvious); padding alone would
// leave a frame of bare desktop. A border is painted once and the canvas
// sits inside it, so every pixel of the panel gets exactly one coat.
// 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.
return (
<div
ref={box}
data-term-region
className="h-full w-full min-h-0 min-w-0 border-4 border-[color:var(--p-bg)]"
className="h-full w-full min-h-0 min-w-0 bg-[var(--p-bg)] p-1"
/>
)
}
41 changes: 41 additions & 0 deletions tools/e2e/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,44 @@ const scenarios = {
await until(async () => (await tabLabels(page)).length === 1)
const mode = () => page.evaluate(() => document.documentElement.dataset.mode)
ok((await mode()) === 'dark', 'the default theme is dark')
// The panel is ONE surface in the theme's ground, down to its last pixel.
// xterm sizes itself in whole rows, so a strip under the last row is never
// its to paint; left transparent, that strip showed the native window
// background (owner screenshot: a grey bar under a black terminal).
const ground = () =>
page.evaluate(() => {
const rgb = (c) => (c.match(/[\d.]+/g) ?? []).slice(0, 3).map(Number).join(',')
const probe = document.createElement('span')
probe.style.color = getComputedStyle(document.documentElement).getPropertyValue('--p-bg-solid')
document.body.appendChild(probe)
const want = rgb(getComputedStyle(probe).color)
probe.remove()
const box = document.querySelector('[data-term-region]').getBoundingClientRect()
const rows = document.querySelector('.xterm-screen').getBoundingClientRect()
// The first painted ground under a point, walking up from what is there.
const under = (x, y) => {
let el = document.elementFromPoint(x, y)
while (el) {
const c = getComputedStyle(el).backgroundColor
const alpha = Number((c.match(/[\d.]+/g) ?? [])[3] ?? 1)
if (c.startsWith('rgb') && alpha > 0) return rgb(c)
el = el.parentElement
}
return 'none'
}
const x = box.left + box.width / 2
return {
want,
strip: Math.round(box.bottom - rows.bottom),
underLastRow: under(x, box.bottom - 2),
frame: under(box.left + 1, box.top + box.height / 2),
rows: under(x, rows.top + rows.height / 2)
}
})
const g = await ground()
ok(g.strip > 0, `there IS a strip under the last row to get wrong (${g.strip}px)`)
ok(g.underLastRow === g.want, `and it is the theme's ground (${g.underLastRow} vs ${g.want})`)
ok(g.frame === g.want && g.rows === g.want, 'as are the frame round the rows and the rows themselves')
await page.keyboard.press('Control+,')
await page.locator('[data-settings-tab="appearance"]').click()
await page.locator('[data-term-card="github"]').first().click()
Expand All @@ -218,6 +256,9 @@ const scenarios = {
return 0.2126 * lin((n >> 16) & 255) + 0.7152 * lin((n >> 8) & 255) + 0.0722 * lin(n & 255)
})
ok(lum > 0.4, `the ground really is light (luminance ${lum.toFixed(2)})`)
await page.locator('[data-tab]').first().click()
const g2 = await ground()
ok(g2.underLastRow === g2.want && g2.want !== g.want, `the strip follows the theme (${g2.underLastRow})`)
const bg = await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].getBackgroundColor())
ok(/^#f/i.test(bg), `main was told the window's ground (${bg})`)
await app.close().catch(() => {})
Expand Down
Loading