-
Notifications
You must be signed in to change notification settings - Fork 0
ui: detect the terminal background and pick a light or dark palette #115
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| import { applyThemeMode, type ThemeMode } from './theme' | ||
|
|
||
| // Which palette to render: ask the terminal what its background is, the same | ||
| // way Claude Code's "auto" theme does. Order of trust: | ||
| // | ||
| // 1. OSC 11 — the terminal reports its actual background color. Supported | ||
| // by every mainstream emulator (iTerm2, Terminal.app, kitty, WezTerm, | ||
| // Ghostty, Windows Terminal, VS Code); tmux passes it through. | ||
| // 2. COLORFGBG — a legacy env hint ("15;0" = light-on-dark) set by rxvt and | ||
| // a few others. Stale after a mid-session theme change, but better than | ||
| // guessing. | ||
| // 3. dark — the brand's main mode and the safe default when nothing answers. | ||
| // | ||
| // Called once at each UI entry point, BEFORE the first Ink render, so no frame | ||
| // ever paints in the wrong palette and nothing needs to re-render on the | ||
| // answer. | ||
|
|
||
| // OSC 11 reply: `\x1b]11;rgb:RRRR/GGGG/BBBB` terminated by BEL or ST, where | ||
| // each channel is 1-4 hex digits scaled to 16 bits. | ||
| const OSC_REPLY = /\x1b\]11;rgb:([0-9a-f]{1,4})\/([0-9a-f]{1,4})\/([0-9a-f]{1,4})/i | ||
|
|
||
| function channelToUnit(hex: string): number { | ||
| return parseInt(hex, 16) / (16 ** hex.length - 1) | ||
| } | ||
|
|
||
| export function modeFromOscReply(reply: string): ThemeMode | null { | ||
| const m = OSC_REPLY.exec(reply) | ||
| if (!m) return null | ||
| const [r, g, b] = [m[1]!, m[2]!, m[3]!].map(channelToUnit) as [number, number, number] | ||
| // Perceived luminance (Rec. 601). The cut is the midpoint: a background | ||
| // brighter than half is a light theme. | ||
| return 0.299 * r + 0.587 * g + 0.114 * b > 0.5 ? 'light' : 'dark' | ||
| } | ||
|
|
||
| // COLORFGBG is "fg;bg" or "fg;default;bg"; the LAST field is the background's | ||
| // ANSI-16 index. 7 and 15 are the whites; everything else is dark or unknown. | ||
| export function modeFromColorFgBg(value: string | undefined): ThemeMode | null { | ||
| const bg = value?.trim().split(';').pop() | ||
| if (!bg || !/^\d+$/.test(bg)) return null | ||
| return bg === '7' || bg === '15' ? 'light' : 'dark' | ||
| } | ||
|
|
||
| function queryOsc11(timeoutMs = 200): Promise<ThemeMode | null> { | ||
| return new Promise((resolve) => { | ||
| const { stdin, stdout } = process | ||
| if (!stdin.isTTY || !stdout.isTTY || typeof stdin.setRawMode !== 'function') { | ||
| resolve(null) | ||
| return | ||
| } | ||
| const wasRaw = stdin.isRaw | ||
| let buffer = '' | ||
| let settled = false | ||
| const finish = (mode: ThemeMode | null): void => { | ||
| if (settled) return | ||
| settled = true | ||
| clearTimeout(timer) | ||
| stdin.off('data', onData) | ||
| stdin.setRawMode(wasRaw) | ||
| // Ink attaches its own stdin handling after this; leave the stream | ||
| // paused so a bare detection doesn't hold the process open. | ||
| stdin.pause() | ||
| resolve(mode) | ||
| } | ||
| const onData = (chunk: Buffer): void => { | ||
| buffer += chunk.toString('latin1') | ||
| const mode = modeFromOscReply(buffer) | ||
| if (mode) finish(mode) | ||
| else if (buffer.length > 64) finish(null) | ||
| } | ||
| const timer = setTimeout(() => finish(null), timeoutMs) | ||
| stdin.setRawMode(true) | ||
| stdin.resume() | ||
| stdin.on('data', onData) | ||
| stdout.write('\x1b]11;?\x1b\\') | ||
| }) | ||
| } | ||
|
|
||
| // Detect and apply, once, at a UI entry point. Never throws — a terminal that | ||
| // answers strangely just keeps the dark default. | ||
| export async function applyDetectedThemeMode(): Promise<ThemeMode> { | ||
| let mode: ThemeMode | null = null | ||
| try { | ||
| mode = await queryOsc11() | ||
| } catch { | ||
| mode = null | ||
| } | ||
| mode ??= modeFromColorFgBg(process.env.COLORFGBG) | ||
| mode ??= 'dark' | ||
| applyThemeMode(mode) | ||
| return mode | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,19 +1,21 @@ | ||||||
| import chalk from 'chalk' | ||||||
|
|
||||||
| // The Ellipsis brand palette, dark mode — the CLI's one source of color. | ||||||
| // The Ellipsis brand palette — the CLI's one source of color. | ||||||
| // | ||||||
| // These hexes are COPIES of brand/tokens.json in the ellipsis monorepo (the | ||||||
| // canonical source). This repo can't reach that file, so when a brand color | ||||||
| // changes there, it has to be re-copied here by hand. | ||||||
| // | ||||||
| // Dark mode is the brand's main mode, which is what a terminal is, so the CLI | ||||||
| // only ever renders the dark palette — there is no light variant to switch to. | ||||||
| // The CLI carries BOTH brand modes and picks one at startup by asking the | ||||||
| // terminal for its background (OSC 11, then COLORFGBG, then dark — see | ||||||
| // lib/terminalBackground.ts). Dark is the default because it is the brand's | ||||||
| // main mode and the safe guess when the terminal won't say. | ||||||
| // | ||||||
| // One rule carried over from the web app (landing globals.css `.dark`): the | ||||||
| // accent in dark mode is BONE, not brand blue. Brand ink #175173 scores 1.79:1 | ||||||
| // on a dark surface — unreadable as terminal text. So emphasis is carried by | ||||||
| // brightness (bone against stone), not by hue. The ▶ cursor is the one | ||||||
| // exception, and takes `cursor` below. | ||||||
| // on a dark surface — unreadable as terminal text. So dark emphasis is carried | ||||||
| // by brightness (bone against stone), not by hue. In light mode the brand ink | ||||||
| // IS legible (8.5:1 on white), so the cursor takes it there. | ||||||
| // | ||||||
| // EXACTLY ONE SURFACE IS PAINTED: the composer's (`inputSurface` below). The CLI | ||||||
| // used to paint a canvas behind everything and lift panels onto it, which worked | ||||||
|
|
@@ -35,41 +37,87 @@ import chalk from 'chalk' | |||||
| // takes `muted`, never a bare `dimColor`. (dim is fine ON TOP of an explicit | ||||||
| // colour, where it only shades a known hue.) | ||||||
|
|
||||||
| export const theme = { | ||||||
| // Type. `foreground` is body copy and doubles as the accent (see above); | ||||||
| // `muted` is every secondary string (meta, hints, timestamps) — and, since | ||||||
| // the rule above rules out a bare `dimColor`, it is also how a quiet line | ||||||
| // reads quiet. 7.4:1 on the brand charcoal, so quiet still means legible. | ||||||
| foreground: '#f0efe9', | ||||||
| muted: '#a8a59c', | ||||||
| export interface Palette { | ||||||
| // Type. `foreground` is body copy and doubles as the accent; `muted` is | ||||||
| // every secondary string (meta, hints, timestamps) — and, since the rule | ||||||
| // above rules out a bare `dimColor`, it is also how a quiet line reads | ||||||
| // quiet, while staying legible on its canvas. | ||||||
| foreground: string | ||||||
| muted: string | ||||||
|
|
||||||
| // The ▶ cursor, and nothing else — which, with no highlight bar to fall back | ||||||
| // on, is now the ONLY thing that says "you are here". Bone-on-stone was too | ||||||
| // quiet a step to find at a glance, so the cursor carries HUE as well as | ||||||
| // brightness: cyan is the one hue not already spoken for (green = done, amber | ||||||
| // = working, red = failed), so it never reads as a status. | ||||||
| cursor: '#5fd3e0', | ||||||
| // on, is the ONLY thing that says "you are here". It carries HUE as well as | ||||||
| // brightness: a hue not already spoken for (green = done, amber = working, | ||||||
| // red = failed), so it never reads as a status. | ||||||
| cursor: string | ||||||
|
|
||||||
| // Status. | ||||||
| success: '#4ebc7b', | ||||||
| error: '#e5544b', | ||||||
| success: string | ||||||
| error: string | ||||||
| // In-flight. brand/tokens.json has no dedicated "working" color; this is | ||||||
| // syntaxLiteral, the warm amber, which is the only brand hue that reads as | ||||||
| // activity without colliding with success green or error red. | ||||||
| active: '#d9bd8d', | ||||||
| active: string | ||||||
|
|
||||||
| // Syntax, for rendered markdown in a transcript. Same values the web apps | ||||||
| // use for code blocks, so a snippet reads the same in the CLI as in the docs. | ||||||
| syntaxLiteral: string | ||||||
| syntaxString: string | ||||||
| } | ||||||
|
|
||||||
| // brand/tokens.json `dark` values. muted is 7.4:1 on the brand charcoal. | ||||||
| // cursor is the one non-brand hex: cyan, the hue no status owns (see above). | ||||||
| export const darkPalette: Palette = { | ||||||
| foreground: '#f0efe9', | ||||||
| muted: '#a8a59c', | ||||||
| cursor: '#5fd3e0', | ||||||
| success: '#4ebc7b', | ||||||
| error: '#e5544b', | ||||||
| active: '#d9bd8d', | ||||||
| syntaxLiteral: '#d9bd8d', | ||||||
| syntaxString: '#c8c6bc', | ||||||
| } | ||||||
|
|
||||||
| // The composer's fill — the app's ONE painted surface (see the note above for why | ||||||
| // it is the only one that can be). The brand panel step, neutralized: chalk sends | ||||||
| // any hex whose channels differ to the 6x6x6 colour cube, whose darkest step | ||||||
| // above black is rgb(95,95,95), so the authored warm #262523 paints as a MID GREY | ||||||
| // slab on a terminal that does 256 colours but not truecolor (Terminal.app, tmux | ||||||
| // without RGB, mosh, conhost). Equal channels route to the greyscale ramp | ||||||
| // instead, where a near-black stays near-black. Truecolor terminals lose only the | ||||||
| // warmth, which is invisible at this brightness. | ||||||
| export const inputSurface = chalk.level >= 3 ? '#262523' : '#252525' | ||||||
| // brand/tokens.json `light` values. cursor is the brand ink accent — in light | ||||||
| // mode it is legible AND it is exactly what the accent means on the web apps | ||||||
| // (interactive, never status). active mirrors dark by borrowing syntaxLiteral. | ||||||
| export const lightPalette: Palette = { | ||||||
| foreground: '#1c1b17', | ||||||
| muted: '#706f66', | ||||||
| cursor: '#175173', | ||||||
| success: '#10b981', | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Light-mode success green is 2.5:1 on white — below the 3:1 floor every other value in this palette clears; darken it. Contrast against #ffffff: cursor 8.5, foreground 17.2, syntaxString 7.6, muted 5.1, active 4.9, error 4.8, success 2.5. It is used as a foreground, not a fill — the completed-step gutter (transcriptRows.ts:675) and the closed-session dot in sessions.ts:71, which additionally applies dim on top.
Suggested change
|
||||||
| error: '#dc2626', | ||||||
| active: '#8a6d2a', | ||||||
| syntaxLiteral: '#8a6d2a', | ||||||
| syntaxString: '#56544b', | ||||||
| } | ||||||
|
|
||||||
| // The live palette. MUTATED IN PLACE by applyThemeMode so every existing | ||||||
| // `theme.foreground` call site follows the mode with no plumbing; the mode is | ||||||
| // set once at startup, before the first render, and never after. | ||||||
| export const theme: Palette = { ...darkPalette } | ||||||
|
|
||||||
| // The composer's fill — the app's ONE painted surface (see the note above for | ||||||
| // why it is the only one that can be). The brand panel step, neutralized: chalk | ||||||
| // sends any hex whose channels differ to the 6x6x6 colour cube, whose darkest | ||||||
| // step above black is rgb(95,95,95), so the authored warm dark #262523 paints | ||||||
| // as a MID GREY slab on a terminal that does 256 colours but not truecolor | ||||||
| // (Terminal.app, tmux without RGB, mosh, conhost). Equal channels route to the | ||||||
| // greyscale ramp instead, where a near-black stays near-black — and, in light | ||||||
| // mode, a near-white sand (brand border #e3e1d8) stays near-white. Truecolor | ||||||
| // terminals lose only the warmth, which is invisible at this brightness. | ||||||
| export let inputSurface = chalk.level >= 3 ? '#262523' : '#252525' | ||||||
|
|
||||||
| export type ThemeMode = 'light' | 'dark' | ||||||
|
|
||||||
| export function applyThemeMode(mode: ThemeMode): void { | ||||||
| Object.assign(theme, mode === 'light' ? lightPalette : darkPalette) | ||||||
| inputSurface = | ||||||
| mode === 'light' | ||||||
| ? chalk.level >= 3 | ||||||
| ? '#e3e1d8' | ||||||
| : '#e4e4e4' | ||||||
| : chalk.level >= 3 | ||||||
| ? '#262523' | ||||||
| : '#252525' | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| import { describe, expect, it } from 'vitest' | ||
| import { modeFromColorFgBg, modeFromOscReply } from '../src/lib/terminalBackground' | ||
| import { applyThemeMode, darkPalette, lightPalette, theme } from '../src/lib/theme' | ||
|
|
||
| describe('modeFromOscReply', () => { | ||
| it('reads a light background from a 16-bit-per-channel reply', () => { | ||
| expect(modeFromOscReply('\x1b]11;rgb:ffff/ffff/ffff\x07')).toBe('light') | ||
| expect(modeFromOscReply('\x1b]11;rgb:fdfd/f6f6/e3e3\x1b\\')).toBe('light') | ||
| }) | ||
|
|
||
| it('reads a dark background', () => { | ||
| expect(modeFromOscReply('\x1b]11;rgb:1c1c/1b1b/1a1a\x07')).toBe('dark') | ||
| expect(modeFromOscReply('\x1b]11;rgb:0000/0000/0000\x07')).toBe('dark') | ||
| }) | ||
|
|
||
| it('handles short channel widths', () => { | ||
| expect(modeFromOscReply('\x1b]11;rgb:ff/ff/ff\x07')).toBe('light') | ||
| expect(modeFromOscReply('\x1b]11;rgb:0/0/0\x07')).toBe('dark') | ||
| }) | ||
|
|
||
| it('rejects anything that is not an OSC 11 color reply', () => { | ||
| expect(modeFromOscReply('')).toBeNull() | ||
| expect(modeFromOscReply('\x1b[6n')).toBeNull() | ||
| expect(modeFromOscReply('\x1b]11;?\x07')).toBeNull() | ||
| }) | ||
| }) | ||
|
|
||
| describe('modeFromColorFgBg', () => { | ||
| it('reads the background from the last field', () => { | ||
| expect(modeFromColorFgBg('0;15')).toBe('light') | ||
| expect(modeFromColorFgBg('15;0')).toBe('dark') | ||
| expect(modeFromColorFgBg('0;default;7')).toBe('light') | ||
| }) | ||
|
|
||
| it('returns null when unset or malformed', () => { | ||
| expect(modeFromColorFgBg(undefined)).toBeNull() | ||
| expect(modeFromColorFgBg('')).toBeNull() | ||
| expect(modeFromColorFgBg('default')).toBeNull() | ||
| }) | ||
| }) | ||
|
|
||
| describe('applyThemeMode', () => { | ||
| it('swaps the live palette in place, so existing imports follow', () => { | ||
| applyThemeMode('light') | ||
| expect(theme.foreground).toBe(lightPalette.foreground) | ||
| applyThemeMode('dark') | ||
| expect(theme.foreground).toBe(darkPalette.foreground) | ||
| }) | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
finish() detaches the reader and pauses stdin without draining it, so any part of the OSC 11 reply that has not arrived yet is buffered and replayed into Ink as typed text.
The regex matches before the reply's BEL/ST terminator, and the 200ms timeout can fire before the reply arrives at all. I ran queryOsc11 against a fake TTY: with the reply split so the terminator lands in a second chunk, stdin still holds "\x1b\" after resolve; with the reply arriving at 260ms, stdin holds the entire "\x1b]11;rgb:fdfd/f6f6/e3e3\x1b\". Ink resumes that same stream, and its parser turns those bytes into keypresses with ctrl/meta/escape all false ("\x1b]", "11;rgb:fdfd/f6f6/e3e3", "\x1b\"), which ConnectApp.tsx:1467 feeds to insertAtCursor — the composer opens pre-filled with raw ESC bytes and hex garbage, and enter POSTs it as a message. Requiring the terminator in OSC_REPLY (
(?:\x07|\x1b\\)) fixes the split-chunk half deterministically; the late-reply half needs the stdin reader to stay attached and discarding until just before render, or a\x1b[6nsent after the OSC query as an ordering barrier so a terminal that will not answer OSC 11 is detected by its CPR reply instead of by a timeout that races the answer.