Skip to content

Commit 51966cc

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 8520605 commit 51966cc

File tree

11 files changed

+777
-306
lines changed

11 files changed

+777
-306
lines changed

codex-rs/tui/src/app.rs

Lines changed: 120 additions & 88 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;
@@ -812,6 +814,99 @@ impl App {
812814
}
813815
}
814816

817+
async fn resume_session_target(
818+
&mut self,
819+
tui: &mut tui::Tui,
820+
target_session: SessionTarget,
821+
) -> Result<()> {
822+
let current_cwd = self.config.cwd.clone();
823+
let resume_cwd = match crate::resolve_cwd_for_resume_or_fork(
824+
tui,
825+
&self.config,
826+
&current_cwd,
827+
target_session.thread_id,
828+
&target_session.path,
829+
CwdPromptAction::Resume,
830+
true,
831+
)
832+
.await?
833+
{
834+
crate::ResolveCwdOutcome::Continue(Some(cwd)) => cwd,
835+
crate::ResolveCwdOutcome::Continue(None) => current_cwd.clone(),
836+
crate::ResolveCwdOutcome::Exit => return Ok(()),
837+
};
838+
let mut resume_config = match self
839+
.rebuild_config_for_resume_or_fallback(&current_cwd, resume_cwd)
840+
.await
841+
{
842+
Ok(cfg) => cfg,
843+
Err(err) => {
844+
self.chat_widget.add_error_message(format!(
845+
"Failed to rebuild configuration for resume: {err}"
846+
));
847+
return Ok(());
848+
}
849+
};
850+
self.apply_runtime_policy_overrides(&mut resume_config);
851+
let summary = session_summary(
852+
self.chat_widget.token_usage(),
853+
self.chat_widget.thread_id(),
854+
self.chat_widget.thread_name(),
855+
);
856+
match self
857+
.server
858+
.resume_thread_from_rollout(
859+
resume_config.clone(),
860+
target_session.path.clone(),
861+
self.auth_manager.clone(),
862+
)
863+
.await
864+
{
865+
Ok(resumed) => {
866+
self.shutdown_current_thread().await;
867+
self.config = resume_config;
868+
tui.set_notification_method(self.config.tui_notification_method);
869+
self.file_search.update_search_dir(self.config.cwd.clone());
870+
let init =
871+
self.chatwidget_init_for_forked_or_resumed_thread(tui, self.config.clone());
872+
self.chat_widget =
873+
ChatWidget::new_from_existing(init, resumed.thread, resumed.session_configured);
874+
self.reset_thread_event_state();
875+
if let Some(summary) = summary {
876+
let mut lines: Vec<Line<'static>> = vec![summary.usage_line.clone().into()];
877+
if let Some(command) = summary.resume_command {
878+
let spans = vec!["To continue this session, run ".into(), command.cyan()];
879+
lines.push(spans.into());
880+
}
881+
self.chat_widget.add_plain_history_lines(lines);
882+
}
883+
}
884+
Err(err) => {
885+
let path_display = target_session.path.display();
886+
self.chat_widget.add_error_message(format!(
887+
"Failed to resume session from {path_display}: {err}"
888+
));
889+
}
890+
}
891+
Ok(())
892+
}
893+
894+
async fn resume_session_by_thread_id(
895+
&mut self,
896+
tui: &mut tui::Tui,
897+
thread_id: ThreadId,
898+
) -> Result<()> {
899+
let Some(path) =
900+
find_thread_path_by_id_str(&self.config.codex_home, &thread_id.to_string()).await?
901+
else {
902+
self.chat_widget
903+
.add_error_message(format!("No saved session found for thread {thread_id}."));
904+
return Ok(());
905+
};
906+
self.resume_session_target(tui, SessionTarget { path, thread_id })
907+
.await
908+
}
909+
815910
fn apply_runtime_policy_overrides(&mut self, config: &mut Config) {
816911
if let Some(policy) = self.runtime_approval_policy_override.as_ref()
817912
&& let Err(err) = config.permissions.approval_policy.set(*policy)
@@ -1426,7 +1521,13 @@ impl App {
14261521
description: Some(uuid.clone()),
14271522
is_current: self.active_thread_id == Some(*thread_id),
14281523
actions: vec![Box::new(move |tx| {
1429-
tx.send(AppEvent::SelectAgentThread(id));
1524+
tx.send(AppEvent::HandleSlashCommandDraft(
1525+
format!(
1526+
"/{} {id}",
1527+
crate::slash_command::SlashCommand::Agent.command()
1528+
)
1529+
.into(),
1530+
));
14301531
})],
14311532
dismiss_on_select: true,
14321533
search_value: Some(format!("{name} {uuid}")),
@@ -2150,87 +2251,15 @@ impl App {
21502251
AppEvent::OpenResumePicker => {
21512252
match crate::resume_picker::run_resume_picker(tui, &self.config, false).await? {
21522253
SessionSelection::Resume(target_session) => {
2153-
let current_cwd = self.config.cwd.clone();
2154-
let resume_cwd = match crate::resolve_cwd_for_resume_or_fork(
2155-
tui,
2156-
&self.config,
2157-
&current_cwd,
2158-
target_session.thread_id,
2159-
&target_session.path,
2160-
CwdPromptAction::Resume,
2161-
true,
2162-
)
2163-
.await?
2164-
{
2165-
crate::ResolveCwdOutcome::Continue(Some(cwd)) => cwd,
2166-
crate::ResolveCwdOutcome::Continue(None) => current_cwd.clone(),
2167-
crate::ResolveCwdOutcome::Exit => {
2168-
return Ok(AppRunControl::Exit(ExitReason::UserRequested));
2169-
}
2170-
};
2171-
let mut resume_config = match self
2172-
.rebuild_config_for_resume_or_fallback(&current_cwd, resume_cwd)
2173-
.await
2174-
{
2175-
Ok(cfg) => cfg,
2176-
Err(err) => {
2177-
self.chat_widget.add_error_message(format!(
2178-
"Failed to rebuild configuration for resume: {err}"
2179-
));
2180-
return Ok(AppRunControl::Continue);
2181-
}
2182-
};
2183-
self.apply_runtime_policy_overrides(&mut resume_config);
2184-
let summary = session_summary(
2185-
self.chat_widget.token_usage(),
2186-
self.chat_widget.thread_id(),
2187-
self.chat_widget.thread_name(),
2188-
);
2189-
match self
2190-
.server
2191-
.resume_thread_from_rollout(
2192-
resume_config.clone(),
2193-
target_session.path.clone(),
2194-
self.auth_manager.clone(),
2195-
None,
2254+
self.chat_widget.handle_serialized_slash_command(
2255+
format!(
2256+
"/{} {}",
2257+
crate::slash_command::SlashCommand::Resume.command(),
2258+
target_session.thread_id
21962259
)
2197-
.await
2198-
{
2199-
Ok(resumed) => {
2200-
self.shutdown_current_thread().await;
2201-
self.config = resume_config;
2202-
tui.set_notification_method(self.config.tui_notification_method);
2203-
self.file_search.update_search_dir(self.config.cwd.clone());
2204-
let init = self.chatwidget_init_for_forked_or_resumed_thread(
2205-
tui,
2206-
self.config.clone(),
2207-
);
2208-
self.chat_widget = ChatWidget::new_from_existing(
2209-
init,
2210-
resumed.thread,
2211-
resumed.session_configured,
2212-
);
2213-
self.reset_thread_event_state();
2214-
if let Some(summary) = summary {
2215-
let mut lines: Vec<Line<'static>> =
2216-
vec![summary.usage_line.clone().into()];
2217-
if let Some(command) = summary.resume_command {
2218-
let spans = vec![
2219-
"To continue this session, run ".into(),
2220-
command.cyan(),
2221-
];
2222-
lines.push(spans.into());
2223-
}
2224-
self.chat_widget.add_plain_history_lines(lines);
2225-
}
2226-
}
2227-
Err(err) => {
2228-
let path_display = target_session.path.display();
2229-
self.chat_widget.add_error_message(format!(
2230-
"Failed to resume session from {path_display}: {err}"
2231-
));
2232-
}
2233-
}
2260+
.into(),
2261+
);
2262+
self.refresh_status_line();
22342263
}
22352264
SessionSelection::Exit
22362265
| SessionSelection::StartFresh
@@ -2240,6 +2269,9 @@ impl App {
22402269
// Leaving alt-screen may blank the inline viewport; force a redraw either way.
22412270
tui.frame_requester().schedule_frame();
22422271
}
2272+
AppEvent::ResumeSession(thread_id) => {
2273+
self.resume_session_by_thread_id(tui, thread_id).await?;
2274+
}
22432275
AppEvent::ForkCurrentSession => {
22442276
self.session_telemetry.counter(
22452277
"codex.thread.fork",
@@ -3176,9 +3208,6 @@ impl App {
31763208
AppEvent::SelectAgentThread(thread_id) => {
31773209
self.select_agent_thread(tui, thread_id).await?;
31783210
}
3179-
AppEvent::OpenSkillsList => {
3180-
self.chat_widget.open_skills_list();
3181-
}
31823211
AppEvent::OpenManageSkillsPopup => {
31833212
self.chat_widget.open_manage_skills_popup();
31843213
}
@@ -3875,6 +3904,7 @@ mod tests {
38753904
use crate::app_backtrack::BacktrackSelection;
38763905
use crate::app_backtrack::BacktrackState;
38773906
use crate::app_backtrack::user_count;
3907+
use crate::chatwidget::UserMessage;
38783908
use crate::chatwidget::tests::make_chatwidget_manual_with_sender;
38793909
use crate::chatwidget::tests::set_chatgpt_auth;
38803910
use crate::file_search::FileSearchManager;
@@ -5166,10 +5196,12 @@ mod tests {
51665196
app.chat_widget
51675197
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
51685198

5169-
assert_matches!(
5170-
app_event_rx.try_recv(),
5171-
Ok(AppEvent::SelectAgentThread(selected_thread_id)) if selected_thread_id == thread_id
5172-
);
5199+
match app_event_rx.try_recv() {
5200+
Ok(AppEvent::HandleSlashCommandDraft(draft)) => {
5201+
assert_eq!(draft, UserMessage::from(format!("/agent {thread_id}")));
5202+
}
5203+
other => panic!("expected serialized agent slash draft, got {other:?}"),
5204+
}
51735205
Ok(())
51745206
}
51755207

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
@@ -2533,10 +2533,6 @@ impl ChatComposer {
25332533

25342534
let cmd = slash_commands::find_builtin_command(name, self.builtin_command_flags())?;
25352535

2536-
if !cmd.supports_inline_args() {
2537-
return None;
2538-
}
2539-
25402536
let mut args_elements =
25412537
Self::slash_command_args_elements(rest, rest_offset, &self.textarea.text_elements());
25422538
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)