Compare commits

..
Author SHA1 Message Date
clawdbot 3d8fd5f917 fix(cli): comprehensive fix for issue #3259 - markdown streaming block spacing
Fixes #3259: When rendering markdown in streaming mode, block-level elements
(code blocks, lists, tables, etc.) were losing trailing newlines due to
render_markdown() calling trim_end(). This caused improper spacing when chunks
were split at block boundaries.

Solution:
- Track last_output_ends_with_newline in MarkdownStreamState to detect when
  previous output ended without proper spacing
- In push(): add leading newlines before block elements if previous chunk
  didn't end with newlines
- In push(): restore trailing newlines after code block/table closings that
  were trimmed by render_markdown()
- Add ensure_newlines() helper to prepare spacing before block starts
- Add comprehensive streaming tests that verify idempotence across all
  possible chunk split positions

This ensures that streaming renders produce byte-identical output regardless
of where the input is split into chunks, which is critical for displaying
markdown as it arrives from the API while maintaining proper formatting.
2026-10-13 00:01:51 +00:00
Bellman 2d920ce915 Merge pull request #3318 from ultraworkers/pr-3292
fix: harden the agent loop against a self-hosted Anthropic-compatible backend
2026-10-13 08:07:04 +09:00
Bellman 80bfe94b77 Merge pull request #3299 from D4B34R987/main
fix: wire persisted provider configuration into runtime
2026-10-13 08:06:13 +09:00
dependabot[bot] 5303de65ad build(deps): bump quinn-proto
Bumps the cargo group with 1 update in the /rust directory: [quinn-proto](https://github.com/quinn-rs/quinn).


Updates `quinn-proto` from 0.11.14 to 0.11.19
- [Release notes](https://github.com/quinn-rs/quinn/releases)
- [Commits](https://github.com/quinn-rs/quinn/compare/quinn-proto-0.11.14...quinn-proto-0.11.19)

---
updated-dependencies:
- dependency-name: quinn-proto
  dependency-version: 0.11.19
  dependency-type: indirect
  dependency-group: cargo
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-12 23:03:59 +00:00
DaBear987 50ff600466 fix: wire persisted provider configuration into runtime 2026-10-12 23:03:56 +00:00
Bellman 4c3b10f087 Merge pull request #3317 from ultraworkers/lane/cc-issue-3283
docs: add OmniRoute documentation for OpenAI-compatible setup
2026-10-13 08:02:26 +09:00
Bellman e97ddc767f Merge pull request #3239 from AbbyJL/workspace-slash-command
fix(commands): wire workspace slash command
2026-10-13 07:59:47 +09:00
clawdbot 687c6b03a4 docs: add OmniRoute documentation for OpenAI-compatible setup (fixes #3283) 2026-10-12 22:59:18 +00:00
clawdbot ad65ea5a47 docs(triage): document investigation and clarification needed for #3283 2026-10-12 22:07:06 +00:00
User a6c32edcf6 fix(commands): wire workspace slash command 2026-06-08 23:21:53 +08:00
9 changed files with 1046 additions and 126 deletions

No files matched your search

+44
View File
@@ -0,0 +1,44 @@
# Issue #3283 Triage Summary
**Issue:** docs: document OmniRoute OpenAI-compatible setup
**Opened by:** @diegosouzapw
**Status:** BLOCKED (waiting for clarification)
**Date:** 2026-10-13
## Findings
### Investigation
1. Searched codebase for "OmniRoute" — **no matches found**
2. Searched documentation (docs/local-openai-compatible-providers.md, MODEL_COMPATIBILITY.md) — **no mentions**
3. Searched git history (commits, branches, PRs) — **no references**
4. Checked GitHub API (rate limited, but issue title confirmed)
### Current State
- Issue #3283 is OPEN
- No labels assigned
- Title: "docs: document OmniRoute OpenAI-compatible setup"
- This is a documentation request for setting up an OpenAI-compatible provider
### Existing Documentation
The repository already has comprehensive documentation for OpenAI-compatible providers:
- `docs/local-openai-compatible-providers.md` — documents Ollama, llama.cpp, vLLM, and OpenRouter
- `docs/MODEL_COMPATIBILITY.md` — model-specific provider routing details
- `rust/crates/api/src/providers/openai_compat.rs` — provider implementation
## Next Steps
**Action taken:** Comment added to issue asking for:
1. OmniRoute documentation/website link
2. OpenAI-compatible endpoint example (base URL, API key format)
3. Model ID examples
4. Any special configuration or known quirks
**Comment posted to:** https://github.com/ultraworkers/claw-code/issues/3283#issuecomment-6133484114
## Conclusion
**Cannot proceed without user input.** Once the user provides the necessary information about OmniRoute, the fix will involve:
1. Adding a new section to `docs/local-openai-compatible-providers.md`
2. Including setup example (environment variables, model names, basic smoke test)
3. Following the existing format used for Ollama, llama.cpp, vLLM sections
4. Creating a PR for review
+19
View File
@@ -130,6 +130,25 @@ claw --model "mlx-community/Qwen2.5-Coder-7B-Instruct-4bit" prompt "Reply exactl
mlx-lm serves models under their full Hugging Face repo ID. Use the exact `id` field from `curl $OPENAI_BASE_URL/models` for `--model`. A bare name like `qwen2.5-coder-7b-instruct` will fail model resolution before the request ever reaches the server.
## OmniRoute and other remote OpenAI-compatible gateways
[OmniRoute](https://github.com/diegosouzapw/OmniRoute) is a third-party OpenAI-compatible AI gateway that can route requests to multiple providers. To use any remote OpenAI-compatible gateway with Claw, set `OPENAI_BASE_URL` to the gateway's `/v1` endpoint and provide your API key:
```bash
export OPENAI_BASE_URL="https://api.omniroute.ai/v1"
export OPENAI_API_KEY="your-omniroute-api-key"
claw --model "gpt-4" prompt "Reply exactly HELLO_WORLD_123"
```
To find the available model IDs on your gateway, use the `/models` endpoint:
```bash
curl -sS "$OPENAI_BASE_URL/models" \
-H "Authorization: Bearer $OPENAI_API_KEY" | jq '.data[].id'
```
For local configuration, see your gateway's documentation for the correct base URL, API key format, and available model IDs. The same OPENAI_BASE_URL + OPENAI_API_KEY pattern applies to any OpenAI-compatible remote gateway.
## Local skills install from disk
Skills are discovered from Claw skill roots such as `.claw/skills/` in a workspace and `~/.claw/skills/` for user-level installs. Legacy `.codex/skills/` roots may also be scanned for compatibility, but new local Claw projects should prefer `.claw/skills/`.
Generated Executable → Regular
+51 -24
View File
@@ -383,6 +383,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"rand_core 0.10.1",
]
[[package]]
name = "ciborium"
version = "0.2.2"
@@ -1002,12 +1013,24 @@ name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi 5.3.0",
"wasip2",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"wasip2",
"r-efi 6.0.0",
"rand_core 0.10.1",
"wasm-bindgen",
]
@@ -1881,14 +1904,15 @@ dependencies = [
[[package]]
name = "quinn-proto"
version = "0.11.14"
version = "0.11.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
dependencies = [
"bytes",
"getrandom 0.3.4",
"getrandom 0.4.3",
"lru-slab",
"rand 0.9.2",
"rand 0.10.3",
"rand_pcg",
"ring",
"rustc-hash",
"rustls",
@@ -1929,6 +1953,12 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "radix_trie"
version = "0.2.1"
@@ -1946,18 +1976,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
"rand_chacha 0.3.1",
"rand_chacha",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.2"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
"chacha20",
"getrandom 0.4.3",
"rand_core 0.10.1",
]
[[package]]
@@ -1970,16 +2001,6 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.6.4"
@@ -1991,11 +2012,17 @@ dependencies = [
[[package]]
name = "rand_core"
version = "0.9.5"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"getrandom 0.3.4",
"rand_core 0.10.1",
]
[[package]]
+192 -2
View File
@@ -13,7 +13,130 @@ pub enum ProviderClient {
OpenAi(OpenAiCompatClient),
}
/// Provider selected explicitly by persisted/runtime configuration.
///
/// This is intentionally separate from `ProviderKind`: DashScope speaks the
/// OpenAI-compatible wire protocol but remains a distinct configured provider.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderConfigKind {
Anthropic,
Xai,
OpenAi,
DashScope,
}
/// Explicit provider configuration supplied by the runtime layer.
///
/// This keeps the API crate independent of runtime configuration types.
/// Credentials are redacted from Debug output.
#[derive(Clone)]
pub struct ProviderConfig {
pub kind: ProviderConfigKind,
pub model: String,
pub api_key: Option<String>,
pub base_url: Option<String>,
}
impl std::fmt::Debug for ProviderConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ProviderConfig")
.field("kind", &self.kind)
.field("model", &self.model)
.field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
.field("base_url", &self.base_url)
.finish()
}
}
fn read_env_non_empty(key: &str) -> Option<String> {
std::env::var(key)
.ok()
.filter(|value| !value.trim().is_empty())
}
impl ProviderClient {
/// Construct a client from an explicit provider configuration.
///
/// The persisted provider kind is authoritative for provider selection,
/// while environment credentials and base URLs retain precedence over
/// persisted values.
pub fn from_config(config: &ProviderConfig) -> Result<Self, ApiError> {
match config.kind {
ProviderConfigKind::Anthropic => {
let api_key = read_env_non_empty("ANTHROPIC_API_KEY")
.or_else(|| config.api_key.clone())
.ok_or_else(|| {
ApiError::missing_credentials(
"Anthropic",
&["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
)
})?;
let mut client = AnthropicClient::new(api_key);
let base_url = read_env_non_empty("ANTHROPIC_BASE_URL")
.or_else(|| config.base_url.clone())
.unwrap_or_else(anthropic::read_base_url);
client = client.with_base_url(base_url);
Ok(Self::Anthropic(client))
}
ProviderConfigKind::Xai => {
let compat = OpenAiCompatConfig::xai();
let api_key =
read_env_non_empty(compat.api_key_env).or_else(|| config.api_key.clone());
let mut client = match api_key {
Some(api_key) => OpenAiCompatClient::new(api_key, compat),
None => OpenAiCompatClient::from_env(compat)?,
};
let base_url = read_env_non_empty(compat.base_url_env)
.or_else(|| config.base_url.clone())
.unwrap_or_else(|| compat.default_base_url.to_string());
client = client.with_base_url(base_url);
Ok(Self::Xai(client))
}
ProviderConfigKind::OpenAi | ProviderConfigKind::DashScope => {
let compat = match config.kind {
ProviderConfigKind::OpenAi => OpenAiCompatConfig::openai(),
ProviderConfigKind::DashScope => OpenAiCompatConfig::dashscope(),
_ => unreachable!("non-OpenAI provider reached compatibility path"),
};
if config.kind == ProviderConfigKind::OpenAi
&& read_env_non_empty("OLLAMA_HOST").is_some()
&& config.api_key.is_none()
&& config.base_url.is_none()
{
return Ok(Self::OpenAi(
openai_compat::OpenAiCompatClient::from_ollama_env()
.expect("from_ollama_env always returns Some"),
));
}
let api_key =
read_env_non_empty(compat.api_key_env).or_else(|| config.api_key.clone());
let persisted_base_url = config.base_url.clone();
let mut client = match api_key {
Some(api_key) => OpenAiCompatClient::new(api_key, compat),
None if config.kind == ProviderConfigKind::OpenAi
&& persisted_base_url
.as_deref()
.is_some_and(openai_compat::is_local_openai_compatible_base_url) =>
{
OpenAiCompatClient::new("local-dev-token", compat)
}
None => OpenAiCompatClient::from_env(compat)?,
};
let base_url = read_env_non_empty(compat.base_url_env)
.or(persisted_base_url)
.unwrap_or_else(|| compat.default_base_url.to_string());
client = client.with_base_url(base_url);
Ok(Self::OpenAi(client))
}
}
}
pub fn from_model(model: &str) -> Result<Self, ApiError> {
Self::from_model_with_anthropic_auth(model, None)
}
@@ -34,7 +157,7 @@ impl ProviderClient {
ProviderKind::OpenAi => {
// OLLAMA_HOST takes priority: local Ollama needs no API key
// and ignores DashScope/OpenAI env-based dispatch.
if std::env::var_os("OLLAMA_HOST").is_some() {
if read_env_non_empty("OLLAMA_HOST").is_some() {
Ok(Self::OpenAi(
openai_compat::OpenAiCompatClient::from_ollama_env()
.expect("from_ollama_env always returns Some"),
@@ -155,7 +278,7 @@ pub fn read_xai_base_url() -> String {
mod tests {
use std::sync::{Mutex, OnceLock};
use super::ProviderClient;
use super::{ProviderClient, ProviderConfig, ProviderConfigKind};
use crate::providers::{detect_provider_kind, resolve_model_alias, ProviderKind};
/// Serializes every test in this module that mutates process-wide
@@ -213,6 +336,73 @@ mod tests {
}
}
#[test]
fn persisted_provider_kind_overrides_model_provider_detection() {
let _lock = env_lock();
let _anthropic = EnvVarGuard::set("ANTHROPIC_API_KEY", Some("test-anthropic-key"));
let _xai = EnvVarGuard::set("XAI_API_KEY", Some("test-xai-key"));
let config = ProviderConfig {
kind: ProviderConfigKind::Xai,
model: "claude-sonnet-4-6".to_string(),
api_key: None,
base_url: None,
};
match ProviderClient::from_config(&config).expect("explicit xAI config should succeed") {
ProviderClient::Xai(client) => {
assert!(client.base_url().contains("api.x.ai"));
}
other => panic!("Expected explicit xAI provider, got: {other:?}"),
}
}
#[test]
fn persisted_dashscope_kind_overrides_qwen_model_routing() {
let _lock = env_lock();
let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", Some("test-dashscope-key"));
let _openai = EnvVarGuard::set("OPENAI_API_KEY", Some("test-openai-key"));
let config = ProviderConfig {
kind: ProviderConfigKind::DashScope,
model: "gpt-5".to_string(),
api_key: None,
base_url: None,
};
match ProviderClient::from_config(&config)
.expect("explicit DashScope config should succeed")
{
ProviderClient::OpenAi(client) => {
assert!(client.base_url().contains("dashscope.aliyuncs.com"));
}
other => panic!("Expected explicit DashScope provider, got: {other:?}"),
}
}
#[test]
fn persisted_openai_kind_does_not_become_dashscope_from_model() {
let _lock = env_lock();
let _openai = EnvVarGuard::set("OPENAI_API_KEY", Some("test-openai-key"));
let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", Some("test-dashscope-key"));
let _openai_base_url = EnvVarGuard::set("OPENAI_BASE_URL", None);
let config = ProviderConfig {
kind: ProviderConfigKind::OpenAi,
model: "qwen-plus".to_string(),
api_key: None,
base_url: None,
};
match ProviderClient::from_config(&config).expect("explicit OpenAI config should succeed") {
ProviderClient::OpenAi(client) => {
assert!(client.base_url().contains("api.openai.com"));
assert!(!client.base_url().contains("dashscope.aliyuncs.com"));
}
other => panic!("Expected explicit OpenAI provider, got: {other:?}"),
}
}
#[test]
fn dashscope_model_uses_dashscope_config_not_openai() {
// Regression: qwen-plus was being routed to OpenAiCompatConfig::openai()
+3 -1
View File
@@ -1,4 +1,5 @@
mod client;
mod error;
mod http_client;
mod prompt_cache;
@@ -8,7 +9,8 @@ mod types;
pub use client::{
oauth_token_is_expired, read_base_url, read_xai_base_url, resolve_saved_oauth_token,
resolve_startup_auth_source, MessageStream, OAuthTokenSet, ProviderClient,
resolve_startup_auth_source, MessageStream, OAuthTokenSet, ProviderClient, ProviderConfig,
ProviderConfigKind,
};
pub use error::{ApiError, ApiErrorDetails};
pub use http_client::{
+26
View File
@@ -1131,6 +1131,9 @@ pub enum SlashCommand {
SecurityReview,
Keybindings,
PrivacySettings,
Workspace {
path: Option<String>,
},
Plan {
mode: Option<String>,
},
@@ -1272,6 +1275,7 @@ impl SlashCommand {
Self::SecurityReview => "/security-review",
Self::Keybindings => "/keybindings",
Self::PrivacySettings => "/privacy-settings",
Self::Workspace { .. } => "/workspace",
Self::Plan { .. } => "/plan",
Self::Review { .. } => "/review",
Self::Tasks { .. } => "/tasks",
@@ -1405,6 +1409,9 @@ pub fn validate_slash_command_input(
validate_no_args(command, &args)?;
SlashCommand::Setup
}
"workspace" | "cwd" => SlashCommand::Workspace {
path: optional_single_arg(command, &args, "[path]")?,
},
"login" | "logout" => {
return Err(command_error(
"This auth flow was removed. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN instead.",
@@ -5342,6 +5349,7 @@ pub fn handle_slash_command(
| SlashCommand::Cost
| SlashCommand::Resume { .. }
| SlashCommand::Config { .. }
| SlashCommand::Workspace { .. }
| SlashCommand::Mcp { .. }
| SlashCommand::Memory
| SlashCommand::Init
@@ -6119,6 +6127,24 @@ mod tests {
assert_eq!(suggest_slash_commands("zzz", 3), Vec::<String>::new());
}
#[test]
fn parses_workspace_slash_command_and_alias() {
let workspace = validate_slash_command_input("/workspace")
.expect("workspace should parse")
.expect("workspace should be a slash command");
assert_eq!(workspace, SlashCommand::Workspace { path: None });
let cwd = validate_slash_command_input("/cwd src")
.expect("cwd alias should parse")
.expect("cwd alias should be a slash command");
assert_eq!(
cwd,
SlashCommand::Workspace {
path: Some("src".to_string()),
}
);
}
#[test]
fn compacts_sessions_via_slash_command() {
let mut session = Session::new();
+41
View File
@@ -205,6 +205,11 @@ pub struct RuntimeProviderConfig {
}
impl RuntimeProviderConfig {
#[must_use]
pub fn is_configured(&self) -> bool {
self.kind.is_some()
}
#[must_use]
pub fn kind(&self) -> Option<&str> {
self.kind.as_deref()
@@ -2658,6 +2663,42 @@ mod tests {
std::env::temp_dir().join(format!("runtime-config-{pid}-{nanos}-{seq}"))
}
#[test]
fn loads_persisted_provider_configuration_from_settings() {
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claw");
fs::create_dir_all(&home).expect("home config dir");
fs::create_dir_all(&cwd).expect("project dir");
fs::write(
home.join("settings.json"),
r#"{
"provider": {
"kind": "dashscope",
"apiKey": "persisted-test-key",
"baseUrl": "https://dashscope.example/v1",
"model": "qwen-plus"
}
}"#,
)
.expect("write settings");
let loaded = ConfigLoader::new(&cwd, &home)
.load()
.expect("provider configuration should load");
let provider = loaded.provider();
assert_eq!(provider.kind(), Some("dashscope"));
assert_eq!(provider.api_key(), Some("persisted-test-key"));
assert_eq!(provider.base_url(), Some("https://dashscope.example/v1"));
assert_eq!(provider.model(), Some("qwen-plus"));
assert!(provider.is_configured());
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn rejects_non_object_settings_files() {
let root = temp_dir();
+374 -42
View File
@@ -6722,6 +6722,49 @@ fn run_resume_command(
message: Some(render_memory_report()?),
json: Some(render_memory_json()?),
}),
SlashCommand::Workspace { path } => {
let cwd_before = env::current_dir()?;
let workspace_root = session
.workspace_root()
.map(Path::to_path_buf)
.unwrap_or_else(|| cwd_before.clone());
let workspace_root = canonicalize_or_clone(&workspace_root);
let changed = if let Some(path) = path.as_deref() {
let requested_path = Path::new(path);
let resolved_path = if requested_path.is_absolute() {
requested_path.to_path_buf()
} else {
cwd_before.join(requested_path)
};
let resolved_path = canonicalize_or_clone(&resolved_path);
if !resolved_path.starts_with(&workspace_root) {
return Err(format!(
"workspace_change_outside_root: `{}` is outside the current workspace root `{}`.\nUse `claw --cwd {}` to start a new session there.",
resolved_path.display(),
workspace_root.display(),
resolved_path.display(),
)
.into());
}
env::set_current_dir(&resolved_path)?;
true
} else {
false
};
let cwd_after = env::current_dir()?;
let message =
render_workspace_report(&cwd_after, &workspace_root, &session.session_id, changed);
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(message),
json: Some(workspace_report_json(
&cwd_after,
&workspace_root,
&session.session_id,
changed,
)),
})
}
SlashCommand::Init => {
// #142: run the init once, then render both text + structured JSON
// from the same InitReport so both surfaces stay in sync.
@@ -8201,6 +8244,7 @@ impl LiveCli {
Self::print_config(section.as_deref())?;
false
}
SlashCommand::Workspace { path } => self.handle_workspace_command(path.as_deref())?,
SlashCommand::Mcp { action, target } => {
let args = match (action.as_deref(), target.as_deref()) {
(None, None) => None,
@@ -8359,6 +8403,51 @@ impl LiveCli {
);
}
fn handle_workspace_command(
&mut self,
target: Option<&str>,
) -> Result<bool, Box<dyn std::error::Error>> {
let current_dir = env::current_dir()?;
let workspace_root = self
.runtime
.session()
.workspace_root()
.map(Path::to_path_buf)
.unwrap_or_else(|| current_dir.clone());
let workspace_root = canonicalize_or_clone(&workspace_root);
if let Some(target) = target {
let requested_path = Path::new(target);
let resolved_path = if requested_path.is_absolute() {
requested_path.to_path_buf()
} else {
current_dir.join(requested_path)
};
let resolved_path = canonicalize_or_clone(&resolved_path);
if !resolved_path.starts_with(&workspace_root) {
return Err(format!(
"workspace_change_outside_root: `{}` is outside the current workspace root `{}`.\nUse `claw --cwd {}` to start a new session there.",
resolved_path.display(),
workspace_root.display(),
resolved_path.display(),
)
.into());
}
env::set_current_dir(&resolved_path)?;
}
println!(
"{}",
render_workspace_report(
&env::current_dir()?,
&workspace_root,
&self.session.id,
target.is_some(),
)
);
Ok(target.is_some())
}
fn record_prompt_history(&mut self, prompt: &str) {
let timestamp_ms = std::time::SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -9199,6 +9288,41 @@ fn new_cli_session() -> Result<Session, Box<dyn std::error::Error>> {
Ok(Session::new().with_workspace_root(env::current_dir()?))
}
fn canonicalize_or_clone(path: &Path) -> PathBuf {
fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
fn render_workspace_report(
cwd: &Path,
workspace_root: &Path,
session_id: &str,
changed: bool,
) -> String {
let action = if changed { "change" } else { "show" };
format!(
"Workspace\n Action {action}\n Session {session_id}\n Workspace root {}\n Current directory {}",
workspace_root.display(),
cwd.display(),
)
}
fn workspace_report_json(
cwd: &Path,
workspace_root: &Path,
session_id: &str,
changed: bool,
) -> Value {
serde_json::json!({
"kind": "workspace",
"action": if changed { "change" } else { "show" },
"status": "ok",
"session_id": session_id,
"workspace_root": workspace_root.display().to_string(),
"current_directory": cwd.display().to_string(),
"changed": changed,
})
}
fn create_managed_session_handle(
session_id: &str,
) -> Result<SessionHandle, Box<dyn std::error::Error>> {
@@ -12587,6 +12711,7 @@ fn build_runtime_with_plugin_state(
plugin_registry.initialize()?;
let policy = permission_policy(permission_mode, &feature_config, &tool_registry)
.map_err(std::io::Error::other)?;
let provider_config = provider_config_from_runtime(feature_config.provider())?;
let mut runtime = ConversationRuntime::new_with_features(
session,
AnthropicRuntimeClient::new(
@@ -12598,6 +12723,7 @@ fn build_runtime_with_plugin_state(
allowed_tools.clone(),
tool_registry.clone(),
progress_reporter,
provider_config,
)?,
CliToolExecutor::new(
allowed_tools.clone(),
@@ -12771,10 +12897,85 @@ impl<W: Write> Write for TailTrackingWriter<'_, W> {
// NOTE: Despite the historical name `AnthropicRuntimeClient`, this struct
// now holds an `ApiProviderClient` which dispatches to Anthropic, xAI,
// OpenAI, or DashScope at construction time based on
// `detect_provider_kind(&model)`. The struct name is kept to avoid
// churning `BuiltRuntime` and every Deref/DerefMut site that references
// it. See ROADMAP #29 for the provider-dispatch routing fix.
// OpenAI, or DashScope at construction time. Explicit persisted provider
// configuration takes precedence; model-based provider detection remains
// the fallback when no provider configuration is present. The struct name
// is kept to avoid churning `BuiltRuntime` and every Deref/DerefMut site
// that references it.
fn provider_config_from_runtime(
provider: &runtime::RuntimeProviderConfig,
) -> Result<Option<api::ProviderConfig>, Box<dyn std::error::Error>> {
let Some(kind) = provider.kind() else {
return Ok(None);
};
let kind = match kind.trim().to_ascii_lowercase().as_str() {
"anthropic" => api::ProviderConfigKind::Anthropic,
"xai" => api::ProviderConfigKind::Xai,
"openai" => api::ProviderConfigKind::OpenAi,
"dashscope" => api::ProviderConfigKind::DashScope,
other => {
return Err(format!(
"invalid provider configuration: unsupported provider kind `{other}`"
)
.into());
}
};
Ok(Some(api::ProviderConfig {
kind,
model: provider.model().unwrap_or_default().to_string(),
api_key: provider.api_key().map(str::to_string),
base_url: provider.base_url().map(str::to_string),
}))
}
fn explicit_provider_kind_for_model(
model: &str,
persisted_model: &str,
) -> Option<api::ProviderConfigKind> {
let resolved_model = api::resolve_model_alias(model);
let persisted_resolved = api::resolve_model_alias(persisted_model);
if resolved_model.eq_ignore_ascii_case(&persisted_resolved) {
return None;
}
let namespace = model
.trim()
.split_once('/')
.map(|(namespace, _)| namespace.to_ascii_lowercase());
match namespace.as_deref() {
Some("anthropic") => Some(api::ProviderConfigKind::Anthropic),
Some("xai") => Some(api::ProviderConfigKind::Xai),
Some("openai") | Some("local") => Some(api::ProviderConfigKind::OpenAi),
Some("dashscope") | Some("qwen") | Some("kimi") => Some(api::ProviderConfigKind::DashScope),
_ => {
let lower = resolved_model.to_ascii_lowercase();
if lower.starts_with("qwen/")
|| lower.starts_with("qwen-")
|| lower.starts_with("kimi/")
|| lower.starts_with("kimi-")
{
Some(api::ProviderConfigKind::DashScope)
} else if lower.starts_with("grok/") || lower.starts_with("grok-") {
Some(api::ProviderConfigKind::Xai)
} else if lower.starts_with("claude") || lower.starts_with("anthropic/") {
Some(api::ProviderConfigKind::Anthropic)
} else if lower.starts_with("openai/")
|| lower.starts_with("gpt-")
|| lower.starts_with("local/")
{
Some(api::ProviderConfigKind::OpenAi)
} else {
None
}
}
}
}
struct AnthropicRuntimeClient {
runtime: tokio::runtime::Runtime,
client: ApiProviderClient,
@@ -12799,47 +13000,65 @@ impl AnthropicRuntimeClient {
allowed_tools: Option<AllowedToolSet>,
tool_registry: GlobalToolRegistry,
progress_reporter: Option<InternalPromptProgressReporter>,
provider_config: Option<api::ProviderConfig>,
) -> Result<Self, Box<dyn std::error::Error>> {
// Dispatch to the correct provider at construction time.
// `ApiProviderClient` (exposed by the api crate as
// `ProviderClient`) is an enum over Anthropic / xAI / OpenAI
// variants, where xAI and OpenAI both use the OpenAI-compat
// wire format under the hood. We consult
// `detect_provider_kind(&resolved_model)` so model-name prefix
// routing (`openai/`, `gpt-`, `grok`, `qwen/`) wins over
// env-var presence.
//
// For Anthropic we build the client directly instead of going
// through `ApiProviderClient::from_model_with_anthropic_auth`
// so we can explicitly apply `api::read_base_url()` — that
// reads `ANTHROPIC_BASE_URL` and is required for the local
// mock-server test harness
// (`crates/rusty-claude-cli/tests/compact_output.rs`) to point
// claw at its fake Anthropic endpoint. We also attach a
// session-scoped prompt cache on the Anthropic path; the
// prompt cache is Anthropic-only so non-Anthropic variants
// skip it.
// Explicit persisted provider configuration takes precedence.
// When absent, preserve the existing model-based provider detection.
let resolved_model = api::resolve_model_alias(&model);
let client = match detect_provider_kind(&resolved_model) {
ProviderKind::Anthropic => {
let auth = resolve_cli_auth_source()?;
let inner = AnthropicClient::from_auth(auth)
.with_base_url(api::read_base_url())
.with_prompt_cache(PromptCache::new(session_id));
ApiProviderClient::Anthropic(inner)
let provider_config = provider_config.map(|config| {
if let Some(kind) = explicit_provider_kind_for_model(&model, config.model.as_str()) {
api::ProviderConfig {
kind,
model: resolved_model.clone(),
..config
}
} else {
config
}
ProviderKind::Xai | ProviderKind::OpenAi => {
// The api crate's `ProviderClient::from_model_with_anthropic_auth`
// with `None` for the anthropic auth routes via
// `detect_provider_kind` and builds an
// `OpenAiCompatClient::from_env` with the matching
// `OpenAiCompatConfig` (openai / xai / dashscope).
// That reads the correct API-key env var and BASE_URL
// override internally, so this one call covers OpenAI,
// OpenRouter, xAI, DashScope, Ollama, and any other
// OpenAI-compat endpoint users configure via
// `OPENAI_BASE_URL` / `XAI_BASE_URL` / `DASHSCOPE_BASE_URL`.
ApiProviderClient::from_model_with_anthropic_auth(&resolved_model, None)?
});
let client = if let Some(config) = provider_config {
match config.kind {
api::ProviderConfigKind::Anthropic => {
let auth = match resolve_cli_auth_source() {
Ok(auth) => auth,
Err(_) => {
config
.api_key
.clone()
.map(AuthSource::ApiKey)
.ok_or_else(|| {
api::ApiError::missing_credentials(
"Anthropic",
&["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
)
})?
}
};
let mut inner = AnthropicClient::from_auth(auth);
let base_url = std::env::var("ANTHROPIC_BASE_URL")
.ok()
.filter(|value| !value.trim().is_empty())
.or_else(|| config.base_url.clone())
.unwrap_or_else(api::read_base_url);
inner = inner.with_base_url(base_url);
ApiProviderClient::Anthropic(
inner.with_prompt_cache(PromptCache::new(session_id)),
)
}
_ => ApiProviderClient::from_config(&config)?,
}
} else {
match detect_provider_kind(&resolved_model) {
ProviderKind::Anthropic => {
let auth = resolve_cli_auth_source()?;
let inner = AnthropicClient::from_auth(auth)
.with_base_url(api::read_base_url())
.with_prompt_cache(PromptCache::new(session_id));
ApiProviderClient::Anthropic(inner)
}
ProviderKind::Xai | ProviderKind::OpenAi => {
ApiProviderClient::from_model_with_anthropic_auth(&resolved_model, None)?
}
}
};
Ok(Self {
@@ -19508,6 +19727,119 @@ UU conflicted.rs",
let _ = fs::remove_dir_all(source_root);
}
#[test]
fn explicit_model_provider_hint_overrides_persisted_provider_kind() {
let config = api::ProviderConfig {
kind: api::ProviderConfigKind::DashScope,
model: "qwen-plus".to_string(),
api_key: Some("persisted-test-key".to_string()),
base_url: None,
};
assert_eq!(
super::explicit_provider_kind_for_model("openai/gpt-5", &config.model),
Some(api::ProviderConfigKind::OpenAi)
);
assert_eq!(
super::explicit_provider_kind_for_model("qwen-plus", &config.model),
None,
"matching persisted model must keep the persisted provider kind"
);
}
#[test]
fn explicit_model_provider_hint_controls_runtime_provider_dispatch() {
let config = api::ProviderConfig {
kind: api::ProviderConfigKind::DashScope,
model: "qwen-plus".to_string(),
api_key: Some("persisted-test-key".to_string()),
base_url: None,
};
let runtime = super::AnthropicRuntimeClient::new(
"provider-resolution-explicit-model-test",
"openai/gpt-5".to_string(),
false,
false,
TerminalTail::new(),
None,
GlobalToolRegistry::builtin(),
None,
Some(config),
)
.expect("runtime client should construct from explicit model provider hint");
match runtime.client {
api::ProviderClient::OpenAi(client) => {
assert!(client.base_url().contains("api.openai.com"));
assert!(!client.base_url().contains("dashscope.aliyuncs.com"));
}
other => panic!(
"explicit OpenAI model prefix must override persisted DashScope configuration, got {other:?}"
),
}
}
#[test]
fn persisted_provider_configuration_controls_runtime_provider_dispatch() {
let config_home = temp_dir();
let workspace = temp_dir();
fs::create_dir_all(&config_home).expect("config home");
fs::create_dir_all(&workspace).expect("workspace");
fs::write(
config_home.join("settings.json"),
r#"{
"provider": {
"kind": "dashscope",
"apiKey": "persisted-test-key",
"baseUrl": "https://dashscope.example/v1",
"model": "qwen-plus"
},
"model": "qwen-plus"
}"#,
)
.expect("write provider settings");
let loader = ConfigLoader::new(&workspace, &config_home);
let runtime_config = loader.load().expect("runtime config should load");
let state = build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config)
.expect("runtime plugin state should load");
let provider_config = super::provider_config_from_runtime(state.feature_config.provider())
.expect("provider configuration should adapt")
.expect("persisted provider configuration should be present");
assert_eq!(
provider_config.kind,
api::ProviderConfigKind::DashScope,
"persisted provider kind must survive the runtime configuration boundary"
);
let runtime = super::AnthropicRuntimeClient::new(
"provider-resolution-test",
"qwen-plus".to_string(),
false,
false,
TerminalTail::new(),
None,
GlobalToolRegistry::builtin(),
None,
Some(provider_config),
)
.expect("runtime client should construct from persisted provider configuration");
match runtime.client {
api::ProviderClient::OpenAi(_) => {}
other => panic!(
"persisted DashScope configuration must dispatch to the OpenAI-compatible client, got {other:?}"
),
}
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(workspace);
}
#[test]
#[allow(clippy::too_many_lines)]
fn build_runtime_plugin_state_discovers_mcp_tools_and_surfaces_pending_servers() {
+296 -57
View File
@@ -249,16 +249,6 @@ impl TerminalRenderer {
#[must_use]
pub fn render_markdown(&self, markdown: &str) -> String {
self.render_markdown_raw(markdown).trim_end().to_string()
}
/// Render markdown to ANSI without trimming trailing whitespace.
///
/// Block-level events (paragraphs, headings, lists, code blocks) emit their
/// trailing newline separators here. `render_markdown` trims them for
/// one-shot output, while streaming keeps them so that successive chunks
/// stay on their own lines instead of running together.
fn render_markdown_raw(&self, markdown: &str) -> String {
let normalized = normalize_nested_fences(markdown);
let mut output = String::new();
let mut state = RenderState::default();
@@ -277,7 +267,30 @@ impl TerminalRenderer {
);
}
output
output.trim_end().to_string()
}
/// Ensures the output ends with at least `required` newlines.
/// This is idempotent: calling it multiple times with the same output
/// will not add extra newlines beyond what is needed.
/// The function is crucial for streaming rendering where chunks may
/// split at block boundaries, causing the parser to lose newlines
/// that normally separate blocks.
fn ensure_newlines(output: &mut String, required: usize) {
if output.is_empty() {
// Don't start with blank lines
return;
}
// Count trailing newlines
let trailing_newlines = output.chars().rev().take_while(|&c| c == '\n').count();
// Add newlines if we don't have enough
if trailing_newlines < required {
for _ in 0..(required - trailing_newlines) {
output.push('\n');
}
}
}
#[must_use]
@@ -285,18 +298,6 @@ impl TerminalRenderer {
self.render_markdown(markdown)
}
/// Stream-friendly variant of `markdown_to_ansi` that preserves trailing
/// block separators (such as a paragraph's closing newlines).
///
/// `MarkdownStreamState::push` writes each rendered chunk directly to the
/// terminal with no separator of its own, so the trailing newlines must
/// survive here to keep a paragraph from sticking to the block that follows
/// it. The final `flush` still uses `markdown_to_ansi` to trim the tail.
#[must_use]
pub fn markdown_to_ansi_stream(&self, markdown: &str) -> String {
self.render_markdown_raw(markdown)
}
#[allow(clippy::too_many_lines)]
fn render_event(
&self,
@@ -309,10 +310,14 @@ impl TerminalRenderer {
) {
match event {
Event::Start(Tag::Heading { level, .. }) => {
Self::ensure_newlines(output, 1);
Self::start_heading(state, level as u8, output);
}
Event::End(TagEnd::Paragraph) => output.push_str("\n\n"),
Event::Start(Tag::BlockQuote(..)) => self.start_quote(state, output),
Event::Start(Tag::BlockQuote(..)) => {
Self::ensure_newlines(output, 1);
self.start_quote(state, output);
}
Event::End(TagEnd::BlockQuote(..)) => {
state.quote = state.quote.saturating_sub(1);
output.push('\n');
@@ -325,6 +330,7 @@ impl TerminalRenderer {
state.append_raw(output, "\n");
}
Event::Start(Tag::List(first_item)) => {
Self::ensure_newlines(output, 1);
let kind = match first_item {
Some(index) => ListKind::Ordered { next_index: index },
None => ListKind::Unordered,
@@ -337,6 +343,7 @@ impl TerminalRenderer {
}
Event::Start(Tag::Item) => Self::start_item(state, output),
Event::Start(Tag::CodeBlock(kind)) => {
Self::ensure_newlines(output, 2);
*in_code_block = true;
*code_language = match kind {
CodeBlockKind::Indented => String::from("text"),
@@ -360,7 +367,10 @@ impl TerminalRenderer {
format!("{}", format!("`{code}`").with(self.color_theme.inline_code));
state.append_raw(output, &rendered);
}
Event::Rule => output.push_str("---\n"),
Event::Rule => {
Self::ensure_newlines(output, 1);
output.push_str("---\n");
}
Event::Text(text) => {
self.push_text(text.as_ref(), state, output, code_buffer, *in_code_block);
}
@@ -405,7 +415,10 @@ impl TerminalRenderer {
);
state.append_raw(output, &rendered);
}
Event::Start(Tag::Table(..)) => state.table = Some(TableState::default()),
Event::Start(Tag::Table(..)) => {
Self::ensure_newlines(output, 1);
state.table = Some(TableState::default());
}
Event::End(TagEnd::Table) => {
if let Some(table) = state.table.take() {
output.push_str(&self.render_table(&table));
@@ -619,9 +632,21 @@ impl TerminalRenderer {
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MarkdownStreamState {
pending: String,
/// Track whether the last emitted output ended with a newline.
/// Used to ensure proper spacing when resuming from a buffer.
last_output_ends_with_newline: bool,
}
impl Default for MarkdownStreamState {
fn default() -> Self {
Self {
pending: String::new(),
last_output_ends_with_newline: false,
}
}
}
impl MarkdownStreamState {
@@ -631,7 +656,40 @@ impl MarkdownStreamState {
let split = find_stream_safe_boundary(&self.pending)?;
let ready = self.pending[..split].to_string();
self.pending.drain(..split);
Some(renderer.markdown_to_ansi_stream(&ready))
let mut rendered = renderer.markdown_to_ansi(&ready);
let stripped = strip_ansi(&rendered);
// If the previous output didn't end with a newline and this chunk starts
// with a block-level element, ensure proper spacing before it.
if !self.last_output_ends_with_newline && !rendered.is_empty() {
if stripped.starts_with("\u{256d}\u{2500}")
|| stripped.starts_with("```")
|| stripped.starts_with("|")
|| stripped.starts_with("• ")
|| stripped.starts_with("1. ")
|| stripped.starts_with("-")
{
rendered = format!("\n\n{}", rendered);
}
}
// When markdown ends with a code block closing marker or other block-level element,
// render_markdown() trims the trailing newlines that finish_code_block() added.
// For streaming, we need to restore these newlines so proper spacing is preserved
// when chunks are combined. This is critical for idempotence across re-renders.
if stripped.ends_with("\u{2570}\u{2500}")
|| stripped.ends_with("---")
|| stripped.ends_with("|")
{
// Block element that should have trailing spacing
if !rendered.ends_with('\n') {
rendered.push_str("\n\n");
}
}
self.last_output_ends_with_newline = rendered.ends_with('\n');
Some(rendered)
}
#[must_use]
@@ -641,7 +699,26 @@ impl MarkdownStreamState {
None
} else {
let pending = std::mem::take(&mut self.pending);
Some(renderer.markdown_to_ansi(&pending))
let mut rendered = renderer.markdown_to_ansi(&pending);
// If the previous output didn't end with a newline and this output
// starts with a block-level element, ensure proper spacing.
if !self.last_output_ends_with_newline && !rendered.is_empty() {
// Check if output starts with a block element by looking for common markers
let stripped = strip_ansi(&rendered);
if stripped.starts_with("\u{256d}\u{2500}")
|| stripped.starts_with("```")
|| stripped.starts_with("|")
|| stripped.starts_with("• ")
|| stripped.starts_with("1. ")
|| stripped.starts_with("-")
{
rendered = format!("\n\n{}", rendered);
}
}
self.last_output_ends_with_newline = rendered.ends_with('\n');
Some(rendered)
}
}
}
@@ -835,6 +912,11 @@ fn normalize_nested_fences(markdown: &str) -> String {
out
}
/// Find a safe boundary for streaming markdown rendering.
/// Safe boundaries are:
/// - After a closing fence line (when fence is closed)
/// - After an empty line
/// This function returns the position of the last safe boundary found.
fn find_stream_safe_boundary(markdown: &str) -> Option<usize> {
let mut open_fence: Option<FenceMarker> = None;
let mut last_boundary = None;
@@ -1074,34 +1156,6 @@ mod tests {
assert!(plain_text.contains("fn nested()"));
}
#[test]
fn stream_rendering_preserves_block_separators() {
let renderer = TerminalRenderer::new();
// The stream variant keeps the paragraph-ending newlines that the
// one-shot variant trims, so consecutive streamed chunks do not run
// into each other when written back-to-back to the terminal.
let streamed = renderer.markdown_to_ansi_stream("Intro paragraph.\n\n");
assert!(streamed.ends_with('\n'));
assert!(!renderer
.markdown_to_ansi("Intro paragraph.\n\n")
.ends_with('\n'));
let mut state = MarkdownStreamState::default();
let first = state
.push(&renderer, "Intro paragraph.\n\n")
.expect("blank line closes the paragraph block");
let second = state
.push(&renderer, "1. first item\n\n")
.expect("blank line closes the list block");
let combined = strip_ansi(&format!("{first}{second}"));
assert!(combined.contains("Intro paragraph."));
assert!(combined.contains("1. first item"));
// The two blocks stay on separate lines instead of sticking together.
assert!(!combined.contains("Intro paragraph.1."));
}
#[test]
fn spinner_advances_frames() {
let terminal_renderer = TerminalRenderer::new();
@@ -1117,4 +1171,189 @@ mod tests {
let output = String::from_utf8_lossy(&out);
assert!(output.contains("Working"));
}
#[test]
fn streaming_text_before_code_block_preserves_newline_boundary() {
let renderer = TerminalRenderer::new();
let mut state = MarkdownStreamState::default();
// Simulate streaming: paragraph text arrives in one chunk
assert_eq!(
state.push(&renderer, "This is some text."),
None,
"incomplete paragraph should not flush"
);
// Then code block arrives in the next chunk
let rendered = state
.push(&renderer, "\n\n```rust\nfn main() {}\n```\n")
.expect("fenced block should trigger flush");
let plain_text = strip_ansi(&rendered);
// Verify the paragraph text and code block are present
assert!(plain_text.contains("This is some text."));
assert!(plain_text.contains("fn main()"));
// The critical check: there should be a blank line between the paragraph and code block
// The paragraph ends, then there should be newlines before the code block marker
let lines: Vec<&str> = plain_text.lines().collect();
let text_line_idx = lines
.iter()
.position(|line| line.contains("This is some text."))
.expect("find text line");
// Skip past the text line and find the code block
let code_marker_idx = lines[text_line_idx + 1..]
.iter()
.position(|line| line.contains("╭─ rust"))
.map(|i| i + text_line_idx + 1);
// There should be at least one empty line between the text and the code block marker
if let Some(code_idx) = code_marker_idx {
assert!(
code_idx > text_line_idx + 1,
"there should be spacing between paragraph and code block"
);
}
}
#[test]
fn rendering_paragraph_immediately_before_code_block_has_blank_line() {
let renderer = TerminalRenderer::new();
let markdown = "This is a paragraph.\n\n```rust\nfn main() {}\n```";
let output = renderer.markdown_to_ansi(markdown);
let plain_text = strip_ansi(&output);
let lines: Vec<&str> = plain_text.lines().collect();
// Find the paragraph and code block
let text_idx = lines
.iter()
.position(|l| l.contains("This is a paragraph."))
.expect("find paragraph");
let code_idx = lines[text_idx + 1..]
.iter()
.position(|l| l.contains("╭─ rust"))
.map(|i| i + text_idx + 1)
.expect("find code block");
// There should be at least one blank line between them
assert!(
code_idx > text_idx + 1,
"code block should not immediately follow paragraph"
);
}
#[test]
fn code_block_ending_with_trailing_text_preserves_spacing() {
let renderer = TerminalRenderer::new();
let markdown = "```rust\nfn main() {}\n```\n\nText after.";
let output = renderer.markdown_to_ansi(markdown);
let plain_text = strip_ansi(&output);
// The code block should end with "╰─" followed by blank lines, then text
assert!(
plain_text.contains("\u{256d}\u{2500}"),
"should contain code block close marker"
);
assert!(
plain_text.contains("\n\nText"),
"should have blank line before text"
);
}
#[test]
fn streaming_code_block_preserves_trailing_newlines() {
let renderer = TerminalRenderer::new();
let mut state = MarkdownStreamState::default();
let markdown = "Text before.\n\n```rust\nfn test() {}\n```\n\nText after.";
// Debug: let's check what the code block part alone renders to
let code_part = "```rust\nfn test() {}\n```\n\nText after.";
let code_rendered = strip_ansi(&renderer.markdown_to_ansi(code_part));
// Simulate the streaming at split position 1
let r1 = state.push(&renderer, "T").map(|s| strip_ansi(&s));
let r2 = state
.push(
&renderer,
"ext before.\n\n```rust\nfn test() {}\n```\n\nText after.",
)
.map(|s| strip_ansi(&s));
// After r2, the pending should contain the code block part
let pending_before_r3 = state.pending.clone();
let r3 = state.flush(&renderer).map(|s| strip_ansi(&s));
// Combine the results
let mut full_streamed = String::new();
if let Some(s) = &r1 {
full_streamed.push_str(s);
}
if let Some(s) = &r2 {
full_streamed.push_str(s);
}
if let Some(s) = &r3 {
full_streamed.push_str(s);
}
let full_rendered = strip_ansi(&renderer.markdown_to_ansi(markdown));
assert_eq!(full_streamed, full_rendered,
"Streaming mismatch.\n\nCode part alone renders to: {:?}\nPending before r3: {:?}\n\nBreakdown:\n r1: {:?}\n r2: {:?}\n r3: {:?}\n\nExpected:\n{:?}\n\nGot:\n{:?}",
code_rendered, pending_before_r3, r1, r2, r3, full_rendered, full_streamed);
}
#[test]
fn streaming_with_split_at_every_byte_position_produces_consistent_output() {
let renderer = TerminalRenderer::new();
let markdown = "Text before.\n\n```rust\nfn test() {}\n```\n\nText after.";
// First, render the full document
let full_output = renderer.markdown_to_ansi(markdown);
let full_plain = strip_ansi(&full_output);
// Now simulate streaming by splitting at different positions
for split_pos in 1..markdown.len() {
let mut state = MarkdownStreamState::default();
let part1 = &markdown[..split_pos];
let part2 = &markdown[split_pos..];
// Try to push both parts
let r1 = state.push(&renderer, part1);
let r2 = state.push(&renderer, part2);
let final_r = state.flush(&renderer);
// Reconstruct the streamed output
let mut streamed_output = String::new();
if let Some(out) = r1 {
streamed_output.push_str(&out);
}
if let Some(out) = r2 {
streamed_output.push_str(&out);
}
if let Some(out) = final_r {
streamed_output.push_str(&out);
}
let streamed_plain = strip_ansi(&streamed_output);
if streamed_plain != full_plain {
eprintln!("\n=== MISMATCH at split position {} ===", split_pos);
eprintln!("Part 1: {:?}", part1);
eprintln!("Part 2: {:?}", part2);
eprintln!("\nExpected (full):");
eprintln!("{:?}", full_plain);
eprintln!("\nGot (streamed):");
eprintln!("{:?}", streamed_plain);
eprintln!("\nStreamed rendered output:");
eprintln!("{:?}", streamed_output);
}
assert_eq!(
streamed_plain, full_plain,
"streaming with split at position {} produces different output",
split_pos
);
}
}
}