From ef96b486cbfab127091c51698e35e18cd5549e25 Mon Sep 17 00:00:00 2001 From: Cristian Fleischer Date: Thu, 2 Apr 2026 13:37:34 +0200 Subject: [PATCH 01/29] feat(shell): add terminal context capture for the zsh plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When invoking forge from the terminal via the `:` modifier, the agent now receives context about recent terminal activity — commands run, exit codes, and (when supported) their full output. Three layers of context capture: - preexec/precmd hooks maintain a ring buffer of last 10 commands with exit codes and timestamps (always active in any terminal) - OSC 133 semantic markers are emitted for compatible terminals (Kitty, WezTerm, Ghostty, iTerm2, VS Code, foot) - Terminal scrollback capture extracts per-command output blocks from Kitty, WezTerm, Zellij, or tmux (auto-detected priority chain) Context is written to a temp file and passed to forge via a new hidden `--shell-context` CLI flag. On the Rust side it flows through the existing `Event.additional_context` droppable message infrastructure. All settings are configurable via environment variables (FORGE_CTX_ENABLED, FORGE_CTX_MAX_ENTRIES, etc.) and the feature degrades gracefully when terminal output capture is unavailable. --- .gitignore | 1 + crates/forge_api/src/api.rs | 6 +- crates/forge_api/src/forge_api.rs | 8 +- crates/forge_app/src/command_generator.rs | 29 +- crates/forge_main/src/cli.rs | 10 + crates/forge_main/src/main.rs | 10 + crates/forge_main/src/ui.rs | 32 ++- shell-plugin/forge.plugin.zsh | 3 + shell-plugin/lib/actions/editor.zsh | 15 +- shell-plugin/lib/config.zsh | 18 ++ shell-plugin/lib/context.zsh | 306 ++++++++++++++++++++++ shell-plugin/lib/helpers.zsh | 10 + 12 files changed, 423 insertions(+), 25 deletions(-) create mode 100644 shell-plugin/lib/context.zsh diff --git a/.gitignore b/.gitignore index 077bfbece7..73d39e49e2 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,4 @@ Cargo.lock **/.forge/request.body.json node_modules/ bench/__pycache__ +.ai/ diff --git a/crates/forge_api/src/api.rs b/crates/forge_api/src/api.rs index ceef7975d9..54a78a1f59 100644 --- a/crates/forge_api/src/api.rs +++ b/crates/forge_api/src/api.rs @@ -177,7 +177,11 @@ pub trait API: Sync + Send { async fn get_skills(&self) -> Result>; /// Generate a shell command from natural language prompt - async fn generate_command(&self, prompt: UserPrompt) -> Result; + async fn generate_command( + &self, + prompt: UserPrompt, + shell_context: Option, + ) -> Result; /// Initiate provider auth flow async fn init_provider_auth( diff --git a/crates/forge_api/src/forge_api.rs b/crates/forge_api/src/forge_api.rs index 8569b21d6b..9a69bae20f 100644 --- a/crates/forge_api/src/forge_api.rs +++ b/crates/forge_api/src/forge_api.rs @@ -312,10 +312,14 @@ impl< self.infra.load_skills().await } - async fn generate_command(&self, prompt: UserPrompt) -> Result { + async fn generate_command( + &self, + prompt: UserPrompt, + shell_context: Option, + ) -> Result { use forge_app::CommandGenerator; let generator = CommandGenerator::new(self.services.clone()); - generator.generate(prompt).await + generator.generate(prompt, shell_context).await } async fn init_provider_auth( diff --git a/crates/forge_app/src/command_generator.rs b/crates/forge_app/src/command_generator.rs index 688d3b6f65..301ec73171 100644 --- a/crates/forge_app/src/command_generator.rs +++ b/crates/forge_app/src/command_generator.rs @@ -32,8 +32,16 @@ where Self { services } } - /// Generates a shell command from a natural language prompt - pub async fn generate(&self, prompt: UserPrompt) -> Result { + /// Generates a shell command from a natural language prompt. + /// + /// When `shell_context` is provided (from the zsh plugin's terminal context + /// capture), it is included in the user prompt so the LLM can reference + /// recent commands, exit codes, and terminal output. + pub async fn generate( + &self, + prompt: UserPrompt, + shell_context: Option, + ) -> Result { // Get system information for context let env = self.services.get_environment(); @@ -59,8 +67,15 @@ where } }; - // Build user prompt with task and recent commands - let user_content = format!("{}", prompt.as_str()); + // Build user prompt with task, optionally including terminal context + let user_content = match shell_context { + Some(ctx) => format!( + "\n{}\n\n\n{}", + ctx, + prompt.as_str() + ), + None => format!("{}", prompt.as_str()), + }; // Create context with system and user prompts let ctx = self.create_context(rendered_system_prompt, user_content, &model); @@ -288,7 +303,7 @@ mod tests { let generator = CommandGenerator::new(fixture.clone()); let actual = generator - .generate(UserPrompt::from("list all files".to_string())) + .generate(UserPrompt::from("list all files".to_string()), None) .await .unwrap(); @@ -303,7 +318,7 @@ mod tests { let generator = CommandGenerator::new(fixture.clone()); let actual = generator - .generate(UserPrompt::from("show current directory".to_string())) + .generate(UserPrompt::from("show current directory".to_string()), None) .await .unwrap(); @@ -318,7 +333,7 @@ mod tests { let generator = CommandGenerator::new(fixture); let actual = generator - .generate(UserPrompt::from("do something".to_string())) + .generate(UserPrompt::from("do something".to_string()), None) .await; assert!(actual.is_err()); diff --git a/crates/forge_main/src/cli.rs b/crates/forge_main/src/cli.rs index a60d7a5041..ff731ab755 100644 --- a/crates/forge_main/src/cli.rs +++ b/crates/forge_main/src/cli.rs @@ -29,6 +29,16 @@ pub struct Cli { #[arg(skip)] pub piped_input: Option, + /// Path to a file containing shell terminal context (recent commands, exit + /// codes, terminal output). Populated by the zsh plugin to provide + /// terminal context when invoking forge from the shell. + #[arg(long, hide = true)] + pub shell_context: Option, + + /// Shell context content (populated internally from --shell-context file) + #[arg(skip)] + pub shell_context_content: Option, + /// Path to a JSON file containing the conversation to execute. #[arg(long)] pub conversation: Option, diff --git a/crates/forge_main/src/main.rs b/crates/forge_main/src/main.rs index 0b68b7e30b..7eeab9dd7a 100644 --- a/crates/forge_main/src/main.rs +++ b/crates/forge_main/src/main.rs @@ -105,6 +105,16 @@ async fn run() -> Result<()> { let config = ForgeConfig::read().context("Failed to read Forge configuration from .forge.toml")?; + // Read shell context file if provided by the zsh plugin + if let Some(ref ctx_path) = cli.shell_context { + if let Ok(content) = std::fs::read_to_string(ctx_path) { + let trimmed = content.trim(); + if !trimmed.is_empty() { + cli.shell_context_content = Some(trimmed.to_string()); + } + } + } + // Handle worktree creation if specified let cwd: PathBuf = match (&cli.sandbox, &cli.directory) { (Some(sandbox), Some(cli)) => { diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index fde42605d5..249569c367 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -1805,8 +1805,9 @@ impl A + Send + Sync> UI /// Handle the cmd command - generates shell command from natural language async fn on_cmd(&mut self, prompt: UserPrompt) -> anyhow::Result<()> { self.spinner.start(Some("Generating"))?; + let shell_context = self.cli.shell_context_content.clone(); - match self.api.generate_command(prompt).await { + match self.api.generate_command(prompt, shell_context).await { Ok(command) => { self.spinner.stop(None)?; self.writeln(command)?; @@ -3141,24 +3142,29 @@ impl A + Send + Sync> UI None => Event::empty(), }; - // Only use CLI piped_input as additional context when BOTH --prompt and piped - // input are provided. This handles the case: `echo "context" | forge -p - // "question"` where piped input provides context and --prompt provides - // the actual question. - // - // When only piped input is provided (no --prompt), it's already used as the - // main content (passed via the `content` parameter). We must NOT add it again - // as additional_context, otherwise the input appears twice in the - // conversation. We detect this by checking if cli.prompt exists - if it - // does, the content came from --prompt and piped input should be - // additional context. + // Build additional context from shell context and/or piped input. + // Shell context (from --shell-context) is always included when available. + // Piped input is only additional context when BOTH --prompt and piped + // input are provided (e.g., `echo "context" | forge -p "question"`). + // When only piped input is provided (no --prompt), it's already used as + // the main content via the `content` parameter. + let mut additional_parts: Vec = Vec::new(); + + if let Some(shell_ctx) = self.cli.shell_context_content.clone() { + additional_parts.push(shell_ctx); + } + let piped_input = self.cli.piped_input.clone(); let has_explicit_prompt = self.cli.prompt.is_some(); if let Some(piped) = piped_input && has_content && has_explicit_prompt { - event = event.additional_context(piped); + additional_parts.push(piped); + } + + if !additional_parts.is_empty() { + event = event.additional_context(additional_parts.join("\n\n")); } // Create the chat request with the event diff --git a/shell-plugin/forge.plugin.zsh b/shell-plugin/forge.plugin.zsh index e877afdff7..45137add7a 100755 --- a/shell-plugin/forge.plugin.zsh +++ b/shell-plugin/forge.plugin.zsh @@ -13,6 +13,9 @@ source "${0:A:h}/lib/highlight.zsh" # Core utilities (includes logging) source "${0:A:h}/lib/helpers.zsh" +# Terminal context capture (preexec/precmd hooks, OSC 133) +source "${0:A:h}/lib/context.zsh" + # Completion widget source "${0:A:h}/lib/completion.zsh" diff --git a/shell-plugin/lib/actions/editor.zsh b/shell-plugin/lib/actions/editor.zsh index e03bf79d16..7fb775f082 100644 --- a/shell-plugin/lib/actions/editor.zsh +++ b/shell-plugin/lib/actions/editor.zsh @@ -80,10 +80,21 @@ function _forge_action_suggest() { fi echo + + # Build shell context and pass via temp file (same pattern as _forge_exec_interactive) + local ctx_file="" + local -a ctx_args=() + if ctx_file=$(_forge_build_shell_context); then + ctx_args=(--shell-context "$ctx_file") + fi + # Generate the command local generated_command - generated_command=$(FORCE_COLOR=true CLICOLOR_FORCE=1 _forge_exec suggest "$description") - + generated_command=$(FORCE_COLOR=true CLICOLOR_FORCE=1 _forge_exec "${ctx_args[@]}" suggest "$description") + + # Clean up temp file + [[ -n "$ctx_file" ]] && rm -f "$ctx_file" 2>/dev/null + if [[ -n "$generated_command" ]]; then # Replace the buffer with the generated command BUFFER="$generated_command" diff --git a/shell-plugin/lib/config.zsh b/shell-plugin/lib/config.zsh index bbc05371e2..cdd36e42ce 100644 --- a/shell-plugin/lib/config.zsh +++ b/shell-plugin/lib/config.zsh @@ -38,3 +38,21 @@ typeset -h _FORGE_SESSION_PROVIDER # Session-scoped reasoning effort override (set via :reasoning-effort / :re). # When non-empty, exported as FORGE_REASONING__EFFORT for every forge invocation. typeset -h _FORGE_SESSION_REASONING_EFFORT + +# Terminal context capture settings +# Master switch for terminal context capture (preexec/precmd hooks) +typeset -h _FORGE_CTX_ENABLED="${FORGE_CTX_ENABLED:-true}" +# Maximum number of commands to keep in the ring buffer (metadata: cmd + exit code) +typeset -h _FORGE_CTX_MAX_ENTRIES="${FORGE_CTX_MAX_ENTRIES:-10}" +# Number of recent commands to include full output for +typeset -h _FORGE_CTX_FULL_OUTPUT_COUNT="${FORGE_CTX_FULL_OUTPUT_COUNT:-5}" +# Maximum output lines per command block +typeset -h _FORGE_CTX_MAX_LINES_PER_CMD="${FORGE_CTX_MAX_LINES_PER_CMD:-200}" +# Scrollback lines to capture from the terminal for command block extraction +typeset -h _FORGE_CTX_SCROLLBACK_LINES="${FORGE_CTX_SCROLLBACK_LINES:-1000}" +# OSC 133 semantic prompt marker emission: "auto", "on", or "off" +typeset -h _FORGE_CTX_OSC133="${FORGE_CTX_OSC133:-auto}" +# Ring buffer arrays for context capture +typeset -ha _FORGE_CTX_COMMANDS=() +typeset -ha _FORGE_CTX_EXIT_CODES=() +typeset -ha _FORGE_CTX_TIMESTAMPS=() diff --git a/shell-plugin/lib/context.zsh b/shell-plugin/lib/context.zsh new file mode 100644 index 0000000000..ff3914b678 --- /dev/null +++ b/shell-plugin/lib/context.zsh @@ -0,0 +1,306 @@ +#!/usr/bin/env zsh + +# Terminal context capture for forge plugin +# +# Provides three layers of terminal context: +# 1. preexec/precmd hooks: ring buffer of recent commands + exit codes +# 2. OSC 133 emission: semantic terminal markers for compatible terminals +# 3. Terminal-specific output capture: Kitty > WezTerm > tmux +# +# Context is organized by command blocks: each command's metadata and its +# full output are grouped together, using the known command strings from +# the ring buffer to detect boundaries in the terminal scrollback. + +# --------------------------------------------------------------------------- +# OSC 133 helpers +# --------------------------------------------------------------------------- + +# Determines whether OSC 133 semantic markers should be emitted. +# Auto-detection is conservative: only emit for terminals known to support it +# to avoid garbled output in unsupported terminals. +function _forge_osc133_should_emit() { + case "$_FORGE_CTX_OSC133" in + on) return 0 ;; + off) return 1 ;; + auto) + # Kitty sets KITTY_PID + [[ -n "${KITTY_PID:-}" ]] && return 0 + # Detect by TERM_PROGRAM + case "${TERM_PROGRAM:-}" in + WezTerm|iTerm.app|vscode) return 0 ;; + esac + # Foot terminal + [[ "${TERM:-}" == "foot"* ]] && return 0 + # Ghostty + [[ "${TERM_PROGRAM:-}" == "ghostty" ]] && return 0 + # Unknown terminal: don't emit + return 1 + ;; + *) return 1 ;; + esac +} + +# Emits an OSC 133 marker if the terminal supports it. +# Usage: _forge_osc133_emit "A" or _forge_osc133_emit "D;0" +function _forge_osc133_emit() { + _forge_osc133_should_emit || return 0 + printf '\e]133;%s\a' "$1" +} + +# --------------------------------------------------------------------------- +# preexec / precmd hooks +# --------------------------------------------------------------------------- + +# Ring buffer storage uses parallel arrays declared in config.zsh: +# _FORGE_CTX_COMMANDS, _FORGE_CTX_EXIT_CODES, _FORGE_CTX_TIMESTAMPS +# Pending command state: +typeset -g _FORGE_CTX_PENDING_CMD="" +typeset -g _FORGE_CTX_PENDING_TS="" + +# Called before each command executes. +# Records the command text and timestamp, emits OSC 133 B+C markers. +function _forge_context_preexec() { + [[ "$_FORGE_CTX_ENABLED" != "true" ]] && return + _FORGE_CTX_PENDING_CMD="$1" + _FORGE_CTX_PENDING_TS="$(date +%s)" + # OSC 133 B: prompt end / command start + _forge_osc133_emit "B" + # OSC 133 C: command output start + _forge_osc133_emit "C" +} + +# Called after each command completes, before the next prompt is drawn. +# Captures exit code, pushes to ring buffer, emits OSC 133 D+A markers. +function _forge_context_precmd() { + local last_exit=$? # MUST be first line to capture exit code + [[ "$_FORGE_CTX_ENABLED" != "true" ]] && return + + # OSC 133 D: command finished with exit code + _forge_osc133_emit "D;$last_exit" + + # Only record if we have a pending command from preexec + if [[ -n "$_FORGE_CTX_PENDING_CMD" ]]; then + _FORGE_CTX_COMMANDS+=("$_FORGE_CTX_PENDING_CMD") + _FORGE_CTX_EXIT_CODES+=("$last_exit") + _FORGE_CTX_TIMESTAMPS+=("$_FORGE_CTX_PENDING_TS") + + # Trim ring buffer to max size + while (( ${#_FORGE_CTX_COMMANDS} > _FORGE_CTX_MAX_ENTRIES )); do + shift _FORGE_CTX_COMMANDS + shift _FORGE_CTX_EXIT_CODES + shift _FORGE_CTX_TIMESTAMPS + done + + _FORGE_CTX_PENDING_CMD="" + _FORGE_CTX_PENDING_TS="" + fi + + # OSC 133 A: prompt start (for the next prompt) + _forge_osc133_emit "A" +} + +# --------------------------------------------------------------------------- +# Terminal scrollback capture +# --------------------------------------------------------------------------- + +# Captures raw scrollback text from the terminal. The amount captured is +# controlled by _FORGE_CTX_SCROLLBACK_LINES. +# Returns the scrollback on stdout, or returns 1 if unavailable. +# Priority: Kitty > WezTerm > Zellij > tmux > none +function _forge_capture_scrollback() { + local lines="${_FORGE_CTX_SCROLLBACK_LINES:-1000}" + local output="" + + # Priority 1: Kitty — get full scrollback (OSC 133 aware) + if [[ -n "${KITTY_PID:-}" ]] && command -v kitty &>/dev/null; then + output=$(kitty @ get-text --extent=all 2>/dev/null) + if [[ -n "$output" ]]; then + echo "$output" | tail -"$lines" + return 0 + fi + fi + + # Priority 2: WezTerm + if [[ "${TERM_PROGRAM:-}" == "WezTerm" ]] && command -v wezterm &>/dev/null; then + output=$(wezterm cli get-text 2>/dev/null) + if [[ -n "$output" ]]; then + echo "$output" | tail -"$lines" + return 0 + fi + fi + + # Priority 3: Zellij — full scrollback dump + if [[ -n "${ZELLIJ:-}" ]] && command -v zellij &>/dev/null; then + output=$(zellij action dump-screen --full 2>/dev/null) + if [[ -n "$output" ]]; then + echo "$output" | tail -"$lines" + return 0 + fi + fi + + # Priority 4: tmux scrollback + if [[ -n "${TMUX:-}" ]] && command -v tmux &>/dev/null; then + output=$(tmux capture-pane -p -S -"$lines" 2>/dev/null) + if [[ -n "$output" ]]; then + echo "$output" + return 0 + fi + fi + + # No terminal-specific capture available + return 1 +} + +# --------------------------------------------------------------------------- +# Command block extraction +# --------------------------------------------------------------------------- + +# Given raw scrollback text, extracts the output block for a specific command +# by finding the command string and capturing everything until the next known +# command (or end of text). Uses fixed-string grep for reliability. +# +# Args: $1=scrollback, $2=command string, $3=next command string (or empty) +# Outputs the extracted block on stdout, truncated to max lines per command. +function _forge_extract_block() { + local scrollback="$1" + local cmd="$2" + local next_cmd="$3" + local max_lines="${_FORGE_CTX_MAX_LINES_PER_CMD:-200}" + + # Find the LAST occurrence of this command in scrollback (most recent run) + local cmd_line + cmd_line=$(echo "$scrollback" | grep -n -F -- "$cmd" | tail -1 | cut -d: -f1) + [[ -z "$cmd_line" ]] && return 1 + + # Start from the line AFTER the command itself (that's the output) + local output_start=$(( cmd_line + 1 )) + + if [[ -n "$next_cmd" ]]; then + # Find where the next command appears after our command + local next_line + next_line=$(echo "$scrollback" | tail -n +"$output_start" | grep -n -F -- "$next_cmd" | head -1 | cut -d: -f1) + if [[ -n "$next_line" ]]; then + # next_line is relative to output_start, adjust to absolute + # Subtract 2: one for the prompt line before the command, one for 1-indexing + local output_end=$(( output_start + next_line - 2 )) + if (( output_end >= output_start )); then + echo "$scrollback" | sed -n "${output_start},${output_end}p" | head -"$max_lines" + return 0 + fi + fi + fi + + # No next command found — take everything from output_start to end + echo "$scrollback" | tail -n +"$output_start" | head -"$max_lines" + return 0 +} + +# --------------------------------------------------------------------------- +# Context builder +# --------------------------------------------------------------------------- + +# Builds a shell context file containing: +# 1. Metadata for all commands in ring buffer (last N commands + exit codes) +# 2. Full output blocks for the most recent M commands (extracted from scrollback) +# +# Writes to a temp file and echoes the path on stdout. +# Returns non-zero if context is disabled or empty. +function _forge_build_shell_context() { + [[ "$_FORGE_CTX_ENABLED" != "true" ]] && return 1 + [[ ${#_FORGE_CTX_COMMANDS} -eq 0 ]] && return 1 + + local ctx_file + ctx_file=$(mktemp "${TMPDIR:-/tmp}/forge-ctx-XXXXXX") || return 1 + + local count=${#_FORGE_CTX_COMMANDS} + local full_output_count="${_FORGE_CTX_FULL_OUTPUT_COUNT:-5}" + + # Determine which commands get full output (the most recent N) + local full_output_start=$(( count - full_output_count + 1 )) + (( full_output_start < 1 )) && full_output_start=1 + + # Capture scrollback once (expensive operation, do it only once) + local scrollback="" + scrollback=$(_forge_capture_scrollback 2>/dev/null) + + { + echo "# Terminal Context" + echo "" + echo "The following is the user's recent terminal activity. Commands are listed" + echo "from oldest to newest. The last ${full_output_count} commands include their full output" + echo "when terminal capture is available." + echo "" + + # --- Section 1: Metadata-only commands (older ones) --- + if (( full_output_start > 1 )); then + echo "## Earlier Commands" + echo "" + for (( i=1; i < full_output_start; i++ )); do + local ts_human + ts_human=$(date -d "@${_FORGE_CTX_TIMESTAMPS[$i]}" '+%H:%M:%S' 2>/dev/null \ + || date -r "${_FORGE_CTX_TIMESTAMPS[$i]}" '+%H:%M:%S' 2>/dev/null \ + || echo "${_FORGE_CTX_TIMESTAMPS[$i]}") + local exit_marker="" + if [[ "${_FORGE_CTX_EXIT_CODES[$i]}" != "0" ]]; then + exit_marker=" [EXIT CODE: ${_FORGE_CTX_EXIT_CODES[$i]}]" + fi + echo "- \`${_FORGE_CTX_COMMANDS[$i]}\` at ${ts_human}${exit_marker}" + done + echo "" + fi + + # --- Section 2: Full output command blocks (recent ones) --- + echo "## Recent Commands (with output)" + echo "" + + for (( i=full_output_start; i <= count; i++ )); do + local cmd="${_FORGE_CTX_COMMANDS[$i]}" + local exit_code="${_FORGE_CTX_EXIT_CODES[$i]}" + local ts_human + ts_human=$(date -d "@${_FORGE_CTX_TIMESTAMPS[$i]}" '+%H:%M:%S' 2>/dev/null \ + || date -r "${_FORGE_CTX_TIMESTAMPS[$i]}" '+%H:%M:%S' 2>/dev/null \ + || echo "${_FORGE_CTX_TIMESTAMPS[$i]}") + + local status_label="ok" + [[ "$exit_code" != "0" ]] && status_label="FAILED (exit ${exit_code})" + + echo "### \`${cmd}\` — ${status_label} at ${ts_human}" + echo "" + + # Try to extract this command's output from scrollback + if [[ -n "$scrollback" ]]; then + # Determine the next command string for boundary detection + local next_cmd="" + if (( i < count )); then + next_cmd="${_FORGE_CTX_COMMANDS[$((i+1))]}" + fi + + local block + block=$(_forge_extract_block "$scrollback" "$cmd" "$next_cmd") + if [[ -n "$block" ]]; then + echo '```' + echo "$block" + echo '```' + else + echo "_No output captured._" + fi + else + echo "_Terminal output capture not available._" + fi + echo "" + done + } > "$ctx_file" + + echo "$ctx_file" + return 0 +} + +# --------------------------------------------------------------------------- +# Hook registration +# --------------------------------------------------------------------------- + +# Register using standard zsh hook arrays for coexistence with other plugins +if [[ "$_FORGE_CTX_ENABLED" == "true" ]]; then + preexec_functions+=(_forge_context_preexec) + precmd_functions+=(_forge_context_precmd) +fi diff --git a/shell-plugin/lib/helpers.zsh b/shell-plugin/lib/helpers.zsh index 01f0dbb679..a610f6b709 100644 --- a/shell-plugin/lib/helpers.zsh +++ b/shell-plugin/lib/helpers.zsh @@ -39,11 +39,21 @@ function _forge_exec_interactive() { local agent_id="${_FORGE_ACTIVE_AGENT:-forge}" local -a cmd cmd=($_FORGE_BIN --agent "$agent_id") + + # Build shell context and pass via temp file + local ctx_file="" + if ctx_file=$(_forge_build_shell_context); then + cmd+=(--shell-context "$ctx_file") + fi + cmd+=("$@") [[ -n "$_FORGE_SESSION_MODEL" ]] && local -x FORGE_SESSION__MODEL_ID="$_FORGE_SESSION_MODEL" [[ -n "$_FORGE_SESSION_PROVIDER" ]] && local -x FORGE_SESSION__PROVIDER_ID="$_FORGE_SESSION_PROVIDER" [[ -n "$_FORGE_SESSION_REASONING_EFFORT" ]] && local -x FORGE_REASONING__EFFORT="$_FORGE_SESSION_REASONING_EFFORT" "${cmd[@]}" /dev/tty + + # Clean up temp file + [[ -n "$ctx_file" ]] && rm -f "$ctx_file" 2>/dev/null } function _forge_reset() { From 378fc76a0cdbd87ead6030991efe2bc90269faf2 Mon Sep 17 00:00:00 2001 From: Cristian Fleischer Date: Fri, 3 Apr 2026 23:12:37 +0200 Subject: [PATCH 02/29] fix(shell): prepend precmd hook and add shell context test Prepend _forge_context_precmd to precmd_functions so it captures the real command exit code before other plugins (powerlevel10k, etc.) overwrite $?. Add test verifying terminal context is included in the suggest command prompt. --- crates/forge_app/src/command_generator.rs | 36 ++++++++++++++++++++++- shell-plugin/lib/context.zsh | 6 ++-- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/crates/forge_app/src/command_generator.rs b/crates/forge_app/src/command_generator.rs index 301ec73171..df5a1f12dc 100644 --- a/crates/forge_app/src/command_generator.rs +++ b/crates/forge_app/src/command_generator.rs @@ -118,7 +118,7 @@ where mod tests { use forge_domain::{ AuthCredential, AuthDetails, AuthMethod, ChatCompletionMessage, Content, FinishReason, - ModelSource, ProviderId, ProviderResponse, ResultStream, + ModelSource, ProviderId, ProviderResponse, ResultStream, Role, }; use tokio::sync::Mutex; use url::Url; @@ -327,6 +327,40 @@ mod tests { insta::assert_yaml_snapshot!(captured_context); } + #[tokio::test] + async fn test_generate_with_shell_context() { + let fixture = MockServices::new( + r#"{"command": "cargo build --release"}"#, + vec![("Cargo.toml", false)], + ); + let generator = CommandGenerator::new(fixture.clone()); + let shell_context = Some( + "## Recent Commands\n| # | Command | Exit | Time |\n|---|---------|------|------|\n| 1 | cargo build | 101 | 12:00:00 |".to_string(), + ); + + let actual = generator + .generate( + UserPrompt::from("fix the command I just ran".to_string()), + shell_context, + ) + .await + .unwrap(); + + assert_eq!(actual, "cargo build --release"); + let captured_context = fixture.captured_context.lock().await.clone().unwrap(); + let user_content = captured_context + .messages + .iter() + .find(|m| m.has_role(Role::User)) + .expect("should have a user message") + .content() + .expect("user message should have content"); + assert!(user_content.contains("")); + assert!(user_content.contains("")); + assert!(user_content.contains("cargo build")); + assert!(user_content.contains("fix the command I just ran")); + } + #[tokio::test] async fn test_generate_fails_when_missing_tag() { let fixture = MockServices::new(r#"{"invalid": "json"}"#, vec![]); diff --git a/shell-plugin/lib/context.zsh b/shell-plugin/lib/context.zsh index ff3914b678..1b979a4c28 100644 --- a/shell-plugin/lib/context.zsh +++ b/shell-plugin/lib/context.zsh @@ -299,8 +299,10 @@ function _forge_build_shell_context() { # Hook registration # --------------------------------------------------------------------------- -# Register using standard zsh hook arrays for coexistence with other plugins +# Register using standard zsh hook arrays for coexistence with other plugins. +# precmd is prepended so it runs first and captures the real $? from the +# command, before other plugins (powerlevel10k, starship, etc.) overwrite it. if [[ "$_FORGE_CTX_ENABLED" == "true" ]]; then preexec_functions+=(_forge_context_preexec) - precmd_functions+=(_forge_context_precmd) + precmd_functions=(_forge_context_precmd "${precmd_functions[@]}") fi From 5e754b3f505e2558ea3853926dd844786842f0d8 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 09:12:39 +0000 Subject: [PATCH 03/29] [autofix.ci] apply automated fixes --- crates/forge_main/src/main.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/forge_main/src/main.rs b/crates/forge_main/src/main.rs index 7eeab9dd7a..df20981067 100644 --- a/crates/forge_main/src/main.rs +++ b/crates/forge_main/src/main.rs @@ -106,14 +106,13 @@ async fn run() -> Result<()> { ForgeConfig::read().context("Failed to read Forge configuration from .forge.toml")?; // Read shell context file if provided by the zsh plugin - if let Some(ref ctx_path) = cli.shell_context { - if let Ok(content) = std::fs::read_to_string(ctx_path) { + if let Some(ref ctx_path) = cli.shell_context + && let Ok(content) = std::fs::read_to_string(ctx_path) { let trimmed = content.trim(); if !trimmed.is_empty() { cli.shell_context_content = Some(trimmed.to_string()); } } - } // Handle worktree creation if specified let cwd: PathBuf = match (&cli.sandbox, &cli.directory) { From 1aa76b781f7f5e0b7dbd8660c945b08cb6e06280 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 12 Apr 2026 08:06:39 +0000 Subject: [PATCH 04/29] [autofix.ci] apply automated fixes --- crates/forge_main/src/main.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/forge_main/src/main.rs b/crates/forge_main/src/main.rs index df20981067..a67fcd5952 100644 --- a/crates/forge_main/src/main.rs +++ b/crates/forge_main/src/main.rs @@ -107,12 +107,13 @@ async fn run() -> Result<()> { // Read shell context file if provided by the zsh plugin if let Some(ref ctx_path) = cli.shell_context - && let Ok(content) = std::fs::read_to_string(ctx_path) { - let trimmed = content.trim(); - if !trimmed.is_empty() { - cli.shell_context_content = Some(trimmed.to_string()); - } + && let Ok(content) = std::fs::read_to_string(ctx_path) + { + let trimmed = content.trim(); + if !trimmed.is_empty() { + cli.shell_context_content = Some(trimmed.to_string()); } + } // Handle worktree creation if specified let cwd: PathBuf = match (&cli.sandbox, &cli.directory) { From 250ab448196a0e4384b1c830db370be686872c8b Mon Sep 17 00:00:00 2001 From: Tushar Date: Sun, 12 Apr 2026 22:40:25 +0530 Subject: [PATCH 05/29] feat(terminal-context): add FIXME markers for terminal context refactor --- .forge/skills/resolve-fixme/SKILL.md | 33 +++++++++++++++-------- crates/forge_api/src/api.rs | 5 +++- crates/forge_api/src/forge_api.rs | 3 ++- crates/forge_app/src/command_generator.rs | 4 +++ crates/forge_main/src/cli.rs | 2 ++ crates/forge_main/src/ui.rs | 4 +++ shell-plugin/lib/actions/editor.zsh | 2 ++ shell-plugin/lib/config.zsh | 4 +++ shell-plugin/lib/context.zsh | 4 +++ shell-plugin/lib/helpers.zsh | 5 ++++ 10 files changed, 53 insertions(+), 13 deletions(-) diff --git a/.forge/skills/resolve-fixme/SKILL.md b/.forge/skills/resolve-fixme/SKILL.md index eb694b48f2..5c2c7a47a2 100644 --- a/.forge/skills/resolve-fixme/SKILL.md +++ b/.forge/skills/resolve-fixme/SKILL.md @@ -22,19 +22,30 @@ bash .forge/skills/resolve-fixme/scripts/find-fixme.sh [PATH] ### 2. Triage the results -Read the script output and build a work list. For each FIXME note: -- The file and line number (shown in the header of each block). -- The surrounding context to understand what the FIXME is asking for. -- Whether the fix requires code changes, further research, or is blocked. +Read the script output and build a work list. For each FIXME: -### 3. Resolve each FIXME +**Collect the full comment first.** A FIXME may span multiple lines. The discovery script only shows the line where `FIXME` appears, but the actual instruction often continues on the lines immediately below it as additional comment lines. Before interpreting any FIXME, read forward from the FIXME line until the comment block ends — treat all consecutive comment lines as part of the same instruction. Do not act on a partial reading. + +**Group related FIXMEs across files.** The same underlying task is often described by FIXMEs spread across multiple files — each one expressing a different facet of the same change (e.g. one file describes a new domain type to create, another describes a parameter to drop once that type exists, a third describes a service to build). Before planning any implementation, read all FIXMEs in full and identify which ones belong to the same task by looking for shared vocabulary, cross-references, or complementary instructions. Group these into a single consolidated task. Implement the task as a whole — do not fix one file in isolation if the FIXMEs describe a coordinated change. + +For each individual FIXME (or group of related FIXMEs), record: + +- The files, start lines, and end lines of all comment blocks in the group. +- A single consolidated description of the full implementation required — synthesising the intent from all comments in the group into one coherent plan. + +Every FIXME must be resolved. There is no skip option. + +### 3. Resolve every FIXME Work through the list one at a time: -1. Read the full file section to understand the intent. -2. Implement the fix — edit the code, add the missing logic, or refactor as needed. -3. Remove the FIXME comment once the issue is resolved. -4. If a FIXME cannot be safely resolved (e.g. requires external input or is intentionally deferred), leave it in place and note why. +1. Read the full relevant section of the file. +2. Implement the fix fully — write the code, add the missing logic, refactor as needed. Do not stop short. +3. Remove the FIXME comment **only after** the implementation is complete and correct. + +> **Critical rule:** Never delete or modify a FIXME comment without first completing the work it describes. A FIXME comment is the only record of what needs to be built. Removing it without doing the work silently destroys that record and is strictly forbidden. + +If the implementation requires understanding other parts of the codebase first — read them. If it requires creating new types, files, or services — create them. Keep working until every FIXME is fully resolved. ### 4. Verify @@ -44,10 +55,10 @@ After resolving all FIXMEs, run the project's standard verification steps: cargo insta test --accept ``` -Re-run the discovery script to confirm no FIXMEs remain unresolved. +Re-run the discovery script to confirm no FIXMEs remain. ## Notes - Prefer targeted, minimal fixes — only change what the FIXME describes. -- If the FIXME comment describes a TODO that was intentionally deferred (e.g. `FIXME(later):` or `FIXME(blocked):`), skip it and report it to the user. - When the context is ambiguous, read more of the surrounding file before making a change. +- If an implementation turns out to be larger than expected, break it into steps and work through them — do not use complexity as a reason to leave a FIXME unresolved. diff --git a/crates/forge_api/src/api.rs b/crates/forge_api/src/api.rs index 54a78a1f59..eee233424d 100644 --- a/crates/forge_api/src/api.rs +++ b/crates/forge_api/src/api.rs @@ -175,11 +175,14 @@ pub trait API: Sync + Send { /// List of available skills async fn get_skills(&self) -> Result>; - + // FIXME: Revert this file to that in main /// Generate a shell command from natural language prompt async fn generate_command( &self, prompt: UserPrompt, + + // FIXME: drop this parameter + // We will implement an repo to extract/parse and render terminal data shell_context: Option, ) -> Result; diff --git a/crates/forge_api/src/forge_api.rs b/crates/forge_api/src/forge_api.rs index 9a69bae20f..ed4b8b164a 100644 --- a/crates/forge_api/src/forge_api.rs +++ b/crates/forge_api/src/forge_api.rs @@ -311,10 +311,11 @@ impl< async fn get_skills(&self) -> Result> { self.infra.load_skills().await } - + // FIXME: Revert this file to that in main async fn generate_command( &self, prompt: UserPrompt, + shell_context: Option, ) -> Result { use forge_app::CommandGenerator; diff --git a/crates/forge_app/src/command_generator.rs b/crates/forge_app/src/command_generator.rs index df5a1f12dc..be488a700c 100644 --- a/crates/forge_app/src/command_generator.rs +++ b/crates/forge_app/src/command_generator.rs @@ -40,6 +40,10 @@ where pub async fn generate( &self, prompt: UserPrompt, + // FIXME: Drop this parameter + // Create a domain + repo + service that extract the terminal variables, renders them and makes it available here directly via a service + // Repo should read the data from env variables and create a domain type - TerminalContext + // We use TerminalContext to render a template that we use to render the prompt shell_context: Option, ) -> Result { // Get system information for context diff --git a/crates/forge_main/src/cli.rs b/crates/forge_main/src/cli.rs index 768fa10ddf..800312e7cb 100644 --- a/crates/forge_main/src/cli.rs +++ b/crates/forge_main/src/cli.rs @@ -29,6 +29,8 @@ pub struct Cli { #[arg(skip)] pub piped_input: Option, + + // FIXME: Drop this CLI parameter /// Path to a file containing shell terminal context (recent commands, exit /// codes, terminal output). Populated by the zsh plugin to provide /// terminal context when invoking forge from the shell. diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index e8f420d297..14a021fc9c 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -1809,6 +1809,10 @@ impl A + Send + Sync> UI /// Handle the cmd command - generates shell command from natural language async fn on_cmd(&mut self, prompt: UserPrompt) -> anyhow::Result<()> { self.spinner.start(Some("Generating"))?; + + // FIXME: Revert this file to main + // UserPrompt rendering is a complex process and already abstracted out + // Use that process to use the new services/repos to render the prompt and add it to the context let shell_context = self.cli.shell_context_content.clone(); match self.api.generate_command(prompt, shell_context).await { diff --git a/shell-plugin/lib/actions/editor.zsh b/shell-plugin/lib/actions/editor.zsh index 7fb775f082..abf48da03e 100644 --- a/shell-plugin/lib/actions/editor.zsh +++ b/shell-plugin/lib/actions/editor.zsh @@ -81,6 +81,8 @@ function _forge_action_suggest() { echo + # FIXME: Revert this file to that in main + # Build shell context and pass via temp file (same pattern as _forge_exec_interactive) local ctx_file="" local -a ctx_args=() diff --git a/shell-plugin/lib/config.zsh b/shell-plugin/lib/config.zsh index cdd36e42ce..2f8c7ad321 100644 --- a/shell-plugin/lib/config.zsh +++ b/shell-plugin/lib/config.zsh @@ -39,8 +39,12 @@ typeset -h _FORGE_SESSION_PROVIDER # When non-empty, exported as FORGE_REASONING__EFFORT for every forge invocation. typeset -h _FORGE_SESSION_REASONING_EFFORT + # Terminal context capture settings # Master switch for terminal context capture (preexec/precmd hooks) +# FIXME: This feature should be controlled via `forge_config::Config` +# Move all the configurations to the Config +# FIXME: Rename variables to FORGE_TERM_* suggesting its terminal related typeset -h _FORGE_CTX_ENABLED="${FORGE_CTX_ENABLED:-true}" # Maximum number of commands to keep in the ring buffer (metadata: cmd + exit code) typeset -h _FORGE_CTX_MAX_ENTRIES="${FORGE_CTX_MAX_ENTRIES:-10}" diff --git a/shell-plugin/lib/context.zsh b/shell-plugin/lib/context.zsh index 1b979a4c28..5bb2382947 100644 --- a/shell-plugin/lib/context.zsh +++ b/shell-plugin/lib/context.zsh @@ -19,6 +19,7 @@ # Auto-detection is conservative: only emit for terminals known to support it # to avoid garbled output in unsupported terminals. function _forge_osc133_should_emit() { + // FIXME: Detect and cache for the session case "$_FORGE_CTX_OSC133" in on) return 0 ;; off) return 1 ;; @@ -195,6 +196,9 @@ function _forge_extract_block() { return 0 } +# FIXME: Templating should be implemented in templates dir in a .md file +# Create a new serializable type to pass to that template for rendering + # --------------------------------------------------------------------------- # Context builder # --------------------------------------------------------------------------- diff --git a/shell-plugin/lib/helpers.zsh b/shell-plugin/lib/helpers.zsh index 95b54098e8..3ebc008d34 100644 --- a/shell-plugin/lib/helpers.zsh +++ b/shell-plugin/lib/helpers.zsh @@ -1,3 +1,4 @@ +# FIXME: Revert this file to main #!/usr/bin/env zsh # Core utility functions for forge plugin @@ -40,6 +41,8 @@ function _forge_exec_interactive() { local -a cmd cmd=($_FORGE_BIN --agent "$agent_id") + # FIXME: We will implement a repo to get terminal context + # We don't need a ctx file - revert to `branch main` # Build shell context and pass via temp file local ctx_file="" if ctx_file=$(_forge_build_shell_context); then @@ -52,6 +55,8 @@ function _forge_exec_interactive() { [[ -n "$_FORGE_SESSION_REASONING_EFFORT" ]] && local -x FORGE_REASONING__EFFORT="$_FORGE_SESSION_REASONING_EFFORT" "${cmd[@]}" /dev/tty + # FIXME: We will implement a repo to get terminal context + # Revert to `branch main` # Clean up temp file [[ -n "$ctx_file" ]] && rm -f "$ctx_file" 2>/dev/null } From a55b49a72407f3f3ffc61e467a0c982bf1e1bfa9 Mon Sep 17 00:00:00 2001 From: Tushar Date: Sun, 12 Apr 2026 22:40:45 +0530 Subject: [PATCH 06/29] docs(resolve-fixme): expand skill instructions with consolidation and verification steps --- .forge/skills/resolve-fixme/SKILL.md | 92 +++++++++++++++++++++------- 1 file changed, 69 insertions(+), 23 deletions(-) diff --git a/.forge/skills/resolve-fixme/SKILL.md b/.forge/skills/resolve-fixme/SKILL.md index 5c2c7a47a2..4c4d721dd4 100644 --- a/.forge/skills/resolve-fixme/SKILL.md +++ b/.forge/skills/resolve-fixme/SKILL.md @@ -1,6 +1,6 @@ --- name: resolve-fixme -description: Find all FIXME comments across the codebase and attempt to resolve them. Use when the user asks to fix, resolve, or address FIXME comments, or when running the "fixme" command. Runs a script to locate every FIXME with surrounding context (2 lines before, 5 lines after) and then works through each one systematically. +description: Find all FIXME comments across the codebase and fully implement the work they describe. Use when the user asks to fix, resolve, or address FIXME comments, or when running the "fixme" command. Runs a discovery script to find every FIXME, expands multiline comment blocks, groups related FIXMEs across files into a single implementation task, completes the full underlying code changes, removes the FIXME comments only after the work is done, and verifies that no FIXMEs remain. --- # Resolve FIXME Comments @@ -20,45 +20,91 @@ bash .forge/skills/resolve-fixme/scripts/find-fixme.sh [PATH] - Skips `.git/`, `target/`, `node_modules/`, and `vendor/`. - Requires either `rg` (ripgrep) or `grep` + `python3`. -### 2. Triage the results +### 2. Expand each FIXME into its full instruction -Read the script output and build a work list. For each FIXME: +Do not rely on the discovery output alone. -**Collect the full comment first.** A FIXME may span multiple lines. The discovery script only shows the line where `FIXME` appears, but the actual instruction often continues on the lines immediately below it as additional comment lines. Before interpreting any FIXME, read forward from the FIXME line until the comment block ends — treat all consecutive comment lines as part of the same instruction. Do not act on a partial reading. +For every hit: -**Group related FIXMEs across files.** The same underlying task is often described by FIXMEs spread across multiple files — each one expressing a different facet of the same change (e.g. one file describes a new domain type to create, another describes a parameter to drop once that type exists, a third describes a service to build). Before planning any implementation, read all FIXMEs in full and identify which ones belong to the same task by looking for shared vocabulary, cross-references, or complementary instructions. Group these into a single consolidated task. Implement the task as a whole — do not fix one file in isolation if the FIXMEs describe a coordinated change. +1. Open the file and read around the reported line. +2. Expand the FIXME to include the **entire comment block**. +3. Treat all consecutive related comment lines as part of the same instruction. -For each individual FIXME (or group of related FIXMEs), record: +Important: -- The files, start lines, and end lines of all comment blocks in the group. -- A single consolidated description of the full implementation required — synthesising the intent from all comments in the group into one coherent plan. +- A FIXME may be **multiline**. The line containing `FIXME` is often only the beginning. +- The real instruction may continue on following comment lines and may contain the actual implementation details. +- Do not interpret or edit a FIXME until you have read the full block. -Every FIXME must be resolved. There is no skip option. +For each expanded FIXME, capture: -### 3. Resolve every FIXME +- file path +- start line and end line of the full comment block +- a short summary of what that FIXME is asking for -Work through the list one at a time: +### 3. Consolidate related FIXMEs across files -1. Read the full relevant section of the file. -2. Implement the fix fully — write the code, add the missing logic, refactor as needed. Do not stop short. -3. Remove the FIXME comment **only after** the implementation is complete and correct. +Before editing code, review **all** expanded FIXMEs together. -> **Critical rule:** Never delete or modify a FIXME comment without first completing the work it describes. A FIXME comment is the only record of what needs to be built. Removing it without doing the work silently destroys that record and is strictly forbidden. +Many FIXMEs describe different facets of the same underlying task across multiple files. For example: -If the implementation requires understanding other parts of the codebase first — read them. If it requires creating new types, files, or services — create them. Keep working until every FIXME is fully resolved. +- one file may describe a domain type that needs to be introduced +- another may describe a parameter that should disappear once that type exists +- another may describe a service, repo, or UI update needed to complete the same refactor -### 4. Verify +Group such FIXMEs into a single implementation task. -After resolving all FIXMEs, run the project's standard verification steps: +When grouping, look for: -``` +- shared vocabulary +- references to the same type, service, repo, parameter, or feature +- comments that clearly describe prerequisite and follow-up changes in different files +- comments that only make sense when read together + +For each group, produce one consolidated understanding of the task: + +- all files and line ranges involved +- the complete implementation required across the group +- the order in which the changes should be made + +Do not resolve grouped FIXMEs one file at a time in isolation. Resolve the whole task consistently. + +### 4. Implement every FIXME completely + +Every FIXME must be resolved. There is no skip path. + +Work through each grouped task until the underlying implementation is complete: + +1. Read any additional files needed to understand the design. +2. Create or modify the required code, types, services, repos, tests, configs, or templates. +3. Propagate the change through every affected file in the group. +4. Remove each FIXME comment **only after** the work it describes has actually been implemented. + +> **Critical rule:** Never delete or rewrite a FIXME comment unless the underlying implementation is finished. The comment is a record of required work. Removing it before completing that work is a failure. + +If the FIXME implies a larger refactor, do the refactor. If it requires creating new supporting code, create it. Do not stop at the first local change if the comment clearly implies additional follow-through elsewhere. + +### 5. Verify + +After resolving all FIXMEs: + +1. Run the project's standard verification step: + +```sh cargo insta test --accept ``` -Re-run the discovery script to confirm no FIXMEs remain. +2. Re-run the discovery script: + +```sh +bash .forge/skills/resolve-fixme/scripts/find-fixme.sh [PATH] +``` + +3. Confirm that no FIXME comments remain in the targeted scope. ## Notes -- Prefer targeted, minimal fixes — only change what the FIXME describes. -- When the context is ambiguous, read more of the surrounding file before making a change. -- If an implementation turns out to be larger than expected, break it into steps and work through them — do not use complexity as a reason to leave a FIXME unresolved. +- Prefer targeted fixes, but do not under-scope the work when multiple FIXMEs describe one larger task. +- Read broadly before editing when the intent is ambiguous. +- Consistency matters more than locality: grouped FIXMEs should lead to one coherent implementation. +- The job is not to clean up comments. The job is to complete the implementation those comments are pointing at. From 564181f92e39e891e407fdd6315c2fd3d5f90340 Mon Sep 17 00:00:00 2001 From: Tushar Date: Sun, 12 Apr 2026 22:47:13 +0530 Subject: [PATCH 07/29] feat(terminal-context): replace file-based context passing with FORGE_TERM_CONTEXT env var --- crates/forge_api/src/api.rs | 6 +- crates/forge_api/src/forge_api.rs | 10 +- crates/forge_app/src/command_generator.rs | 61 ++++++---- crates/forge_app/src/infra.rs | 17 ++- crates/forge_domain/src/lib.rs | 2 + crates/forge_domain/src/terminal_context.rs | 32 +++++ crates/forge_main/src/cli.rs | 12 -- crates/forge_main/src/main.rs | 10 -- crates/forge_main/src/ui.rs | 14 +-- crates/forge_services/src/forge_services.rs | 32 ++++- shell-plugin/lib/actions/editor.zsh | 15 +-- shell-plugin/lib/config.zsh | 21 ++-- shell-plugin/lib/context.zsh | 123 ++++++++++---------- shell-plugin/lib/helpers.zsh | 15 +-- 14 files changed, 200 insertions(+), 170 deletions(-) create mode 100644 crates/forge_domain/src/terminal_context.rs diff --git a/crates/forge_api/src/api.rs b/crates/forge_api/src/api.rs index eee233424d..e511d3ed33 100644 --- a/crates/forge_api/src/api.rs +++ b/crates/forge_api/src/api.rs @@ -175,15 +175,11 @@ pub trait API: Sync + Send { /// List of available skills async fn get_skills(&self) -> Result>; - // FIXME: Revert this file to that in main + /// Generate a shell command from natural language prompt async fn generate_command( &self, prompt: UserPrompt, - - // FIXME: drop this parameter - // We will implement an repo to extract/parse and render terminal data - shell_context: Option, ) -> Result; /// Initiate provider auth flow diff --git a/crates/forge_api/src/forge_api.rs b/crates/forge_api/src/forge_api.rs index ed4b8b164a..aa892f18a9 100644 --- a/crates/forge_api/src/forge_api.rs +++ b/crates/forge_api/src/forge_api.rs @@ -8,7 +8,8 @@ use forge_app::{ AgentProviderResolver, AgentRegistry, AppConfigService, AuthService, CommandInfra, CommandLoaderService, ConversationService, DataGenerationApp, EnvironmentInfra, FileDiscoveryService, ForgeApp, GitApp, GrpcInfra, McpConfigManager, McpService, - ProviderAuthService, ProviderService, Services, User, UserUsage, Walker, WorkspaceService, + ProviderAuthService, ProviderService, Services, TerminalContextRepo, User, UserUsage, Walker, + WorkspaceService, }; use forge_config::ForgeConfig; use forge_domain::{Agent, ConsoleWriter, *}; @@ -63,7 +64,7 @@ impl ForgeAPI>, ForgeRepo> { #[async_trait::async_trait] impl< - A: Services + EnvironmentInfra, + A: Services + EnvironmentInfra + TerminalContextRepo, F: CommandInfra + EnvironmentInfra + SkillRepository @@ -311,16 +312,13 @@ impl< async fn get_skills(&self) -> Result> { self.infra.load_skills().await } - // FIXME: Revert this file to that in main async fn generate_command( &self, prompt: UserPrompt, - - shell_context: Option, ) -> Result { use forge_app::CommandGenerator; let generator = CommandGenerator::new(self.services.clone()); - generator.generate(prompt, shell_context).await + generator.generate(prompt).await } async fn init_provider_auth( diff --git a/crates/forge_app/src/command_generator.rs b/crates/forge_app/src/command_generator.rs index be488a700c..60c1423b4a 100644 --- a/crates/forge_app/src/command_generator.rs +++ b/crates/forge_app/src/command_generator.rs @@ -7,8 +7,8 @@ use serde::Deserialize; use crate::{ AppConfigService, EnvironmentInfra, FileDiscoveryService, ProviderService, TemplateEngine, + TerminalContextRepo, }; - /// Response struct for shell command generation using JSON format #[derive(Debug, Clone, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] @@ -25,7 +25,11 @@ pub struct CommandGenerator { impl CommandGenerator where - S: EnvironmentInfra + FileDiscoveryService + ProviderService + AppConfigService, + S: EnvironmentInfra + + FileDiscoveryService + + ProviderService + + AppConfigService + + TerminalContextRepo, { /// Creates a new CommandGenerator instance with the provided services. pub fn new(services: Arc) -> Self { @@ -34,17 +38,13 @@ where /// Generates a shell command from a natural language prompt. /// - /// When `shell_context` is provided (from the zsh plugin's terminal context - /// capture), it is included in the user prompt so the LLM can reference - /// recent commands, exit codes, and terminal output. + /// Terminal context is read automatically from the `FORGE_TERM_CONTEXT` + /// environment variable via the [`TerminalContextRepo`] and included in the + /// user prompt so the LLM can reference recent commands, exit codes, and + /// terminal output. pub async fn generate( &self, prompt: UserPrompt, - // FIXME: Drop this parameter - // Create a domain + repo + service that extract the terminal variables, renders them and makes it available here directly via a service - // Repo should read the data from env variables and create a domain type - TerminalContext - // We use TerminalContext to render a template that we use to render the prompt - shell_context: Option, ) -> Result { // Get system information for context let env = self.services.get_environment(); @@ -72,10 +72,10 @@ where }; // Build user prompt with task, optionally including terminal context - let user_content = match shell_context { + let user_content = match self.services.get_terminal_context() { Some(ctx) => format!( "\n{}\n\n\n{}", - ctx, + ctx.as_str(), prompt.as_str() ), None => format!("{}", prompt.as_str()), @@ -135,6 +135,7 @@ mod tests { response: Arc>>, captured_context: Arc>>, environment: Environment, + terminal_context: Option, } impl MockServices { @@ -152,10 +153,28 @@ mod tests { response: Arc::new(Mutex::new(Some(response.to_string()))), captured_context: Arc::new(Mutex::new(None)), environment: env, + terminal_context: None, + }) + } + + fn with_terminal_context(self: Arc, ctx: &str) -> Arc { + // Safety: only called during test setup before sharing + Arc::new(Self { + files: self.files.clone(), + response: self.response.clone(), + captured_context: self.captured_context.clone(), + environment: self.environment.clone(), + terminal_context: Some(ctx.to_string()), }) } } + impl TerminalContextRepo for MockServices { + fn get_terminal_context(&self) -> Option { + self.terminal_context.clone().map(TerminalContext::new) + } + } + impl EnvironmentInfra for MockServices { type Config = forge_config::ForgeConfig; @@ -307,7 +326,7 @@ mod tests { let generator = CommandGenerator::new(fixture.clone()); let actual = generator - .generate(UserPrompt::from("list all files".to_string()), None) + .generate(UserPrompt::from("list all files".to_string())) .await .unwrap(); @@ -322,7 +341,7 @@ mod tests { let generator = CommandGenerator::new(fixture.clone()); let actual = generator - .generate(UserPrompt::from("show current directory".to_string()), None) + .generate(UserPrompt::from("show current directory".to_string())) .await .unwrap(); @@ -333,20 +352,16 @@ mod tests { #[tokio::test] async fn test_generate_with_shell_context() { + let ctx_str = "## Recent Commands\n| # | Command | Exit | Time |\n|---|---------|------|------|\n| 1 | cargo build | 101 | 12:00:00 |"; let fixture = MockServices::new( r#"{"command": "cargo build --release"}"#, vec![("Cargo.toml", false)], - ); + ) + .with_terminal_context(ctx_str); let generator = CommandGenerator::new(fixture.clone()); - let shell_context = Some( - "## Recent Commands\n| # | Command | Exit | Time |\n|---|---------|------|------|\n| 1 | cargo build | 101 | 12:00:00 |".to_string(), - ); let actual = generator - .generate( - UserPrompt::from("fix the command I just ran".to_string()), - shell_context, - ) + .generate(UserPrompt::from("fix the command I just ran".to_string())) .await .unwrap(); @@ -371,7 +386,7 @@ mod tests { let generator = CommandGenerator::new(fixture); let actual = generator - .generate(UserPrompt::from("do something".to_string()), None) + .generate(UserPrompt::from("do something".to_string())) .await; assert!(actual.is_err()); diff --git a/crates/forge_app/src/infra.rs b/crates/forge_app/src/infra.rs index e4b49bfb66..037de317e2 100644 --- a/crates/forge_app/src/infra.rs +++ b/crates/forge_app/src/infra.rs @@ -6,7 +6,7 @@ use anyhow::Result; use bytes::Bytes; use forge_domain::{ AuthCodeParams, CommandOutput, ConfigOperation, Environment, FileInfo, McpServerConfig, - OAuthConfig, OAuthTokenResponse, ToolDefinition, ToolName, ToolOutput, + OAuthConfig, OAuthTokenResponse, TerminalContext, ToolDefinition, ToolName, ToolOutput, }; use reqwest::Response; use reqwest::header::HeaderMap; @@ -48,6 +48,21 @@ pub trait EnvironmentInfra: Send + Sync { ) -> impl std::future::Future> + Send; } +// FIXME: We can drop this infra completely and directly access infra.get_env_var +// Keep the feature in the services though. + +/// Repository for reading terminal context from environment variables. +/// +/// Reads the `FORGE_TERM_CONTEXT` environment variable set by the zsh plugin +/// before invoking forge, and returns it as a [`TerminalContext`] domain type. +pub trait TerminalContextRepo: Send + Sync { + /// Returns the terminal context if available. + /// + /// Reads the `FORGE_TERM_CONTEXT` environment variable. Returns `None` if + /// the variable is not set or contains only whitespace. + fn get_terminal_context(&self) -> Option; +} + /// Repository for accessing system environment information /// This uses the EnvironmentService trait from forge_domain /// A service for reading files from the filesystem. diff --git a/crates/forge_domain/src/lib.rs b/crates/forge_domain/src/lib.rs index 5db0a8553b..5ae3fca85d 100644 --- a/crates/forge_domain/src/lib.rs +++ b/crates/forge_domain/src/lib.rs @@ -43,6 +43,7 @@ mod suggestion; mod system_context; mod temperature; mod template; +mod terminal_context; mod tools; mod tool_order; @@ -98,6 +99,7 @@ pub use suggestion::*; pub use system_context::*; pub use temperature::*; pub use template::*; +pub use terminal_context::*; pub use tool_order::*; pub use tools::*; pub use top_k::*; diff --git a/crates/forge_domain/src/terminal_context.rs b/crates/forge_domain/src/terminal_context.rs new file mode 100644 index 0000000000..c06803fa4b --- /dev/null +++ b/crates/forge_domain/src/terminal_context.rs @@ -0,0 +1,32 @@ +/// Represents the terminal context captured by the shell plugin. +/// +/// Contains the serialized string of recent shell commands, exit codes, and +/// terminal output that the zsh plugin exports via the `FORGE_TERM_CONTEXT` +/// environment variable before invoking forge. +#[derive(Debug, Clone, PartialEq, Eq)] +// FIXME: Add fields to extract data for each env variable instead of a concatenated text +pub struct TerminalContext(String); + +impl TerminalContext { + /// Creates a new `TerminalContext` from a raw string. + pub fn new(content: impl Into) -> Self { + Self(content.into()) + } + + /// Returns the raw context string. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl From for TerminalContext { + fn from(s: String) -> Self { + Self(s) + } +} + +impl From<&str> for TerminalContext { + fn from(s: &str) -> Self { + Self(s.to_string()) + } +} diff --git a/crates/forge_main/src/cli.rs b/crates/forge_main/src/cli.rs index 800312e7cb..d0c1e9eef3 100644 --- a/crates/forge_main/src/cli.rs +++ b/crates/forge_main/src/cli.rs @@ -29,18 +29,6 @@ pub struct Cli { #[arg(skip)] pub piped_input: Option, - - // FIXME: Drop this CLI parameter - /// Path to a file containing shell terminal context (recent commands, exit - /// codes, terminal output). Populated by the zsh plugin to provide - /// terminal context when invoking forge from the shell. - #[arg(long, hide = true)] - pub shell_context: Option, - - /// Shell context content (populated internally from --shell-context file) - #[arg(skip)] - pub shell_context_content: Option, - /// Path to a JSON file containing the conversation to execute. #[arg(long)] pub conversation: Option, diff --git a/crates/forge_main/src/main.rs b/crates/forge_main/src/main.rs index a67fcd5952..0b68b7e30b 100644 --- a/crates/forge_main/src/main.rs +++ b/crates/forge_main/src/main.rs @@ -105,16 +105,6 @@ async fn run() -> Result<()> { let config = ForgeConfig::read().context("Failed to read Forge configuration from .forge.toml")?; - // Read shell context file if provided by the zsh plugin - if let Some(ref ctx_path) = cli.shell_context - && let Ok(content) = std::fs::read_to_string(ctx_path) - { - let trimmed = content.trim(); - if !trimmed.is_empty() { - cli.shell_context_content = Some(trimmed.to_string()); - } - } - // Handle worktree creation if specified let cwd: PathBuf = match (&cli.sandbox, &cli.directory) { (Some(sandbox), Some(cli)) => { diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index 14a021fc9c..37d5517265 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -1810,12 +1810,7 @@ impl A + Send + Sync> UI async fn on_cmd(&mut self, prompt: UserPrompt) -> anyhow::Result<()> { self.spinner.start(Some("Generating"))?; - // FIXME: Revert this file to main - // UserPrompt rendering is a complex process and already abstracted out - // Use that process to use the new services/repos to render the prompt and add it to the context - let shell_context = self.cli.shell_context_content.clone(); - - match self.api.generate_command(prompt, shell_context).await { + match self.api.generate_command(prompt).await { Ok(command) => { self.spinner.stop(None)?; self.writeln(command)?; @@ -3150,18 +3145,13 @@ impl A + Send + Sync> UI None => Event::empty(), }; - // Build additional context from shell context and/or piped input. - // Shell context (from --shell-context) is always included when available. + // Build additional context from piped input. // Piped input is only additional context when BOTH --prompt and piped // input are provided (e.g., `echo "context" | forge -p "question"`). // When only piped input is provided (no --prompt), it's already used as // the main content via the `content` parameter. let mut additional_parts: Vec = Vec::new(); - if let Some(shell_ctx) = self.cli.shell_context_content.clone() { - additional_parts.push(shell_ctx); - } - let piped_input = self.cli.piped_input.clone(); let has_explicit_prompt = self.cli.prompt.is_some(); if let Some(piped) = piped_input diff --git a/crates/forge_services/src/forge_services.rs b/crates/forge_services/src/forge_services.rs index 7ff1d1a2fb..90905b0cf9 100644 --- a/crates/forge_services/src/forge_services.rs +++ b/crates/forge_services/src/forge_services.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use forge_app::{ AgentRepository, CommandInfra, DirectoryReaderInfra, EnvironmentInfra, FileDirectoryInfra, FileInfoInfra, FileReaderInfra, FileRemoverInfra, FileWriterInfra, HttpInfra, KVStore, - McpServerInfra, Services, StrategyFactory, UserInfra, WalkerInfra, + McpServerInfra, Services, StrategyFactory, TerminalContextRepo, UserInfra, WalkerInfra, }; use forge_domain::{ ChatRepository, ConversationRepository, FuzzySearchRepository, ProviderRepository, @@ -382,3 +382,33 @@ impl< self.infra.get_env_vars() } } + +impl< + F: EnvironmentInfra + + HttpInfra + + McpServerInfra + + WalkerInfra + + SnapshotRepository + + ConversationRepository + + KVStore + + ChatRepository + + ProviderRepository + + WorkspaceIndexRepository + + AgentRepository + + SkillRepository + + ValidationRepository + + Send + + Sync, +> TerminalContextRepo for ForgeServices +{ + fn get_terminal_context(&self) -> Option { + // FIXME: Create a new service for getting terminal context and here we simply route the call to that service + let value = self.infra.get_env_var("FORGE_TERM_CONTEXT")?; + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(forge_domain::TerminalContext::new(trimmed)) + } + } +} diff --git a/shell-plugin/lib/actions/editor.zsh b/shell-plugin/lib/actions/editor.zsh index abf48da03e..02e41b9a4c 100644 --- a/shell-plugin/lib/actions/editor.zsh +++ b/shell-plugin/lib/actions/editor.zsh @@ -81,21 +81,12 @@ function _forge_action_suggest() { echo - # FIXME: Revert this file to that in main - - # Build shell context and pass via temp file (same pattern as _forge_exec_interactive) - local ctx_file="" - local -a ctx_args=() - if ctx_file=$(_forge_build_shell_context); then - ctx_args=(--shell-context "$ctx_file") - fi + # Build terminal context and export it as FORGE_TERM_CONTEXT env var + _forge_build_shell_context # Generate the command local generated_command - generated_command=$(FORCE_COLOR=true CLICOLOR_FORCE=1 _forge_exec "${ctx_args[@]}" suggest "$description") - - # Clean up temp file - [[ -n "$ctx_file" ]] && rm -f "$ctx_file" 2>/dev/null + generated_command=$(FORCE_COLOR=true CLICOLOR_FORCE=1 _forge_exec suggest "$description") if [[ -n "$generated_command" ]]; then # Replace the buffer with the generated command diff --git a/shell-plugin/lib/config.zsh b/shell-plugin/lib/config.zsh index 2f8c7ad321..698f335342 100644 --- a/shell-plugin/lib/config.zsh +++ b/shell-plugin/lib/config.zsh @@ -42,21 +42,18 @@ typeset -h _FORGE_SESSION_REASONING_EFFORT # Terminal context capture settings # Master switch for terminal context capture (preexec/precmd hooks) -# FIXME: This feature should be controlled via `forge_config::Config` -# Move all the configurations to the Config -# FIXME: Rename variables to FORGE_TERM_* suggesting its terminal related -typeset -h _FORGE_CTX_ENABLED="${FORGE_CTX_ENABLED:-true}" +typeset -h _FORGE_TERM_ENABLED="${FORGE_TERM_ENABLED:-true}" # Maximum number of commands to keep in the ring buffer (metadata: cmd + exit code) -typeset -h _FORGE_CTX_MAX_ENTRIES="${FORGE_CTX_MAX_ENTRIES:-10}" +typeset -h _FORGE_TERM_MAX_ENTRIES="${FORGE_TERM_MAX_ENTRIES:-10}" # Number of recent commands to include full output for -typeset -h _FORGE_CTX_FULL_OUTPUT_COUNT="${FORGE_CTX_FULL_OUTPUT_COUNT:-5}" +typeset -h _FORGE_TERM_FULL_OUTPUT_COUNT="${FORGE_TERM_FULL_OUTPUT_COUNT:-5}" # Maximum output lines per command block -typeset -h _FORGE_CTX_MAX_LINES_PER_CMD="${FORGE_CTX_MAX_LINES_PER_CMD:-200}" +typeset -h _FORGE_TERM_MAX_LINES_PER_CMD="${FORGE_TERM_MAX_LINES_PER_CMD:-200}" # Scrollback lines to capture from the terminal for command block extraction -typeset -h _FORGE_CTX_SCROLLBACK_LINES="${FORGE_CTX_SCROLLBACK_LINES:-1000}" +typeset -h _FORGE_TERM_SCROLLBACK_LINES="${FORGE_TERM_SCROLLBACK_LINES:-1000}" # OSC 133 semantic prompt marker emission: "auto", "on", or "off" -typeset -h _FORGE_CTX_OSC133="${FORGE_CTX_OSC133:-auto}" +typeset -h _FORGE_TERM_OSC133="${FORGE_TERM_OSC133:-auto}" # Ring buffer arrays for context capture -typeset -ha _FORGE_CTX_COMMANDS=() -typeset -ha _FORGE_CTX_EXIT_CODES=() -typeset -ha _FORGE_CTX_TIMESTAMPS=() +typeset -ha _FORGE_TERM_COMMANDS=() +typeset -ha _FORGE_TERM_EXIT_CODES=() +typeset -ha _FORGE_TERM_TIMESTAMPS=() diff --git a/shell-plugin/lib/context.zsh b/shell-plugin/lib/context.zsh index 5bb2382947..c45d7df224 100644 --- a/shell-plugin/lib/context.zsh +++ b/shell-plugin/lib/context.zsh @@ -18,26 +18,32 @@ # Determines whether OSC 133 semantic markers should be emitted. # Auto-detection is conservative: only emit for terminals known to support it # to avoid garbled output in unsupported terminals. +# The detection result is cached per session in _FORGE_TERM_OSC133_CACHED +# ("1" = emit, "0" = don't emit) to avoid repeated detection overhead. +typeset -g _FORGE_TERM_OSC133_CACHED="" function _forge_osc133_should_emit() { - // FIXME: Detect and cache for the session - case "$_FORGE_CTX_OSC133" in - on) return 0 ;; - off) return 1 ;; + if [[ -n "$_FORGE_TERM_OSC133_CACHED" ]]; then + [[ "$_FORGE_TERM_OSC133_CACHED" == "1" ]] && return 0 || return 1 + fi + case "$_FORGE_TERM_OSC133" in + on) _FORGE_TERM_OSC133_CACHED="1"; return 0 ;; + off) _FORGE_TERM_OSC133_CACHED="0"; return 1 ;; auto) # Kitty sets KITTY_PID - [[ -n "${KITTY_PID:-}" ]] && return 0 + if [[ -n "${KITTY_PID:-}" ]]; then _FORGE_TERM_OSC133_CACHED="1"; return 0; fi # Detect by TERM_PROGRAM case "${TERM_PROGRAM:-}" in - WezTerm|iTerm.app|vscode) return 0 ;; + WezTerm|iTerm.app|vscode) _FORGE_TERM_OSC133_CACHED="1"; return 0 ;; esac # Foot terminal - [[ "${TERM:-}" == "foot"* ]] && return 0 + if [[ "${TERM:-}" == "foot"* ]]; then _FORGE_TERM_OSC133_CACHED="1"; return 0; fi # Ghostty - [[ "${TERM_PROGRAM:-}" == "ghostty" ]] && return 0 + if [[ "${TERM_PROGRAM:-}" == "ghostty" ]]; then _FORGE_TERM_OSC133_CACHED="1"; return 0; fi # Unknown terminal: don't emit + _FORGE_TERM_OSC133_CACHED="0" return 1 ;; - *) return 1 ;; + *) _FORGE_TERM_OSC133_CACHED="0"; return 1 ;; esac } @@ -53,17 +59,17 @@ function _forge_osc133_emit() { # --------------------------------------------------------------------------- # Ring buffer storage uses parallel arrays declared in config.zsh: -# _FORGE_CTX_COMMANDS, _FORGE_CTX_EXIT_CODES, _FORGE_CTX_TIMESTAMPS +# _FORGE_TERM_COMMANDS, _FORGE_TERM_EXIT_CODES, _FORGE_TERM_TIMESTAMPS # Pending command state: -typeset -g _FORGE_CTX_PENDING_CMD="" -typeset -g _FORGE_CTX_PENDING_TS="" +typeset -g _FORGE_TERM_PENDING_CMD="" +typeset -g _FORGE_TERM_PENDING_TS="" # Called before each command executes. # Records the command text and timestamp, emits OSC 133 B+C markers. function _forge_context_preexec() { - [[ "$_FORGE_CTX_ENABLED" != "true" ]] && return - _FORGE_CTX_PENDING_CMD="$1" - _FORGE_CTX_PENDING_TS="$(date +%s)" + [[ "$_FORGE_TERM_ENABLED" != "true" ]] && return + _FORGE_TERM_PENDING_CMD="$1" + _FORGE_TERM_PENDING_TS="$(date +%s)" # OSC 133 B: prompt end / command start _forge_osc133_emit "B" # OSC 133 C: command output start @@ -74,26 +80,26 @@ function _forge_context_preexec() { # Captures exit code, pushes to ring buffer, emits OSC 133 D+A markers. function _forge_context_precmd() { local last_exit=$? # MUST be first line to capture exit code - [[ "$_FORGE_CTX_ENABLED" != "true" ]] && return + [[ "$_FORGE_TERM_ENABLED" != "true" ]] && return # OSC 133 D: command finished with exit code _forge_osc133_emit "D;$last_exit" # Only record if we have a pending command from preexec - if [[ -n "$_FORGE_CTX_PENDING_CMD" ]]; then - _FORGE_CTX_COMMANDS+=("$_FORGE_CTX_PENDING_CMD") - _FORGE_CTX_EXIT_CODES+=("$last_exit") - _FORGE_CTX_TIMESTAMPS+=("$_FORGE_CTX_PENDING_TS") + if [[ -n "$_FORGE_TERM_PENDING_CMD" ]]; then + _FORGE_TERM_COMMANDS+=("$_FORGE_TERM_PENDING_CMD") + _FORGE_TERM_EXIT_CODES+=("$last_exit") + _FORGE_TERM_TIMESTAMPS+=("$_FORGE_TERM_PENDING_TS") # Trim ring buffer to max size - while (( ${#_FORGE_CTX_COMMANDS} > _FORGE_CTX_MAX_ENTRIES )); do - shift _FORGE_CTX_COMMANDS - shift _FORGE_CTX_EXIT_CODES - shift _FORGE_CTX_TIMESTAMPS + while (( ${#_FORGE_TERM_COMMANDS} > _FORGE_TERM_MAX_ENTRIES )); do + shift _FORGE_TERM_COMMANDS + shift _FORGE_TERM_EXIT_CODES + shift _FORGE_TERM_TIMESTAMPS done - _FORGE_CTX_PENDING_CMD="" - _FORGE_CTX_PENDING_TS="" + _FORGE_TERM_PENDING_CMD="" + _FORGE_TERM_PENDING_TS="" fi # OSC 133 A: prompt start (for the next prompt) @@ -105,11 +111,11 @@ function _forge_context_precmd() { # --------------------------------------------------------------------------- # Captures raw scrollback text from the terminal. The amount captured is -# controlled by _FORGE_CTX_SCROLLBACK_LINES. +# controlled by _FORGE_TERM_SCROLLBACK_LINES. # Returns the scrollback on stdout, or returns 1 if unavailable. # Priority: Kitty > WezTerm > Zellij > tmux > none function _forge_capture_scrollback() { - local lines="${_FORGE_CTX_SCROLLBACK_LINES:-1000}" + local lines="${_FORGE_TERM_SCROLLBACK_LINES:-1000}" local output="" # Priority 1: Kitty — get full scrollback (OSC 133 aware) @@ -166,7 +172,7 @@ function _forge_extract_block() { local scrollback="$1" local cmd="$2" local next_cmd="$3" - local max_lines="${_FORGE_CTX_MAX_LINES_PER_CMD:-200}" + local max_lines="${_FORGE_TERM_MAX_LINES_PER_CMD:-200}" # Find the LAST occurrence of this command in scrollback (most recent run) local cmd_line @@ -196,28 +202,20 @@ function _forge_extract_block() { return 0 } -# FIXME: Templating should be implemented in templates dir in a .md file -# Create a new serializable type to pass to that template for rendering - -# --------------------------------------------------------------------------- -# Context builder -# --------------------------------------------------------------------------- - -# Builds a shell context file containing: +# FIXME: Drop this function completely - we will render it in a service by reading all the variables and exposing them via TerminalContext domain type +# Builds a shell context string containing: # 1. Metadata for all commands in ring buffer (last N commands + exit codes) # 2. Full output blocks for the most recent M commands (extracted from scrollback) # -# Writes to a temp file and echoes the path on stdout. +# Exports the context as the FORGE_TERM_CONTEXT environment variable so that +# forge can read it without requiring a temp file. # Returns non-zero if context is disabled or empty. function _forge_build_shell_context() { - [[ "$_FORGE_CTX_ENABLED" != "true" ]] && return 1 - [[ ${#_FORGE_CTX_COMMANDS} -eq 0 ]] && return 1 - - local ctx_file - ctx_file=$(mktemp "${TMPDIR:-/tmp}/forge-ctx-XXXXXX") || return 1 + [[ "$_FORGE_TERM_ENABLED" != "true" ]] && return 1 + [[ ${#_FORGE_TERM_COMMANDS} -eq 0 ]] && return 1 - local count=${#_FORGE_CTX_COMMANDS} - local full_output_count="${_FORGE_CTX_FULL_OUTPUT_COUNT:-5}" + local count=${#_FORGE_TERM_COMMANDS} + local full_output_count="${_FORGE_TERM_FULL_OUTPUT_COUNT:-5}" # Determine which commands get full output (the most recent N) local full_output_start=$(( count - full_output_count + 1 )) @@ -227,7 +225,8 @@ function _forge_build_shell_context() { local scrollback="" scrollback=$(_forge_capture_scrollback 2>/dev/null) - { + local ctx_content + ctx_content=$({ echo "# Terminal Context" echo "" echo "The following is the user's recent terminal activity. Commands are listed" @@ -241,14 +240,14 @@ function _forge_build_shell_context() { echo "" for (( i=1; i < full_output_start; i++ )); do local ts_human - ts_human=$(date -d "@${_FORGE_CTX_TIMESTAMPS[$i]}" '+%H:%M:%S' 2>/dev/null \ - || date -r "${_FORGE_CTX_TIMESTAMPS[$i]}" '+%H:%M:%S' 2>/dev/null \ - || echo "${_FORGE_CTX_TIMESTAMPS[$i]}") + ts_human=$(date -d "@${_FORGE_TERM_TIMESTAMPS[$i]}" '+%H:%M:%S' 2>/dev/null \ + || date -r "${_FORGE_TERM_TIMESTAMPS[$i]}" '+%H:%M:%S' 2>/dev/null \ + || echo "${_FORGE_TERM_TIMESTAMPS[$i]}") local exit_marker="" - if [[ "${_FORGE_CTX_EXIT_CODES[$i]}" != "0" ]]; then - exit_marker=" [EXIT CODE: ${_FORGE_CTX_EXIT_CODES[$i]}]" + if [[ "${_FORGE_TERM_EXIT_CODES[$i]}" != "0" ]]; then + exit_marker=" [EXIT CODE: ${_FORGE_TERM_EXIT_CODES[$i]}]" fi - echo "- \`${_FORGE_CTX_COMMANDS[$i]}\` at ${ts_human}${exit_marker}" + echo "- \`${_FORGE_TERM_COMMANDS[$i]}\` at ${ts_human}${exit_marker}" done echo "" fi @@ -258,12 +257,12 @@ function _forge_build_shell_context() { echo "" for (( i=full_output_start; i <= count; i++ )); do - local cmd="${_FORGE_CTX_COMMANDS[$i]}" - local exit_code="${_FORGE_CTX_EXIT_CODES[$i]}" + local cmd="${_FORGE_TERM_COMMANDS[$i]}" + local exit_code="${_FORGE_TERM_EXIT_CODES[$i]}" local ts_human - ts_human=$(date -d "@${_FORGE_CTX_TIMESTAMPS[$i]}" '+%H:%M:%S' 2>/dev/null \ - || date -r "${_FORGE_CTX_TIMESTAMPS[$i]}" '+%H:%M:%S' 2>/dev/null \ - || echo "${_FORGE_CTX_TIMESTAMPS[$i]}") + ts_human=$(date -d "@${_FORGE_TERM_TIMESTAMPS[$i]}" '+%H:%M:%S' 2>/dev/null \ + || date -r "${_FORGE_TERM_TIMESTAMPS[$i]}" '+%H:%M:%S' 2>/dev/null \ + || echo "${_FORGE_TERM_TIMESTAMPS[$i]}") local status_label="ok" [[ "$exit_code" != "0" ]] && status_label="FAILED (exit ${exit_code})" @@ -276,7 +275,7 @@ function _forge_build_shell_context() { # Determine the next command string for boundary detection local next_cmd="" if (( i < count )); then - next_cmd="${_FORGE_CTX_COMMANDS[$((i+1))]}" + next_cmd="${_FORGE_TERM_COMMANDS[$((i+1))]}" fi local block @@ -293,20 +292,18 @@ function _forge_build_shell_context() { fi echo "" done - } > "$ctx_file" + }) - echo "$ctx_file" + export FORGE_TERM_CONTEXT="$ctx_content" return 0 } -# --------------------------------------------------------------------------- # Hook registration -# --------------------------------------------------------------------------- # Register using standard zsh hook arrays for coexistence with other plugins. # precmd is prepended so it runs first and captures the real $? from the # command, before other plugins (powerlevel10k, starship, etc.) overwrite it. -if [[ "$_FORGE_CTX_ENABLED" == "true" ]]; then +if [[ "$_FORGE_TERM_ENABLED" == "true" ]]; then preexec_functions+=(_forge_context_preexec) precmd_functions=(_forge_context_precmd "${precmd_functions[@]}") fi diff --git a/shell-plugin/lib/helpers.zsh b/shell-plugin/lib/helpers.zsh index 3ebc008d34..6fa59a07a1 100644 --- a/shell-plugin/lib/helpers.zsh +++ b/shell-plugin/lib/helpers.zsh @@ -1,4 +1,3 @@ -# FIXME: Revert this file to main #!/usr/bin/env zsh # Core utility functions for forge plugin @@ -41,24 +40,14 @@ function _forge_exec_interactive() { local -a cmd cmd=($_FORGE_BIN --agent "$agent_id") - # FIXME: We will implement a repo to get terminal context - # We don't need a ctx file - revert to `branch main` - # Build shell context and pass via temp file - local ctx_file="" - if ctx_file=$(_forge_build_shell_context); then - cmd+=(--shell-context "$ctx_file") - fi + # Build terminal context and export it as FORGE_TERM_CONTEXT env var + _forge_build_shell_context cmd+=("$@") [[ -n "$_FORGE_SESSION_MODEL" ]] && local -x FORGE_SESSION__MODEL_ID="$_FORGE_SESSION_MODEL" [[ -n "$_FORGE_SESSION_PROVIDER" ]] && local -x FORGE_SESSION__PROVIDER_ID="$_FORGE_SESSION_PROVIDER" [[ -n "$_FORGE_SESSION_REASONING_EFFORT" ]] && local -x FORGE_REASONING__EFFORT="$_FORGE_SESSION_REASONING_EFFORT" "${cmd[@]}" /dev/tty - - # FIXME: We will implement a repo to get terminal context - # Revert to `branch main` - # Clean up temp file - [[ -n "$ctx_file" ]] && rm -f "$ctx_file" 2>/dev/null } function _forge_reset() { From 67cc7213630d561c7e065fe13c8ae7e41d91e571 Mon Sep 17 00:00:00 2001 From: Tushar Date: Sun, 12 Apr 2026 23:07:52 +0530 Subject: [PATCH 08/29] feat(terminal-context): replace TerminalContextRepo trait with TerminalContextService and structured env vars --- crates/forge_api/src/forge_api.rs | 4 +- crates/forge_app/src/command_generator.rs | 94 ++++--- crates/forge_app/src/infra.rs | 17 +- crates/forge_domain/src/terminal_context.rs | 81 ++++-- crates/forge_services/src/forge_services.rs | 32 +-- crates/forge_services/src/lib.rs | 2 + crates/forge_services/src/terminal_context.rs | 231 ++++++++++++++++++ shell-plugin/lib/actions/editor.zsh | 9 +- shell-plugin/lib/context.zsh | 96 -------- shell-plugin/lib/helpers.zsh | 9 +- 10 files changed, 372 insertions(+), 203 deletions(-) create mode 100644 crates/forge_services/src/terminal_context.rs diff --git a/crates/forge_api/src/forge_api.rs b/crates/forge_api/src/forge_api.rs index aa892f18a9..d7a92aa2e3 100644 --- a/crates/forge_api/src/forge_api.rs +++ b/crates/forge_api/src/forge_api.rs @@ -8,7 +8,7 @@ use forge_app::{ AgentProviderResolver, AgentRegistry, AppConfigService, AuthService, CommandInfra, CommandLoaderService, ConversationService, DataGenerationApp, EnvironmentInfra, FileDiscoveryService, ForgeApp, GitApp, GrpcInfra, McpConfigManager, McpService, - ProviderAuthService, ProviderService, Services, TerminalContextRepo, User, UserUsage, Walker, + ProviderAuthService, ProviderService, Services, User, UserUsage, Walker, WorkspaceService, }; use forge_config::ForgeConfig; @@ -64,7 +64,7 @@ impl ForgeAPI>, ForgeRepo> { #[async_trait::async_trait] impl< - A: Services + EnvironmentInfra + TerminalContextRepo, + A: Services + EnvironmentInfra, F: CommandInfra + EnvironmentInfra + SkillRepository diff --git a/crates/forge_app/src/command_generator.rs b/crates/forge_app/src/command_generator.rs index 60c1423b4a..6d9bca153c 100644 --- a/crates/forge_app/src/command_generator.rs +++ b/crates/forge_app/src/command_generator.rs @@ -7,7 +7,6 @@ use serde::Deserialize; use crate::{ AppConfigService, EnvironmentInfra, FileDiscoveryService, ProviderService, TemplateEngine, - TerminalContextRepo, }; /// Response struct for shell command generation using JSON format #[derive(Debug, Clone, Deserialize, JsonSchema)] @@ -25,11 +24,7 @@ pub struct CommandGenerator { impl CommandGenerator where - S: EnvironmentInfra - + FileDiscoveryService - + ProviderService - + AppConfigService - + TerminalContextRepo, + S: EnvironmentInfra + FileDiscoveryService + ProviderService + AppConfigService, { /// Creates a new CommandGenerator instance with the provided services. pub fn new(services: Arc) -> Self { @@ -38,14 +33,11 @@ where /// Generates a shell command from a natural language prompt. /// - /// Terminal context is read automatically from the `FORGE_TERM_CONTEXT` - /// environment variable via the [`TerminalContextRepo`] and included in the - /// user prompt so the LLM can reference recent commands, exit codes, and - /// terminal output. - pub async fn generate( - &self, - prompt: UserPrompt, - ) -> Result { + /// Terminal context is read automatically from the `FORGE_TERM_COMMANDS`, + /// `FORGE_TERM_EXIT_CODES`, and `FORGE_TERM_TIMESTAMPS` environment variables + /// exported by the zsh plugin, and included in the user prompt so the LLM + /// can reference recent commands, exit codes, and timestamps. + pub async fn generate(&self, prompt: UserPrompt) -> Result { // Get system information for context let env = self.services.get_environment(); @@ -72,10 +64,10 @@ where }; // Build user prompt with task, optionally including terminal context - let user_content = match self.services.get_terminal_context() { + let user_content = match self.read_terminal_context() { Some(ctx) => format!( "\n{}\n\n\n{}", - ctx.as_str(), + ctx, prompt.as_str() ), None => format!("{}", prompt.as_str()), @@ -101,6 +93,43 @@ where Ok(response.command) } + /// Reads terminal context from environment variables set by the zsh plugin. + /// + /// Returns `None` if `FORGE_TERM_COMMANDS` is not set or is empty. + + // FIXME: Reuse TerminalContextService over here + fn read_terminal_context(&self) -> Option { + let commands_raw = self.services.get_env_var("FORGE_TERM_COMMANDS")?; + let commands: Vec = commands_raw + .split(':') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(String::from) + .collect(); + if commands.is_empty() { + return None; + } + let exit_codes: Vec = self + .services + .get_env_var("FORGE_TERM_EXIT_CODES") + .unwrap_or_default() + .split(':') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.parse::().unwrap_or(0)) + .collect(); + let timestamps: Vec = self + .services + .get_env_var("FORGE_TERM_TIMESTAMPS") + .unwrap_or_default() + .split(':') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.parse::().unwrap_or(0)) + .collect(); + Some(TerminalContext::new(commands, exit_codes, timestamps)) + } + /// Creates a context with system and user messages for the LLM fn create_context( &self, @@ -135,7 +164,7 @@ mod tests { response: Arc>>, captured_context: Arc>>, environment: Environment, - terminal_context: Option, + env_vars: std::collections::BTreeMap, } impl MockServices { @@ -153,28 +182,30 @@ mod tests { response: Arc::new(Mutex::new(Some(response.to_string()))), captured_context: Arc::new(Mutex::new(None)), environment: env, - terminal_context: None, + env_vars: std::collections::BTreeMap::new(), }) } - fn with_terminal_context(self: Arc, ctx: &str) -> Arc { - // Safety: only called during test setup before sharing + fn with_terminal_context( + self: Arc, + commands: &str, + exit_codes: &str, + timestamps: &str, + ) -> Arc { + let mut env_vars = self.env_vars.clone(); + env_vars.insert("FORGE_TERM_COMMANDS".to_string(), commands.to_string()); + env_vars.insert("FORGE_TERM_EXIT_CODES".to_string(), exit_codes.to_string()); + env_vars.insert("FORGE_TERM_TIMESTAMPS".to_string(), timestamps.to_string()); Arc::new(Self { files: self.files.clone(), response: self.response.clone(), captured_context: self.captured_context.clone(), environment: self.environment.clone(), - terminal_context: Some(ctx.to_string()), + env_vars, }) } } - impl TerminalContextRepo for MockServices { - fn get_terminal_context(&self) -> Option { - self.terminal_context.clone().map(TerminalContext::new) - } - } - impl EnvironmentInfra for MockServices { type Config = forge_config::ForgeConfig; @@ -193,12 +224,12 @@ mod tests { unimplemented!() } - fn get_env_var(&self, _key: &str) -> Option { - None + fn get_env_var(&self, key: &str) -> Option { + self.env_vars.get(key).cloned() } fn get_env_vars(&self) -> std::collections::BTreeMap { - std::collections::BTreeMap::new() + self.env_vars.clone() } } @@ -352,12 +383,11 @@ mod tests { #[tokio::test] async fn test_generate_with_shell_context() { - let ctx_str = "## Recent Commands\n| # | Command | Exit | Time |\n|---|---------|------|------|\n| 1 | cargo build | 101 | 12:00:00 |"; let fixture = MockServices::new( r#"{"command": "cargo build --release"}"#, vec![("Cargo.toml", false)], ) - .with_terminal_context(ctx_str); + .with_terminal_context("cargo build", "101", "1700000000"); let generator = CommandGenerator::new(fixture.clone()); let actual = generator diff --git a/crates/forge_app/src/infra.rs b/crates/forge_app/src/infra.rs index 037de317e2..e4b49bfb66 100644 --- a/crates/forge_app/src/infra.rs +++ b/crates/forge_app/src/infra.rs @@ -6,7 +6,7 @@ use anyhow::Result; use bytes::Bytes; use forge_domain::{ AuthCodeParams, CommandOutput, ConfigOperation, Environment, FileInfo, McpServerConfig, - OAuthConfig, OAuthTokenResponse, TerminalContext, ToolDefinition, ToolName, ToolOutput, + OAuthConfig, OAuthTokenResponse, ToolDefinition, ToolName, ToolOutput, }; use reqwest::Response; use reqwest::header::HeaderMap; @@ -48,21 +48,6 @@ pub trait EnvironmentInfra: Send + Sync { ) -> impl std::future::Future> + Send; } -// FIXME: We can drop this infra completely and directly access infra.get_env_var -// Keep the feature in the services though. - -/// Repository for reading terminal context from environment variables. -/// -/// Reads the `FORGE_TERM_CONTEXT` environment variable set by the zsh plugin -/// before invoking forge, and returns it as a [`TerminalContext`] domain type. -pub trait TerminalContextRepo: Send + Sync { - /// Returns the terminal context if available. - /// - /// Reads the `FORGE_TERM_CONTEXT` environment variable. Returns `None` if - /// the variable is not set or contains only whitespace. - fn get_terminal_context(&self) -> Option; -} - /// Repository for accessing system environment information /// This uses the EnvironmentService trait from forge_domain /// A service for reading files from the filesystem. diff --git a/crates/forge_domain/src/terminal_context.rs b/crates/forge_domain/src/terminal_context.rs index c06803fa4b..a2ba5ff154 100644 --- a/crates/forge_domain/src/terminal_context.rs +++ b/crates/forge_domain/src/terminal_context.rs @@ -1,32 +1,69 @@ -/// Represents the terminal context captured by the shell plugin. -/// -/// Contains the serialized string of recent shell commands, exit codes, and -/// terminal output that the zsh plugin exports via the `FORGE_TERM_CONTEXT` -/// environment variable before invoking forge. +/// A single command entry captured by the shell plugin. #[derive(Debug, Clone, PartialEq, Eq)] -// FIXME: Add fields to extract data for each env variable instead of a concatenated text -pub struct TerminalContext(String); +pub struct TerminalCommand { + /// The command text as entered by the user. + pub command: String, + /// The exit code produced by the command. + pub exit_code: i32, + /// Unix timestamp (seconds since epoch) when the command was run. + pub timestamp: u64, +} -impl TerminalContext { - /// Creates a new `TerminalContext` from a raw string. - pub fn new(content: impl Into) -> Self { - Self(content.into()) - } +/// Structured terminal context captured by the shell plugin. +/// +/// Each field corresponds to one of the environment variables exported by the +/// zsh plugin before invoking forge: +/// - `FORGE_TERM_COMMANDS` — colon-separated command strings +/// - `FORGE_TERM_EXIT_CODES` — colon-separated exit codes +/// - `FORGE_TERM_TIMESTAMPS` — colon-separated Unix timestamps +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct TerminalContext { + /// Ordered list of recent commands, from oldest to newest. + pub commands: Vec, +} - /// Returns the raw context string. - pub fn as_str(&self) -> &str { - &self.0 +impl TerminalContext { + /// Creates a new `TerminalContext` from parallel vectors of command data. + /// + /// All three slices must have the same length; entries at the same index + /// are combined into a single [`TerminalCommand`]. If the lengths differ, + /// the shortest slice determines how many entries are produced. + pub fn new( + commands: Vec, + exit_codes: Vec, + timestamps: Vec, + ) -> Self { + let entries = commands + .into_iter() + .zip(exit_codes) + .zip(timestamps) + .map(|((command, exit_code), timestamp)| TerminalCommand { + command, + exit_code, + timestamp, + }) + .collect(); + Self { commands: entries } } -} -impl From for TerminalContext { - fn from(s: String) -> Self { - Self(s) + /// Returns `true` if there are no recorded commands. + pub fn is_empty(&self) -> bool { + self.commands.is_empty() } } -impl From<&str> for TerminalContext { - fn from(s: &str) -> Self { - Self(s.to_string()) +// FIXME: Drop `Display` use TemplateEngine to render the logic using template +// Define the template in md format inside the /template dirs +impl std::fmt::Display for TerminalContext { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for entry in &self.commands { + let status = if entry.exit_code == 0 { + "ok".to_string() + } else { + format!("FAILED (exit {})", entry.exit_code) + }; + writeln!(f, "- `{}` [{}] @ {}", entry.command, status, entry.timestamp)?; + } + Ok(()) } } diff --git a/crates/forge_services/src/forge_services.rs b/crates/forge_services/src/forge_services.rs index 90905b0cf9..980534acb2 100644 --- a/crates/forge_services/src/forge_services.rs +++ b/crates/forge_services/src/forge_services.rs @@ -3,13 +3,12 @@ use std::sync::Arc; use forge_app::{ AgentRepository, CommandInfra, DirectoryReaderInfra, EnvironmentInfra, FileDirectoryInfra, FileInfoInfra, FileReaderInfra, FileRemoverInfra, FileWriterInfra, HttpInfra, KVStore, - McpServerInfra, Services, StrategyFactory, TerminalContextRepo, UserInfra, WalkerInfra, + McpServerInfra, Services, StrategyFactory, UserInfra, WalkerInfra, }; use forge_domain::{ ChatRepository, ConversationRepository, FuzzySearchRepository, ProviderRepository, SkillRepository, SnapshotRepository, ValidationRepository, WorkspaceIndexRepository, }; - use crate::ForgeProviderAuthService; use crate::agent_registry::ForgeAgentRegistryService; use crate::app_config::ForgeAppConfigService; @@ -383,32 +382,3 @@ impl< } } -impl< - F: EnvironmentInfra - + HttpInfra - + McpServerInfra - + WalkerInfra - + SnapshotRepository - + ConversationRepository - + KVStore - + ChatRepository - + ProviderRepository - + WorkspaceIndexRepository - + AgentRepository - + SkillRepository - + ValidationRepository - + Send - + Sync, -> TerminalContextRepo for ForgeServices -{ - fn get_terminal_context(&self) -> Option { - // FIXME: Create a new service for getting terminal context and here we simply route the call to that service - let value = self.infra.get_env_var("FORGE_TERM_CONTEXT")?; - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(forge_domain::TerminalContext::new(trimmed)) - } - } -} diff --git a/crates/forge_services/src/lib.rs b/crates/forge_services/src/lib.rs index bb102e86c6..816accca92 100644 --- a/crates/forge_services/src/lib.rs +++ b/crates/forge_services/src/lib.rs @@ -21,6 +21,7 @@ mod provider_service; mod range; mod sync; mod template; +mod terminal_context; mod tool_services; mod utils; @@ -34,6 +35,7 @@ pub use forge_services::*; pub use instructions::*; pub use policy::*; pub use provider_auth::*; +pub use terminal_context::*; /// Converts a type from its external representation into its domain model /// representation. diff --git a/crates/forge_services/src/terminal_context.rs b/crates/forge_services/src/terminal_context.rs new file mode 100644 index 0000000000..926119305e --- /dev/null +++ b/crates/forge_services/src/terminal_context.rs @@ -0,0 +1,231 @@ +use forge_app::EnvironmentInfra; +use forge_domain::{TerminalContext, TerminalCommand}; + +/// Service that reads terminal context from environment variables exported by +/// the zsh plugin and constructs a structured [`TerminalContext`]. +/// +/// The zsh plugin exports three colon-separated environment variables before +/// invoking forge: +/// - `FORGE_TERM_COMMANDS` — the command strings +/// - `FORGE_TERM_EXIT_CODES` — the corresponding exit codes +/// - `FORGE_TERM_TIMESTAMPS` — the corresponding Unix timestamps +#[derive(Clone)] +pub struct TerminalContextService(std::sync::Arc); + +impl TerminalContextService { + /// Creates a new `TerminalContextService` backed by the provided infrastructure. + pub fn new(infra: std::sync::Arc) -> Self { + Self(infra) + } +} + +impl TerminalContextService { + /// Reads the terminal context from environment variables. + /// + /// Returns `None` if none of the required variables are set or if no + /// commands were recorded. + pub fn get_terminal_context(&self) -> Option { + + // FIXME: Move the env variable names to a const + // FIXME: Add use `_FORGE_TERM_...` as the prefix + let commands_raw = self.0.get_env_var("FORGE_TERM_COMMANDS")?; + let exit_codes_raw = self + .0 + .get_env_var("FORGE_TERM_EXIT_CODES") + .unwrap_or_default(); + let timestamps_raw = self + .0 + .get_env_var("FORGE_TERM_TIMESTAMPS") + .unwrap_or_default(); + + let commands: Vec = split_env_list(&commands_raw); + if commands.is_empty() { + return None; + } + + let exit_codes: Vec = split_env_list(&exit_codes_raw) + .iter() + .map(|s| s.parse::().unwrap_or(0)) + .collect(); + + let timestamps: Vec = split_env_list(×tamps_raw) + .iter() + .map(|s| s.parse::().unwrap_or(0)) + .collect(); + + // Zip the three lists together, stopping at the shortest + let entries: Vec = commands + .into_iter() + .zip( + exit_codes + .into_iter() + .chain(std::iter::repeat(0)) + .take(usize::MAX), + ) + .zip( + timestamps + .into_iter() + .chain(std::iter::repeat(0)) + .take(usize::MAX), + ) + .map(|((command, exit_code), timestamp)| TerminalCommand { + command, + exit_code, + timestamp, + }) + .collect(); + + if entries.is_empty() { + None + } else { + Some(TerminalContext { commands: entries }) + } + } +} + +/// Splits a colon-separated environment variable value into a list of strings, +/// filtering out any empty segments produced by leading/trailing/double colons. +fn split_env_list(raw: &str) -> Vec { + raw.split(':') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(String::from) + .collect() +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + use std::sync::Arc; + + use forge_domain::{Environment, TerminalCommand, TerminalContext}; + use pretty_assertions::assert_eq; + + use super::*; + + struct MockInfra { + env_vars: BTreeMap, + } + + impl MockInfra { + fn new(vars: &[(&str, &str)]) -> Arc { + Arc::new(Self { + env_vars: vars + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + }) + } + } + + impl forge_app::EnvironmentInfra for MockInfra { + type Config = forge_config::ForgeConfig; + + fn get_environment(&self) -> Environment { + use fake::{Fake, Faker}; + Faker.fake() + } + + fn get_config(&self) -> anyhow::Result { + Ok(forge_config::ForgeConfig::default()) + } + + async fn update_environment( + &self, + _ops: Vec, + ) -> anyhow::Result<()> { + Ok(()) + } + + fn get_env_var(&self, key: &str) -> Option { + self.env_vars.get(key).cloned() + } + + fn get_env_vars(&self) -> BTreeMap { + self.env_vars.clone() + } + } + + #[test] + fn test_no_env_vars_returns_none() { + let fixture = TerminalContextService::new(MockInfra::new(&[])); + let actual = fixture.get_terminal_context(); + assert_eq!(actual, None); + } + + #[test] + fn test_empty_commands_returns_none() { + let fixture = TerminalContextService::new(MockInfra::new(&[ + ("FORGE_TERM_COMMANDS", ""), + ])); + let actual = fixture.get_terminal_context(); + assert_eq!(actual, None); + } + + #[test] + fn test_single_command_no_extras() { + let fixture = TerminalContextService::new(MockInfra::new(&[ + ("FORGE_TERM_COMMANDS", "cargo build"), + ])); + let actual = fixture.get_terminal_context(); + let expected = Some(TerminalContext { + commands: vec![TerminalCommand { + command: "cargo build".to_string(), + exit_code: 0, + timestamp: 0, + }], + }); + assert_eq!(actual, expected); + } + + #[test] + fn test_multiple_commands_with_exit_codes_and_timestamps() { + let fixture = TerminalContextService::new(MockInfra::new(&[ + ("FORGE_TERM_COMMANDS", "ls:cargo test:git status"), + ("FORGE_TERM_EXIT_CODES", "0:1:0"), + ("FORGE_TERM_TIMESTAMPS", "1700000001:1700000002:1700000003"), + ])); + let actual = fixture.get_terminal_context(); + let expected = Some(TerminalContext { + commands: vec![ + TerminalCommand { + command: "ls".to_string(), + exit_code: 0, + timestamp: 1700000001, + }, + TerminalCommand { + command: "cargo test".to_string(), + exit_code: 1, + timestamp: 1700000002, + }, + TerminalCommand { + command: "git status".to_string(), + exit_code: 0, + timestamp: 1700000003, + }, + ], + }); + assert_eq!(actual, expected); + } + + #[test] + fn test_split_env_list_empty() { + let actual = split_env_list(""); + let expected: Vec = vec![]; + assert_eq!(actual, expected); + } + + #[test] + fn test_split_env_list_single() { + let actual = split_env_list("hello"); + let expected = vec!["hello".to_string()]; + assert_eq!(actual, expected); + } + + #[test] + fn test_split_env_list_multiple() { + let actual = split_env_list("a:b:c"); + let expected = vec!["a".to_string(), "b".to_string(), "c".to_string()]; + assert_eq!(actual, expected); + } +} diff --git a/shell-plugin/lib/actions/editor.zsh b/shell-plugin/lib/actions/editor.zsh index 02e41b9a4c..d1b18fe8fb 100644 --- a/shell-plugin/lib/actions/editor.zsh +++ b/shell-plugin/lib/actions/editor.zsh @@ -81,8 +81,13 @@ function _forge_action_suggest() { echo - # Build terminal context and export it as FORGE_TERM_CONTEXT env var - _forge_build_shell_context + # Export terminal context arrays as colon-separated env vars so that the + # Rust TerminalContextService can read them via get_env_var. + if [[ "$_FORGE_TERM_ENABLED" == "true" && ${#_FORGE_TERM_COMMANDS} -gt 0 ]]; then + export FORGE_TERM_COMMANDS="${(j.:.)_FORGE_TERM_COMMANDS}" + export FORGE_TERM_EXIT_CODES="${(j.:.)_FORGE_TERM_EXIT_CODES}" + export FORGE_TERM_TIMESTAMPS="${(j.:.)_FORGE_TERM_TIMESTAMPS}" + fi # Generate the command local generated_command diff --git a/shell-plugin/lib/context.zsh b/shell-plugin/lib/context.zsh index c45d7df224..dcd501fc6a 100644 --- a/shell-plugin/lib/context.zsh +++ b/shell-plugin/lib/context.zsh @@ -202,102 +202,6 @@ function _forge_extract_block() { return 0 } -# FIXME: Drop this function completely - we will render it in a service by reading all the variables and exposing them via TerminalContext domain type -# Builds a shell context string containing: -# 1. Metadata for all commands in ring buffer (last N commands + exit codes) -# 2. Full output blocks for the most recent M commands (extracted from scrollback) -# -# Exports the context as the FORGE_TERM_CONTEXT environment variable so that -# forge can read it without requiring a temp file. -# Returns non-zero if context is disabled or empty. -function _forge_build_shell_context() { - [[ "$_FORGE_TERM_ENABLED" != "true" ]] && return 1 - [[ ${#_FORGE_TERM_COMMANDS} -eq 0 ]] && return 1 - - local count=${#_FORGE_TERM_COMMANDS} - local full_output_count="${_FORGE_TERM_FULL_OUTPUT_COUNT:-5}" - - # Determine which commands get full output (the most recent N) - local full_output_start=$(( count - full_output_count + 1 )) - (( full_output_start < 1 )) && full_output_start=1 - - # Capture scrollback once (expensive operation, do it only once) - local scrollback="" - scrollback=$(_forge_capture_scrollback 2>/dev/null) - - local ctx_content - ctx_content=$({ - echo "# Terminal Context" - echo "" - echo "The following is the user's recent terminal activity. Commands are listed" - echo "from oldest to newest. The last ${full_output_count} commands include their full output" - echo "when terminal capture is available." - echo "" - - # --- Section 1: Metadata-only commands (older ones) --- - if (( full_output_start > 1 )); then - echo "## Earlier Commands" - echo "" - for (( i=1; i < full_output_start; i++ )); do - local ts_human - ts_human=$(date -d "@${_FORGE_TERM_TIMESTAMPS[$i]}" '+%H:%M:%S' 2>/dev/null \ - || date -r "${_FORGE_TERM_TIMESTAMPS[$i]}" '+%H:%M:%S' 2>/dev/null \ - || echo "${_FORGE_TERM_TIMESTAMPS[$i]}") - local exit_marker="" - if [[ "${_FORGE_TERM_EXIT_CODES[$i]}" != "0" ]]; then - exit_marker=" [EXIT CODE: ${_FORGE_TERM_EXIT_CODES[$i]}]" - fi - echo "- \`${_FORGE_TERM_COMMANDS[$i]}\` at ${ts_human}${exit_marker}" - done - echo "" - fi - - # --- Section 2: Full output command blocks (recent ones) --- - echo "## Recent Commands (with output)" - echo "" - - for (( i=full_output_start; i <= count; i++ )); do - local cmd="${_FORGE_TERM_COMMANDS[$i]}" - local exit_code="${_FORGE_TERM_EXIT_CODES[$i]}" - local ts_human - ts_human=$(date -d "@${_FORGE_TERM_TIMESTAMPS[$i]}" '+%H:%M:%S' 2>/dev/null \ - || date -r "${_FORGE_TERM_TIMESTAMPS[$i]}" '+%H:%M:%S' 2>/dev/null \ - || echo "${_FORGE_TERM_TIMESTAMPS[$i]}") - - local status_label="ok" - [[ "$exit_code" != "0" ]] && status_label="FAILED (exit ${exit_code})" - - echo "### \`${cmd}\` — ${status_label} at ${ts_human}" - echo "" - - # Try to extract this command's output from scrollback - if [[ -n "$scrollback" ]]; then - # Determine the next command string for boundary detection - local next_cmd="" - if (( i < count )); then - next_cmd="${_FORGE_TERM_COMMANDS[$((i+1))]}" - fi - - local block - block=$(_forge_extract_block "$scrollback" "$cmd" "$next_cmd") - if [[ -n "$block" ]]; then - echo '```' - echo "$block" - echo '```' - else - echo "_No output captured._" - fi - else - echo "_Terminal output capture not available._" - fi - echo "" - done - }) - - export FORGE_TERM_CONTEXT="$ctx_content" - return 0 -} - # Hook registration # Register using standard zsh hook arrays for coexistence with other plugins. diff --git a/shell-plugin/lib/helpers.zsh b/shell-plugin/lib/helpers.zsh index 6fa59a07a1..a592e1b3fd 100644 --- a/shell-plugin/lib/helpers.zsh +++ b/shell-plugin/lib/helpers.zsh @@ -40,8 +40,13 @@ function _forge_exec_interactive() { local -a cmd cmd=($_FORGE_BIN --agent "$agent_id") - # Build terminal context and export it as FORGE_TERM_CONTEXT env var - _forge_build_shell_context + # Export terminal context arrays as colon-separated env vars so that the + # Rust TerminalContextService can read them via get_env_var. + if [[ "$_FORGE_TERM_ENABLED" == "true" && ${#_FORGE_TERM_COMMANDS} -gt 0 ]]; then + export FORGE_TERM_COMMANDS="${(j.:.)_FORGE_TERM_COMMANDS}" + export FORGE_TERM_EXIT_CODES="${(j.:.)_FORGE_TERM_EXIT_CODES}" + export FORGE_TERM_TIMESTAMPS="${(j.:.)_FORGE_TERM_TIMESTAMPS}" + fi cmd+=("$@") [[ -n "$_FORGE_SESSION_MODEL" ]] && local -x FORGE_SESSION__MODEL_ID="$_FORGE_SESSION_MODEL" From bd1e39170ce543b635cde5ac63a037d26d8d4462 Mon Sep 17 00:00:00 2001 From: Tushar Date: Sun, 12 Apr 2026 23:17:19 +0530 Subject: [PATCH 09/29] feat(terminal-context): move TerminalContextService to forge_app and render context via template --- crates/forge_app/src/command_generator.rs | 56 ++++------------ crates/forge_app/src/lib.rs | 2 + .../src/terminal_context.rs | 67 ++++++++++--------- crates/forge_domain/src/terminal_context.rs | 20 +----- crates/forge_services/src/lib.rs | 2 - templates/forge-terminal-context.md | 2 + 6 files changed, 56 insertions(+), 93 deletions(-) rename crates/{forge_services => forge_app}/src/terminal_context.rs (78%) create mode 100644 templates/forge-terminal-context.md diff --git a/crates/forge_app/src/command_generator.rs b/crates/forge_app/src/command_generator.rs index 6d9bca153c..ecd6b5b38a 100644 --- a/crates/forge_app/src/command_generator.rs +++ b/crates/forge_app/src/command_generator.rs @@ -7,6 +7,7 @@ use serde::Deserialize; use crate::{ AppConfigService, EnvironmentInfra, FileDiscoveryService, ProviderService, TemplateEngine, + TerminalContextService, }; /// Response struct for shell command generation using JSON format #[derive(Debug, Clone, Deserialize, JsonSchema)] @@ -64,12 +65,18 @@ where }; // Build user prompt with task, optionally including terminal context - let user_content = match self.read_terminal_context() { - Some(ctx) => format!( - "\n{}\n\n\n{}", - ctx, - prompt.as_str() - ), + let terminal_ctx = + TerminalContextService::new(self.services.clone()).get_terminal_context(); + let user_content = match terminal_ctx { + Some(ctx) => { + let rendered = TemplateEngine::default() + .render("forge-terminal-context.md", &serde_json::to_value(&ctx)?)?; + format!( + "\n{}\n\n\n{}", + rendered, + prompt.as_str() + ) + } None => format!("{}", prompt.as_str()), }; @@ -93,43 +100,6 @@ where Ok(response.command) } - /// Reads terminal context from environment variables set by the zsh plugin. - /// - /// Returns `None` if `FORGE_TERM_COMMANDS` is not set or is empty. - - // FIXME: Reuse TerminalContextService over here - fn read_terminal_context(&self) -> Option { - let commands_raw = self.services.get_env_var("FORGE_TERM_COMMANDS")?; - let commands: Vec = commands_raw - .split(':') - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(String::from) - .collect(); - if commands.is_empty() { - return None; - } - let exit_codes: Vec = self - .services - .get_env_var("FORGE_TERM_EXIT_CODES") - .unwrap_or_default() - .split(':') - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(|s| s.parse::().unwrap_or(0)) - .collect(); - let timestamps: Vec = self - .services - .get_env_var("FORGE_TERM_TIMESTAMPS") - .unwrap_or_default() - .split(':') - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(|s| s.parse::().unwrap_or(0)) - .collect(); - Some(TerminalContext::new(commands, exit_codes, timestamps)) - } - /// Creates a context with system and user messages for the LLM fn create_context( &self, diff --git a/crates/forge_app/src/lib.rs b/crates/forge_app/src/lib.rs index 1b3295498c..66de3e618d 100644 --- a/crates/forge_app/src/lib.rs +++ b/crates/forge_app/src/lib.rs @@ -26,6 +26,7 @@ mod services; mod set_conversation_id; pub mod system_prompt; mod template_engine; +mod terminal_context; mod title_generator; mod tool_executor; mod tool_registry; @@ -48,6 +49,7 @@ pub use git_app::*; pub use infra::*; pub use services::*; pub use template_engine::*; +pub use terminal_context::*; pub use tool_resolver::*; pub use user::*; pub use utils::{compute_hash, is_binary_content_type}; diff --git a/crates/forge_services/src/terminal_context.rs b/crates/forge_app/src/terminal_context.rs similarity index 78% rename from crates/forge_services/src/terminal_context.rs rename to crates/forge_app/src/terminal_context.rs index 926119305e..577f68a603 100644 --- a/crates/forge_services/src/terminal_context.rs +++ b/crates/forge_app/src/terminal_context.rs @@ -1,20 +1,35 @@ -use forge_app::EnvironmentInfra; +use std::sync::Arc; + use forge_domain::{TerminalContext, TerminalCommand}; +use crate::EnvironmentInfra; + +/// Environment variable exported by the zsh plugin containing colon-separated +/// command strings. +pub const ENV_TERM_COMMANDS: &str = "FORGE_TERM_COMMANDS"; + +/// Environment variable exported by the zsh plugin containing colon-separated +/// exit codes corresponding to [`ENV_TERM_COMMANDS`]. +pub const ENV_TERM_EXIT_CODES: &str = "FORGE_TERM_EXIT_CODES"; + +/// Environment variable exported by the zsh plugin containing colon-separated +/// Unix timestamps corresponding to [`ENV_TERM_COMMANDS`]. +pub const ENV_TERM_TIMESTAMPS: &str = "FORGE_TERM_TIMESTAMPS"; + /// Service that reads terminal context from environment variables exported by /// the zsh plugin and constructs a structured [`TerminalContext`]. /// /// The zsh plugin exports three colon-separated environment variables before /// invoking forge: -/// - `FORGE_TERM_COMMANDS` — the command strings -/// - `FORGE_TERM_EXIT_CODES` — the corresponding exit codes -/// - `FORGE_TERM_TIMESTAMPS` — the corresponding Unix timestamps +/// - [`ENV_TERM_COMMANDS`] — the command strings +/// - [`ENV_TERM_EXIT_CODES`] — the corresponding exit codes +/// - [`ENV_TERM_TIMESTAMPS`] — the corresponding Unix timestamps #[derive(Clone)] -pub struct TerminalContextService(std::sync::Arc); +pub struct TerminalContextService(Arc); impl TerminalContextService { /// Creates a new `TerminalContextService` backed by the provided infrastructure. - pub fn new(infra: std::sync::Arc) -> Self { + pub fn new(infra: Arc) -> Self { Self(infra) } } @@ -25,24 +40,16 @@ impl TerminalContextService { /// Returns `None` if none of the required variables are set or if no /// commands were recorded. pub fn get_terminal_context(&self) -> Option { - - // FIXME: Move the env variable names to a const - // FIXME: Add use `_FORGE_TERM_...` as the prefix - let commands_raw = self.0.get_env_var("FORGE_TERM_COMMANDS")?; - let exit_codes_raw = self - .0 - .get_env_var("FORGE_TERM_EXIT_CODES") - .unwrap_or_default(); - let timestamps_raw = self - .0 - .get_env_var("FORGE_TERM_TIMESTAMPS") - .unwrap_or_default(); + let commands_raw = self.0.get_env_var(ENV_TERM_COMMANDS)?; let commands: Vec = split_env_list(&commands_raw); if commands.is_empty() { return None; } + let exit_codes_raw = self.0.get_env_var(ENV_TERM_EXIT_CODES).unwrap_or_default(); + let timestamps_raw = self.0.get_env_var(ENV_TERM_TIMESTAMPS).unwrap_or_default(); + let exit_codes: Vec = split_env_list(&exit_codes_raw) .iter() .map(|s| s.parse::().unwrap_or(0)) @@ -53,7 +60,7 @@ impl TerminalContextService { .map(|s| s.parse::().unwrap_or(0)) .collect(); - // Zip the three lists together, stopping at the shortest + // Zip the three lists together; pad missing exit codes/timestamps with 0 let entries: Vec = commands .into_iter() .zip( @@ -85,7 +92,7 @@ impl TerminalContextService { /// Splits a colon-separated environment variable value into a list of strings, /// filtering out any empty segments produced by leading/trailing/double colons. -fn split_env_list(raw: &str) -> Vec { +pub fn split_env_list(raw: &str) -> Vec { raw.split(':') .map(str::trim) .filter(|s| !s.is_empty()) @@ -118,7 +125,7 @@ mod tests { } } - impl forge_app::EnvironmentInfra for MockInfra { + impl crate::EnvironmentInfra for MockInfra { type Config = forge_config::ForgeConfig; fn get_environment(&self) -> Environment { @@ -155,18 +162,18 @@ mod tests { #[test] fn test_empty_commands_returns_none() { - let fixture = TerminalContextService::new(MockInfra::new(&[ - ("FORGE_TERM_COMMANDS", ""), - ])); + let fixture = + TerminalContextService::new(MockInfra::new(&[(ENV_TERM_COMMANDS, "")])); let actual = fixture.get_terminal_context(); assert_eq!(actual, None); } #[test] fn test_single_command_no_extras() { - let fixture = TerminalContextService::new(MockInfra::new(&[ - ("FORGE_TERM_COMMANDS", "cargo build"), - ])); + let fixture = TerminalContextService::new(MockInfra::new(&[( + ENV_TERM_COMMANDS, + "cargo build", + )])); let actual = fixture.get_terminal_context(); let expected = Some(TerminalContext { commands: vec![TerminalCommand { @@ -181,9 +188,9 @@ mod tests { #[test] fn test_multiple_commands_with_exit_codes_and_timestamps() { let fixture = TerminalContextService::new(MockInfra::new(&[ - ("FORGE_TERM_COMMANDS", "ls:cargo test:git status"), - ("FORGE_TERM_EXIT_CODES", "0:1:0"), - ("FORGE_TERM_TIMESTAMPS", "1700000001:1700000002:1700000003"), + (ENV_TERM_COMMANDS, "ls:cargo test:git status"), + (ENV_TERM_EXIT_CODES, "0:1:0"), + (ENV_TERM_TIMESTAMPS, "1700000001:1700000002:1700000003"), ])); let actual = fixture.get_terminal_context(); let expected = Some(TerminalContext { diff --git a/crates/forge_domain/src/terminal_context.rs b/crates/forge_domain/src/terminal_context.rs index a2ba5ff154..76b95460fc 100644 --- a/crates/forge_domain/src/terminal_context.rs +++ b/crates/forge_domain/src/terminal_context.rs @@ -1,5 +1,5 @@ /// A single command entry captured by the shell plugin. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] pub struct TerminalCommand { /// The command text as entered by the user. pub command: String, @@ -16,7 +16,7 @@ pub struct TerminalCommand { /// - `FORGE_TERM_COMMANDS` — colon-separated command strings /// - `FORGE_TERM_EXIT_CODES` — colon-separated exit codes /// - `FORGE_TERM_TIMESTAMPS` — colon-separated Unix timestamps -#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize)] pub struct TerminalContext { /// Ordered list of recent commands, from oldest to newest. pub commands: Vec, @@ -51,19 +51,3 @@ impl TerminalContext { self.commands.is_empty() } } - -// FIXME: Drop `Display` use TemplateEngine to render the logic using template -// Define the template in md format inside the /template dirs -impl std::fmt::Display for TerminalContext { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - for entry in &self.commands { - let status = if entry.exit_code == 0 { - "ok".to_string() - } else { - format!("FAILED (exit {})", entry.exit_code) - }; - writeln!(f, "- `{}` [{}] @ {}", entry.command, status, entry.timestamp)?; - } - Ok(()) - } -} diff --git a/crates/forge_services/src/lib.rs b/crates/forge_services/src/lib.rs index 816accca92..bb102e86c6 100644 --- a/crates/forge_services/src/lib.rs +++ b/crates/forge_services/src/lib.rs @@ -21,7 +21,6 @@ mod provider_service; mod range; mod sync; mod template; -mod terminal_context; mod tool_services; mod utils; @@ -35,7 +34,6 @@ pub use forge_services::*; pub use instructions::*; pub use policy::*; pub use provider_auth::*; -pub use terminal_context::*; /// Converts a type from its external representation into its domain model /// representation. diff --git a/templates/forge-terminal-context.md b/templates/forge-terminal-context.md new file mode 100644 index 0000000000..a5aed5571f --- /dev/null +++ b/templates/forge-terminal-context.md @@ -0,0 +1,2 @@ +{{#each commands}}- `{{command}}` [{{#if exit_code}}FAILED (exit {{exit_code}}){{else}}ok{{/if}}] @ {{timestamp}} +{{/each}} \ No newline at end of file From a00bb83a6892b419b0aa34265273ac2b5ec9f019 Mon Sep 17 00:00:00 2001 From: Tushar Date: Sun, 12 Apr 2026 23:27:25 +0530 Subject: [PATCH 10/29] feat(terminal-context): add FIXME markers for next refactor steps --- crates/forge_api/src/api.rs | 5 +---- crates/forge_api/src/forge_api.rs | 8 ++------ crates/forge_app/src/command_generator.rs | 10 +++++++--- crates/forge_app/src/terminal_context.rs | 15 +++++++-------- crates/forge_app/src/user_prompt.rs | 3 +++ crates/forge_domain/src/terminal_context.rs | 8 +++----- crates/forge_main/src/ui.rs | 2 +- crates/forge_services/src/forge_services.rs | 2 +- shell-plugin/lib/helpers.zsh | 2 ++ templates/forge-terminal-context.md | 1 + 10 files changed, 28 insertions(+), 28 deletions(-) diff --git a/crates/forge_api/src/api.rs b/crates/forge_api/src/api.rs index e511d3ed33..ceef7975d9 100644 --- a/crates/forge_api/src/api.rs +++ b/crates/forge_api/src/api.rs @@ -177,10 +177,7 @@ pub trait API: Sync + Send { async fn get_skills(&self) -> Result>; /// Generate a shell command from natural language prompt - async fn generate_command( - &self, - prompt: UserPrompt, - ) -> Result; + async fn generate_command(&self, prompt: UserPrompt) -> Result; /// Initiate provider auth flow async fn init_provider_auth( diff --git a/crates/forge_api/src/forge_api.rs b/crates/forge_api/src/forge_api.rs index d7a92aa2e3..1372a314ec 100644 --- a/crates/forge_api/src/forge_api.rs +++ b/crates/forge_api/src/forge_api.rs @@ -8,8 +8,7 @@ use forge_app::{ AgentProviderResolver, AgentRegistry, AppConfigService, AuthService, CommandInfra, CommandLoaderService, ConversationService, DataGenerationApp, EnvironmentInfra, FileDiscoveryService, ForgeApp, GitApp, GrpcInfra, McpConfigManager, McpService, - ProviderAuthService, ProviderService, Services, User, UserUsage, Walker, - WorkspaceService, + ProviderAuthService, ProviderService, Services, User, UserUsage, Walker, WorkspaceService, }; use forge_config::ForgeConfig; use forge_domain::{Agent, ConsoleWriter, *}; @@ -312,10 +311,7 @@ impl< async fn get_skills(&self) -> Result> { self.infra.load_skills().await } - async fn generate_command( - &self, - prompt: UserPrompt, - ) -> Result { + async fn generate_command(&self, prompt: UserPrompt) -> Result { use forge_app::CommandGenerator; let generator = CommandGenerator::new(self.services.clone()); generator.generate(prompt).await diff --git a/crates/forge_app/src/command_generator.rs b/crates/forge_app/src/command_generator.rs index ecd6b5b38a..507d1a8b2b 100644 --- a/crates/forge_app/src/command_generator.rs +++ b/crates/forge_app/src/command_generator.rs @@ -35,9 +35,10 @@ where /// Generates a shell command from a natural language prompt. /// /// Terminal context is read automatically from the `FORGE_TERM_COMMANDS`, - /// `FORGE_TERM_EXIT_CODES`, and `FORGE_TERM_TIMESTAMPS` environment variables - /// exported by the zsh plugin, and included in the user prompt so the LLM - /// can reference recent commands, exit codes, and timestamps. + /// `FORGE_TERM_EXIT_CODES`, and `FORGE_TERM_TIMESTAMPS` environment + /// variables exported by the zsh plugin, and included in the user + /// prompt so the LLM can reference recent commands, exit codes, and + /// timestamps. pub async fn generate(&self, prompt: UserPrompt) -> Result { // Get system information for context let env = self.services.get_environment(); @@ -64,6 +65,9 @@ where } }; + // FIXME: Move the template engine rendering logic also into TerminalContextService + + // Build user prompt with task, optionally including terminal context let terminal_ctx = TerminalContextService::new(self.services.clone()).get_terminal_context(); diff --git a/crates/forge_app/src/terminal_context.rs b/crates/forge_app/src/terminal_context.rs index 577f68a603..0950c3cc3e 100644 --- a/crates/forge_app/src/terminal_context.rs +++ b/crates/forge_app/src/terminal_context.rs @@ -1,9 +1,10 @@ use std::sync::Arc; -use forge_domain::{TerminalContext, TerminalCommand}; +use forge_domain::{TerminalCommand, TerminalContext}; use crate::EnvironmentInfra; +// FIXME: The env variable names have to start with an `_` - update the zsh implementation also /// Environment variable exported by the zsh plugin containing colon-separated /// command strings. pub const ENV_TERM_COMMANDS: &str = "FORGE_TERM_COMMANDS"; @@ -28,7 +29,8 @@ pub const ENV_TERM_TIMESTAMPS: &str = "FORGE_TERM_TIMESTAMPS"; pub struct TerminalContextService(Arc); impl TerminalContextService { - /// Creates a new `TerminalContextService` backed by the provided infrastructure. + /// Creates a new `TerminalContextService` backed by the provided + /// infrastructure. pub fn new(infra: Arc) -> Self { Self(infra) } @@ -162,18 +164,15 @@ mod tests { #[test] fn test_empty_commands_returns_none() { - let fixture = - TerminalContextService::new(MockInfra::new(&[(ENV_TERM_COMMANDS, "")])); + let fixture = TerminalContextService::new(MockInfra::new(&[(ENV_TERM_COMMANDS, "")])); let actual = fixture.get_terminal_context(); assert_eq!(actual, None); } #[test] fn test_single_command_no_extras() { - let fixture = TerminalContextService::new(MockInfra::new(&[( - ENV_TERM_COMMANDS, - "cargo build", - )])); + let fixture = + TerminalContextService::new(MockInfra::new(&[(ENV_TERM_COMMANDS, "cargo build")])); let actual = fixture.get_terminal_context(); let expected = Some(TerminalContext { commands: vec![TerminalCommand { diff --git a/crates/forge_app/src/user_prompt.rs b/crates/forge_app/src/user_prompt.rs index 382ba8e765..9adbac1e94 100644 --- a/crates/forge_app/src/user_prompt.rs +++ b/crates/forge_app/src/user_prompt.rs @@ -52,6 +52,9 @@ impl UserPromptGenerator { } else { conversation }; + + // FIXME: Use TerminalContextService here to add the context of the terminal into the conversation + Ok(conversation) } diff --git a/crates/forge_domain/src/terminal_context.rs b/crates/forge_domain/src/terminal_context.rs index 76b95460fc..58fa2dba25 100644 --- a/crates/forge_domain/src/terminal_context.rs +++ b/crates/forge_domain/src/terminal_context.rs @@ -28,11 +28,7 @@ impl TerminalContext { /// All three slices must have the same length; entries at the same index /// are combined into a single [`TerminalCommand`]. If the lengths differ, /// the shortest slice determines how many entries are produced. - pub fn new( - commands: Vec, - exit_codes: Vec, - timestamps: Vec, - ) -> Self { + pub fn new(commands: Vec, exit_codes: Vec, timestamps: Vec) -> Self { let entries = commands .into_iter() .zip(exit_codes) @@ -50,4 +46,6 @@ impl TerminalContext { pub fn is_empty(&self) -> bool { self.commands.is_empty() } + + // FIXME: Add a `render` method on TemplateContext which will internally use the `Element` type to render the TerminalContext } diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index 37d5517265..0ef500200b 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -3129,7 +3129,7 @@ impl A + Send + Sync> UI Ok(()) } - + // FIXME: Revert this file to that in `main` async fn on_message(&mut self, content: Option) -> Result<()> { let conversation_id = self.init_conversation().await?; diff --git a/crates/forge_services/src/forge_services.rs b/crates/forge_services/src/forge_services.rs index 980534acb2..7ff1d1a2fb 100644 --- a/crates/forge_services/src/forge_services.rs +++ b/crates/forge_services/src/forge_services.rs @@ -9,6 +9,7 @@ use forge_domain::{ ChatRepository, ConversationRepository, FuzzySearchRepository, ProviderRepository, SkillRepository, SnapshotRepository, ValidationRepository, WorkspaceIndexRepository, }; + use crate::ForgeProviderAuthService; use crate::agent_registry::ForgeAgentRegistryService; use crate::app_config::ForgeAppConfigService; @@ -381,4 +382,3 @@ impl< self.infra.get_env_vars() } } - diff --git a/shell-plugin/lib/helpers.zsh b/shell-plugin/lib/helpers.zsh index a592e1b3fd..9bd6eaf5e1 100644 --- a/shell-plugin/lib/helpers.zsh +++ b/shell-plugin/lib/helpers.zsh @@ -43,6 +43,8 @@ function _forge_exec_interactive() { # Export terminal context arrays as colon-separated env vars so that the # Rust TerminalContextService can read them via get_env_var. if [[ "$_FORGE_TERM_ENABLED" == "true" && ${#_FORGE_TERM_COMMANDS} -gt 0 ]]; then + # FIXME: Use `_` prefixed variable names since these are internal + # FIXME: Do we need export? We aren't using it down below export FORGE_TERM_COMMANDS="${(j.:.)_FORGE_TERM_COMMANDS}" export FORGE_TERM_EXIT_CODES="${(j.:.)_FORGE_TERM_EXIT_CODES}" export FORGE_TERM_TIMESTAMPS="${(j.:.)_FORGE_TERM_TIMESTAMPS}" diff --git a/templates/forge-terminal-context.md b/templates/forge-terminal-context.md index a5aed5571f..acb56be71f 100644 --- a/templates/forge-terminal-context.md +++ b/templates/forge-terminal-context.md @@ -1,2 +1,3 @@ + {{#each commands}}- `{{command}}` [{{#if exit_code}}FAILED (exit {{exit_code}}){{else}}ok{{/if}}] @ {{timestamp}} {{/each}} \ No newline at end of file From 73cb12f00ad3ad148c3288089fd7a7165ca20258 Mon Sep 17 00:00:00 2001 From: Tushar Date: Sun, 12 Apr 2026 23:32:48 +0530 Subject: [PATCH 11/29] feat(terminal-context): resolve FIXME markers and integrate terminal context into user prompt --- crates/forge_app/src/command_generator.rs | 32 +++----- crates/forge_app/src/terminal_context.rs | 15 +++- crates/forge_app/src/user_prompt.rs | 87 ++++++++++++++++++++- crates/forge_domain/src/terminal_context.rs | 14 +++- crates/forge_main/src/ui.rs | 26 +++--- shell-plugin/lib/helpers.zsh | 13 +-- templates/forge-terminal-context.md | 3 - 7 files changed, 141 insertions(+), 49 deletions(-) delete mode 100644 templates/forge-terminal-context.md diff --git a/crates/forge_app/src/command_generator.rs b/crates/forge_app/src/command_generator.rs index 507d1a8b2b..b70e5208ba 100644 --- a/crates/forge_app/src/command_generator.rs +++ b/crates/forge_app/src/command_generator.rs @@ -9,6 +9,7 @@ use crate::{ AppConfigService, EnvironmentInfra, FileDiscoveryService, ProviderService, TemplateEngine, TerminalContextService, }; + /// Response struct for shell command generation using JSON format #[derive(Debug, Clone, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] @@ -65,22 +66,15 @@ where } }; - // FIXME: Move the template engine rendering logic also into TerminalContextService - - - // Build user prompt with task, optionally including terminal context - let terminal_ctx = - TerminalContextService::new(self.services.clone()).get_terminal_context(); - let user_content = match terminal_ctx { - Some(ctx) => { - let rendered = TemplateEngine::default() - .render("forge-terminal-context.md", &serde_json::to_value(&ctx)?)?; - format!( - "\n{}\n\n\n{}", - rendered, - prompt.as_str() - ) - } + // Build user prompt with task, optionally including terminal context. + // Rendering is handled entirely by TerminalContextService::render(). + let terminal_service = TerminalContextService::new(self.services.clone()); + let user_content = match terminal_service.render() { + Some(rendered) => format!( + "{}\n\n{}", + rendered, + prompt.as_str() + ), None => format!("{}", prompt.as_str()), }; @@ -167,9 +161,9 @@ mod tests { timestamps: &str, ) -> Arc { let mut env_vars = self.env_vars.clone(); - env_vars.insert("FORGE_TERM_COMMANDS".to_string(), commands.to_string()); - env_vars.insert("FORGE_TERM_EXIT_CODES".to_string(), exit_codes.to_string()); - env_vars.insert("FORGE_TERM_TIMESTAMPS".to_string(), timestamps.to_string()); + env_vars.insert("_FORGE_TERM_COMMANDS".to_string(), commands.to_string()); + env_vars.insert("_FORGE_TERM_EXIT_CODES".to_string(), exit_codes.to_string()); + env_vars.insert("_FORGE_TERM_TIMESTAMPS".to_string(), timestamps.to_string()); Arc::new(Self { files: self.files.clone(), response: self.response.clone(), diff --git a/crates/forge_app/src/terminal_context.rs b/crates/forge_app/src/terminal_context.rs index 0950c3cc3e..a300413d6f 100644 --- a/crates/forge_app/src/terminal_context.rs +++ b/crates/forge_app/src/terminal_context.rs @@ -4,18 +4,17 @@ use forge_domain::{TerminalCommand, TerminalContext}; use crate::EnvironmentInfra; -// FIXME: The env variable names have to start with an `_` - update the zsh implementation also /// Environment variable exported by the zsh plugin containing colon-separated /// command strings. -pub const ENV_TERM_COMMANDS: &str = "FORGE_TERM_COMMANDS"; +pub const ENV_TERM_COMMANDS: &str = "_FORGE_TERM_COMMANDS"; /// Environment variable exported by the zsh plugin containing colon-separated /// exit codes corresponding to [`ENV_TERM_COMMANDS`]. -pub const ENV_TERM_EXIT_CODES: &str = "FORGE_TERM_EXIT_CODES"; +pub const ENV_TERM_EXIT_CODES: &str = "_FORGE_TERM_EXIT_CODES"; /// Environment variable exported by the zsh plugin containing colon-separated /// Unix timestamps corresponding to [`ENV_TERM_COMMANDS`]. -pub const ENV_TERM_TIMESTAMPS: &str = "FORGE_TERM_TIMESTAMPS"; +pub const ENV_TERM_TIMESTAMPS: &str = "_FORGE_TERM_TIMESTAMPS"; /// Service that reads terminal context from environment variables exported by /// the zsh plugin and constructs a structured [`TerminalContext`]. @@ -90,6 +89,14 @@ impl TerminalContextService { Some(TerminalContext { commands: entries }) } } + + /// Reads the terminal context from environment variables and renders it as + /// an XML string via [`TerminalContext::render`]. + /// + /// Returns `None` if no terminal context is available. + pub fn render(&self) -> Option { + self.get_terminal_context().map(|ctx| ctx.render().to_string()) + } } /// Splits a colon-separated environment variable value into a list of strings, diff --git a/crates/forge_app/src/user_prompt.rs b/crates/forge_app/src/user_prompt.rs index 9adbac1e94..dfc0b5f391 100644 --- a/crates/forge_app/src/user_prompt.rs +++ b/crates/forge_app/src/user_prompt.rs @@ -5,7 +5,7 @@ use forge_domain::{Agent, *}; use serde_json::json; use tracing::debug; -use crate::{AttachmentService, TemplateEngine}; +use crate::{AttachmentService, EnvironmentInfra, TemplateEngine, TerminalContextService}; /// Service responsible for setting user prompts in the conversation context #[derive(Clone)] @@ -16,7 +16,7 @@ pub struct UserPromptGenerator { current_time: chrono::DateTime, } -impl UserPromptGenerator { +impl UserPromptGenerator { /// Creates a new UserPromptService pub fn new( service: Arc, @@ -53,7 +53,7 @@ impl UserPromptGenerator { conversation }; - // FIXME: Use TerminalContextService here to add the context of the terminal into the conversation + let conversation = self.add_terminal_context(conversation); Ok(conversation) } @@ -108,6 +108,32 @@ impl UserPromptGenerator { content } + /// Adds the terminal context as a droppable user message if available. + /// + /// Reads terminal context from environment variables via [`TerminalContextService`] + /// and appends it as a droppable message so it can be removed during context + /// compression. + fn add_terminal_context(&self, mut conversation: Conversation) -> Conversation { + let Some(rendered) = TerminalContextService::new(self.services.clone()).render() else { + return conversation; + }; + + let mut context = conversation.context.take().unwrap_or_default(); + let message = TextMessage { + role: Role::User, + content: rendered, + raw_content: None, + tool_calls: None, + thought_signature: None, + reasoning_details: None, + model: Some(self.agent.model.clone()), + droppable: true, + phase: None, + }; + context = context.add_message(ContextMessage::Text(message)); + conversation.context(context) + } + /// Adds additional context (piped input) as a droppable user message async fn add_additional_context( &self, @@ -264,6 +290,34 @@ mod tests { } } + impl crate::EnvironmentInfra for MockService { + type Config = forge_config::ForgeConfig; + + fn get_environment(&self) -> forge_domain::Environment { + use fake::{Fake, Faker}; + Faker.fake() + } + + fn get_config(&self) -> anyhow::Result { + Ok(forge_config::ForgeConfig::default()) + } + + async fn update_environment( + &self, + _ops: Vec, + ) -> anyhow::Result<()> { + Ok(()) + } + + fn get_env_var(&self, _key: &str) -> Option { + None + } + + fn get_env_vars(&self) -> std::collections::BTreeMap { + Default::default() + } + } + fn fixture_agent_without_user_prompt() -> Agent { Agent::new( AgentId::from("test_agent"), @@ -390,6 +444,15 @@ mod tests { // Setup - Create a service that returns file attachments struct MockServiceWithFiles; + impl crate::EnvironmentInfra for MockServiceWithFiles { + type Config = forge_config::ForgeConfig; + fn get_environment(&self) -> forge_domain::Environment { use fake::{Fake,Faker}; Faker.fake() } + fn get_config(&self) -> anyhow::Result { Ok(forge_config::ForgeConfig::default()) } + async fn update_environment(&self, _ops: Vec) -> anyhow::Result<()> { Ok(()) } + fn get_env_var(&self, _key: &str) -> Option { None } + fn get_env_vars(&self) -> std::collections::BTreeMap { Default::default() } + } + #[async_trait::async_trait] impl AttachmentService for MockServiceWithFiles { async fn attachments(&self, _url: &str) -> anyhow::Result> { @@ -471,6 +534,15 @@ mod tests { // Setup - Simple mock that returns no attachments struct MockServiceWithTodos; + impl crate::EnvironmentInfra for MockServiceWithTodos { + type Config = forge_config::ForgeConfig; + fn get_environment(&self) -> forge_domain::Environment { use fake::{Fake,Faker}; Faker.fake() } + fn get_config(&self) -> anyhow::Result { Ok(forge_config::ForgeConfig::default()) } + async fn update_environment(&self, _ops: Vec) -> anyhow::Result<()> { Ok(()) } + fn get_env_var(&self, _key: &str) -> Option { None } + fn get_env_vars(&self) -> std::collections::BTreeMap { Default::default() } + } + #[async_trait::async_trait] impl AttachmentService for MockServiceWithTodos { async fn attachments(&self, _url: &str) -> anyhow::Result> { @@ -548,6 +620,15 @@ mod tests { // Setup - Simple mock with no attachments struct MockServiceNoTodos; + impl crate::EnvironmentInfra for MockServiceNoTodos { + type Config = forge_config::ForgeConfig; + fn get_environment(&self) -> forge_domain::Environment { use fake::{Fake,Faker}; Faker.fake() } + fn get_config(&self) -> anyhow::Result { Ok(forge_config::ForgeConfig::default()) } + async fn update_environment(&self, _ops: Vec) -> anyhow::Result<()> { Ok(()) } + fn get_env_var(&self, _key: &str) -> Option { None } + fn get_env_vars(&self) -> std::collections::BTreeMap { Default::default() } + } + #[async_trait::async_trait] impl AttachmentService for MockServiceNoTodos { async fn attachments(&self, _url: &str) -> anyhow::Result> { diff --git a/crates/forge_domain/src/terminal_context.rs b/crates/forge_domain/src/terminal_context.rs index 58fa2dba25..e35d36dcd2 100644 --- a/crates/forge_domain/src/terminal_context.rs +++ b/crates/forge_domain/src/terminal_context.rs @@ -47,5 +47,17 @@ impl TerminalContext { self.commands.is_empty() } - // FIXME: Add a `render` method on TemplateContext which will internally use the `Element` type to render the TerminalContext + /// Renders the terminal context as an XML element using [`forge_template::Element`]. + /// + /// Each command is represented as an `` child with nested + /// ``, ``, and `` elements. + pub fn render(&self) -> forge_template::Element { + use forge_template::Element; + Element::new("terminal_context").append(self.commands.iter().map(|cmd| { + Element::new("entry") + .append(Element::new("command").text(&cmd.command)) + .append(Element::new("exit_code").text(cmd.exit_code.to_string())) + .append(Element::new("timestamp").text(cmd.timestamp.to_string())) + })) + } } diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index 0ef500200b..e3d8bdd722 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -3129,7 +3129,7 @@ impl A + Send + Sync> UI Ok(()) } - // FIXME: Revert this file to that in `main` + async fn on_message(&mut self, content: Option) -> Result<()> { let conversation_id = self.init_conversation().await?; @@ -3145,24 +3145,24 @@ impl A + Send + Sync> UI None => Event::empty(), }; - // Build additional context from piped input. - // Piped input is only additional context when BOTH --prompt and piped - // input are provided (e.g., `echo "context" | forge -p "question"`). - // When only piped input is provided (no --prompt), it's already used as - // the main content via the `content` parameter. - let mut additional_parts: Vec = Vec::new(); - + // Only use CLI piped_input as additional context when BOTH --prompt and piped + // input are provided. This handles the case: `echo "context" | forge -p + // "question"` where piped input provides context and --prompt provides + // the actual question. + // + // When only piped input is provided (no --prompt), it's already used as the + // main content (passed via the `content` parameter). We must NOT add it again + // as additional_context, otherwise the input appears twice in the + // conversation. We detect this by checking if cli.prompt exists - if it + // does, the content came from --prompt and piped input should be + // additional context. let piped_input = self.cli.piped_input.clone(); let has_explicit_prompt = self.cli.prompt.is_some(); if let Some(piped) = piped_input && has_content && has_explicit_prompt { - additional_parts.push(piped); - } - - if !additional_parts.is_empty() { - event = event.additional_context(additional_parts.join("\n\n")); + event = event.additional_context(piped); } // Create the chat request with the event diff --git a/shell-plugin/lib/helpers.zsh b/shell-plugin/lib/helpers.zsh index 9bd6eaf5e1..ca48cfed29 100644 --- a/shell-plugin/lib/helpers.zsh +++ b/shell-plugin/lib/helpers.zsh @@ -40,14 +40,15 @@ function _forge_exec_interactive() { local -a cmd cmd=($_FORGE_BIN --agent "$agent_id") - # Export terminal context arrays as colon-separated env vars so that the + # Expose terminal context arrays as colon-separated env vars so that the # Rust TerminalContextService can read them via get_env_var. + # Use `local -x` so the variables are exported only for the duration of + # this function call (i.e. inherited by the child forge process) and do + # not leak into the caller's shell environment. if [[ "$_FORGE_TERM_ENABLED" == "true" && ${#_FORGE_TERM_COMMANDS} -gt 0 ]]; then - # FIXME: Use `_` prefixed variable names since these are internal - # FIXME: Do we need export? We aren't using it down below - export FORGE_TERM_COMMANDS="${(j.:.)_FORGE_TERM_COMMANDS}" - export FORGE_TERM_EXIT_CODES="${(j.:.)_FORGE_TERM_EXIT_CODES}" - export FORGE_TERM_TIMESTAMPS="${(j.:.)_FORGE_TERM_TIMESTAMPS}" + local -x _FORGE_TERM_COMMANDS="${(j.:.)_FORGE_TERM_COMMANDS}" + local -x _FORGE_TERM_EXIT_CODES="${(j.:.)_FORGE_TERM_EXIT_CODES}" + local -x _FORGE_TERM_TIMESTAMPS="${(j.:.)_FORGE_TERM_TIMESTAMPS}" fi cmd+=("$@") diff --git a/templates/forge-terminal-context.md b/templates/forge-terminal-context.md deleted file mode 100644 index acb56be71f..0000000000 --- a/templates/forge-terminal-context.md +++ /dev/null @@ -1,3 +0,0 @@ - -{{#each commands}}- `{{command}}` [{{#if exit_code}}FAILED (exit {{exit_code}}){{else}}ok{{/if}}] @ {{timestamp}} -{{/each}} \ No newline at end of file From c5607da5d2a69c6b8f29fca339411396af3de097 Mon Sep 17 00:00:00 2001 From: Tushar Date: Sun, 12 Apr 2026 23:57:30 +0530 Subject: [PATCH 12/29] feat(terminal-context): inject terminal context via event context and config flag --- crates/forge_app/src/command_generator.rs | 26 ++- crates/forge_app/src/terminal_context.rs | 7 - crates/forge_app/src/user_prompt.rs | 201 +++++++++++--------- crates/forge_config/src/config.rs | 11 ++ crates/forge_domain/src/event.rs | 9 +- crates/forge_domain/src/terminal_context.rs | 17 +- crates/forge_repo/src/agents/forge.md | 1 + crates/forge_repo/src/agents/muse.md | 1 + crates/forge_repo/src/agents/sage.md | 1 + forge.schema.json | 5 + 10 files changed, 163 insertions(+), 116 deletions(-) diff --git a/crates/forge_app/src/command_generator.rs b/crates/forge_app/src/command_generator.rs index b70e5208ba..c51c730ba9 100644 --- a/crates/forge_app/src/command_generator.rs +++ b/crates/forge_app/src/command_generator.rs @@ -67,14 +67,26 @@ where }; // Build user prompt with task, optionally including terminal context. - // Rendering is handled entirely by TerminalContextService::render(). let terminal_service = TerminalContextService::new(self.services.clone()); - let user_content = match terminal_service.render() { - Some(rendered) => format!( - "{}\n\n{}", - rendered, - prompt.as_str() - ), + let user_content = match terminal_service.get_terminal_context() { + Some(ctx) => { + let entries: String = ctx + .commands + .iter() + .map(|cmd| { + // FIXME: Use element type to create this markup + format!( + "{}{}{}", + cmd.command, cmd.exit_code, cmd.timestamp + ) + }) + .collect(); + format!( + "{}\n\n{}", + entries, + prompt.as_str() + ) + } None => format!("{}", prompt.as_str()), }; diff --git a/crates/forge_app/src/terminal_context.rs b/crates/forge_app/src/terminal_context.rs index a300413d6f..1ab150c00b 100644 --- a/crates/forge_app/src/terminal_context.rs +++ b/crates/forge_app/src/terminal_context.rs @@ -90,13 +90,6 @@ impl TerminalContextService { } } - /// Reads the terminal context from environment variables and renders it as - /// an XML string via [`TerminalContext::render`]. - /// - /// Returns `None` if no terminal context is available. - pub fn render(&self) -> Option { - self.get_terminal_context().map(|ctx| ctx.render().to_string()) - } } /// Splits a colon-separated environment variable value into a list of strings, diff --git a/crates/forge_app/src/user_prompt.rs b/crates/forge_app/src/user_prompt.rs index dfc0b5f391..bfa452adc4 100644 --- a/crates/forge_app/src/user_prompt.rs +++ b/crates/forge_app/src/user_prompt.rs @@ -16,7 +16,9 @@ pub struct UserPromptGenerator { current_time: chrono::DateTime, } -impl UserPromptGenerator { +impl> + UserPromptGenerator +{ /// Creates a new UserPromptService pub fn new( service: Arc, @@ -53,8 +55,6 @@ impl UserPromptGenerator { conversation }; - let conversation = self.add_terminal_context(conversation); - Ok(conversation) } @@ -108,32 +108,6 @@ impl UserPromptGenerator { content } - /// Adds the terminal context as a droppable user message if available. - /// - /// Reads terminal context from environment variables via [`TerminalContextService`] - /// and appends it as a droppable message so it can be removed during context - /// compression. - fn add_terminal_context(&self, mut conversation: Conversation) -> Conversation { - let Some(rendered) = TerminalContextService::new(self.services.clone()).render() else { - return conversation; - }; - - let mut context = conversation.context.take().unwrap_or_default(); - let message = TextMessage { - role: Role::User, - content: rendered, - raw_content: None, - tool_calls: None, - thought_signature: None, - reasoning_details: None, - model: Some(self.agent.model.clone()), - droppable: true, - phase: None, - }; - context = context.add_message(ContextMessage::Text(message)); - conversation.context(context) - } - /// Adds additional context (piped input) as a droppable user message async fn add_additional_context( &self, @@ -169,53 +143,66 @@ impl UserPromptGenerator { let event_value = self.event.value.clone(); let template_engine = TemplateEngine::default(); - let content = - if let Some(user_prompt) = &self.agent.user_prompt - && self.event.value.is_some() - { - let user_input = self - .event - .value - .as_ref() - .and_then(|v| v.as_user_prompt().map(|u| u.as_str().to_string())) - .unwrap_or_default(); - let mut event_context = EventContext::new(EventContextValue::new(user_input)) - .current_date(self.current_time.format("%Y-%m-%d").to_string()); - - // Check if context already contains user messages to determine if it's feedback - let has_user_messages = context.messages.iter().any(|msg| msg.has_role(Role::User)); - - if has_user_messages { - event_context = event_context.into_feedback(); - } else { - event_context = event_context.into_task(); - } + let content = if let Some(user_prompt) = &self.agent.user_prompt + && self.event.value.is_some() + { + let user_input = self + .event + .value + .as_ref() + .and_then(|v| v.as_user_prompt().map(|u| u.as_str().to_string())) + .unwrap_or_default(); + let mut event_context = EventContext::new(EventContextValue::new(user_input)) + .current_date(self.current_time.format("%Y-%m-%d").to_string()); + + // Check if context already contains user messages to determine if it's feedback + let has_user_messages = context.messages.iter().any(|msg| msg.has_role(Role::User)); + + if has_user_messages { + event_context = event_context.into_feedback(); + } else { + event_context = event_context.into_task(); + } - debug!(event_context = ?event_context, "Event context"); + debug!(event_context = ?event_context, "Event context"); + + // Render the command first. + let event_context = match self.event.value.as_ref().and_then(|v| v.as_command()) { + Some(command) => { + let rendered_prompt = template_engine.render_template( + command.template.clone(), + &json!({"parameters": command.parameters.join(" ")}), + )?; + event_context.event(EventContextValue::new(rendered_prompt)) + } + None => event_context, + }; - // Render the command first. - let event_context = match self.event.value.as_ref().and_then(|v| v.as_command()) { - Some(command) => { - let rendered_prompt = template_engine.render_template( - command.template.clone(), - &json!({"parameters": command.parameters.join(" ")}), - )?; - event_context.event(EventContextValue::new(rendered_prompt)) + // Inject terminal context into the event context if enabled in config. + let event_context = + match self.services.get_config().map(|c| c.terminal_context) { + Ok(true) => { + match TerminalContextService::new(self.services.clone()) + .get_terminal_context() + { + Some(ctx) => event_context.terminal_context(Some(ctx)), + None => event_context, + } } - None => event_context, + _ => event_context, }; - // Render the event value into agent's user prompt template. - Some(template_engine.render_template( - Template::new(user_prompt.template.as_str()), - &event_context, - )?) - } else { - // Use the raw event value as content if no user_prompt is provided - event_value - .as_ref() - .and_then(|v| v.as_user_prompt().map(|p| p.deref().to_owned())) - }; + // Render the event value into agent's user prompt template. + Some(template_engine.render_template( + Template::new(user_prompt.template.as_str()), + &event_context, + )?) + } else { + // Use the raw event value as content if no user_prompt is provided + event_value + .as_ref() + .and_then(|v| v.as_user_prompt().map(|p| p.deref().to_owned())) + }; if let Some(content) = &content { // Create User Message @@ -446,11 +433,25 @@ mod tests { impl crate::EnvironmentInfra for MockServiceWithFiles { type Config = forge_config::ForgeConfig; - fn get_environment(&self) -> forge_domain::Environment { use fake::{Fake,Faker}; Faker.fake() } - fn get_config(&self) -> anyhow::Result { Ok(forge_config::ForgeConfig::default()) } - async fn update_environment(&self, _ops: Vec) -> anyhow::Result<()> { Ok(()) } - fn get_env_var(&self, _key: &str) -> Option { None } - fn get_env_vars(&self) -> std::collections::BTreeMap { Default::default() } + fn get_environment(&self) -> forge_domain::Environment { + use fake::{Fake, Faker}; + Faker.fake() + } + fn get_config(&self) -> anyhow::Result { + Ok(forge_config::ForgeConfig::default()) + } + async fn update_environment( + &self, + _ops: Vec, + ) -> anyhow::Result<()> { + Ok(()) + } + fn get_env_var(&self, _key: &str) -> Option { + None + } + fn get_env_vars(&self) -> std::collections::BTreeMap { + Default::default() + } } #[async_trait::async_trait] @@ -536,11 +537,25 @@ mod tests { impl crate::EnvironmentInfra for MockServiceWithTodos { type Config = forge_config::ForgeConfig; - fn get_environment(&self) -> forge_domain::Environment { use fake::{Fake,Faker}; Faker.fake() } - fn get_config(&self) -> anyhow::Result { Ok(forge_config::ForgeConfig::default()) } - async fn update_environment(&self, _ops: Vec) -> anyhow::Result<()> { Ok(()) } - fn get_env_var(&self, _key: &str) -> Option { None } - fn get_env_vars(&self) -> std::collections::BTreeMap { Default::default() } + fn get_environment(&self) -> forge_domain::Environment { + use fake::{Fake, Faker}; + Faker.fake() + } + fn get_config(&self) -> anyhow::Result { + Ok(forge_config::ForgeConfig::default()) + } + async fn update_environment( + &self, + _ops: Vec, + ) -> anyhow::Result<()> { + Ok(()) + } + fn get_env_var(&self, _key: &str) -> Option { + None + } + fn get_env_vars(&self) -> std::collections::BTreeMap { + Default::default() + } } #[async_trait::async_trait] @@ -622,11 +637,25 @@ mod tests { impl crate::EnvironmentInfra for MockServiceNoTodos { type Config = forge_config::ForgeConfig; - fn get_environment(&self) -> forge_domain::Environment { use fake::{Fake,Faker}; Faker.fake() } - fn get_config(&self) -> anyhow::Result { Ok(forge_config::ForgeConfig::default()) } - async fn update_environment(&self, _ops: Vec) -> anyhow::Result<()> { Ok(()) } - fn get_env_var(&self, _key: &str) -> Option { None } - fn get_env_vars(&self) -> std::collections::BTreeMap { Default::default() } + fn get_environment(&self) -> forge_domain::Environment { + use fake::{Fake, Faker}; + Faker.fake() + } + fn get_config(&self) -> anyhow::Result { + Ok(forge_config::ForgeConfig::default()) + } + async fn update_environment( + &self, + _ops: Vec, + ) -> anyhow::Result<()> { + Ok(()) + } + fn get_env_var(&self, _key: &str) -> Option { + None + } + fn get_env_vars(&self) -> std::collections::BTreeMap { + Default::default() + } } #[async_trait::async_trait] diff --git a/crates/forge_config/src/config.rs b/crates/forge_config/src/config.rs index 6b9baaa213..a0e5443423 100644 --- a/crates/forge_config/src/config.rs +++ b/crates/forge_config/src/config.rs @@ -98,6 +98,10 @@ pub struct ProviderEntry { pub auth_methods: Vec, } +fn default_true() -> bool { + true +} + /// Top-level Forge configuration merged from all sources (defaults, file, /// environment). #[derive(Default, Debug, Setters, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Dummy)] @@ -281,6 +285,13 @@ pub struct ForgeConfig { /// when a task ends and reminds the LLM about them. #[serde(default)] pub verify_todos: bool, + + /// Whether to include terminal context (recent shell commands, exit codes, + /// and timestamps) in the user prompt. When enabled, the shell plugin + /// exports the captured command history and the Rust service injects it + /// into the rendered prompt via [`EventContext`]. + #[serde(default = "default_true")] + pub terminal_context: bool, } impl ForgeConfig { diff --git a/crates/forge_domain/src/event.rs b/crates/forge_domain/src/event.rs index 45256e51b2..aee7e38317 100644 --- a/crates/forge_domain/src/event.rs +++ b/crates/forge_domain/src/event.rs @@ -5,7 +5,7 @@ use derive_setters::Setters; use serde::{Deserialize, Serialize}; use serde_json::Value; -use crate::{Attachment, NamedTool, Template, ToolName}; +use crate::{Attachment, NamedTool, Template, TerminalContext, ToolName}; /// Represents a partial event structure used for CLI event dispatching /// @@ -90,6 +90,11 @@ pub struct EventContext { suggestions: Vec, variables: HashMap, current_date: String, + /// Structured terminal context injected by [`TerminalContextService`], + /// or `None` when terminal context is unavailable or disabled. + #[serde(default, skip_serializing_if = "Option::is_none")] + terminal_context: Option, + } #[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Setters)] @@ -111,7 +116,9 @@ impl EventContext { suggestions: Default::default(), variables: Default::default(), current_date: chrono::Local::now().format("%Y-%m-%d").to_string(), + terminal_context: None, } + } /// Converts this EventContext into a feedback event by setting the event diff --git a/crates/forge_domain/src/terminal_context.rs b/crates/forge_domain/src/terminal_context.rs index e35d36dcd2..24994d46e5 100644 --- a/crates/forge_domain/src/terminal_context.rs +++ b/crates/forge_domain/src/terminal_context.rs @@ -1,5 +1,5 @@ /// A single command entry captured by the shell plugin. -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct TerminalCommand { /// The command text as entered by the user. pub command: String, @@ -16,7 +16,7 @@ pub struct TerminalCommand { /// - `FORGE_TERM_COMMANDS` — colon-separated command strings /// - `FORGE_TERM_EXIT_CODES` — colon-separated exit codes /// - `FORGE_TERM_TIMESTAMPS` — colon-separated Unix timestamps -#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] pub struct TerminalContext { /// Ordered list of recent commands, from oldest to newest. pub commands: Vec, @@ -47,17 +47,4 @@ impl TerminalContext { self.commands.is_empty() } - /// Renders the terminal context as an XML element using [`forge_template::Element`]. - /// - /// Each command is represented as an `` child with nested - /// ``, ``, and `` elements. - pub fn render(&self) -> forge_template::Element { - use forge_template::Element; - Element::new("terminal_context").append(self.commands.iter().map(|cmd| { - Element::new("entry") - .append(Element::new("command").text(&cmd.command)) - .append(Element::new("exit_code").text(cmd.exit_code.to_string())) - .append(Element::new("timestamp").text(cmd.timestamp.to_string())) - })) - } } diff --git a/crates/forge_repo/src/agents/forge.md b/crates/forge_repo/src/agents/forge.md index 1bb59d4b4e..d87e9c8d66 100644 --- a/crates/forge_repo/src/agents/forge.md +++ b/crates/forge_repo/src/agents/forge.md @@ -23,6 +23,7 @@ tools: user_prompt: |- <{{event.name}}>{{event.value}} {{current_date}} + {{#if terminal_context}}{{#each terminal_context.commands}}{{command}}{{exit_code}}{{timestamp}}{{/each}}{{/if}} --- You are Forge, an expert software engineering assistant designed to help users with programming tasks, file operations, and software development processes. Your knowledge spans multiple programming languages, frameworks, design patterns, and best practices. diff --git a/crates/forge_repo/src/agents/muse.md b/crates/forge_repo/src/agents/muse.md index d990f9a43d..1ef9e522e4 100644 --- a/crates/forge_repo/src/agents/muse.md +++ b/crates/forge_repo/src/agents/muse.md @@ -15,6 +15,7 @@ tools: user_prompt: |- <{{event.name}}>{{event.value}} {{current_date}} + {{#if terminal_context}}{{#each terminal_context.commands}}{{command}}{{exit_code}}{{timestamp}}{{/each}}{{/if}} --- You are Muse, an expert strategic planning and analysis assistant designed to help users with detailed implementation planning. Your primary function is to analyze requirements, create structured plans, and provide strategic recommendations without making any actual changes to the codebase or repository. diff --git a/crates/forge_repo/src/agents/sage.md b/crates/forge_repo/src/agents/sage.md index 3e314f7044..9990cc798d 100644 --- a/crates/forge_repo/src/agents/sage.md +++ b/crates/forge_repo/src/agents/sage.md @@ -12,6 +12,7 @@ tools: user_prompt: |- <{{event.name}}>{{event.value}} {{current_date}} + {{#if terminal_context}}{{#each terminal_context.commands}}{{command}}{{exit_code}}{{timestamp}}{{/each}}{{/if}} --- You are Sage, an expert codebase research and exploration assistant designed to help users understand software projects through deep analysis and investigation. Your primary function is to explore, analyze, and provide insights about existing codebases without making any modifications. diff --git a/forge.schema.json b/forge.schema.json index 43cc190609..845b70025a 100644 --- a/forge.schema.json +++ b/forge.schema.json @@ -302,6 +302,11 @@ } ] }, + "terminal_context": { + "description": "Whether to include terminal context (recent shell commands, exit codes,\nand timestamps) in the user prompt. When enabled, the shell plugin\nexports the captured command history and the Rust service injects it\ninto the rendered prompt via [`EventContext`].", + "type": "boolean", + "default": true + }, "tool_supported": { "description": "Whether tool use is supported in the current environment; when false,\nall tool calls are disabled.", "type": "boolean", From c5df34bd55e9be3c7b06232f08f926a7b05dbafc Mon Sep 17 00:00:00 2001 From: Tushar Date: Mon, 13 Apr 2026 00:05:42 +0530 Subject: [PATCH 13/29] refactor(terminal-context): use Element builder for terminal context markup --- crates/forge_app/src/command_generator.rs | 28 +++++++++-------------- crates/forge_repo/src/agents/forge.md | 4 +++- crates/forge_repo/src/agents/muse.md | 4 +++- crates/forge_repo/src/agents/sage.md | 4 +++- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/crates/forge_app/src/command_generator.rs b/crates/forge_app/src/command_generator.rs index c51c730ba9..24198ce93b 100644 --- a/crates/forge_app/src/command_generator.rs +++ b/crates/forge_app/src/command_generator.rs @@ -67,27 +67,21 @@ where }; // Build user prompt with task, optionally including terminal context. + use forge_template::Element; + let task_elm = Element::new("task").text(prompt.as_str()); let terminal_service = TerminalContextService::new(self.services.clone()); let user_content = match terminal_service.get_terminal_context() { Some(ctx) => { - let entries: String = ctx - .commands - .iter() - .map(|cmd| { - // FIXME: Use element type to create this markup - format!( - "{}{}{}", - cmd.command, cmd.exit_code, cmd.timestamp - ) - }) - .collect(); - format!( - "{}\n\n{}", - entries, - prompt.as_str() - ) + let terminal_elm = + Element::new("terminal_context").append(ctx.commands.iter().map(|cmd| { + Element::new("entry") + .append(Element::new("command").text(&cmd.command)) + .append(Element::new("exit_code").text(cmd.exit_code.to_string())) + .append(Element::new("timestamp").text(cmd.timestamp.to_string())) + })); + format!("{}\n\n{}", terminal_elm.render(), task_elm.render()) } - None => format!("{}", prompt.as_str()), + None => task_elm.render(), }; // Create context with system and user prompts diff --git a/crates/forge_repo/src/agents/forge.md b/crates/forge_repo/src/agents/forge.md index d87e9c8d66..edf6a0be3d 100644 --- a/crates/forge_repo/src/agents/forge.md +++ b/crates/forge_repo/src/agents/forge.md @@ -23,7 +23,9 @@ tools: user_prompt: |- <{{event.name}}>{{event.value}} {{current_date}} - {{#if terminal_context}}{{#each terminal_context.commands}}{{command}}{{exit_code}}{{timestamp}}{{/each}}{{/if}} + {{#if terminal_context}}{{#each terminal_context.commands}} + {{command}} + {{/if}} --- You are Forge, an expert software engineering assistant designed to help users with programming tasks, file operations, and software development processes. Your knowledge spans multiple programming languages, frameworks, design patterns, and best practices. diff --git a/crates/forge_repo/src/agents/muse.md b/crates/forge_repo/src/agents/muse.md index 1ef9e522e4..c7d0c705e1 100644 --- a/crates/forge_repo/src/agents/muse.md +++ b/crates/forge_repo/src/agents/muse.md @@ -15,7 +15,9 @@ tools: user_prompt: |- <{{event.name}}>{{event.value}} {{current_date}} - {{#if terminal_context}}{{#each terminal_context.commands}}{{command}}{{exit_code}}{{timestamp}}{{/each}}{{/if}} + {{#if terminal_context}}{{#each terminal_context.commands}} + {{command}} + {{/if}} --- You are Muse, an expert strategic planning and analysis assistant designed to help users with detailed implementation planning. Your primary function is to analyze requirements, create structured plans, and provide strategic recommendations without making any actual changes to the codebase or repository. diff --git a/crates/forge_repo/src/agents/sage.md b/crates/forge_repo/src/agents/sage.md index 9990cc798d..f9926a7eca 100644 --- a/crates/forge_repo/src/agents/sage.md +++ b/crates/forge_repo/src/agents/sage.md @@ -12,7 +12,9 @@ tools: user_prompt: |- <{{event.name}}>{{event.value}} {{current_date}} - {{#if terminal_context}}{{#each terminal_context.commands}}{{command}}{{exit_code}}{{timestamp}}{{/each}}{{/if}} + {{#if terminal_context}}{{#each terminal_context.commands}} + {{command}} + {{/if}} --- You are Sage, an expert codebase research and exploration assistant designed to help users understand software projects through deep analysis and investigation. Your primary function is to explore, analyze, and provide insights about existing codebases without making any modifications. From 3fdbfbc816f3ec44a31b12448c88b7c032f3a02d Mon Sep 17 00:00:00 2001 From: Tushar Date: Mon, 13 Apr 2026 00:07:34 +0530 Subject: [PATCH 14/29] refactor(terminal-context): simplify command element structure using attributes --- crates/forge_app/src/command_generator.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/forge_app/src/command_generator.rs b/crates/forge_app/src/command_generator.rs index 24198ce93b..daaf1e53ad 100644 --- a/crates/forge_app/src/command_generator.rs +++ b/crates/forge_app/src/command_generator.rs @@ -74,10 +74,9 @@ where Some(ctx) => { let terminal_elm = Element::new("terminal_context").append(ctx.commands.iter().map(|cmd| { - Element::new("entry") - .append(Element::new("command").text(&cmd.command)) - .append(Element::new("exit_code").text(cmd.exit_code.to_string())) - .append(Element::new("timestamp").text(cmd.timestamp.to_string())) + Element::new("command") + .attr("exit_code", cmd.exit_code.to_string()) + .text(&cmd.command) })); format!("{}\n\n{}", terminal_elm.render(), task_elm.render()) } From 74cdbbfaf29ddf87d3eb7c85c8a70361b3286854 Mon Sep 17 00:00:00 2001 From: Tushar Date: Mon, 13 Apr 2026 00:14:50 +0530 Subject: [PATCH 15/29] fix(terminal-context): fix exit_code attribute quoting and template formatting in agent prompts --- crates/forge_repo/src/agents/forge.md | 10 +++++++--- crates/forge_repo/src/agents/muse.md | 10 +++++++--- crates/forge_repo/src/agents/sage.md | 10 +++++++--- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/crates/forge_repo/src/agents/forge.md b/crates/forge_repo/src/agents/forge.md index edf6a0be3d..d0aee0ee92 100644 --- a/crates/forge_repo/src/agents/forge.md +++ b/crates/forge_repo/src/agents/forge.md @@ -23,9 +23,13 @@ tools: user_prompt: |- <{{event.name}}>{{event.value}} {{current_date}} - {{#if terminal_context}}{{#each terminal_context.commands}} - {{command}} - {{/if}} + {{#if terminal_context}} + + {{#each terminal_context.commands}} + {{command}} + {{/each}} + + {{/if}} --- You are Forge, an expert software engineering assistant designed to help users with programming tasks, file operations, and software development processes. Your knowledge spans multiple programming languages, frameworks, design patterns, and best practices. diff --git a/crates/forge_repo/src/agents/muse.md b/crates/forge_repo/src/agents/muse.md index c7d0c705e1..8d5644390e 100644 --- a/crates/forge_repo/src/agents/muse.md +++ b/crates/forge_repo/src/agents/muse.md @@ -15,9 +15,13 @@ tools: user_prompt: |- <{{event.name}}>{{event.value}} {{current_date}} - {{#if terminal_context}}{{#each terminal_context.commands}} - {{command}} - {{/if}} + {{#if terminal_context}} + + {{#each terminal_context.commands}} + {{command}} + {{/each}} + + {{/if}} --- You are Muse, an expert strategic planning and analysis assistant designed to help users with detailed implementation planning. Your primary function is to analyze requirements, create structured plans, and provide strategic recommendations without making any actual changes to the codebase or repository. diff --git a/crates/forge_repo/src/agents/sage.md b/crates/forge_repo/src/agents/sage.md index f9926a7eca..b9153d79f5 100644 --- a/crates/forge_repo/src/agents/sage.md +++ b/crates/forge_repo/src/agents/sage.md @@ -12,9 +12,13 @@ tools: user_prompt: |- <{{event.name}}>{{event.value}} {{current_date}} - {{#if terminal_context}}{{#each terminal_context.commands}} - {{command}} - {{/if}} + {{#if terminal_context}} + + {{#each terminal_context.commands}} + {{command}} + {{/each}} + + {{/if}} --- You are Sage, an expert codebase research and exploration assistant designed to help users understand software projects through deep analysis and investigation. Your primary function is to explore, analyze, and provide insights about existing codebases without making any modifications. From fbdc38069f7a1c79c069ea4d614deff9a892d336 Mon Sep 17 00:00:00 2001 From: Tushar Date: Mon, 13 Apr 2026 00:19:59 +0530 Subject: [PATCH 16/29] refactor(terminal-context): rename terminal_context xml tag to command_trace in agent prompts --- crates/forge_repo/src/agents/forge.md | 4 ++-- crates/forge_repo/src/agents/muse.md | 4 ++-- crates/forge_repo/src/agents/sage.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/forge_repo/src/agents/forge.md b/crates/forge_repo/src/agents/forge.md index d0aee0ee92..14fea74699 100644 --- a/crates/forge_repo/src/agents/forge.md +++ b/crates/forge_repo/src/agents/forge.md @@ -24,11 +24,11 @@ user_prompt: |- <{{event.name}}>{{event.value}} {{current_date}} {{#if terminal_context}} - + {{#each terminal_context.commands}} {{command}} {{/each}} - + {{/if}} --- diff --git a/crates/forge_repo/src/agents/muse.md b/crates/forge_repo/src/agents/muse.md index 8d5644390e..39f341647c 100644 --- a/crates/forge_repo/src/agents/muse.md +++ b/crates/forge_repo/src/agents/muse.md @@ -16,11 +16,11 @@ user_prompt: |- <{{event.name}}>{{event.value}} {{current_date}} {{#if terminal_context}} - + {{#each terminal_context.commands}} {{command}} {{/each}} - + {{/if}} --- diff --git a/crates/forge_repo/src/agents/sage.md b/crates/forge_repo/src/agents/sage.md index b9153d79f5..0287a22101 100644 --- a/crates/forge_repo/src/agents/sage.md +++ b/crates/forge_repo/src/agents/sage.md @@ -13,11 +13,11 @@ user_prompt: |- <{{event.name}}>{{event.value}} {{current_date}} {{#if terminal_context}} - + {{#each terminal_context.commands}} {{command}} {{/each}} - + {{/if}} --- From 7b3581daeac2e70b960bd035a01c11c558e4ce6e Mon Sep 17 00:00:00 2001 From: Tushar Date: Mon, 13 Apr 2026 00:28:11 +0530 Subject: [PATCH 17/29] chore(terminal-context): add FIXME markers and increase max entries default to 100 --- shell-plugin/lib/config.zsh | 4 ++-- shell-plugin/lib/context.zsh | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/shell-plugin/lib/config.zsh b/shell-plugin/lib/config.zsh index 698f335342..e70062cb75 100644 --- a/shell-plugin/lib/config.zsh +++ b/shell-plugin/lib/config.zsh @@ -39,12 +39,12 @@ typeset -h _FORGE_SESSION_PROVIDER # When non-empty, exported as FORGE_REASONING__EFFORT for every forge invocation. typeset -h _FORGE_SESSION_REASONING_EFFORT - +# FIXME: Drop unused variables # Terminal context capture settings # Master switch for terminal context capture (preexec/precmd hooks) typeset -h _FORGE_TERM_ENABLED="${FORGE_TERM_ENABLED:-true}" # Maximum number of commands to keep in the ring buffer (metadata: cmd + exit code) -typeset -h _FORGE_TERM_MAX_ENTRIES="${FORGE_TERM_MAX_ENTRIES:-10}" +typeset -h _FORGE_TERM_MAX_ENTRIES="${FORGE_TERM_MAX_ENTRIES:-100}" # Number of recent commands to include full output for typeset -h _FORGE_TERM_FULL_OUTPUT_COUNT="${FORGE_TERM_FULL_OUTPUT_COUNT:-5}" # Maximum output lines per command block diff --git a/shell-plugin/lib/context.zsh b/shell-plugin/lib/context.zsh index dcd501fc6a..6c14e43894 100644 --- a/shell-plugin/lib/context.zsh +++ b/shell-plugin/lib/context.zsh @@ -168,6 +168,7 @@ function _forge_capture_scrollback() { # # Args: $1=scrollback, $2=command string, $3=next command string (or empty) # Outputs the extracted block on stdout, truncated to max lines per command. +# FIXME: Drop this function if its unused function _forge_extract_block() { local scrollback="$1" local cmd="$2" From 793a923937ab22614e8d5d9de6474fa92f940115 Mon Sep 17 00:00:00 2001 From: Tushar Date: Mon, 13 Apr 2026 00:31:56 +0530 Subject: [PATCH 18/29] refactor(terminal-context): use local -x for env vars and remove unused scrollback functions --- shell-plugin/lib/actions/editor.zsh | 11 ++-- shell-plugin/lib/config.zsh | 7 --- shell-plugin/lib/context.zsh | 97 ----------------------------- 3 files changed, 7 insertions(+), 108 deletions(-) diff --git a/shell-plugin/lib/actions/editor.zsh b/shell-plugin/lib/actions/editor.zsh index d1b18fe8fb..3673d26992 100644 --- a/shell-plugin/lib/actions/editor.zsh +++ b/shell-plugin/lib/actions/editor.zsh @@ -81,12 +81,15 @@ function _forge_action_suggest() { echo - # Export terminal context arrays as colon-separated env vars so that the + # Expose terminal context arrays as colon-separated env vars so that the # Rust TerminalContextService can read them via get_env_var. + # Use `local -x` so the variables are exported only for the duration of + # this function call (i.e. inherited by the child forge process) and do + # not leak into the caller's shell environment. if [[ "$_FORGE_TERM_ENABLED" == "true" && ${#_FORGE_TERM_COMMANDS} -gt 0 ]]; then - export FORGE_TERM_COMMANDS="${(j.:.)_FORGE_TERM_COMMANDS}" - export FORGE_TERM_EXIT_CODES="${(j.:.)_FORGE_TERM_EXIT_CODES}" - export FORGE_TERM_TIMESTAMPS="${(j.:.)_FORGE_TERM_TIMESTAMPS}" + local -x _FORGE_TERM_COMMANDS="${(j.:.)_FORGE_TERM_COMMANDS}" + local -x _FORGE_TERM_EXIT_CODES="${(j.:.)_FORGE_TERM_EXIT_CODES}" + local -x _FORGE_TERM_TIMESTAMPS="${(j.:.)_FORGE_TERM_TIMESTAMPS}" fi # Generate the command diff --git a/shell-plugin/lib/config.zsh b/shell-plugin/lib/config.zsh index e70062cb75..18281ba36e 100644 --- a/shell-plugin/lib/config.zsh +++ b/shell-plugin/lib/config.zsh @@ -39,18 +39,11 @@ typeset -h _FORGE_SESSION_PROVIDER # When non-empty, exported as FORGE_REASONING__EFFORT for every forge invocation. typeset -h _FORGE_SESSION_REASONING_EFFORT -# FIXME: Drop unused variables # Terminal context capture settings # Master switch for terminal context capture (preexec/precmd hooks) typeset -h _FORGE_TERM_ENABLED="${FORGE_TERM_ENABLED:-true}" # Maximum number of commands to keep in the ring buffer (metadata: cmd + exit code) typeset -h _FORGE_TERM_MAX_ENTRIES="${FORGE_TERM_MAX_ENTRIES:-100}" -# Number of recent commands to include full output for -typeset -h _FORGE_TERM_FULL_OUTPUT_COUNT="${FORGE_TERM_FULL_OUTPUT_COUNT:-5}" -# Maximum output lines per command block -typeset -h _FORGE_TERM_MAX_LINES_PER_CMD="${FORGE_TERM_MAX_LINES_PER_CMD:-200}" -# Scrollback lines to capture from the terminal for command block extraction -typeset -h _FORGE_TERM_SCROLLBACK_LINES="${FORGE_TERM_SCROLLBACK_LINES:-1000}" # OSC 133 semantic prompt marker emission: "auto", "on", or "off" typeset -h _FORGE_TERM_OSC133="${FORGE_TERM_OSC133:-auto}" # Ring buffer arrays for context capture diff --git a/shell-plugin/lib/context.zsh b/shell-plugin/lib/context.zsh index 6c14e43894..596a677c4c 100644 --- a/shell-plugin/lib/context.zsh +++ b/shell-plugin/lib/context.zsh @@ -106,103 +106,6 @@ function _forge_context_precmd() { _forge_osc133_emit "A" } -# --------------------------------------------------------------------------- -# Terminal scrollback capture -# --------------------------------------------------------------------------- - -# Captures raw scrollback text from the terminal. The amount captured is -# controlled by _FORGE_TERM_SCROLLBACK_LINES. -# Returns the scrollback on stdout, or returns 1 if unavailable. -# Priority: Kitty > WezTerm > Zellij > tmux > none -function _forge_capture_scrollback() { - local lines="${_FORGE_TERM_SCROLLBACK_LINES:-1000}" - local output="" - - # Priority 1: Kitty — get full scrollback (OSC 133 aware) - if [[ -n "${KITTY_PID:-}" ]] && command -v kitty &>/dev/null; then - output=$(kitty @ get-text --extent=all 2>/dev/null) - if [[ -n "$output" ]]; then - echo "$output" | tail -"$lines" - return 0 - fi - fi - - # Priority 2: WezTerm - if [[ "${TERM_PROGRAM:-}" == "WezTerm" ]] && command -v wezterm &>/dev/null; then - output=$(wezterm cli get-text 2>/dev/null) - if [[ -n "$output" ]]; then - echo "$output" | tail -"$lines" - return 0 - fi - fi - - # Priority 3: Zellij — full scrollback dump - if [[ -n "${ZELLIJ:-}" ]] && command -v zellij &>/dev/null; then - output=$(zellij action dump-screen --full 2>/dev/null) - if [[ -n "$output" ]]; then - echo "$output" | tail -"$lines" - return 0 - fi - fi - - # Priority 4: tmux scrollback - if [[ -n "${TMUX:-}" ]] && command -v tmux &>/dev/null; then - output=$(tmux capture-pane -p -S -"$lines" 2>/dev/null) - if [[ -n "$output" ]]; then - echo "$output" - return 0 - fi - fi - - # No terminal-specific capture available - return 1 -} - -# --------------------------------------------------------------------------- -# Command block extraction -# --------------------------------------------------------------------------- - -# Given raw scrollback text, extracts the output block for a specific command -# by finding the command string and capturing everything until the next known -# command (or end of text). Uses fixed-string grep for reliability. -# -# Args: $1=scrollback, $2=command string, $3=next command string (or empty) -# Outputs the extracted block on stdout, truncated to max lines per command. -# FIXME: Drop this function if its unused -function _forge_extract_block() { - local scrollback="$1" - local cmd="$2" - local next_cmd="$3" - local max_lines="${_FORGE_TERM_MAX_LINES_PER_CMD:-200}" - - # Find the LAST occurrence of this command in scrollback (most recent run) - local cmd_line - cmd_line=$(echo "$scrollback" | grep -n -F -- "$cmd" | tail -1 | cut -d: -f1) - [[ -z "$cmd_line" ]] && return 1 - - # Start from the line AFTER the command itself (that's the output) - local output_start=$(( cmd_line + 1 )) - - if [[ -n "$next_cmd" ]]; then - # Find where the next command appears after our command - local next_line - next_line=$(echo "$scrollback" | tail -n +"$output_start" | grep -n -F -- "$next_cmd" | head -1 | cut -d: -f1) - if [[ -n "$next_line" ]]; then - # next_line is relative to output_start, adjust to absolute - # Subtract 2: one for the prompt line before the command, one for 1-indexing - local output_end=$(( output_start + next_line - 2 )) - if (( output_end >= output_start )); then - echo "$scrollback" | sed -n "${output_start},${output_end}p" | head -"$max_lines" - return 0 - fi - fi - fi - - # No next command found — take everything from output_start to end - echo "$scrollback" | tail -n +"$output_start" | head -"$max_lines" - return 0 -} - # Hook registration # Register using standard zsh hook arrays for coexistence with other plugins. From f04debab8a7bc3c8266e6f4425e53f48456f2dd1 Mon Sep 17 00:00:00 2001 From: Tushar Date: Mon, 13 Apr 2026 00:51:01 +0530 Subject: [PATCH 19/29] fix(terminal-context): rename env vars to use leading underscore and change default to false --- crates/forge_app/src/command_generator.rs | 4 ++-- crates/forge_config/src/config.rs | 10 +++++----- crates/forge_domain/src/event.rs | 2 -- crates/forge_domain/src/terminal_context.rs | 7 +++---- 4 files changed, 10 insertions(+), 13 deletions(-) diff --git a/crates/forge_app/src/command_generator.rs b/crates/forge_app/src/command_generator.rs index daaf1e53ad..c75eb00c47 100644 --- a/crates/forge_app/src/command_generator.rs +++ b/crates/forge_app/src/command_generator.rs @@ -35,8 +35,8 @@ where /// Generates a shell command from a natural language prompt. /// - /// Terminal context is read automatically from the `FORGE_TERM_COMMANDS`, - /// `FORGE_TERM_EXIT_CODES`, and `FORGE_TERM_TIMESTAMPS` environment + /// Terminal context is read automatically from the `_FORGE_TERM_COMMANDS`, + /// `_FORGE_TERM_EXIT_CODES`, and `_FORGE_TERM_TIMESTAMPS` environment /// variables exported by the zsh plugin, and included in the user /// prompt so the LLM can reference recent commands, exit codes, and /// timestamps. diff --git a/crates/forge_config/src/config.rs b/crates/forge_config/src/config.rs index a0e5443423..b49f3d6e3c 100644 --- a/crates/forge_config/src/config.rs +++ b/crates/forge_config/src/config.rs @@ -98,10 +98,6 @@ pub struct ProviderEntry { pub auth_methods: Vec, } -fn default_true() -> bool { - true -} - /// Top-level Forge configuration merged from all sources (defaults, file, /// environment). #[derive(Default, Debug, Setters, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Dummy)] @@ -290,7 +286,11 @@ pub struct ForgeConfig { /// and timestamps) in the user prompt. When enabled, the shell plugin /// exports the captured command history and the Rust service injects it /// into the rendered prompt via [`EventContext`]. - #[serde(default = "default_true")] + /// + /// Defaults to `false` so that users explicitly opt in before their shell + /// history is sent to the LLM. Shell history can contain passwords, API + /// keys, and other sensitive data passed as command-line arguments. + #[serde(default)] pub terminal_context: bool, } diff --git a/crates/forge_domain/src/event.rs b/crates/forge_domain/src/event.rs index aee7e38317..4609aaf76d 100644 --- a/crates/forge_domain/src/event.rs +++ b/crates/forge_domain/src/event.rs @@ -94,7 +94,6 @@ pub struct EventContext { /// or `None` when terminal context is unavailable or disabled. #[serde(default, skip_serializing_if = "Option::is_none")] terminal_context: Option, - } #[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Setters)] @@ -118,7 +117,6 @@ impl EventContext { current_date: chrono::Local::now().format("%Y-%m-%d").to_string(), terminal_context: None, } - } /// Converts this EventContext into a feedback event by setting the event diff --git a/crates/forge_domain/src/terminal_context.rs b/crates/forge_domain/src/terminal_context.rs index 24994d46e5..0405be2565 100644 --- a/crates/forge_domain/src/terminal_context.rs +++ b/crates/forge_domain/src/terminal_context.rs @@ -13,9 +13,9 @@ pub struct TerminalCommand { /// /// Each field corresponds to one of the environment variables exported by the /// zsh plugin before invoking forge: -/// - `FORGE_TERM_COMMANDS` — colon-separated command strings -/// - `FORGE_TERM_EXIT_CODES` — colon-separated exit codes -/// - `FORGE_TERM_TIMESTAMPS` — colon-separated Unix timestamps +/// - `_FORGE_TERM_COMMANDS` — `\x1F`-separated command strings +/// - `_FORGE_TERM_EXIT_CODES` — `\x1F`-separated exit codes +/// - `_FORGE_TERM_TIMESTAMPS` — `\x1F`-separated Unix timestamps #[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] pub struct TerminalContext { /// Ordered list of recent commands, from oldest to newest. @@ -46,5 +46,4 @@ impl TerminalContext { pub fn is_empty(&self) -> bool { self.commands.is_empty() } - } From 1b2fbe4e87bcb5cda2862c12a3518cd5647858e0 Mon Sep 17 00:00:00 2001 From: Tushar Date: Mon, 13 Apr 2026 00:51:36 +0530 Subject: [PATCH 20/29] fix(terminal-context): change default to false and document sensitive data rationale in schema --- forge.schema.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/forge.schema.json b/forge.schema.json index 845b70025a..7697b4d65f 100644 --- a/forge.schema.json +++ b/forge.schema.json @@ -303,9 +303,9 @@ ] }, "terminal_context": { - "description": "Whether to include terminal context (recent shell commands, exit codes,\nand timestamps) in the user prompt. When enabled, the shell plugin\nexports the captured command history and the Rust service injects it\ninto the rendered prompt via [`EventContext`].", + "description": "Whether to include terminal context (recent shell commands, exit codes,\nand timestamps) in the user prompt. When enabled, the shell plugin\nexports the captured command history and the Rust service injects it\ninto the rendered prompt via [`EventContext`].\n\nDefaults to `false` so that users explicitly opt in before their shell\nhistory is sent to the LLM. Shell history can contain passwords, API\nkeys, and other sensitive data passed as command-line arguments.", "type": "boolean", - "default": true + "default": false }, "tool_supported": { "description": "Whether tool use is supported in the current environment; when false,\nall tool calls are disabled.", From 83951860bd2c294297ccc469c1df73baeffe402e Mon Sep 17 00:00:00 2001 From: Tushar Date: Mon, 13 Apr 2026 01:45:00 +0530 Subject: [PATCH 21/29] fix(terminal-context): use ascii unit separator instead of colon for env var lists --- crates/forge_app/src/terminal_context.rs | 71 ++++++++++++++---------- crates/forge_config/.forge.toml | 1 + shell-plugin/lib/actions/editor.zsh | 11 ---- shell-plugin/lib/context.zsh | 8 ++- shell-plugin/lib/helpers.zsh | 34 ++++++++++-- 5 files changed, 77 insertions(+), 48 deletions(-) diff --git a/crates/forge_app/src/terminal_context.rs b/crates/forge_app/src/terminal_context.rs index 1ab150c00b..8f28159927 100644 --- a/crates/forge_app/src/terminal_context.rs +++ b/crates/forge_app/src/terminal_context.rs @@ -4,22 +4,29 @@ use forge_domain::{TerminalCommand, TerminalContext}; use crate::EnvironmentInfra; -/// Environment variable exported by the zsh plugin containing colon-separated -/// command strings. +/// Environment variable exported by the zsh plugin containing +/// `\x1F`-separated (ASCII Unit Separator) command strings. pub const ENV_TERM_COMMANDS: &str = "_FORGE_TERM_COMMANDS"; -/// Environment variable exported by the zsh plugin containing colon-separated -/// exit codes corresponding to [`ENV_TERM_COMMANDS`]. +/// Environment variable exported by the zsh plugin containing +/// `\x1F`-separated exit codes corresponding to [`ENV_TERM_COMMANDS`]. pub const ENV_TERM_EXIT_CODES: &str = "_FORGE_TERM_EXIT_CODES"; -/// Environment variable exported by the zsh plugin containing colon-separated -/// Unix timestamps corresponding to [`ENV_TERM_COMMANDS`]. +/// Environment variable exported by the zsh plugin containing +/// `\x1F`-separated Unix timestamps corresponding to [`ENV_TERM_COMMANDS`]. pub const ENV_TERM_TIMESTAMPS: &str = "_FORGE_TERM_TIMESTAMPS"; +/// The separator used to join and split environment variable lists. +/// +/// ASCII Unit Separator (`\x1F`) is chosen because it cannot appear in +/// shell command strings, paths, URLs, or exit codes — unlike `:` which +/// is common in all of those. +pub const ENV_LIST_SEPARATOR: char = '\x1F'; + /// Service that reads terminal context from environment variables exported by /// the zsh plugin and constructs a structured [`TerminalContext`]. /// -/// The zsh plugin exports three colon-separated environment variables before +/// The zsh plugin exports three `\x1F`-separated environment variables before /// invoking forge: /// - [`ENV_TERM_COMMANDS`] — the command strings /// - [`ENV_TERM_EXIT_CODES`] — the corresponding exit codes @@ -60,27 +67,19 @@ impl TerminalContextService { .iter() .map(|s| s.parse::().unwrap_or(0)) .collect(); - - // Zip the three lists together; pad missing exit codes/timestamps with 0 + // Zip the three lists together; pad missing exit codes/timestamps with 0. + // The outer zip() truncates to the length of `commands`, so the + // repeat() padding never produces extra entries. let entries: Vec = commands .into_iter() - .zip( - exit_codes - .into_iter() - .chain(std::iter::repeat(0)) - .take(usize::MAX), - ) - .zip( - timestamps - .into_iter() - .chain(std::iter::repeat(0)) - .take(usize::MAX), - ) + .zip(exit_codes.into_iter().chain(std::iter::repeat(0))) + .zip(timestamps.into_iter().chain(std::iter::repeat(0))) .map(|((command, exit_code), timestamp)| TerminalCommand { command, exit_code, timestamp, }) + // FIXME: Use forge_config::Config to control the total number of commands. Make sure its sorted by timestamp - recent should be last .collect(); if entries.is_empty() { @@ -89,14 +88,12 @@ impl TerminalContextService { Some(TerminalContext { commands: entries }) } } - } -/// Splits a colon-separated environment variable value into a list of strings, -/// filtering out any empty segments produced by leading/trailing/double colons. +/// Splits an `\x1F`-separated (ASCII Unit Separator) environment variable +/// value into a list of strings, filtering out any empty segments. pub fn split_env_list(raw: &str) -> Vec { - raw.split(':') - .map(str::trim) + raw.split(ENV_LIST_SEPARATOR) .filter(|s| !s.is_empty()) .map(String::from) .collect() @@ -186,10 +183,11 @@ mod tests { #[test] fn test_multiple_commands_with_exit_codes_and_timestamps() { + let sep = ENV_LIST_SEPARATOR; let fixture = TerminalContextService::new(MockInfra::new(&[ - (ENV_TERM_COMMANDS, "ls:cargo test:git status"), - (ENV_TERM_EXIT_CODES, "0:1:0"), - (ENV_TERM_TIMESTAMPS, "1700000001:1700000002:1700000003"), + (ENV_TERM_COMMANDS, &format!("ls{sep}cargo test{sep}git status")), + (ENV_TERM_EXIT_CODES, &format!("0{sep}1{sep}0")), + (ENV_TERM_TIMESTAMPS, &format!("1700000001{sep}1700000002{sep}1700000003")), ])); let actual = fixture.get_terminal_context(); let expected = Some(TerminalContext { @@ -230,8 +228,21 @@ mod tests { #[test] fn test_split_env_list_multiple() { - let actual = split_env_list("a:b:c"); + let sep = ENV_LIST_SEPARATOR; + let actual = split_env_list(&format!("a{sep}b{sep}c")); let expected = vec!["a".to_string(), "b".to_string(), "c".to_string()]; assert_eq!(actual, expected); } + + #[test] + fn test_split_env_list_command_with_colon() { + // Commands containing `:` (e.g. URLs, port mappings) must not be split. + let sep = ENV_LIST_SEPARATOR; + let actual = split_env_list(&format!("curl https://example.com{sep}docker run -p 8080:80 nginx")); + let expected = vec![ + "curl https://example.com".to_string(), + "docker run -p 8080:80 nginx".to_string(), + ]; + assert_eq!(actual, expected); + } } diff --git a/crates/forge_config/.forge.toml b/crates/forge_config/.forge.toml index 1abdbc915c..45a08e9231 100644 --- a/crates/forge_config/.forge.toml +++ b/crates/forge_config/.forge.toml @@ -22,6 +22,7 @@ model_cache_ttl_secs = 604800 restricted = false sem_search_top_k = 10 services_url = "https://api.forgecode.dev/" +terminal_context = false tool_supported = true tool_timeout_secs = 300 top_k = 30 diff --git a/shell-plugin/lib/actions/editor.zsh b/shell-plugin/lib/actions/editor.zsh index 3673d26992..2fcaef52d0 100644 --- a/shell-plugin/lib/actions/editor.zsh +++ b/shell-plugin/lib/actions/editor.zsh @@ -81,17 +81,6 @@ function _forge_action_suggest() { echo - # Expose terminal context arrays as colon-separated env vars so that the - # Rust TerminalContextService can read them via get_env_var. - # Use `local -x` so the variables are exported only for the duration of - # this function call (i.e. inherited by the child forge process) and do - # not leak into the caller's shell environment. - if [[ "$_FORGE_TERM_ENABLED" == "true" && ${#_FORGE_TERM_COMMANDS} -gt 0 ]]; then - local -x _FORGE_TERM_COMMANDS="${(j.:.)_FORGE_TERM_COMMANDS}" - local -x _FORGE_TERM_EXIT_CODES="${(j.:.)_FORGE_TERM_EXIT_CODES}" - local -x _FORGE_TERM_TIMESTAMPS="${(j.:.)_FORGE_TERM_TIMESTAMPS}" - fi - # Generate the command local generated_command generated_command=$(FORCE_COLOR=true CLICOLOR_FORCE=1 _forge_exec suggest "$description") diff --git a/shell-plugin/lib/context.zsh b/shell-plugin/lib/context.zsh index 596a677c4c..8ef1e6a578 100644 --- a/shell-plugin/lib/context.zsh +++ b/shell-plugin/lib/context.zsh @@ -80,11 +80,15 @@ function _forge_context_preexec() { # Captures exit code, pushes to ring buffer, emits OSC 133 D+A markers. function _forge_context_precmd() { local last_exit=$? # MUST be first line to capture exit code - [[ "$_FORGE_TERM_ENABLED" != "true" ]] && return - # OSC 133 D: command finished with exit code + # OSC 133 D: command finished with exit code. + # Emitted unconditionally (before the enabled check) so that terminals + # relying on paired A/B/C/D markers never receive an unpaired sequence, + # even when context capture is disabled. _forge_osc133_emit "D;$last_exit" + [[ "$_FORGE_TERM_ENABLED" != "true" ]] && return + # Only record if we have a pending command from preexec if [[ -n "$_FORGE_TERM_PENDING_CMD" ]]; then _FORGE_TERM_COMMANDS+=("$_FORGE_TERM_PENDING_CMD") diff --git a/shell-plugin/lib/helpers.zsh b/shell-plugin/lib/helpers.zsh index ca48cfed29..a7a62a57e9 100644 --- a/shell-plugin/lib/helpers.zsh +++ b/shell-plugin/lib/helpers.zsh @@ -22,6 +22,25 @@ function _forge_exec() { local agent_id="${_FORGE_ACTIVE_AGENT:-forge}" local -a cmd cmd=($_FORGE_BIN --agent "$agent_id") + + # Expose terminal context arrays as US-separated (\x1F) env vars so that + # the Rust TerminalContextService can read them via get_env_var. + # ASCII Unit Separator (\x1F) is used instead of `:` because commands + # can legitimately contain colons (URLs, port mappings, paths, etc.). + # Use `local -x` so the variables are exported only to the child forge + # process and do not leak into the caller's shell environment. + if [[ "$_FORGE_TERM_ENABLED" == "true" && ${#_FORGE_TERM_COMMANDS} -gt 0 ]]; then + # Join the ring-buffer arrays with the ASCII Unit Separator (\x1F). + # We use IFS-based joining ("${arr[*]}") rather than ${(j.SEP.)arr} because + # zsh does NOT expand $'...' ANSI-C escapes inside parameter expansion flags. + local _old_ifs="$IFS" _sep=$'\x1f' + IFS="$_sep" + local -x _FORGE_TERM_COMMANDS="${_FORGE_TERM_COMMANDS[*]}" + local -x _FORGE_TERM_EXIT_CODES="${_FORGE_TERM_EXIT_CODES[*]}" + local -x _FORGE_TERM_TIMESTAMPS="${_FORGE_TERM_TIMESTAMPS[*]}" + IFS="$_old_ifs" + fi + cmd+=("$@") [[ -n "$_FORGE_SESSION_MODEL" ]] && local -x FORGE_SESSION__MODEL_ID="$_FORGE_SESSION_MODEL" [[ -n "$_FORGE_SESSION_PROVIDER" ]] && local -x FORGE_SESSION__PROVIDER_ID="$_FORGE_SESSION_PROVIDER" @@ -40,15 +59,20 @@ function _forge_exec_interactive() { local -a cmd cmd=($_FORGE_BIN --agent "$agent_id") - # Expose terminal context arrays as colon-separated env vars so that the - # Rust TerminalContextService can read them via get_env_var. + # Expose terminal context arrays as US-separated (\x1F) env vars so that + # the Rust TerminalContextService can read them via get_env_var. + # ASCII Unit Separator (\x1F) is used instead of `:` because commands + # can legitimately contain colons (URLs, port mappings, paths, etc.). # Use `local -x` so the variables are exported only for the duration of # this function call (i.e. inherited by the child forge process) and do # not leak into the caller's shell environment. if [[ "$_FORGE_TERM_ENABLED" == "true" && ${#_FORGE_TERM_COMMANDS} -gt 0 ]]; then - local -x _FORGE_TERM_COMMANDS="${(j.:.)_FORGE_TERM_COMMANDS}" - local -x _FORGE_TERM_EXIT_CODES="${(j.:.)_FORGE_TERM_EXIT_CODES}" - local -x _FORGE_TERM_TIMESTAMPS="${(j.:.)_FORGE_TERM_TIMESTAMPS}" + local _old_ifs="$IFS" _sep=$'\x1f' + IFS="$_sep" + local -x _FORGE_TERM_COMMANDS="${_FORGE_TERM_COMMANDS[*]}" + local -x _FORGE_TERM_EXIT_CODES="${_FORGE_TERM_EXIT_CODES[*]}" + local -x _FORGE_TERM_TIMESTAMPS="${_FORGE_TERM_TIMESTAMPS[*]}" + IFS="$_old_ifs" fi cmd+=("$@") From a24ce418991537c249947541b4c8e19e73be821c Mon Sep 17 00:00:00 2001 From: Tushar Date: Mon, 13 Apr 2026 01:47:31 +0530 Subject: [PATCH 22/29] feat(terminal-context): add max_terminal_commands config to limit and sort captured commands --- crates/forge_app/src/command_generator.rs | 5 +- crates/forge_app/src/terminal_context.rs | 116 +++++++++++++++++++++- crates/forge_config/src/config.rs | 7 ++ forge.schema.json | 7 ++ 4 files changed, 130 insertions(+), 5 deletions(-) diff --git a/crates/forge_app/src/command_generator.rs b/crates/forge_app/src/command_generator.rs index c75eb00c47..36fc99730d 100644 --- a/crates/forge_app/src/command_generator.rs +++ b/crates/forge_app/src/command_generator.rs @@ -26,7 +26,10 @@ pub struct CommandGenerator { impl CommandGenerator where - S: EnvironmentInfra + FileDiscoveryService + ProviderService + AppConfigService, + S: EnvironmentInfra + + FileDiscoveryService + + ProviderService + + AppConfigService, { /// Creates a new CommandGenerator instance with the provided services. pub fn new(services: Arc) -> Self { diff --git a/crates/forge_app/src/terminal_context.rs b/crates/forge_app/src/terminal_context.rs index 8f28159927..db4309bb96 100644 --- a/crates/forge_app/src/terminal_context.rs +++ b/crates/forge_app/src/terminal_context.rs @@ -42,9 +42,13 @@ impl TerminalContextService { } } -impl TerminalContextService { +impl> TerminalContextService { /// Reads the terminal context from environment variables. /// + /// Commands are sorted by timestamp (oldest first, most recent last) and + /// limited to the number specified by [`forge_config::ForgeConfig::max_terminal_commands`]. + /// When `max_terminal_commands` is `0`, all captured commands are included. + /// /// Returns `None` if none of the required variables are set or if no /// commands were recorded. pub fn get_terminal_context(&self) -> Option { @@ -70,7 +74,7 @@ impl TerminalContextService { // Zip the three lists together; pad missing exit codes/timestamps with 0. // The outer zip() truncates to the length of `commands`, so the // repeat() padding never produces extra entries. - let entries: Vec = commands + let mut entries: Vec = commands .into_iter() .zip(exit_codes.into_iter().chain(std::iter::repeat(0))) .zip(timestamps.into_iter().chain(std::iter::repeat(0))) @@ -79,9 +83,22 @@ impl TerminalContextService { exit_code, timestamp, }) - // FIXME: Use forge_config::Config to control the total number of commands. Make sure its sorted by timestamp - recent should be last .collect(); + // Sort by timestamp so the most recent command appears last. + entries.sort_by_key(|e| e.timestamp); + + // Limit to the configured maximum number of commands. When the limit is + // 0 (the default), all commands are included. + let max = self + .0 + .get_config() + .map(|c| c.max_terminal_commands) + .unwrap_or(0); + if max > 0 && entries.len() > max { + entries = entries.split_off(entries.len() - max); + } + if entries.is_empty() { None } else { @@ -111,6 +128,7 @@ mod tests { struct MockInfra { env_vars: BTreeMap, + config: forge_config::ForgeConfig, } impl MockInfra { @@ -120,6 +138,17 @@ mod tests { .iter() .map(|(k, v)| (k.to_string(), v.to_string())) .collect(), + config: forge_config::ForgeConfig::default(), + }) + } + + fn new_with_config(vars: &[(&str, &str)], config: forge_config::ForgeConfig) -> Arc { + Arc::new(Self { + env_vars: vars + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + config, }) } } @@ -133,7 +162,7 @@ mod tests { } fn get_config(&self) -> anyhow::Result { - Ok(forge_config::ForgeConfig::default()) + Ok(self.config.clone()) } async fn update_environment( @@ -245,4 +274,83 @@ mod tests { ]; assert_eq!(actual, expected); } + + #[test] + fn test_commands_sorted_by_timestamp_oldest_first() { + // Supply commands in reverse-timestamp order to confirm sorting is applied. + let sep = ENV_LIST_SEPARATOR; + let fixture = TerminalContextService::new(MockInfra::new(&[ + (ENV_TERM_COMMANDS, &format!("git status{sep}cargo test{sep}ls")), + (ENV_TERM_EXIT_CODES, &format!("0{sep}1{sep}0")), + (ENV_TERM_TIMESTAMPS, &format!("1700000003{sep}1700000002{sep}1700000001")), + ])); + let actual = fixture.get_terminal_context(); + let expected = Some(TerminalContext { + commands: vec![ + TerminalCommand { + command: "ls".to_string(), + exit_code: 0, + timestamp: 1700000001, + }, + TerminalCommand { + command: "cargo test".to_string(), + exit_code: 1, + timestamp: 1700000002, + }, + TerminalCommand { + command: "git status".to_string(), + exit_code: 0, + timestamp: 1700000003, + }, + ], + }); + assert_eq!(actual, expected); + } + + #[test] + fn test_max_terminal_commands_limits_to_most_recent() { + let sep = ENV_LIST_SEPARATOR; + let config = forge_config::ForgeConfig { + max_terminal_commands: 2, + ..Default::default() + }; + let fixture = TerminalContextService::new(MockInfra::new_with_config( + &[ + (ENV_TERM_COMMANDS, &format!("ls{sep}cargo test{sep}git status")), + (ENV_TERM_EXIT_CODES, &format!("0{sep}1{sep}0")), + (ENV_TERM_TIMESTAMPS, &format!("1700000001{sep}1700000002{sep}1700000003")), + ], + config, + )); + let actual = fixture.get_terminal_context(); + // Only the 2 most recent commands (by timestamp) should be kept, oldest first. + let expected = Some(TerminalContext { + commands: vec![ + TerminalCommand { + command: "cargo test".to_string(), + exit_code: 1, + timestamp: 1700000002, + }, + TerminalCommand { + command: "git status".to_string(), + exit_code: 0, + timestamp: 1700000003, + }, + ], + }); + assert_eq!(actual, expected); + } + + #[test] + fn test_max_terminal_commands_zero_includes_all() { + // max_terminal_commands = 0 (default) means no limit. + let sep = ENV_LIST_SEPARATOR; + let fixture = TerminalContextService::new(MockInfra::new(&[ + (ENV_TERM_COMMANDS, &format!("ls{sep}cargo test{sep}git status")), + (ENV_TERM_EXIT_CODES, &format!("0{sep}1{sep}0")), + (ENV_TERM_TIMESTAMPS, &format!("1700000001{sep}1700000002{sep}1700000003")), + ])); + let actual = fixture.get_terminal_context(); + assert_eq!(actual.unwrap().commands.len(), 3); + } } diff --git a/crates/forge_config/src/config.rs b/crates/forge_config/src/config.rs index b49f3d6e3c..fbaab07d18 100644 --- a/crates/forge_config/src/config.rs +++ b/crates/forge_config/src/config.rs @@ -292,6 +292,13 @@ pub struct ForgeConfig { /// keys, and other sensitive data passed as command-line arguments. #[serde(default)] pub terminal_context: bool, + + /// Maximum number of recent terminal commands included in the terminal + /// context injected into the user prompt. Commands are sorted by timestamp + /// so that the most recent command appears last. When set to `0` (the + /// default), all captured commands are included. + #[serde(default)] + pub max_terminal_commands: usize, } impl ForgeConfig { diff --git a/forge.schema.json b/forge.schema.json index 7697b4d65f..0e2b9e77b9 100644 --- a/forge.schema.json +++ b/forge.schema.json @@ -198,6 +198,13 @@ "default": 0, "minimum": 0 }, + "max_terminal_commands": { + "description": "Maximum number of recent terminal commands included in the terminal\ncontext injected into the user prompt. Commands are sorted by timestamp\nso that the most recent command appears last. When set to `0` (the\ndefault), all captured commands are included.", + "type": "integer", + "format": "uint", + "default": 0, + "minimum": 0 + }, "max_tokens": { "description": "Maximum tokens the model may generate per response for all agents\n(1–100,000).", "type": [ From a57401989c68689707c0b1205ab127c8cfd77da7 Mon Sep 17 00:00:00 2001 From: Tushar Date: Mon, 13 Apr 2026 01:48:34 +0530 Subject: [PATCH 23/29] refactor(terminal-context): rename terminal_context xml tag to command_trace in command generator --- crates/forge_app/src/command_generator.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/forge_app/src/command_generator.rs b/crates/forge_app/src/command_generator.rs index 36fc99730d..a6f7789fac 100644 --- a/crates/forge_app/src/command_generator.rs +++ b/crates/forge_app/src/command_generator.rs @@ -76,7 +76,7 @@ where let user_content = match terminal_service.get_terminal_context() { Some(ctx) => { let terminal_elm = - Element::new("terminal_context").append(ctx.commands.iter().map(|cmd| { + Element::new("command_trace").append(ctx.commands.iter().map(|cmd| { Element::new("command") .attr("exit_code", cmd.exit_code.to_string()) .text(&cmd.command) From f9ae9478c1b81e1bf48476fc93f9b13c6d5342e8 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 12 Apr 2026 20:26:09 +0000 Subject: [PATCH 24/29] [autofix.ci] apply automated fixes --- crates/forge_app/src/terminal_context.rs | 52 +++++++++++++++++------- crates/forge_app/src/user_prompt.rs | 30 +++++++------- 2 files changed, 53 insertions(+), 29 deletions(-) diff --git a/crates/forge_app/src/terminal_context.rs b/crates/forge_app/src/terminal_context.rs index db4309bb96..b96d97a18a 100644 --- a/crates/forge_app/src/terminal_context.rs +++ b/crates/forge_app/src/terminal_context.rs @@ -46,7 +46,8 @@ impl> TerminalContextSer /// Reads the terminal context from environment variables. /// /// Commands are sorted by timestamp (oldest first, most recent last) and - /// limited to the number specified by [`forge_config::ForgeConfig::max_terminal_commands`]. + /// limited to the number specified by + /// [`forge_config::ForgeConfig::max_terminal_commands`]. /// When `max_terminal_commands` is `0`, all captured commands are included. /// /// Returns `None` if none of the required variables are set or if no @@ -214,9 +215,15 @@ mod tests { fn test_multiple_commands_with_exit_codes_and_timestamps() { let sep = ENV_LIST_SEPARATOR; let fixture = TerminalContextService::new(MockInfra::new(&[ - (ENV_TERM_COMMANDS, &format!("ls{sep}cargo test{sep}git status")), + ( + ENV_TERM_COMMANDS, + &format!("ls{sep}cargo test{sep}git status"), + ), (ENV_TERM_EXIT_CODES, &format!("0{sep}1{sep}0")), - (ENV_TERM_TIMESTAMPS, &format!("1700000001{sep}1700000002{sep}1700000003")), + ( + ENV_TERM_TIMESTAMPS, + &format!("1700000001{sep}1700000002{sep}1700000003"), + ), ])); let actual = fixture.get_terminal_context(); let expected = Some(TerminalContext { @@ -267,7 +274,9 @@ mod tests { fn test_split_env_list_command_with_colon() { // Commands containing `:` (e.g. URLs, port mappings) must not be split. let sep = ENV_LIST_SEPARATOR; - let actual = split_env_list(&format!("curl https://example.com{sep}docker run -p 8080:80 nginx")); + let actual = split_env_list(&format!( + "curl https://example.com{sep}docker run -p 8080:80 nginx" + )); let expected = vec![ "curl https://example.com".to_string(), "docker run -p 8080:80 nginx".to_string(), @@ -280,9 +289,15 @@ mod tests { // Supply commands in reverse-timestamp order to confirm sorting is applied. let sep = ENV_LIST_SEPARATOR; let fixture = TerminalContextService::new(MockInfra::new(&[ - (ENV_TERM_COMMANDS, &format!("git status{sep}cargo test{sep}ls")), + ( + ENV_TERM_COMMANDS, + &format!("git status{sep}cargo test{sep}ls"), + ), (ENV_TERM_EXIT_CODES, &format!("0{sep}1{sep}0")), - (ENV_TERM_TIMESTAMPS, &format!("1700000003{sep}1700000002{sep}1700000001")), + ( + ENV_TERM_TIMESTAMPS, + &format!("1700000003{sep}1700000002{sep}1700000001"), + ), ])); let actual = fixture.get_terminal_context(); let expected = Some(TerminalContext { @@ -310,15 +325,18 @@ mod tests { #[test] fn test_max_terminal_commands_limits_to_most_recent() { let sep = ENV_LIST_SEPARATOR; - let config = forge_config::ForgeConfig { - max_terminal_commands: 2, - ..Default::default() - }; + let config = forge_config::ForgeConfig { max_terminal_commands: 2, ..Default::default() }; let fixture = TerminalContextService::new(MockInfra::new_with_config( &[ - (ENV_TERM_COMMANDS, &format!("ls{sep}cargo test{sep}git status")), + ( + ENV_TERM_COMMANDS, + &format!("ls{sep}cargo test{sep}git status"), + ), (ENV_TERM_EXIT_CODES, &format!("0{sep}1{sep}0")), - (ENV_TERM_TIMESTAMPS, &format!("1700000001{sep}1700000002{sep}1700000003")), + ( + ENV_TERM_TIMESTAMPS, + &format!("1700000001{sep}1700000002{sep}1700000003"), + ), ], config, )); @@ -346,9 +364,15 @@ mod tests { // max_terminal_commands = 0 (default) means no limit. let sep = ENV_LIST_SEPARATOR; let fixture = TerminalContextService::new(MockInfra::new(&[ - (ENV_TERM_COMMANDS, &format!("ls{sep}cargo test{sep}git status")), + ( + ENV_TERM_COMMANDS, + &format!("ls{sep}cargo test{sep}git status"), + ), (ENV_TERM_EXIT_CODES, &format!("0{sep}1{sep}0")), - (ENV_TERM_TIMESTAMPS, &format!("1700000001{sep}1700000002{sep}1700000003")), + ( + ENV_TERM_TIMESTAMPS, + &format!("1700000001{sep}1700000002{sep}1700000003"), + ), ])); let actual = fixture.get_terminal_context(); assert_eq!(actual.unwrap().commands.len(), 3); diff --git a/crates/forge_app/src/user_prompt.rs b/crates/forge_app/src/user_prompt.rs index bfa452adc4..d1a691212b 100644 --- a/crates/forge_app/src/user_prompt.rs +++ b/crates/forge_app/src/user_prompt.rs @@ -179,24 +179,24 @@ impl }; // Inject terminal context into the event context if enabled in config. - let event_context = - match self.services.get_config().map(|c| c.terminal_context) { - Ok(true) => { - match TerminalContextService::new(self.services.clone()) - .get_terminal_context() - { - Some(ctx) => event_context.terminal_context(Some(ctx)), - None => event_context, - } + let event_context = match self.services.get_config().map(|c| c.terminal_context) { + Ok(true) => { + match TerminalContextService::new(self.services.clone()).get_terminal_context() + { + Some(ctx) => event_context.terminal_context(Some(ctx)), + None => event_context, } - _ => event_context, - }; + } + _ => event_context, + }; // Render the event value into agent's user prompt template. - Some(template_engine.render_template( - Template::new(user_prompt.template.as_str()), - &event_context, - )?) + Some( + template_engine.render_template( + Template::new(user_prompt.template.as_str()), + &event_context, + )?, + ) } else { // Use the raw event value as content if no user_prompt is provided event_value From 7fdbdffdba96d4802521c6c00341aece9f3ed606 Mon Sep 17 00:00:00 2001 From: Tushar Date: Mon, 13 Apr 2026 08:21:15 +0530 Subject: [PATCH 25/29] refactor(terminal-context): remove terminal_context and max_terminal_commands config options --- crates/forge_app/src/terminal_context.rs | 71 ++---------------------- crates/forge_app/src/user_prompt.rs | 17 ++---- crates/forge_config/src/config.rs | 17 ------ 3 files changed, 10 insertions(+), 95 deletions(-) diff --git a/crates/forge_app/src/terminal_context.rs b/crates/forge_app/src/terminal_context.rs index b96d97a18a..0079342938 100644 --- a/crates/forge_app/src/terminal_context.rs +++ b/crates/forge_app/src/terminal_context.rs @@ -45,10 +45,7 @@ impl TerminalContextService { impl> TerminalContextService { /// Reads the terminal context from environment variables. /// - /// Commands are sorted by timestamp (oldest first, most recent last) and - /// limited to the number specified by - /// [`forge_config::ForgeConfig::max_terminal_commands`]. - /// When `max_terminal_commands` is `0`, all captured commands are included. + /// Commands are sorted by timestamp (oldest first, most recent last). /// /// Returns `None` if none of the required variables are set or if no /// commands were recorded. @@ -89,17 +86,6 @@ impl> TerminalContextSer // Sort by timestamp so the most recent command appears last. entries.sort_by_key(|e| e.timestamp); - // Limit to the configured maximum number of commands. When the limit is - // 0 (the default), all commands are included. - let max = self - .0 - .get_config() - .map(|c| c.max_terminal_commands) - .unwrap_or(0); - if max > 0 && entries.len() > max { - entries = entries.split_off(entries.len() - max); - } - if entries.is_empty() { None } else { @@ -129,7 +115,6 @@ mod tests { struct MockInfra { env_vars: BTreeMap, - config: forge_config::ForgeConfig, } impl MockInfra { @@ -139,17 +124,6 @@ mod tests { .iter() .map(|(k, v)| (k.to_string(), v.to_string())) .collect(), - config: forge_config::ForgeConfig::default(), - }) - } - - fn new_with_config(vars: &[(&str, &str)], config: forge_config::ForgeConfig) -> Arc { - Arc::new(Self { - env_vars: vars - .iter() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect(), - config, }) } } @@ -163,7 +137,7 @@ mod tests { } fn get_config(&self) -> anyhow::Result { - Ok(self.config.clone()) + Ok(forge_config::ForgeConfig::default()) } async fn update_environment( @@ -323,45 +297,8 @@ mod tests { } #[test] - fn test_max_terminal_commands_limits_to_most_recent() { - let sep = ENV_LIST_SEPARATOR; - let config = forge_config::ForgeConfig { max_terminal_commands: 2, ..Default::default() }; - let fixture = TerminalContextService::new(MockInfra::new_with_config( - &[ - ( - ENV_TERM_COMMANDS, - &format!("ls{sep}cargo test{sep}git status"), - ), - (ENV_TERM_EXIT_CODES, &format!("0{sep}1{sep}0")), - ( - ENV_TERM_TIMESTAMPS, - &format!("1700000001{sep}1700000002{sep}1700000003"), - ), - ], - config, - )); - let actual = fixture.get_terminal_context(); - // Only the 2 most recent commands (by timestamp) should be kept, oldest first. - let expected = Some(TerminalContext { - commands: vec![ - TerminalCommand { - command: "cargo test".to_string(), - exit_code: 1, - timestamp: 1700000002, - }, - TerminalCommand { - command: "git status".to_string(), - exit_code: 0, - timestamp: 1700000003, - }, - ], - }); - assert_eq!(actual, expected); - } - - #[test] - fn test_max_terminal_commands_zero_includes_all() { - // max_terminal_commands = 0 (default) means no limit. + fn test_all_commands_included() { + // All captured commands are included (no limit). let sep = ENV_LIST_SEPARATOR; let fixture = TerminalContextService::new(MockInfra::new(&[ ( diff --git a/crates/forge_app/src/user_prompt.rs b/crates/forge_app/src/user_prompt.rs index d1a691212b..b076c58933 100644 --- a/crates/forge_app/src/user_prompt.rs +++ b/crates/forge_app/src/user_prompt.rs @@ -178,17 +178,12 @@ impl None => event_context, }; - // Inject terminal context into the event context if enabled in config. - let event_context = match self.services.get_config().map(|c| c.terminal_context) { - Ok(true) => { - match TerminalContextService::new(self.services.clone()).get_terminal_context() - { - Some(ctx) => event_context.terminal_context(Some(ctx)), - None => event_context, - } - } - _ => event_context, - }; + // Inject terminal context into the event context when available. + let event_context = + match TerminalContextService::new(self.services.clone()).get_terminal_context() { + Some(ctx) => event_context.terminal_context(Some(ctx)), + None => event_context, + }; // Render the event value into agent's user prompt template. Some( diff --git a/crates/forge_config/src/config.rs b/crates/forge_config/src/config.rs index fbaab07d18..d3e6fac16d 100644 --- a/crates/forge_config/src/config.rs +++ b/crates/forge_config/src/config.rs @@ -282,23 +282,6 @@ pub struct ForgeConfig { #[serde(default)] pub verify_todos: bool, - /// Whether to include terminal context (recent shell commands, exit codes, - /// and timestamps) in the user prompt. When enabled, the shell plugin - /// exports the captured command history and the Rust service injects it - /// into the rendered prompt via [`EventContext`]. - /// - /// Defaults to `false` so that users explicitly opt in before their shell - /// history is sent to the LLM. Shell history can contain passwords, API - /// keys, and other sensitive data passed as command-line arguments. - #[serde(default)] - pub terminal_context: bool, - - /// Maximum number of recent terminal commands included in the terminal - /// context injected into the user prompt. Commands are sorted by timestamp - /// so that the most recent command appears last. When set to `0` (the - /// default), all captured commands are included. - #[serde(default)] - pub max_terminal_commands: usize, } impl ForgeConfig { From 1f5652785c1f28047603377ad350aa50e64d3569 Mon Sep 17 00:00:00 2001 From: Tushar Date: Mon, 13 Apr 2026 08:21:26 +0530 Subject: [PATCH 26/29] refactor(terminal-context): rename max_entries to max_commands and lower default to 5 --- shell-plugin/lib/config.zsh | 2 +- shell-plugin/lib/context.zsh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/shell-plugin/lib/config.zsh b/shell-plugin/lib/config.zsh index 18281ba36e..4086f72f27 100644 --- a/shell-plugin/lib/config.zsh +++ b/shell-plugin/lib/config.zsh @@ -43,7 +43,7 @@ typeset -h _FORGE_SESSION_REASONING_EFFORT # Master switch for terminal context capture (preexec/precmd hooks) typeset -h _FORGE_TERM_ENABLED="${FORGE_TERM_ENABLED:-true}" # Maximum number of commands to keep in the ring buffer (metadata: cmd + exit code) -typeset -h _FORGE_TERM_MAX_ENTRIES="${FORGE_TERM_MAX_ENTRIES:-100}" +typeset -h _FORGE_TERM_MAX_COMMANDS="${FORGE_TERM_MAX_COMMANDS:-5}" # OSC 133 semantic prompt marker emission: "auto", "on", or "off" typeset -h _FORGE_TERM_OSC133="${FORGE_TERM_OSC133:-auto}" # Ring buffer arrays for context capture diff --git a/shell-plugin/lib/context.zsh b/shell-plugin/lib/context.zsh index 8ef1e6a578..207a58b487 100644 --- a/shell-plugin/lib/context.zsh +++ b/shell-plugin/lib/context.zsh @@ -96,7 +96,7 @@ function _forge_context_precmd() { _FORGE_TERM_TIMESTAMPS+=("$_FORGE_TERM_PENDING_TS") # Trim ring buffer to max size - while (( ${#_FORGE_TERM_COMMANDS} > _FORGE_TERM_MAX_ENTRIES )); do + while (( ${#_FORGE_TERM_COMMANDS} > _FORGE_TERM_MAX_COMMANDS )); do shift _FORGE_TERM_COMMANDS shift _FORGE_TERM_EXIT_CODES shift _FORGE_TERM_TIMESTAMPS From f99934a4554db551245c45a002d7c2d44b44045d Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 03:14:16 +0000 Subject: [PATCH 27/29] [autofix.ci] apply automated fixes --- crates/forge_config/src/config.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/forge_config/src/config.rs b/crates/forge_config/src/config.rs index d3e6fac16d..6b9baaa213 100644 --- a/crates/forge_config/src/config.rs +++ b/crates/forge_config/src/config.rs @@ -281,7 +281,6 @@ pub struct ForgeConfig { /// when a task ends and reminds the LLM about them. #[serde(default)] pub verify_todos: bool, - } impl ForgeConfig { From 5bbb7338ea5b12f8a3fcdf879b71dd67d535c8a4 Mon Sep 17 00:00:00 2001 From: Tushar Date: Mon, 13 Apr 2026 08:54:06 +0530 Subject: [PATCH 28/29] refactor(terminal-context): update test assertions to use command_trace xml tag --- crates/forge_app/src/command_generator.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/forge_app/src/command_generator.rs b/crates/forge_app/src/command_generator.rs index a6f7789fac..78ac004569 100644 --- a/crates/forge_app/src/command_generator.rs +++ b/crates/forge_app/src/command_generator.rs @@ -380,8 +380,8 @@ mod tests { .expect("should have a user message") .content() .expect("user message should have content"); - assert!(user_content.contains("")); - assert!(user_content.contains("")); + assert!(user_content.contains("")); + assert!(user_content.contains("")); assert!(user_content.contains("cargo build")); assert!(user_content.contains("fix the command I just ran")); } From f41efb178e5ba66f78730a170dc872ebeb877fd8 Mon Sep 17 00:00:00 2001 From: Tushar Date: Mon, 13 Apr 2026 08:59:57 +0530 Subject: [PATCH 29/29] refactor(terminal-context): remove terminal_context and max_terminal_commands from schema --- forge.schema.json | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/forge.schema.json b/forge.schema.json index 0e2b9e77b9..43cc190609 100644 --- a/forge.schema.json +++ b/forge.schema.json @@ -198,13 +198,6 @@ "default": 0, "minimum": 0 }, - "max_terminal_commands": { - "description": "Maximum number of recent terminal commands included in the terminal\ncontext injected into the user prompt. Commands are sorted by timestamp\nso that the most recent command appears last. When set to `0` (the\ndefault), all captured commands are included.", - "type": "integer", - "format": "uint", - "default": 0, - "minimum": 0 - }, "max_tokens": { "description": "Maximum tokens the model may generate per response for all agents\n(1–100,000).", "type": [ @@ -309,11 +302,6 @@ } ] }, - "terminal_context": { - "description": "Whether to include terminal context (recent shell commands, exit codes,\nand timestamps) in the user prompt. When enabled, the shell plugin\nexports the captured command history and the Rust service injects it\ninto the rendered prompt via [`EventContext`].\n\nDefaults to `false` so that users explicitly opt in before their shell\nhistory is sent to the LLM. Shell history can contain passwords, API\nkeys, and other sensitive data passed as command-line arguments.", - "type": "boolean", - "default": false - }, "tool_supported": { "description": "Whether tool use is supported in the current environment; when false,\nall tool calls are disabled.", "type": "boolean",