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: 3 additions & 0 deletions src/commands/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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.
Expand Down
20 changes: 10 additions & 10 deletions src/lib/facts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,16 @@
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.',
"The QWERTY layout was designed partly to slow typists down so mechanical typewriter arms wouldn't jam. We just never stopped using it.",
'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.',
Expand All @@ -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.',
Expand All @@ -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.',
Expand All @@ -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.',
Expand Down Expand Up @@ -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.',
Expand Down
91 changes: 91 additions & 0 deletions src/lib/terminalBackground.ts
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\\')
Comment on lines +64 to +74

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An OSC 11 reply that arrives after the 200ms timeout is typed into the composer: finish(null) detaches the listener but leaves the bytes in the stream, and Ink's readable-based reader picks them up as keyboard input.

Verified on a paused PassThrough that a chunk written after off('data') + pause() is delivered to a later on('readable') reader, and against ink@7.1.1's parser: \x1b]11;rgb:2222/2222/2222\x1b\\ parses to three input events, the middle one the literal string 11;rgb:2222/2222/2222, which ConnectApp's useInput falls through to insertAtCursor. 200ms is an ordinary round trip over SSH, so the first frame opens with junk in the input box. The same window also drops any keystroke the user types during detection — buffer is discarded on finish. A grace-period drain won't fix it cleanly (it keeps eating typeahead); the usual fix is to chase the query with a DA1 (\x1b[c) sync marker and only give up once DA1's reply comes back, so a late OSC 11 reply cannot exist.

})
}

// 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
}
108 changes: 78 additions & 30 deletions src/lib/theme.ts
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
Expand All @@ -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'
}
Loading