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:
committed by
GitHub
parent
4a16d7ed4f
commit
f3260f4e2f
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user