Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 172 additions & 15 deletions crates/goose-cli/src/session/streaming_buffer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Comment thread
spendletonliveaction marked this conversation as resolved.
)
});
*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();
};

Expand All @@ -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 {
Comment thread
spendletonliveaction marked this conversation as resolved.
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;
Comment thread
spendletonliveaction marked this conversation as resolved.

let file_msg = save_to_temp_file(code_content)
.map(|p| format!(" → {}", p))
Expand All @@ -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-")
Expand Down Expand Up @@ -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);
}
}
9 changes: 9 additions & 0 deletions documentation/docs/guides/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,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 |
Expand Down Expand Up @@ -296,6 +299,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

Expand Down