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
51 changes: 51 additions & 0 deletions crates/kild-ui/src/terminal/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)`
Expand Down Expand Up @@ -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<String>,
/// Reconnection state for daemon terminals. Shared with the view layer.
reconnect_state: Arc<Mutex<ReconnectState>>,
}

impl Terminal {
Expand Down Expand Up @@ -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)),
})
}

Expand Down Expand Up @@ -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)),
})
}

Expand Down Expand Up @@ -797,6 +816,38 @@ 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)
}

/// 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) {
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"
);
}
}
}
}

/// Resize implementation, determined by terminal mode (local PTY or daemon IPC).
Expand Down
177 changes: 172 additions & 5 deletions crates/kild-ui/src/terminal/terminal_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<Task<()>>,
}

impl TerminalView {
Expand Down Expand Up @@ -88,6 +92,7 @@ impl TerminalView {
cmd_held: false,
},
keybindings,
_reconnect_task: None,
}
}

Expand Down Expand Up @@ -138,6 +143,7 @@ impl TerminalView {
cmd_held: false,
},
keybindings,
_reconnect_task: None,
}
}

Expand Down Expand Up @@ -199,12 +205,142 @@ 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<Self>) {
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);
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, rows, cols).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>) {
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.eq_ignore_ascii_case("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;
Expand Down Expand Up @@ -314,18 +450,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(),
"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),
);
}

Expand Down
Loading