Przeglądaj źródła

Merge pull request #4178 from deepseek-harness/worktree/terminal-color-contrast

fix(web): preserve terminal colors and cursor contrast
Yichen Jiang 3 dni temu
rodzic
commit
4f7336c831

+ 2 - 2
.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md
-2026-09-09-web-sidebar-terminal.md: cdc10181360362c31a7fbfe226886444ae1eb6c2
-2026-09-09-web-sidebar-terminal.zh.md: 9e759371b0a39e4667b97111d84267236b6ad954
+2026-09-09-web-sidebar-terminal.md: 9b0c1916872c04611f94bf00f7057cce99671c68
+2026-09-09-web-sidebar-terminal.zh.md: 5bc25f80cf6d33e07d2cec1b48e1f4ca7f7d750c

+ 1 - 1
.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md

@@ -12,7 +12,7 @@ Web users need an interactive shell beside a Session to inspect the workspace an
 
 Guide entries declare stable ids within their provider. A keyed `sidebar.right.tab.guide.entry` slot dispatches by the active provider id, so an extension replacing a builtin also controls its guide rendering. The sidebar owns card placement and the default fallback; provider components own their controls and read the enclosing tab through framework hooks. An entry slot allows a shell menu without replacing the entire guide or nesting an interactive button inside another button.
 
-The application theme owns terminal background, default text, cursor and selection colors. The terminal body reads the rendered DSH palette after framework-delivered theme changes, so CSS token overrides remain effective. Updating the existing xterm instance preserves its output and PTY attachment; shell-provided ANSI colors remain independent.
+The application theme supplies terminal default colors. The body reads resolved CSS tokens, and updates xterm only when those colors change. Public OSC parser observers retain indexed and default-color overrides separately from the DSH defaults; resets remove the corresponding override before restoring the current theme. Observers delegate queries and color handling to xterm. xterm's minimum contrast adjustment improves text legibility without remapping ANSI backgrounds. The DOM cursor reads the rendered cell background after each render and uses a contrasting fill through scoped CSS variables, so cursor movement never resets the palette. Browser checks cover indexed, true-color and inverse cells, light/dark switching, OSC retention and reset, and blinking cursor styles.
 
 `api-terminal-controller` owns user terminals by Session and exposes the `terminal` Remote namespace. `ui-sidebar-terminal` registers native right-sidebar tabs, xterm.js rendering and FitAddon sizing. The terminal guide card has a primary action for the remembered available shell and a separate installed-shell menu. Selecting a menu item records its path and opens a new terminal immediately; discovery alone allocates no process. Each tab owns its startup and close lifecycle. Host discovery verifies the configured candidates, with the execution default first; creation accepts only a currently discovered path. The browser remembers the last selected shell path in origin-scoped localStorage and falls back to the current default if that path is unavailable. The terminal type declares independent instances, so ordinary page deduplication cannot collapse separate processes when opening or docking tabs. The existing sidebar controls open additional tabs; double-clicking a tab title renames its terminal. Terminal processes use the composed subprocess provider and Session sandbox policy. Shell resolution occurs during discovery and creation; reading limits and reconnecting an existing process do not depend on the default executable remaining available. Interactive shell configuration supplies Tab completion and optional inline suggestions.
 

+ 1 - 1
.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md

@@ -12,7 +12,7 @@ Web 用户需要在 Session 旁使用交互式 shell 检查工作区和运行命
 
 开始页入口声明 provider 内稳定的 id。keyed `sidebar.right.tab.guide.entry` slot 按当前生效的 provider id 分发,因此替代 builtin 的 extension 也控制对应入口的渲染。侧栏负责卡片排列和默认回退,provider 组件负责自己的控件,并通过框架 hook 读取所在标签页。入口 slot 可以承载 shell 菜单,无需替换整个开始页或把交互按钮嵌套在另一个按钮内。
 
-应用主题负责终端背景、默认文字、光标和选区颜色。终端正文在收到框架传递的主题变化后读取 DSH 渲染配色,使 CSS token 覆盖继续生效。更新现有 xterm 实例会保留输出和 PTY 连接;shell 输出的 ANSI 颜色独立生效
+应用主题提供终端的默认颜色。终端正文读取解析后的 CSS 令牌,仅在颜色变化时更新 xterm。公开的 OSC 解析观察器将索引色和默认颜色覆盖与 DSH 默认值分开保存;重置命令先删除对应覆盖,再恢复当前主题。观察器将查询和颜色处理交给 xterm。xterm 的最小对比度调整改善文字可读性,同时不重新映射 ANSI 背景色。DOM 光标在每次渲染后读取单元格实际背景,通过局部 CSS 变量使用有足够对比度的填充色,因此光标移动不会重置调色板。浏览器检查覆盖索引色、真彩色、反色单元格、明暗主题切换、OSC 保留和重置,以及闪烁光标样式
 
 `api-terminal-controller` 按 Session 管理用户终端并提供 `terminal` Remote namespace。`ui-sidebar-terminal` 注册原生右侧栏标签页,使用 xterm.js 渲染和 FitAddon 测量尺寸。终端开始页卡片的主操作打开上次选择且仍可用的 shell,独立菜单提供已安装 shell。选择菜单项会记录路径并立即打开新终端;仅探测 shell 不分配进程。每个标签页拥有自己的启动和关闭生命周期。Host 探测会验证配置的候选,并把执行环境默认项放在首位;创建只接受当前探测返回的路径。浏览器在当前站点 localStorage 中记住上次选择的 shell 路径,该路径不可用时回到当前默认项。终端类型声明独立实例,因此打开或停靠标签页时,普通页面的去重规则不会合并不同进程。已有侧栏控件负责打开更多标签页,双击标签页标题可重命名终端。终端进程使用组合的 subprocess provider 和 Session sandbox policy。shell 在探测和创建时解析;读取限制和重新连接已有进程不依赖默认可执行文件仍然可用。交互式 shell 配置提供 Tab 补全和可选的内联建议。
 

+ 30 - 0
apps/web/tests/expected/sidebar-terminal/colors.expected.md

@@ -0,0 +1,30 @@
+{
+  "lightText": {
+    "foreground": "rgb(111, 111, 110)",
+    "background": "rgba(0, 0, 0, 0)"
+  },
+  "customLight": {
+    "ansi": {
+      "foreground": "rgb(0, 137, 0)",
+      "background": "rgba(0, 0, 0, 0)"
+    },
+    "extended": {
+      "foreground": "rgb(153, 0, 153)",
+      "background": "rgba(0, 0, 0, 0)"
+    }
+  },
+  "ronLight": {
+    "background": "rgb(255, 255, 255)",
+    "foreground": "rgb(0, 0, 0)",
+    "shadow": "none",
+    "border": "rgb(0, 0, 0)",
+    "outline": "rgb(0, 0, 0)"
+  },
+  "whiteInDark": {
+    "background": "rgb(0, 0, 0)",
+    "foreground": "rgb(255, 255, 255)",
+    "shadow": "none",
+    "border": "rgb(255, 255, 255)",
+    "outline": "rgb(255, 255, 255)"
+  }
+}

+ 112 - 14
apps/web/tests/sidebar-terminal.e2e.ts

@@ -28,6 +28,18 @@ async function command(page: Page, text: string): Promise<void> {
   await page.keyboard.press('Enter')
 }
 
+async function selectTerminalTheme(page: Page, name: string): Promise<void> {
+  await page.getByRole('button', { name: 'Settings', exact: true }).click()
+  const dialog = page.getByRole('dialog', { name: 'Settings' })
+  const [response] = await Promise.all([
+    page.waitForResponse(candidate => new URL(candidate.url()).pathname === '/api/settings/mutate' && candidate.request().method() === 'POST'),
+    dialog.getByRole('button', { name, exact: true }).click(),
+  ])
+  expect(response.ok()).toBe(true)
+  await page.keyboard.press('Escape')
+  await dialog.waitFor({ state: 'hidden' })
+}
+
 describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => {
   let scaffold: WebScaffold
   let browser: Browser
@@ -80,6 +92,92 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => {
     }
   })
 
+  it('preserves program palettes and keeps ANSI text and cursors readable across DSH themes', async () => {
+    onTestFailed(() => saveFailureShot(page, 'terminal-colors'))
+    await page.emulateMedia({ colorScheme: 'light' })
+    await openTerminal(page)
+    const terminal = page.locator('[data-sidebar-terminal]')
+    const screen = page.locator('.xterm-rows:visible')
+    await command(page, "PS1=''; printf '\\033c\\033[97mBRIGHT_WHITE\\033[0m\\n'")
+    const colorOf = async (text: string) => {
+      const cell = screen.getByText(text, { exact: true })
+      await cell.waitFor()
+      return cell.evaluate(element => ({
+        foreground: getComputedStyle(element).color, background: getComputedStyle(element).backgroundColor,
+      }))
+    }
+    const lightText = await colorOf('BRIGHT_WHITE')
+    // Compare the rendered glyph with its real surface; the raw ANSI palette remains untouched.
+    expect(contrastRatio(lightText.foreground, 'rgb(255, 255, 255)')).toBeGreaterThanOrEqual(4.5)
+    await command(page, "printf '\\033]4;1;#009900;255;#990099\\007\\033[31mANSI_CUSTOM\\033[38;5;255mEXTENDED_CUSTOM\\033[0m\\n'")
+    const customLight = { ansi: await colorOf('ANSI_CUSTOM'), extended: await colorOf('EXTENDED_CUSTOM') }
+    await selectTerminalTheme(page, 'Dark')
+    await expect.poll(() => screen.evaluate(element => getComputedStyle(element).color)).toBe('rgb(249, 250, 251)')
+    await selectTerminalTheme(page, 'Light')
+    await expect.poll(() => screen.evaluate(element => getComputedStyle(element).color)).toBe('rgb(15, 17, 21)')
+    expect({ ansi: await colorOf('ANSI_CUSTOM'), extended: await colorOf('EXTENDED_CUSTOM') }).toEqual(customLight)
+    await command(page, "printf '\\033]104;1;255\\007'")
+    await expect.poll(async () => (await colorOf('ANSI_CUSTOM')).foreground).not.toBe(customLight.ansi.foreground)
+    await expect.poll(async () => (await colorOf('EXTENDED_CUSTOM')).foreground).not.toBe(customLight.extended.foreground)
+
+    await command(page, "printf '\\033]10;#112233;#ddeeff;#990099\\007'")
+    const defaults = () => terminal.evaluate(root => ({
+      foreground: getComputedStyle(root.querySelector('.xterm-rows')!).color,
+      background: getComputedStyle(root.querySelector('.xterm-scrollable-element')!).backgroundColor,
+    }))
+    const applicationDefaults = { foreground: 'rgb(17, 34, 51)', background: 'rgb(221, 238, 255)' }
+    await expect.poll(defaults).toEqual(applicationDefaults)
+    await selectTerminalTheme(page, 'Dark')
+    await expect.poll(() => terminal.evaluate(root => getComputedStyle(root.querySelector('.xterm')!.parentElement!).backgroundColor))
+      .toBe('rgb(21, 21, 23)')
+    expect(await defaults()).toEqual(applicationDefaults)
+    await command(page, "printf '\\033]110\\007\\033]111\\007\\033]112\\007'")
+    await expect.poll(defaults).toEqual({ foreground: 'rgb(249, 250, 251)', background: 'rgb(21, 21, 23)' })
+    await selectTerminalTheme(page, 'Light')
+    await expect.poll(defaults).toEqual({ foreground: 'rgb(15, 17, 21)', background: 'rgb(255, 255, 255)' })
+
+    const cursorColors = () => screen.locator('.xterm-cursor').evaluate((cursor) => {
+      const style = getComputedStyle(cursor)
+      return {
+        background: style.backgroundColor, foreground: style.color,
+        shadow: style.boxShadow, border: style.borderBottomColor, outline: style.outlineColor,
+      }
+    })
+    const paintCursor = async (sgr: string, shape = 2) => {
+      await command(page, `printf '\\033[0m\\033[2J\\033[H\\033[${sgr}mCURSOR\\033[1G\\033[${shape} q'`)
+      await expect.poll(() => screen.locator('.xterm-cursor').innerText()).toBe('C')
+    }
+    // ron uses foreground 51 and background 16; no installed Vim is required by CI.
+    await paintCursor('38;5;51;48;5;16')
+    await expect.poll(async () => (await cursorColors()).background).toBe('rgb(255, 255, 255)')
+    const ronLight = await cursorColors()
+    expect(ronLight.foreground).toBe('rgb(0, 0, 0)')
+    await terminal.screenshot({ path: `${shots}/ron-light.png`, animations: 'disabled' })
+    await selectTerminalTheme(page, 'Dark')
+    await page.locator('.xterm-helper-textarea:visible').click()
+    await expect.poll(async () => (await cursorColors()).background).toBe('rgb(249, 250, 251)')
+    await paintCursor('38;2;0;0;0;48;2;255;255;255')
+    await expect.poll(async () => (await cursorColors()).background).toBe('rgb(0, 0, 0)')
+    const whiteInDark = await cursorColors()
+    await paintCursor('0;7')
+    await expect.poll(async () => (await cursorColors()).background).toBe('rgb(0, 0, 0)')
+    for (const shape of [4, 6]) {
+      await paintCursor('38;5;51;48;5;16', shape)
+      await expect.poll(async () => shape === 4 ? (await cursorColors()).border : (await cursorColors()).shadow).toContain('rgb(249, 250, 251)')
+    }
+    await paintCursor('38;2;0;0;0;48;2;255;255;255', 1)
+    await page.addStyleTag({ content: '.xterm-cursor { animation-delay: -0.1s !important; animation-play-state: paused !important; }' })
+    await expect.poll(async () => (await cursorColors()).background).toBe('rgb(0, 0, 0)')
+    await page.addStyleTag({ content: '.xterm-cursor { animation-delay: -0.6s !important; }' })
+    await expect.poll(async () => (await cursorColors()).background).toBe('rgb(255, 255, 255)')
+    await page.locator('.xterm-helper-textarea:visible').evaluate((element) =>{  element.blur() })
+    await expect.poll(async () => (await cursorColors()).outline).toBe('rgb(0, 0, 0)')
+    await compareOrRefreshGolden(fileURLToPath(new URL('./expected/sidebar-terminal/colors.expected.md', import.meta.url)),
+      JSON.stringify({ lightText, customLight, ronLight, whiteInDark }, null, 2), webSnapshotMode())
+    expect(handles).toHaveLength(1)
+    expect(tripwire.pageErrors).toEqual([])
+  })
+
   it('follows light, dark and system themes while preserving the running shell and its output', async () => {
     await page.emulateMedia({ colorScheme: 'light' })
     await openTerminal(page)
@@ -98,31 +196,21 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => {
         foreground: getComputedStyle(rows).color,
       }
     })
-    const selectTheme = async (name: string) => {
-      await page.getByRole('button', { name: 'Settings', exact: true }).click()
-      const dialog = page.getByRole('dialog', { name: 'Settings' })
-      const [response] = await Promise.all([
-        page.waitForResponse(candidate => new URL(candidate.url()).pathname === '/api/settings/mutate' && candidate.request().method() === 'POST'),
-        dialog.getByRole('button', { name, exact: true }).click(),
-      ])
-      expect(response.ok()).toBe(true)
-      await page.keyboard.press('Escape')
-      await dialog.waitFor({ state: 'hidden' })
-    }
+
     const light = await readColors()
     expect(light.viewport).toBe(light.surface)
     expect(light.underlay).toBe(light.surface)
     await terminal.screenshot({ path: `${shots}/theme-light.png`, animations: 'disabled' })
-    await selectTheme('Dark')
+    await selectTerminalTheme(page, 'Dark')
     await expect.poll(async () => (await readColors()).viewport).not.toBe(light.viewport)
     const dark = await readColors()
     expect(dark.viewport).toBe(dark.surface)
     expect(dark.underlay).toBe(dark.surface)
     expect(dark.foreground).not.toBe(light.foreground)
     await terminal.screenshot({ path: `${shots}/theme-dark.png`, animations: 'disabled' })
-    await selectTheme('Light')
+    await selectTerminalTheme(page, 'Light')
     await expect.poll(readColors).toEqual(light)
-    await selectTheme('System')
+    await selectTerminalTheme(page, 'System')
     await page.emulateMedia({ colorScheme: 'dark' })
     await expect.poll(readColors).toEqual(dark)
     await page.emulateMedia({ colorScheme: 'light' })
@@ -282,3 +370,13 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => {
   })
 
 })
+
+function contrastRatio(first: string, second: string): number {
+  const luminance = (color: string) => {
+    const [r, g, b] = color.match(/[\d.]+/gu)!.map(Number).map(channel => channel / 255)
+      .map(value => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4)
+    return 0.2126 * r! + 0.7152 * g! + 0.0722 * b!
+  }
+  const a = luminance(first), b = luminance(second)
+  return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)
+}

+ 2 - 2
packages/client/ui-sidebar-terminal/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-sidebar-terminal/README.md
-README.md: 0245c699253b68ce4f8a441d5f1bd988340b8a32
-README.zh.md: 6d103ed97a17f0b72c810b9d81102d24760d5f63
+README.md: 8efe326007a816e6cde00942ee8c411c86e82ff6
+README.zh.md: 9f48103b1ebbcbb8507f92214b1499abb42f362a

+ 2 - 1
packages/client/ui-sidebar-terminal/README.md

@@ -33,7 +33,7 @@ Closing or replacing a terminal tab removes it immediately and ends its process
 
 Displaying a Session after a page reload reopens its retained Host terminals as new tabs. A recovery failure offers **Retry terminal recovery**. A recovered process that disappears reports an error instead of starting another shell. The [sidebar layout remains memory-only](../../client/ui-sidebar-right/README.md#state).
 
-The terminal background, default text, cursor, and selection follow the DSH theme, including system preference and theme-token overrides. Theme changes update the existing screen without clearing output or restarting the shell. Shell-provided ANSI colors remain in effect.
+The terminal background, default text, cursor, and selection follow the DSH theme, including system preference and theme-token overrides. Theme changes preserve the running shell, output, and application OSC color overrides. Reset commands restore colors to the current DSH defaults. xterm adjusts text toward 4.5:1 contrast; the cursor keeps at least 3:1 contrast against its cell background, including Vim colorschemes.
 
 <a id="understand-the-implementation"></a>
 ## Understand the implementation
@@ -69,6 +69,7 @@ None; terminal output travels only between the browser and Host.
 
 - Shell discovery or native PTY startup can fail. The tab reports the failure without launching a different shell.
 - Completion menus and inline suggestions depend on shell configuration. The Web UI adds no independent completion engine.
+- Application OSC color overrides are retained by the mounted renderer; a newly opened renderer cannot recover them from the Host screen snapshot.
 - Terminal history is bounded. The feature does not send terminal output to the Agent, provide split terminal panes inside a tab, or restore processes after Host restart.
 
 <a id="dev-note"></a>

+ 2 - 1
packages/client/ui-sidebar-terminal/README.zh.md

@@ -33,7 +33,7 @@ kind: "package-reference"
 
 刷新页面后显示某个 Session,会把 Host 保留的终端重新打开为新标签页。恢复失败时可点击**重试恢复终端**。恢复目标进程消失时显示错误,不启动另一个 shell。[侧栏布局仅保存在内存中](../../client/ui-sidebar-right/README.zh.md#state)。
 
-终端背景、默认文字、光标和选区跟随 DSH 主题,包括系统偏好和主题 token 覆盖。切换主题会更新现有屏幕,不清空输出或重启 shell。shell 输出的 ANSI 颜色仍然生效
+终端背景、默认文字、光标和选区跟随 DSH 主题,包括系统偏好和主题令牌覆盖。切换主题会保留运行中的 shell、输出和应用通过 OSC 设置的颜色。颜色重置命令恢复到当前 DSH 默认值。xterm 将文字对比度调整到 4.5:1;光标与所在单元格背景保持至少 3:1 的对比度,包括 Vim 配色方案
 
 <a id="understand-the-implementation"></a>
 ## 了解实现
@@ -69,6 +69,7 @@ Session header contribution 查询 Host 终端并打开恢复标签页。导航
 
 - 默认 shell 或原生 PTY 可能启动失败。标签页显示错误,不启动其他 shell。
 - 补全菜单和内联建议取决于 shell 配置,Web UI 不提供独立补全引擎。
+- 应用的 OSC 颜色覆盖由已挂载的渲染器保留;新打开的渲染器无法从 Host 屏幕快照恢复这些颜色。
 - 终端历史有上限。此功能不向 Agent 发送终端输出,不在单个标签页内拆分终端,也不在 Host 重启后恢复进程。
 
 <a id="dev-note"></a>

+ 10 - 0
packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css

@@ -8,3 +8,13 @@
 .status button { padding: 6px 10px; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 6px; color: inherit; background: transparent; cursor: pointer; }
 .status button:hover { background: var(--dsw-alias-bg-l2); }
 .error { margin: 0; padding: 10px 12px; overflow-wrap: anywhere; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-l2); }
+
+/* Cursor colors depend on its cell, so they must not update xterm's shared ANSI theme. */
+.screen :global(.xterm-cursor) { opacity: 1; }
+.screen :global(.xterm .xterm-rows .xterm-cursor.xterm-cursor-block) { background-color: var(--terminal-cursor); color: var(--terminal-cursor-accent); }
+.screen :global(.xterm .xterm-rows .xterm-cursor.xterm-cursor-block:not(.xterm-cursor-blink)) { background-color: var(--terminal-cursor) !important; color: var(--terminal-cursor-accent) !important; }
+.screen :global(.xterm .xterm-rows .xterm-cursor.xterm-cursor-outline) { outline-color: var(--terminal-cursor); }
+.screen :global(.xterm .xterm-rows .xterm-cursor.xterm-cursor-bar) { box-shadow: 1px 0 0 var(--terminal-cursor) inset; }
+.screen :global(.xterm .xterm-rows .xterm-cursor.xterm-cursor-underline) { border-bottom-color: var(--terminal-cursor); }
+.screen :global(.xterm .xterm-rows.xterm-focus .xterm-cursor.xterm-cursor-block.xterm-cursor-blink) { animation: terminal-cursor-blink 1s step-end infinite; }
+@keyframes terminal-cursor-blink { 0% { background-color: var(--terminal-cursor); color: var(--terminal-cursor-accent); } 50% { background-color: var(--terminal-cursor-cell-background); color: var(--terminal-cursor-cell-foreground); } }

+ 10 - 7
packages/client/ui-sidebar-terminal/src/client/TerminalBody.tsx

@@ -9,6 +9,8 @@ import type { TerminalBodyInjected } from './face.ts'
 import type {} from './locales.ts'
 import '@xterm/xterm/css/xterm.css'
 import css from './TerminalBody.module.css'
+import { TerminalTheme } from './terminal-theme.ts'
+import { observeTerminalCursor } from './terminal-cursor.ts'
 
 /** Standard sidebar owner share plus terminal model and localized copy. */
 export type TerminalBodyProps = PropsRuntime<'sidebar.right.pane.tab'> & PropsLocale<'sidebarTerminal'> & InjectFace<TerminalBodyInjected>
@@ -60,16 +62,20 @@ function TerminalScreen({ state, model, visible, label, theme }: {
   const element = useRef<HTMLDivElement>(null)
   const terminal = useRef<Terminal>()
   const fit = useRef<FitAddon>()
+  const colors = useRef<TerminalTheme>()
   const lastRevision = useRef(0)
   const current = useRef({ state, visible })
   current.current = { state, visible }
 
   useLayoutEffect(() => {
     const node = element.current!
-    const xterm = new Terminal({ cursorBlink: true, fontSize: 13, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', scrollback: current.current.state.environment?.scrollback ?? 0 })
+    const xterm = new Terminal({ minimumContrastRatio: 4.5, cursorBlink: true, fontSize: 13, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', scrollback: current.current.state.environment?.scrollback ?? 0 })
     const addon = new FitAddon()
     xterm.loadAddon(addon)
     xterm.open(node)
+    const palette = new TerminalTheme(xterm)
+    colors.current = palette
+    const cursor = observeTerminalCursor(xterm, node, () => palette.cursor)
     xterm.textarea?.setAttribute('aria-label', label)
     terminal.current = xterm
     fit.current = addon
@@ -84,6 +90,8 @@ function TerminalScreen({ state, model, visible, label, theme }: {
     return () => {
       observer.disconnect()
       input.dispose()
+      cursor.dispose()
+      palette.dispose()
       xterm.dispose()
       terminal.current = undefined
       fit.current = undefined
@@ -92,12 +100,7 @@ function TerminalScreen({ state, model, visible, label, theme }: {
 
   useLayoutEffect(() => {
     const style = getComputedStyle(element.current!)
-    terminal.current!.options.theme = {
-      background: style.backgroundColor, foreground: style.color,
-      cursor: style.color, cursorAccent: style.backgroundColor,
-      selectionBackground: style.color, selectionForeground: style.backgroundColor,
-      selectionInactiveBackground: style.color,
-    }
+    colors.current!.update(style.backgroundColor, style.color)
   }, [theme, model])
 
   useLayoutEffect(() => {

+ 61 - 0
packages/client/ui-sidebar-terminal/src/client/terminal-cursor.ts

@@ -0,0 +1,61 @@
+/** Contrast-aware styling for xterm's DOM cursor, independent of its ANSI palette. */
+import type { IDisposable, Terminal } from '@xterm/xterm'
+
+/* oxlint-disable typescript/no-non-null-assertion -- The mounted xterm DOM supplies elements and sRGB channels. */
+
+/**
+ * Keep the cursor visible on default, indexed, true-color and inverse cell backgrounds.
+ * @param terminal - opened DOM-rendered emulator.
+ * @param node - screen root containing the emulator and scoped cursor CSS variables.
+ * @param preferredCursor - current DSH or application cursor color.
+ * @returns listener disposer; no terminal input or palette changes are emitted.
+ */
+export function observeTerminalCursor(terminal: Terminal, node: HTMLElement, preferredCursor: () => string): IDisposable {
+  return terminal.onRender(() => {
+    const cursor = node.querySelector<HTMLElement>('.xterm-cursor')
+    if (cursor === null) return
+    // Removing only the cursor marker exposes the cell's rendered colors, including reverse video.
+    cursor.classList.remove('xterm-cursor')
+    let background: string
+    let foreground: string
+    try {
+      const style = getComputedStyle(cursor)
+      foreground = style.color
+      background = opaqueColor(style.backgroundColor, () => getComputedStyle(node.querySelector('.xterm-scrollable-element')!).backgroundColor)
+    } finally { cursor.classList.add('xterm-cursor') }
+    const preferred = opaqueColor(preferredCursor(), () => background)
+    const backgroundLuminance = luminance(background)
+    const preferredLuminance = luminance(preferred)
+    // WCAG contrast is 3:1 for the cursor and 4.5:1 for the character inside it.
+    const contrast = (Math.max(backgroundLuminance, preferredLuminance) + 0.05) / (Math.min(backgroundLuminance, preferredLuminance) + 0.05)
+    const fill = contrast >= 3 ? preferred : backgroundLuminance > 0.179 ? '#000000' : '#ffffff'
+    node.style.setProperty('--terminal-cursor', fill)
+    node.style.setProperty('--terminal-cursor-accent', luminance(fill) > 0.179 ? '#000000' : '#ffffff')
+    node.style.setProperty('--terminal-cursor-cell-background', background)
+    node.style.setProperty('--terminal-cursor-cell-foreground', foreground)
+  })
+}
+
+// xterm emits sRGB colors; OSC values are normalized to six-digit hex by TerminalTheme.
+function luminance(color: string): number {
+  const [r, g, b] = colorChannels(color).map((channel) => {
+    const value = channel / 255
+    return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4
+  })
+  return 0.2126 * r! + 0.7152 * g! + 0.0722 * b!
+}
+
+function colorChannels(color: string): number[] {
+  return color.startsWith('#')
+    ? [color.slice(1, 3), color.slice(3, 5), color.slice(5, 7)].map(value => Number.parseInt(value, 16))
+    : color.match(/[\d.]+/gu)!.map(Number)
+}
+
+function opaqueColor(color: string, background: () => string): string {
+  const channels = colorChannels(color)
+  const alpha = channels[3] ?? 1
+  if (alpha === 1) return color
+  const surface = colorChannels(background())
+  const rgb = channels.slice(0, 3).map((channel, index) => Math.round(channel * alpha + surface[index]! * (1 - alpha)))
+  return `rgb(${rgb.join(', ')})`
+}

+ 104 - 0
packages/client/ui-sidebar-terminal/src/client/terminal-theme.ts

@@ -0,0 +1,104 @@
+/** DSH defaults and application OSC colors for one xterm screen. */
+import type { IDisposable, ITheme, Terminal } from '@xterm/xterm'
+
+/* oxlint-disable typescript/no-non-null-assertion -- OSC widths/indices are checked; DSH defaults precede cursor reads. */
+const ansiKeys = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white', 'brightBlack', 'brightRed', 'brightGreen', 'brightYellow', 'brightBlue', 'brightMagenta', 'brightCyan', 'brightWhite'] as const
+const specialKeys = ['foreground', 'background', 'cursor'] as const
+
+/** Keep program color overrides separate from the DSH defaults used by OSC resets. */
+export class TerminalTheme implements IDisposable {
+  private defaults: ITheme = {}
+  private readonly indexed = new Map<number, string>()
+  private readonly special = new Map<(typeof specialKeys)[number], string>()
+  private readonly subscriptions: IDisposable[]
+
+  /**
+   * Observe complete OSC commands without consuming xterm's queries or color handling.
+   * @param terminal - opened emulator owned by the mounted screen.
+   */
+  constructor(private readonly terminal: Terminal) {
+    const observe = (code: number, change: (data: string) => void): IDisposable =>
+      terminal.parser.registerOscHandler(code, (data) => { change(data); return false })
+    this.subscriptions = [
+      observe(4, (data) => {
+        const parts = data.split(';')
+        for (let i = 0; i + 1 < parts.length; i += 2) {
+          const index = colorIndex(parts[i]!)
+          const color = oscColor(parts[i + 1]!)
+          if (index !== undefined && color !== undefined) this.indexed.set(index, color)
+        }
+      }),
+      observe(104, (data) => {
+        if (data === '') this.indexed.clear()
+        else for (const part of data.split(';')) {
+          const index = colorIndex(part)
+          if (index !== undefined) this.indexed.delete(index)
+        }
+        this.apply()
+      }),
+      ...specialKeys.flatMap((key, offset) => [
+        observe(10 + offset, (data) => {
+          for (const [i, part] of data.split(';').entries()) {
+            const target = specialKeys[offset + i]
+            const color = oscColor(part)
+            if (target !== undefined && color !== undefined) this.special.set(target, color)
+          }
+        }),
+        observe(110 + offset, () => { this.special.delete(key); this.apply() }),
+      ]),
+    ]
+  }
+
+  /**
+   * Apply changed DSH colors without replacing application-defined palette entries.
+   * @param background - resolved opaque DSH background.
+   * @param foreground - resolved DSH text color.
+   */
+  update(background: string, foreground: string): void {
+    if (this.defaults.background === background && this.defaults.foreground === foreground) return
+    this.defaults = {
+      background, foreground, cursor: foreground, cursorAccent: background,
+      selectionBackground: foreground, selectionForeground: background, selectionInactiveBackground: foreground,
+    }
+    this.apply()
+  }
+
+  /** Preferred cursor color after the initial DSH update, including OSC 12 overrides. */
+  get cursor(): string { return this.special.get('cursor') ?? this.defaults.cursor! }
+
+  /** Remove parser observers before the emulator is disposed. */
+  dispose(): void { for (const subscription of this.subscriptions) subscription.dispose() }
+
+  private apply(): void {
+    const theme: ITheme = { ...this.defaults, ...Object.fromEntries(this.special) }
+    for (const [index, color] of this.indexed) {
+      if (index < ansiKeys.length) theme[ansiKeys[index]!] = color
+      else (theme.extendedAnsi ??= [])[index - ansiKeys.length] = color
+    }
+    this.terminal.options.theme = theme
+  }
+}
+
+function colorIndex(value: string): number | undefined {
+  return /^\d+$/u.test(value) && Number(value) < 256 ? Number(value) : undefined
+}
+
+// XParseColor hashes truncate channels; rgb: channels scale. CSS #rgb has different semantics.
+function oscColor(value: string): string | undefined {
+  const rgb = /^rgb:([\da-f]{1,4})\/([\da-f]{1,4})\/([\da-f]{1,4})$/iu.exec(value)
+  let channels: string[]
+  if (rgb !== null) {
+    channels = rgb.slice(1)
+    if (!channels.every(channel => channel.length === channels[0]!.length)) return
+  } else {
+    if (!/^#(?:[\da-f]{3}){1,4}$/iu.test(value)) return
+    const width = (value.length - 1) / 3
+    channels = [value.slice(1, width + 1), value.slice(width + 1, 2 * width + 1), value.slice(2 * width + 1)]
+  }
+  return `#${channels.map((channel) => {
+    const n = rgb === null
+      ? Number.parseInt(channel.padEnd(2, '0').slice(0, 2), 16)
+      : Math.round(Number.parseInt(channel, 16) * 255 / (16 ** channel.length - 1))
+    return n.toString(16).padStart(2, '0')
+  }).join('')}`
+}

+ 39 - 0
packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx

@@ -21,6 +21,13 @@ class FakeTerminal {
   textarea: HTMLTextAreaElement | undefined = document.createElement('textarea')
   input: ((data: string) => void) | undefined
   readonly disposeInput = vi.fn()
+  readonly parser = { registerOscHandler: vi.fn(() => ({ dispose: vi.fn() })) }
+  renderFrame: (() => void) | undefined
+  readonly disposeRender = vi.fn(() => { this.renderFrame = undefined })
+  readonly onRender = vi.fn((listener: () => void) => {
+    this.renderFrame = listener
+    return { dispose: this.disposeRender }
+  })
   readonly resize = vi.fn()
   readonly reset = vi.fn()
   readonly focus = vi.fn()
@@ -184,6 +191,9 @@ it('updates screen, cursor and selection colors without replacing the terminal o
   expect(terminal.options.theme).toMatchObject({
     background: colors.backgroundColor, foreground: colors.color, cursor: colors.color, selectionForeground: colors.backgroundColor,
   })
+  const appliedTheme = terminal.options.theme
+  h.changeTheme()
+  expect(terminal.options.theme).toBe(appliedTheme)
   colors.backgroundColor = 'rgb(23, 25, 29)'
   colors.color = 'rgb(231, 233, 238)'
   h.changeTheme()
@@ -198,6 +208,35 @@ it('updates screen, cursor and selection colors without replacing the terminal o
   expect(h.detach).not.toHaveBeenCalled()
 })
 
+it('reads the current theme on xterm render and releases the cursor listener on unmount', () => {
+  const colors = { backgroundColor: 'rgb(255, 255, 255)', color: 'rgb(23, 25, 29)' }
+  const cursor = document.createElement('span')
+  cursor.className = 'xterm-cursor'
+  cursor.style.backgroundColor = 'black'
+  cursor.style.color = 'cyan'
+  const readStyle = window.getComputedStyle.bind(window)
+  vi.spyOn(window, 'getComputedStyle').mockImplementation(element => element === cursor ? readStyle(element) : colors as CSSStyleDeclaration)
+  const h = mount({ ...idle, info, phase: 'connected', writable: true })
+  const terminal = fake.terminals[0]!
+  const screen = terminal.textarea!.parentElement!
+  screen.append(cursor)
+  const appliedTheme = terminal.options.theme
+  terminal.renderFrame?.()
+  expect(screen.style.getPropertyValue('--terminal-cursor')).toBe('#ffffff')
+  expect(screen.style.getPropertyValue('--terminal-cursor-accent')).toBe('#000000')
+  expect(terminal.options.theme).toBe(appliedTheme)
+
+  colors.backgroundColor = 'rgb(23, 25, 29)'
+  colors.color = 'rgb(231, 233, 238)'
+  h.changeTheme()
+  terminal.renderFrame?.()
+  expect(screen.style.getPropertyValue('--terminal-cursor')).toBe(colors.color)
+  expect(fake.terminals).toEqual([terminal])
+  h.view.unmount()
+  expect(terminal.disposeRender).toHaveBeenCalledOnce()
+  expect(terminal.renderFrame).toBeUndefined()
+})
+
 it('fits only visible writable terminals with measurable dimensions and clamps the provider limits', () => {
   const state: TerminalViewState = { ...idle, info, phase: 'connected', writable: true }
   const h = mount(state)

+ 51 - 0
packages/client/ui-sidebar-terminal/tests/terminal-cursor.client.spec.ts

@@ -0,0 +1,51 @@
+// @vitest-environment jsdom
+/** DOM cursor contrast without writing escape sequences or replacing the palette. */
+import { expect, it, vi } from 'vitest'
+import type { Terminal } from '@xterm/xterm'
+import { observeTerminalCursor } from '../src/client/terminal-cursor.ts'
+
+it('tracks actual cell backgrounds and restores the cursor marker after measuring', () => {
+  let update: () => void = () => {}
+  const dispose = vi.fn()
+  const terminal = { onRender: (listener: () => void) => { update = listener; return { dispose } } } as unknown as Terminal
+  const node = document.createElement('div')
+  node.innerHTML = '<div class="xterm-scrollable-element" style="background: white"><span class="xterm-cursor" style="color: cyan"></span></div>'
+  document.body.append(node)
+  try {
+    const cursor = node.querySelector<HTMLElement>('span')!
+    let preferred = 'rgb(0, 0, 0)'
+    const listener = observeTerminalCursor(terminal, node, () => preferred)
+    update()
+    expect(node.style.getPropertyValue('--terminal-cursor')).toBe('rgb(0, 0, 0)')
+    expect(node.style.getPropertyValue('--terminal-cursor-accent')).toBe('#ffffff')
+    cursor.style.backgroundColor = '#000000'
+    update()
+    expect(node.style.getPropertyValue('--terminal-cursor')).toBe('#ffffff')
+    expect(node.style.getPropertyValue('--terminal-cursor-accent')).toBe('#000000')
+    expect(node.style.getPropertyValue('--terminal-cursor-cell-background')).toBe('rgb(0, 0, 0)')
+    expect(node.style.getPropertyValue('--terminal-cursor-cell-foreground')).toBe('rgb(0, 255, 255)')
+    preferred = 'rgba(255, 255, 255, 0)'
+    update()
+    expect(node.style.getPropertyValue('--terminal-cursor')).toBe('#ffffff')
+    preferred = '#00ff00'
+    update()
+    expect(node.style.getPropertyValue('--terminal-cursor')).toBe('#00ff00')
+    preferred = '#ffffff'
+    cursor.style.backgroundColor = '#ffffff'
+    update()
+    expect(node.style.getPropertyValue('--terminal-cursor')).toBe('#000000')
+    cursor.style.backgroundColor = 'rgba(0, 153, 0, 0.5)'
+    update()
+    expect(node.style.getPropertyValue('--terminal-cursor')).toBe('#000000')
+    expect(node.style.getPropertyValue('--terminal-cursor-cell-background')).toBe('rgb(128, 204, 128)')
+    preferred = '#000000'
+    cursor.style.backgroundColor = '#888888'
+    update()
+    expect(node.style.getPropertyValue('--terminal-cursor')).toBe('#000000')
+    expect(cursor.classList.contains('xterm-cursor')).toBe(true)
+    cursor.remove()
+    update()
+    listener.dispose()
+    expect(dispose).toHaveBeenCalledOnce()
+  } finally { node.remove() }
+})

+ 85 - 0
packages/client/ui-sidebar-terminal/tests/terminal-theme.client.spec.ts

@@ -0,0 +1,85 @@
+/** Program palette retention and XParseColor reset semantics across application themes. */
+import { expect, it, vi } from 'vitest'
+import type { Terminal } from '@xterm/xterm'
+import { TerminalTheme } from '../src/client/terminal-theme.ts'
+
+function harness() {
+  const handlers = new Map<number, (data: string) => boolean>()
+  const dispose = vi.fn()
+  const options: Terminal['options'] = {}
+  const terminal = {
+    options,
+    parser: { registerOscHandler: (code: number, handler: (data: string) => boolean) => {
+      handlers.set(code, handler)
+      return { dispose }
+    } },
+  } as unknown as Terminal
+  const theme = new TerminalTheme(terminal)
+  const send = (code: number, data = '') => { expect(handlers.get(code)!(data)).toBe(false) }
+  theme.update('#ffffff', '#0f1115')
+  return { theme, options, send, dispose }
+}
+
+it('preserves indexed, extended and default application colors while updating DSH selection colors', () => {
+  const h = harness()
+  h.send(4, '1;#00ff00;16;rgb:f/0/0;255;#123456')
+  h.send(10, '#abcdef;#123456;#aa0000;#999999')
+  expect(h.theme.cursor).toBe('#aa0000')
+  h.theme.update('#151517', '#f9fafb')
+  expect(h.options.theme).toMatchObject({
+    red: '#00ff00', foreground: '#abcdef', background: '#123456', cursor: '#aa0000',
+    selectionBackground: '#f9fafb', selectionForeground: '#151517',
+  })
+  expect(h.options.theme?.extendedAnsi?.[0]).toBe('#ff0000')
+  expect(h.options.theme?.extendedAnsi?.[239]).toBe('#123456')
+  const previous = h.options.theme
+  h.theme.update('#151517', '#f9fafb')
+  expect(h.options.theme).toBe(previous)
+  h.theme.dispose()
+  expect(h.dispose).toHaveBeenCalledTimes(8)
+})
+
+it('restores selected entries, all indexed colors and special colors to the current DSH defaults', () => {
+  const h = harness()
+  h.send(4, '1;#00ff00;2;#ff0000;255;#fedcba')
+  h.send(11, '#111111;#222222')
+  h.send(10, '#333333')
+  h.theme.update('#151517', '#f9fafb')
+  h.send(104, '1;255;bad;-1;256')
+  expect(h.options.theme).not.toHaveProperty('red')
+  expect(h.options.theme).not.toHaveProperty('extendedAnsi')
+  expect(h.options.theme?.green).toBe('#ff0000')
+  h.send(104)
+  expect(h.options.theme).not.toHaveProperty('green')
+  for (const code of [110, 111, 112]) h.send(code)
+  expect(h.theme.cursor).toBe('#f9fafb')
+  expect(h.options.theme).toMatchObject({ background: '#151517', foreground: '#f9fafb', cursor: '#f9fafb' })
+})
+
+it.each([
+  ['#f00', '#f00000'], ['#123456', '#123456'], ['#123456789', '#124578'], ['#123456789abc', '#12569a'],
+  ['rgb:f/0/0', '#ff0000'], ['RGB:FF/00/00', '#ff0000'], ['rgb:abc/def/123', '#abde12'],
+  ['rgb:ffff/0000/8080', '#ff0080'],
+])('retains the xterm interpretation of %s', (source, expected) => {
+  const h = harness()
+  h.send(12, source)
+  h.theme.update('#000000', '#ffffff')
+  expect(h.options.theme?.cursor).toBe(expected)
+})
+
+it.each(['?', 'red', '#12', '#zzzzzz', '#12345g', 'rgb:f/00/000', 'rgb:fffff/00000/00000', '', 'rgba:ff/00/00'])('leaves unsupported or query color %s to xterm', (color) => {
+  const h = harness()
+  h.send(4, `1;#00ff00;1;${color};dangling`)
+  h.send(10, color)
+  h.theme.update('#000000', '#ffffff')
+  expect(h.options.theme?.red).toBe('#00ff00')
+  expect(h.options.theme?.foreground).toBe('#ffffff')
+})
+
+it('ignores invalid palette indices without losing valid pairs in the same command', () => {
+  const h = harness()
+  h.send(4, '-1;#ffffff;256;#ffffff;x;#ffffff;1.5;#ffffff;;#ffffff;01;#010203')
+  h.theme.update('#000000', '#ffffff')
+  expect(h.options.theme?.red).toBe('#010203')
+  expect(Object.keys(h.options.theme!)).toHaveLength(8)
+})