From f3260f4e2fea966d7496d639a3fed0e29121bf77 Mon Sep 17 00:00:00 2001 From: spendletonliveaction <105226994+spendletonliveaction@users.noreply.github.com> Date: Fri, 22 May 2026 14:12:55 -0400 Subject: [PATCH] feat(cli): make MAX_CODE_BLOCK_LINES configurable via env vars (#9301) Signed-off-by: Stephen Pendleton Signed-off-by: Douwe Osinga Co-authored-by: Stephen Pendleton Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Douwe Osinga --- .../goose-cli/src/session/streaming_buffer.rs | 187 ++++++++++++++++-- .../docs/guides/environment-variables.md | 9 + 2 files changed, 181 insertions(+), 15 deletions(-) diff --git a/crates/goose-cli/src/session/streaming_buffer.rs b/crates/goose-cli/src/session/streaming_buffer.rs index 1f26205a82..ac8ddbd95f 100644 --- a/crates/goose-cli/src/session/streaming_buffer.rs +++ b/crates/goose-cli/src/session/streaming_buffer.rs @@ -24,31 +24,69 @@ use regex::Regex; use std::io::Write; use std::sync::LazyLock; -const MAX_CODE_BLOCK_LINES: usize = 50; -const TRUNCATED_SHOW_LINES: usize = 20; +const DEFAULT_MAX_CODE_BLOCK_LINES: usize = 50; +const DEFAULT_TRUNCATED_SHOW_LINES: usize = 20; + +/// Parse a line-count env value, rejecting anything that isn't a positive +/// integer. Zero is invalid because it would hide every non-empty code block +/// behind a temp-file pointer. +fn parse_positive_lines(value: &str) -> Option { + value.parse::().ok().filter(|&n| n > 0) +} + +fn max_code_block_lines() -> Option { + static VALUE: LazyLock> = LazyLock::new(|| { + if std::env::var("GOOSE_NO_CODE_TRUNCATION") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false) + { + return None; + } + Some( + std::env::var("GOOSE_MAX_CODE_BLOCK_LINES") + .ok() + .and_then(|v| parse_positive_lines(&v)) + .unwrap_or(DEFAULT_MAX_CODE_BLOCK_LINES), + ) + }); + *VALUE +} + +fn truncated_show_lines() -> usize { + static VALUE: LazyLock = LazyLock::new(|| { + std::env::var("GOOSE_TRUNCATED_SHOW_LINES") + .ok() + .and_then(|v| parse_positive_lines(&v)) + .unwrap_or(DEFAULT_TRUNCATED_SHOW_LINES) + }); + *VALUE +} fn truncate_code_blocks(content: &str) -> String { - let (open_pos, fence) = match (content.find("```"), content.find("~~~")) { - (Some(a), Some(b)) if a <= b => (a, "```"), - (Some(a), None) => (a, "```"), - (None, Some(b)) => (b, "~~~"), - (Some(_), Some(b)) => (b, "~~~"), - (None, None) => return content.to_string(), + let Some(max_lines) = max_code_block_lines() else { + return content.to_string(); + }; + truncate_code_blocks_with(content, max_lines, truncated_show_lines()) +} + +fn truncate_code_blocks_with(content: &str, max_lines: usize, show_lines: usize) -> String { + let Some((open_pos, fence_char, fence_len)) = find_opening_fence(content) else { + return content.to_string(); }; - let Some(after_open) = content.get(open_pos + 3..) else { + let after_fence = open_pos + fence_len; + let Some(after_open) = content.get(after_fence..) else { return content.to_string(); }; let Some(newline_pos) = after_open.find('\n') else { return content.to_string(); }; - let code_start = open_pos + 3 + newline_pos + 1; + let code_start = after_fence + newline_pos + 1; let Some(code_region) = content.get(code_start..) else { return content.to_string(); }; - let close_pattern = format!("\n{}", fence); - let Some(close_offset) = code_region.find(&close_pattern) else { + let Some(close_offset) = find_closing_fence(code_region, fence_char, fence_len) else { return content.to_string(); }; @@ -57,17 +95,18 @@ fn truncate_code_blocks(content: &str) -> String { }; let lines: Vec<&str> = code_content.lines().collect(); - if lines.len() <= MAX_CODE_BLOCK_LINES { + if lines.len() <= max_lines { return content.to_string(); } + let show_lines = show_lines.min(max_lines).min(lines.len()); let truncated: String = lines .iter() - .take(TRUNCATED_SHOW_LINES) + .take(show_lines) .copied() .collect::>() .join("\n"); - let remaining = lines.len() - TRUNCATED_SHOW_LINES; + let remaining = lines.len() - show_lines; let file_msg = save_to_temp_file(code_content) .map(|p| format!(" → {}", p)) @@ -82,6 +121,61 @@ fn truncate_code_blocks(content: &str) -> String { ) } +/// Find the first opening code fence in `content`. +/// +/// Returns the byte offset of the fence, the fence character (`` ` `` or `~`), +/// and the actual run length (≥ 3 consecutive characters). The run length is +/// needed so the matching closing fence can be located even when an inner +/// fence of a shorter length appears inside the block. +// SAFETY: `pos` comes from `str::find`, which returns a char-boundary byte +// offset. Fence chars (`` ` `` and `~`) are ASCII, so the slice always starts +// on a char boundary. +#[allow(clippy::string_slice)] +fn find_opening_fence(content: &str) -> Option<(usize, char, usize)> { + let (pos, ch) = match (content.find("```"), content.find("~~~")) { + (Some(a), Some(b)) if a <= b => (a, '`'), + (Some(a), None) => (a, '`'), + (None, Some(b)) => (b, '~'), + (Some(_), Some(b)) => (b, '~'), + (None, None) => return None, + }; + let len = content[pos..].chars().take_while(|&c| c == ch).count(); + Some((pos, ch, len)) +} + +/// Find the closing fence for a block opened with `min_len` `fence_char` +/// characters. A closing fence is a line whose only non-whitespace content +/// is a run of at least `min_len` matching fence characters. +/// +/// Returns the offset (within `region`) of the newline preceding the closing +/// fence line, matching the offset semantics that the rest of +/// `truncate_code_blocks_with` expects. +// SAFETY: All slice indices are at char boundaries: +// - `search_from` starts at 0 and only advances to `line_start = nl_pos + 1`, +// where `nl_pos` is a `\n` byte (ASCII, single byte). +// - `fence_count` is `chars().take_while(== fence_char).count()`; fence chars +// are ASCII, so the char count equals the byte length. +// - `line_end` comes from `find('\n')` (ASCII boundary) or `after_fence.len()`. +#[allow(clippy::string_slice)] +fn find_closing_fence(region: &str, fence_char: char, min_len: usize) -> Option { + let mut search_from = 0; + while let Some(nl_rel) = region[search_from..].find('\n') { + let nl_pos = search_from + nl_rel; + let line_start = nl_pos + 1; + let line = region.get(line_start..)?; + let fence_count = line.chars().take_while(|&c| c == fence_char).count(); + if fence_count >= min_len { + let after_fence = &line[fence_count..]; + let line_end = after_fence.find('\n').unwrap_or(after_fence.len()); + if after_fence[..line_end].trim().is_empty() { + return Some(nl_pos); + } + } + search_from = line_start; + } + None +} + fn save_to_temp_file(content: &str) -> Option { let mut file = tempfile::Builder::new() .prefix("goose-") @@ -668,4 +762,67 @@ mod tests { fn test_incomplete_constructs(chunks: &[&str], expected: &[&str]) { assert_eq!(stream(chunks), expected); } + + // =========================================== + // Code-block truncation + // =========================================== + + #[test] + fn truncation_preserves_longer_outer_backtick_fence() { + let content = "````md\n```\nline1\nline2\nline3\n```\n````\n"; + let out = truncate_code_blocks_with(content, 2, 1); + + assert!( + out.starts_with("````md\n"), + "outer fence should be preserved at the open: {out:?}" + ); + assert!( + out.contains("\n````\n"), + "outer fence should still close the block: {out:?}" + ); + assert!( + out.contains("... (4 more lines"), + "all 4 inner lines (including the inner ``` fences) should count toward truncation: {out:?}" + ); + } + + #[test] + fn truncation_preserves_longer_outer_tilde_fence() { + let content = "~~~~md\n~~~\nline1\nline2\nline3\n~~~\n~~~~\n"; + let out = truncate_code_blocks_with(content, 2, 1); + + assert!(out.starts_with("~~~~md\n"), "{out:?}"); + assert!(out.contains("\n~~~~\n"), "{out:?}"); + assert!(out.contains("... (4 more lines"), "{out:?}"); + } + + #[test] + fn truncation_ignores_non_fence_lines_containing_backticks() { + // A code line that begins with `````` but also has trailing text should + // not be treated as a closing fence. + let content = "````\nline1\n``` not a fence\nline3\nline4\nline5\n````\n"; + let out = truncate_code_blocks_with(content, 2, 1); + + assert!(out.starts_with("````\n"), "{out:?}"); + assert!(out.contains("\n````\n"), "{out:?}"); + assert!(out.contains("... (4 more lines"), "{out:?}"); + } + + #[test] + fn truncation_skips_when_block_is_within_limit() { + let content = "```\nline1\nline2\n```\n"; + let out = truncate_code_blocks_with(content, 10, 5); + assert_eq!(out, content); + } + + #[test] + fn parse_positive_lines_rejects_invalid_inputs() { + assert_eq!(parse_positive_lines("50"), Some(50)); + assert_eq!(parse_positive_lines("1"), Some(1)); + assert_eq!(parse_positive_lines("0"), None); + assert_eq!(parse_positive_lines("-1"), None); + assert_eq!(parse_positive_lines(""), None); + assert_eq!(parse_positive_lines("not-a-number"), None); + assert_eq!(parse_positive_lines("3.14"), None); + } } diff --git a/documentation/docs/guides/environment-variables.md b/documentation/docs/guides/environment-variables.md index b423ece7c1..2f2108515a 100644 --- a/documentation/docs/guides/environment-variables.md +++ b/documentation/docs/guides/environment-variables.md @@ -246,6 +246,9 @@ These variables control how goose manages conversation sessions and context. | `GOOSE_CLI_SHOW_THINKING` | Shows model reasoning/thinking output in CLI responses. Some models (e.g., DeepSeek-R1, Kimi, Gemini) expose their internal reasoning process — this variable makes it visible in the CLI. | Set to any value to enable | Disabled | | `GOOSE_RANDOM_THINKING_MESSAGES` | Controls whether to show amusing random messages during processing | "true", "false" | "true" | | `GOOSE_CLI_SHOW_COST` | Toggles display of model cost estimates in CLI output | "1", "true" (case-insensitive) to enable | false | +| `GOOSE_MAX_CODE_BLOCK_LINES` | Line count threshold before code blocks are truncated in CLI output. Full content is saved to a temp file. | Positive integer | 50 | +| `GOOSE_TRUNCATED_SHOW_LINES` | Number of lines shown before the "... (N more lines)" message when a code block is truncated | Positive integer | 20 | +| `GOOSE_NO_CODE_TRUNCATION` | Disable code block truncation entirely — all code blocks are shown in full | "1", "true" (case-insensitive) to enable | false | | `GOOSE_AUTO_COMPACT_THRESHOLD` | Set the percentage threshold at which goose [automatically summarizes your session](/docs/guides/sessions/smart-context-management#automatic-compaction). | Float between 0.0 and 1.0 (disabled at 0.0) | 0.8 | | `GOOSE_TOOL_CALL_CUTOFF` | Number of tool calls to keep in full detail before summarizing older tool outputs to help maintain efficient context usage | Integer (e.g., 5, 10, 20) | 10 | | `GOOSE_MOIM_MESSAGE_TEXT` | Injects persistent text into goose's [working memory](/docs/guides/context-engineering/using-persistent-instructions) every turn. Useful for behavioral guardrails or persistent reminders. | Any text string | Not set | @@ -305,6 +308,12 @@ export GOOSE_CLI_SHOW_THINKING=1 # Enable model cost display in CLI export GOOSE_CLI_SHOW_COST=true +# Show code blocks up to 100 lines before truncating +export GOOSE_MAX_CODE_BLOCK_LINES=100 + +# Disable code block truncation entirely (show all lines inline) +export GOOSE_NO_CODE_TRUNCATION=true + # Automatically compact sessions when 60% of available tokens are used export GOOSE_AUTO_COMPACT_THRESHOLD=0.6