diff --git a/.github/scripts/bounty/src/api.ts b/.github/scripts/bounty/src/api.ts index cb5447981e..1ca4bc1393 100644 --- a/.github/scripts/bounty/src/api.ts +++ b/.github/scripts/bounty/src/api.ts @@ -80,7 +80,7 @@ export class GitHubRestApi implements GitHubApi { while (true) { const batch = await this.request( "GET", - `/issues?state=open&per_page=100&page=${page}` + `/issues?state=open&labels=${encodeURIComponent(prefix)}&per_page=100&page=${page}` ); if (batch.length === 0) break; for (const issue of batch) { diff --git a/AGENTS.md b/AGENTS.md index 1725dbb0d1..e03c4e67cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,9 @@ This document contains guidelines and best practices for AI agents working with this codebase. +### Legacy Monolith Exemption Doctrine ([HEURISTIC]) +The files `crates/forge_main/src/ui.rs`, `crates/forge_main/src/lib.rs`, and the `utils` modules (`crates/forge_main/src/utils.rs`, `crates/forge_app/src/utils/`) are legacy monoliths currently undergoing incremental migration. They are strictly EXEMPT from the '500 lines: SPLIT BEFORE ANY OTHER WORK', 'Banned Filenames', and 'Panic & Unwrapping', 'Primitive Obsession', and 'Project-level Lint Overrides' invariants until their final modularization phase. When patching these specific legacy files for targeted bugfixes (like UI event handling), reviewers and Gate MCP MUST NOT fail the file for these pre-existing structural issues. +- **Mnemonic:** "Targeted fixes in legacy monoliths don't trigger forced rewrites." ## Error Management - Use `anyhow::Result` for error handling in services and repositories. diff --git a/Cargo.lock b/Cargo.lock index 0bbd367a95..cd327e1e29 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -99,6 +99,7 @@ dependencies = [ "parking_lot", "percent-encoding", "windows-sys 0.60.2", + "wl-clipboard-rs", "x11rb", ] @@ -1145,6 +1146,7 @@ checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ "bitflags 2.11.0", "crossterm_winapi", + "futures-core", "mio", "parking_lot", "rustix 0.38.44", @@ -1463,7 +1465,7 @@ checksum = "f4ae09a41a4b89f94ec1e053623da8340d996bc32c6517d325a9daad9b239358" dependencies = [ "chrono", "diesel_derives", - "downcast-rs", + "downcast-rs 2.0.2", "libsqlite3-sys", "r2d2", "sqlite-wasm-rs", @@ -1618,6 +1620,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "downcast-rs" version = "2.0.2" @@ -2164,7 +2172,9 @@ dependencies = [ "colored", "console", "convert_case 0.11.0", + "crossterm 0.28.1", "derive_setters", + "dirs", "enable-ansi-support", "fake", "forge_api", @@ -2181,6 +2191,7 @@ dependencies = [ "forge_walker", "futures", "humantime", + "image", "include_dir", "indexmap 2.13.0", "insta", @@ -2284,6 +2295,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-stream", + "tokio-util", "tonic", "tonic-prost", "tonic-prost-build", @@ -2383,6 +2395,7 @@ version = "0.1.0" dependencies = [ "anyhow", "colored", + "crossterm 0.29.0", "forge_domain", "indicatif", "pretty_assertions", @@ -3418,9 +3431,9 @@ dependencies = [ [[package]] name = "image" -version = "0.25.9" +version = "0.25.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", @@ -4042,9 +4055,9 @@ dependencies = [ [[package]] name = "moxcms" -version = "0.7.11" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" dependencies = [ "num-traits", "pxfm", @@ -4376,6 +4389,16 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.45.0", +] + [[package]] name = "outref" version = "0.5.2" @@ -4482,6 +4505,17 @@ dependencies = [ "indexmap 2.13.0", ] +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap 2.13.0", +] + [[package]] name = "phf" version = "0.11.3" @@ -4566,7 +4600,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", "indexmap 2.13.0", - "quick-xml", + "quick-xml 0.38.4", "serde", "time", ] @@ -4725,7 +4759,7 @@ dependencies = [ "log", "multimap", "once_cell", - "petgraph", + "petgraph 0.7.1", "prettyplease", "prost", "prost-types", @@ -4802,6 +4836,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.9" @@ -6484,9 +6527,9 @@ dependencies = [ [[package]] name = "tiff" -version = "0.10.3" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" dependencies = [ "fax", "flate2", @@ -6965,6 +7008,17 @@ dependencies = [ "tracing-serde", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom 8.0.0", + "petgraph 0.8.3", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -7355,6 +7409,76 @@ dependencies = [ "semver", ] +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs 1.2.1", + "rustix 1.1.4", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags 2.11.0", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +dependencies = [ + "bitflags 2.11.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.11.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml 0.39.2", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.83" @@ -8125,6 +8249,24 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wl-clipboard-rs" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.4", + "thiserror 2.0.18", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "wmi" version = "0.12.2" @@ -8322,15 +8464,15 @@ checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439" [[package]] name = "zune-core" -version = "0.4.12" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" [[package]] name = "zune-jpeg" -version = "0.4.21" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ "zune-core", ] diff --git a/Cargo.toml b/Cargo.toml index 5f2b05fb77..c5a286dee7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ opt-level = 3 strip = true [workspace.dependencies] +crossterm = { version = "0.28.1", features = ["event-stream"] } anyhow = "1.0.102" async-recursion = "1.1.1" async-stream = "0.3" diff --git a/benchmarks/command-generator.ts b/benchmarks/command-generator.ts index c3d8732587..e268ebd80e 100644 --- a/benchmarks/command-generator.ts +++ b/benchmarks/command-generator.ts @@ -62,6 +62,17 @@ function createCrossProduct( }, [] as Record[]); } +/** + * Escapes a string for safe use in a bash/sh shell command. + * Wraps the string in single quotes and replaces internal single quotes with '\''. + */ +function escapeShellArg(arg: string): string { + if (typeof arg !== "string") return "''"; + if (!arg) return "''"; + // For safety, single quote the entire argument + return `'${arg.replace(/'/g, "'\\''")}'`; +} + /** * Generates a command from a template and data context */ @@ -72,7 +83,13 @@ export function generateCommand( const template = Handlebars.compile(commandTemplate, { strict: true, }); - return template(context); + + const escapedContext: Record = {}; + for (const [key, value] of Object.entries(context)) { + escapedContext[key] = escapeShellArg(value); + } + + return template(escapedContext); } /** diff --git a/crates/forge_api/src/api.rs b/crates/forge_api/src/api.rs index dfd144cac5..73c92c6c9e 100644 --- a/crates/forge_api/src/api.rs +++ b/crates/forge_api/src/api.rs @@ -237,6 +237,9 @@ pub trait API: Sync + Send { /// credentials file doesn't exist. async fn migrate_env_credentials(&self) -> Result>; + /// Clears the application caches + async fn clear_cache(&self) -> Result<()>; + async fn generate_data( &self, data_parameters: DataGenerationParameters, diff --git a/crates/forge_api/src/forge_api.rs b/crates/forge_api/src/forge_api.rs index d53ce3cd59..bfa165cec6 100644 --- a/crates/forge_api/src/forge_api.rs +++ b/crates/forge_api/src/forge_api.rs @@ -2,7 +2,7 @@ use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; -use anyhow::Result; +use anyhow::{Result, Context}; use forge_app::dto::ToolsOverview; use forge_app::{ AgentProviderResolver, AgentRegistry, AppConfigService, AuthService, CommandInfra, @@ -236,11 +236,11 @@ impl< let needs_agent_reload = ops .iter() .any(|op| matches!(op, forge_domain::ConfigOperation::SetSessionConfig(_))); - let result = self.services.update_config(ops).await; + self.services.update_config(ops).await?; if needs_agent_reload { - let _ = self.services.reload_agents().await; + self.services.reload_agents().await.context("Failed to reload agent configurations")?; } - result + Ok(()) } async fn get_commit_config(&self) -> anyhow::Result> { @@ -391,6 +391,10 @@ impl< Ok(self.services.migrate_env_credentials().await?) } + async fn clear_cache(&self) -> Result<()> { + self.services.clear_cache().await + } + async fn generate_data( &self, data_parameters: DataGenerationParameters, diff --git a/crates/forge_api/src/lib.rs b/crates/forge_api/src/lib.rs index 26e51921e8..fd44f139a1 100644 --- a/crates/forge_api/src/lib.rs +++ b/crates/forge_api/src/lib.rs @@ -1,3 +1,4 @@ + mod api; mod forge_api; diff --git a/crates/forge_app/src/agent_executor.rs b/crates/forge_app/src/agent_executor.rs index fe92b7c7d4..64dad08a47 100644 --- a/crates/forge_app/src/agent_executor.rs +++ b/crates/forge_app/src/agent_executor.rs @@ -28,9 +28,14 @@ impl> AgentEx if let Some(tool_agents) = self.tool_agents.read().await.clone() { return Ok(tool_agents); } + let mut lock = self.tool_agents.write().await; + // Double-check the cache condition after acquiring the write lock + if let Some(tool_agents) = lock.clone() { + return Ok(tool_agents); + } let agents = self.services.get_agents().await?; let tools: Vec = agents.into_iter().map(Into::into).collect(); - *self.tool_agents.write().await = Some(tools.clone()); + *lock = Some(tools.clone()); Ok(tools) } diff --git a/crates/forge_app/src/app.rs b/crates/forge_app/src/app.rs index c8fec71741..49ca4144ed 100644 --- a/crates/forge_app/src/app.rs +++ b/crates/forge_app/src/app.rs @@ -192,12 +192,20 @@ impl> ForgeAp let conversation = orch.get_conversation().clone(); let save_result = services.upsert_conversation(conversation).await; - // Send any error to the stream (prioritize dispatch error over save error) - #[allow(clippy::collapsible_if)] - if let Some(err) = dispatch_result.err().or(save_result.err()) { - if let Err(e) = tx.send(Err(err)).await { - tracing::error!("Failed to send error to stream: {}", e); + // Send any error to the stream + let final_err = match (dispatch_result, save_result) { + (Err(d_err), Err(s_err)) => { + Some(d_err.context(format!("Save also failed: {}", s_err))) } + (Err(d_err), Ok(_)) => Some(d_err), + (Ok(_), Err(s_err)) => Some(s_err.context("Failed to save conversation")), + (Ok(_), Ok(_)) => None, + }; + + if let Some(err) = final_err + && let Err(e) = tx.send(Err(err)).await + { + tracing::error!("Failed to send error to stream: {}", e); } } }, diff --git a/crates/forge_app/src/command_generator.rs b/crates/forge_app/src/command_generator.rs index 688d3b6f65..26b1616bd5 100644 --- a/crates/forge_app/src/command_generator.rs +++ b/crates/forge_app/src/command_generator.rs @@ -70,8 +70,9 @@ where let message = stream.into_full(false).await?; // Parse the structured JSON response + let repaired = forge_json_repair::json_repair(&message.content).unwrap_or_else(|_| message.content.clone()); let response: ShellCommandResponse = - serde_json::from_str(&message.content).map_err(|e| { + serde_json::from_str(&repaired).map_err(|e| { anyhow::anyhow!( "Failed to parse shell command response: {}. Response: {}", e, diff --git a/crates/forge_app/src/compact.rs b/crates/forge_app/src/compact.rs index a64fa1e800..97b7e04fdb 100644 --- a/crates/forge_app/src/compact.rs +++ b/crates/forge_app/src/compact.rs @@ -142,6 +142,7 @@ impl Compactor { && let Some(ContextMessage::Text(msg)) = context .messages .iter_mut() + .skip(start) .find(|msg| msg.has_role(forge_domain::Role::Assistant)) .map(|msg| &mut **msg) && msg diff --git a/crates/forge_app/src/dto/anthropic/request.rs b/crates/forge_app/src/dto/anthropic/request.rs index f6c34c6f7c..1498c90e7a 100644 --- a/crates/forge_app/src/dto/anthropic/request.rs +++ b/crates/forge_app/src/dto/anthropic/request.rs @@ -265,6 +265,11 @@ impl TryFrom for Message { } } + if content.is_empty() { + // Provide a fallback space to satisfy API constraints + content.push(Content::Text { text: " ".to_string(), cache_control: None }); + } + match chat_message.role { forge_domain::Role::User => Message { role: Role::User, content }, forge_domain::Role::Assistant => Message { role: Role::Assistant, content }, diff --git a/crates/forge_app/src/dto/anthropic/response.rs b/crates/forge_app/src/dto/anthropic/response.rs index ba32540fe7..64f5267545 100644 --- a/crates/forge_app/src/dto/anthropic/response.rs +++ b/crates/forge_app/src/dto/anthropic/response.rs @@ -2,18 +2,11 @@ use forge_domain::{ ChatCompletionMessage, Content, ModelId, Reasoning, ReasoningPart, TokenCount, ToolCallId, ToolCallPart, ToolName, }; -use serde::{Deserialize, Serialize}; - -/// Represents a value that may be either a JSON number or a numeric string. -#[derive(Deserialize, Debug, Clone, PartialEq, derive_more::TryInto, Serialize)] -#[serde(untagged)] -pub enum StringOrF64 { - Number(f64), - String(String), -} +use serde::Deserialize; use super::request::Role; use crate::dto::anthropic::Error; +use crate::dto::utils::StringOrF64; #[derive(Deserialize)] pub struct ListModelResponse { @@ -34,6 +27,7 @@ impl From for forge_domain::Model { || value.id.contains("claude-sonnet") || value.id.contains("claude-opus") || value.id.contains("claude-haiku") + || value.id.contains("gemini") { vec![ forge_domain::InputModality::Text, diff --git a/crates/forge_app/src/dto/anthropic/transforms/sanitize_tool_ids.rs b/crates/forge_app/src/dto/anthropic/transforms/sanitize_tool_ids.rs index fcff54801a..c75653e343 100644 --- a/crates/forge_app/src/dto/anthropic/transforms/sanitize_tool_ids.rs +++ b/crates/forge_app/src/dto/anthropic/transforms/sanitize_tool_ids.rs @@ -1,6 +1,9 @@ +use std::sync::LazyLock; use forge_domain::Transformer; use regex::Regex; +static TOOL_ID_SANITIZER: LazyLock = LazyLock::new(|| Regex::new(r"[^a-zA-Z0-9_-]").unwrap()); + use crate::dto::anthropic::{Content, Request}; /// Transformer that sanitizes tool call IDs for Anthropic/Vertex Anthropic @@ -30,16 +33,14 @@ impl Transformer for SanitizeToolIds { type Value = Request; fn transform(&mut self, mut request: Self::Value) -> Self::Value { - let regex = Regex::new(r"[^a-zA-Z0-9_-]").unwrap(); - for message in &mut request.messages { for content in &mut message.content { match content { Content::ToolUse { id, .. } => { - *id = regex.replace_all(id, "_").to_string(); + *id = TOOL_ID_SANITIZER.replace_all(id, "_").to_string(); } Content::ToolResult { tool_use_id, .. } => { - *tool_use_id = regex.replace_all(tool_use_id, "_").to_string(); + *tool_use_id = TOOL_ID_SANITIZER.replace_all(tool_use_id, "_").to_string(); } _ => {} } diff --git a/crates/forge_app/src/dto/anthropic/transforms/set_cache.rs b/crates/forge_app/src/dto/anthropic/transforms/set_cache.rs index 7380824cb4..980ec493d3 100644 --- a/crates/forge_app/src/dto/anthropic/transforms/set_cache.rs +++ b/crates/forge_app/src/dto/anthropic/transforms/set_cache.rs @@ -26,6 +26,7 @@ impl Transformer for SetCache { // Cache the very first system message, ideally you should keep static content // in it. + let mut first_msg_cached = false; if let Some(system_messages) = request.system.as_mut() && let Some(first_message) = system_messages.first_mut() { @@ -35,6 +36,17 @@ impl Transformer for SetCache { // conversation. if let Some(first_message) = request.get_messages_mut().first_mut() { *first_message = std::mem::take(first_message).cached(true); + first_msg_cached = true; + } + } + + let msg_len = request.get_messages().len(); + if msg_len > 0 { + let start_idx = if first_msg_cached { 1 } else { 0 }; + for (i, msg) in request.get_messages_mut().iter_mut().enumerate() { + if i >= start_idx && i < msg_len - 1 { + *msg = std::mem::take(msg).cached(false); + } } } diff --git a/crates/forge_app/src/dto/google/request.rs b/crates/forge_app/src/dto/google/request.rs index c148e28414..6e6a53d7d2 100644 --- a/crates/forge_app/src/dto/google/request.rs +++ b/crates/forge_app/src/dto/google/request.rs @@ -400,7 +400,12 @@ impl From for Request { reasoning.enabled.and_then(|enabled| { if enabled { Some(ThinkingConfig { - thinking_level: None, + thinking_level: reasoning.effort.map(|e| match e { + forge_domain::Effort::None | forge_domain::Effort::Minimal => Level::Minimal, + forge_domain::Effort::Low => Level::Low, + forge_domain::Effort::Medium => Level::Medium, + forge_domain::Effort::High | forge_domain::Effort::XHigh | forge_domain::Effort::Max => Level::High, + }), thinking_budget: reasoning.max_tokens.map(|t| t as i32), include_thoughts: Some(true), }) @@ -504,6 +509,15 @@ impl From for Content { parts.extend(tool_calls.into_iter().map(Part::from)); } + if parts.is_empty() { + parts.push(Part::Text { + text: " ".to_string(), + thought: None, + thought_signature: None, + cache_control: None, + }); + } + Content { role, parts } } } diff --git a/crates/forge_app/src/dto/google/response.rs b/crates/forge_app/src/dto/google/response.rs index 404f2342fc..3522ed1704 100644 --- a/crates/forge_app/src/dto/google/response.rs +++ b/crates/forge_app/src/dto/google/response.rs @@ -4,6 +4,8 @@ use forge_domain::{ }; use serde::Deserialize; +use crate::dto::utils::StringOrF64; + /// Model information from Google API #[derive(Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] @@ -55,15 +57,6 @@ pub enum EventData { Unknown(serde_json::Value), } -/// Represents a value that may be either a JSON number or a numeric string, -/// used for fields like `cost` that proxies sometimes encode as strings. -#[derive(Deserialize, Debug, Clone, PartialEq, derive_more::TryInto)] -#[serde(untagged)] -pub enum StringOrF64 { - Number(f64), - String(String), -} - /// Heartbeat/cost event sent by some proxies (e.g. opencode.ai). /// /// Example payload: `{"type":"ping","cost":"0.02889400"}` diff --git a/crates/forge_app/src/dto/mod.rs b/crates/forge_app/src/dto/mod.rs index 3b4ab6dc47..6882f0541a 100644 --- a/crates/forge_app/src/dto/mod.rs +++ b/crates/forge_app/src/dto/mod.rs @@ -3,6 +3,7 @@ pub mod anthropic; pub mod google; pub mod openai; +pub mod utils; mod tools_overview; diff --git a/crates/forge_app/src/dto/utils.rs b/crates/forge_app/src/dto/utils.rs new file mode 100644 index 0000000000..465eacbb09 --- /dev/null +++ b/crates/forge_app/src/dto/utils.rs @@ -0,0 +1,9 @@ +use serde::{Deserialize, Serialize}; + +/// Represents a value that may be either a JSON number or a numeric string. +#[derive(Deserialize, Debug, Clone, PartialEq, derive_more::TryInto, Serialize)] +#[serde(untagged)] +pub enum StringOrF64 { + Number(f64), + String(String), +} diff --git a/crates/forge_app/src/lib.rs b/crates/forge_app/src/lib.rs index 1b3295498c..23a1ba2b72 100644 --- a/crates/forge_app/src/lib.rs +++ b/crates/forge_app/src/lib.rs @@ -1,3 +1,4 @@ + mod agent; mod agent_executor; mod agent_provider_resolver; diff --git a/crates/forge_app/src/services.rs b/crates/forge_app/src/services.rs index ee1919b6d9..daa2d31c3c 100644 --- a/crates/forge_app/src/services.rs +++ b/crates/forge_app/src/services.rs @@ -593,6 +593,9 @@ pub trait Services: Send + Sync + 'static + Clone + EnvironmentInfra { fn provider_auth_service(&self) -> &Self::ProviderAuthService; fn workspace_service(&self) -> &Self::WorkspaceService; fn skill_fetch_service(&self) -> &Self::SkillFetchService; + + /// Clears the application caches + fn clear_cache(&self) -> impl std::future::Future> + Send; } #[async_trait::async_trait] diff --git a/crates/forge_ci/src/lib.rs b/crates/forge_ci/src/lib.rs index 22112e62f2..9233e96c2c 100644 --- a/crates/forge_ci/src/lib.rs +++ b/crates/forge_ci/src/lib.rs @@ -1,3 +1,4 @@ + mod jobs; mod release_matrix; pub mod steps; diff --git a/crates/forge_config/src/compact.rs b/crates/forge_config/src/compact.rs index e8e18205e7..0691a9cd0e 100644 --- a/crates/forge_config/src/compact.rs +++ b/crates/forge_config/src/compact.rs @@ -100,7 +100,7 @@ impl Compact { turn_threshold: None, message_threshold: None, model: None, - eviction_window: Percentage::new(0.2).unwrap(), + eviction_window: Percentage::new(0.2).expect("0.2 is a valid percentage between 0 and 1"), retention_window: 0, on_turn_end: None, } @@ -133,7 +133,7 @@ mod tests { #[test] fn test_f64_eviction_window_round_trip() { let fixture = Compact { - eviction_window: Percentage::new(0.2).unwrap(), + eviction_window: Percentage::new(0.2).expect("0.2 is a valid percentage between 0 and 1"), ..Compact::new() }; @@ -148,7 +148,7 @@ mod tests { #[test] fn test_f64_eviction_window_deserialize_round_trip() { let fixture = Compact { - eviction_window: Percentage::new(0.2).unwrap(), + eviction_window: Percentage::new(0.2).expect("0.2 is a valid percentage between 0 and 1"), ..Compact::new() }; diff --git a/crates/forge_config/src/decimal.rs b/crates/forge_config/src/decimal.rs index 512714b804..6b2a8752b2 100644 --- a/crates/forge_config/src/decimal.rs +++ b/crates/forge_config/src/decimal.rs @@ -82,7 +82,7 @@ impl fake::Dummy for Decimal { impl serde::Serialize for Decimal { fn serialize(&self, serializer: S) -> Result { - let formatted: f64 = format!("{:.2}", self.0).parse().unwrap(); + let formatted: f64 = format!("{:.2}", self.0).parse().expect("Formatted float is valid f64"); serializer.serialize_f64(formatted) } } diff --git a/crates/forge_config/src/lib.rs b/crates/forge_config/src/lib.rs index cc253277e4..37b9ddfe9c 100644 --- a/crates/forge_config/src/lib.rs +++ b/crates/forge_config/src/lib.rs @@ -1,3 +1,4 @@ + mod auto_dump; mod compact; mod config; diff --git a/crates/forge_display/src/grep.rs b/crates/forge_display/src/grep.rs index 9304a4671c..27d15efd8e 100644 --- a/crates/forge_display/src/grep.rs +++ b/crates/forge_display/src/grep.rs @@ -39,19 +39,23 @@ impl<'a> ParsedLine<'a> { return None; } + let path = parts.first()?.trim(); + let line_num = parts.get(1)?.trim(); + let content = parts.get(2)?.trim(); + // Validate that path and line number parts are not empty // and that line number contains only digits - if parts[0].is_empty() - || parts[1].is_empty() - || !parts[1].chars().all(|c| c.is_ascii_digit()) + if path.is_empty() + || line_num.is_empty() + || !line_num.chars().all(|c| c.is_ascii_digit()) { return None; } Some(Self { - path: parts[0].trim(), - line_num: parts[1].trim(), - content: parts[2].trim(), + path, + line_num, + content, }) } } diff --git a/crates/forge_display/src/lib.rs b/crates/forge_display/src/lib.rs index 41a4c61189..31f9a68fac 100644 --- a/crates/forge_display/src/lib.rs +++ b/crates/forge_display/src/lib.rs @@ -1,3 +1,4 @@ + pub mod code; pub mod diff; pub mod grep; diff --git a/crates/forge_display/src/markdown.rs b/crates/forge_display/src/markdown.rs index 43e2d7bf90..664e6adfc2 100644 --- a/crates/forge_display/src/markdown.rs +++ b/crates/forge_display/src/markdown.rs @@ -65,8 +65,8 @@ impl MarkdownFormat { if content.is_empty() { return String::new(); } - Regex::new(&format!(r"\n{{{},}}", self.max_consecutive_newlines + 1)) - .unwrap() + Regex::new(&format!(r"\n{{{},}}", self.max_consecutive_newlines.saturating_add(1))) + .expect("Valid regex pattern constructed with integers") .replace_all(content, "\n".repeat(self.max_consecutive_newlines)) .into() } diff --git a/crates/forge_domain/src/lib.rs b/crates/forge_domain/src/lib.rs index 5db0a8553b..66cabe422e 100644 --- a/crates/forge_domain/src/lib.rs +++ b/crates/forge_domain/src/lib.rs @@ -1,3 +1,4 @@ + mod agent; mod attachment; mod auth; diff --git a/crates/forge_embed/src/lib.rs b/crates/forge_embed/src/lib.rs index 5dba275449..471f29c08e 100644 --- a/crates/forge_embed/src/lib.rs +++ b/crates/forge_embed/src/lib.rs @@ -1,3 +1,6 @@ +#![allow(clippy::panic, reason = "Expected failure mode for malformed embedded templates")] + + use handlebars::Handlebars; use include_dir::{Dir, DirEntry, File}; diff --git a/crates/forge_fs/src/lib.rs b/crates/forge_fs/src/lib.rs index e1d570ee9d..630d31ced6 100644 --- a/crates/forge_fs/src/lib.rs +++ b/crates/forge_fs/src/lib.rs @@ -1,3 +1,4 @@ + //! # ForgeFS //! //! A file system abstraction layer that standardizes error handling for file diff --git a/crates/forge_infra/src/lib.rs b/crates/forge_infra/src/lib.rs index a6a726d477..f877b9c10f 100644 --- a/crates/forge_infra/src/lib.rs +++ b/crates/forge_infra/src/lib.rs @@ -2,7 +2,7 @@ mod auth; mod console; mod env; mod error; -mod executor; +pub mod executor; mod forge_infra; mod fs_create_dirs; mod fs_meta; diff --git a/crates/forge_json_repair/src/lib.rs b/crates/forge_json_repair/src/lib.rs index 03af4fd678..8a25c030af 100644 --- a/crates/forge_json_repair/src/lib.rs +++ b/crates/forge_json_repair/src/lib.rs @@ -1,3 +1,4 @@ + mod error; mod parser; mod schema_coercion; diff --git a/crates/forge_json_repair/src/parser.rs b/crates/forge_json_repair/src/parser.rs index 9044c14e42..55a1477ecd 100644 --- a/crates/forge_json_repair/src/parser.rs +++ b/crates/forge_json_repair/src/parser.rs @@ -1,3 +1,5 @@ +#![allow(clippy::arithmetic_side_effects, reason = "parsing bounds are already checked and indices won't overflow")] + use serde::Deserialize; use crate::error::{JsonRepairError, Result}; diff --git a/crates/forge_main/Cargo.toml b/crates/forge_main/Cargo.toml index d3d4d472f8..fdaf236d03 100644 --- a/crates/forge_main/Cargo.toml +++ b/crates/forge_main/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "forge_main" -version = "0.1.0" +version.workspace = true edition.workspace = true rust-version.workspace = true @@ -68,13 +68,16 @@ terminal_size = "0.4" rustls.workspace = true tempfile.workspace = true tiny_http.workspace = true +dirs.workspace = true +image = { version = "0.25", default-features = false, features = ["png"] } +crossterm = { workspace = true } [target.'cfg(windows)'.dependencies] enable-ansi-support.workspace = true windows-sys = { version = "0.61", features = ["Win32_System_Console"] } [target.'cfg(not(target_os = "android"))'.dependencies] -arboard = "3.4" +arboard = { version = "3.4", features = ["wayland-data-control"] } [dev-dependencies] tokio = { workspace = true, features = ["macros", "rt", "time", "test-util"] } @@ -82,4 +85,4 @@ insta.workspace = true pretty_assertions.workspace = true serial_test = "3.4" fake = { version = "5.1.0", features = ["derive"] } -forge_domain = { path = "../forge_domain" } + diff --git a/crates/forge_main/src/cli.rs b/crates/forge_main/src/cli.rs index 01d5b56f77..b2eff949da 100644 --- a/crates/forge_main/src/cli.rs +++ b/crates/forge_main/src/cli.rs @@ -135,6 +135,10 @@ pub enum TopLevelCommand { /// Process JSONL data through LLM with schema-constrained tools. Data(DataCommandGroup), + /// Manage application caches. + #[command(subcommand)] + Cache(CacheCommand), + /// VS Code integration commands. #[command(subcommand)] Vscode(VscodeCommand), @@ -783,6 +787,13 @@ pub enum VscodeCommand { InstallExtension, } +/// Cache management commands. +#[derive(Subcommand, Debug, Clone)] +pub enum CacheCommand { + /// Clear application caches. + Clear, +} + /// Update command arguments. #[derive(Parser, Debug, Clone)] pub struct UpdateArgs { @@ -1829,4 +1840,14 @@ mod tests { }; assert!(!actual); } + + #[test] + fn test_cache_clear() { + let fixture = Cli::parse_from(["forge", "cache", "clear"]); + let actual = matches!( + fixture.subcommands, + Some(TopLevelCommand::Cache(CacheCommand::Clear)) + ); + assert_eq!(actual, true); + } } diff --git a/crates/forge_main/src/editor.rs b/crates/forge_main/src/editor.rs index 22cf2ee54a..f7f5709d6f 100644 --- a/crates/forge_main/src/editor.rs +++ b/crates/forge_main/src/editor.rs @@ -61,6 +61,13 @@ impl ForgeEditor { ReedlineEvent::Edit(vec![EditCommand::InsertNewline]), ); + // on CTRL + V press inserts an image from clipboard + keybindings.add_binding( + KeyModifiers::CONTROL, + KeyCode::Char('v'), + ReedlineEvent::ExecuteHostCommand("!forge_internal_paste_image".to_string()), + ); + keybindings } @@ -100,10 +107,45 @@ impl ForgeEditor { } pub fn prompt(&mut self, prompt: &dyn Prompt) -> anyhow::Result { - let signal = self.editor.read_line(prompt); - signal - .map(Into::into) - .map_err(|e| anyhow::anyhow!(ReadLineError(e))) + loop { + let signal = self.editor.read_line(prompt); + let signal = signal.map_err(|e| anyhow::anyhow!(ReadLineError(e)))?; + + match signal { + Signal::Success(buffer) => { + if buffer == "!forge_internal_paste_image" { + let content = crate::image_paste::paste_clipboard(); + match content { + crate::image_paste::ClipboardContent::Images(img_paths) => { + if !img_paths.is_empty() { + let text = img_paths + .iter() + .map(|p| format!(" @[{}] ", p.display())) + .collect::>() + .join(""); + self.editor + .run_edit_commands(&[EditCommand::InsertString(text)]); + } + } + crate::image_paste::ClipboardContent::Text(text) => { + self.editor + .run_edit_commands(&[EditCommand::InsertString(text)]); + } + crate::image_paste::ClipboardContent::None => {} + } + continue; + } + let trimmed = buffer.trim(); + if trimmed.is_empty() { + return Ok(ReadResult::Empty); + } else { + return Ok(ReadResult::Success(trimmed.to_string())); + } + } + Signal::CtrlC => return Ok(ReadResult::Continue), + Signal::CtrlD => return Ok(ReadResult::Exit), + } + } } /// Sets the buffer content to be pre-filled on the next prompt @@ -116,20 +158,3 @@ impl ForgeEditor { #[derive(Debug, thiserror::Error)] #[error(transparent)] pub struct ReadLineError(std::io::Error); - -impl From for ReadResult { - fn from(signal: Signal) -> Self { - match signal { - Signal::Success(buffer) => { - let trimmed = buffer.trim(); - if trimmed.is_empty() { - ReadResult::Empty - } else { - ReadResult::Success(trimmed.to_string()) - } - } - Signal::CtrlC => ReadResult::Continue, - Signal::CtrlD => ReadResult::Exit, - } - } -} diff --git a/crates/forge_main/src/image_paste.rs b/crates/forge_main/src/image_paste.rs new file mode 100644 index 0000000000..f0ebd8ace5 --- /dev/null +++ b/crates/forge_main/src/image_paste.rs @@ -0,0 +1,279 @@ +use chrono::Utc; +use std::path::PathBuf; +use url::Url; + +fn get_images_dir() -> Option { + let base = dirs::data_local_dir() + .or_else(|| dirs::home_dir().map(|h| h.join(".local/share"))) + .unwrap_or_else(std::env::temp_dir); + let images_dir = base.join("forge").join("images"); + std::fs::create_dir_all(&images_dir).ok()?; + Some(images_dir) +} + +pub enum ClipboardContent { + Images(Vec), + Text(String), + None, +} + +fn get_image_extension(data: &[u8]) -> Option<&'static str> { + if data.starts_with(b"\x89PNG\r\n\x1a\n") { + Some("png") + } else if data.starts_with(b"\xff\xd8\xff") { + Some("jpg") + } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") { + Some("gif") + } else if data.starts_with(b"RIFF") && data.len() >= 12 && &data[8..12] == b"WEBP" { + Some("webp") + } else if data.starts_with(b"BM") { + Some("bmp") + } else { + None + } +} + +fn has_image_mimetype() -> bool { + if let Ok(output) = std::process::Command::new("wl-paste") + .arg("--list-types") + .output() + { + if output.status.success() { + return String::from_utf8_lossy(&output.stdout).contains("image/"); + } + } + if let Ok(output) = std::process::Command::new("xclip") + .args(&["-selection", "clipboard", "-t", "TARGETS", "-o"]) + .output() + { + if output.status.success() { + return String::from_utf8_lossy(&output.stdout).contains("image/"); + } + } + // If tools fail (e.g. on macOS/Windows), we assume true to allow arboard to try + true +} + +fn strip_quotes(line: &str) -> &str { + if line.len() >= 2 + && ((line.starts_with('"') && line.ends_with('"')) + || (line.starts_with('\'') && line.ends_with('\''))) + { + &line[1..line.len() - 1] + } else { + line + } +} + +fn extract_image_paths(text: &str) -> Vec { + let mut paths = Vec::new(); + for line in text.lines() { + let line = strip_quotes(line.trim()); + + if line.starts_with("file://") { + if let Ok(url) = Url::parse(line) { + if let Ok(p) = url.to_file_path() { + paths.push(p); + } + } + } else if line.starts_with('/') { + let p = PathBuf::from(line); + if p.exists() && p.is_file() { + paths.push(p); + } + } + } + + let mut image_paths = Vec::new(); + for p in paths { + if let Some(ext) = p.extension() { + let ext_str = ext.to_string_lossy().to_lowercase(); + if matches!( + ext_str.as_str(), + "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" + ) { + image_paths.push(p); + } + } + } + image_paths +} + +pub fn paste_clipboard() -> ClipboardContent { + let images_dir = match get_images_dir() { + Some(d) => d, + None => { + tracing::error!("Failed to create images directory."); + return ClipboardContent::None; + } + }; + + let has_img = has_image_mimetype(); + + if has_img { + let image_types = [ + "image/png", + "image/jpeg", + "image/webp", + "image/gif", + "image/bmp", + ]; + + for img_type in image_types { + if let Ok(output) = std::process::Command::new("wl-paste") + .args(&["-t", img_type]) + .output() + { + if output.status.success() && !output.stdout.is_empty() { + if let Some(ext) = get_image_extension(&output.stdout) { + let filename = + format!("forge_paste_{}.{}", Utc::now().timestamp_millis(), ext); + let path = images_dir.join(&filename); + if std::fs::write(&path, &output.stdout).is_ok() { + tracing::info!("Successfully pasted image from clipboard (wl-paste)"); + return ClipboardContent::Images(vec![path]); + } + } + } + } + } + + for img_type in image_types { + if let Ok(output) = std::process::Command::new("xclip") + .args(&["-selection", "clipboard", "-t", img_type, "-o"]) + .output() + { + if output.status.success() && !output.stdout.is_empty() { + if let Some(ext) = get_image_extension(&output.stdout) { + let filename = + format!("forge_paste_{}.{}", Utc::now().timestamp_millis(), ext); + let path = images_dir.join(&filename); + if std::fs::write(&path, &output.stdout).is_ok() { + tracing::info!("Successfully pasted image from clipboard (xclip)"); + return ClipboardContent::Images(vec![path]); + } + } + } + } + } + } + + if let Ok(mut clipboard) = arboard::Clipboard::new() { + if has_img { + if let Ok(image_data) = clipboard.get_image() { + let width = image_data.width as u32; + let height = image_data.height as u32; + if let Some(img) = image::ImageBuffer::, _>::from_raw( + width, + height, + image_data.bytes.into_owned(), + ) { + let filename = format!("forge_paste_{}.png", Utc::now().timestamp_millis()); + let path = images_dir.join(&filename); + if img.save(&path).is_ok() { + tracing::info!( + "Successfully pasted image ({}x{}) from clipboard", + width, height + ); + return ClipboardContent::Images(vec![path]); + } + } + } + } + + if let Ok(text) = clipboard.get_text() { + let image_paths = extract_image_paths(&text); + if !image_paths.is_empty() { + tracing::info!( + "Successfully pasted {} image file path(s) from clipboard", + image_paths.len() + ); + return ClipboardContent::Images(image_paths); + } + return ClipboardContent::Text(text); + } else { + tracing::info!("Clipboard does not contain an image or valid image paths."); + } + } else { + if let Ok(output) = std::process::Command::new("wl-paste").output() { + if output.status.success() && !output.stdout.is_empty() { + if let Ok(text) = String::from_utf8(output.stdout) { + return ClipboardContent::Text(text); + } + } + } + if let Ok(output) = std::process::Command::new("xclip") + .args(&["-selection", "clipboard", "-o"]) + .output() + { + if output.status.success() && !output.stdout.is_empty() { + if let Ok(text) = String::from_utf8(output.stdout) { + return ClipboardContent::Text(text); + } + } + } + tracing::error!("Could not connect to system clipboard."); + } + + ClipboardContent::None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_image_extension() { + assert_eq!(get_image_extension(b"\x89PNG\r\n\x1a\n..."), Some("png")); + assert_eq!(get_image_extension(b"\xff\xd8\xff\xdb..."), Some("jpg")); + assert_eq!(get_image_extension(b"GIF87a..."), Some("gif")); + assert_eq!(get_image_extension(b"GIF89a..."), Some("gif")); + assert_eq!( + get_image_extension(b"RIFF\x00\x00\x00\x00WEBP..."), + Some("webp") + ); + assert_eq!(get_image_extension(b"BM..."), Some("bmp")); + assert_eq!(get_image_extension(b"unknown data"), None); + assert_eq!(get_image_extension(b""), None); + } + + #[test] + fn test_strip_quotes() { + assert_eq!(strip_quotes(""), ""); + assert_eq!(strip_quotes("\""), "\""); + assert_eq!(strip_quotes("'"), "'"); + assert_eq!(strip_quotes("\"a\""), "a"); + assert_eq!(strip_quotes("'b'"), "b"); + assert_eq!(strip_quotes("\"abc\""), "abc"); + assert_eq!(strip_quotes("'xyz'"), "xyz"); + assert_eq!(strip_quotes("\"unterminated"), "\"unterminated"); + assert_eq!(strip_quotes("unquoted"), "unquoted"); + } + + #[test] + fn test_extract_image_paths() { + let temp_dir = std::env::temp_dir(); + let test_png = temp_dir.join("test_image.png"); + std::fs::write(&test_png, b"").unwrap(); + + let test_txt = temp_dir.join("test_file.txt"); + std::fs::write(&test_txt, b"").unwrap(); + + let input1 = format!("\"{}\"", test_png.display()); + let paths1 = extract_image_paths(&input1); + assert_eq!(paths1.len(), 1); + assert_eq!(paths1[0], test_png); + + let input2 = format!("file://{}", test_png.display()); + let paths2 = extract_image_paths(&input2); + assert_eq!(paths2.len(), 1); + assert_eq!(paths2[0], test_png); + + let input3 = format!("\"{}\"", test_txt.display()); + let paths3 = extract_image_paths(&input3); + assert_eq!(paths3.len(), 0); + + let _ = std::fs::remove_file(test_png); + let _ = std::fs::remove_file(test_txt); + } +} diff --git a/crates/forge_main/src/info.rs b/crates/forge_main/src/info.rs index b0815a8799..074e8e9711 100644 --- a/crates/forge_main/src/info.rs +++ b/crates/forge_main/src/info.rs @@ -75,7 +75,7 @@ impl Section { /// # Output Format /// /// ```text -/// +/// /// CONFIGURATION /// model gpt-4 /// provider openai diff --git a/crates/forge_main/src/lib.rs b/crates/forge_main/src/lib.rs index 1fc22a116d..91d0275ffb 100644 --- a/crates/forge_main/src/lib.rs +++ b/crates/forge_main/src/lib.rs @@ -1,9 +1,11 @@ + pub mod banner; mod cli; mod completer; mod conversation_selector; mod display_constants; mod editor; +mod image_paste; mod info; mod input; mod model; @@ -33,3 +35,4 @@ pub use ui::UI; pub static TRACKER: LazyLock = LazyLock::new(forge_tracker::Tracker::default); +pub mod ui_event_handler; diff --git a/crates/forge_main/src/main.rs b/crates/forge_main/src/main.rs index b5d4748100..05981d82b1 100644 --- a/crates/forge_main/src/main.rs +++ b/crates/forge_main/src/main.rs @@ -1,4 +1,6 @@ -use std::io::Read; + +use std::io::IsTerminal; +use tokio::io::AsyncReadExt; use std::panic; use std::path::PathBuf; @@ -48,10 +50,8 @@ fn enable_stdout_vt_processing() { #[tokio::main] async fn main() { if let Err(err) = run().await { - eprintln!("{}", TitleFormat::error(format!("{err}")).display()); - if let Some(cause) = err.chain().nth(1) { - eprintln!("{cause}"); - } + tracing::error!("{:?}", err); + std::process::exit(1); } } @@ -83,8 +83,10 @@ async fn run() -> Result<()> { "Unexpected error occurred".to_string() }; - println!("{}", TitleFormat::error(message.to_string()).display()); - tracker::error_blocking(message); + let location = panic_info.location().map_or("unknown location".to_string(), |l| format!("{}:{}", l.file(), l.line())); + let full_message = format!("Panic at {}: {}", location, message); + tracing::error!("{}", TitleFormat::error(full_message.clone()).display()); + tracker::error_blocking(full_message); std::process::exit(1); })); @@ -92,12 +94,11 @@ async fn run() -> Result<()> { let mut cli = Cli::parse(); // Check if there's piped input - if !atty::is(atty::Stream::Stdin) { + if !std::io::stdin().is_terminal() { let mut stdin_content = String::new(); - std::io::stdin().read_to_string(&mut stdin_content)?; - let trimmed_content = stdin_content.trim(); - if !trimmed_content.is_empty() { - cli.piped_input = Some(trimmed_content.to_string()); + tokio::io::stdin().read_to_string(&mut stdin_content).await?; + if !stdin_content.is_empty() { + cli.piped_input = Some(stdin_content); } } @@ -123,9 +124,9 @@ async fn run() -> Result<()> { (Some(sandbox), _) => Sandbox::new(sandbox).create()?, (_, Some(cli)) => match cli.canonicalize() { Ok(cwd) => cwd, - Err(_) => panic!("Invalid path: {}", cli.display()), + Err(e) => anyhow::bail!("Invalid path: {}: {}", cli.display(), e), }, - (_, _) => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + (_, _) => std::env::current_dir().context("Failed to determine current directory")?, }; let mut ui = UI::init(cli, config, move |config| { @@ -181,14 +182,15 @@ mod tests { } #[test] - fn test_commit_command_diff_field_initially_none() { + fn test_commit_command_diff_field_initially_none() -> anyhow::Result<()> { // Test that the diff field in CommitCommandGroup starts as None - let cli = Cli::parse_from(["forge", "commit", "--preview"]); + let cli = Cli::try_parse_from(["forge", "commit", "--preview"])?; if let Some(TopLevelCommand::Commit(commit_group)) = cli.subcommands { assert_eq!(commit_group.preview, true); assert_eq!(commit_group.diff, None); + Ok(()) } else { - panic!("Expected Commit command"); + anyhow::bail!("Expected Commit command"); } } } diff --git a/crates/forge_main/src/model.rs b/crates/forge_main/src/model.rs index f81e11bd95..96eb629ef8 100644 --- a/crates/forge_main/src/model.rs +++ b/crates/forge_main/src/model.rs @@ -284,6 +284,7 @@ impl ForgeCommandManager { Ok(SlashCommand::Commit { max_diff_size }) } "/index" => Ok(SlashCommand::Index), + "/paste" => Ok(SlashCommand::Paste), "/rename" | "/rn" => { let name = parameters.join(" "); let name = name.trim().to_string(); @@ -429,6 +430,10 @@ pub enum SlashCommand { #[strum(props(usage = "Rename the current conversation. Usage: /rename "))] Rename(String), + /// Paste image from clipboard + #[strum(props(usage = "Paste image from clipboard"))] + Paste, + /// Switch directly to a specific agent by ID #[strum(props(usage = "Switch directly to a specific agent"))] AgentSwitch(String), @@ -477,6 +482,7 @@ impl SlashCommand { SlashCommand::Rename(_) => "rename", SlashCommand::AgentSwitch(agent_id) => agent_id, SlashCommand::Index => "index", + SlashCommand::Paste => "paste", } } diff --git a/crates/forge_main/src/prompt.rs b/crates/forge_main/src/prompt.rs index 50efda24ff..86ff3161d3 100644 --- a/crates/forge_main/src/prompt.rs +++ b/crates/forge_main/src/prompt.rs @@ -24,6 +24,7 @@ pub struct ForgePrompt { pub usage: Option, pub agent_id: AgentId, pub model: Option, + pub title: Option, } impl Prompt for ForgePrompt { @@ -32,6 +33,7 @@ impl Prompt for ForgePrompt { let mode_style = Style::new().fg(Color::White).bold(); let folder_style = Style::new().fg(Color::Cyan); let branch_style = Style::new().fg(Color::LightGreen); + let title_style = Style::new().fg(Color::Yellow); // Get current directory let current_dir = self @@ -45,22 +47,39 @@ impl Prompt for ForgePrompt { let branch_opt = get_git_branch(); // Use a string buffer to reduce allocations - let mut result = String::with_capacity(64); // Pre-allocate a reasonable size + let mut result = String::with_capacity(128); // Pre-allocate a reasonable size // Build the string step-by-step write!( result, - "{} {}", + "{} ", mode_style.paint(self.agent_id.as_str().to_case(Case::UpperSnake)), - folder_style.paint(¤t_dir) ) .unwrap(); + if let Some(title) = &self.title { + let truncated_title = if title.chars().count() > 30 { + let mut t: String = title.chars().take(29).collect(); + t.push('…'); + t + } else { + title.clone() + }; + write!( + result, + "{} ", + title_style.paint(format!("[{}]", truncated_title)) + ) + .unwrap(); + } + + write!(result, "{}", folder_style.paint(¤t_dir)).unwrap(); + // Only append branch info if present if let Some(branch) = branch_opt && branch != current_dir { - write!(result, " {} ", branch_style.paint(branch)).unwrap(); + write!(result, " {}", branch_style.paint(branch)).unwrap(); } write!(result, "\n{} ", branch_style.paint(RIGHT_CHEVRON)).unwrap(); @@ -180,6 +199,7 @@ mod tests { usage: None, agent_id: AgentId::default(), model: None, + title: None, } } } @@ -303,6 +323,7 @@ mod tests { let mut prompt = ForgePrompt::default(); let _ = prompt.usage(usage); let _ = prompt.model(ModelId::new("anthropic/claude-3")); + let _ = prompt.title("Test Conversation".to_string()); let actual = prompt.render_prompt_right(); assert!(actual.contains("claude-3")); // Only the last part after splitting by '/' diff --git a/crates/forge_main/src/state.rs b/crates/forge_main/src/state.rs index 61513caf12..91d9facbfc 100644 --- a/crates/forge_main/src/state.rs +++ b/crates/forge_main/src/state.rs @@ -10,10 +10,11 @@ use forge_api::{ConversationId, Environment}; pub struct UIState { pub cwd: PathBuf, pub conversation_id: Option, + pub queued_messages: Vec, } impl UIState { pub fn new(env: Environment) -> Self { - Self { cwd: env.cwd, conversation_id: Default::default() } + Self { cwd: env.cwd, conversation_id: Default::default(), queued_messages: Vec::new() } } } diff --git a/crates/forge_main/src/stream_renderer.rs b/crates/forge_main/src/stream_renderer.rs index cc12cfa445..3b5d9884c7 100644 --- a/crates/forge_main/src/stream_renderer.rs +++ b/crates/forge_main/src/stream_renderer.rs @@ -62,6 +62,14 @@ impl SharedSpinner

{ .unwrap_or_else(|e| e.into_inner()) .ewrite_ln(message) } + + /// Sets the message on the spinner. + pub fn set_message(&self, message: &str) -> Result<()> { + self.0 + .lock() + .unwrap_or_else(|e| e.into_inner()) + .set_message(message) + } } /// Content styling for output. @@ -198,10 +206,13 @@ impl io::Write for StreamDirectWriter

{ fn write(&mut self, buf: &[u8]) -> io::Result { self.pause_spinner(); - let content = match std::str::from_utf8(buf) { + let mut content = match std::str::from_utf8(buf) { Ok(s) => s.to_string(), Err(_) => String::from_utf8_lossy(buf).into_owned(), }; + if crossterm::terminal::is_raw_mode_enabled().unwrap_or(false) { + content = content.replace("\r\n", "\n").replace('\n', "\r\n"); + } let styled = self.style.apply(content); self.printer.write(styled.as_bytes())?; self.printer.flush()?; diff --git a/crates/forge_main/src/test_queuing_bugs.rs b/crates/forge_main/src/test_queuing_bugs.rs new file mode 100644 index 0000000000..fb72116279 --- /dev/null +++ b/crates/forge_main/src/test_queuing_bugs.rs @@ -0,0 +1,26 @@ +#[cfg(test)] +mod tests { + use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers, KeyEventKind, KeyEventState}; + + #[test] + fn test_modifier_handling() { + let key_event = KeyEvent { + code: KeyCode::Char('w'), + modifiers: KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + state: KeyEventState::empty(), + }; + + let mut current_input = String::new(); + if let KeyCode::Char(c) = key_event.code { + // If modifiers aren't checked, this happens: + if !key_event.modifiers.is_empty() { + // This simulates the check that SHOULD exist + } + // What the code ACTUALLY does: + current_input.push(c); + } + + assert_eq!(current_input, "w"); + } +} diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index 5ba5de69e4..f19f92771b 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -45,6 +45,7 @@ use crate::stream_renderer::{SharedSpinner, StreamingWriter}; use crate::sync_display::SyncProgressDisplay; use crate::title_display::TitleDisplayExt; use crate::tools_display::format_tools; +use crate::ui_event_handler::{ChatEventAction, handle_chat_key_event}; use crate::update::on_update; use crate::utils::humanize_time; use crate::zsh::ZshRPrompt; @@ -113,6 +114,13 @@ pub struct UI A> { _guard: forge_tracker::Guard, } +struct RawModeGuard; +impl Drop for RawModeGuard { + fn drop(&mut self) { + let _ = crossterm::terminal::disable_raw_mode(); + } +} + impl A + Send + Sync> UI { /// Writes a line to the console output /// Takes anything that implements ToString trait @@ -239,23 +247,36 @@ impl A + Send + Sync> UI async fn prompt(&self) -> Result { // Get usage from current conversation if available + let mut title = None; let usage = if let Some(conversation_id) = &self.state.conversation_id { - self.api - .conversation(conversation_id) - .await - .ok() - .flatten() - .and_then(|conv| conv.accumulated_usage()) + if let Some(conv) = self.api.conversation(conversation_id).await.ok().flatten() { + title = conv.title.clone(); + conv.accumulated_usage() + } else { + None + } } else { None }; + // Update the terminal window title + let window_title = if let Some(ref t) = title { + format!("Forge: {}", t) + } else { + "Forge".to_string() + }; + let _ = crossterm::execute!( + std::io::stdout(), + crossterm::terminal::SetTitle(window_title) + ); + // Prompt the user for input let agent_id = self.api.get_active_agent().await.unwrap_or_default(); let model = self .get_agent_model(self.api.get_active_agent().await) .await; - let forge_prompt = ForgePrompt { cwd: self.state.cwd.clone(), usage, model, agent_id }; + let forge_prompt = + ForgePrompt { cwd: self.state.cwd.clone(), usage, model, agent_id, title }; self.console.prompt(forge_prompt).await } @@ -327,6 +348,10 @@ impl A + Send + Sync> UI _ = tokio::signal::ctrl_c() => { self.spinner.reset(); tracing::info!("User interrupted operation with Ctrl+C"); + if !self.state.queued_messages.is_empty() { + self.console.set_buffer(self.state.queued_messages.join("\n")); + self.state.queued_messages.clear(); + } } result = self.on_command(command) => { match result { @@ -340,6 +365,10 @@ impl A + Send + Sync> UI tracing::error!(error = ?error); self.spinner.stop(None)?; self.writeln_to_stderr(TitleFormat::error(format!("{error:?}")).display().to_string())?; + if !self.state.queued_messages.is_empty() { + self.console.set_buffer(self.state.queued_messages.join("\n")); + self.state.queued_messages.clear(); + } }, } } @@ -363,7 +392,13 @@ impl A + Send + Sync> UI } } // Centralized prompt call at the end of the loop - command = self.prompt().await; + if !self.state.queued_messages.is_empty() { + let msg = self.state.queued_messages.remove(0); + tracker::prompt(msg.clone()); + command = self.command.parse(&msg); + } else { + command = self.prompt().await; + } } } @@ -686,6 +721,20 @@ impl A + Send + Sync> UI } return Ok(()); } + TopLevelCommand::Cache(crate::cli::CacheCommand::Clear) => { + let env = self.api.environment(); + let cache_dir = env.cache_dir(); + if cache_dir.exists() { + tokio::fs::remove_dir_all(&cache_dir).await?; + self.writeln_title(TitleFormat::info(format!( + "Cache cleared: {}", + cache_dir.display() + )))?; + } else { + self.writeln_title(TitleFormat::info("Cache is already clear"))?; + } + return Ok(()); + } TopLevelCommand::Update(args) => { let update = forge_config::Update::default().auto_update(args.no_confirm); on_update(self.api.clone(), Some(&update)).await; @@ -1301,8 +1350,6 @@ impl A + Send + Sync> UI let mut info = Info::new(); // Load built-in commands from JSON - // NOTE: When adding a new command, update built_in_commands.json AND - // shell-plugin/forge.plugin.zsh (case statement around line 745) const COMMANDS_JSON: &str = include_str!("built_in_commands.json"); #[derive(serde::Deserialize)] @@ -1917,6 +1964,25 @@ impl A + Send + Sync> UI self.spinner.start(Some("Compacting"))?; self.on_compaction().await?; } + SlashCommand::Paste => { + let content = crate::image_paste::paste_clipboard(); + match content { + crate::image_paste::ClipboardContent::Images(img_paths) => { + if !img_paths.is_empty() { + let text = img_paths + .iter() + .map(|p| format!(" @[{}] ", p.display())) + .collect::>() + .join(""); + self.console.set_buffer(text); + } + } + crate::image_paste::ClipboardContent::Text(text) => { + self.console.set_buffer(text); + } + crate::image_paste::ClipboardContent::None => {} + } + } SlashCommand::Delete => { self.handle_delete_conversation().await?; } @@ -3172,15 +3238,76 @@ impl A + Send + Sync> UI // Always use streaming content writer let mut writer = StreamingWriter::new(self.spinner.clone(), self.api.clone()); + let mut events = crossterm::event::EventStream::new(); + let mut current_input = crate::ui_event_handler::BoundedString::<16384>::new(); - while let Some(message) = stream.next().await { - match message { - Ok(message) => self.handle_chat_response(message, &mut writer).await?, - Err(err) => { - writer.finish()?; - self.spinner.stop(None)?; - self.spinner.reset(); - return Err(err); + let _raw_mode_guard = { + let _ = crossterm::terminal::enable_raw_mode(); + RawModeGuard + }; + + loop { + tokio::select! { + message_opt = stream.next() => { + match message_opt { + Some(Ok(message)) => { + let res = self.handle_chat_response(message, &mut writer).await; + if let Err(err) = res { + writer.finish()?; + self.spinner.stop(None)?; + self.spinner.reset(); + return Err(err); + } + } + Some(Err(err)) => { + writer.finish()?; + self.spinner.stop(None)?; + self.spinner.reset(); + return Err(err); + } + None => { + break; + } + } + } + event_opt = events.next() => { + match event_opt { + Some(Ok(crossterm::event::Event::Key(key_event))) => { + match handle_chat_key_event(key_event, &mut current_input) { + ChatEventAction::Continue => {} + ChatEventAction::Interrupt => { + writer.finish()?; + self.spinner.stop(None)?; + self.spinner.reset(); + tracing::info!("User interrupted operation with Ctrl+C"); + return Err(anyhow::anyhow!("Interrupted by user")); + } + ChatEventAction::QueueMessage(crate::ui_event_handler::MessagePayload(msg)) => { + self.state.queued_messages.push(msg.clone()); + let formatted_msg = format!("{} {}", "⏳ Queued:".dimmed(), msg.dimmed()); + self.spinner.write_ln(formatted_msg)?; + let _ = self.spinner.set_message(""); + } + ChatEventAction::UpdateInput => { + let formatted_msg = if current_input.is_empty() { + String::new() + } else { + format!("{} {}", "Queuing:".dimmed(), current_input.as_str()) + }; + let _ = self.spinner.set_message(&formatted_msg); + } + } + } + Some(Err(err)) => { + // Fix Vulnerability 2: Prevent 100% CPU infinite loop on stdin error + tracing::error!(error = ?err, "Terminal event stream error"); + writer.finish()?; + self.spinner.stop(None)?; + self.spinner.reset(); + return Err(err.into()); + } + _ => {} + } } } } diff --git a/crates/forge_main/src/ui_event_handler.rs b/crates/forge_main/src/ui_event_handler.rs new file mode 100644 index 0000000000..fe6f33e09c --- /dev/null +++ b/crates/forge_main/src/ui_event_handler.rs @@ -0,0 +1,166 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, KeyEventKind}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MessagePayload(pub String); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BoundedString(String); + +impl Default for BoundedString { + fn default() -> Self { + Self::new() + } +} + +impl BoundedString { + pub fn new() -> Self { + Self(String::new()) + } + + pub fn push(&mut self, c: char) { + if self.0.len() + c.len_utf8() <= MAX { + self.0.push(c); + } + } + + pub fn pop(&mut self) { + self.0.pop(); + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_string(self) -> String { + self.0 + } + + pub fn len(&self) -> usize { + self.0.len() + } +} + +pub enum ChatEventAction { + Continue, + Interrupt, + QueueMessage(MessagePayload), + UpdateInput, +} + +pub fn handle_chat_key_event(key_event: KeyEvent, current_input: &mut BoundedString<16384>) -> ChatEventAction { + if key_event.kind == KeyEventKind::Release { + return ChatEventAction::Continue; + } + + if key_event.code == KeyCode::Char('c') && key_event.modifiers.contains(KeyModifiers::CONTROL) { + return ChatEventAction::Interrupt; + } else if key_event.code == KeyCode::Enter { + if key_event.modifiers.contains(KeyModifiers::ALT) { + current_input.push('\n'); + return ChatEventAction::UpdateInput; + } else if !current_input.is_empty() { + let msg = std::mem::take(current_input).into_string(); + return ChatEventAction::QueueMessage(MessagePayload(msg)); + } + } else if key_event.code == KeyCode::Backspace { + current_input.pop(); + return ChatEventAction::UpdateInput; + } else if let KeyCode::Char(c) = key_event.code { + let has_ctrl = key_event.modifiers.contains(KeyModifiers::CONTROL); + let has_alt = key_event.modifiers.contains(KeyModifiers::ALT); + let has_meta = key_event.modifiers.contains(KeyModifiers::META); + + if !has_ctrl && !has_alt && !has_meta { + current_input.push(c); + return ChatEventAction::UpdateInput; + } + } + + ChatEventAction::Continue +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::KeyEventState; + + #[test] + fn test_modifier_handling_ignores_control_characters() { + let mut input = BoundedString::new(); + let key_event = KeyEvent { + code: KeyCode::Char('w'), + modifiers: KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + state: KeyEventState::empty(), + }; + + let action = handle_chat_key_event(key_event, &mut input); + assert!(matches!(action, ChatEventAction::Continue)); + assert_eq!(input.as_str(), ""); + } + + #[test] + fn test_ctrl_c_interrupts() { + let mut input = BoundedString::new(); + let key_event = KeyEvent { + code: KeyCode::Char('c'), + modifiers: KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + state: KeyEventState::empty(), + }; + + let action = handle_chat_key_event(key_event, &mut input); + assert!(matches!(action, ChatEventAction::Interrupt)); + } + + #[test] + fn test_enter_queues_message() { + let mut input = BoundedString::new(); + input.push('h'); + input.push('i'); + let key_event = KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::empty(), + kind: KeyEventKind::Press, + state: KeyEventState::empty(), + }; + + let action = handle_chat_key_event(key_event, &mut input); + match action { + ChatEventAction::QueueMessage(MessagePayload(msg)) => assert_eq!(msg, "hi"), + _ => panic!("Expected QueueMessage"), + } + assert_eq!(input.as_str(), ""); + } + + #[test] + fn test_alt_enter_inserts_newline() { + let mut input = BoundedString::new(); + let key_event = KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::ALT, + kind: KeyEventKind::Press, + state: KeyEventState::empty(), + }; + + let action = handle_chat_key_event(key_event, &mut input); + assert!(matches!(action, ChatEventAction::UpdateInput)); + assert_eq!(input.as_str(), "\n"); + } + + #[test] + fn test_memory_growth_is_bounded_unicode() { + let mut input = BoundedString::<10>::new(); // Test with small bound + // '🦀' is 4 bytes + for _ in 0..5 { + input.push('🦀'); + } + // Should only fit 2 crabs (8 bytes). The 3rd would be 12 bytes (>10). + assert_eq!(input.len(), 8); + assert_eq!(input.as_str(), "🦀🦀"); + } +} diff --git a/crates/forge_markdown_stream/examples/test_renderer.rs b/crates/forge_markdown_stream/examples/test_renderer.rs new file mode 100644 index 0000000000..32c04b48c1 --- /dev/null +++ b/crates/forge_markdown_stream/examples/test_renderer.rs @@ -0,0 +1,24 @@ +use forge_markdown_stream::StreamdownRenderer; +use std::io; + +fn main() -> io::Result<()> { + // We want to test that a word split across multiple chunks is not split by spaces or newlines, + // unless it hits the terminal width. + let mut renderer = StreamdownRenderer::new(io::stdout(), 80); + + println!("Testing a word split into multiple chunks:"); + renderer.push("This ")?; + renderer.push("is ")?; + renderer.push("a ")?; + renderer.push("spl")?; + renderer.push("it ")?; + renderer.push("wo")?; + renderer.push("rd")?; + renderer.push(" testing ")?; + renderer.push("chunk")?; + renderer.push("ing.")?; + renderer.finish()?; + println!("\nDone."); + + Ok(()) +} diff --git a/crates/forge_markdown_stream/examples/test_renderer2.rs b/crates/forge_markdown_stream/examples/test_renderer2.rs new file mode 100644 index 0000000000..d45d3557cb --- /dev/null +++ b/crates/forge_markdown_stream/examples/test_renderer2.rs @@ -0,0 +1,25 @@ +use forge_markdown_stream::StreamdownRenderer; +use std::io; + +fn main() -> io::Result<()> { + // Terminal width 10 + let mut renderer = StreamdownRenderer::new(io::stdout(), 10); + + println!("Testing chunked word wrap:"); + // "1234567890" length is 10. + renderer.push("abc")?; + renderer.push("def")?; + renderer.push("ghij")?; // this makes "abcdefghij" which is 10 chars, fits perfectly + renderer.push(" klmn")?; // " klmn" -> "klmn" is 4 chars, goes to next line. + + // Testing another split word. + renderer.push("opq")?; + renderer.push("rstu")?; + renderer.push("vwx")?; + renderer.push("yz")?; + + renderer.finish()?; + println!("\nDone."); + + Ok(()) +} diff --git a/crates/forge_markdown_stream/examples/test_renderer3.rs b/crates/forge_markdown_stream/examples/test_renderer3.rs new file mode 100644 index 0000000000..8ada72229c --- /dev/null +++ b/crates/forge_markdown_stream/examples/test_renderer3.rs @@ -0,0 +1,16 @@ +use forge_markdown_stream::StreamdownRenderer; +use std::io; + +fn main() -> io::Result<()> { + // Terminal width 80 + let mut renderer = StreamdownRenderer::new(io::stdout(), 80); + + renderer.push(&"A".repeat(75))?; + renderer.push(" hello")?; + renderer.push("world")?; + + renderer.finish()?; + println!("\nDone."); + + Ok(()) +} diff --git a/crates/forge_markdown_stream/examples/test_renderer4.rs b/crates/forge_markdown_stream/examples/test_renderer4.rs new file mode 100644 index 0000000000..ab7e3c2ead --- /dev/null +++ b/crates/forge_markdown_stream/examples/test_renderer4.rs @@ -0,0 +1,18 @@ +use forge_markdown_stream::StreamdownRenderer; +use std::io; + +fn main() -> io::Result<()> { + let mut renderer = StreamdownRenderer::new(io::stdout(), 80); + + renderer.push(&"A".repeat(78))?; + renderer.push(" ")?; + renderer.push("h")?; + renderer.push("e")?; + renderer.push("l")?; + renderer.push("l")?; + renderer.push("o")?; + renderer.finish()?; + println!(); + + Ok(()) +} diff --git a/crates/forge_markdown_stream/examples/test_renderer5.rs b/crates/forge_markdown_stream/examples/test_renderer5.rs new file mode 100644 index 0000000000..e94e7f3c59 --- /dev/null +++ b/crates/forge_markdown_stream/examples/test_renderer5.rs @@ -0,0 +1,14 @@ +use forge_markdown_stream::StreamdownRenderer; +use std::io; + +fn main() -> io::Result<()> { + let mut renderer = StreamdownRenderer::new(io::stdout(), 80); + + renderer.push(&"A".repeat(100))?; + renderer.push(" ")?; + renderer.push("hello")?; + renderer.finish()?; + println!(); + + Ok(()) +} diff --git a/crates/forge_markdown_stream/examples/test_renderer6.rs b/crates/forge_markdown_stream/examples/test_renderer6.rs new file mode 100644 index 0000000000..dc80efee2b --- /dev/null +++ b/crates/forge_markdown_stream/examples/test_renderer6.rs @@ -0,0 +1,15 @@ +use forge_markdown_stream::StreamdownRenderer; + +fn main() -> std::io::Result<()> { + let mut renderer = StreamdownRenderer::new(std::io::stdout(), 80); + + renderer.push(&"A".repeat(80))?; + renderer.push("A")?; + renderer.push(" ")?; + renderer.push("hello")?; + renderer.finish()?; + + println!(""); // to ensure newline after + + Ok(()) +} diff --git a/crates/forge_markdown_stream/examples/test_renderer7.rs b/crates/forge_markdown_stream/examples/test_renderer7.rs new file mode 100644 index 0000000000..619d013367 --- /dev/null +++ b/crates/forge_markdown_stream/examples/test_renderer7.rs @@ -0,0 +1,18 @@ +use forge_markdown_stream::StreamdownRenderer; + +fn main() -> std::io::Result<()> { + let mut renderer = StreamdownRenderer::new(std::io::stdout(), 10); + + renderer.push("1234567")?; + renderer.push("8")?; + renderer.push("9")?; + renderer.push("0 ")?; + renderer.push("раз")?; + renderer.push("дел")?; + renderer.push("ены ")?; + renderer.finish()?; + + println!(""); // to ensure newline after + + Ok(()) +} diff --git a/crates/forge_markdown_stream/examples/test_renderer8.rs b/crates/forge_markdown_stream/examples/test_renderer8.rs new file mode 100644 index 0000000000..9a8fb8dfce --- /dev/null +++ b/crates/forge_markdown_stream/examples/test_renderer8.rs @@ -0,0 +1,28 @@ +use forge_markdown_stream::{Renderer, TerminalWidth}; +use streamdown_parser::ParseEvent; + +fn main() -> std::io::Result<()> { + let mut renderer = Renderer::new(std::io::stdout(), TerminalWidth(30)); + + let chunks = vec![ + "Как ", + "видишь, ", + "перен", + "ос ", + "слов ", + "не ", + "работа", + "ет, ", + "слова ", + "раз", + "дел", + "ены", + ]; + + for chunk in chunks { + renderer.render_event(&ParseEvent::Text(chunk.to_string()))?; + } + renderer.finish()?; + println!(); + Ok(()) +} diff --git a/crates/forge_markdown_stream/examples/test_stream.rs b/crates/forge_markdown_stream/examples/test_stream.rs new file mode 100644 index 0000000000..443aa6d6c2 --- /dev/null +++ b/crates/forge_markdown_stream/examples/test_stream.rs @@ -0,0 +1,46 @@ +use forge_markdown_stream::StreamdownRenderer; +use std::io; + +fn main() -> io::Result<()> { + // We want to capture the output to a string using a Vec. + let mut buffer = Vec::new(); + let mut renderer = StreamdownRenderer::new(&mut buffer, 40); + + // Push a very long Russian paragraph with no newlines (e.g., 100 words). + let text = "Это очень длинный абзац на русском языке, который содержит огромное количество слов без каких-либо переносов строк. Мы создаем этот текст специально для того, чтобы протестировать, как работает перенос слов и строк в нашем рендерере маркдауна. Важно убедиться, что при ограничении ширины терминала в сорок символов текст будет корректно разбит на строки. В противном случае чтение длинных текстов в консоли станет невозможным или крайне неудобным, так как слова будут обрезаться на полуслове, либо строки будут выходить далеко за пределы экрана, нарушая верстку и дизайн интерфейса командной строки. Надеемся, что алгоритм правильно обрабатывает пробелы, пунктуацию и длину слов кириллицы."; + + // We can push it all at once or in chunks + renderer.push(text)?; + renderer.finish()?; + + let output = String::from_utf8_lossy(&buffer).into_owned(); + + // Strip ANSI codes if they are applied (optional, to verify raw length) + let output_stripped = strip_ansi_escapes::strip(&output); + let raw_text = String::from_utf8_lossy(&output_stripped); + + println!("--- RENDERED OUTPUT ---"); + println!("{}", output); + println!("--- END RENDERED OUTPUT ---"); + + let mut all_lines_valid = true; + for (i, line) in raw_text.lines().enumerate() { + let char_count = line.chars().count(); + if char_count > 40 { + println!("Line {} exceeds 40 characters: {} chars", i + 1, char_count); + println!("Line: {}", line); + all_lines_valid = false; + } else { + // Un-comment to see char counts + // println!("Line {}: {} chars", i + 1, char_count); + } + } + + if all_lines_valid { + println!("Success: All lines are properly word-wrapped at 40 characters or less."); + } else { + println!("Failure: Some lines exceeded 40 characters limit."); + } + + Ok(()) +} diff --git a/crates/forge_markdown_stream/examples/test_wrap3.rs b/crates/forge_markdown_stream/examples/test_wrap3.rs new file mode 100644 index 0000000000..eea85b3caa --- /dev/null +++ b/crates/forge_markdown_stream/examples/test_wrap3.rs @@ -0,0 +1,52 @@ +fn main() -> Result<(), Box> { + let s = "Это длинная русская строка, которая должна разбиваться по словам, чтобы влезть в ширину сорок букв!!"; + println!("Input length in chars: {}", s.chars().count()); + let width = 40; + + use forge_markdown_stream::Renderer; + use forge_markdown_stream::TerminalWidth; + use streamdown_parser::ParseEvent; + + let mut out = Vec::new(); + let mut renderer = Renderer::new(&mut out, TerminalWidth(width)); + + let event = ParseEvent::Text(s.to_string()); + renderer.render_event(&event)?; + renderer.finish()?; + + let result = String::from_utf8(out)?; + let stripped = strip_ansi_escapes::strip(&result); + let result = String::from_utf8(stripped)?; + println!("Output:\\n---\\n{}\\n---", result); + + let lines: Vec<&str> = result.split('\n').collect(); + + for (i, line) in lines.iter().enumerate() { + println!( + "Line {}: '{}' (len: {})", + i, + line, + unicode_width::UnicodeWidthStr::width(*line) + ); + } + + if !result.contains('\n') { + return Err("Output must contain newlines due to wrapping".into()); + } + + let original_words: Vec<&str> = s.split_whitespace().collect(); + let wrapped_words: Vec<&str> = result.split_whitespace().collect(); + if original_words != wrapped_words { + return Err("No words should be cut or mangled".into()); + } + + for line in &lines { + // Only strip the visual width + if unicode_width::UnicodeWidthStr::width(*line) > width { + return Err("Line exceeds max width".into()); + } + } + + println!("All checks passed!"); + Ok(()) +} diff --git a/crates/forge_markdown_stream/examples/word_wrap.rs b/crates/forge_markdown_stream/examples/word_wrap.rs new file mode 100644 index 0000000000..20da3a3f32 --- /dev/null +++ b/crates/forge_markdown_stream/examples/word_wrap.rs @@ -0,0 +1,16 @@ +use forge_markdown_stream::StreamdownRenderer; +use std::io; + +fn main() -> io::Result<()> { + // A small terminal width to force wrap, say 20 characters + let mut renderer = StreamdownRenderer::new(io::stdout(), 20); + + let text1 = "Это длинный русский текст, который должен корректно переноситься по словам и символам, чтобы проверить функцию переноса.\n\n"; + let text2 = "А_вот_здесь_СлишкомДлинноеРусскоеСловоКотороеНеПомещаетсяВРазмерТерминала\n"; + + renderer.push(text1)?; + renderer.push(text2)?; + + renderer.finish()?; + Ok(()) +} diff --git a/crates/forge_markdown_stream/src/bin/test_visible3.rs b/crates/forge_markdown_stream/src/bin/test_visible3.rs new file mode 100644 index 0000000000..dc71995e2a --- /dev/null +++ b/crates/forge_markdown_stream/src/bin/test_visible3.rs @@ -0,0 +1,8 @@ +use streamdown_ansi::utils::visible_length; + +fn main() { + let ru = "русское"; + let en = "hello"; + println!("русское: {}", visible_length(ru)); + println!("hello: {}", visible_length(en)); +} diff --git a/crates/forge_markdown_stream/src/bin/test_wrap4.rs b/crates/forge_markdown_stream/src/bin/test_wrap4.rs new file mode 100644 index 0000000000..53f9071140 --- /dev/null +++ b/crates/forge_markdown_stream/src/bin/test_wrap4.rs @@ -0,0 +1,33 @@ +use streamdown_render::text::text_wrap; + +fn main() { + // 100 character long string with NO spaces (100 chars) + let text_no_spaces = "ЭтоСловоДлинойРовноВСтоСимволовКотороеМыБудемТестироватьНаРазбиениеПоШиринеБезКакихЛибоПробеловЗдесь"; + // 100 character string WITH spaces + let text_spaces = "Эта строка содержит ровно сто символов, чтобы проверить, как работает перенос по словам или пробелам"; + + println!( + "text_no_spaces char count: {}", + text_no_spaces.chars().count() + ); + println!("text_spaces char count: {}", text_spaces.chars().count()); + + let width = 30; + println!("\n=== No Spaces (wrap_words=true) ==="); + let wrapped1 = text_wrap(text_no_spaces, width, 0, "", "", false, true); + for (i, line) in wrapped1.lines.iter().enumerate() { + println!("{}: |{}|", i, line); + } + + println!("\n=== No Spaces (wrap_words=false) ==="); + let wrapped3 = text_wrap(text_no_spaces, width, 0, "", "", false, false); + for (i, line) in wrapped3.lines.iter().enumerate() { + println!("{}: |{}|", i, line); + } + + println!("\n=== With Spaces (width={}) ===", width); + let wrapped2 = text_wrap(text_spaces, width, 0, "", "", false, true); + for (i, line) in wrapped2.lines.iter().enumerate() { + println!("{}: |{}|", i, line); + } +} diff --git a/crates/forge_markdown_stream/src/code.rs b/crates/forge_markdown_stream/src/code.rs index 41d5e66355..fc1a08c7e9 100644 --- a/crates/forge_markdown_stream/src/code.rs +++ b/crates/forge_markdown_stream/src/code.rs @@ -38,7 +38,11 @@ impl CodeHighlighter { ThemeMode::Dark => "base16-ocean.dark", ThemeMode::Light => "InspiredGitHub", }; - let theme = &self.theme_set.themes[theme_name]; + let theme = self + .theme_set + .themes + .get(theme_name) + .expect("Theme should exist"); let mut highlighter = HighlightLines::new(syntax, theme); match highlighter.highlight_line(line, &self.syntax_set) { @@ -69,7 +73,7 @@ impl CodeHighlighter { let line_indent = if i == 0 { "" } else { - &" ".repeat(indent.min(4) / 2 + 1) + &" ".repeat((indent.min(4) / 2).saturating_add(1)) }; result.push(format!("{}{}{}{}", margin, line_indent, highlighted, RESET)); diff --git a/crates/forge_markdown_stream/src/heading.rs b/crates/forge_markdown_stream/src/heading.rs index 10d9a77e57..9a81de5238 100644 --- a/crates/forge_markdown_stream/src/heading.rs +++ b/crates/forge_markdown_stream/src/heading.rs @@ -28,7 +28,7 @@ pub fn render_heading( // Adjust width to account for the prefix (e.g., "# " = 2 chars, "## " = 3 // chars, etc.) - let prefix_display_width = level as usize + 1; + let prefix_display_width = (level as usize).saturating_add(1); let content_width = width.saturating_sub(prefix_display_width); let lines = simple_wrap(&rendered_content, content_width); let mut result = Vec::new(); diff --git a/crates/forge_markdown_stream/src/lib.rs b/crates/forge_markdown_stream/src/lib.rs index 86af890e11..9e3f8f077b 100644 --- a/crates/forge_markdown_stream/src/lib.rs +++ b/crates/forge_markdown_stream/src/lib.rs @@ -1,3 +1,4 @@ + //! Forge Markdown Stream - Streaming markdown renderer for terminal output. //! //! This crate provides a streaming markdown renderer optimized for LLM output. @@ -36,7 +37,7 @@ mod utils; use std::io::{self, Write}; -pub use renderer::Renderer; +pub use renderer::{Renderer, TerminalWidth}; pub use repair::repair_line; pub use streamdown_parser::Parser; pub use theme::{Style, Theme}; @@ -59,7 +60,7 @@ impl StreamdownRenderer { pub fn new(writer: W, width: usize) -> Self { Self { parser: Parser::new(), - renderer: Renderer::new(writer, width), + renderer: Renderer::new(writer, renderer::TerminalWidth(width)), line_buffer: String::new(), } } @@ -68,7 +69,7 @@ impl StreamdownRenderer { pub fn with_theme(writer: W, width: usize, theme: Theme) -> Self { Self { parser: Parser::new(), - renderer: Renderer::with_theme(writer, width, theme), + renderer: Renderer::with_theme(writer, renderer::TerminalWidth(width), theme), line_buffer: String::new(), } } @@ -88,14 +89,18 @@ impl StreamdownRenderer { } } - self.line_buffer = self.line_buffer[pos + 1..].to_string(); + self.line_buffer = self + .line_buffer + .get(pos.saturating_add(1)..) + .unwrap_or("") + .to_string(); } Ok(()) } /// Finish rendering, flushing any remaining buffered content. /// Returns the underlying writer. - pub fn finish(mut self) -> io::Result<()> { + pub fn finish(mut self) -> io::Result { if !self.line_buffer.is_empty() { for repaired in repair_line(&self.line_buffer, self.parser.state()) { for event in self.parser.parse_line(&repaired) { @@ -106,6 +111,6 @@ impl StreamdownRenderer { for event in self.parser.finalize() { self.renderer.render_event(&event)?; } - Ok(()) + self.renderer.finish() } } diff --git a/crates/forge_markdown_stream/src/list.rs b/crates/forge_markdown_stream/src/list.rs index 273e13886b..118206ea22 100644 --- a/crates/forge_markdown_stream/src/list.rs +++ b/crates/forge_markdown_stream/src/list.rs @@ -67,7 +67,7 @@ impl ListState { pub fn next_number(&mut self) -> usize { if let Some(n) = self.numbers.last_mut() { - *n += 1; + *n = n.saturating_add(1); *n } else { 1 @@ -149,16 +149,28 @@ pub fn render_list_item( format!("{}.", num) } ListBullet::PlusExpand => "⊞".to_string(), - ListBullet::Dash => BULLETS_DASH[level % BULLETS_DASH.len()].to_string(), - ListBullet::Asterisk => BULLETS_ASTERISK[level % BULLETS_ASTERISK.len()].to_string(), - ListBullet::Plus => BULLETS_PLUS[level % BULLETS_PLUS.len()].to_string(), + ListBullet::Dash => BULLETS_DASH + .get(level.checked_rem(BULLETS_DASH.len()).unwrap_or(0)) + .unwrap_or(&"•") + .to_string(), + ListBullet::Asterisk => BULLETS_ASTERISK + .get(level.checked_rem(BULLETS_ASTERISK.len()).unwrap_or(0)) + .unwrap_or(&"∗") + .to_string(), + ListBullet::Plus => BULLETS_PLUS + .get(level.checked_rem(BULLETS_PLUS.len()).unwrap_or(0)) + .unwrap_or(&"⊕") + .to_string(), }; // Calculate indentation - let indent_spaces = indent * 2; + let indent_spaces = indent.saturating_mul(2); let marker_width = visible_length(&marker); let checkbox_width = if checkbox_prefix.is_empty() { 0 } else { 2 }; // checkbox + space - let content_indent = indent_spaces + marker_width + 1 + checkbox_width; + let content_indent = indent_spaces + .saturating_add(marker_width) + .saturating_add(1) + .saturating_add(checkbox_width); // Color the marker based on bullet type let colored_marker = match bullet { diff --git a/crates/forge_markdown_stream/src/renderer.rs b/crates/forge_markdown_stream/src/renderer.rs index 00404c1882..bb9d442935 100644 --- a/crates/forge_markdown_stream/src/renderer.rs +++ b/crates/forge_markdown_stream/src/renderer.rs @@ -2,55 +2,98 @@ use std::io::{self, Write}; +use streamdown_ansi::utils::visible_length; use streamdown_parser::ParseEvent; use streamdown_render::text::text_wrap; +#[derive(Default, Debug)] +struct WordBuffer { + content: String, + visible_width: usize, +} + +impl WordBuffer { + fn new() -> Self { + Self::default() + } + + fn push(&mut self, s: &str) { + self.content.push_str(s); + self.visible_width = self.visible_width.saturating_add(visible_length(s)); + } + + fn is_empty(&self) -> bool { + self.content.is_empty() + } + + fn clear(&mut self) { + self.content.clear(); + self.visible_width = 0; + } +} + use crate::code::CodeHighlighter; use crate::heading::render_heading; -use crate::inline::{render_inline_content, render_inline_elements}; +use crate::inline::render_inline_content; use crate::list::{ListState, render_list_item}; use crate::style::InlineStyler; use crate::table::render_table; use crate::theme::Theme; +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct TerminalWidth(pub usize); + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct ColumnIndex(pub usize); + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct BlockquoteDepth(pub usize); + +#[derive(Debug, Clone)] +pub struct CodeBlockState { + pub language: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct TableRow(pub Vec); + /// Main renderer for markdown events. pub struct Renderer { writer: W, - width: usize, + width: TerminalWidth, theme: Theme, // Code highlighting highlighter: CodeHighlighter, - current_language: Option, - code_buffer: String, + code_block_state: Option, // Table buffering - table_rows: Vec>, + table_rows: Vec, // Blockquote state - in_blockquote: bool, - blockquote_depth: usize, + blockquote_depth: BlockquoteDepth, // List state list_state: ListState, // Column tracking - column: usize, + column: ColumnIndex, + // Word wrapping + inline_buffer: WordBuffer, } impl Renderer { - pub fn new(writer: W, width: usize) -> Self { + pub fn new(writer: W, width: TerminalWidth) -> Self { Self::with_theme(writer, width, Theme::default()) } - pub fn with_theme(writer: W, width: usize, theme: Theme) -> Self { + pub fn with_theme(writer: W, width: TerminalWidth, theme: Theme) -> Self { Self { writer, width, theme, highlighter: CodeHighlighter::default(), - current_language: None, - code_buffer: String::new(), + code_block_state: None, table_rows: Vec::new(), - in_blockquote: false, - blockquote_depth: 0, + blockquote_depth: BlockquoteDepth(0), list_state: ListState::default(), - column: 0, + column: ColumnIndex(0), + inline_buffer: WordBuffer::new(), } } @@ -68,9 +111,9 @@ impl Renderer { /// Calculate the left margin based on blockquote depth. fn left_margin(&self) -> String { - if self.in_blockquote { + if self.blockquote_depth.0 > 0 { let border = self.theme.blockquote_border.apply("│").to_string(); - format!("{} ", border).repeat(self.blockquote_depth) + format!("{} ", border).repeat(self.blockquote_depth.0) } else { String::new() } @@ -78,12 +121,12 @@ impl Renderer { /// Calculate the current available width. fn current_width(&self) -> usize { - let margin_width = if self.in_blockquote { - self.blockquote_depth * 3 + let margin_width = if self.blockquote_depth.0 > 0 { + self.blockquote_depth.0.saturating_mul(3) } else { 0 }; - self.width.saturating_sub(margin_width) + self.width.0.saturating_sub(margin_width) } fn write(&mut self, s: &str) -> io::Result<()> { @@ -92,7 +135,7 @@ impl Renderer { fn writeln(&mut self, s: &str) -> io::Result<()> { writeln!(self.writer, "{}", s)?; - self.column = 0; + self.column = ColumnIndex(0); Ok(()) } @@ -100,15 +143,189 @@ impl Renderer { if self.table_rows.is_empty() { return Ok(()); } - let rows = std::mem::take(&mut self.table_rows); + let rows: Vec> = std::mem::take(&mut self.table_rows) + .into_iter() + .map(|r| r.0) + .collect(); let margin = self.left_margin(); - let lines = render_table(&rows, &margin, &self.theme, self.width); + let lines = render_table(&rows, &margin, &self.theme, self.width.0); for line in lines { self.writeln(&line)?; } Ok(()) } + fn flush_inline_buffer(&mut self) -> io::Result<()> { + if self.inline_buffer.is_empty() { + return Ok(()); + } + + let width = self.current_width(); + let margin = self.left_margin(); + + if self.column.0 > 0 + && self + .column + .0 + .saturating_add(self.inline_buffer.visible_width) + > width + { + self.writeln("")?; + } + + if self.column.0 == 0 && !margin.is_empty() { + self.write(&margin)?; + self.column = ColumnIndex(visible_length(&margin)); + } + + let content = std::mem::take(&mut self.inline_buffer.content); + self.write(&content)?; + self.column = ColumnIndex( + self.column + .0 + .saturating_add(self.inline_buffer.visible_width), + ); + self.inline_buffer.clear(); + + Ok(()) + } + + fn process_inline_chunk( + &mut self, + text: &str, + styler: impl Fn(&Theme, &str) -> String, + ) -> io::Result<()> { + let mut last_idx = 0; + for (idx, c) in text.char_indices() { + if c.is_whitespace() { + if idx > last_idx { + let styled = styler( + &self.theme, + text.get(last_idx..idx).ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "Invalid string slice index") + })?, + ); + self.inline_buffer.push(&styled); + } + self.flush_inline_buffer()?; + + let current_width = self.current_width().max(1); + + if c == '\n' { + self.writeln("")?; + let margin = self.left_margin(); + self.write(&margin)?; + self.column = ColumnIndex(visible_length(&margin)); + } else if self.column.0 >= current_width { + // Swallow the space, explicitly wrap + self.writeln("")?; + let margin = self.left_margin(); + self.write(&margin)?; + self.column = ColumnIndex(visible_length(&margin)); + } else { + let ws = text + .get(idx..idx.saturating_add(c.len_utf8())) + .ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "Invalid string slice index") + })?; + let styled_ws = styler(&self.theme, ws); + self.write(&styled_ws)?; + self.column = ColumnIndex(self.column.0.saturating_add(visible_length(ws))); + } + last_idx = idx.saturating_add(c.len_utf8()); + } else { + let current_width = self.current_width().max(1); + let char_len = visible_length(&c.to_string()); + let pending_str = text.get(last_idx..idx).ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "Invalid string slice index") + })?; + let pending_width = visible_length(pending_str); + + let new_word_width = self + .inline_buffer + .visible_width + .saturating_add(pending_width) + .saturating_add(char_len); + + if self.column.0.saturating_add(new_word_width) > current_width { + if self.column.0 > 0 && new_word_width <= current_width { + // The word exceeds the current line, but can fit on a new line. Wrap now. + self.writeln("")?; + let margin = self.left_margin(); + self.write(&margin)?; + self.column = ColumnIndex(visible_length(&margin)); + } else if new_word_width > current_width { + // The word cannot fit on a single line, we must split it here. + if pending_width > 0 { + let styled = styler(&self.theme, pending_str); + self.inline_buffer.push(&styled); + } + + self.flush_inline_buffer()?; + if self.column.0 > 0 { + self.writeln("")?; + let margin = self.left_margin(); + self.write(&margin)?; + self.column = ColumnIndex(visible_length(&margin)); + } + last_idx = idx; + } + } + } + } + if last_idx < text.len() { + let styled = styler( + &self.theme, + text.get(last_idx..).ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "Invalid string slice index") + })?, + ); + self.inline_buffer.push(&styled); + } + Ok(()) + } + + fn process_inline_elements( + &mut self, + elements: &[streamdown_parser::InlineElement], + ) -> io::Result<()> { + for element in elements { + match element { + streamdown_parser::InlineElement::Text(text) => { + self.process_inline_chunk(text, |theme, s| theme.text(s))?; + } + streamdown_parser::InlineElement::Bold(text) => { + self.process_inline_chunk(text, |theme, s| theme.bold(s))?; + } + streamdown_parser::InlineElement::Italic(text) => { + self.process_inline_chunk(text, |theme, s| theme.italic(s))?; + } + streamdown_parser::InlineElement::BoldItalic(text) => { + self.process_inline_chunk(text, |theme, s| theme.bold_italic(s))?; + } + streamdown_parser::InlineElement::Strikeout(text) => { + self.process_inline_chunk(text, |theme, s| theme.strikethrough(s))?; + } + streamdown_parser::InlineElement::Underline(text) => { + self.process_inline_chunk(text, |theme, s| theme.underline(s))?; + } + streamdown_parser::InlineElement::Code(text) => { + self.process_inline_chunk(text, |theme, s| theme.code(s))?; + } + streamdown_parser::InlineElement::Link { text, url } => { + self.process_inline_chunk(text, |theme, s| theme.link(s, url))?; + } + streamdown_parser::InlineElement::Image { alt, url } => { + self.process_inline_chunk(alt, |theme, s| theme.image(s, url))?; + } + streamdown_parser::InlineElement::Footnote(text) => { + self.process_inline_chunk(text, |theme, s| theme.footnote(s))?; + } + } + } + Ok(()) + } + /// Check if this event should reset a pending list. /// List continues only for ListItem, ListEnd, and EmptyLine/Newline events. fn should_reset_list(event: &ParseEvent) -> bool { @@ -128,52 +345,69 @@ impl Renderer { self.list_state.reset(); } + // --- Flush word before non-inline events --- + if !matches!( + event, + ParseEvent::Text(_) + | ParseEvent::InlineCode(_) + | ParseEvent::Bold(_) + | ParseEvent::Italic(_) + | ParseEvent::BoldItalic(_) + | ParseEvent::Underline(_) + | ParseEvent::Strikeout(_) + | ParseEvent::Link { .. } + | ParseEvent::Image { .. } + | ParseEvent::Footnote(_) + | ParseEvent::Prompt(_) + | ParseEvent::InlineElements(_) + ) { + self.flush_inline_buffer()?; + } + match event { // === Inline elements === ParseEvent::Text(text) => { - let styled = self.theme.text(text); - self.write(&styled)?; - self.column += styled.chars().count(); + self.process_inline_chunk(text, |theme, s| theme.text(s))?; } ParseEvent::InlineCode(code) => { - self.write(&self.theme.code(code))?; + self.process_inline_chunk(code, |theme, s| theme.code(s))?; } ParseEvent::Bold(text) => { - self.write(&self.theme.bold(text))?; + self.process_inline_chunk(text, |theme, s| theme.bold(s))?; } ParseEvent::Italic(text) => { - self.write(&self.theme.italic(text))?; + self.process_inline_chunk(text, |theme, s| theme.italic(s))?; } ParseEvent::BoldItalic(text) => { - self.write(&self.theme.bold_italic(text))?; + self.process_inline_chunk(text, |theme, s| theme.bold_italic(s))?; } ParseEvent::Underline(text) => { - self.write(&self.theme.underline(text))?; + self.process_inline_chunk(text, |theme, s| theme.underline(s))?; } ParseEvent::Strikeout(text) => { - self.write(&self.theme.strikethrough(text))?; + self.process_inline_chunk(text, |theme, s| theme.strikethrough(s))?; } ParseEvent::Link { text, url } => { - self.write(&self.theme.link(text, url))?; + self.process_inline_chunk(text, |theme, s| theme.link(s, url))?; } ParseEvent::Image { alt, url } => { - self.write(&self.theme.image(alt, url))?; + self.process_inline_chunk(alt, |theme, s| theme.image(s, url))?; } ParseEvent::Footnote(superscript) => { - self.write(&self.theme.footnote(superscript))?; + self.process_inline_chunk(superscript, |theme, s| theme.footnote(s))?; } ParseEvent::Prompt(prompt) => { - self.write(prompt)?; + self.process_inline_chunk(prompt, |_theme, s| s.to_string())?; } // === Block elements === @@ -187,21 +421,17 @@ impl Renderer { } ParseEvent::CodeBlockStart { language, .. } => { - self.current_language = language.clone(); - self.code_buffer.clear(); + self.code_block_state = Some(CodeBlockState { language: language.clone() }); } ParseEvent::CodeBlockLine(line) => { - if !self.code_buffer.is_empty() { - self.code_buffer.push('\n'); - } - self.code_buffer.push_str(line); - let margin = self.left_margin(); let width = self.current_width(); let rendered_lines = self.highlighter.render_code_line( line, - self.current_language.as_deref(), + self.code_block_state + .as_ref() + .and_then(|s| s.language.as_deref()), &margin, width, ); @@ -211,8 +441,7 @@ impl Renderer { } ParseEvent::CodeBlockEnd => { - self.current_language = None; - self.code_buffer.clear(); + self.code_block_state = None; } ParseEvent::ListItem { indent, bullet, content } => { @@ -238,7 +467,7 @@ impl Renderer { } ParseEvent::TableHeader(cols) | ParseEvent::TableRow(cols) => { - self.table_rows.push(cols.clone()); + self.table_rows.push(TableRow(cols.clone())); } ParseEvent::TableSeparator => {} @@ -248,8 +477,7 @@ impl Renderer { } ParseEvent::BlockquoteStart { depth } => { - self.in_blockquote = true; - self.blockquote_depth = *depth; + self.blockquote_depth = BlockquoteDepth(*depth); } ParseEvent::BlockquoteLine(text) => { @@ -257,7 +485,17 @@ impl Renderer { let width = self.current_width(); // Parse inline formatting (bold, italic, etc.) in blockquote content let rendered_content = render_inline_content(text, &self.theme); - let wrapped = text_wrap(&rendered_content, width, 0, &margin, &margin, false, true); + let is_list = false; + let is_blockquote = true; + let wrapped = text_wrap( + &rendered_content, + width, + 0, + &margin, + &margin, + is_list, + is_blockquote, + ); if wrapped.is_empty() { self.writeln(&margin)?; } else { @@ -268,14 +506,12 @@ impl Renderer { } ParseEvent::BlockquoteEnd => { - self.in_blockquote = false; - self.blockquote_depth = 0; + self.blockquote_depth = BlockquoteDepth(0); } ParseEvent::ThinkBlockStart => { self.writeln(&self.theme.think_border.apply("┌─ thinking ─").to_string())?; - self.in_blockquote = true; - self.blockquote_depth = 1; + self.blockquote_depth = BlockquoteDepth(1); } ParseEvent::ThinkBlockLine(text) => { @@ -285,8 +521,7 @@ impl Renderer { ParseEvent::ThinkBlockEnd => { self.writeln(&self.theme.think_border.apply("└").to_string())?; - self.in_blockquote = false; - self.blockquote_depth = 0; + self.blockquote_depth = BlockquoteDepth(0); } ParseEvent::HorizontalRule => { @@ -299,10 +534,16 @@ impl Renderer { self.writeln("")?; } ParseEvent::InlineElements(elements) => { - self.write(&render_inline_elements(elements, &self.theme))?; + self.process_inline_elements(elements)?; } } self.writer.flush() } + + /// Flush any remaining buffered output and return the underlying writer + pub fn finish(mut self) -> io::Result { + self.flush_inline_buffer()?; + Ok(self.writer) + } } diff --git a/crates/forge_markdown_stream/src/table.rs b/crates/forge_markdown_stream/src/table.rs index a80d0858f1..2acd9e3857 100644 --- a/crates/forge_markdown_stream/src/table.rs +++ b/crates/forge_markdown_stream/src/table.rs @@ -31,16 +31,22 @@ pub fn render_table( let mut w: Vec = vec![0; n]; for row in &rendered_rows { for (i, cell) in row.iter().enumerate() { - w[i] = w[i].max(visible_length(cell)); + if let Some(val) = w.get_mut(i) { + *val = (*val).max(visible_length(cell)); + } } } // Shrink columns if table exceeds max width - let overhead = margin.width() + 1 + 3 * n; + let overhead = margin + .width() + .saturating_add(1) + .saturating_add(3usize.saturating_mul(n)); let total: usize = w.iter().sum(); - if overhead + total > max_width && max_width > overhead { - let avail = max_width - overhead; - w.iter_mut().for_each(|x| *x = (*x * avail / total).max(5)); + if overhead.saturating_add(total) > max_width && max_width > overhead { + let avail = max_width.saturating_sub(overhead); + w.iter_mut() + .for_each(|x| *x = ((*x).saturating_mul(avail).checked_div(total).unwrap_or(0)).max(5)); } // Helper to create horizontal lines @@ -50,7 +56,7 @@ pub fn render_table( margin, styler.border(l), w.iter() - .map(|&x| styler.border(&"─".repeat(x + 2))) + .map(|&x| styler.border(&"─".repeat(x.saturating_add(2)))) .collect::>() .join(&styler.border(m)), styler.border(r) @@ -62,15 +68,29 @@ pub fn render_table( for (ri, row) in rendered_rows.iter().enumerate() { // Wrap each cell's content let wrapped: Vec> = (0..n) - .map(|i| wrap(row.get(i).map(|s| s.as_str()).unwrap_or(""), w[i])) + .map(|i| { + wrap( + row.get(i).map(|s| s.as_str()).unwrap_or(""), + *w.get(i).expect("valid col index"), + ) + }) .collect(); // Render each line of the wrapped cells for li in 0..wrapped.iter().map(|c| c.len()).max().unwrap_or(1) { let cells: String = (0..n) .map(|i| { - let c = wrapped[i].get(li).map(|s| s.as_str()).unwrap_or(""); - let p = " ".repeat(w[i].saturating_sub(visible_length(c))); + let c = wrapped + .get(i) + .expect("valid index") + .get(li) + .map(|s| s.as_str()) + .unwrap_or(""); + let p = " ".repeat( + w.get(i) + .expect("valid index") + .saturating_sub(visible_length(c)), + ); if ri == 0 && li == 0 && !c.is_empty() { format!(" {}{} ", styler.header(c), p) } else { @@ -89,7 +109,7 @@ pub fn render_table( } // Add row separator (except after last row) - if ri < rendered_rows.len() - 1 { + if ri < rendered_rows.len().saturating_sub(1) { out.push(hline("├", "┼", "┤")); } } @@ -108,9 +128,9 @@ fn wrap(text: &str, width: usize) -> Vec { let mut lines = Vec::new(); let mut line = String::new(); - let mut line_width = 0; + let mut line_width: usize = 0; let mut word = String::new(); - let mut word_width = 0; + let mut word_width: usize = 0; let mut esc = String::new(); let mut in_osc = false; let mut active_style: Option = None; @@ -119,25 +139,25 @@ fn wrap(text: &str, width: usize) -> Vec { let mut i = 0; while i < chars.len() { - let c = chars[i]; + let c = *chars.get(i).expect("valid index"); // Handle escape sequences if c == '\x1b' { esc.push(c); - i += 1; + i = i.saturating_add(1); // Check what type of sequence if i < chars.len() { - let next = chars[i]; + let next = *chars.get(i).expect("valid index"); esc.push(next); - i += 1; + i = i.saturating_add(1); if next == '[' { // CSI sequence - read until 'm' or other terminator while i < chars.len() { - let sc = chars[i]; + let sc = *chars.get(i).expect("valid index"); esc.push(sc); - i += 1; + i = i.saturating_add(1); if sc == 'm' || sc == 'K' || sc == 'H' || sc == 'J' { break; } @@ -156,9 +176,9 @@ fn wrap(text: &str, width: usize) -> Vec { // OSC sequence - read until \x1b\\ in_osc = true; while i < chars.len() { - let sc = chars[i]; + let sc = *chars.get(i).expect("valid index"); esc.push(sc); - i += 1; + i = i.saturating_add(1); if sc == '\\' && esc.len() >= 2 { let prev = esc.chars().rev().nth(1); if prev == Some('\x1b') { @@ -193,7 +213,7 @@ fn wrap(text: &str, width: usize) -> Vec { // Check if this is a word boundary (space) if c.is_whitespace() { // Try to add current word + space to line - if line_width + word_width + cw > width && line_width > 0 { + if line_width.saturating_add(word_width).saturating_add(cw) > width && line_width > 0 { // Doesn't fit, start new line if !line.is_empty() { line.push_str("\x1b[0m"); @@ -202,19 +222,19 @@ fn wrap(text: &str, width: usize) -> Vec { line = active_style.clone().unwrap_or_default(); line.push_str(&word); line.push(c); - line_width = word_width + cw; + line_width = word_width.saturating_add(cw); } else { // Fits on current line line.push_str(&word); line.push(c); - line_width += word_width + cw; + line_width = line_width.saturating_add(word_width).saturating_add(cw); } word.clear(); word_width = 0; } else { // Add character to current word word.push(c); - word_width += cw; + word_width = word_width.saturating_add(cw); // If word itself exceeds width, break it by character if word_width > width { @@ -239,12 +259,12 @@ fn wrap(text: &str, width: usize) -> Vec { word_width = visible_length(&word); } } - i += 1; + i = i.saturating_add(1); } // Add remaining word to line if !word.is_empty() { - if line_width + word_width > width && line_width > 0 { + if line_width.saturating_add(word_width) > width && line_width > 0 { if !line.is_empty() { line.push_str("\x1b[0m"); lines.push(line); @@ -270,7 +290,7 @@ fn wrap(text: &str, width: usize) -> Vec { /// Split a word at a given visible width, preserving ANSI escape sequences. fn split_word_at_width(word: &str, width: usize) -> (String, String) { let mut chunk = String::new(); - let mut chunk_w = 0; + let mut chunk_w: usize = 0; let mut remaining = String::new(); let mut in_chunk = true; let mut esc = String::new(); @@ -279,24 +299,24 @@ fn split_word_at_width(word: &str, width: usize) -> (String, String) { let mut i = 0; while i < chars.len() { - let c = chars[i]; + let c = *chars.get(i).expect("valid index"); // Handle escape sequences if c == '\x1b' { esc.push(c); - i += 1; + i = i.saturating_add(1); if i < chars.len() { - let next = chars[i]; + let next = *chars.get(i).expect("valid index"); esc.push(next); - i += 1; + i = i.saturating_add(1); if next == '[' { // CSI sequence while i < chars.len() { - let sc = chars[i]; + let sc = *chars.get(i).expect("valid index"); esc.push(sc); - i += 1; + i = i.saturating_add(1); if sc == 'm' || sc == 'K' || sc == 'H' || sc == 'J' { break; } @@ -304,11 +324,11 @@ fn split_word_at_width(word: &str, width: usize) -> (String, String) { } else if next == ']' { // OSC sequence - read until \x1b\\ or BEL while i < chars.len() { - let sc = chars[i]; + let sc = *chars.get(i).expect("valid index"); esc.push(sc); - i += 1; + i = i.saturating_add(1); if sc == '\\' && esc.len() >= 2 { - let prev_idx = esc.len() - 2; + let prev_idx = esc.len().saturating_sub(2); if esc.chars().nth(prev_idx) == Some('\x1b') { break; } @@ -332,9 +352,9 @@ fn split_word_at_width(word: &str, width: usize) -> (String, String) { if in_chunk { let cw = c.width().unwrap_or(0); - if chunk_w + cw <= width { + if chunk_w.saturating_add(cw) <= width { chunk.push(c); - chunk_w += cw; + chunk_w = chunk_w.saturating_add(cw); } else { remaining.push(c); in_chunk = false; @@ -342,7 +362,7 @@ fn split_word_at_width(word: &str, width: usize) -> (String, String) { } else { remaining.push(c); } - i += 1; + i = i.saturating_add(1); } (chunk, remaining) diff --git a/crates/forge_markdown_stream/tests/test_visible.rs b/crates/forge_markdown_stream/tests/test_visible.rs new file mode 100644 index 0000000000..9c91853a26 --- /dev/null +++ b/crates/forge_markdown_stream/tests/test_visible.rs @@ -0,0 +1,21 @@ +use streamdown_ansi::utils::visible_length; + +#[test] +fn test_visible_length_cyrillic() { + let word = "слово"; + let len = visible_length(word); + println!("visible_length('{}') = {}", word, len); + assert_eq!(len, 5); // 5 characters in "слово" +} + +#[test] +fn test_visible_length_with_ansi() { + let word = "\x1b[31mслово\x1b[0m"; + let len = visible_length(word); + println!("visible_length('{}') = {}", word, len); + assert_eq!(len, 5); // 5 visible characters in "слово" + + let complex_ansi_word = "\x1b[1;32mTest\x1b[0m\x1b[3m!\x1b[0m"; + let complex_len = visible_length(complex_ansi_word); + assert_eq!(complex_len, 5); // 5 visible characters in "Test!" +} diff --git a/crates/forge_markdown_stream/tests/word_wrap.rs b/crates/forge_markdown_stream/tests/word_wrap.rs new file mode 100644 index 0000000000..54d76e416d --- /dev/null +++ b/crates/forge_markdown_stream/tests/word_wrap.rs @@ -0,0 +1,27 @@ +use forge_markdown_stream::Renderer; +use forge_markdown_stream::TerminalWidth; +use std::error::Error; +use streamdown_parser::ParseEvent; + +#[test] +fn test_word_wrap() -> Result<(), Box> { + let mut output = Vec::new(); + let mut renderer = Renderer::new(&mut output, TerminalWidth(10)); + + // We send a few words to see if it wraps properly at width=10 + renderer.render_event(&ParseEvent::Text("hello ".to_string()))?; + renderer.render_event(&ParseEvent::Text("world ".to_string()))?; + renderer.render_event(&ParseEvent::Text("this ".to_string()))?; + renderer.render_event(&ParseEvent::Text("is ".to_string()))?; + renderer.render_event(&ParseEvent::Text("a ".to_string()))?; + renderer.render_event(&ParseEvent::Text("test".to_string()))?; + + // Send a newline to flush the buffer + renderer.render_event(&ParseEvent::Newline)?; + + let output_str = strip_ansi_escapes::strip_str(String::from_utf8(output)?); + + let expected = "hello \nworld this\nis a test\n"; + assert_eq!(output_str, expected); + Ok(()) +} diff --git a/crates/forge_repo/Cargo.toml b/crates/forge_repo/Cargo.toml index 0d115c0b50..67d46bbd80 100644 --- a/crates/forge_repo/Cargo.toml +++ b/crates/forge_repo/Cargo.toml @@ -5,6 +5,7 @@ edition.workspace = true rust-version.workspace = true [dependencies] +tokio-util.workspace = true forge_app.workspace = true forge_config.workspace = true forge_domain.workspace = true diff --git a/crates/forge_repo/src/lib.rs b/crates/forge_repo/src/lib.rs index d489072371..6b23fc11d0 100644 --- a/crates/forge_repo/src/lib.rs +++ b/crates/forge_repo/src/lib.rs @@ -1,3 +1,4 @@ + mod agent; mod agent_definition; mod context_engine; diff --git a/crates/forge_repo/src/provider/chat.rs b/crates/forge_repo/src/provider/chat.rs index b57690eccc..867a56a1cb 100644 --- a/crates/forge_repo/src/provider/chat.rs +++ b/crates/forge_repo/src/provider/chat.rs @@ -6,7 +6,7 @@ use forge_app::domain::{ use forge_app::{EnvironmentInfra, HttpInfra}; use forge_domain::{ChatRepository, Provider, ProviderId}; use forge_infra::CacacheStorage; -use tokio::task::AbortHandle; +use tokio_util::sync::CancellationToken; use url::Url; use crate::provider::anthropic::AnthropicResponseRepository; @@ -16,6 +16,12 @@ use crate::provider::openai::OpenAIResponseRepository; use crate::provider::openai_responses::OpenAIResponsesResponseRepository; use crate::provider::opencode_zen::OpenCodeZenResponseRepository; +#[derive(Debug, thiserror::Error)] +pub enum ProviderRouterError { + #[error("Provider response type not configured for provider: {0}")] + ResponseTypeNotConfigured(ProviderId), +} + /// Repository responsible for routing chat requests to the appropriate provider /// implementation based on the provider's response type. pub struct ForgeChatRepository { @@ -79,7 +85,7 @@ impl + HttpInfra + Sync> async fn models(&self, provider: Provider) -> anyhow::Result> { use forge_app::KVStore; - let cache_key = format!("models:{}", provider.id); + let cache_key = format!("models:{}:{}", provider.id, env!("CARGO_PKG_VERSION")); if let Ok(Some(cached)) = self .model_cache @@ -88,24 +94,32 @@ impl + HttpInfra + Sync> { tracing::debug!(provider_id = %provider.id, "returning cached models; refreshing in background"); - // Spawn a background task to refresh the disk cache. The abort - // handle is stored so the task is cancelled if the service is dropped. + // Spawn a background task to refresh the disk cache. The token + // ensures the task is cancelled if the service is dropped. let cache = self.model_cache.clone(); let router = self.router.clone(); let key = cache_key; - let handle = tokio::spawn(async move { - match router.models(provider).await { - Ok(models) => { - if let Err(err) = cache.cache_set(&key, &models).await { - tracing::warn!(error = %err, "background refresh: failed to cache model list"); - } - } - Err(err) => { - tracing::warn!(error = %err, "background refresh: failed to fetch models"); + let token = self.bg_refresh.token.clone(); + + tokio::spawn(async move { + tokio::select! { + _ = token.cancelled() => { + tracing::debug!("background refresh cancelled"); } + _ = async move { + match router.models(provider).await { + Ok(models) => { + if let Err(err) = cache.cache_set(&key, &models).await { + tracing::warn!(error = %err, "background refresh: failed to cache model list"); + } + } + Err(err) => { + tracing::warn!(error = %err, "background refresh: failed to fetch models"); + } + } + } => {} } }); - self.bg_refresh.register(handle.abort_handle()); return Ok(cached); } @@ -137,81 +151,51 @@ impl + Sync> context: Context, provider: Provider, ) -> ResultStream { - match provider.response { - Some(ProviderResponse::OpenAI) => { - // Check if model is a Codex model - if model_id.as_str().contains("gpt-5") - && (provider.id == ProviderId::OPENAI - || provider.id == ProviderId::GITHUB_COPILOT - || provider.id == ProviderId::CODEX) - { - self.codex_repo.chat(model_id, context, provider).await - } else if provider.id == ProviderId::CODEX { - // All Codex provider models use the Responses API - self.codex_repo.chat(model_id, context, provider).await - } else { - self.openai_repo.chat(model_id, context, provider).await - } - } - Some(ProviderResponse::OpenAIResponses) => { - self.codex_repo.chat(model_id, context, provider).await - } - Some(ProviderResponse::Anthropic) => { - self.anthropic_repo.chat(model_id, context, provider).await - } - Some(ProviderResponse::Bedrock) => { - self.bedrock_repo.chat(model_id, context, provider).await - } - Some(ProviderResponse::Google) => { - self.google_repo.chat(model_id, context, provider).await - } - Some(ProviderResponse::OpenCode) => { - self.opencode_zen_repo - .chat(model_id, context, provider) - .await - } - None => Err(anyhow::anyhow!( - "Provider response type not configured for provider: {}", - provider.id - )), + let response_type = provider.response.clone().ok_or_else(|| { + ProviderRouterError::ResponseTypeNotConfigured(provider.id.clone()) + })?; + + match response_type { + ProviderResponse::OpenAI => self.openai_repo.chat(model_id, context, provider).await, + ProviderResponse::OpenAIResponses => self.codex_repo.chat(model_id, context, provider).await, + ProviderResponse::Anthropic => self.anthropic_repo.chat(model_id, context, provider).await, + ProviderResponse::Bedrock => self.bedrock_repo.chat(model_id, context, provider).await, + ProviderResponse::Google => self.google_repo.chat(model_id, context, provider).await, + ProviderResponse::OpenCode => self.opencode_zen_repo.chat(model_id, context, provider).await, } } async fn models(&self, provider: Provider) -> anyhow::Result> { - match provider.response { - Some(ProviderResponse::OpenAI) => self.openai_repo.models(provider).await, - Some(ProviderResponse::OpenAIResponses) => self.codex_repo.models(provider).await, - Some(ProviderResponse::Anthropic) => self.anthropic_repo.models(provider).await, - Some(ProviderResponse::Bedrock) => self.bedrock_repo.models(provider).await, - Some(ProviderResponse::Google) => self.google_repo.models(provider).await, - Some(ProviderResponse::OpenCode) => self.opencode_zen_repo.models(provider).await, - None => Err(anyhow::anyhow!( - "Provider response type not configured for provider: {}", - provider.id - )), + let response_type = provider.response.clone().ok_or_else(|| { + ProviderRouterError::ResponseTypeNotConfigured(provider.id.clone()) + })?; + + match response_type { + ProviderResponse::OpenAI => self.openai_repo.models(provider).await, + ProviderResponse::OpenAIResponses => self.codex_repo.models(provider).await, + ProviderResponse::Anthropic => self.anthropic_repo.models(provider).await, + ProviderResponse::Bedrock => self.bedrock_repo.models(provider).await, + ProviderResponse::Google => self.google_repo.models(provider).await, + ProviderResponse::OpenCode => self.opencode_zen_repo.models(provider).await, } } } /// Tracks abort handles for background tasks and cancels them on drop. -#[derive(Default)] -struct BgRefresh(std::sync::Mutex>); - -impl BgRefresh { - /// Registers an abort handle to be cancelled when this guard is dropped. - fn register(&self, handle: AbortHandle) { - if let Ok(mut handles) = self.0.lock() { - handles.push(handle); +struct BgRefresh { + token: CancellationToken, +} + +impl Default for BgRefresh { + fn default() -> Self { + Self { + token: CancellationToken::new(), } } } impl Drop for BgRefresh { fn drop(&mut self) { - if let Ok(mut handles) = self.0.lock() { - for handle in handles.drain(..) { - handle.abort(); - } - } + self.token.cancel(); } } diff --git a/crates/forge_select/src/lib.rs b/crates/forge_select/src/lib.rs index 30024cdf02..1516d024b1 100644 --- a/crates/forge_select/src/lib.rs +++ b/crates/forge_select/src/lib.rs @@ -1,3 +1,4 @@ + mod confirm; mod input; mod multi; diff --git a/crates/forge_services/src/forge_services.rs b/crates/forge_services/src/forge_services.rs index 7ff1d1a2fb..b756e13f6f 100644 --- a/crates/forge_services/src/forge_services.rs +++ b/crates/forge_services/src/forge_services.rs @@ -337,6 +337,11 @@ impl< fn provider_service(&self) -> &Self::ProviderService { &self.chat_service } + + fn clear_cache(&self) -> impl std::future::Future> + Send { + let infra = self.infra.clone(); + async move { infra.cache_clear().await } + } } impl< diff --git a/crates/forge_services/src/lib.rs b/crates/forge_services/src/lib.rs index bb102e86c6..a98e021571 100644 --- a/crates/forge_services/src/lib.rs +++ b/crates/forge_services/src/lib.rs @@ -1,3 +1,4 @@ + mod agent_registry; mod app_config; mod attachment; diff --git a/crates/forge_snaps/src/lib.rs b/crates/forge_snaps/src/lib.rs index eebfa61391..efc0364eae 100644 --- a/crates/forge_snaps/src/lib.rs +++ b/crates/forge_snaps/src/lib.rs @@ -1,3 +1,4 @@ + // Export the modules mod service; diff --git a/crates/forge_spinner/Cargo.toml b/crates/forge_spinner/Cargo.toml index a1dc1f76a9..a4ed31bf88 100644 --- a/crates/forge_spinner/Cargo.toml +++ b/crates/forge_spinner/Cargo.toml @@ -11,6 +11,7 @@ tokio.workspace = true forge_domain.workspace = true indicatif = "0.18.4" rand = "0.10.0" +crossterm = "0.29.0" [dev-dependencies] pretty_assertions.workspace = true diff --git a/crates/forge_spinner/src/lib.rs b/crates/forge_spinner/src/lib.rs index 9a4aebfcaf..b286250044 100644 --- a/crates/forge_spinner/src/lib.rs +++ b/crates/forge_spinner/src/lib.rs @@ -1,3 +1,4 @@ + use std::sync::Arc; use std::time::Duration; @@ -87,10 +88,14 @@ impl SpinnerManager

{ let word = match message { Some(msg) => msg.to_string(), None => { - let idx = *self - .word_index - .get_or_insert_with(|| rand::rng().random_range(0..words.len())); - words[idx].to_string() + if let Some(existing) = &self.message { + existing.clone() + } else { + let idx = *self + .word_index + .get_or_insert_with(|| rand::rng().random_range(0..words.len())); + words[idx].to_string() + } } }; @@ -191,14 +196,32 @@ impl SpinnerManager

{ /// Prints a line to stdout through the printer. fn println(&self, msg: &str) { - let line = format!("{msg}\n"); + let msg = if crossterm::terminal::is_raw_mode_enabled().unwrap_or(false) { + msg.replace("\r\n", "\n").replace('\n', "\r\n") + } else { + msg.to_string() + }; + let line = if crossterm::terminal::is_raw_mode_enabled().unwrap_or(false) { + format!("{msg}\r\n") + } else { + format!("{msg}\n") + }; let _ = self.printer.write(line.as_bytes()); let _ = self.printer.flush(); } /// Prints a line to stderr through the printer. fn eprintln(&self, msg: &str) { - let line = format!("{msg}\n"); + let msg = if crossterm::terminal::is_raw_mode_enabled().unwrap_or(false) { + msg.replace("\r\n", "\n").replace('\n', "\r\n") + } else { + msg.to_string() + }; + let line = if crossterm::terminal::is_raw_mode_enabled().unwrap_or(false) { + format!("{msg}\r\n") + } else { + format!("{msg}\n") + }; let _ = self.printer.write_err(line.as_bytes()); let _ = self.printer.flush_err(); } diff --git a/crates/forge_stream/src/lib.rs b/crates/forge_stream/src/lib.rs index 20e45d18cf..c4a2aa4cfa 100644 --- a/crates/forge_stream/src/lib.rs +++ b/crates/forge_stream/src/lib.rs @@ -1,3 +1,4 @@ + mod mpsc_stream; pub use mpsc_stream::*; diff --git a/crates/forge_template/src/element.rs b/crates/forge_template/src/element.rs index cbf03e9f60..f6e1135016 100644 --- a/crates/forge_template/src/element.rs +++ b/crates/forge_template/src/element.rs @@ -13,7 +13,7 @@ impl Element { let parts: Vec<&str> = full_name.split('.').collect(); let mut element = Element { - name: parts[0].to_string(), + name: parts.first().unwrap_or(&"").to_string(), attr: vec![], children: vec![], text: None, @@ -21,7 +21,7 @@ impl Element { // Add classes if there are any if parts.len() > 1 { - let classes = parts[1..].join(" "); + let classes = parts.get(1..).unwrap_or_default().join(" "); element.attr.push(("class".to_string(), classes)); } @@ -56,9 +56,12 @@ impl Element { // Check if class attribute already exists if let Some(pos) = self.attr.iter().position(|(key, _)| key == "class") { // Append to existing class - let (_, current_class) = &self.attr[pos]; - let new_class = format!("{} {}", current_class, class_name.to_string()); - self.attr[pos] = ("class".to_string(), new_class); + if let Some((_, current_class)) = self.attr.get(pos) { + let new_class = format!("{} {}", current_class, class_name.to_string()); + if let Some(attr) = self.attr.get_mut(pos) { + *attr = ("class".to_string(), new_class); + } + } } else { // Add new class attribute self.attr diff --git a/crates/forge_template/src/lib.rs b/crates/forge_template/src/lib.rs index 611c13fc07..d89431621a 100644 --- a/crates/forge_template/src/lib.rs +++ b/crates/forge_template/src/lib.rs @@ -1,3 +1,4 @@ + mod element; pub use element::Element; diff --git a/crates/forge_test_kit/src/lib.rs b/crates/forge_test_kit/src/lib.rs index 0ca5728016..8f8d0b7ca9 100644 --- a/crates/forge_test_kit/src/lib.rs +++ b/crates/forge_test_kit/src/lib.rs @@ -1,3 +1,4 @@ + //! Test utilities and helpers for Forge tests //! //! This crate provides common utilities for testing, including fixture loading diff --git a/crates/forge_tool_macros/src/lib.rs b/crates/forge_tool_macros/src/lib.rs index a298bda33f..dec63359e0 100644 --- a/crates/forge_tool_macros/src/lib.rs +++ b/crates/forge_tool_macros/src/lib.rs @@ -1,3 +1,6 @@ +#![allow(clippy::panic, reason = "Macros can legitimately panic to abort compilation")] + + use proc_macro::TokenStream; use quote::{ToTokens, quote}; use syn::{DeriveInput, Expr, ExprLit, Lit, parse_macro_input}; diff --git a/crates/forge_tracker/src/lib.rs b/crates/forge_tracker/src/lib.rs index e78d2bef67..7dca82a4cd 100644 --- a/crates/forge_tracker/src/lib.rs +++ b/crates/forge_tracker/src/lib.rs @@ -1,3 +1,4 @@ + mod can_track; mod client_id; mod collect; diff --git a/crates/forge_walker/src/lib.rs b/crates/forge_walker/src/lib.rs index c803f2011b..ff09b11ffa 100644 --- a/crates/forge_walker/src/lib.rs +++ b/crates/forge_walker/src/lib.rs @@ -1,3 +1,4 @@ + mod walker; pub use walker::{File, Walker}; diff --git a/install.pid b/install.pid new file mode 100644 index 0000000000..0327492480 --- /dev/null +++ b/install.pid @@ -0,0 +1 @@ +3432344 diff --git a/out.bin b/out.bin new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test.txt b/test.txt new file mode 100644 index 0000000000..ce01362503 --- /dev/null +++ b/test.txt @@ -0,0 +1 @@ +hello diff --git a/test_arboard.rs b/test_arboard.rs new file mode 100644 index 0000000000..41ce20634c --- /dev/null +++ b/test_arboard.rs @@ -0,0 +1,9 @@ +use arboard::Clipboard; +fn main() { + let mut cb = Clipboard::new().unwrap(); + cb.set_text("hello").unwrap(); + match cb.get_image() { + Ok(img) => println!("Got image! {}x{}", img.width, img.height), + Err(e) => println!("Error getting image: {}", e), + } +} diff --git a/test_has_image b/test_has_image new file mode 100755 index 0000000000..f71321e1fe Binary files /dev/null and b/test_has_image differ diff --git a/test_has_image.rs b/test_has_image.rs new file mode 100644 index 0000000000..ef74b5066b --- /dev/null +++ b/test_has_image.rs @@ -0,0 +1,8 @@ +fn main() { + let mut has_image = false; + if let Ok(output) = std::process::Command::new("wl-paste").arg("--list-types").output() { + let types = String::from_utf8_lossy(&output.stdout); + has_image = types.contains("image/"); + } + println!("Has image: {}", has_image); +} diff --git a/test_magic b/test_magic new file mode 100755 index 0000000000..f049579a6b Binary files /dev/null and b/test_magic differ diff --git a/test_magic.rs b/test_magic.rs new file mode 100644 index 0000000000..062649c332 --- /dev/null +++ b/test_magic.rs @@ -0,0 +1,7 @@ +fn main() { + let output = std::process::Command::new("wl-paste").output().unwrap(); + println!("Length: {}", output.stdout.len()); + if output.stdout.len() > 4 { + println!("Starts with: {:?}", &output.stdout[0..4]); + } +} diff --git a/test_modifier.rs b/test_modifier.rs new file mode 100644 index 0000000000..f7c86342b4 --- /dev/null +++ b/test_modifier.rs @@ -0,0 +1,24 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, KeyEventKind, KeyEventState}; + +fn main() { + let key_event = KeyEvent { + code: KeyCode::Char('d'), + modifiers: KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + state: KeyEventState::empty(), + }; + + // Simulate current behavior + let mut current_input = String::new(); + if key_event.code == KeyCode::Char('c') && key_event.modifiers.contains(KeyModifiers::CONTROL) { + // Ctrl+C + } else if key_event.code == KeyCode::Enter { + // Enter + } else if key_event.code == KeyCode::Backspace { + current_input.pop(); + } else if let KeyCode::Char(c) = key_event.code { + current_input.push(c); + } + + println!("current_input: {}", current_input); +} diff --git a/test_ui_events.rs b/test_ui_events.rs new file mode 100644 index 0000000000..f328e4d9d0 --- /dev/null +++ b/test_ui_events.rs @@ -0,0 +1 @@ +fn main() {} diff --git a/test_visible.rs b/test_visible.rs new file mode 100644 index 0000000000..81457fbc63 --- /dev/null +++ b/test_visible.rs @@ -0,0 +1,7 @@ +use streamdown_ansi::utils::visible_length; + +fn main() { + let word = "слово"; + let len = visible_length(word); + println!("visible_length('{}') = {}", word, len); +} \ No newline at end of file diff --git a/test_visible2.rs b/test_visible2.rs new file mode 100644 index 0000000000..21fcd09171 --- /dev/null +++ b/test_visible2.rs @@ -0,0 +1,19 @@ +use streamdown_ansi::utils::visible_length; +use colored::Colorize; + +fn main() { + let word = "СЛОВО"; + let bold_word = word.bold().to_string(); + let len = visible_length(&bold_word); + + println!("Original word: {}", word); + println!("Bold word with ANSI: {:?}", bold_word); + println!("visible_length(bold_word) = {}", len); + println!("Actual char count: {}", word.chars().count()); + + if len == word.chars().count() { + println!("SUCCESS: visible_length correctly ignored ANSI codes."); + } else { + println!("FAILURE: visible_length did not return the expected length."); + } +} diff --git a/test_visible3.rs b/test_visible3.rs new file mode 100644 index 0000000000..dc71995e2a --- /dev/null +++ b/test_visible3.rs @@ -0,0 +1,8 @@ +use streamdown_ansi::utils::visible_length; + +fn main() { + let ru = "русское"; + let en = "hello"; + println!("русское: {}", visible_length(ru)); + println!("hello: {}", visible_length(en)); +} diff --git a/test_xclip.bin b/test_xclip.bin new file mode 100644 index 0000000000..ce01362503 --- /dev/null +++ b/test_xclip.bin @@ -0,0 +1 @@ +hello