* deps: bump @opencode-ai/sdk to 1.4.6 and add @base-ui/react
* build: align vite configs with Base UI and packages update
* feat(ui): migrate primitives to Base UI
Replace Radix-based wrappers (button, checkbox, collapsible, command,
dialog, dropdown-menu, input, radio, scroll-area, select, separator,
sonner, switch, textarea, toggle, tooltip) with @base-ui/react
equivalents and a refreshed visual language: flexoki theme tokens,
typography utilities, and a redesigned CommandPalette. Add new
fancy-button variant set and a local slot helper for asChild composition.
* refactor(ui consumers): adopt Base UI primitives across the app
Update chat, layout, git views, settings, multirun, session and agent
management screens to use the new Base UI-backed primitives (anchor
width token rename, dropdown/select/dialog usage adjustments, new
component APIs).
* fix(ScrollableOverlay): forward `disabled` to inner element
Add `disabled` to the type so it propagates via rest spread to the
underlying element (e.g. textarea in simple mode). Resolves a type
error reported after the main merge.
* refactor(dropdown consumers): migrate preventDefault sites to closeOnClick
Base UI's Menu.Item does not check `event.defaultPrevented` before
closing the menu, so the Radix-era `onSelect={(e) => e.preventDefault()}`
pattern no longer keeps the menu open. Replace those sites with the
Base UI `closeOnClick={false}` prop (VSCodeLayout rate-limit info
rows; AgentGroupDetail worktree actions).
* docs(AGENTS): note Base UI as primary source for UI primitives
* fix(ContextPanel): center expand/close buttons in tab header
Header (h-8) had the action row pinned with items-end + pb-1.5, which
placed the h-7 buttons ~2px above the header top and made them look
cropped. Switch to items-center so they sit on the same baseline as
the tabs.
* style(tabs): drop transitions on default variant, soften active pill
Default (underline) variant now switches instantly — no transform/width
transition on the underline indicator, no color transition on tab
buttons. Active pill bg is color-mixed down to 55% of
--interactive-selection so light-theme selection doesn't scream.
* style(button): pill-style elevation and introduce chip variant
Solid variants (default/destructive/neutral/primary/basic) now use the
active-pill shadow stack — hairline navy outer ring + stacked tight/soft
drops — instead of the pure-black inset/border approach. Outline
variant (and FancyButton basic) gets the same elevation without the
white top highlight (no fill to catch light).
Add a new chip variant: flat `border border-border/60` button without
elevation, intended for one-of-N toggle groups where a raised
button would read as the wrong affordance.
* style(header): apply pill-style shadow to OpenIn and Project Actions
Both split-button wrappers previously used a 1px CSS border for
definition. Replace that with the pill elevation stack so the header
buttons match the new Button variants and feel less hard-edged.
* refactor(toggles): migrate one-of-N segmented groups to chip variant
Color Mode, When-sessions-expire, voice/STT providers, auth method,
agent mode, MCP transport, tunnel type and terminal Ctrl/Cmd modifier
toggles all read as flat chip selectors rather than raised buttons.
Switch them from variant="outline" to the new chip variant so the
raised pill-style elevation doesn't fight the selected-state tint.
* fix(elevation): theme-adaptive pill-style shadow for dark mode
The navy (rgba(14,18,27,*)) outer ring was invisible on dark surfaces,
erasing the button/pill edge. Add dark: variants that use a subtle
white outer ring (rgba(255,255,255,0.10)) and darker drop shadows
(rgba(0,0,0,0.30/0.20)) so the elevation reads on both themes.
Applies to Button solid/outline variants, FancyButton equivalents,
the OpenIn and Project Actions header split-buttons, and the active
pill indicator in SortableTabsStrip.
* style(button): flat tinted variants with squircle corner-shape
Replace elevated pill-shadow design with a flat tinted language: pale tinted
fill + hairline tinted border + saturated tinted text for default/destructive.
Add CSS corner-shape: squircle with @supports gate so Chrome 136+ gets the
softened superellipse corners at 50px, Safari falls back to a slightly larger
round radius.
* style(tabs): replace active pill shadow with tinted border
* style(header): flat border and squircle corners for OpenIn / Actions
Drop pill-style shadow stacks in favor of a plain hairline border and apply
the same squircle corner-shape treatment as Button so header controls match
the new visual language.
* style(checkbox): drop filled background for tinted checkmark
Checked / indeterminate states now keep the transparent box, show a
primary-base check/minus glyph, and use a softened primary-tinted inset
border (50% mix) so the control reads as selected without a solid swatch.
* style(layout): widen sidebar-to-chat corner radius to 10px
* style(button): retune tinted variants and unify outline fill
- Bump primary fill toward noticeable but still soft (light 10/16/22,
dark 16/22/30) and weaken both primary and destructive borders
(12% light / 20% dark) so the tone reads without shouting.
- Further calm destructive across fill and border so revert-style
actions match the muted reference.
- Align the outline variant fill with --surface-elevated so git-view
generate/commit buttons share the header action button surface.
* style(tabs): align pill variant with new button language
Active pill now uses --surface-elevated fill and border-border/60 to
match the outline button. Track and tab/pill geometry adopt the same
squircle treatment as Button: 9-10px fallback with 50px radius under
@supports(corner-shape:squircle). Also tighten track padding (inline
2px, block 2px) and grow active tab height so the indicator fills
the track instead of floating inside it.
* refactor(theme): hardcode radius scale, drop per-theme override
Radius values are now fixed in the design system (flexoki-dark's scale
becomes the app default). Themes can no longer override --radius-* — the
config.radius block is removed from every theme JSON, dropped from the
Theme type, and no longer emitted by the CSS generator.
* chore(ui): lint/type cleanup across Base UI wrappers
* style(tabs): dial pill track background down to near-invisible
* refactor(chip): aria-pressed drives the selected tinted state
The chip variant now carries the tinted primary palette (same as the
default filled button) under aria-pressed, so consumers toggle selection
via a single aria attribute instead of repeating the border/text/bg
override classes. All existing chip consumers migrate to aria-pressed
and drop their hand-rolled selected className.
* chore(defaults): humanize variant label in thinking selector
* fix(diff): render all lines in single-file mode
Single-file mode wrapped PierreDiffViewer in its own layout=fill which
creates a nested virtual-root; combined with the outer CSS containment
on the wrapper, Pierre's virtualizer measured a clamped viewport and
stopped rendering lines past ~150. Mirror the stacked-mode approach:
hoist a ScrollableOverlay as the sole virtual root/content and render
PierreDiffViewer inline, so the shared virtualizer sees the real scroll
viewport and paints every line.
Also drops an unused cn import in SessionRetentionSettings.
* feat(files): collapse floating editor controls behind icon button
The floating file editor toolbar now starts as a single pill icon button
and expands into the full control row on hover or click. Clicking outside
or moving the pointer away collapses it back, keeping the editor surface
unobstructed while the file is being read/edited. Fullscreen overlay
keeps its always-visible toolbar.
* fix(chat): align mobile session status bar radius with chat input
* fix(mobile): keep radio/checkbox/switch at their native size
Mobile CSS forced min 36x36px on every <button>, which swallowed our
Radio and Checkbox primitives (also buttons with role=radio/checkbox)
and ballooned them on touch devices. Exclude role=radio/checkbox/switch
from the touch-target rule and pin the Radio/Checkbox box size with
arbitrary px so padding-scale overrides cannot stretch them either.
* chore(mobile): drop unused cornerRadius prop plumbing
The status bar now hardcodes var(--radius-lg) on both views, so the
cornerRadius prop and the useUIStore selector that sourced it in
ChatInput are dead weight.
1117 lines
43 KiB
TypeScript
1117 lines
43 KiB
TypeScript
import React from 'react';
|
||
import { RiAddLine, RiArrowDownLine, RiArrowGoBackLine, RiArrowLeftLine, RiArrowRightLine, RiArrowUpLine, RiCloseLine, RiCommandLine } from '@remixicon/react';
|
||
|
||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||
import { useTerminalStore } from '@/stores/useTerminalStore';
|
||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||
import { type TerminalStreamEvent } from '@/lib/api/types';
|
||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||
import { useFontPreferences } from '@/hooks/useFontPreferences';
|
||
import { CODE_FONT_OPTION_MAP, DEFAULT_MONO_FONT } from '@/lib/fontOptions';
|
||
import { convertThemeToXterm } from '@/lib/terminalTheme';
|
||
import { TerminalViewport, type TerminalController } from '@/components/terminal/TerminalViewport';
|
||
import { cn } from '@/lib/utils';
|
||
import { useUIStore } from '@/stores/useUIStore';
|
||
import { Button } from '@/components/ui/button';
|
||
import { useDeviceInfo } from '@/lib/device';
|
||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||
import { primeTerminalInputTransport } from '@/lib/terminalApi';
|
||
|
||
type Modifier = 'ctrl' | 'cmd';
|
||
type MobileKey =
|
||
| 'esc'
|
||
| 'tab'
|
||
| 'enter'
|
||
| 'arrow-up'
|
||
| 'arrow-down'
|
||
| 'arrow-left'
|
||
| 'arrow-right';
|
||
|
||
const BASE_KEY_SEQUENCES: Record<MobileKey, string> = {
|
||
esc: '\u001b',
|
||
tab: '\t',
|
||
enter: '\r',
|
||
'arrow-up': '\u001b[A',
|
||
'arrow-down': '\u001b[B',
|
||
'arrow-left': '\u001b[D',
|
||
'arrow-right': '\u001b[C',
|
||
};
|
||
|
||
const MODIFIER_ARROW_SUFFIX: Record<Modifier, string> = {
|
||
ctrl: '5',
|
||
cmd: '3',
|
||
};
|
||
|
||
|
||
const STREAM_OPTIONS = {
|
||
retry: {
|
||
maxRetries: 3,
|
||
initialDelayMs: 500,
|
||
maxDelayMs: 8000,
|
||
},
|
||
connectionTimeoutMs: 10_000,
|
||
};
|
||
|
||
const REHYDRATED_STREAM_OPTIONS = {
|
||
retry: {
|
||
...STREAM_OPTIONS.retry,
|
||
initialDelayMs: 200,
|
||
maxDelayMs: 500,
|
||
},
|
||
connectionTimeoutMs: 1_500,
|
||
};
|
||
|
||
const getSequenceForKey = (key: MobileKey, modifier: Modifier | null): string | null => {
|
||
if (modifier) {
|
||
switch (key) {
|
||
case 'arrow-up':
|
||
return `\u001b[1;${MODIFIER_ARROW_SUFFIX[modifier]}A`;
|
||
case 'arrow-down':
|
||
return `\u001b[1;${MODIFIER_ARROW_SUFFIX[modifier]}B`;
|
||
case 'arrow-right':
|
||
return `\u001b[1;${MODIFIER_ARROW_SUFFIX[modifier]}C`;
|
||
case 'arrow-left':
|
||
return `\u001b[1;${MODIFIER_ARROW_SUFFIX[modifier]}D`;
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
|
||
return BASE_KEY_SEQUENCES[key] ?? null;
|
||
};
|
||
|
||
export const TerminalView: React.FC = () => {
|
||
const { terminal, runtime } = useRuntimeAPIs();
|
||
const { currentTheme } = useThemeSystem();
|
||
const { monoFont } = useFontPreferences();
|
||
const terminalFontSize = useUIStore(state => state.terminalFontSize);
|
||
const bottomTerminalHeight = useUIStore((state) => state.bottomTerminalHeight);
|
||
const isBottomTerminalExpanded = useUIStore((state) => state.isBottomTerminalExpanded);
|
||
const { isMobile, hasTouchInput } = useDeviceInfo();
|
||
// Tabs are supported for web + desktop runtimes, including mobile (not VSCode).
|
||
const enableTabs = runtime.platform !== 'vscode';
|
||
const showTerminalQuickKeysOnDesktop = useUIStore((state) => state.showTerminalQuickKeysOnDesktop);
|
||
const showQuickKeys = isMobile || showTerminalQuickKeysOnDesktop;
|
||
|
||
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
||
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
|
||
const hasActiveContext = currentSessionId !== null || newSessionDraft?.open === true;
|
||
|
||
const effectiveDirectory = useEffectiveDirectory() ?? null;
|
||
const terminalStore = useTerminalStore();
|
||
const terminalSessions = terminalStore.sessions;
|
||
const terminalHydrated = terminalStore.hasHydrated;
|
||
const ensureDirectory = terminalStore.ensureDirectory;
|
||
const createTab = terminalStore.createTab;
|
||
const setActiveTab = terminalStore.setActiveTab;
|
||
const closeTab = terminalStore.closeTab;
|
||
const setTabSessionId = terminalStore.setTabSessionId;
|
||
const setTabLifecycle = terminalStore.setTabLifecycle;
|
||
const setConnecting = terminalStore.setConnecting;
|
||
const appendToBuffer = terminalStore.appendToBuffer;
|
||
|
||
const directoryTerminalState = React.useMemo(() => {
|
||
if (!effectiveDirectory) return undefined;
|
||
return terminalSessions.get(effectiveDirectory);
|
||
}, [terminalSessions, effectiveDirectory]);
|
||
|
||
const activeTabId = React.useMemo(() => {
|
||
if (!directoryTerminalState) return null;
|
||
if (enableTabs) {
|
||
return directoryTerminalState.activeTabId ?? directoryTerminalState.tabs[0]?.id ?? null;
|
||
}
|
||
return directoryTerminalState.tabs[0]?.id ?? null;
|
||
}, [directoryTerminalState, enableTabs]);
|
||
|
||
const activeTab = React.useMemo(() => {
|
||
if (!directoryTerminalState) return undefined;
|
||
if (!activeTabId) return directoryTerminalState.tabs[0];
|
||
return (
|
||
directoryTerminalState.tabs.find((tab) => tab.id === activeTabId) ??
|
||
directoryTerminalState.tabs[0]
|
||
);
|
||
}, [directoryTerminalState, activeTabId]);
|
||
|
||
const terminalSessionId = activeTab?.terminalSessionId ?? null;
|
||
const terminalLifecycle = activeTab?.lifecycle ?? 'idle';
|
||
const bufferChunks = activeTab?.bufferChunks ?? [];
|
||
const isConnecting = activeTab?.isConnecting ?? false;
|
||
|
||
const [connectionError, setConnectionError] = React.useState<string | null>(null);
|
||
const [isFatalError, setIsFatalError] = React.useState(false);
|
||
const [isReconnectPending, setIsReconnectPending] = React.useState(false);
|
||
const [activeModifier, setActiveModifier] = React.useState<Modifier | null>(null);
|
||
const [isRestarting, setIsRestarting] = React.useState(false);
|
||
const [viewportLayoutVersion, setViewportLayoutVersion] = React.useState(0);
|
||
const keyboardAvoidTargetId = React.useId();
|
||
|
||
const streamCleanupRef = React.useRef<(() => void) | null>(null);
|
||
const activeTerminalIdRef = React.useRef<string | null>(null);
|
||
const activeTabIdRef = React.useRef<string | null>(activeTabId);
|
||
const terminalIdRef = React.useRef<string | null>(terminalSessionId);
|
||
const directoryRef = React.useRef<string | null>(effectiveDirectory);
|
||
const terminalControllerRef = React.useRef<TerminalController | null>(null);
|
||
const lastViewportSizeRef = React.useRef<{ cols: number; rows: number } | null>(null);
|
||
const isTerminalVisibleRef = React.useRef(false);
|
||
const nudgeOnConnectTerminalIdRef = React.useRef<string | null>(null);
|
||
const rehydratedTerminalIdsRef = React.useRef<Set<string>>(new Set());
|
||
const rehydratedSnapshotTakenRef = React.useRef(false);
|
||
|
||
const focusTerminalWhenWindowActive = React.useCallback(() => {
|
||
if (typeof document !== 'undefined' && !document.hasFocus()) {
|
||
return;
|
||
}
|
||
terminalControllerRef.current?.focus();
|
||
}, []);
|
||
|
||
React.useEffect(() => {
|
||
if (!terminalHydrated) {
|
||
return;
|
||
}
|
||
|
||
if (rehydratedSnapshotTakenRef.current) {
|
||
return;
|
||
}
|
||
rehydratedSnapshotTakenRef.current = true;
|
||
|
||
const ids = new Set<string>();
|
||
for (const [, dirState] of useTerminalStore.getState().sessions.entries()) {
|
||
for (const tab of dirState.tabs) {
|
||
if (tab.terminalSessionId) {
|
||
ids.add(tab.terminalSessionId);
|
||
}
|
||
}
|
||
}
|
||
rehydratedTerminalIdsRef.current = ids;
|
||
}, [terminalHydrated]);
|
||
|
||
const activeMainTab = useUIStore((state) => state.activeMainTab);
|
||
const isBottomTerminalOpen = useUIStore((state) => state.isBottomTerminalOpen);
|
||
const isTerminalActive = activeMainTab === 'terminal';
|
||
const isTerminalVisible = isTerminalActive || isBottomTerminalOpen;
|
||
const [hasOpenedTerminalViewport, setHasOpenedTerminalViewport] = React.useState(isTerminalVisible);
|
||
|
||
React.useEffect(() => {
|
||
if (!isTerminalVisible || runtime.platform === 'vscode') {
|
||
return;
|
||
}
|
||
|
||
primeTerminalInputTransport();
|
||
}, [isTerminalVisible, runtime.platform]);
|
||
|
||
React.useEffect(() => {
|
||
if (isTerminalVisible) {
|
||
setHasOpenedTerminalViewport(true);
|
||
}
|
||
}, [isTerminalVisible]);
|
||
|
||
React.useEffect(() => {
|
||
isTerminalVisibleRef.current = isTerminalVisible;
|
||
}, [isTerminalVisible]);
|
||
|
||
React.useEffect(() => {
|
||
terminalIdRef.current = terminalSessionId;
|
||
}, [terminalSessionId]);
|
||
|
||
React.useEffect(() => {
|
||
activeTabIdRef.current = activeTabId;
|
||
}, [activeTabId]);
|
||
|
||
React.useEffect(() => {
|
||
directoryRef.current = effectiveDirectory;
|
||
}, [effectiveDirectory]);
|
||
|
||
React.useEffect(() => {
|
||
if (!showQuickKeys && activeModifier !== null) {
|
||
setActiveModifier(null);
|
||
}
|
||
}, [showQuickKeys, activeModifier, setActiveModifier]);
|
||
|
||
React.useEffect(() => {
|
||
if (!terminalSessionId && activeModifier !== null) {
|
||
setActiveModifier(null);
|
||
}
|
||
}, [terminalSessionId, activeModifier, setActiveModifier]);
|
||
|
||
const disconnectStream = React.useCallback(() => {
|
||
streamCleanupRef.current?.();
|
||
streamCleanupRef.current = null;
|
||
activeTerminalIdRef.current = null;
|
||
setIsReconnectPending(false);
|
||
}, []);
|
||
|
||
React.useEffect(
|
||
() => () => {
|
||
disconnectStream();
|
||
terminalIdRef.current = null;
|
||
},
|
||
[disconnectStream]
|
||
);
|
||
|
||
const startStream = React.useCallback(
|
||
(
|
||
directory: string,
|
||
tabId: string,
|
||
terminalId: string,
|
||
streamOptions = STREAM_OPTIONS
|
||
) => {
|
||
if (activeTerminalIdRef.current === terminalId) {
|
||
return;
|
||
}
|
||
|
||
disconnectStream();
|
||
|
||
// Mark active before connect so early events aren't dropped.
|
||
activeTerminalIdRef.current = terminalId;
|
||
|
||
const subscription = terminal.connect(
|
||
terminalId,
|
||
{
|
||
onEvent: (event: TerminalStreamEvent) => {
|
||
if (activeTerminalIdRef.current !== terminalId) {
|
||
return;
|
||
}
|
||
|
||
switch (event.type) {
|
||
case 'connected': {
|
||
setConnecting(directory, tabId, false);
|
||
setConnectionError(null);
|
||
setIsFatalError(false);
|
||
setIsReconnectPending(false);
|
||
focusTerminalWhenWindowActive();
|
||
|
||
// After a reload, buffer is empty and a reused PTY can look "stuck"
|
||
// until the first output arrives. Nudge with a newline once.
|
||
if (nudgeOnConnectTerminalIdRef.current === terminalId) {
|
||
nudgeOnConnectTerminalIdRef.current = null;
|
||
void terminal.sendInput(terminalId, '\r').catch(() => {
|
||
// ignore
|
||
});
|
||
}
|
||
break;
|
||
}
|
||
case 'reconnecting': {
|
||
void event;
|
||
setConnectionError(null);
|
||
setIsFatalError(false);
|
||
setIsReconnectPending(true);
|
||
break;
|
||
}
|
||
case 'data': {
|
||
if (event.data) {
|
||
appendToBuffer(directory, tabId, event.data);
|
||
}
|
||
break;
|
||
}
|
||
case 'exit': {
|
||
const exitCode =
|
||
typeof event.exitCode === 'number' ? event.exitCode : null;
|
||
const signal = typeof event.signal === 'number' ? event.signal : null;
|
||
const currentTab = useTerminalStore.getState()
|
||
.getDirectoryState(directory)
|
||
?.tabs.find((t) => t.id === tabId);
|
||
const isActionTab = Boolean(currentTab?.label?.startsWith('Action:'));
|
||
appendToBuffer(
|
||
directory,
|
||
tabId,
|
||
`\r\n[Process exited${
|
||
exitCode !== null ? ` with code ${exitCode}` : ''
|
||
}${signal !== null ? ` (signal ${signal})` : ''}]\r\n`
|
||
);
|
||
setTabLifecycle(directory, tabId, 'exited');
|
||
setTabSessionId(directory, tabId, null);
|
||
setConnecting(directory, tabId, false);
|
||
setConnectionError(isActionTab ? null : 'Terminal session ended');
|
||
setIsFatalError(false);
|
||
setIsReconnectPending(false);
|
||
disconnectStream();
|
||
break;
|
||
}
|
||
}
|
||
},
|
||
onError: (error, fatal) => {
|
||
if (activeTerminalIdRef.current !== terminalId) {
|
||
return;
|
||
}
|
||
|
||
if (!fatal) {
|
||
setConnectionError(null);
|
||
setIsFatalError(false);
|
||
return;
|
||
}
|
||
|
||
setIsReconnectPending(false);
|
||
setConnectionError(`Connection failed: ${error.message}`);
|
||
setIsFatalError(true);
|
||
setConnecting(directory, tabId, false);
|
||
setTabLifecycle(directory, tabId, 'exited');
|
||
setTabSessionId(directory, tabId, null);
|
||
disconnectStream();
|
||
},
|
||
},
|
||
streamOptions
|
||
);
|
||
|
||
streamCleanupRef.current = () => {
|
||
subscription.close();
|
||
activeTerminalIdRef.current = null;
|
||
};
|
||
},
|
||
[appendToBuffer, disconnectStream, focusTerminalWhenWindowActive, setConnecting, setTabLifecycle, setTabSessionId, terminal]
|
||
);
|
||
|
||
React.useEffect(() => {
|
||
let cancelled = false;
|
||
|
||
if (!terminalHydrated || !hasOpenedTerminalViewport) {
|
||
return;
|
||
}
|
||
|
||
if (!effectiveDirectory) {
|
||
setConnectionError(
|
||
hasActiveContext
|
||
? 'No working directory available for terminal.'
|
||
: 'Select a session to open the terminal.'
|
||
);
|
||
disconnectStream();
|
||
return;
|
||
}
|
||
|
||
const ensureSession = async () => {
|
||
const directory = effectiveDirectory;
|
||
if (!directoryRef.current || directoryRef.current !== directory) return;
|
||
|
||
ensureDirectory(directory);
|
||
|
||
const state = useTerminalStore.getState().getDirectoryState(directory);
|
||
if (!state || state.tabs.length === 0) {
|
||
return;
|
||
}
|
||
|
||
const tabId = enableTabs
|
||
? (state.activeTabId ?? state.tabs[0]?.id ?? null)
|
||
: (state.tabs[0]?.id ?? null);
|
||
if (!tabId) {
|
||
return;
|
||
}
|
||
|
||
const tab = state.tabs.find((t) => t.id === tabId) ?? state.tabs[0];
|
||
let terminalId = tab?.terminalSessionId ?? null;
|
||
const terminalLifecycle = tab?.lifecycle ?? 'idle';
|
||
const isActionTab = Boolean(tab?.label?.startsWith('Action:'));
|
||
const hasBufferedOutput = (tab?.bufferLength ?? 0) > 0 || (tab?.bufferChunks?.length ?? 0) > 0;
|
||
|
||
const shouldNudgeExisting =
|
||
Boolean(terminalId) &&
|
||
rehydratedTerminalIdsRef.current.has(terminalId as string) &&
|
||
(tab?.bufferLength ?? 0) === 0 &&
|
||
(tab?.bufferChunks?.length ?? 0) === 0;
|
||
|
||
const isRehydratedSession =
|
||
Boolean(terminalId) && rehydratedTerminalIdsRef.current.has(terminalId as string);
|
||
|
||
if (!terminalId) {
|
||
if (terminalLifecycle === 'exited') {
|
||
setConnecting(directory, tabId, false);
|
||
return;
|
||
}
|
||
|
||
if (isActionTab && hasBufferedOutput) {
|
||
setConnecting(directory, tabId, false);
|
||
return;
|
||
}
|
||
|
||
setConnectionError(null);
|
||
setIsFatalError(false);
|
||
setIsReconnectPending(false);
|
||
setConnecting(directory, tabId, true);
|
||
try {
|
||
const size = lastViewportSizeRef.current;
|
||
const session = await terminal.createSession({
|
||
cwd: directory,
|
||
cols: size?.cols,
|
||
rows: size?.rows,
|
||
});
|
||
|
||
const stillActive =
|
||
!cancelled &&
|
||
directoryRef.current === directory &&
|
||
activeTabIdRef.current === tabId;
|
||
|
||
if (!stillActive) {
|
||
try {
|
||
await terminal.close(session.sessionId);
|
||
} catch { /* ignored */ }
|
||
return;
|
||
}
|
||
|
||
setTabSessionId(directory, tabId, session.sessionId);
|
||
terminalId = session.sessionId;
|
||
} catch (error) {
|
||
if (!cancelled) {
|
||
setConnectionError(
|
||
error instanceof Error
|
||
? error.message
|
||
: 'Failed to start terminal session'
|
||
);
|
||
setIsFatalError(true);
|
||
setIsReconnectPending(false);
|
||
setConnecting(directory, tabId, false);
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (!terminalId || cancelled) return;
|
||
|
||
terminalIdRef.current = terminalId;
|
||
|
||
if (isRehydratedSession) {
|
||
rehydratedTerminalIdsRef.current.delete(terminalId);
|
||
}
|
||
|
||
if (shouldNudgeExisting) {
|
||
nudgeOnConnectTerminalIdRef.current = terminalId;
|
||
}
|
||
startStream(
|
||
directory,
|
||
tabId,
|
||
terminalId,
|
||
isRehydratedSession ? REHYDRATED_STREAM_OPTIONS : STREAM_OPTIONS
|
||
);
|
||
};
|
||
|
||
void ensureSession();
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
terminalIdRef.current = null;
|
||
disconnectStream();
|
||
};
|
||
}, [
|
||
hasActiveContext,
|
||
effectiveDirectory,
|
||
terminalSessionId,
|
||
terminalLifecycle,
|
||
activeTabId,
|
||
hasOpenedTerminalViewport,
|
||
enableTabs,
|
||
terminalHydrated,
|
||
ensureDirectory,
|
||
setConnecting,
|
||
setTabLifecycle,
|
||
setTabSessionId,
|
||
startStream,
|
||
disconnectStream,
|
||
terminal,
|
||
]);
|
||
|
||
React.useEffect(() => {
|
||
if (!isTerminalVisible) {
|
||
return;
|
||
}
|
||
|
||
if (typeof window === 'undefined') {
|
||
focusTerminalWhenWindowActive();
|
||
return;
|
||
}
|
||
|
||
const rafId = window.requestAnimationFrame(() => {
|
||
focusTerminalWhenWindowActive();
|
||
});
|
||
|
||
return () => {
|
||
window.cancelAnimationFrame(rafId);
|
||
};
|
||
}, [activeTabId, focusTerminalWhenWindowActive, isTerminalVisible]);
|
||
|
||
const handleRestart = React.useCallback(async () => {
|
||
if (!effectiveDirectory) return;
|
||
if (isRestarting) return;
|
||
|
||
const state = useTerminalStore.getState().getDirectoryState(effectiveDirectory);
|
||
const tabId = enableTabs
|
||
? (activeTabId ?? state?.activeTabId ?? state?.tabs[0]?.id ?? null)
|
||
: (state?.tabs[0]?.id ?? null);
|
||
if (!tabId) return;
|
||
|
||
setIsRestarting(true);
|
||
setConnectionError(null);
|
||
setIsFatalError(false);
|
||
setIsReconnectPending(false);
|
||
|
||
disconnectStream();
|
||
|
||
try {
|
||
await closeTab(effectiveDirectory, tabId);
|
||
} catch (error) {
|
||
setConnectionError(error instanceof Error ? error.message : 'Failed to restart terminal');
|
||
setIsFatalError(true);
|
||
setIsReconnectPending(false);
|
||
} finally {
|
||
setIsRestarting(false);
|
||
}
|
||
}, [activeTabId, closeTab, disconnectStream, effectiveDirectory, enableTabs, isRestarting]);
|
||
|
||
const handleHardRestart = React.useCallback(async () => {
|
||
// Keep semantics: “close tab -> new clean tab”.
|
||
await handleRestart();
|
||
}, [handleRestart]);
|
||
|
||
const handleCreateTab = React.useCallback(() => {
|
||
if (!effectiveDirectory) return;
|
||
const tabId = createTab(effectiveDirectory);
|
||
setActiveTab(effectiveDirectory, tabId);
|
||
setConnectionError(null);
|
||
setIsFatalError(false);
|
||
setIsReconnectPending(false);
|
||
disconnectStream();
|
||
}, [createTab, disconnectStream, effectiveDirectory, setActiveTab]);
|
||
|
||
const handleSelectTab = React.useCallback(
|
||
(tabId: string) => {
|
||
if (!effectiveDirectory) return;
|
||
setActiveTab(effectiveDirectory, tabId);
|
||
setConnectionError(null);
|
||
setIsFatalError(false);
|
||
setIsReconnectPending(false);
|
||
disconnectStream();
|
||
},
|
||
[disconnectStream, effectiveDirectory, setActiveTab]
|
||
);
|
||
|
||
const handleCloseTab = React.useCallback(
|
||
(tabId: string) => {
|
||
if (!effectiveDirectory) return;
|
||
|
||
if (tabId === activeTabId) {
|
||
disconnectStream();
|
||
}
|
||
|
||
setConnectionError(null);
|
||
setIsFatalError(false);
|
||
setIsReconnectPending(false);
|
||
void closeTab(effectiveDirectory, tabId);
|
||
},
|
||
[activeTabId, closeTab, disconnectStream, effectiveDirectory]
|
||
);
|
||
|
||
|
||
const handleViewportInput = React.useCallback(
|
||
(data: string) => {
|
||
if (!data || isReconnectPending) {
|
||
return;
|
||
}
|
||
|
||
let payload = data;
|
||
let modifierConsumed = false;
|
||
|
||
if (activeModifier && data.length > 0) {
|
||
const firstChar = data[0];
|
||
if (firstChar.length === 1 && /[a-zA-Z]/.test(firstChar)) {
|
||
const upper = firstChar.toUpperCase();
|
||
if (activeModifier === 'ctrl' || activeModifier === 'cmd') {
|
||
payload = String.fromCharCode(upper.charCodeAt(0) & 0b11111);
|
||
modifierConsumed = true;
|
||
}
|
||
}
|
||
|
||
if (!modifierConsumed) {
|
||
modifierConsumed = true;
|
||
}
|
||
}
|
||
|
||
const terminalId = terminalIdRef.current;
|
||
if (!terminalId) return;
|
||
|
||
void terminal.sendInput(terminalId, payload).catch((error) => {
|
||
if (!isReconnectPending) {
|
||
setConnectionError(error instanceof Error ? error.message : 'Failed to send input');
|
||
}
|
||
});
|
||
|
||
if (modifierConsumed) {
|
||
setActiveModifier(null);
|
||
terminalControllerRef.current?.focus();
|
||
}
|
||
},
|
||
[activeModifier, isReconnectPending, setActiveModifier, terminal]
|
||
);
|
||
|
||
const handleViewportResize = React.useCallback(
|
||
(cols: number, rows: number) => {
|
||
lastViewportSizeRef.current = { cols, rows };
|
||
if (!isTerminalVisibleRef.current) {
|
||
return;
|
||
}
|
||
const terminalId = terminalIdRef.current;
|
||
if (!terminalId) return;
|
||
void terminal.resize({ sessionId: terminalId, cols, rows }).catch(() => {
|
||
|
||
});
|
||
},
|
||
[terminal]
|
||
);
|
||
|
||
const handleModifierToggle = React.useCallback(
|
||
(modifier: Modifier) => {
|
||
setActiveModifier((current) => (current === modifier ? null : modifier));
|
||
terminalControllerRef.current?.focus();
|
||
},
|
||
[setActiveModifier]
|
||
);
|
||
|
||
const handleMobileKeyPress = React.useCallback(
|
||
(key: MobileKey) => {
|
||
const sequence = getSequenceForKey(key, activeModifier);
|
||
if (!sequence) {
|
||
return;
|
||
}
|
||
handleViewportInput(sequence);
|
||
setActiveModifier(null);
|
||
terminalControllerRef.current?.focus();
|
||
},
|
||
[activeModifier, handleViewportInput, setActiveModifier]
|
||
);
|
||
|
||
React.useEffect(() => {
|
||
if (!showQuickKeys || !activeModifier || !terminalSessionId) {
|
||
return;
|
||
}
|
||
|
||
const handleKeyDown = (event: KeyboardEvent) => {
|
||
if (event.repeat) {
|
||
return;
|
||
}
|
||
|
||
const rawKey = event.key;
|
||
if (!rawKey) {
|
||
return;
|
||
}
|
||
|
||
if (rawKey === 'Control' || rawKey === 'Meta' || rawKey === 'Alt' || rawKey === 'Shift') {
|
||
return;
|
||
}
|
||
|
||
const normalizedKey = rawKey.length === 1 ? rawKey.toLowerCase() : rawKey;
|
||
const code = event.code ?? '';
|
||
const upperFromCode =
|
||
code.startsWith('Key') && code.length === 4
|
||
? code.slice(3).toUpperCase()
|
||
: null;
|
||
const upperKey =
|
||
rawKey.length === 1 && /[a-zA-Z]/.test(rawKey)
|
||
? rawKey.toUpperCase()
|
||
: upperFromCode;
|
||
|
||
const toMobileKey: Record<string, MobileKey> = {
|
||
Tab: 'tab',
|
||
Enter: 'enter',
|
||
ArrowUp: 'arrow-up',
|
||
ArrowDown: 'arrow-down',
|
||
ArrowLeft: 'arrow-left',
|
||
ArrowRight: 'arrow-right',
|
||
Escape: 'esc',
|
||
tab: 'tab',
|
||
enter: 'enter',
|
||
arrowup: 'arrow-up',
|
||
arrowdown: 'arrow-down',
|
||
arrowleft: 'arrow-left',
|
||
arrowright: 'arrow-right',
|
||
escape: 'esc',
|
||
};
|
||
|
||
if (normalizedKey in toMobileKey) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
handleMobileKeyPress(toMobileKey[normalizedKey]);
|
||
return;
|
||
}
|
||
|
||
if (activeModifier === 'ctrl' && upperKey && upperKey.length === 1) {
|
||
if (upperKey >= 'A' && upperKey <= 'Z') {
|
||
const controlCode = String.fromCharCode(upperKey.charCodeAt(0) & 0b11111);
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
handleViewportInput(controlCode);
|
||
setActiveModifier(null);
|
||
terminalControllerRef.current?.focus();
|
||
}
|
||
}
|
||
};
|
||
|
||
window.addEventListener('keydown', handleKeyDown);
|
||
return () => {
|
||
window.removeEventListener('keydown', handleKeyDown);
|
||
};
|
||
}, [
|
||
activeModifier,
|
||
handleMobileKeyPress,
|
||
handleViewportInput,
|
||
showQuickKeys,
|
||
setActiveModifier,
|
||
terminalSessionId,
|
||
]);
|
||
|
||
const resolvedFontStack = React.useMemo(() => {
|
||
const defaultStack = CODE_FONT_OPTION_MAP[DEFAULT_MONO_FONT].stack;
|
||
if (typeof window === 'undefined') {
|
||
const fallbackDefinition =
|
||
CODE_FONT_OPTION_MAP[monoFont] ?? CODE_FONT_OPTION_MAP[DEFAULT_MONO_FONT];
|
||
return fallbackDefinition.stack;
|
||
}
|
||
|
||
const root = window.getComputedStyle(document.documentElement);
|
||
const cssStack = root.getPropertyValue('--font-family-mono');
|
||
if (cssStack && cssStack.trim().length > 0) {
|
||
return cssStack.trim();
|
||
}
|
||
|
||
const definition =
|
||
CODE_FONT_OPTION_MAP[monoFont] ?? CODE_FONT_OPTION_MAP[DEFAULT_MONO_FONT];
|
||
return definition.stack ?? defaultStack;
|
||
}, [monoFont]);
|
||
|
||
const xtermTheme = React.useMemo(() => convertThemeToXterm(currentTheme), [currentTheme]);
|
||
|
||
const terminalSessionKey = React.useMemo(() => {
|
||
const directoryPart = effectiveDirectory ?? 'no-dir';
|
||
const tabPart = activeTabId ?? 'no-tab';
|
||
const terminalPart = terminalSessionId ?? `pending-${tabPart}`;
|
||
return `${directoryPart}::${tabPart}::${terminalPart}`;
|
||
}, [effectiveDirectory, activeTabId, terminalSessionId]);
|
||
|
||
const viewportSessionKey = React.useMemo(() => {
|
||
const base = terminalSessionId ?? terminalSessionKey;
|
||
return `${base}::layout-${viewportLayoutVersion}`;
|
||
}, [terminalSessionId, terminalSessionKey, viewportLayoutVersion]);
|
||
|
||
React.useEffect(() => {
|
||
if (isMobile || !isBottomTerminalOpen || !isTerminalVisible) {
|
||
return;
|
||
}
|
||
|
||
if (typeof window === 'undefined') {
|
||
setViewportLayoutVersion((value) => value + 1);
|
||
return;
|
||
}
|
||
|
||
const timeoutId = window.setTimeout(() => {
|
||
setViewportLayoutVersion((value) => value + 1);
|
||
}, 140);
|
||
|
||
return () => {
|
||
window.clearTimeout(timeoutId);
|
||
};
|
||
}, [bottomTerminalHeight, isBottomTerminalExpanded, isBottomTerminalOpen, isMobile, isTerminalVisible]);
|
||
|
||
React.useEffect(() => {
|
||
if (!isTerminalVisible) {
|
||
return;
|
||
}
|
||
const controller = terminalControllerRef.current;
|
||
if (!controller) {
|
||
return;
|
||
}
|
||
const fitOnce = () => {
|
||
controller.fit();
|
||
};
|
||
if (typeof window !== 'undefined') {
|
||
const rafId = window.requestAnimationFrame(() => {
|
||
fitOnce();
|
||
focusTerminalWhenWindowActive();
|
||
});
|
||
const timeoutIds = [220, 400].map((delay) => window.setTimeout(fitOnce, delay));
|
||
return () => {
|
||
window.cancelAnimationFrame(rafId);
|
||
timeoutIds.forEach((id) => window.clearTimeout(id));
|
||
};
|
||
}
|
||
fitOnce();
|
||
}, [focusTerminalWhenWindowActive, isTerminalVisible, terminalSessionKey, terminalSessionId]);
|
||
|
||
React.useEffect(() => {
|
||
if (isMobile || !isTerminalVisible || !isBottomTerminalOpen) {
|
||
return;
|
||
}
|
||
|
||
const controller = terminalControllerRef.current;
|
||
if (!controller) {
|
||
return;
|
||
}
|
||
|
||
const fitOnce = () => {
|
||
controller.fit();
|
||
};
|
||
|
||
if (typeof window !== 'undefined') {
|
||
const rafId = window.requestAnimationFrame(() => {
|
||
fitOnce();
|
||
});
|
||
const timeoutIds = [0, 80, 180, 320].map((delay) => window.setTimeout(fitOnce, delay));
|
||
return () => {
|
||
window.cancelAnimationFrame(rafId);
|
||
timeoutIds.forEach((id) => window.clearTimeout(id));
|
||
};
|
||
}
|
||
|
||
fitOnce();
|
||
}, [bottomTerminalHeight, isBottomTerminalExpanded, isBottomTerminalOpen, isMobile, isTerminalVisible]);
|
||
|
||
if (!hasActiveContext) {
|
||
return (
|
||
<div className="flex h-full items-center justify-center p-4 text-center text-sm text-muted-foreground">
|
||
Select a session to open the terminal
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!effectiveDirectory) {
|
||
return (
|
||
<div className="flex h-full flex-col items-center justify-center gap-2 p-4 text-center text-sm text-muted-foreground">
|
||
<p>No working directory available for this session.</p>
|
||
<button
|
||
onClick={handleRestart}
|
||
className="rounded-lg bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
||
>
|
||
Retry
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const quickKeysDisabled = !terminalSessionId || isConnecting || isRestarting || isReconnectPending;
|
||
const shouldRenderViewport = isMobile ? isTerminalVisible : hasOpenedTerminalViewport;
|
||
const quickKeysControls = (
|
||
<>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="outline"
|
||
className="h-6 px-2 text-xs"
|
||
onClick={() => handleMobileKeyPress('esc')}
|
||
disabled={quickKeysDisabled}
|
||
>
|
||
Esc
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="outline"
|
||
className="h-6 w-9 p-0"
|
||
onClick={() => handleMobileKeyPress('tab')}
|
||
disabled={quickKeysDisabled}
|
||
>
|
||
<RiArrowRightLine size={16} />
|
||
<span className="sr-only">Tab</span>
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="chip"
|
||
aria-pressed={activeModifier === 'ctrl'}
|
||
className="h-6 w-9 p-0"
|
||
onClick={() => handleModifierToggle('ctrl')}
|
||
disabled={quickKeysDisabled}
|
||
>
|
||
<span className="text-xs font-medium">Ctrl</span>
|
||
<span className="sr-only">Control modifier</span>
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="chip"
|
||
aria-pressed={activeModifier === 'cmd'}
|
||
className="h-6 w-9 p-0"
|
||
onClick={() => handleModifierToggle('cmd')}
|
||
disabled={quickKeysDisabled}
|
||
>
|
||
<RiCommandLine size={16} />
|
||
<span className="sr-only">Command modifier</span>
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="outline"
|
||
className="h-6 w-9 p-0"
|
||
onClick={() => handleMobileKeyPress('arrow-up')}
|
||
disabled={quickKeysDisabled}
|
||
>
|
||
<RiArrowUpLine size={16} />
|
||
<span className="sr-only">Arrow up</span>
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="outline"
|
||
className="h-6 w-9 p-0"
|
||
onClick={() => handleMobileKeyPress('arrow-left')}
|
||
disabled={quickKeysDisabled}
|
||
>
|
||
<RiArrowLeftLine size={16} />
|
||
<span className="sr-only">Arrow left</span>
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="outline"
|
||
className="h-6 w-9 p-0"
|
||
onClick={() => handleMobileKeyPress('arrow-down')}
|
||
disabled={quickKeysDisabled}
|
||
>
|
||
<RiArrowDownLine size={16} />
|
||
<span className="sr-only">Arrow down</span>
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="outline"
|
||
className="h-6 w-9 p-0"
|
||
onClick={() => handleMobileKeyPress('arrow-right')}
|
||
disabled={quickKeysDisabled}
|
||
>
|
||
<RiArrowRightLine size={16} />
|
||
<span className="sr-only">Arrow right</span>
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="outline"
|
||
className="h-6 w-9 p-0"
|
||
onClick={() => handleMobileKeyPress('enter')}
|
||
disabled={quickKeysDisabled}
|
||
>
|
||
<RiArrowGoBackLine size={16} />
|
||
<span className="sr-only">Enter</span>
|
||
</Button>
|
||
</>
|
||
);
|
||
|
||
return (
|
||
<div className="flex h-full flex-col overflow-hidden bg-[var(--surface-background)]">
|
||
<div className={cn('sticky top-0 z-20 shrink-0 bg-[var(--surface-background)] text-xs', isMobile ? 'px-4 py-1.5' : 'px-5 py-2')}>
|
||
{enableTabs && directoryTerminalState ? (
|
||
<div className={cn('pl-1 pr-1 flex items-center gap-2', isMobile ? 'mt-1' : 'mt-2')}>
|
||
<div className={cn('min-w-0 flex-1 overflow-x-auto', isMobile ? 'pb-0.5' : 'pb-1')}>
|
||
<div className={cn('flex w-max items-center pr-1', isMobile ? 'gap-1' : 'gap-1')}>
|
||
{directoryTerminalState.tabs.map((tab) => {
|
||
const isActive = tab.id === activeTabId;
|
||
return (
|
||
<div
|
||
key={tab.id}
|
||
className={cn(
|
||
'group flex items-center rounded-md border whitespace-nowrap',
|
||
isMobile ? 'h-8 gap-0.5 pl-2 pr-1.5 text-sm leading-none' : 'gap-1 pl-2 pr-1 py-1 text-xs',
|
||
isActive
|
||
? 'bg-[var(--interactive-selection)] border-[var(--primary-muted)] text-[var(--interactive-selection-foreground)]'
|
||
: 'bg-transparent border-[var(--interactive-border)] text-[var(--surface-muted-foreground)] hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]'
|
||
)}
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={() => handleSelectTab(tab.id)}
|
||
className={cn(
|
||
'truncate text-left',
|
||
isMobile ? '!min-h-0 !min-w-0 max-w-[9.5rem]' : 'max-w-[10rem]'
|
||
)}
|
||
title={tab.label}
|
||
>
|
||
{tab.label}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={cn(
|
||
'flex items-center justify-center rounded-sm text-[var(--surface-muted-foreground)] hover:text-[var(--surface-foreground)]',
|
||
isMobile ? '!min-h-0 !min-w-0 h-3.5 w-3.5 p-0 leading-none' : 'h-4 w-4 p-0 leading-none',
|
||
!isMobile && !isActive && 'opacity-0 group-hover:opacity-100'
|
||
)}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleCloseTab(tab.id);
|
||
}}
|
||
title="Close tab"
|
||
>
|
||
{isMobile ? <span aria-hidden>×</span> : <RiCloseLine size={12} />}
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
<button
|
||
type="button"
|
||
onClick={handleCreateTab}
|
||
className={cn(
|
||
'ml-1 flex items-center justify-center rounded-md border border-[var(--interactive-border)] bg-transparent text-[var(--surface-muted-foreground)] hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]',
|
||
isMobile ? '!min-h-0 !min-w-0 h-8 w-8' : 'h-6.5 w-6.5'
|
||
)}
|
||
title="New tab"
|
||
>
|
||
<RiAddLine size={isMobile ? 18 : 16} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{!isMobile && showQuickKeys ? (
|
||
<div className="flex shrink-0 items-center gap-1 overflow-x-auto pb-1">
|
||
{quickKeysControls}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
|
||
{showQuickKeys && (isMobile || !enableTabs || !directoryTerminalState) ? (
|
||
<div className="mt-2 flex flex-wrap items-center gap-1">
|
||
{quickKeysControls}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
<div
|
||
className="relative flex-1 overflow-hidden"
|
||
style={{ backgroundColor: xtermTheme.background }}
|
||
id={keyboardAvoidTargetId}
|
||
data-keyboard-avoid="true"
|
||
>
|
||
<div className="h-full w-full box-border pl-7 pr-5 pt-3 pb-4">
|
||
{shouldRenderViewport ? (
|
||
<TerminalViewport
|
||
key={viewportSessionKey}
|
||
ref={(controller) => {
|
||
terminalControllerRef.current = controller;
|
||
}}
|
||
sessionKey={viewportSessionKey}
|
||
chunks={bufferChunks}
|
||
onInput={handleViewportInput}
|
||
onResize={handleViewportResize}
|
||
theme={xtermTheme}
|
||
fontFamily={resolvedFontStack}
|
||
fontSize={terminalFontSize}
|
||
enableTouchScroll={hasTouchInput}
|
||
autoFocus={isTerminalVisible}
|
||
keyboardAvoidTargetId={keyboardAvoidTargetId}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
{!isReconnectPending && connectionError && (
|
||
<div className="absolute inset-x-0 bottom-0 bg-[var(--status-error-background)] px-3 py-2 text-xs text-[var(--status-error-foreground)] flex items-center justify-between gap-2">
|
||
<span>{connectionError}</span>
|
||
{isFatalError && isMobile && (
|
||
<Button
|
||
size="sm"
|
||
variant="secondary"
|
||
className="h-6 px-2 py-0 text-xs"
|
||
onClick={handleHardRestart}
|
||
disabled={isRestarting}
|
||
title="Force kill and create fresh session"
|
||
type="button"
|
||
>
|
||
Hard Restart
|
||
</Button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|