mirror of
https://github.com/openai/codex.git
synced 2026-11-09 08:40:33 +00:00
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:
1 parent
b27a6321fa
commit
1d741742c5
25 files changed
+712
-170
No files matched your search
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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}"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
+2
-2
@@ -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
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
+2
-2
@@ -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
|
||||||
+28
@@ -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
|
||||||
+10
@@ -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
|
||||||
+1
@@ -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]
|
||||||
+1
-1
@@ -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
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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
-2
@@ -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
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user