From 7f41e06af2e93bea2075b6426c82ea7d83a57e6e Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Wed, 1 Jul 2026 12:28:35 -0700 Subject: [PATCH 1/2] feat: surface next_actions as a "Next steps" footer in human output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Human/terminal output dropped an envelope's `next_actions` entirely — they appeared only in JSON/TOON — so commands that suggest follow-up steps gave no on-screen guidance. `render_human_with_view` now appends a "Next steps:" footer listing each action's command template (placeholders like `` shown as-is) with its description. Error envelopes and empty action lists render exactly as before. Co-Authored-By: Claude Opus 4.8 --- src/output/human.rs | 77 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 3 deletions(-) diff --git a/src/output/human.rs b/src/output/human.rs index aed848c..50e9d41 100644 --- a/src/output/human.rs +++ b/src/output/human.rs @@ -6,7 +6,7 @@ use std::{ use serde_json::Value; -use super::Envelope; +use super::{Envelope, NextAction}; /// Column definition for registered human table views. #[derive(Clone, Debug, Eq, PartialEq)] @@ -277,12 +277,21 @@ fn select_columns(columns: &[TableColumn], fields: &str) -> Vec { /// Renders an envelope using explicit table columns. #[must_use] pub fn render_human_with_view(envelope: &Envelope, columns: Option<&[TableColumn]>) -> String { + // Errors render on their own; success output gets the data body plus, when + // present, a "Next steps:" footer built from the envelope's next_actions + // (these otherwise appear only in JSON/TOON). if let Some(error) = &envelope.error { return format!("Error: {}\n", error.message); } - let Some(data) = &envelope.data else { - return "(no data)\n".to_owned(); + let body = match &envelope.data { + None => "(no data)\n".to_owned(), + Some(data) => render_data_body(data, columns), }; + format!("{body}{}", render_next_actions(&envelope.next_actions)) +} + +/// Render just the data portion of a success envelope (no next-steps footer). +fn render_data_body(data: &Value, columns: Option<&[TableColumn]>) -> String { if let Some(columns) = columns { return match data { Value::Array(items) => render_array_with_columns(items, columns), @@ -311,6 +320,23 @@ pub fn render_human_with_view(envelope: &Envelope, columns: Option<&[TableColumn } } +/// Build the "Next steps:" footer listing suggested follow-up commands, or an +/// empty string when there are none. Each action shows its command template +/// (placeholders like `` shown as-is) with the description beneath it. +fn render_next_actions(actions: &[NextAction]) -> String { + if actions.is_empty() { + return String::new(); + } + let mut out = String::from("\nNext steps:\n"); + for action in actions { + out.push_str(&format!( + " {}\n {}\n", + action.command, action.description + )); + } + out +} + fn render_array_with_columns(items: &[Value], columns: &[TableColumn]) -> String { if items.is_empty() { return "(no results)\n".to_owned(); @@ -509,3 +535,48 @@ fn truncate(value: &str, width: usize) -> String { fn format_number(number: &serde_json::Number) -> String { number.to_string() } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn human_output_appends_next_steps_footer() { + let envelope = Envelope::success(json!({ "domain": "example.com" }), "domain") + .with_next_actions(vec![NextAction::new( + "domain purchase --quote-token --agree --confirm", + "Register at the quoted price", + )]); + let out = render_human(&envelope); + // Data still renders as before… + assert!(out.contains("domain: example.com"), "{out}"); + // …followed by a Next steps footer with the command and its description. + assert!(out.contains("\nNext steps:\n"), "{out}"); + assert!( + out.contains("domain purchase --quote-token --agree --confirm"), + "{out}" + ); + assert!(out.contains("Register at the quoted price"), "{out}"); + } + + #[test] + fn human_output_has_no_footer_without_next_actions() { + let envelope = Envelope::success(json!({ "domain": "example.com" }), "domain"); + let out = render_human(&envelope); + assert!(out.contains("domain: example.com"), "{out}"); + assert!( + !out.contains("Next steps"), + "no footer when there are no actions: {out}" + ); + } + + #[test] + fn error_output_has_no_next_steps_footer() { + // An error envelope carries no next_actions and must render only the error. + let envelope = Envelope::error("ERROR", "boom", "domain"); + let out = render_human(&envelope); + assert!(out.starts_with("Error:"), "{out}"); + assert!(!out.contains("Next steps"), "{out}"); + } +} From 21b0b2766c3719535c9485d055e9581d25a396eb Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Wed, 1 Jul 2026 12:34:00 -0700 Subject: [PATCH 2/2] perf: append next-steps footer in place, avoiding extra allocations Address Copilot review: render the footer by writing directly into the data body instead of `format!`-ing a new string. The no-footer path now leaves the body untouched (no realloc/copy), and each action is pushed straight into the buffer (no per-action temporaries). Output is unchanged. Co-Authored-By: Claude Opus 4.8 --- src/output/human.rs | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/src/output/human.rs b/src/output/human.rs index 50e9d41..ae0f49e 100644 --- a/src/output/human.rs +++ b/src/output/human.rs @@ -283,11 +283,15 @@ pub fn render_human_with_view(envelope: &Envelope, columns: Option<&[TableColumn if let Some(error) = &envelope.error { return format!("Error: {}\n", error.message); } - let body = match &envelope.data { + let mut body = match &envelope.data { None => "(no data)\n".to_owned(), Some(data) => render_data_body(data, columns), }; - format!("{body}{}", render_next_actions(&envelope.next_actions)) + // Append the footer in place: the common no-footer path leaves `body` + // untouched (no realloc/copy), and non-empty actions are written directly + // into it (no per-action temporaries). + append_next_actions(&mut body, &envelope.next_actions); + body } /// Render just the data portion of a success envelope (no next-steps footer). @@ -320,21 +324,22 @@ fn render_data_body(data: &Value, columns: Option<&[TableColumn]>) -> String { } } -/// Build the "Next steps:" footer listing suggested follow-up commands, or an -/// empty string when there are none. Each action shows its command template +/// Append a "Next steps:" footer listing suggested follow-up commands to `out` +/// (a no-op when there are none). Each action shows its command template /// (placeholders like `` shown as-is) with the description beneath it. -fn render_next_actions(actions: &[NextAction]) -> String { +/// Writes directly into `out` to avoid per-action temporaries. +fn append_next_actions(out: &mut String, actions: &[NextAction]) { if actions.is_empty() { - return String::new(); + return; } - let mut out = String::from("\nNext steps:\n"); + out.push_str("\nNext steps:\n"); for action in actions { - out.push_str(&format!( - " {}\n {}\n", - action.command, action.description - )); + out.push_str(" "); + out.push_str(&action.command); + out.push_str("\n "); + out.push_str(&action.description); + out.push('\n'); } - out } fn render_array_with_columns(items: &[Value], columns: &[TableColumn]) -> String {