From 473813cc9593e07569f4fcef3b36368b58a43c6d Mon Sep 17 00:00:00 2001 From: hbrooks Date: Thu, 20 Aug 2026 14:04:31 -0400 Subject: [PATCH 1/2] ui: detect the terminal background and pick a light or dark palette --- src/commands/connect.ts | 3 + src/lib/terminalBackground.ts | 91 ++++++++++++++++++++++++++ src/lib/theme.ts | 108 ++++++++++++++++++++++--------- src/ui/launch.tsx | 4 +- test/terminal-background.test.ts | 49 ++++++++++++++ 5 files changed, 224 insertions(+), 31 deletions(-) create mode 100644 src/lib/terminalBackground.ts create mode 100644 test/terminal-background.test.ts 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) + }) +}) From 9908e0fe267f2ca892f2d4cc3b60942e2c22cdda Mon Sep 17 00:00:00 2001 From: hbrooks Date: Thu, 20 Aug 2026 14:05:41 -0400 Subject: [PATCH 2/2] facts: trim sassy closing lines --- src/lib/facts.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/lib/facts.ts b/src/lib/facts.ts index 022fe63..8d802cc 100644 --- a/src/lib/facts.ts +++ b/src/lib/facts.ts @@ -6,7 +6,7 @@ export const FACTS: readonly string[] = [ 'The first computer "bug" was literal: a moth taped into the Harvard Mark II logbook in 1947, with the note "first actual case of bug being found."', 'Python is named after Monty Python, not the snake. Guido van Rossum was reading the scripts while building it.', - 'The Apollo 11 guidance computer had about 4KB of RAM. Your terminal prompt is using more memory than the moon landing did.', + 'The Apollo 11 guidance computer had about 4KB of RAM.', 'In 1996, a $370 million Ariane 5 rocket exploded because of a 64-bit float being stuffed into a 16-bit integer.', '"Ada", the language, is named after Ada Lovelace, who wrote the first published algorithm in 1843 for a computer that was never built.', 'Linus Torvalds also wrote Git in about two weeks, mostly because he was annoyed at every existing version control system.', @@ -14,8 +14,8 @@ export const FACTS: readonly string[] = [ 'NASA still flies code written in the 1970s: the Voyager probes get patches from billions of miles away, at 160 bits per second.', 'The "404" error is just HTTP\'s fourth class of status code, fourth entry. The legend about a "room 404 at CERN" is itself a 404: not found.', "Windows 95 had a timer bug that crashed the OS after exactly 49.7 days of uptime. Nobody noticed for years because nobody's Windows 95 stayed up that long.", - 'JavaScript was built in 10 days. It now runs most of the visible internet. Sleep well.', - 'A single bit flipped by a cosmic ray once gave a Belgian election candidate 4,096 extra votes. The universe has write access to your RAM.', + 'JavaScript was built in 10 days. It now runs most of the visible internet.', + 'A single bit flipped by a cosmic ray once gave a Belgian election candidate 4,096 extra votes.', "There's a number so illegal that publishing it was a crime: the AACS encryption key. People printed it on t-shirts anyway.", 'Grace Hopper handed out foot-long wires to explain a nanosecond: that\'s how far light travels in one. She kept a "microsecond" coil of wire that was 984 feet.', 'In binary, 42 is 101010. Douglas Adams claimed the choice was a joke with no meaning. Fans refuse to believe him to this day.', @@ -30,16 +30,16 @@ export const FACTS: readonly string[] = [ 'The Unix epoch began January 1, 1970. On January 19, 2038, 32-bit timestamps overflow. Y2K fans call it "the sequel."', 'The @ symbol was a dying accounting character until 1971. Ray Tomlinson picked it to separate user from machine in the first email address.', 'Google was misspelled. The founders meant "googol," the number 1 followed by 100 zeros, and registered the typo.', - 'The first banner ad ran in 1994. It said "Have you ever clicked your mouse right HERE?" and 44% of people clicked. It has been downhill ever since.', + 'The first banner ad ran in 1994. It said "Have you ever clicked your mouse right HERE?" and 44% of people clicked.', 'Space Invaders sped up as you killed aliens because the hardware had less to draw. The bug became the difficulty curve.', 'Pac-Man breaks on level 256 because the level counter is a single byte. The kill screen is an integer overflow you can visit.', 'Walk far enough in old Minecraft and the terrain turned to glitchy chaos, the "Far Lands." That was floating point math running out of precision.', 'The Konami Code was written by Kazuhisa Hashimoto, who was porting Gradius and could not beat his own game while testing it. He added a cheat and forgot to remove it.', 'Mario has a mustache because 1981 pixels could not draw a mouth. He wears a hat because they could not animate hair.', 'Wi-Fi does not stand for anything. A branding agency made it up to sound like "hi-fi." People invented "wireless fidelity" after the fact.', - 'CAPTCHA stands for "Completely Automated Public Turing test to tell Computers and Humans Apart." You have failed one before. Think about that.', - 'The first 1GB hard drive shipped in 1980. It cost $40,000 and weighed 550 pounds. Today a gigabyte falls out of your pocket.', - 'Junk mail is called "spam" because of a Monty Python sketch. Vikings chant "spam" until nobody can talk. That is also how your inbox works.', + 'CAPTCHA stands for "Completely Automated Public Turing test to tell Computers and Humans Apart."', + 'The first 1GB hard drive shipped in 1980. It cost $40,000 and weighed 550 pounds.', + 'Junk mail is called "spam" because of a Monty Python sketch. Vikings chant "spam" until nobody can talk.', 'A "jiffy" is a real unit of time. The Linux kernel uses it for its clock tick. "Back in a jiffy" is a promise you can measure.', 'The Boeing 787 had a counter that overflowed after 248 days and cut electrical power. The fix: reboot the plane on a schedule. Turn it off and on again, but with paperwork.', 'The first domain name ever registered was symbolics.com, in 1985. It is still up, as a museum of itself.', @@ -66,7 +66,7 @@ export const FACTS: readonly string[] = [ 'The Apple II shipped in April 1977 at $1,298 for 4 kilobytes of RAM. It stayed in production for 16 years and sold around 6 million units.', 'IBM announced the System/360 on April 7, 1964. It made the 8-bit byte standard, and code written for it can still run on IBM Z mainframes today.', 'The IBM Personal Computer launched on August 12, 1981 at $1,565 with an Intel 8088. It shipped over 750,000 units in two years.', - 'Herman Hollerith patented punched-card tabulation in 1889 and founded the company that became IBM in 1911. Your CSV import has deep roots.', + 'Herman Hollerith patented punched-card tabulation in 1889 and founded the company that became IBM in 1911.', 'The Xerox Alto arrived on March 1, 1973 with a GUI, a mouse, Ethernet, and WYSIWYG editing. Xerox built about 2,000 and sold none of them.', 'The Macintosh 128K shipped January 24, 1984 at $2,495 with a Motorola 68000, and effectively created desktop publishing.', 'Donald Knuth started The Art of Computer Programming in 1962 and published Volume 1 in 1968. He then wrote TeX because he hated how the typesetting looked.', @@ -80,7 +80,7 @@ export const FACTS: readonly string[] = [ 'Vint Cerf and Bob Kahn published the design for TCP/IP in May 1974. They received the Turing Award for it 30 years later.', 'RFC 1 was written by Steve Crocker of UCLA and published April 7, 1969. It was titled "Host Software" and read like a polite suggestion.', 'TAT-8 opened in 1988 as the first transatlantic fiber-optic cable to carry internet traffic. It could handle 40,000 phone calls at once.', - 'Amazon S3 launched on March 14, 2006, as one of the first generally available cloud services. Half the internet now has a bucket somewhere.', + 'Amazon S3 launched on March 14, 2006, as one of the first generally available cloud services.', 'Usenet was built in 1980 by Tom Truscott and Jim Ellis at Duke and UNC. It invented threaded discussion, flame wars, and FAQs.', 'Mike Muuss wrote ping in December 1983, in about a thousand lines, and named it after sonar. He wrote it to debug a misbehaving network.', 'Van Jacobson wrote traceroute in 1987 after Steve Deering suggested the trick of abusing the IP time-to-live field on purpose.', @@ -161,7 +161,7 @@ export const FACTS: readonly string[] = [ 'Ctrl-Alt-Delete was invented in 1981 by IBM engineer David Bradley, who later said, "I may have invented it, but I think Bill made it famous."', 'Semiconductor-grade silicon is refined to 99.9999999% purity, known in the industry as nine nines, before it is sliced into 300 mm wafers.', 'The Cray-2 of 1985 cooled itself by fully immersing its circuit boards in liquid Fluorinert, which earned it the nickname Bubbles.', - 'The GRiD Compass of 1982 was the first clamshell laptop, cost $8,150, and flew on the Space Shuttle. NASA had a higher budget than you.', + 'The GRiD Compass of 1982 was the first clamshell laptop, cost $8,150, and flew on the Space Shuttle.', "NASA's Perseverance rover and the James Webb Space Telescope both run on the RAD750, a radiation-hardened PowerPC that tops out near 200 MHz.", 'The first webcam watched a coffee pot at Cambridge starting in 1991 so researchers would stop walking to an empty pot. It went on the web in 1993, was switched off in 2001, and the pot sold on eBay for 3,350 pounds.', 'id Software released Doom on December 10, 1993 by uploading it to a University of Wisconsin FTP server. So many people connected at once that the university network fell over.',