Skip to content

Commit 369c35b

Browse files
authored
feat(ui): add terminal reconnection on daemon disconnect (#641)
* 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. * 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
1 parent 14b208c commit 369c35b

2 files changed

Lines changed: 223 additions & 5 deletions

File tree

crates/kild-ui/src/terminal/state.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,17 @@ use portable_pty::{Child, CommandBuilder, MasterPty, PtySize, native_pty_system}
1818
use super::errors::TerminalError;
1919
use crate::daemon_client::{self, DaemonConnection};
2020

21+
/// State of a reconnection attempt in a daemon terminal.
22+
#[derive(Debug, Clone, PartialEq)]
23+
pub enum ReconnectState {
24+
/// No reconnection in progress — error banner may be shown.
25+
Idle,
26+
/// Reconnect attempt underway.
27+
Connecting,
28+
/// Reconnect failed with this message.
29+
Failed(String),
30+
}
31+
2132
/// Resolve the working directory for a new terminal.
2233
///
2334
/// - `Some(path)` that exists and is a directory → returns `Some(path)`
@@ -221,6 +232,10 @@ pub struct Terminal {
221232
/// Last-known terminal mode flags. Updated by sync() in render().
222233
/// Used by on_key_down to read APP_CURSOR without re-acquiring the lock.
223234
last_mode: TermMode,
235+
/// Daemon session ID for reconnection. `None` for local terminals.
236+
daemon_session_id: Option<String>,
237+
/// Reconnection state for daemon terminals. Shared with the view layer.
238+
reconnect_state: Arc<Mutex<ReconnectState>>,
224239
}
225240

226241
impl Terminal {
@@ -376,6 +391,8 @@ impl Terminal {
376391
exited: Arc::new(AtomicBool::new(false)),
377392
current_size,
378393
last_mode: initial_mode,
394+
daemon_session_id: None,
395+
reconnect_state: Arc::new(Mutex::new(ReconnectState::Idle)),
379396
})
380397
}
381398

@@ -604,6 +621,8 @@ impl Terminal {
604621
exited,
605622
current_size,
606623
last_mode: initial_mode,
624+
daemon_session_id: Some(session_id),
625+
reconnect_state: Arc::new(Mutex::new(ReconnectState::Idle)),
607626
})
608627
}
609628

@@ -797,6 +816,38 @@ impl Terminal {
797816
pub fn last_mode(&self) -> TermMode {
798817
self.last_mode
799818
}
819+
820+
/// Daemon session ID, if this terminal is backed by a daemon session.
821+
pub fn daemon_session_id(&self) -> Option<&str> {
822+
self.daemon_session_id.as_deref()
823+
}
824+
825+
/// Current reconnection state (cloned snapshot).
826+
pub fn reconnect_state(&self) -> ReconnectState {
827+
self.reconnect_state
828+
.lock()
829+
.map(|s| s.clone())
830+
.unwrap_or(ReconnectState::Idle)
831+
}
832+
833+
/// Current PTY dimensions (rows, cols).
834+
pub fn current_size(&self) -> (u16, u16) {
835+
self.current_size.lock().map(|s| *s).unwrap_or((24, 80))
836+
}
837+
838+
/// Update the reconnection state.
839+
pub fn set_reconnect_state(&self, state: ReconnectState) {
840+
match self.reconnect_state.lock() {
841+
Ok(mut s) => *s = state,
842+
Err(e) => {
843+
tracing::error!(
844+
event = "ui.terminal.reconnect_state_lock_poisoned",
845+
error = %e,
846+
"Could not update reconnect state — lock poisoned"
847+
);
848+
}
849+
}
850+
}
800851
}
801852

802853
/// Resize implementation, determined by terminal mode (local PTY or daemon IPC).

crates/kild-ui/src/terminal/terminal_view.rs

Lines changed: 172 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@ use gpui::{
44
};
55

66
use super::blink::BlinkManager;
7+
use super::state::ReconnectState;
78
use super::terminal_element::scroll_delta_lines;
89

910
use super::input;
1011
use super::state::Terminal;
1112
use super::terminal_element::{MouseState, TerminalElement};
1213
use super::types::TerminalContent;
14+
use crate::daemon_client;
1315
use crate::theme;
1416
use crate::views::main_view::keybindings::UiKeybindings;
1517

@@ -34,6 +36,8 @@ pub struct TerminalView {
3436
mouse_state: MouseState,
3537
/// Parsed keybindings for routing keys between PTY and MainView.
3638
keybindings: UiKeybindings,
39+
/// In-flight reconnection task. Stored to prevent cancellation.
40+
_reconnect_task: Option<Task<()>>,
3741
}
3842

3943
impl TerminalView {
@@ -88,6 +92,7 @@ impl TerminalView {
8892
cmd_held: false,
8993
},
9094
keybindings,
95+
_reconnect_task: None,
9196
}
9297
}
9398

@@ -138,6 +143,7 @@ impl TerminalView {
138143
cmd_held: false,
139144
},
140145
keybindings,
146+
_reconnect_task: None,
141147
}
142148
}
143149

@@ -199,12 +205,142 @@ impl TerminalView {
199205
}
200206
}
201207

208+
/// Attempt to reconnect a disconnected daemon terminal.
209+
///
210+
/// Spawns an async task that re-attaches to the daemon session, builds a
211+
/// fresh `Terminal::from_daemon()`, and swaps it in along with a new batch
212+
/// loop task. No-op for local terminals or if a reconnect is already in
213+
/// progress.
214+
fn try_reconnect(&mut self, cx: &mut Context<Self>) {
215+
let session_id = match self.terminal.daemon_session_id() {
216+
Some(id) => id.to_string(),
217+
None => return,
218+
};
219+
220+
if self.terminal.reconnect_state() == ReconnectState::Connecting {
221+
return;
222+
}
223+
224+
tracing::info!(
225+
event = "ui.terminal.reconnect_started",
226+
session_id = session_id
227+
);
228+
self.terminal
229+
.set_reconnect_state(ReconnectState::Connecting);
230+
let (rows, cols) = self.terminal.current_size();
231+
cx.notify();
232+
233+
let task = cx.spawn(async move |this, cx: &mut gpui::AsyncApp| {
234+
let sid = session_id.clone();
235+
let conn_result = cx
236+
.background_executor()
237+
.spawn(async move { daemon_client::connect_for_attach(&sid, rows, cols).await })
238+
.await;
239+
240+
match conn_result {
241+
Err(e) => {
242+
tracing::error!(
243+
event = "ui.terminal.reconnect_failed",
244+
session_id = session_id,
245+
error = %e,
246+
);
247+
let msg = e.to_string();
248+
let _ = this.update(cx, |view, cx| {
249+
view.terminal
250+
.set_reconnect_state(ReconnectState::Failed(msg));
251+
view._reconnect_task = None;
252+
cx.notify();
253+
});
254+
}
255+
Ok(conn) => {
256+
let _ = this.update(cx, |view, cx| {
257+
match Terminal::from_daemon(session_id.clone(), conn, cx) {
258+
Ok(mut new_terminal) => {
259+
let (byte_rx, event_rx) = match new_terminal.take_channels() {
260+
Ok(ch) => ch,
261+
Err(e) => {
262+
tracing::error!(
263+
event = "ui.terminal.reconnect_channels_failed",
264+
error = %e,
265+
);
266+
view.terminal.set_reconnect_state(ReconnectState::Failed(
267+
e.to_string(),
268+
));
269+
view._reconnect_task = None;
270+
cx.notify();
271+
return;
272+
}
273+
};
274+
275+
let term = new_terminal.term().clone();
276+
let pty_writer = new_terminal.pty_writer().clone();
277+
let error_state = new_terminal.error_state().clone();
278+
let exited = new_terminal.exited_flag().clone();
279+
let executor = cx.background_executor().clone();
280+
281+
let event_task =
282+
cx.spawn(async move |this2, cx2: &mut gpui::AsyncApp| {
283+
Terminal::run_batch_loop(
284+
term,
285+
pty_writer,
286+
error_state,
287+
exited,
288+
byte_rx,
289+
event_rx,
290+
executor,
291+
|| {
292+
let _ = this2.update(cx2, |_, cx| cx.notify());
293+
},
294+
)
295+
.await;
296+
});
297+
298+
view.terminal = new_terminal;
299+
view._event_task = event_task;
300+
view._reconnect_task = None;
301+
tracing::info!(
302+
event = "ui.terminal.reconnect_completed",
303+
session_id = session_id,
304+
);
305+
cx.notify();
306+
}
307+
Err(e) => {
308+
tracing::error!(
309+
event = "ui.terminal.reconnect_terminal_failed",
310+
session_id = session_id,
311+
error = %e,
312+
);
313+
view.terminal
314+
.set_reconnect_state(ReconnectState::Failed(e.to_string()));
315+
view._reconnect_task = None;
316+
cx.notify();
317+
}
318+
}
319+
});
320+
}
321+
}
322+
});
323+
324+
self._reconnect_task = Some(task);
325+
}
326+
202327
fn on_key_down(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
203328
self.blink.reset(cx);
204329

205330
let key = event.keystroke.key.as_str();
206331
let cmd = event.keystroke.modifiers.platform;
207332

333+
// Intercept R key on dead daemon terminals to trigger reconnect.
334+
if key.eq_ignore_ascii_case("r")
335+
&& !event.keystroke.modifiers.control
336+
&& !cmd
337+
&& self.terminal.has_exited()
338+
&& self.terminal.daemon_session_id().is_some()
339+
{
340+
self.try_reconnect(cx);
341+
return;
342+
}
343+
208344
if event.keystroke.modifiers.control && key == "tab" {
209345
cx.propagate();
210346
return;
@@ -314,18 +450,49 @@ impl Render for TerminalView {
314450
.bg(theme::terminal_background());
315451

316452
if let Some(msg) = error {
453+
let is_daemon = self.terminal.daemon_session_id().is_some();
454+
let reconnect = self.terminal.reconnect_state();
455+
456+
let (banner_bg, banner_text) = if is_daemon {
457+
match &reconnect {
458+
ReconnectState::Connecting => (
459+
theme::surface(),
460+
"Reconnecting to daemon session...".to_string(),
461+
),
462+
ReconnectState::Failed(err) => (
463+
theme::ember(),
464+
format!(
465+
"Reconnect failed: {err}. Press R to retry or {} to return.",
466+
self.keybindings.terminal.focus_escape.hint_str()
467+
),
468+
),
469+
ReconnectState::Idle => (
470+
theme::ember(),
471+
format!(
472+
"Terminal error: {msg}. Press R to reconnect or {} to return.",
473+
self.keybindings.terminal.focus_escape.hint_str()
474+
),
475+
),
476+
}
477+
} else {
478+
(
479+
theme::ember(),
480+
format!(
481+
"Terminal error: {msg}. {} to return.",
482+
self.keybindings.terminal.focus_escape.hint_str()
483+
),
484+
)
485+
};
486+
317487
container = container.child(
318488
div()
319489
.w_full()
320490
.px(px(theme::SPACE_3))
321491
.py(px(theme::SPACE_2))
322-
.bg(theme::ember())
492+
.bg(banner_bg)
323493
.text_color(theme::text_white())
324494
.text_size(px(theme::TEXT_SM))
325-
.child(format!(
326-
"Terminal error: {msg}. {} to return.",
327-
self.keybindings.terminal.focus_escape.hint_str()
328-
)),
495+
.child(banner_text),
329496
);
330497
}
331498

0 commit comments

Comments
 (0)