use super::*;
use crate::tui_internal::app_server_session::source_agent_path;
use crate::tui_internal::app_server_session::thread_blocks_direct_input;
use lemurclaw_core::config::types::ResumeCwdMode;
use std::collections::HashSet;
#[derive(Clone, Copy)]
pub(super) enum ThreadAttachPresentation {
SessionLineage,
PromptEdit,
}
#[derive(Default)]
pub(super) struct LoadedSubagentBackfill {
pub(super) completed: bool,
pub(super) refreshed_thread_ids: HashSet<ThreadId>,
}
impl App {
pub(super) async fn open_agent_picker(&mut self, app_server: &mut AppServerSession) {
let backfill = self.backfill_loaded_subagent_threads(app_server).await;
let path_backed_thread_ids: Vec<_> = self
.agent_navigation
.ordered_path_backed_subagent_threads(self.primary_thread_id)
.into_iter()
.map(|(thread_id, _)| thread_id)
.collect();
for thread_id in path_backed_thread_ids.iter().copied() {
if let Some(channel) = self.thread_event_channels.get(&thread_id)
&& channel.attachment() == ThreadEventAttachment::Live
{
let (has_active_turn, has_terminal_snapshot) = {
let store = channel.store.lock().await;
(
store.active_turn_id().is_some(),
store
.turns
.last()
.is_some_and(|turn| !matches!(turn.status, TurnStatus::InProgress)),
)
};
if has_active_turn {
self.agent_navigation.mark_running(thread_id);
} else if has_terminal_snapshot {
self.agent_navigation.mark_stopped(thread_id);
}
} else if !backfill.refreshed_thread_ids.contains(&thread_id) {
self.refresh_agent_picker_thread_liveness(app_server, thread_id)
.await;
}
}
let path_backed_threads = self
.agent_navigation
.ordered_path_backed_subagent_threads(self.primary_thread_id);
if !path_backed_threads.is_empty() {
let running_threads: Vec<_> = path_backed_threads
.into_iter()
.filter_map(|(thread_id, entry)| {
if !entry.is_running || entry.is_closed {
return None;
}
Some((thread_id, entry.agent_path.as_deref()?.trim().to_string()))
})
.collect();
let mut entries = Vec::new();
for (thread_id, agent_path) in running_threads {
let preview = if let Some(channel) = self.thread_event_channels.get(&thread_id) {
let store = channel.store.lock().await;
super::agent_status_feed::AgentStatusThreadPreview::from_store(
agent_path, &store,
)
} else {
super::agent_status_feed::AgentStatusThreadPreview::empty(agent_path)
};
entries.push(preview);
}
self.chat_widget
.add_to_history(super::agent_status_feed::AgentStatusHistoryCell::new(
entries,
));
}
let mut thread_ids = self.agent_navigation.tracked_thread_ids();
for thread_id in self.thread_event_channels.keys().copied() {
if !thread_ids.contains(&thread_id) {
thread_ids.push(thread_id);
}
}
for thread_id in thread_ids {
if path_backed_thread_ids.contains(&thread_id)
|| self.side_threads.contains_key(&thread_id)
|| backfill.refreshed_thread_ids.contains(&thread_id)
{
continue;
}
if !self
.refresh_agent_picker_thread_liveness(app_server, thread_id)
.await
{
continue;
}
}
let has_non_primary_agent_thread = self
.agent_navigation
.has_non_primary_thread(self.primary_thread_id);
if !self.config.features.enabled(Feature::Collab) && !has_non_primary_agent_thread {
self.chat_widget.open_multi_agent_enable_prompt();
return;
}
if self.agent_navigation.is_empty() {
self.chat_widget
.add_info_message("No agents available yet.".to_string(), None);
return;
}
let mut initial_selected_idx = None;
let items: Vec<SelectionItem> = self
.agent_navigation
.ordered_threads()
.into_iter()
.enumerate()
.map(|(idx, (thread_id, entry))| {
if self.active_thread_id == Some(thread_id) {
initial_selected_idx = Some(idx);
}
let id = thread_id;
let is_primary = self.primary_thread_id == Some(thread_id);
let name = entry
.agent_path
.as_deref()
.map(str::trim)
.filter(|agent_path| !is_primary && !agent_path.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| {
format_agent_picker_item_name(
entry.agent_nickname.as_deref(),
entry.agent_role.as_deref(),
is_primary,
)
});
let uuid = thread_id.to_string();
SelectionItem {
name: name.clone(),
name_prefix_spans: agent_picker_status_dot_spans(entry.is_closed),
description: Some(uuid.clone()),
is_current: self.active_thread_id == Some(thread_id),
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::SelectAgentThread(id));
})],
dismiss_on_select: true,
search_value: Some(format!("{name} {uuid}")),
..Default::default()
}
})
.collect();
self.chat_widget.show_selection_view(SelectionViewParams {
title: Some("Subagents".to_string()),
subtitle: Some(AgentNavigationState::picker_subtitle()),
footer_hint: Some(standard_popup_hint_line()),
items,
initial_selected_idx,
..Default::default()
});
}
pub(super) fn is_terminal_thread_read_error(err: &color_eyre::Report) -> bool {
err.chain()
.any(|cause| cause.to_string().contains("thread not loaded:"))
}
pub(super) fn closed_state_for_thread_read_error(
err: &color_eyre::Report,
existing_is_closed: Option<bool>,
) -> bool {
Self::is_terminal_thread_read_error(err) || existing_is_closed.unwrap_or(false)
}
pub(super) fn can_fallback_from_include_turns_error(err: &color_eyre::Report) -> bool {
err.chain().any(|cause| {
let message = cause.to_string();
message.contains("includeTurns is unavailable before first user message")
|| message.contains("ephemeral threads do not support includeTurns")
})
}
pub(super) fn upsert_agent_picker_thread(
&mut self,
thread_id: ThreadId,
agent_nickname: Option<String>,
agent_role: Option<String>,
is_closed: bool,
) {
self.chat_widget.set_collab_agent_metadata(
thread_id,
agent_nickname.clone(),
agent_role.clone(),
);
self.agent_navigation
.upsert(thread_id, agent_nickname, agent_role, is_closed);
self.sync_active_agent_label();
}
pub(super) fn mark_primary_thread_parent_owned(&mut self, thread_id: ThreadId) {
self.agent_navigation.mark_parent_owned(thread_id);
self.chat_widget.set_parent_owned_thread();
}
pub(super) fn mark_agent_picker_thread_closed(&mut self, thread_id: ThreadId) {
self.agent_navigation.mark_closed(thread_id);
self.sync_active_agent_label();
}
pub(super) async fn refresh_agent_picker_thread_liveness(
&mut self,
app_server: &mut AppServerSession,
thread_id: ThreadId,
) -> bool {
let existing_entry = self.agent_navigation.get(&thread_id).cloned();
let has_replay_channel = self.thread_event_channels.contains_key(&thread_id);
match app_server
.thread_read(thread_id, false)
.await
{
Ok(thread) => {
let is_parent_owned = thread_blocks_direct_input(&thread);
let agent_path = source_agent_path(&thread.source);
let is_running = matches!(
thread.status,
lemurclaw_core::app_server_protocol::ThreadStatus::Active { .. }
);
let is_closed = matches!(
thread.status,
lemurclaw_core::app_server_protocol::ThreadStatus::NotLoaded
);
self.upsert_agent_picker_thread(
thread_id,
thread.agent_nickname.or_else(|| {
existing_entry
.as_ref()
.and_then(|entry| entry.agent_nickname.clone())
}),
thread.agent_role.or_else(|| {
existing_entry
.as_ref()
.and_then(|entry| entry.agent_role.clone())
}),
is_closed,
);
if is_parent_owned {
self.agent_navigation.mark_parent_owned(thread_id);
}
self.agent_navigation.set_agent_path(thread_id, agent_path);
if is_running {
self.agent_navigation.mark_running(thread_id);
} else {
self.agent_navigation
.set_running(thread_id, false);
}
true
}
Err(err) => {
if Self::is_terminal_thread_read_error(&err) && !has_replay_channel {
self.agent_navigation.remove(thread_id);
return false;
}
let is_closed = Self::closed_state_for_thread_read_error(
&err,
existing_entry.as_ref().map(|entry| entry.is_closed),
);
if let Some(entry) = existing_entry {
self.upsert_agent_picker_thread(
thread_id,
entry.agent_nickname,
entry.agent_role,
is_closed,
);
} else {
self.upsert_agent_picker_thread(
thread_id, None, None,
is_closed,
);
}
self.agent_navigation
.set_running(thread_id, false);
true
}
}
}
pub(super) async fn attach_live_thread_for_selection(
&mut self,
app_server: &mut AppServerSession,
thread_id: ThreadId,
) -> Result<bool> {
if self.thread_event_channels.contains_key(&thread_id) {
return Ok(true);
}
let (session, turns, live_attached) = match app_server
.resume_thread(self.config.clone(), thread_id, self.resume_model_settings())
.await
{
Ok(started) => {
if started.blocks_direct_input {
self.agent_navigation.mark_parent_owned(thread_id);
}
(started.session, started.turns, true)
}
Err(resume_err) => {
tracing::warn!(
thread_id = %thread_id,
error = %resume_err,
"failed to resume live thread for selection; falling back to thread/read"
);
let (thread, turns) = match app_server
.thread_read(thread_id, true)
.await
{
Ok(thread) => {
let turns = thread.turns.clone();
(thread, turns)
}
Err(err) if Self::can_fallback_from_include_turns_error(&err) => {
let thread = app_server
.thread_read(thread_id, false)
.await?;
(thread, Vec::new())
}
Err(err) => return Err(err),
};
if turns.is_empty() {
return Err(color_eyre::eyre::eyre!(
"Agent thread {thread_id} is not yet available for replay or live attach."
));
}
let mut session = self.session_state_for_thread_read(thread_id, &thread).await;
session.model.clear();
(session, turns, false)
}
};
let channel = self.ensure_thread_channel(thread_id);
if !live_attached {
channel.mark_replay_only();
}
let mut store = channel.store.lock().await;
store.set_session(session, turns);
Ok(live_attached)
}
pub(super) fn replace_chat_widget(&mut self, mut chat_widget: ChatWidget) {
let previous_terminal_title = self.chat_widget.last_terminal_title.take();
if chat_widget.last_terminal_title.is_none() {
chat_widget.last_terminal_title = previous_terminal_title;
}
chat_widget.remote_connection = self.chat_widget.remote_connection.clone();
for (thread_id, entry) in self.agent_navigation.ordered_threads() {
chat_widget.set_collab_agent_metadata(
thread_id,
entry.agent_nickname.clone(),
entry.agent_role.clone(),
);
}
self.chat_widget = chat_widget;
self.sync_active_agent_label();
}
pub(super) async fn select_agent_thread(
&mut self,
tui: &mut tui::Tui,
app_server: &mut AppServerSession,
thread_id: ThreadId,
) -> Result<()> {
if self.active_thread_id == Some(thread_id) {
return Ok(());
}
if !(self.side_threads.contains_key(&thread_id)
&& self.thread_event_channels.contains_key(&thread_id)
|| self
.refresh_agent_picker_thread_liveness(app_server, thread_id)
.await)
{
self.chat_widget
.add_error_message(format!("Agent thread {thread_id} is no longer available."));
return Ok(());
}
let mut is_replay_only = self
.agent_navigation
.get(&thread_id)
.is_some_and(|entry| entry.is_closed);
let mut attached_replay_only = false;
if self.should_attach_live_thread_for_selection(thread_id) {
match self
.attach_live_thread_for_selection(app_server, thread_id)
.await
{
Ok(live_attached) => {
attached_replay_only = !live_attached;
if attached_replay_only {
is_replay_only = true;
}
}
Err(err) => {
self.chat_widget.add_error_message(format!(
"Failed to attach to agent thread {thread_id}: {err}"
));
return Ok(());
}
}
} else if !self.thread_event_channels.contains_key(&thread_id) && is_replay_only {
self.chat_widget
.add_error_message(format!("Agent thread {thread_id} is no longer available."));
return Ok(());
}
let previous_thread_id = self.active_thread_id;
self.store_active_thread_receiver().await;
self.active_thread_id = None;
let Some((receiver, mut snapshot)) = self.activate_thread_for_replay(thread_id).await
else {
self.chat_widget
.add_error_message(format!("Agent thread {thread_id} is already active."));
if let Some(previous_thread_id) = previous_thread_id {
self.activate_thread_channel(previous_thread_id).await;
}
return Ok(());
};
self.refresh_snapshot_session_if_needed(
app_server,
thread_id,
is_replay_only,
&mut snapshot,
)
.await;
let blocks_direct_input = self.agent_navigation.is_parent_owned(thread_id);
self.active_thread_id = Some(thread_id);
self.active_thread_rx = Some(receiver);
let init = self.chatwidget_init_for_forked_or_resumed_thread(
tui,
self.config.clone(),
None,
);
self.replace_chat_widget(ChatWidget::new_with_app_event(init));
if blocks_direct_input {
self.chat_widget.set_parent_owned_thread();
}
self.reset_for_thread_switch(tui)?;
self.replay_thread_snapshot(snapshot, !is_replay_only);
if is_replay_only {
let message = if attached_replay_only {
format!(
"Agent thread {thread_id} could not be resumed live. Replaying saved transcript."
)
} else {
format!("Agent thread {thread_id} is closed. Replaying saved transcript.")
};
self.chat_widget.add_info_message(message, None);
}
self.drain_active_thread_events(tui).await?;
self.refresh_pending_thread_approvals().await;
Ok(())
}
pub(super) fn should_attach_live_thread_for_selection(&self, thread_id: ThreadId) -> bool {
!self.thread_event_channels.contains_key(&thread_id)
&& self
.agent_navigation
.get(&thread_id)
.is_none_or(|entry| !entry.is_closed)
}
pub(super) fn reset_for_thread_switch(&mut self, tui: &mut tui::Tui) -> Result<()> {
self.reset_transcript_state_after_clear();
tui.clear_pending_history_lines();
Self::clear_terminal_for_thread_switch(&mut tui.terminal)?;
Ok(())
}
pub(super) fn clear_terminal_for_thread_switch<B>(
terminal: &mut crate::tui_internal::custom_terminal::Terminal<B>,
) -> Result<()>
where
B: Backend + Write,
{
terminal.clear_scrollback_and_visible_screen_ansi()?;
let mut area = terminal.viewport_area;
if area.y > 0 {
area.y = 0;
terminal.set_viewport_area(area);
}
Ok(())
}
pub(super) fn reset_thread_event_state(&mut self) {
self.abort_all_thread_event_listeners();
self.thread_event_channels.clear();
self.agent_navigation.clear();
self.side_threads.clear();
self.active_thread_id = None;
self.active_thread_rx = None;
self.primary_thread_id = None;
self.last_subagent_backfill_attempt = None;
self.primary_session_configured = None;
self.pending_primary_events.clear();
self.pending_app_server_requests.clear();
self.pending_startup_thread_start = false;
self.chat_widget.set_pending_thread_approvals(Vec::new());
self.sync_active_agent_label();
}
pub(super) async fn handle_startup_thread_started(
&mut self,
app_server: &mut AppServerSession,
result: Result<AppServerStartedThread, String>,
) -> Result<()> {
if !self.pending_startup_thread_start {
if let Ok(started) = result {
let thread_id = started.session.thread_id;
if let Err(err) = app_server.thread_unsubscribe(thread_id).await {
tracing::warn!(
thread_id = %thread_id,
"failed to unsubscribe stale startup thread: {err}"
);
}
self.discard_thread_local_state(thread_id).await;
}
return Ok(());
}
self.pending_startup_thread_start = false;
self.chat_widget
.set_queue_submissions_until_session_configured( false);
match result {
Ok(started) => {
if started.blocks_direct_input {
self.mark_primary_thread_parent_owned(started.session.thread_id);
}
self.enqueue_primary_thread_session(started.session, started.turns)
.await?;
self.chat_widget.maybe_send_next_queued_input();
}
Err(err) => {
return Err(color_eyre::eyre::eyre!(
"Failed to start a fresh session through the app server: {err}"
));
}
}
Ok(())
}
pub(super) async fn start_fresh_session_with_summary_hint(
&mut self,
tui: &mut tui::Tui,
app_server: &mut AppServerSession,
session_start_source: Option<ThreadStartSource>,
initial_user_message: Option<crate::tui_internal::chatwidget::UserMessage>,
) {
self.refresh_in_memory_config_from_disk_best_effort("starting a new thread")
.await;
let model = self.chat_widget.current_model().to_string();
let mut config = self.fresh_session_config();
apply_managed_new_thread_defaults(
&mut config,
app_server.managed_new_thread_defaults(),
&self.cli_kv_overrides,
&self.harness_overrides,
);
let summary = session_summary(
self.chat_widget.token_usage(),
self.chat_widget.thread_id(),
self.chat_widget.thread_name(),
self.chat_widget.rollout_path().as_deref(),
);
self.shutdown_current_thread(app_server).await;
let tracked_thread_ids: Vec<ThreadId> =
self.thread_event_channels.keys().copied().collect();
for thread_id in tracked_thread_ids {
if let Err(err) = app_server.thread_unsubscribe(thread_id).await {
tracing::warn!("failed to unsubscribe tracked thread {thread_id}: {err}");
}
}
self.config = config.clone();
match app_server
.start_thread_with_session_start_source(&config, session_start_source)
.await
{
Ok(started) => {
if let Err(err) = self
.replace_chat_widget_with_app_server_thread(
tui,
started,
ThreadAttachPresentation::SessionLineage,
initial_user_message,
)
.await
{
self.chat_widget.add_error_message(format!(
"Failed to attach to fresh app-server thread: {err}"
));
} else if let Some(summary) = summary {
let mut lines: Vec<Line<'static>> = Vec::new();
if let Some(usage_line) = summary.usage_line {
lines.push(usage_line.into());
}
if let Some(command) = summary.resume_hint {
let spans = vec!["To continue this session, run ".into(), command.cyan()];
lines.push(spans.into());
}
self.chat_widget.add_plain_history_lines(lines);
}
}
Err(err) => {
self.chat_widget.add_error_message(format!(
"Failed to start a fresh session through the app server: {err}"
));
self.config.model = Some(model);
}
}
tui.frame_requester().schedule_frame();
}
pub(super) async fn replace_chat_widget_with_app_server_thread(
&mut self,
tui: &mut tui::Tui,
started: AppServerStartedThread,
presentation: ThreadAttachPresentation,
initial_user_message: Option<crate::tui_internal::chatwidget::UserMessage>,
) -> Result<()> {
self.reset_thread_event_state();
let init = self.chatwidget_init_for_forked_or_resumed_thread(
tui,
self.config.clone(),
initial_user_message,
);
self.replace_chat_widget(ChatWidget::new_with_app_event(init));
if started.blocks_direct_input {
self.mark_primary_thread_parent_owned(started.session.thread_id);
}
self.enqueue_primary_thread_session_with_presentation(
started.session,
started.turns,
presentation,
)
.await?;
Ok(())
}
pub(super) async fn backfill_loaded_subagent_threads(
&mut self,
app_server: &mut AppServerSession,
) -> LoadedSubagentBackfill {
let Some(primary_thread_id) = self.primary_thread_id else {
return LoadedSubagentBackfill::default();
};
let loaded_thread_ids = match app_server
.thread_loaded_list(ThreadLoadedListParams {
cursor: None,
limit: None,
})
.await
{
Ok(response) => response.data,
Err(err) => {
tracing::warn!(%err, "failed to list loaded threads for subagent backfill");
return LoadedSubagentBackfill::default();
}
};
let mut threads = Vec::new();
let mut had_read_error = false;
for thread_id in loaded_thread_ids {
let Ok(thread_id) = ThreadId::from_string(&thread_id) else {
tracing::warn!("ignoring loaded thread with invalid id during subagent backfill");
continue;
};
if thread_id == primary_thread_id {
continue;
}
match app_server
.thread_read(thread_id, false)
.await
{
Ok(thread) => threads.push(thread),
Err(err) => {
had_read_error = true;
tracing::warn!(thread_id = %thread_id, %err, "failed to read loaded thread");
}
}
}
let mut refreshed_thread_ids = HashSet::new();
for thread in find_loaded_subagent_threads_for_primary(threads, primary_thread_id) {
let agent_path = thread.agent_path;
let has_live_channel = self
.thread_event_channels
.get(&thread.thread_id)
.is_some_and(|channel| channel.attachment() == ThreadEventAttachment::Live);
let is_closed = !has_live_channel && thread.is_closed;
if thread.blocks_direct_input {
self.agent_navigation.mark_parent_owned(thread.thread_id);
}
self.upsert_agent_picker_thread(
thread.thread_id,
thread.agent_nickname,
thread.agent_role,
is_closed,
);
self.agent_navigation
.set_agent_path(thread.thread_id, agent_path);
if !has_live_channel {
if thread.is_running {
self.agent_navigation.mark_running(thread.thread_id);
} else {
self.agent_navigation
.set_running(thread.thread_id, false);
}
refreshed_thread_ids.insert(thread.thread_id);
}
}
self.sync_active_agent_label();
LoadedSubagentBackfill {
completed: !had_read_error,
refreshed_thread_ids,
}
}
pub(super) async fn adjacent_thread_id_with_backfill(
&mut self,
app_server: &mut AppServerSession,
direction: AgentNavigationDirection,
) -> Option<ThreadId> {
let current_thread = self.current_displayed_thread_id();
if let Some(thread_id) = self
.agent_navigation
.adjacent_thread_id(current_thread, direction)
{
return Some(thread_id);
}
let primary_thread_id = self.primary_thread_id?;
if self.last_subagent_backfill_attempt == Some(primary_thread_id) {
return None;
}
if self
.backfill_loaded_subagent_threads(app_server)
.await
.completed
{
self.last_subagent_backfill_attempt = Some(primary_thread_id);
}
self.agent_navigation
.adjacent_thread_id(self.current_displayed_thread_id(), direction)
}
pub(super) fn fresh_session_config(&self) -> Config {
let mut config = self.config.clone();
config.service_tier = self.chat_widget.configured_service_tier();
config
}
pub(super) async fn resume_target_session(
&mut self,
tui: &mut tui::Tui,
app_server: &mut AppServerSession,
target_session: SessionTarget,
) -> Result<AppRunControl> {
if self.ignore_same_thread_resume(&target_session) {
tui.frame_requester().schedule_frame();
return Ok(AppRunControl::Continue);
}
self.refresh_in_memory_config_from_disk_best_effort("resuming a thread")
.await;
let cwd_override = self
.harness_overrides
.cwd
.as_deref()
.or_else(|| app_server.remote_cwd_override());
let resume_cwd_mode = crate::tui_internal::session_resume::effective_resume_cwd_mode(
self.config.tui_resume_cwd,
cwd_override,
);
let remembered_current_cwd = cwd_override.unwrap_or(self.launch_cwd.as_path());
let current_cwd = if matches!(resume_cwd_mode, Some(ResumeCwdMode::Current)) {
remembered_current_cwd.to_path_buf()
} else {
self.config.cwd.to_path_buf()
};
let uses_remote_workspace_or_environment = crate::tui_internal::uses_remote_workspace_or_environment(
&self.app_server_target,
&self.environment_manager,
);
if uses_remote_workspace_or_environment
&& self.harness_overrides.cwd.is_none()
&& app_server.remote_cwd_override().is_none()
&& matches!(resume_cwd_mode, Some(ResumeCwdMode::Current))
{
self.chat_widget.add_error_message(
"`tui.resume_cwd = \"current\"` requires `--cd` when using a remote workspace"
.to_string(),
);
return Ok(AppRunControl::Continue);
}
let resume_cwd = if self.app_server_target.uses_remote_workspace() {
current_cwd.clone()
} else {
let outcome = crate::tui_internal::session_resume::resolve_cwd_for_resume_or_fork(
tui,
&self.config,
self.state_db.as_deref(),
&target_session,
CwdPromptAction::Resume,
crate::tui_internal::session_resume::ResumeCwdContext {
current_cwd: ¤t_cwd,
remembered_current_cwd,
allow_remember_current: !uses_remote_workspace_or_environment
|| cwd_override.is_some(),
mode: resume_cwd_mode,
},
)
.await;
match outcome {
Err(err) => {
self.chat_widget.add_error_message(format!(
"Failed to determine working directory for resume: {err}"
));
return Ok(AppRunControl::Continue);
}
Ok(crate::tui_internal::session_resume::ResolveCwdOutcome::Continue(Some(cwd))) => cwd,
Ok(crate::tui_internal::session_resume::ResolveCwdOutcome::Continue(None)) => current_cwd.clone(),
Ok(crate::tui_internal::session_resume::ResolveCwdOutcome::Exit) => {
return Ok(AppRunControl::Exit(ExitReason::UserRequested));
}
}
};
let (config_current_cwd, config_resume_cwd) =
if self.app_server_target.uses_remote_workspace() {
let local_config_cwd = self.config.cwd.to_path_buf();
(local_config_cwd.clone(), local_config_cwd)
} else {
(current_cwd, resume_cwd)
};
let mut resume_config = match self
.rebuild_config_for_resume_or_fallback(&config_current_cwd, config_resume_cwd)
.await
{
Ok(cfg) => cfg,
Err(err) => {
self.chat_widget.add_error_message(format!(
"Failed to rebuild configuration for resume: {err}"
));
return Ok(AppRunControl::Continue);
}
};
self.apply_runtime_policy_overrides(&mut resume_config);
let summary = session_summary(
self.chat_widget.token_usage(),
self.chat_widget.thread_id(),
self.chat_widget.thread_name(),
self.chat_widget.rollout_path().as_deref(),
);
match app_server
.resume_thread(
resume_config.clone(),
target_session.thread_id,
self.resume_model_settings(),
)
.await
{
Ok(resumed) => {
let resumed_thread_id = resumed.session.thread_id;
self.shutdown_current_thread(app_server).await;
self.config = resume_config;
tui.set_notification_settings(
self.config.tui_notifications.method,
self.config.tui_notifications.condition,
);
self.file_search
.update_search_dir(self.config.cwd.to_path_buf());
match self
.replace_chat_widget_with_app_server_thread(
tui,
resumed,
ThreadAttachPresentation::SessionLineage,
None,
)
.await
{
Ok(()) => {
self.backfill_loaded_subagent_threads(app_server).await;
if let Some(summary) = summary {
let mut lines: Vec<Line<'static>> = Vec::new();
if let Some(usage_line) = summary.usage_line {
lines.push(usage_line.into());
}
if let Some(command) = summary.resume_hint {
let spans =
vec!["To continue this session, run ".into(), command.cyan()];
lines.push(spans.into());
}
self.chat_widget.add_plain_history_lines(lines);
}
self.maybe_prompt_resume_paused_goal_after_resume(
app_server,
resumed_thread_id,
)
.await;
}
Err(err) => {
self.chat_widget.add_error_message(format!(
"Failed to attach to resumed app-server thread: {err}"
));
}
}
}
Err(err) => {
let path_display = target_session.display_label();
self.chat_widget.add_error_message(format!(
"Failed to resume session from {path_display}: {err}"
));
}
}
Ok(AppRunControl::Continue)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn terminal_thread_read_error_detection_matches_not_loaded_errors() {
let err = color_eyre::eyre::eyre!(
"thread/read failed during TUI session lookup: thread/read failed: thread not loaded: thr_123"
);
assert!(App::is_terminal_thread_read_error(&err));
}
#[test]
fn terminal_thread_read_error_detection_ignores_transient_failures() {
let err = color_eyre::eyre::eyre!(
"thread/read failed during TUI session lookup: thread/read transport error: broken pipe"
);
assert!(!App::is_terminal_thread_read_error(&err));
}
#[test]
fn closed_state_for_thread_read_error_preserves_live_state_without_cache_on_transient_error() {
let err = color_eyre::eyre::eyre!(
"thread/read failed during TUI session lookup: thread/read transport error: broken pipe"
);
assert!(!App::closed_state_for_thread_read_error(
&err, None
));
}
#[test]
fn closed_state_for_thread_read_error_marks_terminal_uncached_threads_closed() {
let err = color_eyre::eyre::eyre!(
"thread/read failed during TUI session lookup: thread/read failed: thread not loaded: thr_123"
);
assert!(App::closed_state_for_thread_read_error(
&err, None
));
}
#[test]
fn include_turns_fallback_detection_handles_unmaterialized_and_ephemeral_threads() {
let unmaterialized = color_eyre::eyre::eyre!(
"thread/read failed during TUI session lookup: thread/read failed: thread thr_123 is not materialized yet; includeTurns is unavailable before first user message"
);
let ephemeral = color_eyre::eyre::eyre!(
"thread/read failed during TUI session lookup: thread/read failed: ephemeral threads do not support includeTurns"
);
assert!(App::can_fallback_from_include_turns_error(&unmaterialized));
assert!(App::can_fallback_from_include_turns_error(&ephemeral));
}
}