diff --git a/desktop/bun.lock b/desktop/bun.lock index a49e026c..588289f4 100644 --- a/desktop/bun.lock +++ b/desktop/bun.lock @@ -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=="], diff --git a/desktop/package.json b/desktop/package.json index 62b9cdda..6e444e25 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -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", diff --git a/desktop/src/components/chat/ImageGalleryModal.test.tsx b/desktop/src/components/chat/ImageGalleryModal.test.tsx index 98cef473..8c06acb0 100644 --- a/desktop/src/components/chat/ImageGalleryModal.test.tsx +++ b/desktop/src/components/chat/ImageGalleryModal.test.tsx @@ -113,7 +113,7 @@ describe('ImageGalleryModal · navigation', () => { it('uses an immersive media stage with a named close control', () => { render( {}} 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() }) diff --git a/desktop/src/components/chat/ImageGalleryModal.tsx b/desktop/src/components/chat/ImageGalleryModal.tsx index 75824bea..9f089676 100644 --- a/desktop/src/components/chat/ImageGalleryModal.tsx +++ b/desktop/src/components/chat/ImageGalleryModal.tsx @@ -53,7 +53,7 @@ export function ImageGalleryModal({ open, images, activeIndex, onClose, onSelect
- + {activeIndex + 1} / {images.length}
@@ -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)} />
@@ -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)} /> @@ -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' }`} > {image.name} diff --git a/desktop/src/components/ui/IconButton.test.tsx b/desktop/src/components/ui/IconButton.test.tsx index b20f7bf0..0f4602af 100644 --- a/desktop/src/components/ui/IconButton.test.tsx +++ b/desktop/src/components/ui/IconButton.test.tsx @@ -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( } 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( + } 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(} label="Act" tone="muted" />) const className = container.firstElementChild!.className diff --git a/desktop/src/components/ui/IconButton.tsx b/desktop/src/components/ui/IconButton.tsx index 074cc8c4..92160b4e 100644 --- a/desktop/src/components/ui/IconButton.tsx +++ b/desktop/src/components/ui/IconButton.tsx @@ -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, 'children' | 'aria-label' | 'title'> & { @@ -100,11 +100,8 @@ const REST_TEXT: Record = { * 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> = { 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 = { @@ -194,6 +212,7 @@ const PRESSED_CLASSES: Record = { 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 = [ diff --git a/desktop/src/components/ui/Modal.test.tsx b/desktop/src/components/ui/Modal.test.tsx index ed999891..64811e00 100644 --- a/desktop/src/components/ui/Modal.test.tsx +++ b/desktop/src/components/ui/Modal.test.tsx @@ -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') diff --git a/desktop/src/components/ui/Modal.tsx b/desktop/src/components/ui/Modal.tsx index a0b77c42..3a167a72 100644 --- a/desktop/src/components/ui/Modal.tsx +++ b/desktop/src/components/ui/Modal.tsx @@ -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)' } diff --git a/desktop/src/components/workbench/WorkspaceTabStrip.test.tsx b/desktop/src/components/workbench/WorkspaceTabStrip.test.tsx index 0c8ccbbe..97cb1616 100644 --- a/desktop/src/components/workbench/WorkspaceTabStrip.test.tsx +++ b/desktop/src/components/workbench/WorkspaceTabStrip.test.tsx @@ -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, 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') + }) +}) diff --git a/desktop/src/components/workbench/WorkspaceTabStrip.tsx b/desktop/src/components/workbench/WorkspaceTabStrip.tsx index 89475189..a4bbf4c0 100644 --- a/desktop/src/components/workbench/WorkspaceTabStrip.tsx +++ b/desktop/src/components/workbench/WorkspaceTabStrip.tsx @@ -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(null) const menuTriggerRef = useRef(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(null) const [focusedTabId, setFocusedTabId] = useState(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 ? : ( @@ -340,6 +368,24 @@ export function WorkspaceTabStrip({ + {activeTerminalTab ? ( + + } + 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 }) + }} + /> + + ) : null} + {menu ? (
+ {menuRuntime ? <> + { menuRuntime.terminal?.clear(); closeMenu(); menuRuntime.terminal?.focus() }} + /> + { menuRuntime.restart?.(); closeMenu() }} + /> +
+ : null} { onClose(menu.tabId); closeMenu() }} /> { onCloseScope(menu.tabId, 'others'); closeMenu() }} /> { onCloseScope(menu.tabId, 'right'); closeMenu() }} /> diff --git a/desktop/src/components/workbench/WorkspaceTerminalTab.test.tsx b/desktop/src/components/workbench/WorkspaceTerminalTab.test.tsx index d5ff7bea..997ce62d 100644 --- a/desktop/src/components/workbench/WorkspaceTerminalTab.test.tsx +++ b/desktop/src/components/workbench/WorkspaceTerminalTab.test.tsx @@ -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', () => ({
{ 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') diff --git a/desktop/src/components/workbench/WorkspaceTerminalTab.tsx b/desktop/src/components/workbench/WorkspaceTerminalTab.tsx index 14218505..22667917 100644 --- a/desktop/src/components/workbench/WorkspaceTerminalTab.tsx +++ b/desktop/src/components/workbench/WorkspaceTerminalTab.tsx @@ -58,6 +58,7 @@ export function WorkspaceTerminalTab({ sessionId, tab, active }: WorkspaceTermin 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>, + patch: Partial>, ) { Object.assign(runtime, patch) notifyTerminalRuntime(runtime) diff --git a/desktop/src/lib/terminalTheme.ts b/desktop/src/lib/terminalTheme.ts index a427ce7d..d5344bfe 100644 --- a/desktop/src/lib/terminalTheme.ts +++ b/desktop/src/lib/terminalTheme.ts @@ -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 +} diff --git a/desktop/src/pages/ActiveSession.test.tsx b/desktop/src/pages/ActiveSession.test.tsx index 02e3192b..4351378b 100644 --- a/desktop/src/pages/ActiveSession.test.tsx +++ b/desktop/src/pages/ActiveSession.test.tsx @@ -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' }) diff --git a/desktop/src/pages/ActiveSession.tsx b/desktop/src/pages/ActiveSession.tsx index 9de79387..501ca12d 100644 --- a/desktop/src/pages/ActiveSession.tsx +++ b/desktop/src/pages/ActiveSession.tsx @@ -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" > -
+
) } @@ -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 }} diff --git a/desktop/src/pages/TerminalSettings.test.tsx b/desktop/src/pages/TerminalSettings.test.tsx index b896ec28..1d1382e7 100644 --- a/desktop/src/pages/TerminalSettings.test.tsx +++ b/desktop/src/pages/TerminalSettings.test.tsx @@ -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() + 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() + 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() diff --git a/desktop/src/pages/TerminalSettings.tsx b/desktop/src/pages/TerminalSettings.tsx index 214a7515..4cd7b3dc 100644 --- a/desktop/src/pages/TerminalSettings.tsx +++ b/desktop/src/pages/TerminalSettings.tsx @@ -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(' ')} > -
)}
-
+
} + + {compactHeader && status === 'starting' && ( +
+ {t(STATUS_LABEL_KEYS[status])} +
+ )} {status === 'unavailable' ? ( { } }) - 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) }) })