feat(terminal): add restart and force kill session functionality

This commit is contained in:
Bohdan Triapitsyn
2025-12-10 00:25:02 +02:00
parent c4cb2d5b92
commit 610ccf4c62
9 changed files with 515 additions and 107 deletions
@@ -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<CreateTerminalResponse, String> {
{
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<String>,
pub cwd: Option<String>,
}
#[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<String> = 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<String> = 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<dyn Read + Send>, 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) {
+4 -1
View File
@@ -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,
])
+38
View File
@@ -120,4 +120,42 @@ export const createDesktopTerminalAPI = (): TerminalAPI => ({
session_id: sessionId,
});
},
async restartSession(
currentSessionId: string,
options: CreateTerminalOptions
): Promise<TerminalSession> {
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<void> {
await safeTerminalInvoke('force_kill_terminal', {
payload: {
session_id: options.sessionId ?? null,
cwd: options.cwd ?? null,
},
});
},
});
+133 -68
View File
@@ -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<string | null>(null);
const [isFatalError, setIsFatalError] = React.useState(false);
const [activeModifier, setActiveModifier] = React.useState<Modifier | null>(null);
const [isRestarting, setIsRestarting] = React.useState(false);
const streamCleanupRef = React.useRef<(() => void) | null>(null);
const activeTerminalIdRef = React.useRef<string | null>(null);
const sessionIdRef = React.useRef<string | null>(currentSessionId ?? null);
const terminalIdRef = React.useRef<string | null>(terminalSessionId);
const directoryRef = React.useRef<string | null>(effectiveDirectory);
const terminalControllerRef = React.useRef<TerminalController | null>(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
? <RiAlertLine size={20} className="text-amber-400" />
: <RiCloseLine size={20} className="text-destructive" />
: terminalSessionId && !isConnecting
: terminalSessionId && !isConnecting && !isRestarting
? <RiCheckboxCircleLine size={20} className="text-emerald-400" />
: isConnecting
: isConnecting || isRestarting
? <RiCircleLine size={20} className="text-amber-400 animate-pulse" />
: <RiCircleLine size={20} className="text-muted-foreground" />;
@@ -600,7 +651,7 @@ export const TerminalView: React.FC = () => {
<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={handleReconnect}
onClick={handleRestart}
className="rounded-lg bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
>
Retry
@@ -609,7 +660,7 @@ export const TerminalView: React.FC = () => {
);
}
const quickKeysDisabled = !terminalSessionId || isConnecting;
const quickKeysDisabled = !terminalSessionId || isConnecting || isRestarting;
return (
<div className="flex h-full flex-col overflow-hidden bg-background">
@@ -636,11 +687,12 @@ export const TerminalView: React.FC = () => {
size="sm"
variant="default"
className="h-7 px-2 py-0"
onClick={handleReconnect}
onClick={handleRestart}
disabled={isRestarting}
title="Restart terminal session"
type="button"
>
<RiRestartLine size={16} className={cn(isConnecting && 'animate-spin')} />
<RiRestartLine size={16} className={cn((isConnecting || isRestarting) && 'animate-spin')} />
Restart
</Button>
</div>
@@ -774,8 +826,21 @@ export const TerminalView: React.FC = () => {
) : null}
</div>
{connectionError && (
<div className="absolute inset-x-0 bottom-0 bg-destructive/90 px-3 py-2 text-xs text-destructive-foreground">
{connectionError}
<div className="absolute inset-x-0 bottom-0 bg-destructive/90 px-3 py-2 text-xs text-destructive-foreground flex items-center justify-between gap-2">
<span>{connectionError}</span>
{isFatalError && (
<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>
+7
View File
@@ -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<TerminalSession>;
connect(sessionId: string, handlers: TerminalHandlers, options?: TerminalStreamOptions): Subscription;
sendInput(sessionId: string, input: string): Promise<void>;
resize(payload: ResizeTerminalPayload): Promise<void>;
close(sessionId: string): Promise<void>;
restartSession?(currentSessionId: string, options: CreateTerminalOptions): Promise<TerminalSession>;
forceKill?(options: ForceKillOptions): Promise<void>;
}
export interface GitStatusFile {
+38
View File
@@ -231,3 +231,41 @@ export async function closeTerminal(sessionId: string): Promise<void> {
throw new Error(error.error || 'Failed to close terminal');
}
}
export async function restartTerminalSession(
currentSessionId: string,
options: { cwd: string; cols?: number; rows?: number }
): Promise<TerminalSession> {
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<void> {
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');
}
}
+49 -37
View File
@@ -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<string, TerminalSessionState>;
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<TerminalStore>((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<TerminalStore>((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<TerminalStore>((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<TerminalStore>((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<TerminalStore>((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<TerminalStore>((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<TerminalStore>((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 };
});
},
+105
View File
@@ -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}`);
+18
View File
@@ -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<void> {
await closeTerminal(sessionId);
},
async restartSession(
currentSessionId: string,
options: CreateTerminalOptions
): Promise<TerminalSession> {
return restartTerminalSession(currentSessionId, {
cwd: options.cwd ?? '',
cols: options.cols,
rows: options.rows,
});
},
async forceKill(options: ForceKillOptions): Promise<void> {
await forceKillTerminal(options);
},
});