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
Binary file modified src/lib/markdown.ts
Binary file not shown.
68 changes: 63 additions & 5 deletions src/lib/theme.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 `<Text>` 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',

Expand All @@ -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
Expand Down
37 changes: 21 additions & 16 deletions src/ui/ConnectApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
rowViewport,
snapToEntry,
spacerRow,
spanColor,
type RowSpan,
type ScrollAnchor,
type TranscriptRow,
Expand Down Expand Up @@ -1293,7 +1294,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
inside the budget so they never push a row out. */}
<Box flexDirection="column" flexGrow={1} flexShrink={1} overflow="hidden">
{view.showAbove && (
<Text dimColor wrap="truncate">
<Text color={theme.muted} wrap="truncate">
{` ↑ ${view.hiddenAbove} more line${view.hiddenAbove === 1 ? '' : 's'} above`}
</Text>
)}
Expand All @@ -1319,7 +1320,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
/>
))}
{view.showBelow && (
<Text dimColor wrap="truncate">
<Text color={theme.muted} wrap="truncate">
{` ↓ ${view.hiddenBelow} more line${view.hiddenBelow === 1 ? '' : 's'} below`}
</Text>
)}
Expand All @@ -1333,7 +1334,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
detail) can't grow the frame past the pane. */}
{notice && noticeRows > 0 && (
<Box height={noticeRows} flexShrink={0} overflow="hidden">
<Text dimColor>· {notice}</Text>
<Text color={theme.muted}>· {notice}</Text>
</Box>
)}
{/* The composer: the input area on the elevated surface — one step
Expand Down Expand Up @@ -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. */}
<Text key={`${composer.text}:${composer.cursor}:${focused && navKey === null}`}>
<Text color={theme.foreground} dimColor={!(focused && navKey === null)}>
{/* 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. */}
<Text
key={`${composer.text}:${composer.cursor}:${focused && navKey === null}`}
color={theme.foreground}
>
<Text color={focused && navKey === null ? theme.foreground : theme.muted}>
{SELECTION_GLYPH}{' '}
</Text>
{composer.text.slice(0, composer.cursor)}
Expand All @@ -1395,7 +1404,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
</Text>
</Box>
)}
{!props.hideMetaLine && <Text dimColor>{metaLine}</Text>}
{!props.hideMetaLine && <Text color={theme.muted}>{metaLine}</Text>}
</Box>
</Box>
)
Expand Down Expand Up @@ -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 }
Expand All @@ -2130,7 +2138,6 @@ const RowLine = React.memo(function RowLine({
a heartbeat rather than a character swapping in and out. */}
<Text
color={selected || !row.gutter ? theme.foreground : markColor(row.gutter)}
dimColor={!selected && !row.gutter?.pulse && (row.gutter?.dim ?? false)}
wrap="truncate"
>
{marker ? SELECTION_GLYPH : (row.gutter?.text ?? '')}
Expand All @@ -2142,7 +2149,6 @@ const RowLine = React.memo(function RowLine({
<Text
key={i}
color={selected ? theme.foreground : markColor(span)}
dimColor={!selected && !span.pulse && span.dim}
bold={span.bold}
>
{span.text}
Expand All @@ -2153,8 +2159,7 @@ const RowLine = React.memo(function RowLine({
{right && (
<Box flexShrink={0} paddingLeft={1}>
<Text
color={selected ? theme.foreground : right.color}
dimColor={!selected && right.dim}
color={selected ? theme.foreground : markColor(right)}
wrap="truncate"
>
{right.text}
Expand Down
64 changes: 40 additions & 24 deletions src/ui/SessionsApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -472,7 +472,7 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement {
<Box flexShrink={0}>
{/* 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. */}
<Text dimColor wrap="truncate">
<Text color={theme.muted} wrap="truncate">
{metaText ?? whoText}
</Text>
</Box>
Expand Down Expand Up @@ -515,10 +515,10 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement {
paddingRight={1}
paddingTop={1}
>
<Text dimColor>
<Text color={theme.muted}>
{loadError ? `✗ ${loadError}` : `loading ${mainPane.sessionId}…`}
</Text>
{loadError && <Text dimColor>esc: back to the sessions</Text>}
{loadError && <Text color={theme.muted}>esc: back to the sessions</Text>}
<EscOnlyInput active={focus === 'chat'} rawMode={isRawModeSupported} onEsc={focusNav} />
</Box>
)
Expand Down Expand Up @@ -620,27 +620,30 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement {
{cursorHere ? SELECTION_GLYPH : g.glyph}
</Text>{' '}
<Text
color={cursorHere ? theme.foreground : attention.has(s.id) ? theme.foreground : undefined}
color={
cursorHere || attention.has(s.id) || !g.dim
? theme.foreground
: theme.muted
}
bold={isOpen}
dimColor={!cursorHere && !attention.has(s.id) && g.dim}
>
{desc.slice(0, descW)}
</Text>
</Text>
</Box>
<Box flexShrink={0}>
<Text dimColor>{meta}</Text>
<Text color={theme.muted}>{meta}</Text>
</Box>
</Box>
)
})}
{rows.length === 0 && (
<Text dimColor>{polledOnce ? 'no sessions yet' : 'loading sessions…'}</Text>
<Text color={theme.muted}>{polledOnce ? 'no sessions yet' : 'loading sessions…'}</Text>
)}
{/* Absorbs the rows a short list leaves empty, keeping the hint on the
band's bottom edge. */}
<Box flexGrow={1} />
<Text wrap="truncate" dimColor>
<Text wrap="truncate" color={theme.muted}>
{navFocused
? `↑↓ move · enter open · n new · esc chat · q quit${
win.end < rows.length ? ` · ${rows.length - win.end} more below` : ''
Expand Down Expand Up @@ -1045,21 +1048,23 @@ function NewSessionPane({
<Box width={width} height={height} flexDirection="column">
<Box flexGrow={1} />
<Box justifyContent="center">
<Text bold>What are we shipping today?</Text>
<Text bold color={theme.foreground}>
What are we shipping today?
</Text>
</Box>
{/* 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. */}
<Box justifyContent="center" paddingTop={1}>
<Box width={Math.min(fact.length, Math.max(8, width - 4), 72)}>
<Text dimColor wrap="wrap">
<Text color={theme.muted} wrap="wrap">
{fact}
</Text>
</Box>
</Box>
<Box flexGrow={1} />
{error && <Text color={theme.error}> ✗ {error}</Text>}
{starting && <Text dimColor> ✻ Starting session…</Text>}
{starting && <Text color={theme.muted}> ✻ Starting session…</Text>}
{/* 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
Expand All @@ -1085,8 +1090,10 @@ function NewSessionPane({
if (isOpen) {
return (
<Box key={r.key} flexDirection="column" width={inputWidth}>
<Text dimColor>{' '}{r.label}:</Text>
{win.start > 0 && <Text dimColor>{' '}… {win.start} more</Text>}
<Text color={theme.muted}>{' '}{r.label}:</Text>
{win.start > 0 && (
<Text color={theme.muted}>{' '}… {win.start} more</Text>
)}
{openOptions.slice(win.start, win.end).map((opt, j) => {
const at = win.start + j
const hovered = at === openHover
Expand All @@ -1098,30 +1105,29 @@ function NewSessionPane({
<Text color={theme.foreground}>
{hovered ? SELECTION_GLYPH : ' '}
</Text>{' '}
<Text
color={hovered ? theme.foreground : undefined}
dimColor={!hovered && !picked}
>
<Text color={hovered || picked ? theme.foreground : theme.muted}>
{`[${picked ? 'x' : ' '}] ${opt.label}`}
</Text>
</Text>
</Box>
)
})}
{win.end < openOptions.length && (
<Text dimColor>{' '}… {openOptions.length - win.end} more</Text>
<Text color={theme.muted}>
{' '}… {openOptions.length - win.end} more
</Text>
)}
</Box>
)
}
return (
<Box key={r.key} width={inputWidth}>
<Text wrap="truncate">
<Text color={theme.foreground} dimColor={!active}>
<Text color={active ? theme.foreground : theme.muted}>
{active ? SELECTION_GLYPH : ' '}
</Text>{' '}
<Text dimColor>{r.label}: </Text>
<Text color={active ? theme.foreground : undefined} dimColor={!active}>
<Text color={theme.muted}>{r.label}: </Text>
<Text color={active ? theme.foreground : theme.muted}>
{rowValue(r.key)}
</Text>
</Text>
Expand All @@ -1140,7 +1146,15 @@ function NewSessionPane({
remounts the node so a stale measurement can't wrap the caret
onto the border row. */}
<Box width={inputWidth} minHeight={2} alignItems="flex-start">
<Text wrap="wrap" key={`${text}:${cursor}:${focused && row === 'prompt'}`}>
{/* 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. */}
<Text
wrap="wrap"
key={`${text}:${cursor}:${focused && row === 'prompt'}`}
color={theme.foreground}
>
<Text color={theme.foreground}>
{focused && row === 'prompt' && openPicker === null ? SELECTION_GLYPH : ' '}{' '}
</Text>
Expand All @@ -1155,10 +1169,12 @@ function NewSessionPane({
{text === '' && caretVisible && (
<Text>
<Text inverse>S</Text>
<Text dimColor>tart a cloud session…</Text>
<Text color={theme.muted}>tart a cloud session…</Text>
</Text>
)}
{text === '' && !caretVisible && <Text dimColor>Start a cloud session…</Text>}
{text === '' && !caretVisible && (
<Text color={theme.muted}>Start a cloud session…</Text>
)}
</Text>
</Box>
</Box>
Expand Down
Loading