Преглед изворни кода

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
Tianyi Cui пре 2 месеци
родитељ
комит
c92c24667d

+ 10 - 4
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)', () => {

+ 33 - 3
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()
   }
 

+ 53 - 0
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)
+  })
 })