Jelajahi Sumber

feat(web): follow application theme in sidebar terminals

Yichen Jiang 6 hari lalu
induk
melakukan
1daed09185

+ 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: ce1162a84d3f96336cbb217dbdfe62d12ac23280
-2026-09-09-web-sidebar-terminal.zh.md: 51e0f374b5fde389323868e56fbd86d3bc5720b3
+2026-09-09-web-sidebar-terminal.md: db7f42e9f53940262d806ff1133c1059e3f74829
+2026-09-09-web-sidebar-terminal.zh.md: cbb6bc6bab5e3ed4927399b90363332f1624825b

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

@@ -10,6 +10,8 @@ Web users need an interactive shell beside a Session to inspect the workspace an
 
 ## Decision
 
+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.
+
 `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. A new terminal offers installed shells and waits for Start. 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.
 
 Close and replacement remove the tab synchronously and run process cleanup in the background. The Client first records the unfinished close request under a terminal-specific localStorage key; success removes it, and startup retries requests that remain. A cleanup failure produces a lightweight notification with a retry action without reopening the tab. Independent keys prevent another window from overwriting unrelated cleanup requests. Collapse, tab/Session switching, floating, fullscreen and browser disconnection preserve the process. Component cleanup and `TabDomain.signal` only detach browser work because the same lifetime can end during plugin reload. Failed process cleanup retains ownership, including failures after allocation but before create publication. Session owner disposal and Host plugin disposal also clean up terminals. A definitive missing-Session response retires its saved close request because the Session owns process cleanup; transport failures remain retryable. Client plugin disposal awaits every detached stream so a replacement plugin does not inherit unfinished Client cleanup.

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

@@ -10,6 +10,8 @@ Web 用户需要在 Session 旁使用交互式 shell 检查工作区和运行命
 
 ## 决定
 
+应用主题负责终端背景、默认文字、光标和选区颜色。终端正文在收到框架传递的主题变化后读取 DSH 渲染配色,使 CSS token 覆盖继续生效。更新现有 xterm 实例会保留输出和 PTY 连接;shell 输出的 ANSI 颜色独立生效。
+
 `api-terminal-controller` 按 Session 管理用户终端并提供 `terminal` Remote namespace。`ui-sidebar-terminal` 注册原生右侧栏标签页,使用 xterm.js 渲染和 FitAddon 测量尺寸。新终端提供已安装 shell 的选择并等待用户启动。Host 探测会验证配置的候选,并把执行环境默认项放在首位;创建只接受当前探测返回的路径。浏览器在当前站点 localStorage 中记住上次选择的 shell 路径,该路径不可用时回到当前默认项。终端类型声明独立实例,因此打开或停靠标签页时,普通页面的去重规则不会合并不同进程。已有侧栏控件负责打开更多标签页,双击标签页标题可重命名终端。终端进程使用组合的 subprocess provider 和 Session sandbox policy。shell 在探测和创建时解析;读取限制和重新连接已有进程不依赖默认可执行文件仍然可用。交互式 shell 配置提供 Tab 补全和可选的内联建议。
 
 关闭和替换会同步移除标签页,并在后台清理进程。Client 先以终端独立的 localStorage key 保存未完成的关闭请求;成功后删除,启动时重试剩余请求。清理失败时显示带重试操作的轻量通知,不重新打开标签页。独立 key 避免其他窗口覆盖无关的清理请求。折叠、切换标签页或 Session、浮动、全屏和浏览器断线均保留进程。组件清理和 `TabDomain.signal` 只停止浏览器工作,因为插件重新加载也会结束这些生命周期。进程清理失败时保留所有权,包括分配完成但 create 尚未发布时的失败。Session owner 和 Host 插件卸载也会清理终端。 明确的 Session 不存在响应会清除已保存的关闭请求,因为进程清理由 Session 负责;传输失败仍可重试。Client 插件卸载等待所有断开的流结束,避免替换插件继承未完成的 Client 清理。

+ 14 - 0
apps/web/tests/expected/sidebar-terminal/theme.expected.md

@@ -0,0 +1,14 @@
+{
+  "light": {
+    "surface": "rgb(255, 255, 255)",
+    "viewport": "rgb(255, 255, 255)",
+    "underlay": "rgb(255, 255, 255)",
+    "foreground": "rgb(15, 17, 21)"
+  },
+  "dark": {
+    "surface": "rgb(21, 21, 23)",
+    "viewport": "rgb(21, 21, 23)",
+    "underlay": "rgb(21, 21, 23)",
+    "foreground": "rgb(249, 250, 251)"
+  }
+}

+ 57 - 0
apps/web/tests/sidebar-terminal.e2e.ts

@@ -81,6 +81,63 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => {
     }
   })
 
+  it('follows light, dark and system themes while preserving the running shell and its output', async () => {
+    await page.emulateMedia({ colorScheme: 'light' })
+    await openTerminal(page)
+    const process = processIdentity(0)
+    const terminal = page.locator('[data-sidebar-terminal]')
+    const screen = page.locator('.xterm-rows:visible')
+    await command(page, "DSH_THEME_PROBE=retained; PS1=''; printf '\\033cTHEME_CONTENT_RETAINED\\n'")
+    await expect.poll(() => screen.innerText()).toContain('THEME_CONTENT_RETAINED')
+    const readColors = () => terminal.evaluate((root) => {
+      const xterm = root.querySelector('.xterm')!
+      const rows = root.querySelector('.xterm-rows')!
+      return {
+        surface: getComputedStyle(xterm.parentElement!).backgroundColor,
+        viewport: getComputedStyle(root.querySelector('.xterm-scrollable-element')!).backgroundColor,
+        underlay: getComputedStyle(root.querySelector('.xterm-viewport')!).backgroundColor,
+        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 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 expect.poll(readColors).toEqual(light)
+    await selectTheme('System')
+    await page.emulateMedia({ colorScheme: 'dark' })
+    await expect.poll(readColors).toEqual(dark)
+    await page.emulateMedia({ colorScheme: 'light' })
+    await expect.poll(readColors).toEqual(light)
+    await expect.poll(() => screen.innerText()).toContain('THEME_CONTENT_RETAINED')
+    await command(page, 'printf "THEME_STATE:%s\\n" "$DSH_THEME_PROBE"')
+    await expect.poll(() => screen.innerText()).toContain('THEME_STATE:retained')
+    expect(handles).toHaveLength(1)
+    expect(alive(process)).toBe(true)
+    await compareOrRefreshGolden(fileURLToPath(new URL('./expected/sidebar-terminal/theme.expected.md', import.meta.url)),
+      JSON.stringify({ light, dark }, null, 2), webSnapshotMode())
+    expect(tripwire.pageErrors).toEqual([])
+  })
+
   it('completes commands, preserves the process through collapse and reload, resizes, and kills on tab close', async () => {
     onTestFailed(() => saveFailureShot(page, 'sidebar-terminal'))
     await openTerminal(page)

+ 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: dec4c0a88649b74e90afa23ef07d3f11a9dc67cb
-README.zh.md: a10cde602ad4edfd13652a93535c4f5d7fd26b52
+README.md: 0a3809af01caec728536aafa06333a2f254feae1
+README.zh.md: 954febb206ad9f4344f57365b0e091df702ebbe8

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

@@ -33,6 +33,8 @@ 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.
+
 <a id="understand-the-implementation"></a>
 ## Understand the implementation
 

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

@@ -33,6 +33,8 @@ kind: "package-reference"
 
 刷新页面后显示某个 Session,会把 Host 保留的终端重新打开为新标签页。恢复失败时可点击**重试恢复终端**。恢复目标进程消失时显示错误,不启动另一个 shell。[侧栏布局仅保存在内存中](../../client/ui-sidebar-right/README.zh.md#state)。
 
+终端背景、默认文字、光标和选区跟随 DSH 主题,包括系统偏好和主题 token 覆盖。切换主题会更新现有屏幕,不清空输出或重启 shell。shell 输出的 ANSI 颜色仍然生效。
+
 <a id="understand-the-implementation"></a>
 ## 了解实现
 

+ 4 - 2
packages/client/ui-sidebar-terminal/package.json

@@ -30,7 +30,8 @@
       "inject": [
         "@deepseek-ai/dsh-api-terminal-controller",
         "@deepseek-ai/dsh-client-ui-sidebar-right",
-        "@deepseek-ai/dsh-api-remotes"
+        "@deepseek-ai/dsh-api-remotes",
+        "@deepseek-ai/dsh-client-ui-theme"
       ],
       "platform": "web"
     }
@@ -66,7 +67,8 @@
     "@xterm/xterm": "^6.0.0",
     "@xterm/addon-fit": "^0.11.0",
     "@deepseek-ai/dsh-client-ui-layout": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-conversation": "workspace:^"
+    "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-theme": "workspace:^"
   },
   "files": [
     "lib/index.js",

+ 2 - 1
packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css

@@ -1,8 +1,9 @@
 .root { display: flex; flex: 1; flex-direction: column; box-sizing: border-box; height: 100%; min-height: 0; padding-top: 8px; color: var(--dsw-alias-label-primary); font-size: 13px; }
 .title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
 .name { width: 120px; min-width: 48px; max-width: 100%; padding: 0 4px; color: inherit; font: inherit; background: var(--dsw-alias-bg-l1); border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 4px; outline: none; }
-.screen { flex: 1; min-height: 0; min-width: 0; overflow: hidden; padding: 8px; background: #17191d; }
+.screen { flex: 1; min-height: 0; min-width: 0; overflow: hidden; padding: 8px; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-base); }
 .screen :global(.xterm) { height: 100%; }
+.screen :global(.xterm-viewport) { background: var(--dsw-alias-bg-base); }
 .status { display: flex; align-items: center; gap: 8px; padding: 5px 12px; background: var(--dsw-alias-bg-l2); }
 .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); }

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

@@ -5,21 +5,23 @@ import { Terminal } from '@xterm/xterm'
 import { FitAddon } from '@xterm/addon-fit'
 import type { TerminalViewState, TerminalView } from '@deepseek-ai/dsh-api-terminal-controller/client'
 import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
-import type { TerminalInjected } from './face.ts'
+import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
+import type { TerminalBodyInjected } from './face.ts'
 import type {} from './locales.ts'
 import '@xterm/xterm/css/xterm.css'
 import css from './TerminalBody.module.css'
 
 /** Standard sidebar owner share plus terminal model and localized copy. */
-export type TerminalBodyProps = PropsRuntime<'sidebar.right.pane.tab'> & PropsLocale<'sidebarTerminal'> & InjectFace<TerminalInjected>
+export type TerminalBodyProps = PropsRuntime<'sidebar.right.pane.tab'> & PropsLocale<'sidebarTerminal'> & InjectFace<TerminalBodyInjected>
 
 /**
- * Offer shell selection for a new terminal or mount a restored screen.
+ * Offer shell selection or render the retained terminal with the application theme.
  * @param props - sidebar occurrence, model lookup and translated copy.
  * @returns the terminal screen and any pending or exceptional state.
  */
-export function TerminalBody({ useTabInfo, useTerminal, view, t }: TerminalBodyProps): ReactNode {
+export function TerminalBody({ useTabInfo, useTerminal, useTheme, view, t }: TerminalBodyProps): ReactNode {
   const { tab } = useTabInfo()
+  const theme = useTheme(value => value)
   const model = view(tab.id)
   const state = useTerminal(tab.id)
   useEffect(() => model.mount(), [model])
@@ -43,7 +45,7 @@ export function TerminalBody({ useTabInfo, useTerminal, view, t }: TerminalBodyP
           ? <button type="button" onClick={() => { void model.refresh() }}>{t('retry')}</button>
           : <button type="button" onClick={() => { model.connect() }}>{t('reconnect')}</button>)}
       </div>}
-      {state.info !== undefined && <TerminalScreen state={state} model={model} visible={tab.visible} label={t('title')} />}
+      {state.info !== undefined && <TerminalScreen state={state} model={model} visible={tab.visible} label={t('title')} theme={theme} />}
       {error !== undefined && <p className={css.error} role="alert">{t('failed', { message: error })}</p>}
     </section>
   )
@@ -81,11 +83,12 @@ function TerminalLauncher({ state, model, t }: {
 }
 
 /* oxlint-disable typescript/no-non-null-assertion -- React sets the DOM ref, then these effects initialize and use the emulator. */
-function TerminalScreen({ state, model, visible, label }: {
+function TerminalScreen({ state, model, visible, label, theme }: {
   state: TerminalViewState
   model: TerminalView
   visible: boolean
   label: string
+  theme: ThemeSnapshot
 }): ReactNode {
   const element = useRef<HTMLDivElement>(null)
   const terminal = useRef<Terminal>()
@@ -96,7 +99,7 @@ function TerminalScreen({ state, model, visible, label }: {
 
   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, theme: { background: '#17191d', foreground: '#e7e9ee' } })
+    const xterm = new Terminal({ 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)
@@ -120,6 +123,16 @@ function TerminalScreen({ state, model, visible, label }: {
     }
   }, [model])
 
+  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,
+    }
+  }, [theme, model])
+
   useLayoutEffect(() => {
     const xterm = terminal.current!
     const render = state.render

+ 6 - 0
packages/client/ui-sidebar-terminal/src/client/face.ts

@@ -2,6 +2,7 @@
 import type { WebTerminalId } from '@deepseek-ai/dsh-api-terminal-controller/types'
 import type { TerminalView, TerminalViewState } from '@deepseek-ai/dsh-api-terminal-controller/client'
 import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
+import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
 
 /** The terminal's React-free model is resolved by sidebar occurrence. */
 export interface TerminalInjected {
@@ -11,6 +12,11 @@ export interface TerminalInjected {
 }
 
 
+/** The terminal screen follows the resolved application theme through a framework hook. */
+export interface TerminalBodyInjected extends TerminalInjected {
+  readonly hooks: { readonly theme: HostObservable<ThemeSnapshot> }
+}
+
 declare module '@deepseek-ai/dsh-client-ui-sidebar-right/client' {
   interface SidebarRightTabParamsMap {
     /** An existing Host terminal selected from the Session terminal list. */

+ 10 - 3
packages/client/ui-sidebar-terminal/src/client/index.ts

@@ -8,16 +8,17 @@ import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
 import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
 import type {} from '@deepseek-ai/dsh-client-locale/client'
 import type {} from '@deepseek-ai/dsh-client-ui-session/client'
+import type {} from '@deepseek-ai/dsh-client-ui-theme/client'
 import { TerminalGuideIcon } from './TerminalIcon.tsx'
 import { TerminalBody } from './TerminalBody.tsx'
 import { TerminalTitle } from './TerminalTitle.tsx'
 import { TerminalRecovery, type TerminalRecoveryInjected } from './TerminalRecovery.tsx'
 import { TerminalCleanup, type TerminalCleanupInjected } from './TerminalCleanup.tsx'
-import type { TerminalInjected } from './face.ts'
+import type { TerminalBodyInjected, TerminalInjected } from './face.ts'
 import { en, zh } from './locales.ts'
 
 /** Services needed by the terminal's two sidebar seats. */
-export const inject = ['slots', 'locale', 'sidebarRight', 'sidebarRightTabs', 'webTerminals']
+export const inject = ['slots', 'locale', 'sidebarRight', 'sidebarRightTabs', 'webTerminals', 'theme']
 
 /**
  * Register the terminal type, observable views and background process cleanup.
@@ -47,8 +48,14 @@ export function apply(ctx: Context): void {
     view: key => view(sessionId, key),
     keyedHooks: { terminal: key => view(sessionId, key).state },
   })
+  const theme: TerminalBodyInjected['hooks']['theme'] = {
+    getSnapshot: () => ctx.theme.getTheme(),
+    subscribe: listener => ctx.on('theme/change', listener),
+  }
   ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register(
-    { name: 'sidebar.right.pane.tab', key: id, locale: namespace, inject }, TerminalBody,
+    { name: 'sidebar.right.pane.tab', key: id, locale: namespace,
+      inject: (sessionId): TerminalBodyInjected => ({ ...inject(sessionId), hooks: { theme } }),
+    }, TerminalBody,
   )), 'ui-sidebar-terminal.body')
   ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register(
     { name: 'sidebar.right.pane.tab.title', key: id, locale: namespace, inject }, TerminalTitle,

+ 14 - 3
packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts

@@ -14,7 +14,7 @@ import { TerminalBody } from '../src/client/TerminalBody.tsx'
 import { TerminalTitle } from '../src/client/TerminalTitle.tsx'
 import { TerminalRecovery, type TerminalRecoveryInjected } from '../src/client/TerminalRecovery.tsx'
 import { TerminalCleanup, type TerminalCleanupInjected } from '../src/client/TerminalCleanup.tsx'
-import type { TerminalInjected } from '../src/client/face.ts'
+import type { TerminalBodyInjected } from '../src/client/face.ts'
 import { en, zh } from '../src/client/locales.ts'
 
 vi.mock('@xterm/xterm', () => ({ Terminal: vi.fn() }))
@@ -56,9 +56,12 @@ async function mountPlugin() {
     bind: () => (key: string) => key,
     register: (name: string, values: unknown) => { dictionaries.set(name, values); return () => { dictionaries.delete(name) } },
   } as never)
+  const theme = { preference: 'light' as const, fontSize: 14, active: { id: 'light', colorScheme: 'light' as const, tokens: {} }, themes: [], revision: 0 }
+  ctx.provide('theme', { getTheme: () => theme } as never)
   const fiber = await ctx.plugin({ inject, apply })
   return {
-    tabs, entries, dictionaries, terminals, model, occurrence, openTabIn,
+    tabs, entries, dictionaries, terminals, model, occurrence, openTabIn, theme,
+    emitTheme() { ctx.emit('theme/change', theme) },
     get closeHandler() { return closeHandler },
     setParams(next: typeof params) { params = next },
     async dispose() { await fiber.dispose(); await ctx.fiber.dispose() },
@@ -85,7 +88,15 @@ it('registers terminal views, recovery and cleanup, then releases every contribu
       ['shell.overlay', TerminalCleanup, 'sidebarTerminal'],
     ])
     const sessionId = 'session' as SessionId
-    const face = h.entries[0]!.inject(sessionId) as TerminalInjected
+    const face = h.entries[0]!.inject(sessionId) as TerminalBodyInjected
+    expect(face.hooks.theme.getSnapshot()).toBe(h.theme)
+    const changed = vi.fn()
+    const unsubscribe = face.hooks.theme.subscribe(changed)
+    h.emitTheme()
+    expect(changed).toHaveBeenCalledOnce()
+    unsubscribe()
+    h.emitTheme()
+    expect(changed).toHaveBeenCalledOnce()
     expect(face.view('tab')).toBe(h.model)
     expect(h.terminals.view).toHaveBeenLastCalledWith(sessionId, 'tab', undefined)
     const terminalId = 'retained' as WebTerminalId

+ 28 - 1
packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx

@@ -3,6 +3,8 @@
 import { afterEach, beforeEach, expect, it, vi } from 'vitest'
 import { cleanup, fireEvent, render } from '@testing-library/react'
 import type { ReactNode } from 'react'
+import type { ITheme } from '@xterm/xterm'
+import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
 import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import type { TerminalViewState } from '@deepseek-ai/dsh-api-terminal-controller/client'
 import type { WebTerminalId } from '@deepseek-ai/dsh-api-terminal-controller/types'
@@ -15,7 +17,7 @@ const fake = vi.hoisted(() => ({
   dimensions: { cols: 120, rows: 40 } as { cols: number; rows: number } | undefined,
 }))
 class FakeTerminal {
-  options: { disableStdin?: boolean }
+  options: { disableStdin?: boolean; theme?: ITheme }
   textarea: HTMLTextAreaElement | undefined = document.createElement('textarea')
   input: ((data: string) => void) | undefined
   readonly disposeInput = vi.fn()
@@ -63,6 +65,7 @@ const titleSurfaces = [
 function mount(initial: TerminalViewState | undefined = idle, dictionary = en) {
   let state: TerminalViewState | undefined = initial
   let visible = true
+  let theme: ThemeSnapshot = { preference: 'light', fontSize: 14, active: { id: 'light', colorScheme: 'light', tokens: {} }, themes: [], revision: 0 }
   const detach = vi.fn()
   const model = {
     start: vi.fn(async () => {}), selectShell: vi.fn(),
@@ -75,11 +78,13 @@ function mount(initial: TerminalViewState | undefined = idle, dictionary = en) {
   const props = {
     view: () => model,
     useTerminal: (_key: string, select?: (value: TerminalViewState | undefined) => unknown) => select === undefined ? state : select(state),
+    useTheme: (select: (value: ThemeSnapshot) => unknown) => select(theme),
     useTabInfo: tab, t: makeTranslate(dictionary),
   } as unknown as TerminalBodyProps
   const view = render(<TerminalBody {...props} />)
   return {
     view, props, model, detach, openTab,
+    changeTheme() { theme = { ...theme, revision: theme.revision + 1 }; view.rerender(<TerminalBody {...props} />) },
     update(next: TerminalViewState | undefined, shown = visible) {
       state = next; visible = shown; view.rerender(<TerminalBody {...props} />)
     },
@@ -172,6 +177,28 @@ it('displays disconnect, close and exit states and offers explicit reconnection
   expect(h.view.getByRole('status').textContent).toBe(en.unavailable)
 })
 
+it('updates screen, cursor and selection colors without replacing the terminal or clearing output', () => {
+  const colors = { backgroundColor: 'rgb(255, 255, 255)', color: 'rgb(23, 25, 29)' }
+  vi.spyOn(window, 'getComputedStyle').mockImplementation(() => colors as CSSStyleDeclaration)
+  const h = mount({ ...idle, info, phase: 'connected', writable: true })
+  const terminal = fake.terminals[0]!
+  expect(terminal.options.theme).toMatchObject({
+    background: colors.backgroundColor, foreground: colors.color, cursor: colors.color, selectionForeground: colors.backgroundColor,
+  })
+  colors.backgroundColor = 'rgb(23, 25, 29)'
+  colors.color = 'rgb(231, 233, 238)'
+  h.changeTheme()
+  expect(terminal.options.theme).toEqual({
+    background: colors.backgroundColor, foreground: colors.color, cursor: colors.color, cursorAccent: colors.backgroundColor,
+    selectionBackground: colors.color, selectionForeground: colors.backgroundColor, selectionInactiveBackground: colors.color,
+  })
+  expect(fake.terminals).toEqual([terminal])
+  expect(terminal.reset).not.toHaveBeenCalled()
+  expect(terminal.dispose).not.toHaveBeenCalled()
+  expect(h.model.mount).toHaveBeenCalledOnce()
+  expect(h.detach).not.toHaveBeenCalled()
+})
+
 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)

+ 3 - 0
packages/client/ui-sidebar-terminal/tsconfig.json

@@ -46,6 +46,9 @@
     },
     {
       "path": "../ui-conversation"
+    },
+    {
+      "path": "../ui-theme"
     }
   ]
 }

+ 6 - 3
pnpm-lock.yaml

@@ -637,12 +637,12 @@ importers:
       '@deepseek-ai/dsh-pwsh-local':
         specifier: workspace:^
         version: link:../../packages/shell/pwsh-local
-      '@deepseek-ai/dsh-subprocess-local':
-        specifier: workspace:^
-        version: link:../../packages/subprocess/subprocess-local
       '@deepseek-ai/dsh-remote-mock':
         specifier: workspace:^
         version: link:../../packages/test-support/remote-mock
+      '@deepseek-ai/dsh-subprocess-local':
+        specifier: workspace:^
+        version: link:../../packages/subprocess/subprocess-local
       '@types/node':
         specifier: ^22.0.0
         version: 22.20.0
@@ -4224,6 +4224,9 @@ importers:
       '@deepseek-ai/dsh-client-ui-slots':
         specifier: workspace:^
         version: link:../ui-slots
+      '@deepseek-ai/dsh-client-ui-theme':
+        specifier: workspace:^
+        version: link:../ui-theme
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session