diff --git a/crates/jp_cli/src/cmd/conversation/print_tests.rs b/crates/jp_cli/src/cmd/conversation/print_tests.rs index 0d77b6ec..45f04b95 100644 --- a/crates/jp_cli/src/cmd/conversation/print_tests.rs +++ b/crates/jp_cli/src/cmd/conversation/print_tests.rs @@ -449,6 +449,97 @@ fn turn_separators_between_turns() { assert!(output.contains("Second question"), "got: {output}"); } +#[test] +fn turn_header_shows_turn_number_and_relative_time() { + let (mut ctx, id, out, _err, _rt) = setup_ctx(vec![ + ConversationEvent::new(TurnStart, ts(0, 0, 0)), + ConversationEvent::new(ChatRequest::from("First question"), ts(0, 0, 1)), + ConversationEvent::new(ChatResponse::message("First answer.\n\n"), ts(0, 0, 2)), + ConversationEvent::new(TurnStart, ts(0, 1, 0)), + ConversationEvent::new(ChatRequest::from("Second question"), ts(0, 1, 1)), + ConversationEvent::new(ChatResponse::message("Second answer.\n\n"), ts(0, 1, 2)), + ]); + + let print = Print { + target: PositionalIds::from_targets(vec![ConversationTarget::Id(id)]), + last: None, + turn: None, + current_config: false, + style: None, + compacted: false, + }; + let h = ctx.workspace.acquire_conversation(&id).unwrap(); + print.run(&mut ctx, &[h]).unwrap(); + ctx.printer.flush(); + + let output = strip_ansi(&out.lock()); + let lines: Vec<&str> = output.lines().collect(); + + // The first (user) header of each turn carries the 1-based turn number and + // a relative timestamp. + let user_headers: Vec<&&str> = lines + .iter() + .filter(|l| l.contains("\u{2500}\u{2500} user")) + .collect(); + assert_eq!( + user_headers.len(), + 2, + "expected one user header per turn, got: {output:?}" + ); + assert!( + user_headers[0].contains("turn 1,") && user_headers[0].contains("ago"), + "first turn header should show `turn 1` and a relative time, got: {output:?}" + ); + assert!( + user_headers[1].contains("turn 2,") && user_headers[1].contains("ago"), + "second turn header should show `turn 2` and a relative time, got: {output:?}" + ); + + // The assistant header within a turn is not the first shown header, so it + // carries no turn detail. + for line in lines.iter().filter(|l| l.contains("\u{2500}\u{2500} jp")) { + assert!( + !line.contains("turn "), + "only the first shown header in a turn carries the detail, got: {line:?}" + ); + } +} + +#[test] +fn turn_header_detail_on_assistant_first_turn() { + // A turn whose first shown event is an assistant response (no user request) + // must still carry the detail on the assistant header. This pins the + // `ensure_assistant_header` consumption path, which + // `turn_header_shows_turn_number_and_relative_time` (user-first) does not. + let (mut ctx, id, out, _err, _rt) = setup_ctx(vec![ConversationEvent::new( + ChatResponse::message("Answer only.\n\n"), + ts(0, 0, 0), + )]); + + let print = Print { + target: PositionalIds::from_targets(vec![ConversationTarget::Id(id)]), + last: None, + turn: None, + current_config: false, + style: None, + compacted: false, + }; + let h = ctx.workspace.acquire_conversation(&id).unwrap(); + print.run(&mut ctx, &[h]).unwrap(); + ctx.printer.flush(); + + let output = strip_ansi(&out.lock()); + let jp_headers: Vec<&str> = output + .lines() + .filter(|l| l.contains("\u{2500}\u{2500} jp")) + .collect(); + assert_eq!(jp_headers.len(), 1, "got: {output:?}"); + assert!( + jp_headers[0].contains("turn 1,") && jp_headers[0].contains("ago"), + "assistant header opening the turn should carry the detail, got: {output:?}" + ); +} + #[test] fn prints_conversation_by_id() { let (mut ctx, id, out, _err, _rt) = setup_ctx(vec![ConversationEvent::new( diff --git a/crates/jp_cli/src/render/chat.rs b/crates/jp_cli/src/render/chat.rs index 190286e3..4996e9aa 100644 --- a/crates/jp_cli/src/render/chat.rs +++ b/crates/jp_cli/src/render/chat.rs @@ -150,6 +150,8 @@ impl ChatRenderer { /// Draws a single line with the label embedded near the left and an /// optional dimmed suffix appended after it, with `─` characters filling /// the remaining width. + /// An optional `detail` is appended dimmed at the right edge, after the + /// fill — e.g. `── alice ──…── turn 2, 12 minutes ago ──`. /// Used by [`TurnRenderer`] to mark which participant is speaking next — /// e.g. `── alice ──…` before a user turn, `── jp /// (anthropic/claude-opus-4-8) ──…` before an assistant turn. @@ -158,11 +160,17 @@ impl ChatRenderer { /// boundaries from any HR markdown the assistant itself emits. /// /// [`TurnRenderer`]: super::TurnRenderer - pub fn render_role_header(&mut self, label: &str, suffix: Option<&str>) { + pub fn render_role_header(&mut self, label: &str, suffix: Option<&str>, detail: Option<&str>) { self.flush(); let pretty = self.printer.pretty_printing_enabled(); - let line = build_role_header_line(label, suffix, self.config.markdown.wrap_width, pretty); + let line = build_role_header_line( + label, + suffix, + detail, + self.config.markdown.wrap_width, + pretty, + ); self.printer.println(""); self.printer.println(&line); @@ -554,18 +562,29 @@ impl ChatRenderer { /// Build a labeled horizontal rule used as a role-boundary marker. /// -/// Layout: `──