diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx
index a1a90068bf6..583030cd91e 100644
--- a/src/renderer/src/App.tsx
+++ b/src/renderer/src/App.tsx
@@ -8,7 +8,7 @@ import { useIpcEvents } from './hooks/useIpcEvents'
import Sidebar from './components/Sidebar'
import Terminal from './components/Terminal'
import Landing from './components/Landing'
-import Settings from './components/Settings'
+import Settings from './components/settings/Settings'
import RightSidebar from './components/right-sidebar'
function App(): React.JSX.Element {
@@ -100,7 +100,9 @@ function App(): React.JSX.Element {
])
useEffect(() => {
- if (!workspaceSessionReady) return
+ if (!workspaceSessionReady) {
+ return
+ }
const timer = window.setTimeout(() => {
void window.api.session.set({
@@ -123,7 +125,9 @@ function App(): React.JSX.Element {
])
useEffect(() => {
- if (!persistedUIReady) return
+ if (!persistedUIReady) {
+ return
+ }
const timer = window.setTimeout(() => {
void window.api.ui.set({
@@ -139,7 +143,9 @@ function App(): React.JSX.Element {
// Apply theme to document
useEffect(() => {
- if (!settings) return
+ if (!settings) {
+ return
+ }
const applyTheme = (dark: boolean): void => {
document.documentElement.classList.toggle('dark', dark)
@@ -147,10 +153,10 @@ function App(): React.JSX.Element {
if (settings.theme === 'dark') {
applyTheme(true)
- return
+ return undefined
} else if (settings.theme === 'light') {
applyTheme(false)
- return
+ return undefined
} else {
// system
const mq = window.matchMedia('(prefers-color-scheme: dark)')
@@ -178,7 +184,9 @@ function App(): React.JSX.Element {
const showSidebar = activeView !== 'settings'
const handleToggleExpand = (): void => {
- if (!effectiveActiveTabId) return
+ if (!effectiveActiveTabId) {
+ return
+ }
window.dispatchEvent(
new CustomEvent(TOGGLE_TERMINAL_PANE_EXPAND_EVENT, {
detail: { tabId: effectiveActiveTabId }
@@ -188,12 +196,18 @@ function App(): React.JSX.Element {
useEffect(() => {
const onKeyDown = (e: KeyboardEvent): void => {
- if (e.repeat) return
- if (!e.metaKey) return
+ if (e.repeat) {
+ return
+ }
+ if (!e.metaKey) {
+ return
+ }
// Cmd+N — create worktree
if (!e.ctrlKey && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'n') {
- if (repos.length === 0) return
+ if (repos.length === 0) {
+ return
+ }
e.preventDefault()
openModal('create-worktree')
return
@@ -212,7 +226,6 @@ function App(): React.JSX.Element {
e.preventDefault()
setRightSidebarTab('source-control')
setRightSidebarOpen(true)
- return
}
}
diff --git a/src/renderer/src/components/Settings.tsx b/src/renderer/src/components/Settings.tsx
deleted file mode 100644
index b406b9e47c2..00000000000
--- a/src/renderer/src/components/Settings.tsx
+++ /dev/null
@@ -1,1720 +0,0 @@
-import { useEffect, useState, useCallback, useMemo, useRef } from 'react'
-import type { OrcaHooks, Repo, RepoHookSettings } from '../../../shared/types'
-import { REPO_COLORS, getDefaultRepoHookSettings } from '../../../shared/constants'
-import { useAppStore } from '../store'
-import { ScrollArea } from './ui/scroll-area'
-import { Button } from './ui/button'
-import { Input } from './ui/input'
-import { Label } from './ui/label'
-import { Separator } from './ui/separator'
-import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group'
-import { TerminalThemePreview } from './settings/TerminalThemePreview'
-import { applyUIZoom } from '@/lib/ui-zoom'
-import {
- BUILTIN_TERMINAL_THEME_NAMES,
- clampNumber,
- getSystemPrefersDark,
- normalizeColor,
- resolvePaneStyleOptions,
- resolveEffectiveTerminalAppearance
-} from '@/lib/terminal-theme'
-import {
- ArrowLeft,
- Check,
- ChevronsUpDown,
- CircleX,
- Download,
- FolderOpen,
- Loader2,
- Minus,
- Palette,
- Plus,
- RefreshCw,
- SlidersHorizontal,
- RotateCcw,
- SquareTerminal,
- Trash2
-} from 'lucide-react'
-
-type HookName = keyof OrcaHooks['scripts']
-const DEFAULT_REPO_HOOK_SETTINGS = getDefaultRepoHookSettings()
-const MAX_THEME_RESULTS = 80
-const MAX_FONT_RESULTS = 12
-const SCROLLBACK_PRESETS_MB = [10, 25, 50, 100, 250] as const
-const ZOOM_STEP = 0.5
-const ZOOM_MIN = -3
-const ZOOM_MAX = 5
-
-function zoomLevelToPercent(level: number): number {
- return Math.round(100 * Math.pow(1.2, level))
-}
-
-function UIZoomControl(): React.JSX.Element {
- const [zoomLevel, setZoomLevel] = useState(() => window.api.ui.getZoomLevel())
-
- useEffect(() => {
- return window.api.ui.onTerminalZoom(() => {
- setZoomLevel(window.api.ui.getZoomLevel())
- })
- }, [])
-
- const applyZoom = useCallback((level: number) => {
- const clamped = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, level))
- applyUIZoom(clamped)
- setZoomLevel(clamped)
- window.api.ui.set({ uiZoomLevel: clamped })
- }, [])
-
- const percent = zoomLevelToPercent(zoomLevel)
-
- return (
-
-
applyZoom(zoomLevel - ZOOM_STEP)}
- disabled={zoomLevel <= ZOOM_MIN}
- >
-
-
-
{percent}%
-
applyZoom(zoomLevel + ZOOM_STEP)}
- disabled={zoomLevel >= ZOOM_MAX}
- >
-
-
-
applyZoom(0)}
- disabled={zoomLevel === 0}
- className="ml-1 gap-1.5"
- >
-
- Reset
-
-
- )
-}
-
-function getFallbackTerminalFonts(): string[] {
- const nav =
- typeof navigator !== 'undefined'
- ? (navigator as Navigator & { userAgentData?: { platform?: string } })
- : null
- const platform = nav ? (nav.userAgentData?.platform ?? nav.platform ?? '') : ''
- const normalizedPlatform = platform.toLowerCase()
-
- if (normalizedPlatform.includes('mac')) {
- return ['SF Mono', 'Menlo', 'Monaco', 'JetBrains Mono', 'Fira Code']
- }
-
- if (normalizedPlatform.includes('win')) {
- return ['Cascadia Mono', 'Consolas', 'Lucida Console', 'JetBrains Mono', 'Fira Code']
- }
-
- return [
- 'JetBrains Mono',
- 'Fira Code',
- 'DejaVu Sans Mono',
- 'Liberation Mono',
- 'Ubuntu Mono',
- 'Noto Sans Mono'
- ]
-}
-
-type ThemePickerProps = {
- label: string
- description: string
- selectedTheme: string
- query: string
- onQueryChange: (value: string) => void
- onSelectTheme: (theme: string) => void
-}
-
-type ColorFieldProps = {
- label: string
- description: string
- value: string
- fallback: string
- onChange: (value: string) => void
-}
-
-function ThemePicker({
- label,
- description,
- selectedTheme,
- query,
- onQueryChange,
- onSelectTheme
-}: ThemePickerProps): React.JSX.Element {
- const normalizedQuery = query.trim().toLowerCase()
- const filteredThemes = BUILTIN_TERMINAL_THEME_NAMES.filter((theme) =>
- theme.toLowerCase().includes(normalizedQuery)
- ).slice(0, MAX_THEME_RESULTS)
-
- return (
-
-
-
{label}
-
{description}
-
-
onQueryChange(e.target.value)}
- placeholder="Search builtin themes"
- />
-
-
- Selected: {selectedTheme}
-
- Showing {filteredThemes.length}
- {normalizedQuery
- ? ` matching "${query.trim()}"`
- : ` of ${BUILTIN_TERMINAL_THEME_NAMES.length}`}
-
-
-
-
- {filteredThemes.map((theme) => (
-
onSelectTheme(theme)}
- className={`flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors ${
- selectedTheme === theme
- ? 'bg-accent font-medium text-accent-foreground'
- : 'hover:bg-muted/60'
- }`}
- >
- {theme}
- {selectedTheme === theme ? (
-
- Current
-
- ) : null}
-
- ))}
- {filteredThemes.length === 0 ? (
-
No themes found.
- ) : null}
-
-
-
-
- )
-}
-
-function ColorField({
- label,
- description,
- value,
- fallback,
- onChange
-}: ColorFieldProps): React.JSX.Element {
- const normalized = normalizeColor(value, fallback)
-
- return (
-
-
-
{label}
-
{description}
-
-
- onChange(e.target.value)}
- className="h-9 w-12 rounded-md border border-input bg-transparent p-1"
- />
- onChange(e.target.value)}
- placeholder={fallback}
- className="max-w-xs font-mono text-xs"
- />
-
-
- )
-}
-
-type NumberFieldProps = {
- label: string
- description: string
- value: number
- defaultValue?: number
- min: number
- max: number
- step?: number
- onChange: (value: number) => void
- suffix?: string
-}
-
-type FontAutocompleteProps = {
- value: string
- suggestions: string[]
- onChange: (value: string) => void
-}
-
-function NumberField({
- label,
- description,
- value,
- defaultValue,
- min,
- max,
- step = 1,
- onChange,
- suffix
-}: NumberFieldProps): React.JSX.Element {
- return (
-
-
-
{label}
-
{description}
-
-
- {
- const next = Number(e.target.value)
- if (!Number.isFinite(next)) return
- onChange(next)
- }}
- className="number-input-clean w-28 tabular-nums"
- />
- {suffix ? {suffix} : null}
-
-
- Current: {value}
- {defaultValue !== undefined ? ` · Default: ${defaultValue}` : ''}
-
-
- )
-}
-
-function FontAutocomplete({
- value,
- suggestions,
- onChange
-}: FontAutocompleteProps): React.JSX.Element {
- const [query, setQuery] = useState(value)
- const [prevValue, setPrevValue] = useState(value)
- const [open, setOpen] = useState(false)
- const rootRef = useRef(null)
-
- if (value !== prevValue) {
- setPrevValue(value)
- setQuery(value)
- }
-
- useEffect(() => {
- if (!open) return
-
- const handlePointerDown = (event: MouseEvent): void => {
- if (!rootRef.current?.contains(event.target as Node)) {
- setOpen(false)
- }
- }
-
- document.addEventListener('mousedown', handlePointerDown)
- return () => document.removeEventListener('mousedown', handlePointerDown)
- }, [open])
-
- const normalizedQuery = query.trim().toLowerCase()
- const filteredSuggestions = useMemo(() => {
- const startsWith = suggestions.filter((font) => font.toLowerCase().startsWith(normalizedQuery))
- const includes = suggestions.filter(
- (font) =>
- !font.toLowerCase().startsWith(normalizedQuery) &&
- font.toLowerCase().includes(normalizedQuery)
- )
- const ordered = normalizedQuery ? [...startsWith, ...includes] : suggestions
- return ordered.slice(0, MAX_FONT_RESULTS)
- }, [suggestions, normalizedQuery])
-
- const commitValue = (nextValue: string): void => {
- setQuery(nextValue)
- onChange(nextValue)
- setOpen(false)
- }
-
- return (
-
-
-
{
- const next = e.target.value
- setQuery(next)
- onChange(next)
- setOpen(true)
- }}
- onFocus={() => setOpen(true)}
- placeholder="SF Mono"
- className="pr-18"
- />
-
- {query ? (
- {
- setQuery('')
- onChange('')
- setOpen(true)
- }}
- className="rounded-sm p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
- aria-label="Clear font selection"
- title="Clear"
- >
-
-
- ) : null}
- setOpen((current) => !current)}
- className="rounded-sm p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
- aria-label="Toggle font suggestions"
- title="Fonts"
- >
-
-
-
-
-
- {open ? (
-
-
-
- {filteredSuggestions.length > 0 ? (
- filteredSuggestions.map((font) => (
-
e.preventDefault()}
- onClick={() => commitValue(font)}
- className={`flex w-full items-center justify-between rounded-sm px-3 py-2 text-left text-sm transition-colors ${
- font === value ? 'bg-accent text-accent-foreground' : 'hover:bg-muted/60'
- }`}
- >
- {font}
- {font === value ? : null}
-
- ))
- ) : (
-
No matching fonts.
- )}
-
-
-
- ) : null}
-
- )
-}
-
-function Settings(): React.JSX.Element {
- const settings = useAppStore((s) => s.settings)
- const updateSettings = useAppStore((s) => s.updateSettings)
- const fetchSettings = useAppStore((s) => s.fetchSettings)
- const setActiveView = useAppStore((s) => s.setActiveView)
- const repos = useAppStore((s) => s.repos)
- const updateRepo = useAppStore((s) => s.updateRepo)
- const removeRepo = useAppStore((s) => s.removeRepo)
-
- const [confirmingRemove, setConfirmingRemove] = useState(null)
- const [selectedPane, setSelectedPane] = useState<'general' | 'appearance' | 'terminal' | 'repo'>(
- 'general'
- )
- const [selectedRepoId, setSelectedRepoId] = useState(null)
- const [repoHooksMap, setRepoHooksMap] = useState<
- Record
- >({})
- const [defaultBaseRef, setDefaultBaseRef] = useState('origin/main')
- const [baseRefQuery, setBaseRefQuery] = useState('')
- const [baseRefResults, setBaseRefResults] = useState([])
- const [isSearchingBaseRefs, setIsSearchingBaseRefs] = useState(false)
- const [themeSearchDark, setThemeSearchDark] = useState('')
- const [themeSearchLight, setThemeSearchLight] = useState('')
- const [systemPrefersDark, setSystemPrefersDark] = useState(getSystemPrefersDark())
- const [scrollbackMode, setScrollbackMode] = useState<'preset' | 'custom'>('preset')
- const [prevSettings, setPrevSettings] = useState(settings)
- const [terminalFontSuggestions, setTerminalFontSuggestions] = useState(
- getFallbackTerminalFonts()
- )
- const terminalFontsLoadedRef = useRef(false)
- const updateStatus = useAppStore((s) => s.updateStatus)
-
- useEffect(() => {
- fetchSettings()
- }, [fetchSettings])
-
- useEffect(() => {
- const media = window.matchMedia('(prefers-color-scheme: dark)')
- const handleChange = (event: MediaQueryListEvent): void => {
- setSystemPrefersDark(event.matches)
- }
- setSystemPrefersDark(media.matches)
- media.addEventListener('change', handleChange)
- return () => media.removeEventListener('change', handleChange)
- }, [])
-
- useEffect(() => {
- if (selectedPane !== 'terminal' || terminalFontsLoadedRef.current) return
-
- let stale = false
-
- const loadFontSuggestions = async (): Promise => {
- try {
- const fonts = await window.api.settings.listFonts()
- if (stale || fonts.length === 0) return
- terminalFontsLoadedRef.current = true
- setTerminalFontSuggestions((prev) => Array.from(new Set([...fonts, ...prev])).slice(0, 320))
- } catch {
- // Fall back to curated cross-platform suggestions.
- }
- }
-
- void loadFontSuggestions()
-
- return () => {
- stale = true
- }
- }, [selectedPane])
-
- if (settings !== prevSettings) {
- setPrevSettings(settings)
- if (settings) {
- const scrollbackMb = Math.max(1, Math.round(settings.terminalScrollbackBytes / 1_000_000))
- setScrollbackMode(
- SCROLLBACK_PRESETS_MB.includes(scrollbackMb as (typeof SCROLLBACK_PRESETS_MB)[number])
- ? 'preset'
- : 'custom'
- )
- }
- }
-
- useEffect(() => {
- let stale = false
- const checkHooks = async () => {
- const results = await Promise.all(
- repos.map(async (repo) => {
- try {
- const result = await window.api.hooks.check({ repoId: repo.id })
- return [repo.id, result] as const
- } catch {
- return [repo.id, { hasHooks: false, hooks: null }] as const
- }
- })
- )
-
- if (!stale) {
- setRepoHooksMap(Object.fromEntries(results))
- }
- }
-
- if (repos.length > 0) {
- checkHooks()
- } else {
- setRepoHooksMap({})
- }
-
- return () => {
- stale = true
- }
- }, [repos])
-
- useEffect(() => {
- let stale = false
-
- const loadDefaultBaseRef = async (repoId: string) => {
- try {
- const result = await window.api.repos.getBaseRefDefault({ repoId })
- if (stale) return
- setDefaultBaseRef(result)
- } catch {
- if (stale) return
- setDefaultBaseRef('origin/main')
- }
- }
-
- if (!selectedRepoId) {
- setDefaultBaseRef('origin/main')
- setBaseRefQuery('')
- setBaseRefResults([])
- } else {
- setBaseRefQuery('')
- setBaseRefResults([])
- void loadDefaultBaseRef(selectedRepoId)
- }
-
- return () => {
- stale = true
- }
- }, [selectedRepoId])
-
- useEffect(() => {
- if (!selectedRepoId) return
-
- const trimmedQuery = baseRefQuery.trim()
- if (trimmedQuery.length < 2) {
- setBaseRefResults([])
- setIsSearchingBaseRefs(false)
- return
- }
-
- let stale = false
- setIsSearchingBaseRefs(true)
-
- const timer = window.setTimeout(() => {
- void window.api.repos
- .searchBaseRefs({
- repoId: selectedRepoId,
- query: trimmedQuery,
- limit: 20
- })
- .then((results) => {
- if (!stale) {
- setBaseRefResults(results)
- }
- })
- .catch(() => {
- if (!stale) {
- setBaseRefResults([])
- }
- })
- .finally(() => {
- if (!stale) {
- setIsSearchingBaseRefs(false)
- }
- })
- }, 200)
-
- return () => {
- stale = true
- window.clearTimeout(timer)
- }
- }, [selectedRepoId, baseRefQuery])
-
- // Validate selectedRepoId against current repos (adjusting state during render)
- if (repos.length === 0) {
- if (selectedRepoId !== null) {
- setSelectedRepoId(null)
- if (selectedPane === 'repo') setSelectedPane('general')
- }
- } else if (!selectedRepoId || !repos.some((repo) => repo.id === selectedRepoId)) {
- setSelectedRepoId(repos[0].id)
- }
-
- const applyTheme = useCallback((theme: 'system' | 'dark' | 'light') => {
- const root = document.documentElement
- if (theme === 'dark') {
- root.classList.add('dark')
- } else if (theme === 'light') {
- root.classList.remove('dark')
- } else {
- const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
- if (prefersDark) {
- root.classList.add('dark')
- } else {
- root.classList.remove('dark')
- }
- }
- }, [])
-
- const handleBrowseWorkspace = async () => {
- const path = await window.api.repos.pickFolder()
- if (path) {
- updateSettings({ workspaceDir: path })
- }
- }
-
- const handleRemoveRepo = (repoId: string) => {
- if (confirmingRemove === repoId) {
- removeRepo(repoId)
- setConfirmingRemove(null)
- return
- }
-
- setConfirmingRemove(repoId)
- }
-
- const selectedRepo = repos.find((repo) => repo.id === selectedRepoId) ?? null
- const selectedYamlHooks = selectedRepo ? (repoHooksMap[selectedRepo.id]?.hooks ?? null) : null
- const showGeneralPane = selectedPane === 'general'
- const showAppearancePane = selectedPane === 'appearance'
- const showTerminalPane = selectedPane === 'terminal'
- const showRepoPane = selectedPane === 'repo' && !!selectedRepo
- const displayedGitUsername = (selectedRepo ?? repos[0])?.gitUsername ?? ''
- const effectiveBaseRef = selectedRepo?.worktreeBaseRef ?? defaultBaseRef
-
- const updateSelectedRepoHookSettings = (
- repo: Repo,
- updates: Omit, 'scripts'> & {
- scripts?: Partial
- }
- ) => {
- const nextSettings: RepoHookSettings = {
- ...DEFAULT_REPO_HOOK_SETTINGS,
- ...repo.hookSettings,
- ...updates,
- scripts: {
- ...DEFAULT_REPO_HOOK_SETTINGS.scripts,
- ...repo.hookSettings?.scripts,
- ...updates.scripts
- }
- }
-
- updateRepo(repo.id, {
- hookSettings: nextSettings
- })
- }
-
- if (!settings) {
- return (
-
- Loading settings...
-
- )
- }
-
- const darkPreviewAppearance = resolveEffectiveTerminalAppearance(
- {
- ...settings,
- theme: 'dark'
- },
- systemPrefersDark
- )
- const lightPreviewAppearance = resolveEffectiveTerminalAppearance(
- {
- ...settings,
- theme: 'light'
- },
- systemPrefersDark
- )
- const paneStyleOptions = resolvePaneStyleOptions(settings)
- const scrollbackMb = Math.max(1, Math.round(settings.terminalScrollbackBytes / 1_000_000))
- const scrollbackPresetSelection = SCROLLBACK_PRESETS_MB.includes(
- scrollbackMb as (typeof SCROLLBACK_PRESETS_MB)[number]
- )
- ? `${scrollbackMb}`
- : 'custom'
- const scrollbackToggleValue = scrollbackMode === 'custom' ? 'custom' : scrollbackPresetSelection
- const contentClassName = 'w-full max-w-5xl px-8'
- const pageHeader = showGeneralPane ? (
-
-
General
-
Workspace, naming, and updates.
-
- ) : showAppearancePane ? (
-
-
Appearance
-
Theme and UI scaling.
-
- ) : showTerminalPane ? (
-
-
Terminal
-
- Terminal appearance, previews, and defaults for new panes.
-
-
- ) : selectedRepo ? (
-
-
-
-
{selectedRepo.displayName}
-
-
{selectedRepo.path}
-
- ) : (
-
-
Repository Settings
-
Select a repository to edit its settings.
-
- )
-
- return (
-
-
-
-
setActiveView('terminal')}
- className="w-full justify-start gap-2 text-muted-foreground"
- >
-
- Back to app
-
-
-
-
-
-
-
setSelectedPane('general')}
- className={`flex w-full items-center rounded-lg px-3 py-2 text-left text-sm transition-colors ${
- showGeneralPane
- ? 'bg-accent font-medium text-accent-foreground'
- : 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
- }`}
- >
-
- General
-
-
setSelectedPane('appearance')}
- className={`flex w-full items-center rounded-lg px-3 py-2 text-left text-sm transition-colors ${
- showAppearancePane
- ? 'bg-accent font-medium text-accent-foreground'
- : 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
- }`}
- >
-
- Appearance
-
-
setSelectedPane('terminal')}
- className={`flex w-full items-center rounded-lg px-3 py-2 text-left text-sm transition-colors ${
- showTerminalPane
- ? 'bg-accent font-medium text-accent-foreground'
- : 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
- }`}
- >
-
- Terminal
-
-
-
-
-
- Repositories
-
-
- {repos.length === 0 ? (
-
No repositories added yet.
- ) : (
-
- {repos.map((repo) => (
- {
- setSelectedRepoId(repo.id)
- setSelectedPane('repo')
- }}
- className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
- showRepoPane && selectedRepoId === repo.id
- ? 'bg-accent font-medium text-accent-foreground'
- : 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
- }`}
- >
-
- {repo.displayName}
-
- ))}
-
- )}
-
-
-
-
-
-
-
-
-
-
- {showGeneralPane ? (
-
-
-
-
Workspace
-
- Configure where new worktrees are created.
-
-
-
-
-
Workspace Directory
-
- updateSettings({ workspaceDir: e.target.value })}
- className="flex-1 font-mono text-xs"
- />
-
-
- Browse
-
-
-
- Root directory where worktree folders are created.
-
-
-
-
-
-
Nest Workspaces
-
- Create worktrees inside a repo-named subfolder.
-
-
-
- updateSettings({
- nestWorkspaces: !settings.nestWorkspaces
- })
- }
- className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
- settings.nestWorkspaces ? 'bg-foreground' : 'bg-muted-foreground/30'
- }`}
- >
-
-
-
-
-
-
-
-
-
-
Branch Naming
-
- Prefix added to branch names when creating worktrees.
-
-
-
-
- {(['git-username', 'custom', 'none'] as const).map((option) => (
- updateSettings({ branchPrefix: option })}
- className={`rounded-sm px-3 py-1 text-sm transition-colors ${
- settings.branchPrefix === option
- ? 'bg-accent font-medium text-accent-foreground'
- : 'text-muted-foreground hover:text-foreground'
- }`}
- >
- {option === 'git-username'
- ? 'Git Username'
- : option === 'custom'
- ? 'Custom'
- : 'None'}
-
- ))}
-
- {(settings.branchPrefix === 'custom' ||
- settings.branchPrefix === 'git-username') && (
- updateSettings({ branchPrefixCustom: e.target.value })}
- placeholder={
- settings.branchPrefix === 'git-username'
- ? 'No git username configured'
- : 'e.g. feature'
- }
- className="max-w-xs"
- readOnly={settings.branchPrefix === 'git-username'}
- />
- )}
-
-
-
-
-
-
-
Updates
-
Check for new versions of Orca.
-
-
-
- window.api.updater.check()}
- disabled={
- updateStatus.state === 'checking' || updateStatus.state === 'downloading'
- }
- className="gap-2"
- >
- {updateStatus.state === 'checking' ? (
-
- ) : (
-
- )}
- Check for Updates
-
-
- {updateStatus.state === 'downloaded' ? (
- window.api.updater.quitAndInstall()}
- className="gap-2"
- >
-
- Restart to Update ({updateStatus.version})
-
- ) : null}
-
-
-
- {updateStatus.state === 'idle' &&
- 'Updates are checked automatically on launch.'}
- {updateStatus.state === 'checking' && 'Checking for updates...'}
- {updateStatus.state === 'available' &&
- `Version ${updateStatus.version} is available. Downloading...`}
- {updateStatus.state === 'not-available' && 'You\u2019re on the latest version.'}
- {updateStatus.state === 'downloading' &&
- `Downloading update... ${updateStatus.percent}%`}
- {updateStatus.state === 'downloaded' &&
- `Version ${updateStatus.version} is ready to install.`}
- {updateStatus.state === 'error' && `Update error: ${updateStatus.message}`}
-
-
-
- ) : showAppearancePane ? (
-
-
-
-
Theme
-
- Choose how Orca looks in the app window.
-
-
-
-
- {(['system', 'dark', 'light'] as const).map((option) => (
- {
- updateSettings({ theme: option })
- applyTheme(option)
- }}
- className={`rounded-sm px-3 py-1 text-sm capitalize transition-colors ${
- settings.theme === option
- ? 'bg-accent font-medium text-accent-foreground'
- : 'text-muted-foreground hover:text-foreground'
- }`}
- >
- {option}
-
- ))}
-
-
-
-
-
-
-
-
UI Zoom
-
- Scale the entire application interface. Use{' '}
- ⌘+ /{' '}
- ⌘- when not in a
- terminal pane.
-
-
-
-
-
-
- ) : showTerminalPane ? (
-
-
-
-
Typography
-
- Default terminal typography for new panes and live updates.
-
-
-
-
-
Font Size
-
-
{
- const next = Math.max(10, settings.terminalFontSize - 1)
- updateSettings({ terminalFontSize: next })
- }}
- disabled={settings.terminalFontSize <= 10}
- >
-
-
-
{
- const value = parseInt(e.target.value, 10)
- if (!Number.isNaN(value) && value >= 10 && value <= 24) {
- updateSettings({ terminalFontSize: value })
- }
- }}
- className="w-16 text-center tabular-nums"
- />
-
{
- const next = Math.min(24, settings.terminalFontSize + 1)
- updateSettings({ terminalFontSize: next })
- }}
- disabled={settings.terminalFontSize >= 24}
- >
-
-
-
px
-
-
-
-
- Font Family
- updateSettings({ terminalFontFamily: value })}
- />
-
-
-
-
-
-
-
-
Cursor
-
- Default cursor appearance for Orca terminal panes.
-
-
-
-
-
-
Cursor Shape
-
- {(['bar', 'block', 'underline'] as const).map((option) => (
- updateSettings({ terminalCursorStyle: option })}
- className={`rounded-sm px-3 py-1 text-sm capitalize transition-colors ${
- settings.terminalCursorStyle === option
- ? 'bg-accent font-medium text-accent-foreground'
- : 'text-muted-foreground hover:text-foreground'
- }`}
- >
- {option}
-
- ))}
-
-
-
-
-
-
Blinking Cursor
-
- Uses the blinking variant of the selected cursor shape.
-
-
-
- updateSettings({
- terminalCursorBlink: !settings.terminalCursorBlink
- })
- }
- className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
- settings.terminalCursorBlink ? 'bg-foreground' : 'bg-muted-foreground/30'
- }`}
- >
-
-
-
-
-
-
-
-
-
-
-
Pane Styling
-
- Control inactive pane dimming, divider thickness, and transition timing.
-
-
-
-
-
- updateSettings({
- terminalInactivePaneOpacity: clampNumber(value, 0, 1)
- })
- }
- />
-
- updateSettings({
- terminalDividerThicknessPx: clampNumber(value, 1, 32)
- })
- }
- />
-
-
-
-
-
-
-
- updateSettings({ terminalThemeDark: theme })}
- />
-
- updateSettings({ terminalDividerColorDark: value })}
- />
-
-
-
-
-
-
-
-
-
-
-
Use Separate Theme In Light Mode
-
- When disabled, light mode reuses the dark terminal theme.
-
-
-
- updateSettings({
- terminalUseSeparateLightTheme: !settings.terminalUseSeparateLightTheme
- })
- }
- className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
- settings.terminalUseSeparateLightTheme
- ? 'bg-foreground'
- : 'bg-muted-foreground/30'
- }`}
- >
-
-
-
-
-
-
-
-
- updateSettings({ terminalThemeLight: theme })}
- />
-
-
- updateSettings({ terminalDividerColorLight: value })
- }
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
Advanced
-
- Scrollback is bounded for stability. This setting applies to new terminal
- panes.
-
-
-
-
- Scrollback Size
- {
- if (!value) return
- if (value === 'custom') {
- setScrollbackMode('custom')
- return
- }
-
- setScrollbackMode('preset')
- updateSettings({
- terminalScrollbackBytes: Number(value) * 1_000_000
- })
- }}
- variant="outline"
- size="sm"
- className="h-8 flex-wrap"
- >
- {SCROLLBACK_PRESETS_MB.map((preset) => (
-
- {preset} MB
-
- ))}
-
- Custom
-
-
-
- {scrollbackMode === 'custom' ? (
-
- updateSettings({
- terminalScrollbackBytes: clampNumber(value, 1, 256) * 1_000_000
- })
- }
- />
- ) : null}
-
-
-
- ) : selectedRepo ? (
-
-
-
-
-
Identity
-
- Repo-specific display details for the sidebar and tabs.
-
-
-
-
handleRemoveRepo(selectedRepo.id)}
- onBlur={() => setConfirmingRemove(null)}
- className="gap-2"
- >
-
- {confirmingRemove === selectedRepo.id ? 'Confirm Remove' : 'Remove Repo'}
-
-
-
-
- Display Name
-
- updateRepo(selectedRepo.id, {
- displayName: e.target.value
- })
- }
- className="h-9 text-sm"
- />
-
-
-
-
Badge Color
-
- {REPO_COLORS.map((color) => (
- updateRepo(selectedRepo.id, { badgeColor: color })}
- className={`size-7 rounded-full transition-all ${
- selectedRepo.badgeColor === color
- ? 'ring-2 ring-foreground ring-offset-2 ring-offset-background'
- : 'hover:ring-1 hover:ring-muted-foreground hover:ring-offset-2 hover:ring-offset-background'
- }`}
- style={{ backgroundColor: color }}
- title={color}
- />
- ))}
-
-
-
-
-
Default Worktree Base
-
-
-
-
- {effectiveBaseRef}
-
-
- {selectedRepo.worktreeBaseRef
- ? 'Pinned for this repo'
- : `Following primary branch (${defaultBaseRef})`}
-
-
-
{
- setBaseRefQuery('')
- setBaseRefResults([])
- updateRepo(selectedRepo.id, {
- worktreeBaseRef: undefined
- })
- }}
- disabled={!selectedRepo.worktreeBaseRef}
- >
- Use Primary
-
-
-
-
-
setBaseRefQuery(e.target.value)}
- placeholder="Search branches by name..."
- className="max-w-md"
- />
-
Type at least 2 characters.
-
-
- {isSearchingBaseRefs ? (
-
Searching branches...
- ) : null}
-
- {!isSearchingBaseRefs && baseRefQuery.trim().length >= 2 ? (
- baseRefResults.length > 0 ? (
-
-
- {baseRefResults.map((ref) => (
- {
- setBaseRefQuery(ref)
- setBaseRefResults([])
- updateRepo(selectedRepo.id, {
- worktreeBaseRef: ref
- })
- }}
- className={`flex w-full items-center justify-between rounded-sm px-3 py-2 text-left text-sm transition-colors hover:bg-muted/60 ${
- selectedRepo.worktreeBaseRef === ref
- ? 'bg-accent text-accent-foreground'
- : 'text-foreground'
- }`}
- >
- {ref}
- {selectedRepo.worktreeBaseRef === ref ? (
-
- Current
-
- ) : null}
-
- ))}
-
-
- ) : (
-
- No matching branches found.
-
- )
- ) : null}
-
-
- New worktrees default to the repo primary branch unless you pin a different
- base here.
-
-
-
-
-
-
-
-
-
Hook Source
-
- Auto prefers `orca.yaml` when present, then falls back to the UI script.
- Override ignores YAML and only uses the UI script.
-
-
-
-
- {(['auto', 'override'] as const).map((mode) => (
- updateSelectedRepoHookSettings(selectedRepo, { mode })}
- className={`rounded-lg px-3 py-1.5 text-sm transition-colors ${
- selectedRepo.hookSettings?.mode === mode
- ? 'bg-accent font-medium text-accent-foreground'
- : 'text-muted-foreground hover:text-foreground'
- }`}
- >
- {mode === 'auto' ? 'Use YAML First' : 'Override in UI'}
-
- ))}
-
-
-
- {selectedYamlHooks ? (
-
-
- YAML hooks detected in `orca.yaml`
-
-
- {(['setup', 'archive'] as HookName[]).map((hookName) =>
- selectedYamlHooks.scripts[hookName] ? (
-
- {hookName}
-
- ) : null
- )}
-
-
- ) : (
-
No YAML hooks detected for this repo.
- )}
-
-
-
-
-
-
-
-
Lifecycle Hooks
-
- Write scripts directly in the UI. Each repo stores its own setup and archive
- hook script.
-
-
-
-
- {(['setup', 'archive'] as HookName[]).map((hookName) => (
-
- updateSelectedRepoHookSettings(selectedRepo, {
- scripts: hookName === 'setup' ? { setup: script } : { archive: script }
- })
- }
- />
- ))}
-
-
-
- ) : (
-
- Select a repository to edit its settings.
-
- )}
-
-
-
-
- )
-}
-
-function HookEditor({
- hookName,
- repo,
- yamlHooks,
- onScriptChange
-}: {
- hookName: HookName
- repo: Repo
- yamlHooks: OrcaHooks | null
- onScriptChange: (script: string) => void
-}): React.JSX.Element {
- const uiScript = repo.hookSettings?.scripts[hookName] ?? ''
- const yamlScript = yamlHooks?.scripts[hookName]
- const effectiveSource =
- repo.hookSettings?.mode === 'auto' && yamlScript ? 'yaml' : uiScript.trim() ? 'ui' : 'none'
-
- return (
-
-
-
-
{hookName}
-
- {hookName === 'setup'
- ? 'Runs after a worktree is created.'
- : 'Runs before a worktree is archived.'}
-
-
-
-
- {effectiveSource === 'yaml'
- ? 'Honoring YAML'
- : effectiveSource === 'ui'
- ? 'Using UI'
- : 'Inactive'}
-
-
-
- {yamlScript && (
-
-
-
- YAML Script
-
- Read-only from `orca.yaml`
-
-
- {yamlScript}
-
-
- )}
-
-
-
-
- UI Script
-
-
- {repo.hookSettings?.mode === 'auto' && yamlScript
- ? 'Stored as fallback until you switch to override.'
- : 'Editable script stored with this repo.'}
-
-
-
-
- )
-}
-
-export default Settings
diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx
index 6c580b892fd..697ae54a393 100644
--- a/src/renderer/src/components/Terminal.tsx
+++ b/src/renderer/src/components/Terminal.tsx
@@ -10,8 +10,8 @@ import {
DialogTitle
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
-import TabBar from './TabBar'
-import TerminalPane from './TerminalPane'
+import TabBar from './tab-bar/TabBar'
+import TerminalPane from './terminal-pane/TerminalPane'
const EditorPanel = lazy(() => import('./editor/EditorPanel'))
@@ -61,9 +61,13 @@ export default function Terminal(): React.JSX.Element | null {
)
const handleSaveDialogSave = useCallback(async () => {
- if (!saveDialogFileId) return
+ if (!saveDialogFileId) {
+ return
+ }
const file = useAppStore.getState().openFiles.find((f) => f.id === saveDialogFileId)
- if (!file) return
+ if (!file) {
+ return
+ }
// EditorPanel stores edit buffers internally — we need to read the current content from the editor.
// The simplest approach: dispatch a custom event that the MonacoEditor listens for to trigger save,
// then close. But that's complex. Instead, just save via the editor ref approach.
@@ -77,7 +81,9 @@ export default function Terminal(): React.JSX.Element | null {
}, [saveDialogFileId])
const handleSaveDialogDiscard = useCallback(() => {
- if (!saveDialogFileId) return
+ if (!saveDialogFileId) {
+ return
+ }
markFileDirty(saveDialogFileId, false)
closeFile(saveDialogFileId)
setSaveDialogFileId(null)
@@ -104,7 +110,9 @@ export default function Terminal(): React.JSX.Element | null {
// Auto-create first tab when worktree activates
useEffect(() => {
- if (!workspaceSessionReady) return
+ if (!workspaceSessionReady) {
+ return
+ }
if (!activeWorktreeId) {
initialTabCreationGuardRef.current = null
return
@@ -119,7 +127,9 @@ export default function Terminal(): React.JSX.Element | null {
// In React StrictMode (dev), mount effects are intentionally invoked twice.
// Track the worktree we already initialized so we only create one first tab.
- if (initialTabCreationGuardRef.current === activeWorktreeId) return
+ if (initialTabCreationGuardRef.current === activeWorktreeId) {
+ return
+ }
initialTabCreationGuardRef.current = activeWorktreeId
createTab(activeWorktreeId)
}, [workspaceSessionReady, activeWorktreeId, tabs.length, createTab])
@@ -127,13 +137,17 @@ export default function Terminal(): React.JSX.Element | null {
const totalTabs = tabs.length + openFiles.length
const handleNewTab = useCallback(() => {
- if (!activeWorktreeId) return
+ if (!activeWorktreeId) {
+ return
+ }
createTab(activeWorktreeId)
}, [activeWorktreeId, createTab])
const handleCloseTab = useCallback(
(tabId: string) => {
- if (!activeWorktreeId) return
+ if (!activeWorktreeId) {
+ return
+ }
const currentTabs = useAppStore.getState().tabsByWorktree[activeWorktreeId] ?? []
if (currentTabs.length <= 1) {
// Last tab - deactivate worktree
@@ -146,7 +160,9 @@ export default function Terminal(): React.JSX.Element | null {
if (tabId === useAppStore.getState().activeTabId) {
const idx = currentTabs.findIndex((t) => t.id === tabId)
const nextTab = currentTabs[idx + 1] ?? currentTabs[idx - 1]
- if (nextTab) setActiveTab(nextTab.id)
+ if (nextTab) {
+ setActiveTab(nextTab.id)
+ }
}
closeTab(tabId)
},
@@ -155,7 +171,9 @@ export default function Terminal(): React.JSX.Element | null {
const handlePtyExit = useCallback(
(tabId: string, ptyId: string) => {
- if (consumeSuppressedPtyExit(ptyId)) return
+ if (consumeSuppressedPtyExit(ptyId)) {
+ return
+ }
handleCloseTab(tabId)
},
[consumeSuppressedPtyExit, handleCloseTab]
@@ -163,7 +181,9 @@ export default function Terminal(): React.JSX.Element | null {
const handleCloseOthers = useCallback(
(tabId: string) => {
- if (!activeWorktreeId) return
+ if (!activeWorktreeId) {
+ return
+ }
const currentTabs = useAppStore.getState().tabsByWorktree[activeWorktreeId] ?? []
setActiveTab(tabId)
for (const tab of currentTabs) {
@@ -177,10 +197,14 @@ export default function Terminal(): React.JSX.Element | null {
const handleCloseTabsToRight = useCallback(
(tabId: string) => {
- if (!activeWorktreeId) return
+ if (!activeWorktreeId) {
+ return
+ }
const currentTabs = useAppStore.getState().tabsByWorktree[activeWorktreeId] ?? []
const index = currentTabs.findIndex((t) => t.id === tabId)
- if (index === -1) return
+ if (index === -1) {
+ return
+ }
const rightTabs = currentTabs.slice(index + 1)
for (const tab of rightTabs) {
closeTab(tab.id)
@@ -213,7 +237,9 @@ export default function Terminal(): React.JSX.Element | null {
// Keyboard shortcuts
useEffect(() => {
- if (!activeWorktreeId) return
+ if (!activeWorktreeId) {
+ return
+ }
const onKeyDown = (e: KeyboardEvent): void => {
// Cmd+T - new tab
@@ -262,7 +288,6 @@ export default function Terminal(): React.JSX.Element | null {
state.setActiveTabType('editor')
}
}
- return
}
}
window.addEventListener('keydown', onKeyDown, { capture: true })
@@ -281,7 +306,9 @@ export default function Terminal(): React.JSX.Element | null {
return () => window.removeEventListener('beforeunload', handler)
}, [])
- if (!activeWorktreeId) return null
+ if (!activeWorktreeId) {
+ return null
+ }
return (
@@ -367,7 +394,9 @@ export default function Terminal(): React.JSX.Element | null {
{
- if (!open) handleSaveDialogCancel()
+ if (!open) {
+ handleSaveDialogCancel()
+ }
}}
>
diff --git a/src/renderer/src/components/TerminalPane.tsx b/src/renderer/src/components/TerminalPane.tsx
deleted file mode 100644
index 13a9a18eef7..00000000000
--- a/src/renderer/src/components/TerminalPane.tsx
+++ /dev/null
@@ -1,1338 +0,0 @@
-import { useEffect, useRef, useState } from 'react'
-import { createPortal } from 'react-dom'
-import type { CSSProperties } from 'react'
-import type { ITheme } from '@xterm/xterm'
-import {
- Clipboard,
- Copy,
- Eraser,
- Maximize2,
- Minimize2,
- PanelBottomOpen,
- PanelRightOpen,
- X
-} from 'lucide-react'
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuShortcut,
- DropdownMenuTrigger
-} from '@/components/ui/dropdown-menu'
-import { TOGGLE_TERMINAL_PANE_EXPAND_EVENT } from '@/constants/terminal'
-import type {
- TerminalLayoutSnapshot,
- TerminalPaneLayoutNode,
- TerminalPaneSplitDirection
-} from '../../../shared/types'
-import { useAppStore } from '../store'
-import {
- DEFAULT_TERMINAL_DIVIDER_DARK,
- getCursorStyleSequence,
- getBuiltinTheme,
- normalizeColor,
- resolvePaneStyleOptions,
- resolveEffectiveTerminalAppearance
-} from '@/lib/terminal-theme'
-import { PaneManager, type ManagedPane } from '@/lib/pane-manager'
-import TerminalSearch from '@/components/TerminalSearch'
-
-type PtyTransport = {
- connect: (options: {
- url: string
- cols?: number
- rows?: number
- callbacks: {
- onConnect?: () => void
- onDisconnect?: () => void
- onData?: (data: string) => void
- onStatus?: (shell: string) => void
- onError?: (message: string, errors?: string[]) => void
- onExit?: (code: number) => void
- }
- }) => void | Promise
- disconnect: () => void
- sendInput: (data: string) => boolean
- resize: (
- cols: number,
- rows: number,
- meta?: { widthPx?: number; heightPx?: number; cellW?: number; cellH?: number }
- ) => boolean
- isConnected: () => boolean
- destroy?: () => void | Promise
-}
-
-// Singleton PTY event dispatcher — one global IPC listener per channel,
-// routes events to transports by PTY ID. Eliminates the N-listener problem
-// that triggers MaxListenersExceededWarning with many panes/tabs.
-const ptyDataHandlers = new Map void>()
-const ptyExitHandlers = new Map void>()
-let ptyDispatcherAttached = false
-
-function ensurePtyDispatcher(): void {
- if (ptyDispatcherAttached) return
- ptyDispatcherAttached = true
- window.api.pty.onData((payload) => {
- ptyDataHandlers.get(payload.id)?.(payload.data)
- })
- window.api.pty.onExit((payload) => {
- ptyExitHandlers.get(payload.id)?.(payload.code)
- })
-}
-
-const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus'
-const EMPTY_LAYOUT: TerminalLayoutSnapshot = {
- root: null,
- activeLeafId: null,
- expandedLeafId: null
-}
-
-const OSC_TITLE_RE = /\x1b\]([012]);([^\x07\x1b]*?)(?:\x07|\x1b\\)/g
-
-function extractLastOscTitle(data: string): string | null {
- let last: string | null = null
- let m: RegExpExecArray | null
- OSC_TITLE_RE.lastIndex = 0
- while ((m = OSC_TITLE_RE.exec(data)) !== null) {
- last = m[2]
- }
- return last
-}
-
-function createIpcPtyTransport(
- cwd?: string,
- onPtyExit?: (ptyId: string) => void,
- onTitleChange?: (title: string) => void,
- onPtySpawn?: (ptyId: string) => void,
- onBell?: () => void
-): PtyTransport {
- let connected = false
- let destroyed = false
- let ptyId: string | null = null
- let pendingEscape = false
- let inOsc = false
- let pendingOscEscape = false
- let storedCallbacks: {
- onConnect?: () => void
- onDisconnect?: () => void
- onData?: (data: string) => void
- onStatus?: (shell: string) => void
- onError?: (message: string, errors?: string[]) => void
- onExit?: (code: number) => void
- } = {}
-
- function unregisterPtyHandlers(id: string): void {
- ptyDataHandlers.delete(id)
- ptyExitHandlers.delete(id)
- }
-
- return {
- async connect(options) {
- storedCallbacks = options.callbacks
- ensurePtyDispatcher()
-
- try {
- const result = await window.api.pty.spawn({
- cols: options.cols ?? 80,
- rows: options.rows ?? 24,
- cwd
- })
-
- // If destroyed while spawn was in flight, kill the new pty and bail
- if (destroyed) {
- window.api.pty.kill(result.id)
- return
- }
-
- ptyId = result.id
- connected = true
- onPtySpawn?.(result.id)
-
- ptyDataHandlers.set(result.id, (data) => {
- storedCallbacks.onData?.(data)
- if (onTitleChange) {
- const title = extractLastOscTitle(data)
- if (title !== null) onTitleChange(title)
- }
- if (onBell && chunkContainsBell(data)) {
- onBell()
- }
- })
-
- const spawnedId = result.id
- ptyExitHandlers.set(spawnedId, (code) => {
- connected = false
- ptyId = null
- unregisterPtyHandlers(spawnedId)
- storedCallbacks.onExit?.(code)
- storedCallbacks.onDisconnect?.()
- onPtyExit?.(spawnedId)
- })
-
- storedCallbacks.onConnect?.()
- storedCallbacks.onStatus?.('shell')
- } catch (err) {
- const msg = err instanceof Error ? err.message : String(err)
- storedCallbacks.onError?.(msg)
- }
- },
-
- disconnect() {
- if (ptyId) {
- const id = ptyId
- window.api.pty.kill(id)
- connected = false
- ptyId = null
- unregisterPtyHandlers(id)
- storedCallbacks.onDisconnect?.()
- }
- },
-
- sendInput(data: string): boolean {
- if (!connected || !ptyId) return false
- window.api.pty.write(ptyId, data)
- return true
- },
-
- resize(cols: number, rows: number): boolean {
- if (!connected || !ptyId) return false
- window.api.pty.resize(ptyId, cols, rows)
- return true
- },
-
- isConnected() {
- return connected
- },
-
- destroy() {
- destroyed = true
- this.disconnect()
- }
- }
-
- function chunkContainsBell(data: string): boolean {
- for (let i = 0; i < data.length; i += 1) {
- const char = data[i]
-
- if (inOsc) {
- if (pendingOscEscape) {
- pendingOscEscape = char === '\x1b'
- if (char === '\\') {
- inOsc = false
- pendingOscEscape = false
- }
- continue
- }
-
- if (char === '\x07') {
- inOsc = false
- continue
- }
-
- pendingOscEscape = char === '\x1b'
- continue
- }
-
- if (pendingEscape) {
- pendingEscape = false
- if (char === ']') {
- inOsc = true
- pendingOscEscape = false
- } else if (char === '\x1b') {
- pendingEscape = true
- }
- continue
- }
-
- if (char === '\x1b') {
- pendingEscape = true
- continue
- }
-
- if (char === '\x07') return true
- }
-
- return false
- }
-}
-
-function paneLeafId(paneId: number): string {
- return `pane:${paneId}`
-}
-
-function buildFontFamily(fontFamily: string): string {
- const trimmed = fontFamily.trim()
- const parts = trimmed ? [`"${trimmed}"`] : []
- // Always include fallbacks
- if (!parts.some((p) => p.toLowerCase().includes('sf mono'))) {
- parts.push('"SF Mono"')
- }
- parts.push('Menlo', 'monospace')
- return parts.join(', ')
-}
-
-function getLayoutChildNodes(split: HTMLElement): HTMLElement[] {
- return Array.from(split.children).filter(
- (child): child is HTMLElement =>
- child instanceof HTMLElement &&
- (child.classList.contains('pane') || child.classList.contains('pane-split'))
- )
-}
-
-function serializePaneTree(node: HTMLElement | null): TerminalPaneLayoutNode | null {
- if (!node) return null
-
- if (node.classList.contains('pane')) {
- const paneId = Number(node.dataset.paneId ?? '')
- if (!Number.isFinite(paneId)) return null
- return { type: 'leaf', leafId: paneLeafId(paneId) }
- }
-
- if (!node.classList.contains('pane-split')) return null
- const [first, second] = getLayoutChildNodes(node)
- const firstNode = serializePaneTree(first ?? null)
- const secondNode = serializePaneTree(second ?? null)
- if (!firstNode || !secondNode) return null
-
- // Capture the flex ratio so resized panes survive serialization round-trips.
- // We read the computed flex-grow values to derive the first-child proportion.
- let ratio: number | undefined
- if (first && second) {
- const firstGrow = parseFloat(first.style.flex) || 1
- const secondGrow = parseFloat(second.style.flex) || 1
- const total = firstGrow + secondGrow
- if (total > 0) {
- const r = firstGrow / total
- // Only store if meaningfully different from 0.5 (default equal split)
- if (Math.abs(r - 0.5) > 0.005) {
- ratio = Math.round(r * 1000) / 1000
- }
- }
- }
-
- return {
- type: 'split',
- direction: node.classList.contains('is-horizontal') ? 'horizontal' : 'vertical',
- first: firstNode,
- second: secondNode,
- ...(ratio !== undefined && { ratio })
- }
-}
-
-function serializeTerminalLayout(
- root: HTMLDivElement | null,
- activePaneId: number | null,
- expandedPaneId: number | null
-): TerminalLayoutSnapshot {
- const rootNode = serializePaneTree(
- root?.firstElementChild instanceof HTMLElement ? root.firstElementChild : null
- )
- return {
- root: rootNode,
- activeLeafId: activePaneId === null ? null : paneLeafId(activePaneId),
- expandedLeafId: expandedPaneId === null ? null : paneLeafId(expandedPaneId)
- }
-}
-
-function replayTerminalLayout(
- manager: PaneManager,
- snapshot: TerminalLayoutSnapshot | null | undefined,
- focusInitialPane: boolean
-): Map {
- const paneByLeafId = new Map()
-
- const initialPane = manager.createInitialPane({ focus: focusInitialPane })
- if (!snapshot?.root) {
- paneByLeafId.set(paneLeafId(initialPane.id), initialPane.id)
- return paneByLeafId
- }
-
- const restoreNode = (node: TerminalPaneLayoutNode, paneId: number): void => {
- if (node.type === 'leaf') {
- paneByLeafId.set(node.leafId, paneId)
- return
- }
-
- const createdPane = manager.splitPane(paneId, node.direction as TerminalPaneSplitDirection, {
- ratio: node.ratio
- })
- if (!createdPane) {
- collectLeafIds(node, paneByLeafId, paneId)
- return
- }
-
- restoreNode(node.first, paneId)
- restoreNode(node.second, createdPane.id)
- }
-
- restoreNode(snapshot.root, initialPane.id)
- return paneByLeafId
-}
-
-function collectLeafIds(
- node: TerminalPaneLayoutNode,
- paneByLeafId: Map,
- paneId: number
-): void {
- if (node.type === 'leaf') {
- paneByLeafId.set(node.leafId, paneId)
- return
- }
- collectLeafIds(node.first, paneByLeafId, paneId)
- collectLeafIds(node.second, paneByLeafId, paneId)
-}
-
-interface TerminalPaneProps {
- tabId: string
- worktreeId: string
- cwd?: string
- isActive: boolean
- onPtyExit: (ptyId: string) => void
-}
-
-export default function TerminalPane({
- tabId,
- worktreeId,
- cwd,
- isActive,
- onPtyExit
-}: TerminalPaneProps): React.JSX.Element {
- const containerRef = useRef(null)
- const managerRef = useRef(null)
- const contextPaneIdRef = useRef(null)
- const wasActiveRef = useRef(false)
- const paneFontSizesRef = useRef>(new Map())
- const expandedPaneIdRef = useRef(null)
- const expandedStyleSnapshotRef = useRef>(
- new Map()
- )
- // Track transports per pane for PTY communication
- const paneTransportsRef = useRef>(new Map())
- // Buffer PTY data for background (non-visible) terminals to avoid
- // unnecessary parser/render work. Flushed when the tab becomes active.
- const pendingWritesRef = useRef>(new Map())
- const isActiveRef = useRef(isActive)
- isActiveRef.current = isActive
- const [terminalMenuOpen, setTerminalMenuOpen] = useState(false)
- const [terminalMenuPoint, setTerminalMenuPoint] = useState({ x: 0, y: 0 })
- const menuOpenedAtRef = useRef(0)
- const [expandedPaneId, setExpandedPaneId] = useState(null)
- const [searchOpen, setSearchOpen] = useState(false)
- const setTabPaneExpanded = useAppStore((s) => s.setTabPaneExpanded)
- const setTabCanExpandPane = useAppStore((s) => s.setTabCanExpandPane)
- const savedLayout = useAppStore((s) => s.terminalLayoutsByTabId[tabId] ?? EMPTY_LAYOUT)
- const setTabLayout = useAppStore((s) => s.setTabLayout)
- const initialLayoutRef = useRef(savedLayout)
-
- const persistLayoutSnapshot = (): void => {
- const manager = managerRef.current
- const container = containerRef.current
- if (!manager || !container) return
- const activePaneId = manager.getActivePane()?.id ?? manager.getPanes()[0]?.id ?? null
- setTabLayout(tabId, serializeTerminalLayout(container, activePaneId, expandedPaneIdRef.current))
- }
-
- const setExpandedPane = (paneId: number | null): void => {
- expandedPaneIdRef.current = paneId
- setExpandedPaneId(paneId)
- setTabPaneExpanded(tabId, paneId !== null)
- persistLayoutSnapshot()
- }
-
- const rememberPaneStyle = (
- snapshots: Map,
- el: HTMLElement
- ): void => {
- if (snapshots.has(el)) return
- snapshots.set(el, { display: el.style.display, flex: el.style.flex })
- }
-
- const restoreExpandedLayout = (): void => {
- const snapshots = expandedStyleSnapshotRef.current
- for (const [el, prev] of snapshots.entries()) {
- el.style.display = prev.display
- el.style.flex = prev.flex
- }
- snapshots.clear()
- }
-
- const applyExpandedLayout = (paneId: number): boolean => {
- const manager = managerRef.current
- const root = containerRef.current
- if (!manager || !root) return false
-
- const panes = manager.getPanes()
- if (panes.length <= 1) return false
- const targetPane = panes.find((pane) => pane.id === paneId)
- if (!targetPane) return false
-
- restoreExpandedLayout()
- const snapshots = expandedStyleSnapshotRef.current
- let current: HTMLElement | null = targetPane.container
- while (current && current !== root) {
- const parent = current.parentElement
- if (!parent) break
- for (const child of Array.from(parent.children)) {
- if (!(child instanceof HTMLElement)) continue
- rememberPaneStyle(snapshots, child)
- if (child === current) {
- child.style.display = ''
- child.style.flex = '1 1 auto'
- } else {
- child.style.display = 'none'
- }
- }
- current = parent
- }
- return true
- }
-
- const refreshPaneSizes = (focusActive: boolean): void => {
- requestAnimationFrame(() => {
- const manager = managerRef.current
- if (!manager) return
- const panes = manager.getPanes()
- for (const p of panes) {
- try {
- p.fitAddon.fit()
- } catch {
- /* container may not have dimensions */
- }
- }
- if (focusActive) {
- const active = manager.getActivePane() ?? panes[0]
- active?.terminal.focus()
- }
- })
- }
-
- const syncExpandedLayout = (): void => {
- const paneId = expandedPaneIdRef.current
- if (paneId === null) {
- restoreExpandedLayout()
- return
- }
-
- const manager = managerRef.current
- if (!manager) return
- const panes = manager.getPanes()
- if (panes.length <= 1 || !panes.some((pane) => pane.id === paneId)) {
- setExpandedPane(null)
- restoreExpandedLayout()
- return
- }
- applyExpandedLayout(paneId)
- }
-
- const syncCanExpandState = (): void => {
- const paneCount = managerRef.current?.getPanes().length ?? 1
- setTabCanExpandPane(tabId, paneCount > 1)
- }
-
- const toggleExpandPane = (paneId: number): void => {
- const manager = managerRef.current
- if (!manager) return
- const panes = manager.getPanes()
- if (panes.length <= 1) return
-
- const isAlreadyExpanded = expandedPaneIdRef.current === paneId
- if (isAlreadyExpanded) {
- setExpandedPane(null)
- restoreExpandedLayout()
- refreshPaneSizes(true)
- persistLayoutSnapshot()
- return
- }
-
- setExpandedPane(paneId)
- if (!applyExpandedLayout(paneId)) {
- setExpandedPane(null)
- restoreExpandedLayout()
- persistLayoutSnapshot()
- return
- }
- manager.setActivePane(paneId, { focus: true })
- refreshPaneSizes(true)
- persistLayoutSnapshot()
- }
-
- useEffect(() => {
- const closeMenu = (): void => {
- // Skip if we just opened (same frame / same event cycle)
- if (Date.now() - menuOpenedAtRef.current < 100) return
- setTerminalMenuOpen(false)
- }
- window.addEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu)
- return () => window.removeEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu)
- }, [])
-
- const updateTabTitle = useAppStore((s) => s.updateTabTitle)
- const updateTabPtyId = useAppStore((s) => s.updateTabPtyId)
- const clearTabPtyId = useAppStore((s) => s.clearTabPtyId)
- const markWorktreeUnreadFromBell = useAppStore((s) => s.markWorktreeUnreadFromBell)
- const settings = useAppStore((s) => s.settings)
- const [systemPrefersDark, setSystemPrefersDark] = useState(() =>
- typeof window !== 'undefined' && typeof window.matchMedia === 'function'
- ? window.matchMedia('(prefers-color-scheme: dark)').matches
- : true
- )
- const settingsRef = useRef(settings)
- settingsRef.current = settings
-
- // Use a ref so the PaneManager closure always calls the latest onPtyExit
- const onPtyExitRef = useRef(onPtyExit)
- onPtyExitRef.current = onPtyExit
-
- useEffect(() => {
- const media = window.matchMedia('(prefers-color-scheme: dark)')
- const handleChange = (event: MediaQueryListEvent): void => {
- setSystemPrefersDark(event.matches)
- }
- setSystemPrefersDark(media.matches)
- media.addEventListener('change', handleChange)
- return () => media.removeEventListener('change', handleChange)
- }, [])
-
- const applyTerminalAppearance = (manager: PaneManager): void => {
- const currentSettings = settingsRef.current
- if (!currentSettings) return
-
- const appearance = resolveEffectiveTerminalAppearance(currentSettings, systemPrefersDark)
- const paneStyles = resolvePaneStyleOptions(currentSettings)
- const cursorSequence = getCursorStyleSequence(
- currentSettings.terminalCursorStyle,
- currentSettings.terminalCursorBlink
- )
- const theme: ITheme | null = appearance.theme ?? getBuiltinTheme(appearance.themeName)
- const paneBackground = theme?.background ?? '#000000'
-
- for (const pane of manager.getPanes()) {
- if (theme) {
- pane.terminal.options.theme = theme
- }
- pane.terminal.options.cursorStyle = currentSettings.terminalCursorStyle
- pane.terminal.options.cursorBlink = currentSettings.terminalCursorBlink
- const paneSize = paneFontSizesRef.current.get(pane.id)
- pane.terminal.options.fontSize = paneSize ?? currentSettings.terminalFontSize
- try {
- pane.fitAddon.fit()
- } catch {
- /* ignore */
- }
- // Send cursor style sequence to PTY
- const transport = paneTransportsRef.current.get(pane.id)
- transport?.sendInput(cursorSequence)
- }
-
- manager.setPaneStyleOptions({
- splitBackground: paneBackground,
- paneBackground,
- inactivePaneOpacity: paneStyles.inactivePaneOpacity,
- activePaneOpacity: paneStyles.activePaneOpacity,
- opacityTransitionMs: paneStyles.opacityTransitionMs,
- dividerThicknessPx: paneStyles.dividerThicknessPx
- })
- }
-
- // Connect a pane's terminal to a PTY via IPC transport
- const connectPanePty = (pane: ManagedPane, manager: PaneManager): void => {
- const onExit = (ptyId: string): void => {
- // Always clear the dead PTY ID from the store to avoid stale state
- clearTabPtyId(tabId, ptyId)
-
- const panes = manager.getPanes()
- if (panes.length <= 1) {
- onPtyExitRef.current(ptyId)
- return
- }
- manager.closePane(pane.id)
- }
-
- const onTitleChange = (title: string): void => {
- updateTabTitle(tabId, title)
- }
-
- const onPtySpawn = (ptyId: string): void => updateTabPtyId(tabId, ptyId)
- const onBell = (): void => markWorktreeUnreadFromBell(worktreeId)
-
- const transport = createIpcPtyTransport(cwd, onExit, onTitleChange, onPtySpawn, onBell)
- paneTransportsRef.current.set(pane.id, transport)
-
- // Wire terminal → PTY
- pane.terminal.onData((data) => {
- transport.sendInput(data)
- })
-
- // Wire terminal resize → PTY resize
- pane.terminal.onResize(({ cols, rows }) => {
- transport.resize(cols, rows)
- })
-
- // Defer PTY spawn to next frame so FitAddon has time to calculate
- // the correct terminal dimensions from the laid-out container. Without
- // this, the PTY is spawned with the default 80×24 and never resized
- // to fill the actual container.
- pendingWritesRef.current.set(pane.id, '')
- requestAnimationFrame(() => {
- // Fit first so cols/rows reflect the real container size
- try {
- pane.fitAddon.fit()
- } catch {
- /* ignore */
- }
- const cols = pane.terminal.cols
- const rows = pane.terminal.rows
- transport.connect({
- url: '',
- cols,
- rows,
- callbacks: {
- onData: (data) => {
- if (isActiveRef.current) {
- // Visible — write immediately for responsive output
- pane.terminal.write(data)
- } else {
- // Hidden — buffer data to avoid unnecessary render work.
- // The buffer is flushed in one write() call when the tab
- // becomes visible, which is much cheaper than N small writes.
- const pending = pendingWritesRef.current
- pending.set(pane.id, (pending.get(pane.id) ?? '') + data)
- }
- }
- }
- })
- })
- }
-
- // Initialize PaneManager instance once
- useEffect(() => {
- const container = containerRef.current
- if (!container) return
- let resizeRaf: number | null = null
-
- const queueResizeAll = (focusActive: boolean): void => {
- if (resizeRaf !== null) cancelAnimationFrame(resizeRaf)
- resizeRaf = requestAnimationFrame(() => {
- resizeRaf = null
- const manager = managerRef.current
- if (!manager) return
- const panes = manager.getPanes()
- for (const p of panes) {
- try {
- p.fitAddon.fit()
- } catch {
- /* ignore */
- }
- }
- if (focusActive) {
- const active = manager.getActivePane() ?? panes[0]
- active?.terminal.focus()
- }
- })
- }
-
- let shouldPersistLayout = false
-
- const manager = new PaneManager(container, {
- onPaneCreated: (pane) => {
- // Apply appearance before connecting PTY
- applyTerminalAppearance(manager)
- // Connect PTY
- connectPanePty(pane, manager)
- queueResizeAll(true)
- },
- onPaneClosed: (paneId) => {
- // Clean up transport for closed pane
- const transport = paneTransportsRef.current.get(paneId)
- if (transport) {
- transport.destroy?.()
- paneTransportsRef.current.delete(paneId)
- }
- paneFontSizesRef.current.delete(paneId)
- pendingWritesRef.current.delete(paneId)
- },
- onActivePaneChange: () => {
- if (shouldPersistLayout) persistLayoutSnapshot()
- },
- onLayoutChanged: () => {
- syncExpandedLayout()
- syncCanExpandState()
- queueResizeAll(false)
- if (shouldPersistLayout) persistLayoutSnapshot()
- },
- terminalOptions: () => {
- const currentSettings = settingsRef.current
- return {
- fontSize: currentSettings?.terminalFontSize ?? 14,
- fontFamily: buildFontFamily(currentSettings?.terminalFontFamily ?? 'SF Mono'),
- // Convert byte budget to line count. ~200 bytes/line is a reasonable
- // average for typical terminal output (columns × ~2 bytes + overhead).
- // Default 10MB → ~50K lines; cap at 50K to keep memory reasonable.
- scrollback: Math.min(
- 50_000,
- Math.max(
- 1000,
- Math.round((currentSettings?.terminalScrollbackBytes ?? 10_000_000) / 200)
- )
- ),
- cursorStyle: currentSettings?.terminalCursorStyle ?? 'bar',
- cursorBlink: currentSettings?.terminalCursorBlink ?? true
- }
- },
- onLinkClick: (url) => {
- window.api.shell.openExternal(url)
- }
- })
-
- managerRef.current = manager
- const restoredPaneByLeafId = replayTerminalLayout(manager, initialLayoutRef.current, isActive)
- const restoredActivePaneId =
- (initialLayoutRef.current.activeLeafId
- ? restoredPaneByLeafId.get(initialLayoutRef.current.activeLeafId)
- : null) ??
- manager.getActivePane()?.id ??
- manager.getPanes()[0]?.id ??
- null
- if (restoredActivePaneId !== null) {
- manager.setActivePane(restoredActivePaneId, { focus: isActive })
- }
- const restoredExpandedPaneId = initialLayoutRef.current.expandedLeafId
- ? (restoredPaneByLeafId.get(initialLayoutRef.current.expandedLeafId) ?? null)
- : null
- if (restoredExpandedPaneId !== null && manager.getPanes().length > 1) {
- setExpandedPane(restoredExpandedPaneId)
- applyExpandedLayout(restoredExpandedPaneId)
- } else {
- setExpandedPane(null)
- }
- shouldPersistLayout = true
- syncCanExpandState()
- applyTerminalAppearance(manager)
- queueResizeAll(isActive)
- persistLayoutSnapshot()
-
- return () => {
- if (resizeRaf !== null) cancelAnimationFrame(resizeRaf)
- restoreExpandedLayout()
- // Destroy all transports
- for (const transport of paneTransportsRef.current.values()) {
- transport.destroy?.()
- }
- paneTransportsRef.current.clear()
- pendingWritesRef.current.clear()
- manager.destroy()
- managerRef.current = null
- setTabPaneExpanded(tabId, false)
- setTabCanExpandPane(tabId, false)
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [tabId, cwd])
-
- useEffect(() => {
- const manager = managerRef.current
- if (!manager || !settings) return
- applyTerminalAppearance(manager)
- // Update font family on all panes
- const fontFamily = buildFontFamily(settings.terminalFontFamily)
- for (const pane of manager.getPanes()) {
- pane.terminal.options.fontFamily = fontFamily
- try {
- pane.fitAddon.fit()
- } catch {
- /* ignore */
- }
- }
- }, [settings, systemPrefersDark])
-
- // Per-pane font zoom via Cmd+Plus/Minus/0
- useEffect(() => {
- if (!isActive) return
- const MIN_FONT_SIZE = 8
- const MAX_FONT_SIZE = 32
- const FONT_SIZE_STEP = 1
-
- return window.api.ui.onTerminalZoom((direction) => {
- const manager = managerRef.current
- if (!manager) return
- const pane = manager.getActivePane()
- if (!pane) return
-
- const globalSize = settingsRef.current?.terminalFontSize ?? 14
- const currentSize = paneFontSizesRef.current.get(pane.id) ?? globalSize
-
- let nextSize: number
- if (direction === 'reset') {
- nextSize = globalSize
- paneFontSizesRef.current.delete(pane.id)
- } else if (direction === 'in') {
- nextSize = Math.min(MAX_FONT_SIZE, currentSize + FONT_SIZE_STEP)
- paneFontSizesRef.current.set(pane.id, nextSize)
- } else {
- nextSize = Math.max(MIN_FONT_SIZE, currentSize - FONT_SIZE_STEP)
- paneFontSizesRef.current.set(pane.id, nextSize)
- }
-
- pane.terminal.options.fontSize = nextSize
- try {
- pane.fitAddon.fit()
- } catch {
- /* ignore */
- }
- })
- }, [isActive])
-
- // Handle focus, resize, and WebGL suspend/resume when tab becomes active/inactive
- useEffect(() => {
- const manager = managerRef.current
- if (!manager) return
-
- if (isActive) {
- // Resume GPU rendering — recreate WebGL addons that were disposed
- manager.resumeRendering()
-
- // Flush any buffered PTY data that arrived while hidden
- for (const [paneId, buf] of pendingWritesRef.current.entries()) {
- if (buf.length > 0) {
- const pane = manager.getPanes().find((p) => p.id === paneId)
- if (pane) pane.terminal.write(buf)
- pendingWritesRef.current.set(paneId, '')
- }
- }
-
- // Ensure size/focus is correct both on initial mount and tab activation.
- requestAnimationFrame(() => {
- const panes = manager.getPanes()
- for (const p of panes) {
- try {
- p.fitAddon.fit()
- } catch {
- /* ignore */
- }
- }
- const active = manager.getActivePane() ?? panes[0]
- if (active) {
- active.terminal.focus()
- }
- })
- } else if (wasActiveRef.current) {
- // Went from active → inactive: free GPU contexts
- manager.suspendRendering()
- }
- wasActiveRef.current = isActive
- }, [isActive])
-
- useEffect(() => {
- const onToggleExpand = (event: Event): void => {
- const detail = (event as CustomEvent<{ tabId?: string }>).detail
- if (!detail?.tabId || detail.tabId !== tabId) return
- const manager = managerRef.current
- if (!manager) return
- const panes = manager.getPanes()
- if (panes.length < 2) return
- const pane = manager.getActivePane() ?? panes[0]
- if (!pane) return
- toggleExpandPane(pane.id)
- }
-
- window.addEventListener(TOGGLE_TERMINAL_PANE_EXPAND_EVENT, onToggleExpand)
- return () => window.removeEventListener(TOGGLE_TERMINAL_PANE_EXPAND_EVENT, onToggleExpand)
- }, [tabId])
-
- // ResizeObserver to keep terminal sized to container
- useEffect(() => {
- if (!isActive) return
-
- const container = containerRef.current
- if (!container) return
-
- const ro = new ResizeObserver(() => {
- const manager = managerRef.current
- if (!manager) return
- const panes = manager.getPanes()
- for (const p of panes) {
- try {
- p.fitAddon.fit()
- } catch {
- /* ignore */
- }
- }
- })
- ro.observe(container)
- return () => ro.disconnect()
- }, [isActive])
-
- // Terminal pane shortcuts handled at window capture phase so they remain
- // reliable even when focus is inside the canvas/IME internals.
- useEffect(() => {
- if (!isActive) return
-
- const onKeyDown = (e: KeyboardEvent): void => {
- if (e.repeat) return
- if (!e.metaKey || e.altKey || e.ctrlKey) return
-
- const manager = managerRef.current
- if (!manager) return
-
- // Cmd+F opens search
- if (!e.shiftKey && e.key.toLowerCase() === 'f') {
- e.preventDefault()
- e.stopPropagation()
- setSearchOpen((prev) => !prev)
- return
- }
-
- // Cmd+K clears active pane screen + scrollback.
- if (!e.shiftKey && e.key.toLowerCase() === 'k') {
- e.preventDefault()
- e.stopPropagation()
- const pane = manager.getActivePane() ?? manager.getPanes()[0]
- if (pane) {
- pane.terminal.clear()
- }
- return
- }
-
- // Cmd+[ / Cmd+] cycles active split pane focus.
- if (!e.shiftKey && (e.code === 'BracketLeft' || e.code === 'BracketRight')) {
- const panes = manager.getPanes()
- if (panes.length < 2) return
- e.preventDefault()
- e.stopPropagation()
-
- // Collapse expanded pane before switching
- if (expandedPaneIdRef.current !== null) {
- setExpandedPane(null)
- restoreExpandedLayout()
- refreshPaneSizes(true)
- persistLayoutSnapshot()
- }
-
- const activeId = manager.getActivePane()?.id ?? panes[0].id
- const currentIdx = panes.findIndex((p) => p.id === activeId)
- if (currentIdx === -1) return
-
- const dir = e.code === 'BracketRight' ? 1 : -1
- const nextPane = panes[(currentIdx + dir + panes.length) % panes.length]
- manager.setActivePane(nextPane.id, { focus: true })
- return
- }
-
- // Cmd+Shift+Enter expands/collapses the active pane to full terminal area.
- if (e.shiftKey && e.key === 'Enter' && (e.code === 'Enter' || e.code === 'NumpadEnter')) {
- const panes = manager.getPanes()
- if (panes.length < 2) return
- e.preventDefault()
- e.stopPropagation()
- const pane = manager.getActivePane() ?? panes[0]
- if (!pane) return
- toggleExpandPane(pane.id)
- return
- }
-
- // Cmd+W closes only the active split pane and prevents the tab-level
- // handler from closing the entire terminal tab.
- if (!e.shiftKey && e.key.toLowerCase() === 'w') {
- const panes = manager.getPanes()
- if (panes.length < 2) return
- e.preventDefault()
- e.stopPropagation()
- const pane = manager.getActivePane() ?? panes[0]
- if (!pane) return
- manager.closePane(pane.id)
- return
- }
-
- // Cmd+D / Cmd+Shift+D split the active pane in the focused tab only.
- if (e.key.toLowerCase() === 'd') {
- e.preventDefault()
- e.stopPropagation()
- const pane = manager.getActivePane() ?? manager.getPanes()[0]
- if (!pane) return
- manager.splitPane(pane.id, e.shiftKey ? 'horizontal' : 'vertical')
- }
- }
-
- // Ctrl+Backspace → send \x17 (backward-kill-word) to PTY.
- const onCtrlBackspace = (e: KeyboardEvent): void => {
- if (!e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return
- if (e.key !== 'Backspace') return
-
- const manager = managerRef.current
- if (!manager) return
-
- e.preventDefault()
- e.stopPropagation()
- const pane = manager.getActivePane() ?? manager.getPanes()[0]
- if (!pane) return
- const transport = paneTransportsRef.current.get(pane.id)
- transport?.sendInput('\x17')
- }
-
- // Alt+Backspace → send ESC + DEL (\x1b\x7f, backward-kill-word) to PTY.
- const onAltBackspace = (e: KeyboardEvent): void => {
- if (!e.altKey || e.metaKey || e.ctrlKey || e.shiftKey) return
- if (e.key !== 'Backspace') return
-
- const manager = managerRef.current
- if (!manager) return
-
- e.preventDefault()
- e.stopPropagation()
- const pane = manager.getActivePane() ?? manager.getPanes()[0]
- if (!pane) return
- const transport = paneTransportsRef.current.get(pane.id)
- transport?.sendInput('\x1b\x7f')
- }
-
- // Shift+Enter → insert a literal newline into the shell command line.
- const onShiftEnter = (e: KeyboardEvent): void => {
- if (!e.shiftKey || e.metaKey || e.altKey || e.ctrlKey) return
- if (e.key !== 'Enter') return
-
- const manager = managerRef.current
- if (!manager) return
-
- e.preventDefault()
- e.stopPropagation()
- const pane = manager.getActivePane() ?? manager.getPanes()[0]
- if (!pane) return
- const transport = paneTransportsRef.current.get(pane.id)
- transport?.sendInput('\x16\x0a')
- }
-
- window.addEventListener('keydown', onKeyDown, { capture: true })
- window.addEventListener('keydown', onCtrlBackspace, { capture: true })
- window.addEventListener('keydown', onAltBackspace, { capture: true })
- window.addEventListener('keydown', onShiftEnter, { capture: true })
- return () => {
- window.removeEventListener('keydown', onKeyDown, { capture: true })
- window.removeEventListener('keydown', onCtrlBackspace, { capture: true })
- window.removeEventListener('keydown', onAltBackspace, { capture: true })
- window.removeEventListener('keydown', onShiftEnter, { capture: true })
- }
- }, [isActive])
-
- const resolveMenuPane = () => {
- const manager = managerRef.current
- if (!manager) return null
- const panes = manager.getPanes()
-
- if (contextPaneIdRef.current !== null) {
- const clickedPane = panes.find((p) => p.id === contextPaneIdRef.current) ?? null
- if (clickedPane) return clickedPane
- }
- return manager.getActivePane() ?? panes[0] ?? null
- }
-
- const handleCopy = async (): Promise => {
- const pane = resolveMenuPane()
- if (!pane) return
- const selection = pane.terminal.getSelection()
- if (selection) {
- await navigator.clipboard.writeText(selection)
- }
- }
-
- const handlePaste = async (): Promise => {
- const pane = resolveMenuPane()
- if (!pane) return
- const text = await navigator.clipboard.readText()
- if (text) {
- const transport = paneTransportsRef.current.get(pane.id)
- transport?.sendInput(text)
- }
- }
-
- const handleSplitRight = (): void => {
- const pane = resolveMenuPane()
- if (!pane) return
- managerRef.current?.splitPane(pane.id, 'vertical')
- }
-
- const handleSplitDown = (): void => {
- const pane = resolveMenuPane()
- if (!pane) return
- managerRef.current?.splitPane(pane.id, 'horizontal')
- }
-
- const handleClosePane = (): void => {
- const pane = resolveMenuPane()
- if (!pane) return
- const panes = managerRef.current?.getPanes() ?? []
- if (panes.length <= 1) return
- managerRef.current?.closePane(pane.id)
- }
-
- const handleClearScreen = (): void => {
- const pane = resolveMenuPane()
- if (!pane) return
- pane.terminal.clear()
- }
-
- const handleToggleExpand = (): void => {
- const pane = resolveMenuPane()
- if (!pane) return
- toggleExpandPane(pane.id)
- }
-
- const paneCount = managerRef.current?.getPanes().length ?? 1
- const canClosePane = paneCount > 1
- const canExpandPane = paneCount > 1
- const menuPaneId = resolveMenuPane()?.id ?? null
- const menuPaneIsExpanded = menuPaneId !== null && menuPaneId === expandedPaneId
- const effectiveAppearance = settings
- ? resolveEffectiveTerminalAppearance(settings, systemPrefersDark)
- : null
- const terminalContainerStyle: CSSProperties = {
- display: isActive ? 'flex' : 'none',
- ['--orca-terminal-divider-color' as string]:
- effectiveAppearance?.dividerColor ?? DEFAULT_TERMINAL_DIVIDER_DARK,
- ['--orca-terminal-divider-color-strong' as string]: normalizeColor(
- effectiveAppearance?.dividerColor,
- DEFAULT_TERMINAL_DIVIDER_DARK
- )
- }
-
- // Get the search addon for the active pane and its container for portal
- const activePane = managerRef.current?.getActivePane()
- const activeSearchAddon = activePane?.searchAddon ?? null
- const activePaneContainer = activePane?.container ?? null
-
- // Drag & drop file paths into terminal.
- // The preload script handles dragover/drop (File.path is only available there),
- // sends paths to main process, which relays them here via IPC.
- useEffect(() => {
- if (!isActive) return
-
- const shellEscape = (p: string): string => {
- if (/^[a-zA-Z0-9_./@:-]+$/.test(p)) return p
- return "'" + p.replace(/'/g, "'\\''") + "'"
- }
-
- return window.api.ui.onFileDrop(({ path: filePath }) => {
- const manager = managerRef.current
- if (!manager) return
- const pane = manager.getActivePane() ?? manager.getPanes()[0]
- if (!pane) return
- const transport = paneTransportsRef.current.get(pane.id)
- if (!transport) return
- transport.sendInput(shellEscape(filePath))
- })
- }, [isActive])
-
- return (
- <>
- {
- event.preventDefault()
- menuOpenedAtRef.current = Date.now()
- window.dispatchEvent(new Event(CLOSE_ALL_CONTEXT_MENUS_EVENT))
-
- const manager = managerRef.current
- if (!manager) {
- contextPaneIdRef.current = null
- return
- }
-
- const target = event.target
- if (!(target instanceof Node)) {
- contextPaneIdRef.current = null
- return
- }
- const clickedPane =
- manager.getPanes().find((pane) => pane.container.contains(target)) ?? null
- contextPaneIdRef.current = clickedPane?.id ?? null
-
- const bounds = event.currentTarget.getBoundingClientRect()
- setTerminalMenuPoint({ x: event.clientX - bounds.left, y: event.clientY - bounds.top })
- setTerminalMenuOpen(true)
- }}
- />
- {activePaneContainer &&
- createPortal(
-
setSearchOpen(false)}
- searchAddon={activeSearchAddon}
- />,
- activePaneContainer
- )}
- {
- if (!open && Date.now() - menuOpenedAtRef.current < 100) return
- setTerminalMenuOpen(open)
- }}
- modal={false}
- >
-
-
-
- {
- // Prevent Radix from moving focus back to the hidden trigger;
- // let xterm keep focus naturally.
- e.preventDefault()
- }}
- onFocusOutside={(e) => {
- // xterm reclaims focus after the contextmenu event; don't let
- // Radix treat that as a dismiss signal.
- e.preventDefault()
- }}
- >
- void handleCopy()}>
-
- Copy
- ⌘C
-
- void handlePaste()}>
-
- Paste
- ⌘V
-
-
-
-
- Split Right
- ⌘D
-
-
-
- Split Down
- ⌘⇧D
-
- {canExpandPane && (
-
- {menuPaneIsExpanded ? : }
- {menuPaneIsExpanded ? 'Collapse Pane' : 'Expand Pane'}
- ⌘⇧↩
-
- )}
- {canClosePane && (
-
-
- Close Pane
-
- )}
-
-
-
- Clear Screen
-
-
-
- >
- )
-}
diff --git a/src/renderer/src/components/settings/AppearancePane.tsx b/src/renderer/src/components/settings/AppearancePane.tsx
new file mode 100644
index 00000000000..f4ba81ee648
--- /dev/null
+++ b/src/renderer/src/components/settings/AppearancePane.tsx
@@ -0,0 +1,61 @@
+import type { GlobalSettings } from '../../../../shared/types'
+import { Separator } from '../ui/separator'
+import { UIZoomControl } from './UIZoomControl'
+
+type AppearancePaneProps = {
+ settings: GlobalSettings
+ updateSettings: (updates: Partial) => void
+ applyTheme: (theme: 'system' | 'dark' | 'light') => void
+}
+
+export function AppearancePane({
+ settings,
+ updateSettings,
+ applyTheme
+}: AppearancePaneProps): React.JSX.Element {
+ return (
+
+
+
+
Theme
+
Choose how Orca looks in the app window.
+
+
+
+ {(['system', 'dark', 'light'] as const).map((option) => (
+ {
+ updateSettings({ theme: option })
+ applyTheme(option)
+ }}
+ className={`rounded-sm px-3 py-1 text-sm capitalize transition-colors ${
+ settings.theme === option
+ ? 'bg-accent font-medium text-accent-foreground'
+ : 'text-muted-foreground hover:text-foreground'
+ }`}
+ >
+ {option}
+
+ ))}
+
+
+
+
+
+
+
+
UI Zoom
+
+ Scale the entire application interface. Use{' '}
+ ⌘+ /{' '}
+ ⌘- when not in a terminal
+ pane.
+
+
+
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/settings/GeneralPane.tsx b/src/renderer/src/components/settings/GeneralPane.tsx
new file mode 100644
index 00000000000..4d6f955e396
--- /dev/null
+++ b/src/renderer/src/components/settings/GeneralPane.tsx
@@ -0,0 +1,185 @@
+import type { GlobalSettings } from '../../../../shared/types'
+import { Button } from '../ui/button'
+import { Input } from '../ui/input'
+import { Label } from '../ui/label'
+import { Separator } from '../ui/separator'
+import { Download, FolderOpen, Loader2, RefreshCw } from 'lucide-react'
+import { useAppStore } from '../../store'
+
+type GeneralPaneProps = {
+ settings: GlobalSettings
+ updateSettings: (updates: Partial) => void
+ displayedGitUsername: string
+}
+
+export function GeneralPane({
+ settings,
+ updateSettings,
+ displayedGitUsername
+}: GeneralPaneProps): React.JSX.Element {
+ const updateStatus = useAppStore((s) => s.updateStatus)
+
+ const handleBrowseWorkspace = async () => {
+ const path = await window.api.repos.pickFolder()
+ if (path) {
+ updateSettings({ workspaceDir: path })
+ }
+ }
+
+ return (
+
+
+
+
Workspace
+
+ Configure where new worktrees are created.
+
+
+
+
+
Workspace Directory
+
+ updateSettings({ workspaceDir: e.target.value })}
+ className="flex-1 font-mono text-xs"
+ />
+
+
+ Browse
+
+
+
+ Root directory where worktree folders are created.
+
+
+
+
+
+
Nest Workspaces
+
+ Create worktrees inside a repo-named subfolder.
+
+
+
+ updateSettings({
+ nestWorkspaces: !settings.nestWorkspaces
+ })
+ }
+ className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
+ settings.nestWorkspaces ? 'bg-foreground' : 'bg-muted-foreground/30'
+ }`}
+ >
+
+
+
+
+
+
+
+
+
+
Branch Naming
+
+ Prefix added to branch names when creating worktrees.
+
+
+
+
+ {(['git-username', 'custom', 'none'] as const).map((option) => (
+ updateSettings({ branchPrefix: option })}
+ className={`rounded-sm px-3 py-1 text-sm transition-colors ${
+ settings.branchPrefix === option
+ ? 'bg-accent font-medium text-accent-foreground'
+ : 'text-muted-foreground hover:text-foreground'
+ }`}
+ >
+ {option === 'git-username' ? 'Git Username' : option === 'custom' ? 'Custom' : 'None'}
+
+ ))}
+
+ {(settings.branchPrefix === 'custom' || settings.branchPrefix === 'git-username') && (
+ updateSettings({ branchPrefixCustom: e.target.value })}
+ placeholder={
+ settings.branchPrefix === 'git-username'
+ ? 'No git username configured'
+ : 'e.g. feature'
+ }
+ className="max-w-xs"
+ readOnly={settings.branchPrefix === 'git-username'}
+ />
+ )}
+
+
+
+
+
+
+
Updates
+
Check for new versions of Orca.
+
+
+
+ window.api.updater.check()}
+ disabled={updateStatus.state === 'checking' || updateStatus.state === 'downloading'}
+ className="gap-2"
+ >
+ {updateStatus.state === 'checking' ? (
+
+ ) : (
+
+ )}
+ Check for Updates
+
+
+ {updateStatus.state === 'downloaded' ? (
+ window.api.updater.quitAndInstall()}
+ className="gap-2"
+ >
+
+ Restart to Update ({updateStatus.version})
+
+ ) : null}
+
+
+
+ {updateStatus.state === 'idle' && 'Updates are checked automatically on launch.'}
+ {updateStatus.state === 'checking' && 'Checking for updates...'}
+ {updateStatus.state === 'available' &&
+ `Version ${updateStatus.version} is available. Downloading...`}
+ {updateStatus.state === 'not-available' && 'You\u2019re on the latest version.'}
+ {updateStatus.state === 'downloading' && `Downloading update... ${updateStatus.percent}%`}
+ {updateStatus.state === 'downloaded' &&
+ `Version ${updateStatus.version} is ready to install.`}
+ {updateStatus.state === 'error' && `Update error: ${updateStatus.message}`}
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/settings/HookEditor.tsx b/src/renderer/src/components/settings/HookEditor.tsx
new file mode 100644
index 00000000000..61f90670b1b
--- /dev/null
+++ b/src/renderer/src/components/settings/HookEditor.tsx
@@ -0,0 +1,89 @@
+import type { OrcaHooks, Repo } from '../../../../shared/types'
+import { Label } from '../ui/label'
+import type { HookName } from './SettingsConstants'
+
+export function HookEditor({
+ hookName,
+ repo,
+ yamlHooks,
+ onScriptChange
+}: {
+ hookName: HookName
+ repo: Repo
+ yamlHooks: OrcaHooks | null
+ onScriptChange: (script: string) => void
+}): React.JSX.Element {
+ const uiScript = repo.hookSettings?.scripts[hookName] ?? ''
+ const yamlScript = yamlHooks?.scripts[hookName]
+ const effectiveSource =
+ repo.hookSettings?.mode === 'auto' && yamlScript ? 'yaml' : uiScript.trim() ? 'ui' : 'none'
+
+ return (
+
+
+
+
{hookName}
+
+ {hookName === 'setup'
+ ? 'Runs after a worktree is created.'
+ : 'Runs before a worktree is archived.'}
+
+
+
+
+ {effectiveSource === 'yaml'
+ ? 'Honoring YAML'
+ : effectiveSource === 'ui'
+ ? 'Using UI'
+ : 'Inactive'}
+
+
+
+ {yamlScript && (
+
+
+
+ YAML Script
+
+ Read-only from `orca.yaml`
+
+
+ {yamlScript}
+
+
+ )}
+
+
+
+
+ UI Script
+
+
+ {repo.hookSettings?.mode === 'auto' && yamlScript
+ ? 'Stored as fallback until you switch to override.'
+ : 'Editable script stored with this repo.'}
+
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/settings/RepositoryPane.tsx b/src/renderer/src/components/settings/RepositoryPane.tsx
new file mode 100644
index 00000000000..f24b3fd57f4
--- /dev/null
+++ b/src/renderer/src/components/settings/RepositoryPane.tsx
@@ -0,0 +1,349 @@
+import { useEffect, useState } from 'react'
+import type { OrcaHooks, Repo, RepoHookSettings } from '../../../../shared/types'
+import { REPO_COLORS } from '../../../../shared/constants'
+import { ScrollArea } from '../ui/scroll-area'
+import { Button } from '../ui/button'
+import { Input } from '../ui/input'
+import { Label } from '../ui/label'
+import { Separator } from '../ui/separator'
+import { Trash2 } from 'lucide-react'
+import { HookEditor } from './HookEditor'
+import { DEFAULT_REPO_HOOK_SETTINGS } from './SettingsConstants'
+import type { HookName } from './SettingsConstants'
+
+type RepositoryPaneProps = {
+ repo: Repo
+ yamlHooks: OrcaHooks | null
+ updateRepo: (repoId: string, updates: Partial) => void
+ removeRepo: (repoId: string) => void
+}
+
+export function RepositoryPane({
+ repo,
+ yamlHooks,
+ updateRepo,
+ removeRepo
+}: RepositoryPaneProps): React.JSX.Element {
+ const [confirmingRemove, setConfirmingRemove] = useState(null)
+ const [defaultBaseRef, setDefaultBaseRef] = useState('origin/main')
+ const [baseRefQuery, setBaseRefQuery] = useState('')
+ const [baseRefResults, setBaseRefResults] = useState([])
+ const [isSearchingBaseRefs, setIsSearchingBaseRefs] = useState(false)
+
+ useEffect(() => {
+ let stale = false
+
+ const loadDefaultBaseRef = async (repoId: string) => {
+ try {
+ const result = await window.api.repos.getBaseRefDefault({ repoId })
+ if (stale) {
+ return
+ }
+ setDefaultBaseRef(result)
+ } catch {
+ if (stale) {
+ return
+ }
+ setDefaultBaseRef('origin/main')
+ }
+ }
+
+ setBaseRefQuery('')
+ setBaseRefResults([])
+ void loadDefaultBaseRef(repo.id)
+
+ return () => {
+ stale = true
+ }
+ }, [repo.id])
+
+ useEffect(() => {
+ const trimmedQuery = baseRefQuery.trim()
+ if (trimmedQuery.length < 2) {
+ setBaseRefResults([])
+ setIsSearchingBaseRefs(false)
+ return
+ }
+
+ let stale = false
+ setIsSearchingBaseRefs(true)
+
+ const timer = window.setTimeout(() => {
+ void window.api.repos
+ .searchBaseRefs({
+ repoId: repo.id,
+ query: trimmedQuery,
+ limit: 20
+ })
+ .then((results) => {
+ if (!stale) {
+ setBaseRefResults(results)
+ }
+ })
+ .catch(() => {
+ if (!stale) {
+ setBaseRefResults([])
+ }
+ })
+ .finally(() => {
+ if (!stale) {
+ setIsSearchingBaseRefs(false)
+ }
+ })
+ }, 200)
+
+ return () => {
+ stale = true
+ window.clearTimeout(timer)
+ }
+ }, [repo.id, baseRefQuery])
+
+ const effectiveBaseRef = repo.worktreeBaseRef ?? defaultBaseRef
+
+ const handleRemoveRepo = (repoId: string) => {
+ if (confirmingRemove === repoId) {
+ removeRepo(repoId)
+ setConfirmingRemove(null)
+ return
+ }
+
+ setConfirmingRemove(repoId)
+ }
+
+ const updateSelectedRepoHookSettings = (
+ updates: Omit, 'scripts'> & {
+ scripts?: Partial
+ }
+ ) => {
+ const nextSettings: RepoHookSettings = {
+ ...DEFAULT_REPO_HOOK_SETTINGS,
+ ...repo.hookSettings,
+ ...updates,
+ scripts: {
+ ...DEFAULT_REPO_HOOK_SETTINGS.scripts,
+ ...repo.hookSettings?.scripts,
+ ...updates.scripts
+ }
+ }
+
+ updateRepo(repo.id, {
+ hookSettings: nextSettings
+ })
+ }
+
+ return (
+
+
+
+
+
Identity
+
+ Repo-specific display details for the sidebar and tabs.
+
+
+
+
handleRemoveRepo(repo.id)}
+ onBlur={() => setConfirmingRemove(null)}
+ className="gap-2"
+ >
+
+ {confirmingRemove === repo.id ? 'Confirm Remove' : 'Remove Repo'}
+
+
+
+
+ Display Name
+
+ updateRepo(repo.id, {
+ displayName: e.target.value
+ })
+ }
+ className="h-9 text-sm"
+ />
+
+
+
+
Badge Color
+
+ {REPO_COLORS.map((color) => (
+ updateRepo(repo.id, { badgeColor: color })}
+ className={`size-7 rounded-full transition-all ${
+ repo.badgeColor === color
+ ? 'ring-2 ring-foreground ring-offset-2 ring-offset-background'
+ : 'hover:ring-1 hover:ring-muted-foreground hover:ring-offset-2 hover:ring-offset-background'
+ }`}
+ style={{ backgroundColor: color }}
+ title={color}
+ />
+ ))}
+
+
+
+
+
Default Worktree Base
+
+
+
+
{effectiveBaseRef}
+
+ {repo.worktreeBaseRef
+ ? 'Pinned for this repo'
+ : `Following primary branch (${defaultBaseRef})`}
+
+
+
{
+ setBaseRefQuery('')
+ setBaseRefResults([])
+ updateRepo(repo.id, {
+ worktreeBaseRef: undefined
+ })
+ }}
+ disabled={!repo.worktreeBaseRef}
+ >
+ Use Primary
+
+
+
+
+
setBaseRefQuery(e.target.value)}
+ placeholder="Search branches by name..."
+ className="max-w-md"
+ />
+
Type at least 2 characters.
+
+
+ {isSearchingBaseRefs ? (
+
Searching branches...
+ ) : null}
+
+ {!isSearchingBaseRefs && baseRefQuery.trim().length >= 2 ? (
+ baseRefResults.length > 0 ? (
+
+
+ {baseRefResults.map((ref) => (
+ {
+ setBaseRefQuery(ref)
+ setBaseRefResults([])
+ updateRepo(repo.id, {
+ worktreeBaseRef: ref
+ })
+ }}
+ className={`flex w-full items-center justify-between rounded-sm px-3 py-2 text-left text-sm transition-colors hover:bg-muted/60 ${
+ repo.worktreeBaseRef === ref
+ ? 'bg-accent text-accent-foreground'
+ : 'text-foreground'
+ }`}
+ >
+ {ref}
+ {repo.worktreeBaseRef === ref ? (
+ Current
+ ) : null}
+
+ ))}
+
+
+ ) : (
+
No matching branches found.
+ )
+ ) : null}
+
+
+ New worktrees default to the repo primary branch unless you pin a different base here.
+
+
+
+
+
+
+
+
+
Hook Source
+
+ Auto prefers `orca.yaml` when present, then falls back to the UI script. Override
+ ignores YAML and only uses the UI script.
+
+
+
+
+ {(['auto', 'override'] as const).map((mode) => (
+ updateSelectedRepoHookSettings({ mode })}
+ className={`rounded-lg px-3 py-1.5 text-sm transition-colors ${
+ repo.hookSettings?.mode === mode
+ ? 'bg-accent font-medium text-accent-foreground'
+ : 'text-muted-foreground hover:text-foreground'
+ }`}
+ >
+ {mode === 'auto' ? 'Use YAML First' : 'Override in UI'}
+
+ ))}
+
+
+
+ {yamlHooks ? (
+
+
YAML hooks detected in `orca.yaml`
+
+ {(['setup', 'archive'] as HookName[]).map((hookName) =>
+ yamlHooks.scripts[hookName] ? (
+
+ {hookName}
+
+ ) : null
+ )}
+
+
+ ) : (
+
No YAML hooks detected for this repo.
+ )}
+
+
+
+
+
+
+
+
Lifecycle Hooks
+
+ Write scripts directly in the UI. Each repo stores its own setup and archive hook
+ script.
+
+
+
+
+ {(['setup', 'archive'] as HookName[]).map((hookName) => (
+
+ updateSelectedRepoHookSettings({
+ scripts: hookName === 'setup' ? { setup: script } : { archive: script }
+ })
+ }
+ />
+ ))}
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx
new file mode 100644
index 00000000000..07917b5d71c
--- /dev/null
+++ b/src/renderer/src/components/settings/Settings.tsx
@@ -0,0 +1,337 @@
+import { useEffect, useState, useCallback, useRef } from 'react'
+import type { OrcaHooks } from '../../../../shared/types'
+import { useAppStore } from '../../store'
+import { ScrollArea } from '../ui/scroll-area'
+import { Button } from '../ui/button'
+import { ArrowLeft, Palette, SlidersHorizontal, SquareTerminal } from 'lucide-react'
+import { getSystemPrefersDark } from '@/lib/terminal-theme'
+import { SCROLLBACK_PRESETS_MB, getFallbackTerminalFonts } from './SettingsConstants'
+import { GeneralPane } from './GeneralPane'
+import { AppearancePane } from './AppearancePane'
+import { TerminalPane } from './TerminalPane'
+import { RepositoryPane } from './RepositoryPane'
+
+function Settings(): React.JSX.Element {
+ const settings = useAppStore((s) => s.settings)
+ const updateSettings = useAppStore((s) => s.updateSettings)
+ const fetchSettings = useAppStore((s) => s.fetchSettings)
+ const setActiveView = useAppStore((s) => s.setActiveView)
+ const repos = useAppStore((s) => s.repos)
+ const updateRepo = useAppStore((s) => s.updateRepo)
+ const removeRepo = useAppStore((s) => s.removeRepo)
+
+ const [selectedPane, setSelectedPane] = useState<'general' | 'appearance' | 'terminal' | 'repo'>(
+ 'general'
+ )
+ const [selectedRepoId, setSelectedRepoId] = useState(null)
+ const [repoHooksMap, setRepoHooksMap] = useState<
+ Record
+ >({})
+ const [systemPrefersDark, setSystemPrefersDark] = useState(getSystemPrefersDark())
+ const [scrollbackMode, setScrollbackMode] = useState<'preset' | 'custom'>('preset')
+ const [prevSettings, setPrevSettings] = useState(settings)
+ const [terminalFontSuggestions, setTerminalFontSuggestions] = useState(
+ getFallbackTerminalFonts()
+ )
+ const terminalFontsLoadedRef = useRef(false)
+
+ useEffect(() => {
+ fetchSettings()
+ }, [fetchSettings])
+
+ useEffect(() => {
+ const media = window.matchMedia('(prefers-color-scheme: dark)')
+ const handleChange = (event: MediaQueryListEvent): void => {
+ setSystemPrefersDark(event.matches)
+ }
+ setSystemPrefersDark(media.matches)
+ media.addEventListener('change', handleChange)
+ return () => media.removeEventListener('change', handleChange)
+ }, [])
+
+ useEffect(() => {
+ if (selectedPane !== 'terminal' || terminalFontsLoadedRef.current) {
+ return
+ }
+
+ let stale = false
+
+ const loadFontSuggestions = async (): Promise => {
+ try {
+ const fonts = await window.api.settings.listFonts()
+ if (stale || fonts.length === 0) {
+ return
+ }
+ terminalFontsLoadedRef.current = true
+ setTerminalFontSuggestions((prev) => Array.from(new Set([...fonts, ...prev])).slice(0, 320))
+ } catch {
+ // Fall back to curated cross-platform suggestions.
+ }
+ }
+
+ void loadFontSuggestions()
+
+ return () => {
+ stale = true
+ }
+ }, [selectedPane])
+
+ if (settings !== prevSettings) {
+ setPrevSettings(settings)
+ if (settings) {
+ const scrollbackMb = Math.max(1, Math.round(settings.terminalScrollbackBytes / 1_000_000))
+ setScrollbackMode(
+ SCROLLBACK_PRESETS_MB.includes(scrollbackMb as (typeof SCROLLBACK_PRESETS_MB)[number])
+ ? 'preset'
+ : 'custom'
+ )
+ }
+ }
+
+ useEffect(() => {
+ let stale = false
+ const checkHooks = async () => {
+ const results = await Promise.all(
+ repos.map(async (repo) => {
+ try {
+ const result = await window.api.hooks.check({ repoId: repo.id })
+ return [repo.id, result] as const
+ } catch {
+ return [repo.id, { hasHooks: false, hooks: null }] as const
+ }
+ })
+ )
+
+ if (!stale) {
+ setRepoHooksMap(Object.fromEntries(results))
+ }
+ }
+
+ if (repos.length > 0) {
+ checkHooks()
+ } else {
+ setRepoHooksMap({})
+ }
+
+ return () => {
+ stale = true
+ }
+ }, [repos])
+
+ // Validate selectedRepoId against current repos (adjusting state during render)
+ if (repos.length === 0) {
+ if (selectedRepoId !== null) {
+ setSelectedRepoId(null)
+ if (selectedPane === 'repo') {
+ setSelectedPane('general')
+ }
+ }
+ } else if (!selectedRepoId || !repos.some((repo) => repo.id === selectedRepoId)) {
+ setSelectedRepoId(repos[0].id)
+ }
+
+ const applyTheme = useCallback((theme: 'system' | 'dark' | 'light') => {
+ const root = document.documentElement
+ if (theme === 'dark') {
+ root.classList.add('dark')
+ } else if (theme === 'light') {
+ root.classList.remove('dark')
+ } else {
+ const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
+ if (prefersDark) {
+ root.classList.add('dark')
+ } else {
+ root.classList.remove('dark')
+ }
+ }
+ }, [])
+
+ const selectedRepo = repos.find((repo) => repo.id === selectedRepoId) ?? null
+ const selectedYamlHooks = selectedRepo ? (repoHooksMap[selectedRepo.id]?.hooks ?? null) : null
+ const showGeneralPane = selectedPane === 'general'
+ const showAppearancePane = selectedPane === 'appearance'
+ const showTerminalPane = selectedPane === 'terminal'
+ const showRepoPane = selectedPane === 'repo' && !!selectedRepo
+ const displayedGitUsername = (selectedRepo ?? repos[0])?.gitUsername ?? ''
+
+ if (!settings) {
+ return (
+
+ Loading settings...
+
+ )
+ }
+
+ const contentClassName = 'w-full max-w-5xl px-8'
+ const pageHeader = showGeneralPane ? (
+
+
General
+
Workspace, naming, and updates.
+
+ ) : showAppearancePane ? (
+
+
Appearance
+
Theme and UI scaling.
+
+ ) : showTerminalPane ? (
+
+
Terminal
+
+ Terminal appearance, previews, and defaults for new panes.
+
+
+ ) : selectedRepo ? (
+
+
+
+
{selectedRepo.displayName}
+
+
{selectedRepo.path}
+
+ ) : (
+
+
Repository Settings
+
Select a repository to edit its settings.
+
+ )
+
+ return (
+
+
+
+
setActiveView('terminal')}
+ className="w-full justify-start gap-2 text-muted-foreground"
+ >
+
+ Back to app
+
+
+
+
+
+
+
setSelectedPane('general')}
+ className={`flex w-full items-center rounded-lg px-3 py-2 text-left text-sm transition-colors ${
+ showGeneralPane
+ ? 'bg-accent font-medium text-accent-foreground'
+ : 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
+ }`}
+ >
+
+ General
+
+
setSelectedPane('appearance')}
+ className={`flex w-full items-center rounded-lg px-3 py-2 text-left text-sm transition-colors ${
+ showAppearancePane
+ ? 'bg-accent font-medium text-accent-foreground'
+ : 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
+ }`}
+ >
+
+ Appearance
+
+
setSelectedPane('terminal')}
+ className={`flex w-full items-center rounded-lg px-3 py-2 text-left text-sm transition-colors ${
+ showTerminalPane
+ ? 'bg-accent font-medium text-accent-foreground'
+ : 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
+ }`}
+ >
+
+ Terminal
+
+
+
+
+
+ Repositories
+
+
+ {repos.length === 0 ? (
+
No repositories added yet.
+ ) : (
+
+ {repos.map((repo) => (
+ {
+ setSelectedRepoId(repo.id)
+ setSelectedPane('repo')
+ }}
+ className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
+ showRepoPane && selectedRepoId === repo.id
+ ? 'bg-accent font-medium text-accent-foreground'
+ : 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
+ }`}
+ >
+
+ {repo.displayName}
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ {showGeneralPane ? (
+
+ ) : showAppearancePane ? (
+
+ ) : showTerminalPane ? (
+
+ ) : selectedRepo ? (
+
+ ) : (
+
+ Select a repository to edit its settings.
+
+ )}
+
+
+
+
+ )
+}
+
+export default Settings
diff --git a/src/renderer/src/components/settings/SettingsConstants.ts b/src/renderer/src/components/settings/SettingsConstants.ts
new file mode 100644
index 00000000000..b10fb63ff7e
--- /dev/null
+++ b/src/renderer/src/components/settings/SettingsConstants.ts
@@ -0,0 +1,41 @@
+import type { OrcaHooks } from '../../../../shared/types'
+import { getDefaultRepoHookSettings } from '../../../../shared/constants'
+
+export type HookName = keyof OrcaHooks['scripts']
+export const DEFAULT_REPO_HOOK_SETTINGS = getDefaultRepoHookSettings()
+export const MAX_THEME_RESULTS = 80
+export const MAX_FONT_RESULTS = 12
+export const SCROLLBACK_PRESETS_MB = [10, 25, 50, 100, 250] as const
+export const ZOOM_STEP = 0.5
+export const ZOOM_MIN = -3
+export const ZOOM_MAX = 5
+
+export function zoomLevelToPercent(level: number): number {
+ return Math.round(100 * Math.pow(1.2, level))
+}
+
+export function getFallbackTerminalFonts(): string[] {
+ const nav =
+ typeof navigator !== 'undefined'
+ ? (navigator as Navigator & { userAgentData?: { platform?: string } })
+ : null
+ const platform = nav ? (nav.userAgentData?.platform ?? nav.platform ?? '') : ''
+ const normalizedPlatform = platform.toLowerCase()
+
+ if (normalizedPlatform.includes('mac')) {
+ return ['SF Mono', 'Menlo', 'Monaco', 'JetBrains Mono', 'Fira Code']
+ }
+
+ if (normalizedPlatform.includes('win')) {
+ return ['Cascadia Mono', 'Consolas', 'Lucida Console', 'JetBrains Mono', 'Fira Code']
+ }
+
+ return [
+ 'JetBrains Mono',
+ 'Fira Code',
+ 'DejaVu Sans Mono',
+ 'Liberation Mono',
+ 'Ubuntu Mono',
+ 'Noto Sans Mono'
+ ]
+}
diff --git a/src/renderer/src/components/settings/SettingsFormControls.tsx b/src/renderer/src/components/settings/SettingsFormControls.tsx
new file mode 100644
index 00000000000..7acac4a4da7
--- /dev/null
+++ b/src/renderer/src/components/settings/SettingsFormControls.tsx
@@ -0,0 +1,303 @@
+import { useEffect, useState, useMemo, useRef } from 'react'
+import { ScrollArea } from '../ui/scroll-area'
+import { Input } from '../ui/input'
+import { Label } from '../ui/label'
+import { Check, ChevronsUpDown, CircleX } from 'lucide-react'
+import { BUILTIN_TERMINAL_THEME_NAMES, normalizeColor } from '@/lib/terminal-theme'
+import { MAX_THEME_RESULTS, MAX_FONT_RESULTS } from './SettingsConstants'
+
+type ThemePickerProps = {
+ label: string
+ description: string
+ selectedTheme: string
+ query: string
+ onQueryChange: (value: string) => void
+ onSelectTheme: (theme: string) => void
+}
+
+type ColorFieldProps = {
+ label: string
+ description: string
+ value: string
+ fallback: string
+ onChange: (value: string) => void
+}
+
+type NumberFieldProps = {
+ label: string
+ description: string
+ value: number
+ defaultValue?: number
+ min: number
+ max: number
+ step?: number
+ onChange: (value: number) => void
+ suffix?: string
+}
+
+type FontAutocompleteProps = {
+ value: string
+ suggestions: string[]
+ onChange: (value: string) => void
+}
+
+export function ThemePicker({
+ label,
+ description,
+ selectedTheme,
+ query,
+ onQueryChange,
+ onSelectTheme
+}: ThemePickerProps): React.JSX.Element {
+ const normalizedQuery = query.trim().toLowerCase()
+ const filteredThemes = BUILTIN_TERMINAL_THEME_NAMES.filter((theme) =>
+ theme.toLowerCase().includes(normalizedQuery)
+ ).slice(0, MAX_THEME_RESULTS)
+
+ return (
+
+
+
{label}
+
{description}
+
+
onQueryChange(e.target.value)}
+ placeholder="Search builtin themes"
+ />
+
+
+ Selected: {selectedTheme}
+
+ Showing {filteredThemes.length}
+ {normalizedQuery
+ ? ` matching "${query.trim()}"`
+ : ` of ${BUILTIN_TERMINAL_THEME_NAMES.length}`}
+
+
+
+
+ {filteredThemes.map((theme) => (
+
onSelectTheme(theme)}
+ className={`flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors ${
+ selectedTheme === theme
+ ? 'bg-accent font-medium text-accent-foreground'
+ : 'hover:bg-muted/60'
+ }`}
+ >
+ {theme}
+ {selectedTheme === theme ? (
+
+ Current
+
+ ) : null}
+
+ ))}
+ {filteredThemes.length === 0 ? (
+
No themes found.
+ ) : null}
+
+
+
+
+ )
+}
+
+export function ColorField({
+ label,
+ description,
+ value,
+ fallback,
+ onChange
+}: ColorFieldProps): React.JSX.Element {
+ const normalized = normalizeColor(value, fallback)
+
+ return (
+
+
+
{label}
+
{description}
+
+
+ onChange(e.target.value)}
+ className="h-9 w-12 rounded-md border border-input bg-transparent p-1"
+ />
+ onChange(e.target.value)}
+ placeholder={fallback}
+ className="max-w-xs font-mono text-xs"
+ />
+
+
+ )
+}
+
+export function NumberField({
+ label,
+ description,
+ value,
+ defaultValue,
+ min,
+ max,
+ step = 1,
+ onChange,
+ suffix
+}: NumberFieldProps): React.JSX.Element {
+ return (
+
+
+
{label}
+
{description}
+
+
+ {
+ const next = Number(e.target.value)
+ if (!Number.isFinite(next)) {
+ return
+ }
+ onChange(next)
+ }}
+ className="number-input-clean w-28 tabular-nums"
+ />
+ {suffix ? {suffix} : null}
+
+
+ Current: {value}
+ {defaultValue !== undefined ? ` · Default: ${defaultValue}` : ''}
+
+
+ )
+}
+
+export function FontAutocomplete({
+ value,
+ suggestions,
+ onChange
+}: FontAutocompleteProps): React.JSX.Element {
+ const [query, setQuery] = useState(value)
+ const [prevValue, setPrevValue] = useState(value)
+ const [open, setOpen] = useState(false)
+ const rootRef = useRef(null)
+
+ if (value !== prevValue) {
+ setPrevValue(value)
+ setQuery(value)
+ }
+
+ useEffect(() => {
+ if (!open) {
+ return
+ }
+
+ const handlePointerDown = (event: MouseEvent): void => {
+ if (!rootRef.current?.contains(event.target as Node)) {
+ setOpen(false)
+ }
+ }
+
+ document.addEventListener('mousedown', handlePointerDown)
+ return () => document.removeEventListener('mousedown', handlePointerDown)
+ }, [open])
+
+ const normalizedQuery = query.trim().toLowerCase()
+ const filteredSuggestions = useMemo(() => {
+ const startsWith = suggestions.filter((font) => font.toLowerCase().startsWith(normalizedQuery))
+ const includes = suggestions.filter(
+ (font) =>
+ !font.toLowerCase().startsWith(normalizedQuery) &&
+ font.toLowerCase().includes(normalizedQuery)
+ )
+ const ordered = normalizedQuery ? [...startsWith, ...includes] : suggestions
+ return ordered.slice(0, MAX_FONT_RESULTS)
+ }, [suggestions, normalizedQuery])
+
+ const commitValue = (nextValue: string): void => {
+ setQuery(nextValue)
+ onChange(nextValue)
+ setOpen(false)
+ }
+
+ return (
+
+
+
{
+ const next = e.target.value
+ setQuery(next)
+ onChange(next)
+ setOpen(true)
+ }}
+ onFocus={() => setOpen(true)}
+ placeholder="SF Mono"
+ className="pr-18"
+ />
+
+ {query ? (
+ {
+ setQuery('')
+ onChange('')
+ setOpen(true)
+ }}
+ className="rounded-sm p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
+ aria-label="Clear font selection"
+ title="Clear"
+ >
+
+
+ ) : null}
+ setOpen((current) => !current)}
+ className="rounded-sm p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
+ aria-label="Toggle font suggestions"
+ title="Fonts"
+ >
+
+
+
+
+
+ {open ? (
+
+
+
+ {filteredSuggestions.length > 0 ? (
+ filteredSuggestions.map((font) => (
+
e.preventDefault()}
+ onClick={() => commitValue(font)}
+ className={`flex w-full items-center justify-between rounded-sm px-3 py-2 text-left text-sm transition-colors ${
+ font === value ? 'bg-accent text-accent-foreground' : 'hover:bg-muted/60'
+ }`}
+ >
+ {font}
+ {font === value ? : null}
+
+ ))
+ ) : (
+
No matching fonts.
+ )}
+
+
+
+ ) : null}
+
+ )
+}
diff --git a/src/renderer/src/components/settings/TerminalPane.tsx b/src/renderer/src/components/settings/TerminalPane.tsx
new file mode 100644
index 00000000000..736f895e3f7
--- /dev/null
+++ b/src/renderer/src/components/settings/TerminalPane.tsx
@@ -0,0 +1,395 @@
+import { useState } from 'react'
+import type { GlobalSettings } from '../../../../shared/types'
+import { Button } from '../ui/button'
+import { Input } from '../ui/input'
+import { Label } from '../ui/label'
+import { Separator } from '../ui/separator'
+import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group'
+import { TerminalThemePreview } from './TerminalThemePreview'
+import { Minus, Plus } from 'lucide-react'
+import {
+ clampNumber,
+ resolveEffectiveTerminalAppearance,
+ resolvePaneStyleOptions
+} from '@/lib/terminal-theme'
+import { ThemePicker, ColorField, NumberField, FontAutocomplete } from './SettingsFormControls'
+import { SCROLLBACK_PRESETS_MB } from './SettingsConstants'
+
+type TerminalPaneProps = {
+ settings: GlobalSettings
+ updateSettings: (updates: Partial) => void
+ systemPrefersDark: boolean
+ terminalFontSuggestions: string[]
+ scrollbackMode: 'preset' | 'custom'
+ setScrollbackMode: (mode: 'preset' | 'custom') => void
+}
+
+export function TerminalPane({
+ settings,
+ updateSettings,
+ systemPrefersDark,
+ terminalFontSuggestions,
+ scrollbackMode,
+ setScrollbackMode
+}: TerminalPaneProps): React.JSX.Element {
+ const [themeSearchDark, setThemeSearchDark] = useState('')
+ const [themeSearchLight, setThemeSearchLight] = useState('')
+
+ const darkPreviewAppearance = resolveEffectiveTerminalAppearance(
+ { ...settings, theme: 'dark' },
+ systemPrefersDark
+ )
+ const lightPreviewAppearance = resolveEffectiveTerminalAppearance(
+ { ...settings, theme: 'light' },
+ systemPrefersDark
+ )
+ const paneStyleOptions = resolvePaneStyleOptions(settings)
+ const scrollbackMb = Math.max(1, Math.round(settings.terminalScrollbackBytes / 1_000_000))
+ const isPreset = SCROLLBACK_PRESETS_MB.includes(
+ scrollbackMb as (typeof SCROLLBACK_PRESETS_MB)[number]
+ )
+ const scrollbackToggleValue =
+ scrollbackMode === 'custom' ? 'custom' : isPreset ? `${scrollbackMb}` : 'custom'
+
+ return (
+
+
+
+
Typography
+
+ Default terminal typography for new panes and live updates.
+
+
+
+
+
Font Size
+
+
{
+ const next = Math.max(10, settings.terminalFontSize - 1)
+ updateSettings({ terminalFontSize: next })
+ }}
+ disabled={settings.terminalFontSize <= 10}
+ >
+
+
+
{
+ const value = parseInt(e.target.value, 10)
+ if (!Number.isNaN(value) && value >= 10 && value <= 24) {
+ updateSettings({ terminalFontSize: value })
+ }
+ }}
+ className="w-16 text-center tabular-nums"
+ />
+
{
+ const next = Math.min(24, settings.terminalFontSize + 1)
+ updateSettings({ terminalFontSize: next })
+ }}
+ disabled={settings.terminalFontSize >= 24}
+ >
+
+
+
px
+
+
+
+
+ Font Family
+ updateSettings({ terminalFontFamily: value })}
+ />
+
+
+
+
+
+
+
+
Cursor
+
+ Default cursor appearance for Orca terminal panes.
+
+
+
+
+
+
Cursor Shape
+
+ {(['bar', 'block', 'underline'] as const).map((option) => (
+ updateSettings({ terminalCursorStyle: option })}
+ className={`rounded-sm px-3 py-1 text-sm capitalize transition-colors ${
+ settings.terminalCursorStyle === option
+ ? 'bg-accent font-medium text-accent-foreground'
+ : 'text-muted-foreground hover:text-foreground'
+ }`}
+ >
+ {option}
+
+ ))}
+
+
+
+
+
+
Blinking Cursor
+
+ Uses the blinking variant of the selected cursor shape.
+
+
+
+ updateSettings({
+ terminalCursorBlink: !settings.terminalCursorBlink
+ })
+ }
+ className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
+ settings.terminalCursorBlink ? 'bg-foreground' : 'bg-muted-foreground/30'
+ }`}
+ >
+
+
+
+
+
+
+
+
+
+
+
Pane Styling
+
+ Control inactive pane dimming, divider thickness, and transition timing.
+
+
+
+
+
+ updateSettings({
+ terminalInactivePaneOpacity: clampNumber(value, 0, 1)
+ })
+ }
+ />
+
+ updateSettings({
+ terminalDividerThicknessPx: clampNumber(value, 1, 32)
+ })
+ }
+ />
+
+
+
+
+
+
+
+ updateSettings({ terminalThemeDark: theme })}
+ />
+
+ updateSettings({ terminalDividerColorDark: value })}
+ />
+
+
+
+
+
+
+
+
+
+
+
Use Separate Theme In Light Mode
+
+ When disabled, light mode reuses the dark terminal theme.
+
+
+
+ updateSettings({
+ terminalUseSeparateLightTheme: !settings.terminalUseSeparateLightTheme
+ })
+ }
+ className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
+ settings.terminalUseSeparateLightTheme ? 'bg-foreground' : 'bg-muted-foreground/30'
+ }`}
+ >
+
+
+
+
+
+
+
+
+ updateSettings({ terminalThemeLight: theme })}
+ />
+
+ updateSettings({ terminalDividerColorLight: value })}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
Advanced
+
+ Scrollback is bounded for stability. This setting applies to new terminal panes.
+
+
+
+
+ Scrollback Size
+ {
+ if (!value) {
+ return
+ }
+ if (value === 'custom') {
+ setScrollbackMode('custom')
+ return
+ }
+
+ setScrollbackMode('preset')
+ updateSettings({
+ terminalScrollbackBytes: Number(value) * 1_000_000
+ })
+ }}
+ variant="outline"
+ size="sm"
+ className="h-8 flex-wrap"
+ >
+ {SCROLLBACK_PRESETS_MB.map((preset) => (
+
+ {preset} MB
+
+ ))}
+
+ Custom
+
+
+
+ {scrollbackMode === 'custom' ? (
+
+ updateSettings({
+ terminalScrollbackBytes: clampNumber(value, 1, 256) * 1_000_000
+ })
+ }
+ />
+ ) : null}
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/settings/UIZoomControl.tsx b/src/renderer/src/components/settings/UIZoomControl.tsx
new file mode 100644
index 00000000000..088536492d9
--- /dev/null
+++ b/src/renderer/src/components/settings/UIZoomControl.tsx
@@ -0,0 +1,56 @@
+import { useEffect, useState, useCallback } from 'react'
+import { Button } from '../ui/button'
+import { Minus, Plus, RotateCcw } from 'lucide-react'
+import { applyUIZoom } from '@/lib/ui-zoom'
+import { ZOOM_STEP, ZOOM_MIN, ZOOM_MAX, zoomLevelToPercent } from './SettingsConstants'
+
+export function UIZoomControl(): React.JSX.Element {
+ const [zoomLevel, setZoomLevel] = useState(() => window.api.ui.getZoomLevel())
+
+ useEffect(() => {
+ return window.api.ui.onTerminalZoom(() => {
+ setZoomLevel(window.api.ui.getZoomLevel())
+ })
+ }, [])
+
+ const applyZoom = useCallback((level: number) => {
+ const clamped = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, level))
+ applyUIZoom(clamped)
+ setZoomLevel(clamped)
+ window.api.ui.set({ uiZoomLevel: clamped })
+ }, [])
+
+ const percent = zoomLevelToPercent(zoomLevel)
+
+ return (
+
+
applyZoom(zoomLevel - ZOOM_STEP)}
+ disabled={zoomLevel <= ZOOM_MIN}
+ >
+
+
+
{percent}%
+
applyZoom(zoomLevel + ZOOM_STEP)}
+ disabled={zoomLevel >= ZOOM_MAX}
+ >
+
+
+
applyZoom(0)}
+ disabled={zoomLevel === 0}
+ className="ml-1 gap-1.5"
+ >
+
+ Reset
+
+
+ )
+}
diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.tsx
new file mode 100644
index 00000000000..1183515dd57
--- /dev/null
+++ b/src/renderer/src/components/tab-bar/EditorFileTab.tsx
@@ -0,0 +1,126 @@
+import { useEffect, useState } from 'react'
+import { X, FileCode, GitCompareArrows, Copy } from 'lucide-react'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger
+} from '@/components/ui/dropdown-menu'
+import type { OpenFile } from '../../store/slices/editor'
+import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from './SortableTab'
+
+export default function EditorFileTab({
+ file,
+ isActive,
+ editorFileCount,
+ onActivate,
+ onClose,
+ onCloseOthers,
+ onCloseAll
+}: {
+ file: OpenFile
+ isActive: boolean
+ editorFileCount: number
+ onActivate: () => void
+ onClose: () => void
+ onCloseOthers: () => void
+ onCloseAll: () => void
+}): React.JSX.Element {
+ const fileName = file.relativePath.split('/').pop() ?? file.relativePath
+ const isDiff = file.mode === 'diff'
+ const [menuOpen, setMenuOpen] = useState(false)
+ const [menuPoint, setMenuPoint] = useState({ x: 0, y: 0 })
+
+ useEffect(() => {
+ const closeMenu = (): void => setMenuOpen(false)
+ window.addEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu)
+ return () => window.removeEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu)
+ }, [])
+
+ return (
+ <>
+ {
+ event.preventDefault()
+ window.dispatchEvent(new Event(CLOSE_ALL_CONTEXT_MENUS_EVENT))
+ setMenuPoint({ x: event.clientX, y: event.clientY })
+ setMenuOpen(true)
+ }}
+ >
+
{
+ if (e.button === 1) {
+ e.preventDefault()
+ e.stopPropagation()
+ onClose()
+ }
+ }}
+ >
+ {isDiff ? (
+
+ ) : (
+
+ )}
+ {file.isDirty && (
+
+ )}
+
+ {isDiff
+ ? file.relativePath === 'All Changes'
+ ? 'All Changes'
+ : `${fileName} (diff${file.diffStaged ? ' staged' : ''})`
+ : fileName}
+
+ e.stopPropagation()}
+ onClick={(e) => {
+ e.stopPropagation()
+ onClose()
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+ Close
+
+ Close Others
+
+ Close All Editor Tabs
+
+ {
+ navigator.clipboard.writeText(file.filePath)
+ }}
+ >
+
+ Copy Path
+
+
+
+ >
+ )
+}
diff --git a/src/renderer/src/components/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx
similarity index 96%
rename from src/renderer/src/components/SortableTab.tsx
rename to src/renderer/src/components/tab-bar/SortableTab.tsx
index 96aaa2b59c4..54a8e4059b6 100644
--- a/src/renderer/src/components/SortableTab.tsx
+++ b/src/renderer/src/components/tab-bar/SortableTab.tsx
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
-import { X, Minimize2, Terminal as TerminalIcon } from 'lucide-react'
+import { X, Terminal as TerminalIcon, Minimize2 } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
@@ -19,9 +19,9 @@ import {
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
-import type { TerminalTab } from '../../../shared/types'
+import type { TerminalTab } from '../../../../shared/types'
-export type SortableTabProps = {
+type SortableTabProps = {
tab: TerminalTab
tabCount: number
hasTabsToRight: boolean
@@ -36,7 +36,7 @@ export type SortableTabProps = {
onToggleExpand: (tabId: string) => void
}
-const TAB_COLORS = [
+export const TAB_COLORS = [
{ label: 'None', value: null },
{ label: 'Blue', value: '#3b82f6' },
{ label: 'Purple', value: '#a855f7' },
@@ -49,9 +49,9 @@ const TAB_COLORS = [
{ label: 'Gray', value: '#9ca3af' }
]
-const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus'
+export const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus'
-export function SortableTab({
+export default function SortableTab({
tab,
tabCount,
hasTabsToRight,
diff --git a/src/renderer/src/components/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx
similarity index 51%
rename from src/renderer/src/components/TabBar.tsx
rename to src/renderer/src/components/tab-bar/TabBar.tsx
index 5c35adefa51..bc7380f10f2 100644
--- a/src/renderer/src/components/TabBar.tsx
+++ b/src/renderer/src/components/tab-bar/TabBar.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { useCallback, useEffect, useMemo, useRef } from 'react'
import {
DndContext,
closestCenter,
@@ -8,134 +8,11 @@ import {
type DragEndEvent
} from '@dnd-kit/core'
import { SortableContext, horizontalListSortingStrategy, arrayMove } from '@dnd-kit/sortable'
-import { X, Plus, FileCode, GitCompareArrows, Copy } from 'lucide-react'
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger
-} from '@/components/ui/dropdown-menu'
-import type { TerminalTab } from '../../../shared/types'
-import type { OpenFile } from '../store/slices/editor'
-import { SortableTab } from './SortableTab'
-
-const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus'
-
-function EditorFileTab({
- file,
- isActive,
- editorFileCount,
- onActivate,
- onClose,
- onCloseOthers,
- onCloseAll
-}: {
- file: OpenFile
- isActive: boolean
- editorFileCount: number
- onActivate: () => void
- onClose: () => void
- onCloseOthers: () => void
- onCloseAll: () => void
-}): React.JSX.Element {
- const fileName = file.relativePath.split('/').pop() ?? file.relativePath
- const isDiff = file.mode === 'diff'
- const [menuOpen, setMenuOpen] = useState(false)
- const [menuPoint, setMenuPoint] = useState({ x: 0, y: 0 })
-
- useEffect(() => {
- const closeMenu = (): void => setMenuOpen(false)
- window.addEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu)
- return () => window.removeEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu)
- }, [])
-
- return (
- <>
- {
- event.preventDefault()
- window.dispatchEvent(new Event(CLOSE_ALL_CONTEXT_MENUS_EVENT))
- setMenuPoint({ x: event.clientX, y: event.clientY })
- setMenuOpen(true)
- }}
- >
-
{
- if (e.button === 1) {
- e.preventDefault()
- e.stopPropagation()
- onClose()
- }
- }}
- >
- {isDiff ? (
-
- ) : (
-
- )}
- {file.isDirty && (
-
- )}
-
- {isDiff
- ? file.relativePath === 'All Changes'
- ? 'All Changes'
- : `${fileName} (diff${file.diffStaged ? ' staged' : ''})`
- : fileName}
-
- e.stopPropagation()}
- onClick={(e) => {
- e.stopPropagation()
- onClose()
- }}
- >
-
-
-
-
-
-
-
-
-
-
- Close
-
- Close Others
-
- Close All Editor Tabs
-
- {
- navigator.clipboard.writeText(file.filePath)
- }}
- >
-
- Copy Path
-
-
-
- >
- )
-}
+import { Plus } from 'lucide-react'
+import type { TerminalTab } from '../../../../shared/types'
+import type { OpenFile } from '../../store/slices/editor'
+import SortableTab from './SortableTab'
+import EditorFileTab from './EditorFileTab'
type TabBarProps = {
tabs: TerminalTab[]
@@ -178,9 +55,8 @@ export default function TabBar({
activeTabType,
onActivateFile,
onCloseFile,
- onCloseAllFiles: _onCloseAllFiles
+ onCloseAllFiles
}: TabBarProps): React.JSX.Element {
- void _onCloseAllFiles
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 5 }
@@ -274,7 +150,7 @@ export default function TabBar({
onActivate={() => onActivateFile?.(file.id)}
onClose={() => onCloseFile?.(file.id)}
onCloseOthers={() => handleCloseOtherEditorFiles(file.id)}
- onCloseAll={() => _onCloseAllFiles?.()}
+ onCloseAll={() => onCloseAllFiles?.()}
/>
))}
diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx
new file mode 100644
index 00000000000..1c551b17bf3
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx
@@ -0,0 +1,129 @@
+import {
+ Clipboard,
+ Copy,
+ Eraser,
+ Maximize2,
+ Minimize2,
+ PanelBottomOpen,
+ PanelRightOpen,
+ X
+} from 'lucide-react'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuTrigger
+} from '@/components/ui/dropdown-menu'
+
+type TerminalContextMenuProps = {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ menuPoint: { x: number; y: number }
+ menuOpenedAtRef: React.RefObject
+ canClosePane: boolean
+ canExpandPane: boolean
+ menuPaneIsExpanded: boolean
+ onCopy: () => void
+ onPaste: () => void
+ onSplitRight: () => void
+ onSplitDown: () => void
+ onClosePane: () => void
+ onClearScreen: () => void
+ onToggleExpand: () => void
+}
+
+export default function TerminalContextMenu({
+ open,
+ onOpenChange,
+ menuPoint,
+ menuOpenedAtRef,
+ canClosePane,
+ canExpandPane,
+ menuPaneIsExpanded,
+ onCopy,
+ onPaste,
+ onSplitRight,
+ onSplitDown,
+ onClosePane,
+ onClearScreen,
+ onToggleExpand
+}: TerminalContextMenuProps): React.JSX.Element {
+ return (
+ {
+ if (!nextOpen && Date.now() - menuOpenedAtRef.current < 100) {
+ return
+ }
+ onOpenChange(nextOpen)
+ }}
+ modal={false}
+ >
+
+
+
+ {
+ // Prevent Radix from moving focus back to the hidden trigger;
+ // let xterm keep focus naturally.
+ e.preventDefault()
+ }}
+ onFocusOutside={(e) => {
+ // xterm reclaims focus after the contextmenu event; don't let
+ // Radix treat that as a dismiss signal.
+ e.preventDefault()
+ }}
+ >
+
+
+ Copy
+ ⌘C
+
+
+
+ Paste
+ ⌘V
+
+
+
+
+ Split Right
+ ⌘D
+
+
+
+ Split Down
+ ⌘⇧D
+
+ {canExpandPane && (
+
+ {menuPaneIsExpanded ? : }
+ {menuPaneIsExpanded ? 'Collapse Pane' : 'Expand Pane'}
+ ⌘⇧↩
+
+ )}
+ {canClosePane && (
+
+
+ Close Pane
+
+ )}
+
+
+
+ Clear Screen
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx
new file mode 100644
index 00000000000..5ddc8b509ca
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx
@@ -0,0 +1,578 @@
+import { useEffect, useRef, useState } from 'react'
+import { createPortal } from 'react-dom'
+import type { CSSProperties } from 'react'
+import { TOGGLE_TERMINAL_PANE_EXPAND_EVENT } from '@/constants/terminal'
+import { useAppStore } from '../../store'
+import {
+ DEFAULT_TERMINAL_DIVIDER_DARK,
+ normalizeColor,
+ resolveEffectiveTerminalAppearance
+} from '@/lib/terminal-theme'
+import { PaneManager } from '@/lib/pane-manager/pane-manager'
+import TerminalSearch from '@/components/TerminalSearch'
+import type { PtyTransport } from './pty-transport'
+import {
+ EMPTY_LAYOUT,
+ buildFontFamily,
+ serializeTerminalLayout,
+ replayTerminalLayout
+} from './layout-serialization'
+import {
+ createExpandCollapseActions,
+ restoreExpandedLayoutFrom,
+ applyExpandedLayoutTo
+} from './expand-collapse'
+import { useTerminalKeyboardShortcuts, useTerminalFontZoom } from './keyboard-handlers'
+import { applyTerminalAppearance } from './terminal-appearance'
+import { connectPanePty } from './pty-connection'
+import TerminalContextMenu from './TerminalContextMenu'
+
+const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus'
+
+type TerminalPaneProps = {
+ tabId: string
+ worktreeId: string
+ cwd?: string
+ isActive: boolean
+ onPtyExit: (ptyId: string) => void
+}
+
+export default function TerminalPane({
+ tabId,
+ worktreeId,
+ cwd,
+ isActive,
+ onPtyExit
+}: TerminalPaneProps): React.JSX.Element {
+ const containerRef = useRef(null)
+ const managerRef = useRef(null)
+ const contextPaneIdRef = useRef(null)
+ const wasActiveRef = useRef(false)
+ const paneFontSizesRef = useRef>(new Map())
+ const expandedPaneIdRef = useRef(null)
+ const expandedStyleSnapshotRef = useRef>(
+ new Map()
+ )
+ const paneTransportsRef = useRef>(new Map())
+ const pendingWritesRef = useRef>(new Map())
+ const isActiveRef = useRef(isActive)
+ isActiveRef.current = isActive
+ const [terminalMenuOpen, setTerminalMenuOpen] = useState(false)
+ const [terminalMenuPoint, setTerminalMenuPoint] = useState({ x: 0, y: 0 })
+ const menuOpenedAtRef = useRef(0)
+ const [expandedPaneId, setExpandedPaneId] = useState(null)
+ const [searchOpen, setSearchOpen] = useState(false)
+ const setTabPaneExpanded = useAppStore((s) => s.setTabPaneExpanded)
+ const setTabCanExpandPane = useAppStore((s) => s.setTabCanExpandPane)
+ const savedLayout = useAppStore((s) => s.terminalLayoutsByTabId[tabId] ?? EMPTY_LAYOUT)
+ const setTabLayout = useAppStore((s) => s.setTabLayout)
+ const initialLayoutRef = useRef(savedLayout)
+ const updateTabTitle = useAppStore((s) => s.updateTabTitle)
+ const updateTabPtyId = useAppStore((s) => s.updateTabPtyId)
+ const clearTabPtyId = useAppStore((s) => s.clearTabPtyId)
+ const markWorktreeUnreadFromBell = useAppStore((s) => s.markWorktreeUnreadFromBell)
+ const settings = useAppStore((s) => s.settings)
+ const settingsRef = useRef(settings)
+ settingsRef.current = settings
+ const onPtyExitRef = useRef(onPtyExit)
+ onPtyExitRef.current = onPtyExit
+
+ const [systemPrefersDark, setSystemPrefersDark] = useState(() =>
+ typeof window !== 'undefined' && typeof window.matchMedia === 'function'
+ ? window.matchMedia('(prefers-color-scheme: dark)').matches
+ : true
+ )
+
+ const persistLayoutSnapshot = (): void => {
+ const manager = managerRef.current
+ const container = containerRef.current
+ if (!manager || !container) {
+ return
+ }
+ const activePaneId = manager.getActivePane()?.id ?? manager.getPanes()[0]?.id ?? null
+ setTabLayout(tabId, serializeTerminalLayout(container, activePaneId, expandedPaneIdRef.current))
+ }
+
+ const {
+ setExpandedPane,
+ restoreExpandedLayout,
+ refreshPaneSizes,
+ syncExpandedLayout,
+ toggleExpandPane
+ } = createExpandCollapseActions({
+ expandedPaneIdRef,
+ expandedStyleSnapshotRef,
+ containerRef,
+ managerRef,
+ setExpandedPaneId,
+ setTabPaneExpanded,
+ tabId,
+ persistLayoutSnapshot
+ })
+
+ const syncCanExpandState = (): void => {
+ const paneCount = managerRef.current?.getPanes().length ?? 1
+ setTabCanExpandPane(tabId, paneCount > 1)
+ }
+
+ const doApplyAppearance = (manager: PaneManager): void => {
+ const s = settingsRef.current
+ if (!s) {
+ return
+ }
+ applyTerminalAppearance(
+ manager,
+ s,
+ systemPrefersDark,
+ paneFontSizesRef.current,
+ paneTransportsRef.current
+ )
+ }
+
+ useEffect(() => {
+ const closeMenu = (): void => {
+ if (Date.now() - menuOpenedAtRef.current < 100) {
+ return
+ }
+ setTerminalMenuOpen(false)
+ }
+ window.addEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu)
+ return () => window.removeEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu)
+ }, [])
+
+ useEffect(() => {
+ const media = window.matchMedia('(prefers-color-scheme: dark)')
+ const handleChange = (event: MediaQueryListEvent): void => setSystemPrefersDark(event.matches)
+ setSystemPrefersDark(media.matches)
+ media.addEventListener('change', handleChange)
+ return () => media.removeEventListener('change', handleChange)
+ }, [])
+
+ // Initialize PaneManager instance once
+ useEffect(() => {
+ const container = containerRef.current
+ if (!container) {
+ return
+ }
+ let resizeRaf: number | null = null
+
+ const queueResizeAll = (focusActive: boolean): void => {
+ if (resizeRaf !== null) {
+ cancelAnimationFrame(resizeRaf)
+ }
+ resizeRaf = requestAnimationFrame(() => {
+ resizeRaf = null
+ const m = managerRef.current
+ if (!m) {
+ return
+ }
+ const panes = m.getPanes()
+ for (const p of panes) {
+ try {
+ p.fitAddon.fit()
+ } catch {
+ /* ignore */
+ }
+ }
+ if (focusActive) {
+ const active = m.getActivePane() ?? panes[0]
+ active?.terminal.focus()
+ }
+ })
+ }
+
+ let shouldPersistLayout = false
+ const ptyDeps = {
+ tabId,
+ worktreeId,
+ cwd,
+ paneTransportsRef,
+ pendingWritesRef,
+ isActiveRef,
+ onPtyExitRef,
+ clearTabPtyId,
+ updateTabTitle,
+ updateTabPtyId,
+ markWorktreeUnreadFromBell
+ }
+
+ const manager = new PaneManager(container, {
+ onPaneCreated: (pane) => {
+ doApplyAppearance(manager)
+ connectPanePty(pane, manager, ptyDeps)
+ queueResizeAll(true)
+ },
+ onPaneClosed: (paneId) => {
+ const transport = paneTransportsRef.current.get(paneId)
+ if (transport) {
+ transport.destroy?.()
+ paneTransportsRef.current.delete(paneId)
+ }
+ paneFontSizesRef.current.delete(paneId)
+ pendingWritesRef.current.delete(paneId)
+ },
+ onActivePaneChange: () => {
+ if (shouldPersistLayout) {
+ persistLayoutSnapshot()
+ }
+ },
+ onLayoutChanged: () => {
+ syncExpandedLayout()
+ syncCanExpandState()
+ queueResizeAll(false)
+ if (shouldPersistLayout) {
+ persistLayoutSnapshot()
+ }
+ },
+ terminalOptions: () => {
+ const cs = settingsRef.current
+ return {
+ fontSize: cs?.terminalFontSize ?? 14,
+ fontFamily: buildFontFamily(cs?.terminalFontFamily ?? 'SF Mono'),
+ scrollback: Math.min(
+ 50_000,
+ Math.max(1000, Math.round((cs?.terminalScrollbackBytes ?? 10_000_000) / 200))
+ ),
+ cursorStyle: cs?.terminalCursorStyle ?? 'bar',
+ cursorBlink: cs?.terminalCursorBlink ?? true
+ }
+ },
+ onLinkClick: (url) => {
+ window.api.shell.openExternal(url)
+ }
+ })
+
+ managerRef.current = manager
+ const restoredPaneByLeafId = replayTerminalLayout(manager, initialLayoutRef.current, isActive)
+ const restoredActivePaneId =
+ (initialLayoutRef.current.activeLeafId
+ ? restoredPaneByLeafId.get(initialLayoutRef.current.activeLeafId)
+ : null) ??
+ manager.getActivePane()?.id ??
+ manager.getPanes()[0]?.id ??
+ null
+ if (restoredActivePaneId !== null) {
+ manager.setActivePane(restoredActivePaneId, { focus: isActive })
+ }
+
+ const restoredExpandedPaneId = initialLayoutRef.current.expandedLeafId
+ ? (restoredPaneByLeafId.get(initialLayoutRef.current.expandedLeafId) ?? null)
+ : null
+ if (restoredExpandedPaneId !== null && manager.getPanes().length > 1) {
+ setExpandedPane(restoredExpandedPaneId)
+ applyExpandedLayoutTo(restoredExpandedPaneId, {
+ managerRef,
+ containerRef,
+ expandedStyleSnapshotRef
+ })
+ } else {
+ setExpandedPane(null)
+ }
+ shouldPersistLayout = true
+ syncCanExpandState()
+ doApplyAppearance(manager)
+ queueResizeAll(isActive)
+ persistLayoutSnapshot()
+
+ return () => {
+ if (resizeRaf !== null) {
+ cancelAnimationFrame(resizeRaf)
+ }
+ restoreExpandedLayoutFrom(expandedStyleSnapshotRef.current)
+ for (const transport of paneTransportsRef.current.values()) {
+ transport.destroy?.()
+ }
+ paneTransportsRef.current.clear()
+ pendingWritesRef.current.clear()
+ manager.destroy()
+ managerRef.current = null
+ setTabPaneExpanded(tabId, false)
+ setTabCanExpandPane(tabId, false)
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [tabId, cwd])
+
+ useEffect(() => {
+ const manager = managerRef.current
+ if (!manager || !settings) {
+ return
+ }
+ doApplyAppearance(manager)
+ const fontFamily = buildFontFamily(settings.terminalFontFamily)
+ for (const pane of manager.getPanes()) {
+ pane.terminal.options.fontFamily = fontFamily
+ try {
+ pane.fitAddon.fit()
+ } catch {
+ /* ignore */
+ }
+ }
+ }, [settings, systemPrefersDark])
+
+ useTerminalFontZoom({ isActive, managerRef, paneFontSizesRef, settingsRef })
+
+ useEffect(() => {
+ const manager = managerRef.current
+ if (!manager) {
+ return
+ }
+ if (isActive) {
+ manager.resumeRendering()
+ for (const [paneId, buf] of pendingWritesRef.current.entries()) {
+ if (buf.length > 0) {
+ const pane = manager.getPanes().find((p) => p.id === paneId)
+ if (pane) {
+ pane.terminal.write(buf)
+ }
+ pendingWritesRef.current.set(paneId, '')
+ }
+ }
+ requestAnimationFrame(() => {
+ const panes = manager.getPanes()
+ for (const p of panes) {
+ try {
+ p.fitAddon.fit()
+ } catch {
+ /* ignore */
+ }
+ }
+ const active = manager.getActivePane() ?? panes[0]
+ if (active) {
+ active.terminal.focus()
+ }
+ })
+ } else if (wasActiveRef.current) {
+ manager.suspendRendering()
+ }
+ wasActiveRef.current = isActive
+ }, [isActive])
+
+ useEffect(() => {
+ const onToggleExpand = (event: Event): void => {
+ const detail = (event as CustomEvent<{ tabId?: string }>).detail
+ if (!detail?.tabId || detail.tabId !== tabId) {
+ return
+ }
+ const manager = managerRef.current
+ if (!manager) {
+ return
+ }
+ const panes = manager.getPanes()
+ if (panes.length < 2) {
+ return
+ }
+ const pane = manager.getActivePane() ?? panes[0]
+ if (!pane) {
+ return
+ }
+ toggleExpandPane(pane.id)
+ }
+ window.addEventListener(TOGGLE_TERMINAL_PANE_EXPAND_EVENT, onToggleExpand)
+ return () => window.removeEventListener(TOGGLE_TERMINAL_PANE_EXPAND_EVENT, onToggleExpand)
+ }, [tabId])
+
+ useEffect(() => {
+ if (!isActive) {
+ return
+ }
+ const container = containerRef.current
+ if (!container) {
+ return
+ }
+ const ro = new ResizeObserver(() => {
+ const manager = managerRef.current
+ if (!manager) {
+ return
+ }
+ for (const p of manager.getPanes()) {
+ try {
+ p.fitAddon.fit()
+ } catch {
+ /* ignore */
+ }
+ }
+ })
+ ro.observe(container)
+ return () => ro.disconnect()
+ }, [isActive])
+
+ useTerminalKeyboardShortcuts({
+ isActive,
+ managerRef,
+ paneTransportsRef,
+ expandedPaneIdRef,
+ setExpandedPane,
+ restoreExpandedLayout,
+ refreshPaneSizes,
+ persistLayoutSnapshot,
+ toggleExpandPane,
+ setSearchOpen
+ })
+
+ useEffect(() => {
+ if (!isActive) {
+ return
+ }
+ const shellEscape = (p: string): string => {
+ if (/^[a-zA-Z0-9_./@:-]+$/.test(p)) {
+ return p
+ }
+ return `'${p.replace(/'/g, "'\\''")}'`
+ }
+ return window.api.ui.onFileDrop(({ path: filePath }) => {
+ const manager = managerRef.current
+ if (!manager) {
+ return
+ }
+ const pane = manager.getActivePane() ?? manager.getPanes()[0]
+ if (!pane) {
+ return
+ }
+ const transport = paneTransportsRef.current.get(pane.id)
+ if (!transport) {
+ return
+ }
+ transport.sendInput(shellEscape(filePath))
+ })
+ }, [isActive])
+
+ const resolveMenuPane = () => {
+ const manager = managerRef.current
+ if (!manager) {
+ return null
+ }
+ const panes = manager.getPanes()
+ if (contextPaneIdRef.current !== null) {
+ const clickedPane = panes.find((p) => p.id === contextPaneIdRef.current) ?? null
+ if (clickedPane) {
+ return clickedPane
+ }
+ }
+ return manager.getActivePane() ?? panes[0] ?? null
+ }
+
+ const handleCopy = async (): Promise => {
+ const pane = resolveMenuPane()
+ if (!pane) {
+ return
+ }
+ const selection = pane.terminal.getSelection()
+ if (selection) {
+ await navigator.clipboard.writeText(selection)
+ }
+ }
+
+ const handlePaste = async (): Promise => {
+ const pane = resolveMenuPane()
+ if (!pane) {
+ return
+ }
+ const text = await navigator.clipboard.readText()
+ if (text) {
+ paneTransportsRef.current.get(pane.id)?.sendInput(text)
+ }
+ }
+
+ const handleSplitRight = (): void => {
+ const p = resolveMenuPane()
+ if (p) {
+ managerRef.current?.splitPane(p.id, 'vertical')
+ }
+ }
+ const handleSplitDown = (): void => {
+ const p = resolveMenuPane()
+ if (p) {
+ managerRef.current?.splitPane(p.id, 'horizontal')
+ }
+ }
+ const handleClosePane = (): void => {
+ const p = resolveMenuPane()
+ if (p && (managerRef.current?.getPanes().length ?? 0) > 1) {
+ managerRef.current?.closePane(p.id)
+ }
+ }
+ const handleClearScreen = (): void => {
+ const p = resolveMenuPane()
+ if (p) {
+ p.terminal.clear()
+ }
+ }
+ const handleToggleExpand = (): void => {
+ const p = resolveMenuPane()
+ if (p) {
+ toggleExpandPane(p.id)
+ }
+ }
+
+ const paneCount = managerRef.current?.getPanes().length ?? 1
+ const menuPaneId = resolveMenuPane()?.id ?? null
+ const effectiveAppearance = settings
+ ? resolveEffectiveTerminalAppearance(settings, systemPrefersDark)
+ : null
+ const terminalContainerStyle: CSSProperties = {
+ display: isActive ? 'flex' : 'none',
+ ['--orca-terminal-divider-color' as string]:
+ effectiveAppearance?.dividerColor ?? DEFAULT_TERMINAL_DIVIDER_DARK,
+ ['--orca-terminal-divider-color-strong' as string]: normalizeColor(
+ effectiveAppearance?.dividerColor,
+ DEFAULT_TERMINAL_DIVIDER_DARK
+ )
+ }
+ const activePane = managerRef.current?.getActivePane()
+
+ return (
+ <>
+ {
+ event.preventDefault()
+ menuOpenedAtRef.current = Date.now()
+ window.dispatchEvent(new Event(CLOSE_ALL_CONTEXT_MENUS_EVENT))
+ const manager = managerRef.current
+ if (!manager) {
+ contextPaneIdRef.current = null
+ return
+ }
+ const target = event.target
+ if (!(target instanceof Node)) {
+ contextPaneIdRef.current = null
+ return
+ }
+ const clickedPane =
+ manager.getPanes().find((pane) => pane.container.contains(target)) ?? null
+ contextPaneIdRef.current = clickedPane?.id ?? null
+ const bounds = event.currentTarget.getBoundingClientRect()
+ setTerminalMenuPoint({ x: event.clientX - bounds.left, y: event.clientY - bounds.top })
+ setTerminalMenuOpen(true)
+ }}
+ />
+ {activePane?.container &&
+ createPortal(
+
setSearchOpen(false)}
+ searchAddon={activePane.searchAddon ?? null}
+ />,
+ activePane.container
+ )}
+ 1}
+ canExpandPane={paneCount > 1}
+ menuPaneIsExpanded={menuPaneId !== null && menuPaneId === expandedPaneId}
+ onCopy={() => void handleCopy()}
+ onPaste={() => void handlePaste()}
+ onSplitRight={handleSplitRight}
+ onSplitDown={handleSplitDown}
+ onClosePane={handleClosePane}
+ onClearScreen={handleClearScreen}
+ onToggleExpand={handleToggleExpand}
+ />
+ >
+ )
+}
diff --git a/src/renderer/src/components/terminal-pane/expand-collapse.ts b/src/renderer/src/components/terminal-pane/expand-collapse.ts
new file mode 100644
index 00000000000..5732d7a0603
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/expand-collapse.ts
@@ -0,0 +1,171 @@
+import type { PaneManager } from '@/lib/pane-manager/pane-manager'
+
+type ExpandCollapseState = {
+ expandedPaneIdRef: React.MutableRefObject
+ expandedStyleSnapshotRef: React.MutableRefObject<
+ Map
+ >
+ containerRef: React.RefObject
+ managerRef: React.RefObject
+ setExpandedPaneId: (paneId: number | null) => void
+ setTabPaneExpanded: (tabId: string, expanded: boolean) => void
+ tabId: string
+ persistLayoutSnapshot: () => void
+}
+
+function rememberPaneStyle(
+ snapshots: Map,
+ el: HTMLElement
+): void {
+ if (snapshots.has(el)) {
+ return
+ }
+ snapshots.set(el, { display: el.style.display, flex: el.style.flex })
+}
+
+export function restoreExpandedLayoutFrom(
+ snapshots: Map
+): void {
+ for (const [el, prev] of snapshots.entries()) {
+ el.style.display = prev.display
+ el.style.flex = prev.flex
+ }
+ snapshots.clear()
+}
+
+export function applyExpandedLayoutTo(
+ paneId: number,
+ state: Pick
+): boolean {
+ const manager = state.managerRef.current
+ const root = state.containerRef.current
+ if (!manager || !root) {
+ return false
+ }
+
+ const panes = manager.getPanes()
+ if (panes.length <= 1) {
+ return false
+ }
+ const targetPane = panes.find((pane) => pane.id === paneId)
+ if (!targetPane) {
+ return false
+ }
+
+ restoreExpandedLayoutFrom(state.expandedStyleSnapshotRef.current)
+ const snapshots = state.expandedStyleSnapshotRef.current
+ let current: HTMLElement | null = targetPane.container
+ while (current && current !== root) {
+ const parent = current.parentElement
+ if (!parent) {
+ break
+ }
+ for (const child of Array.from(parent.children)) {
+ if (!(child instanceof HTMLElement)) {
+ continue
+ }
+ rememberPaneStyle(snapshots, child)
+ if (child === current) {
+ child.style.display = ''
+ child.style.flex = '1 1 auto'
+ } else {
+ child.style.display = 'none'
+ }
+ }
+ current = parent
+ }
+ return true
+}
+
+export function createExpandCollapseActions(state: ExpandCollapseState) {
+ const setExpandedPane = (paneId: number | null): void => {
+ state.expandedPaneIdRef.current = paneId
+ state.setExpandedPaneId(paneId)
+ state.setTabPaneExpanded(state.tabId, paneId !== null)
+ state.persistLayoutSnapshot()
+ }
+
+ const restoreExpandedLayout = (): void => {
+ restoreExpandedLayoutFrom(state.expandedStyleSnapshotRef.current)
+ }
+
+ const refreshPaneSizes = (focusActive: boolean): void => {
+ requestAnimationFrame(() => {
+ const manager = state.managerRef.current
+ if (!manager) {
+ return
+ }
+ const panes = manager.getPanes()
+ for (const p of panes) {
+ try {
+ p.fitAddon.fit()
+ } catch {
+ /* container may not have dimensions */
+ }
+ }
+ if (focusActive) {
+ const active = manager.getActivePane() ?? panes[0]
+ active?.terminal.focus()
+ }
+ })
+ }
+
+ const syncExpandedLayout = (): void => {
+ const paneId = state.expandedPaneIdRef.current
+ if (paneId === null) {
+ restoreExpandedLayout()
+ return
+ }
+
+ const manager = state.managerRef.current
+ if (!manager) {
+ return
+ }
+ const panes = manager.getPanes()
+ if (panes.length <= 1 || !panes.some((pane) => pane.id === paneId)) {
+ setExpandedPane(null)
+ restoreExpandedLayout()
+ return
+ }
+ applyExpandedLayoutTo(paneId, state)
+ }
+
+ const toggleExpandPane = (paneId: number): void => {
+ const manager = state.managerRef.current
+ if (!manager) {
+ return
+ }
+ const panes = manager.getPanes()
+ if (panes.length <= 1) {
+ return
+ }
+
+ const isAlreadyExpanded = state.expandedPaneIdRef.current === paneId
+ if (isAlreadyExpanded) {
+ setExpandedPane(null)
+ restoreExpandedLayout()
+ refreshPaneSizes(true)
+ state.persistLayoutSnapshot()
+ return
+ }
+
+ setExpandedPane(paneId)
+ if (!applyExpandedLayoutTo(paneId, state)) {
+ setExpandedPane(null)
+ restoreExpandedLayout()
+ state.persistLayoutSnapshot()
+ return
+ }
+ manager.setActivePane(paneId, { focus: true })
+ refreshPaneSizes(true)
+ state.persistLayoutSnapshot()
+ }
+
+ return {
+ setExpandedPane,
+ restoreExpandedLayout,
+ refreshPaneSizes,
+ syncExpandedLayout,
+ toggleExpandPane
+ }
+}
diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts
new file mode 100644
index 00000000000..012818b9b29
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts
@@ -0,0 +1,280 @@
+import { useEffect } from 'react'
+import type { PaneManager } from '@/lib/pane-manager/pane-manager'
+import type { PtyTransport } from './pty-transport'
+
+type KeyboardHandlersDeps = {
+ isActive: boolean
+ managerRef: React.RefObject
+ paneTransportsRef: React.RefObject>
+ expandedPaneIdRef: React.RefObject
+ setExpandedPane: (paneId: number | null) => void
+ restoreExpandedLayout: () => void
+ refreshPaneSizes: (focusActive: boolean) => void
+ persistLayoutSnapshot: () => void
+ toggleExpandPane: (paneId: number) => void
+ setSearchOpen: React.Dispatch>
+}
+
+export function useTerminalKeyboardShortcuts({
+ isActive,
+ managerRef,
+ paneTransportsRef,
+ expandedPaneIdRef,
+ setExpandedPane,
+ restoreExpandedLayout,
+ refreshPaneSizes,
+ persistLayoutSnapshot,
+ toggleExpandPane,
+ setSearchOpen
+}: KeyboardHandlersDeps): void {
+ useEffect(() => {
+ if (!isActive) {
+ return
+ }
+
+ const onKeyDown = (e: KeyboardEvent): void => {
+ if (e.repeat) {
+ return
+ }
+ if (!e.metaKey || e.altKey || e.ctrlKey) {
+ return
+ }
+
+ const manager = managerRef.current
+ if (!manager) {
+ return
+ }
+
+ // Cmd+F opens search
+ if (!e.shiftKey && e.key.toLowerCase() === 'f') {
+ e.preventDefault()
+ e.stopPropagation()
+ setSearchOpen((prev) => !prev)
+ return
+ }
+
+ // Cmd+K clears active pane screen + scrollback.
+ if (!e.shiftKey && e.key.toLowerCase() === 'k') {
+ e.preventDefault()
+ e.stopPropagation()
+ const pane = manager.getActivePane() ?? manager.getPanes()[0]
+ if (pane) {
+ pane.terminal.clear()
+ }
+ return
+ }
+
+ // Cmd+[ / Cmd+] cycles active split pane focus.
+ if (!e.shiftKey && (e.code === 'BracketLeft' || e.code === 'BracketRight')) {
+ const panes = manager.getPanes()
+ if (panes.length < 2) {
+ return
+ }
+ e.preventDefault()
+ e.stopPropagation()
+
+ // Collapse expanded pane before switching
+ if (expandedPaneIdRef.current !== null) {
+ setExpandedPane(null)
+ restoreExpandedLayout()
+ refreshPaneSizes(true)
+ persistLayoutSnapshot()
+ }
+
+ const activeId = manager.getActivePane()?.id ?? panes[0].id
+ const currentIdx = panes.findIndex((p) => p.id === activeId)
+ if (currentIdx === -1) {
+ return
+ }
+
+ const dir = e.code === 'BracketRight' ? 1 : -1
+ const nextPane = panes[(currentIdx + dir + panes.length) % panes.length]
+ manager.setActivePane(nextPane.id, { focus: true })
+ return
+ }
+
+ // Cmd+Shift+Enter expands/collapses the active pane to full terminal area.
+ if (e.shiftKey && e.key === 'Enter' && (e.code === 'Enter' || e.code === 'NumpadEnter')) {
+ const panes = manager.getPanes()
+ if (panes.length < 2) {
+ return
+ }
+ e.preventDefault()
+ e.stopPropagation()
+ const pane = manager.getActivePane() ?? panes[0]
+ if (!pane) {
+ return
+ }
+ toggleExpandPane(pane.id)
+ return
+ }
+
+ // Cmd+W closes only the active split pane and prevents the tab-level
+ // handler from closing the entire terminal tab.
+ if (!e.shiftKey && e.key.toLowerCase() === 'w') {
+ const panes = manager.getPanes()
+ if (panes.length < 2) {
+ return
+ }
+ e.preventDefault()
+ e.stopPropagation()
+ const pane = manager.getActivePane() ?? panes[0]
+ if (!pane) {
+ return
+ }
+ manager.closePane(pane.id)
+ return
+ }
+
+ // Cmd+D / Cmd+Shift+D split the active pane in the focused tab only.
+ if (e.key.toLowerCase() === 'd') {
+ e.preventDefault()
+ e.stopPropagation()
+ const pane = manager.getActivePane() ?? manager.getPanes()[0]
+ if (!pane) {
+ return
+ }
+ manager.splitPane(pane.id, e.shiftKey ? 'horizontal' : 'vertical')
+ }
+ }
+
+ // Ctrl+Backspace → send \x17 (backward-kill-word) to PTY.
+ const onCtrlBackspace = (e: KeyboardEvent): void => {
+ if (!e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) {
+ return
+ }
+ if (e.key !== 'Backspace') {
+ return
+ }
+
+ const manager = managerRef.current
+ if (!manager) {
+ return
+ }
+
+ e.preventDefault()
+ e.stopPropagation()
+ const pane = manager.getActivePane() ?? manager.getPanes()[0]
+ if (!pane) {
+ return
+ }
+ const transport = paneTransportsRef.current.get(pane.id)
+ transport?.sendInput('\x17')
+ }
+
+ // Alt+Backspace → send ESC + DEL (\x1b\x7f, backward-kill-word) to PTY.
+ const onAltBackspace = (e: KeyboardEvent): void => {
+ if (!e.altKey || e.metaKey || e.ctrlKey || e.shiftKey) {
+ return
+ }
+ if (e.key !== 'Backspace') {
+ return
+ }
+
+ const manager = managerRef.current
+ if (!manager) {
+ return
+ }
+
+ e.preventDefault()
+ e.stopPropagation()
+ const pane = manager.getActivePane() ?? manager.getPanes()[0]
+ if (!pane) {
+ return
+ }
+ const transport = paneTransportsRef.current.get(pane.id)
+ transport?.sendInput('\x1b\x7f')
+ }
+
+ // Shift+Enter → insert a literal newline into the shell command line.
+ const onShiftEnter = (e: KeyboardEvent): void => {
+ if (!e.shiftKey || e.metaKey || e.altKey || e.ctrlKey) {
+ return
+ }
+ if (e.key !== 'Enter') {
+ return
+ }
+
+ const manager = managerRef.current
+ if (!manager) {
+ return
+ }
+
+ e.preventDefault()
+ e.stopPropagation()
+ const pane = manager.getActivePane() ?? manager.getPanes()[0]
+ if (!pane) {
+ return
+ }
+ const transport = paneTransportsRef.current.get(pane.id)
+ transport?.sendInput('\x16\x0a')
+ }
+
+ window.addEventListener('keydown', onKeyDown, { capture: true })
+ window.addEventListener('keydown', onCtrlBackspace, { capture: true })
+ window.addEventListener('keydown', onAltBackspace, { capture: true })
+ window.addEventListener('keydown', onShiftEnter, { capture: true })
+ return () => {
+ window.removeEventListener('keydown', onKeyDown, { capture: true })
+ window.removeEventListener('keydown', onCtrlBackspace, { capture: true })
+ window.removeEventListener('keydown', onAltBackspace, { capture: true })
+ window.removeEventListener('keydown', onShiftEnter, { capture: true })
+ }
+ }, [isActive])
+}
+
+type FontZoomDeps = {
+ isActive: boolean
+ managerRef: React.RefObject
+ paneFontSizesRef: React.RefObject>
+ settingsRef: React.RefObject<{ terminalFontSize?: number } | null>
+}
+
+export function useTerminalFontZoom({
+ isActive,
+ managerRef,
+ paneFontSizesRef,
+ settingsRef
+}: FontZoomDeps): void {
+ useEffect(() => {
+ if (!isActive) {
+ return
+ }
+ const MIN_FONT_SIZE = 8
+ const MAX_FONT_SIZE = 32
+ const FONT_SIZE_STEP = 1
+
+ return window.api.ui.onTerminalZoom((direction) => {
+ const manager = managerRef.current
+ if (!manager) {
+ return
+ }
+ const pane = manager.getActivePane()
+ if (!pane) {
+ return
+ }
+
+ const globalSize = settingsRef.current?.terminalFontSize ?? 14
+ const currentSize = paneFontSizesRef.current.get(pane.id) ?? globalSize
+
+ let nextSize: number
+ if (direction === 'reset') {
+ nextSize = globalSize
+ paneFontSizesRef.current.delete(pane.id)
+ } else if (direction === 'in') {
+ nextSize = Math.min(MAX_FONT_SIZE, currentSize + FONT_SIZE_STEP)
+ paneFontSizesRef.current.set(pane.id, nextSize)
+ } else {
+ nextSize = Math.max(MIN_FONT_SIZE, currentSize - FONT_SIZE_STEP)
+ paneFontSizesRef.current.set(pane.id, nextSize)
+ }
+
+ pane.terminal.options.fontSize = nextSize
+ try {
+ pane.fitAddon.fit()
+ } catch {
+ /* ignore */
+ }
+ })
+ }, [isActive])
+}
diff --git a/src/renderer/src/components/terminal-pane/layout-serialization.ts b/src/renderer/src/components/terminal-pane/layout-serialization.ts
new file mode 100644
index 00000000000..b1f13ce9839
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/layout-serialization.ts
@@ -0,0 +1,146 @@
+import type {
+ TerminalLayoutSnapshot,
+ TerminalPaneLayoutNode,
+ TerminalPaneSplitDirection
+} from '../../../../shared/types'
+import type { PaneManager } from '@/lib/pane-manager/pane-manager'
+
+export const EMPTY_LAYOUT: TerminalLayoutSnapshot = {
+ root: null,
+ activeLeafId: null,
+ expandedLeafId: null
+}
+
+export function paneLeafId(paneId: number): string {
+ return `pane:${paneId}`
+}
+
+export function buildFontFamily(fontFamily: string): string {
+ const trimmed = fontFamily.trim()
+ const parts = trimmed ? [`"${trimmed}"`] : []
+ // Always include fallbacks
+ if (!parts.some((p) => p.toLowerCase().includes('sf mono'))) {
+ parts.push('"SF Mono"')
+ }
+ parts.push('Menlo', 'monospace')
+ return parts.join(', ')
+}
+
+export function getLayoutChildNodes(split: HTMLElement): HTMLElement[] {
+ return Array.from(split.children).filter(
+ (child): child is HTMLElement =>
+ child instanceof HTMLElement &&
+ (child.classList.contains('pane') || child.classList.contains('pane-split'))
+ )
+}
+
+export function serializePaneTree(node: HTMLElement | null): TerminalPaneLayoutNode | null {
+ if (!node) {
+ return null
+ }
+
+ if (node.classList.contains('pane')) {
+ const paneId = Number(node.dataset.paneId ?? '')
+ if (!Number.isFinite(paneId)) {
+ return null
+ }
+ return { type: 'leaf', leafId: paneLeafId(paneId) }
+ }
+
+ if (!node.classList.contains('pane-split')) {
+ return null
+ }
+ const [first, second] = getLayoutChildNodes(node)
+ const firstNode = serializePaneTree(first ?? null)
+ const secondNode = serializePaneTree(second ?? null)
+ if (!firstNode || !secondNode) {
+ return null
+ }
+
+ // Capture the flex ratio so resized panes survive serialization round-trips.
+ // We read the computed flex-grow values to derive the first-child proportion.
+ let ratio: number | undefined
+ if (first && second) {
+ const firstGrow = parseFloat(first.style.flex) || 1
+ const secondGrow = parseFloat(second.style.flex) || 1
+ const total = firstGrow + secondGrow
+ if (total > 0) {
+ const r = firstGrow / total
+ // Only store if meaningfully different from 0.5 (default equal split)
+ if (Math.abs(r - 0.5) > 0.005) {
+ ratio = Math.round(r * 1000) / 1000
+ }
+ }
+ }
+
+ return {
+ type: 'split',
+ direction: node.classList.contains('is-horizontal') ? 'horizontal' : 'vertical',
+ first: firstNode,
+ second: secondNode,
+ ...(ratio !== undefined && { ratio })
+ }
+}
+
+export function serializeTerminalLayout(
+ root: HTMLDivElement | null,
+ activePaneId: number | null,
+ expandedPaneId: number | null
+): TerminalLayoutSnapshot {
+ const rootNode = serializePaneTree(
+ root?.firstElementChild instanceof HTMLElement ? root.firstElementChild : null
+ )
+ return {
+ root: rootNode,
+ activeLeafId: activePaneId === null ? null : paneLeafId(activePaneId),
+ expandedLeafId: expandedPaneId === null ? null : paneLeafId(expandedPaneId)
+ }
+}
+
+function collectLeafIds(
+ node: TerminalPaneLayoutNode,
+ paneByLeafId: Map,
+ paneId: number
+): void {
+ if (node.type === 'leaf') {
+ paneByLeafId.set(node.leafId, paneId)
+ return
+ }
+ collectLeafIds(node.first, paneByLeafId, paneId)
+ collectLeafIds(node.second, paneByLeafId, paneId)
+}
+
+export function replayTerminalLayout(
+ manager: PaneManager,
+ snapshot: TerminalLayoutSnapshot | null | undefined,
+ focusInitialPane: boolean
+): Map {
+ const paneByLeafId = new Map()
+
+ const initialPane = manager.createInitialPane({ focus: focusInitialPane })
+ if (!snapshot?.root) {
+ paneByLeafId.set(paneLeafId(initialPane.id), initialPane.id)
+ return paneByLeafId
+ }
+
+ const restoreNode = (node: TerminalPaneLayoutNode, paneId: number): void => {
+ if (node.type === 'leaf') {
+ paneByLeafId.set(node.leafId, paneId)
+ return
+ }
+
+ const createdPane = manager.splitPane(paneId, node.direction as TerminalPaneSplitDirection, {
+ ratio: node.ratio
+ })
+ if (!createdPane) {
+ collectLeafIds(node, paneByLeafId, paneId)
+ return
+ }
+
+ restoreNode(node.first, paneId)
+ restoreNode(node.second, createdPane.id)
+ }
+
+ restoreNode(snapshot.root, initialPane.id)
+ return paneByLeafId
+}
diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts
new file mode 100644
index 00000000000..a74f8ad22f6
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/pty-connection.ts
@@ -0,0 +1,79 @@
+import type { PaneManager, ManagedPane } from '@/lib/pane-manager/pane-manager'
+import type { PtyTransport } from './pty-transport'
+import { createIpcPtyTransport } from './pty-transport'
+
+type PtyConnectionDeps = {
+ tabId: string
+ worktreeId: string
+ cwd?: string
+ paneTransportsRef: React.RefObject>
+ pendingWritesRef: React.RefObject>
+ isActiveRef: React.RefObject
+ onPtyExitRef: React.RefObject<(ptyId: string) => void>
+ clearTabPtyId: (tabId: string, ptyId: string) => void
+ updateTabTitle: (tabId: string, title: string) => void
+ updateTabPtyId: (tabId: string, ptyId: string) => void
+ markWorktreeUnreadFromBell: (worktreeId: string) => void
+}
+
+export function connectPanePty(
+ pane: ManagedPane,
+ manager: PaneManager,
+ deps: PtyConnectionDeps
+): void {
+ const onExit = (ptyId: string): void => {
+ deps.clearTabPtyId(deps.tabId, ptyId)
+ const panes = manager.getPanes()
+ if (panes.length <= 1) {
+ deps.onPtyExitRef.current(ptyId)
+ return
+ }
+ manager.closePane(pane.id)
+ }
+
+ const onTitleChange = (title: string): void => {
+ deps.updateTabTitle(deps.tabId, title)
+ }
+
+ const onPtySpawn = (ptyId: string): void => deps.updateTabPtyId(deps.tabId, ptyId)
+ const onBell = (): void => deps.markWorktreeUnreadFromBell(deps.worktreeId)
+
+ const transport = createIpcPtyTransport(deps.cwd, onExit, onTitleChange, onPtySpawn, onBell)
+ deps.paneTransportsRef.current.set(pane.id, transport)
+
+ pane.terminal.onData((data) => {
+ transport.sendInput(data)
+ })
+
+ pane.terminal.onResize(({ cols, rows }) => {
+ transport.resize(cols, rows)
+ })
+
+ // Defer PTY spawn to next frame so FitAddon has time to calculate
+ // the correct terminal dimensions from the laid-out container.
+ deps.pendingWritesRef.current.set(pane.id, '')
+ requestAnimationFrame(() => {
+ try {
+ pane.fitAddon.fit()
+ } catch {
+ /* ignore */
+ }
+ const cols = pane.terminal.cols
+ const rows = pane.terminal.rows
+ transport.connect({
+ url: '',
+ cols,
+ rows,
+ callbacks: {
+ onData: (data) => {
+ if (deps.isActiveRef.current) {
+ pane.terminal.write(data)
+ } else {
+ const pending = deps.pendingWritesRef.current
+ pending.set(pane.id, (pending.get(pane.id) ?? '') + data)
+ }
+ }
+ }
+ })
+ })
+}
diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts
new file mode 100644
index 00000000000..1e2f4a782d6
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/pty-transport.ts
@@ -0,0 +1,222 @@
+export type PtyTransport = {
+ connect: (options: {
+ url: string
+ cols?: number
+ rows?: number
+ callbacks: {
+ onConnect?: () => void
+ onDisconnect?: () => void
+ onData?: (data: string) => void
+ onStatus?: (shell: string) => void
+ onError?: (message: string, errors?: string[]) => void
+ onExit?: (code: number) => void
+ }
+ }) => void | Promise
+ disconnect: () => void
+ sendInput: (data: string) => boolean
+ resize: (
+ cols: number,
+ rows: number,
+ meta?: { widthPx?: number; heightPx?: number; cellW?: number; cellH?: number }
+ ) => boolean
+ isConnected: () => boolean
+ destroy?: () => void | Promise
+}
+
+// Singleton PTY event dispatcher — one global IPC listener per channel,
+// routes events to transports by PTY ID. Eliminates the N-listener problem
+// that triggers MaxListenersExceededWarning with many panes/tabs.
+const ptyDataHandlers = new Map void>()
+const ptyExitHandlers = new Map void>()
+let ptyDispatcherAttached = false
+
+function ensurePtyDispatcher(): void {
+ if (ptyDispatcherAttached) {
+ return
+ }
+ ptyDispatcherAttached = true
+ window.api.pty.onData((payload) => {
+ ptyDataHandlers.get(payload.id)?.(payload.data)
+ })
+ window.api.pty.onExit((payload) => {
+ ptyExitHandlers.get(payload.id)?.(payload.code)
+ })
+}
+
+// eslint-disable-next-line no-control-regex -- intentional terminal escape sequence matching
+const OSC_TITLE_RE = /\x1b\]([012]);([^\x07\x1b]*?)(?:\x07|\x1b\\)/g
+
+export function extractLastOscTitle(data: string): string | null {
+ let last: string | null = null
+ let m: RegExpExecArray | null
+ OSC_TITLE_RE.lastIndex = 0
+ while ((m = OSC_TITLE_RE.exec(data)) !== null) {
+ last = m[2]
+ }
+ return last
+}
+
+export function createIpcPtyTransport(
+ cwd?: string,
+ onPtyExit?: (ptyId: string) => void,
+ onTitleChange?: (title: string) => void,
+ onPtySpawn?: (ptyId: string) => void,
+ onBell?: () => void
+): PtyTransport {
+ let connected = false
+ let destroyed = false
+ let ptyId: string | null = null
+ let pendingEscape = false
+ let inOsc = false
+ let pendingOscEscape = false
+ let storedCallbacks: {
+ onConnect?: () => void
+ onDisconnect?: () => void
+ onData?: (data: string) => void
+ onStatus?: (shell: string) => void
+ onError?: (message: string, errors?: string[]) => void
+ onExit?: (code: number) => void
+ } = {}
+
+ function unregisterPtyHandlers(id: string): void {
+ ptyDataHandlers.delete(id)
+ ptyExitHandlers.delete(id)
+ }
+
+ return {
+ async connect(options) {
+ storedCallbacks = options.callbacks
+ ensurePtyDispatcher()
+
+ try {
+ const result = await window.api.pty.spawn({
+ cols: options.cols ?? 80,
+ rows: options.rows ?? 24,
+ cwd
+ })
+
+ // If destroyed while spawn was in flight, kill the new pty and bail
+ if (destroyed) {
+ window.api.pty.kill(result.id)
+ return
+ }
+
+ ptyId = result.id
+ connected = true
+ onPtySpawn?.(result.id)
+
+ ptyDataHandlers.set(result.id, (data) => {
+ storedCallbacks.onData?.(data)
+ if (onTitleChange) {
+ const title = extractLastOscTitle(data)
+ if (title !== null) {
+ onTitleChange(title)
+ }
+ }
+ if (onBell && chunkContainsBell(data)) {
+ onBell()
+ }
+ })
+
+ const spawnedId = result.id
+ ptyExitHandlers.set(spawnedId, (code) => {
+ connected = false
+ ptyId = null
+ unregisterPtyHandlers(spawnedId)
+ storedCallbacks.onExit?.(code)
+ storedCallbacks.onDisconnect?.()
+ onPtyExit?.(spawnedId)
+ })
+
+ storedCallbacks.onConnect?.()
+ storedCallbacks.onStatus?.('shell')
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err)
+ storedCallbacks.onError?.(msg)
+ }
+ },
+
+ disconnect() {
+ if (ptyId) {
+ const id = ptyId
+ window.api.pty.kill(id)
+ connected = false
+ ptyId = null
+ unregisterPtyHandlers(id)
+ storedCallbacks.onDisconnect?.()
+ }
+ },
+
+ sendInput(data: string): boolean {
+ if (!connected || !ptyId) {
+ return false
+ }
+ window.api.pty.write(ptyId, data)
+ return true
+ },
+
+ resize(cols: number, rows: number): boolean {
+ if (!connected || !ptyId) {
+ return false
+ }
+ window.api.pty.resize(ptyId, cols, rows)
+ return true
+ },
+
+ isConnected() {
+ return connected
+ },
+
+ destroy() {
+ destroyed = true
+ this.disconnect()
+ }
+ }
+
+ function chunkContainsBell(data: string): boolean {
+ for (let i = 0; i < data.length; i += 1) {
+ const char = data[i]
+
+ if (inOsc) {
+ if (pendingOscEscape) {
+ pendingOscEscape = char === '\x1b'
+ if (char === '\\') {
+ inOsc = false
+ pendingOscEscape = false
+ }
+ continue
+ }
+
+ if (char === '\x07') {
+ inOsc = false
+ continue
+ }
+
+ pendingOscEscape = char === '\x1b'
+ continue
+ }
+
+ if (pendingEscape) {
+ pendingEscape = false
+ if (char === ']') {
+ inOsc = true
+ pendingOscEscape = false
+ } else if (char === '\x1b') {
+ pendingEscape = true
+ }
+ continue
+ }
+
+ if (char === '\x1b') {
+ pendingEscape = true
+ continue
+ }
+
+ if (char === '\x07') {
+ return true
+ }
+ }
+
+ return false
+ }
+}
diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.ts
new file mode 100644
index 00000000000..468386c8e6d
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/terminal-appearance.ts
@@ -0,0 +1,53 @@
+import type { ITheme } from '@xterm/xterm'
+import type { PaneManager } from '@/lib/pane-manager/pane-manager'
+import type { GlobalSettings } from '../../../../shared/types'
+import {
+ getCursorStyleSequence,
+ getBuiltinTheme,
+ resolvePaneStyleOptions,
+ resolveEffectiveTerminalAppearance
+} from '@/lib/terminal-theme'
+import type { PtyTransport } from './pty-transport'
+
+export function applyTerminalAppearance(
+ manager: PaneManager,
+ settings: GlobalSettings,
+ systemPrefersDark: boolean,
+ paneFontSizes: Map,
+ paneTransports: Map
+): void {
+ const appearance = resolveEffectiveTerminalAppearance(settings, systemPrefersDark)
+ const paneStyles = resolvePaneStyleOptions(settings)
+ const cursorSequence = getCursorStyleSequence(
+ settings.terminalCursorStyle,
+ settings.terminalCursorBlink
+ )
+ const theme: ITheme | null = appearance.theme ?? getBuiltinTheme(appearance.themeName)
+ const paneBackground = theme?.background ?? '#000000'
+
+ for (const pane of manager.getPanes()) {
+ if (theme) {
+ pane.terminal.options.theme = theme
+ }
+ pane.terminal.options.cursorStyle = settings.terminalCursorStyle
+ pane.terminal.options.cursorBlink = settings.terminalCursorBlink
+ const paneSize = paneFontSizes.get(pane.id)
+ pane.terminal.options.fontSize = paneSize ?? settings.terminalFontSize
+ try {
+ pane.fitAddon.fit()
+ } catch {
+ /* ignore */
+ }
+ const transport = paneTransports.get(pane.id)
+ transport?.sendInput(cursorSequence)
+ }
+
+ manager.setPaneStyleOptions({
+ splitBackground: paneBackground,
+ paneBackground,
+ inactivePaneOpacity: paneStyles.inactivePaneOpacity,
+ activePaneOpacity: paneStyles.activePaneOpacity,
+ opacityTransitionMs: paneStyles.opacityTransitionMs,
+ dividerThicknessPx: paneStyles.dividerThicknessPx
+ })
+}
diff --git a/src/renderer/src/lib/pane-manager.ts b/src/renderer/src/lib/pane-manager.ts
deleted file mode 100644
index a8bb8be8824..00000000000
--- a/src/renderer/src/lib/pane-manager.ts
+++ /dev/null
@@ -1,1060 +0,0 @@
-import { Terminal } from '@xterm/xterm'
-import type { ITerminalOptions } from '@xterm/xterm'
-import { FitAddon } from '@xterm/addon-fit'
-import { SearchAddon } from '@xterm/addon-search'
-import { Unicode11Addon } from '@xterm/addon-unicode11'
-import { WebLinksAddon } from '@xterm/addon-web-links'
-import { WebglAddon } from '@xterm/addon-webgl'
-
-// ---------------------------------------------------------------------------
-// Public interfaces
-// ---------------------------------------------------------------------------
-
-export interface PaneManagerOptions {
- onPaneCreated?: (pane: ManagedPane) => void | Promise
- onPaneClosed?: (paneId: number) => void
- onActivePaneChange?: (pane: ManagedPane) => void
- onLayoutChanged?: () => void
- terminalOptions?: (paneId: number) => Partial
- onLinkClick?: (url: string) => void
-}
-
-export interface PaneStyleOptions {
- splitBackground?: string
- paneBackground?: string
- inactivePaneOpacity?: number
- activePaneOpacity?: number
- opacityTransitionMs?: number
- dividerThicknessPx?: number
-}
-
-export interface ManagedPane {
- id: number
- terminal: Terminal
- container: HTMLElement // the .pane element
- fitAddon: FitAddon
- searchAddon: SearchAddon
-}
-
-// ---------------------------------------------------------------------------
-// Internal types
-// ---------------------------------------------------------------------------
-
-interface ManagedPaneInternal extends ManagedPane {
- xtermContainer: HTMLElement
- webglAddon: WebglAddon | null
- unicode11Addon: Unicode11Addon
- webLinksAddon: WebLinksAddon
-}
-
-// ---------------------------------------------------------------------------
-// PaneManager
-// ---------------------------------------------------------------------------
-
-export type DropZone = 'top' | 'bottom' | 'left' | 'right'
-
-export class PaneManager {
- private root: HTMLElement
- private panes: Map = new Map()
- private activePaneId: number | null = null
- private nextPaneId = 1
- private options: PaneManagerOptions
- private styleOptions: PaneStyleOptions = {}
- private destroyed = false
-
- // Drag-to-reorder state
- private dragSourcePaneId: number | null = null
- private dropOverlay: HTMLElement | null = null
- private currentDropTarget: { paneId: number; zone: DropZone } | null = null
-
- constructor(root: HTMLElement, options: PaneManagerOptions) {
- this.root = root
- this.options = options
- }
-
- // -----------------------------------------------------------------------
- // Public API
- // -----------------------------------------------------------------------
-
- createInitialPane(opts?: { focus?: boolean }): ManagedPane {
- const pane = this.createPaneInternal()
-
- // When the pane is the sole child of root (no splits), it must
- // fill the root container so FitAddon calculates correct dimensions.
- pane.container.style.width = '100%'
- pane.container.style.height = '100%'
- pane.container.style.position = 'relative'
- pane.container.style.overflow = 'hidden'
-
- // Place directly into root
- this.root.appendChild(pane.container)
-
- this.openTerminal(pane)
-
- this.activePaneId = pane.id
- this.applyPaneOpacity()
-
- if (opts?.focus !== false) {
- pane.terminal.focus()
- }
-
- void this.options.onPaneCreated?.(this.toPublic(pane))
- return this.toPublic(pane)
- }
-
- splitPane(
- paneId: number,
- direction: 'vertical' | 'horizontal',
- opts?: { ratio?: number }
- ): ManagedPane | null {
- const existing = this.panes.get(paneId)
- if (!existing) return null
-
- const newPane = this.createPaneInternal()
-
- const parent = existing.container.parentElement
- if (!parent) return null
-
- const isVertical = direction === 'vertical'
-
- // Capture the flex style of the element we're replacing BEFORE modifying it,
- // so the new split wrapper inherits its position in the parent flex layout.
- const existingFlex = existing.container.style.flex || ''
- const existingMinW = existing.container.style.minWidth || ''
- const existingMinH = existing.container.style.minHeight || ''
-
- // Create split container
- const split = document.createElement('div')
- split.className = `pane-split ${isVertical ? 'is-vertical' : 'is-horizontal'}`
- split.style.display = 'flex'
- split.style.flexDirection = isVertical ? 'row' : 'column'
-
- // If the existing pane was inside a flex parent (another split), inherit its
- // flex properties so the split wrapper occupies the same slot. Otherwise
- // (direct child of root) use full width/height.
- if (parent.classList.contains('pane-split')) {
- split.style.flex = existingFlex || '1 1 0%'
- split.style.minWidth = existingMinW || '0'
- split.style.minHeight = existingMinH || '0'
- split.style.overflow = 'hidden'
- } else {
- split.style.width = '100%'
- split.style.height = '100%'
- }
-
- // Create divider
- const divider = this.createDivider(isVertical)
-
- // Apply flex styles to existing pane container (child of the new split)
- this.applyPaneFlexStyle(existing.container)
-
- // Apply flex styles to new pane container
- this.applyPaneFlexStyle(newPane.container)
-
- // Apply custom ratio if provided (e.g. restoring a saved layout)
- const ratio = opts?.ratio
- if (ratio !== undefined && ratio > 0 && ratio < 1) {
- const firstGrow = ratio
- const secondGrow = 1 - ratio
- existing.container.style.flex = `${firstGrow} 1 0%`
- newPane.container.style.flex = `${secondGrow} 1 0%`
- }
-
- // Replace existing pane with split in the DOM
- parent.replaceChild(split, existing.container)
-
- // Build split: [existing] [divider] [new]
- split.appendChild(existing.container)
- split.appendChild(divider)
- split.appendChild(newPane.container)
-
- // Open terminal for new pane
- this.openTerminal(newPane)
-
- // Set new pane active
- this.activePaneId = newPane.id
- this.applyPaneOpacity()
- this.applyDividerStyles()
-
- if (newPane.terminal) {
- newPane.terminal.focus()
- }
-
- // Refit existing pane since it now shares space
- this.safeFit(existing)
-
- this.updateMultiPaneState()
-
- void this.options.onPaneCreated?.(this.toPublic(newPane))
- this.options.onLayoutChanged?.()
-
- return this.toPublic(newPane)
- }
-
- closePane(paneId: number): void {
- const pane = this.panes.get(paneId)
- if (!pane) return
-
- const paneContainer = pane.container
- const parent = paneContainer.parentElement
- if (!parent) return
-
- // Dispose terminal and addons
- this.disposePane(pane)
-
- if (parent.classList.contains('pane-split')) {
- // Find sibling (skip divider)
- const children = Array.from(parent.children).filter(
- (child): child is HTMLElement =>
- child instanceof HTMLElement &&
- (child.classList.contains('pane') || child.classList.contains('pane-split'))
- )
-
- const sibling = children.find((c) => c !== paneContainer) ?? null
-
- // Remove pane element
- paneContainer.remove()
-
- // Remove divider(s)
- const dividers = Array.from(parent.children).filter(
- (child): child is HTMLElement =>
- child instanceof HTMLElement && child.classList.contains('pane-divider')
- )
- for (const d of dividers) d.remove()
-
- if (sibling) {
- // Unwrap: replace the split container with the sibling
- const grandparent = parent.parentElement
- if (grandparent) {
- if (grandparent === this.root) {
- // Going back to root level — fill the root container
- sibling.style.flex = ''
- sibling.style.minWidth = ''
- sibling.style.minHeight = ''
- sibling.style.width = '100%'
- sibling.style.height = '100%'
- sibling.style.position = 'relative'
- sibling.style.overflow = 'hidden'
- } else if (grandparent.classList.contains('pane-split')) {
- // Going into another split — inherit the flex slot from the
- // split container we're removing
- sibling.style.flex = parent.style.flex || '1 1 0%'
- sibling.style.minWidth = parent.style.minWidth || '0'
- sibling.style.minHeight = parent.style.minHeight || '0'
- sibling.style.overflow = 'hidden'
- }
- grandparent.replaceChild(sibling, parent)
- }
- } else {
- // No sibling left, just remove the split
- parent.remove()
- }
- } else {
- // Direct child of root (only pane) — just remove
- paneContainer.remove()
- }
-
- // Activate next pane if needed
- if (this.activePaneId === paneId) {
- const remaining = Array.from(this.panes.values())
- if (remaining.length > 0) {
- this.activePaneId = remaining[0].id
- remaining[0].terminal.focus()
- } else {
- this.activePaneId = null
- }
- }
-
- this.applyPaneOpacity()
-
- // Refit remaining panes
- for (const p of this.panes.values()) {
- this.safeFit(p)
- }
-
- this.updateMultiPaneState()
- this.options.onPaneClosed?.(paneId)
- this.options.onLayoutChanged?.()
- }
-
- getPanes(): ManagedPane[] {
- return Array.from(this.panes.values()).map((p) => this.toPublic(p))
- }
-
- getActivePane(): ManagedPane | null {
- if (this.activePaneId === null) return null
- const pane = this.panes.get(this.activePaneId)
- return pane ? this.toPublic(pane) : null
- }
-
- setActivePane(paneId: number, opts?: { focus?: boolean }): void {
- const pane = this.panes.get(paneId)
- if (!pane) return
-
- const changed = this.activePaneId !== paneId
- this.activePaneId = paneId
- this.applyPaneOpacity()
-
- if (opts?.focus !== false) {
- pane.terminal.focus()
- }
-
- if (changed) {
- this.options.onActivePaneChange?.(this.toPublic(pane))
- }
- }
-
- setPaneStyleOptions(opts: PaneStyleOptions): void {
- this.styleOptions = { ...opts }
- this.applyPaneOpacity()
- this.applyDividerStyles()
- this.applyRootBackground()
- }
-
- /**
- * Suspend GPU rendering for all panes. Disposes WebGL addons to free
- * GPU contexts while keeping Terminal instances alive (scrollback, cursor,
- * screen buffer all preserved). Call when this tab/worktree becomes hidden.
- */
- suspendRendering(): void {
- for (const pane of this.panes.values()) {
- if (pane.webglAddon) {
- try {
- pane.webglAddon.dispose()
- } catch {
- /* ignore */
- }
- pane.webglAddon = null
- }
- }
- }
-
- /**
- * Resume GPU rendering for all panes. Recreates WebGL addons. Call when
- * this tab/worktree becomes visible again. Must be followed by a fit() pass.
- */
- resumeRendering(): void {
- for (const pane of this.panes.values()) {
- if (!pane.webglAddon) {
- this.attachWebgl(pane)
- }
- }
- }
-
- destroy(): void {
- this.destroyed = true
- this.hideDropOverlay()
- for (const pane of this.panes.values()) {
- this.disposePane(pane)
- }
- this.root.innerHTML = ''
- this.activePaneId = null
- }
-
- // -----------------------------------------------------------------------
- // Internal helpers
- // -----------------------------------------------------------------------
-
- private createPaneInternal(): ManagedPaneInternal {
- const id = this.nextPaneId++
-
- // Create .pane container
- const container = document.createElement('div')
- container.className = 'pane'
- container.dataset.paneId = String(id)
-
- // Create .xterm-container with small inset padding
- const TERMINAL_PADDING = 4
- const xtermContainer = document.createElement('div')
- xtermContainer.className = 'xterm-container'
- xtermContainer.style.width = `calc(100% - ${TERMINAL_PADDING}px)`
- xtermContainer.style.height = `calc(100% - ${TERMINAL_PADDING}px)`
- xtermContainer.style.marginTop = `${TERMINAL_PADDING}px`
- xtermContainer.style.marginLeft = `${TERMINAL_PADDING}px`
- container.appendChild(xtermContainer)
-
- // Build terminal options
- const userOpts = this.options.terminalOptions?.(id) ?? {}
- const terminalOpts: ITerminalOptions = {
- allowProposedApi: true,
- cursorBlink: true,
- cursorStyle: 'bar',
- fontSize: 14,
- fontFamily: '"SF Mono", Menlo, monospace',
- fontWeight: '300',
- fontWeightBold: '500',
- scrollback: 10000,
- allowTransparency: false,
- macOptionIsMeta: true,
- macOptionClickForcesSelection: true,
- drawBoldTextInBrightColors: true,
- ...userOpts
- }
-
- const terminal = new Terminal(terminalOpts)
- const fitAddon = new FitAddon()
- const searchAddon = new SearchAddon()
- const unicode11Addon = new Unicode11Addon()
- // URL tooltip element — Ghostty-style bottom-left hint on hover
- const linkTooltip = document.createElement('div')
- linkTooltip.className = 'pane-link-tooltip'
- linkTooltip.style.cssText =
- 'display:none;position:absolute;bottom:4px;left:8px;z-index:40;' +
- 'padding:2px 8px;border-radius:4px;font-size:11px;font-family:inherit;' +
- 'color:#a1a1aa;background:rgba(24,24,27,0.85);border:1px solid rgba(63,63,70,0.6);' +
- 'pointer-events:none;max-width:80%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'
- container.appendChild(linkTooltip)
-
- // Ghostty-style drag handle — appears at top of pane on hover when 2+ panes
- const dragHandle = document.createElement('div')
- dragHandle.className = 'pane-drag-handle'
- container.appendChild(dragHandle)
- this.attachPaneDrag(dragHandle, id)
-
- const webLinksAddon = new WebLinksAddon(
- this.options.onLinkClick ? (_event, uri) => this.options.onLinkClick!(uri) : undefined,
- {
- hover: (event, uri) => {
- if (event.type === 'mouseover' && uri) {
- linkTooltip.textContent = uri
- linkTooltip.style.display = ''
- } else {
- linkTooltip.style.display = 'none'
- }
- }
- }
- )
-
- const pane: ManagedPaneInternal = {
- id,
- terminal,
- container,
- xtermContainer,
- fitAddon,
- searchAddon,
- unicode11Addon,
- webLinksAddon,
- webglAddon: null
- }
-
- // Focus handler: clicking a pane makes it active and explicitly focuses
- // the terminal. We must call focus: true here because after DOM reparenting
- // (e.g. splitPane moves the original pane into a flex container), xterm.js's
- // native click-to-focus on its internal textarea may not fire reliably.
- container.addEventListener('pointerdown', () => {
- if (!this.destroyed && this.activePaneId !== id) {
- this.setActivePane(id, { focus: true })
- }
- })
-
- this.panes.set(id, pane)
- return pane
- }
-
- /** Open terminal into its container and load addons. Must be called after the container is in the DOM. */
- private openTerminal(pane: ManagedPaneInternal): void {
- const { terminal, xtermContainer, fitAddon, searchAddon, unicode11Addon, webLinksAddon } = pane
-
- // Open terminal into DOM
- terminal.open(xtermContainer)
-
- // Load addons (order matters: WebGL must be after open())
- terminal.loadAddon(fitAddon)
- terminal.loadAddon(searchAddon)
- terminal.loadAddon(unicode11Addon)
- terminal.loadAddon(webLinksAddon)
-
- // Activate unicode 11
- terminal.unicode.activeVersion = '11'
-
- // Attach GPU renderer
- this.attachWebgl(pane)
-
- // Initial fit (deferred to ensure layout has settled)
- requestAnimationFrame(() => {
- this.safeFit(pane)
- })
- }
-
- private attachWebgl(pane: ManagedPaneInternal): void {
- try {
- const webglAddon = new WebglAddon()
- webglAddon.onContextLoss(() => {
- webglAddon.dispose()
- pane.webglAddon = null
- })
- pane.terminal.loadAddon(webglAddon)
- pane.webglAddon = webglAddon
- } catch {
- // WebGL not available — default DOM renderer is fine
- pane.webglAddon = null
- }
- }
-
- private safeFit(pane: ManagedPaneInternal): void {
- try {
- pane.fitAddon.fit()
- } catch {
- // Container may not have dimensions yet
- }
- }
-
- private disposePane(pane: ManagedPaneInternal): void {
- try {
- pane.webglAddon?.dispose()
- } catch {
- /* ignore */
- }
- try {
- pane.searchAddon.dispose()
- } catch {
- /* ignore */
- }
- try {
- pane.unicode11Addon.dispose()
- } catch {
- /* ignore */
- }
- try {
- pane.webLinksAddon.dispose()
- } catch {
- /* ignore */
- }
- try {
- pane.fitAddon.dispose()
- } catch {
- /* ignore */
- }
- try {
- pane.terminal.dispose()
- } catch {
- /* ignore */
- }
- this.panes.delete(pane.id)
- }
-
- private applyPaneFlexStyle(el: HTMLElement): void {
- el.style.flex = '1 1 0%'
- el.style.minWidth = '0'
- el.style.minHeight = '0'
- el.style.position = 'relative'
- el.style.overflow = 'hidden'
- // Clear any fixed width/height from createInitialPane so flex sizing
- // controls the layout instead of the leftover 100% values.
- el.style.width = ''
- el.style.height = ''
- }
-
- private createDivider(isVertical: boolean): HTMLElement {
- const divider = document.createElement('div')
- divider.className = `pane-divider ${isVertical ? 'is-vertical' : 'is-horizontal'}`
-
- // Ghostty-style: the element itself is a wide transparent hit area for easy
- // grabbing. The visible line is drawn by a CSS ::after pseudo-element
- // (see main.css), so `background` on the element stays transparent.
- const hitSize = this.getDividerHitSize()
- if (isVertical) {
- divider.style.width = `${hitSize}px`
- divider.style.cursor = 'col-resize'
- } else {
- divider.style.height = `${hitSize}px`
- divider.style.cursor = 'row-resize'
- }
- divider.style.flex = 'none'
- divider.style.position = 'relative'
-
- this.attachDividerDrag(divider, isVertical)
- return divider
- }
-
- /** Total hit area size = visible thickness + invisible padding on each side */
- private getDividerHitSize(): number {
- const thickness = this.styleOptions.dividerThicknessPx ?? 4
- const HIT_PADDING = 3
- return thickness + HIT_PADDING * 2
- }
-
- private attachDividerDrag(divider: HTMLElement, isVertical: boolean): void {
- const MIN_PANE_SIZE = 50
-
- let dragging = false
- let didMove = false
- let startPos = 0
- let prevFlex = 0
- let nextFlex = 0
- let totalSize = 0
- let prevEl: HTMLElement | null = null
- let nextEl: HTMLElement | null = null
-
- const onPointerDown = (e: PointerEvent): void => {
- e.preventDefault()
- divider.setPointerCapture(e.pointerId)
- divider.classList.add('is-dragging')
- dragging = true
- didMove = false
-
- startPos = isVertical ? e.clientX : e.clientY
-
- // Find previous and next pane/split siblings
- prevEl = divider.previousElementSibling as HTMLElement | null
- nextEl = divider.nextElementSibling as HTMLElement | null
-
- if (!prevEl || !nextEl) return
-
- const prevRect = prevEl.getBoundingClientRect()
- const nextRect = nextEl.getBoundingClientRect()
- const prevSize = isVertical ? prevRect.width : prevRect.height
- const nextSize = isVertical ? nextRect.width : nextRect.height
- totalSize = prevSize + nextSize
-
- // Store current proportions as flex-basis values
- prevFlex = prevSize
- nextFlex = nextSize
- }
-
- const onPointerMove = (e: PointerEvent): void => {
- if (!dragging || !prevEl || !nextEl) return
- didMove = true
-
- const currentPos = isVertical ? e.clientX : e.clientY
- const delta = currentPos - startPos
-
- let newPrev = prevFlex + delta
- let newNext = nextFlex - delta
-
- // Enforce minimum pane size
- if (newPrev < MIN_PANE_SIZE) {
- newPrev = MIN_PANE_SIZE
- newNext = totalSize - MIN_PANE_SIZE
- }
- if (newNext < MIN_PANE_SIZE) {
- newNext = MIN_PANE_SIZE
- newPrev = totalSize - MIN_PANE_SIZE
- }
-
- // Use flex-grow proportionally
- prevEl.style.flex = `${newPrev} 1 0%`
- nextEl.style.flex = `${newNext} 1 0%`
-
- // Refit terminals in affected panes
- this.refitPanesUnder(prevEl)
- this.refitPanesUnder(nextEl)
- }
-
- const onPointerUp = (e: PointerEvent): void => {
- if (!dragging) return
- dragging = false
- divider.releasePointerCapture(e.pointerId)
- divider.classList.remove('is-dragging')
- prevEl = null
- nextEl = null
-
- // Persist updated ratios after a real drag
- if (didMove) {
- this.options.onLayoutChanged?.()
- }
- }
-
- // Ghostty-style: double-click divider to equalize sibling panes
- const onDoubleClick = (): void => {
- const prev = divider.previousElementSibling as HTMLElement | null
- const next = divider.nextElementSibling as HTMLElement | null
- if (!prev || !next) return
-
- prev.style.flex = '1 1 0%'
- next.style.flex = '1 1 0%'
-
- this.refitPanesUnder(prev)
- this.refitPanesUnder(next)
- this.options.onLayoutChanged?.()
- }
-
- divider.addEventListener('pointerdown', onPointerDown)
- divider.addEventListener('pointermove', onPointerMove)
- divider.addEventListener('pointerup', onPointerUp)
- divider.addEventListener('dblclick', onDoubleClick)
- }
-
- // -----------------------------------------------------------------------
- // Drag-to-reorder
- // -----------------------------------------------------------------------
-
- /** Move a pane from its current position to a new position relative to a target pane. */
- movePane(sourcePaneId: number, targetPaneId: number, zone: DropZone): void {
- if (sourcePaneId === targetPaneId) return
- const source = this.panes.get(sourcePaneId)
- const target = this.panes.get(targetPaneId)
- if (!source || !target) return
-
- // 1. Detach source pane from the tree (without disposing its terminal)
- this.detachPaneFromTree(source)
-
- // 2. Insert source next to target in the requested zone
- this.insertPaneNextTo(source, target, zone)
-
- // 3. Refit all panes and persist
- for (const p of this.panes.values()) this.safeFit(p)
- this.applyPaneOpacity()
- this.applyDividerStyles()
- this.updateMultiPaneState()
- this.options.onLayoutChanged?.()
- }
-
- /**
- * Detach a pane's container from the split tree without disposing the terminal.
- * The sibling is promoted to take the split container's slot.
- */
- private detachPaneFromTree(pane: ManagedPaneInternal): void {
- const container = pane.container
- const parent = container.parentElement
- if (!parent) return
-
- if (!parent.classList.contains('pane-split')) {
- // Direct child of root — just remove it
- container.remove()
- return
- }
-
- // Find sibling (skip dividers)
- const children = Array.from(parent.children).filter(
- (child): child is HTMLElement =>
- child instanceof HTMLElement &&
- (child.classList.contains('pane') || child.classList.contains('pane-split'))
- )
- const sibling = children.find((c) => c !== container) ?? null
-
- // Remove pane and dividers from the split
- container.remove()
- const dividers = Array.from(parent.children).filter(
- (child): child is HTMLElement =>
- child instanceof HTMLElement && child.classList.contains('pane-divider')
- )
- for (const d of dividers) d.remove()
-
- // Promote sibling to replace the split container
- if (sibling) {
- const grandparent = parent.parentElement
- if (grandparent) {
- if (grandparent === this.root) {
- sibling.style.flex = ''
- sibling.style.minWidth = ''
- sibling.style.minHeight = ''
- sibling.style.width = '100%'
- sibling.style.height = '100%'
- sibling.style.position = 'relative'
- sibling.style.overflow = 'hidden'
- } else if (grandparent.classList.contains('pane-split')) {
- sibling.style.flex = parent.style.flex || '1 1 0%'
- sibling.style.minWidth = parent.style.minWidth || '0'
- sibling.style.minHeight = parent.style.minHeight || '0'
- sibling.style.overflow = 'hidden'
- }
- grandparent.replaceChild(sibling, parent)
- }
- } else {
- parent.remove()
- }
- }
-
- /** Insert source pane next to target pane by wrapping target in a new split. */
- private insertPaneNextTo(
- source: ManagedPaneInternal,
- target: ManagedPaneInternal,
- zone: DropZone
- ): void {
- const targetContainer = target.container
- const parent = targetContainer.parentElement
- if (!parent) return
-
- const isVertical = zone === 'left' || zone === 'right'
- const sourceFirst = zone === 'left' || zone === 'top'
-
- // Capture target's flex slot
- const targetFlex = targetContainer.style.flex || ''
- const targetMinW = targetContainer.style.minWidth || ''
- const targetMinH = targetContainer.style.minHeight || ''
-
- // Create split wrapper
- const split = document.createElement('div')
- split.className = `pane-split ${isVertical ? 'is-vertical' : 'is-horizontal'}`
- split.style.display = 'flex'
- split.style.flexDirection = isVertical ? 'row' : 'column'
-
- if (parent.classList.contains('pane-split')) {
- split.style.flex = targetFlex || '1 1 0%'
- split.style.minWidth = targetMinW || '0'
- split.style.minHeight = targetMinH || '0'
- split.style.overflow = 'hidden'
- } else {
- split.style.width = '100%'
- split.style.height = '100%'
- }
-
- // Create divider
- const divider = this.createDivider(isVertical)
-
- // Apply flex styles to both panes
- this.applyPaneFlexStyle(source.container)
- this.applyPaneFlexStyle(targetContainer)
-
- // Replace target with the split in the DOM
- parent.replaceChild(split, targetContainer)
-
- // Build split: [first] [divider] [second]
- if (sourceFirst) {
- split.appendChild(source.container)
- split.appendChild(divider)
- split.appendChild(targetContainer)
- } else {
- split.appendChild(targetContainer)
- split.appendChild(divider)
- split.appendChild(source.container)
- }
-
- // Refit both
- requestAnimationFrame(() => {
- this.safeFit(source)
- this.safeFit(target)
- })
- }
-
- /** Attach drag-to-reorder handlers to a pane's drag handle. */
- private attachPaneDrag(handle: HTMLElement, paneId: number): void {
- let dragging = false
- let startX = 0
- let startY = 0
- const DRAG_THRESHOLD = 5
-
- const onPointerDown = (e: PointerEvent): void => {
- // Only start drag if there are 2+ panes
- if (this.panes.size < 2) return
- e.preventDefault()
- e.stopPropagation()
- handle.setPointerCapture(e.pointerId)
- startX = e.clientX
- startY = e.clientY
- dragging = false
-
- const onPointerMoveOuter = (ev: PointerEvent): void => {
- const dx = ev.clientX - startX
- const dy = ev.clientY - startY
- if (!dragging && Math.hypot(dx, dy) >= DRAG_THRESHOLD) {
- dragging = true
- this.dragSourcePaneId = paneId
- this.root.classList.add('is-pane-dragging')
- const sourcePane = this.panes.get(paneId)
- if (sourcePane) sourcePane.container.classList.add('is-drag-source')
- this.showDropOverlay()
- }
- if (dragging) {
- this.updateDropTarget(ev.clientX, ev.clientY)
- }
- }
-
- const onPointerUpOuter = (ev: PointerEvent): void => {
- handle.releasePointerCapture(ev.pointerId)
- handle.removeEventListener('pointermove', onPointerMoveOuter)
- handle.removeEventListener('pointerup', onPointerUpOuter)
-
- if (dragging) {
- this.root.classList.remove('is-pane-dragging')
- const sourcePane = this.panes.get(paneId)
- if (sourcePane) sourcePane.container.classList.remove('is-drag-source')
-
- // Execute the drop
- if (this.currentDropTarget && this.dragSourcePaneId !== null) {
- this.movePane(
- this.dragSourcePaneId,
- this.currentDropTarget.paneId,
- this.currentDropTarget.zone
- )
- }
-
- this.hideDropOverlay()
- this.dragSourcePaneId = null
- this.currentDropTarget = null
- }
- }
-
- handle.addEventListener('pointermove', onPointerMoveOuter)
- handle.addEventListener('pointerup', onPointerUpOuter)
- }
-
- handle.addEventListener('pointerdown', onPointerDown)
- }
-
- /** Determine which pane and zone the cursor is over, and position the overlay. */
- private updateDropTarget(clientX: number, clientY: number): void {
- const overlay = this.dropOverlay
- if (!overlay) return
-
- // Find which pane the cursor is over (excluding the source)
- let targetPane: ManagedPaneInternal | null = null
- for (const pane of this.panes.values()) {
- if (pane.id === this.dragSourcePaneId) continue
- const rect = pane.container.getBoundingClientRect()
- if (
- clientX >= rect.left &&
- clientX <= rect.right &&
- clientY >= rect.top &&
- clientY <= rect.bottom
- ) {
- targetPane = pane
- break
- }
- }
-
- if (!targetPane) {
- overlay.style.display = 'none'
- this.currentDropTarget = null
- return
- }
-
- const rect = targetPane.container.getBoundingClientRect()
- const relX = (clientX - rect.left) / rect.width
- const relY = (clientY - rect.top) / rect.height
-
- // Determine zone: which edge is the cursor closest to?
- const distTop = relY
- const distBottom = 1 - relY
- const distLeft = relX
- const distRight = 1 - relX
- const minDist = Math.min(distTop, distBottom, distLeft, distRight)
-
- let zone: DropZone
- if (minDist === distTop) zone = 'top'
- else if (minDist === distBottom) zone = 'bottom'
- else if (minDist === distLeft) zone = 'left'
- else zone = 'right'
-
- this.currentDropTarget = { paneId: targetPane.id, zone }
-
- // Position overlay to cover the target half
- overlay.style.display = ''
- const scrollX = window.scrollX
- const scrollY = window.scrollY
-
- switch (zone) {
- case 'top':
- overlay.style.left = `${rect.left + scrollX}px`
- overlay.style.top = `${rect.top + scrollY}px`
- overlay.style.width = `${rect.width}px`
- overlay.style.height = `${rect.height / 2}px`
- break
- case 'bottom':
- overlay.style.left = `${rect.left + scrollX}px`
- overlay.style.top = `${rect.top + scrollY + rect.height / 2}px`
- overlay.style.width = `${rect.width}px`
- overlay.style.height = `${rect.height / 2}px`
- break
- case 'left':
- overlay.style.left = `${rect.left + scrollX}px`
- overlay.style.top = `${rect.top + scrollY}px`
- overlay.style.width = `${rect.width / 2}px`
- overlay.style.height = `${rect.height}px`
- break
- case 'right':
- overlay.style.left = `${rect.left + scrollX + rect.width / 2}px`
- overlay.style.top = `${rect.top + scrollY}px`
- overlay.style.width = `${rect.width / 2}px`
- overlay.style.height = `${rect.height}px`
- break
- }
- }
-
- private showDropOverlay(): void {
- if (!this.dropOverlay) {
- const overlay = document.createElement('div')
- overlay.className = 'pane-drop-overlay'
- document.body.appendChild(overlay)
- this.dropOverlay = overlay
- }
- this.dropOverlay.style.display = 'none'
- }
-
- private hideDropOverlay(): void {
- if (this.dropOverlay) {
- this.dropOverlay.remove()
- this.dropOverlay = null
- }
- }
-
- /** Add/remove .has-multiple-panes on root to control drag handle visibility. */
- private updateMultiPaneState(): void {
- if (this.panes.size >= 2) {
- this.root.classList.add('has-multiple-panes')
- } else {
- this.root.classList.remove('has-multiple-panes')
- }
- }
-
- private refitPanesUnder(el: HTMLElement): void {
- // If the element is a pane, refit it
- if (el.classList.contains('pane')) {
- const paneId = Number(el.dataset.paneId)
- const pane = this.panes.get(paneId)
- if (pane) this.safeFit(pane)
- return
- }
-
- // If it's a split, refit all panes inside it
- if (el.classList.contains('pane-split')) {
- const paneEls = el.querySelectorAll('.pane[data-pane-id]')
- for (const paneEl of paneEls) {
- const paneId = Number((paneEl as HTMLElement).dataset.paneId)
- const pane = this.panes.get(paneId)
- if (pane) this.safeFit(pane)
- }
- }
- }
-
- private applyPaneOpacity(): void {
- const {
- activePaneOpacity = 1,
- inactivePaneOpacity = 1,
- opacityTransitionMs = 0
- } = this.styleOptions
-
- const transition = opacityTransitionMs > 0 ? `opacity ${opacityTransitionMs}ms ease` : ''
-
- for (const pane of this.panes.values()) {
- const isActive = pane.id === this.activePaneId
- pane.container.style.opacity = String(isActive ? activePaneOpacity : inactivePaneOpacity)
- pane.container.style.transition = transition
- }
- }
-
- private applyDividerStyles(): void {
- const thickness = this.styleOptions.dividerThicknessPx ?? 4
- const hitSize = this.getDividerHitSize()
-
- const dividers = this.root.querySelectorAll('.pane-divider')
- for (const div of dividers) {
- const el = div as HTMLElement
- const isVertical = el.classList.contains('is-vertical')
- if (isVertical) {
- el.style.width = `${hitSize}px`
- } else {
- el.style.height = `${hitSize}px`
- }
- // Store the visual thickness for the CSS ::after pseudo-element
- el.style.setProperty('--divider-thickness', `${thickness}px`)
- }
- }
-
- private applyRootBackground(): void {
- if (this.styleOptions.splitBackground) {
- this.root.style.background = this.styleOptions.splitBackground
- }
- }
-
- private toPublic(pane: ManagedPaneInternal): ManagedPane {
- return {
- id: pane.id,
- terminal: pane.terminal,
- container: pane.container,
- fitAddon: pane.fitAddon,
- searchAddon: pane.searchAddon
- }
- }
-}
diff --git a/src/renderer/src/lib/pane-manager/pane-divider.ts b/src/renderer/src/lib/pane-manager/pane-divider.ts
new file mode 100644
index 00000000000..e5cc7f64036
--- /dev/null
+++ b/src/renderer/src/lib/pane-manager/pane-divider.ts
@@ -0,0 +1,197 @@
+import type { PaneStyleOptions, ManagedPaneInternal } from './pane-manager-types'
+
+// ---------------------------------------------------------------------------
+// Divider creation & drag-to-resize
+// ---------------------------------------------------------------------------
+
+/** Total hit area size = visible thickness + invisible padding on each side */
+export function getDividerHitSize(styleOptions: PaneStyleOptions): number {
+ const thickness = styleOptions.dividerThicknessPx ?? 4
+ const HIT_PADDING = 3
+ return thickness + HIT_PADDING * 2
+}
+
+export function createDivider(
+ isVertical: boolean,
+ styleOptions: PaneStyleOptions,
+ callbacks: {
+ refitPanesUnder: (el: HTMLElement) => void
+ onLayoutChanged?: () => void
+ }
+): HTMLElement {
+ const divider = document.createElement('div')
+ divider.className = `pane-divider ${isVertical ? 'is-vertical' : 'is-horizontal'}`
+
+ // Ghostty-style: the element itself is a wide transparent hit area for easy
+ // grabbing. The visible line is drawn by a CSS ::after pseudo-element
+ // (see main.css), so `background` on the element stays transparent.
+ const hitSize = getDividerHitSize(styleOptions)
+ if (isVertical) {
+ divider.style.width = `${hitSize}px`
+ divider.style.cursor = 'col-resize'
+ } else {
+ divider.style.height = `${hitSize}px`
+ divider.style.cursor = 'row-resize'
+ }
+ divider.style.flex = 'none'
+ divider.style.position = 'relative'
+
+ attachDividerDrag(divider, isVertical, callbacks)
+ return divider
+}
+
+function attachDividerDrag(
+ divider: HTMLElement,
+ isVertical: boolean,
+ callbacks: {
+ refitPanesUnder: (el: HTMLElement) => void
+ onLayoutChanged?: () => void
+ }
+): void {
+ const MIN_PANE_SIZE = 50
+
+ let dragging = false
+ let didMove = false
+ let startPos = 0
+ let prevFlex = 0
+ let nextFlex = 0
+ let totalSize = 0
+ let prevEl: HTMLElement | null = null
+ let nextEl: HTMLElement | null = null
+
+ const onPointerDown = (e: PointerEvent): void => {
+ e.preventDefault()
+ divider.setPointerCapture(e.pointerId)
+ divider.classList.add('is-dragging')
+ dragging = true
+ didMove = false
+
+ startPos = isVertical ? e.clientX : e.clientY
+
+ // Find previous and next pane/split siblings
+ prevEl = divider.previousElementSibling as HTMLElement | null
+ nextEl = divider.nextElementSibling as HTMLElement | null
+
+ if (!prevEl || !nextEl) {
+ return
+ }
+
+ const prevRect = prevEl.getBoundingClientRect()
+ const nextRect = nextEl.getBoundingClientRect()
+ const prevSize = isVertical ? prevRect.width : prevRect.height
+ const nextSize = isVertical ? nextRect.width : nextRect.height
+ totalSize = prevSize + nextSize
+
+ // Store current proportions as flex-basis values
+ prevFlex = prevSize
+ nextFlex = nextSize
+ }
+
+ const onPointerMove = (e: PointerEvent): void => {
+ if (!dragging || !prevEl || !nextEl) {
+ return
+ }
+ didMove = true
+
+ const currentPos = isVertical ? e.clientX : e.clientY
+ const delta = currentPos - startPos
+
+ let newPrev = prevFlex + delta
+ let newNext = nextFlex - delta
+
+ // Enforce minimum pane size
+ if (newPrev < MIN_PANE_SIZE) {
+ newPrev = MIN_PANE_SIZE
+ newNext = totalSize - MIN_PANE_SIZE
+ }
+ if (newNext < MIN_PANE_SIZE) {
+ newNext = MIN_PANE_SIZE
+ newPrev = totalSize - MIN_PANE_SIZE
+ }
+
+ // Use flex-grow proportionally
+ prevEl.style.flex = `${newPrev} 1 0%`
+ nextEl.style.flex = `${newNext} 1 0%`
+
+ // Refit terminals in affected panes
+ callbacks.refitPanesUnder(prevEl)
+ callbacks.refitPanesUnder(nextEl)
+ }
+
+ const onPointerUp = (e: PointerEvent): void => {
+ if (!dragging) {
+ return
+ }
+ dragging = false
+ divider.releasePointerCapture(e.pointerId)
+ divider.classList.remove('is-dragging')
+ prevEl = null
+ nextEl = null
+
+ // Persist updated ratios after a real drag
+ if (didMove) {
+ callbacks.onLayoutChanged?.()
+ }
+ }
+
+ // Ghostty-style: double-click divider to equalize sibling panes
+ const onDoubleClick = (): void => {
+ const prev = divider.previousElementSibling as HTMLElement | null
+ const next = divider.nextElementSibling as HTMLElement | null
+ if (!prev || !next) {
+ return
+ }
+
+ prev.style.flex = '1 1 0%'
+ next.style.flex = '1 1 0%'
+
+ callbacks.refitPanesUnder(prev)
+ callbacks.refitPanesUnder(next)
+ callbacks.onLayoutChanged?.()
+ }
+
+ divider.addEventListener('pointerdown', onPointerDown)
+ divider.addEventListener('pointermove', onPointerMove)
+ divider.addEventListener('pointerup', onPointerUp)
+ divider.addEventListener('dblclick', onDoubleClick)
+}
+
+export function applyDividerStyles(root: HTMLElement, styleOptions: PaneStyleOptions): void {
+ const thickness = styleOptions.dividerThicknessPx ?? 4
+ const hitSize = getDividerHitSize(styleOptions)
+
+ const dividers = root.querySelectorAll('.pane-divider')
+ for (const div of dividers) {
+ const el = div as HTMLElement
+ const isVertical = el.classList.contains('is-vertical')
+ if (isVertical) {
+ el.style.width = `${hitSize}px`
+ } else {
+ el.style.height = `${hitSize}px`
+ }
+ // Store the visual thickness for the CSS ::after pseudo-element
+ el.style.setProperty('--divider-thickness', `${thickness}px`)
+ }
+}
+
+export function applyPaneOpacity(
+ panes: Iterable,
+ activePaneId: number | null,
+ styleOptions: PaneStyleOptions
+): void {
+ const { activePaneOpacity = 1, inactivePaneOpacity = 1, opacityTransitionMs = 0 } = styleOptions
+
+ const transition = opacityTransitionMs > 0 ? `opacity ${opacityTransitionMs}ms ease` : ''
+
+ for (const pane of panes) {
+ const isActive = pane.id === activePaneId
+ pane.container.style.opacity = String(isActive ? activePaneOpacity : inactivePaneOpacity)
+ pane.container.style.transition = transition
+ }
+}
+
+export function applyRootBackground(root: HTMLElement, styleOptions: PaneStyleOptions): void {
+ if (styleOptions.splitBackground) {
+ root.style.background = styleOptions.splitBackground
+ }
+}
diff --git a/src/renderer/src/lib/pane-manager/pane-drag-reorder.ts b/src/renderer/src/lib/pane-manager/pane-drag-reorder.ts
new file mode 100644
index 00000000000..dc6103eb717
--- /dev/null
+++ b/src/renderer/src/lib/pane-manager/pane-drag-reorder.ts
@@ -0,0 +1,264 @@
+import type { DropZone, ManagedPaneInternal } from './pane-manager-types'
+import type { PaneStyleOptions } from './pane-manager-types'
+import { detachPaneFromTree, insertPaneNextTo } from './pane-tree-ops'
+
+// ---------------------------------------------------------------------------
+// Drag-to-reorder panes
+// ---------------------------------------------------------------------------
+
+export type DragReorderState = {
+ dragSourcePaneId: number | null
+ dropOverlay: HTMLElement | null
+ currentDropTarget: { paneId: number; zone: DropZone } | null
+}
+
+export type DragReorderCallbacks = {
+ getPanes: () => Map
+ getRoot: () => HTMLElement
+ getStyleOptions: () => PaneStyleOptions
+ isDestroyed: () => boolean
+ safeFit: (pane: ManagedPaneInternal) => void
+ applyPaneOpacity: () => void
+ applyDividerStyles: () => void
+ refitPanesUnder: (el: HTMLElement) => void
+ onLayoutChanged?: () => void
+}
+
+export function createDragReorderState(): DragReorderState {
+ return {
+ dragSourcePaneId: null,
+ dropOverlay: null,
+ currentDropTarget: null
+ }
+}
+
+/** Attach drag-to-reorder handlers to a pane's drag handle. */
+export function attachPaneDrag(
+ handle: HTMLElement,
+ paneId: number,
+ state: DragReorderState,
+ callbacks: DragReorderCallbacks
+): void {
+ let dragging = false
+ let startX = 0
+ let startY = 0
+ const DRAG_THRESHOLD = 5
+
+ const onPointerDown = (e: PointerEvent): void => {
+ // Only start drag if there are 2+ panes
+ if (callbacks.getPanes().size < 2) {
+ return
+ }
+ e.preventDefault()
+ e.stopPropagation()
+ handle.setPointerCapture(e.pointerId)
+ startX = e.clientX
+ startY = e.clientY
+ dragging = false
+
+ const onPointerMoveOuter = (ev: PointerEvent): void => {
+ const dx = ev.clientX - startX
+ const dy = ev.clientY - startY
+ if (!dragging && Math.hypot(dx, dy) >= DRAG_THRESHOLD) {
+ dragging = true
+ state.dragSourcePaneId = paneId
+ callbacks.getRoot().classList.add('is-pane-dragging')
+ const sourcePane = callbacks.getPanes().get(paneId)
+ if (sourcePane) {
+ sourcePane.container.classList.add('is-drag-source')
+ }
+ showDropOverlay(state)
+ }
+ if (dragging) {
+ updateDropTarget(ev.clientX, ev.clientY, state, callbacks)
+ }
+ }
+
+ const onPointerUpOuter = (ev: PointerEvent): void => {
+ handle.releasePointerCapture(ev.pointerId)
+ handle.removeEventListener('pointermove', onPointerMoveOuter)
+ handle.removeEventListener('pointerup', onPointerUpOuter)
+
+ if (dragging) {
+ callbacks.getRoot().classList.remove('is-pane-dragging')
+ const sourcePane = callbacks.getPanes().get(paneId)
+ if (sourcePane) {
+ sourcePane.container.classList.remove('is-drag-source')
+ }
+
+ // Execute the drop
+ if (state.currentDropTarget && state.dragSourcePaneId !== null) {
+ handlePaneDrop(
+ state.dragSourcePaneId,
+ state.currentDropTarget.paneId,
+ state.currentDropTarget.zone,
+ state,
+ callbacks
+ )
+ }
+
+ hideDropOverlay(state)
+ state.dragSourcePaneId = null
+ state.currentDropTarget = null
+ }
+ }
+
+ handle.addEventListener('pointermove', onPointerMoveOuter)
+ handle.addEventListener('pointerup', onPointerUpOuter)
+ }
+
+ handle.addEventListener('pointerdown', onPointerDown)
+}
+
+/** Move a pane from its current position to a new position relative to a target pane. */
+export function handlePaneDrop(
+ sourcePaneId: number,
+ targetPaneId: number,
+ zone: DropZone,
+ _state: DragReorderState,
+ callbacks: DragReorderCallbacks
+): void {
+ if (sourcePaneId === targetPaneId) {
+ return
+ }
+ const panes = callbacks.getPanes()
+ const source = panes.get(sourcePaneId)
+ const target = panes.get(targetPaneId)
+ if (!source || !target) {
+ return
+ }
+
+ // 1. Detach source pane from the tree (without disposing its terminal)
+ detachPaneFromTree(source, callbacks)
+
+ // 2. Insert source next to target in the requested zone
+ insertPaneNextTo(source, target, zone, callbacks)
+
+ // 3. Refit all panes and persist
+ for (const p of panes.values()) {
+ callbacks.safeFit(p)
+ }
+ callbacks.applyPaneOpacity()
+ callbacks.applyDividerStyles()
+ updateMultiPaneState(callbacks)
+ callbacks.onLayoutChanged?.()
+}
+
+export function showDropOverlay(state: DragReorderState): void {
+ if (!state.dropOverlay) {
+ const overlay = document.createElement('div')
+ overlay.className = 'pane-drop-overlay'
+ document.body.appendChild(overlay)
+ state.dropOverlay = overlay
+ }
+ state.dropOverlay.style.display = 'none'
+}
+
+export function hideDropOverlay(state: DragReorderState): void {
+ if (state.dropOverlay) {
+ state.dropOverlay.remove()
+ state.dropOverlay = null
+ }
+}
+
+/** Add/remove .has-multiple-panes on root to control drag handle visibility. */
+export function updateMultiPaneState(callbacks: DragReorderCallbacks): void {
+ if (callbacks.getPanes().size >= 2) {
+ callbacks.getRoot().classList.add('has-multiple-panes')
+ } else {
+ callbacks.getRoot().classList.remove('has-multiple-panes')
+ }
+}
+
+/** Determine which pane and zone the cursor is over, and position the overlay. */
+function updateDropTarget(
+ clientX: number,
+ clientY: number,
+ state: DragReorderState,
+ callbacks: DragReorderCallbacks
+): void {
+ const overlay = state.dropOverlay
+ if (!overlay) {
+ return
+ }
+
+ // Find which pane the cursor is over (excluding the source)
+ let targetPane: ManagedPaneInternal | null = null
+ for (const pane of callbacks.getPanes().values()) {
+ if (pane.id === state.dragSourcePaneId) {
+ continue
+ }
+ const rect = pane.container.getBoundingClientRect()
+ if (
+ clientX >= rect.left &&
+ clientX <= rect.right &&
+ clientY >= rect.top &&
+ clientY <= rect.bottom
+ ) {
+ targetPane = pane
+ break
+ }
+ }
+
+ if (!targetPane) {
+ overlay.style.display = 'none'
+ state.currentDropTarget = null
+ return
+ }
+
+ const rect = targetPane.container.getBoundingClientRect()
+ const relX = (clientX - rect.left) / rect.width
+ const relY = (clientY - rect.top) / rect.height
+
+ // Determine zone: which edge is the cursor closest to?
+ const distTop = relY
+ const distBottom = 1 - relY
+ const distLeft = relX
+ const distRight = 1 - relX
+ const minDist = Math.min(distTop, distBottom, distLeft, distRight)
+
+ let zone: DropZone
+ if (minDist === distTop) {
+ zone = 'top'
+ } else if (minDist === distBottom) {
+ zone = 'bottom'
+ } else if (minDist === distLeft) {
+ zone = 'left'
+ } else {
+ zone = 'right'
+ }
+
+ state.currentDropTarget = { paneId: targetPane.id, zone }
+
+ // Position overlay to cover the target half
+ overlay.style.display = ''
+ const scrollX = window.scrollX
+ const scrollY = window.scrollY
+
+ switch (zone) {
+ case 'top':
+ overlay.style.left = `${rect.left + scrollX}px`
+ overlay.style.top = `${rect.top + scrollY}px`
+ overlay.style.width = `${rect.width}px`
+ overlay.style.height = `${rect.height / 2}px`
+ break
+ case 'bottom':
+ overlay.style.left = `${rect.left + scrollX}px`
+ overlay.style.top = `${rect.top + scrollY + rect.height / 2}px`
+ overlay.style.width = `${rect.width}px`
+ overlay.style.height = `${rect.height / 2}px`
+ break
+ case 'left':
+ overlay.style.left = `${rect.left + scrollX}px`
+ overlay.style.top = `${rect.top + scrollY}px`
+ overlay.style.width = `${rect.width / 2}px`
+ overlay.style.height = `${rect.height}px`
+ break
+ case 'right':
+ overlay.style.left = `${rect.left + scrollX + rect.width / 2}px`
+ overlay.style.top = `${rect.top + scrollY}px`
+ overlay.style.width = `${rect.width / 2}px`
+ overlay.style.height = `${rect.height}px`
+ break
+ }
+}
diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts
new file mode 100644
index 00000000000..51f9b0f3679
--- /dev/null
+++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts
@@ -0,0 +1,193 @@
+import { Terminal } from '@xterm/xterm'
+import type { ITerminalOptions } from '@xterm/xterm'
+import { FitAddon } from '@xterm/addon-fit'
+import { SearchAddon } from '@xterm/addon-search'
+import { Unicode11Addon } from '@xterm/addon-unicode11'
+import { WebLinksAddon } from '@xterm/addon-web-links'
+import { WebglAddon } from '@xterm/addon-webgl'
+
+import type { PaneManagerOptions, ManagedPaneInternal } from './pane-manager-types'
+import type { DragReorderState } from './pane-drag-reorder'
+import type { DragReorderCallbacks } from './pane-drag-reorder'
+import { attachPaneDrag } from './pane-drag-reorder'
+import { safeFit } from './pane-tree-ops'
+
+// ---------------------------------------------------------------------------
+// Pane creation, terminal open/close, addon management
+// ---------------------------------------------------------------------------
+
+const TERMINAL_PADDING = 4
+
+export function createPaneDOM(
+ id: number,
+ options: PaneManagerOptions,
+ dragState: DragReorderState,
+ dragCallbacks: DragReorderCallbacks,
+ onPointerDown: (id: number) => void
+): ManagedPaneInternal {
+ // Create .pane container
+ const container = document.createElement('div')
+ container.className = 'pane'
+ container.dataset.paneId = String(id)
+
+ // Create .xterm-container with small inset padding
+ const xtermContainer = document.createElement('div')
+ xtermContainer.className = 'xterm-container'
+ xtermContainer.style.width = `calc(100% - ${TERMINAL_PADDING}px)`
+ xtermContainer.style.height = `calc(100% - ${TERMINAL_PADDING}px)`
+ xtermContainer.style.marginTop = `${TERMINAL_PADDING}px`
+ xtermContainer.style.marginLeft = `${TERMINAL_PADDING}px`
+ container.appendChild(xtermContainer)
+
+ // Build terminal options
+ const userOpts = options.terminalOptions?.(id) ?? {}
+ const terminalOpts: ITerminalOptions = {
+ allowProposedApi: true,
+ cursorBlink: true,
+ cursorStyle: 'bar',
+ fontSize: 14,
+ fontFamily: '"SF Mono", Menlo, monospace',
+ fontWeight: '300',
+ fontWeightBold: '500',
+ scrollback: 10000,
+ allowTransparency: false,
+ macOptionIsMeta: true,
+ macOptionClickForcesSelection: true,
+ drawBoldTextInBrightColors: true,
+ ...userOpts
+ }
+
+ const terminal = new Terminal(terminalOpts)
+ const fitAddon = new FitAddon()
+ const searchAddon = new SearchAddon()
+ const unicode11Addon = new Unicode11Addon()
+
+ // URL tooltip element — Ghostty-style bottom-left hint on hover
+ const linkTooltip = document.createElement('div')
+ linkTooltip.className = 'pane-link-tooltip'
+ linkTooltip.style.cssText =
+ 'display:none;position:absolute;bottom:4px;left:8px;z-index:40;' +
+ 'padding:2px 8px;border-radius:4px;font-size:11px;font-family:inherit;' +
+ 'color:#a1a1aa;background:rgba(24,24,27,0.85);border:1px solid rgba(63,63,70,0.6);' +
+ 'pointer-events:none;max-width:80%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'
+ container.appendChild(linkTooltip)
+
+ // Ghostty-style drag handle — appears at top of pane on hover when 2+ panes
+ const dragHandle = document.createElement('div')
+ dragHandle.className = 'pane-drag-handle'
+ container.appendChild(dragHandle)
+ attachPaneDrag(dragHandle, id, dragState, dragCallbacks)
+
+ const webLinksAddon = new WebLinksAddon(
+ options.onLinkClick ? (_event, uri) => options.onLinkClick!(uri) : undefined,
+ {
+ hover: (event, uri) => {
+ if (event.type === 'mouseover' && uri) {
+ linkTooltip.textContent = uri
+ linkTooltip.style.display = ''
+ } else {
+ linkTooltip.style.display = 'none'
+ }
+ }
+ }
+ )
+
+ const pane: ManagedPaneInternal = {
+ id,
+ terminal,
+ container,
+ xtermContainer,
+ fitAddon,
+ searchAddon,
+ unicode11Addon,
+ webLinksAddon,
+ webglAddon: null
+ }
+
+ // Focus handler: clicking a pane makes it active and explicitly focuses
+ // the terminal. We must call focus: true here because after DOM reparenting
+ // (e.g. splitPane moves the original pane into a flex container), xterm.js's
+ // native click-to-focus on its internal textarea may not fire reliably.
+ container.addEventListener('pointerdown', () => {
+ onPointerDown(id)
+ })
+
+ return pane
+}
+
+/** Open terminal into its container and load addons. Must be called after the container is in the DOM. */
+export function openTerminal(pane: ManagedPaneInternal): void {
+ const { terminal, xtermContainer, fitAddon, searchAddon, unicode11Addon, webLinksAddon } = pane
+
+ // Open terminal into DOM
+ terminal.open(xtermContainer)
+
+ // Load addons (order matters: WebGL must be after open())
+ terminal.loadAddon(fitAddon)
+ terminal.loadAddon(searchAddon)
+ terminal.loadAddon(unicode11Addon)
+ terminal.loadAddon(webLinksAddon)
+
+ // Activate unicode 11
+ terminal.unicode.activeVersion = '11'
+
+ // Attach GPU renderer
+ attachWebgl(pane)
+
+ // Initial fit (deferred to ensure layout has settled)
+ requestAnimationFrame(() => {
+ safeFit(pane)
+ })
+}
+
+export function attachWebgl(pane: ManagedPaneInternal): void {
+ try {
+ const webglAddon = new WebglAddon()
+ webglAddon.onContextLoss(() => {
+ webglAddon.dispose()
+ pane.webglAddon = null
+ })
+ pane.terminal.loadAddon(webglAddon)
+ pane.webglAddon = webglAddon
+ } catch {
+ // WebGL not available — default DOM renderer is fine
+ pane.webglAddon = null
+ }
+}
+
+export function disposePane(
+ pane: ManagedPaneInternal,
+ panes: Map
+): void {
+ try {
+ pane.webglAddon?.dispose()
+ } catch {
+ /* ignore */
+ }
+ try {
+ pane.searchAddon.dispose()
+ } catch {
+ /* ignore */
+ }
+ try {
+ pane.unicode11Addon.dispose()
+ } catch {
+ /* ignore */
+ }
+ try {
+ pane.webLinksAddon.dispose()
+ } catch {
+ /* ignore */
+ }
+ try {
+ pane.fitAddon.dispose()
+ } catch {
+ /* ignore */
+ }
+ try {
+ pane.terminal.dispose()
+ } catch {
+ /* ignore */
+ }
+ panes.delete(pane.id)
+}
diff --git a/src/renderer/src/lib/pane-manager/pane-manager-types.ts b/src/renderer/src/lib/pane-manager/pane-manager-types.ts
new file mode 100644
index 00000000000..b8a823a8744
--- /dev/null
+++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts
@@ -0,0 +1,50 @@
+import type { Terminal } from '@xterm/xterm'
+import type { ITerminalOptions } from '@xterm/xterm'
+import type { FitAddon } from '@xterm/addon-fit'
+import type { SearchAddon } from '@xterm/addon-search'
+import type { Unicode11Addon } from '@xterm/addon-unicode11'
+import type { WebLinksAddon } from '@xterm/addon-web-links'
+import type { WebglAddon } from '@xterm/addon-webgl'
+
+// ---------------------------------------------------------------------------
+// Public interfaces
+// ---------------------------------------------------------------------------
+
+export type PaneManagerOptions = {
+ onPaneCreated?: (pane: ManagedPane) => void | Promise
+ onPaneClosed?: (paneId: number) => void
+ onActivePaneChange?: (pane: ManagedPane) => void
+ onLayoutChanged?: () => void
+ terminalOptions?: (paneId: number) => Partial
+ onLinkClick?: (url: string) => void
+}
+
+export type PaneStyleOptions = {
+ splitBackground?: string
+ paneBackground?: string
+ inactivePaneOpacity?: number
+ activePaneOpacity?: number
+ opacityTransitionMs?: number
+ dividerThicknessPx?: number
+}
+
+export type ManagedPane = {
+ id: number
+ terminal: Terminal
+ container: HTMLElement // the .pane element
+ fitAddon: FitAddon
+ searchAddon: SearchAddon
+}
+
+// ---------------------------------------------------------------------------
+// Internal types
+// ---------------------------------------------------------------------------
+
+export type ManagedPaneInternal = {
+ xtermContainer: HTMLElement
+ webglAddon: WebglAddon | null
+ unicode11Addon: Unicode11Addon
+ webLinksAddon: WebLinksAddon
+} & ManagedPane
+
+export type DropZone = 'top' | 'bottom' | 'left' | 'right'
diff --git a/src/renderer/src/lib/pane-manager/pane-manager.ts b/src/renderer/src/lib/pane-manager/pane-manager.ts
new file mode 100644
index 00000000000..eeae058a01e
--- /dev/null
+++ b/src/renderer/src/lib/pane-manager/pane-manager.ts
@@ -0,0 +1,314 @@
+import type {
+ PaneManagerOptions,
+ PaneStyleOptions,
+ ManagedPane,
+ ManagedPaneInternal,
+ DropZone
+} from './pane-manager-types'
+import {
+ createDivider,
+ applyDividerStyles,
+ applyPaneOpacity,
+ applyRootBackground
+} from './pane-divider'
+import {
+ createDragReorderState,
+ hideDropOverlay,
+ handlePaneDrop,
+ updateMultiPaneState
+} from './pane-drag-reorder'
+import { createPaneDOM, openTerminal, attachWebgl, disposePane } from './pane-lifecycle'
+import {
+ findPaneChildren,
+ removeDividers,
+ promoteSibling,
+ wrapInSplit,
+ safeFit,
+ refitPanesUnder
+} from './pane-tree-ops'
+
+export type { PaneManagerOptions, PaneStyleOptions, ManagedPane, DropZone }
+
+export class PaneManager {
+ private root: HTMLElement
+ private panes: Map = new Map()
+ private activePaneId: number | null = null
+ private nextPaneId = 1
+ private options: PaneManagerOptions
+ private styleOptions: PaneStyleOptions = {}
+ private destroyed = false
+
+ // Drag-to-reorder state
+ private dragState = createDragReorderState()
+
+ constructor(root: HTMLElement, options: PaneManagerOptions) {
+ this.root = root
+ this.options = options
+ }
+
+ // -----------------------------------------------------------------------
+ // Public API
+ // -----------------------------------------------------------------------
+
+ createInitialPane(opts?: { focus?: boolean }): ManagedPane {
+ const pane = this.createPaneInternal()
+
+ // When the pane is the sole child of root (no splits), it must
+ // fill the root container so FitAddon calculates correct dimensions.
+ pane.container.style.width = '100%'
+ pane.container.style.height = '100%'
+ pane.container.style.position = 'relative'
+ pane.container.style.overflow = 'hidden'
+
+ // Place directly into root
+ this.root.appendChild(pane.container)
+
+ openTerminal(pane)
+
+ this.activePaneId = pane.id
+ applyPaneOpacity(this.panes.values(), this.activePaneId, this.styleOptions)
+
+ if (opts?.focus !== false) {
+ pane.terminal.focus()
+ }
+
+ void this.options.onPaneCreated?.(this.toPublic(pane))
+ return this.toPublic(pane)
+ }
+
+ splitPane(
+ paneId: number,
+ direction: 'vertical' | 'horizontal',
+ opts?: { ratio?: number }
+ ): ManagedPane | null {
+ const existing = this.panes.get(paneId)
+ if (!existing) {
+ return null
+ }
+
+ const newPane = this.createPaneInternal()
+
+ const parent = existing.container.parentElement
+ if (!parent) {
+ return null
+ }
+
+ const isVertical = direction === 'vertical'
+ const divider = this.createDividerWrapped(isVertical)
+
+ wrapInSplit(existing.container, newPane.container, isVertical, divider, opts)
+
+ // Open terminal for new pane
+ openTerminal(newPane)
+
+ // Set new pane active
+ this.activePaneId = newPane.id
+ applyPaneOpacity(this.panes.values(), this.activePaneId, this.styleOptions)
+ this.applyDividerStylesWrapped()
+
+ if (newPane.terminal) {
+ newPane.terminal.focus()
+ }
+
+ // Refit existing pane since it now shares space
+ safeFit(existing)
+
+ updateMultiPaneState(this.getDragCallbacks())
+
+ void this.options.onPaneCreated?.(this.toPublic(newPane))
+ this.options.onLayoutChanged?.()
+
+ return this.toPublic(newPane)
+ }
+
+ closePane(paneId: number): void {
+ const pane = this.panes.get(paneId)
+ if (!pane) {
+ return
+ }
+
+ const paneContainer = pane.container
+ const parent = paneContainer.parentElement
+ if (!parent) {
+ return
+ }
+
+ // Dispose terminal and addons
+ disposePane(pane, this.panes)
+
+ if (parent.classList.contains('pane-split')) {
+ const siblings = findPaneChildren(parent)
+ const sibling = siblings.find((c) => c !== paneContainer) ?? null
+
+ paneContainer.remove()
+ removeDividers(parent)
+ promoteSibling(sibling, parent, this.root)
+ } else {
+ // Direct child of root (only pane) — just remove
+ paneContainer.remove()
+ }
+
+ // Activate next pane if needed
+ if (this.activePaneId === paneId) {
+ const remaining = Array.from(this.panes.values())
+ if (remaining.length > 0) {
+ this.activePaneId = remaining[0].id
+ remaining[0].terminal.focus()
+ } else {
+ this.activePaneId = null
+ }
+ }
+
+ applyPaneOpacity(this.panes.values(), this.activePaneId, this.styleOptions)
+
+ // Refit remaining panes
+ for (const p of this.panes.values()) {
+ safeFit(p)
+ }
+
+ updateMultiPaneState(this.getDragCallbacks())
+ this.options.onPaneClosed?.(paneId)
+ this.options.onLayoutChanged?.()
+ }
+
+ getPanes(): ManagedPane[] {
+ return Array.from(this.panes.values()).map((p) => this.toPublic(p))
+ }
+
+ getActivePane(): ManagedPane | null {
+ if (this.activePaneId === null) {
+ return null
+ }
+ const pane = this.panes.get(this.activePaneId)
+ return pane ? this.toPublic(pane) : null
+ }
+
+ setActivePane(paneId: number, opts?: { focus?: boolean }): void {
+ const pane = this.panes.get(paneId)
+ if (!pane) {
+ return
+ }
+
+ const changed = this.activePaneId !== paneId
+ this.activePaneId = paneId
+ applyPaneOpacity(this.panes.values(), this.activePaneId, this.styleOptions)
+
+ if (opts?.focus !== false) {
+ pane.terminal.focus()
+ }
+
+ if (changed) {
+ this.options.onActivePaneChange?.(this.toPublic(pane))
+ }
+ }
+
+ setPaneStyleOptions(opts: PaneStyleOptions): void {
+ this.styleOptions = { ...opts }
+ applyPaneOpacity(this.panes.values(), this.activePaneId, this.styleOptions)
+ this.applyDividerStylesWrapped()
+ applyRootBackground(this.root, this.styleOptions)
+ }
+
+ /**
+ * Suspend GPU rendering for all panes. Disposes WebGL addons to free
+ * GPU contexts while keeping Terminal instances alive (scrollback, cursor,
+ * screen buffer all preserved). Call when this tab/worktree becomes hidden.
+ */
+ suspendRendering(): void {
+ for (const pane of this.panes.values()) {
+ if (pane.webglAddon) {
+ try {
+ pane.webglAddon.dispose()
+ } catch {
+ /* ignore */
+ }
+ pane.webglAddon = null
+ }
+ }
+ }
+
+ /**
+ * Resume GPU rendering for all panes. Recreates WebGL addons. Call when
+ * this tab/worktree becomes visible again. Must be followed by a fit() pass.
+ */
+ resumeRendering(): void {
+ for (const pane of this.panes.values()) {
+ if (!pane.webglAddon) {
+ attachWebgl(pane)
+ }
+ }
+ }
+
+ /** Move a pane from its current position to a new position relative to a target pane. */
+ movePane(sourcePaneId: number, targetPaneId: number, zone: DropZone): void {
+ handlePaneDrop(sourcePaneId, targetPaneId, zone, this.dragState, this.getDragCallbacks())
+ }
+
+ destroy(): void {
+ this.destroyed = true
+ hideDropOverlay(this.dragState)
+ for (const pane of this.panes.values()) {
+ disposePane(pane, this.panes)
+ }
+ this.root.innerHTML = ''
+ this.activePaneId = null
+ }
+
+ // -----------------------------------------------------------------------
+ // Internal helpers
+ // -----------------------------------------------------------------------
+
+ private createPaneInternal(): ManagedPaneInternal {
+ const id = this.nextPaneId++
+ const pane = createPaneDOM(
+ id,
+ this.options,
+ this.dragState,
+ this.getDragCallbacks(),
+ (paneId) => {
+ if (!this.destroyed && this.activePaneId !== paneId) {
+ this.setActivePane(paneId, { focus: true })
+ }
+ }
+ )
+ this.panes.set(id, pane)
+ return pane
+ }
+
+ private createDividerWrapped(isVertical: boolean): HTMLElement {
+ return createDivider(isVertical, this.styleOptions, {
+ refitPanesUnder: (el) => refitPanesUnder(el, this.panes),
+ onLayoutChanged: this.options.onLayoutChanged
+ })
+ }
+
+ private applyDividerStylesWrapped(): void {
+ applyDividerStyles(this.root, this.styleOptions)
+ }
+
+ private toPublic(pane: ManagedPaneInternal): ManagedPane {
+ return {
+ id: pane.id,
+ terminal: pane.terminal,
+ container: pane.container,
+ fitAddon: pane.fitAddon,
+ searchAddon: pane.searchAddon
+ }
+ }
+
+ /** Build the callbacks object for drag-reorder functions. */
+ private getDragCallbacks() {
+ return {
+ getPanes: () => this.panes,
+ getRoot: () => this.root,
+ getStyleOptions: () => this.styleOptions,
+ isDestroyed: () => this.destroyed,
+ safeFit: (pane: ManagedPaneInternal) => safeFit(pane),
+ applyPaneOpacity: () =>
+ applyPaneOpacity(this.panes.values(), this.activePaneId, this.styleOptions),
+ applyDividerStyles: () => this.applyDividerStylesWrapped(),
+ refitPanesUnder: (el: HTMLElement) => refitPanesUnder(el, this.panes),
+ onLayoutChanged: this.options.onLayoutChanged
+ }
+ }
+}
diff --git a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts
new file mode 100644
index 00000000000..352fc89d6c6
--- /dev/null
+++ b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts
@@ -0,0 +1,268 @@
+import type { DropZone, ManagedPaneInternal, PaneStyleOptions } from './pane-manager-types'
+import { createDivider } from './pane-divider'
+
+// ---------------------------------------------------------------------------
+// Split-tree manipulation: detach, insert, promote sibling
+// ---------------------------------------------------------------------------
+
+type TreeOpsCallbacks = {
+ getRoot: () => HTMLElement
+ getStyleOptions: () => PaneStyleOptions
+ safeFit: (pane: ManagedPaneInternal) => void
+ refitPanesUnder: (el: HTMLElement) => void
+ onLayoutChanged?: () => void
+}
+
+export function safeFit(pane: ManagedPaneInternal): void {
+ try {
+ pane.fitAddon.fit()
+ } catch {
+ // Container may not have dimensions yet
+ }
+}
+
+export function refitPanesUnder(el: HTMLElement, panes: Map): void {
+ // If the element is a pane, refit it
+ if (el.classList.contains('pane')) {
+ const paneId = Number(el.dataset.paneId)
+ const pane = panes.get(paneId)
+ if (pane) {
+ safeFit(pane)
+ }
+ return
+ }
+
+ // If it's a split, refit all panes inside it
+ if (el.classList.contains('pane-split')) {
+ const paneEls = el.querySelectorAll('.pane[data-pane-id]')
+ for (const paneEl of paneEls) {
+ const paneId = Number((paneEl as HTMLElement).dataset.paneId)
+ const pane = panes.get(paneId)
+ if (pane) {
+ safeFit(pane)
+ }
+ }
+ }
+}
+
+/**
+ * Detach a pane's container from the split tree without disposing the terminal.
+ * The sibling is promoted to take the split container's slot.
+ */
+export function detachPaneFromTree(pane: ManagedPaneInternal, callbacks: TreeOpsCallbacks): void {
+ const container = pane.container
+ const parent = container.parentElement
+ if (!parent) {
+ return
+ }
+
+ if (!parent.classList.contains('pane-split')) {
+ // Direct child of root — just remove it
+ container.remove()
+ return
+ }
+
+ // Find sibling (skip dividers)
+ const children = Array.from(parent.children).filter(
+ (child): child is HTMLElement =>
+ child instanceof HTMLElement &&
+ (child.classList.contains('pane') || child.classList.contains('pane-split'))
+ )
+ const sibling = children.find((c) => c !== container) ?? null
+
+ // Remove pane and dividers from the split
+ container.remove()
+ removeDividers(parent)
+
+ // Promote sibling to replace the split container
+ promoteSibling(sibling, parent, callbacks.getRoot())
+}
+
+/** Insert source pane next to target pane by wrapping target in a new split. */
+export function insertPaneNextTo(
+ source: ManagedPaneInternal,
+ target: ManagedPaneInternal,
+ zone: DropZone,
+ callbacks: TreeOpsCallbacks
+): void {
+ const targetContainer = target.container
+ const parent = targetContainer.parentElement
+ if (!parent) {
+ return
+ }
+
+ const isVertical = zone === 'left' || zone === 'right'
+ const sourceFirst = zone === 'left' || zone === 'top'
+
+ // Capture target's flex slot
+ const targetFlex = targetContainer.style.flex || ''
+ const targetMinW = targetContainer.style.minWidth || ''
+ const targetMinH = targetContainer.style.minHeight || ''
+
+ // Create split wrapper
+ const split = document.createElement('div')
+ split.className = `pane-split ${isVertical ? 'is-vertical' : 'is-horizontal'}`
+ split.style.display = 'flex'
+ split.style.flexDirection = isVertical ? 'row' : 'column'
+
+ if (parent.classList.contains('pane-split')) {
+ split.style.flex = targetFlex || '1 1 0%'
+ split.style.minWidth = targetMinW || '0'
+ split.style.minHeight = targetMinH || '0'
+ split.style.overflow = 'hidden'
+ } else {
+ split.style.width = '100%'
+ split.style.height = '100%'
+ }
+
+ // Create divider
+ const divider = createDivider(isVertical, callbacks.getStyleOptions(), {
+ refitPanesUnder: callbacks.refitPanesUnder,
+ onLayoutChanged: callbacks.onLayoutChanged
+ })
+
+ // Apply flex styles to both panes
+ applyPaneFlexStyle(source.container)
+ applyPaneFlexStyle(targetContainer)
+
+ // Replace target with the split in the DOM
+ parent.replaceChild(split, targetContainer)
+
+ // Build split: [first] [divider] [second]
+ if (sourceFirst) {
+ split.appendChild(source.container)
+ split.appendChild(divider)
+ split.appendChild(targetContainer)
+ } else {
+ split.appendChild(targetContainer)
+ split.appendChild(divider)
+ split.appendChild(source.container)
+ }
+
+ // Refit both
+ requestAnimationFrame(() => {
+ callbacks.safeFit(source)
+ callbacks.safeFit(target)
+ })
+}
+
+/**
+ * Promote a sibling element to replace its parent split container.
+ * Used when a pane is removed and the split wrapper becomes unnecessary.
+ */
+export function promoteSibling(
+ sibling: HTMLElement | null,
+ parent: HTMLElement,
+ root: HTMLElement
+): void {
+ if (sibling) {
+ const grandparent = parent.parentElement
+ if (grandparent) {
+ if (grandparent === root) {
+ sibling.style.flex = ''
+ sibling.style.minWidth = ''
+ sibling.style.minHeight = ''
+ sibling.style.width = '100%'
+ sibling.style.height = '100%'
+ sibling.style.position = 'relative'
+ sibling.style.overflow = 'hidden'
+ } else if (grandparent.classList.contains('pane-split')) {
+ sibling.style.flex = parent.style.flex || '1 1 0%'
+ sibling.style.minWidth = parent.style.minWidth || '0'
+ sibling.style.minHeight = parent.style.minHeight || '0'
+ sibling.style.overflow = 'hidden'
+ }
+ grandparent.replaceChild(sibling, parent)
+ }
+ } else {
+ parent.remove()
+ }
+}
+
+/** Apply standard flex styles to a pane container inside a split. */
+export function applyPaneFlexStyle(el: HTMLElement): void {
+ el.style.flex = '1 1 0%'
+ el.style.minWidth = '0'
+ el.style.minHeight = '0'
+ el.style.position = 'relative'
+ el.style.overflow = 'hidden'
+ // Clear any fixed width/height from createInitialPane so flex sizing
+ // controls the layout instead of the leftover 100% values.
+ el.style.width = ''
+ el.style.height = ''
+}
+
+/** Remove all divider elements from a parent element. */
+export function removeDividers(parent: HTMLElement): void {
+ const dividers = Array.from(parent.children).filter(
+ (child): child is HTMLElement =>
+ child instanceof HTMLElement && child.classList.contains('pane-divider')
+ )
+ for (const d of dividers) {
+ d.remove()
+ }
+}
+
+/** Find non-divider children (panes and splits) of an element. */
+export function findPaneChildren(parent: HTMLElement): HTMLElement[] {
+ return Array.from(parent.children).filter(
+ (child): child is HTMLElement =>
+ child instanceof HTMLElement &&
+ (child.classList.contains('pane') || child.classList.contains('pane-split'))
+ )
+}
+
+/**
+ * Create a flex split wrapper that replaces `existingContainer` in the DOM,
+ * then places [existing] [divider] [new] inside it.
+ */
+export function wrapInSplit(
+ existingContainer: HTMLElement,
+ newContainer: HTMLElement,
+ isVertical: boolean,
+ divider: HTMLElement,
+ opts?: { ratio?: number }
+): void {
+ const parent = existingContainer.parentElement
+ if (!parent) {
+ return
+ }
+
+ // Capture the flex style BEFORE modifying
+ const existingFlex = existingContainer.style.flex || ''
+ const existingMinW = existingContainer.style.minWidth || ''
+ const existingMinH = existingContainer.style.minHeight || ''
+
+ // Create split container
+ const split = document.createElement('div')
+ split.className = `pane-split ${isVertical ? 'is-vertical' : 'is-horizontal'}`
+ split.style.display = 'flex'
+ split.style.flexDirection = isVertical ? 'row' : 'column'
+
+ if (parent.classList.contains('pane-split')) {
+ split.style.flex = existingFlex || '1 1 0%'
+ split.style.minWidth = existingMinW || '0'
+ split.style.minHeight = existingMinH || '0'
+ split.style.overflow = 'hidden'
+ } else {
+ split.style.width = '100%'
+ split.style.height = '100%'
+ }
+
+ // Apply flex styles to both pane containers
+ applyPaneFlexStyle(existingContainer)
+ applyPaneFlexStyle(newContainer)
+
+ // Apply custom ratio if provided
+ const ratio = opts?.ratio
+ if (ratio !== undefined && ratio > 0 && ratio < 1) {
+ existingContainer.style.flex = `${ratio} 1 0%`
+ newContainer.style.flex = `${1 - ratio} 1 0%`
+ }
+
+ // Replace existing with split in the DOM, then build children
+ parent.replaceChild(split, existingContainer)
+ split.appendChild(existingContainer)
+ split.appendChild(divider)
+ split.appendChild(newContainer)
+}