Add session resume to the agent command center (#42419)

## What changed

- Add a configurable `agents.resume` action, bound to `Ctrl+O` by default, that opens the session resume picker from the agent command center.
- Preserve command-center state when the picker is cancelled, and preserve per-thread composer and queued-input state when switching sessions.
- Keep resume failures within the command-center flow and wrap footer shortcuts on narrow terminals.

## Testing

- Add coverage for picker cancellation, active-session selection, resume failures, per-thread input restoration, custom keybindings, and narrow footer layout.

GitOrigin-RevId: 59159f838a0c642200cd164e3af45b0971d29bf8
This commit is contained in:
Eric Traut authored and copyberry committed 2026-09-03 02:18:43 +00:00
1 parent b27a6321fa
commit 1d741742c5
25 files changed
+712 -170

No files matched your search

+2
View File
@@ -429,6 +429,8 @@ pub struct TuiListKeymap {
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
#[schemars(deny_unknown_fields)] #[schemars(deny_unknown_fields)]
pub struct TuiAgentsKeymap { pub struct TuiAgentsKeymap {
/// Open the session resume picker.
pub resume: Option<KeybindingsSpec>,
/// Search the available agent tasks. /// Search the available agent tasks.
pub search: Option<KeybindingsSpec>, pub search: Option<KeybindingsSpec>,
/// Start composing a new agent task. /// Start composing a new agent task.
+10
View File
@@ -3858,6 +3858,7 @@
"agents": { "agents": {
"new_task": null, "new_task": null,
"rename": null, "rename": null,
"resume": null,
"search": null, "search": null,
"stop": null, "stop": null,
"toggle_grouping": null "toggle_grouping": null
@@ -4164,6 +4165,14 @@
], ],
"description": "Rename the selected task." "description": "Rename the selected task."
}, },
"resume": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Open the session resume picker."
},
"search": { "search": {
"allOf": [ "allOf": [
{ {
@@ -4631,6 +4640,7 @@
"default": { "default": {
"new_task": null, "new_task": null,
"rename": null, "rename": null,
"resume": null,
"search": null, "search": null,
"stop": null, "stop": null,
"toggle_grouping": null "toggle_grouping": null
+1
View File
@@ -237,6 +237,7 @@ mod resize_reflow;
mod resume_config; mod resume_config;
mod safety_buffering; mod safety_buffering;
mod session_lifecycle; mod session_lifecycle;
mod session_picker;
mod side; mod side;
mod startup; mod startup;
mod startup_prompts; mod startup_prompts;
+106 -87
View File
@@ -1,7 +1,106 @@
//! Layout rendering and cursor placement for the agent dashboard. //! Layout rendering and cursor placement for the agent dashboard.
//! The prompt and its cursor reserve the same height for wrapped footer hints.
use super::*; use super::*;
impl AgentsOverviewView {
fn footer_lines(&self, width: u16) -> Vec<Line<'static>> {
if self.state().connection_notice.is_some() {
return vec![
"ctrl+c quit · actions paused until the list is refreshed"
.dim()
.into(),
];
}
let list_hint = |action| {
self.keymap.primary_hint(action).filter(|hint| {
!matches!(hint, ShortcutHint::Single(binding)
if is_plain_text_key_event(KeyEvent::new(
binding.parts().0,
binding.parts().1,
)) || [
&self.agents_keymap.resume,
&self.agents_keymap.search,
&self.agents_keymap.new_task,
&self.agents_keymap.rename,
&self.agents_keymap.stop,
&self.agents_keymap.toggle_grouping,
]
.into_iter()
.any(|bindings| bindings.contains(binding)))
})
};
let navigation_hints = [ListAction::MoveUp, ListAction::MoveDown]
.into_iter()
.filter_map(list_hint)
.map(|hint| hint.display_label().replace(" + ", "+"))
.collect::<Vec<_>>();
let navigation_hint = navigation_hints.join(
if navigation_hints
.iter()
.all(|hint| hint.chars().count() == 1)
{
""
} else {
" "
},
);
let mut footer_spans = Vec::new();
if !navigation_hint.is_empty() {
footer_spans.extend([navigation_hint.bold(), " navigate ".dim()]);
}
let mut add_hint = |hint: Option<ShortcutHint>, label: &'static str, enabled: bool| {
if let Some(hint) = hint {
let key = hint.display_label().replace(" + ", "+");
footer_spans.push(if enabled { key.bold() } else { key.dim() });
footer_spans.push(format!(" {label} ").dim());
}
};
add_hint(
self.agents_keymap
.primary_hint("resume", &self.agents_keymap.resume),
"resume",
true,
);
add_hint(list_hint(ListAction::Accept), "open", true);
add_hint(
self.agents_keymap
.primary_hint("search", &self.agents_keymap.search),
"search",
true,
);
add_hint(
self.agents_keymap
.primary_hint("toggle_grouping", &self.agents_keymap.toggle_grouping),
"group",
true,
);
add_hint(
self.agents_keymap
.primary_hint("rename", &self.agents_keymap.rename),
"rename",
true,
);
add_hint(
self.agents_keymap
.primary_hint("stop", &self.agents_keymap.stop),
"stop",
self.selected_row()
.is_some_and(|row| matches!(row.thread.status, ThreadStatus::Active { .. })),
);
add_hint(list_hint(ListAction::Cancel), "back", true);
let mut footer_line: Line = footer_spans.into();
if footer_line.width() > usize::from(width) {
for span in &mut footer_line.spans {
if span.content.ends_with(" ") {
span.content.to_mut().pop();
}
}
}
crate::wrapping::word_wrap_lines([footer_line], usize::from(width))
}
}
impl Renderable for AgentsOverviewView { impl Renderable for AgentsOverviewView {
fn desired_height(&self, _width: u16) -> u16 { fn desired_height(&self, _width: u16) -> u16 {
24 24
@@ -20,7 +119,9 @@ impl Renderable for AgentsOverviewView {
.x .x
.saturating_add((label.width() + input.width()) as u16) .saturating_add((label.width() + input.width()) as u16)
.min(area.right().saturating_sub(3)); .min(area.right().saturating_sub(3));
Some((x, area.bottom().saturating_sub(2))) let footer_height = (self.footer_lines(area.width.saturating_sub(4)).len() as u16)
.min(area.height.saturating_sub(7));
Some((x, area.bottom().saturating_sub(footer_height + 1)))
} }
fn render(&self, area: Rect, buf: &mut Buffer) { fn render(&self, area: Rect, buf: &mut Buffer) {
@@ -28,13 +129,15 @@ impl Renderable for AgentsOverviewView {
return; return;
} }
Clear.render(area, buf); Clear.render(area, buf);
let footer_lines = self.footer_lines(area.width.saturating_sub(4));
let footer_height = (footer_lines.len() as u16).min(area.height.saturating_sub(7));
let [header, summary, divider, body, prompt, footer] = Layout::vertical([ let [header, summary, divider, body, prompt, footer] = Layout::vertical([
Constraint::Length(1), Constraint::Length(1),
Constraint::Length(1), Constraint::Length(1),
Constraint::Length(1), Constraint::Length(1),
Constraint::Min(3), Constraint::Min(3),
Constraint::Length(1), Constraint::Length(1),
Constraint::Length(1), Constraint::Length(footer_height),
]) ])
.areas(area); .areas(area);
let inset = let inset =
@@ -105,90 +208,6 @@ impl Renderable for AgentsOverviewView {
let input = &input[visible_start..]; let input = &input[visible_start..];
Line::from(vec![label.cyan().bold(), input.into(), placeholder.dim()]) Line::from(vec![label.cyan().bold(), input.into(), placeholder.dim()])
.render(inset(prompt), buf); .render(inset(prompt), buf);
if state.connection_notice.is_some() { Paragraph::new(footer_lines).render(inset(footer), buf);
Line::from("ctrl+c quit · actions paused until the list is refreshed".dim())
.render(inset(footer), buf);
return;
}
let list_hint = |action| {
self.keymap.primary_hint(action).filter(|hint| {
!matches!(hint, ShortcutHint::Single(binding)
if is_plain_text_key_event(KeyEvent::new(
binding.parts().0,
binding.parts().1,
)) || [
&self.agents_keymap.search,
&self.agents_keymap.new_task,
&self.agents_keymap.rename,
&self.agents_keymap.stop,
&self.agents_keymap.toggle_grouping,
]
.into_iter()
.any(|bindings| bindings.contains(binding)))
})
};
let navigation_hints = [ListAction::MoveUp, ListAction::MoveDown]
.into_iter()
.filter_map(list_hint)
.map(|hint| hint.display_label().replace(" + ", "+"))
.collect::<Vec<_>>();
let navigation_hint = navigation_hints.join(
if navigation_hints
.iter()
.all(|hint| hint.chars().count() == 1)
{
""
} else {
" "
},
);
let mut footer_spans = Vec::new();
if !navigation_hint.is_empty() {
footer_spans.extend([navigation_hint.bold(), " navigate ".dim()]);
}
let mut add_hint = |hint: Option<ShortcutHint>, label: &'static str, enabled: bool| {
if let Some(hint) = hint {
let key = hint.display_label().replace(" + ", "+");
footer_spans.push(if enabled { key.bold() } else { key.dim() });
footer_spans.push(format!(" {label} ").dim());
}
};
add_hint(list_hint(ListAction::Accept), "open", true);
add_hint(
self.agents_keymap
.primary_hint("search", &self.agents_keymap.search),
"search",
true,
);
add_hint(
self.agents_keymap
.primary_hint("toggle_grouping", &self.agents_keymap.toggle_grouping),
"group",
true,
);
add_hint(
self.agents_keymap
.primary_hint("rename", &self.agents_keymap.rename),
"rename",
true,
);
add_hint(
self.agents_keymap
.primary_hint("stop", &self.agents_keymap.stop),
"stop",
self.selected_row()
.is_some_and(|row| matches!(row.thread.status, ThreadStatus::Active { .. })),
);
add_hint(list_hint(ListAction::Cancel), "back", true);
let footer_area = inset(footer);
let mut footer_line: Line = footer_spans.into();
if footer_line.width() > usize::from(footer_area.width) {
for span in &mut footer_line.spans {
if span.content.ends_with(" ") {
span.content.to_mut().pop();
}
}
}
footer_line.render(footer_area, buf);
} }
} }
+339 -4
View File
@@ -199,7 +199,7 @@ async fn shared_overview_seeds_once_and_retains_locally_resumed_history() -> Res
)?); )?);
} }
let config = app.config.clone(); let config = app.config.clone();
let mut app_server = crate::start_embedded_app_server_for_picker(&config).await?; let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&config)).await?;
for thread_id in [ids[0], ids[21]] { for thread_id in [ids[0], ids[21]] {
app_server app_server
.resume_thread( .resume_thread(
@@ -284,15 +284,14 @@ async fn shared_overview_seeds_once_and_retains_locally_resumed_history() -> Res
"[tui]\nresume_cwd = \"session\"\n", "[tui]\nresume_cwd = \"session\"\n",
)?; )?;
let mut tui = crate::tui::test_support::make_test_tui()?; let mut tui = crate::tui::test_support::make_test_tui()?;
app.select_agents_overview_thread(&mut tui, &mut app_server, ids[2]) Box::pin(app.select_agents_overview_thread(&mut tui, &mut app_server, ids[2])).await?;
.await?;
assert_eq!(app.primary_thread_id, Some(ids[2])); assert_eq!(app.primary_thread_id, Some(ids[2]));
app_server.shutdown().await?; app_server.shutdown().await?;
// A fresh TUI/server has no in-memory additions; read-only resumes did not promote history. // A fresh TUI/server has no in-memory additions; read-only resumes did not promote history.
let mut restarted = make_test_app().await; let mut restarted = make_test_app().await;
restarted.app_server_target = app.app_server_target.clone(); restarted.app_server_target = app.app_server_target.clone();
let app_server = crate::start_embedded_app_server_for_picker(&config).await?; let app_server = Box::pin(crate::start_embedded_app_server_for_picker(&config)).await?;
let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel(); let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel();
restarted.app_event_tx = crate::app_event_sender::AppEventSender::new(event_tx); restarted.app_event_tx = crate::app_event_sender::AppEventSender::new(event_tx);
restarted.refresh_agents_overview_threads(&app_server); restarted.refresh_agents_overview_threads(&app_server);
@@ -672,6 +671,7 @@ async fn filtered_dashboard_actions_use_configured_shortcuts() {
let mut keymap = TuiKeymap::default(); let mut keymap = TuiKeymap::default();
keymap.agents.search = Some(KeybindingsSpec::One(KeybindingSpec("f6".to_string()))); keymap.agents.search = Some(KeybindingsSpec::One(KeybindingSpec("f6".to_string())));
keymap.agents.stop = Some(KeybindingsSpec::One(KeybindingSpec("f10".to_string()))); keymap.agents.stop = Some(KeybindingsSpec::One(KeybindingSpec("f10".to_string())));
keymap.agents.resume = Some(KeybindingsSpec::One(KeybindingSpec("f8".to_string())));
app.keymap = crate::keymap::RuntimeKeymap::from_config(&keymap).expect("runtime keymap"); app.keymap = crate::keymap::RuntimeKeymap::from_config(&keymap).expect("runtime keymap");
let first = ThreadId::new(); let first = ThreadId::new();
let second = ThreadId::new(); let second = ThreadId::new();
@@ -709,6 +709,12 @@ async fn filtered_dashboard_actions_use_configured_shortcuts() {
assert!(view.handle_paste("Do not dispatch this draft".to_string())); assert!(view.handle_paste("Do not dispatch this draft".to_string()));
view.handle_key_event(KeyEvent::new(KeyCode::F(6), KeyModifiers::NONE)); view.handle_key_event(KeyEvent::new(KeyCode::F(6), KeyModifiers::NONE));
assert!(view.handle_paste("Second task".to_string())); assert!(view.handle_paste("Second task".to_string()));
view.handle_key_event(KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE));
assert!(matches!(
event_rx.try_recv(),
Ok(AppEvent::OpenResumePicker)
));
assert!(!view.is_complete());
view.handle_key_event(KeyEvent::new(KeyCode::F(10), KeyModifiers::NONE)); view.handle_key_event(KeyEvent::new(KeyCode::F(10), KeyModifiers::NONE));
assert!(matches!( assert!(matches!(
event_rx.try_recv(), event_rx.try_recv(),
@@ -1135,3 +1141,332 @@ async fn restored_server_permission_profile_survives_cd_without_turn_override()
app_server.shutdown().await?; app_server.shutdown().await?;
Ok(()) Ok(())
} }
#[tokio::test]
async fn cancelling_resume_picker_preserves_command_center_state() -> Result<()> {
for primary_thread_id in [None, Some(ThreadId::new())] {
let mut app = make_test_app().await;
let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel();
app.app_event_tx = crate::app_event_sender::AppEventSender::new(event_tx);
app.primary_thread_id = primary_thread_id;
let threads = ["First task", "Second task"].map(|name| {
overview_thread(
ThreadId::new(),
/*parent_thread_id*/ None,
name,
ThreadStatus::Idle,
)
});
let selected = ThreadId::from_string(&threads[1].id).unwrap();
let view = app.agents_overview_view(threads.into(), Some(selected));
app.chat_widget.show_bottom_pane_view(Box::new(view));
for key in [
KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL),
KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL),
KeyEvent::new(KeyCode::Char('S'), KeyModifiers::NONE),
] {
app.chat_widget.handle_key_event(key);
}
let before = render_bottom_popup(&app.chat_widget, /*width*/ 96);
let selection = app
.chat_widget
.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID);
while event_rx.try_recv().is_ok() {}
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL));
assert!(matches!(
event_rx.try_recv(),
Ok(AppEvent::OpenResumePicker)
));
assert!(event_rx.try_recv().is_err());
let mut tui = crate::tui::test_support::make_test_tui()?;
let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
for selection in [SessionSelection::StartFresh, SessionSelection::Exit] {
assert!(matches!(
app.apply_resume_picker_selection(&mut tui, &mut server, selection)
.await?,
AppRunControl::Continue
));
app.chat_widget.pre_draw_tick();
assert_eq!(render_bottom_popup(&app.chat_widget, /*width*/ 96), before);
}
server.shutdown().await?;
assert_eq!(
app.chat_widget
.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID),
selection
);
assert_eq!(app.agents_overview.view_state.lock().unwrap().input, "d");
}
Ok(())
}
#[tokio::test]
async fn command_center_cursor_tracks_wrapped_footer() {
let app = make_test_app().await;
let mut view = app.agents_overview_view(
vec![overview_thread(
ThreadId::new(),
/*parent_thread_id*/ None,
"Task",
ThreadStatus::Idle,
)],
/*selected_thread_id*/ None,
);
for (key, label) in [('n', "New task ›"), ('f', "Search ›"), ('r', "Rename ›")] {
view.handle_key_event(KeyEvent::new(KeyCode::Char(key), KeyModifiers::CONTROL));
for width in [48, 96, 120] {
let area =
ratatui::layout::Rect::new(/*x*/ 0, /*y*/ 0, width, /*height*/ 24);
let mut buffer = ratatui::buffer::Buffer::empty(area);
view.render(area, &mut buffer);
let prompt_row = buffer
.content()
.chunks(usize::from(width))
.position(|row| {
row.iter()
.map(ratatui::buffer::Cell::symbol)
.collect::<String>()
.contains(label)
})
.expect("rendered prompt");
assert_eq!(
view.cursor_pos(area).map(|(_, y)| usize::from(y)),
Some(prompt_row)
);
}
}
}
#[tokio::test]
async fn empty_command_center_can_open_resume_picker() {
let mut app = make_test_app().await;
let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel();
app.app_event_tx = crate::app_event_sender::AppEventSender::new(event_tx);
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
app.chat_widget.show_bottom_pane_view(Box::new(view));
while event_rx.try_recv().is_ok() {}
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL));
assert!(matches!(
event_rx.try_recv(),
Ok(AppEvent::OpenResumePicker)
));
insta::with_settings!({snapshot_path => "../snapshots"}, {
insta::assert_snapshot!("agents_overview_empty_narrow", render_bottom_popup(&app.chat_widget, /*width*/ 48));
});
}
#[tokio::test]
async fn resuming_active_session_closes_command_center() -> Result<()> {
let mut app = make_test_app().await;
let thread_id = ThreadId::new();
app.active_thread_id = Some(thread_id);
app.primary_thread_id = Some(thread_id);
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
app.chat_widget.show_bottom_pane_view(Box::new(view));
let mut tui = crate::tui::test_support::make_test_tui()?;
let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
assert!(matches!(
app.apply_resume_picker_selection(
&mut tui,
&mut server,
SessionSelection::Resume(SessionTarget {
path: None,
thread_id,
history_mode: None,
})
)
.await?,
AppRunControl::Continue
));
assert_eq!(
app.chat_widget
.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID),
None
);
server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn resume_failure_keeps_command_center_available() {
let mut app = make_test_app().await;
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
app.chat_widget.show_bottom_pane_view(Box::new(view));
let before = render_bottom_popup(&app.chat_widget, /*width*/ 96);
app.add_session_picker_error("The session is unavailable.".to_string());
insta::with_settings!({snapshot_path => "../snapshots"}, {
insta::assert_snapshot!("agents_overview_resume_error", render_bottom_popup(&app.chat_widget, /*width*/ 96));
});
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(render_bottom_popup(&app.chat_widget, /*width*/ 96), before);
}
#[tokio::test]
async fn resume_picker_round_trip_preserves_each_threads_input() -> Result<()> {
let mut app = make_test_app().await;
std::fs::write(
app.config.codex_home.join("config.toml"),
"[tui]\nresume_cwd = \"current\"\n",
)?;
let mut targets = Vec::new();
for (timestamp, name) in [
("2025-01-05T12-00-00", "First task"),
("2025-01-05T13-00-00", "Second task"),
] {
let id = app_test_support::create_fake_rollout(
app.config.codex_home.as_path(),
timestamp,
"2025-01-05T12:00:00Z",
name,
Some(&app.config.model_provider_id),
/*git_info*/ None,
)
.expect("saved rollout");
targets.push(SessionTarget {
path: Some(app_test_support::rollout_path(
app.config.codex_home.as_path(),
timestamp,
&id,
)),
thread_id: ThreadId::from_string(&id)?,
history_mode: None,
});
}
let mut tui = crate::tui::test_support::make_test_tui()?;
let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
let mut expected_states = Vec::new();
for target in targets.iter().chain(&targets) {
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
app.chat_widget.show_bottom_pane_view(Box::new(view));
app.apply_resume_picker_selection(
&mut tui,
&mut server,
SessionSelection::Resume(target.clone()),
)
.await?;
assert_eq!(app.chat_widget.thread_id(), Some(target.thread_id));
if expected_states.len() < targets.len() {
assert_eq!(app.chat_widget.composer_text_with_pending(), "");
assert!(app.chat_widget.queued_user_message_texts().is_empty());
app.chat_widget.handle_server_notification(
ServerNotification::TurnStarted(
codex_app_server_protocol::TurnStartedNotification {
thread_id: target.thread_id.to_string(),
turn: codex_app_server_protocol::Turn {
id: "turn-with-follow-up".to_string(),
items_view: codex_app_server_protocol::TurnItemsView::Full,
items: Vec::new(),
status: codex_app_server_protocol::TurnStatus::InProgress,
error: None,
started_at: None,
completed_at: None,
duration_ms: None,
},
},
),
/*replay_kind*/ None,
);
let follow_up = format!("Follow-up for {}", target.thread_id);
app.chat_widget.apply_external_edit(follow_up.clone());
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert_eq!(app.chat_widget.queued_user_message_texts(), vec![follow_up]);
app.chat_widget
.apply_external_edit(format!("Draft for {}", target.thread_id));
let mut input_state = app.chat_widget.capture_thread_input_state().unwrap();
// Recovered follow-ups stay paused until the user explicitly submits them.
input_state.recovered_queue = true;
app.chat_widget.restore_thread_input_state(
Some(input_state),
crate::chatwidget::ThreadInputStateRestoreMode {
preserve_in_flight_turn: false,
},
);
expected_states.push(app.chat_widget.capture_thread_input_state());
} else {
let index = targets
.iter()
.position(|t| t.thread_id == target.thread_id)
.unwrap();
assert_eq!(
app.chat_widget.capture_thread_input_state(),
expected_states[index]
);
}
}
server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn command_center_handles_resume_failure_and_success() -> Result<()> {
let mut app = make_test_app().await;
std::fs::write(
app.config.codex_home.join("config.toml"),
"[tui]\nresume_cwd = \"current\"\n",
)?;
let timestamp = "2025-01-05T12-00-00";
let id = app_test_support::create_fake_rollout(
app.config.codex_home.as_path(),
timestamp,
"2025-01-05T12:00:00Z",
"Saved task",
Some(&app.config.model_provider_id),
/*git_info*/ None,
)
.expect("saved rollout");
let thread_id = ThreadId::from_string(&id)?;
let path = app_test_support::rollout_path(app.config.codex_home.as_path(), timestamp, &id);
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
app.chat_widget.show_bottom_pane_view(Box::new(view));
let mut tui = crate::tui::test_support::make_test_tui()?;
let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
assert!(matches!(
app.apply_resume_picker_selection(
&mut tui,
&mut server,
SessionSelection::Resume(SessionTarget {
path: None,
thread_id: ThreadId::new(),
history_mode: None,
})
)
.await?,
AppRunControl::Continue
));
assert!(
app.chat_widget
.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID)
.is_some()
);
assert!(
render_bottom_popup(&app.chat_widget, /*width*/ 96).contains("Unable to resume session")
);
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(matches!(
app.apply_resume_picker_selection(
&mut tui,
&mut server,
SessionSelection::Resume(SessionTarget {
path: Some(path),
thread_id,
history_mode: None,
})
)
.await?,
AppRunControl::Continue
));
assert_eq!(app.chat_widget.thread_id(), Some(thread_id));
assert_eq!(
app.chat_widget
.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID),
None
);
server.shutdown().await?;
Ok(())
}
+11 -6
View File
@@ -93,6 +93,8 @@ pub(super) struct AgentsOverviewViewState {
searching: bool, searching: bool,
pub(super) status_grouping: bool, pub(super) status_grouping: bool,
pub(super) renaming: bool, pub(super) renaming: bool,
// The picker can finish this retained view when it selects the already active session.
pub(super) completion: Option<ViewCompletion>,
} }
pub(super) struct AgentsOverviewView { pub(super) struct AgentsOverviewView {
@@ -100,7 +102,6 @@ pub(super) struct AgentsOverviewView {
selected: usize, selected: usize,
state: Arc<Mutex<AgentsOverviewViewState>>, state: Arc<Mutex<AgentsOverviewViewState>>,
exit_on_cancel: bool, exit_on_cancel: bool,
completion: Option<ViewCompletion>,
app_event_tx: AppEventSender, app_event_tx: AppEventSender,
keymap: ListKeymap, keymap: ListKeymap,
agents_keymap: AgentsKeymap, agents_keymap: AgentsKeymap,
@@ -124,11 +125,11 @@ impl AgentsOverviewView {
selected, selected,
state, state,
exit_on_cancel, exit_on_cancel,
completion: None,
app_event_tx, app_event_tx,
keymap: keymap.list, keymap: keymap.list,
agents_keymap: keymap.agents, agents_keymap: keymap.agents,
}; };
view.state().completion = None;
let visible = view.visible_indices(); let visible = view.visible_indices();
if !visible.contains(&view.selected) { if !visible.contains(&view.selected) {
view.selected = visible.first().copied().unwrap_or(usize::MAX); view.selected = visible.first().copied().unwrap_or(usize::MAX);
@@ -234,7 +235,7 @@ impl AgentsOverviewView {
state.search.clear(); state.search.clear();
state.searching = false; state.searching = false;
} }
self.completion = Some(ViewCompletion::Accepted); self.state().completion = Some(ViewCompletion::Accepted);
} }
} }
@@ -416,11 +417,11 @@ impl BottomPaneView for AgentsOverviewView {
} }
fn completion(&self) -> Option<ViewCompletion> { fn completion(&self) -> Option<ViewCompletion> {
self.completion self.state().completion
} }
fn is_complete(&self) -> bool { fn is_complete(&self) -> bool {
self.completion.is_some() self.completion().is_some()
} }
fn prefer_esc_to_handle_key_event(&self) -> bool { fn prefer_esc_to_handle_key_event(&self) -> bool {
@@ -475,6 +476,10 @@ impl BottomPaneView for AgentsOverviewView {
return; return;
} }
if self.agents_keymap.resume.is_pressed(key) {
self.app_event_tx.send(AppEvent::OpenResumePicker);
return;
}
if self.agents_keymap.toggle_grouping.is_pressed(key) { if self.agents_keymap.toggle_grouping.is_pressed(key) {
let mut state = self.state(); let mut state = self.state();
state.status_grouping = !state.status_grouping; state.status_grouping = !state.status_grouping;
@@ -541,7 +546,7 @@ impl BottomPaneView for AgentsOverviewView {
self.app_event_tx self.app_event_tx
.send(AppEvent::Exit(crate::app::ExitMode::Immediate)); .send(AppEvent::Exit(crate::app::ExitMode::Immediate));
} }
self.completion = Some(ViewCompletion::Cancelled); state.completion = Some(ViewCompletion::Cancelled);
} }
} }
ListAction::PageUp | ListAction::PageDown => { ListAction::PageUp | ListAction::PageDown => {
+1 -55
View File
@@ -238,61 +238,7 @@ impl App {
.await; .await;
} }
AppEvent::OpenResumePicker => { AppEvent::OpenResumePicker => {
let picker_app_server = match crate::start_app_server_for_picker( return self.open_resume_picker(tui, app_server).await;
&self.config,
&self.app_server_target,
self.state_db.clone(),
self.environment_manager.clone(),
)
.await
{
Ok(app_server) => app_server,
Err(err) => {
self.chat_widget.add_error_message(format!(
"Failed to start TUI session picker: {err}"
));
self.chat_widget.maybe_send_next_queued_input();
return Ok(AppRunControl::Continue);
}
};
match crate::resume_picker::run_resume_picker_from_existing_session_with_app_server(
tui,
&self.config,
&self.local_settings,
/*show_all*/ false,
/*include_non_interactive*/ false,
picker_app_server,
app_server.request_handle(),
self.primary_thread_id
.or(self.current_displayed_thread_id()),
)
.await?
{
SessionSelection::Resume(target_session) => {
match self
.resume_target_session(tui, app_server, target_session)
.await?
{
AppRunControl::Continue => {}
AppRunControl::Exit(reason) => {
return Ok(AppRunControl::Exit(reason));
}
}
}
SessionSelection::Exit
| SessionSelection::StartFresh
| SessionSelection::AgentsOverview => {
self.refresh_in_memory_config_from_disk_best_effort(
"closing the session picker",
)
.await;
}
SessionSelection::Fork(_) => {}
}
self.chat_widget.maybe_send_next_queued_input();
// Leaving alt-screen may blank the inline viewport; force a redraw either way.
tui.frame_requester().schedule_frame();
} }
AppEvent::OpenExternalAgentConfigMigration => { AppEvent::OpenExternalAgentConfigMigration => {
match crate::external_agent_config_migration::flow::handle_external_agent_config_migration_prompt( match crate::external_agent_config_migration::flow::handle_external_agent_config_migration_prompt(
+3 -3
View File
@@ -38,7 +38,7 @@ impl App {
&& app_server.remote_cwd_override().is_none() && app_server.remote_cwd_override().is_none()
&& matches!(resume_cwd_mode, Some(ResumeCwdMode::Current)) && matches!(resume_cwd_mode, Some(ResumeCwdMode::Current))
{ {
self.chat_widget.add_error_message( self.add_session_picker_error(
"`tui.resume_cwd = \"current\"` requires `--cd` when using a remote workspace" "`tui.resume_cwd = \"current\"` requires `--cd` when using a remote workspace"
.to_string(), .to_string(),
); );
@@ -64,7 +64,7 @@ impl App {
.await; .await;
match outcome { match outcome {
Err(err) => { Err(err) => {
self.chat_widget.add_error_message(format!( self.add_session_picker_error(format!(
"Failed to determine working directory for resume: {err}" "Failed to determine working directory for resume: {err}"
)); ));
return Err(AppRunControl::Continue); return Err(AppRunControl::Continue);
@@ -91,7 +91,7 @@ impl App {
{ {
Ok(cfg) => cfg, Ok(cfg) => cfg,
Err(err) => { Err(err) => {
self.chat_widget.add_error_message(format!( self.add_session_picker_error(format!(
"Failed to rebuild configuration for resume: {err}" "Failed to rebuild configuration for resume: {err}"
)); ));
return Err(AppRunControl::Continue); return Err(AppRunControl::Continue);
+2 -2
View File
@@ -1158,7 +1158,7 @@ impl App {
.await; .await;
} }
Err(err) => { Err(err) => {
self.chat_widget.add_error_message(format!( self.add_session_picker_error(format!(
"Failed to attach to resumed app-server thread: {err}" "Failed to attach to resumed app-server thread: {err}"
)); ));
} }
@@ -1166,7 +1166,7 @@ impl App {
} }
Err(err) => { Err(err) => {
let path_display = target_session.display_label(); let path_display = target_session.display_label();
self.chat_widget.add_error_message(format!( self.add_session_picker_error(format!(
"Failed to resume session from {path_display}: {err}" "Failed to resume session from {path_display}: {err}"
)); ));
} }
+142
View File
@@ -0,0 +1,142 @@
//! In-app session picker transitions, preserving per-thread input and the view on cancellation.
use super::*;
use crate::chatwidget::ThreadInputStateRestoreMode;
impl App {
pub(super) async fn open_resume_picker(
&mut self,
tui: &mut tui::Tui,
app_server: &mut AppServerSession,
) -> Result<AppRunControl> {
let picker_app_server = match crate::start_app_server_for_picker(
&self.config,
&self.app_server_target,
self.state_db.clone(),
self.environment_manager.clone(),
)
.await
{
Ok(app_server) => app_server,
Err(err) => {
self.add_session_picker_error(format!("Failed to start TUI session picker: {err}"));
self.chat_widget.maybe_send_next_queued_input();
return Ok(AppRunControl::Continue);
}
};
let selection =
crate::resume_picker::run_resume_picker_from_existing_session_with_app_server(
tui,
&self.config,
&self.local_settings,
/*show_all*/ false,
/*include_non_interactive*/ false,
picker_app_server,
app_server.request_handle(),
self.primary_thread_id
.or(self.current_displayed_thread_id()),
)
.await;
match selection {
Ok(selection) => {
self.apply_resume_picker_selection(tui, app_server, selection)
.await
}
Err(err) => {
self.add_session_picker_error(format!("Failed to open session picker: {err}"));
self.chat_widget.maybe_send_next_queued_input();
Ok(AppRunControl::Continue)
}
}
}
pub(super) async fn apply_resume_picker_selection(
&mut self,
tui: &mut tui::Tui,
app_server: &mut AppServerSession,
selection: SessionSelection,
) -> Result<AppRunControl> {
match selection {
SessionSelection::Resume(target_session) => {
let thread_id = target_session.thread_id;
let switching_threads = self.current_displayed_thread_id() != Some(thread_id);
if switching_threads {
for (id, channel) in &self.thread_event_channels {
if let Some(input_state) = channel.store.lock().await.input_state.clone() {
self.agents_overview.input_states.insert(*id, input_state);
}
}
if let Some(current_thread_id) = self.current_displayed_thread_id()
&& let Some(input_state) = self.chat_widget.capture_thread_input_state()
{
self.agents_overview
.input_states
.insert(current_thread_id, input_state);
}
}
if let AppRunControl::Exit(reason) = self
.resume_target_session(tui, app_server, target_session)
.await?
{
return Ok(AppRunControl::Exit(reason));
}
if switching_threads
&& self.current_displayed_thread_id() == Some(thread_id)
&& let Some(input_state) = self.agents_overview.input_states.remove(&thread_id)
{
let preserve_in_flight_turn =
self.active_turn_id_for_thread(thread_id).await.is_some();
self.chat_widget.restore_thread_input_state(
Some(input_state),
ThreadInputStateRestoreMode {
preserve_in_flight_turn,
},
);
}
if self.active_thread_id == Some(thread_id)
&& self
.chat_widget
.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID)
.is_some()
{
if let Ok(mut state) = self.agents_overview.view_state.lock() {
state.completion = Some(crate::bottom_pane::ViewCompletion::Accepted);
}
self.chat_widget.pre_draw_tick();
}
}
SessionSelection::Exit
| SessionSelection::StartFresh
| SessionSelection::AgentsOverview => {
self.refresh_in_memory_config_from_disk_best_effort("closing the session picker")
.await;
}
SessionSelection::Fork(_) => {}
}
self.chat_widget.maybe_send_next_queued_input();
// Leaving alt-screen may blank the inline viewport; force a redraw either way.
tui.frame_requester().schedule_frame();
Ok(AppRunControl::Continue)
}
pub(super) fn add_session_picker_error(&mut self, message: String) {
if self
.chat_widget
.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID)
.is_some()
{
self.chat_widget.show_selection_view(SelectionViewParams {
title: Some("Unable to resume session".to_string()),
subtitle: Some(message.clone()),
items: vec![SelectionItem {
name: "Return to command center".to_string(),
dismiss_on_select: true,
..Default::default()
}],
..Default::default()
});
}
self.chat_widget.add_error_message(message);
}
}
@@ -218,6 +218,7 @@ async fn disconnected_command_center_keeps_input_and_blocks_actions() -> Result<
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL), KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL),
KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL), KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL),
KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL),
] { ] {
app.handle_tui_event(&mut tui, &mut session, TuiEvent::Key(key)) app.handle_tui_event(&mut tui, &mut session, TuiEvent::Key(key))
.await?; .await?;
@@ -235,8 +236,10 @@ async fn disconnected_command_center_keeps_input_and_blocks_actions() -> Result<
"task draft!" "task draft!"
); );
assert!( assert!(
!std::iter::from_fn(|| events.try_recv().ok()) !std::iter::from_fn(|| events.try_recv().ok()).any(|event| matches!(
.any(|event| matches!(event, AppEvent::DispatchAgentsOverviewTask { .. })) event,
AppEvent::DispatchAgentsOverviewTask { .. } | AppEvent::OpenResumePicker
))
); );
assert_snapshot!( assert_snapshot!(
"offline_command_center", "offline_command_center",
@@ -23,6 +23,6 @@ expression: "render_bottom_popup(&app.chat_widget, 100)"
│ │
│ │
│ │
│
New task › Describe a task and press enter to dispatch it New task › Describe a task and press enter to dispatch it
↑↓ navigate enter open ctrl+f search ctrl+s group ctrl+r rename ctrl+x stop esc back ↑↓ navigate ctrl+o resume enter open ctrl+f search ctrl+s group ctrl+r rename ctrl+x stop esc
back
+37
View File
@@ -386,6 +386,7 @@ impl ListKeymap {
/// Task-management shortcuts specific to the shared agents dashboard. /// Task-management shortcuts specific to the shared agents dashboard.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub(crate) struct AgentsKeymap { pub(crate) struct AgentsKeymap {
pub(crate) resume: Vec<KeyBinding>,
pub(crate) search: Vec<KeyBinding>, pub(crate) search: Vec<KeyBinding>,
pub(crate) new_task: Vec<KeyBinding>, pub(crate) new_task: Vec<KeyBinding>,
pub(crate) rename: Vec<KeyBinding>, pub(crate) rename: Vec<KeyBinding>,
@@ -1198,7 +1199,20 @@ impl RuntimeKeymap {
chord_hints: Arc::clone(&chords), chord_hints: Arc::clone(&chords),
}; };
let resume_default_is_shadowed = keymap.agents.resume.is_none()
&& (configured_context_alias_is_used(&keymap.agents, "ctrl-o")
|| configured_context_alias_is_used(&keymap.list, "ctrl-o")
|| chords.bindings.iter().any(|binding| {
binding.action.context.overlaps(KeymapContext::Agents)
&& binding.chord.prefix.parts()
== key_hint::ctrl(KeyCode::Char('o')).parts()
}));
let mut agents = AgentsKeymap { let mut agents = AgentsKeymap {
resume: if resume_default_is_shadowed {
Vec::new()
} else {
resolve_local!(keymap, defaults, agents, resume)
},
search: resolve_local!(keymap, defaults, agents, search), search: resolve_local!(keymap, defaults, agents, search),
new_task: resolve_local!(keymap, defaults, agents, new_task), new_task: resolve_local!(keymap, defaults, agents, new_task),
rename: resolve_local!(keymap, defaults, agents, rename), rename: resolve_local!(keymap, defaults, agents, rename),
@@ -1334,6 +1348,7 @@ impl RuntimeKeymap {
}; };
for (configured, bindings) in [ for (configured, bindings) in [
(keymap.agents.resume.as_ref(), &mut agents.resume),
(keymap.agents.search.as_ref(), &mut agents.search), (keymap.agents.search.as_ref(), &mut agents.search),
(keymap.agents.new_task.as_ref(), &mut agents.new_task), (keymap.agents.new_task.as_ref(), &mut agents.new_task),
(keymap.agents.rename.as_ref(), &mut agents.rename), (keymap.agents.rename.as_ref(), &mut agents.rename),
@@ -1693,6 +1708,7 @@ impl RuntimeKeymap {
chord_hints: Arc::default(), chord_hints: Arc::default(),
}, },
agents: AgentsKeymap { agents: AgentsKeymap {
resume: default_bindings![ctrl(KeyCode::Char('o'))],
search: default_bindings![ctrl(KeyCode::Char('f'))], search: default_bindings![ctrl(KeyCode::Char('f'))],
new_task: default_bindings![ctrl(KeyCode::Char('n'))], new_task: default_bindings![ctrl(KeyCode::Char('n'))],
rename: default_bindings![ctrl(KeyCode::Char('r'))], rename: default_bindings![ctrl(KeyCode::Char('r'))],
@@ -3310,6 +3326,7 @@ mod tests {
keymap.global.open_agents = Some(one("f12")); keymap.global.open_agents = Some(one("f12"));
keymap.agents.search = Some(one("f6")); keymap.agents.search = Some(one("f6"));
keymap.agents.new_task = Some(one("f7")); keymap.agents.new_task = Some(one("f7"));
keymap.agents.resume = Some(one("f5"));
keymap.agents.rename = Some(one("f9")); keymap.agents.rename = Some(one("f9"));
keymap.agents.stop = Some(one("f10")); keymap.agents.stop = Some(one("f10"));
keymap.agents.toggle_grouping = Some(one("f8")); keymap.agents.toggle_grouping = Some(one("f8"));
@@ -3320,6 +3337,7 @@ mod tests {
runtime.app.open_agents, runtime.app.open_agents,
runtime.agents.search, runtime.agents.search,
runtime.agents.new_task, runtime.agents.new_task,
runtime.agents.resume,
runtime.agents.rename, runtime.agents.rename,
runtime.agents.stop, runtime.agents.stop,
runtime.agents.toggle_grouping, runtime.agents.toggle_grouping,
@@ -3328,12 +3346,17 @@ mod tests {
vec![key_hint::plain(KeyCode::F(12))], vec![key_hint::plain(KeyCode::F(12))],
vec![key_hint::plain(KeyCode::F(6))], vec![key_hint::plain(KeyCode::F(6))],
vec![key_hint::plain(KeyCode::F(7))], vec![key_hint::plain(KeyCode::F(7))],
vec![key_hint::plain(KeyCode::F(5))],
vec![key_hint::plain(KeyCode::F(9))], vec![key_hint::plain(KeyCode::F(9))],
vec![key_hint::plain(KeyCode::F(10))], vec![key_hint::plain(KeyCode::F(10))],
vec![key_hint::plain(KeyCode::F(8))], vec![key_hint::plain(KeyCode::F(8))],
) )
); );
keymap.agents.resume = Some(one("f6"));
expect_conflict(&keymap, "resume", "search");
keymap.agents.resume = Some(one("f5"));
keymap.agents.toggle_grouping = Some(one("right")); keymap.agents.toggle_grouping = Some(one("right"));
let runtime = RuntimeKeymap::from_config(&keymap).expect("runtime keymap"); let runtime = RuntimeKeymap::from_config(&keymap).expect("runtime keymap");
assert!( assert!(
@@ -3428,6 +3451,20 @@ mod tests {
); );
} }
#[test]
fn agents_resume_default_yields_to_existing_custom_shortcuts() {
for binding in ["ctrl-o", "ctrl-o f6"] {
let mut keymap = TuiKeymap::default();
keymap.agents.search = Some(one(binding));
let runtime =
RuntimeKeymap::from_config(&keymap).expect("existing keymap remains valid");
assert!(runtime.agents.resume.is_empty());
keymap.agents.resume = Some(one("ctrl-o"));
assert!(RuntimeKeymap::from_config(&keymap).is_err());
}
}
#[test] #[test]
fn toggle_side_conversation_can_be_remapped_and_rejects_conflicts() { fn toggle_side_conversation_can_be_remapped_and_rejects_conflicts() {
let mut keymap = TuiKeymap::default(); let mut keymap = TuiKeymap::default();
+1
View File
@@ -383,6 +383,7 @@ define_runtime_action_bindings! {
cancel, cancel,
], ],
"agents" => Agents, agents, agents [ "agents" => Agents, agents, agents [
resume,
search, search,
new_task, new_task,
rename, rename,
+2
View File
@@ -210,6 +210,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
action("list", "List", "jump_bottom", "Jump to the last list item."), action("list", "List", "jump_bottom", "Jump to the last list item."),
action("list", "List", "accept", "Accept the current list selection."), action("list", "List", "accept", "Accept the current list selection."),
action("list", "List", "cancel", "Cancel and close selection views."), action("list", "List", "cancel", "Cancel and close selection views."),
action("agents", "Agents", "resume", "Open the session resume picker."),
action("agents", "Agents", "search", "Search the available agent tasks."), action("agents", "Agents", "search", "Search the available agent tasks."),
action("agents", "Agents", "new_task", "Start composing a new agent task."), action("agents", "Agents", "new_task", "Start composing a new agent task."),
action("agents", "Agents", "rename", "Rename the selected task."), action("agents", "Agents", "rename", "Rename the selected task."),
@@ -383,6 +384,7 @@ pub(super) fn binding_slot<'a>(
("list", "jump_bottom") => Some(&mut keymap.list.jump_bottom), ("list", "jump_bottom") => Some(&mut keymap.list.jump_bottom),
("list", "accept") => Some(&mut keymap.list.accept), ("list", "accept") => Some(&mut keymap.list.accept),
("list", "cancel") => Some(&mut keymap.list.cancel), ("list", "cancel") => Some(&mut keymap.list.cancel),
("agents", "resume") => Some(&mut keymap.agents.resume),
("agents", "search") => Some(&mut keymap.agents.search), ("agents", "search") => Some(&mut keymap.agents.search),
("agents", "new_task") => Some(&mut keymap.agents.new_task), ("agents", "new_task") => Some(&mut keymap.agents.new_task),
("agents", "rename") => Some(&mut keymap.agents.rename), ("agents", "rename") => Some(&mut keymap.agents.rename),
@@ -23,6 +23,6 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj
│ │
│ │
│ │
│
New task › Describe a task and press enter to dispatch it New task › Describe a task and press enter to dispatch it
↑↓ navigate enter open ctrl+f search ctrl+s group ctrl+r rename ctrl+x stop esc back ↑↓ navigate ctrl+o resume enter open ctrl+f search ctrl+s group ctrl+r rename ctrl+x stop
esc back
@@ -0,0 +1,28 @@
---
source: tui/src/app/agents_overview_tests.rs
expression: "render_bottom_popup(&app.chat_widget, 48)"
---
Agent command center
0 need input 0 working 0 ready
────────────────────────────────────────────
New task › Describe a task and press enter t
↑↓ navigate ctrl+o resume enter open ctrl+f
search ctrl+s group ctrl+r rename ctrl+x
stop esc back
@@ -0,0 +1,10 @@
---
source: tui/src/app/agents_overview_tests.rs
expression: "render_bottom_popup(&app.chat_widget, 96)"
---
Unable to resume session
The session is unavailable.
› 1. Return to command center
Press enter to confirm or esc to go back
@@ -12,3 +12,4 @@ Raw event: code=Char('o'), modifiers=ctrl, kind=Press
Assigned actions: Assigned actions:
- global.copy (Copy) - Copy the last agent response to the clipboard. [Default] - global.copy (Copy) - Copy the last agent response to the clipboard. [Default]
- agents.resume (Resume) - Open the session resume picker. [Default]
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap Keymap
All configurable shortcuts. All configurable shortcuts.
138 actions, 1 customized, 5 unbound. 139 actions, 1 customized, 5 unbound.
[All] Common Customized (1) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug [All] Common Customized (1) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap Keymap
All configurable shortcuts. All configurable shortcuts.
139 actions, 0 customized, 6 unbound. 140 actions, 0 customized, 6 unbound.
[All] Common Customized (0) Unbound (6) App Composer Editor Vim Navigation Agents Approval Debug [All] Common Customized (0) Unbound (6) App Composer Editor Vim Navigation Agents Approval Debug
@@ -2,7 +2,7 @@
source: tui/src/keymap_setup.rs source: tui/src/keymap_setup.rs
expression: snapshot expression: snapshot
--- ---
tab: All (138 selectable) tab: All (139 selectable)
tab: Common (20 selectable) tab: Common (20 selectable)
tab: Customized (0) (0 selectable) tab: Customized (0) (0 selectable)
tab: Unbound (5) (5 selectable) tab: Unbound (5) (5 selectable)
@@ -11,7 +11,7 @@ tab: Composer (5 selectable)
tab: Editor (17 selectable) tab: Editor (17 selectable)
tab: Vim (69 selectable) tab: Vim (69 selectable)
tab: Navigation (20 selectable) tab: Navigation (20 selectable)
tab: Agents (5 selectable) tab: Agents (6 selectable)
tab: Approval (8 selectable) tab: Approval (8 selectable)
tab: Debug (1 selectable) tab: Debug (1 selectable)
Open Agents | unbound | Global open_agents Open Agents Open the shared agent-session overview. unbound Default Open Agents | unbound | Global open_agents Open Agents Open the shared agent-session overview. unbound Default
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap Keymap
All configurable shortcuts. All configurable shortcuts.
138 actions, 1 customized, 5 unbound. 139 actions, 1 customized, 5 unbound.
[All] Common Customized (1) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug [All] Common Customized (1) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug
@@ -5,7 +5,7 @@ expression: "render_picker(params, 78)"
Keymap Keymap
All configurable shortcuts. All configurable shortcuts.
138 actions, 0 customized, 5 unbound. 139 actions, 0 customized, 5 unbound.
[All] Common Customized (0) Unbound (5) App Composer Editor Vim [All] Common Customized (0) Unbound (5) App Composer Editor Vim
Navigation Agents Approval Debug Navigation Agents Approval Debug
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap Keymap
All configurable shortcuts. All configurable shortcuts.
138 actions, 0 customized, 5 unbound. 139 actions, 0 customized, 5 unbound.
[All] Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug [All] Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug