From 11c9dbe7a0a78b8d52df8fe05b75b0cb21c2c770 Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Thu, 16 Apr 2026 17:13:04 +0200 Subject: [PATCH] feat(cli): Add `--style` preset flag to `conversation print` Add a `--style` / `-s` flag to `jp conversation print` with two presets that override the active config's rendering behaviour: - `brief`: hides reasoning blocks, tool arguments, and tool results; only user messages, assistant text, and tool-call headers are shown. - `full`: forces reasoning visible, shows full JSON tool arguments, and renders tool results without truncation. Example usage: ``` jp conversation print --style brief jp conversation print --style full ``` This is useful when piping a conversation to another tool or reading it as a quick summary (`brief`), or when debugging an agentic run and needing every detail (`full`). The renderer's tool-style lookup was also corrected: tool call and response rendering now properly fall back to `defaults.style` when no per-tool override exists, rather than silently using hard-coded defaults. Custom-formatter output is now only replayed when the parameters style is `Custom`, preventing stale rendered arguments from appearing with other styles. `ToolsConfig::iter_mut` was added to `jp_config` to support bulk mutation of per-tool styles when applying a preset. Signed-off-by: Jean Mertz --- crates/jp_cli/src/cmd/conversation/print.rs | 79 +++++++- .../src/cmd/conversation/print_tests.rs | 172 ++++++++++++++++++ crates/jp_cli/src/render/turn.rs | 46 +++-- crates/jp_config/src/conversation/tool.rs | 5 + 4 files changed, 274 insertions(+), 28 deletions(-) diff --git a/crates/jp_cli/src/cmd/conversation/print.rs b/crates/jp_cli/src/cmd/conversation/print.rs index 2a31f945..80c8f20f 100644 --- a/crates/jp_cli/src/cmd/conversation/print.rs +++ b/crates/jp_cli/src/cmd/conversation/print.rs @@ -1,4 +1,7 @@ -use jp_config::style::typewriter::DelayDuration; +use jp_config::{ + conversation::tool::style::{self, DisplayStyleConfig, InlineResults, ParametersStyle}, + style::{reasoning::ReasoningDisplayConfig, typewriter::DelayDuration}, +}; use jp_workspace::ConversationHandle; use crate::{ @@ -7,6 +10,22 @@ use crate::{ render::{ConfigSource, TurnRenderer}, }; +/// Brief-mode tool display style: no arguments, no results, no file links. +const BRIEF_TOOL_STYLE: DisplayStyleConfig = DisplayStyleConfig { + hidden: false, + parameters: ParametersStyle::Off, + inline_results: InlineResults::Off, + results_file_link: style::LinkStyle::Off, +}; + +/// Full-mode tool display style: everything visible, nothing truncated. +const FULL_TOOL_STYLE: DisplayStyleConfig = DisplayStyleConfig { + hidden: false, + parameters: ParametersStyle::Json, + inline_results: InlineResults::Full, + results_file_link: style::LinkStyle::Full, +}; + #[derive(Debug, clap::Args)] pub(crate) struct Print { #[command(flatten)] @@ -27,6 +46,25 @@ pub(crate) struct Print { /// config for all turns. #[arg(long, default_value_t = false)] current_config: bool, + + /// Output style preset. + /// + /// - `brief`: Hide reasoning, tool arguments, and tool results. Shows + /// only user messages, assistant messages, and tool call headers. + /// - `full`: Show everything including reasoning, tool arguments, and + /// untruncated tool results. + #[arg(long, short = 's', value_enum)] + style: Option, +} + +/// Output style presets for `jp conversation print`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +pub(crate) enum PrintStyle { + /// Hide reasoning, tool arguments, and tool results. + Brief, + /// Show everything: full reasoning, tool arguments, and untruncated + /// tool results. + Full, } impl Print { @@ -44,7 +82,7 @@ impl Print { }; for handle in handles { - Self::print_conversation(ctx, handle, &selection, self.current_config)?; + Self::print_conversation(ctx, handle, &selection, self.current_config, self.style)?; } ctx.printer.println(""); ctx.printer.flush(); @@ -56,6 +94,7 @@ impl Print { handle: &ConversationHandle, selection: &TurnSelection, current_config: bool, + print_style: Option, ) -> Output { let events = ctx.workspace.events(handle)?.clone(); let cfg = ctx.config(); @@ -65,21 +104,27 @@ impl Print { .unwrap_or(ctx.workspace.root()) .to_path_buf(); - let source = if current_config { + let source = if current_config || print_style.is_some() { ConfigSource::Fixed } else { ConfigSource::PerTurn }; // Disable typewriter delays — print replays content instantly. - let mut style = cfg.style.clone(); - style.typewriter.text_delay = DelayDuration::instant(); - style.typewriter.code_delay = DelayDuration::instant(); + let mut render_style = cfg.style.clone(); + render_style.typewriter.text_delay = DelayDuration::instant(); + render_style.typewriter.code_delay = DelayDuration::instant(); + + let mut tools_config = cfg.conversation.tools.clone(); + + if let Some(preset) = print_style { + apply_style_preset(preset, &mut render_style, &mut tools_config); + } let mut renderer = TurnRenderer::new( ctx.printer.clone(), - style, - cfg.conversation.tools.clone(), + render_style, + tools_config, root, ctx.term.is_tty, source, @@ -117,6 +162,24 @@ impl Print { } } +/// Apply a style preset to the rendering config and tool config. +fn apply_style_preset( + preset: PrintStyle, + style: &mut jp_config::style::StyleConfig, + tools_config: &mut jp_config::conversation::tool::ToolsConfig, +) { + let (reasoning_display, tool_style) = match preset { + PrintStyle::Brief => (ReasoningDisplayConfig::Hidden, BRIEF_TOOL_STYLE), + PrintStyle::Full => (ReasoningDisplayConfig::Full, FULL_TOOL_STYLE), + }; + + style.reasoning.display = reasoning_display; + tools_config.defaults.style = tool_style.clone(); + for (_name, tool) in tools_config.iter_mut() { + tool.style = Some(tool_style.clone()); + } +} + /// How to select which turns to print. enum TurnSelection { /// Print all turns. diff --git a/crates/jp_cli/src/cmd/conversation/print_tests.rs b/crates/jp_cli/src/cmd/conversation/print_tests.rs index 9fd2d5dc..93eefef4 100644 --- a/crates/jp_cli/src/cmd/conversation/print_tests.rs +++ b/crates/jp_cli/src/cmd/conversation/print_tests.rs @@ -87,6 +87,7 @@ fn prints_user_message() { last: None, turn: None, current_config: false, + style: None, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); let result = print.run(&mut ctx, &[h]); @@ -109,6 +110,7 @@ fn prints_assistant_message() { last: None, turn: None, current_config: false, + style: None, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); let result = print.run(&mut ctx, &[h]); @@ -137,6 +139,7 @@ fn prints_reasoning_full() { last: None, turn: None, current_config: false, + style: None, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); let result = print.run(&mut ctx, &[h]); @@ -166,6 +169,7 @@ fn hides_reasoning_when_hidden() { last: None, turn: None, current_config: false, + style: None, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); let result = print.run(&mut ctx, &[h]); @@ -197,6 +201,7 @@ fn truncates_reasoning() { last: None, turn: None, current_config: false, + style: None, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); let result = print.run(&mut ctx, &[h]); @@ -237,6 +242,7 @@ fn prints_tool_call_and_result() { last: None, turn: None, current_config: false, + style: None, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); let result = print.run(&mut ctx, &[h]); @@ -264,6 +270,7 @@ fn prints_structured_data() { last: None, turn: None, current_config: false, + style: None, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); let result = print.run(&mut ctx, &[h]); @@ -291,6 +298,7 @@ fn turn_separators_between_turns() { last: None, turn: None, current_config: false, + style: None, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); let result = print.run(&mut ctx, &[h]); @@ -314,6 +322,7 @@ fn prints_conversation_by_id() { last: None, turn: None, current_config: false, + style: None, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); let result = print.run(&mut ctx, &[h]); @@ -336,6 +345,7 @@ fn empty_conversation_produces_no_content() { last: None, turn: None, current_config: false, + style: None, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); let result = print.run(&mut ctx, &[h]); @@ -387,6 +397,7 @@ fn full_conversation_round_trip() { last: None, turn: None, current_config: false, + style: None, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); let result = print.run(&mut ctx, &[h]); @@ -426,6 +437,7 @@ fn last_prints_only_last_turn() { last: Some(1), turn: None, current_config: false, + style: None, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); let result = print.run(&mut ctx, &[h]); @@ -466,6 +478,7 @@ fn last_two_with_three_turns() { last: Some(2), turn: None, current_config: false, + style: None, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); let result = print.run(&mut ctx, &[h]); @@ -494,6 +507,7 @@ fn last_exceeding_turn_count_prints_all() { last: Some(5), turn: None, current_config: false, + style: None, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); let result = print.run(&mut ctx, &[h]); @@ -538,6 +552,7 @@ fn blank_line_between_tool_calls_and_message() { last: None, turn: None, current_config: false, + style: None, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); let result = print.run(&mut ctx, &[h]); @@ -590,6 +605,7 @@ fn blank_line_between_message_and_tool_calls() { last: None, turn: None, current_config: false, + style: None, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); let result = print.run(&mut ctx, &[h]); @@ -658,6 +674,7 @@ fn no_extra_blank_line_between_consecutive_tool_calls() { last: None, turn: None, current_config: false, + style: None, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); let result = print.run(&mut ctx, &[h]); @@ -694,6 +711,7 @@ fn last_zero_prints_nothing() { last: Some(0), turn: None, current_config: false, + style: None, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); let result = print.run(&mut ctx, &[h]); @@ -728,6 +746,7 @@ fn turn_prints_specific_turn() { last: None, turn: Some(2), current_config: false, + style: None, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); let result = print.run(&mut ctx, &[h]); @@ -765,6 +784,7 @@ fn turn_out_of_range_errors() { last: None, turn: Some(5), current_config: false, + style: None, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); let result = print.run(&mut ctx, &[h]); @@ -783,8 +803,160 @@ fn turn_zero_errors() { last: None, turn: Some(0), current_config: false, + style: None, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); let result = print.run(&mut ctx, &[h]); assert!(result.is_err(), "should error for turn 0"); } + +#[test] +fn style_brief_hides_reasoning_and_tool_details() { + let mut config = AppConfig::new_test(); + config.style.reasoning.display = ReasoningDisplayConfig::Full; + + let (mut ctx, id, out, err, _rt) = setup_ctx_with_config(config, vec![ + ConversationEvent::new(TurnStart, ts(0, 0, 0)), + ConversationEvent::new(ChatRequest::from("Explain Rust"), ts(0, 0, 1)), + ConversationEvent::new( + ChatResponse::reasoning("Let me think deeply about this...\n\n"), + ts(0, 0, 2), + ), + ConversationEvent::new( + ToolCallRequest { + id: "tc1".into(), + name: "read_file".into(), + arguments: Map::from_iter([("path".into(), json!("src/main.rs"))]), + }, + ts(0, 0, 3), + ), + ConversationEvent::new( + ToolCallResponse { + id: "tc1".into(), + result: Ok("fn main() {}".into()), + }, + ts(0, 0, 4), + ), + ConversationEvent::new( + ChatResponse::message("Rust is a systems language.\n\n"), + ts(0, 0, 5), + ), + ]); + + let print = Print { + target: PositionalIds::from_targets(vec![ConversationTarget::Id(id)]), + last: None, + turn: None, + current_config: false, + style: Some(PrintStyle::Brief), + }; + let h = ctx.workspace.acquire_conversation(&id).unwrap(); + let result = print.run(&mut ctx, &[h]); + ctx.printer.flush(); + + result.unwrap(); + let output = out.lock().clone(); + let chrome = strip_ansi(&err.lock()); + + // Reasoning should be hidden. + assert!( + !output.contains("think deeply"), + "reasoning should be hidden in brief mode, got: {output}" + ); + + // Tool call header should still show, but without arguments. + assert!( + chrome.contains("Calling tool read_file"), + "tool header should still appear, got: {chrome}" + ); + assert!( + !chrome.contains("src/main.rs"), + "tool arguments should be hidden in brief mode, got: {chrome}" + ); + + // Tool results should not appear. + assert!( + !chrome.contains("fn main()"), + "tool results should be hidden in brief mode, got: {chrome}" + ); + + // Assistant message should still be visible. + assert!( + output.contains("Rust is a systems language."), + "message content should still show, got: {output}" + ); +} + +#[test] +fn style_full_shows_reasoning_and_untruncated_results() { + let mut config = AppConfig::new_test(); + // Start with reasoning hidden and results truncated to 1 line. + config.style.reasoning.display = ReasoningDisplayConfig::Hidden; + + let (mut ctx, id, out, err, _rt) = setup_ctx_with_config(config, vec![ + ConversationEvent::new(TurnStart, ts(0, 0, 0)), + ConversationEvent::new(ChatRequest::from("Check the file"), ts(0, 0, 1)), + ConversationEvent::new( + ChatResponse::reasoning("Let me reason about this carefully.\n\n"), + ts(0, 0, 2), + ), + ConversationEvent::new( + ToolCallRequest { + id: "tc1".into(), + name: "read_file".into(), + arguments: Map::from_iter([("path".into(), json!("src/lib.rs"))]), + }, + ts(0, 0, 3), + ), + ConversationEvent::new( + ToolCallResponse { + id: "tc1".into(), + result: Ok("line 1\nline 2\nline 3\nline 4\nline 5".into()), + }, + ts(0, 0, 4), + ), + ConversationEvent::new( + ChatResponse::message("Here are the contents.\n\n"), + ts(0, 0, 5), + ), + ]); + + let print = Print { + target: PositionalIds::from_targets(vec![ConversationTarget::Id(id)]), + last: None, + turn: None, + current_config: false, + style: Some(PrintStyle::Full), + }; + let h = ctx.workspace.acquire_conversation(&id).unwrap(); + let result = print.run(&mut ctx, &[h]); + ctx.printer.flush(); + + result.unwrap(); + let output = out.lock().clone(); + let chrome = strip_ansi(&err.lock()); + + // Reasoning should be visible despite config hiding it. + assert!( + output.contains("reason about this carefully"), + "reasoning should be shown in full mode, got: {output}" + ); + + // Tool arguments should be visible. + assert!( + chrome.contains("src/lib.rs"), + "tool arguments should be shown in full mode, got: {chrome}" + ); + + // All result lines should be visible (not truncated). + assert!( + chrome.contains("line 5"), + "all result lines should be shown in full mode, got: {chrome}" + ); + + // Message still visible. + assert!( + output.contains("Here are the contents."), + "message should still show, got: {output}" + ); +} diff --git a/crates/jp_cli/src/render/turn.rs b/crates/jp_cli/src/render/turn.rs index c6fbe5ae..6cd92ffc 100644 --- a/crates/jp_cli/src/render/turn.rs +++ b/crates/jp_cli/src/render/turn.rs @@ -9,7 +9,7 @@ use std::{collections::HashMap, sync::Arc}; use camino::Utf8PathBuf; use jp_config::{ AppConfig, PartialAppConfig, - conversation::tool::ToolsConfig, + conversation::tool::{ToolConfigWithDefaults, ToolsConfig, style::ParametersStyle}, style::{StyleConfig, typewriter::DelayDuration}, }; use jp_conversation::{EventKind, stream::turn_iter::Turn}; @@ -112,19 +112,24 @@ impl TurnRenderer { EventKind::ToolCallRequest(req) => { self.tool_names.insert(req.id.clone(), req.name.clone()); + let default_style = &self.tools_config.defaults.style; let tool_cfg = self.tools_config.get(&req.name); - if !tool_cfg.as_ref().is_some_and(|c| c.style().hidden) { + let style = tool_cfg + .as_ref() + .map_or(default_style, ToolConfigWithDefaults::style); + + if !style.hidden { self.chat.flush(); self.chat.transition_to_tool_call(); - let params_style = tool_cfg - .as_ref() - .map(|c| c.style().parameters.clone()) - .unwrap_or_default(); self.tool - .render_tool_call(&req.name, &req.arguments, ¶ms_style); - - // Show stored custom-formatter output if available. - if let Some(rendered) = get_rendered_arguments(event_with_cfg.event) { + .render_tool_call(&req.name, &req.arguments, &style.parameters); + + // Show stored custom-formatter output when replaying + // a tool call that was originally rendered with a + // Custom parameters style. + if matches!(style.parameters, ParametersStyle::Custom(_)) + && let Some(rendered) = get_rendered_arguments(event_with_cfg.event) + { self.tool.render_formatted_arguments(&rendered); } } @@ -132,17 +137,18 @@ impl TurnRenderer { EventKind::ToolCallResponse(resp) => { let name = self.tool_names.get(&resp.id); + let default_style = &self.tools_config.defaults.style; let tool_cfg = name.and_then(|n| self.tools_config.get(n)); - if !tool_cfg.as_ref().is_some_and(|c| c.style().hidden) { - let inline = tool_cfg - .as_ref() - .map(|c| c.style().inline_results.clone()) - .unwrap_or_default(); - let link = tool_cfg - .as_ref() - .map(|c| c.style().results_file_link.clone()) - .unwrap_or_default(); - self.tool.render_result(resp, &inline, &link); + let style = tool_cfg + .as_ref() + .map_or(default_style, ToolConfigWithDefaults::style); + + if !style.hidden { + self.tool.render_result( + resp, + &style.inline_results, + &style.results_file_link, + ); } } diff --git a/crates/jp_config/src/conversation/tool.rs b/crates/jp_config/src/conversation/tool.rs index e7ad3f0b..1c4c63d2 100644 --- a/crates/jp_config/src/conversation/tool.rs +++ b/crates/jp_config/src/conversation/tool.rs @@ -129,6 +129,11 @@ impl ToolsConfig { }) } + /// Iterate tool configurations mutably. + pub fn iter_mut(&mut self) -> impl Iterator { + self.tools.iter_mut().map(|(k, v)| (k.as_str(), v)) + } + /// Insert a tool configuration. pub fn insert(&mut self, name: String, tool: ToolConfig) { self.tools.insert(name, tool);