Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion nori-rs/cli/docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <session-id>` 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.
68 changes: 54 additions & 14 deletions nori-rs/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -216,26 +218,27 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec<Stri
let AppExitInfo {
token_usage,
conversation_id,
conversation_has_activity,
..
} = exit_info;

if token_usage.is_zero() {
return Vec::new();
let mut lines = Vec::new();
if !token_usage.is_zero() {
lines.push(format!(
"{}",
codex_core::protocol::FinalOutput::from(token_usage)
));
}

let mut lines = vec![format!(
"{}",
codex_core::protocol::FinalOutput::from(token_usage)
)];

if let Some(session_id) = conversation_id {
let resume_cmd = format!("nori resume {session_id}");
if conversation_has_activity && let Some(session_id) = conversation_id {
let resume_cmd = resume_command_for_conversation(&session_id);
let command = if color_enabled {
resume_cmd.cyan().to_string()
} else {
resume_cmd
};
lines.push(format!("To continue this session, run {command}"));
lines.push(RESUME_HINT_LEAD.to_string());
lines.push(command);
}

lines
Expand Down Expand Up @@ -624,6 +627,7 @@ mod tests {
conversation_id: conversation
.map(ConversationId::from_string)
.map(Result::unwrap),
conversation_has_activity: conversation.is_some(),
update_action: None,
}
}
Expand All @@ -633,6 +637,41 @@ mod tests {
let exit_info = AppExitInfo {
token_usage: TokenUsage::default(),
conversation_id: None,
conversation_has_activity: false,
update_action: None,
};
let lines = format_exit_messages(exit_info, false);
assert!(lines.is_empty());
}

#[test]
fn format_exit_messages_includes_resume_hint_without_token_usage() {
let exit_info = AppExitInfo {
token_usage: TokenUsage::default(),
conversation_id: Some(
ConversationId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap(),
),
conversation_has_activity: true,
update_action: None,
};
let lines = format_exit_messages(exit_info, false);
assert_eq!(
lines,
vec![
"To continue this session, run:".to_string(),
"nori resume 123e4567-e89b-12d3-a456-426614174000".to_string(),
]
);
}

#[test]
fn format_exit_messages_skips_resume_hint_without_activity() {
let exit_info = AppExitInfo {
token_usage: TokenUsage::default(),
conversation_id: Some(
ConversationId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap(),
),
conversation_has_activity: false,
update_action: None,
};
let lines = format_exit_messages(exit_info, false);
Expand All @@ -647,8 +686,8 @@ mod tests {
lines,
vec![
"Token usage: total=2 input=0 output=2".to_string(),
"To continue this session, run nori resume 123e4567-e89b-12d3-a456-426614174000"
.to_string(),
"To continue this session, run:".to_string(),
"nori resume 123e4567-e89b-12d3-a456-426614174000".to_string(),
]
);
}
Expand All @@ -657,8 +696,9 @@ mod tests {
fn format_exit_messages_applies_color_when_enabled() {
let exit_info = sample_exit_info(Some("123e4567-e89b-12d3-a456-426614174000"));
let lines = format_exit_messages(exit_info, true);
assert_eq!(lines.len(), 2);
assert!(lines[1].contains("\u{1b}[36m"));
assert_eq!(lines.len(), 3);
assert_eq!(lines[1], "To continue this session, run:");
assert!(lines[2].contains("\u{1b}[36m"));
}

#[test]
Expand Down
5 changes: 5 additions & 0 deletions nori-rs/tui-pty-e2e/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -948,6 +948,11 @@ pub fn normalize_for_snapshot(contents: String) -> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,5 @@ expression: normalize_for_input_snapshot(session.screen_contents())
│ Subagents Used │
│ (none) │
╰───────────────────────────────────────╯
To continue this session, run:
nori resume [SESSION_ID]
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,5 @@ expression: normalize_for_input_snapshot(session.screen_contents())
│ Subagents Used │
│ (none) │
╰───────────────────────────────────────╯
To continue this session, run:
nori resume [SESSION_ID]
2 changes: 2 additions & 0 deletions nori-rs/tui/docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <session-id>` 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`).
Expand Down
10 changes: 7 additions & 3 deletions nori-rs/tui/src/app/event_handling.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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<Line<'static>> = vec![summary.usage_line.clone().into()];
let mut lines: Vec<Line<'static>> = 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);
}
Expand Down
21 changes: 16 additions & 5 deletions nori-rs/tui/src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ConversationId>,
pub conversation_has_activity: bool,
pub update_action: Option<UpdateAction>,
}

fn session_summary(
token_usage: TokenUsage,
conversation_id: Option<ConversationId>,
conversation_has_activity: bool,
) -> Option<SessionSummary> {
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<String>,
resume_command: Option<String>,
}

Expand Down Expand Up @@ -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,
});
}
Expand Down Expand Up @@ -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,
})
}
Expand Down
26 changes: 23 additions & 3 deletions nori-rs/tui/src/app/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -404,17 +404,37 @@ 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,
Some("nori resume 123e4567-e89b-12d3-a456-426614174000".to_string())
);
}

#[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(
Expand Down
6 changes: 6 additions & 0 deletions nori-rs/tui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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),
});
}
Expand Down Expand Up @@ -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,
});
}
Expand Down Expand Up @@ -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,
});
}
Expand Down Expand Up @@ -637,6 +642,7 @@ fn resume_startup_error(tui: &mut Tui, message: String) -> color_eyre::Result<Ap
Ok(AppExitInfo {
token_usage: codex_core::protocol::TokenUsage::default(),
conversation_id: None,
conversation_has_activity: false,
update_action: None,
})
}
Expand Down
Loading