diff --git a/tui/src/app.tsx b/tui/src/app.tsx index 9a77f23..d5ad221 100644 --- a/tui/src/app.tsx +++ b/tui/src/app.tsx @@ -20,6 +20,9 @@ import { useTerminalSize } from "./hooks/useTerminalSize"; import { useCancelKey } from "./hooks/useCancelKey"; import { planLayout } from "./layout"; import { PaletteProvider, usePalette } from "./styles/palette"; +// The canvas never dims — it is the background a dialog is dimmed *toward* — +// so it reads the lit palette directly rather than through usePalette(). +import { colors as theme } from "./styles/theme"; import { ClockProvider } from "./hooks/useClock"; import { Scrollbar } from "./components/Scrollbar"; import { getModelDisplayName } from "../../providers/client"; @@ -76,7 +79,7 @@ export function App({ controller, onExit, homeScreen }: AppProps) { flexDirection="column" width={width} height={height} - backgroundColor="#000000" + backgroundColor={theme.bgCanvas} > {/* One interval drives every animation below. See useClock for what the four independent timers this replaced were costing. */} @@ -86,11 +89,17 @@ export function App({ controller, onExit, homeScreen }: AppProps) { {/* Header — pinned at top */} -
+
{/* Main content */} - + {showHome ? ( diff --git a/tui/src/components/ApprovalFooter.tsx b/tui/src/components/ApprovalFooter.tsx index 8d02165..a928db2 100644 --- a/tui/src/components/ApprovalFooter.tsx +++ b/tui/src/components/ApprovalFooter.tsx @@ -1,29 +1,32 @@ import { Box, Text } from "ink"; +import { usePalette } from "../styles/palette"; export function ApprovalFooter() { + const colors = usePalette(); + return ( - + [A] - Apply + Apply - + [R] - Reject + Reject - + [Esc] - Cancel + Cancel ); } diff --git a/tui/src/components/ApprovalPicker.tsx b/tui/src/components/ApprovalPicker.tsx index f9891cc..125912e 100644 --- a/tui/src/components/ApprovalPicker.tsx +++ b/tui/src/components/ApprovalPicker.tsx @@ -67,7 +67,9 @@ export function ApprovalPicker({ mode }: ApprovalPickerProps) { width={layout.dialogWidth} paddingX={layout.dialogWidth < 40 ? 1 : 3} paddingY={1} - backgroundColor="#101010" + backgroundColor={colors.bgElevated} + borderStyle={layout.showDialogBorder ? "round" : undefined} + borderColor={colors.borderElevated} > @@ -83,14 +85,14 @@ export function ApprovalPicker({ mode }: ApprovalPickerProps) { return ( - - + + {active ? "● " : " "} {entry.label} @@ -98,7 +100,7 @@ export function ApprovalPicker({ mode }: ApprovalPickerProps) { {entry.unsafe && ( - unsafe + unsafe )} {/* The description only for the highlighted row: four of them at diff --git a/tui/src/components/CapabilityRow.tsx b/tui/src/components/CapabilityRow.tsx index bec1117..3f3fb63 100644 --- a/tui/src/components/CapabilityRow.tsx +++ b/tui/src/components/CapabilityRow.tsx @@ -5,14 +5,23 @@ export interface CapabilityRowProps { capabilities: readonly string[]; } +/** + * What the agent does, under the wordmark. + * + * Separated by the same middle dot the header, the composer and the turn footer + * use. These were `#Build #Plan #Review` — hashtags, which say "tag" to a + * reader who has met them anywhere else, and which nothing on any other screen + * matched. + */ export function CapabilityRow({ capabilities }: CapabilityRowProps) { const colors = usePalette(); return ( {capabilities.map((capability, index) => ( - - #{capability} + + {index > 0 && {" · "}} + {capability} ))} diff --git a/tui/src/components/CommandApproval.tsx b/tui/src/components/CommandApproval.tsx index e61bef8..a2ede86 100644 --- a/tui/src/components/CommandApproval.tsx +++ b/tui/src/components/CommandApproval.tsx @@ -41,7 +41,9 @@ export function CommandApproval({ command }: { command: PendingCommand }) { width={layout.dialogWidth} paddingX={layout.dialogWidth < 40 ? 1 : 3} paddingY={1} - backgroundColor="#101010" + backgroundColor={colors.bgElevated} + borderStyle={layout.showDialogBorder ? "round" : undefined} + borderColor={colors.borderElevated} > @@ -51,7 +53,7 @@ export function CommandApproval({ command }: { command: PendingCommand }) { {/* The command itself, as it will be run. */} - + {"$ "} diff --git a/tui/src/components/CommandPreview.tsx b/tui/src/components/CommandPreview.tsx index 39c8b12..f96480a 100644 --- a/tui/src/components/CommandPreview.tsx +++ b/tui/src/components/CommandPreview.tsx @@ -118,7 +118,7 @@ export function CommandPreview({ const { hiddenAbove, hiddenBelow, showIndicators: useIndicators } = visible; return ( - + {visible.showHeader && ( @@ -145,15 +145,15 @@ export function CommandPreview({ height={1} flexShrink={0} paddingX={1} - backgroundColor={isSelected ? "#fb923c" : undefined} + backgroundColor={isSelected ? colors.selectionBg : undefined} > - / - + / + {cmd.name} - + {cmd.description} {aliases} diff --git a/tui/src/components/ContinueTurn.tsx b/tui/src/components/ContinueTurn.tsx index 2fcdc83..1e7002c 100644 --- a/tui/src/components/ContinueTurn.tsx +++ b/tui/src/components/ContinueTurn.tsx @@ -41,7 +41,9 @@ export function ContinueTurn({ continuation }: { continuation: PendingContinuati width={layout.dialogWidth} paddingX={layout.dialogWidth < 40 ? 1 : 3} paddingY={1} - backgroundColor="#101010" + backgroundColor={colors.bgElevated} + borderStyle={layout.showDialogBorder ? "round" : undefined} + borderColor={colors.borderElevated} > diff --git a/tui/src/components/InlineCode.tsx b/tui/src/components/InlineCode.tsx index 7095a40..5167cbd 100644 --- a/tui/src/components/InlineCode.tsx +++ b/tui/src/components/InlineCode.tsx @@ -1,23 +1,18 @@ import { Text } from "ink"; -import { useDimmed } from "../styles/palette"; -import { dimHex } from "../styles/theme"; +import { usePalette } from "../styles/palette"; interface InlineCodeProps { text: string; } -const CODE_COLOR = "#7fd88f"; -const CODE_BACKGROUND = "#1e1e1e"; - export function InlineCode({ text }: InlineCodeProps) { - // Its own colours rather than the theme's, so they need fading explicitly. - const dimmed = useDimmed(); + // Read through the palette rather than from two module constants of its own, + // which is what removes the manual dimHex this used to need: behind a dialog + // the span now fades with everything around it. + const colors = usePalette(); return ( - + {` ${text} `} ); diff --git a/tui/src/components/LogoReveal.tsx b/tui/src/components/LogoReveal.tsx index c418582..f87b162 100644 --- a/tui/src/components/LogoReveal.tsx +++ b/tui/src/components/LogoReveal.tsx @@ -146,7 +146,7 @@ function renderScanReveal( {/* Scan line character (if not at end) */} {scanIndex < text.length && ( - + {text[scanIndex]} )} diff --git a/tui/src/components/ModelPicker.tsx b/tui/src/components/ModelPicker.tsx index 8179909..5d5f1b4 100644 --- a/tui/src/components/ModelPicker.tsx +++ b/tui/src/components/ModelPicker.tsx @@ -5,7 +5,7 @@ import { getConfig, saveConfig } from "../../../config/config"; import { allModels, isRunnable, providerLabel } from "../../../providers/modelCatalog"; import type { AgentController } from "../../../commands/agentController"; import { store } from "../store/ui-store"; -import { colors } from "../styles/theme"; +import { usePalette } from "../styles/palette"; import { planLayout, windowAround } from "../layout"; import { useTerminalSize } from "../hooks/useTerminalSize"; import { applyModelSelection } from "../model-selection"; @@ -16,6 +16,7 @@ interface ModelPickerProps { } export function ModelPicker({ controller, selectedModel }: ModelPickerProps) { + const colors = usePalette(); const [query, setQuery] = useState(""); const [showCursor, setShowCursor] = useState(true); const [saving, setSaving] = useState(false); @@ -113,7 +114,9 @@ export function ModelPicker({ controller, selectedModel }: ModelPickerProps) { width={layout.dialogWidth} paddingX={layout.dialogWidth < 40 ? 1 : 3} paddingY={1} - backgroundColor="#101010" + backgroundColor={colors.bgElevated} + borderStyle={layout.showDialogBorder ? "round" : undefined} + borderColor={colors.borderElevated} > Select model @@ -147,16 +150,16 @@ export function ModelPicker({ controller, selectedModel }: ModelPickerProps) { const index = visible.start + offset; const selected = index === selectedIndex; return ( - - {selected ? "● " : " "} + + {selected ? "● " : " "} - + {model.name} {layout.dialogWidth >= 40 && ( - + {providerLabel(model.provider)} )} diff --git a/tui/src/components/QuestionDialog.tsx b/tui/src/components/QuestionDialog.tsx index eb23d14..2ef36bb 100644 --- a/tui/src/components/QuestionDialog.tsx +++ b/tui/src/components/QuestionDialog.tsx @@ -3,9 +3,14 @@ import TextInput from "ink-text-input"; import { useState } from "react"; import type { PendingQuestion } from "../types"; import { store } from "../store/ui-store"; -import { colors } from "../styles/theme"; +import { usePalette } from "../styles/palette"; +import { planLayout } from "../layout"; +import { useTerminalSize } from "../hooks/useTerminalSize"; export function QuestionDialog({ question }: { question: PendingQuestion }) { + const colors = usePalette(); + const { width, height } = useTerminalSize(); + const layout = planLayout(width, height); const [index, setIndex] = useState(0); const [value, setValue] = useState(""); const [answers, setAnswers] = useState([]); @@ -32,9 +37,9 @@ export function QuestionDialog({ question }: { question: PendingQuestion }) { Question {index + 1} of {question.questions.length} diff --git a/tui/src/components/SessionPicker.tsx b/tui/src/components/SessionPicker.tsx index 6c2a5d9..55f1439 100644 --- a/tui/src/components/SessionPicker.tsx +++ b/tui/src/components/SessionPicker.tsx @@ -4,7 +4,7 @@ import { useEffect, useMemo, useState } from "react"; import { listSessions, type SessionSummary } from "../../../config/sessions"; import type { AgentController } from "../../../commands/agentController"; import { store } from "../store/ui-store"; -import { colors } from "../styles/theme"; +import { usePalette } from "../styles/palette"; import { planLayout, windowAround } from "../layout"; import { useTerminalSize } from "../hooks/useTerminalSize"; import { relativeTime } from "../relative-time"; @@ -14,6 +14,7 @@ interface SessionPickerProps { } export function SessionPicker({ controller }: SessionPickerProps) { + const colors = usePalette(); const [query, setQuery] = useState(""); const [showCursor, setShowCursor] = useState(true); const [switching, setSwitching] = useState(false); @@ -142,7 +143,9 @@ export function SessionPicker({ controller }: SessionPickerProps) { width={layout.dialogWidth} paddingX={layout.dialogWidth < 40 ? 1 : 3} paddingY={1} - backgroundColor="#101010" + backgroundColor={colors.bgElevated} + borderStyle={layout.showDialogBorder ? "round" : undefined} + borderColor={colors.borderElevated} > Resume session @@ -187,15 +190,15 @@ export function SessionPicker({ controller }: SessionPickerProps) { - + {session.id === activeId ? "● " : selected ? "› " : " "} {label} @@ -203,7 +206,7 @@ export function SessionPicker({ controller }: SessionPickerProps) { {layout.dialogWidth >= 40 && ( - + {relativeTime(session.updated)} )} diff --git a/tui/src/components/StatusSpinner.tsx b/tui/src/components/StatusSpinner.tsx index 30e61f2..bbb5625 100644 --- a/tui/src/components/StatusSpinner.tsx +++ b/tui/src/components/StatusSpinner.tsx @@ -1,6 +1,6 @@ import { Box, Text } from "ink"; import { useDimmed } from "../styles/palette"; -import { dimHex } from "../styles/theme"; +import { colors, dimHex, parseHex, primaryRamp } from "../styles/theme"; import { useClock } from "../hooks/useClock"; const TRACK_LENGTH = 8; @@ -12,17 +12,16 @@ const HOLD_AT_END = 5; const TOTAL_FRAMES = TRACK_LENGTH + HOLD_AT_END + (TRACK_LENGTH - 1) + HOLD_AT_START; -// A periwinkle ramp stepped around the theme's primary (#ACA3EC): the head sits -// on the accent itself, the bloom one step lighter, and the trail darkens -// through it. +// The theme's accent ramp, read by role: the head sits on the accent itself, +// the bloom one step lighter, and the trail darkens through the rest. +const [BLOOM, HEAD, ...TRAIL] = primaryRamp; const spinnerColors = { - head: "#ACA3EC", - bloom: "#C6C0F4", - trailNear: "#8F83E0", - trailMid: "#7263CE", - trailFar: "#5A4CAB", - trailLast: "#453B82", + head: HEAD, + bloom: BLOOM, + trail: TRAIL, } as const; +/** Where the trail ends, and so where the inactive track fades up from. */ +const TRAIL_END = TRAIL[TRAIL.length - 1] ?? HEAD; function getScannerFrame(frameIndex: number) { const frame = frameIndex % TOTAL_FRAMES; @@ -77,11 +76,12 @@ function inactiveColor( ? 1 - holdProgress / holdTotal : movementProgress / (TRACK_LENGTH - 1); const amount = Math.max(0, Math.min(1, progress)); - // Blend from the terminal background into the inactive periwinkle. True-color - // interpolation avoids the hard brightness jumps that make a TUI spinner - // look jittery at its turnaround points. - const start: [number, number, number] = [10, 10, 10]; - const end: [number, number, number] = [44, 39, 82]; + // Blend from the terminal background into the darkest step of the accent ramp. + // True-color interpolation avoids the hard brightness jumps that make a TUI + // spinner look jittery at its turnaround points. Both ends are read from the + // theme so the track cannot drift away from the trail that runs along it. + const start = parseHex(colors.bgCanvas) ?? [0, 0, 0]; + const end = parseHex(TRAIL_END) ?? [0, 0, 0]; const channel = (index: 0 | 1 | 2) => Math.round(start[index] + (end[index] - start[index]) * amount) .toString(16) @@ -126,12 +126,7 @@ export function StatusSpinner() { ); } - const trailColors = [ - spinnerColors.trailNear, - spinnerColors.trailMid, - spinnerColors.trailFar, - spinnerColors.trailLast, - ].map(shade); + const trailColors = spinnerColors.trail.map(shade); if (colorIndex >= 2 && colorIndex < trailColors.length + 2) { return ( diff --git a/tui/src/components/TurnFooter.tsx b/tui/src/components/TurnFooter.tsx index 3b4e63c..a58af4d 100644 --- a/tui/src/components/TurnFooter.tsx +++ b/tui/src/components/TurnFooter.tsx @@ -1,7 +1,7 @@ import { Box, Text } from "ink"; import { usePalette } from "../styles/palette"; import { useClock } from "../hooks/useClock"; -import type { Palette } from "../styles/theme"; +import { primaryRamp, type Palette } from "../styles/theme"; import { getModelDisplayName } from "../../../providers/client"; import type { TurnIdentity, TurnOutcome } from "../types"; import { planLayout } from "../layout"; @@ -17,8 +17,18 @@ import { useTerminalSize } from "../hooks/useTerminalSize"; */ const PULSE_EVERY_FRAMES = 2; -/** Breathes the marker while the turn is in flight. */ -const pulseColors = ["#453B82", "#7263CE", "#8F83E0", "#ACA3EC", "#8F83E0", "#7263CE"] as const; +/** + * Breathes the marker while the turn is in flight: up the theme's accent ramp + * and back down, so the pulse cannot drift away from the spinner beside it. + */ +const pulseColors = [ + primaryRamp[5], + primaryRamp[3], + primaryRamp[2], + primaryRamp[1], + primaryRamp[2], + primaryRamp[3], +] as const; /** * Takes the palette as an argument so it fades with the layer it renders in. diff --git a/tui/src/header.tsx b/tui/src/header.tsx index 2b686c7..27617eb 100644 --- a/tui/src/header.tsx +++ b/tui/src/header.tsx @@ -6,9 +6,18 @@ import { useTerminalSize } from "./hooks/useTerminalSize"; interface HeaderProps { branch: string; provider: string; + /** + * True while the home screen is showing the ASCII wordmark. + * + * The header then drops its own "Woopcode / coding agent", which was printing + * the app's name in small type directly above the same name in six-row figlet. + * The branch and provider stay — those are the only things the header says + * that the wordmark does not. + */ + wordmarkShowing?: boolean; } -export function Header({ branch, provider }: HeaderProps) { +export function Header({ branch, provider, wordmarkShowing = false }: HeaderProps) { const colors = usePalette(); const { width, height } = useTerminalSize(); @@ -20,13 +29,17 @@ export function Header({ branch, provider }: HeaderProps) { return ( - - Woopcode - - {layout.showHeaderTagline && ( - - {" / coding agent"} - + {!wordmarkShowing && ( + <> + + Woopcode + + {layout.showHeaderTagline && ( + + {" / coding agent"} + + )} + )} diff --git a/tui/src/layout.test.ts b/tui/src/layout.test.ts index 8967b98..ecd8e09 100644 --- a/tui/src/layout.test.ts +++ b/tui/src/layout.test.ts @@ -112,6 +112,7 @@ describe("width fitting", () => { for (let height = 6; height <= 40; height++) { const layout = planLayout(80, height); const fixed = + (layout.showDialogBorder ? 2 : 0) + // border, top and bottom 2 + // padding 1 + layout.dialogRhythm + // title 1 + layout.dialogRhythm + // search @@ -124,6 +125,19 @@ describe("width fitting", () => { expect(layout.dialogListRows).toBeGreaterThanOrEqual(1); } }); + + test("drops the dialog border before it drops the list", () => { + // The border is decoration and costs two rows. In a window short enough that + // paying for it would leave nothing to list, it goes — the same order the + // hints and the label already degrade in. Adding it unconditionally made a + // 6-row terminal unable to fit a dialog at all. + expect(planLayout(80, 30).showDialogBorder).toBe(true); + expect(planLayout(80, 6).showDialogBorder).toBe(false); + + // And it is genuinely paid for where it is drawn: two rows the list gives up. + const withBorder = planLayout(80, 30); + expect(withBorder.dialogListRows).toBe(30 - 15); + }); }); describe("list windowing", () => { diff --git a/tui/src/layout.ts b/tui/src/layout.ts index 00eeed4..e63c228 100644 --- a/tui/src/layout.ts +++ b/tui/src/layout.ts @@ -17,6 +17,18 @@ const WORDMARK_FULL_MIN_COLUMNS = 77; /** Below this even "WOOPCODE" in plain text crowds out everything else. */ const WORDMARK_COMPACT_MIN_COLUMNS = 24; +/** + * Columns every transcript row spends before its content starts: one for the + * rail or the state glyph, one blank. + * + * A constant because the transcript had four conventions at once — the user row + * at column 1, the assistant's label at column 1 with its own body at column 3, + * tool rows at column 3, a command block's rail back at column 1 — so nothing + * shared a left edge and the speaker floated free of its own text. One number + * gives the transcript a single vertical spine. + */ +export const TRANSCRIPT_GUTTER = 2; + /** Rows the bordered composer occupies: border, padding, input, meta line. */ export const BLOCK_COMPOSER_ROWS = 6; /** Rows the single-line `❯ ` composer occupies. */ @@ -55,6 +67,13 @@ const COMMAND_POPUP_HEADER_MIN_ROWS = 14; const DIALOG_SCROLL_INDICATOR_ROWS = 2; const DIALOG_LABEL_MIN_ROWS = 12; const DIALOG_HINTS_MIN_ROWS = 18; +/** + * The dialog border costs two rows, and it is decoration: it exists to lift a + * panel off a canvas it differs from by 6% luminance. In a window too short to + * afford it the border goes, the way the hints and the label already do — + * a bordered dialog with no room for its own list is the worse of the two. + */ +const DIALOG_BORDER_MIN_ROWS = 10; export type Wordmark = "full" | "compact" | "hidden"; export type ComposerVariant = "block" | "inline"; @@ -93,6 +112,8 @@ export interface LayoutPlan { dialogRhythm: number; showDialogLabel: boolean; showDialogHints: boolean; + /** The border lifting a dialog off the canvas; dropped in a short window. */ + showDialogBorder: boolean; /** * False when the window is so short that the "↑ n more" rows would cost more * than the list rows they describe. @@ -138,11 +159,15 @@ export function planLayout(width: number, height: number): LayoutPlan { // A dialog has to fit its own chrome before it can offer list rows, and the // scroll indicators are part of that chrome. Budgeting for them is what stops - // a long list from pushing its own title off the top of the screen. + // a long list from pushing its own title off the top of the screen. The border + // is chrome too: it was added to lift the panel off a canvas it differed from + // by 6% luminance, and two unbudgeted rows would cost the title it protects. const dialogRhythm = height >= DIALOG_LABEL_MIN_ROWS ? 1 : 0; const showDialogLabel = height >= DIALOG_LABEL_MIN_ROWS; const showDialogHints = height >= DIALOG_HINTS_MIN_ROWS; + const showDialogBorder = height >= DIALOG_BORDER_MIN_ROWS; const dialogFixedRows = + (showDialogBorder ? 2 : 0) + // the border, top and bottom 2 + // vertical padding 1 + dialogRhythm + // title and its gap 1 + dialogRhythm + // search field and its gap @@ -194,6 +219,7 @@ export function planLayout(width: number, height: number): LayoutPlan { dialogRhythm, showDialogLabel, showDialogHints, + showDialogBorder, showDialogScrollIndicators, }; } diff --git a/tui/src/prompt.shape.test.tsx b/tui/src/prompt.shape.test.tsx index 8854448..73e33b0 100644 --- a/tui/src/prompt.shape.test.tsx +++ b/tui/src/prompt.shape.test.tsx @@ -95,6 +95,7 @@ function renderComposer() { placeholder="Find duplicate code" onValueChange={() => {}} modelName="Gemini 3.5 Flash Lite" + providerName="Anthropic" variant="block" showProvider inputActive @@ -145,6 +146,23 @@ describe("the composer card", () => { composer.unmount(); }); + test("names the provider it was given, not a hardcoded one", async () => { + // The meta row read ` Google`: the prop was accepted and + // ignored, so the composer claimed Google on Anthropic and OpenAI alike — + // and with no separator, which ran it into the model name as + // "Gemini 3.5 Flash Lite Google". + const composer = renderComposer(); + await settle(); + + const meta = composer.stdout.lines().at(-1)!; + + expect(meta).toContain("Anthropic"); + expect(meta).not.toContain("Google"); + expect(meta).toContain("Gemini 3.5 Flash Lite · Anthropic"); + + composer.unmount(); + }); + test("draws the bar in the mode's colour", async () => { const composer = renderComposer(); await settle(); diff --git a/tui/src/prompt.tsx b/tui/src/prompt.tsx index 47f0ab5..cf93771 100644 --- a/tui/src/prompt.tsx +++ b/tui/src/prompt.tsx @@ -275,9 +275,9 @@ export function Prompt({ showHeader={layout.showCommandPopupHeader} /> )} - - {showProvider && ( + {/* The provider it is actually talking to. This was the literal + " Google" — the prop was passed in and ignored, so the composer + claimed Google on every provider — and with no separator, which + ran it into the model name as "Gemini 3 Pro Google". */} + {showProvider && providerName && ( - Google + {` · ${providerName}`} )} diff --git a/tui/src/statusBar.tsx b/tui/src/statusBar.tsx index 9e3f6ed..89f071c 100644 --- a/tui/src/statusBar.tsx +++ b/tui/src/statusBar.tsx @@ -70,7 +70,7 @@ function StatusIcon({ status }: { status: StatusState }) { if (status === "thinking" || status === "tool") { return ; } - if (status === "ready") return ; + if (status === "ready") return ; if (status === "error") return ; // cancelled return ; diff --git a/tui/src/styles/contrast.test.ts b/tui/src/styles/contrast.test.ts new file mode 100644 index 0000000..1cecbb0 --- /dev/null +++ b/tui/src/styles/contrast.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from "bun:test"; +import { colors } from "./theme"; + +/** + * Contrast, as a number rather than an opinion. + * + * "Is this readable?" sounds like taste and mostly is not. The pairs below are + * the ones a token change can quietly break — foreground on a selected row, + * text on a floating panel, and the border that says where that panel stops — + * and each has a threshold that can be checked without a terminal. + * + * The border is the reason this file exists. `bgElevated` sits 1.14:1 above + * `bgCanvas`, which is nowhere near enough to show an edge, so the border + * carries the whole separation. Drawn in `borderBase` it measured 1.78:1 + * against the panel — a boundary invisible on a dim display, which is exactly + * the problem the border was added to fix. Nothing rendered would have caught + * it: the frame is identical either way, and only the arithmetic disagrees. + */ + +/** WCAG relative luminance. */ +function luminance(hex: string): number { + const value = hex.replace("#", ""); + const channels = [0, 2, 4].map((offset) => { + const srgb = Number.parseInt(value.slice(offset, offset + 2), 16) / 255; + return srgb <= 0.03928 ? srgb / 12.92 : Math.pow((srgb + 0.055) / 1.055, 2.4); + }); + + return 0.2126 * channels[0]! + 0.7152 * channels[1]! + 0.0722 * channels[2]!; +} + +export function contrastRatio(a: string, b: string): number { + const [lighter, darker] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return (lighter! + 0.05) / (darker! + 0.05); +} + +/** WCAG 1.4.3, small text. */ +const AA_TEXT = 4.5; +/** WCAG 1.4.11, non-text: UI boundaries and state indicators. */ +const AA_NON_TEXT = 3; + +describe("contrast ratio", () => { + test("agrees with known values", () => { + // Anchors the maths itself, so a wrong formula cannot make the thresholds + // below pass by being generous. + expect(contrastRatio("#ffffff", "#000000")).toBeCloseTo(21, 1); + expect(contrastRatio("#000000", "#000000")).toBeCloseTo(1, 5); + // Order must not matter. + expect(contrastRatio("#aca3ec", "#0a0a0a")).toBeCloseTo( + contrastRatio("#0a0a0a", "#aca3ec"), + 5, + ); + }); +}); + +describe("selected rows stay readable", () => { + test("primary and secondary text clear AA on the selection fill", () => { + expect(contrastRatio(colors.selectionFg, colors.selectionBg)).toBeGreaterThanOrEqual(AA_TEXT); + expect(contrastRatio(colors.selectionFgMuted, colors.selectionBg)).toBeGreaterThanOrEqual(AA_TEXT); + }); + + test("the warning on a selected row clears the non-text bar", () => { + // "unsafe" beside an approval mode. It is short, coloured, and paired with + // a label that already carries the meaning, so it is held to 1.4.11 rather + // than to body-text contrast. + expect(contrastRatio(colors.selectionFgWarn, colors.selectionBg)).toBeGreaterThanOrEqual(AA_NON_TEXT); + }); +}); + +describe("a floating panel is actually delimited", () => { + test("the fill alone cannot do it, which is why the border exists", () => { + // Not a requirement — a record of the fact the border is load-bearing. If + // this ever clears 3:1 the border could become optional; until then it + // cannot. + expect(contrastRatio(colors.bgElevated, colors.bgCanvas)).toBeLessThan(AA_NON_TEXT); + }); + + test("the border clears 3:1 against both surfaces it separates", () => { + // Both sides, not just one. A border only readable against the darker side + // still leaves the panel edge ambiguous where it meets the panel. + expect(contrastRatio(colors.borderElevated, colors.bgCanvas)).toBeGreaterThanOrEqual(AA_NON_TEXT); + expect(contrastRatio(colors.borderElevated, colors.bgElevated)).toBeGreaterThanOrEqual(AA_NON_TEXT); + }); + + test("panel text clears AA on the elevated surface", () => { + expect(contrastRatio(colors.textBase, colors.bgElevated)).toBeGreaterThanOrEqual(AA_TEXT); + expect(contrastRatio(colors.textMuted, colors.bgElevated)).toBeGreaterThanOrEqual(AA_TEXT); + }); +}); + +describe("the transcript stays readable on the canvas", () => { + test("body and accent text clear AA", () => { + for (const token of ["textBase", "textMuted", "primary", "accent", "successBase", "warningBase", "dangerBase"] as const) { + expect( + contrastRatio(colors[token], colors.bgCanvas), + `${token} on bgCanvas`, + ).toBeGreaterThanOrEqual(AA_TEXT); + } + }); + + test("inline code clears AA on its own background", () => { + expect(contrastRatio(colors.accent, colors.bgCode)).toBeGreaterThanOrEqual(AA_TEXT); + }); +}); diff --git a/tui/src/styles/syntax.ts b/tui/src/styles/syntax.ts index 7519155..5a46d87 100644 --- a/tui/src/styles/syntax.ts +++ b/tui/src/styles/syntax.ts @@ -1,45 +1,53 @@ import chalk from "chalk"; import type { Theme } from "cli-highlight"; +import { colors } from "./theme"; /** * Syntax colours for code shown in the TUI — diff rows and fenced code blocks. * * cli-highlight defaults to the 16-colour ANSI palette, which renders as flat - * primary red/green/blue and reads nothing like the rest of the interface. These - * are the same hues the markdown renderer already uses, so highlighted code and - * prose belong to one palette. + * primary red/green/blue and reads nothing like the rest of the interface. + * + * Every entry is a theme token, so highlighted code and the chrome around it are + * one system. This file used to say that and not do it: it carried thirty of its + * own hex literals on a third palette, One Dark's, which agreed with neither the + * interface nor the markdown renderer it claimed to match. + * + * Six roles, drawn from the tokens: periwinkle for keywords, indigo for the + * names of things, teal for built-ins and attributes, emerald for strings, amber + * for literals, neutrals for everything else. */ export const syntaxTheme: Theme = { - keyword: chalk.hex("#9d7cd8"), - built_in: chalk.hex("#56b6c2"), - type: chalk.hex("#e5c07b"), - literal: chalk.hex("#d19a66"), - number: chalk.hex("#d19a66"), - regexp: chalk.hex("#7fd88f"), - string: chalk.hex("#7fd88f"), - subst: chalk.hex("#e5e5e5"), - symbol: chalk.hex("#56b6c2"), - class: chalk.hex("#e5c07b"), - function: chalk.hex("#61afef"), - title: chalk.hex("#61afef"), - params: chalk.hex("#e5e5e5"), - comment: chalk.hex("#6b7280").italic, - doctag: chalk.hex("#9d7cd8"), - meta: chalk.hex("#a3a3a3"), - "meta-keyword": chalk.hex("#9d7cd8"), - "meta-string": chalk.hex("#7fd88f"), - section: chalk.hex("#61afef").bold, - tag: chalk.hex("#9d7cd8"), - name: chalk.hex("#61afef"), - attr: chalk.hex("#56b6c2"), - attribute: chalk.hex("#56b6c2"), - variable: chalk.hex("#e5e5e5"), - bullet: chalk.hex("#fab283"), - quote: chalk.hex("#e5c07b"), - link: chalk.hex("#56b6c2").underline, + keyword: chalk.hex(colors.primary), + built_in: chalk.hex(colors.accent), + type: chalk.hex(colors.secondary), + literal: chalk.hex(colors.warningBase), + number: chalk.hex(colors.warningBase), + regexp: chalk.hex(colors.successBase), + string: chalk.hex(colors.successBase), + subst: chalk.hex(colors.textBase), + symbol: chalk.hex(colors.accent), + class: chalk.hex(colors.secondary), + function: chalk.hex(colors.secondary), + title: chalk.hex(colors.secondary), + params: chalk.hex(colors.textBase), + comment: chalk.hex(colors.textFaint).italic, + doctag: chalk.hex(colors.primary), + meta: chalk.hex(colors.textMuted), + "meta-keyword": chalk.hex(colors.primary), + "meta-string": chalk.hex(colors.successBase), + section: chalk.hex(colors.secondary).bold, + tag: chalk.hex(colors.primary), + name: chalk.hex(colors.secondary), + attr: chalk.hex(colors.accent), + attribute: chalk.hex(colors.accent), + variable: chalk.hex(colors.textBase), + bullet: chalk.hex(colors.primary), + quote: chalk.hex(colors.borderStrong), + link: chalk.hex(colors.accent).underline, emphasis: chalk.italic, strong: chalk.bold, - addition: chalk.hex("#7dd3fc"), - deletion: chalk.hex("#f0a6bb"), - default: chalk.hex("#e5e5e5"), + addition: chalk.hex(colors.diffAdd), + deletion: chalk.hex(colors.diffRemove), + default: chalk.hex(colors.textBase), }; diff --git a/tui/src/styles/theme.test.ts b/tui/src/styles/theme.test.ts index 5381795..34d9e01 100644 --- a/tui/src/styles/theme.test.ts +++ b/tui/src/styles/theme.test.ts @@ -10,19 +10,25 @@ import { describe("colour dimming", () => { test("moves a colour toward the terminal background", () => { - // #e5e5e5 at 0.6 toward #0a0a0a lands on #626262. - expect(dimHex("#e5e5e5")).toBe("#626262"); - expect(dimHex("#a3a3a3")).toBe("#474747"); + // #e5e5e5 at 0.6 toward #000000 lands on #5c5c5c. + expect(dimHex("#e5e5e5")).toBe("#5c5c5c"); + expect(dimHex("#a3a3a3")).toBe("#414141"); }); test("respects the endpoints", () => { expect(dimHex("#3b82f6", 0)).toBe("#3b82f6"); - expect(dimHex("#3b82f6", 1)).toBe(colors.bgBase); + expect(dimHex("#3b82f6", 1)).toBe(colors.bgCanvas); }); test("clamps an out-of-range amount instead of overshooting", () => { expect(dimHex("#3b82f6", -1)).toBe("#3b82f6"); - expect(dimHex("#3b82f6", 5)).toBe(colors.bgBase); + expect(dimHex("#3b82f6", 5)).toBe(colors.bgCanvas); + }); + + test("fades toward the colour the app actually paints", () => { + // Not bgBase. Fading toward a surface that is never drawn leaves every + // dimmed layer a shade brighter than the background behind it. + expect(dimHex("#ffffff", 1)).toBe("#000000"); }); test("expands shorthand hex", () => { diff --git a/tui/src/styles/theme.ts b/tui/src/styles/theme.ts index 09f1b0d..0b51d0c 100644 --- a/tui/src/styles/theme.ts +++ b/tui/src/styles/theme.ts @@ -1,7 +1,16 @@ /** - * Theme configuration inspired by OpenCode's dark design system. - * Uses a near-black base with a soft-periwinkle primary, indigo secondary, and - * muted grays. + * The one place a colour literal appears. + * + * The interface is a single periwinkle accent over neutrals, with amber, emerald + * and red reserved for meaning — plan mode, success, failure — and teal for + * code. Everything else in `tui/` reads a token from here, so a hue cannot enter + * the interface without being named first. `styles/tokens.test.ts` enforces that: + * it fails on a raw hex or a named ANSI colour anywhere outside this directory. + * + * The rule is worth the test. The palette had fractured into five sources — this + * file, a separate markdown palette on One Dark hues, a syntax theme on a third + * set, an orange selection row hardcoded into all four pickers, and bare + * `color="green"` ANSI — so one screen could show ten hues from four systems. */ export const colors = { @@ -10,25 +19,62 @@ export const colors = { textMuted: "#a3a3a3", // Neutral 400 textFaint: "#737373", // Neutral 500 textStrong: "#ffffff", - textAccent: "#ACA3EC", + textAccent: "#aca3ec", textCode: "#2dd4bf", // Teal 400 - // Background colors + // ─── Surfaces ────────────────────────────────────────────────────────────── + // + // Named for what they are in the stack rather than for a neutral step, and + // these are the values actually painted. The app used to draw #000000, + // #101010, #1a1a1a and #1e1e1e as literals while the tokens described a + // different set entirely — which meant `dimHex` faded toward a colour nothing + // ever drew. + /** What the app paints edge to edge. Every layer below sits on this. */ + bgCanvas: "#000000", bgBase: "#0a0a0a", // Neutral 950 bgLayer01: "#171717", // Neutral 900 bgLayer02: "#262626", // Neutral 800 + /** A dialog floating over the app. Paired with a border — see bgElevated's use. */ + bgElevated: "#141414", + /** Recessed strips: the composer card, the command popup, a quoted command. */ + bgInset: "#1a1a1a", + /** Behind an inline code span. */ + bgCode: "#1e1e1e", // Border colors borderBase: "#404040", // Neutral 700 borderMuted: "#262626", // Neutral 800 borderStrong: "#525252", // Neutral 600 - borderActive: "#ACA3EC", // Periwinkle + borderActive: "#aca3ec", // Periwinkle + /** + * The edge of a floating panel, and the one border that carries real meaning. + * + * Brighter than `borderBase` on purpose. `bgElevated` sits only 1.14:1 above + * `bgCanvas`, so the fill cannot say where the dialog stops — the border is + * the separation, and a border below 3:1 against the surfaces on either side + * of it is one nobody sees on a dim display. Measured, this is 3.11:1 against + * the panel and 3.55:1 against the canvas, clearing WCAG 1.4.11 on both sides; + * `borderBase` managed 1.78 and 2.03. `styles/contrast.test.ts` holds the bar. + */ + borderElevated: "#646464", + + // ─── Selection ───────────────────────────────────────────────────────────── + // + // The highlighted row in every picker. It is the accent, not a hue of its own: + // this was #fb923c orange in four separate components, a colour that appeared + // in no token and belonged to no part of the identity. + selectionBg: "#aca3ec", + selectionFg: "#0a0a0a", + /** Secondary text on a selected row — a description, a timestamp. */ + selectionFgMuted: "#3d3866", + /** A warning on a selected row, dark enough to read on periwinkle. */ + selectionFgWarn: "#6d3a06", // Primary and accent - primary: "#ACA3EC", // Periwinkle + primary: "#aca3ec", // Periwinkle // The WOOP half of the wordmark. Its own token rather than `primary` so the // logo can be tuned without recolouring every accent in the interface. - logo: "#ACA3EC", + logo: "#aca3ec", secondary: "#818cf8", // Indigo 400 accent: "#2dd4bf", // Teal 400 @@ -58,6 +104,23 @@ export const colors = { diffModified: "#fbbf24", }; +/** + * The accent as a six-step ramp, lightest to darkest, stepped around `primary`. + * + * One ramp because there was already one written twice: the status spinner's + * head/bloom/trail and the turn footer's pulse were the same six periwinkles in + * different orders, in two files, free to drift apart. Anything that animates in + * the accent reads it from here. + */ +export const primaryRamp = [ + "#c6c0f4", // bloom — one step lighter than the accent + "#aca3ec", // primary + "#8f83e0", + "#7263ce", + "#5a4cab", + "#453b82", +] as const; + export const spacing = { xs: 0.5, sm: 1, @@ -88,7 +151,8 @@ export type Palette = typeof colors; /** How far background colours travel toward the terminal background. */ export const DIM_AMOUNT = 0.6; -function parseHex(hex: string): [number, number, number] | null { +/** A hex colour as RGB channels, for anything that interpolates between two. */ +export function parseHex(hex: string): [number, number, number] | null { const value = hex.trim().replace(/^#/, ""); const full = value.length === 3 @@ -116,7 +180,10 @@ export function dimHex(hex: string, amount = DIM_AMOUNT): string { const parsed = parseHex(hex); if (!parsed) return hex; - const background = parseHex(colors.bgBase) ?? [0, 0, 0]; + // bgCanvas, not bgBase: the target has to be the colour actually painted + // behind the thing being faded, or "dimmed" lands short of the background and + // the layer keeps a faint glow the rest of the frame does not have. + const background = parseHex(colors.bgCanvas) ?? [0, 0, 0]; const ratio = Math.min(Math.max(amount, 0), 1); const channel = (index: 0 | 1 | 2) => Math.round(parsed[index] + (background[index] - parsed[index]) * ratio) @@ -132,22 +199,24 @@ export const dimmedColors: Palette = Object.fromEntries( ) as Palette; /** - * Markdown body colours. Separate from `colors` because they are a syntax - * palette rather than a UI one, but they dim the same way — assistant prose is - * most of what sits behind a dialog. + * Markdown body colours. Its own object because prose has roles the chrome does + * not — a heading is not a "primary", it is a heading — and because it dims the + * same way, assistant prose being most of what sits behind a dialog. But every + * value is a token: these were One Dark purple, orange and peach, so the model's + * reply was painted in a palette the interface around it did not share. */ export const markdownColors = { - text: "#eeeeee", - heading: "#9d7cd8", - strong: "#f5a742", - emph: "#e5c07b", - code: "#7fd88f", - link: "#fab283", - linkText: "#56b6c2", - blockQuote: "#e5c07b", - listItem: "#fab283", - listEnum: "#56b6c2", - hr: "#808080", + text: colors.textBase, + heading: colors.primary, + strong: colors.textStrong, + emph: colors.secondary, + code: colors.accent, + link: colors.secondary, + linkText: colors.accent, + blockQuote: colors.borderStrong, + listItem: colors.primary, + listEnum: colors.secondary, + hr: colors.borderBase, } as const; export type MarkdownPalette = { -readonly [K in keyof typeof markdownColors]: string }; diff --git a/tui/src/styles/tokens.test.ts b/tui/src/styles/tokens.test.ts new file mode 100644 index 0000000..be3266a --- /dev/null +++ b/tui/src/styles/tokens.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test } from "bun:test"; +import { readdir } from "node:fs/promises"; +import { join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * The palette has one source, and this is what keeps it that way. + * + * It had fractured into five: `styles/theme.ts`, a markdown palette on One Dark + * hues, `styles/syntax.ts` on a third set, an orange `#fb923c` selection row + * hardcoded into four separate pickers, and bare `color="green"` ANSI. One + * screen could show ten hues from four unrelated systems, and nothing failed — + * every one of them was a valid colour that rendered fine on its own. + * + * So the check is structural rather than visual: outside `styles/`, a colour has + * to arrive as a token. A new hue can still enter the interface, but only by + * being named in `theme.ts` first, where the next reader will find it. + */ + +const STYLES_DIRECTORY = "styles"; +const SOURCE = /\.tsx?$/; +const IS_TEST = /\.test\.tsx?$/; + +/** `#abc` and `#aabbcc`, but not a `#` heading or an id in prose. */ +const HEX_LITERAL = /#[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3})?\b/; + +/** + * `color="green"`, `backgroundColor="gray"`, `borderColor="yellow"`. + * + * The 16-colour ANSI names are the other way the palette leaks: they are not + * hex, so they read as harmless, and they render as flat primaries that look + * like nothing else in the interface. + */ +const ANSI_NAMED = /(?:^|[^A-Za-z])[Cc]olor="[a-zA-Z]+"/; + +/** + * A directory walk, not `git ls-files`. + * + * The tracked-files sweep in CLAUDE.md silently skips a file that has not been + * added yet — which is exactly when a new component carrying a new hex literal + * would be introduced, and exactly when this test needs to see it. + */ +async function sourceFiles(directory: string): Promise { + const entries = await readdir(directory, { withFileTypes: true }); + const found: string[] = []; + + for (const entry of entries) { + const path = join(directory, entry.name); + + if (entry.isDirectory()) { + if (entry.name === STYLES_DIRECTORY) continue; + found.push(...(await sourceFiles(path))); + continue; + } + + if (SOURCE.test(entry.name) && !IS_TEST.test(entry.name)) found.push(path); + } + + return found; +} + +/** Comments describe history — "it used to draw #000000" — and are not colours. */ +function withoutComments(source: string): string[] { + return source + .split("\n") + .map((line) => (/^\s*(?:\/\/|\/\*|\*)/.test(line) ? "" : line)); +} + +/** + * `fileURLToPath`, not `.pathname`. + * + * A URL's pathname is percent-encoded, so a checkout under a directory with a + * space in it — `/Users/me/my repo` — yields `/Users/me/my%20repo`, which no + * `readdir` will find. The test would then fail on a machine where nothing is + * actually wrong, which is its own kind of broken. + */ +const TUI_ROOT = fileURLToPath(new URL("..", import.meta.url)); + +describe("colour tokens", () => { + test("finds the components it is meant to be checking", async () => { + // A walk that returned nothing would pass every assertion below. + const files = await sourceFiles(TUI_ROOT); + expect(files.length).toBeGreaterThan(20); + }); + + test("no component carries a raw hex colour", async () => { + const offenders: string[] = []; + + for (const file of await sourceFiles(TUI_ROOT)) { + const lines = withoutComments(await Bun.file(file).text()); + lines.forEach((line, index) => { + if (HEX_LITERAL.test(line)) { + offenders.push(`${relative(TUI_ROOT, file)}:${index + 1} ${line.trim()}`); + } + }); + } + + expect(offenders).toEqual([]); + }); + + test("no component uses a named ANSI colour", async () => { + const offenders: string[] = []; + + for (const file of await sourceFiles(TUI_ROOT)) { + const lines = withoutComments(await Bun.file(file).text()); + lines.forEach((line, index) => { + if (ANSI_NAMED.test(line)) { + offenders.push(`${relative(TUI_ROOT, file)}:${index + 1} ${line.trim()}`); + } + }); + } + + expect(offenders).toEqual([]); + }); +}); diff --git a/tui/src/timeline.shape.test.tsx b/tui/src/timeline.shape.test.tsx new file mode 100644 index 0000000..eb94a85 --- /dev/null +++ b/tui/src/timeline.shape.test.tsx @@ -0,0 +1,192 @@ +import { describe, expect, test } from "bun:test"; +import chalk from "chalk"; +import { render } from "ink"; +import { Writable } from "node:stream"; +import { EventEmitter } from "node:events"; +import { Timeline } from "./timeline"; +import { TRANSCRIPT_GUTTER } from "./layout"; +import type { TimeLineItem } from "./types"; + +/** + * The transcript's left edge, row by row. + * + * Every row type indents itself the same amount, and nothing but a rendered + * frame can check that. Reading the diff cannot: each row's indent is correct in + * isolation and the defect is only the relationship between them. It went + * unnoticed for exactly that reason — the transcript ran four conventions at + * once, the user row at one column, the assistant's label at another with its + * own prose at a third, tool rows at a fourth. + */ + +chalk.level = 3; + +const ESC = new RegExp("\\u001B\\[[0-9;?]*[A-Za-z]", "g"); + +class Capture extends Writable { + isTTY = true; + columns = 110; + rows = 40; + frames: string[] = []; + override _write(chunk: unknown, _encoding: unknown, done: () => void) { + this.frames.push(String(chunk)); + done(); + } + /** The latest frame with content, split into lines with escapes stripped. */ + lines() { + for (let index = this.frames.length - 1; index >= 0; index--) { + const stripped = this.frames[index]!.replace(ESC, ""); + if (stripped.trim() !== "") { + return stripped.replace(/\n$/, "").split("\n"); + } + } + return []; + } +} + +/** Ink puts stdin in raw mode when interactive; the timeline reads no input. */ +class FakeStdin extends EventEmitter { + isTTY = true; + setRawMode() { return this; } + setEncoding() { return this; } + resume() { return this; } + pause() { return this; } + read() { return null; } + ref() {} + unref() {} +} + +const STARTED = 1_700_000_000_000; + +/** One of every row type the timeline can draw. */ +const items: TimeLineItem[] = [ + { id: "1", type: "user", content: "Add retry to the provider client" }, + { + id: "2", + type: "assistant", + content: "Reading the client first.", + streaming: false, + }, + { + id: "3", + type: "tool", + name: "grep", + arguments: { pattern: "withRetry" }, + status: "completed", + summary: "8 matches", + }, + { + id: "4", + type: "tool", + name: "edit_file", + arguments: { path: "runtime/retry.ts" }, + status: "failed", + }, + { + id: "5", + type: "tool", + name: "run_terminal", + arguments: { command: "bun test" }, + status: "completed", + output: "42 pass", + }, + { + id: "6", + type: "todo", + items: [{ content: "Add the retry", status: "completed" }], + }, + { id: "7", type: "system", content: "Switched to gemini-3-pro" }, + { + id: "8", + type: "turn", + agent: "Build", + model: "gemini-3-pro", + startedAt: STARTED, + endedAt: STARTED + 4200, + outcome: "completed", + }, +] as TimeLineItem[]; + +/** + * `interactive: true` is load-bearing, and its absence is invisible locally. + * + * Ink decides interactivity as `interactive ?? (!isInCi && stdout.isTTY)`. The + * capture above reports `isTTY`, so on a developer machine the frame is written + * on every render and reading it before unmount works. Under CI the `isInCi` + * half flips, ink batches, and nothing reaches the stream until unmount — so + * every assertion here saw an empty frame and all four tests failed on both + * runners while passing locally, including under the reverse-order sweep and + * four concurrent suites. Passing the flag explicitly short-circuits the `??` + * and makes the harness say what it means. `prompt.shape.test.tsx` already did + * this; reproduce with `CI=true bun test`. + */ +function renderTimeline() { + const stdout = new Capture(); + const instance = render(, { + stdout: stdout as unknown as NodeJS.WriteStream, + stdin: new FakeStdin() as unknown as NodeJS.ReadStream, + patchConsole: false, + exitOnCtrlC: false, + interactive: true, + }); + const lines = stdout.lines(); + instance.unmount(); + return lines; +} + +/** Columns before the first non-space character, or null for a blank row. */ +function indentOf(line: string): number | null { + if (line.trim() === "") return null; + return line.length - line.trimStart().length; +} + +describe("transcript grid", () => { + test("renders something to measure", () => { + // A frame that came back empty would satisfy every assertion below. + const lines = renderTimeline(); + expect(lines.filter((line) => line.trim() !== "").length).toBeGreaterThan(6); + }); + + test("every row starts at the rail or at the gutter, and nowhere else", () => { + // Two legal columns, not one. A row that opens a block puts its rail — │, + // the state glyph, the turn marker — in column zero; a row continuing that + // block starts at the gutter, under the content above it. Anything else is + // a third convention, which is what this file exists to prevent. + const indents = renderTimeline() + .map(indentOf) + .filter((indent): indent is number => indent !== null); + + expect(new Set(indents)).toEqual(new Set([0, TRANSCRIPT_GUTTER])); + }); + + test("content sits at the gutter on every kind of row", () => { + const lines = renderTimeline().filter((line) => line.trim() !== ""); + + // Each of these is a different row type, and the text after the rail has to + // begin at the same column in all of them. + const contentColumn = (needle: string) => { + const line = lines.find((candidate) => candidate.includes(needle)); + expect(line, `no row containing ${needle}`).toBeDefined(); + return line!.indexOf(needle); + }; + + expect(contentColumn("Add retry to the provider client")).toBe(TRANSCRIPT_GUTTER); + expect(contentColumn("Woopcode")).toBe(TRANSCRIPT_GUTTER); + expect(contentColumn("Reading the client first.")).toBe(TRANSCRIPT_GUTTER); + expect(contentColumn("Grep")).toBe(TRANSCRIPT_GUTTER); + expect(contentColumn("Edit")).toBe(TRANSCRIPT_GUTTER); + expect(contentColumn("$ bun test")).toBe(TRANSCRIPT_GUTTER); + expect(contentColumn("Tasks")).toBe(TRANSCRIPT_GUTTER); + expect(contentColumn("Switched to")).toBe(TRANSCRIPT_GUTTER); + expect(contentColumn("Build")).toBe(TRANSCRIPT_GUTTER); + }); + + test("an assistant reply shares a left edge with its own speaker label", () => { + // The row this test exists for: the label used to sit two columns outside + // the prose it introduced. + const lines = renderTimeline().filter((line) => line.trim() !== ""); + const label = lines.find((line) => line.includes("Woopcode"))!; + const prose = lines.find((line) => line.includes("Reading the client"))!; + + expect(label.indexOf("Woopcode")).toBe(prose.indexOf("Reading the client")); + }); +}); diff --git a/tui/src/timeline.tsx b/tui/src/timeline.tsx index 42a253b..93a3474 100644 --- a/tui/src/timeline.tsx +++ b/tui/src/timeline.tsx @@ -1,5 +1,5 @@ import { Box, Text } from "ink"; -import { memo } from "react"; +import { memo, type ReactNode } from "react"; import type { ActiveTurn, TimeLineItem } from "./types"; import { MessageRenderer } from "./components/MessageRenderer"; import { ToolStatus } from "./components/ToolStatus"; @@ -14,6 +14,7 @@ import { } from "./tool-display"; import { CommandBlock } from "./components/CommandBlock"; import { TodoList } from "./components/TodoList"; +import { TRANSCRIPT_GUTTER } from "./layout"; interface TimelineProps { items: TimeLineItem[]; @@ -72,7 +73,7 @@ const TimelineHistory = memo(function TimelineHistory({ return ( <> {hidden > 0 && ( - + {`… ${hidden} earlier item${hidden === 1 ? "" : "s"}`} @@ -85,48 +86,68 @@ const TimelineHistory = memo(function TimelineHistory({ ); }); +/** + * The shape every transcript row takes: a one-column rail, a blank column, then + * the content. + * + * Rows used to each choose their own indent, so a reader's eye had no single + * left edge to follow down the transcript. Routing them through one component is + * what makes the spine a property of the timeline rather than a coincidence + * between six components — and `timeline.shape.test.tsx` asserts it by rendering + * a frame, because it is invisible in a diff. + */ +function TimelineRow({ + rail, + children, + marginBottom = 1, +}: { + rail?: ReactNode; + children: ReactNode; + marginBottom?: number; +}) { + return ( + + + {rail} + + + {children} + + + ); +} + const TimelineItem = memo(function TimelineItem({ item }: { item: TimeLineItem }) { const colors = usePalette(); switch (item.type) { case "user": return ( - - {/* Left accent bar — OpenCode style */} - - - - - {item.content} - - + }> + {item.content} + ); case "assistant": + // The label now sits inside the gutter with the prose it introduces, + // rather than two columns outside its own body. return ( - - + }> + Woopcode - {item.streaming && ( - · thinking - )} - - - + {item.streaming && · thinking} - + + ); case "system": return ( - - - - {item.content} - - + }> + {item.content} + ); case "todo": @@ -162,25 +183,30 @@ const TimelineItem = memo(function TimelineItem({ item }: { item: TimeLineItem } const argument = formatToolArgument(item.arguments); - // One quiet line: glyph, tool, the argument worth reading, and what came - // back. The whole row stays muted — it is a record of work, not the work. + // One quiet line: the state glyph in the rail, then tool, the argument + // worth reading, and what came back. The whole row stays muted — it is a + // record of work, not the work. return ( - - - {toolLabel(item.name)} - {argument && ( - - - {argument.quoted ? `"${argument.text}"` : argument.text} - - - )} - {item.summary && ( - - {`(${item.summary})`} - - )} - + } + > + + {toolLabel(item.name)} + {argument && ( + + + {argument.quoted ? `"${argument.text}"` : argument.text} + + + )} + {item.summary && ( + + {`(${item.summary})`} + + )} + + ); } } diff --git a/tui/src/tool-display.test.ts b/tui/src/tool-display.test.ts index 5983956..33b7d44 100644 --- a/tui/src/tool-display.test.ts +++ b/tui/src/tool-display.test.ts @@ -7,19 +7,31 @@ import { } from "./tool-display"; describe("tool glyphs", () => { - test("groups tools by the kind of work they do", () => { - expect(toolGlyph("glob")).toBe("*"); - expect(toolGlyph("grep")).toBe("*"); - expect(toolGlyph("list_files")).toBe("*"); - expect(toolGlyph("read_file")).toBe("→"); - expect(toolGlyph("web_fetch")).toBe("→"); - expect(toolGlyph("edit_file")).toBe("±"); - expect(toolGlyph("run_tests")).toBe("$"); - expect(toolGlyph("ask_user")).toBe("?"); + test("marks every settled call the same way, whatever the tool", () => { + // The label beside it already says which kind of work it was. Five marks in + // one column said it twice, in five different visual families. + const marks = [ + "glob", + "grep", + "list_files", + "read_file", + "web_fetch", + "edit_file", + "run_tests", + "ask_user", + ].map(toolGlyph); + + expect(new Set(marks)).toEqual(new Set(["·"])); + }); + + test("marks an unknown tool rather than rendering nothing", () => { + expect(toolGlyph("some_new_tool")).toBe("·"); }); - test("falls back rather than rendering nothing for an unknown tool", () => { - expect(toolGlyph("some_new_tool")).toBe("•"); + test("stays one column wide", () => { + // It renders into a fixed two-column gutter. A wider mark would push every + // tool row's content out of line with the rest of the transcript. + expect([...toolGlyph("grep")]).toHaveLength(1); }); }); diff --git a/tui/src/tool-display.ts b/tui/src/tool-display.ts index 62ff071..1cec320 100644 --- a/tui/src/tool-display.ts +++ b/tui/src/tool-display.ts @@ -51,13 +51,25 @@ export function rendersItself(name: string) { return SELF_RENDERING_TOOLS.includes(name); } -export function toolGlyph(name: string) { - if (SEARCH_TOOLS.includes(name)) return "*"; - if (READ_TOOLS.includes(name)) return "→"; - if (WRITE_TOOLS.includes(name)) return "±"; - if (RUN_TOOLS.includes(name)) return "$"; - if (name === "ask_user") return "?"; - return "•"; +/** + * The mark in a settled tool call's rail. + * + * One mark for every kind, deliberately. This returned five — `*` for a search, + * `→` for a read, `±` for a write, `$` for a run, `•` for anything else — which + * put ASCII punctuation, an arrow and a maths operator in the same column of the + * same list, alongside the `⊘`, `✗`, `▪` and `⊙` used elsewhere. Nine families + * of mark on one screen reads as clutter, not as information. + * + * Nothing is lost: the row already names the tool beside the glyph, and "Read" + * says what `→` was meant to. Marks are kept for the three things a glyph is + * genuinely better at than a word — running, blocked, failed — where the state + * is not otherwise written anywhere on the row. `ToolStatus` owns those. + * + * Still a function of the name so this stays the one place the rule lives, and + * so a tool that earns a mark of its own has somewhere to declare it. + */ +export function toolGlyph(_name: string) { + return "·"; } export function toolLabel(name: string) {