diff --git a/codex-rs/app-server/tests/common/rollout.rs b/codex-rs/app-server/tests/common/rollout.rs index 31d435663..72bc07753 100644 --- a/codex-rs/app-server/tests/common/rollout.rs +++ b/codex-rs/app-server/tests/common/rollout.rs @@ -52,6 +52,7 @@ pub fn create_fake_rollout( // Build JSONL lines let meta = SessionMeta { id: conversation_id, + forked_from_id: None, timestamp: meta_rfc3339.to_string(), cwd: PathBuf::from("/"), originator: "codex".to_string(), @@ -129,6 +130,7 @@ pub fn create_fake_rollout_with_text_elements( // Build JSONL lines let meta = SessionMeta { id: conversation_id, + forked_from_id: None, timestamp: meta_rfc3339.to_string(), cwd: PathBuf::from("/"), originator: "codex".to_string(), diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 61fb48f69..00aa70aa6 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -588,6 +588,8 @@ impl Session { )); } + let forked_from_id = initial_history.forked_from_id(); + let (conversation_id, rollout_params) = match &initial_history { InitialHistory::New | InitialHistory::Forked(_) => { let conversation_id = ThreadId::default(); @@ -595,6 +597,7 @@ impl Session { conversation_id, RolloutRecorderParams::new( conversation_id, + forked_from_id, session_configuration.user_instructions.clone(), session_source, ), @@ -733,6 +736,7 @@ impl Session { id: INITIAL_SUBMIT_ID.to_owned(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: conversation_id, + forked_from_id, model: session_configuration.model.clone(), model_provider_id: config.model_provider_id.clone(), approval_policy: session_configuration.approval_policy.value(), diff --git a/codex-rs/core/src/rollout/recorder.rs b/codex-rs/core/src/rollout/recorder.rs index 3bde40a73..13eace9f3 100644 --- a/codex-rs/core/src/rollout/recorder.rs +++ b/codex-rs/core/src/rollout/recorder.rs @@ -57,6 +57,7 @@ pub struct RolloutRecorder { pub enum RolloutRecorderParams { Create { conversation_id: ThreadId, + forked_from_id: Option, instructions: Option, source: SessionSource, }, @@ -79,11 +80,13 @@ enum RolloutCmd { impl RolloutRecorderParams { pub fn new( conversation_id: ThreadId, + forked_from_id: Option, instructions: Option, source: SessionSource, ) -> Self { Self::Create { conversation_id, + forked_from_id, instructions, source, } @@ -158,6 +161,7 @@ impl RolloutRecorder { let (file, rollout_path, meta) = match params { RolloutRecorderParams::Create { conversation_id, + forked_from_id, instructions, source, } => { @@ -181,6 +185,7 @@ impl RolloutRecorder { path, Some(SessionMeta { id: session_id, + forked_from_id, timestamp, cwd: config.cwd.clone(), originator: originator().value, diff --git a/codex-rs/core/src/rollout/tests.rs b/codex-rs/core/src/rollout/tests.rs index d0675e97e..660e83e56 100644 --- a/codex-rs/core/src/rollout/tests.rs +++ b/codex-rs/core/src/rollout/tests.rs @@ -641,6 +641,7 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> { item: RolloutItem::SessionMeta(SessionMetaLine { meta: SessionMeta { id: conversation_id, + forked_from_id: None, timestamp: ts.to_string(), instructions: None, cwd: ".".into(), diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index e60fcba8e..e0b250966 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -75,6 +75,7 @@ fn session_configured_produces_thread_started_event() { "e1", EventMsg::SessionConfigured(SessionConfiguredEvent { session_id, + forked_from_id: None, model: "codex-mini-latest".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index 0b05bc360..b7c66592f 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -257,6 +257,7 @@ mod tests { id: "1".to_string(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: thread_id, + forked_from_id: None, model: "gpt-4o".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -296,6 +297,7 @@ mod tests { let rollout_file = NamedTempFile::new()?; let session_configured_event = SessionConfiguredEvent { session_id: conversation_id, + forked_from_id: None, model: "gpt-4o".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -359,6 +361,7 @@ mod tests { let rollout_file = NamedTempFile::new()?; let session_configured_event = SessionConfiguredEvent { session_id: thread_id, + forked_from_id: None, model: "gpt-4o".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index a08e30e22..6ddc0aeb5 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1376,6 +1376,22 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn forked_from_id(&self) -> Option { + match self { + InitialHistory::New => None, + InitialHistory::Resumed(resumed) => { + resumed.history.iter().find_map(|item| match item { + RolloutItem::SessionMeta(meta_line) => meta_line.meta.forked_from_id, + _ => None, + }) + } + InitialHistory::Forked(items) => items.iter().find_map(|item| match item { + RolloutItem::SessionMeta(meta_line) => Some(meta_line.meta.id), + _ => None, + }), + } + } + pub fn get_rollout_items(&self) -> Vec { match self { InitialHistory::New => Vec::new(), @@ -1459,6 +1475,8 @@ impl fmt::Display for SubAgentSource { #[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, TS)] pub struct SessionMeta { pub id: ThreadId, + #[serde(skip_serializing_if = "Option::is_none")] + pub forked_from_id: Option, pub timestamp: String, pub cwd: PathBuf, pub originator: String, @@ -1473,6 +1491,7 @@ impl Default for SessionMeta { fn default() -> Self { SessionMeta { id: ThreadId::default(), + forked_from_id: None, timestamp: String::new(), cwd: PathBuf::new(), originator: String::new(), @@ -2003,6 +2022,8 @@ pub struct SkillsListEntry { pub struct SessionConfiguredEvent { /// Name left as session_id instead of thread_id for backwards compatibility. pub session_id: ThreadId, + #[serde(skip_serializing_if = "Option::is_none")] + pub forked_from_id: Option, /// Tell the client what model is being queried. pub model: String, @@ -2376,6 +2397,7 @@ mod tests { id: "1234".to_string(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: conversation_id, + forked_from_id: None, model: "codex-mini-latest".to_string(), model_provider_id: "openai".to_string(), approval_policy: AskForApproval::Never, diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index adc48c1d1..0178b31b2 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1992,6 +1992,7 @@ mod tests { let make_header = |is_first| { let event = SessionConfiguredEvent { session_id: ThreadId::new(), + forked_from_id: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -2033,6 +2034,7 @@ mod tests { id: String::new(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: base_id, + forked_from_id: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -2075,6 +2077,7 @@ mod tests { let thread_id = ThreadId::new(); let event = SessionConfiguredEvent { session_id: thread_id, + forked_from_id: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index d301243ca..e1054d0a3 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -440,6 +440,7 @@ pub(crate) struct ChatWidget { // Previous status header to restore after a transient stream retry. retry_status_header: Option, thread_id: Option, + forked_from: Option, frame_requester: FrameRequester, // Whether to include the initial welcome banner on session configured show_welcome_banner: bool, @@ -600,6 +601,7 @@ impl ChatWidget { .set_history_metadata(event.history_log_id, event.history_entry_count); self.set_skills(None); self.thread_id = Some(event.session_id); + self.forked_from = event.forked_from_id; self.current_rollout_path = Some(event.rollout_path.clone()); let initial_messages = event.initial_messages.clone(); let model_for_header = event.model.clone(); @@ -1699,6 +1701,7 @@ impl ChatWidget { current_status_header: String::from("Working"), retry_status_header: None, thread_id: None, + forked_from: None, queued_user_messages: VecDeque::new(), show_welcome_banner: is_first_run, suppress_session_configured_redraw: false, @@ -1797,6 +1800,7 @@ impl ChatWidget { current_status_header: String::from("Working"), retry_status_header: None, thread_id: None, + forked_from: None, queued_user_messages: VecDeque::new(), show_welcome_banner: false, suppress_session_configured_redraw: true, @@ -2635,6 +2639,7 @@ impl ChatWidget { token_info, total_usage, &self.thread_id, + self.forked_from, self.rate_limit_snapshot.as_ref(), self.plan_type, Local::now(), diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 6b216e6d7..19d10ad51 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -131,6 +131,7 @@ async fn resumed_initial_messages_render_history() { let rollout_file = NamedTempFile::new().unwrap(); let configured = codex_core::protocol::SessionConfiguredEvent { session_id: conversation_id, + forked_from_id: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -428,6 +429,7 @@ async fn make_chatwidget_manual( current_status_header: String::from("Working"), retry_status_header: None, thread_id: None, + forked_from: None, frame_requester: FrameRequester::test_dummy(), show_welcome_banner: true, queued_user_messages: VecDeque::new(), diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 7174cf41c..b860b0394 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -66,6 +66,7 @@ struct StatusHistoryCell { model_provider: Option, account: Option, session_id: Option, + forked_from: Option, token_usage: StatusTokenUsageData, rate_limits: StatusRateLimitData, } @@ -77,6 +78,7 @@ pub(crate) fn new_status_output( token_info: Option<&TokenUsageInfo>, total_usage: &TokenUsage, session_id: &Option, + forked_from: Option, rate_limits: Option<&RateLimitSnapshotDisplay>, plan_type: Option, now: DateTime, @@ -89,6 +91,7 @@ pub(crate) fn new_status_output( token_info, total_usage, session_id, + forked_from, rate_limits, plan_type, now, @@ -106,6 +109,7 @@ impl StatusHistoryCell { token_info: Option<&TokenUsageInfo>, total_usage: &TokenUsage, session_id: &Option, + forked_from: Option, rate_limits: Option<&RateLimitSnapshotDisplay>, plan_type: Option, now: DateTime, @@ -134,6 +138,7 @@ impl StatusHistoryCell { let model_provider = format_model_provider(config); let account = compose_account_display(auth_manager, plan_type); let session_id = session_id.as_ref().map(std::string::ToString::to_string); + let forked_from = forked_from.map(|id| id.to_string()); let default_usage = TokenUsage::default(); let (context_usage, context_window) = match token_info { Some(info) => (&info.last_token_usage, info.model_context_window), @@ -163,6 +168,7 @@ impl StatusHistoryCell { model_provider, account, session_id, + forked_from, token_usage, rate_limits, } @@ -351,6 +357,9 @@ impl HistoryCell for StatusHistoryCell { if self.session_id.is_some() { push_label(&mut labels, &mut seen, "Session"); } + if self.session_id.is_some() && self.forked_from.is_some() { + push_label(&mut labels, &mut seen, "Forked from"); + } push_label(&mut labels, &mut seen, "Token usage"); if self.token_usage.context_window.is_some() { push_label(&mut labels, &mut seen, "Context window"); @@ -403,6 +412,11 @@ impl HistoryCell for StatusHistoryCell { if let Some(session) = self.session_id.as_ref() { lines.push(formatter.line("Session", vec![Span::from(session.clone())])); } + if self.session_id.is_some() + && let Some(forked_from) = self.forked_from.as_ref() + { + lines.push(formatter.line("Forked from", vec![Span::from(forked_from.clone())])); + } lines.push(Line::from(Vec::>::new())); // Hide token usage only for ChatGPT subscribers diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_forked_from.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_forked_from.snap new file mode 100644 index 000000000..18772b7d7 --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_forked_from.snap @@ -0,0 +1,24 @@ +--- +source: tui/src/status/tests.rs +expression: sanitized +--- +/status + +╭───────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Approval: on-request │ +│ Sandbox: read-only │ +│ Agents.md: │ +│ Session: 0f0f3c13-6cf9-4aa4-8b80-7d49c2f1be2e │ +│ Forked from: e9f18a88-8081-4e51-9d4e-8af5cde2d8dd │ +│ │ +│ Token usage: 1.2K total (800 input + 400 output) │ +│ Context window: 100% left (1.2K used / 272K) │ +│ Limits: data not available yet │ +╰───────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index bb16f1332..b01a13952 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -14,6 +14,7 @@ use codex_core::protocol::RateLimitWindow; use codex_core::protocol::SandboxPolicy; use codex_core::protocol::TokenUsage; use codex_core::protocol::TokenUsageInfo; +use codex_protocol::ThreadId; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::openai_models::ReasoningEffort; use insta::assert_snapshot; @@ -146,6 +147,7 @@ async fn status_snapshot_includes_reasoning_details() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -161,6 +163,57 @@ async fn status_snapshot_includes_reasoning_details() { assert_snapshot!(sanitized); } +#[tokio::test] +async fn status_snapshot_includes_forked_from() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config.model = Some("gpt-5.1-codex-max".to_string()); + config.model_provider_id = "openai".to_string(); + config.cwd = PathBuf::from("/workspace/tests"); + + let auth_manager = test_auth_manager(&config); + let usage = TokenUsage { + input_tokens: 800, + cached_input_tokens: 0, + output_tokens: 400, + reasoning_output_tokens: 0, + total_tokens: 1_200, + }; + + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 8, 9, 10, 11, 12) + .single() + .expect("valid time"); + + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let token_info = token_info_for(&model_slug, &config, &usage); + let session_id = + ThreadId::from_string("0f0f3c13-6cf9-4aa4-8b80-7d49c2f1be2e").expect("session id"); + let forked_from = + ThreadId::from_string("e9f18a88-8081-4e51-9d4e-8af5cde2d8dd").expect("forked id"); + + let composite = new_status_output( + &config, + &auth_manager, + Some(&token_info), + &usage, + &Some(session_id), + Some(forked_from), + None, + None, + captured_at, + &model_slug, + ); + let mut rendered_lines = render_lines(&composite.display_lines(80)); + if cfg!(windows) { + for line in &mut rendered_lines { + *line = line.replace('\\', "/"); + } + } + let sanitized = sanitize_directory(rendered_lines).join("\n"); + assert_snapshot!(sanitized); +} + #[tokio::test] async fn status_snapshot_includes_monthly_limit() { let temp_home = TempDir::new().expect("temp home"); @@ -202,6 +255,7 @@ async fn status_snapshot_includes_monthly_limit() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -246,6 +300,7 @@ async fn status_snapshot_shows_unlimited_credits() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -289,6 +344,7 @@ async fn status_snapshot_shows_positive_credits() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -332,6 +388,7 @@ async fn status_snapshot_hides_zero_credits() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -373,6 +430,7 @@ async fn status_snapshot_hides_when_has_no_credits_flag() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -416,6 +474,7 @@ async fn status_card_token_usage_excludes_cached_tokens() { &None, None, None, + None, now, &model_slug, ); @@ -470,6 +529,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -517,6 +577,7 @@ async fn status_snapshot_shows_missing_limits_message() { &None, None, None, + None, now, &model_slug, ); @@ -578,6 +639,7 @@ async fn status_snapshot_includes_credits_and_limits() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -629,6 +691,7 @@ async fn status_snapshot_shows_empty_limits_message() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -689,6 +752,7 @@ async fn status_snapshot_shows_stale_limits_message() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, now, @@ -753,6 +817,7 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, now, @@ -809,6 +874,7 @@ async fn status_context_window_uses_last_usage() { &None, None, None, + None, now, &model_slug, ); diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index 52bf41641..5b6686905 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -2627,6 +2627,7 @@ mod tests { let make_header = |is_first| { let event = SessionConfiguredEvent { session_id: ThreadId::new(), + forked_from_id: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -2668,6 +2669,7 @@ mod tests { id: String::new(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: base_id, + forked_from_id: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -2950,6 +2952,7 @@ mod tests { let conversation_id = ThreadId::new(); let event = SessionConfiguredEvent { session_id: conversation_id, + forked_from_id: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index ab40b19c7..c0944fa08 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -383,6 +383,7 @@ pub(crate) struct ChatWidget { // Previous status header to restore after a transient stream retry. retry_status_header: Option, conversation_id: Option, + forked_from: Option, frame_requester: FrameRequester, // Whether to include the initial welcome banner on session configured show_welcome_banner: bool, @@ -522,6 +523,7 @@ impl ChatWidget { .set_history_metadata(event.history_log_id, event.history_entry_count); self.set_skills(None); self.conversation_id = Some(event.session_id); + self.forked_from = event.forked_from_id; self.current_rollout_path = Some(event.rollout_path.clone()); let initial_messages = event.initial_messages.clone(); let model_for_header = event.model.clone(); @@ -1502,6 +1504,7 @@ impl ChatWidget { current_status_header: String::from("Working"), retry_status_header: None, conversation_id: None, + forked_from: None, queued_user_messages: VecDeque::new(), show_welcome_banner: is_first_run, suppress_session_configured_redraw: false, @@ -1598,6 +1601,7 @@ impl ChatWidget { current_status_header: String::from("Working"), retry_status_header: None, conversation_id: None, + forked_from: None, queued_user_messages: VecDeque::new(), show_welcome_banner: false, suppress_session_configured_redraw: true, @@ -2400,6 +2404,7 @@ impl ChatWidget { token_info, total_usage, &self.conversation_id, + self.forked_from, self.rate_limit_snapshot.as_ref(), self.plan_type, Local::now(), diff --git a/codex-rs/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs index fa555e597..b3ad2114c 100644 --- a/codex-rs/tui2/src/chatwidget/tests.rs +++ b/codex-rs/tui2/src/chatwidget/tests.rs @@ -120,6 +120,7 @@ async fn resumed_initial_messages_render_history() { let rollout_file = NamedTempFile::new().unwrap(); let configured = codex_core::protocol::SessionConfiguredEvent { session_id: conversation_id, + forked_from_id: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -414,6 +415,7 @@ async fn make_chatwidget_manual( current_status_header: String::from("Working"), retry_status_header: None, conversation_id: None, + forked_from: None, frame_requester: FrameRequester::test_dummy(), show_welcome_banner: true, queued_user_messages: VecDeque::new(), diff --git a/codex-rs/tui2/src/status/card.rs b/codex-rs/tui2/src/status/card.rs index 55896365e..3e9e07d53 100644 --- a/codex-rs/tui2/src/status/card.rs +++ b/codex-rs/tui2/src/status/card.rs @@ -66,6 +66,7 @@ struct StatusHistoryCell { model_provider: Option, account: Option, session_id: Option, + forked_from: Option, token_usage: StatusTokenUsageData, rate_limits: StatusRateLimitData, } @@ -77,6 +78,7 @@ pub(crate) fn new_status_output( token_info: Option<&TokenUsageInfo>, total_usage: &TokenUsage, session_id: &Option, + forked_from: Option, rate_limits: Option<&RateLimitSnapshotDisplay>, plan_type: Option, now: DateTime, @@ -89,6 +91,7 @@ pub(crate) fn new_status_output( token_info, total_usage, session_id, + forked_from, rate_limits, plan_type, now, @@ -106,6 +109,7 @@ impl StatusHistoryCell { token_info: Option<&TokenUsageInfo>, total_usage: &TokenUsage, session_id: &Option, + forked_from: Option, rate_limits: Option<&RateLimitSnapshotDisplay>, plan_type: Option, now: DateTime, @@ -134,6 +138,7 @@ impl StatusHistoryCell { let model_provider = format_model_provider(config); let account = compose_account_display(auth_manager, plan_type); let session_id = session_id.as_ref().map(std::string::ToString::to_string); + let forked_from = forked_from.map(|id| id.to_string()); let default_usage = TokenUsage::default(); let (context_usage, context_window) = match token_info { Some(info) => (&info.last_token_usage, info.model_context_window), @@ -163,6 +168,7 @@ impl StatusHistoryCell { model_provider, account, session_id, + forked_from, token_usage, rate_limits, } @@ -351,6 +357,9 @@ impl HistoryCell for StatusHistoryCell { if self.session_id.is_some() { push_label(&mut labels, &mut seen, "Session"); } + if self.session_id.is_some() && self.forked_from.is_some() { + push_label(&mut labels, &mut seen, "Forked from"); + } push_label(&mut labels, &mut seen, "Token usage"); if self.token_usage.context_window.is_some() { push_label(&mut labels, &mut seen, "Context window"); @@ -402,6 +411,11 @@ impl HistoryCell for StatusHistoryCell { if let Some(session) = self.session_id.as_ref() { lines.push(formatter.line("Session", vec![Span::from(session.clone())])); } + if self.session_id.is_some() + && let Some(forked_from) = self.forked_from.as_ref() + { + lines.push(formatter.line("Forked from", vec![Span::from(forked_from.clone())])); + } lines.push(Line::from(Vec::>::new())); // Hide token usage only for ChatGPT subscribers diff --git a/codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_includes_forked_from.snap b/codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_includes_forked_from.snap new file mode 100644 index 000000000..f238fd0ee --- /dev/null +++ b/codex-rs/tui2/src/status/snapshots/codex_tui2__status__tests__status_snapshot_includes_forked_from.snap @@ -0,0 +1,24 @@ +--- +source: tui2/src/status/tests.rs +expression: sanitized +--- +/status + +╭───────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Approval: on-request │ +│ Sandbox: read-only │ +│ Agents.md: │ +│ Session: 0f0f3c13-6cf9-4aa4-8b80-7d49c2f1be2e │ +│ Forked from: e9f18a88-8081-4e51-9d4e-8af5cde2d8dd │ +│ │ +│ Token usage: 1.2K total (800 input + 400 output) │ +│ Context window: 100% left (1.2K used / 272K) │ +│ Limits: data not available yet │ +╰───────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui2/src/status/tests.rs b/codex-rs/tui2/src/status/tests.rs index b16940a4d..e79688f44 100644 --- a/codex-rs/tui2/src/status/tests.rs +++ b/codex-rs/tui2/src/status/tests.rs @@ -14,6 +14,7 @@ use codex_core::protocol::RateLimitWindow; use codex_core::protocol::SandboxPolicy; use codex_core::protocol::TokenUsage; use codex_core::protocol::TokenUsageInfo; +use codex_protocol::ThreadId; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::openai_models::ReasoningEffort; use insta::assert_snapshot; @@ -146,6 +147,7 @@ async fn status_snapshot_includes_reasoning_details() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -160,6 +162,58 @@ async fn status_snapshot_includes_reasoning_details() { let sanitized = sanitize_directory(rendered_lines).join("\n"); assert_snapshot!(sanitized); } + +#[tokio::test] +async fn status_snapshot_includes_forked_from() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config.model = Some("gpt-5.1-codex-max".to_string()); + config.model_provider_id = "openai".to_string(); + config.cwd = PathBuf::from("/workspace/tests"); + + let auth_manager = test_auth_manager(&config); + let usage = TokenUsage { + input_tokens: 800, + cached_input_tokens: 0, + output_tokens: 400, + reasoning_output_tokens: 0, + total_tokens: 1_200, + }; + + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 8, 9, 10, 11, 12) + .single() + .expect("valid time"); + + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let token_info = token_info_for(&model_slug, &config, &usage); + let session_id = + ThreadId::from_string("0f0f3c13-6cf9-4aa4-8b80-7d49c2f1be2e").expect("session id"); + let forked_from = + ThreadId::from_string("e9f18a88-8081-4e51-9d4e-8af5cde2d8dd").expect("forked id"); + + let composite = new_status_output( + &config, + &auth_manager, + Some(&token_info), + &usage, + &Some(session_id), + Some(forked_from), + None, + None, + captured_at, + &model_slug, + ); + let mut rendered_lines = render_lines(&composite.display_lines(80)); + if cfg!(windows) { + for line in &mut rendered_lines { + *line = line.replace('\\', "/"); + } + } + let sanitized = sanitize_directory(rendered_lines).join("\n"); + assert_snapshot!(sanitized); +} + #[tokio::test] async fn status_snapshot_includes_monthly_limit() { let temp_home = TempDir::new().expect("temp home"); @@ -201,6 +255,7 @@ async fn status_snapshot_includes_monthly_limit() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -244,6 +299,7 @@ async fn status_snapshot_shows_unlimited_credits() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -287,6 +343,7 @@ async fn status_snapshot_shows_positive_credits() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -330,6 +387,7 @@ async fn status_snapshot_hides_zero_credits() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -371,6 +429,7 @@ async fn status_snapshot_hides_when_has_no_credits_flag() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -414,6 +473,7 @@ async fn status_card_token_usage_excludes_cached_tokens() { &None, None, None, + None, now, &model_slug, ); @@ -468,6 +528,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -515,6 +576,7 @@ async fn status_snapshot_shows_missing_limits_message() { &None, None, None, + None, now, &model_slug, ); @@ -575,6 +637,7 @@ async fn status_snapshot_includes_credits_and_limits() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -626,6 +689,7 @@ async fn status_snapshot_shows_empty_limits_message() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, captured_at, @@ -686,6 +750,7 @@ async fn status_snapshot_shows_stale_limits_message() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, now, @@ -750,6 +815,7 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() { Some(&token_info), &usage, &None, + None, Some(&rate_display), None, now, @@ -806,6 +872,7 @@ async fn status_context_window_uses_last_usage() { &None, None, None, + None, now, &model_slug, );