From 610ccf4c62911cad805ff915de2988826166bc5d Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 10 Dec 2025 00:25:02 +0200 Subject: [PATCH] feat(terminal): add restart and force kill session functionality --- .../src-tauri/src/commands/terminal.rs | 124 ++++++++++- packages/desktop/src-tauri/src/main.rs | 5 +- packages/desktop/src/api/terminal.ts | 38 ++++ .../ui/src/components/views/TerminalView.tsx | 201 ++++++++++++------ packages/ui/src/lib/api/types.ts | 7 + packages/ui/src/lib/terminalApi.ts | 38 ++++ packages/ui/src/stores/useTerminalStore.ts | 86 ++++---- packages/web/server/index.js | 105 +++++++++ packages/web/src/api/terminal.ts | 18 ++ 9 files changed, 515 insertions(+), 107 deletions(-) diff --git a/packages/desktop/src-tauri/src/commands/terminal.rs b/packages/desktop/src-tauri/src/commands/terminal.rs index bca4563c..0aa30146 100644 --- a/packages/desktop/src-tauri/src/commands/terminal.rs +++ b/packages/desktop/src-tauri/src/commands/terminal.rs @@ -176,9 +176,131 @@ pub async fn close_terminal( Ok(()) } +#[derive(Deserialize)] +pub struct RestartTerminalPayload { + pub session_id: String, + pub cols: u16, + pub rows: u16, + pub cwd: String, +} + +#[tauri::command] +pub async fn restart_terminal_session( + payload: RestartTerminalPayload, + state: State<'_, TerminalState>, + window: Window, +) -> Result { + { + let mut sessions = state.sessions.lock().unwrap(); + if let Some(session) = sessions.remove(&payload.session_id) { + if let Ok(mut child) = session.child.lock() { + let _ = child.kill(); + } + } + } + + let pty_system = NativePtySystem::default(); + let size = PtySize { + rows: payload.rows, + cols: payload.cols, + pixel_width: 0, + pixel_height: 0, + }; + + let working_dir = resolve_working_directory(Some(&payload.cwd))?; + let shell_path = resolve_shell(); + + let mut cmd = CommandBuilder::new(&shell_path); + if shell_accepts_login_flag(&shell_path) { + cmd.arg("-l"); + } + if let Some(cwd) = working_dir.to_str() { + cmd.cwd(cwd); + } + apply_terminal_environment(&mut cmd, &shell_path); + + let pair = pty_system.openpty(size).map_err(|e| e.to_string())?; + let child = pair + .slave + .spawn_command(cmd) + .map_err(|e| format!("Failed to spawn shell: {e}"))?; + drop(pair.slave); + + let reader = pair + .master + .try_clone_reader() + .map_err(|e| format!("Failed to clone PTY reader: {e}"))?; + let writer = Arc::new(Mutex::new( + pair.master + .take_writer() + .map_err(|e| format!("Failed to take PTY writer: {e}"))?, + )); + let master = pair.master; + let child = Arc::new(Mutex::new(child)); + + let session_id = uuid::Uuid::new_v4().to_string(); + state.sessions.lock().unwrap().insert( + session_id.clone(), + TerminalSession { + master, + writer: writer.clone(), + child: child.clone(), + }, + ); + + spawn_reader_thread(reader, window.clone(), session_id.clone()); + spawn_exit_watcher(child, window, state.sessions.clone(), session_id.clone()); + + Ok(CreateTerminalResponse { session_id }) +} + +#[derive(Deserialize)] +pub struct ForceKillPayload { + pub session_id: Option, + pub cwd: Option, +} + +#[tauri::command] +pub async fn force_kill_terminal( + payload: ForceKillPayload, + state: State<'_, TerminalState>, +) -> Result<(), String> { + let mut sessions = state.sessions.lock().unwrap(); + + if let Some(session_id) = payload.session_id { + // Kill by session_id + if let Some(session) = sessions.remove(&session_id) { + if let Ok(mut child) = session.child.lock() { + let _ = child.kill(); + } + } + } else if let Some(cwd) = payload.cwd { + let ids: Vec = sessions.keys().cloned().collect(); + for id in ids { + if let Some(session) = sessions.remove(&id) { + if let Ok(mut child) = session.child.lock() { + let _ = child.kill(); + } + } + } + let _ = cwd; + } else { + let ids: Vec = sessions.keys().cloned().collect(); + for id in ids { + if let Some(session) = sessions.remove(&id) { + if let Ok(mut child) = session.child.lock() { + let _ = child.kill(); + } + } + } + } + + Ok(()) +} + fn spawn_reader_thread(mut reader: Box, window: Window, session_id: String) { thread::spawn(move || { - let mut buffer = [0u8; 4096]; + let mut buffer = [0u8; 16384]; let event_name = format!("terminal://{}", session_id); loop { match reader.read(&mut buffer) { diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index 85bea8a3..875f1a19 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -38,7 +38,8 @@ use commands::permissions::{ use commands::notifications::desktop_notify; use commands::settings::{load_settings, restart_opencode, save_settings}; use commands::terminal::{ - close_terminal, create_terminal_session, resize_terminal, send_terminal_input, TerminalState, + close_terminal, create_terminal_session, force_kill_terminal, resize_terminal, + restart_terminal_session, send_terminal_input, TerminalState, }; use futures_util::StreamExt as FuturesStreamExt; use log::{error, info, warn}; @@ -412,6 +413,8 @@ fn main() { send_terminal_input, resize_terminal, close_terminal, + restart_terminal_session, + force_kill_terminal, fetch_desktop_logs, desktop_notify, ]) diff --git a/packages/desktop/src/api/terminal.ts b/packages/desktop/src/api/terminal.ts index 2a8b960c..e2f3e6c2 100644 --- a/packages/desktop/src/api/terminal.ts +++ b/packages/desktop/src/api/terminal.ts @@ -120,4 +120,42 @@ export const createDesktopTerminalAPI = (): TerminalAPI => ({ session_id: sessionId, }); }, + + async restartSession( + currentSessionId: string, + options: CreateTerminalOptions + ): Promise { + const cols = options.cols ?? 80; + const rows = options.rows ?? 24; + + const res = await safeTerminalInvoke<{ session_id: string }>( + 'restart_terminal_session', + { + payload: { + session_id: currentSessionId, + cols, + rows, + cwd: options.cwd ?? '', + }, + } + ); + + return { + sessionId: res.session_id, + cols, + rows, + }; + }, + + async forceKill(options: { + sessionId?: string; + cwd?: string; + }): Promise { + await safeTerminalInvoke('force_kill_terminal', { + payload: { + session_id: options.sessionId ?? null, + cwd: options.cwd ?? null, + }, + }); + }, }); diff --git a/packages/ui/src/components/views/TerminalView.tsx b/packages/ui/src/components/views/TerminalView.tsx index 9d3b9fa7..74f589fd 100644 --- a/packages/ui/src/components/views/TerminalView.tsx +++ b/packages/ui/src/components/views/TerminalView.tsx @@ -116,9 +116,9 @@ export const TerminalView: React.FC = () => { const clearBuffer = terminalStore.clearBuffer; const terminalState = React.useMemo(() => { - if (!currentSessionId) return undefined; - return terminalSessions.get(currentSessionId); - }, [terminalSessions, currentSessionId]); + if (!effectiveDirectory) return undefined; + return terminalSessions.get(effectiveDirectory); + }, [terminalSessions, effectiveDirectory]); const terminalSessionRef = terminalState?.terminalSessionId ?? null; const bufferChunks = terminalState?.bufferChunks ?? []; const bufferLength = terminalState?.bufferLength ?? 0; @@ -126,11 +126,12 @@ export const TerminalView: React.FC = () => { const terminalSessionId = terminalSessionRef; const [connectionError, setConnectionError] = React.useState(null); + const [isFatalError, setIsFatalError] = React.useState(false); const [activeModifier, setActiveModifier] = React.useState(null); + const [isRestarting, setIsRestarting] = React.useState(false); const streamCleanupRef = React.useRef<(() => void) | null>(null); const activeTerminalIdRef = React.useRef(null); - const sessionIdRef = React.useRef(currentSessionId ?? null); const terminalIdRef = React.useRef(terminalSessionId); const directoryRef = React.useRef(effectiveDirectory); const terminalControllerRef = React.useRef(null); @@ -138,10 +139,6 @@ export const TerminalView: React.FC = () => { const activeMainTab = useUIStore((state) => state.activeMainTab); const isTerminalActive = activeMainTab === 'terminal'; - React.useEffect(() => { - sessionIdRef.current = currentSessionId ?? null; - }, [currentSessionId]); - React.useEffect(() => { terminalIdRef.current = terminalSessionId; }, [terminalSessionId]); @@ -188,13 +185,14 @@ export const TerminalView: React.FC = () => { terminalId, { onEvent: (event: TerminalStreamEvent) => { - const sessionId = sessionIdRef.current; - if (!sessionId) return; + const directory = directoryRef.current; + if (!directory) return; switch (event.type) { case 'connected': { - setConnecting(sessionId, false); + setConnecting(directory, false); setConnectionError(null); + setIsFatalError(false); terminalControllerRef.current?.focus(); break; } @@ -202,11 +200,12 @@ export const TerminalView: React.FC = () => { const attempt = event.attempt ?? 0; const maxAttempts = event.maxAttempts ?? 3; setConnectionError(`Reconnecting (${attempt}/${maxAttempts})...`); + setIsFatalError(false); break; } case 'data': { if (event.data) { - appendToBuffer(sessionId, event.data); + appendToBuffer(directory, event.data); } break; } @@ -215,33 +214,35 @@ export const TerminalView: React.FC = () => { typeof event.exitCode === 'number' ? event.exitCode : null; const signal = typeof event.signal === 'number' ? event.signal : null; appendToBuffer( - sessionId, + directory, `\r\n[Process exited${ exitCode !== null ? ` with code ${exitCode}` : '' }${signal !== null ? ` (signal ${signal})` : ''}]\r\n` ); - clearTerminalSession(sessionId); - setConnecting(sessionId, false); + clearTerminalSession(directory); + setConnecting(directory, false); setConnectionError('Terminal session ended'); + setIsFatalError(false); disconnectStream(); break; } } }, onError: (error, fatal) => { - const sessionId = sessionIdRef.current; - if (!sessionId) return; + const directory = directoryRef.current; + if (!directory) return; const errorMsg = fatal ? `Connection failed: ${error.message}` : error.message || 'Terminal stream connection error'; setConnectionError(errorMsg); + setIsFatalError(!!fatal); if (fatal) { - setConnecting(sessionId, false); + setConnecting(directory, false); disconnectStream(); - removeTerminalSession(sessionId); + removeTerminalSession(directory); } }, }, @@ -259,11 +260,10 @@ export const TerminalView: React.FC = () => { React.useEffect(() => { let cancelled = false; - const sessionId = currentSessionId; - if (!sessionId || !effectiveDirectory) { + if (!effectiveDirectory) { setConnectionError( - sessionId + currentSessionId ? 'No working directory available for terminal.' : 'Select a session to open the terminal.' ); @@ -272,40 +272,27 @@ export const TerminalView: React.FC = () => { } const ensureSession = async () => { - if (!sessionIdRef.current || sessionIdRef.current !== sessionId) return; - const currentState = useTerminalStore.getState().sessions.get(sessionId); - - if ( - currentState?.terminalSessionId && - currentState.directory && - currentState.directory !== effectiveDirectory - ) { - disconnectStream(); - try { - if (currentState.terminalSessionId) { - await terminal.close(currentState.terminalSessionId); - } - } catch { /* ignored */ } - removeTerminalSession(sessionId); - return; - } + const directory = effectiveDirectory; + if (!directoryRef.current || directoryRef.current !== directory) return; + const currentState = useTerminalStore.getState().getTerminalSession(directory); let terminalId = currentState?.terminalSessionId ?? null; if (!terminalId) { setConnectionError(null); - setConnecting(sessionId, true); + setIsFatalError(false); + setConnecting(directory, true); try { const session = await terminal.createSession({ - cwd: effectiveDirectory, + cwd: directory, }); if (cancelled) { try { - await terminal.close(session.sessionId); + await terminal.close(session.sessionId); } catch { /* ignored */ } return; } - setTerminalSession(sessionId, session, effectiveDirectory); + setTerminalSession(directory, session); terminalId = session.sessionId; } catch (error) { if (!cancelled) { @@ -314,7 +301,8 @@ export const TerminalView: React.FC = () => { ? error.message : 'Failed to start terminal session' ); - setConnecting(sessionId, false); + setIsFatalError(true); + setConnecting(directory, false); } return; } @@ -345,22 +333,86 @@ export const TerminalView: React.FC = () => { terminal, ]); - const handleReconnect = React.useCallback(async () => { - if (!currentSessionId) return; + const handleRestart = React.useCallback(async () => { + if (!effectiveDirectory) return; + if (isRestarting) return; + + setIsRestarting(true); setConnectionError(null); + setIsFatalError(false); disconnectStream(); - const terminalId = terminalSessionId; - if (terminalId) { - try { - await terminal.close(terminalId); - } catch { /* ignored */ } + + const currentTerminalId = terminalIdRef.current; + + try { + if (terminal.restartSession && currentTerminalId) { + const newSession = await terminal.restartSession(currentTerminalId, { + cwd: effectiveDirectory, + }); + setTerminalSession(effectiveDirectory, newSession); + terminalIdRef.current = newSession.sessionId; + startStream(newSession.sessionId); + } else { + if (currentTerminalId) { + try { + await terminal.close(currentTerminalId); + } catch { /* ignored */ } + } + removeTerminalSession(effectiveDirectory); + } + } catch (error) { + setConnectionError( + error instanceof Error ? error.message : 'Failed to restart terminal' + ); + setIsFatalError(true); + } finally { + setIsRestarting(false); } - removeTerminalSession(currentSessionId); - }, [currentSessionId, disconnectStream, removeTerminalSession, terminal, terminalSessionId]); + }, [effectiveDirectory, isRestarting, disconnectStream, terminal, setTerminalSession, startStream, removeTerminalSession]); + + const handleHardRestart = React.useCallback(async () => { + if (!effectiveDirectory) return; + if (isRestarting) return; + + setIsRestarting(true); + setConnectionError(null); + setIsFatalError(false); + disconnectStream(); + + try { + if (terminal.forceKill) { + await terminal.forceKill({ cwd: effectiveDirectory }); + } + } catch { /* ignored */ } + + removeTerminalSession(effectiveDirectory); + clearBuffer(effectiveDirectory); + terminalControllerRef.current?.clear(); + + await new Promise(r => setTimeout(r, 100)); + + try { + setConnecting(effectiveDirectory, true); + const session = await terminal.createSession({ + cwd: effectiveDirectory, + }); + setTerminalSession(effectiveDirectory, session); + terminalIdRef.current = session.sessionId; + startStream(session.sessionId); + } catch (error) { + setConnectionError( + error instanceof Error ? error.message : 'Failed to create terminal' + ); + setIsFatalError(true); + setConnecting(effectiveDirectory, false); + } finally { + setIsRestarting(false); + } + }, [effectiveDirectory, isRestarting, disconnectStream, terminal, removeTerminalSession, clearBuffer, setConnecting, setTerminalSession, startStream]); const handleClear = React.useCallback(() => { - if (!currentSessionId) return; - clearBuffer(currentSessionId); + if (!effectiveDirectory) return; + clearBuffer(effectiveDirectory); terminalControllerRef.current?.clear(); terminalControllerRef.current?.focus(); @@ -370,7 +422,7 @@ export const TerminalView: React.FC = () => { setConnectionError(error instanceof Error ? error.message : 'Failed to refresh prompt'); }); } - }, [clearBuffer, currentSessionId, setConnectionError, terminal]); + }, [clearBuffer, effectiveDirectory, setConnectionError, terminal]); const handleViewportInput = React.useCallback( (data: string) => { @@ -544,11 +596,10 @@ export const TerminalView: React.FC = () => { const xtermTheme = React.useMemo(() => convertThemeToXterm(currentTheme), [currentTheme]); const terminalSessionKey = React.useMemo(() => { - const sessionPart = currentSessionId ?? 'none'; const directoryPart = effectiveDirectory ?? 'no-dir'; const terminalPart = terminalSessionId ?? 'pending'; - return `${sessionPart}::${directoryPart}::${terminalPart}`; - }, [currentSessionId, effectiveDirectory, terminalSessionId]); + return `${directoryPart}::${terminalPart}`; + }, [effectiveDirectory, terminalSessionId]); React.useEffect(() => { if (!isTerminalActive) { @@ -573,7 +624,7 @@ export const TerminalView: React.FC = () => { }; } fitOnce(); - }, [isTerminalActive, terminalSessionKey, currentSessionId, terminalSessionId]); + }, [isTerminalActive, terminalSessionKey, terminalSessionId]); const isReconnecting = connectionError?.includes('Reconnecting'); @@ -581,9 +632,9 @@ export const TerminalView: React.FC = () => { ? isReconnecting ? : - : terminalSessionId && !isConnecting + : terminalSessionId && !isConnecting && !isRestarting ? - : isConnecting + : isConnecting || isRestarting ? : ; @@ -600,7 +651,7 @@ export const TerminalView: React.FC = () => {

No working directory available for this session.

@@ -774,8 +826,21 @@ export const TerminalView: React.FC = () => { ) : null} {connectionError && ( -
- {connectionError} +
+ {connectionError} + {isFatalError && ( + + )}
)}
diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 9827f667..e383d193 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -64,12 +64,19 @@ export interface TerminalHandlers { onError?: (error: Error, fatal?: boolean) => void; } +export interface ForceKillOptions { + sessionId?: string; + cwd?: string; +} + export interface TerminalAPI { createSession(options: CreateTerminalOptions): Promise; connect(sessionId: string, handlers: TerminalHandlers, options?: TerminalStreamOptions): Subscription; sendInput(sessionId: string, input: string): Promise; resize(payload: ResizeTerminalPayload): Promise; close(sessionId: string): Promise; + restartSession?(currentSessionId: string, options: CreateTerminalOptions): Promise; + forceKill?(options: ForceKillOptions): Promise; } export interface GitStatusFile { diff --git a/packages/ui/src/lib/terminalApi.ts b/packages/ui/src/lib/terminalApi.ts index 08a59f68..87a37e11 100644 --- a/packages/ui/src/lib/terminalApi.ts +++ b/packages/ui/src/lib/terminalApi.ts @@ -231,3 +231,41 @@ export async function closeTerminal(sessionId: string): Promise { throw new Error(error.error || 'Failed to close terminal'); } } + +export async function restartTerminalSession( + currentSessionId: string, + options: { cwd: string; cols?: number; rows?: number } +): Promise { + const response = await fetch(`/api/terminal/${currentSessionId}/restart`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + cwd: options.cwd, + cols: options.cols ?? 80, + rows: options.rows ?? 24, + }), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Failed to restart terminal' })); + throw new Error(error.error || 'Failed to restart terminal'); + } + + return response.json(); +} + +export async function forceKillTerminal(options: { + sessionId?: string; + cwd?: string; +}): Promise { + const response = await fetch('/api/terminal/force-kill', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(options), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Failed to force kill terminal' })); + throw new Error(error.error || 'Failed to force kill terminal'); + } +} diff --git a/packages/ui/src/stores/useTerminalStore.ts b/packages/ui/src/stores/useTerminalStore.ts index 126c83ee..dbbd1509 100644 --- a/packages/ui/src/stores/useTerminalStore.ts +++ b/packages/ui/src/stores/useTerminalStore.ts @@ -7,9 +7,8 @@ export interface TerminalChunk { } interface TerminalSessionState { - sessionId: string; - terminalSessionId: string | null; directory: string; + terminalSessionId: string | null; isConnecting: boolean; buffer: string; bufferChunks: TerminalChunk[]; @@ -21,22 +20,29 @@ interface TerminalStore { sessions: Map; nextChunkId: number; - getTerminalSession: (sessionId: string) => TerminalSessionState | undefined; - setTerminalSession: (sessionId: string, terminalSession: TerminalSession, directory: string) => void; - setConnecting: (sessionId: string, isConnecting: boolean) => void; - appendToBuffer: (sessionId: string, chunk: string) => void; - clearTerminalSession: (sessionId: string) => void; - clearBuffer: (sessionId: string) => void; - removeTerminalSession: (sessionId: string) => void; + getTerminalSession: (directory: string) => TerminalSessionState | undefined; + setTerminalSession: (directory: string, terminalSession: TerminalSession) => void; + setConnecting: (directory: string, isConnecting: boolean) => void; + appendToBuffer: (directory: string, chunk: string) => void; + clearTerminalSession: (directory: string) => void; + clearBuffer: (directory: string) => void; + removeTerminalSession: (directory: string) => void; clearAllTerminalSessions: () => void; } -const TERMINAL_BUFFER_LIMIT = 60_000; +const TERMINAL_BUFFER_LIMIT = 256_000; -const createEmptySessionState = (sessionId: string): TerminalSessionState => ({ - sessionId, +function normalizeDirectory(dir: string): string { + let normalized = dir.trim(); + while (normalized.length > 1 && normalized.endsWith('/')) { + normalized = normalized.slice(0, -1); + } + return normalized; +} + +const createEmptySessionState = (directory: string): TerminalSessionState => ({ + directory, terminalSessionId: null, - directory: '', isConnecting: false, buffer: '', bufferChunks: [], @@ -48,27 +54,28 @@ export const useTerminalStore = create((set, get) => ({ sessions: new Map(), nextChunkId: 1, - getTerminalSession: (sessionId: string) => { - return get().sessions.get(sessionId); + getTerminalSession: (directory: string) => { + const key = normalizeDirectory(directory); + return get().sessions.get(key); }, - setTerminalSession: (sessionId: string, terminalSession: TerminalSession, directory: string) => { + setTerminalSession: (directory: string, terminalSession: TerminalSession) => { + const key = normalizeDirectory(directory); set((state) => { const newSessions = new Map(state.sessions); - const existing = newSessions.get(sessionId); + const existing = newSessions.get(key); const shouldResetBuffer = !existing || - existing.terminalSessionId !== terminalSession.sessionId || - existing.directory !== directory; + existing.terminalSessionId !== terminalSession.sessionId; const baseState = shouldResetBuffer - ? createEmptySessionState(sessionId) - : existing ?? createEmptySessionState(sessionId); + ? createEmptySessionState(key) + : existing ?? createEmptySessionState(key); - newSessions.set(sessionId, { + newSessions.set(key, { ...baseState, terminalSessionId: terminalSession.sessionId, - directory, + directory: key, isConnecting: false, updatedAt: Date.now(), }); @@ -77,11 +84,12 @@ export const useTerminalStore = create((set, get) => ({ }); }, - setConnecting: (sessionId: string, isConnecting: boolean) => { + setConnecting: (directory: string, isConnecting: boolean) => { + const key = normalizeDirectory(directory); set((state) => { const newSessions = new Map(state.sessions); - const existing = newSessions.get(sessionId) ?? createEmptySessionState(sessionId); - newSessions.set(sessionId, { + const existing = newSessions.get(key) ?? createEmptySessionState(key); + newSessions.set(key, { ...existing, isConnecting, updatedAt: Date.now(), @@ -90,14 +98,15 @@ export const useTerminalStore = create((set, get) => ({ }); }, - appendToBuffer: (sessionId: string, chunk: string) => { + appendToBuffer: (directory: string, chunk: string) => { if (!chunk) { return; } + const key = normalizeDirectory(directory); set((state) => { const newSessions = new Map(state.sessions); - const existing = newSessions.get(sessionId) ?? createEmptySessionState(sessionId); + const existing = newSessions.get(key) ?? createEmptySessionState(key); const chunkId = state.nextChunkId; const chunkEntry: TerminalChunk = { id: chunkId, data: chunk }; @@ -115,7 +124,7 @@ export const useTerminalStore = create((set, get) => ({ const buffer = bufferChunks.map((entry) => entry.data).join(''); - newSessions.set(sessionId, { + newSessions.set(key, { ...existing, buffer, bufferChunks, @@ -127,12 +136,13 @@ export const useTerminalStore = create((set, get) => ({ }); }, - clearTerminalSession: (sessionId: string) => { + clearTerminalSession: (directory: string) => { + const key = normalizeDirectory(directory); set((state) => { const newSessions = new Map(state.sessions); - const existing = newSessions.get(sessionId); + const existing = newSessions.get(key); if (existing) { - newSessions.set(sessionId, { + newSessions.set(key, { ...existing, terminalSessionId: null, isConnecting: false, @@ -143,14 +153,15 @@ export const useTerminalStore = create((set, get) => ({ }); }, - clearBuffer: (sessionId: string) => { + clearBuffer: (directory: string) => { + const key = normalizeDirectory(directory); set((state) => { const newSessions = new Map(state.sessions); - const existing = newSessions.get(sessionId); + const existing = newSessions.get(key); if (!existing) { return state; } - newSessions.set(sessionId, { + newSessions.set(key, { ...existing, buffer: '', bufferChunks: [], @@ -161,10 +172,11 @@ export const useTerminalStore = create((set, get) => ({ }); }, - removeTerminalSession: (sessionId: string) => { + removeTerminalSession: (directory: string) => { + const key = normalizeDirectory(directory); set((state) => { const newSessions = new Map(state.sessions); - newSessions.delete(sessionId); + newSessions.delete(key); return { sessions: newSessions }; }); }, diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 48a24d7a..80035c63 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -2929,6 +2929,111 @@ async function main(options = {}) { } }); + app.post('/api/terminal/:sessionId/restart', async (req, res) => { + const { sessionId } = req.params; + const { cwd, cols, rows } = req.body; + + if (!cwd) { + return res.status(400).json({ error: 'cwd is required' }); + } + + const existingSession = terminalSessions.get(sessionId); + if (existingSession) { + try { + existingSession.ptyProcess.kill(); + } catch (error) { + } + terminalSessions.delete(sessionId); + } + + try { + if (!fs.existsSync(cwd)) { + return res.status(400).json({ error: 'Invalid working directory' }); + } + + const pty = await getPtyLib(); + const shell = process.env.SHELL || (process.platform === 'win32' ? 'powershell.exe' : '/bin/zsh'); + + const newSessionId = Math.random().toString(36).substring(2, 15) + + Math.random().toString(36).substring(2, 15); + + const envPath = buildAugmentedPath(); + const resolvedEnv = { ...process.env, PATH: envPath }; + + const ptyProcess = pty.spawn(shell, [], { + name: 'xterm-256color', + cols: cols || 80, + rows: rows || 24, + cwd: cwd, + env: { + ...resolvedEnv, + TERM: 'xterm-256color', + COLORTERM: 'truecolor', + }, + }); + + const session = { + ptyProcess, + cwd, + lastActivity: Date.now(), + clients: new Set(), + }; + + terminalSessions.set(newSessionId, session); + + ptyProcess.onExit(({ exitCode, signal }) => { + console.log(`Terminal session ${newSessionId} exited with code ${exitCode}, signal ${signal}`); + terminalSessions.delete(newSessionId); + }); + + console.log(`Restarted terminal session: ${sessionId} -> ${newSessionId} in ${cwd}`); + res.json({ sessionId: newSessionId, cols: cols || 80, rows: rows || 24 }); + } catch (error) { + console.error('Failed to restart terminal session:', error); + res.status(500).json({ error: error.message || 'Failed to restart terminal session' }); + } + }); + + app.post('/api/terminal/force-kill', (req, res) => { + const { sessionId, cwd } = req.body; + let killedCount = 0; + + if (sessionId) { + const session = terminalSessions.get(sessionId); + if (session) { + try { + session.ptyProcess.kill(); + } catch (error) { + } + terminalSessions.delete(sessionId); + killedCount++; + } + } else if (cwd) { + for (const [id, session] of terminalSessions) { + if (session.cwd === cwd) { + try { + session.ptyProcess.kill(); + } catch (error) { + } + terminalSessions.delete(id); + killedCount++; + } + } + } else { + for (const [id, session] of terminalSessions) { + try { + session.ptyProcess.kill(); + } catch (error) { + } + terminalSessions.delete(id); + killedCount++; + } + } + + console.log(`Force killed ${killedCount} terminal session(s)`); + res.json({ success: true, killedCount }); + }); + try { if (ENV_CONFIGURED_OPENCODE_PORT) { console.log(`Using OpenCode port from environment: ${ENV_CONFIGURED_OPENCODE_PORT}`); diff --git a/packages/web/src/api/terminal.ts b/packages/web/src/api/terminal.ts index 86f5cc48..10b3f52b 100644 --- a/packages/web/src/api/terminal.ts +++ b/packages/web/src/api/terminal.ts @@ -4,6 +4,8 @@ import { resizeTerminal, sendTerminalInput, closeTerminal, + restartTerminalSession, + forceKillTerminal, } from '@openchamber/ui/lib/terminalApi'; import type { TerminalAPI, @@ -12,6 +14,7 @@ import type { CreateTerminalOptions, ResizeTerminalPayload, TerminalSession, + ForceKillOptions, } from '@openchamber/ui/lib/api/types'; const getRetryPolicy = (options?: TerminalStreamOptions) => { @@ -53,4 +56,19 @@ export const createWebTerminalAPI = (): TerminalAPI => ({ async close(sessionId: string): Promise { await closeTerminal(sessionId); }, + + async restartSession( + currentSessionId: string, + options: CreateTerminalOptions + ): Promise { + return restartTerminalSession(currentSessionId, { + cwd: options.cwd ?? '', + cols: options.cols, + rows: options.rows, + }); + }, + + async forceKill(options: ForceKillOptions): Promise { + await forceKillTerminal(options); + }, });