From b77263d0938371f96da13b9857a9ec4e63e25f0e Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Mon, 29 Jun 2026 12:57:04 +0200 Subject: [PATCH 1/2] feat(cli): Add turn number and relative time to role headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When printing a conversation, the first role header of each turn now shows a dimmed detail at the right edge: the 1-based turn number and how long ago that turn started, e.g. `── user ──…── turn 2, 12 minutes ago ──`. The detail is taken from the `TurnStart` event's timestamp, falling back to the turn's first event for legacy streams that lack an explicit marker. The assistant header within the same turn renders without it, so the annotation appears exactly once per turn regardless of which role opens it. Signed-off-by: Jean Mertz --- .../src/cmd/conversation/print_tests.rs | 56 +++++++++++++++++++ crates/jp_cli/src/render/chat.rs | 40 ++++++++++--- crates/jp_cli/src/render/turn.rs | 21 +++++++ crates/jp_cli/src/render/turn_view.rs | 26 ++++++++- 4 files changed, 132 insertions(+), 11 deletions(-) diff --git a/crates/jp_cli/src/cmd/conversation/print_tests.rs b/crates/jp_cli/src/cmd/conversation/print_tests.rs index 0d77b6ec9..c829b780e 100644 --- a/crates/jp_cli/src/cmd/conversation/print_tests.rs +++ b/crates/jp_cli/src/cmd/conversation/print_tests.rs @@ -449,6 +449,62 @@ 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 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 190286e3f..4996e9aad 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: `──