diff --git a/src/commands/connect.ts b/src/commands/connect.ts index ec72f6a..b34dd57 100644 --- a/src/commands/connect.ts +++ b/src/commands/connect.ts @@ -8,6 +8,7 @@ import { requireToken, resolveApiBase, resolveAppBase } from '../lib/config' import { runAction } from '../lib/output' import { sessionUrl } from '../lib/urls' import { makeOpenSocket, resolveWsBase } from '../lib/stream' +import { applyDetectedThemeMode } from '../lib/terminalBackground' import { ConnectApp } from '../ui/ConnectApp' import { canHostSessionsUi, defaultStartRequest, runSessionsUi } from '../ui/launch' @@ -99,6 +100,8 @@ export async function runConnect( const [{ session }, me] = await Promise.all([ client.sessions.get(sessionId), client.me(), + // Pick the palette for this terminal's background before the first frame. + applyDetectedThemeMode(), ]) const c = connectability(session) // --no-input forces watch-only even when the session would accept messages. diff --git a/src/lib/terminalBackground.ts b/src/lib/terminalBackground.ts new file mode 100644 index 0000000..018f705 --- /dev/null +++ b/src/lib/terminalBackground.ts @@ -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 { + 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 { + let mode: ThemeMode | null = null + try { + mode = await queryOsc11() + } catch { + mode = null + } + mode ??= modeFromColorFgBg(process.env.COLORFGBG) + mode ??= 'dark' + applyThemeMode(mode) + return mode +} diff --git a/src/lib/theme.ts b/src/lib/theme.ts index 3e358d1..06301f5 100644 --- a/src/lib/theme.ts +++ b/src/lib/theme.ts @@ -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', + 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' +} diff --git a/src/ui/launch.tsx b/src/ui/launch.tsx index dffa953..2824ec8 100644 --- a/src/ui/launch.tsx +++ b/src/ui/launch.tsx @@ -4,6 +4,7 @@ import { api } from '../lib/api' import { requireToken, resolveApiBase, resolveAppBase, sessionBar } from '../lib/config' import { repoFromCwd } from '../lib/laptop' import { makeOpenSocket, resolveWsBase } from '../lib/stream' +import { applyDetectedThemeMode } from '../lib/terminalBackground' import type { StartAgentSessionRequest } from '../lib/types' import { SessionsApp } from './SessionsApp' @@ -51,7 +52,8 @@ export async function runSessionsUi(options: SessionsUiOptions): Promise { const client = api() const token = requireToken() const openSocket = makeOpenSocket(token, resolveWsBase(resolveApiBase())) - const me = await client.me() + // Pick the palette for this terminal's background before the first frame. + const [me] = await Promise.all([client.me(), applyDetectedThemeMode()]) // No screen-clearing dance: the chat prints its settled transcript into THIS // terminal's scrollback (see ConnectApp), so the conversation grows down the diff --git a/test/terminal-background.test.ts b/test/terminal-background.test.ts new file mode 100644 index 0000000..e070a7a --- /dev/null +++ b/test/terminal-background.test.ts @@ -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) + }) +})