Skip to content

Commit 4465243

Browse files
authored
fix(resume): Show resume hint for any conversation (#461)
## Summary 🤖 Generated with [Nori](https://noriagentic.com/) - Decouple the post-exit `nori resume <session-id>` hint from token usage so ACP sessions without token reports still show the resume command when the session recorded activity. - Render resume hints as two lines, with `To continue this session, run:` followed by a standalone `nori resume <session-id>` command for easier copying. - Share the resume hint lead text and command construction between the post-exit CLI output and the in-TUI new-conversation summary, and update snapshots/docs for the aligned behavior. ## Test Plan - [x] `cargo test -p nori-cli` - [x] `cargo test -p nori-tui` - [x] `cargo build --bin nori` - [x] `cargo test -p tui-pty-e2e` - [x] `just fmt` - [x] `just fix -p nori-cli` - [x] `just fix -p nori-tui` - [x] `cargo insta pending-snapshots` - [x] Live tmux TUI check with ElizACP: prompt appeared, accepted `hello`, exited, and printed the two-line resume hint with the `nori resume <session-id>` command on its own line - [x] GitHub CI green: `cargo-deny`, Linux checks, macOS checks Share Nori with your team: https://www.npmjs.com/package/nori-skillsets
1 parent aeca799 commit 4465243

10 files changed

Lines changed: 118 additions & 26 deletions

File tree

nori-rs/cli/docs.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,6 @@ On non-Windows, `wsl_paths.rs` normalizes paths for WSL environments to ensure c
130130

131131
**Exit Handling:**
132132

133-
`handle_app_exit()` prints token usage and session resume hints after TUI exits, then optionally runs update actions if the user requested an upgrade.
133+
`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.
134134

135135
Created and maintained by Nori.

nori-rs/cli/src/main.rs

Lines changed: 54 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ use nori_cli::login::run_logout;
2323

2424
use nori_tui::AppExitInfo;
2525
use nori_tui::Cli as TuiCli;
26+
use nori_tui::RESUME_HINT_LEAD;
27+
use nori_tui::resume_command_for_conversation;
2628
use nori_tui::update_action::UpdateAction;
2729
use owo_colors::OwoColorize;
2830
use std::path::PathBuf;
@@ -216,26 +218,27 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec<Stri
216218
let AppExitInfo {
217219
token_usage,
218220
conversation_id,
221+
conversation_has_activity,
219222
..
220223
} = exit_info;
221224

222-
if token_usage.is_zero() {
223-
return Vec::new();
225+
let mut lines = Vec::new();
226+
if !token_usage.is_zero() {
227+
lines.push(format!(
228+
"{}",
229+
codex_core::protocol::FinalOutput::from(token_usage)
230+
));
224231
}
225232

226-
let mut lines = vec![format!(
227-
"{}",
228-
codex_core::protocol::FinalOutput::from(token_usage)
229-
)];
230-
231-
if let Some(session_id) = conversation_id {
232-
let resume_cmd = format!("nori resume {session_id}");
233+
if conversation_has_activity && let Some(session_id) = conversation_id {
234+
let resume_cmd = resume_command_for_conversation(&session_id);
233235
let command = if color_enabled {
234236
resume_cmd.cyan().to_string()
235237
} else {
236238
resume_cmd
237239
};
238-
lines.push(format!("To continue this session, run {command}"));
240+
lines.push(RESUME_HINT_LEAD.to_string());
241+
lines.push(command);
239242
}
240243

241244
lines
@@ -624,6 +627,7 @@ mod tests {
624627
conversation_id: conversation
625628
.map(ConversationId::from_string)
626629
.map(Result::unwrap),
630+
conversation_has_activity: conversation.is_some(),
627631
update_action: None,
628632
}
629633
}
@@ -633,6 +637,41 @@ mod tests {
633637
let exit_info = AppExitInfo {
634638
token_usage: TokenUsage::default(),
635639
conversation_id: None,
640+
conversation_has_activity: false,
641+
update_action: None,
642+
};
643+
let lines = format_exit_messages(exit_info, false);
644+
assert!(lines.is_empty());
645+
}
646+
647+
#[test]
648+
fn format_exit_messages_includes_resume_hint_without_token_usage() {
649+
let exit_info = AppExitInfo {
650+
token_usage: TokenUsage::default(),
651+
conversation_id: Some(
652+
ConversationId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap(),
653+
),
654+
conversation_has_activity: true,
655+
update_action: None,
656+
};
657+
let lines = format_exit_messages(exit_info, false);
658+
assert_eq!(
659+
lines,
660+
vec![
661+
"To continue this session, run:".to_string(),
662+
"nori resume 123e4567-e89b-12d3-a456-426614174000".to_string(),
663+
]
664+
);
665+
}
666+
667+
#[test]
668+
fn format_exit_messages_skips_resume_hint_without_activity() {
669+
let exit_info = AppExitInfo {
670+
token_usage: TokenUsage::default(),
671+
conversation_id: Some(
672+
ConversationId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap(),
673+
),
674+
conversation_has_activity: false,
636675
update_action: None,
637676
};
638677
let lines = format_exit_messages(exit_info, false);
@@ -647,8 +686,8 @@ mod tests {
647686
lines,
648687
vec![
649688
"Token usage: total=2 input=0 output=2".to_string(),
650-
"To continue this session, run nori resume 123e4567-e89b-12d3-a456-426614174000"
651-
.to_string(),
689+
"To continue this session, run:".to_string(),
690+
"nori resume 123e4567-e89b-12d3-a456-426614174000".to_string(),
652691
]
653692
);
654693
}
@@ -657,8 +696,9 @@ mod tests {
657696
fn format_exit_messages_applies_color_when_enabled() {
658697
let exit_info = sample_exit_info(Some("123e4567-e89b-12d3-a456-426614174000"));
659698
let lines = format_exit_messages(exit_info, true);
660-
assert_eq!(lines.len(), 2);
661-
assert!(lines[1].contains("\u{1b}[36m"));
699+
assert_eq!(lines.len(), 3);
700+
assert_eq!(lines[1], "To continue this session, run:");
701+
assert!(lines[2].contains("\u{1b}[36m"));
662702
}
663703

664704
#[test]

nori-rs/tui-pty-e2e/src/lib.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -948,6 +948,11 @@ pub fn normalize_for_snapshot(contents: String) -> String {
948948
line = result;
949949
}
950950

951+
// Resume hint: "nori resume 019bb411-..." -> "nori resume [SESSION_ID]"
952+
if let Some(result) = replace_after_marker(&line, "nori resume", "[SESSION_ID]") {
953+
line = result;
954+
}
955+
951956
// Git diff stats in footer: strip "· +N -M " segment
952957
// The stats vary based on repo state and would cause flaky snapshots
953958
line = strip_git_stats_segment(&line);

nori-rs/tui-pty-e2e/tests/snapshots/exit_statistics__exit_message_ctrl_d.snap

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,5 @@ expression: normalize_for_input_snapshot(session.screen_contents())
2020
Subagents Used
2121
│ (none) │
2222
╰───────────────────────────────────────╯
23+
To continue this session, run:
24+
nori resume [SESSION_ID]

nori-rs/tui-pty-e2e/tests/snapshots/exit_statistics__exit_message_slash_exit.snap

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,5 @@ expression: normalize_for_input_snapshot(session.screen_contents())
2020
Subagents Used
2121
│ (none) │
2222
╰───────────────────────────────────────╯
23+
To continue this session, run:
24+
nori resume [SESSION_ID]

nori-rs/tui/docs.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -748,6 +748,8 @@ Selection behavior:
748748

749749
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.
750750

751+
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.
752+
751753
**Session Resume (`/resume`):**
752754

753755
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`).

nori-rs/tui/src/app/event_handling.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ impl App {
8080
let summary = session_summary(
8181
self.chat_widget.token_usage(),
8282
self.chat_widget.conversation_id(),
83+
self.chat_widget.session_stats().has_activity(),
8384
);
8485
self.shutdown_current_conversation();
8586
let init = self.chat_widget_init(
@@ -93,10 +94,13 @@ impl App {
9394
self.chat_widget = ChatWidget::new(init);
9495
self.configure_new_chat_widget();
9596
if let Some(summary) = summary {
96-
let mut lines: Vec<Line<'static>> = vec![summary.usage_line.clone().into()];
97+
let mut lines: Vec<Line<'static>> = Vec::new();
98+
if let Some(usage_line) = summary.usage_line {
99+
lines.push(usage_line.into());
100+
}
97101
if let Some(command) = summary.resume_command {
98-
let spans = vec!["To continue this session, run ".into(), command.cyan()];
99-
lines.push(spans.into());
102+
lines.push(RESUME_HINT_LEAD.into());
103+
lines.push(command.cyan().into());
100104
}
101105
self.chat_widget.add_plain_history_lines(lines);
102106
}

nori-rs/tui/src/app/mod.rs

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,34 +64,43 @@ use crate::history_cell::UpdateAvailableHistoryCell;
6464

6565
const GPT_5_1_MIGRATION_AUTH_MODES: [AuthMode; 2] = [AuthMode::ChatGPT, AuthMode::ApiKey];
6666
const GPT_5_1_CODEX_MIGRATION_AUTH_MODES: [AuthMode; 1] = [AuthMode::ChatGPT];
67+
pub const RESUME_HINT_LEAD: &str = "To continue this session, run:";
6768

6869
#[derive(Debug, Clone)]
6970
pub struct AppExitInfo {
7071
pub token_usage: TokenUsage,
7172
pub conversation_id: Option<ConversationId>,
73+
pub conversation_has_activity: bool,
7274
pub update_action: Option<UpdateAction>,
7375
}
7476

7577
fn session_summary(
7678
token_usage: TokenUsage,
7779
conversation_id: Option<ConversationId>,
80+
conversation_has_activity: bool,
7881
) -> Option<SessionSummary> {
79-
if token_usage.is_zero() {
82+
let usage_line = (!token_usage.is_zero()).then(|| FinalOutput::from(token_usage).to_string());
83+
let resume_command = conversation_id
84+
.filter(|_| conversation_has_activity)
85+
.map(|conversation_id| resume_command_for_conversation(&conversation_id));
86+
87+
if usage_line.is_none() && resume_command.is_none() {
8088
return None;
8189
}
8290

83-
let usage_line = FinalOutput::from(token_usage).to_string();
84-
let resume_command =
85-
conversation_id.map(|conversation_id| format!("nori resume {conversation_id}"));
8691
Some(SessionSummary {
8792
usage_line,
8893
resume_command,
8994
})
9095
}
9196

97+
pub fn resume_command_for_conversation(conversation_id: &ConversationId) -> String {
98+
format!("nori resume {conversation_id}")
99+
}
100+
92101
#[derive(Debug, Clone, PartialEq, Eq)]
93102
struct SessionSummary {
94-
usage_line: String,
103+
usage_line: Option<String>,
95104
resume_command: Option<String>,
96105
}
97106

@@ -187,6 +196,7 @@ async fn handle_model_migration_prompt_if_needed(
187196
return Some(AppExitInfo {
188197
token_usage: TokenUsage::default(),
189198
conversation_id: None,
199+
conversation_has_activity: false,
190200
update_action: None,
191201
});
192202
}
@@ -481,6 +491,7 @@ impl App {
481491
Ok(AppExitInfo {
482492
token_usage: app.token_usage(),
483493
conversation_id: app.chat_widget.conversation_id(),
494+
conversation_has_activity: app.chat_widget.session_stats().has_activity(),
484495
update_action: app.pending_update_action,
485496
})
486497
}

nori-rs/tui/src/app/tests.rs

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -391,7 +391,7 @@ fn apply_approval_preset_updates_app_widget_and_backend_for_full_access() {
391391

392392
#[test]
393393
fn session_summary_skip_zero_usage() {
394-
assert!(session_summary(TokenUsage::default(), None).is_none());
394+
assert!(session_summary(TokenUsage::default(), None, false).is_none());
395395
}
396396

397397
#[test]
@@ -404,17 +404,37 @@ fn session_summary_includes_resume_hint() {
404404
};
405405
let conversation = ConversationId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap();
406406

407-
let summary = session_summary(usage, Some(conversation)).expect("summary");
407+
let summary = session_summary(usage, Some(conversation), true).expect("summary");
408408
assert_eq!(
409409
summary.usage_line,
410-
"Token usage: total=12 input=10 output=2"
410+
Some("Token usage: total=12 input=10 output=2".to_string())
411411
);
412412
assert_eq!(
413413
summary.resume_command,
414414
Some("nori resume 123e4567-e89b-12d3-a456-426614174000".to_string())
415415
);
416416
}
417417

418+
#[test]
419+
fn session_summary_includes_resume_hint_without_token_usage() {
420+
let conversation = ConversationId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap();
421+
422+
let summary =
423+
session_summary(TokenUsage::default(), Some(conversation), true).expect("summary");
424+
assert_eq!(summary.usage_line, None);
425+
assert_eq!(
426+
summary.resume_command,
427+
Some("nori resume 123e4567-e89b-12d3-a456-426614174000".to_string())
428+
);
429+
}
430+
431+
#[test]
432+
fn session_summary_skips_resume_hint_without_activity() {
433+
let conversation = ConversationId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap();
434+
435+
assert!(session_summary(TokenUsage::default(), Some(conversation), false).is_none());
436+
}
437+
418438
#[test]
419439
fn gpt5_migration_allows_api_key_and_chatgpt() {
420440
assert!(migration_prompt_allows_auth_mode(

nori-rs/tui/src/lib.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
use additional_dirs::add_dir_warning_message;
77
use app::App;
88
pub use app::AppExitInfo;
9+
pub use app::RESUME_HINT_LEAD;
10+
pub use app::resume_command_for_conversation;
911
use codex_app_server_protocol::AuthMode;
1012
use codex_core::AuthManager;
1113
use codex_core::CodexAuth;
@@ -417,6 +419,7 @@ async fn run_ratatui_app(
417419
return Ok(AppExitInfo {
418420
token_usage: codex_core::protocol::TokenUsage::default(),
419421
conversation_id: None,
422+
conversation_has_activity: false,
420423
update_action: Some(action),
421424
});
422425
}
@@ -456,6 +459,7 @@ async fn run_ratatui_app(
456459
return Ok(AppExitInfo {
457460
token_usage: codex_core::protocol::TokenUsage::default(),
458461
conversation_id: None,
462+
conversation_has_activity: false,
459463
update_action: None,
460464
});
461465
}
@@ -567,6 +571,7 @@ async fn run_ratatui_app(
567571
return Ok(AppExitInfo {
568572
token_usage: codex_core::protocol::TokenUsage::default(),
569573
conversation_id: None,
574+
conversation_has_activity: false,
570575
update_action: None,
571576
});
572577
}
@@ -637,6 +642,7 @@ fn resume_startup_error(tui: &mut Tui, message: String) -> color_eyre::Result<Ap
637642
Ok(AppExitInfo {
638643
token_usage: codex_core::protocol::TokenUsage::default(),
639644
conversation_id: None,
645+
conversation_has_activity: false,
640646
update_action: None,
641647
})
642648
}

0 commit comments

Comments
 (0)