feat(cli): make MAX_CODE_BLOCK_LINES configurable via env vars (#9301)

Signed-off-by: Stephen Pendleton <spendleton@bluecatnetworks.com>
Signed-off-by: Douwe Osinga <douwe@squareup.com>
Co-authored-by: Stephen Pendleton <spendleton@bluecatnetworks.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
spendletonliveaction
2026-05-22 14:12:55 -04:00
committed by GitHub
parent 4a16d7ed4f
commit f3260f4e2f
2 changed files with 181 additions and 15 deletions
+172 -15
View File
@@ -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<usize> {
value.parse::<usize>().ok().filter(|&n| n > 0)
}
fn max_code_block_lines() -> Option<usize> {
static VALUE: LazyLock<Option<usize>> = 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<usize> = 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::<Vec<_>>()
.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<usize> {
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<String> {
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);
}
}
@@ -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