fix(desktop): align terminal themes and streamline workspace controls

This commit is contained in:
程序员阿江(Relakkes)
2026-09-13 22:10:30 +08:00
parent 69e8c014af
commit 57feabde05
20 changed files with 392 additions and 79 deletions
+3
View File
@@ -11,6 +11,7 @@
"@tailwindcss/typography": "^0.5.19",
"@types/dompurify": "^3.2.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"dompurify": "^3.3.3",
"electron-updater": "^6.8.3",
@@ -634,6 +635,8 @@
"@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "https://registry.npmmirror.com/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="],
"@xterm/addon-web-links": ["@xterm/addon-web-links@0.12.0", "https://registry.npmmirror.com/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz", {}, "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw=="],
"@xterm/xterm": ["@xterm/xterm@6.0.0", "https://registry.npmmirror.com/@xterm/xterm/-/xterm-6.0.0.tgz", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="],
"abbrev": ["abbrev@4.0.0", "https://registry.npmmirror.com/abbrev/-/abbrev-4.0.0.tgz", {}, "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA=="],
+1
View File
@@ -114,6 +114,7 @@
"@tailwindcss/typography": "^0.5.19",
"@types/dompurify": "^3.2.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"dompurify": "^3.3.3",
"electron-updater": "^6.8.3",
@@ -113,7 +113,7 @@ describe('ImageGalleryModal · navigation', () => {
it('uses an immersive media stage with a named close control', () => {
render(<ImageGalleryModal open images={images} activeIndex={0} onClose={() => {}} onSelect={() => {}} />)
expect(screen.getByRole('dialog', { name: 'a.png' })).toHaveClass('bg-[var(--color-terminal-bg)]')
expect(screen.getByRole('dialog', { name: 'a.png' })).toHaveClass('bg-[var(--color-media-bg)]')
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument()
})
@@ -53,7 +53,7 @@ export function ImageGalleryModal({ open, images, activeIndex, onClose, onSelect
<Modal open={open} onClose={onClose} title={activeImage.name} variant="media">
<div className="flex h-full min-h-0 flex-col">
<div className="flex h-12 shrink-0 items-center justify-between px-3">
<span className="font-mono text-xs tabular-nums text-[var(--color-terminal-muted)]">
<span className="font-mono text-xs tabular-nums text-[var(--color-media-muted)]">
{activeIndex + 1} / {images.length}
</span>
<IconButton
@@ -62,7 +62,7 @@ export function ImageGalleryModal({ open, images, activeIndex, onClose, onSelect
size="lg"
tone="secondary"
shape="circle"
surface="terminal"
surface="media"
onClick={onClose}
/>
</div>
@@ -83,8 +83,8 @@ export function ImageGalleryModal({ open, images, activeIndex, onClose, onSelect
size="xl"
tone="secondary"
shape="circle"
surface="terminal"
className="bg-[var(--color-terminal-header)] shadow-[var(--shadow-card)]"
surface="media"
className="bg-[var(--color-media-header)] shadow-[var(--shadow-card)]"
onClick={() => onSelect((activeIndex - 1 + images.length) % images.length)}
/>
</div>
@@ -95,8 +95,8 @@ export function ImageGalleryModal({ open, images, activeIndex, onClose, onSelect
size="xl"
tone="secondary"
shape="circle"
surface="terminal"
className="bg-[var(--color-terminal-header)] shadow-[var(--shadow-card)]"
surface="media"
className="bg-[var(--color-media-header)] shadow-[var(--shadow-card)]"
onClick={() => onSelect((activeIndex + 1) % images.length)}
/>
</div>
@@ -113,8 +113,8 @@ export function ImageGalleryModal({ open, images, activeIndex, onClose, onSelect
onClick={() => onSelect(index)}
className={`overflow-hidden rounded-[var(--radius-md)] border transition-[border-color,opacity,transform] duration-200 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${
index === activeIndex
? 'border-[var(--color-terminal-fg)] opacity-100'
: 'border-[var(--color-terminal-border)] opacity-55 hover:opacity-90'
? 'border-[var(--color-media-fg)] opacity-100'
: 'border-[var(--color-media-border)] opacity-55 hover:opacity-90'
}`}
>
<img src={image.src} alt={image.name} className="h-12 w-12 object-cover" />
+17 -4
View File
@@ -123,10 +123,7 @@ describe('IconButton', () => {
})
it('draws the terminal surface from the terminal palette, not the page one', () => {
// The terminal panel is one warm-ink block under every palette. The page
// tokens are inverted against it: --color-text-tertiary is a dark grey and
// --color-surface-hover a light cream, so a `muted` button on the default
// surface renders as an invisible glyph that flashes a pale square.
// Keep terminal controls on the same palette as xterm, including hover.
const { container } = render(
<IconButton icon={<span />} label="Clear" tone="muted" surface="terminal" />,
)
@@ -138,6 +135,22 @@ describe('IconButton', () => {
expect(className).not.toContain('hover:bg-[var(--color-surface-hover)]')
})
it('draws the media surface from the media palette, not the page one', () => {
// Media remains dark even when the terminal follows a light theme. The page
// tokens are inverted against it: --color-text-tertiary is a dark grey and
// --color-surface-hover a light cream, so a `muted` button on the default
// surface renders as an invisible glyph that flashes a pale square.
const { container } = render(
<IconButton icon={<span />} label="Clear" tone="muted" surface="media" />,
)
const className = container.firstElementChild!.className
expect(className).toContain('text-[var(--color-media-muted)]')
expect(className).toContain('hover:bg-[var(--color-media-selection)]')
expect(className).toContain('hover:text-[var(--color-media-fg)]')
expect(className).not.toContain('text-[var(--color-text-tertiary)]')
expect(className).not.toContain('hover:bg-[var(--color-surface-hover)]')
})
it('leaves the default and sidebar surfaces untouched by the terminal maps', () => {
const { container } = render(<IconButton icon={<span />} label="Act" tone="muted" />)
const className = container.firstElementChild!.className
+25 -6
View File
@@ -7,7 +7,7 @@ import { Spinner } from './Spinner'
export type IconButtonSize = '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
export type IconButtonTone = 'default' | 'secondary' | 'muted' | 'brand' | 'danger'
/** Which surface this button sits on — decides its hover fill. */
export type IconButtonSurface = 'default' | 'sidebar' | 'terminal'
export type IconButtonSurface = 'default' | 'sidebar' | 'terminal' | 'media'
export type IconButtonProps =
Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children' | 'aria-label' | 'title'> & {
@@ -100,11 +100,8 @@ const REST_TEXT: Record<IconButtonTone, string> = {
* Surfaces whose ground does not follow `--color-surface`, and so cannot use
* the text colors above.
*
* The terminal panel is one warm-ink block in every palette (the handoff pins
* it; only `ink-blue` swaps in a cool ground). Under the four paper themes
* `--color-text-tertiary` is a dark warm grey — invisible on it — and
* `--color-surface-hover` is a light cream, which flashes a pale square on the
* dark bar. Both have to come from the `--color-terminal-*` set instead.
* Terminal controls follow the workspace, while media controls keep their
* dark surround. Each surface supplies its own text and hover tokens.
*
* Partial on purpose: `default` and `sidebar` fall through to the maps above,
* so their output is unchanged.
@@ -117,6 +114,13 @@ const SURFACE_REST_TEXT: Partial<Record<IconButtonSurface, Record<IconButtonTone
brand: 'text-[var(--color-terminal-cursor)]',
danger: 'text-[var(--color-terminal-danger)]',
},
media: {
default: 'text-[var(--color-media-fg)]',
secondary: 'text-[var(--color-media-muted)]',
muted: 'text-[var(--color-media-muted)]',
brand: 'text-[var(--color-media-cursor)]',
danger: 'text-[var(--color-media-danger)]',
},
}
/**
@@ -151,6 +155,13 @@ const HOVER_BG: Record<IconButtonSurface, Record<IconButtonTone, string>> = {
brand: 'hover:bg-[var(--color-terminal-selection)]',
danger: 'hover:bg-[var(--color-terminal-selection)]',
},
media: {
default: 'hover:bg-[var(--color-media-selection)]',
secondary: 'hover:bg-[var(--color-media-selection)]',
muted: 'hover:bg-[var(--color-media-selection)]',
brand: 'hover:bg-[var(--color-media-selection)]',
danger: 'hover:bg-[var(--color-media-selection)]',
},
}
/** Hover text color. Muted and secondary brighten toward primary on hover. */
@@ -171,6 +182,13 @@ const SURFACE_HOVER_TEXT: Partial<Record<IconButtonSurface, Record<IconButtonTon
brand: '',
danger: '',
},
media: {
default: '',
secondary: 'hover:text-[var(--color-media-fg)]',
muted: 'hover:text-[var(--color-media-fg)]',
brand: '',
danger: '',
},
}
const FILLED_CLASSES: Record<IconButtonTone, string> = {
@@ -194,6 +212,7 @@ const PRESSED_CLASSES: Record<IconButtonSurface, string> = {
default: 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]',
sidebar: 'bg-[var(--color-sidebar-item-hover)] text-[var(--color-text-primary)]',
terminal: 'bg-[var(--color-terminal-selection)] text-[var(--color-terminal-fg)]',
media: 'bg-[var(--color-media-selection)] text-[var(--color-media-fg)]',
}
const BASE_CLASSES = [
+1 -1
View File
@@ -56,7 +56,7 @@ describe('Modal', () => {
)
const dialog = screen.getByRole('dialog', { name: 'Preview' })
expect(dialog).toHaveClass('bg-[var(--color-terminal-bg)]')
expect(dialog).toHaveClass('bg-[var(--color-media-bg)]')
expect(dialog).toHaveClass('h-[calc(100dvh-24px)]')
expect(dialog).not.toHaveClass('dialog-panel')
expect(screen.getByRole('img', { name: 'Preview' }).parentElement).not.toHaveClass('px-6')
+1 -1
View File
@@ -95,7 +95,7 @@ export function Modal({
// `dialog-panel`, not `glass-panel`: the fill has to be opaque on its
// own rather than leaning on a blur that may never run.
className={variant === 'media'
? 'relative flex h-[calc(100dvh-24px)] w-[calc(100vw-24px)] flex-col overflow-hidden rounded-[var(--radius-2xl)] bg-[var(--color-terminal-bg)] text-[var(--color-terminal-fg)]'
? 'relative flex h-[calc(100dvh-24px)] w-[calc(100vw-24px)] flex-col overflow-hidden rounded-[var(--radius-2xl)] bg-[var(--color-media-bg)] text-[var(--color-media-fg)]'
: 'dialog-panel relative flex max-h-[85vh] flex-col rounded-[var(--radius-3xl)]'}
style={variant === 'media'
? { maxHeight: 'calc(100dvh - 24px)', maxWidth: 'calc(100vw - 24px)' }
@@ -452,3 +452,59 @@ describe('WorkspaceTabStrip', () => {
expect(screen.getByTestId('workspace-tab-strip-side').className).toContain('h-10')
})
})
describe('terminal tab chrome', () => {
it('keeps the expanded terminal menu inside the viewport near the bottom edge', () => {
const measure = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
return this.getAttribute('role') === 'menu'
? { x: 0, y: 0, top: 0, left: 0, right: 210, bottom: 300, width: 210, height: 300, toJSON: () => ({}) }
: { x: 900, y: 720, top: 720, left: 900, right: 928, bottom: 748, width: 28, height: 28, toJSON: () => ({}) }
})
try {
renderStrip({ tabs: [TERMINAL_TAB], activeTabId: TERMINAL_TAB.id })
fireEvent.keyDown(screen.getByRole('tab'), { key: 'F10', shiftKey: true })
const menu = screen.getByRole('menu')
expect(Number.parseFloat(menu.style.top) + 300).toBeLessThanOrEqual(window.innerHeight - 8)
expect(menu).toHaveClass('overflow-y-auto')
} finally {
measure.mockRestore()
}
})
it('updates the title and shell tooltip without replacing the persisted tab', async () => {
const { getTerminalRuntime, updateTerminalRuntime, destroyTerminalRuntime } = await import('@/lib/terminalRuntime')
const { unmount } = renderStrip({ tabs: [TERMINAL_TAB], activeTabId: TERMINAL_TAB.id })
const runtime = getTerminalRuntime('rt-1', 'idle')
act(() => updateTerminalRuntime(runtime, { title: 'repo — zsh', shellInfo: { cwd: '/repo/packages', shell: '/bin/zsh' } }))
expect(screen.getByRole('tab', { name: 'repo — zsh' })).toHaveAttribute('title', '/repo/packages · /bin/zsh')
expect(TERMINAL_TAB).not.toHaveProperty('title')
act(() => updateTerminalRuntime(runtime, { title: '' }))
expect(screen.getByRole('tab')).not.toHaveTextContent('repo — zsh')
unmount()
destroyTerminalRuntime('rt-1')
})
it('offers terminal actions from the visible menu and disables restart while starting', async () => {
const { getTerminalRuntime, updateTerminalRuntime, destroyTerminalRuntime } = await import('@/lib/terminalRuntime')
const { t } = await import('@/i18n')
const runtime = getTerminalRuntime('rt-1', 'idle')
const clear = vi.fn()
const restart = vi.fn()
const focus = vi.fn()
const dispose = vi.fn()
updateTerminalRuntime(runtime, { terminal: { clear, dispose, focus } as unknown as NonNullable<typeof runtime.terminal>, restart, status: 'running' })
const { unmount } = renderStrip({ tabs: [TERMINAL_TAB], activeTabId: TERMINAL_TAB.id })
fireEvent.click(screen.getByRole('button', { name: t('workspace.tabMenu') }))
fireEvent.click(screen.getByRole('menuitem', { name: t('settings.terminal.clear') }))
expect(clear).toHaveBeenCalledOnce()
expect(focus).toHaveBeenCalledOnce()
fireEvent.keyDown(screen.getByRole('tab'), { key: 'F10', shiftKey: true })
fireEvent.click(screen.getByRole('menuitem', { name: t('settings.terminal.restart') }))
expect(restart).toHaveBeenCalledOnce()
act(() => updateTerminalRuntime(runtime, { status: 'starting' }))
fireEvent.click(screen.getByRole('button', { name: t('workspace.tabMenu') }))
expect(screen.getByRole('menuitem', { name: t('settings.terminal.restart') })).toBeDisabled()
unmount()
destroyTerminalRuntime('rt-1')
})
})
@@ -1,7 +1,9 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { FolderClosed, Globe, Plus, SquareTerminal, SquareSplitVertical } from 'lucide-react'
import { FolderClosed, Globe, MoreHorizontal, Plus, SquareTerminal, SquareSplitVertical } from 'lucide-react'
import { IconButton } from '@/components/ui/IconButton'
import { useDismissable } from '@/hooks/useDismissable'
import { useAnchoredPosition } from '@/hooks/useAnchoredPosition'
import { getTerminalRuntime, subscribeTerminalRuntime } from '@/lib/terminalRuntime'
import { useTranslation } from '../../i18n'
import { workspaceTabTitle } from '../../stores/workspaceStore'
import { useMenuKeyboard } from './menuKeyboard'
@@ -54,9 +56,23 @@ export function WorkspaceTabStrip({
onAdd,
}: WorkspaceTabStripProps) {
const t = useTranslation()
const [, refreshTerminalState] = useState(0)
useEffect(() => {
const unsubscribers = tabs.filter((tab) => tab.kind === 'terminal').map((tab) =>
subscribeTerminalRuntime(getTerminalRuntime(tab.runtimeId, 'idle'), () => refreshTerminalState((value) => value + 1)),
)
return () => unsubscribers.forEach((unsubscribe) => unsubscribe())
}, [tabs])
const [menu, setMenu] = useState<{ tabId: string; x: number; y: number } | null>(null)
const menuRef = useRef<HTMLDivElement>(null)
const menuTriggerRef = useRef<HTMLElement | null>(null)
const menuPosition = useAnchoredPosition({
open: menu !== null,
anchorRect: { top: menu?.y ?? 0, bottom: menu?.y ?? 0, left: menu?.x ?? 0, right: menu?.x ?? 0 },
floatingRef: menuRef,
offset: 0,
clampHeight: true,
})
const [draggingId, setDraggingId] = useState<string | null>(null)
const [focusedTabId, setFocusedTabId] = useState<string | null>(null)
const dragRef = useRef<{ tabId: string; index: number; startX: number } | null>(null)
@@ -184,6 +200,9 @@ export function WorkspaceTabStrip({
const menuDockTabs = tabs.filter((tab) => tab.dock === dock)
const menuTabIndex = menuDockTabs.findIndex((tab) => tab.id === menu?.tabId)
const menuTab = menuDockTabs[menuTabIndex]
const menuRuntime = menuTab?.kind === 'terminal' ? getTerminalRuntime(menuTab.runtimeId, 'idle') : null
const activeTerminalTab = tabs.find((tab) => tab.id === activeTabId && tab.kind === 'terminal')
const canCloseOthers = menuTabIndex >= 0 && menuDockTabs.length > 1
const canCloseRight = menuTabIndex >= 0 && menuTabIndex < menuDockTabs.length - 1
@@ -205,7 +224,8 @@ export function WorkspaceTabStrip({
>
{tabs.map((tab, index) => {
const Icon = KIND_ICON[tab.kind]
const title = workspaceTabTitle(tab, {
const runtime = tab.kind === 'terminal' ? getTerminalRuntime(tab.runtimeId, 'idle') : null
const title = runtime?.title?.trim() || workspaceTabTitle(tab, {
newTab: t('workspace.newTabTitle'),
review: t('workspace.reviewTabTitle'),
files: t('workspace.files.openTitle'),
@@ -244,6 +264,7 @@ export function WorkspaceTabStrip({
id={`workspace-tab-${dock}-${tab.id}`}
tabIndex={isRoving ? 0 : -1}
aria-selected={isActive}
title={tab.kind === 'terminal' ? [runtime?.shellInfo?.cwd || tab.cwd, runtime?.shellInfo?.shell].filter(Boolean).join(' · ') : title}
/*
The tab does carry a popup (right-click), so this is true.
`aria-expanded` deliberately stays off: on `role="tab"` it
@@ -279,7 +300,14 @@ export function WorkspaceTabStrip({
event.stopPropagation()
onClose(tab.id)
}}
onKeyDown={(event) => handleTabKeyDown(event, index)}
onKeyDown={(event) => {
if (event.key === 'ContextMenu' || (event.shiftKey && event.key === 'F10')) {
event.preventDefault()
const rect = event.currentTarget.getBoundingClientRect()
menuTriggerRef.current = event.currentTarget
setMenu({ tabId: tab.id, x: rect.left, y: rect.bottom })
} else handleTabKeyDown(event, index)
}}
className="flex h-7 min-w-0 flex-1 cursor-default items-center gap-1.5 rounded-[var(--radius-sm)] text-left outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-border-focus)]"
>
{tab.kind === 'file' && tab.path ? <WorkspaceFileIcon path={tab.path} /> : (
@@ -340,6 +368,24 @@ export function WorkspaceTabStrip({
</span>
</div>
{activeTerminalTab ? (
<span className="flex shrink-0 items-center">
<IconButton
icon={<MoreHorizontal size={16} />}
label={t('workspace.tabMenu')}
size="sm"
tone="muted"
aria-haspopup="menu"
aria-expanded={menu?.tabId === activeTerminalTab.id}
onClick={(event) => {
const rect = event.currentTarget.getBoundingClientRect()
menuTriggerRef.current = event.currentTarget
setMenu({ tabId: activeTerminalTab.id, x: Math.max(8, rect.right - 210), y: rect.bottom })
}}
/>
</span>
) : null}
{menu ? (
<div
ref={menuRef}
@@ -347,9 +393,22 @@ export function WorkspaceTabStrip({
aria-label={t('workspace.tabMenu')}
data-testid="workspace-tab-menu"
onKeyDown={handleMenuKeyDown}
className="fixed z-[var(--z-dropdown)] min-w-[190px] overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-1.5 shadow-[var(--shadow-dropdown)]"
style={{ left: menu.x, top: menu.y }}
className="fixed z-[var(--z-dropdown)] min-w-[190px] overflow-y-auto rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-1.5 shadow-[var(--shadow-dropdown)]"
style={menuPosition.style}
>
{menuRuntime ? <>
<WorkspaceTabMenuItem
label={t('settings.terminal.clear')}
disabled={!menuRuntime.terminal}
onSelect={() => { menuRuntime.terminal?.clear(); closeMenu(); menuRuntime.terminal?.focus() }}
/>
<WorkspaceTabMenuItem
label={t('settings.terminal.restart')}
disabled={!menuRuntime.restart || menuRuntime.status === 'starting' || menuRuntime.status === 'unavailable'}
onSelect={() => { menuRuntime.restart?.(); closeMenu() }}
/>
<div className="my-1 border-t border-[var(--color-border)]" />
</> : null}
<WorkspaceTabMenuItem label={t('workspace.tabCloseCurrent')} onSelect={() => { onClose(menu.tabId); closeMenu() }} />
<WorkspaceTabMenuItem label={t('workspace.tabCloseOthers')} disabled={!canCloseOthers} onSelect={() => { onCloseScope(menu.tabId, 'others'); closeMenu() }} />
<WorkspaceTabMenuItem label={t('workspace.tabCloseRight')} disabled={!canCloseRight} onSelect={() => { onCloseScope(menu.tabId, 'right'); closeMenu() }} />
@@ -8,12 +8,14 @@ vi.mock('../../pages/TerminalSettings', () => ({
cwd,
runtimeId,
preserveOnUnmount,
compactHeader,
autoStart,
testId,
}: {
active?: boolean
cwd?: string
runtimeId?: string
compactHeader?: boolean
preserveOnUnmount?: boolean
autoStart?: boolean
testId: string
@@ -21,6 +23,7 @@ vi.mock('../../pages/TerminalSettings', () => ({
<div
data-testid={testId}
data-active={active ? 'true' : 'false'}
data-compact-header={compactHeader ? 'true' : 'false'}
data-cwd={cwd ?? ''}
data-runtime-id={runtimeId ?? ''}
data-preserve-on-unmount={preserveOnUnmount ? 'true' : 'false'}
@@ -81,6 +84,7 @@ describe('WorkspaceTerminalTab', () => {
const host = screen.getByTestId('workspace-terminal-host-1')
expect(host).toHaveAttribute('data-runtime-id', 'runtime-1')
expect(host).toHaveAttribute('data-cwd', '/repo')
expect(host).toHaveAttribute('data-compact-header', 'true')
// `preserveOnUnmount` is what lets a terminal move docks, survive the panel
// being hidden and survive a task switch without restarting the PTY.
expect(host).toHaveAttribute('data-preserve-on-unmount', 'true')
@@ -58,6 +58,7 @@ export function WorkspaceTerminalTab({ sessionId, tab, active }: WorkspaceTermin
<TerminalSettings
active={active}
docked
compactHeader
cwd={tab.cwd}
runtimeId={runtimeId}
preserveOnUnmount
+5 -1
View File
@@ -20,6 +20,8 @@ export type TerminalRuntime = {
startToken: number
status: TerminalStatus
error: string | null
title: string
restart: (() => void) | null
shellInfo: TerminalShellInfo | null
listeners: Set<() => void>
}
@@ -48,6 +50,8 @@ export function getTerminalRuntime(id: string, initialStatus: TerminalStatus): T
status: initialStatus,
error: null,
shellInfo: null,
title: '',
restart: null,
listeners: new Set(),
}
runtimes.set(id, runtime)
@@ -56,7 +60,7 @@ export function getTerminalRuntime(id: string, initialStatus: TerminalStatus): T
export function updateTerminalRuntime(
runtime: TerminalRuntime,
patch: Partial<Pick<TerminalRuntime, 'terminal' | 'fit' | 'nativeSessionId' | 'status' | 'error' | 'shellInfo'>>,
patch: Partial<Pick<TerminalRuntime, 'terminal' | 'fit' | 'nativeSessionId' | 'status' | 'error' | 'shellInfo' | 'title' | 'restart'>>,
) {
Object.assign(runtime, patch)
notifyTerminalRuntime(runtime)
+9
View File
@@ -103,3 +103,12 @@ export function readTerminalPalette(root?: HTMLElement): TerminalPalette {
}
return palette
}
/** Resolve custom properties before passing fonts to xterm's canvas renderer. */
export function readTerminalFontFamily(root?: HTMLElement): string {
const element = root ?? (typeof document === 'undefined' ? null : document.documentElement)
const fallback = "'SFMono-Regular', Consolas, monospace"
if (!element || typeof getComputedStyle !== 'function') return fallback
const font = getComputedStyle(element).getPropertyValue('--font-mono').trim()
return font && !font.includes('var(') ? font : fallback
}
+5
View File
@@ -2207,6 +2207,11 @@ describe('ActiveSession task polling', () => {
expect(host).toHaveAttribute('data-preserve-on-unmount', 'true')
expect(resizeHandle).toHaveAttribute('aria-valuemin', `${WORKSPACE_BOTTOM_MIN_HEIGHT}`)
expect(resizeHandle).toHaveAttribute('aria-valuemax', `${WORKSPACE_BOTTOM_MAX_HEIGHT}`)
// The hit target overlays the existing panel border instead of adding a
// second visible line and a spacer above the terminal tabs.
expect(panel).toHaveClass('relative', 'border-t')
expect(resizeHandle).toHaveClass('absolute', 'bg-transparent')
expect(resizeHandle.firstElementChild).toHaveClass('bg-transparent')
act(() => {
fireEvent.keyDown(resizeHandle, { key: 'ArrowUp' })
+3 -3
View File
@@ -309,9 +309,9 @@ function TerminalResizeHandle() {
}
}}
onDoubleClick={() => setHeight(WORKSPACE_BOTTOM_DEFAULT_HEIGHT)}
className="group flex h-2.5 shrink-0 cursor-row-resize items-center bg-[var(--color-surface)] outline-none focus-visible:bg-[var(--color-surface-container)]"
className="group absolute inset-x-0 -top-1 z-[var(--z-raised)] flex h-2 cursor-row-resize items-center bg-transparent outline-none"
>
<div className="mx-3 h-px flex-1 rounded-full bg-[var(--color-border)] transition-colors group-hover:bg-[var(--color-border-focus)] group-focus-visible:bg-[var(--color-border-focus)]" />
<div className="h-px flex-1 bg-transparent transition-colors group-hover:bg-[var(--color-border-focus)] group-focus-visible:bg-[var(--color-border-focus)]" />
</div>
)
}
@@ -906,7 +906,7 @@ export function ActiveSession() {
data-testid="session-terminal-panel"
aria-label={t('workspace.bottomPanelLabel')}
className={[
'flex min-h-0 shrink-0 flex-col border-t border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]',
'relative flex min-h-0 shrink-0 flex-col border-t border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]',
showTerminalPanel ? '' : 'hidden',
].join(' ')}
style={{ height: showTerminalPanel ? terminalPanelHeight : 0 }}
+40 -1
View File
@@ -4,7 +4,7 @@ import '@testing-library/jest-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSettingsStore } from '../stores/settingsStore'
import { useUIStore } from '../stores/uiStore'
import { destroyTerminalRuntime } from '../lib/terminalRuntime'
import { destroyTerminalRuntime, getTerminalRuntime } from '../lib/terminalRuntime'
import { browserHost } from '../lib/desktopHost/browserHost'
const terminalMocks = vi.hoisted(() => {
@@ -19,6 +19,7 @@ const terminalMocks = vi.hoisted(() => {
open: vi.fn(),
dispose: vi.fn(),
onData: vi.fn(),
onTitleChange: vi.fn((_listener: (title: string) => void) => ({ dispose: vi.fn() })),
write: vi.fn(),
writeln: vi.fn(),
clear: vi.fn(),
@@ -49,6 +50,8 @@ vi.mock('@xterm/xterm', () => ({
Terminal: vi.fn(() => terminalMocks.terminalInstance),
}))
vi.mock('@xterm/addon-web-links', () => ({ WebLinksAddon: vi.fn() }))
vi.mock('@xterm/addon-fit', () => ({
FitAddon: vi.fn(() => terminalMocks.fitInstance),
}))
@@ -124,6 +127,42 @@ describe('TerminalSettings', () => {
vi.spyOn(navigator, 'platform', 'get').mockReturnValue('MacIntel')
})
it('resolves typography and enables links without forcing history scroll', async () => {
terminalMocks.available = true
document.documentElement.style.setProperty('--font-mono', '"Test Mono", monospace')
render(<TerminalSettings />)
await waitFor(() => expect(terminalMocks.spawn).toHaveBeenCalled())
const { Terminal } = await import('@xterm/xterm')
expect(Terminal).toHaveBeenLastCalledWith(expect.objectContaining({
fontFamily: '"Test Mono", monospace', fontSize: 13, letterSpacing: 0, cursorStyle: 'bar',
scrollOnEraseInDisplay: false,
}))
const { WebLinksAddon } = await import('@xterm/addon-web-links')
expect(WebLinksAddon).toHaveBeenCalledWith(expect.any(Function))
const open = vi.spyOn(browserHost.shell, 'open').mockResolvedValue(undefined)
const handleLink = vi.mocked(WebLinksAddon).mock.calls.at(-1)![0]!
handleLink(new MouseEvent('click'), 'https://example.com/docs')
expect(open).toHaveBeenCalledWith('https://example.com/docs')
handleLink(new MouseEvent('click'), 'javascript:alert(1)')
expect(open).toHaveBeenCalledTimes(1)
document.documentElement.style.removeProperty('--font-mono')
})
it('exposes live title and restart to workspace chrome without a second toolbar', async () => {
terminalMocks.available = true
const { unmount } = render(<TerminalSettings runtimeId="chrome-bridge" compactHeader />)
await waitFor(() => expect(terminalMocks.spawn).toHaveBeenCalled())
expect(screen.queryByTestId('settings-terminal-toolbar')).not.toBeInTheDocument()
const runtime = getTerminalRuntime('chrome-bridge', 'idle')
act(() => terminalMocks.terminalInstance.onTitleChange.mock.calls.at(-1)?.[0]('project — zsh'))
expect(runtime.title).toBe('project — zsh')
await act(async () => { runtime.restart?.() })
expect(terminalMocks.spawn).toHaveBeenCalledTimes(2)
expect(runtime.title).toBe('')
unmount()
expect(runtime.restart).toBeNull()
})
it('shows a desktop-runtime empty state outside Tauri', () => {
render(<TerminalSettings />)
+53 -9
View File
@@ -4,7 +4,7 @@ import { useTranslation, type TranslationKey } from '../i18n'
import { terminalApi } from '../api/terminal'
import { useSettingsStore } from '../stores/settingsStore'
import { useUIStore } from '../stores/uiStore'
import { readTerminalPalette } from '../lib/terminalTheme'
import { readTerminalPalette, readTerminalFontFamily } from '../lib/terminalTheme'
import { Dropdown } from '@/components/ui/Dropdown'
import { Input } from '@/components/ui/Input'
import { Button } from '@/components/ui/Button'
@@ -58,6 +58,7 @@ type TerminalSettingsProps = {
testId?: string
workspace?: boolean
docked?: boolean
compactHeader?: boolean
showPreferences?: boolean
runtimeId?: string
preserveOnUnmount?: boolean
@@ -79,6 +80,7 @@ export function TerminalSettings({
testId = 'settings-terminal-host',
workspace = false,
docked = false,
compactHeader = false,
showPreferences = false,
runtimeId,
preserveOnUnmount = false,
@@ -203,7 +205,7 @@ export function TerminalSettings({
const startPromise = Promise.resolve().then(async () => {
if (!isCurrentStart()) return
updateTerminalRuntime(runtime, { error: null, status: 'starting', shellInfo: null })
updateTerminalRuntime(runtime, { error: null, status: 'starting', shellInfo: null, title: '' })
const existing = runtime.nativeSessionId
if (existing) {
@@ -222,11 +224,13 @@ export function TerminalSettings({
host.innerHTML = ''
let TerminalModule: typeof import('@xterm/xterm')
let WebLinksAddonModule: typeof import('@xterm/addon-web-links')
let FitAddonModule: typeof import('@xterm/addon-fit')
try {
[TerminalModule, FitAddonModule] = await Promise.all([
[TerminalModule, FitAddonModule, WebLinksAddonModule] = await Promise.all([
import('@xterm/xterm'),
import('@xterm/addon-fit'),
import('@xterm/addon-web-links'),
])
} catch (err) {
if (isCurrentStart()) {
@@ -248,9 +252,14 @@ export function TerminalSettings({
terminal = new TerminalModule.Terminal({
cursorBlink: true,
convertEol: false,
fontFamily: "var(--font-mono), 'SFMono-Regular', Consolas, monospace",
fontSize: 12,
lineHeight: 1.25,
fontFamily: readTerminalFontFamily(),
fontSize: 13,
letterSpacing: 0,
lineHeight: 1.2,
cursorStyle: 'bar',
// xterm preserves scrollback position while writing; erasing the
// display must not override a user reading earlier output either.
scrollOnEraseInDisplay: false,
scrollback: 4000,
theme: readTerminalPalette(),
})
@@ -258,6 +267,13 @@ export function TerminalSettings({
const activeTerminal = terminal
const activeFit = fit
activeTerminal.loadAddon(activeFit)
activeTerminal.loadAddon(new WebLinksAddonModule.WebLinksAddon((_event, uri) => {
// Use the same external-open boundary as other desktop surfaces.
if (/^https?:\/\//i.test(uri)) void getDesktopHost().shell.open(uri).catch(() => {})
}))
activeTerminal.onTitleChange((title) => {
if (isCurrentStart()) updateTerminalRuntime(runtime, { title })
})
activeTerminal.open(host)
if (!isCurrentStart()) {
activeTerminal.dispose()
@@ -343,6 +359,19 @@ export function TerminalSettings({
return startPromise
}, [cwd, resizeSession, runtime])
useEffect(() => {
const restart = () => {
void startTerminal().then(() => {
const host = hostRef.current
if (host && host.clientWidth > 0 && host.clientHeight > 0) runtime.terminal?.focus()
})
}
updateTerminalRuntime(runtime, { restart })
return () => {
if (runtime.restart === restart) updateTerminalRuntime(runtime, { restart: null })
}
}, [runtime, startTerminal])
useEffect(() => {
lifecycleVersionRef.current += 1
const lifecycleVersion = lifecycleVersionRef.current
@@ -392,7 +421,16 @@ export function TerminalSettings({
const terminal = runtime.terminal
if (!terminal) return
terminal.options.theme = readTerminalPalette()
}, [runtime, theme])
terminal.options.fontFamily = readTerminalFontFamily()
resizeSession()
}, [runtime, theme, resizeSession])
useEffect(() => {
const fonts = document.fonts
if (!fonts) return
fonts.addEventListener('loadingdone', resizeSession)
return () => fonts.removeEventListener('loadingdone', resizeSession)
}, [resizeSession])
const clearTerminal = () => {
runtime.terminal?.clear()
@@ -571,7 +609,7 @@ export function TerminalSettings({
hasTerminalPanel ? 'bg-[var(--color-terminal-bg)]' : 'bg-[var(--color-surface-container-lowest)]',
].join(' ')}
>
<div
{!compactHeader && <div
data-testid="settings-terminal-toolbar"
className={`flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1.5 border-b px-3.5 ${
docked ? 'min-h-9 py-1.5' : 'min-h-11 py-2'
@@ -663,7 +701,13 @@ export function TerminalSettings({
/>
)}
</div>
</div>
</div>}
{compactHeader && status === 'starting' && (
<div role="status" className="px-3 py-1 text-xs text-[var(--color-terminal-muted)]">
{t(STATUS_LABEL_KEYS[status])}
</div>
)}
{status === 'unavailable' ? (
<EmptyState
+50 -29
View File
@@ -159,7 +159,11 @@
}
.settings-terminal-host .xterm-viewport {
scrollbar-color: var(--color-outline-a72) transparent;
scrollbar-color: var(--color-terminal-scrollbar) transparent;
}
.settings-terminal-host .xterm-viewport:hover {
scrollbar-color: var(--color-terminal-scrollbar-hover) transparent;
}
.settings-terminal-host .xterm-screen {
@@ -576,13 +580,29 @@
}
/* ─── Terminal ANSI palette ───────────────────────────────────────
The embedded terminal stays dark under all three themes (that is what
the per-theme `--color-terminal-*` values encode — light/white/dark
differ only in hue, not in lightness), so the 16 ANSI slots are shared.
Split them into the theme blocks if a theme ever needs its own ramp.
Consumed by `lib/terminalTheme.ts`, which is the only place allowed to
turn these into an xterm ITheme object. */
ANSI foregrounds use separate light and dark ramps. Black/white retain
ANSI semantics for programs that explicitly paint their own backgrounds. */
:root {
--color-terminal-ansi-black: #242424;
--color-terminal-ansi-red: #B42318;
--color-terminal-ansi-green: #226C3C;
--color-terminal-ansi-yellow: #795500;
--color-terminal-ansi-blue: #155DB5;
--color-terminal-ansi-magenta: #8642A0;
--color-terminal-ansi-cyan: #176A72;
--color-terminal-ansi-white: #D7D2D0;
--color-terminal-ansi-bright-black: #696158;
--color-terminal-ansi-bright-red: #A82018;
--color-terminal-ansi-bright-green: #1D6735;
--color-terminal-ansi-bright-yellow: #755000;
--color-terminal-ansi-bright-blue: #1258AB;
--color-terminal-ansi-bright-magenta: #7E3698;
--color-terminal-ansi-bright-cyan: #12656D;
--color-terminal-ansi-bright-white: #FFFFFF;
}
[data-theme="dark"],
[data-theme="ink-blue"] {
--color-terminal-ansi-black: #1F1F1F;
--color-terminal-ansi-red: #FF6D67;
--color-terminal-ansi-green: #7EF18A;
@@ -1432,18 +1452,29 @@
--color-diff-syntax-type: #7E5520;
--color-diff-syntax-punctuation: #5C504A;
/* Terminal. The handoff pins one warm-ink panel across every theme; the
ink-blue theme swaps in its own cool ground below. */
--color-terminal-header: #221F19;
--color-terminal-bg: #191713;
--color-terminal-border: #14120F;
--color-terminal-fg: #E4DCCA;
--color-terminal-cursor: #D07B52;
--color-terminal-selection: #4C4433;
--color-terminal-muted: #8B8069;
--color-terminal-accent: #84B383;
--color-terminal-danger: #E06B52;
--color-terminal-warning: #D3A34C;
/* The terminal belongs to the current workspace theme. */
--color-terminal-header: var(--cc-bg);
--color-terminal-bg: var(--cc-bg);
--color-terminal-border: var(--cc-bd);
--color-terminal-fg: var(--cc-t1);
--color-terminal-cursor: var(--cc-t1);
--color-terminal-selection: var(--cc-ac-soft);
--color-terminal-muted: var(--cc-t3);
--color-terminal-accent: var(--cc-ok-ink);
--color-terminal-danger: var(--cc-er-ink);
--color-terminal-warning: var(--cc-wn-ink);
--color-terminal-scrollbar: var(--cc-bd2);
--color-terminal-scrollbar-hover: var(--cc-t3);
/* Image viewing keeps a dark neutral surround independently of terminals. */
--color-media-bg: #191713;
--color-media-header: #221F19;
--color-media-border: #4C4433;
--color-media-fg: #E4DCCA;
--color-media-muted: #B5AA96;
--color-media-selection: #4C4433;
--color-media-cursor: #D07B52;
--color-media-danger: #E06B52;
/* Find-in-page highlights. These paint over arbitrary page content, not
over a brand-soft panel, so they get their own pair instead of borrowing
@@ -1521,16 +1552,6 @@
--color-activity-tooltip-border: rgba(var(--cc-bg-rgb), 0.28);
}
/* Ink-blue runs a cool terminal ground rather than the shared warm ink. */
[data-theme="ink-blue"] {
--color-terminal-header: #1B1F27;
--color-terminal-bg: #14171D;
--color-terminal-border: #101318;
--color-terminal-fg: #DDE1EB;
--color-terminal-selection: #434A5B;
--color-terminal-muted: #79808F;
}
/* ─── Material Symbols ─────────────────────────────────────────────
Inside @layer base ON PURPOSE. Unlayered CSS beats every @layer, and
Tailwind v4 puts its utilities in `@layer utilities` — so an unlayered
+45 -10
View File
@@ -488,17 +488,52 @@ describe('terminal palette tokens', () => {
}
})
it('defines the terminal ground once and lets ink-blue override it', () => {
// The handoff pins one warm-ink terminal panel across the paper themes;
// only ink-blue swaps in a cool ground, so it is the one override.
const root = getThemeBlock(':root')
expect(root).toContain('--color-terminal-cursor:')
expect(root).toContain('--color-terminal-selection:')
expect(root).toContain('--color-terminal-bg:')
function terminalTokens(theme: string) {
const blocks = [getThemeBlock(':root')]
if (theme === 'dark' || theme === 'ink-blue') {
blocks.push(getThemeBlock('[data-theme="dark"],\n[data-theme="ink-blue"]'))
}
blocks.push(getThemeBlock(`[data-theme="${theme}"]`))
const tokens = new Map(Array.from(blocks.join('\n').matchAll(/(--[\w-]+):\s*([^;]+);/g), (match) => [match[1]!, match[2]!.trim()]))
const resolve = (name: string): string => {
const value = tokens.get(name)
if (!value) throw new Error(`Missing ${name}`)
const alias = /^var\((--[\w-]+)\)$/.exec(value)
return alias ? resolve(alias[1]!) : value
}
return resolve
}
const inkBlue = getThemeBlock('[data-theme="ink-blue"]')
expect(inkBlue).toContain('--color-terminal-bg:')
expect(inkBlue).toContain('--color-terminal-selection:')
function contrast(foreground: string, background: string) {
const luminance = (hex: string) => {
const channels = hex.slice(1).match(/../g)!.map((part) => {
const channel = parseInt(part, 16) / 255
return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4
})
return channels[0]! * 0.2126 + channels[1]! * 0.7152 + channels[2]! * 0.0722
}
const values = [luminance(foreground), luminance(background)].sort((a, b) => b - a)
return (values[0]! + 0.05) / (values[1]! + 0.05)
}
it.each(THEME_MODES)('integrates the terminal with the %s workspace and keeps output readable', (theme) => {
const token = terminalTokens(theme)
const background = token('--color-terminal-bg')
expect(background).toBe(token('--cc-bg'))
expect(token('--color-terminal-header')).toBe(background)
expect(token('--color-terminal-fg')).toBe(token('--cc-t1'))
for (const slot of ['fg', 'muted', 'accent', 'danger', 'warning']) {
expect(contrast(token(`--color-terminal-${slot}`), background), `${theme} ${slot}`).toBeGreaterThanOrEqual(4.5)
}
expect(contrast(token('--color-terminal-fg'), token('--color-terminal-selection'))).toBeGreaterThanOrEqual(4.5)
for (const slot of ansiSlots.filter((slot) => !['black', 'white', 'bright-white'].includes(slot))) {
expect(contrast(token(`--color-terminal-ansi-${slot}`), background), `${theme} ANSI ${slot}`).toBeGreaterThanOrEqual(4.5)
}
expect(token('--color-terminal-scrollbar')).toBe(token('--cc-bd2'))
expect(token('--color-terminal-scrollbar-hover')).toBe(token('--cc-t3'))
// Image viewing must remain independent when a terminal switches to paper.
expect(token('--color-media-bg')).toBe('#191713')
expect(contrast(token('--color-media-muted'), token('--color-media-bg'))).toBeGreaterThanOrEqual(4.5)
})
})