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
20 changes: 19 additions & 1 deletion ui-tui/src/__tests__/blockLayout.test.ts
Original file line number Diff line number Diff line change
@@ -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>): Msg => ({ role: 'assistant', text: '', ...over })
Expand Down Expand Up @@ -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
})
})
2 changes: 1 addition & 1 deletion ui-tui/src/__tests__/expandResults.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
20 changes: 18 additions & 2 deletions ui-tui/src/__tests__/forceTruecolor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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')
}
)
})
Expand Down
59 changes: 57 additions & 2 deletions ui-tui/src/__tests__/messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<string, unknown>) => {
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('·')
})
})

Expand Down
46 changes: 46 additions & 0 deletions ui-tui/src/__tests__/structuredDiff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
28 changes: 28 additions & 0 deletions ui-tui/src/__tests__/theme.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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: '$' })
Expand Down
2 changes: 1 addition & 1 deletion ui-tui/src/__tests__/toolTranscript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
15 changes: 12 additions & 3 deletions ui-tui/src/__tests__/virtualHeights.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
18 changes: 11 additions & 7 deletions ui-tui/src/app/useMainApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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) =>
Expand All @@ -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,
Expand Down
16 changes: 8 additions & 8 deletions ui-tui/src/components/appLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -88,12 +88,12 @@ const TranscriptPane = memo(function TranscriptPane({
}: Pick<AppLayoutProps, 'actions' | 'composer' | 'progress' | 'transcript'>) {
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]
)

Expand All @@ -116,7 +116,7 @@ const TranscriptPane = memo(function TranscriptPane({

{transcript.virtualRows.slice(transcript.virtualHistory.start, transcript.virtualHistory.end).map(row => (
<Box flexDirection="column" key={row.key} ref={transcript.virtualHistory.measureRef(row.key)}>
{row.msg.role === 'user' && firstUserIdx >= 0 && row.index > firstUserIdx && (
{showsInterTurnSeparator(row.msg, row.index, firstUserIdx, TRANSCRIPT_COLOR) && (
<Box marginTop={1}>
<Text color={ui.theme.color.border}>───</Text>
</Box>
Expand Down
23 changes: 14 additions & 9 deletions ui-tui/src/components/diffView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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 ────────────────────────────────────────────
Expand Down
Loading
Loading