Merge pull request #431 from deepseek-harness/fix/tui-color-scheme-v2

fix(tui): detect terminal color scheme; harden the flaky sandbox probe-timeout test
This commit is contained in:
Tianyi Cui
2026-07-22 10:29:35 +08:00
committed by GitHub
3 changed files with 96 additions and 7 deletions
@@ -325,13 +325,19 @@ describe('probeTimeoutMs config', () => {
}) })
it('bounds the default probes: a launcher slower than the configured timeout reads as unusable', async () => { it('bounds the default probes: a launcher slower than the configured timeout reads as unusable', async () => {
// The same sleeping launcher passes under the default 5000ms budget and // The same 1s launcher reads usable under a generous budget and unusable
// fails under a 250ms one — the config demonstrably reaches spawnSync. // under a 250ms one — the config demonstrably reaches spawnSync. Both bounds
// keep a wide margin from the launcher's 1s runtime so a loaded host (where
// spawnSync blocks the worker and fork/exec latency inflates wall-clock)
// cannot flip either verdict; the vitest timeout clears the patient budget.
const dir = mkdtempSync(join(tmpdir(), 'dsh-slow-landlock-')) const dir = mkdtempSync(join(tmpdir(), 'dsh-slow-landlock-'))
const launcher = join(dir, 'landlock-run') const launcher = join(dir, 'landlock-run')
writeFileSync(launcher, '#!/bin/sh\nsleep 1\necho "landlock: fully enforced"\nexit 0\n', { mode: 0o755 }) writeFileSync(launcher, '#!/bin/sh\nsleep 1\necho "landlock: fully enforced"\nexit 0\n', { mode: 0o755 })
const patient = await setup({}, { platform: 'linux', probeBwrap: () => false, landlockLauncher: launcher }) const patient = await setup(
{ probeTimeoutMs: 15_000 },
{ platform: 'linux', probeBwrap: () => false, landlockLauncher: launcher },
)
expect(patient.sandbox.confine(['true'], RO).enforcement).toBe('full') expect(patient.sandbox.confine(['true'], RO).enforcement).toBe('full')
const impatient = await setup( const impatient = await setup(
@@ -339,7 +345,7 @@ describe('probeTimeoutMs config', () => {
{ platform: 'linux', probeBwrap: () => false, landlockLauncher: launcher }, { platform: 'linux', probeBwrap: () => false, landlockLauncher: launcher },
) )
expect(() => impatient.sandbox.confine(['true'], RO)).toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE })) expect(() => impatient.sandbox.confine(['true'], RO)).toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
}) }, 30_000)
}) })
describe('the default seatbelt probe (sandbox-exec contract)', () => { describe('the default seatbelt probe (sandbox-exec contract)', () => {
+33 -3
View File
@@ -30,6 +30,7 @@ import {
type OverlayHandle, type OverlayHandle,
type SelectListTheme, type SelectListTheme,
type Terminal, type Terminal,
type TerminalColorScheme,
} from '@earendil-works/pi-tui' } from '@earendil-works/pi-tui'
import type { Context } from 'cordis' import type { Context } from 'cordis'
import z from 'schemastery' import z from 'schemastery'
@@ -237,17 +238,21 @@ function displayText(text: string): string {
* backgrounds alike; grouping uses foreground-only gutter bars and reverse * backgrounds alike; grouping uses foreground-only gutter bars and reverse
* video rather than fixed background fills. * video rather than fixed background fills.
*/ */
function createPalette(enabled: boolean): Palette { function createPalette(enabled: boolean, scheme: TerminalColorScheme = 'dark'): Palette {
return { return {
accent: ansi('94', '39', enabled), accent: ansi('94', '39', enabled),
accent2: ansi('95', '39', enabled), accent2: ansi('95', '39', enabled),
text: text => text, text: text => text,
muted: ansi('90', '39', enabled), muted: ansi('90', '39', enabled),
dim: ansi('2', '22', enabled), // SGR 2 (dim) lightens text on a light background — substitute ANSI 90
// (bright black / gray) which renders as a readable muted tone on any scheme.
dim: scheme === 'light' ? ansi('90', '39', enabled) : ansi('2', '22', enabled),
success: ansi('32', '39', enabled), success: ansi('32', '39', enabled),
warning: ansi('33', '39', enabled), warning: ansi('33', '39', enabled),
error: ansi('31', '39', enabled), error: ansi('31', '39', enabled),
code: ansi('36', '39', enabled), // ANSI 36 (cyan) is difficult to read on a light background — use
// ANSI 34 (blue) which is legible on both light and dark schemes.
code: scheme === 'light' ? ansi('34', '39', enabled) : ansi('36', '39', enabled),
added: ansi('32', '39', enabled), added: ansi('32', '39', enabled),
removed: ansi('31', '39', enabled), removed: ansi('31', '39', enabled),
bold: ansi('1', '22', enabled), bold: ansi('1', '22', enabled),
@@ -1504,6 +1509,30 @@ export function createTuiChat(
void shutdown(true) void shutdown(true)
} }
/** Swap the palette and all derived themes for the given terminal color scheme. */
const applyColorScheme = (scheme: TerminalColorScheme): void => {
if (scheme === currentScheme) return
currentScheme = scheme
Object.assign(palette, createPalette(resolved.color, scheme))
Object.assign(mdTheme, markdownTheme(palette))
rebuildTranscript(false)
setStatus(agent.status)
requestRender()
}
let currentScheme: TerminalColorScheme = 'dark'
// Apply any color scheme the terminal reports. Registering before the query
// below means even a synchronous reply reaches `applyColorScheme`; in practice
// the startup query's reply is the only report, since dsh-tui leaves
// unsolicited color-scheme notifications disabled.
const disposeSchemeListener = ui.onTerminalColorSchemeChange(applyColorScheme)
// Ask the terminal for its color scheme via device-status report; the reply,
// if any, arrives through the listener above. Most terminals do not respond,
// so we keep the dark-optimised palette. Swallow a query-write failure for the
// same reason.
ui.queryTerminalColorScheme({ timeoutMs: 2000 }).catch(() => {})
const toggleTools = (): void => { const toggleTools = (): void => {
toolsExpanded = !toolsExpanded toolsExpanded = !toolsExpanded
for (const card of allToolCards) card.setExpanded(toolsExpanded) for (const card of allToolCards) card.setExpanded(toolsExpanded)
@@ -1714,6 +1743,7 @@ export function createTuiChat(
disposeStatus() disposeStatus()
disposeError() disposeError()
disposeAgent() disposeAgent()
disposeSchemeListener()
disposeTargetListeners() disposeTargetListeners()
} }
+53
View File
@@ -1399,4 +1399,57 @@ describe('terminal mounting', () => {
expect(() => createTuiChat(ctx, { sessionId: 'missing' }, runtime)).toThrow('is not running') expect(() => createTuiChat(ctx, { sessionId: 'missing' }, runtime)).toThrow('is not running')
await ctx.fiber.dispose() await ctx.fiber.dispose()
}) })
it('detects a light terminal color scheme and switches from dark- to light-optimised ANSI codes', async () => {
const result = await setup({ config: { color: true } })
// Initial render uses dark-optimised palette: SGR 2 (dim) for dim text.
expect(result.terminal.output).toContain('\x1b[2mdeepseek-v4-flash')
// A report matching the current scheme is a no-op: no palette rebuild or
// re-render (ESC [?997;1n = dark, the startup default).
const beforeSameScheme = result.terminal.output.length
result.terminal.send('\x1b[?997;1n')
await tick()
expect(result.terminal.output.length).toBe(beforeSameScheme)
// Simulate the terminal responding with a light color scheme report
// (ESC [?997;2n = light, ESC [?997;1n = dark).
result.terminal.send('\x1b[?997;2n')
await tick()
await tick()
// After switching to light-optimised palette: palette.dim uses ANSI 90
// (gray) instead of SGR 2. The header now uses \x1b[90m for the detail
// line. The cumulative output still contains the initial SGR 2 render,
// so we assert that a LATER write (appended after the scheme switch)
// uses ANSI 90 for the same header text.
expect(result.terminal.output).toContain('\x1b[90mdeepseek-v4-flash')
// Switch back to dark scheme.
result.terminal.send('\x1b[?997;1n')
await tick()
await tick()
// After switching back, a new write uses SGR 2 for the header detail.
expect(result.terminal.output).toContain('\x1b[2mdeepseek-v4-flash')
await dispose(result)
})
it('keeps the dark palette when the terminal rejects the color-scheme query', async () => {
class QueryFailTerminal extends FakeTerminal {
override write(data: string): void {
// The device-status query is the only write that fails; the promise
// rejects and the swallowed `.catch` leaves the dark palette in place.
if (data === '\x1b[?996n') throw new Error('query write failed')
super.write(data)
}
}
const terminal = new QueryFailTerminal()
const result = await createTuiTestHarness(terminal, vi.fn(), {
config: { color: true },
cwd: process.cwd(),
})
await tick()
expect(terminal.output).toContain('\x1b[2mdeepseek-v4-flash')
await disposeTuiTestHarness(result)
})
}) })