Skip to content

Commit 679b033

Browse files
charley-oaicodex
andcommitted
tui: canonicalize interactive slash drafts
Route interactive slash-command pickers through canonical serialized drafts so live dispatch and queued replay share one parser/executor path. Validation: cargo test -p codex-tui; just fix -p codex-tui; just fmt Co-authored-by: Codex <noreply@openai.com>
1 parent 9ad54a4 commit 679b033

File tree

11 files changed

+777
-305
lines changed

11 files changed

+777
-305
lines changed

codex-rs/tui/src/app.rs

Lines changed: 120 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ use crate::pager_overlay::Overlay;
3434
use crate::render::highlight::highlight_bash_to_lines;
3535
use crate::render::renderable::Renderable;
3636
use crate::resume_picker::SessionSelection;
37+
use crate::resume_picker::SessionTarget;
3738
use crate::tui;
3839
use crate::tui::TuiEvent;
3940
use crate::update_action::UpdateAction;
@@ -51,6 +52,7 @@ use codex_core::config::edit::ConfigEditsBuilder;
5152
use codex_core::config::types::ModelAvailabilityNuxConfig;
5253
use codex_core::config_loader::ConfigLayerStackOrdering;
5354
use codex_core::features::Feature;
55+
use codex_core::find_thread_path_by_id_str;
5456
use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig;
5557
use codex_core::models_manager::manager::RefreshStrategy;
5658
use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG;
@@ -809,6 +811,99 @@ impl App {
809811
}
810812
}
811813

814+
async fn resume_session_target(
815+
&mut self,
816+
tui: &mut tui::Tui,
817+
target_session: SessionTarget,
818+
) -> Result<()> {
819+
let current_cwd = self.config.cwd.clone();
820+
let resume_cwd = match crate::resolve_cwd_for_resume_or_fork(
821+
tui,
822+
&self.config,
823+
&current_cwd,
824+
target_session.thread_id,
825+
&target_session.path,
826+
CwdPromptAction::Resume,
827+
true,
828+
)
829+
.await?
830+
{
831+
crate::ResolveCwdOutcome::Continue(Some(cwd)) => cwd,
832+
crate::ResolveCwdOutcome::Continue(None) => current_cwd.clone(),
833+
crate::ResolveCwdOutcome::Exit => return Ok(()),
834+
};
835+
let mut resume_config = match self
836+
.rebuild_config_for_resume_or_fallback(&current_cwd, resume_cwd)
837+
.await
838+
{
839+
Ok(cfg) => cfg,
840+
Err(err) => {
841+
self.chat_widget.add_error_message(format!(
842+
"Failed to rebuild configuration for resume: {err}"
843+
));
844+
return Ok(());
845+
}
846+
};
847+
self.apply_runtime_policy_overrides(&mut resume_config);
848+
let summary = session_summary(
849+
self.chat_widget.token_usage(),
850+
self.chat_widget.thread_id(),
851+
self.chat_widget.thread_name(),
852+
);
853+
match self
854+
.server
855+
.resume_thread_from_rollout(
856+
resume_config.clone(),
857+
target_session.path.clone(),
858+
self.auth_manager.clone(),
859+
)
860+
.await
861+
{
862+
Ok(resumed) => {
863+
self.shutdown_current_thread().await;
864+
self.config = resume_config;
865+
tui.set_notification_method(self.config.tui_notification_method);
866+
self.file_search.update_search_dir(self.config.cwd.clone());
867+
let init =
868+
self.chatwidget_init_for_forked_or_resumed_thread(tui, self.config.clone());
869+
self.chat_widget =
870+
ChatWidget::new_from_existing(init, resumed.thread, resumed.session_configured);
871+
self.reset_thread_event_state();
872+
if let Some(summary) = summary {
873+
let mut lines: Vec<Line<'static>> = vec![summary.usage_line.clone().into()];
874+
if let Some(command) = summary.resume_command {
875+
let spans = vec!["To continue this session, run ".into(), command.cyan()];
876+
lines.push(spans.into());
877+
}
878+
self.chat_widget.add_plain_history_lines(lines);
879+
}
880+
}
881+
Err(err) => {
882+
let path_display = target_session.path.display();
883+
self.chat_widget.add_error_message(format!(
884+
"Failed to resume session from {path_display}: {err}"
885+
));
886+
}
887+
}
888+
Ok(())
889+
}
890+
891+
async fn resume_session_by_thread_id(
892+
&mut self,
893+
tui: &mut tui::Tui,
894+
thread_id: ThreadId,
895+
) -> Result<()> {
896+
let Some(path) =
897+
find_thread_path_by_id_str(&self.config.codex_home, &thread_id.to_string()).await?
898+
else {
899+
self.chat_widget
900+
.add_error_message(format!("No saved session found for thread {thread_id}."));
901+
return Ok(());
902+
};
903+
self.resume_session_target(tui, SessionTarget { path, thread_id })
904+
.await
905+
}
906+
812907
fn apply_runtime_policy_overrides(&mut self, config: &mut Config) {
813908
if let Some(policy) = self.runtime_approval_policy_override.as_ref()
814909
&& let Err(err) = config.permissions.approval_policy.set(*policy)
@@ -1397,7 +1492,13 @@ impl App {
13971492
description: Some(uuid.clone()),
13981493
is_current: self.active_thread_id == Some(*thread_id),
13991494
actions: vec![Box::new(move |tx| {
1400-
tx.send(AppEvent::SelectAgentThread(id));
1495+
tx.send(AppEvent::HandleSlashCommandDraft(
1496+
format!(
1497+
"/{} {id}",
1498+
crate::slash_command::SlashCommand::Agent.command()
1499+
)
1500+
.into(),
1501+
));
14011502
})],
14021503
dismiss_on_select: true,
14031504
search_value: Some(format!("{name} {uuid}")),
@@ -2110,86 +2211,15 @@ impl App {
21102211
AppEvent::OpenResumePicker => {
21112212
match crate::resume_picker::run_resume_picker(tui, &self.config, false).await? {
21122213
SessionSelection::Resume(target_session) => {
2113-
let current_cwd = self.config.cwd.clone();
2114-
let resume_cwd = match crate::resolve_cwd_for_resume_or_fork(
2115-
tui,
2116-
&self.config,
2117-
&current_cwd,
2118-
target_session.thread_id,
2119-
&target_session.path,
2120-
CwdPromptAction::Resume,
2121-
true,
2122-
)
2123-
.await?
2124-
{
2125-
crate::ResolveCwdOutcome::Continue(Some(cwd)) => cwd,
2126-
crate::ResolveCwdOutcome::Continue(None) => current_cwd.clone(),
2127-
crate::ResolveCwdOutcome::Exit => {
2128-
return Ok(AppRunControl::Exit(ExitReason::UserRequested));
2129-
}
2130-
};
2131-
let mut resume_config = match self
2132-
.rebuild_config_for_resume_or_fallback(&current_cwd, resume_cwd)
2133-
.await
2134-
{
2135-
Ok(cfg) => cfg,
2136-
Err(err) => {
2137-
self.chat_widget.add_error_message(format!(
2138-
"Failed to rebuild configuration for resume: {err}"
2139-
));
2140-
return Ok(AppRunControl::Continue);
2141-
}
2142-
};
2143-
self.apply_runtime_policy_overrides(&mut resume_config);
2144-
let summary = session_summary(
2145-
self.chat_widget.token_usage(),
2146-
self.chat_widget.thread_id(),
2147-
self.chat_widget.thread_name(),
2148-
);
2149-
match self
2150-
.server
2151-
.resume_thread_from_rollout(
2152-
resume_config.clone(),
2153-
target_session.path.clone(),
2154-
self.auth_manager.clone(),
2214+
self.chat_widget.handle_serialized_slash_command(
2215+
format!(
2216+
"/{} {}",
2217+
crate::slash_command::SlashCommand::Resume.command(),
2218+
target_session.thread_id
21552219
)
2156-
.await
2157-
{
2158-
Ok(resumed) => {
2159-
self.shutdown_current_thread().await;
2160-
self.config = resume_config;
2161-
tui.set_notification_method(self.config.tui_notification_method);
2162-
self.file_search.update_search_dir(self.config.cwd.clone());
2163-
let init = self.chatwidget_init_for_forked_or_resumed_thread(
2164-
tui,
2165-
self.config.clone(),
2166-
);
2167-
self.chat_widget = ChatWidget::new_from_existing(
2168-
init,
2169-
resumed.thread,
2170-
resumed.session_configured,
2171-
);
2172-
self.reset_thread_event_state();
2173-
if let Some(summary) = summary {
2174-
let mut lines: Vec<Line<'static>> =
2175-
vec![summary.usage_line.clone().into()];
2176-
if let Some(command) = summary.resume_command {
2177-
let spans = vec![
2178-
"To continue this session, run ".into(),
2179-
command.cyan(),
2180-
];
2181-
lines.push(spans.into());
2182-
}
2183-
self.chat_widget.add_plain_history_lines(lines);
2184-
}
2185-
}
2186-
Err(err) => {
2187-
let path_display = target_session.path.display();
2188-
self.chat_widget.add_error_message(format!(
2189-
"Failed to resume session from {path_display}: {err}"
2190-
));
2191-
}
2192-
}
2220+
.into(),
2221+
);
2222+
self.refresh_status_line();
21932223
}
21942224
SessionSelection::Exit
21952225
| SessionSelection::StartFresh
@@ -2199,6 +2229,9 @@ impl App {
21992229
// Leaving alt-screen may blank the inline viewport; force a redraw either way.
22002230
tui.frame_requester().schedule_frame();
22012231
}
2232+
AppEvent::ResumeSession(thread_id) => {
2233+
self.resume_session_by_thread_id(tui, thread_id).await?;
2234+
}
22022235
AppEvent::ForkCurrentSession => {
22032236
self.session_telemetry.counter(
22042237
"codex.thread.fork",
@@ -3132,9 +3165,6 @@ impl App {
31323165
AppEvent::SelectAgentThread(thread_id) => {
31333166
self.select_agent_thread(tui, thread_id).await?;
31343167
}
3135-
AppEvent::OpenSkillsList => {
3136-
self.chat_widget.open_skills_list();
3137-
}
31383168
AppEvent::OpenManageSkillsPopup => {
31393169
self.chat_widget.open_manage_skills_popup();
31403170
}
@@ -3793,6 +3823,7 @@ mod tests {
37933823
use crate::app_backtrack::BacktrackSelection;
37943824
use crate::app_backtrack::BacktrackState;
37953825
use crate::app_backtrack::user_count;
3826+
use crate::chatwidget::UserMessage;
37963827
use crate::chatwidget::tests::make_chatwidget_manual_with_sender;
37973828
use crate::file_search::FileSearchManager;
37983829
use crate::history_cell::AgentMessageCell;
@@ -5083,10 +5114,12 @@ mod tests {
50835114
app.chat_widget
50845115
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
50855116

5086-
assert_matches!(
5087-
app_event_rx.try_recv(),
5088-
Ok(AppEvent::SelectAgentThread(selected_thread_id)) if selected_thread_id == thread_id
5089-
);
5117+
match app_event_rx.try_recv() {
5118+
Ok(AppEvent::HandleSlashCommandDraft(draft)) => {
5119+
assert_eq!(draft, UserMessage::from(format!("/agent {thread_id}")));
5120+
}
5121+
other => panic!("expected serialized agent slash draft, got {other:?}"),
5122+
}
50905123
Ok(())
50915124
}
50925125

codex-rs/tui/src/app_event.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,9 @@ pub(crate) enum AppEvent {
9797
/// Open the resume picker inside the running TUI session.
9898
OpenResumePicker,
9999

100+
/// Resume a saved session by thread id.
101+
ResumeSession(ThreadId),
102+
100103
/// Fork the current session into a new thread.
101104
ForkCurrentSession,
102105

@@ -362,9 +365,6 @@ pub(crate) enum AppEvent {
362365
/// Re-open the approval presets popup.
363366
OpenApprovalsPopup,
364367

365-
/// Open the skills list popup.
366-
OpenSkillsList,
367-
368368
/// Open the skills enable/disable picker.
369369
OpenManageSkillsPopup,
370370

codex-rs/tui/src/bottom_pane/chat_composer.rs

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2528,10 +2528,6 @@ impl ChatComposer {
25282528

25292529
let cmd = slash_commands::find_builtin_command(name, self.builtin_command_flags())?;
25302530

2531-
if !cmd.supports_inline_args() {
2532-
return None;
2533-
}
2534-
25352531
let mut args_elements =
25362532
Self::slash_command_args_elements(rest, rest_offset, &self.textarea.text_elements());
25372533
let trimmed_rest = rest.trim();

codex-rs/tui/src/bottom_pane/experimental_features_view.rs

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,9 @@ use crate::render::Insets;
1717
use crate::render::RectExt as _;
1818
use crate::render::renderable::ColumnRenderable;
1919
use crate::render::renderable::Renderable;
20+
use crate::slash_command::SlashCommand;
2021
use crate::style::user_message_style;
2122

22-
use codex_core::features::Feature;
23-
2423
use super::CancellationEvent;
2524
use super::bottom_pane_view::BottomPaneView;
2625
use super::popup_consts::MAX_POPUP_ROWS;
@@ -30,7 +29,7 @@ use super::selection_popup_common::measure_rows_height;
3029
use super::selection_popup_common::render_rows;
3130

3231
pub(crate) struct ExperimentalFeatureItem {
33-
pub feature: Feature,
32+
pub key: String,
3433
pub name: String,
3534
pub description: String,
3635
pub enabled: bool,
@@ -198,15 +197,16 @@ impl BottomPaneView for ExperimentalFeaturesView {
198197
}
199198

200199
fn on_ctrl_c(&mut self) -> CancellationEvent {
201-
// Save the updates
202200
if !self.features.is_empty() {
203-
let updates = self
201+
let args = self
204202
.features
205203
.iter()
206-
.map(|item| (item.feature, item.enabled))
207-
.collect();
208-
self.app_event_tx
209-
.send(AppEvent::UpdateFeatureFlags { updates });
204+
.map(|item| format!("{}={}", item.key, if item.enabled { "on" } else { "off" }))
205+
.collect::<Vec<_>>()
206+
.join(" ");
207+
self.app_event_tx.send(AppEvent::HandleSlashCommandDraft(
208+
format!("/{} {args}", SlashCommand::Experimental.command()).into(),
209+
));
210210
}
211211

212212
self.complete = true;

codex-rs/tui/src/bottom_pane/feedback_view.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -485,8 +485,17 @@ fn make_feedback_item(
485485
description: &str,
486486
category: FeedbackCategory,
487487
) -> super::SelectionItem {
488+
let token = match category {
489+
FeedbackCategory::Bug => "bug",
490+
FeedbackCategory::BadResult => "bad-result",
491+
FeedbackCategory::GoodResult => "good-result",
492+
FeedbackCategory::SafetyCheck => "safety-check",
493+
FeedbackCategory::Other => "other",
494+
};
488495
let action: super::SelectionAction = Box::new(move |_sender: &AppEventSender| {
489-
app_event_tx.send(AppEvent::OpenFeedbackConsent { category });
496+
app_event_tx.send(AppEvent::HandleSlashCommandDraft(
497+
format!("/feedback {token}").into(),
498+
));
490499
});
491500
super::SelectionItem {
492501
name: name.to_string(),

codex-rs/tui/src/bottom_pane/status_line_setup.rs

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ use crate::bottom_pane::bottom_pane_view::BottomPaneView;
3434
use crate::bottom_pane::multi_select_picker::MultiSelectItem;
3535
use crate::bottom_pane::multi_select_picker::MultiSelectPicker;
3636
use crate::render::renderable::Renderable;
37+
use crate::slash_command::SlashCommand;
3738

3839
/// Available items that can be displayed in the status line.
3940
///
@@ -231,12 +232,14 @@ impl StatusLineSetupView {
231232
.enable_ordering()
232233
.on_preview(move |items| preview_data.line_for_items(items))
233234
.on_confirm(|ids, app_event| {
234-
let items = ids
235-
.iter()
236-
.map(|id| id.parse::<StatusLineItem>())
237-
.collect::<Result<Vec<_>, _>>()
238-
.unwrap_or_default();
239-
app_event.send(AppEvent::StatusLineSetup { items });
235+
let args = if ids.is_empty() {
236+
"none".to_string()
237+
} else {
238+
ids.join(" ")
239+
};
240+
app_event.send(AppEvent::HandleSlashCommandDraft(
241+
format!("/{} {args}", SlashCommand::Statusline.command()).into(),
242+
));
240243
})
241244
.on_cancel(|app_event| {
242245
app_event.send(AppEvent::StatusLineSetupCancelled);

0 commit comments

Comments
 (0)