Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src-tauri/src/backend/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,16 @@ pub(crate) struct TerminalOutput {
pub(crate) data: String,
}

#[derive(Debug, Serialize, Clone)]
pub(crate) struct TerminalExit {
#[serde(rename = "workspaceId")]
pub(crate) workspace_id: String,
#[serde(rename = "terminalId")]
pub(crate) terminal_id: String,
}

pub(crate) trait EventSink: Clone + Send + Sync + 'static {
fn emit_app_server_event(&self, event: AppServerEvent);
fn emit_terminal_output(&self, event: TerminalOutput);
fn emit_terminal_exit(&self, event: TerminalExit);
}
12 changes: 11 additions & 1 deletion src-tauri/src/bin/codex_monitor_daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ use tokio::sync::{broadcast, mpsc, Mutex};
use backend::app_server::{
spawn_workspace_session, WorkspaceSession,
};
use backend::events::{AppServerEvent, EventSink, TerminalOutput};
use backend::events::{AppServerEvent, EventSink, TerminalExit, TerminalOutput};
use storage::{read_settings, read_workspaces};
use shared::{codex_core, files_core, git_core, settings_core, workspaces_core, worktree_core};
use shared::codex_core::CodexLoginCancelState;
Expand Down Expand Up @@ -109,6 +109,8 @@ enum DaemonEvent {
AppServer(AppServerEvent),
#[allow(dead_code)]
TerminalOutput(TerminalOutput),
#[allow(dead_code)]
TerminalExit(TerminalExit),
}

impl EventSink for DaemonEventSink {
Expand All @@ -119,6 +121,10 @@ impl EventSink for DaemonEventSink {
fn emit_terminal_output(&self, event: TerminalOutput) {
let _ = self.tx.send(DaemonEvent::TerminalOutput(event));
}

fn emit_terminal_exit(&self, event: TerminalExit) {
let _ = self.tx.send(DaemonEvent::TerminalExit(event));
}
}

struct DaemonConfig {
Expand Down Expand Up @@ -849,6 +855,10 @@ fn build_event_notification(event: DaemonEvent) -> Option<String> {
"method": "terminal-output",
"params": payload,
}),
DaemonEvent::TerminalExit(payload) => json!({
"method": "terminal-exit",
"params": payload,
}),
};
serde_json::to_string(&payload).ok()
}
Expand Down
6 changes: 5 additions & 1 deletion src-tauri/src/event_sink.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use tauri::{AppHandle, Emitter};

use crate::backend::events::{AppServerEvent, EventSink, TerminalOutput};
use crate::backend::events::{AppServerEvent, EventSink, TerminalExit, TerminalOutput};

#[derive(Clone)]
pub(crate) struct TauriEventSink {
Expand All @@ -21,4 +21,8 @@ impl EventSink for TauriEventSink {
fn emit_terminal_output(&self, event: TerminalOutput) {
let _ = self.app.emit("terminal-output", event);
}

fn emit_terminal_exit(&self, event: TerminalExit) {
let _ = self.app.emit("terminal-exit", event);
}
}
3 changes: 3 additions & 0 deletions src-tauri/src/remote_backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,9 @@ async fn read_loop(
"terminal-output" => {
let _ = app.emit("terminal-output", params);
}
"terminal-exit" => {
let _ = app.emit("terminal-exit", params);
}
_ => {}
}
}
Expand Down
105 changes: 78 additions & 27 deletions src-tauri/src/terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use serde::Serialize;
use tauri::{AppHandle, State};
use tokio::sync::Mutex;

use crate::backend::events::{EventSink, TerminalOutput};
use crate::backend::events::{EventSink, TerminalExit, TerminalOutput};
use crate::event_sink::TauriEventSink;
use crate::state::AppState;

Expand All @@ -27,6 +27,28 @@ fn terminal_key(workspace_id: &str, terminal_id: &str) -> String {
format!("{workspace_id}:{terminal_id}")
}

fn is_terminal_closed_error(message: &str) -> bool {
let lower = message.to_ascii_lowercase();
lower.contains("broken pipe")
|| lower.contains("input/output error")
|| lower.contains("os error 5")
|| lower.contains("eio")
|| lower.contains("io error")
|| lower.contains("not connected")
|| lower.contains("closed")
}

async fn get_terminal_session(
state: &State<'_, AppState>,
key: &str,
) -> Result<Arc<TerminalSession>, String> {
let sessions = state.terminal_sessions.lock().await;
sessions
.get(key)
.cloned()
.ok_or_else(|| "Terminal session not found".to_string())
}

fn shell_path() -> String {
std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string())
}
Expand Down Expand Up @@ -102,6 +124,10 @@ fn spawn_terminal_reader(
Err(_) => break,
}
}
event_sink.emit_terminal_exit(TerminalExit {
workspace_id,
terminal_id,
});
});
}

Expand Down Expand Up @@ -183,11 +209,14 @@ pub(crate) async fn terminal_open(
{
let mut sessions = state.terminal_sessions.lock().await;
if let Some(existing) = sessions.get(&key) {
let mut child = session.child.lock().await;
let _ = child.kill();
return Ok(TerminalSessionInfo {
id: existing.id.clone(),
});
let id = existing.id.clone();
drop(sessions);
let _ = tokio::task::spawn_blocking(move || {
let mut child = session.child.blocking_lock();
let _ = child.kill();
})
.await;
return Ok(TerminalSessionInfo { id });
}
sessions.insert(key, session);
}
Expand All @@ -207,17 +236,27 @@ pub(crate) async fn terminal_write(
state: State<'_, AppState>,
) -> Result<(), String> {
let key = terminal_key(&workspace_id, &terminal_id);
let sessions = state.terminal_sessions.lock().await;
let session = sessions
.get(&key)
.ok_or_else(|| "Terminal session not found".to_string())?;
let mut writer = session.writer.lock().await;
writer
.write_all(data.as_bytes())
.map_err(|e| format!("Failed to write to pty: {e}"))?;
writer
.flush()
.map_err(|e| format!("Failed to flush pty: {e}"))?;
let session = get_terminal_session(&state, &key).await?;
let write_result = tokio::task::spawn_blocking(move || {
let mut writer = session.writer.blocking_lock();
writer
.write_all(data.as_bytes())
.map_err(|e| format!("Failed to write to pty: {e}"))?;
writer
.flush()
.map_err(|e| format!("Failed to flush pty: {e}"))?;
Ok::<(), String>(())
})
.await
.map_err(|e| format!("Terminal write task failed: {e}"))?;

if let Err(err) = write_result {
if is_terminal_closed_error(&err) {
let mut sessions = state.terminal_sessions.lock().await;
sessions.remove(&key);
}
return Err(err);
}
Ok(())
}

Expand All @@ -230,20 +269,28 @@ pub(crate) async fn terminal_resize(
state: State<'_, AppState>,
) -> Result<(), String> {
let key = terminal_key(&workspace_id, &terminal_id);
let sessions = state.terminal_sessions.lock().await;
let session = sessions
.get(&key)
.ok_or_else(|| "Terminal session not found".to_string())?;
let session = get_terminal_session(&state, &key).await?;
let size = PtySize {
rows: rows.max(2),
cols: cols.max(2),
pixel_width: 0,
pixel_height: 0,
};
let master = session.master.lock().await;
master
.resize(size)
.map_err(|e| format!("Failed to resize pty: {e}"))?;
let resize_result = tokio::task::spawn_blocking(move || {
let master = session.master.blocking_lock();
master
.resize(size)
.map_err(|e| format!("Failed to resize pty: {e}"))
})
.await
.map_err(|e| format!("Terminal resize task failed: {e}"))?;
if let Err(err) = resize_result {
if is_terminal_closed_error(&err) {
let mut sessions = state.terminal_sessions.lock().await;
sessions.remove(&key);
}
return Err(err);
}
Ok(())
}

Expand All @@ -258,7 +305,11 @@ pub(crate) async fn terminal_close(
let session = sessions
.remove(&key)
.ok_or_else(|| "Terminal session not found".to_string())?;
let mut child = session.child.lock().await;
let _ = child.kill();
drop(sessions);
let _ = tokio::task::spawn_blocking(move || {
let mut child = session.child.blocking_lock();
let _ = child.kill();
})
.await;
Ok(())
}
24 changes: 20 additions & 4 deletions src/features/terminal/hooks/useTerminalController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,24 @@ export function useTerminalController({
const cleanupTerminalRef = useRef<((workspaceId: string, terminalId: string) => void) | null>(
null,
);
const shouldIgnoreTerminalCloseError = useCallback((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
return message.includes("Terminal session not found");
}, []);

const handleTerminalClose = useCallback(
async (workspaceId: string, terminalId: string) => {
cleanupTerminalRef.current?.(workspaceId, terminalId);
try {
await closeTerminalSession(workspaceId, terminalId);
} catch (error) {
if (shouldIgnoreTerminalCloseError(error)) {
return;
}
onDebug(buildErrorDebugEntry("terminal close error", error));
}
},
[onDebug],
[onDebug, shouldIgnoreTerminalCloseError],
);

const {
Expand All @@ -60,6 +67,16 @@ export function useTerminalController({
activeTerminalId,
isVisible: terminalOpen,
onDebug,
onSessionExit: (workspaceId, terminalId) => {
const shouldClosePanel =
workspaceId === activeWorkspaceId &&
terminalTabs.length === 1 &&
terminalTabs[0]?.id === terminalId;
closeTerminal(workspaceId, terminalId);
if (shouldClosePanel) {
onCloseTerminalPanel?.();
}
},
});

useEffect(() => {
Expand Down Expand Up @@ -104,14 +121,13 @@ export function useTerminalController({
try {
await closeTerminalSession(workspaceId, terminalId);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!message.includes("Terminal session not found")) {
if (!shouldIgnoreTerminalCloseError(error)) {
onDebug(buildErrorDebugEntry("terminal close error", error));
throw error;
}
}
},
[onDebug],
[onDebug, shouldIgnoreTerminalCloseError],
);

return {
Expand Down
37 changes: 35 additions & 2 deletions src/features/terminal/hooks/useTerminalSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ import { FitAddon } from "@xterm/addon-fit";
import "@xterm/xterm/css/xterm.css";
import type { DebugEntry, TerminalStatus, WorkspaceInfo } from "../../../types";
import { buildErrorDebugEntry } from "../../../utils/debugEntries";
import { subscribeTerminalOutput, type TerminalOutputEvent } from "../../../services/events";
import {
subscribeTerminalExit,
subscribeTerminalOutput,
type TerminalExitEvent,
type TerminalOutputEvent,
} from "../../../services/events";
import {
openTerminalSession,
resizeTerminalSession,
Expand All @@ -19,6 +24,7 @@ type UseTerminalSessionOptions = {
activeTerminalId: string | null;
isVisible: boolean;
onDebug?: (entry: DebugEntry) => void;
onSessionExit?: (workspaceId: string, terminalId: string) => void;
};

type TerminalAppearance = {
Expand Down Expand Up @@ -50,7 +56,16 @@ function appendBuffer(existing: string | undefined, data: string): string {

function shouldIgnoreTerminalError(error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return message.includes("Terminal session not found");
const lower = message.toLowerCase();
return (
lower.includes("terminal session not found") ||
lower.includes("broken pipe") ||
lower.includes("input/output error") ||
lower.includes("os error 5") ||
lower.includes("eio") ||
lower.includes("not connected") ||
lower.includes("closed")
);
}

function getTerminalAppearance(container: HTMLElement | null): TerminalAppearance {
Expand Down Expand Up @@ -100,6 +115,7 @@ export function useTerminalSession({
activeTerminalId,
isVisible,
onDebug,
onSessionExit,
}: UseTerminalSessionOptions): TerminalSessionState {
const containerRef = useRef<HTMLDivElement | null>(null);
const terminalRef = useRef<Terminal | null>(null);
Expand Down Expand Up @@ -197,6 +213,23 @@ export function useTerminalSession({
};
}, [onDebug, writeToTerminal]);

useEffect(() => {
const unlisten = subscribeTerminalExit(
(payload: TerminalExitEvent) => {
cleanupTerminalSession(payload.workspaceId, payload.terminalId);
onSessionExit?.(payload.workspaceId, payload.terminalId);
},
{
onError: (error) => {
onDebug?.(buildErrorDebugEntry("terminal exit listen error", error));
},
},
);
return () => {
unlisten();
};
}, [cleanupTerminalSession, onDebug, onSessionExit]);

useEffect(() => {
if (!isVisible) {
inputDisposableRef.current?.dispose();
Expand Down
Loading