fix: make shell and standalone shell feature based components

- In addition, use one copy handler throughout the app.
This commit is contained in:
Haileyesus
2026-02-20 10:39:16 +03:00
parent 6348c01967
commit 200332f4f5
30 changed files with 1478 additions and 906 deletions

View File

@@ -0,0 +1,63 @@
import type { ITerminalOptions } from '@xterm/xterm';
export const CODEX_DEVICE_AUTH_URL = 'https://auth.openai.com/codex/device';
export const SHELL_RESTART_DELAY_MS = 200;
export const TERMINAL_INIT_DELAY_MS = 100;
export const TERMINAL_RESIZE_DELAY_MS = 50;
export const TERMINAL_OPTIONS: ITerminalOptions = {
cursorBlink: true,
fontSize: 14,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
allowProposedApi: true,
allowTransparency: false,
convertEol: true,
scrollback: 10000,
tabStopWidth: 4,
windowsMode: false,
macOptionIsMeta: true,
macOptionClickForcesSelection: true,
// Keep the runtime theme keys used by the previous JSX implementation.
theme: {
background: '#1e1e1e',
foreground: '#d4d4d4',
cursor: '#ffffff',
cursorAccent: '#1e1e1e',
selection: '#264f78',
selectionForeground: '#ffffff',
black: '#000000',
red: '#cd3131',
green: '#0dbc79',
yellow: '#e5e510',
blue: '#2472c8',
magenta: '#bc3fbc',
cyan: '#11a8cd',
white: '#e5e5e5',
brightBlack: '#666666',
brightRed: '#f14c4c',
brightGreen: '#23d18b',
brightYellow: '#f5f543',
brightBlue: '#3b8eea',
brightMagenta: '#d670d6',
brightCyan: '#29b8db',
brightWhite: '#ffffff',
extendedAnsi: [
'#000000',
'#800000',
'#008000',
'#808000',
'#000080',
'#800080',
'#008080',
'#c0c0c0',
'#808080',
'#ff0000',
'#00ff00',
'#ffff00',
'#0000ff',
'#ff00ff',
'#00ffff',
'#ffffff',
],
} as any,
};

View File

@@ -0,0 +1,215 @@
import { useCallback, useEffect, useState } from 'react';
import type { MutableRefObject } from 'react';
import type { FitAddon } from '@xterm/addon-fit';
import type { Terminal } from '@xterm/xterm';
import type { Project, ProjectSession } from '../../../types/app';
import { TERMINAL_INIT_DELAY_MS } from '../constants/constants';
import { getShellWebSocketUrl, parseShellMessage, sendSocketMessage } from '../utils/socket';
const ANSI_ESCAPE_REGEX = /\x1b\[[0-9;]*m/g;
const PROCESS_EXIT_REGEX = /Process exited with code (\d+)/;
type UseShellConnectionOptions = {
wsRef: MutableRefObject<WebSocket | null>;
terminalRef: MutableRefObject<Terminal | null>;
fitAddonRef: MutableRefObject<FitAddon | null>;
selectedProjectRef: MutableRefObject<Project | null | undefined>;
selectedSessionRef: MutableRefObject<ProjectSession | null | undefined>;
initialCommandRef: MutableRefObject<string | null | undefined>;
isPlainShellRef: MutableRefObject<boolean>;
onProcessCompleteRef: MutableRefObject<((exitCode: number) => void) | null | undefined>;
isInitialized: boolean;
autoConnect: boolean;
closeSocket: () => void;
clearTerminalScreen: () => void;
setAuthUrl: (nextAuthUrl: string) => void;
};
type UseShellConnectionResult = {
isConnected: boolean;
isConnecting: boolean;
closeSocket: () => void;
connectToShell: () => void;
disconnectFromShell: () => void;
};
export function useShellConnection({
wsRef,
terminalRef,
fitAddonRef,
selectedProjectRef,
selectedSessionRef,
initialCommandRef,
isPlainShellRef,
onProcessCompleteRef,
isInitialized,
autoConnect,
closeSocket,
clearTerminalScreen,
setAuthUrl,
}: UseShellConnectionOptions): UseShellConnectionResult {
const [isConnected, setIsConnected] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const handleProcessCompletion = useCallback(
(output: string) => {
if (!isPlainShellRef.current || !onProcessCompleteRef.current) {
return;
}
const cleanOutput = output.replace(ANSI_ESCAPE_REGEX, '');
if (cleanOutput.includes('Process exited with code 0')) {
onProcessCompleteRef.current(0);
return;
}
const match = cleanOutput.match(PROCESS_EXIT_REGEX);
if (!match) {
return;
}
const exitCode = Number.parseInt(match[1], 10);
if (!Number.isNaN(exitCode) && exitCode !== 0) {
onProcessCompleteRef.current(exitCode);
}
},
[isPlainShellRef, onProcessCompleteRef],
);
const handleSocketMessage = useCallback(
(rawPayload: string) => {
const message = parseShellMessage(rawPayload);
if (!message) {
console.error('[Shell] Error handling WebSocket message:', rawPayload);
return;
}
if (message.type === 'output') {
const output = typeof message.data === 'string' ? message.data : '';
handleProcessCompletion(output);
terminalRef.current?.write(output);
return;
}
if (message.type === 'auth_url' || message.type === 'url_open') {
const nextAuthUrl = typeof message.url === 'string' ? message.url : '';
if (nextAuthUrl) {
setAuthUrl(nextAuthUrl);
}
}
},
[handleProcessCompletion, setAuthUrl, terminalRef],
);
const connectWebSocket = useCallback(() => {
if (isConnecting || isConnected) {
return;
}
try {
const wsUrl = getShellWebSocketUrl();
if (!wsUrl) {
return;
}
const socket = new WebSocket(wsUrl);
wsRef.current = socket;
socket.onopen = () => {
setIsConnected(true);
setIsConnecting(false);
setAuthUrl('');
window.setTimeout(() => {
const currentTerminal = terminalRef.current;
const currentFitAddon = fitAddonRef.current;
const currentProject = selectedProjectRef.current;
if (!currentTerminal || !currentFitAddon || !currentProject) {
return;
}
currentFitAddon.fit();
sendSocketMessage(wsRef.current, {
type: 'init',
projectPath: currentProject.fullPath || currentProject.path || '',
sessionId: isPlainShellRef.current ? null : selectedSessionRef.current?.id || null,
hasSession: isPlainShellRef.current ? false : Boolean(selectedSessionRef.current),
provider: isPlainShellRef.current
? 'plain-shell'
: selectedSessionRef.current?.__provider || 'claude',
cols: currentTerminal.cols,
rows: currentTerminal.rows,
initialCommand: initialCommandRef.current,
isPlainShell: isPlainShellRef.current,
});
}, TERMINAL_INIT_DELAY_MS);
};
socket.onmessage = (event) => {
const rawPayload = typeof event.data === 'string' ? event.data : String(event.data ?? '');
handleSocketMessage(rawPayload);
};
socket.onclose = () => {
setIsConnected(false);
setIsConnecting(false);
clearTerminalScreen();
};
socket.onerror = () => {
setIsConnected(false);
setIsConnecting(false);
};
} catch {
setIsConnected(false);
setIsConnecting(false);
}
}, [
clearTerminalScreen,
fitAddonRef,
handleSocketMessage,
initialCommandRef,
isConnected,
isConnecting,
isPlainShellRef,
selectedProjectRef,
selectedSessionRef,
setAuthUrl,
terminalRef,
wsRef,
]);
const connectToShell = useCallback(() => {
if (!isInitialized || isConnected || isConnecting) {
return;
}
setIsConnecting(true);
connectWebSocket();
}, [connectWebSocket, isConnected, isConnecting, isInitialized]);
const disconnectFromShell = useCallback(() => {
closeSocket();
clearTerminalScreen();
setIsConnected(false);
setIsConnecting(false);
setAuthUrl('');
}, [clearTerminalScreen, closeSocket, setAuthUrl]);
useEffect(() => {
if (!autoConnect || !isInitialized || isConnecting || isConnected) {
return;
}
connectToShell();
}, [autoConnect, connectToShell, isConnected, isConnecting, isInitialized]);
return {
isConnected,
isConnecting,
closeSocket,
connectToShell,
disconnectFromShell,
};
}

View File

@@ -0,0 +1,166 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { FitAddon } from '@xterm/addon-fit';
import type { Terminal } from '@xterm/xterm';
import { useShellConnection } from './useShellConnection';
import { useShellTerminal } from './useShellTerminal';
import type { UseShellRuntimeOptions, UseShellRuntimeResult } from '../types/types';
import { copyTextToClipboard } from '../../../utils/clipboard';
export function useShellRuntime({
selectedProject,
selectedSession,
initialCommand,
isPlainShell,
minimal,
autoConnect,
isRestarting,
onProcessComplete,
}: UseShellRuntimeOptions): UseShellRuntimeResult {
const terminalContainerRef = useRef<HTMLDivElement>(null);
const terminalRef = useRef<Terminal | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const [authUrl, setAuthUrl] = useState('');
const [authUrlVersion, setAuthUrlVersion] = useState(0);
const selectedProjectRef = useRef(selectedProject);
const selectedSessionRef = useRef(selectedSession);
const initialCommandRef = useRef(initialCommand);
const isPlainShellRef = useRef(isPlainShell);
const onProcessCompleteRef = useRef(onProcessComplete);
const authUrlRef = useRef('');
const lastSessionIdRef = useRef<string | null>(selectedSession?.id ?? null);
// Keep mutable values in refs so websocket handlers always read current data.
useEffect(() => {
selectedProjectRef.current = selectedProject;
selectedSessionRef.current = selectedSession;
initialCommandRef.current = initialCommand;
isPlainShellRef.current = isPlainShell;
onProcessCompleteRef.current = onProcessComplete;
}, [selectedProject, selectedSession, initialCommand, isPlainShell, onProcessComplete]);
const setCurrentAuthUrl = useCallback((nextAuthUrl: string) => {
authUrlRef.current = nextAuthUrl;
setAuthUrl(nextAuthUrl);
setAuthUrlVersion((previous) => previous + 1);
}, []);
const closeSocket = useCallback(() => {
const activeSocket = wsRef.current;
if (!activeSocket) {
return;
}
if (
activeSocket.readyState === WebSocket.OPEN ||
activeSocket.readyState === WebSocket.CONNECTING
) {
activeSocket.close();
}
wsRef.current = null;
}, []);
const openAuthUrlInBrowser = useCallback((url = authUrlRef.current) => {
if (!url) {
return false;
}
const popup = window.open(url, '_blank', 'noopener,noreferrer');
if (popup) {
try {
popup.opener = null;
} catch {
// Ignore cross-origin restrictions when trying to null opener.
}
return true;
}
return false;
}, []);
const copyAuthUrlToClipboard = useCallback(async (url = authUrlRef.current) => {
if (!url) {
return false;
}
return copyTextToClipboard(url);
}, []);
const { isInitialized, clearTerminalScreen, disposeTerminal } = useShellTerminal({
terminalContainerRef,
terminalRef,
fitAddonRef,
wsRef,
selectedProject,
minimal,
isRestarting,
initialCommandRef,
isPlainShellRef,
authUrlRef,
copyAuthUrlToClipboard,
closeSocket,
});
const { isConnected, isConnecting, connectToShell, disconnectFromShell } = useShellConnection({
wsRef,
terminalRef,
fitAddonRef,
selectedProjectRef,
selectedSessionRef,
initialCommandRef,
isPlainShellRef,
onProcessCompleteRef,
isInitialized,
autoConnect,
closeSocket,
clearTerminalScreen,
setAuthUrl: setCurrentAuthUrl,
});
useEffect(() => {
if (!isRestarting) {
return;
}
disconnectFromShell();
disposeTerminal();
}, [disconnectFromShell, disposeTerminal, isRestarting]);
useEffect(() => {
if (selectedProject) {
return;
}
disconnectFromShell();
disposeTerminal();
}, [disconnectFromShell, disposeTerminal, selectedProject]);
useEffect(() => {
const currentSessionId = selectedSession?.id ?? null;
if (
lastSessionIdRef.current !== null &&
lastSessionIdRef.current !== currentSessionId &&
isInitialized
) {
disconnectFromShell();
}
lastSessionIdRef.current = currentSessionId;
}, [disconnectFromShell, isInitialized, selectedSession?.id]);
return {
terminalContainerRef,
isConnected,
isInitialized,
isConnecting,
authUrl,
authUrlVersion,
connectToShell,
disconnectFromShell,
openAuthUrlInBrowser,
copyAuthUrlToClipboard,
};
}

View File

@@ -0,0 +1,233 @@
import { useCallback, useEffect, useState } from 'react';
import type { MutableRefObject, RefObject } from 'react';
import { FitAddon } from '@xterm/addon-fit';
import { WebLinksAddon } from '@xterm/addon-web-links';
import { WebglAddon } from '@xterm/addon-webgl';
import { Terminal } from '@xterm/xterm';
import type { Project } from '../../../types/app';
import {
CODEX_DEVICE_AUTH_URL,
TERMINAL_INIT_DELAY_MS,
TERMINAL_OPTIONS,
TERMINAL_RESIZE_DELAY_MS,
} from '../constants/constants';
import { isCodexLoginCommand } from '../utils/auth';
import { sendSocketMessage } from '../utils/socket';
import { ensureXtermFocusStyles } from '../utils/terminalStyles';
type UseShellTerminalOptions = {
terminalContainerRef: RefObject<HTMLDivElement>;
terminalRef: MutableRefObject<Terminal | null>;
fitAddonRef: MutableRefObject<FitAddon | null>;
wsRef: MutableRefObject<WebSocket | null>;
selectedProject: Project | null | undefined;
minimal: boolean;
isRestarting: boolean;
initialCommandRef: MutableRefObject<string | null | undefined>;
isPlainShellRef: MutableRefObject<boolean>;
authUrlRef: MutableRefObject<string>;
copyAuthUrlToClipboard: (url?: string) => Promise<boolean>;
closeSocket: () => void;
};
type UseShellTerminalResult = {
isInitialized: boolean;
clearTerminalScreen: () => void;
disposeTerminal: () => void;
};
export function useShellTerminal({
terminalContainerRef,
terminalRef,
fitAddonRef,
wsRef,
selectedProject,
minimal,
isRestarting,
initialCommandRef,
isPlainShellRef,
authUrlRef,
copyAuthUrlToClipboard,
closeSocket,
}: UseShellTerminalOptions): UseShellTerminalResult {
const [isInitialized, setIsInitialized] = useState(false);
const selectedProjectKey = selectedProject?.fullPath || selectedProject?.path || '';
const hasSelectedProject = Boolean(selectedProject);
useEffect(() => {
ensureXtermFocusStyles();
}, []);
const clearTerminalScreen = useCallback(() => {
if (!terminalRef.current) {
return;
}
terminalRef.current.clear();
terminalRef.current.write('\x1b[2J\x1b[H');
}, [terminalRef]);
const disposeTerminal = useCallback(() => {
if (terminalRef.current) {
terminalRef.current.dispose();
terminalRef.current = null;
}
fitAddonRef.current = null;
setIsInitialized(false);
}, [fitAddonRef, terminalRef]);
useEffect(() => {
if (!terminalContainerRef.current || !hasSelectedProject || isRestarting || terminalRef.current) {
return;
}
const nextTerminal = new Terminal(TERMINAL_OPTIONS);
terminalRef.current = nextTerminal;
const nextFitAddon = new FitAddon();
fitAddonRef.current = nextFitAddon;
nextTerminal.loadAddon(nextFitAddon);
// Avoid wrapped partial links in compact login flows.
if (!minimal) {
nextTerminal.loadAddon(new WebLinksAddon());
}
try {
nextTerminal.loadAddon(new WebglAddon());
} catch {
console.warn('[Shell] WebGL renderer unavailable, using Canvas fallback');
}
nextTerminal.open(terminalContainerRef.current);
nextTerminal.attachCustomKeyEventHandler((event) => {
const activeAuthUrl = isCodexLoginCommand(initialCommandRef.current)
? CODEX_DEVICE_AUTH_URL
: authUrlRef.current;
if (
event.type === 'keydown' &&
minimal &&
isPlainShellRef.current &&
activeAuthUrl &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
event.key?.toLowerCase() === 'c'
) {
void copyAuthUrlToClipboard(activeAuthUrl);
}
if (
event.type === 'keydown' &&
(event.ctrlKey || event.metaKey) &&
event.key?.toLowerCase() === 'c' &&
nextTerminal.hasSelection()
) {
event.preventDefault();
event.stopPropagation();
document.execCommand('copy');
return false;
}
if (
event.type === 'keydown' &&
(event.ctrlKey || event.metaKey) &&
event.key?.toLowerCase() === 'v'
) {
// Block native paste so data is only injected after clipboard-read resolves.
event.preventDefault();
event.stopPropagation();
if (typeof navigator !== 'undefined' && navigator.clipboard?.readText) {
navigator.clipboard
.readText()
.then((text) => {
sendSocketMessage(wsRef.current, {
type: 'input',
data: text,
});
})
.catch(() => {});
}
return false;
}
return true;
});
window.setTimeout(() => {
const currentFitAddon = fitAddonRef.current;
const currentTerminal = terminalRef.current;
if (!currentFitAddon || !currentTerminal) {
return;
}
currentFitAddon.fit();
sendSocketMessage(wsRef.current, {
type: 'resize',
cols: currentTerminal.cols,
rows: currentTerminal.rows,
});
}, TERMINAL_INIT_DELAY_MS);
setIsInitialized(true);
const dataSubscription = nextTerminal.onData((data) => {
sendSocketMessage(wsRef.current, {
type: 'input',
data,
});
});
const resizeObserver = new ResizeObserver(() => {
window.setTimeout(() => {
const currentFitAddon = fitAddonRef.current;
const currentTerminal = terminalRef.current;
if (!currentFitAddon || !currentTerminal) {
return;
}
currentFitAddon.fit();
sendSocketMessage(wsRef.current, {
type: 'resize',
cols: currentTerminal.cols,
rows: currentTerminal.rows,
});
}, TERMINAL_RESIZE_DELAY_MS);
});
resizeObserver.observe(terminalContainerRef.current);
return () => {
resizeObserver.disconnect();
dataSubscription.dispose();
closeSocket();
disposeTerminal();
};
}, [
authUrlRef,
closeSocket,
copyAuthUrlToClipboard,
disposeTerminal,
fitAddonRef,
initialCommandRef,
isPlainShellRef,
isRestarting,
minimal,
hasSelectedProject,
selectedProjectKey,
terminalContainerRef,
terminalRef,
wsRef,
]);
return {
isInitialized,
clearTerminalScreen,
disposeTerminal,
};
}

View File

@@ -0,0 +1,73 @@
import type { MutableRefObject, RefObject } from 'react';
import type { FitAddon } from '@xterm/addon-fit';
import type { Terminal } from '@xterm/xterm';
import type { Project, ProjectSession } from '../../../types/app';
export type AuthCopyStatus = 'idle' | 'copied' | 'failed';
export type ShellInitMessage = {
type: 'init';
projectPath: string;
sessionId: string | null;
hasSession: boolean;
provider: string;
cols: number;
rows: number;
initialCommand: string | null | undefined;
isPlainShell: boolean;
};
export type ShellResizeMessage = {
type: 'resize';
cols: number;
rows: number;
};
export type ShellInputMessage = {
type: 'input';
data: string;
};
export type ShellOutgoingMessage = ShellInitMessage | ShellResizeMessage | ShellInputMessage;
export type ShellIncomingMessage =
| { type: 'output'; data: string }
| { type: 'auth_url'; url?: string }
| { type: 'url_open'; url?: string }
| { type: string; [key: string]: unknown };
export type UseShellRuntimeOptions = {
selectedProject: Project | null | undefined;
selectedSession: ProjectSession | null | undefined;
initialCommand: string | null | undefined;
isPlainShell: boolean;
minimal: boolean;
autoConnect: boolean;
isRestarting: boolean;
onProcessComplete?: ((exitCode: number) => void) | null;
};
export type ShellSharedRefs = {
wsRef: MutableRefObject<WebSocket | null>;
terminalRef: MutableRefObject<Terminal | null>;
fitAddonRef: MutableRefObject<FitAddon | null>;
authUrlRef: MutableRefObject<string>;
selectedProjectRef: MutableRefObject<Project | null | undefined>;
selectedSessionRef: MutableRefObject<ProjectSession | null | undefined>;
initialCommandRef: MutableRefObject<string | null | undefined>;
isPlainShellRef: MutableRefObject<boolean>;
onProcessCompleteRef: MutableRefObject<((exitCode: number) => void) | null | undefined>;
};
export type UseShellRuntimeResult = {
terminalContainerRef: RefObject<HTMLDivElement>;
isConnected: boolean;
isInitialized: boolean;
isConnecting: boolean;
authUrl: string;
authUrlVersion: number;
connectToShell: () => void;
disconnectFromShell: () => void;
openAuthUrlInBrowser: (url?: string) => boolean;
copyAuthUrlToClipboard: (url?: string) => Promise<boolean>;
};

View File

@@ -0,0 +1,24 @@
import type { ProjectSession } from '../../../types/app';
import { CODEX_DEVICE_AUTH_URL } from '../constants/constants';
export function isCodexLoginCommand(command: string | null | undefined): boolean {
return typeof command === 'string' && /\bcodex\s+login\b/i.test(command);
}
export function resolveAuthUrlForDisplay(command: string | null | undefined, authUrl: string): string {
if (isCodexLoginCommand(command)) {
return CODEX_DEVICE_AUTH_URL;
}
return authUrl;
}
export function getSessionDisplayName(session: ProjectSession | null | undefined): string | null {
if (!session) {
return null;
}
return session.__provider === 'cursor'
? session.name || 'Untitled Session'
: session.summary || 'New Session';
}

View File

@@ -0,0 +1,32 @@
import { IS_PLATFORM } from '../../../constants/config';
import type { ShellIncomingMessage, ShellOutgoingMessage } from '../types/types';
export function getShellWebSocketUrl(): string | null {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
if (IS_PLATFORM) {
return `${protocol}//${window.location.host}/shell`;
}
const token = localStorage.getItem('auth-token');
if (!token) {
console.error('No authentication token found for Shell WebSocket connection');
return null;
}
return `${protocol}//${window.location.host}/shell?token=${encodeURIComponent(token)}`;
}
export function parseShellMessage(payload: string): ShellIncomingMessage | null {
try {
return JSON.parse(payload) as ShellIncomingMessage;
} catch {
return null;
}
}
export function sendSocketMessage(ws: WebSocket | null, message: ShellOutgoingMessage): void {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(message));
}
}

View File

@@ -0,0 +1,29 @@
const XTERM_STYLE_ELEMENT_ID = 'shell-xterm-focus-style';
const XTERM_FOCUS_STYLES = `
.xterm .xterm-screen {
outline: none !important;
}
.xterm:focus .xterm-screen {
outline: none !important;
}
.xterm-screen:focus {
outline: none !important;
}
`;
export function ensureXtermFocusStyles(): void {
if (typeof document === 'undefined') {
return;
}
if (document.getElementById(XTERM_STYLE_ELEMENT_ID)) {
return;
}
const styleSheet = document.createElement('style');
styleSheet.id = XTERM_STYLE_ELEMENT_ID;
styleSheet.type = 'text/css';
styleSheet.innerText = XTERM_FOCUS_STYLES;
document.head.appendChild(styleSheet);
}

View File

@@ -0,0 +1,162 @@
import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import '@xterm/xterm/css/xterm.css';
import type { Project, ProjectSession } from '../../../types/app';
import { SHELL_RESTART_DELAY_MS } from '../constants/constants';
import { useShellRuntime } from '../hooks/useShellRuntime';
import { getSessionDisplayName } from '../utils/auth';
import ShellConnectionOverlay from './subcomponents/ShellConnectionOverlay';
import ShellEmptyState from './subcomponents/ShellEmptyState';
import ShellHeader from './subcomponents/ShellHeader';
import ShellMinimalView from './subcomponents/ShellMinimalView';
type ShellProps = {
selectedProject?: Project | null;
selectedSession?: ProjectSession | null;
initialCommand?: string | null;
isPlainShell?: boolean;
onProcessComplete?: ((exitCode: number) => void) | null;
minimal?: boolean;
autoConnect?: boolean;
isActive?: boolean;
};
export default function Shell({
selectedProject = null,
selectedSession = null,
initialCommand = null,
isPlainShell = false,
onProcessComplete = null,
minimal = false,
autoConnect = false,
isActive,
}: ShellProps) {
const { t } = useTranslation('chat');
const [isRestarting, setIsRestarting] = useState(false);
// Keep the public API stable for existing callers that still pass `isActive`.
void isActive;
const {
terminalContainerRef,
isConnected,
isInitialized,
isConnecting,
authUrl,
authUrlVersion,
connectToShell,
disconnectFromShell,
openAuthUrlInBrowser,
copyAuthUrlToClipboard,
} = useShellRuntime({
selectedProject,
selectedSession,
initialCommand,
isPlainShell,
minimal,
autoConnect,
isRestarting,
onProcessComplete,
});
const sessionDisplayName = useMemo(() => getSessionDisplayName(selectedSession), [selectedSession]);
const sessionDisplayNameShort = useMemo(
() => (sessionDisplayName ? sessionDisplayName.slice(0, 30) : null),
[sessionDisplayName],
);
const sessionDisplayNameLong = useMemo(
() => (sessionDisplayName ? sessionDisplayName.slice(0, 50) : null),
[sessionDisplayName],
);
const handleRestartShell = useCallback(() => {
setIsRestarting(true);
window.setTimeout(() => {
setIsRestarting(false);
}, SHELL_RESTART_DELAY_MS);
}, []);
if (!selectedProject) {
return (
<ShellEmptyState
title={t('shell.selectProject.title')}
description={t('shell.selectProject.description')}
/>
);
}
if (minimal) {
return (
<ShellMinimalView
terminalContainerRef={terminalContainerRef}
authUrl={authUrl}
authUrlVersion={authUrlVersion}
initialCommand={initialCommand}
isConnected={isConnected}
openAuthUrlInBrowser={openAuthUrlInBrowser}
copyAuthUrlToClipboard={copyAuthUrlToClipboard}
/>
);
}
const readyDescription = isPlainShell
? t('shell.runCommand', {
command: initialCommand || t('shell.defaultCommand'),
projectName: selectedProject.displayName,
})
: selectedSession
? t('shell.resumeSession', { displayName: sessionDisplayNameLong })
: t('shell.startSession');
const connectingDescription = isPlainShell
? t('shell.runCommand', {
command: initialCommand || t('shell.defaultCommand'),
projectName: selectedProject.displayName,
})
: t('shell.startCli', { projectName: selectedProject.displayName });
const overlayMode = !isInitialized ? 'loading' : isConnecting ? 'connecting' : !isConnected ? 'connect' : null;
const overlayDescription = overlayMode === 'connecting' ? connectingDescription : readyDescription;
return (
<div className="h-full flex flex-col bg-gray-900 w-full">
<ShellHeader
isConnected={isConnected}
isInitialized={isInitialized}
isRestarting={isRestarting}
hasSession={Boolean(selectedSession)}
sessionDisplayNameShort={sessionDisplayNameShort}
onDisconnect={disconnectFromShell}
onRestart={handleRestartShell}
statusNewSessionText={t('shell.status.newSession')}
statusInitializingText={t('shell.status.initializing')}
statusRestartingText={t('shell.status.restarting')}
disconnectLabel={t('shell.actions.disconnect')}
disconnectTitle={t('shell.actions.disconnectTitle')}
restartLabel={t('shell.actions.restart')}
restartTitle={t('shell.actions.restartTitle')}
disableRestart={isRestarting || isConnected}
/>
<div className="flex-1 p-2 overflow-hidden relative">
<div
ref={terminalContainerRef}
className="h-full w-full focus:outline-none"
style={{ outline: 'none' }}
/>
{overlayMode && (
<ShellConnectionOverlay
mode={overlayMode}
description={overlayDescription}
loadingLabel={t('shell.loading')}
connectLabel={t('shell.actions.connect')}
connectTitle={t('shell.actions.connectTitle')}
connectingLabel={t('shell.connecting')}
onConnect={connectToShell}
/>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,59 @@
type ShellConnectionOverlayProps = {
mode: 'loading' | 'connect' | 'connecting';
description: string;
loadingLabel: string;
connectLabel: string;
connectTitle: string;
connectingLabel: string;
onConnect: () => void;
};
export default function ShellConnectionOverlay({
mode,
description,
loadingLabel,
connectLabel,
connectTitle,
connectingLabel,
onConnect,
}: ShellConnectionOverlayProps) {
if (mode === 'loading') {
return (
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90">
<div className="text-white">{loadingLabel}</div>
</div>
);
}
if (mode === 'connect') {
return (
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90 p-4">
<div className="text-center max-w-sm w-full">
<button
onClick={onConnect}
className="px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center justify-center space-x-2 text-base font-medium w-full sm:w-auto"
title={connectTitle}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span>{connectLabel}</span>
</button>
<p className="text-gray-400 text-sm mt-3 px-2">{description}</p>
</div>
</div>
);
}
return (
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90 p-4">
<div className="text-center max-w-sm w-full">
<div className="flex items-center justify-center space-x-3 text-yellow-400">
<div className="w-6 h-6 animate-spin rounded-full border-2 border-yellow-400 border-t-transparent"></div>
<span className="text-base font-medium">{connectingLabel}</span>
</div>
<p className="text-gray-400 text-sm mt-3 px-2">{description}</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,25 @@
type ShellEmptyStateProps = {
title: string;
description: string;
};
export default function ShellEmptyState({ title, description }: ShellEmptyStateProps) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-center text-gray-500 dark:text-gray-400">
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
</div>
<h3 className="text-lg font-semibold mb-2">{title}</h3>
<p>{description}</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,87 @@
type ShellHeaderProps = {
isConnected: boolean;
isInitialized: boolean;
isRestarting: boolean;
hasSession: boolean;
sessionDisplayNameShort: string | null;
onDisconnect: () => void;
onRestart: () => void;
statusNewSessionText: string;
statusInitializingText: string;
statusRestartingText: string;
disconnectLabel: string;
disconnectTitle: string;
restartLabel: string;
restartTitle: string;
disableRestart: boolean;
};
export default function ShellHeader({
isConnected,
isInitialized,
isRestarting,
hasSession,
sessionDisplayNameShort,
onDisconnect,
onRestart,
statusNewSessionText,
statusInitializingText,
statusRestartingText,
disconnectLabel,
disconnectTitle,
restartLabel,
restartTitle,
disableRestart,
}: ShellHeaderProps) {
return (
<div className="flex-shrink-0 bg-gray-800 border-b border-gray-700 px-4 py-2">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`} />
{hasSession && sessionDisplayNameShort && (
<span className="text-xs text-blue-300">({sessionDisplayNameShort}...)</span>
)}
{!hasSession && <span className="text-xs text-gray-400">{statusNewSessionText}</span>}
{!isInitialized && <span className="text-xs text-yellow-400">{statusInitializingText}</span>}
{isRestarting && <span className="text-xs text-blue-400">{statusRestartingText}</span>}
</div>
<div className="flex items-center space-x-3">
{isConnected && (
<button
onClick={onDisconnect}
className="px-3 py-1 text-xs bg-red-600 text-white rounded hover:bg-red-700 flex items-center space-x-1"
title={disconnectTitle}
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<span>{disconnectLabel}</span>
</button>
)}
<button
onClick={onRestart}
disabled={disableRestart}
className="text-xs text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1"
title={restartTitle}
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
<span>{restartLabel}</span>
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,113 @@
import { useEffect, useMemo, useState } from 'react';
import type { RefObject } from 'react';
import type { AuthCopyStatus } from '../../types/types';
import { resolveAuthUrlForDisplay } from '../../utils/auth';
type ShellMinimalViewProps = {
terminalContainerRef: RefObject<HTMLDivElement>;
authUrl: string;
authUrlVersion: number;
initialCommand: string | null | undefined;
isConnected: boolean;
openAuthUrlInBrowser: (url: string) => boolean;
copyAuthUrlToClipboard: (url: string) => Promise<boolean>;
};
export default function ShellMinimalView({
terminalContainerRef,
authUrl,
authUrlVersion,
initialCommand,
isConnected,
openAuthUrlInBrowser,
copyAuthUrlToClipboard,
}: ShellMinimalViewProps) {
const [authUrlCopyStatus, setAuthUrlCopyStatus] = useState<AuthCopyStatus>('idle');
const [isAuthPanelHidden, setIsAuthPanelHidden] = useState(false);
const displayAuthUrl = useMemo(
() => resolveAuthUrlForDisplay(initialCommand, authUrl),
[authUrl, initialCommand],
);
// Keep auth panel UI state local to minimal mode and reset it when connection/url changes.
useEffect(() => {
setAuthUrlCopyStatus('idle');
setIsAuthPanelHidden(false);
}, [authUrlVersion, displayAuthUrl, isConnected]);
const hasAuthUrl = Boolean(displayAuthUrl);
const showMobileAuthPanel = hasAuthUrl && !isAuthPanelHidden;
const showMobileAuthPanelToggle = hasAuthUrl && isAuthPanelHidden;
return (
<div className="h-full w-full bg-gray-900 relative">
<div
ref={terminalContainerRef}
className="h-full w-full focus:outline-none"
style={{ outline: 'none' }}
/>
{showMobileAuthPanel && (
<div className="absolute inset-x-0 bottom-14 z-20 border-t border-gray-700/80 bg-gray-900/95 p-3 backdrop-blur-sm md:hidden">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-gray-300">Open or copy the login URL:</p>
<button
type="button"
onClick={() => setIsAuthPanelHidden(true)}
className="rounded bg-gray-700 px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-gray-100 hover:bg-gray-600"
>
Hide
</button>
</div>
<input
type="text"
value={displayAuthUrl}
readOnly
onClick={(event) => event.currentTarget.select()}
className="w-full rounded border border-gray-600 bg-gray-800 px-2 py-1 text-xs text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
aria-label="Authentication URL"
/>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
openAuthUrlInBrowser(displayAuthUrl);
}}
className="flex-1 rounded bg-blue-600 px-3 py-2 text-xs font-medium text-white hover:bg-blue-700"
>
Open URL
</button>
<button
type="button"
onClick={async () => {
const copied = await copyAuthUrlToClipboard(displayAuthUrl);
setAuthUrlCopyStatus(copied ? 'copied' : 'failed');
}}
className="flex-1 rounded bg-gray-700 px-3 py-2 text-xs font-medium text-white hover:bg-gray-600"
>
{authUrlCopyStatus === 'copied' ? 'Copied' : 'Copy URL'}
</button>
</div>
</div>
</div>
)}
{showMobileAuthPanelToggle && (
<div className="absolute bottom-14 right-3 z-20 md:hidden">
<button
type="button"
onClick={() => setIsAuthPanelHidden(false)}
className="rounded bg-gray-800/95 px-3 py-2 text-xs font-medium text-gray-100 shadow-lg backdrop-blur-sm hover:bg-gray-700"
>
Show login URL
</button>
</div>
)}
</div>
);
}