diff --git a/packages/sandbox/sandbox-local/tests/local.spec.ts b/packages/sandbox/sandbox-local/tests/local.spec.ts index f9efbf992c..f7cc952498 100644 --- a/packages/sandbox/sandbox-local/tests/local.spec.ts +++ b/packages/sandbox/sandbox-local/tests/local.spec.ts @@ -325,13 +325,19 @@ describe('probeTimeoutMs config', () => { }) 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 - // fails under a 250ms one — the config demonstrably reaches spawnSync. + // The same 1s launcher reads usable under a generous budget and unusable + // 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 launcher = join(dir, 'landlock-run') 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') const impatient = await setup( @@ -339,7 +345,7 @@ describe('probeTimeoutMs config', () => { { platform: 'linux', probeBwrap: () => false, landlockLauncher: launcher }, ) expect(() => impatient.sandbox.confine(['true'], RO)).toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE })) - }) + }, 30_000) }) describe('the default seatbelt probe (sandbox-exec contract)', () => { diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 9f77d9843d..0d00887ba9 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -30,6 +30,7 @@ import { type OverlayHandle, type SelectListTheme, type Terminal, + type TerminalColorScheme, } from '@earendil-works/pi-tui' import type { Context } from 'cordis' import z from 'schemastery' @@ -237,17 +238,21 @@ function displayText(text: string): string { * backgrounds alike; grouping uses foreground-only gutter bars and reverse * video rather than fixed background fills. */ -function createPalette(enabled: boolean): Palette { +function createPalette(enabled: boolean, scheme: TerminalColorScheme = 'dark'): Palette { return { accent: ansi('94', '39', enabled), accent2: ansi('95', '39', enabled), text: text => text, 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), warning: ansi('33', '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), removed: ansi('31', '39', enabled), bold: ansi('1', '22', enabled), @@ -1504,6 +1509,30 @@ export function createTuiChat( 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 => { toolsExpanded = !toolsExpanded for (const card of allToolCards) card.setExpanded(toolsExpanded) @@ -1714,6 +1743,7 @@ export function createTuiChat( disposeStatus() disposeError() disposeAgent() + disposeSchemeListener() disposeTargetListeners() } diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index dede95a419..f2c9fc3f34 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -1399,4 +1399,57 @@ describe('terminal mounting', () => { expect(() => createTuiChat(ctx, { sessionId: 'missing' }, runtime)).toThrow('is not running') 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) + }) })