Skip to content
Merged
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
44 changes: 31 additions & 13 deletions crates/jp_cli/src/cmd/conversation/print.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use jp_config::{
},
style::{reasoning::ReasoningDisplayConfig, typewriter::DelayDuration},
};
use jp_conversation::compaction::resolve_range;
use jp_conversation::{compaction::resolve_range, stream::TurnOrigin};
use jp_llm::tool::InvocationContext;
use jp_workspace::ConversationHandle;

Expand Down Expand Up @@ -140,9 +140,12 @@ impl Print {
) -> Output {
let mut events = ctx.workspace.events(handle)?.clone();

if compacted {
events.apply_projection();
}
// Selection and the numbers shown in headers both use the raw
// (pre-projection) turn numbering, so a turn number means the same
// thing whether or not the view is compacted, and matches what
// `compact` accepts. Capture the raw count before projection collapses
// any turns.
let raw_count = events.turn_count();
let cfg = ctx.config();

let root = ctx
Expand Down Expand Up @@ -190,17 +193,17 @@ impl Print {
);
renderer.set_user_only(user_only);

let count = events.turn_count();

// `--last 0` explicitly selects nothing.
// `--last 0` / `--first 0` explicitly selects nothing.
if range.is_empty() {
renderer.flush();
return Ok(());
}

// `--turn` names specific turns; an out-of-range endpoint is an error.
if let Some(n) = range.turn_out_of_range(count) {
return Err(format!("turn {n} out of range (conversation has {count} turns)").into());
if let Some(n) = range.turn_out_of_range(raw_count) {
return Err(
format!("turn {n} out of range (conversation has {raw_count} turns)").into(),
);
}

let from = match range.resolve_from(&events) {
Expand All @@ -220,15 +223,30 @@ impl Print {
Bound::At(b) => Some(b),
};

// A `from > to` or otherwise empty range selects nothing.
// The selected raw 0-based turn range. A `from > to` or otherwise empty
// range selects nothing. Resolved against the raw stream, before
// projection, so it lines up with the header numbers.
let Some(selected) = resolve_range(&events, from, to) else {
renderer.flush();
return Ok(());
};

for turn in events.iter_turns() {
if (selected.from_turn..=selected.to_turn).contains(&turn.index()) {
renderer.render_turn(&turn);
// Project for rendering when compacted; `origins` maps each rendered
// turn back to the raw turn number(s) it represents for the header.
let origins: Vec<TurnOrigin> = if compacted {
events.apply_projection()
} else {
(0..raw_count).map(TurnOrigin::Kept).collect()
};
debug_assert_eq!(
events.turn_count(),
origins.len(),
"turn origins must align with iter_turns()"
);

for (turn, origin) in events.iter_turns().zip(&origins) {
if origin.overlaps(selected.from_turn, selected.to_turn) {
renderer.render_turn(&turn, *origin);
}
}

Expand Down
39 changes: 32 additions & 7 deletions crates/jp_cli/src/render/turn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ use jp_config::{
model::id::PartialModelIdOrAliasConfig,
style::{StyleConfig, typewriter::DelayDuration},
};
use jp_conversation::{EventKind, stream::turn_iter::Turn};
use jp_conversation::{
EventKind,
stream::{TurnOrigin, turn_iter::Turn},
};
use jp_llm::tool::InvocationContext;
use jp_printer::{ErrChannel, Printer};
use tracing::warn;
Expand Down Expand Up @@ -106,14 +109,20 @@ impl TurnRenderer {
}

/// Render all events in a turn.
pub fn render_turn(&mut self, turn: &Turn<'_>) {
///
/// `origin` maps the turn back to the raw conversation turn number(s) it
/// represents, used for the header.
/// For a non-projected stream it is [`TurnOrigin::Kept`] of the turn's own
/// index; for a compacted view a summary turn carries the range it
/// replaced.
pub fn render_turn(&mut self, turn: &Turn<'_>, origin: TurnOrigin) {
if matches!(self.source, ConfigSource::PerTurn)
&& let Some(partial) = turn.iter().next().map(|e| &e.config)
{
self.reconfigure(partial);
}

self.view.set_turn_detail(turn_detail(turn));
self.view.set_turn_detail(turn_detail(turn, origin));

for event_with_cfg in turn {
if self.user_only
Expand Down Expand Up @@ -226,13 +235,18 @@ impl TurnRenderer {
}
}

/// Build the dimmed right-aligned header detail for a turn: its 1-based number
/// and how long ago it started, e.g. `turn 2, 12 minutes ago`.
/// Build the dimmed right-aligned header detail for a turn: its raw turn
/// number(s) and how long ago it started, e.g. `turn 2, 12 minutes ago` or, for
/// a compaction summary spanning several turns, `turns 2–5, 12 minutes ago`.
///
/// The number comes from `origin` (the raw turn number `jp conversation
/// compact` accepts), not the turn's position in the iterator, so it stays
/// stable whether or not the view is compacted.
///
/// The timestamp comes from the turn's `TurnStart` marker, falling back to the
/// turn's first event when the marker is absent (the implicit leading turn of a
/// legacy stream).
fn turn_detail(turn: &Turn<'_>) -> Option<String> {
fn turn_detail(turn: &Turn<'_>, origin: TurnOrigin) -> Option<String> {
let started_at = turn
.iter()
.find(|e| e.event.is_turn_start())
Expand All @@ -244,7 +258,14 @@ fn turn_detail(turn: &Turn<'_>) -> Option<String> {
// renders as "now" rather than a misleading "... ago".
let elapsed = (Utc::now() - started_at).to_std().unwrap_or_default();
let ago = timeago::Formatter::new().convert(elapsed);
Some(format!("turn {}, {ago}", turn.index() + 1))

let number = match origin {
TurnOrigin::Kept(index) => format!("turn {}", index + 1),
TurnOrigin::Summary { from, to } if from == to => format!("turn {}", from + 1),
TurnOrigin::Summary { from, to } => format!("turns {}\u{2013}{}", from + 1, to + 1),
};

Some(format!("{number}, {ago}"))
}

/// Render a partial model id as a display string, treating a fully-empty id as
Expand All @@ -258,3 +279,7 @@ fn render_model_id(id: &PartialModelIdOrAliasConfig) -> Option<String> {
let s = id.to_string();
if s.is_empty() { None } else { Some(s) }
}

#[cfg(test)]
#[path = "turn_tests.rs"]
mod tests;
43 changes: 43 additions & 0 deletions crates/jp_cli/src/render/turn_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
use jp_conversation::{ConversationStream, event::ChatRequest};

use super::{TurnOrigin, turn_detail};

/// A single-turn stream whose turn carries a timestamp, so `turn_detail`
/// produces `Some`.
/// The "ago" suffix is time-dependent, so assertions only check the turn-number
/// prefix.
fn single_turn() -> ConversationStream {
let mut stream = ConversationStream::new_test();
stream.start_turn(ChatRequest::from("hello"));
stream
}

#[test]
fn kept_turn_shows_its_raw_number() {
let stream = single_turn();
let turn = stream.iter_turns().next().unwrap();

let detail = turn_detail(&turn, TurnOrigin::Kept(5)).unwrap();

assert!(detail.starts_with("turn 6, "), "got: {detail}");
}

#[test]
fn multi_turn_summary_shows_the_collapsed_range() {
let stream = single_turn();
let turn = stream.iter_turns().next().unwrap();

let detail = turn_detail(&turn, TurnOrigin::Summary { from: 1, to: 4 }).unwrap();

assert!(detail.starts_with("turns 2\u{2013}5, "), "got: {detail}");
}

#[test]
fn single_turn_summary_shows_one_number() {
let stream = single_turn();
let turn = stream.iter_turns().next().unwrap();

let detail = turn_detail(&turn, TurnOrigin::Summary { from: 2, to: 2 }).unwrap();

assert!(detail.starts_with("turn 3, "), "got: {detail}");
}
10 changes: 7 additions & 3 deletions crates/jp_conversation/src/stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use tracing::{error, warn};
mod projection;
pub mod turn_iter;
pub mod turn_mut;
pub use projection::TurnOrigin;
pub use turn_iter::{IterTurns, Turn};
pub use turn_mut::TurnMut;

Expand Down Expand Up @@ -437,14 +438,17 @@ impl ConversationStream {
/// After this call, the stream's conversation events represent what the LLM
/// should see.
///
/// This is a no-op when no compaction events are present.
/// Returns one [`TurnOrigin`] per resulting turn, in turn order, mapping
/// each projected turn back to the raw turn number(s) it represents.
/// When no compaction events are present the events are left unchanged and
/// every turn maps to its own index.
///
/// This method is called by [`Thread::into_parts()`] before provider
/// visibility filtering.
///
/// [`Thread::into_parts()`]: crate::thread::Thread::into_parts
pub fn apply_projection(&mut self) {
projection::apply(&mut self.events);
pub fn apply_projection(&mut self) -> Vec<TurnOrigin> {
projection::apply(&mut self.events)
}

/// Start a new turn with the given chat request.
Expand Down
Loading
Loading