From 60490222f8f04f70837a121c411f36dab579bcd3 Mon Sep 17 00:00:00 2001 From: Eric Lee Date: Thu, 9 Jul 2026 23:35:17 -0700 Subject: [PATCH] feat(tui): highlight past user inputs with the original userMessageBackground band MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Past user prompts rendered as bare '❯ text' — visually identical in weight to assistant prose, so earlier inputs were hard to find when scrolling a multi-turn transcript. The original Claude Code draws every past user input (and slash echo) on a full-row background band; port that: - theme.ts: new userMessageBackground token — dark rgb(55,55,55) / light rgb(240,240,240), the exact original utils/theme.ts values; skinnable via user_message_bg with a DEFAULT_THEME fallback. - messageLine.tsx: transcriptRowBand(msg, t) paints the inner row Box for user rows and slash echoes (UserPromptMessage.tsx:76 / UserCommandMessage.tsx:62 parity). Slash echoes borrow the user pointer and text color instead of the muted system dot, and the user glyph drops its non-original bold — the band, not bold text, carries the emphasis (HighlightedThinkingText renders the pointer un-bolded). - appLayout/useMainApp/virtualHeights: the '───' inter-turn dash above non-first user rows becomes a monochrome-only fallback. It existed as a crutch for exactly this findability problem; with color available the band replaces it. On NO_COLOR / FORCE_COLOR=0 / TERM=dumb terminals the band emits nothing (chalk level 0), so the textual separator returns — gated by domain/blockLayout.ts::showsInterTurnSeparator fed from config/env.ts::TRANSCRIPT_COLOR (process.stdout.hasColors), with the render gate and the height estimator sharing the same predicate. [Codex adversarial-review catch: the first cut removed the separator unconditionally, leaving no-color terminals with no turn boundary.] - tests: pin band routing (transcriptRowBand), slash pointer swap, theme values incl. skin fallback/override, separator gate matrix, band-mode same-height + monochrome +2 heights; refresh the stale compound-prompt expectation (glyph is the fixed '❯' since the re-theme; the compound brand prompt only widens the gutter). - theme.ts selectionBg: ported the original selection blues — dark rgb(38,79,120) / light rgb(180,213,255) (utils/theme.ts). The previous dark #373737 was the exact band color, so selecting text on a past user row painted band-on-band and vanished. [Critic catch #2.] - diffView.ts structuredDiffSupported(): gate the raw-ANSI ColorDiff path on TRANSCRIPT_COLOR as well as NO_COLOR. ColorDiff builds SGRs itself (not through chalk), so a FORCE_COLOR=0 / TERM=dumb session previously kept colored diff blocks inside an otherwise monochrome transcript; it now falls back to the chalk-suppressed markdown ```diff path on the same signals as transcript chrome. [Codex verification-pass catch.] Verified with the pyte harness (fake NDJSON agent-server): banded rows carry bg 373737 across the row for single-line, multi-line (both lines + indent), and slash echoes; assistant/system rows and the composer stay bandless; with NO_COLOR=1 the '───' separator renders above non-first user turns. ui-tui vitest: 8 failed vs 9 on main baseline (one stale test fixed, no new failures); tsc + eslint clean. - lib/forceTruecolor.ts: the bundled chalk predates NO_COLOR support (it emitted full color regardless — verified level 2 on a PTY with NO_COLOR=1), so the pre-chalk bootstrap now translates NO_COLOR into FORCE_COLOR=0, the channel chalk does honor, unless the user set FORCE_COLOR explicitly. This keeps TRANSCRIPT_COLOR (stdout.hasColors, which honors NO_COLOR) agreeing with what the renderer emits — critic caught the divergence: NO_COLOR previously showed the band AND the fallback separator. diffView already gated on NO_COLOR, so the app is now consistent. [Critic catch: hasColors vs chalk signal divergence under NO_COLOR.] Known pre-existing (NOT introduced here, repro'd on the unmodified build): the inline growth repaint can leave stale right-edge cells from shifted footer/rule rows (e.g. a stray '─' at 100x30 after 3 turns on main). Co-Authored-By: Claude Fable 5 --- ui-tui/src/__tests__/blockLayout.test.ts | 20 ++++++- ui-tui/src/__tests__/expandResults.test.ts | 2 +- ui-tui/src/__tests__/forceTruecolor.test.ts | 20 ++++++- ui-tui/src/__tests__/messages.test.ts | 59 ++++++++++++++++++++- ui-tui/src/__tests__/structuredDiff.test.ts | 46 ++++++++++++++++ ui-tui/src/__tests__/theme.test.ts | 28 ++++++++++ ui-tui/src/__tests__/toolTranscript.test.ts | 2 +- ui-tui/src/__tests__/virtualHeights.test.ts | 15 ++++-- ui-tui/src/app/useMainApp.ts | 18 ++++--- ui-tui/src/components/appLayout.tsx | 16 +++--- ui-tui/src/components/diffView.tsx | 23 ++++---- ui-tui/src/components/messageLine.tsx | 21 ++++++-- ui-tui/src/config/env.ts | 18 +++++++ ui-tui/src/domain/blockLayout.ts | 16 ++++++ ui-tui/src/domain/roles.ts | 8 +-- ui-tui/src/lib/forceTruecolor.ts | 16 +++++- ui-tui/src/lib/virtualHeights.ts | 8 +-- ui-tui/src/theme.ts | 20 ++++++- 18 files changed, 309 insertions(+), 47 deletions(-) diff --git a/ui-tui/src/__tests__/blockLayout.test.ts b/ui-tui/src/__tests__/blockLayout.test.ts index 1bd98f7ff..7e14e8bf1 100644 --- a/ui-tui/src/__tests__/blockLayout.test.ts +++ b/ui-tui/src/__tests__/blockLayout.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { blockRenders, hasLeadGap, messageGroup, prevRenderedMsg } from '../domain/blockLayout.js' +import { blockRenders, hasLeadGap, messageGroup, prevRenderedMsg, showsInterTurnSeparator } from '../domain/blockLayout.js' import type { Msg } from '../types.js' const m = (over: Partial): Msg => ({ role: 'assistant', text: '', ...over }) @@ -121,3 +121,21 @@ describe('prevRenderedMsg', () => { expect(prevRenderedMsg(at, 0, shownCtx)).toBeUndefined() }) }) + +describe('showsInterTurnSeparator', () => { + const user = m({ role: 'user' }) + const assistant = m({ role: 'assistant' }) + const slash = m({ kind: 'slash', role: 'system' }) + + it('never renders when color is available — the band is the turn marker', () => { + expect(showsInterTurnSeparator(user, 5, 1, true)).toBe(false) + }) + + it('renders above non-first user rows only when color is disabled', () => { + expect(showsInterTurnSeparator(user, 5, 1, false)).toBe(true) + expect(showsInterTurnSeparator(user, 1, 1, false)).toBe(false) // first user row + expect(showsInterTurnSeparator(assistant, 5, 1, false)).toBe(false) + expect(showsInterTurnSeparator(slash, 5, 1, false)).toBe(false) + expect(showsInterTurnSeparator(user, 5, -1, false)).toBe(false) // no user yet + }) +}) diff --git a/ui-tui/src/__tests__/expandResults.test.ts b/ui-tui/src/__tests__/expandResults.test.ts index c37768754..256130b64 100644 --- a/ui-tui/src/__tests__/expandResults.test.ts +++ b/ui-tui/src/__tests__/expandResults.test.ts @@ -264,7 +264,7 @@ describe('expanded rendering', () => { it('height estimates count the variant that renders', () => { const msg: Msg = { kind: 'trail', role: 'system', text: '', tools: trail, toolsVerbose: verboseTrail } - const opts = { compact: false, details: true, leadGap: false, withSeparator: false } + const opts = { compact: false, details: true, leadGap: false } const collapsed = estimatedMsgHeight(msg, 80, opts) const expanded = estimatedMsgHeight(msg, 80, { ...opts, toolsExpanded: true }) diff --git a/ui-tui/src/__tests__/forceTruecolor.test.ts b/ui-tui/src/__tests__/forceTruecolor.test.ts index cc151e5fe..168a63e71 100644 --- a/ui-tui/src/__tests__/forceTruecolor.test.ts +++ b/ui-tui/src/__tests__/forceTruecolor.test.ts @@ -147,7 +147,7 @@ describe('forceTruecolor', () => { ) }) - it('respects NO_COLOR', async () => { + it('respects NO_COLOR — even over the explicit truecolor opt-in', async () => { await withCleanEnv( () => { process.env.NO_COLOR = '1' @@ -156,7 +156,23 @@ describe('forceTruecolor', () => { async () => { await import('../lib/forceTruecolor.js?t=no-color-' + importId++) expect(process.env.COLORTERM).toBeUndefined() - expect(process.env.FORCE_COLOR).toBeUndefined() + // The bundled chalk never checks NO_COLOR itself, so the bootstrap + // translates it into the FORCE_COLOR=0 channel chalk does honor. + expect(process.env.FORCE_COLOR).toBe('0') + } + ) + }) + + it('lets an explicit FORCE_COLOR outrank NO_COLOR', async () => { + await withCleanEnv( + () => { + process.env.NO_COLOR = '1' + process.env.FORCE_COLOR = '2' + }, + async () => { + const mod = await import('../lib/forceTruecolor.js?t=no-color-force-' + importId++) + expect(mod.shouldSuppressColorForNoColor(process.env)).toBe(false) + expect(process.env.FORCE_COLOR).toBe('2') } ) }) diff --git a/ui-tui/src/__tests__/messages.test.ts b/ui-tui/src/__tests__/messages.test.ts index 0c12f5240..cc212871b 100644 --- a/ui-tui/src/__tests__/messages.test.ts +++ b/ui-tui/src/__tests__/messages.test.ts @@ -4,7 +4,7 @@ import { renderSync } from '@clawcodex/ink' import React from 'react' import { describe, expect, it } from 'vitest' -import { MessageLine } from '../components/messageLine.js' +import { MessageLine, transcriptRowBand } from '../components/messageLine.js' import { toTranscriptMessages } from '../domain/messages.js' import { upsert } from '../lib/messages.js' import { stripAnsi } from '../lib/text.js' @@ -68,7 +68,62 @@ describe('MessageLine', () => { .split('\n') .find(line => line.includes('Okay')) - expect(renderedLine).toContain('Ψ > Okay') + // The transcript pointer is the fixed `❯` (roles.ts, original CC + // figures.pointer) — the compound brand prompt 'Ψ >' only widens the + // gutter (composerPromptWidth = 4), so the glyph must be padded with the + // full 3-column separator before the body, not collapsed to one space. + expect(renderedLine).toContain('❯ Okay') + }) + + // Original-CC transcript emphasis: past user inputs sit on a + // userMessageBackground band (UserPromptMessage.tsx:76); slash echoes get + // the same band + user pointer (UserCommandMessage.tsx:62); assistant rows + // stay bandless. + const renderRaw = (msg: Record) => { + const stdout = new PassThrough() + const stdin = new PassThrough() + const stderr = new PassThrough() + let output = '' + + Object.assign(stdout, { columns: 80, isTTY: false, rows: 24 }) + Object.assign(stdin, { isTTY: false }) + Object.assign(stderr, { isTTY: false }) + stdout.on('data', chunk => { + output += chunk.toString() + }) + + const instance = renderSync( + React.createElement(MessageLine, { cols: 80, msg: msg as never, t: DEFAULT_THEME }), + { + patchConsole: false, + stderr: stderr as NodeJS.WriteStream, + stdin: stdin as NodeJS.ReadStream, + stdout: stdout as NodeJS.WriteStream + } + ) + + instance.unmount() + instance.cleanup() + + return output + } + + it('paints the userMessageBackground band behind user rows and slash echoes only', () => { + const band = DEFAULT_THEME.color.userMessageBackground + + expect(transcriptRowBand({ role: 'user', text: 'find the bug' } as never, DEFAULT_THEME)).toBe(band) + expect(transcriptRowBand({ kind: 'slash', role: 'system', text: '/cost' } as never, DEFAULT_THEME)).toBe(band) + expect(transcriptRowBand({ role: 'assistant', text: 'prose' } as never, DEFAULT_THEME)).toBeUndefined() + expect(transcriptRowBand({ role: 'system', text: 'note' } as never, DEFAULT_THEME)).toBeUndefined() + expect(transcriptRowBand({ role: 'tool', text: 'result' } as never, DEFAULT_THEME)).toBeUndefined() + }) + + it('renders slash echoes with the user pointer, not the system dot', () => { + const raw = stripAnsi(renderRaw({ kind: 'slash', role: 'system', text: '/cost' })) + + expect(raw).toContain('/cost') + expect(raw).toContain('❯') + expect(raw).not.toContain('·') }) }) diff --git a/ui-tui/src/__tests__/structuredDiff.test.ts b/ui-tui/src/__tests__/structuredDiff.test.ts index fd0eda56d..f5e9250dd 100644 --- a/ui-tui/src/__tests__/structuredDiff.test.ts +++ b/ui-tui/src/__tests__/structuredDiff.test.ts @@ -493,3 +493,49 @@ describe('DiffView rendering', () => { expect(plain).toMatch(/221 \+\s+id: "drone-warfare",/) }) }) + +describe('structuredDiffSupported no-color gating', () => { + // ColorDiff emits raw SGR itself (not through chalk), so the structured + // path must bow out on the SAME signals that turn the rest of the UI + // monochrome — otherwise a FORCE_COLOR=0 / TERM=dumb session gets colored + // diff blocks inside an otherwise colorless transcript. + const importFresh = async () => (await import('../components/diffView.js')).structuredDiffSupported + + // Piped test stdout has no hasColors at all; install/remove an own prop. + const setHasColors = (v: boolean | undefined) => { + if (v === undefined) { + delete (process.stdout as { hasColors?: unknown }).hasColors + } else { + Object.defineProperty(process.stdout, 'hasColors', { configurable: true, value: () => v, writable: true }) + } + } + + afterEach(() => { + setHasColors(undefined) + vi.unstubAllEnvs() + vi.resetModules() + }) + + it('falls back when the stream reports no color support (FORCE_COLOR=0 / TERM=dumb)', async () => { + vi.resetModules() + setHasColors(false) + + expect((await importFresh())()).toBe(false) + }) + + it('falls back under NO_COLOR even when the stream reports color', async () => { + vi.resetModules() + setHasColors(true) + vi.stubEnv('NO_COLOR', '1') + + expect((await importFresh())()).toBe(false) + }) + + it('renders structured diffs on color-capable terminals', async () => { + vi.resetModules() + setHasColors(true) + delete process.env.NO_COLOR + + expect((await importFresh())()).toBe(true) + }) +}) diff --git a/ui-tui/src/__tests__/theme.test.ts b/ui-tui/src/__tests__/theme.test.ts index a9de69168..4dde8b5d7 100644 --- a/ui-tui/src/__tests__/theme.test.ts +++ b/ui-tui/src/__tests__/theme.test.ts @@ -82,6 +82,25 @@ describe('LIGHT_THEME', () => { expect(Object.keys(LIGHT_THEME.color).sort()).toEqual(Object.keys(DARK_THEME.color).sort()) expect(LIGHT_THEME.brand).toEqual(DARK_THEME.brand) }) + + it('pins the original userMessageBackground band values (utils/theme.ts)', async () => { + const { DARK_THEME, LIGHT_THEME } = await importThemeWithCleanEnv() + + expect(DARK_THEME.color.userMessageBackground).toBe('rgb(55,55,55)') + expect(LIGHT_THEME.color.userMessageBackground).toBe('rgb(240,240,240)') + }) + + it('keeps text selection visible on the user-input band', async () => { + // Regression guard: dark selectionBg was #373737 — the exact band color — + // so selecting a past user row painted band-on-band and vanished. The + // original keeps selection a distinct blue in both themes. + const { DARK_THEME, LIGHT_THEME } = await importThemeWithCleanEnv() + + expect(DARK_THEME.color.selectionBg).toBe('rgb(38,79,120)') + expect(LIGHT_THEME.color.selectionBg).toBe('rgb(180,213,255)') + expect(DARK_THEME.color.selectionBg).not.toBe(DARK_THEME.color.userMessageBackground) + expect(LIGHT_THEME.color.selectionBg).not.toBe(LIGHT_THEME.color.userMessageBackground) + }) }) describe('DEFAULT_THEME aliasing', () => { @@ -250,6 +269,15 @@ describe('fromSkin', () => { expect(theme.color.selectionBg).toBe('#654321') }) + it('keeps the user-message band through skins, with user_message_bg override', async () => { + const { DEFAULT_THEME, fromSkin } = await importThemeWithCleanEnv() + + expect(fromSkin({ banner_title: '#FF0000' }, {}).color.userMessageBackground).toBe( + DEFAULT_THEME.color.userMessageBackground + ) + expect(fromSkin({ user_message_bg: '#222233' }, {}).color.userMessageBackground).toBe('#222233') + }) + it('overrides branding', async () => { const { fromSkin } = await importThemeWithCleanEnv() const { brand } = fromSkin({}, { agent_name: 'TestBot', prompt_symbol: '$' }) diff --git a/ui-tui/src/__tests__/toolTranscript.test.ts b/ui-tui/src/__tests__/toolTranscript.test.ts index 0b6e9b681..66d65448e 100644 --- a/ui-tui/src/__tests__/toolTranscript.test.ts +++ b/ui-tui/src/__tests__/toolTranscript.test.ts @@ -118,7 +118,7 @@ describe('estimatedMsgHeight with multi-line tool details', () => { tools: [buildToolTrailLine('Bash', 'ls', false, 'a')] } - const opts = { compact: false, details: true, leadGap: false, withSeparator: false } + const opts = { compact: false, details: true, leadGap: false } const tall = estimatedMsgHeight(base, 80, opts) const short = estimatedMsgHeight(single, 80, opts) diff --git a/ui-tui/src/__tests__/virtualHeights.test.ts b/ui-tui/src/__tests__/virtualHeights.test.ts index 9819a7214..011206608 100644 --- a/ui-tui/src/__tests__/virtualHeights.test.ts +++ b/ui-tui/src/__tests__/virtualHeights.test.ts @@ -79,12 +79,21 @@ describe('virtual height estimates', () => { ).toBe(estimatedMsgHeight(toolsOnly, 80, { compact: false, details: false })) }) - it('reserves two extra rows for the inter-turn separator on non-first user messages', () => { + it('gives every user message the same height when the band renders', () => { + // With color available, the userMessageBackground band replaces the dash + // separator and adds no rows — non-first user rows cost the same. + const msg: Msg = { role: 'user', text: 'follow-up question' } + + expect(estimatedMsgHeight(msg, 80, { compact: false, details: false })).toBe(3) + }) + + it('reserves two rows for the monochrome ─── fallback separator', () => { + // NO_COLOR terminals can't see the band; the textual separator returns + // (1 rule row + 1 margin row) and the estimate must match the render. const msg: Msg = { role: 'user', text: 'follow-up question' } const base = estimatedMsgHeight(msg, 80, { compact: false, details: false }) - const withSep = estimatedMsgHeight(msg, 80, { compact: false, details: false, withSeparator: true }) - expect(withSep).toBe(base + 2) + expect(estimatedMsgHeight(msg, 80, { compact: false, details: false, withSeparator: true })).toBe(base + 2) }) it('caps wrapped-line counting so giant assistant turns do not block offset rebuilds', () => { diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index a3e52a737..4b14ef3ee 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -2,9 +2,9 @@ import { type ScrollBoxHandle, useApp, useHasSelection, useSelection, useStdout, import { useStore } from '@nanostores/react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { STARTUP_RESUME_ID } from '../config/env.js' +import { STARTUP_RESUME_ID, TRANSCRIPT_COLOR } from '../config/env.js' import { MAX_HISTORY, WHEEL_SCROLL_STEP } from '../config/limits.js' -import { hasLeadGap, prevRenderedMsg } from '../domain/blockLayout.js' +import { hasLeadGap, prevRenderedMsg, showsInterTurnSeparator } from '../domain/blockLayout.js' import { SECTION_NAMES, sectionMode } from '../domain/details.js' import { attachedImageNotice, imageTokenMeta } from '../domain/messages.js' import { composeTabTitle, fmtCwdBranch, shortCwd } from '../domain/paths.js' @@ -359,10 +359,14 @@ export function useMainApp(gw: GatewayClient) { return cache }, [heightCacheKey]) - // Index of the first user-role message — separator-rendering in - // appLayout.tsx skips this row, so the height estimator must skip it - // too. -1 when no user message exists yet (no row will gate true). - const firstUserIdx = useMemo(() => virtualRows.findIndex(r => r.msg.role === 'user'), [virtualRows]) + // First user-row index feeds the monochrome `───` fallback gate — both the + // appLayout.tsx render side and the height estimate below must agree (see + // domain/blockLayout.ts::showsInterTurnSeparator). Short-circuited to -1 + // when color renders (the band replaces the separator). + const firstUserIdx = useMemo( + () => (TRANSCRIPT_COLOR ? -1 : virtualRows.findIndex(r => r.msg.role === 'user')), + [virtualRows] + ) const estimateRowHeight = useCallback( (index: number) => @@ -381,7 +385,7 @@ export function useMainApp(gw: GatewayClient) { toolsExpanded: toolsDetailsExpanded, toolsVisible: toolsDetailsVisible, userPrompt: ui.theme.brand.prompt, - withSeparator: virtualRows[index]!.msg.role === 'user' && firstUserIdx >= 0 && index > firstUserIdx + withSeparator: showsInterTurnSeparator(virtualRows[index]!.msg, index, firstUserIdx, TRANSCRIPT_COLOR) }), [ cols, diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 6a65a847a..d362a181f 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -7,9 +7,9 @@ import type { AppLayoutProps } from '../app/interfaces.js' import { $isBlocked, $overlayState, patchOverlayState } from '../app/overlayStore.js' import { $uiState } from '../app/uiStore.js' import { usePet } from '../app/usePet.js' -import { INLINE_MODE, SHOW_FPS, TERMUX_TUI_MODE } from '../config/env.js' +import { INLINE_MODE, SHOW_FPS, TERMUX_TUI_MODE, TRANSCRIPT_COLOR } from '../config/env.js' import { PLACEHOLDER } from '../content/placeholders.js' -import { prevRenderedMsg } from '../domain/blockLayout.js' +import { prevRenderedMsg, showsInterTurnSeparator } from '../domain/blockLayout.js' import { COMPOSER_PROMPT_GAP_WIDTH, composerPromptWidth, @@ -88,12 +88,12 @@ const TranscriptPane = memo(function TranscriptPane({ }: Pick) { const ui = useStore($uiState) - // Index of the first user-role message; every later user message gets a - // small dash above it so multi-turn transcripts visually segment by - // turn. -1 when no user message has been sent yet → no separator ever - // renders. + // Monochrome fallback only: with color disabled the user-input band can't + // render, so non-first user rows keep the textual `───` separator (see + // domain/blockLayout.ts::showsInterTurnSeparator; heights in useMainApp + // mirror this gate). -1 when no user message has been sent yet. const firstUserIdx = useMemo( - () => transcript.historyItems.findIndex(m => m.role === 'user'), + () => (TRANSCRIPT_COLOR ? -1 : transcript.historyItems.findIndex(m => m.role === 'user')), [transcript.historyItems] ) @@ -116,7 +116,7 @@ const TranscriptPane = memo(function TranscriptPane({ {transcript.virtualRows.slice(transcript.virtualHistory.start, transcript.virtualHistory.end).map(row => ( - {row.msg.role === 'user' && firstUserIdx >= 0 && row.index > firstUserIdx && ( + {showsInterTurnSeparator(row.msg, row.index, firstUserIdx, TRANSCRIPT_COLOR) && ( ─── diff --git a/ui-tui/src/components/diffView.tsx b/ui-tui/src/components/diffView.tsx index a86caac87..e265e08ca 100644 --- a/ui-tui/src/components/diffView.tsx +++ b/ui-tui/src/components/diffView.tsx @@ -25,6 +25,7 @@ import { relative } from 'node:path' import { Box, NoSelect, RawAnsi, Text } from '@clawcodex/ink' import { memo, type ReactNode, useSyncExternalStore } from 'react' +import { TRANSCRIPT_COLOR } from '../config/env.js' import { ColorDiff, ColorFile, highlighterReady, subscribeHighlighter } from '../lib/colorDiff.js' import type { Theme } from '../theme.js' import type { MsgDiffData } from '../types.js' @@ -41,17 +42,21 @@ const CREATE_PREVIEW_LINES = 10 const HUNK_SEPARATOR = '\x1b[0m\x1b[2m...\x1b[0m' /** - * True when ANSI diff rendering makes sense for this terminal. Under - * NO_COLOR the raw escape rows would stay fully colored while the rest of - * the UI goes monochrome — callers should fall back to the plain ```diff - * markdown path instead. NO_COLOR is the only gate needed: ColorDiff's - * detectColorMode degrades to 256-color escapes whenever COLORTERM isn't - * truecolor (e.g. Apple Terminal, where forceTruecolor deliberately deletes - * COLORTERM), so any color-capable terminal receives valid sequences — and a - * terminal with no color support at all breaks the chalk-driven UI equally. + * True when ANSI diff rendering makes sense for this terminal. ColorDiff + * builds its escape sequences itself (not through chalk), so whenever the + * rest of the UI goes monochrome the raw diff rows would stay fully colored + * — callers should fall back to the plain ```diff markdown path instead + * (markdown styling flows through chalk and is suppressed with the rest). + * Gate on the same no-color signals as transcript chrome: TRANSCRIPT_COLOR + * (stdout.hasColors → false under FORCE_COLOR=0 / TERM=dumb / NO_COLOR on a + * TTY) plus the call-time NO_COLOR check, which also covers non-TTY streams + * and per-test env overrides. Any color-capable terminal receives valid + * sequences — ColorDiff's detectColorMode degrades to 256-color escapes + * whenever COLORTERM isn't truecolor (e.g. Apple Terminal, where + * forceTruecolor deliberately deletes COLORTERM). */ export function structuredDiffSupported(): boolean { - return !process.env.NO_COLOR + return TRANSCRIPT_COLOR && !process.env.NO_COLOR } // ── Module-level render cache ──────────────────────────────────────────── diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index a831e735d..777e3b26a 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -159,7 +159,14 @@ export const MessageLine = memo(function MessageLine({ ) } - const { body, glyph, prefix } = ROLE[msg.role](t) + // Past user inputs and slash echoes render like the original transcript: + // the `❯ ` pointer in `subtle` with the text on a `userMessageBackground` + // band (UserPromptMessage.tsx:76 / UserCommandMessage.tsx:62 — the band, + // not bold text, carries the emphasis, per HighlightedThinkingText). Slash + // echoes keep their system role (and gutter width) but borrow the user + // pointer, matching UserCommandMessage. + const band = transcriptRowBand(msg, t) + const { body, glyph, prefix } = ROLE[band === undefined ? msg.role : 'user'](t) const gutterWidth = transcriptGutterWidth(msg.role, t.brand.prompt) const showDetails = @@ -169,7 +176,7 @@ export const MessageLine = memo(function MessageLine({ const content = (() => { if (msg.kind === 'slash') { - return {msg.text} + return {msg.text} } // ── Collapsible long system message (system prompt, AGENTS.md, etc.) ── @@ -265,9 +272,9 @@ export const MessageLine = memo(function MessageLine({ )} - + - + {glyph}{' '} @@ -283,6 +290,12 @@ export const MessageLine = memo(function MessageLine({ export const shouldShowResponseSeparator = (msg: Msg, showDetails: boolean): boolean => msg.role === 'assistant' && msg.kind !== 'diff' && showDetails && /\S/.test(msg.text) +// The highlight band behind past user inputs and slash echoes — the original +// userMessageBackground emphasis (UserPromptMessage.tsx:76 / +// UserCommandMessage.tsx:62). Assistant/system/tool rows get none. +export const transcriptRowBand = (msg: Msg, t: Theme): string | undefined => + msg.role === 'user' || msg.kind === 'slash' ? t.color.userMessageBackground : undefined + interface MessageLineProps { cols: number compact?: boolean diff --git a/ui-tui/src/config/env.ts b/ui-tui/src/config/env.ts index a11685126..58cd5bf08 100644 --- a/ui-tui/src/config/env.ts +++ b/ui-tui/src/config/env.ts @@ -73,3 +73,21 @@ export const INLINE_MODE = inlineOverride ?? true // Live FPS counter overlay, fed by ink's onFrame (real render rate, not a // synthetic timer). export const SHOW_FPS = truthy(process.env.CLAWCODEX_TUI_FPS) + +// Whether the output stream renders color at all (NO_COLOR / FORCE_COLOR=0 / +// TERM=dumb → false). The renderer's chalk does NOT read NO_COLOR itself — +// lib/forceTruecolor.ts translates NO_COLOR into FORCE_COLOR=0 before chalk's +// import, which is what keeps this signal and the renderer's actual output in +// agreement (both honor FORCE_COLOR). The transcript's user-input band is +// pure background color, so monochrome terminals fall back to the textual +// `───` inter-turn separator (domain/blockLayout.ts::showsInterTurnSeparator). +// hasColors is missing on mocked/piped streams in tests — treat that as +// color-capable so the designed band path is the default and the fallback +// stays scoped to explicit no-color terminals. +export const TRANSCRIPT_COLOR: boolean = (() => { + try { + return process.stdout.hasColors?.() ?? true + } catch { + return true + } +})() diff --git a/ui-tui/src/domain/blockLayout.ts b/ui-tui/src/domain/blockLayout.ts index 36c511e4c..886fbf41c 100644 --- a/ui-tui/src/domain/blockLayout.ts +++ b/ui-tui/src/domain/blockLayout.ts @@ -145,3 +145,19 @@ export const prevRenderedMsg = ( return undefined } + +/** + * Whether the `───` inter-turn separator renders above this row. The band + * behind past user inputs (messageLine's transcriptRowBand) is the designed + * turn marker, but it is pure background color — on a terminal with color + * disabled (NO_COLOR, FORCE_COLOR=0, TERM=dumb) the band emits nothing, so + * monochrome transcripts fall back to the textual dash above every + * non-first user turn. Callers pass `colorEnabled` from the real stream + * (see TRANSCRIPT_COLOR) so the render gate and the height estimator agree. + */ +export const showsInterTurnSeparator = ( + msg: Pick, + index: number, + firstUserIdx: number, + colorEnabled: boolean +): boolean => !colorEnabled && msg.role === 'user' && firstUserIdx >= 0 && index > firstUserIdx diff --git a/ui-tui/src/domain/roles.ts b/ui-tui/src/domain/roles.ts index 7fbf11cf9..b8e092dce 100644 --- a/ui-tui/src/domain/roles.ts +++ b/ui-tui/src/domain/roles.ts @@ -2,9 +2,11 @@ import type { Theme } from '../theme.js' import type { Role } from '../types.js' // Original Claude Code glyphs/colors (messages/*.tsx): assistant prose gets a -// plain text-white ⏺ (AssistantTextMessage), the user echo is a near-invisible -// `❯` in `subtle` with white text (HighlightedThinkingText figures.pointer + -// pointerColor "subtle"), tools keep the green ⏺ result coloring. +// plain text-white ⏺ (AssistantTextMessage), the user echo is a `❯` in +// `subtle` with white text (HighlightedThinkingText figures.pointer + +// pointerColor "subtle") whose emphasis comes from the userMessageBackground +// band messageLine paints behind the row (UserPromptMessage.tsx:76), and +// tools keep the green ⏺ result coloring. export const ROLE: Record { body: string; glyph: string; prefix: string }> = { assistant: t => ({ body: t.color.text, glyph: '⏺', prefix: t.color.text }), system: t => ({ body: '', glyph: '·', prefix: t.color.muted }), diff --git a/ui-tui/src/lib/forceTruecolor.ts b/ui-tui/src/lib/forceTruecolor.ts index 6e6895833..592a05275 100644 --- a/ui-tui/src/lib/forceTruecolor.ts +++ b/ui-tui/src/lib/forceTruecolor.ts @@ -40,7 +40,21 @@ export function shouldDowngradeAppleTerminalTruecolor(env: NodeJS.ProcessEnv = p return isAdvertisedTruecolor(env) } -if (shouldForceTruecolor()) { +// The bundled chalk predates NO_COLOR support (it never checks the variable), +// so a NO_COLOR terminal still got full color output. Translate NO_COLOR into +// FORCE_COLOR=0 — the env channel this chalk does honor — unless the user set +// FORCE_COLOR themselves (explicit FORCE_COLOR outranks NO_COLOR, matching +// supports-color/Node getColorDepth precedence). This also keeps the +// transcript's monochrome `───` fallback (config/env.ts::TRANSCRIPT_COLOR, +// from stdout.hasColors which DOES honor NO_COLOR) agreeing with what the +// renderer actually emits. +export function shouldSuppressColorForNoColor(env: NodeJS.ProcessEnv = process.env): boolean { + return 'NO_COLOR' in env && (env.FORCE_COLOR ?? '') === '' +} + +if (shouldSuppressColorForNoColor()) { + process.env.FORCE_COLOR = '0' +} else if (shouldForceTruecolor()) { if (!process.env.COLORTERM) { process.env.COLORTERM = 'truecolor' } diff --git a/ui-tui/src/lib/virtualHeights.ts b/ui-tui/src/lib/virtualHeights.ts index 41a638435..e5256c900 100644 --- a/ui-tui/src/lib/virtualHeights.ts +++ b/ui-tui/src/lib/virtualHeights.ts @@ -171,9 +171,11 @@ export const estimatedMsgHeight = ( h++ } - // Inter-turn separator above non-first user messages (1 rule row + 1 - // top-margin row). The render-side gate is in appLayout.tsx; we trust - // the caller to pass `withSeparator` only when it matches that gate. + // Monochrome fallback: the `───` inter-turn separator above non-first user + // rows (1 rule row + 1 top-margin row). Rendered only when color is + // disabled — the band replaces it otherwise. The caller passes the + // domain/blockLayout.ts::showsInterTurnSeparator result so this estimate + // matches the appLayout.tsx render gate exactly. if (withSeparator) { h += 2 } diff --git a/ui-tui/src/theme.ts b/ui-tui/src/theme.ts index 9e5b05662..46383d656 100644 --- a/ui-tui/src/theme.ts +++ b/ui-tui/src/theme.ts @@ -25,6 +25,9 @@ export interface ThemeColors { statusBad: string statusCritical: string selectionBg: string + /** Original CC `userMessageBackground` (utils/theme.ts): the highlight band + * drawn behind past user inputs (and slash echoes) in the transcript. */ + userMessageBackground: string // Original Claude Code tokens (utils/theme.ts) consumed by the ported // surfaces: busy-line shimmer, composer rules, permission-mode badges. @@ -293,7 +296,14 @@ export const DARK_THEME: Theme = { statusWarn: '#FFC107', statusBad: '#FF8C5A', statusCritical: '#FF6B80', - selectionBg: '#373737', + // Original darkTheme selectionBg: "classic dark-mode selection blue (VS + // Code dark default)" (utils/theme.ts) — must stay distinct from + // userMessageBackground or selecting text on a past user row paints + // band-on-band and vanishes (the previous #373737 collided exactly). + selectionBg: 'rgb(38,79,120)', + // Original darkTheme userMessageBackground: "Lighter grey for better + // visual contrast" (utils/theme.ts). + userMessageBackground: 'rgb(55,55,55)', claudeShimmer: 'rgb(235,159,127)', subtle: 'rgb(80,80,80)', @@ -349,7 +359,12 @@ export const LIGHT_THEME: Theme = { statusWarn: '#966C1E', statusBad: '#C25A3A', statusCritical: '#AB2B3F', - selectionBg: '#E0E0E0', + // Original lightTheme selectionBg: "classic light-mode selection blue + // (macOS/VS Code-ish)" (utils/theme.ts); distinct from the 240 band. + selectionBg: 'rgb(180,213,255)', + // Original lightTheme userMessageBackground: "Slightly darker grey for + // optimal contrast" (utils/theme.ts). + userMessageBackground: 'rgb(240,240,240)', claudeShimmer: 'rgb(245,149,117)', subtle: 'rgb(175,175,175)', @@ -652,6 +667,7 @@ export function fromSkin( c('selection_bg') ?? c('completion_menu_current_bg') ?? (hasSkinColors ? completionCurrentBg : d.color.selectionBg), + userMessageBackground: c('user_message_bg') ?? d.color.userMessageBackground, claudeShimmer: d.color.claudeShimmer, subtle: d.color.subtle,