From 87cb4d35b660089f1b15bb4269c2f557452426a6 Mon Sep 17 00:00:00 2001 From: Lexo Liu Date: Thu, 12 Nov 2026 13:31:09 -0500 Subject: [PATCH] refactor: move process-wide statics to their owners Sixteen statics held state outside tests. The quota and secret regexes now live in QuotaPatterns and SecretPatterns on the daemon state and the TUI app; the syntax set, theme and tunnel regex in the chat stream's assets, built once at TUI start; the repo-search client and the per-repository land locks in SharedServices on the daemon state, passed to every session's commands; the chat revision counter in a clock successor streams share; the jank threshold is read once at startup. The crash handler keeps its two statics, which a sigaction handler can only reach that way. Closes #1158 --- src/agent/idle_compaction_tests.rs | 1 + src/agent/may.rs | 7 + src/agent/pr_tool.rs | 4 +- src/bash.rs | 15 +- src/bin/may/exec.rs | 1 + src/bin/may/main.rs | 7 +- src/crash_signal.rs | 9 +- src/daemon/chat/mod.rs | 18 ++- src/daemon/events.rs | 1 + src/daemon/mod.rs | 4 +- src/daemon/server.rs | 4 +- src/daemon/state.rs | 12 +- src/lib.rs | 3 +- src/pr.rs | 36 +++-- src/quota.rs | 197 ++++++++++++++------------- src/repo_search/mod.rs | 8 -- src/secrets.rs | 78 ++++++----- src/terminal_commands.rs | 39 +++++- src/tui/app/mod.rs | 7 +- src/tui/perf.rs | 23 ++-- src/tui/reducer.rs | 2 +- src/tui/ui/chat.rs | 212 ++++++++++++++++++++--------- src/tui/ui/chat_layout.rs | 20 +-- src/tui/ui/highlight.rs | 129 +++++++++++------- src/tui/ui/markdown.rs | 21 ++- src/tui/ui/streaming_markdown.rs | 120 ++++++++-------- 26 files changed, 602 insertions(+), 376 deletions(-) diff --git a/src/agent/idle_compaction_tests.rs b/src/agent/idle_compaction_tests.rs index d0e7d5c08..19e9e5285 100644 --- a/src/agent/idle_compaction_tests.rs +++ b/src/agent/idle_compaction_tests.rs @@ -352,6 +352,7 @@ impl SessionParts { connectivity: aither_connectivity::Connectivity::manual(true).0, rules_import: self.rules_import.clone(), limits: crate::agent::LiveLimits::of(&MayConfig::default()), + services: crate::terminal_commands::SharedServices::new(&self.dir.join(".may")), model_context_window: None, } } diff --git a/src/agent/may.rs b/src/agent/may.rs index 93cfff078..e8127fc9a 100644 --- a/src/agent/may.rs +++ b/src/agent/may.rs @@ -302,6 +302,8 @@ pub struct MayAgentConfig { pub rules_import: Option, /// The daemon's live limits, shared by every agent it builds. pub limits: super::LiveLimits, + /// The repo-search handle and PR land locks the daemon shares. + pub services: crate::terminal_commands::SharedServices, } /// Session-scoped inputs for [`MayAgent::from_config_with_model_and_permissions`]. @@ -377,6 +379,8 @@ pub struct MayAgentSessionInput<'a> { pub rules_import: Option, /// The daemon's live limits, shared by every agent it builds. pub limits: super::LiveLimits, + /// The repo-search handle and PR land locks the daemon shares. + pub services: crate::terminal_commands::SharedServices, /// The model's context window as the daemon resolved it (provider /// report, then the model table); the agent compacts against it. pub model_context_window: Option, @@ -970,6 +974,7 @@ impl MayAgent { #[cfg(target_os = "macos")] computer: config.computer.clone(), rules_import: config.rules_import.clone(), + services: config.services.clone(), }, )) .await?; @@ -1266,6 +1271,7 @@ impl MayAgent { connectivity, rules_import, limits, + services, model_context_window, } = input; let selector = super::provider::parse_model_selector(model)?; @@ -1372,6 +1378,7 @@ impl MayAgent { db, rules_import, limits, + services, }) } diff --git a/src/agent/pr_tool.rs b/src/agent/pr_tool.rs index d0af22dc8..9a83051b9 100644 --- a/src/agent/pr_tool.rs +++ b/src/agent/pr_tool.rs @@ -259,7 +259,7 @@ mod tests { use aither_core::llm::ToolContext; use super::{CommandRole, PrArgs, PrStore, PrTool, Tool}; - use crate::pr::PrContext; + use crate::pr::{LandLocks, PrContext}; use crate::repo_clone::test_sandbox::TestTerminal; fn text(result: &aither_core::llm::ToolResult) -> String { @@ -291,6 +291,7 @@ mod tests { "s1", ), github_api: PrContext::default_github_api(), + land_locks: LandLocks::default(), task_events: None, ci: None, publish: None, @@ -398,6 +399,7 @@ mod tests { screen: None, signing: user.signing(&tmp.join("may-home"), "s1"), github_api: PrContext::default_github_api(), + land_locks: LandLocks::default(), task_events: None, ci: None, publish: None, diff --git a/src/bash.rs b/src/bash.rs index 36f3d0793..7bd9c3236 100644 --- a/src/bash.rs +++ b/src/bash.rs @@ -6,7 +6,7 @@ use std::path::Path; -use crate::secrets::Secrets; +use crate::secrets::{SecretPatterns, Secrets}; /// Masked result of a bash-mode command, ready for display and the agent. #[derive(Debug, serde::Serialize)] @@ -47,8 +47,13 @@ fn cap_stream(bytes: &[u8]) -> String { /// keychain secrets injected as environment variables. Output is masked /// before it returns. #[must_use] -pub fn run_bash_command(command: &str, cwd: &Path, secrets: &Secrets) -> BashOutcome { - let (_, command_secret) = secrets.mask(command); +pub fn run_bash_command( + command: &str, + cwd: &Path, + secrets: &Secrets, + patterns: &SecretPatterns, +) -> BashOutcome { + let (_, command_secret) = secrets.mask(patterns, command); let spawn = || { std::process::Command::new("bash") .arg("-c") @@ -68,8 +73,8 @@ pub fn run_bash_command(command: &str, cwd: &Path, secrets: &Secrets) -> BashOut error: Some(format!("failed to run bash: {error}")), }, Ok(output) => { - let (stdout, out_secret) = secrets.mask(&cap_stream(&output.stdout)); - let (stderr, err_secret) = secrets.mask(&cap_stream(&output.stderr)); + let (stdout, out_secret) = secrets.mask(patterns, &cap_stream(&output.stdout)); + let (stderr, err_secret) = secrets.mask(patterns, &cap_stream(&output.stderr)); // `had_secret` tracks output only — a command-side hit already // warned at submit time. BashOutcome { diff --git a/src/bin/may/exec.rs b/src/bin/may/exec.rs index ae7354e76..e76bce4b6 100644 --- a/src/bin/may/exec.rs +++ b/src/bin/may/exec.rs @@ -492,6 +492,7 @@ where #[cfg(target_os = "macos")] computer: None, rules_import: None, + services: may_agent::SharedServices::new(&context.may_dir), llm, ask_flagship: None, ask_balanced: None, diff --git a/src/bin/may/main.rs b/src/bin/may/main.rs index c5a56107b..bfe8243b8 100644 --- a/src/bin/may/main.rs +++ b/src/bin/may/main.rs @@ -885,6 +885,7 @@ where tracing::info!("=== LOOP START ==="); let mut phases = may_agent::tui::perf::LoopPhases::default(); + let jank_threshold = may_agent::tui::perf::jank_threshold(); // First draw: updates content_height/view_height for accurate overflow calculation. if needs_draw { @@ -2288,9 +2289,12 @@ where }) => { let tx = events.sender(); let cwd = app.cwd.clone(); + let patterns = app.secret_patterns.clone(); spawn(async move { let mut outcome = blocking::unblock(move || { - may_agent::bash::run_bash_command(&command, &cwd, &secrets) + may_agent::bash::run_bash_command( + &command, &cwd, &secrets, &patterns, + ) }) .await; outcome.call_id = call_id; @@ -2331,6 +2335,7 @@ where // Report a stall whenever one busy iteration exceeded the jank // threshold; the log carries the render breakdown for repro. may_agent::tui::perf::report( + jank_threshold, &phases, &app.frame_stats, app.scroll, diff --git a/src/crash_signal.rs b/src/crash_signal.rs index 0afdab548..3cc72bedf 100644 --- a/src/crash_signal.rs +++ b/src/crash_signal.rs @@ -27,7 +27,9 @@ const PATH_CAP: usize = 1024; /// What a signal handler needs, reachable without parameters. A handler /// receives only the signal and its `siginfo`, so the state it uses has to -/// live in a static — the one place a static is unavoidable. +/// live in a static — the one place a static is unavoidable. `sigaction` +/// offers no user-data argument and a handler cannot be a closure, so no +/// handler-safe alternative to a static exists. struct CrashState { ring: LogRingHandle, bugs_dir: CString, @@ -37,7 +39,10 @@ struct CrashState { static CRASH_STATE: OnceLock = OnceLock::new(); /// Set once the first dump is written so a nested or chained signal (the -/// overflow that ends in abort) never writes a second file. +/// overflow that ends in abort) never writes a second file. A static for the +/// same reason as [`CRASH_STATE`]: the handler installed with `sigaction` +/// (`SA_SIGINFO`) receives no context pointer, and an atomic is the one +/// async-signal-safe way to share this flag between invocations. static DUMPED: AtomicBool = AtomicBool::new(false); /// Install the fatal-signal log dump for `SIGSEGV`, `SIGBUS` and `SIGABRT`. diff --git a/src/daemon/chat/mod.rs b/src/daemon/chat/mod.rs index 641ee0fb9..f5aaddd9a 100644 --- a/src/daemon/chat/mod.rs +++ b/src/daemon/chat/mod.rs @@ -50,7 +50,6 @@ use crate::agent::MayAgent; use crate::ask_user; use crate::db::RunFence; use crate::prompt_mentions; -use crate::quota; use crate::session::{ AcquireError, CancelReason, ChatHistory, ChatMessage, ChatMessageStats, FinishError, MayHomePaths, MessageRole, PendingMessageState, PendingUserMessage, PersistedAttachment, @@ -1860,11 +1859,13 @@ pub(super) async fn run_bash( ) .map_err(internal_error)?; let run_secrets = secrets.clone(); - let outcome = - blocking::unblock(move || crate::bash::run_bash_command(&command, &cwd, &run_secrets)) - .await; + let patterns = state.secret_patterns.clone(); + let outcome = blocking::unblock(move || { + crate::bash::run_bash_command(&command, &cwd, &run_secrets, &patterns) + }) + .await; // The transcript returned to the client never carries the raw secret. - let (masked_command, _) = secrets.mask(&outcome.command); + let (masked_command, _) = secrets.mask(&state.secret_patterns, &outcome.command); Ok(Json(crate::daemon::types::BashResponse { command: masked_command, stdout: outcome.stdout, @@ -2529,7 +2530,11 @@ fn run_agent_loop_attempts<'a>( .config_snapshot() .preferences .auto_continue_on_quota_reset - .then(|| quota::reset_at(&err_msg, jiff::Timestamp::now())) + .then(|| { + state + .quota_patterns + .reset_at(&err_msg, jiff::Timestamp::now()) + }) .flatten() .map(RetryWait::QuotaReset), }; @@ -4371,6 +4376,7 @@ fn build_session_agent<'a>( connectivity: state.connectivity.clone(), rules_import: ctx.rules_import.clone(), limits: state.live_limits.clone(), + services: state.services.clone(), model_context_window, }, ) diff --git a/src/daemon/events.rs b/src/daemon/events.rs index 734f537bd..52ca301b0 100644 --- a/src/daemon/events.rs +++ b/src/daemon/events.rs @@ -371,6 +371,7 @@ async fn index_coverage( ) -> Result, String> { let status = async { let client = state + .services .repo_search .client() .await diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index c170519a0..69523bc24 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -103,9 +103,9 @@ use anyhow::Context as _; use skyzen::routing::Router; use crate::config::MayConfig; +use crate::repo_search::RepoSearchCollections; use crate::repo_search::auto_index::AutoIndexer; use crate::repo_search::indexer::{IndexerStatus, SearchIndexer}; -use crate::repo_search::{RepoSearch, RepoSearchCollections}; use crate::ui_event_bus::UiEventTopics; /// How long a shutdown waits for running turns before exiting anyway. @@ -508,7 +508,7 @@ pub async fn run_daemon(options: DaemonOptions) -> anyhow::Result<()> { } }); - let repo_search = RepoSearch::global(paths.root()); + let repo_search = state.services.repo_search.clone(); let indexer = SearchIndexer::new( state.db.clone(), RepoSearchCollections::new(repo_search.clone()), diff --git a/src/daemon/server.rs b/src/daemon/server.rs index 964207371..f2323411d 100644 --- a/src/daemon/server.rs +++ b/src/daemon/server.rs @@ -329,7 +329,9 @@ pub async fn start( tool_distribution, tool_environments: crate::agent::ToolEnvironmentCache::spawn(), job_server, - repo_search: crate::repo_search::RepoSearch::global(&may_dir), + services: crate::terminal_commands::SharedServices::new(&may_dir), + quota_patterns: crate::quota::QuotaPatterns::default(), + secret_patterns: crate::secrets::SecretPatterns::default(), may_dir, search_index_wake_tx, search_index_wake, diff --git a/src/daemon/state.rs b/src/daemon/state.rs index 181a43628..65615796f 100644 --- a/src/daemon/state.rs +++ b/src/daemon/state.rs @@ -459,8 +459,12 @@ pub struct DaemonState { pub(crate) search_index_wake: async_channel::Receiver<()>, /// Latest outcome of the search indexer. pub(crate) search_index_status: crate::repo_search::indexer::IndexerStatus, - /// The repo-search daemon handle worktree coverage is polled through. - pub(crate) repo_search: crate::repo_search::RepoSearch, + /// The compiled quota-reset matchers turns parse upstream errors with. + pub(crate) quota_patterns: crate::quota::QuotaPatterns, + /// The compiled secret shapes bash-mode output is masked with. + pub(crate) secret_patterns: crate::secrets::SecretPatterns, + /// The repo-search handle and PR land locks every session's agent shares. + pub(crate) services: crate::terminal_commands::SharedServices, /// Signalled by `POST /api/daemon/shutdown`. pub(crate) daemon_shutdown: Arc, /// Set once shutdown starts draining: new turns are refused so the @@ -1430,7 +1434,9 @@ impl DaemonState { .await .expect("test jobserver starts"), may_dir: may_dir.to_path_buf(), - repo_search: crate::repo_search::RepoSearch::global(may_dir), + services: crate::terminal_commands::SharedServices::new(may_dir), + quota_patterns: crate::quota::QuotaPatterns::default(), + secret_patterns: crate::secrets::SecretPatterns::default(), search_index_wake_tx, search_index_wake, search_index_status: crate::repo_search::indexer::IndexerStatus::default(), diff --git a/src/lib.rs b/src/lib.rs index 97caf7c3c..972ce2e94 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -169,7 +169,8 @@ pub use session::{ }; pub use skill::{SkillInfo, SkillManager, SkillSource}; pub use terminal_commands::{ - SharedTerminalCommandConfig, TerminalCommandsError, register_shared_terminal_commands, + SharedServices, SharedTerminalCommandConfig, TerminalCommandsError, + register_shared_terminal_commands, }; pub use terminal_permission_handler::TerminalPermissionHandler; pub use workspace::{Workspace, WorkspaceConfig}; diff --git a/src/pr.rs b/src/pr.rs index eedbf17a0..6f5aa9466 100644 --- a/src/pr.rs +++ b/src/pr.rs @@ -17,7 +17,7 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; -use std::sync::{Arc, LazyLock, Mutex}; +use std::sync::{Arc, Mutex}; use askama::Template; @@ -104,6 +104,8 @@ pub struct PrContext { pub signing: crate::commit_signing::CommitSigning, /// GitHub REST client the GitHub backend's direct calls go through. pub github_api: GitHubRest, + /// The per-repository land locks every session of the daemon shares. + pub land_locks: LandLocks, /// The main agent's `tasks.md` inbox: told when a PR opens, merges, is /// rejected or closes. pub task_events: Option, @@ -243,14 +245,20 @@ impl merge_watch::ChecksObserver for CiObserver<'_> { } /// Serialized per-repo merge: one in-flight land per repository. -static LAND_LOCKS: LazyLock>>>> = - LazyLock::new(|| Mutex::new(HashMap::new())); - -fn land_lock(repo_key: &str) -> Arc> { - let mut locks = LAND_LOCKS - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - locks.entry(repo_key.to_string()).or_default().clone() +/// +/// Cloning shares the locks, so the daemon hands one set to every session and +/// a land in one session excludes a land of the same repository in another. +#[derive(Clone, Debug, Default)] +pub struct LandLocks(Arc>>>>); + +impl LandLocks { + fn get(&self, repo_key: &str) -> Arc> { + let mut locks = self + .0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + locks.entry(repo_key.to_string()).or_default().clone() + } } /// The resolved clone a `pr` operates on. @@ -607,7 +615,7 @@ impl PrStore { return Err(PrError::CheckFailed(err)); } // Serialized land: one in-flight merge per repository. - let lock = land_lock(&resolved.record.repo_key); + let lock = self.context.land_locks.get(&resolved.record.repo_key); self.event(&pr, "merging", None).await; let result = blocking::unblock({ let checkout = resolved.record.source.clone(); @@ -1068,7 +1076,7 @@ impl PrStore { let external = pr .external_number .ok_or_else(|| PrError::State("github PR missing its number".to_string()))?; - let lock = land_lock(&resolved.record.repo_key); + let lock = self.context.land_locks.get(&resolved.record.repo_key); let github = merge_watch::GithubPr { api: &self.context.github_api, owner_repo: &owner_repo, @@ -2054,7 +2062,7 @@ fn git(dir: Option<&Path>, args: &[&str]) -> Result Option { - relative(message, now) - .or_else(|| retry_after(message, now)) - .or_else(|| try_again_at(message)) - .or_else(|| resets_in_zone(message, now)) - .or_else(|| at_utc(message, now)) - .filter(|until| *until > now) +/// The compiled matchers, one per announcement format. Built once by the +/// daemon and shared by every turn that parses an upstream error. +#[derive(Debug)] +pub struct QuotaPatterns { + relative: Regex, + retry_after: Regex, + try_again_at: Regex, + resets_in_zone: Regex, + at_utc: Regex, } -fn captures<'a>( - cell: &OnceLock, - pattern: &str, - text: &'a str, -) -> Option> { - cell.get_or_init(|| Regex::new(pattern).expect("quota-reset pattern must compile")) - .captures(text) +fn compile(pattern: &str) -> Regex { + Regex::new(pattern).expect("quota-reset pattern must compile") } -/// `reset(s) in N ` — the delay the message declares. -fn relative(message: &str, now: Timestamp) -> Option { - static RE: OnceLock = OnceLock::new(); - let caps = captures( - &RE, - r"(?i)\breset\w*\s+in\s+(\d+)\s*(second|minute|hour|day)s?\b", - message, - )?; - let count: u64 = caps[1].parse().ok()?; - let unit = caps[2].to_ascii_lowercase(); - let seconds = match unit.as_str() { - "second" => count, - "minute" => count.checked_mul(60)?, - "hour" => count.checked_mul(3_600)?, - "day" => count.checked_mul(86_400)?, - _ => return None, - }; - now.checked_add(Duration::from_secs(seconds)).ok() +impl Default for QuotaPatterns { + fn default() -> Self { + Self { + relative: compile(r"(?i)\breset\w*\s+in\s+(\d+)\s*(second|minute|hour|day)s?\b"), + retry_after: compile(r"(?i)\bretry after\s+(\d+)\s*s\b"), + try_again_at: compile( + r"(?i)\btry again at\s+([A-Za-z]+\s+\d{1,2})(?:st|nd|rd|th)?\s*(,\s*\d{4}\s+\d{1,2}:\d{2}\s*[AP]M)\b", + ), + resets_in_zone: compile( + r"(?i)\bresets?\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*([ap])\.?m\.?\s*\(([A-Za-z][A-Za-z0-9_+\-/]*)\)", + ), + at_utc: compile(r"(?i)\bat\s+(\d{1,2}):(\d{2})\s*UTC\b"), + } + } } -/// aither's `Retry-After` rendering: `(retry after 30s)`. -fn retry_after(message: &str, now: Timestamp) -> Option { - static RE: OnceLock = OnceLock::new(); - let caps = captures(&RE, r"(?i)\bretry after\s+(\d+)\s*s\b", message)?; - now.checked_add(Duration::from_secs(caps[1].parse().ok()?)) - .ok() -} +impl QuotaPatterns { + /// The instant an upstream error message says its quota resets, when the + /// message declares one in a recognized format and that instant is still in + /// the future. `now` anchors relative delays and day-free wall times. + pub fn reset_at(&self, message: &str, now: Timestamp) -> Option { + self.relative(message, now) + .or_else(|| self.retry_after(message, now)) + .or_else(|| self.try_again_at(message)) + .or_else(|| self.resets_in_zone(message, now)) + .or_else(|| self.at_utc(message, now)) + .filter(|until| *until > now) + } + + /// `reset(s) in N ` — the delay the message declares. + fn relative(&self, message: &str, now: Timestamp) -> Option { + let caps = self.relative.captures(message)?; + let count: u64 = caps[1].parse().ok()?; + let unit = caps[2].to_ascii_lowercase(); + let seconds = match unit.as_str() { + "second" => count, + "minute" => count.checked_mul(60)?, + "hour" => count.checked_mul(3_600)?, + "day" => count.checked_mul(86_400)?, + _ => return None, + }; + now.checked_add(Duration::from_secs(seconds)).ok() + } + + /// aither's `Retry-After` rendering: `(retry after 30s)`. + fn retry_after(&self, message: &str, now: Timestamp) -> Option { + let caps = self.retry_after.captures(message)?; + now.checked_add(Duration::from_secs(caps[1].parse().ok()?)) + .ok() + } -/// Codex/`ChatGPT`: `try again at Nov 4th, 2026 1:40 AM` — the message -/// leaves its zone unstated, so it is read in the local zone, matching how -/// the announcing CLI renders it. -fn try_again_at(message: &str) -> Option { - static RE: OnceLock = OnceLock::new(); - let caps = captures( - &RE, - r"(?i)\btry again at\s+([A-Za-z]+\s+\d{1,2})(?:st|nd|rd|th)?\s*(,\s*\d{4}\s+\d{1,2}:\d{2}\s*[AP]M)\b", - message, - )?; - let date_time = format!("{}{}", &caps[1], &caps[2]); - for format in ["%B %d, %Y %I:%M %p", "%b %d, %Y %I:%M %p"] { - if let Ok(date_time) = DateTime::strptime(format, &date_time) { - return TimeZone::system() - .to_ambiguous_zoned(date_time) - .compatible() - .ok() - .map(|zoned| zoned.timestamp()); + /// Codex/`ChatGPT`: `try again at Nov 4th, 2026 1:40 AM` — the message + /// leaves its zone unstated, so it is read in the local zone, matching how + /// the announcing CLI renders it. + fn try_again_at(&self, message: &str) -> Option { + let caps = self.try_again_at.captures(message)?; + let date_time = format!("{}{}", &caps[1], &caps[2]); + for format in ["%B %d, %Y %I:%M %p", "%b %d, %Y %I:%M %p"] { + if let Ok(date_time) = DateTime::strptime(format, &date_time) { + return TimeZone::system() + .to_ambiguous_zoned(date_time) + .compatible() + .ok() + .map(|zoned| zoned.timestamp()); + } } + None } - None -} -/// `Claude`: `resets 2am (America/New_York)` — the next occurrence of that -/// wall time in the named zone. -fn resets_in_zone(message: &str, now: Timestamp) -> Option { - static RE: OnceLock = OnceLock::new(); - let caps = captures( - &RE, - r"(?i)\bresets?\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*([ap])\.?m\.?\s*\(([A-Za-z][A-Za-z0-9_+\-/]*)\)", - message, - )?; - let hour12: i8 = caps[1].parse().ok()?; - let minute: i8 = caps.get(2).map_or(0, |m| m.as_str().parse().unwrap_or(0)); - let hour = hour12 % 12 + i8::from(caps[3].eq_ignore_ascii_case("p")) * 12; - let zone = if caps[4].eq_ignore_ascii_case("utc") { - TimeZone::UTC - } else { - TimeZone::get(&caps[4]).ok()? - }; - next_wall_time(&zone, hour, minute, now) -} + /// `Claude`: `resets 2am (America/New_York)` — the next occurrence of that + /// wall time in the named zone. + fn resets_in_zone(&self, message: &str, now: Timestamp) -> Option { + let caps = self.resets_in_zone.captures(message)?; + let hour12: i8 = caps[1].parse().ok()?; + let minute: i8 = caps.get(2).map_or(0, |m| m.as_str().parse().unwrap_or(0)); + let hour = hour12 % 12 + i8::from(caps[3].eq_ignore_ascii_case("p")) * 12; + let zone = if caps[4].eq_ignore_ascii_case("utc") { + TimeZone::UTC + } else { + TimeZone::get(&caps[4]).ok()? + }; + next_wall_time(&zone, hour, minute, now) + } -/// `at 04:11 UTC` — the next occurrence of the UTC wall time. -fn at_utc(message: &str, now: Timestamp) -> Option { - static RE: OnceLock = OnceLock::new(); - let caps = captures(&RE, r"(?i)\bat\s+(\d{1,2}):(\d{2})\s*UTC\b", message)?; - next_wall_time( - &TimeZone::UTC, - caps[1].parse().ok()?, - caps[2].parse().ok()?, - now, - ) + /// `at 04:11 UTC` — the next occurrence of the UTC wall time. + fn at_utc(&self, message: &str, now: Timestamp) -> Option { + let caps = self.at_utc.captures(message)?; + next_wall_time( + &TimeZone::UTC, + caps[1].parse().ok()?, + caps[2].parse().ok()?, + now, + ) + } } /// The next instant `zone`'s wall clock reads `hour`:`minute`, today or @@ -152,7 +157,11 @@ mod tests { use jiff::civil::DateTime; use jiff::tz::TimeZone; - use super::reset_at; + use super::QuotaPatterns; + + fn reset_at(message: &str, now: Timestamp) -> Option { + QuotaPatterns::default().reset_at(message, now) + } fn now() -> Timestamp { "2026-11-01T00:00:00Z".parse().expect("timestamp") diff --git a/src/repo_search/mod.rs b/src/repo_search/mod.rs index 55b4b0a19..e991e5971 100644 --- a/src/repo_search/mod.rs +++ b/src/repo_search/mod.rs @@ -97,14 +97,6 @@ impl RepoSearch { } } - /// The process-wide handle; `may_dir` is fixed for a process, so the - /// first caller's value is the one kept. - #[must_use] - pub fn global(may_dir: &Path) -> Self { - static GLOBAL: std::sync::OnceLock = std::sync::OnceLock::new(); - GLOBAL.get_or_init(|| Self::new(may_dir)).clone() - } - async fn prepared(&self) -> Result<&Prepared, RepoSearchError> { self.inner .prepared diff --git a/src/secrets.rs b/src/secrets.rs index aee6bb7b8..a1878695c 100644 --- a/src/secrets.rs +++ b/src/secrets.rs @@ -521,7 +521,7 @@ impl Secrets { /// assignments on secret-named variables, well-known token formats). /// Returns the masked text and whether anything was redacted. #[must_use] - pub fn mask(&self, text: &str) -> (String, bool) { + pub fn mask(&self, patterns: &SecretPatterns, text: &str) -> (String, bool) { let mut masked = text.to_string(); // Longest-first so overlapping values (prefix of another) redact cleanly. let mut values: Vec<&str> = self @@ -535,10 +535,10 @@ impl Secrets { for value in values { masked = masked.replace(value, "***"); } - for pattern in literal_patterns() { + for pattern in &patterns.literal { masked = pattern.replace_all(&masked, "${name}***").into_owned(); } - for pattern in token_patterns() { + for pattern in &patterns.token { masked = pattern.replace_all(&masked, "***").into_owned(); } let changed = masked != text; @@ -546,36 +546,40 @@ impl Secrets { } } -/// `SECRET_NAMED=value` style assignments — mask the value side. -fn literal_patterns() -> &'static [regex::Regex] { - static PATTERNS: std::sync::OnceLock> = std::sync::OnceLock::new(); - PATTERNS.get_or_init(|| { - // FOO_KEY=..., API_TOKEN: ..., --password value, etc. Value must - // be >= 8 chars to keep ordinary config text untouched. - vec![regex::Regex::new( - r#"(?i)(?P[a-z_]*(?:api[_-]?key|secret|token|password|passwd|credential|private[_-]?key)[a-z0-9_]*[=:]\s*["']?)[^\s"']{8,}"#, - ) - .expect("secret literal pattern compiles")] - }) +/// The compiled shapes [`Secrets::mask`] recognizes without a stored value. +/// Built once by the host (the daemon, the TUI) and passed to every mask; +/// cloning shares the compiled programs. +#[derive(Debug, Clone)] +pub struct SecretPatterns { + /// `SECRET_NAMED=value` style assignments — mask the value side. + literal: Vec, + /// Well-known secret token shapes masked wholesale. + token: Vec, } -/// Well-known secret token shapes masked wholesale. -fn token_patterns() -> &'static [regex::Regex] { - static PATTERNS: std::sync::OnceLock> = std::sync::OnceLock::new(); - PATTERNS.get_or_init(|| { - [ - r"sk-(?:ant-|live-|test-)?[A-Za-z0-9_-]{16,}", - r"(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}", - r"github_pat_[A-Za-z0-9_]{20,}", - r"xox[baprs]-[A-Za-z0-9-]{10,}", - r"AKIA[0-9A-Z]{16}", - r"AIza[0-9A-Za-z_-]{30,}", - r"-----BEGIN [A-Z ]*PRIVATE KEY-----[^-]*-----END [A-Z ]*PRIVATE KEY-----", - ] - .iter() - .map(|pattern| regex::Regex::new(pattern).expect("secret token pattern compiles")) - .collect() - }) +impl Default for SecretPatterns { + fn default() -> Self { + Self { + // FOO_KEY=..., API_TOKEN: ..., --password value, etc. Value must + // be >= 8 chars to keep ordinary config text untouched. + literal: vec![regex::Regex::new( + r#"(?i)(?P[a-z_]*(?:api[_-]?key|secret|token|password|passwd|credential|private[_-]?key)[a-z0-9_]*[=:]\s*["']?)[^\s"']{8,}"#, + ) + .expect("secret literal pattern compiles")], + token: [ + r"sk-(?:ant-|live-|test-)?[A-Za-z0-9_-]{16,}", + r"(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}", + r"github_pat_[A-Za-z0-9_]{20,}", + r"xox[baprs]-[A-Za-z0-9-]{10,}", + r"AKIA[0-9A-Z]{16}", + r"AIza[0-9A-Za-z_-]{30,}", + r"-----BEGIN [A-Z ]*PRIVATE KEY-----[^-]*-----END [A-Z ]*PRIVATE KEY-----", + ] + .iter() + .map(|pattern| regex::Regex::new(pattern).expect("secret token pattern compiles")) + .collect(), + } + } } /// Shortest stored value that is masked or redacted: shorter values would @@ -923,23 +927,25 @@ mod tests { entries: vec![("MY_TOKEN".to_string(), "supersecretvalue".to_string())], extra_values: Vec::new(), }; - let (masked, hit) = secrets.mask("echo supersecretvalue && echo hi"); + let patterns = SecretPatterns::default(); + let (masked, hit) = secrets.mask(&patterns, "echo supersecretvalue && echo hi"); assert!(hit); assert_eq!(masked, "echo *** && echo hi"); - let (_, hit) = secrets.mask("nothing here"); + let (_, hit) = secrets.mask(&patterns, "nothing here"); assert!(!hit); } #[test] fn mask_catches_token_shapes_and_assignments() { let secrets = Secrets::empty(); - let (masked, hit) = secrets.mask("curl -H 'key: ghp_abcdefghijklmnopqrst'"); + let patterns = SecretPatterns::default(); + let (masked, hit) = secrets.mask(&patterns, "curl -H 'key: ghp_abcdefghijklmnopqrst'"); assert!(hit); assert!(!masked.contains("ghp_")); - let (masked, hit) = secrets.mask("export MY_SECRET=hunter2hunter2"); + let (masked, hit) = secrets.mask(&patterns, "export MY_SECRET=hunter2hunter2"); assert!(hit); assert_eq!(masked, "export MY_SECRET=***"); - let (_, hit) = secrets.mask("set name=lexo debug=1"); + let (_, hit) = secrets.mask(&patterns, "set name=lexo debug=1"); assert!(!hit); } diff --git a/src/terminal_commands.rs b/src/terminal_commands.rs index 67439c3d9..44993ae66 100644 --- a/src/terminal_commands.rs +++ b/src/terminal_commands.rs @@ -143,6 +143,29 @@ pub struct SharedTerminalCommandConfig { /// The rules this session imports, for a session `/import-claude` or /// `/import-codex` opened: it alone gets `global-rules`. pub rules_import: Option, + /// Handles every session of the host shares. + pub services: SharedServices, +} + +/// Handles the host owns once and every session's commands share: cloning +/// shares them. +#[derive(Debug, Clone)] +pub struct SharedServices { + /// The repo-search daemon handle. + pub repo_search: RepoSearch, + /// The per-repository locks serializing `pr` lands. + pub land_locks: crate::pr::LandLocks, +} + +impl SharedServices { + /// Fresh handles over the May home `may_dir`. + #[must_use] + pub fn new(may_dir: &std::path::Path) -> Self { + Self { + repo_search: RepoSearch::new(may_dir), + land_locks: crate::pr::LandLocks::default(), + } + } } /// A core command and the usage its `--help` prints. @@ -236,7 +259,7 @@ where config.session_id.clone(), Some(SearchIndexer::new( config.db.clone(), - RepoSearchCollections::new(RepoSearch::global(&config.may_dir)), + RepoSearchCollections::new(config.services.repo_search.clone()), )), ), ); @@ -244,7 +267,7 @@ where builder, &mut descriptions, CodeSearchTool::new( - RepoSearch::global(&config.may_dir), + config.services.repo_search.clone(), config.cwd.clone(), Some(config.db.clone()), Some(crate::repo_clone::SessionRepos::new( @@ -257,7 +280,7 @@ where ); let issue_search = IssueSearch::new( config.db.clone(), - RepoSearchCollections::new(RepoSearch::global(&config.may_dir)), + RepoSearchCollections::new(config.services.repo_search.clone()), ); let provenance = IssueProvenance::new(config.db.clone(), config.session_id.clone()); let issue_tool = IssueTool::new(config.issue_store.clone(), provenance.clone()) @@ -340,6 +363,7 @@ where screen: Some(screen), signing, github_api: crate::pr::PrContext::default_github_api(), + land_locks: config.services.land_locks.clone(), task_events: Some(config.task_events.clone()), publish: config.publish_gate.clone(), ci: config.ask_user.as_ref().map(|runtime| crate::pr::CiWatch { @@ -809,6 +833,7 @@ mod tests { #[cfg(target_os = "macos")] computer: None, rules_import: None, + services: SharedServices::new(temp.path()), llm: MockLlm, ask_flagship: None, ask_balanced: None, @@ -904,6 +929,7 @@ mod tests { #[cfg(target_os = "macos")] computer: None, rules_import: None, + services: SharedServices::new(temp.path()), llm: MockLlm, ask_flagship: None, ask_balanced: None, @@ -972,6 +998,7 @@ mod tests { #[cfg(target_os = "macos")] computer: None, rules_import: None, + services: SharedServices::new(temp.path()), llm: MockLlm, ask_flagship: None, ask_balanced: None, @@ -1034,6 +1061,7 @@ mod tests { #[cfg(target_os = "macos")] computer: None, rules_import: None, + services: SharedServices::new(temp.path()), llm: MockLlm, ask_flagship: None, ask_balanced: None, @@ -1091,6 +1119,7 @@ mod tests { #[cfg(target_os = "macos")] computer: None, rules_import: None, + services: SharedServices::new(temp.path()), llm: MockLlm, ask_flagship: None, ask_balanced: None, @@ -1261,6 +1290,7 @@ mod tests { #[cfg(target_os = "macos")] computer: None, rules_import: None, + services: SharedServices::new(temp.path()), llm: MockLlm, ask_flagship: None, ask_balanced: None, @@ -1389,6 +1419,7 @@ mod tests { #[cfg(target_os = "macos")] computer: None, rules_import: None, + services: SharedServices::new(temp.path()), llm: MockLlm, ask_flagship: None, ask_balanced: None, @@ -1448,6 +1479,7 @@ mod tests { #[cfg(target_os = "macos")] computer: None, rules_import: None, + services: SharedServices::new(temp.path()), llm: MockLlm, ask_flagship: None, ask_balanced: None, @@ -1511,6 +1543,7 @@ mod tests { #[cfg(target_os = "macos")] computer: None, rules_import: None, + services: SharedServices::new(session_dir), llm: MockLlm, ask_flagship: None, ask_balanced: None, diff --git a/src/tui/app/mod.rs b/src/tui/app/mod.rs index a797b47da..5c4ce3ab6 100644 --- a/src/tui/app/mod.rs +++ b/src/tui/app/mod.rs @@ -395,6 +395,8 @@ pub struct App { /// Loaded secret values for bash-mode masking and env injection, or why /// they could not be read: bash mode then refuses to run. pub secrets: Result, + /// The compiled secret shapes bash-mode masking recognizes. + pub secret_patterns: crate::secrets::SecretPatterns, /// Questions the agent asked that nobody answered yet, for the status bar. pub open_question_count: usize, /// The session's blocking state as the daemon last reported it on the @@ -642,6 +644,7 @@ impl App { ); Self { + secret_patterns: crate::secrets::SecretPatterns::default(), session_manager, worker_settings: crate::agent::AgentLiveSettings::of(&config), config, @@ -1889,7 +1892,7 @@ impl App { /// finished turn folds into its "Worked for" row. The last turn folds /// only when its reply is stored; until then it may still be running. async fn render_history(&mut self, messages: &[ChatMessage]) { - self.chat = ChatStream::new().with_tunnel_base(tunnel_base( + self.chat = ChatStream::successor_of(&self.chat).with_tunnel_base(tunnel_base( &self.config, self.session_manager.current().map(|session| &session.id), )); @@ -4004,7 +4007,7 @@ impl App { }; self.input.clear(); self.cursor = 0; - let (masked, had_secret) = secrets.mask(&command); + let (masked, had_secret) = secrets.mask(&self.secret_patterns, &command); self.history.push(format!("! {masked}")); self.history.save(); if had_secret { diff --git a/src/tui/perf.rs b/src/tui/perf.rs index 8745404e0..69fc01347 100644 --- a/src/tui/perf.rs +++ b/src/tui/perf.rs @@ -92,18 +92,17 @@ impl LoopPhases { } /// Jank-report threshold from `MAY_JANK_MS`; defaults to -/// [`DEFAULT_JANK_THRESHOLD_MS`]. `0` disables reporting. +/// [`DEFAULT_JANK_THRESHOLD_MS`]. `0` disables reporting. The TUI reads it +/// once at startup and passes it to [`report`]. +#[must_use] pub fn jank_threshold() -> Duration { - static THRESHOLD: std::sync::OnceLock = std::sync::OnceLock::new(); - *THRESHOLD.get_or_init(|| { - std::env::var("MAY_JANK_MS") - .ok() - .and_then(|v| v.parse::().ok()) - .map_or_else( - || Duration::from_millis(DEFAULT_JANK_THRESHOLD_MS), - Duration::from_millis, - ) - }) + std::env::var("MAY_JANK_MS") + .ok() + .and_then(|v| v.parse::().ok()) + .map_or_else( + || Duration::from_millis(DEFAULT_JANK_THRESHOLD_MS), + Duration::from_millis, + ) } fn millis(d: Duration) -> u64 { @@ -115,6 +114,7 @@ fn millis(d: Duration) -> u64 { /// document/scroll state needed to reproduce the stall later. #[allow(clippy::too_many_arguments)] pub fn report( + threshold: Duration, phases: &LoopPhases, stats: &FrameStats, scroll: usize, @@ -123,7 +123,6 @@ pub fn report( event: &'static str, ) { let busy = phases.busy(); - let threshold = jank_threshold(); if threshold.is_zero() || busy <= threshold { return; } diff --git a/src/tui/reducer.rs b/src/tui/reducer.rs index 5cfc17c62..ecf391797 100644 --- a/src/tui/reducer.rs +++ b/src/tui/reducer.rs @@ -512,7 +512,7 @@ pub fn reduce_event(app: &mut App, event: AppEvent) -> AppResult secrets.mask(&outcome.command).0, + Ok(secrets) => secrets.mask(&app.secret_patterns, &outcome.command).0, Err(error) => { app.chat.push_error(&error.to_string()); return Ok(None); diff --git a/src/tui/ui/chat.rs b/src/tui/ui/chat.rs index f9ed2a655..0b5d1cfbc 100644 --- a/src/tui/ui/chat.rs +++ b/src/tui/ui/chat.rs @@ -476,7 +476,7 @@ struct CompactionRecoveryNotice<'a> { } /// The chat stream containing all elements. -#[derive(Debug, Default)] +#[derive(Debug)] pub struct ChatStream { elements: Vec, /// Revision of each element, parallel to `elements`. Every mutation of @@ -498,86 +498,147 @@ pub struct ChatStream { /// The elements from it on are the response in flight, which a retry /// discards. response_floor: usize, - /// Base `http://host/tunnel` URL `` tags resolve against. - tunnel_base: String, + /// What rendering the stream's elements reads besides the elements. + assets: ChatAssets, + /// Numbers the revisions of this stream's elements. + clock: RevisionClock, /// The reply streaming text was last appended to: an identity unique /// across every stream of the process, under which a layout cache keeps /// what it rendered of the reply, and the revision the append left. streaming_reply: Option<(u64, u64)>, } -/// The `` control tag. -fn tunnel_tag() -> &'static regex::Regex { - static TAG: std::sync::OnceLock = std::sync::OnceLock::new(); - TAG.get_or_init(|| { - regex::Regex::new(r#"]*?\bid\s*=\s*["']([^"']+)["'][^>]*/>"#) - .expect("tunnel tag pattern must compile") - }) +/// What rendering reads besides the elements: the `` control +/// tag and the daemon URL it renders as, and the syntax highlighting. Built +/// once when the TUI starts; cloning shares the compiled data. +#[derive(Debug, Clone)] +pub(super) struct ChatAssets { + /// Base `http://host/tunnel` URL `` tags resolve against. + base: String, + /// Matches one `` tag. + tag: regex::Regex, + /// Syntaxes and theme of code blocks and commands. + pub(super) highlighting: super::highlight::Highlighting, } -/// Renders `` control tags as daemon URLs so the terminal -/// shows a link instead of the raw marker. -fn resolve_tunnel_tags<'a>(content: &'a str, tunnel_base: &str) -> std::borrow::Cow<'a, str> { - tunnel_tag().replace_all(content, |captures: ®ex::Captures<'_>| { - format!("{tunnel_base}/{}/", &captures[1]) - }) +impl Default for ChatAssets { + fn default() -> Self { + Self { + highlighting: super::highlight::Highlighting::default(), + base: format!("http://localhost:{}/tunnel", crate::daemon::DEFAULT_PORT), + tag: regex::Regex::new(r#"]*?\bid\s*=\s*["']([^"']+)["'][^>]*/>"#) + .expect("tunnel tag pattern must compile"), + } + } } -/// Whether appended text can no longer change the tunnel tags of `content`. -/// -/// It holds when every `` before any quote: a tag holds a `>` only -/// inside its quoted id, so such an opening is no tag whatever follows. -fn tunnel_tags_settled(content: &str) -> bool { - let tags: Vec> = tunnel_tag() - .find_iter(content) - .map(|tag| tag.range()) - .collect(); - content.match_indices("', '"', '\'']) - .is_some_and(|at| content[start + at..].starts_with('>')) - }) +impl ChatAssets { + /// Renders `` control tags as daemon URLs so the terminal + /// shows a link instead of the raw marker. + fn resolve<'a>(&self, content: &'a str) -> std::borrow::Cow<'a, str> { + self.tag + .replace_all(content, |captures: ®ex::Captures<'_>| { + format!("{}/{}/", self.base, &captures[1]) + }) + } + + /// Whether appended text can no longer change the tunnel tags of `content`. + /// + /// It holds when every `` before any quote: a tag holds a `>` only + /// inside its quoted id, so such an opening is no tag whatever follows. + fn settled(&self, content: &str) -> bool { + let tags: Vec> = + self.tag.find_iter(content).map(|tag| tag.range()).collect(); + content.match_indices("', '"', '\'']) + .is_some_and(|at| content[start + at..].starts_with('>')) + }) + } +} + +/// Numbers element revisions. A layout cache outlives the stream it was +/// filled from, so every stream an app creates shares one clock: a revision +/// is then unique across them and a cached entry never matches another +/// stream's element. +#[derive(Debug, Clone, Default)] +struct RevisionClock(std::sync::Arc); + +impl RevisionClock { + fn next(&self) -> u64 { + self.0.fetch_add(1, Ordering::Relaxed) + 1 + } +} + +impl Default for ChatStream { + fn default() -> Self { + Self::with_assets(ChatAssets::default()) + } } impl ChatStream { /// Create a new empty chat stream. #[must_use] pub fn new() -> Self { + Self::default() + } + + /// An empty stream rendering with `assets`, so a stream built after + /// another reuses what the first loaded. + fn with_assets(assets: ChatAssets) -> Self { + Self { + elements: Vec::new(), + revisions: Vec::new(), + next_thinking_id: 0, + next_worked_id: 0, + next_translation_id: 0, + fold_cursor: 0, + response_floor: 0, + assets, + clock: RevisionClock::default(), + streaming_reply: None, + } + } + + /// An empty stream that renders as `previous` does and numbers its + /// revisions on the same clock: a layout cache that outlives `previous` + /// never matches the new stream's elements with the old ones. + #[must_use] + pub fn successor_of(previous: &Self) -> Self { Self { - tunnel_base: format!("http://localhost:{}/tunnel", crate::daemon::DEFAULT_PORT), - ..Self::default() + clock: previous.clock.clone(), + ..Self::with_assets(previous.assets.clone()) } } /// Sets the daemon base URL `` tags render against. #[must_use] pub fn with_tunnel_base(mut self, tunnel_base: String) -> Self { - self.tunnel_base = tunnel_base; + self.assets.base = tunnel_base; self } - /// The base URL `` tags resolve against. - pub(super) fn tunnel_base(&self) -> &str { - &self.tunnel_base + /// What this stream renders its elements with. + pub(super) const fn assets(&self) -> &ChatAssets { + &self.assets } - fn next_revision() -> u64 { - static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); - NEXT.fetch_add(1, Ordering::Relaxed) + fn next_revision(&self) -> u64 { + self.clock.next() } /// Append `element` with a fresh revision. fn push_element(&mut self, element: ChatElement) { - let revision = Self::next_revision(); + let revision = self.next_revision(); self.elements.push(element); self.revisions.push(revision); } /// Mark the element at `index` changed. fn touch(&mut self, index: usize) { - let revision = Self::next_revision(); + let revision = self.next_revision(); if let Some(slot) = self.revisions.get_mut(index) { *slot = revision; } @@ -1018,7 +1079,7 @@ impl ChatStream { expanded: false, }, ); - self.revisions.insert(at, Self::next_revision()); + self.revisions.insert(at, self.next_revision()); at += 1; } for (element, revision) in errors { @@ -1041,7 +1102,7 @@ impl ChatStream { streaming: true, translation: None, }); - self.streaming_reply = Some((Self::next_revision(), self.revision_of_last())); + self.streaming_reply = Some((self.next_revision(), self.revision_of_last())); } /// Identity of the reply in the last element, when the last change to @@ -1094,7 +1155,9 @@ impl ChatStream { content.push_str(chunk); // When anything else changed the last element since text was // last appended to it, the reply streams under a new identity. - let reply = self.streaming_reply().unwrap_or_else(Self::next_revision); + let reply = self + .streaming_reply() + .unwrap_or_else(|| self.next_revision()); self.touch_last(); self.streaming_reply = Some((reply, self.revision_of_last())); } else { @@ -2577,7 +2640,7 @@ fn finish_reply( /// reply's display text (control tags replaced) settles at the /// reply's last line break once no ` { impl StreamingReply { /// A reply with no text yet, laid out at `width` columns. - pub(super) fn new(images_supported: bool, width: u16) -> Self { + pub(super) fn new( + images_supported: bool, + width: u16, + highlighting: super::highlight::Highlighting, + ) -> Self { Self { settled: 0, - markdown: IncrementalMarkdown::new(images_supported, usize::from(width)), + markdown: IncrementalMarkdown::new(images_supported, usize::from(width), highlighting), } } @@ -2621,22 +2688,22 @@ impl StreamingReply { pub(super) fn render( &mut self, content: &str, - tunnel_base: &str, + assets: &ChatAssets, width: u16, ) -> ReplyFrame<'_> { let pending = &content[self.settled..]; let cut = pending.rfind('\n').map_or(0, |at| at + 1); let (head, tail) = pending.split_at(cut); let (head_text, closed) = replace_artifact_tags(head); - let head_is_final = closed && tunnel_tags_settled(&head_text); + let head_is_final = closed && assets.settled(&head_text); let (settled_text, open_source) = if head_is_final { self.settled += cut; - (resolve_tunnel_tags(&head_text, tunnel_base), tail) + (assets.resolve(&head_text), tail) } else { (Cow::Borrowed(""), pending) }; let open_text = artifact_tag_text(open_source); - let open_text = resolve_tunnel_tags(&open_text, tunnel_base); + let open_text = assets.resolve(&open_text); let frame = self.markdown.render(&settled_text, &open_text); let committed = frame.committed; @@ -2672,7 +2739,7 @@ pub(super) fn element_lines( elem: &ChatElement, state: ElementState, images_supported: bool, - tunnel_base: &str, + assets: &ChatAssets, width: u16, ) -> ElementLines { let mut lines: Vec> = Vec::new(); @@ -2738,9 +2805,13 @@ pub(super) fn element_lines( markdown::ImageSupport::Disabled }; let display = artifact_tag_text(content); - let content = resolve_tunnel_tags(&display, tunnel_base); - let md_result = - markdown::render_markdown_with_positions(&content, image_mode, usize::from(width)); + let content = assets.resolve(&display); + let md_result = markdown::render_markdown_with_positions( + &content, + image_mode, + usize::from(width), + assets.highlighting.clone(), + ); let reserved = reserved_end(&md_result.image_positions); let mut md_lines = md_result.lines; finish_reply(&mut md_lines, 0, reserved, *streaming, width); @@ -2764,6 +2835,7 @@ pub(super) fn element_lines( &mut images, state.open, state.spinner_frame, + &assets.highlighting, ); lines.extend(tool_lines.into_iter().map(line_into_static)); if display.expandable() { @@ -2927,6 +2999,7 @@ pub(super) fn element_lines( body, markdown::ImageSupport::Disabled, usize::from(width), + assets.highlighting.clone(), ) .lines; while md_lines.last().is_some_and(|l| l.spans.is_empty()) { @@ -2967,8 +3040,7 @@ pub(super) fn element_lines( // `state` describes the group row itself. let inner_state = ElementState::of(inner, None, state.hide_todos, state.spinner_frame); - let sub = - element_lines(inner, inner_state, images_supported, tunnel_base, width); + let sub = element_lines(inner, inner_state, images_supported, assets, width); let offset = lines.len(); lines.extend(sub.lines); regions.extend( @@ -3933,12 +4005,13 @@ fn status_icon(status: &ToolStatus, spinner_frame: usize, unsafe_exec: bool) -> /// A tool call's styled headline: expand marker + status icon + /// chip/description + collapsed status suffix. -fn tool_header_line( - display: &ToolDisplay, +fn tool_header_line<'a>( + display: &'a ToolDisplay, selection_style: Style, open: bool, spinner_frame: usize, -) -> Line<'_> { + highlighting: &super::highlight::Highlighting, +) -> Line<'a> { let marker = if !display.expandable() { " " } else if open { @@ -3988,7 +4061,8 @@ fn tool_header_line( )); spans.push(Span::styled("$ ", dim.patch(selection_style))); spans.extend( - super::highlight::shell_spans(command.lines().next().unwrap_or_default()) + highlighting + .shell_spans(command.lines().next().unwrap_or_default()) .into_iter() .flatten() .map(|span| Span::styled(span.content, span.style.patch(selection_style))), @@ -4122,10 +4196,12 @@ fn render_tool_inline<'a>( image_positions, open, 0, + &super::highlight::Highlighting::default(), ); } /// Inline rows of a tool call whose body is `open`. +#[allow(clippy::too_many_arguments)] fn render_tool_inline_at<'a>( lines: &mut Vec>, display: &'a ToolDisplay, @@ -4134,6 +4210,7 @@ fn render_tool_inline_at<'a>( image_positions: &mut Vec<(String, usize, String)>, open: bool, spinner_frame: usize, + highlighting: &super::highlight::Highlighting, ) { let selection_style = if is_selected { Style::default().bg(Color::DarkGray) @@ -4147,6 +4224,7 @@ fn render_tool_inline_at<'a>( selection_style, open, spinner_frame, + highlighting, )); // A call that ran one of May's commands shows what the command did, open // or folded; only a subagent's full result waits for the expansion. @@ -4191,7 +4269,7 @@ fn render_tool_inline_at<'a>( let mut command_rows = if recognised.is_some() { Vec::new() } else { - super::highlight::shell_spans(command) + highlighting.shell_spans(command) } .into_iter(); if let Some(reading) = recognised { @@ -5871,7 +5949,7 @@ mod tests { elem, super::ElementState::of(elem, None, false, 0), true, - "http://localhost:8787/tunnel", + chat.assets(), inner, ) .lines, @@ -6672,7 +6750,7 @@ mod tests { elem, super::ElementState::of(elem, None, false, 0), false, - "", + chat.assets(), 80, ) .lines; @@ -7300,7 +7378,7 @@ mod tests { elem, super::ElementState::of(elem, None, false, 0), false, - "", + chat.assets(), 80, ) .lines; diff --git a/src/tui/ui/chat_layout.rs b/src/tui/ui/chat_layout.rs index 9488c474d..8f9008146 100644 --- a/src/tui/ui/chat_layout.rs +++ b/src/tui/ui/chat_layout.rs @@ -146,9 +146,9 @@ impl Entry { state: ElementState, width: u16, images_supported: bool, - tunnel_base: &str, + assets: &super::chat::ChatAssets, ) -> Self { - let laid = element_lines(elem, state, images_supported, tunnel_base, width); + let laid = element_lines(elem, state, images_supported, assets, width); let mut line_starts = Vec::with_capacity(laid.lines.len()); let mut rows = 0usize; for line in &laid.lines { @@ -224,14 +224,18 @@ impl Entry { state: ElementState, width: u16, images_supported: bool, - tunnel_base: &str, + assets: &super::chat::ChatAssets, ) -> Self { let (mut entry, mut stream) = previous .and_then(|mut entry| entry.stream.take().map(|stream| (entry, stream))) .unwrap_or_else(|| { let stream = StreamingEntry { reply, - render: StreamingReply::new(images_supported, width), + render: StreamingReply::new( + images_supported, + width, + assets.highlighting.clone(), + ), settled: Settled::default(), #[cfg(test)] wrapped_lines: 0, @@ -249,7 +253,7 @@ impl Entry { mirror.plain.truncate(settled.rows); mirror.runs.truncate(settled.link_runs); - let frame = stream.render.render(content, tunnel_base, width); + let frame = stream.render.render(content, assets, width); let fresh = &frame.settled[settled.lines..]; #[cfg(test)] let wrapped = fresh.len() + frame.open.len(); @@ -470,7 +474,7 @@ impl ChatLayout { state, width, images_supported, - chat.tunnel_base(), + chat.assets(), ) }, |(reply, content)| { @@ -489,7 +493,7 @@ impl ChatLayout { state, width, images_supported, - chat.tunnel_base(), + chat.assets(), ) }, ) @@ -992,7 +996,7 @@ fn main() { println!(\"안녕\"); }\n\ ElementState::of(elem, None, false, 0), width, images_supported, - chat.tunnel_base(), + chat.assets(), ); let entry = layout.entries.last().expect("the reply has an entry"); assert!( diff --git a/src/tui/ui/highlight.rs b/src/tui/ui/highlight.rs index 9832e86e6..9b788d9ff 100644 --- a/src/tui/ui/highlight.rs +++ b/src/tui/ui/highlight.rs @@ -4,7 +4,7 @@ //! line, colored with a dark syntect theme (the TUI assumes a dark //! terminal). -use std::sync::{Arc, LazyLock}; +use std::sync::Arc; use ratatui::prelude::*; use syntect::highlighting::{ @@ -12,17 +12,33 @@ use syntect::highlighting::{ }; use syntect::parsing::{ParseState, ScopeStack, SyntaxSet}; -/// Bundled default syntaxes; loaded once, immutable afterwards. -static SYNTAXES: LazyLock = LazyLock::new(SyntaxSet::load_defaults_newlines); +/// The syntaxes and theme highlighting reads: loaded once when the TUI +/// starts, immutable afterwards. Cloning shares them. +#[derive(Debug, Clone)] +pub struct Highlighting { + /// Bundled default syntaxes. + syntaxes: Arc, + /// The dark theme's compiled selectors, built once. + highlighter: Arc>, +} -/// The dark theme shell commands are highlighted with. -static THEME: LazyLock = LazyLock::new(|| { - ThemeSet::load_defaults() - .themes - .get("base16-ocean.dark") - .expect("syntect's default themes include base16-ocean.dark") - .clone() -}); +impl Default for Highlighting { + fn default() -> Self { + let theme = ThemeSet::load_defaults() + .themes + .get("base16-ocean.dark") + .expect("syntect's default themes include base16-ocean.dark") + .clone(); + // syntect's `Highlighter` borrows its theme. The theme is loaded once + // per TUI and read until the process exits, so it is leaked rather + // than recompiled into selectors for every highlighted line. + let theme: &'static Theme = Box::leak(Box::new(theme)); + Self { + syntaxes: Arc::new(SyntaxSet::load_defaults_newlines()), + highlighter: Arc::new(Highlighter::new(theme)), + } + } +} /// Convert a syntect style to a ratatui style: foreground color and font /// modifiers; the theme's background is dropped so the chat surface shows @@ -56,52 +72,63 @@ pub(super) struct LineHighlighter { highlight: HighlightState, } -/// A highlighter for `language` — a fence token like `rust` or `rs` -/// matched against syntax names and extensions; `None` when no syntax -/// knows it. -pub(super) fn highlighter(language: &str) -> Option { - SYNTAXES.find_syntax_by_token(language).map(|syntax| { - let highlighter = Highlighter::new(&THEME); - let highlight = HighlightState::new(&highlighter, ScopeStack::new()); - LineHighlighter { - highlighter: Arc::new(highlighter), - parse: ParseState::new(syntax), - highlight, - } - }) -} - -/// Highlight one source line, keeping parse state in `highlighter` so -/// quotes, heredocs and open scopes carry across lines. -pub(super) fn line_spans(highlighter: &mut LineHighlighter, line: &str) -> Vec> { - let ops = highlighter - .parse - .parse_line(line, &SYNTAXES) - .expect("bundled syntaxes highlight without missing references"); - HighlightIterator::new( - &mut highlighter.highlight, - &ops, - line, - &highlighter.highlighter, - ) - .map(|(style, text)| Span::styled(text.to_string(), to_style(style))) - .collect() -} +impl Highlighting { + /// A highlighter for `language` — a fence token like `rust` or `rs` + /// matched against syntax names and extensions; `None` when no syntax + /// knows it. + pub(super) fn highlighter(&self, language: &str) -> Option { + self.syntaxes.find_syntax_by_token(language).map(|syntax| { + let highlight = HighlightState::new(&self.highlighter, ScopeStack::new()); + LineHighlighter { + highlighter: self.highlighter.clone(), + parse: ParseState::new(syntax), + highlight, + } + }) + } -/// Highlight `script` as bash, returning one span vector per source line. -/// The script is highlighted as a unit so state (quotes, heredocs) carries -/// across lines. An empty script yields no lines. -pub(super) fn shell_spans(script: &str) -> Vec>> { - let mut highlighter = highlighter("bash").expect("syntect's default syntaxes include bash"); - script - .lines() - .map(|line| line_spans(&mut highlighter, line)) + /// Highlight one source line, keeping parse state in `highlighter` so + /// quotes, heredocs and open scopes carry across lines. + pub(super) fn line_spans( + &self, + highlighter: &mut LineHighlighter, + line: &str, + ) -> Vec> { + let ops = highlighter + .parse + .parse_line(line, &self.syntaxes) + .expect("bundled syntaxes highlight without missing references"); + HighlightIterator::new( + &mut highlighter.highlight, + &ops, + line, + &highlighter.highlighter, + ) + .map(|(style, text)| Span::styled(text.to_string(), to_style(style))) .collect() + } + + /// Highlight `script` as bash, returning one span vector per source line. + /// The script is highlighted as a unit so state (quotes, heredocs) carries + /// across lines. An empty script yields no lines. + pub(super) fn shell_spans(&self, script: &str) -> Vec>> { + let mut highlighter = self + .highlighter("bash") + .expect("syntect's default syntaxes include bash"); + script + .lines() + .map(|line| self.line_spans(&mut highlighter, line)) + .collect() + } } #[cfg(test)] mod tests { - use super::shell_spans; + use super::Highlighting; + + fn shell_spans(script: &str) -> Vec>> { + Highlighting::default().shell_spans(script) + } #[test] fn shell_spans_colors_a_command() { diff --git a/src/tui/ui/markdown.rs b/src/tui/ui/markdown.rs index 46f849822..a146bd521 100644 --- a/src/tui/ui/markdown.rs +++ b/src/tui/ui/markdown.rs @@ -5,6 +5,7 @@ use ratatui::prelude::*; +use super::highlight::Highlighting; use super::streaming_markdown::StreamingMarkdown; /// Whether terminal image rendering is supported. @@ -41,8 +42,12 @@ pub struct MarkdownRenderResult { /// - true: show loading indicator (actual image rendered as overlay) /// - false: show as styled link #[must_use] -pub fn render_markdown_with_config(text: &str, images: ImageSupport) -> Vec> { - let mut renderer = StreamingMarkdown::with_image_support(images.is_enabled()); +pub fn render_markdown_with_config( + text: &str, + images: ImageSupport, + highlighting: Highlighting, +) -> Vec> { + let mut renderer = StreamingMarkdown::with_image_support(images.is_enabled(), highlighting); renderer.push(text); renderer.flush(); // Ensure last line is parsed even without trailing newline renderer.lines().to_vec() @@ -58,8 +63,10 @@ pub fn render_markdown_with_positions( text: &str, images: ImageSupport, width: usize, + highlighting: Highlighting, ) -> MarkdownRenderResult { - let mut renderer = StreamingMarkdown::with_image_support(images.is_enabled()).with_width(width); + let mut renderer = + StreamingMarkdown::with_image_support(images.is_enabled(), highlighting).with_width(width); renderer.push(text); renderer.flush(); // Ensure last line is parsed even without trailing newline MarkdownRenderResult { @@ -71,16 +78,16 @@ pub fn render_markdown_with_positions( /// Render markdown text to ratatui lines (no image support). /// For image support, use `render_markdown_with_config`. #[must_use] -pub fn render_markdown(text: &str) -> Vec> { - render_markdown_with_config(text, ImageSupport::Disabled) +pub fn render_markdown(text: &str, highlighting: Highlighting) -> Vec> { + render_markdown_with_config(text, ImageSupport::Disabled, highlighting) } /// Extract image URLs from markdown text. /// /// Returns a list of (`alt_text`, url) pairs for all images found. #[must_use] -pub fn extract_images(text: &str) -> Vec<(String, String)> { - let mut renderer = StreamingMarkdown::new(); +pub fn extract_images(text: &str, highlighting: Highlighting) -> Vec<(String, String)> { + let mut renderer = StreamingMarkdown::new(highlighting); renderer.push(text); renderer.flush(); // Ensure last line is parsed even without trailing newline renderer.images() diff --git a/src/tui/ui/streaming_markdown.rs b/src/tui/ui/streaming_markdown.rs index 949740913..f8c1ac4d1 100644 --- a/src/tui/ui/streaming_markdown.rs +++ b/src/tui/ui/streaming_markdown.rs @@ -5,7 +5,7 @@ //! [`IncrementalMarkdown`] renders a reply again after every append while //! parsing each of its complete lines once. -use super::highlight::LineHighlighter; +use super::highlight::{Highlighting, LineHighlighter}; use super::wrap::wrap_line; use ratatui::prelude::*; use streamdown_parser::{InlineElement, InlineParser, ParseEvent, Parser}; @@ -17,6 +17,8 @@ pub const IMAGE_HEIGHT: usize = 8; /// Streaming markdown renderer with incremental parsing. #[derive(Debug)] pub struct StreamingMarkdown { + /// Syntaxes and theme code blocks are highlighted with. + highlighting: Highlighting, /// The incremental parser (maintains state between chunks) parser: Parser, /// Inline parser for block contents the block parser leaves raw @@ -73,18 +75,14 @@ pub struct StreamingMarkdown { highlighted_lines: usize, } -impl Default for StreamingMarkdown { - fn default() -> Self { - Self::new() - } -} - impl StreamingMarkdown { - /// Create a new streaming markdown renderer. - /// Uses default (no image support). For image support, use `with_image_support()`. + /// Create a new streaming markdown renderer, highlighting code with + /// `highlighting`. Uses default (no image support). For image support, + /// use `with_image_support()`. #[must_use] - pub fn new() -> Self { + pub fn new(highlighting: Highlighting) -> Self { Self { + highlighting, parser: Parser::new(), inline_parser: InlineParser::new(), content: String::new(), @@ -146,10 +144,10 @@ impl StreamingMarkdown { /// Create a new streaming markdown renderer with explicit image support flag. #[must_use] - pub fn with_image_support(images_supported: bool) -> Self { + pub fn with_image_support(images_supported: bool, highlighting: Highlighting) -> Self { Self { images_supported, - ..Self::new() + ..Self::new(highlighting) } } @@ -429,14 +427,16 @@ impl StreamingMarkdown { } ParseEvent::CodeBlockStart { language, .. } => { self.end_inline_line(); - self.code_highlighter = language.as_deref().and_then(super::highlight::highlighter); + self.code_highlighter = language + .as_deref() + .and_then(|language| self.highlighting.highlighter(language)); } ParseEvent::CodeBlockLine(line) => { self.end_inline_line(); let highlighted = self .code_highlighter .as_mut() - .map(|highlighter| super::highlight::line_spans(highlighter, &line)); + .map(|highlighter| self.highlighting.line_spans(highlighter, &line)); #[cfg(test)] { self.highlighted_lines += usize::from(highlighted.is_some()); @@ -647,7 +647,12 @@ impl Resume { } /// A renderer, with nothing rendered yet, that continues from here. - fn renderer(&self, images_supported: bool, max_width: usize) -> StreamingMarkdown { + fn renderer( + &self, + images_supported: bool, + max_width: usize, + highlighting: &Highlighting, + ) -> StreamingMarkdown { let mut state = self.parser.state().clone(); let reopen = self.code_fence_line.as_deref(); if reopen.is_some() { @@ -673,7 +678,8 @@ impl Resume { code_fence_line: self.code_fence_line.clone(), #[cfg(test)] parsed_lines: usize::from(replay.is_some()), - ..StreamingMarkdown::with_image_support(images_supported).with_width(max_width) + ..StreamingMarkdown::with_image_support(images_supported, highlighting.clone()) + .with_width(max_width) } } } @@ -698,6 +704,7 @@ impl Resume { /// whole text in one pass. #[derive(Debug)] pub struct IncrementalMarkdown { + highlighting: Highlighting, images_supported: bool, max_width: usize, /// Settled text after the last committed line. @@ -734,8 +741,9 @@ impl IncrementalMarkdown { /// A renderer of an empty reply; tables are laid out to `max_width` /// display columns. #[must_use] - pub fn new(images_supported: bool, max_width: usize) -> Self { + pub fn new(images_supported: bool, max_width: usize, highlighting: Highlighting) -> Self { Self { + highlighting, images_supported, max_width, settled: String::new(), @@ -759,7 +767,9 @@ impl IncrementalMarkdown { text.push_str(&self.settled); text.push_str(tail); - let mut renderer = self.resume.renderer(self.images_supported, self.max_width); + let mut renderer = + self.resume + .renderer(self.images_supported, self.max_width, &self.highlighting); let mut committing = true; let mut committed = 0; let mut start = 0; @@ -1008,7 +1018,7 @@ mod tests { #[test] fn test_basic_rendering() { - let mut md = StreamingMarkdown::new(); + let mut md = StreamingMarkdown::new(Highlighting::default()); md.push("# Hello\n\nWorld\n"); let lines = md.lines(); assert_ne!(lines, []); @@ -1016,7 +1026,7 @@ mod tests { #[test] fn test_incremental_streaming() { - let mut md = StreamingMarkdown::new(); + let mut md = StreamingMarkdown::new(Highlighting::default()); md.push("# Head"); // Incomplete line - not parsed yet assert_eq!(md.lines(), []); @@ -1041,7 +1051,7 @@ mod tests { #[test] fn test_image_rendering() { - let mut md = StreamingMarkdown::new(); + let mut md = StreamingMarkdown::new(Highlighting::default()); md.push("![My Image](https://example.com/test.png)\n"); let lines = md.lines(); let text: String = lines @@ -1055,7 +1065,7 @@ mod tests { #[test] fn test_horizontal_rule() { - let mut md = StreamingMarkdown::new(); + let mut md = StreamingMarkdown::new(Highlighting::default()); md.push("Hello\n\n---\n\nWorld\n"); md.flush(); let lines = md.lines(); @@ -1091,7 +1101,7 @@ mod tests { #[test] fn test_list_item_inline_markdown() { - let mut md = StreamingMarkdown::new(); + let mut md = StreamingMarkdown::new(Highlighting::default()); md.push("- **bold** and *italic* and `code` and [link](https://example.com)\n"); let lines = md.lines(); let text = flattened_text(lines); @@ -1114,7 +1124,7 @@ mod tests { #[test] fn test_list_item_cjk_bold() { - let mut md = StreamingMarkdown::new(); + let mut md = StreamingMarkdown::new(Highlighting::default()); md.push("- **SpaceX**:上季营收\n"); let lines = md.lines(); let text = flattened_text(lines); @@ -1129,7 +1139,7 @@ mod tests { #[test] fn test_blockquote_inline_markdown() { - let mut md = StreamingMarkdown::new(); + let mut md = StreamingMarkdown::new(Highlighting::default()); md.push("> **quoted** text\n"); let lines = md.lines(); let text = flattened_text(lines); @@ -1147,7 +1157,7 @@ mod tests { #[test] fn test_table_inline_markdown() { - let mut md = StreamingMarkdown::new(); + let mut md = StreamingMarkdown::new(Highlighting::default()); md.push("| **head** | plain |\n|---|---|\n| *it* | `c` |\n"); md.flush(); let lines = md.lines(); @@ -1166,7 +1176,7 @@ mod tests { #[test] fn test_table_columns_aligned() { - let mut md = StreamingMarkdown::new(); + let mut md = StreamingMarkdown::new(Highlighting::default()); md.push("| aa | b |\n|---|---|\n| c | dddd |\n"); md.flush(); let lines = md.lines(); @@ -1179,7 +1189,7 @@ mod tests { #[test] fn test_table_cjk_columns_aligned() { - let mut md = StreamingMarkdown::new(); + let mut md = StreamingMarkdown::new(Highlighting::default()); md.push("| 子 issue | 状态 |\n|---|---|\n| #18 登录 | ❌ 无 |\n"); md.flush(); let widths: Vec = md @@ -1204,7 +1214,7 @@ mod tests { #[test] fn test_table_missing_trailing_pipes() { // GFM makes outer pipes optional; models drop the trailing one. - let mut md = StreamingMarkdown::new(); + let mut md = StreamingMarkdown::new(Highlighting::default()); md.push("| a | b\n|---|---\n| c | d\n"); md.flush(); let text = flattened_text(md.lines()); @@ -1222,7 +1232,7 @@ mod tests { #[test] fn test_table_late_wide_row_realigns() { // A wider row streaming in later re-pads the rows already drawn. - let mut md = StreamingMarkdown::new(); + let mut md = StreamingMarkdown::new(Highlighting::default()); md.push("| a | b |\n|---|---|\n| c | d |\n"); md.push("| much longer | x |\n"); md.flush(); @@ -1309,7 +1319,7 @@ mod tests { #[test] fn test_table_wraps_cells_to_width() { for width in [40, 56, 72] { - let mut md = StreamingMarkdown::new().with_width(width); + let mut md = StreamingMarkdown::new(Highlighting::default()).with_width(width); md.push(WIDE_TABLE); md.flush(); let lines = md.lines().to_vec(); @@ -1331,7 +1341,7 @@ mod tests { #[test] fn test_table_wider_area_keeps_one_line_rows() { - let mut md = StreamingMarkdown::new().with_width(200); + let mut md = StreamingMarkdown::new(Highlighting::default()).with_width(200); md.push(WIDE_TABLE); md.flush(); let lines: Vec> = md @@ -1352,7 +1362,7 @@ mod tests { fn test_streaming_table_relayouts_open_table() { // Each arriving row re-lays-out the whole open table to the width. let width = 48; - let mut md = StreamingMarkdown::new().with_width(width); + let mut md = StreamingMarkdown::new(Highlighting::default()).with_width(width); let mut lines_seen = 0; for (index, line) in WIDE_TABLE.split_inclusive('\n').enumerate() { md.push(line); @@ -1373,7 +1383,7 @@ mod tests { #[test] fn test_table_pipe_line_below_is_not_absorbed() { // A pipe-less paragraph after a table ends it instead of joining. - let mut md = StreamingMarkdown::new(); + let mut md = StreamingMarkdown::new(Highlighting::default()); md.push("| a |\n|---|\n| b |\ntext after\n"); md.flush(); let rows: Vec = md @@ -1387,7 +1397,7 @@ mod tests { #[test] fn test_heading_inline_markdown() { - let mut md = StreamingMarkdown::new(); + let mut md = StreamingMarkdown::new(Highlighting::default()); md.push("## **Title** rest\n"); let lines = md.lines(); let text = flattened_text(lines); @@ -1402,7 +1412,7 @@ mod tests { #[test] fn test_code_block_highlighted() { - let mut md = StreamingMarkdown::new(); + let mut md = StreamingMarkdown::new(Highlighting::default()); md.push("```rust\nlet x = 1;\n```\n"); md.flush(); let lines = md.lines(); @@ -1422,7 +1432,7 @@ mod tests { #[test] fn test_code_block_unknown_language_plain() { - let mut md = StreamingMarkdown::new(); + let mut md = StreamingMarkdown::new(Highlighting::default()); md.push("```notalang\nlet x = 1;\n```\n"); md.flush(); let code = &md.lines()[0]; @@ -1435,7 +1445,7 @@ mod tests { // A comment opened on one line stays open into the next: the // second line's code spans all carry the comment color, which // differs from the default white of an unhighlighted block. - let mut md = StreamingMarkdown::new(); + let mut md = StreamingMarkdown::new(Highlighting::default()); md.push("```rust\n/* start\nend */\n```\n"); md.flush(); let lines = md.lines(); @@ -1450,7 +1460,7 @@ mod tests { #[test] fn test_block_inline_image_recorded() { - let mut md = StreamingMarkdown::new(); + let mut md = StreamingMarkdown::new(Highlighting::default()); md.push("- ![diagram](https://example.com/diagram.png)\n"); let images = md.images(); assert_eq!( @@ -1560,14 +1570,6 @@ plain 普通\n"; /// Rendered lines and image positions. type Rendered = (Vec>, Vec<(String, usize, String)>); - /// The render of `text` in one pass. - fn one_shot(text: &str, images_supported: bool) -> Rendered { - let mut md = StreamingMarkdown::with_image_support(images_supported).with_width(WIDTH); - md.push(text); - md.flush(); - (md.lines().to_vec(), md.image_positions().to_vec()) - } - /// The whole render a frame describes. fn whole(frame: &MarkdownFrame<'_>) -> Rendered { let mut lines = frame.committed.to_vec(); @@ -1581,6 +1583,7 @@ plain 普通\n"; /// text reaches it: every complete line settled, the unfinished line /// as the tail. struct Stream { + highlighting: Highlighting, markdown: IncrementalMarkdown, text: String, settled: usize, @@ -1588,13 +1591,25 @@ plain 普通\n"; impl Stream { fn new(images_supported: bool) -> Self { + let highlighting = Highlighting::default(); Self { - markdown: IncrementalMarkdown::new(images_supported, WIDTH), + markdown: IncrementalMarkdown::new(images_supported, WIDTH, highlighting.clone()), + highlighting, text: String::new(), settled: 0, } } + /// The render of the text so far in one pass. + fn one_shot(&self, images_supported: bool) -> Rendered { + let mut md = + StreamingMarkdown::with_image_support(images_supported, self.highlighting.clone()) + .with_width(WIDTH); + md.push(&self.text); + md.flush(); + (md.lines().to_vec(), md.image_positions().to_vec()) + } + /// Append `chunk` and render. fn push(&mut self, chunk: &str) -> Rendered { self.text.push_str(chunk); @@ -1609,7 +1624,7 @@ plain 普通\n"; #[test] fn push_walks_lines_without_losing_the_tail() { - let mut md = StreamingMarkdown::new(); + let mut md = StreamingMarkdown::new(Highlighting::default()); md.push("a\nb\nc"); assert_eq!(md.parsed_line_count, 2); assert_eq!(md.pending_line, "c"); @@ -1631,8 +1646,7 @@ plain 普通\n"; let mut shown = Vec::new(); for chunk in chunks(&reply, size) { let (lines, images) = stream.push(chunk); - let (expected_lines, expected_images) = - one_shot(&stream.text, images_supported); + let (expected_lines, expected_images) = stream.one_shot(images_supported); assert_eq!( lines, expected_lines, @@ -1678,7 +1692,7 @@ plain 普通\n"; let rendered = stream.push(chunk); assert_eq!( rendered, - one_shot(&stream.text, false), + stream.one_shot(false), "render differs after {:?} (chunks of {size})", stream.text ); @@ -1728,7 +1742,7 @@ plain 普通\n"; ); // A one-pass render costs the whole text: compare a sample. if index % 97 == 0 || index + 1 == pieces.len() { - assert_eq!(rendered, one_shot(&stream.text, false)); + assert_eq!(rendered, stream.one_shot(false)); } } let parsed = stream.markdown.parsed_lines();