diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index e4cec3a..25a2ff8 100644 Binary files a/src/lib/markdown.ts and b/src/lib/markdown.ts differ diff --git a/src/lib/theme.ts b/src/lib/theme.ts index 900642c..0af4df7 100644 --- a/src/lib/theme.ts +++ b/src/lib/theme.ts @@ -1,3 +1,5 @@ +import chalk from 'chalk' + // The Ellipsis brand palette, dark mode — the CLI's one source of color. // // These hexes are COPIES of brand/tokens.json in the ellipsis monorepo (the @@ -11,20 +13,76 @@ // accent in dark mode is BONE, not brand blue. Brand ink #175173 scores // 1.79:1 on the panel — unreadable as terminal text. So "you are here" is // carried by brightness (bone against stone), not by hue. +// +// Because the CLI paints its own canvas, the palette only holds if it is used +// for EVERY cell of the frame. Two rules keep it whole on a terminal whose own +// theme is light: +// +// 1. Every glyph takes a color from this file. Ink leaves a `` with no +// `color` prop on the terminal's DEFAULT foreground, which under a light +// theme is near-black — the same near-black we just painted the canvas +// with, so the text vanishes. `dimColor` on its own is that bug plus an +// \x1b[2m: secondary copy takes `muted`, never a bare `dimColor`. (dim is +// fine ON TOP of an explicit color, where it only shades a known hue.) +// 2. Surfaces reach ink already quantized for the terminal's color depth — +// see `surfaceFor`, which is why the three surface entries below are +// computed rather than literal. + +// The surfaces as authored. Call sites never read these: they take the +// `theme.*` entries, which are these run through `surfaceFor`. +const BRAND_SURFACES = { + canvas: '#1c1b1a', + panel: '#262523', + panelActive: '#343330', +} as const + +// A surface hex ink can paint at `level` without losing the step between one +// surface and the next. +// +// chalk resolves a hex onto the 256-color palette two different ways: to the +// 24-rung GREYSCALE RAMP (indexes 232-255, ~10 units apart) when r, g and b +// are equal, and otherwise to the 6x6x6 COLOR CUBE, whose darkest step above +// black is rgb(95,95,95). The brand surfaces are WARM greys — their channels +// differ by a point or two — so on a terminal that does 256 colors but not +// truecolor (Terminal.app, tmux without RGB, mosh, plain conhost) all three +// land on cube index 59 simultaneously: the near-black canvas paints as a mid +// grey slab, and the panel and active steps disappear along with every "you +// are here" highlight that was carried by them. +// +// Averaging the channels is invisible at this brightness (a warm near-black +// and a neutral near-black are the same wall of dark) and puts each surface +// back on its own rung: 234, 235, 236. Truecolor terminals get the authored +// warmth untouched; a 16-color terminal renders both spellings as its palette +// black, so the substitution costs nothing there either. +export function surfaceFor(hex: string, level: number): string { + if (level >= 3) return hex + const value = hex.replace('#', '') + const channels = [0, 2, 4].map((i) => Number.parseInt(value.slice(i, i + 2), 16)) + if (channels.some(Number.isNaN)) return hex + const mean = Math.round((channels[0] + channels[1] + channels[2]) / 3) + return `#${mean.toString(16).padStart(2, '0').repeat(3)}` +} + +// `chalk.level` is read once, at import: ink colorizes through this very chalk +// instance (it is a hoisted single copy), so what it can render is what we +// quantize for. +const COLOR_LEVEL: number = chalk.level export const theme = { // The app canvas and the lifted panel an input sits on. ~1.1:1 apart: barely // a lift, which is the point — a panel should separate, not stripe. - canvas: '#1c1b1a', - panel: '#262523', + canvas: surfaceFor(BRAND_SURFACES.canvas, COLOR_LEVEL), + panel: surfaceFor(BRAND_SURFACES.panel, COLOR_LEVEL), // One step lighter than `panel`: the brand border hairline, doing duty as // the "you are here" surface (highlighted message, focused composer, // selected nav row). Selection is a brightness step between surfaces — // never the full inverse flash, which reads bone-white and far too loud. - panelActive: '#343330', + panelActive: surfaceFor(BRAND_SURFACES.panelActive, COLOR_LEVEL), // Type. `foreground` is body copy and doubles as the accent (see above); - // `muted` is every secondary string (meta, hints, timestamps). + // `muted` is every secondary string (meta, hints, timestamps) — and, since + // rule 1 above rules out a bare `dimColor`, it is also how a quiet line + // reads quiet. 7.4:1 on the canvas, so quiet still means legible. foreground: '#f0efe9', muted: '#a8a59c', @@ -40,7 +98,7 @@ export const theme = { // use for code blocks, so a snippet reads the same in the CLI as in the docs. syntaxLiteral: '#d9bd8d', syntaxString: '#c8c6bc', -} as const +} // The elevated surface an input area sits on. Named separately from // `theme.panel` because call sites mean "this is an input", not "this is diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx index a1a1bf0..a675d60 100644 --- a/src/ui/ConnectApp.tsx +++ b/src/ui/ConnectApp.tsx @@ -55,6 +55,7 @@ import { rowViewport, snapToEntry, spacerRow, + spanColor, type RowSpan, type ScrollAnchor, type TranscriptRow, @@ -1293,7 +1294,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { inside the budget so they never push a row out. */} {view.showAbove && ( - + {` ↑ ${view.hiddenAbove} more line${view.hiddenAbove === 1 ? '' : 's'} above`} )} @@ -1319,7 +1320,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { /> ))} {view.showBelow && ( - + {` ↓ ${view.hiddenBelow} more line${view.hiddenBelow === 1 ? '' : 's'} below`} )} @@ -1333,7 +1334,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { detail) can't grow the frame past the pane. */} {notice && noticeRows > 0 && ( - · {notice} + · {notice} )} {/* The composer: the input area on the elevated surface — one step @@ -1374,8 +1375,16 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { {/* The prompt is the selection glyph while the composer is where you are (focused, no transcript highlight) — the same cyan marker as everywhere else — and dim when it isn't. */} - - + {/* The explicit colour on the parent is what the bare text + children below inherit — ink would otherwise leave the typed + text on the terminal's default foreground, unreadable against + the panel on a light theme — and it gives the inverse caret a + known pair of colours to swap. */} + + {SELECTION_GLYPH}{' '} {composer.text.slice(0, composer.cursor)} @@ -1395,7 +1404,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { )} - {!props.hideMetaLine && {metaLine}} + {!props.hideMetaLine && {metaLine}} ) @@ -2102,12 +2111,11 @@ const RowLine = React.memo(function RowLine({ }, ] : row.spans - // A pulsing mark's off beat SWAPS ITS COLOUR rather than setting ink's - // dimColor: dim is \x1b[2m, which a fair number of terminals drop entirely - // when a 24-bit foreground is also set — the blink would silently do nothing - // there. Bone → grey is a real colour change, so it reads everywhere. - const markColor = (span: RowSpan): string | undefined => - span.pulse && !pulseOn ? theme.muted : span.color + // Every span, gutter mark and right-hand readout below paints an explicit + // brand colour: `spanColor` resolves the pulse's off beat, a `dim` span and a + // span with no colour of its own onto real hexes, so nothing on the row is + // left to the terminal's own palette. See its comment in transcriptRows. + const markColor = (span: RowSpan): string => spanColor(span, pulseOn) // Durations always render parenthesized, in the right-hand metadata column. const right = row.tick ? { text: `(${[humanDuration(seconds), row.right?.text].filter(Boolean).join(' ')})`, dim: true } @@ -2130,7 +2138,6 @@ const RowLine = React.memo(function RowLine({ a heartbeat rather than a character swapping in and out. */} {marker ? SELECTION_GLYPH : (row.gutter?.text ?? '')} @@ -2142,7 +2149,6 @@ const RowLine = React.memo(function RowLine({ {span.text} @@ -2153,8 +2159,7 @@ const RowLine = React.memo(function RowLine({ {right && ( {right.text} diff --git a/src/ui/SessionsApp.tsx b/src/ui/SessionsApp.tsx index dce47f7..ad0803c 100644 --- a/src/ui/SessionsApp.tsx +++ b/src/ui/SessionsApp.tsx @@ -472,7 +472,7 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { {/* truncate, never wrap: the bar is budgeted at exactly one row, and a wrapped meta line pushes the rule off the bottom of the band. */} - + {metaText ?? whoText} @@ -515,10 +515,10 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { paddingRight={1} paddingTop={1} > - + {loadError ? `✗ ${loadError}` : `loading ${mainPane.sessionId}…`} - {loadError && esc: back to the sessions} + {loadError && esc: back to the sessions} ) @@ -620,27 +620,30 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { {cursorHere ? SELECTION_GLYPH : g.glyph} {' '} {desc.slice(0, descW)} - {meta} + {meta} ) })} {rows.length === 0 && ( - {polledOnce ? 'no sessions yet' : 'loading sessions…'} + {polledOnce ? 'no sessions yet' : 'loading sessions…'} )} {/* Absorbs the rows a short list leaves empty, keeping the hint on the band's bottom edge. */} - + {navFocused ? `↑↓ move · enter open · n new · esc chat · q quit${ win.end < rows.length ? ` · ${rows.length - win.end} more below` : '' @@ -1045,21 +1048,23 @@ function NewSessionPane({ - What are we shipping today? + + What are we shipping today? + {/* The fact box is sized to the text (capped so long facts wrap at a readable measure) so short facts sit centered, not left-aligned inside a fixed column. */} - + {fact} {error && ✗ {error}} - {starting && ✻ Starting session…} + {starting && ✻ Starting session…} {/* The input panel — the SAME surface as the chat composer, stepping onto the lighter active surface while ANY of its four rows is where you are (a picker row, its open option list, or the prompt), no @@ -1085,8 +1090,10 @@ function NewSessionPane({ if (isOpen) { return ( - {' '}{r.label}: - {win.start > 0 && {' '}… {win.start} more} + {' '}{r.label}: + {win.start > 0 && ( + {' '}… {win.start} more + )} {openOptions.slice(win.start, win.end).map((opt, j) => { const at = win.start + j const hovered = at === openHover @@ -1098,10 +1105,7 @@ function NewSessionPane({ {hovered ? SELECTION_GLYPH : ' '} {' '} - + {`[${picked ? 'x' : ' '}] ${opt.label}`} @@ -1109,7 +1113,9 @@ function NewSessionPane({ ) })} {win.end < openOptions.length && ( - {' '}… {openOptions.length - win.end} more + + {' '}… {openOptions.length - win.end} more + )} ) @@ -1117,11 +1123,11 @@ function NewSessionPane({ return ( - + {active ? SELECTION_GLYPH : ' '} {' '} - {r.label}: - + {r.label}: + {rowValue(r.key)} @@ -1140,7 +1146,15 @@ function NewSessionPane({ remounts the node so a stale measurement can't wrap the caret onto the border row. */} - + {/* The colour on the parent is what the bare text children below + inherit (ink would leave typed text on the terminal's default + foreground, which a light theme paints near-black on our panel), + and it gives the inverse caret a known pair to swap. */} + {focused && row === 'prompt' && openPicker === null ? SELECTION_GLYPH : ' '}{' '} @@ -1155,10 +1169,12 @@ function NewSessionPane({ {text === '' && caretVisible && ( S - tart a cloud session… + tart a cloud session… )} - {text === '' && !caretVisible && Start a cloud session…} + {text === '' && !caretVisible && ( + Start a cloud session… + )} diff --git a/src/ui/transcriptRows.ts b/src/ui/transcriptRows.ts index 37877c3..ccf9478 100644 --- a/src/ui/transcriptRows.ts +++ b/src/ui/transcriptRows.ts @@ -48,6 +48,29 @@ export type RowSpan = { pulse?: boolean } +// The colour a span actually paints in. Every span resolves to a brand hex — +// there is no "unstyled" span and no `dimColor` — because neither of the two +// things a bare span would fall back on belongs to us: +// +// * NO COLOUR MEANS THE TERMINAL'S COLOUR. Ink leaves an uncoloured `` +// on the terminal's default foreground, which under a LIGHT theme is the +// same near-black as the canvas we paint beneath it. Most spans carry no +// colour of their own (the assistant's prose included, via styleFor), so +// the bulk of a transcript came out dark on dark. See theme.ts, rule 1. +// * DIM IS OPTIONAL, as far as terminals are concerned: \x1b[2m is dropped +// outright by a fair number of them once a 24-bit foreground is also set. +// That is the same reason a pulsing mark SWAPS its colour on the off beat +// instead of dimming — bone → grey is a real colour change, so it reads +// everywhere. `dim` resolving to `muted` applies that everywhere else. +export function spanColor( + span: Pick, + pulseOn: boolean, +): string { + if (span.pulse && !pulseOn) return theme.muted + if (span.color) return span.color + return span.dim ? theme.muted : theme.foreground +} + export type TranscriptRow = { // Unique per row, for React keys. id: string diff --git a/test/connect-app.test.ts b/test/connect-app.test.ts index 916a8cb..f777a66 100644 --- a/test/connect-app.test.ts +++ b/test/connect-app.test.ts @@ -21,10 +21,12 @@ import { layOutItems, rowViewport, snapToEntry, + spanColor, withRenderedMarkdown, type TranscriptRow, } from '../src/ui/transcriptRows' import stripAnsi from 'strip-ansi' +import { theme } from '../src/lib/theme' import type { TranscriptItem } from '@ellipsis-dev/sdk/store' let seq = 0 @@ -947,3 +949,60 @@ describe('foldRun', () => { expect(foldRun('grp:missing', [prose('a')])).toEqual([]) }) }) + +describe('spanColor', () => { + // Nothing on a row may fall through to the terminal's own foreground: under a + // light theme that is the same near-black as the canvas painted beneath it. + it('gives an uncoloured span the brand foreground, never the terminal default', () => { + expect(spanColor({}, true)).toBe(theme.foreground) + }) + + it('resolves dim to muted instead of leaning on the terminal honouring dim', () => { + expect(spanColor({ dim: true }, true)).toBe(theme.muted) + }) + + it('keeps a span its own colour', () => { + expect(spanColor({ color: theme.error, dim: true }, true)).toBe(theme.error) + }) + + it('swaps a pulsing mark to muted on the off beat, whatever it is coloured', () => { + expect(spanColor({ color: theme.success, pulse: true }, false)).toBe(theme.muted) + expect(spanColor({ color: theme.success, pulse: true }, true)).toBe(theme.success) + }) +}) + +describe('itemRows colours', () => { + // styleFor leaves textColor off for most kinds, the assistant's prose (the + // bulk of a transcript) included, so this is the path that used to reach ink + // with no colour and render in the terminal's default foreground. + // What each kind's body text lands on: prose and the work it describes read + // primary, the infrastructure and a tool's output read quiet, a failure reads + // error. Quiet is a COLOUR, so it survives a terminal that ignores dim. + const bodyColor: Array<[TranscriptItem['kind'], string]> = [ + ['assistant', theme.foreground], + ['user', theme.foreground], + ['tool', theme.foreground], + ['tool_result', theme.muted], + ['thinking', theme.muted], + ['system', theme.muted], + ['notice', theme.muted], + ['summary', theme.muted], + ['error', theme.error], + ] + + it('resolves each kind to its brand colour, never to the terminal default', () => { + for (const [kind, expected] of bodyColor) { + const rows = itemRows({ key: `k:${kind}`, kind, text: 'some text' }, 60, { clamp: false }) + const body = rows.filter((r) => r.spans.some((s) => s.text.includes('some text'))) + expect(body.length, kind).toBeGreaterThan(0) + for (const row of body) { + for (const span of row.spans) expect(spanColor(span, true), kind).toBe(expected) + if (row.gutter) { + expect(new Set(Object.values(theme)), `${kind} gutter`).toContain( + spanColor(row.gutter, true), + ) + } + } + } + }) +}) diff --git a/test/markdown.test.ts b/test/markdown.test.ts index 674a602..2aa819f 100644 --- a/test/markdown.test.ts +++ b/test/markdown.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' +import chalk from 'chalk' import stripAnsi from 'strip-ansi' import { fitLines, hasMarkdown, renderMarkdown, visibleWidth } from '../src/lib/markdown' +import { theme } from '../src/lib/theme' const lines = (text: string): string[] => text.split('\n') const plain = (text: string): string => stripAnsi(text) @@ -132,3 +134,48 @@ describe('fitLines', () => { expect(visibleWidth(out[0])).toBeLessThanOrEqual(20) }) }) + +// Everything a table draws used to resolve through the terminal's OWN 16-colour +// palette: chalk.red on the header cells, and cli-table3's `border: ['gray']`, +// which only takes a colour NAME. Whatever the user's theme mapped those two +// slots to was what a table came out as. +describe('renderMarkdown colours', () => { + const table = '| Name | Status |\n| --- | --- |\n| alpha | ok |' + + // Rendered escapes only exist when chalk is actually emitting colour, which + // it isn't under a test runner's pipe. Force a level, and vary the width so + // the (width, source)-keyed cache can't hand back an uncoloured render. + const coloured = (source: string, width: number): string => { + const level = chalk.level + chalk.level = 3 + try { + return renderMarkdown(source, width) + } finally { + chalk.level = level + } + } + + it('draws the header in brand bone, not the palette red', () => { + const out = coloured(table, 41) + expect(out).not.toContain('') + expect(out).toContain(chalk.hex(theme.foreground).bold('Name')) + }) + + it('draws the rules in brand muted, not the palette grey', () => { + const out = coloured(table, 42) + expect(out).not.toContain('') + // Every box-drawing run carries the muted foreground. + for (const run of out.match(/[│┌┐└┘├┤┬┴┼─]+/g) ?? []) { + expect(out).toContain(chalk.hex(theme.muted)(run)) + } + }) + + it('leaves cell text uncoloured so the row it lands on supplies the brand fg', () => { + // The transcript wraps each row in an explicit colour (see spanColor), and + // chalk re-opens it after a nested reset, so a cell needs no colour of its + // own — but it must not carry a full  either, which would drop the + // row's background tint for the rest of the line. + const out = coloured(table, 43) + expect(out).not.toContain('') + }) +}) diff --git a/test/theme.test.ts b/test/theme.test.ts new file mode 100644 index 0000000..f0fa567 --- /dev/null +++ b/test/theme.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { Chalk } from 'chalk' +import { surfaceFor, theme } from '../src/lib/theme' + +// The 256-color index chalk resolves a hex to, which is what ink paints with +// on a terminal that does 256 colors but not truecolor. +const ansi256 = (hex: string): number => { + const match = new Chalk({ level: 2 }).bgHex(hex)('x').match(/48;5;(\d+)/) + return Number(match?.[1]) +} + +describe('surfaceFor', () => { + it('leaves the authored warmth alone on a truecolor terminal', () => { + expect(surfaceFor('#1c1b1a', 3)).toBe('#1c1b1a') + expect(surfaceFor('#262523', 3)).toBe('#262523') + }) + + it('neutralizes the channels below truecolor', () => { + expect(surfaceFor('#1c1b1a', 2)).toBe('#1b1b1b') + expect(surfaceFor('#262523', 2)).toBe('#252525') + expect(surfaceFor('#343330', 2)).toBe('#323232') + expect(surfaceFor('#1c1b1a', 1)).toBe('#1b1b1b') + }) + + it('passes a malformed hex through rather than painting garbage', () => { + expect(surfaceFor('nonsense', 2)).toBe('nonsense') + }) + + // The bug this exists for: chalk sends any hex whose channels differ to the + // 6x6x6 color cube, whose darkest step above black is rgb(95,95,95). All + // three warm brand surfaces landed on that ONE index, so the near-black + // canvas painted mid grey and every surface step (and with it every "you are + // here" highlight) disappeared on a 256-color terminal. + it('keeps the three surfaces three distinct steps on a 256-color terminal', () => { + const authored = [ansi256('#1c1b1a'), ansi256('#262523'), ansi256('#343330')] + expect(new Set(authored).size).toBe(1) + expect(authored[0]).toBe(59) + + const painted = [ + ansi256(surfaceFor('#1c1b1a', 2)), + ansi256(surfaceFor('#262523', 2)), + ansi256(surfaceFor('#343330', 2)), + ] + expect(new Set(painted).size).toBe(3) + // On the greyscale ramp (232-255), where a near-black stays near-black. + expect(painted.every((index) => index >= 232)).toBe(true) + expect(painted).toEqual([...painted].sort((a, b) => a - b)) + }) +}) + +describe('theme', () => { + it('carries a color for every token, so no call site has to fall back', () => { + for (const [name, value] of Object.entries(theme)) { + expect(value, name).toMatch(/^#[0-9a-f]{6}$/) + } + }) +})