From e90275523afb60b487e361d28a5282e46ee1cfb8 Mon Sep 17 00:00:00 2001 From: Rasmus Widing Date: Thu, 19 Mar 2026 08:53:38 +0100 Subject: [PATCH 1/2] feat(ui): add terminal reconnection on daemon disconnect When the daemon reader connection drops, the terminal view now shows "Press R to reconnect" instead of a dead-end error. The Reconnect action spawns an async task that calls connect_for_attach(), builds a new Terminal::from_daemon(), and replaces the terminal + event task atomically. Handles edge cases: daemon gone, session destroyed, multiple rapid presses, local terminals unaffected. --- crates/kild-ui/src/terminal/state.rs | 39 ++++ crates/kild-ui/src/terminal/terminal_view.rs | 179 ++++++++++++++++++- 2 files changed, 213 insertions(+), 5 deletions(-) diff --git a/crates/kild-ui/src/terminal/state.rs b/crates/kild-ui/src/terminal/state.rs index a99ec92a..d33d8159 100644 --- a/crates/kild-ui/src/terminal/state.rs +++ b/crates/kild-ui/src/terminal/state.rs @@ -18,6 +18,17 @@ use portable_pty::{Child, CommandBuilder, MasterPty, PtySize, native_pty_system} use super::errors::TerminalError; use crate::daemon_client::{self, DaemonConnection}; +/// State of a reconnection attempt in a daemon terminal. +#[derive(Debug, Clone, PartialEq)] +pub enum ReconnectState { + /// No reconnection in progress — error banner may be shown. + Idle, + /// Reconnect attempt underway. + Connecting, + /// Reconnect failed with this message. + Failed(String), +} + /// Resolve the working directory for a new terminal. /// /// - `Some(path)` that exists and is a directory → returns `Some(path)` @@ -221,6 +232,10 @@ pub struct Terminal { /// Last-known terminal mode flags. Updated by sync() in render(). /// Used by on_key_down to read APP_CURSOR without re-acquiring the lock. last_mode: TermMode, + /// Daemon session ID for reconnection. `None` for local terminals. + daemon_session_id: Option, + /// Reconnection state for daemon terminals. Shared with the view layer. + reconnect_state: Arc>, } impl Terminal { @@ -376,6 +391,8 @@ impl Terminal { exited: Arc::new(AtomicBool::new(false)), current_size, last_mode: initial_mode, + daemon_session_id: None, + reconnect_state: Arc::new(Mutex::new(ReconnectState::Idle)), }) } @@ -604,6 +621,8 @@ impl Terminal { exited, current_size, last_mode: initial_mode, + daemon_session_id: Some(session_id), + reconnect_state: Arc::new(Mutex::new(ReconnectState::Idle)), }) } @@ -797,6 +816,26 @@ impl Terminal { pub fn last_mode(&self) -> TermMode { self.last_mode } + + /// Daemon session ID, if this terminal is backed by a daemon session. + pub fn daemon_session_id(&self) -> Option<&str> { + self.daemon_session_id.as_deref() + } + + /// Current reconnection state (cloned snapshot). + pub fn reconnect_state(&self) -> ReconnectState { + self.reconnect_state + .lock() + .map(|s| s.clone()) + .unwrap_or(ReconnectState::Idle) + } + + /// Update the reconnection state. + pub fn set_reconnect_state(&self, state: ReconnectState) { + if let Ok(mut s) = self.reconnect_state.lock() { + *s = state; + } + } } /// Resize implementation, determined by terminal mode (local PTY or daemon IPC). diff --git a/crates/kild-ui/src/terminal/terminal_view.rs b/crates/kild-ui/src/terminal/terminal_view.rs index 5537d2b4..2ab7a046 100644 --- a/crates/kild-ui/src/terminal/terminal_view.rs +++ b/crates/kild-ui/src/terminal/terminal_view.rs @@ -4,12 +4,14 @@ use gpui::{ }; use super::blink::BlinkManager; +use super::state::ReconnectState; use super::terminal_element::scroll_delta_lines; use super::input; use super::state::Terminal; use super::terminal_element::{MouseState, TerminalElement}; use super::types::TerminalContent; +use crate::daemon_client; use crate::theme; use crate::views::main_view::keybindings::UiKeybindings; @@ -34,6 +36,8 @@ pub struct TerminalView { mouse_state: MouseState, /// Parsed keybindings for routing keys between PTY and MainView. keybindings: UiKeybindings, + /// In-flight reconnection task. Stored to prevent cancellation. + _reconnect_task: Option>, } impl TerminalView { @@ -88,6 +92,7 @@ impl TerminalView { cmd_held: false, }, keybindings, + _reconnect_task: None, } } @@ -138,6 +143,7 @@ impl TerminalView { cmd_held: false, }, keybindings, + _reconnect_task: None, } } @@ -199,12 +205,144 @@ impl TerminalView { } } + /// Attempt to reconnect a disconnected daemon terminal. + /// + /// Spawns an async task that re-attaches to the daemon session, builds a + /// fresh `Terminal::from_daemon()`, and swaps it in along with a new batch + /// loop task. No-op for local terminals or if a reconnect is already in + /// progress. + fn try_reconnect(&mut self, cx: &mut Context) { + let session_id = match self.terminal.daemon_session_id() { + Some(id) => id.to_string(), + None => return, + }; + + if self.terminal.reconnect_state() == ReconnectState::Connecting { + return; + } + + tracing::info!( + event = "ui.terminal.reconnect_started", + session_id = session_id + ); + self.terminal.set_reconnect_state(ReconnectState::Connecting); + cx.notify(); + + let task = cx.spawn(async move |this, cx: &mut gpui::AsyncApp| { + let sid = session_id.clone(); + let conn_result = cx + .background_executor() + .spawn(async move { + daemon_client::connect_for_attach(&sid, 24, 80).await + }) + .await; + + match conn_result { + Err(e) => { + tracing::error!( + event = "ui.terminal.reconnect_failed", + session_id = session_id, + error = %e, + ); + let msg = e.to_string(); + let _ = this.update(cx, |view, cx| { + view.terminal.set_reconnect_state(ReconnectState::Failed(msg)); + view._reconnect_task = None; + cx.notify(); + }); + } + Ok(conn) => { + let _ = this.update(cx, |view, cx| { + match Terminal::from_daemon(session_id.clone(), conn, cx) { + Ok(mut new_terminal) => { + let (byte_rx, event_rx) = match new_terminal.take_channels() { + Ok(ch) => ch, + Err(e) => { + tracing::error!( + event = "ui.terminal.reconnect_channels_failed", + error = %e, + ); + view.terminal.set_reconnect_state( + ReconnectState::Failed(e.to_string()), + ); + view._reconnect_task = None; + cx.notify(); + return; + } + }; + + let term = new_terminal.term().clone(); + let pty_writer = new_terminal.pty_writer().clone(); + let error_state = new_terminal.error_state().clone(); + let exited = new_terminal.exited_flag().clone(); + let executor = cx.background_executor().clone(); + + let event_task = cx.spawn( + async move |this2, cx2: &mut gpui::AsyncApp| { + Terminal::run_batch_loop( + term, + pty_writer, + error_state, + exited, + byte_rx, + event_rx, + executor, + || { + let _ = + this2.update(cx2, |_, cx| cx.notify()); + }, + ) + .await; + }, + ); + + view.terminal = new_terminal; + view._event_task = event_task; + view._reconnect_task = None; + tracing::info!( + event = "ui.terminal.reconnect_completed", + session_id = session_id, + ); + cx.notify(); + } + Err(e) => { + tracing::error!( + event = "ui.terminal.reconnect_terminal_failed", + session_id = session_id, + error = %e, + ); + view.terminal.set_reconnect_state(ReconnectState::Failed( + e.to_string(), + )); + view._reconnect_task = None; + cx.notify(); + } + } + }); + } + } + }); + + self._reconnect_task = Some(task); + } + fn on_key_down(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context) { self.blink.reset(cx); let key = event.keystroke.key.as_str(); let cmd = event.keystroke.modifiers.platform; + // Intercept R key on dead daemon terminals to trigger reconnect. + if key == "r" + && !event.keystroke.modifiers.control + && !cmd + && self.terminal.has_exited() + && self.terminal.daemon_session_id().is_some() + { + self.try_reconnect(cx); + return; + } + if event.keystroke.modifiers.control && key == "tab" { cx.propagate(); return; @@ -314,18 +452,49 @@ impl Render for TerminalView { .bg(theme::terminal_background()); if let Some(msg) = error { + let is_daemon = self.terminal.daemon_session_id().is_some(); + let reconnect = self.terminal.reconnect_state(); + + let (banner_bg, banner_text) = if is_daemon { + match &reconnect { + ReconnectState::Connecting => ( + theme::surface_1(), + "Reconnecting to daemon session...".to_string(), + ), + ReconnectState::Failed(err) => ( + theme::ember(), + format!( + "Reconnect failed: {err}. Press R to retry or {} to return.", + self.keybindings.terminal.focus_escape.hint_str() + ), + ), + ReconnectState::Idle => ( + theme::ember(), + format!( + "Terminal error: {msg}. Press R to reconnect or {} to return.", + self.keybindings.terminal.focus_escape.hint_str() + ), + ), + } + } else { + ( + theme::ember(), + format!( + "Terminal error: {msg}. {} to return.", + self.keybindings.terminal.focus_escape.hint_str() + ), + ) + }; + container = container.child( div() .w_full() .px(px(theme::SPACE_3)) .py(px(theme::SPACE_2)) - .bg(theme::ember()) + .bg(banner_bg) .text_color(theme::text_white()) .text_size(px(theme::TEXT_SM)) - .child(format!( - "Terminal error: {msg}. {} to return.", - self.keybindings.terminal.focus_escape.hint_str() - )), + .child(banner_text), ); } From 598e8158052892acb02b2ac97fe815845773782e Mon Sep 17 00:00:00 2001 From: Rasmus Widing Date: Mon, 23 Mar 2026 14:33:24 +0200 Subject: [PATCH 2/2] fix(ui): address review findings from PR #641 - Use actual terminal dimensions on reconnect instead of hardcoded 24x80 - Log error on reconnect_state lock poison instead of silently swallowing - Accept uppercase R for reconnect key (CapsLock resilience) - Fix theme::surface_1() -> theme::surface() compilation error --- crates/kild-ui/src/terminal/state.rs | 16 +++++++-- crates/kild-ui/src/terminal/terminal_view.rs | 36 +++++++++----------- 2 files changed, 31 insertions(+), 21 deletions(-) diff --git a/crates/kild-ui/src/terminal/state.rs b/crates/kild-ui/src/terminal/state.rs index d33d8159..dad3da02 100644 --- a/crates/kild-ui/src/terminal/state.rs +++ b/crates/kild-ui/src/terminal/state.rs @@ -830,10 +830,22 @@ impl Terminal { .unwrap_or(ReconnectState::Idle) } + /// Current PTY dimensions (rows, cols). + pub fn current_size(&self) -> (u16, u16) { + self.current_size.lock().map(|s| *s).unwrap_or((24, 80)) + } + /// Update the reconnection state. pub fn set_reconnect_state(&self, state: ReconnectState) { - if let Ok(mut s) = self.reconnect_state.lock() { - *s = state; + match self.reconnect_state.lock() { + Ok(mut s) => *s = state, + Err(e) => { + tracing::error!( + event = "ui.terminal.reconnect_state_lock_poisoned", + error = %e, + "Could not update reconnect state — lock poisoned" + ); + } } } } diff --git a/crates/kild-ui/src/terminal/terminal_view.rs b/crates/kild-ui/src/terminal/terminal_view.rs index 2ab7a046..6aacbfe6 100644 --- a/crates/kild-ui/src/terminal/terminal_view.rs +++ b/crates/kild-ui/src/terminal/terminal_view.rs @@ -225,16 +225,16 @@ impl TerminalView { event = "ui.terminal.reconnect_started", session_id = session_id ); - self.terminal.set_reconnect_state(ReconnectState::Connecting); + self.terminal + .set_reconnect_state(ReconnectState::Connecting); + let (rows, cols) = self.terminal.current_size(); cx.notify(); let task = cx.spawn(async move |this, cx: &mut gpui::AsyncApp| { let sid = session_id.clone(); let conn_result = cx .background_executor() - .spawn(async move { - daemon_client::connect_for_attach(&sid, 24, 80).await - }) + .spawn(async move { daemon_client::connect_for_attach(&sid, rows, cols).await }) .await; match conn_result { @@ -246,7 +246,8 @@ impl TerminalView { ); let msg = e.to_string(); let _ = this.update(cx, |view, cx| { - view.terminal.set_reconnect_state(ReconnectState::Failed(msg)); + view.terminal + .set_reconnect_state(ReconnectState::Failed(msg)); view._reconnect_task = None; cx.notify(); }); @@ -262,9 +263,9 @@ impl TerminalView { event = "ui.terminal.reconnect_channels_failed", error = %e, ); - view.terminal.set_reconnect_state( - ReconnectState::Failed(e.to_string()), - ); + view.terminal.set_reconnect_state(ReconnectState::Failed( + e.to_string(), + )); view._reconnect_task = None; cx.notify(); return; @@ -277,8 +278,8 @@ impl TerminalView { let exited = new_terminal.exited_flag().clone(); let executor = cx.background_executor().clone(); - let event_task = cx.spawn( - async move |this2, cx2: &mut gpui::AsyncApp| { + let event_task = + cx.spawn(async move |this2, cx2: &mut gpui::AsyncApp| { Terminal::run_batch_loop( term, pty_writer, @@ -288,13 +289,11 @@ impl TerminalView { event_rx, executor, || { - let _ = - this2.update(cx2, |_, cx| cx.notify()); + let _ = this2.update(cx2, |_, cx| cx.notify()); }, ) .await; - }, - ); + }); view.terminal = new_terminal; view._event_task = event_task; @@ -311,9 +310,8 @@ impl TerminalView { session_id = session_id, error = %e, ); - view.terminal.set_reconnect_state(ReconnectState::Failed( - e.to_string(), - )); + view.terminal + .set_reconnect_state(ReconnectState::Failed(e.to_string())); view._reconnect_task = None; cx.notify(); } @@ -333,7 +331,7 @@ impl TerminalView { let cmd = event.keystroke.modifiers.platform; // Intercept R key on dead daemon terminals to trigger reconnect. - if key == "r" + if key.eq_ignore_ascii_case("r") && !event.keystroke.modifiers.control && !cmd && self.terminal.has_exited() @@ -458,7 +456,7 @@ impl Render for TerminalView { let (banner_bg, banner_text) = if is_daemon { match &reconnect { ReconnectState::Connecting => ( - theme::surface_1(), + theme::surface(), "Reconnecting to daemon session...".to_string(), ), ReconnectState::Failed(err) => (