diff --git a/nori-rs/cli/docs.md b/nori-rs/cli/docs.md index c8e1398d8..d26c1c940 100644 --- a/nori-rs/cli/docs.md +++ b/nori-rs/cli/docs.md @@ -130,6 +130,6 @@ On non-Windows, `wsl_paths.rs` normalizes paths for WSL environments to ensure c **Exit Handling:** -`handle_app_exit()` prints token usage and session resume hints after TUI exits, then optionally runs update actions if the user requested an upgrade. +`handle_app_exit()` prints token usage when available and prints a copyable two-line resume hint for sessions that recorded activity. The lead line ends with `run:` and the next line contains only `nori resume ` so the command can be copied without surrounding output. It then optionally runs update actions if the user requested an upgrade. Created and maintained by Nori. diff --git a/nori-rs/cli/src/main.rs b/nori-rs/cli/src/main.rs index d2755beae..fce82f5b8 100644 --- a/nori-rs/cli/src/main.rs +++ b/nori-rs/cli/src/main.rs @@ -23,6 +23,8 @@ use nori_cli::login::run_logout; use nori_tui::AppExitInfo; use nori_tui::Cli as TuiCli; +use nori_tui::RESUME_HINT_LEAD; +use nori_tui::resume_command_for_conversation; use nori_tui::update_action::UpdateAction; use owo_colors::OwoColorize; use std::path::PathBuf; @@ -216,26 +218,27 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec String { line = result; } + // Resume hint: "nori resume 019bb411-..." -> "nori resume [SESSION_ID]" + if let Some(result) = replace_after_marker(&line, "nori resume", "[SESSION_ID]") { + line = result; + } + // Git diff stats in footer: strip "· +N -M " segment // The stats vary based on repo state and would cause flaky snapshots line = strip_git_stats_segment(&line); diff --git a/nori-rs/tui-pty-e2e/tests/snapshots/exit_statistics__exit_message_ctrl_d.snap b/nori-rs/tui-pty-e2e/tests/snapshots/exit_statistics__exit_message_ctrl_d.snap index c47565585..76f5dc1d6 100644 --- a/nori-rs/tui-pty-e2e/tests/snapshots/exit_statistics__exit_message_ctrl_d.snap +++ b/nori-rs/tui-pty-e2e/tests/snapshots/exit_statistics__exit_message_ctrl_d.snap @@ -20,3 +20,5 @@ expression: normalize_for_input_snapshot(session.screen_contents()) │ Subagents Used │ │ (none) │ ╰───────────────────────────────────────╯ +To continue this session, run: +nori resume [SESSION_ID] diff --git a/nori-rs/tui-pty-e2e/tests/snapshots/exit_statistics__exit_message_slash_exit.snap b/nori-rs/tui-pty-e2e/tests/snapshots/exit_statistics__exit_message_slash_exit.snap index c47565585..76f5dc1d6 100644 --- a/nori-rs/tui-pty-e2e/tests/snapshots/exit_statistics__exit_message_slash_exit.snap +++ b/nori-rs/tui-pty-e2e/tests/snapshots/exit_statistics__exit_message_slash_exit.snap @@ -20,3 +20,5 @@ expression: normalize_for_input_snapshot(session.screen_contents()) │ Subagents Used │ │ (none) │ ╰───────────────────────────────────────╯ +To continue this session, run: +nori resume [SESSION_ID] diff --git a/nori-rs/tui/docs.md b/nori-rs/tui/docs.md index c411e1010..cdea1c8df 100644 --- a/nori-rs/tui/docs.md +++ b/nori-rs/tui/docs.md @@ -748,6 +748,8 @@ Selection behavior: The startup picker in `@/nori-rs/tui/src/resume_picker/` is transcript-backed. It uses `TranscriptLoader::list_resumable_session_metadata()` and keeps rows lightweight by reading only `session_meta` lines before selection. It does not perform provider-specific rollout discovery. +Resume hints use the shared `RESUME_HINT_LEAD` and `resume_command_for_conversation()` helpers from `app/` so the in-TUI new-conversation summary and the post-exit CLI output stay aligned. Both surfaces put the copyable `nori resume ` command on its own line after the `run:` lead text. + **Session Resume (`/resume`):** The `/resume` command allows reconnecting to a previous ACP session. It uses the ACP agent's `session/load` RPC when available, and otherwise falls back to a fresh ACP session plus normalized replay derived from the saved transcript (see `@/nori-rs/acp/docs.md`). diff --git a/nori-rs/tui/src/app/event_handling.rs b/nori-rs/tui/src/app/event_handling.rs index 936e5d07b..0b9385d98 100644 --- a/nori-rs/tui/src/app/event_handling.rs +++ b/nori-rs/tui/src/app/event_handling.rs @@ -80,6 +80,7 @@ impl App { let summary = session_summary( self.chat_widget.token_usage(), self.chat_widget.conversation_id(), + self.chat_widget.session_stats().has_activity(), ); self.shutdown_current_conversation(); let init = self.chat_widget_init( @@ -93,10 +94,13 @@ impl App { self.chat_widget = ChatWidget::new(init); self.configure_new_chat_widget(); if let Some(summary) = summary { - let mut lines: Vec> = vec![summary.usage_line.clone().into()]; + let mut lines: Vec> = Vec::new(); + if let Some(usage_line) = summary.usage_line { + lines.push(usage_line.into()); + } if let Some(command) = summary.resume_command { - let spans = vec!["To continue this session, run ".into(), command.cyan()]; - lines.push(spans.into()); + lines.push(RESUME_HINT_LEAD.into()); + lines.push(command.cyan().into()); } self.chat_widget.add_plain_history_lines(lines); } diff --git a/nori-rs/tui/src/app/mod.rs b/nori-rs/tui/src/app/mod.rs index 2c22686ad..74eac0635 100644 --- a/nori-rs/tui/src/app/mod.rs +++ b/nori-rs/tui/src/app/mod.rs @@ -64,34 +64,43 @@ use crate::history_cell::UpdateAvailableHistoryCell; const GPT_5_1_MIGRATION_AUTH_MODES: [AuthMode; 2] = [AuthMode::ChatGPT, AuthMode::ApiKey]; const GPT_5_1_CODEX_MIGRATION_AUTH_MODES: [AuthMode; 1] = [AuthMode::ChatGPT]; +pub const RESUME_HINT_LEAD: &str = "To continue this session, run:"; #[derive(Debug, Clone)] pub struct AppExitInfo { pub token_usage: TokenUsage, pub conversation_id: Option, + pub conversation_has_activity: bool, pub update_action: Option, } fn session_summary( token_usage: TokenUsage, conversation_id: Option, + conversation_has_activity: bool, ) -> Option { - if token_usage.is_zero() { + let usage_line = (!token_usage.is_zero()).then(|| FinalOutput::from(token_usage).to_string()); + let resume_command = conversation_id + .filter(|_| conversation_has_activity) + .map(|conversation_id| resume_command_for_conversation(&conversation_id)); + + if usage_line.is_none() && resume_command.is_none() { return None; } - let usage_line = FinalOutput::from(token_usage).to_string(); - let resume_command = - conversation_id.map(|conversation_id| format!("nori resume {conversation_id}")); Some(SessionSummary { usage_line, resume_command, }) } +pub fn resume_command_for_conversation(conversation_id: &ConversationId) -> String { + format!("nori resume {conversation_id}") +} + #[derive(Debug, Clone, PartialEq, Eq)] struct SessionSummary { - usage_line: String, + usage_line: Option, resume_command: Option, } @@ -187,6 +196,7 @@ async fn handle_model_migration_prompt_if_needed( return Some(AppExitInfo { token_usage: TokenUsage::default(), conversation_id: None, + conversation_has_activity: false, update_action: None, }); } @@ -481,6 +491,7 @@ impl App { Ok(AppExitInfo { token_usage: app.token_usage(), conversation_id: app.chat_widget.conversation_id(), + conversation_has_activity: app.chat_widget.session_stats().has_activity(), update_action: app.pending_update_action, }) } diff --git a/nori-rs/tui/src/app/tests.rs b/nori-rs/tui/src/app/tests.rs index 61ee1031d..1f0faa9d3 100644 --- a/nori-rs/tui/src/app/tests.rs +++ b/nori-rs/tui/src/app/tests.rs @@ -391,7 +391,7 @@ fn apply_approval_preset_updates_app_widget_and_backend_for_full_access() { #[test] fn session_summary_skip_zero_usage() { - assert!(session_summary(TokenUsage::default(), None).is_none()); + assert!(session_summary(TokenUsage::default(), None, false).is_none()); } #[test] @@ -404,10 +404,10 @@ fn session_summary_includes_resume_hint() { }; let conversation = ConversationId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap(); - let summary = session_summary(usage, Some(conversation)).expect("summary"); + let summary = session_summary(usage, Some(conversation), true).expect("summary"); assert_eq!( summary.usage_line, - "Token usage: total=12 input=10 output=2" + Some("Token usage: total=12 input=10 output=2".to_string()) ); assert_eq!( summary.resume_command, @@ -415,6 +415,26 @@ fn session_summary_includes_resume_hint() { ); } +#[test] +fn session_summary_includes_resume_hint_without_token_usage() { + let conversation = ConversationId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap(); + + let summary = + session_summary(TokenUsage::default(), Some(conversation), true).expect("summary"); + assert_eq!(summary.usage_line, None); + assert_eq!( + summary.resume_command, + Some("nori resume 123e4567-e89b-12d3-a456-426614174000".to_string()) + ); +} + +#[test] +fn session_summary_skips_resume_hint_without_activity() { + let conversation = ConversationId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap(); + + assert!(session_summary(TokenUsage::default(), Some(conversation), false).is_none()); +} + #[test] fn gpt5_migration_allows_api_key_and_chatgpt() { assert!(migration_prompt_allows_auth_mode( diff --git a/nori-rs/tui/src/lib.rs b/nori-rs/tui/src/lib.rs index e8e9649d9..18f156f95 100644 --- a/nori-rs/tui/src/lib.rs +++ b/nori-rs/tui/src/lib.rs @@ -6,6 +6,8 @@ use additional_dirs::add_dir_warning_message; use app::App; pub use app::AppExitInfo; +pub use app::RESUME_HINT_LEAD; +pub use app::resume_command_for_conversation; use codex_app_server_protocol::AuthMode; use codex_core::AuthManager; use codex_core::CodexAuth; @@ -417,6 +419,7 @@ async fn run_ratatui_app( return Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), conversation_id: None, + conversation_has_activity: false, update_action: Some(action), }); } @@ -456,6 +459,7 @@ async fn run_ratatui_app( return Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), conversation_id: None, + conversation_has_activity: false, update_action: None, }); } @@ -567,6 +571,7 @@ async fn run_ratatui_app( return Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), conversation_id: None, + conversation_has_activity: false, update_action: None, }); } @@ -637,6 +642,7 @@ fn resume_startup_error(tui: &mut Tui, message: String) -> color_eyre::Result