mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
fix(desktop): align terminal themes and streamline workspace controls
This commit is contained in:
@@ -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=="],
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 />)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user